From bb5c40f6eb9b03ed4a72f21ab389de5c939cd429 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 15:24:05 -0400 Subject: [PATCH 01/25] refactor(iec-address): extract compile-time alias resolution out of the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getCompileReadyProjectData` held the only implementation of the rules that turn a variable's `location` into the address the compiler emits. Living in a Zustand action made it unreachable from anything without a store — notably the headless CLI (DOPE-567), which would have had to reimplement it and would then have drifted from the GUI, so a project compiled from the terminal could resolve differently from the same project compiled from the editor. Move the rules to `resolveProjectAliases`, a pure function beside the registry they depend on, and reduce the store action to pairing live project data with the memoized alias index. Behaviour is unchanged: the same two collections are walked (POU interface variables and resource globals), and GVLs stay outside the alias surface, matching `renameAlias`. Byte-identical with openplc-web. Co-Authored-By: Claude Opus 5 (1M context) --- src/frontend/store/slices/project/slice.ts | 22 +--- .../__tests__/resolve-project-aliases.test.ts | 122 ++++++++++++++++++ .../shared/utils/iec-address/index.ts | 1 + .../iec-address/resolve-project-aliases.ts | 52 ++++++++ 4 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/middleware/shared/utils/iec-address/__tests__/resolve-project-aliases.test.ts create mode 100644 src/middleware/shared/utils/iec-address/resolve-project-aliases.ts diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index db319b748..bc9aca0ce 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -17,6 +17,7 @@ import { buildAliasRegistry, describeSource, nextFreeAddress, + resolveProjectAliases, validateAliasEdit, } from '../../../../middleware/shared/utils/iec-address' import { @@ -27,7 +28,6 @@ import { migrateToRegistry, modbusConsumerId, recalculate as recalculateRegistry, - resolveLocation, restoreAliasesFromMemory, unpinAllocatableChannels, } from '../../../../middleware/shared/utils/iec-address/registry' @@ -2097,21 +2097,13 @@ const createProjectSlice: StateCreator = }, getCompileReadyProjectData: () => { // Compile-time alias resolution (editor-side; the compiler/runtime never - // see aliases). Returns a COPY of the project data with every variable's - // `location` resolved: an alias name → its current IEC address, a - // literal `%addr` → verbatim, a missing/orphaned alias → '' (unlocated). - // The store keeps the alias-name form for display; only this snapshot is - // resolved. + // see aliases). The resolution RULES live in `resolveProjectAliases`, a + // pure function shared with openplc-web and with the headless CLI, which + // compiles without a store. This action's only job is to pair the live + // project data with the memoized alias index — so a project compiled + // from the terminal resolves identically to one compiled from the GUI. const live = getState() - const aliasIndex = getMemoizedAliasIndex(live) - const data = structuredClone(live.project.data) - const resolveAll = (variables: PLCVariable[] | undefined): void => { - if (!variables) return - for (const variable of variables) variable.location = resolveLocation(variable.location, aliasIndex) - } - for (const pou of data.pous) resolveAll(pou.interface?.variables) - resolveAll(data.configurations?.resource?.globalVariables) - return data + return resolveProjectAliases(live.project.data, getMemoizedAliasIndex(live)) }, getAliasIndex: () => getMemoizedAliasIndex(getState()), addIOGroup: (deviceName, group) => { diff --git a/src/middleware/shared/utils/iec-address/__tests__/resolve-project-aliases.test.ts b/src/middleware/shared/utils/iec-address/__tests__/resolve-project-aliases.test.ts new file mode 100644 index 000000000..b150746b9 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/__tests__/resolve-project-aliases.test.ts @@ -0,0 +1,122 @@ +import type { PLCPou, PLCProjectData, PLCVariable } from '../../../ports/types' +import { resolveProjectAliases } from '../resolve-project-aliases' + +function intVar(name: string, location: string): PLCVariable { + return { name, class: 'local', type: { definition: 'base-type', value: 'INT' }, location, documentation: '' } +} + +function pou(name: string, variables: PLCVariable[]): PLCPou { + return { + name, + pouType: 'program', + interface: { variables }, + body: { language: 'st', value: '' }, + documentation: '', + } +} + +function projectData(overrides: Partial = {}): PLCProjectData { + return { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + ...overrides, + } +} + +describe('resolveProjectAliases', () => { + it('resolves an alias-bound location to the alias current address', () => { + const data = projectData({ pous: [pou('main', [intVar('door', 'doorSensor')])] }) + + const resolved = resolveProjectAliases(data, new Map([['doorSensor', '%IX0.1']])) + + expect(resolved.pous[0].interface?.variables[0].location).toBe('%IX0.1') + }) + + it('passes a literal %addr through verbatim, ignoring the alias index', () => { + // A manual location is honoured exactly as typed — an index entry that + // happens to share the text must not rewrite it. + const data = projectData({ pous: [pou('main', [intVar('manual', '%QW10')])] }) + + const resolved = resolveProjectAliases(data, new Map([['%QW10', '%QW99']])) + + expect(resolved.pous[0].interface?.variables[0].location).toBe('%QW10') + }) + + it('empties the location of an orphaned alias so the variable becomes unlocated', () => { + const data = projectData({ pous: [pou('main', [intVar('gone', 'deletedDevice')])] }) + + const resolved = resolveProjectAliases(data, new Map()) + + expect(resolved.pous[0].interface?.variables[0].location).toBe('') + }) + + it('leaves an already-empty location empty', () => { + const data = projectData({ pous: [pou('main', [intVar('unbound', '')])] }) + + const resolved = resolveProjectAliases(data, new Map([['', '%IX9.9']])) + + expect(resolved.pous[0].interface?.variables[0].location).toBe('') + }) + + it('resolves resource global variables as well as POU interface variables', () => { + const data = projectData({ + pous: [pou('main', [intVar('local', 'aliasA')])], + configurations: { + resource: { tasks: [], instances: [], globalVariables: [intVar('glob', 'aliasB')] }, + }, + }) + + const resolved = resolveProjectAliases( + data, + new Map([ + ['aliasA', '%IX1.0'], + ['aliasB', '%QX2.0'], + ]), + ) + + expect(resolved.pous[0].interface?.variables[0].location).toBe('%IX1.0') + expect(resolved.configurations.resource.globalVariables[0].location).toBe('%QX2.0') + }) + + it('resolves every variable of every POU, not just the first', () => { + const data = projectData({ + pous: [pou('one', [intVar('a', 'aliasA'), intVar('b', 'aliasB')]), pou('two', [intVar('c', 'aliasC')])], + }) + + const resolved = resolveProjectAliases( + data, + new Map([ + ['aliasA', '%IX0.0'], + ['aliasB', '%IX0.1'], + ['aliasC', '%IX0.2'], + ]), + ) + + expect(resolved.pous[0].interface?.variables.map((v) => v.location)).toEqual(['%IX0.0', '%IX0.1']) + expect(resolved.pous[1].interface?.variables[0].location).toBe('%IX0.2') + }) + + it('never mutates the input — the store keeps the alias-name form for display', () => { + const data = projectData({ pous: [pou('main', [intVar('door', 'doorSensor')])] }) + + const resolved = resolveProjectAliases(data, new Map([['doorSensor', '%IX0.1']])) + + expect(data.pous[0].interface?.variables[0].location).toBe('doorSensor') + expect(resolved).not.toBe(data) + expect(resolved.pous[0]).not.toBe(data.pous[0]) + }) + + it('leaves globalVariableLists untouched — GVLs sit outside the alias surface', () => { + // Deliberate parity with `renameAlias`, which also skips GVLs. Resolving + // them here without cascading renames there would let a GVL variable + // resolve to an address a later rename never updates. + const data = projectData({ + globalVariableLists: [{ name: 'GVL', variables: [intVar('gvlVar', 'doorSensor')] }], + }) + + const resolved = resolveProjectAliases(data, new Map([['doorSensor', '%IX0.1']])) + + expect(resolved.globalVariableLists?.[0].variables[0].location).toBe('doorSensor') + }) +}) diff --git a/src/middleware/shared/utils/iec-address/index.ts b/src/middleware/shared/utils/iec-address/index.ts index ef58658a7..f6066f738 100644 --- a/src/middleware/shared/utils/iec-address/index.ts +++ b/src/middleware/shared/utils/iec-address/index.ts @@ -24,3 +24,4 @@ export { resolveAlias, validateAliasEdit, } from './alias-registry' +export { resolveProjectAliases } from './resolve-project-aliases' diff --git a/src/middleware/shared/utils/iec-address/resolve-project-aliases.ts b/src/middleware/shared/utils/iec-address/resolve-project-aliases.ts new file mode 100644 index 000000000..3c039db1c --- /dev/null +++ b/src/middleware/shared/utils/iec-address/resolve-project-aliases.ts @@ -0,0 +1,52 @@ +/** + * Compile-time alias resolution for a WHOLE project. + * + * A variable's `location` holds EITHER an alias name OR a literal `%addr` + * (the single-field model — see `registry/resolve.ts`). The compiler and the + * runtime never see aliases, so the editor hands the compiler a snapshot in + * which every `location` has been resolved to a concrete address. + * + * This lives here, next to the registry it depends on, rather than in the + * Zustand store, because the snapshot is needed by every caller that compiles + * a project — the desktop GUI, openplc-web, and the headless CLI, which has no + * store at all. Keeping it as a pure function is what stops the CLI from + * growing a second, silently diverging copy of the resolution rules: a project + * compiled from the terminal must resolve identically to the same project + * compiled from the GUI, or automated tests are no longer testing the editor. + * + * Byte-identical with openplc-web. + */ + +import type { PLCProjectData, PLCVariable } from '../../ports/types' +import { resolveLocation } from './registry/resolve' + +/** + * Return a COPY of `data` with every bindable variable's `location` resolved: + * an alias name → its current IEC address, a literal `%addr` → verbatim, an + * orphaned alias → `''` (the variable becomes unlocated and the emitters drop + * the `AT %…`). + * + * The input is never mutated — callers hold the alias-name form for display + * and only the returned snapshot is resolved. + * + * The two collections walked here are POU interface variables and the resource + * global variables. That is the complete alias-binding surface, and it matches + * `projectActions.renameAlias`, which cascades over exactly the same two. + * `globalVariableLists` (CODESYS-style GVLs) are deliberately NOT included: + * they sit outside the alias system on both the rename and the resolve side, + * and adding them to one without the other would let a GVL variable resolve to + * an address that a later rename never updates. + */ +export function resolveProjectAliases(data: PLCProjectData, aliasIndex: ReadonlyMap): PLCProjectData { + const resolved = structuredClone(data) + + const resolveAll = (variables: PLCVariable[] | undefined): void => { + if (!variables) return + for (const variable of variables) variable.location = resolveLocation(variable.location, aliasIndex) + } + + for (const pou of resolved.pous) resolveAll(pou.interface?.variables) + resolveAll(resolved.configurations?.resource?.globalVariables) + + return resolved +} From c4264d055349eb0fc0448b5824fa6bf3ca13b7c0 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 15:32:29 -0400 Subject: [PATCH 02/25] feat(cli): session protocol, registry and output contract for the headless CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for DOPE-567, built in the order the ticket calls for: the session mechanism first, so the REPL can be a client of it rather than a second implementation. - `exit-codes.ts`: distinct exit codes plus stable `ErrorCode` strings, so a test branches on a code instead of matching on prose that is free to change. - `output.ts`: mode chosen from `isatty` rather than a flag — a harness that forgot `--json` still gets JSON. In JSON mode stdout carries exactly one document and progress goes to stderr, so callers can `JSON.parse(stdout)` without filtering. - `session/protocol.ts`: NDJSON request/response with correlation ids, so response completion is explicit instead of inferred from prompt matching. `CloseRequest.releaseForces` defaults to true because forcing lives in the runtime's forced-slot bitmap and the runtime cannot notice a debugger going away — it only clears on program unload/stop, so a session that exits quietly would strand pinned outputs on a live PLC. - `session/registry.ts`: `session_id` → socket mapping with liveness checked on every read. A record outliving its process is the common case (SIGKILL, suspend, CI teardown), and reporting a dead session is worse than omitting it, because an operator reads the list to find forces to clear. - `args.ts`: declared boolean flags never consume the following token. 46 tests, no hardware required. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/__tests__/args.test.ts | 80 ++++++++++ src/cli/__tests__/output.test.ts | 118 ++++++++++++++ src/cli/__tests__/protocol.test.ts | 57 +++++++ src/cli/__tests__/registry.test.ts | 151 ++++++++++++++++++ src/cli/args.ts | 150 ++++++++++++++++++ src/cli/exit-codes.ts | 63 ++++++++ src/cli/output.ts | 138 ++++++++++++++++ src/cli/session/protocol.ts | 242 +++++++++++++++++++++++++++++ src/cli/session/registry.ts | 194 +++++++++++++++++++++++ 9 files changed, 1193 insertions(+) create mode 100644 src/cli/__tests__/args.test.ts create mode 100644 src/cli/__tests__/output.test.ts create mode 100644 src/cli/__tests__/protocol.test.ts create mode 100644 src/cli/__tests__/registry.test.ts create mode 100644 src/cli/args.ts create mode 100644 src/cli/exit-codes.ts create mode 100644 src/cli/output.ts create mode 100644 src/cli/session/protocol.ts create mode 100644 src/cli/session/registry.ts diff --git a/src/cli/__tests__/args.test.ts b/src/cli/__tests__/args.test.ts new file mode 100644 index 000000000..8fe0c479b --- /dev/null +++ b/src/cli/__tests__/args.test.ts @@ -0,0 +1,80 @@ +import { boolFlag, listFlag, parseArgs, stringFlag } from '../args' + +const BOOLEANS = ['json', 'quiet', 'upload-if-needed', 'all'] as const +const SUBCOMMANDS = ['debug'] as const + +const parse = (argv: string[]) => parseArgs(argv, { booleanFlags: BOOLEANS, commandsWithSubcommands: SUBCOMMANDS }) + +describe('parseArgs', () => { + it('reads a command with no arguments', () => { + expect(parse(['compile'])).toEqual({ command: 'compile', subcommand: undefined, positionals: [], flags: {} }) + }) + + it('treats the second token as a subcommand only for commands that take one', () => { + expect(parse(['debug', 'open']).subcommand).toBe('open') + // `compile` takes a path, not a subcommand — it must stay positional. + expect(parse(['compile', './proj']).subcommand).toBeUndefined() + expect(parse(['compile', './proj']).positionals).toEqual(['./proj']) + }) + + it('accepts --flag value and --flag=value identically', () => { + expect(parse(['compile', '--target', 'Runtime v4']).flags.target).toBe('Runtime v4') + expect(parse(['compile', '--target=Runtime v4']).flags.target).toBe('Runtime v4') + }) + + it('does not let a declared boolean flag swallow the next token', () => { + // The bug this guards: `--upload-if-needed` consuming `--target`'s value, + // or worse, consuming a bare positional and leaving target unset. + const args = parse(['debug', 'open', '--upload-if-needed', '--target', 'sim']) + expect(args.flags['upload-if-needed']).toBe(true) + expect(args.flags.target).toBe('sim') + }) + + it('reads --no- as false', () => { + expect(parse(['compile', '--no-json']).flags.json).toBe(false) + }) + + it('treats a trailing flag and a flag followed by another flag as booleans', () => { + expect(parse(['compile', '--verbose']).flags.verbose).toBe(true) + expect(parse(['compile', '--verbose', '--target', 'x']).flags.verbose).toBe(true) + }) + + it('collects a repeated flag into a list', () => { + const args = parse(['debug', 'read', '--var', 'a', '--var', 'b', '--var', 'c']) + expect(listFlag(args, 'var')).toEqual(['a', 'b', 'c']) + }) + + it('stops parsing flags after -- so values can look like flags', () => { + const args = parse(['debug', 'exec', '--', '--not-a-flag', 'x']) + expect(args.positionals).toEqual(['--not-a-flag', 'x']) + expect(args.flags['not-a-flag']).toBeUndefined() + }) + + it('returns no command for empty argv', () => { + expect(parse([]).command).toBeUndefined() + }) +}) + +describe('flag readers', () => { + it('stringFlag takes the last value when a flag is repeated', () => { + expect(stringFlag(parse(['compile', '--target', 'a', '--target', 'b']), 'target')).toBe('b') + }) + + it('stringFlag returns undefined for a missing or boolean flag', () => { + expect(stringFlag(parse(['compile']), 'target')).toBeUndefined() + expect(stringFlag(parse(['compile', '--json']), 'json')).toBeUndefined() + }) + + it('listFlag normalises a single value and a missing flag', () => { + expect(listFlag(parse(['debug', 'read', '--var', 'a']), 'var')).toEqual(['a']) + expect(listFlag(parse(['debug', 'read']), 'var')).toEqual([]) + }) + + it('boolFlag honours the value-bearing negative forms', () => { + expect(boolFlag(parse(['compile', '--json']), 'json')).toBe(true) + expect(boolFlag(parse(['compile', '--json=false']), 'json')).toBe(false) + expect(boolFlag(parse(['compile', '--json=0']), 'json')).toBe(false) + expect(boolFlag(parse(['compile', '--json=yes']), 'json')).toBe(true) + expect(boolFlag(parse(['compile']), 'json')).toBe(false) + }) +}) diff --git a/src/cli/__tests__/output.test.ts b/src/cli/__tests__/output.test.ts new file mode 100644 index 000000000..f48c14d20 --- /dev/null +++ b/src/cli/__tests__/output.test.ts @@ -0,0 +1,118 @@ +import { ErrorCode, ExitCode } from '../exit-codes' +import { Reporter, resolveOutputMode, type WriterStreams } from '../output' + +function capture(): { streams: WriterStreams; out: string[]; err: string[] } { + const out: string[] = [] + const err: string[] = [] + return { streams: { out: (t) => out.push(t), err: (t) => err.push(t) }, out, err } +} + +describe('resolveOutputMode', () => { + it('defaults to human at a TTY and json when piped', () => { + // The whole point: a harness that forgot to pass --json still gets JSON. + expect(resolveOutputMode({ isTTY: true })).toBe('human') + expect(resolveOutputMode({ isTTY: false })).toBe('json') + }) + + it('lets an explicit flag override the guess in both directions', () => { + expect(resolveOutputMode({ isTTY: true, json: true })).toBe('json') + expect(resolveOutputMode({ isTTY: false, noJson: true })).toBe('human') + }) + + it('prefers --json when both flags are somehow present', () => { + expect(resolveOutputMode({ isTTY: false, json: true, noJson: true })).toBe('json') + }) +}) + +describe('Reporter in json mode', () => { + it('puts exactly one parseable document on stdout and nothing else', () => { + const { streams, out, err } = capture() + const reporter = new Reporter({ mode: 'json', streams }) + + reporter.progress('compiling…') + reporter.progress('linking…') + const result = reporter.success({ artifacts: ['a.bin'] }, () => 'should not be used') + + expect(out).toHaveLength(1) + expect(JSON.parse(out[0])).toEqual({ ok: true, artifacts: ['a.bin'] }) + // Progress must not contaminate the result channel. + expect(err).toEqual(['compiling…\n', 'linking…\n']) + expect(result.exitCode).toBe(ExitCode.Ok) + }) + + it('reports a failure as a coded object on stdout with the caller exit code', () => { + const { streams, out } = capture() + const reporter = new Reporter({ mode: 'json', streams }) + + const result = reporter.failure( + { code: ErrorCode.CompileFailed, message: 'two errors', details: { errors: 2 } }, + ExitCode.CompileFailed, + ) + + expect(JSON.parse(out[0])).toEqual({ + ok: false, + error: { code: 'compile_failed', message: 'two errors', details: { errors: 2 } }, + }) + expect(result.exitCode).toBe(ExitCode.CompileFailed) + }) + + it('suppresses progress under --quiet but still emits the result', () => { + const { streams, out, err } = capture() + const reporter = new Reporter({ mode: 'json', streams, quiet: true }) + + reporter.progress('noise') + reporter.success({}, () => '') + + expect(err).toEqual([]) + expect(out).toHaveLength(1) + }) + + it('maps an unexpected throw to an internal error rather than a usage error', () => { + const { streams, out } = capture() + const reporter = new Reporter({ mode: 'json', streams }) + + const result = reporter.internalError(new Error('boom')) + + expect(JSON.parse(out[0]).error).toEqual({ code: 'internal', message: 'boom' }) + expect(result.exitCode).toBe(ExitCode.Internal) + }) + + it('describes a non-Error throw without losing it', () => { + const { streams, out } = capture() + new Reporter({ mode: 'json', streams }).internalError('just a string') + expect(JSON.parse(out[0]).error.message).toBe('just a string') + }) + + it('exposes its mode so commands can skip building human strings', () => { + const { streams } = capture() + expect(new Reporter({ mode: 'json', streams }).isJson).toBe(true) + expect(new Reporter({ mode: 'human', streams }).isJson).toBe(false) + }) +}) + +describe('Reporter in human mode', () => { + it('renders the human form on stdout for a success', () => { + const { streams, out } = capture() + const reporter = new Reporter({ mode: 'human', streams }) + + reporter.success({ artifacts: ['a.bin'] }, () => 'Built 1 artifact') + + expect(out).toEqual(['Built 1 artifact\n']) + }) + + it('sends a failure to stderr, where a human expects errors', () => { + const { streams, out, err } = capture() + const reporter = new Reporter({ mode: 'human', streams }) + + reporter.failure({ code: ErrorCode.SessionNotFound, message: 'no such session' }, ExitCode.NotFound) + + expect(out).toEqual([]) + expect(err).toEqual(['error [session_not_found]: no such session\n']) + }) + + it('does not double the trailing newline the renderer already added', () => { + const { streams, out } = capture() + new Reporter({ mode: 'human', streams }).success({}, () => 'done\n') + expect(out).toEqual(['done\n']) + }) +}) diff --git a/src/cli/__tests__/protocol.test.ts b/src/cli/__tests__/protocol.test.ts new file mode 100644 index 000000000..60941a2e2 --- /dev/null +++ b/src/cli/__tests__/protocol.test.ts @@ -0,0 +1,57 @@ +import { encodeMessage, type Request, splitLines } from '../session/protocol' + +describe('encodeMessage', () => { + it('emits one newline-terminated document per message', () => { + const request: Request = { id: 1, kind: 'read', names: ['MAIN.counter'] } + + const line = encodeMessage(request) + + expect(line.endsWith('\n')).toBe(true) + expect(line.indexOf('\n')).toBe(line.length - 1) + expect(JSON.parse(line)).toEqual(request) + }) +}) + +describe('splitLines', () => { + it('returns complete lines and holds back the trailing partial', () => { + // The real case: a 500-variable list-vars reply does not arrive whole, so + // parsing per chunk would drop or corrupt the tail. + const first = splitLines('', '{"id":1,"ok":true}\n{"id":2,') + expect(first.lines).toEqual(['{"id":1,"ok":true}']) + expect(first.rest).toBe('{"id":2,') + + const second = splitLines(first.rest, '"ok":false}\n') + expect(second.lines).toEqual(['{"id":2,"ok":false}']) + expect(second.rest).toBe('') + }) + + it('handles a message split across three chunks', () => { + let rest = '' + const collected: string[] = [] + for (const chunk of ['{"id', '":7,"ok"', ':true}\n']) { + const step = splitLines(rest, chunk) + collected.push(...step.lines) + rest = step.rest + } + + expect(collected).toEqual(['{"id":7,"ok":true}']) + expect(rest).toBe('') + }) + + it('yields several messages that arrive in one chunk', () => { + const { lines, rest } = splitLines('', '{"a":1}\n{"b":2}\n{"c":3}\n') + expect(lines).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']) + expect(rest).toBe('') + }) + + it('drops blank and whitespace-only lines rather than passing them to JSON.parse', () => { + const { lines } = splitLines('', '{"a":1}\n\n \n{"b":2}\n') + expect(lines).toEqual(['{"a":1}', '{"b":2}']) + }) + + it('reports nothing complete for a chunk with no newline', () => { + const { lines, rest } = splitLines('', '{"partial":') + expect(lines).toEqual([]) + expect(rest).toBe('{"partial":') + }) +}) diff --git a/src/cli/__tests__/registry.test.ts b/src/cli/__tests__/registry.test.ts new file mode 100644 index 000000000..dd4f6da0a --- /dev/null +++ b/src/cli/__tests__/registry.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { mintSessionId, type SessionRecord, SessionRegistry, socketPathFor } from '../session/registry' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'openplc-cli-registry-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const record = (overrides: Partial = {}): SessionRecord => ({ + sessionId: 'aaaaaaaaaaaa', + pid: 4242, + socketPath: join(dir, 'aaaaaaaaaaaa.sock'), + target: 'OpenPLC Runtime v4', + projectPath: '/projects/demo', + programMd5: 'abc', + startedAt: '2026-08-20T10:00:00.000Z', + ...overrides, +}) + +const alwaysAlive = () => true +const alwaysDead = () => false + +describe('mintSessionId', () => { + it('mints distinct ids so concurrent opens cannot collide', () => { + const ids = new Set(Array.from({ length: 500 }, () => mintSessionId())) + expect(ids.size).toBe(500) + }) +}) + +describe('socketPathFor', () => { + it('uses a socket file on posix and the pipe namespace on win32', () => { + expect(socketPathFor('/reg', 'abc', 'darwin')).toBe(join('/reg', 'abc.sock')) + expect(socketPathFor('/reg', 'abc', 'win32')).toBe('\\\\.\\pipe\\openplc-debug-abc') + }) +}) + +describe('SessionRegistry', () => { + it('round-trips a record, creating the directory on demand', () => { + const nested = join(dir, 'deeper', 'still') + const registry = new SessionRegistry(nested, alwaysAlive) + + registry.register(record()) + + expect(registry.get('aaaaaaaaaaaa')).toEqual(record()) + }) + + it('returns undefined for an unknown session', () => { + expect(new SessionRegistry(dir, alwaysAlive).get('nope')).toBeUndefined() + }) + + it('reaps a record whose owning process is gone instead of returning it', () => { + // The failure this prevents: a client dialling a socket nobody is + // listening on and hanging until its timeout. + const registry = new SessionRegistry(dir, alwaysDead) + registry.register(record()) + + expect(registry.get('aaaaaaaaaaaa')).toBeUndefined() + expect(readdirSync(dir)).toEqual([]) + }) + + it('lists only live sessions, oldest first', () => { + const registry = new SessionRegistry(dir, (pid) => pid !== 999) + registry.register(record({ sessionId: 'newer', startedAt: '2026-08-20T12:00:00.000Z' })) + registry.register(record({ sessionId: 'older', startedAt: '2026-08-20T09:00:00.000Z' })) + registry.register(record({ sessionId: 'dead', pid: 999 })) + + expect(registry.list().map((r) => r.sessionId)).toEqual(['older', 'newer']) + }) + + it('ignores non-record files in the registry directory', () => { + const registry = new SessionRegistry(dir, alwaysAlive) + registry.register(record()) + writeFileSync(join(dir, 'notes.txt'), 'ignore me') + + expect(registry.list().map((r) => r.sessionId)).toEqual(['aaaaaaaaaaaa']) + }) + + it('treats a malformed record as absent rather than throwing', () => { + const registry = new SessionRegistry(dir, alwaysAlive) + writeFileSync(join(dir, 'broken.json'), '{ not json') + writeFileSync(join(dir, 'wrong-shape.json'), JSON.stringify({ sessionId: 'x' })) + + expect(registry.list()).toEqual([]) + expect(registry.get('wrong-shape')).toBeUndefined() + }) + + it('returns an empty list when the directory does not exist yet', () => { + expect(new SessionRegistry(join(dir, 'absent'), alwaysAlive).list()).toEqual([]) + expect(new SessionRegistry(join(dir, 'absent'), alwaysAlive).reapStale()).toEqual([]) + }) + + it('unregisters a session on clean shutdown', () => { + const registry = new SessionRegistry(dir, alwaysAlive) + registry.register(record()) + + registry.unregister('aaaaaaaaaaaa') + + expect(registry.get('aaaaaaaaaaaa')).toBeUndefined() + expect(readdirSync(dir)).toEqual([]) + }) + + it('unregistering an unknown session is a no-op, not an error', () => { + expect(() => new SessionRegistry(dir, alwaysAlive).unregister('ghost')).not.toThrow() + }) + + it('reapStale reports the dead sessions it cleaned so an operator sees them', () => { + // Reported, not silent: a session that died may have left forces pinned. + const registry = new SessionRegistry(dir, (pid) => pid === 1) + registry.register(record({ sessionId: 'live', pid: 1 })) + registry.register(record({ sessionId: 'dead1', pid: 111 })) + registry.register(record({ sessionId: 'dead2', pid: 222 })) + + expect(registry.reapStale().sort()).toEqual(['dead1', 'dead2']) + expect(registry.list().map((r) => r.sessionId)).toEqual(['live']) + }) + + it('reapStale deletes unreadable records, which can never be dialled', () => { + const registry = new SessionRegistry(dir, alwaysAlive) + writeFileSync(join(dir, 'garbage.json'), 'not json at all') + + registry.reapStale() + + expect(readdirSync(dir)).toEqual([]) + }) + + it('finds a reusable session for the same project and target', () => { + // Reuse matters because single-client targets never answer a second socket. + const registry = new SessionRegistry(dir, alwaysAlive) + registry.register(record({ sessionId: 'match' })) + + expect(registry.findReusable('/projects/demo', 'OpenPLC Runtime v4')?.sessionId).toBe('match') + expect(registry.findReusable('/projects/other', 'OpenPLC Runtime v4')).toBeUndefined() + expect(registry.findReusable('/projects/demo', 'OpenPLC Simulator')).toBeUndefined() + }) + + it('does not try to unlink a win32 pipe name as if it were a file', () => { + const registry = new SessionRegistry(dir, alwaysDead) + registry.register(record({ socketPath: '\\\\.\\pipe\\openplc-debug-aaaaaaaaaaaa' })) + + expect(() => registry.get('aaaaaaaaaaaa')).not.toThrow() + expect(readdirSync(dir)).toEqual([]) + }) +}) diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 000000000..b76f88456 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,150 @@ +/** + * Argument parsing, deliberately hand-rolled and dependency-free. + * + * The CLI ships inside the app bundle, so every dependency added here is + * weight in the packaged build; the grammar it has to cover is small and + * fixed. What it does need to be is PREDICTABLE for a caller that builds + * argv programmatically: + * + * - `--flag=value` and `--flag value` are the same thing; + * - a flag listed in `booleanFlags` never swallows the next token, so + * `--upload-if-needed --target x` cannot silently parse the target as the + * flag's value (the bug class that makes scripted invocations mysterious); + * - `--no-` sets `` false; + * - `--` ends flag parsing, everything after it is positional; + * - a repeated flag collects into an array, so `--var a --var b` works + * without a separate list syntax. + */ + +export interface ParsedArgs { + /** First non-flag token, e.g. `debug`. */ + command?: string + /** Second non-flag token when the command takes one, e.g. `open`. */ + subcommand?: string + /** Remaining positional tokens, in order. */ + positionals: string[] + /** Flag values. A repeated flag becomes an array; a boolean flag a boolean. */ + flags: Record +} + +export interface ParseOptions { + /** + * Flags that are boolean and must NOT consume the following token. + * Everything else is treated as taking a value. + */ + booleanFlags?: readonly string[] + /** Commands whose second positional is a subcommand rather than an argument. */ + commandsWithSubcommands?: readonly string[] +} + +function setFlag(flags: ParsedArgs['flags'], name: string, value: string | boolean): void { + const existing = flags[name] + if (existing === undefined) { + flags[name] = value + return + } + // Repeat → collect. Booleans repeat harmlessly and keep the last value. + if (typeof value === 'boolean') { + flags[name] = value + return + } + if (Array.isArray(existing)) { + existing.push(value) + return + } + flags[name] = typeof existing === 'string' ? [existing, value] : [value] +} + +export function parseArgs(argv: readonly string[], options: ParseOptions = {}): ParsedArgs { + const booleanFlags = new Set(options.booleanFlags ?? []) + const withSubcommands = new Set(options.commandsWithSubcommands ?? []) + + const positionals: string[] = [] + const flags: ParsedArgs['flags'] = {} + let onlyPositionals = false + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + + if (onlyPositionals) { + positionals.push(token) + continue + } + + if (token === '--') { + onlyPositionals = true + continue + } + + if (!token.startsWith('--')) { + positionals.push(token) + continue + } + + const body = token.slice(2) + + // `--flag=value` — unambiguous, never consumes the next token. + const eq = body.indexOf('=') + if (eq !== -1) { + setFlag(flags, body.slice(0, eq), body.slice(eq + 1)) + continue + } + + if (body.startsWith('no-')) { + setFlag(flags, body.slice(3), false) + continue + } + + if (booleanFlags.has(body)) { + setFlag(flags, body, true) + continue + } + + const next = argv[i + 1] + // A flag at the end, or followed by another flag, is a bare boolean rather + // than an error — `--quiet` should work even if it wasn't declared. + if (next === undefined || next.startsWith('--')) { + setFlag(flags, body, true) + continue + } + + setFlag(flags, body, next) + i += 1 + } + + const [command, second, ...rest] = positionals + const takesSubcommand = command !== undefined && withSubcommands.has(command) + + return { + command, + subcommand: takesSubcommand ? second : undefined, + positionals: takesSubcommand ? rest : positionals.slice(1), + flags, + } +} + +/** Read a flag that must be a single string. Returns undefined when absent. */ +export function stringFlag(args: ParsedArgs, name: string): string | undefined { + const value = args.flags[name] + if (typeof value === 'string') return value + // An array means the caller passed it twice; the last one wins, matching how + // shells treat repeated options elsewhere. + if (Array.isArray(value)) return value[value.length - 1] + return undefined +} + +/** Read a flag that may be repeated, always as a list. */ +export function listFlag(args: ParsedArgs, name: string): string[] { + const value = args.flags[name] + if (typeof value === 'string') return [value] + if (Array.isArray(value)) return [...value] + return [] +} + +/** Read a boolean flag. A value-bearing form (`--json=false`) is honoured. */ +export function boolFlag(args: ParsedArgs, name: string): boolean { + const value = args.flags[name] + if (typeof value === 'boolean') return value + if (typeof value === 'string') return value !== 'false' && value !== '0' + return false +} diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts new file mode 100644 index 000000000..d130a4788 --- /dev/null +++ b/src/cli/exit-codes.ts @@ -0,0 +1,63 @@ +/** + * Process exit codes — the CLI's coarsest machine-readable channel. + * + * A test step reads the exit code before it reads anything else, so the codes + * distinguish the cases a caller actually branches on: "your input was wrong" + * (retrying is pointless) from "the target misbehaved" (retrying might help) + * from "the program is bad" (fail the build). A single generic `1` forces + * callers to parse prose to tell those apart, which is how test suites end up + * matching on error strings. + */ +export const ExitCode = { + /** Command completed and the answer is yes / done. */ + Ok: 0, + /** Usage error: unknown command, missing or malformed argument. */ + Usage: 2, + /** The project, file or session named by the caller does not exist. */ + NotFound: 3, + /** Compilation ran and the program was rejected (diagnostics on stderr). */ + CompileFailed: 4, + /** Could not reach the target, or lost it mid-command. */ + Connection: 5, + /** Reached the target and it refused: bad credentials, or not authorized. */ + Auth: 6, + /** The command ran but the target reported failure (upload rejected, write refused). */ + TargetError: 7, + /** Timed out waiting for the target or for a session to answer. */ + Timeout: 8, + /** Anything unanticipated — a bug in the CLI, not in the caller's input. */ + Internal: 70, +} as const + +export type ExitCodeName = keyof typeof ExitCode +export type ExitCodeValue = (typeof ExitCode)[ExitCodeName] + +/** + * Stable error codes carried in structured output alongside the exit code. + * + * The exit code says which *class* of thing went wrong; this says which + * specific thing, so a caller can assert on a code instead of on a sentence. + * Prose messages are free to change; these are not. + */ +export const ErrorCode = { + UnknownCommand: 'unknown_command', + MissingArgument: 'missing_argument', + InvalidArgument: 'invalid_argument', + ProjectNotFound: 'project_not_found', + ProjectInvalid: 'project_invalid', + TargetUnknown: 'target_unknown', + CompileFailed: 'compile_failed', + SessionNotFound: 'session_not_found', + SessionStale: 'session_stale', + VariableNotFound: 'variable_not_found', + ValueInvalid: 'value_invalid', + NotConnected: 'not_connected', + AuthRequired: 'auth_required', + AuthRejected: 'auth_rejected', + UploadRejected: 'upload_rejected', + Md5Mismatch: 'md5_mismatch', + Timeout: 'timeout', + Internal: 'internal', +} as const + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode] diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 000000000..fc15382ad --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,138 @@ +/** + * How the CLI talks back: structured for machines, formatted for people. + * + * The mode is chosen from whether stdout is a TTY, not from a flag, because + * the callers that need JSON are exactly the ones that cannot pass a flag they + * forgot to pass — a test harness pipes stdout and gets JSON automatically, + * while a human at a terminal gets a table. `--json` / `--no-json` override it + * for the cases where the guess is wrong (a human piping into `less`, a CI job + * with a pty). + * + * Three rules the automated callers depend on: + * + * 1. In JSON mode, stdout carries EXACTLY ONE json document — the result. + * Progress and diagnostics go to stderr. A caller can therefore + * `JSON.parse(stdout)` without filtering, which is what stops harnesses + * from growing line-matching heuristics. + * 2. No ANSI, no spinners, no progress bars in JSON mode. A carriage-return + * redraw is a human affordance and it corrupts captured output. + * 3. Errors are objects with a stable `code` (see `ErrorCode`), never bare + * prose. The sentence may be reworded; the code may not. + */ + +import { ErrorCode, type ErrorCodeValue, ExitCode, type ExitCodeValue } from './exit-codes' + +export type OutputMode = 'json' | 'human' + +export interface WriterStreams { + /** Result channel. Exactly one JSON document in JSON mode. */ + out: (text: string) => void + /** Progress + diagnostics. Never carries the result. */ + err: (text: string) => void +} + +export interface ReporterOptions { + mode: OutputMode + streams: WriterStreams + /** Suppress progress lines entirely (`--quiet`). Errors still print. */ + quiet?: boolean +} + +/** A failure the caller can branch on without reading English. */ +export interface CliFailure { + code: ErrorCodeValue + message: string + /** Optional structured payload — compiler diagnostics, attempted endpoints. */ + details?: unknown +} + +export interface CliResult { + exitCode: ExitCodeValue +} + +/** + * Decide the output mode. An explicit flag always wins; otherwise a TTY means + * a human is reading. + */ +export function resolveOutputMode(options: { json?: boolean; noJson?: boolean; isTTY: boolean }): OutputMode { + if (options.json) return 'json' + if (options.noJson) return 'human' + return options.isTTY ? 'human' : 'json' +} + +export class Reporter { + private readonly mode: OutputMode + private readonly streams: WriterStreams + private readonly quiet: boolean + /** Guards rule 1: a second result would make stdout unparseable. */ + private resultEmitted = false + + constructor(options: ReporterOptions) { + this.mode = options.mode + this.streams = options.streams + this.quiet = options.quiet ?? false + } + + get isJson(): boolean { + return this.mode === 'json' + } + + /** + * A progress line. Goes to stderr in BOTH modes — in human mode because + * that keeps `openplc compile > log` behaving, in JSON mode because stdout + * is reserved for the single result document. + */ + progress(message: string): void { + if (this.quiet) return + this.streams.err(`${message}\n`) + } + + /** + * The command's answer. `payload` is emitted verbatim as JSON in JSON mode; + * `humanRender` produces the terminal form. Callers pass both so neither + * mode is an afterthought that renders `[object Object]`. + */ + success(payload: Record, humanRender: () => string): CliResult { + this.emitResult({ ok: true, ...payload }, humanRender) + return { exitCode: ExitCode.Ok } + } + + /** A failure, with the exit code the caller should see. */ + failure(failure: CliFailure, exitCode: ExitCodeValue): CliResult { + this.emitResult({ ok: false, error: failure }, () => `error [${failure.code}]: ${failure.message}`) + return { exitCode } + } + + /** An unanticipated throw — a CLI bug, reported as one rather than as usage. */ + internalError(error: unknown): CliResult { + const message = error instanceof Error ? error.message : String(error) + return this.failure({ code: ErrorCode.Internal, message }, ExitCode.Internal) + } + + private emitResult(document: Record, humanRender: () => string): void { + /* istanbul ignore if -- guards a CLI bug; no command emits twice */ + if (this.resultEmitted) return + this.resultEmitted = true + + if (this.mode === 'json') { + this.streams.out(`${JSON.stringify(document)}\n`) + return + } + + const rendered = humanRender() + const target = document.ok === true ? this.streams.out : this.streams.err + target(rendered.endsWith('\n') ? rendered : `${rendered}\n`) + } +} + +/** Reporter bound to the real process streams. */ +export function createProcessReporter(options: { json?: boolean; noJson?: boolean; quiet?: boolean }): Reporter { + return new Reporter({ + mode: resolveOutputMode({ json: options.json, noJson: options.noJson, isTTY: Boolean(process.stdout.isTTY) }), + quiet: options.quiet, + streams: { + out: (text) => process.stdout.write(text), + err: (text) => process.stderr.write(text), + }, + }) +} diff --git a/src/cli/session/protocol.ts b/src/cli/session/protocol.ts new file mode 100644 index 000000000..7e51c5e18 --- /dev/null +++ b/src/cli/session/protocol.ts @@ -0,0 +1,242 @@ +/** + * The debug-session wire protocol: NDJSON request/response over a local socket. + * + * Why a protocol at all, rather than a REPL reading stdin: a debug session is + * long-lived and stateful, but the callers that matter — a test step, an AI + * agent — are stateless and turn-based, one fresh process per command. Feeding + * an interactive process and matching on its prompt to decide when a reply has + * finished is the flaky-test tarpit; a framed request/response makes completion + * explicit. + * + * Choices that follow from that, and the reasons they are not arbitrary: + * + * - NDJSON, not JSON-RPC. One JSON document per line, no envelope + * ceremony. JSON-RPC's batching and notification semantics buy nothing + * here and its error-object shape is weaker than `ErrorCode`. + * - Every request carries an `id`, echoed on the response. A caller knows a + * reply is complete when the line ends, and knows WHICH request it answers + * without assuming ordering — so a future pipelined client does not need a + * protocol change. + * - `Response` is a discriminated union on `ok`, so an exhaustive switch is + * checkable and a new response kind cannot be silently unhandled. + * + * The REPL speaks exactly this. It is a client, not a second implementation: + * every command a human types becomes one of these requests, which is what + * keeps "debug from the terminal" and "debug from a script" the same code. + */ + +import type { ErrorCodeValue } from '../exit-codes' + +/** Every operation a session understands. The REPL's vocabulary is this set. */ +export type RequestKind = + | 'status' + | 'list-vars' + | 'read' + | 'write' + | 'force' + | 'unforce' + | 'start' + | 'stop' + | 'watch' + | 'poll' + | 'unwatch' + | 'close' + +export interface RequestBase { + /** Correlates the response. Unique per connection, not globally. */ + id: number + kind: RequestKind +} + +/** Connection state, target, program MD5, PLC state, and what is forced. */ +export interface StatusRequest extends RequestBase { + kind: 'status' +} + +/** Every leaf in the compiled program's debug map. */ +export interface ListVarsRequest extends RequestBase { + kind: 'list-vars' + /** Case-insensitive substring filter on the variable path. */ + filter?: string +} + +export interface ReadRequest extends RequestBase { + kind: 'read' + /** Variable paths, as they appear in `debug-map.json` (case-insensitive). */ + names: string[] +} + +/** Soft write — the program may overwrite it on the next scan. */ +export interface WriteRequest extends RequestBase { + kind: 'write' + name: string + value: string +} + +/** Force — pinned until unforced; survives the program's own writes. */ +export interface ForceRequest extends RequestBase { + kind: 'force' + name: string + value: string +} + +export interface UnforceRequest extends RequestBase { + kind: 'unforce' + name: string +} + +export interface StartRequest extends RequestBase { + kind: 'start' +} + +export interface StopRequest extends RequestBase { + kind: 'stop' +} + +/** + * Begin recording a variable into a bounded server-side buffer. + * + * Recording rather than streaming is the whole point: a stateless caller + * cannot sit and watch a scroll, and a test needs to assert that a transient + * happened between two of its own steps. The session samples; `poll` drains. + */ +export interface WatchRequest extends RequestBase { + kind: 'watch' + names: string[] + /** Sampling period. Clamped by the session to what the medium can carry. */ + intervalMs?: number +} + +/** Drain the recorded window. `since` continues a previous drain. */ +export interface PollRequest extends RequestBase { + kind: 'poll' + since?: number +} + +export interface UnwatchRequest extends RequestBase { + kind: 'unwatch' + /** Omit to stop watching everything. */ + names?: string[] +} + +/** Tear the session down. See `releaseForces` for the safety-relevant part. */ +export interface CloseRequest extends RequestBase { + kind: 'close' + /** + * Unforce everything this session forced before disconnecting. + * + * Defaults to true, and that default is a safety decision, not a + * convenience: forcing lives in the RUNTIME's forced-slot bitmap, and the + * runtime has no way to notice that a debugger went away — it only clears + * forces on program unload/stop (`debug_write_journal_reset`). A session + * that exits quietly therefore leaves outputs pinned on a live PLC. Tests + * that open and close sessions in a loop would strand forces on real + * hardware. Pass false only when the pin is meant to outlive the session. + */ + releaseForces?: boolean +} + +export type Request = + | StatusRequest + | ListVarsRequest + | ReadRequest + | WriteRequest + | ForceRequest + | UnforceRequest + | StartRequest + | StopRequest + | WatchRequest + | PollRequest + | UnwatchRequest + | CloseRequest + +/** One variable's current value, typed so `0` is never ambiguous. */ +export interface VariableValue { + /** Path from `debug-map.json`, in its canonical casing. */ + name: string + /** Canonical IEC type straight from the compiler (e.g. `DINT`). */ + type: string + /** + * The decoded value. A BOOL is a boolean, an integer a number, a 64-bit + * integer a decimal string (it does not survive an IEEE double), a STRING a + * string. `null` means the leaf was unreadable this sample. + */ + value: boolean | number | string | null + /** True when the runtime reports this leaf pinned. */ + forced: boolean +} + +export interface SessionStatus { + sessionId: string + connected: boolean + target: string + /** Transport actually in use, e.g. `websocket`, `tcp`, `rtu`. */ + transport: string + descriptor: string + projectPath: string + /** MD5 of the program the target is running, per `debugger:verify-md5`. */ + programMd5: string | null + /** Whether that MD5 matches the locally compiled artifacts. */ + md5Matches: boolean + plcState: 'running' | 'stopped' | 'unknown' + /** Paths this session has forced and not yet released. */ + forced: string[] + watching: string[] + startedAt: string + lastActivityAt: string +} + +/** One recorded sample from the watch buffer. */ +export interface WatchSample { + /** Monotonic sequence number — pass the last one back as `poll --since`. */ + seq: number + /** Milliseconds since the session started, not a wall clock. */ + atMs: number + values: VariableValue[] +} + +export interface OkResponse { + id: number + ok: true + /** Shape depends on the request kind; each command knows its own. */ + data: + | { kind: 'status'; status: SessionStatus } + | { kind: 'list-vars'; variables: Array<{ name: string; type: string; size: number }> } + | { kind: 'read'; values: VariableValue[] } + | { kind: 'write'; value: VariableValue } + | { kind: 'force'; value: VariableValue } + | { kind: 'unforce'; value: VariableValue } + | { kind: 'plc-state'; plcState: 'running' | 'stopped' } + | { kind: 'watch'; watching: string[]; intervalMs: number } + | { kind: 'poll'; samples: WatchSample[]; dropped: number } + | { kind: 'unwatch'; watching: string[] } + | { kind: 'close'; released: string[] } +} + +export interface ErrResponse { + id: number + ok: false + error: { code: ErrorCodeValue; message: string; details?: unknown } +} + +export type Response = OkResponse | ErrResponse + +/** Serialize one message as a protocol line (trailing newline included). */ +export function encodeMessage(message: Request | Response): string { + return `${JSON.stringify(message)}\n` +} + +/** + * Split a raw socket chunk into complete lines, returning the trailing partial + * for the caller to prepend to the next chunk. + * + * A socket boundary lands mid-line often enough that parsing per chunk is a + * real bug rather than a theoretical one — a 500-variable `list-vars` reply + * does not arrive in one piece. + */ +export function splitLines(buffered: string, chunk: string): { lines: string[]; rest: string } { + const combined = buffered + chunk + const parts = combined.split('\n') + const rest = parts.pop() ?? '' + return { lines: parts.filter((line) => line.trim().length > 0), rest } +} diff --git a/src/cli/session/registry.ts b/src/cli/session/registry.ts new file mode 100644 index 000000000..3ce7886a0 --- /dev/null +++ b/src/cli/session/registry.ts @@ -0,0 +1,194 @@ +/** + * The `session_id` registry: which debug sessions exist, and how to reach them. + * + * A session is a background process holding a live connection. Callers address + * it by id across unrelated invocations, so the mapping id → socket has to + * live somewhere both the daemon and every one-shot client can see. That is + * one small JSON file per session in a registry directory. + * + * The hard part is not writing the file, it is the file OUTLIVING its process. + * A daemon killed with SIGKILL, a laptop suspended mid-test, a CI runner torn + * down — all leave an entry pointing at a socket nobody is listening on. A + * caller that trusts the file then hangs, and a `list` that reports dead + * sessions is worse than no list at all, because an operator reads it to find + * forces they need to clear. So liveness is checked on every read (`kill(pid, + * 0)`) and dead entries are reaped rather than reported. + * + * Ids are content-free random tokens, not sequential counters: two concurrent + * `debug open` invocations must not be able to mint the same id, and a counter + * in a shared file is exactly the race a test suite running in parallel finds. + */ + +import { randomBytes } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** What a one-shot client needs in order to reach a session. */ +export interface SessionRecord { + sessionId: string + /** Owning daemon. Its liveness is what makes this record valid. */ + pid: number + /** Unix socket path, or Windows named pipe. */ + socketPath: string + /** Board / runtime target, as named in `hals.json`. */ + target: string + projectPath: string + /** MD5 of the program this session verified against, when known. */ + programMd5: string | null + startedAt: string +} + +/** A liveness probe, injectable so tests never depend on real pids. */ +export type IsProcessAlive = (pid: number) => boolean + +export const defaultIsProcessAlive: IsProcessAlive = (pid) => { + try { + // Signal 0 performs the permission and existence check without delivering + // anything. EPERM means it exists but is not ours — still alive. + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +/** Enough entropy that concurrent opens cannot collide; short enough to type. */ +export function mintSessionId(): string { + return randomBytes(6).toString('hex') +} + +/** + * Where a session's socket lives. On Windows a unix socket does not exist, so + * the platform's named-pipe namespace is used instead — same addressing role, + * different syntax, and it must never be treated as a filesystem path. + */ +export function socketPathFor(registryDir: string, sessionId: string, platform: NodeJS.Platform): string { + if (platform === 'win32') return `\\\\.\\pipe\\openplc-debug-${sessionId}` + return join(registryDir, `${sessionId}.sock`) +} + +export class SessionRegistry { + constructor( + private readonly dir: string, + private readonly isAlive: IsProcessAlive = defaultIsProcessAlive, + ) {} + + private recordPath(sessionId: string): string { + return join(this.dir, `${sessionId}.json`) + } + + private ensureDir(): void { + if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true }) + } + + register(record: SessionRecord): void { + this.ensureDir() + writeFileSync(this.recordPath(record.sessionId), `${JSON.stringify(record, null, 2)}\n`, 'utf-8') + } + + /** + * Read one record, or undefined when it is absent OR its owner is gone. + * + * A dead owner is reaped here rather than returned, so no caller can dial a + * socket that cannot answer. + */ + get(sessionId: string): SessionRecord | undefined { + const parsed = this.readRecord(this.recordPath(sessionId)) + if (!parsed) return undefined + if (!this.isAlive(parsed.pid)) { + this.reap(parsed) + return undefined + } + return parsed + } + + /** Every live session, oldest first. Dead entries are reaped as a side effect. */ + list(): SessionRecord[] { + if (!existsSync(this.dir)) return [] + const live: SessionRecord[] = [] + for (const entry of readdirSync(this.dir)) { + if (!entry.endsWith('.json')) continue + const parsed = this.readRecord(join(this.dir, entry)) + if (!parsed) continue + if (!this.isAlive(parsed.pid)) { + this.reap(parsed) + continue + } + live.push(parsed) + } + return live.sort((a, b) => a.startedAt.localeCompare(b.startedAt)) + } + + /** Forget a session. Called by the daemon on clean shutdown. */ + unregister(sessionId: string): void { + const parsed = this.readRecord(this.recordPath(sessionId)) + if (parsed) this.reap(parsed) + else rmSync(this.recordPath(sessionId), { force: true }) + } + + /** + * Drop every dead entry and report what was cleaned, so `debug close --all` + * can tell the operator that stale sessions were found — a hint that + * something died holding forces. + */ + reapStale(): string[] { + if (!existsSync(this.dir)) return [] + const reaped: string[] = [] + for (const entry of readdirSync(this.dir)) { + if (!entry.endsWith('.json')) continue + const parsed = this.readRecord(join(this.dir, entry)) + if (!parsed) { + // Unreadable or malformed: it can never be dialled, so it is garbage. + rmSync(join(this.dir, entry), { force: true }) + continue + } + if (this.isAlive(parsed.pid)) continue + this.reap(parsed) + reaped.push(parsed.sessionId) + } + return reaped + } + + /** + * An existing live session for the same project and target, if any. + * + * `debug open` reuses one rather than stacking a second connection to the + * same device: targets that serve a single client (an Arduino Modbus TCP + * server, notably) simply never answer the second socket, and the failure + * looks like a bare timeout while a perfectly good session sits idle. + */ + findReusable(projectPath: string, target: string): SessionRecord | undefined { + return this.list().find((record) => record.projectPath === projectPath && record.target === target) + } + + private readRecord(path: string): SessionRecord | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + return isSessionRecord(parsed) ? parsed : undefined + } catch { + return undefined + } + } + + private reap(record: SessionRecord): void { + rmSync(this.recordPath(record.sessionId), { force: true }) + // The socket file is an artefact of the dead process; on win32 the pipe + // name is not a path and there is nothing on disk to remove. + if (!record.socketPath.startsWith('\\\\')) rmSync(record.socketPath, { force: true }) + } +} + +/** Validate an external file rather than trusting its shape. */ +function isSessionRecord(value: unknown): value is SessionRecord { + if (typeof value !== 'object' || value === null) return false + const record: Record = { ...value } + return ( + typeof record.sessionId === 'string' && + typeof record.pid === 'number' && + typeof record.socketPath === 'string' && + typeof record.target === 'string' && + typeof record.projectPath === 'string' && + typeof record.startedAt === 'string' && + (record.programMd5 === null || typeof record.programMd5 === 'string') + ) +} From ee8fc95b0080044fc7ec88b8f871f4b3dd082979 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 16:38:18 -0400 Subject: [PATCH 03/25] feat(cli): headless CLI driving the editor's own components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `openplc` — devices / compile / upload / debug — running as an Electron main process with no window. Not a plain Node process, because `CompilerModule` and ten other modules under `backend/editor` resolve arduino-cli, the strucpp includes, the licence store and installed VPP packages through Electron's `app` paths; de-Electroning that layer would refactor GUI code for no GUI benefit, and running as main is what keeps the CLI on the SAME paths and packages the GUI uses. Every command forwards into an editor component rather than restating it: - `devices` -> `discoverRuntimes`, extracted out of `MainProcessBridge` so the GUI's Search button and the CLI run one scan. - `compile` / `upload` -> `CompilerModule.compileProgram`, preceded by the renderer's own pre-compile chain (`injectLibraryCppBlocks` -> `preprocessPous` -> `toIpcProjectData`, now exported from the compiler adapter) and `LibraryManagerModule` for archive resolution. `upload` is the same call with a runtime address, not a second flash path. - project loading -> `ProjectService.readRawProjectFiles` + `parseProjectFiles` + a real store hydrated through `sharedWorkspaceActions.handleOpenProjectResponse`, so `getCompileReadyProjectData()` is literally the GUI's call. Alias resolution needs device state (board, pin mapping, VPP screens), which is why the store is stood up rather than reconstructed. - `debug` -> `WebSocketDebugTransport`, the debug map via `parseDebugMap`, and values via the shared codec. Two extractions were forced by finding real drift: - `RuntimeApiClient` (`backend/editor/runtime/`): the runtime REST layer lifted out of `MainProcessBridge`, which now delegates. While the CLI briefly had its own copy it POSTed to `/api/start-plc` (the runtime answers GET) and read HTTP 200 as success, missing that refusal arrives in the body (`START:ERROR_SWITCH_STOP`). Both are invisible without hardware. - `walkDebugResponse` (`frontend/utils/`): the positional walk over a `getVariablesList` reply, now shared with `useDebugPolling`. `lastIndex` handling, consumed-but-undecodable slots, the short-buffer stop and the endian swap are silent-corruption bugs when two callers disagree. Debug is session-first: `debug open` forks a daemon holding the channel and returns a `session_id`; one-shot commands dial its socket; the REPL is another client of the same protocol. `close` releases the session's forces by default, because the runtime clears them only on program unload/stop and cannot notice a debugger leaving. Verified against a live SLM-RP4 (v4.1.9) at 192.168.2.4: discovery, compile (50 debug leaves, VPP plugin sources, bundle composed) and upload all succeed. `debug open` correctly refuses while the device's physical mode switch is in STOP, and says so. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + configs/webpack/webpack.config.cli.dev.ts | 48 ++ configs/webpack/webpack.config.main.prod.ts | 6 + package.json | 4 + .../editor/compiler/compiler-module.ts | 34 +- src/backend/editor/compiler/types.ts | 15 + .../__tests__/discover-runtimes.test.ts | 127 ++++ .../editor/hardware/discover-runtimes.ts | 165 +++++ .../editor/runtime/runtime-api-client.ts | 532 +++++++++++++++ .../editor/services/project-service/index.ts | 22 +- src/cli/commands/build.ts | 280 ++++++++ src/cli/commands/debug.ts | 597 ++++++++++++++++ src/cli/commands/devices.ts | 67 ++ src/cli/compile/headless-bridge.ts | 96 +++ src/cli/daemon-entry.ts | 62 ++ src/cli/debug/format.ts | 37 + src/cli/debug/open-session.ts | 203 ++++++ src/cli/debug/variables.ts | 220 ++++++ src/cli/exit-codes.ts | 1 + src/cli/main.ts | 307 +++++++++ src/cli/project/load.ts | 86 +++ src/cli/session/client.ts | 67 ++ src/cli/session/daemon-main.ts | 106 +++ src/cli/session/protocol.ts | 331 +++++---- src/cli/session/server.ts | 123 ++++ src/cli/session/session-core.ts | 434 ++++++++++++ src/cli/spawn-session.ts | 244 +++++++ src/frontend/hooks/useDebugPolling.ts | 161 ++--- .../__tests__/debug-response-walker.test.ts | 116 ++++ src/frontend/utils/debug-medium-profile.ts | 64 ++ src/frontend/utils/debug-response-walker.ts | 109 +++ src/main/modules/ipc/main.ts | 639 +++--------------- .../adapters/editor/compiler-adapter.ts | 7 +- 33 files changed, 4472 insertions(+), 842 deletions(-) create mode 100644 configs/webpack/webpack.config.cli.dev.ts create mode 100644 src/backend/editor/hardware/__tests__/discover-runtimes.test.ts create mode 100644 src/backend/editor/hardware/discover-runtimes.ts create mode 100644 src/backend/editor/runtime/runtime-api-client.ts create mode 100644 src/cli/commands/build.ts create mode 100644 src/cli/commands/debug.ts create mode 100644 src/cli/commands/devices.ts create mode 100644 src/cli/compile/headless-bridge.ts create mode 100644 src/cli/daemon-entry.ts create mode 100644 src/cli/debug/format.ts create mode 100644 src/cli/debug/open-session.ts create mode 100644 src/cli/debug/variables.ts create mode 100644 src/cli/main.ts create mode 100644 src/cli/project/load.ts create mode 100644 src/cli/session/client.ts create mode 100644 src/cli/session/daemon-main.ts create mode 100644 src/cli/session/server.ts create mode 100644 src/cli/session/session-core.ts create mode 100644 src/cli/spawn-session.ts create mode 100644 src/frontend/utils/__tests__/debug-response-walker.test.ts create mode 100644 src/frontend/utils/debug-medium-profile.ts create mode 100644 src/frontend/utils/debug-response-walker.ts diff --git a/.gitignore b/.gitignore index 786e77b85..847394b14 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,7 @@ playwright-report # Claude Code session state (a running session's id and pid — never a project artefact) .claude/scheduled_tasks.lock + +# Development build of the headless CLI (see webpack.config.cli.dev.ts) +openplc-cli.dev.js +openplc-cli.dev.js.map diff --git a/configs/webpack/webpack.config.cli.dev.ts b/configs/webpack/webpack.config.cli.dev.ts new file mode 100644 index 000000000..d574b5a81 --- /dev/null +++ b/configs/webpack/webpack.config.cli.dev.ts @@ -0,0 +1,48 @@ +/** + * Development build of the headless CLI. + * + * Two things make this a config of its own rather than another entry on the dev + * main build, and both are path resolution rather than bundling: + * + * - `NODE_ENV=development`, because `CompilerModule` chooses between + * `process.cwd()/resources` and `process.resourcesPath` from it, and that + * choice is baked in at build time. A production-built CLI run from a dev + * checkout looks for arduino-cli inside Electron.app and fails with ENOENT. + * - Output at the REPO ROOT, because Electron sets `app.getAppPath()` to the + * directory of the script it is handed, and the compiler resolves the + * STruC++ runtime headers as `getAppPath()/node_modules/strucpp/...`. The + * dev GUI is launched as `electron .` from the root, so the root is what + * the GUI resolves — and matching it is the whole point of the CLI. + * + * The packaged CLI has neither problem: `app.isPackaged` sends every one of + * these lookups to `process.resourcesPath`. + */ + +import webpack from 'webpack' +import { merge } from 'webpack-merge' + +import devMainConfig from './webpack.config.main.dev' +import webpackPaths from './webpack.paths' + +const configuration: webpack.Configuration = { + output: { + path: webpackPaths.rootPath, + filename: '[name].js', + library: { type: 'umd' }, + }, + + // One file, no chunks. Both matter because the output directory is the repo + // ROOT: merging would keep the dev main build's `main`/`preload` entries and + // emit them here too, and code splitting would scatter vendor chunks + // alongside them — which is exactly the litter this replaced. + optimization: { splitChunks: false, runtimeChunk: false }, +} + +const merged = merge(devMainConfig, configuration) + +export default { + ...merged, + // Assigned after the merge: webpack-merge UNIONS `entry` objects, so the dev + // main build's entries would survive an override expressed inside the merge. + entry: { 'openplc-cli.dev': `${webpackPaths.srcPath}/cli/main.ts` }, +} diff --git a/configs/webpack/webpack.config.main.prod.ts b/configs/webpack/webpack.config.main.prod.ts index c47641dca..40e40b3f9 100644 --- a/configs/webpack/webpack.config.main.prod.ts +++ b/configs/webpack/webpack.config.main.prod.ts @@ -27,6 +27,12 @@ const configuration: webpack.Configuration = { entry: { main: join(webpackPaths.srcMainPath, 'main.ts'), preload: join(webpackPaths.srcMainPath, 'modules/preload/preload.ts'), + // The headless CLI (DOPE-567). Built with the main-process target because + // it IS an Electron main process — one that never opens a window — so it can + // reuse CompilerModule and the rest of `backend/editor`, which reach the + // arduino-cli config, strucpp includes, licence store and installed VPP + // packages through Electron's `app` paths. + cli: join(webpackPaths.srcPath, 'cli/main.ts'), }, output: { diff --git a/package.json b/package.json index d33f3a36d..d720eb6e1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.renderer.dev.dll.ts", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.prod.ts", + "build:cli": "npm run build:main", + "build:cli:dev": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.cli.dev.ts", + "cli": "electron ./release/app/dist/main/cli.js", + "cli:dev": "electron ./openplc-cli.dev.js", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.renderer.prod.ts", "lint": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx}", "lint:fix": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx} --fix", diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 61e4ab068..1e3d6f1be 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -116,7 +116,6 @@ import { import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { app as electronApp, dialog, MessageChannelMain } from 'electron' -import type { MessagePortMain } from 'electron/main' import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' @@ -125,7 +124,7 @@ import { formatPackageIntegrityError, PackageManagerModule } from '../package-ma import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' import { createEditorCompilerPlatformPort } from './editor-compiler-platform-port' -import type { ArduinoCoreControl, HalsFile, ToolchainProperties } from './types' +import type { ArduinoCoreControl, CompileProgressChannel, HalsFile, ToolchainProperties } from './types' interface MethodsResult { success: boolean @@ -2524,8 +2523,23 @@ class CompilerModule { * for compile behaviour shared with openplc-web. */ async compileProgram( - args: Array, - _mainProcessPort: MessagePortMain, + /** + * Positional arguments, destructured below. + * + * The element union names every type the destructure actually reads — + * previously it admitted only `string | null | PLCProjectData`, so the four + * boolean/record positions were representable only because the sole caller + * came through IPC, where the payload is re-declared. A direct caller (the + * headless CLI) needs them declared. + * + * The `as` below is the boundary between this loose array and the named + * positions. It stays because `projectData` arrives as the ports-shape + * object converted by `toIpcProjectData`, while this module declares the + * SCHEMA shape — two definitions of `PLCProjectData` that the IPC edge + * already reconciles with a cast. Unifying them is its own piece of work. + */ + args: Array, + _mainProcessPort: CompileProgressChannel, mainProcessBridge: { makeRuntimeApiRequest: ( ipAddress: string, @@ -3079,7 +3093,7 @@ class CompilerModule { async compileForDebugger( args: Array, - _mainProcessPort: MessagePortMain, + _mainProcessPort: CompileProgressChannel, mainProcessBridge: { loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } }, @@ -3309,7 +3323,7 @@ class CompilerModule { */ async compileLibrary( args: Array, - _mainProcessPort: MessagePortMain, + _mainProcessPort: CompileProgressChannel, mainProcessBridge: LibraryCompileBridge, ): Promise { _mainProcessPort.start() @@ -3453,9 +3467,7 @@ class CompilerModule { // The boolean slots (compileOnly / cleanBuild) are runtime // values the inner `compileProgram` re-casts off `args as [...]`, - // so the outer cast is needed to silence the strict arg type - // (which only admits `string | null | PLCProjectData`). - const compileArgs = [ + const compileArgs: Array = [ projectPath, SIMULATOR_BOARD, boardCore, @@ -3464,7 +3476,9 @@ class CompilerModule { null, null, true, - ] as unknown as Array + null, + undefined, + ] void this.compileProgram(compileArgs, channel.port2, bridge).catch((err) => settle({ success: false, message: getErrorMessage(err) }), ) diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index cc5109af6..ad216a6ce 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -30,3 +30,18 @@ export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' export { ArduinoCoreControlSchema } export type { ArduinoCoreControl, ToolchainProperties } + +/** + * What the compile pipeline needs from the channel it reports progress on. + * + * The pipeline is handed an Electron `MessagePortMain` by the main process and + * only ever calls these three methods on it. Naming the narrow contract lets a + * headless caller (the CLI) drive the SAME pipeline with a plain object: + * `MessagePortMain` satisfies this structurally, so the Electron call sites are + * unchanged and neither side needs a type assertion. + */ +export interface CompileProgressChannel { + start(): void + postMessage(message: unknown): void + close(): void +} diff --git a/src/backend/editor/hardware/__tests__/discover-runtimes.test.ts b/src/backend/editor/hardware/__tests__/discover-runtimes.test.ts new file mode 100644 index 000000000..481875ed4 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/discover-runtimes.test.ts @@ -0,0 +1,127 @@ +import type { NetworkInterfaceInfo } from 'node:os' + +import { + broadcastTargets, + clampDiscoveryDuration, + computeBroadcastAddress, + DISCOVERY_DEFAULT_DURATION_MS, + parseAdvertisement, +} from '../discover-runtimes' + +describe('computeBroadcastAddress', () => { + it('sets the host bits for common masks', () => { + expect(computeBroadcastAddress('192.168.1.5', '255.255.255.0')).toBe('192.168.1.255') + expect(computeBroadcastAddress('10.0.5.7', '255.255.0.0')).toBe('10.0.255.255') + expect(computeBroadcastAddress('172.16.3.9', '255.240.0.0')).toBe('172.31.255.255') + }) + + it('falls back to the global broadcast for a degenerate mask', () => { + // A /32 has no directed broadcast; sending somewhere beats dropping the + // interface silently. + expect(computeBroadcastAddress('192.168.1.5', '255.255.255.255')).toBe('192.168.1.5') + expect(computeBroadcastAddress('nonsense', '255.255.255.0')).toBe('255.255.255.255') + expect(computeBroadcastAddress('192.168.1.5', '999.0.0.0')).toBe('255.255.255.255') + }) +}) + +describe('broadcastTargets', () => { + const iface = (address: string, netmask: string, internal: boolean): NetworkInterfaceInfo => ({ + address, + netmask, + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal, + cidr: null, + }) + + it('includes the global broadcast plus one per external IPv4 interface', () => { + // Per-interface broadcast matters: a host with docker bridges or a VPN does + // not reliably deliver 255.255.255.255 to the subnet the PLC is on. + const targets = broadcastTargets({ + en0: [iface('192.168.1.10', '255.255.255.0', false)], + docker0: [iface('172.17.0.1', '255.255.0.0', false)], + }) + + expect(targets).toEqual(expect.arrayContaining(['255.255.255.255', '192.168.1.255', '172.17.255.255'])) + }) + + it('skips loopback and IPv6 addresses', () => { + const targets = broadcastTargets({ + lo0: [iface('127.0.0.1', '255.0.0.0', true)], + en1: [ + { + address: '::1', + netmask: 'ffff::', + family: 'IPv6', + mac: '00:00:00:00:00:00', + internal: false, + cidr: null, + scopeid: 0, + }, + ], + }) + + expect(targets).toEqual(['255.255.255.255']) + }) + + it('tolerates an interface entry with no addresses', () => { + expect(broadcastTargets({ empty: undefined })).toEqual(['255.255.255.255']) + }) +}) + +describe('parseAdvertisement', () => { + it('reads a runtime advertisement, taking the address from the packet source', () => { + const payload = JSON.stringify({ + service: 'openplc-runtime', + hostname: 'plc-lab', + runtime_version: 'v4.1.10', + api_port: 8443, + }) + + expect(parseAdvertisement(payload, '192.168.1.50')).toEqual({ + ipAddress: '192.168.1.50', + hostname: 'plc-lab', + runtimeVersion: 'v4.1.10', + apiPort: 8443, + }) + }) + + it('defaults the api port and tolerates missing string fields', () => { + const payload = JSON.stringify({ service: 'openplc-runtime' }) + + expect(parseAdvertisement(payload, '10.0.0.2')).toEqual({ + ipAddress: '10.0.0.2', + hostname: '', + runtimeVersion: '', + apiPort: 8443, + }) + }) + + it('ignores traffic that is not an OpenPLC runtime', () => { + // It is a broadcast port; other things on the network do send to it. + expect(parseAdvertisement('not json', '10.0.0.3')).toBeUndefined() + expect(parseAdvertisement('null', '10.0.0.3')).toBeUndefined() + expect(parseAdvertisement('"a string"', '10.0.0.3')).toBeUndefined() + expect(parseAdvertisement(JSON.stringify({ service: 'something-else' }), '10.0.0.3')).toBeUndefined() + }) + + it('ignores wrongly-typed fields rather than trusting them', () => { + const payload = JSON.stringify({ service: 'openplc-runtime', hostname: 42, api_port: '8443' }) + + expect(parseAdvertisement(payload, '10.0.0.4')).toEqual({ + ipAddress: '10.0.0.4', + hostname: '', + runtimeVersion: '', + apiPort: 8443, + }) + }) +}) + +describe('clampDiscoveryDuration', () => { + it('defaults when unset and clamps to a sane window', () => { + expect(clampDiscoveryDuration(undefined)).toBe(DISCOVERY_DEFAULT_DURATION_MS) + expect(clampDiscoveryDuration(1)).toBe(500) + expect(clampDiscoveryDuration(999_999)).toBe(10_000) + expect(clampDiscoveryDuration(4000)).toBe(4000) + }) +}) diff --git a/src/backend/editor/hardware/discover-runtimes.ts b/src/backend/editor/hardware/discover-runtimes.ts new file mode 100644 index 000000000..4e5d0f743 --- /dev/null +++ b/src/backend/editor/hardware/discover-runtimes.ts @@ -0,0 +1,165 @@ +/** + * Finding OpenPLC Runtime v4 targets on the local network. + * + * A runtime (bare v4, or v4 behind a VPP package) answers a UDP broadcast on + * port 33333 with a small JSON advertisement. This is the mechanism behind the + * editor's "Search" button, extracted here so the GUI and the headless CLI run + * the SAME scan: a second implementation would drift on the details that + * actually decide whether a device is found — which interfaces get probed, how + * long the window is, and how replies are deduplicated. + * + * Two details worth keeping: + * + * - Every non-internal IPv4 interface is broadcast to individually, not just + * 255.255.255.255. A host with several interfaces (docker bridges, a second + * NIC, a VPN) does not reliably deliver the global broadcast to the subnet + * the PLC is actually on. + * - A per-target send failure is logged and ignored rather than aborting. + * VPN tun adapters routinely refuse broadcast, and treating that as fatal + * would make a scan fail purely because a VPN was connected. + */ + +import dgram from 'node:dgram' +import { networkInterfaces } from 'node:os' + +export const DISCOVERY_PORT = 33333 +export const DISCOVERY_MAGIC = 'OPENPLC_DISCOVER_V1' +export const DISCOVERY_DEFAULT_DURATION_MS = 3000 +const DISCOVERY_MIN_DURATION_MS = 500 +const DISCOVERY_MAX_DURATION_MS = 10000 + +export interface DiscoveredRuntime { + ipAddress: string + hostname: string + runtimeVersion: string + apiPort: number +} + +export interface DiscoverRuntimesOptions { + durationMs?: number + /** Called as each device replies, so a UI can append rows before the window closes. */ + onDevice?: (device: DiscoveredRuntime) => void + /** Diagnostic sink for per-interface send failures. */ + onDiagnostic?: (message: string) => void +} + +export type DiscoverRuntimesResult = { success: true; devices: DiscoveredRuntime[] } | { success: false; error: string } + +/** + * The directed broadcast address for an IPv4 interface, derived from its + * address and netmask: host bits set to 1, so `192.168.1.5/255.255.255.0` + * becomes `192.168.1.255`. + * + * Falls back to the global broadcast for a /32 or otherwise degenerate mask, + * where no meaningful directed broadcast exists — better to send somewhere + * than to drop the interface silently. + */ +export function computeBroadcastAddress(address: string, netmask: string): string { + const toOctets = (value: string): number[] | null => { + const parts = value.split('.').map((part) => Number(part)) + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null + return parts + } + const addressOctets = toOctets(address) + const maskOctets = toOctets(netmask) + if (!addressOctets || !maskOctets) return '255.255.255.255' + return addressOctets.map((octet, i) => (octet & maskOctets[i]) | (~maskOctets[i] & 0xff)).join('.') +} + +/** Every address worth broadcasting to: the global one plus each interface's. */ +export function broadcastTargets(interfaces = networkInterfaces()): string[] { + const targets = new Set(['255.255.255.255']) + for (const list of Object.values(interfaces)) { + if (!list) continue + for (const info of list) { + if (info.family !== 'IPv4' || info.internal) continue + targets.add(computeBroadcastAddress(info.address, info.netmask)) + } + } + return [...targets] +} + +/** + * Parse one advertisement. Returns undefined for anything that is not an + * OpenPLC runtime reply — the port is a broadcast port and other things on the + * network do send to it. + */ +export function parseAdvertisement(payload: string, sourceAddress: string): DiscoveredRuntime | undefined { + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const record: Record = { ...parsed } + if (record.service !== 'openplc-runtime') return undefined + return { + ipAddress: sourceAddress, + hostname: typeof record.hostname === 'string' ? record.hostname : '', + runtimeVersion: typeof record.runtime_version === 'string' ? record.runtime_version : '', + apiPort: typeof record.api_port === 'number' ? record.api_port : 8443, + } +} + +export function clampDiscoveryDuration(durationMs: number | undefined): number { + return Math.max( + DISCOVERY_MIN_DURATION_MS, + Math.min(DISCOVERY_MAX_DURATION_MS, durationMs ?? DISCOVERY_DEFAULT_DURATION_MS), + ) +} + +/** Broadcast, collect replies for the window, and resolve with what answered. */ +export function discoverRuntimes(options: DiscoverRuntimesOptions = {}): Promise { + const duration = clampDiscoveryDuration(options.durationMs) + + return new Promise((resolveOuter) => { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + // Dedup by source IP; last reply wins, so a runtime that changes its + // hostname mid-scan settles on the fresh data. + const discovered = new Map() + let settled = false + let timer: NodeJS.Timeout | null = null + + const finish = (error?: Error) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + try { + socket.close() + } catch { + /* already closed */ + } + resolveOuter( + error ? { success: false, error: error.message } : { success: true, devices: [...discovered.values()] }, + ) + } + + socket.on('error', (error) => finish(error)) + + socket.on('message', (message, remote) => { + const device = parseAdvertisement(message.toString('utf-8'), remote.address) + if (!device) return + discovered.set(device.ipAddress, device) + options.onDevice?.(device) + }) + + socket.bind(0, () => { + try { + socket.setBroadcast(true) + } catch (error) { + finish(error as Error) + return + } + + const magic = new Uint8Array(Buffer.from(DISCOVERY_MAGIC, 'utf-8')) + for (const target of broadcastTargets()) { + socket.send(magic, DISCOVERY_PORT, target, (sendError) => { + if (sendError) options.onDiagnostic?.(`Discovery send to ${target} failed: ${sendError.message}`) + }) + } + + timer = setTimeout(() => finish(), duration) + }) + }) +} diff --git a/src/backend/editor/runtime/runtime-api-client.ts b/src/backend/editor/runtime/runtime-api-client.ts new file mode 100644 index 000000000..6d4bda5ce --- /dev/null +++ b/src/backend/editor/runtime/runtime-api-client.ts @@ -0,0 +1,532 @@ +/** + * The editor's client for the OpenPLC Runtime v3/v4 REST API. + * + * Lifted verbatim out of `MainProcessBridge`, whose handlers now delegate here, + * so the desktop GUI and the headless CLI make the SAME calls. It was not a + * hypothetical concern: while the CLI briefly carried its own copy of this + * layer it drifted within hours — it POSTed to `/api/start-plc` (the runtime + * answers GET, and replies to a POST with `{"PostRequestError":"Unknown + * argument"}`) and it read HTTP 200 as success, missing that the runtime + * reports refusal in the BODY (`START:ERROR_SWITCH_STOP` when a physical mode + * switch gates it, `COMMAND:BUSY` while a previous program is still + * unloading). Both bugs are invisible until you have real hardware in front of + * you, which is exactly the class of bug a second implementation produces. + * + * What it owns: + * - one `RuntimeTokenManager`, so every call (GET, POST, PUT/DELETE and the + * multipart program upload) self-heals identically when the 15-minute JWT + * expires; + * - the TLS posture (`getRuntimeHttpsOptions`), since real runtimes ship a + * self-signed certificate generated at install time; + * - the endpoint vocabulary and each route's success semantics. + * + * The address is per-call rather than per-instance because a single editor + * session talks to whichever runtime the user points it at, while the token + * authority needs one address to re-authenticate against — `setAddress` records + * that at login. + */ + +import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' +import https from 'node:https' + +import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import type { PlcControlResult } from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' +import { getErrorMessage } from '@root/frontend/utils/get-error-message' +import { + createRuntimeTokenManager, + type RuntimeTokenManager, +} from '@root/middleware/shared/runtime-auth/runtime-token-manager' + +/** The runtime's HTTPS API port. Also the debug WebSocket's port. */ +export const RUNTIME_API_PORT = 8443 + +export type RuntimeApiResult = { success: true; data?: T } | { success: false; error: string } + +export interface RuntimeApiClientOptions { + /** Notified on every transparent token refresh (the GUI mirrors it to the renderer). */ + onTokenChanged?: (token: string) => void +} + +export class RuntimeApiClient { + private readonly RUNTIME_API_PORT = RUNTIME_API_PORT + private readonly RUNTIME_CONNECTION_TIMEOUT_MS = 5000 // 5 seconds (important-comment) + private readonly RUNTIME_LOGIN_TIMEOUT_MS = 15000 // 15 seconds + + /** + * Address of the runtime this session is authenticated against. Captured at + * login so the token authority can re-authenticate against the same device. + */ + private runtimeIp: string | null = null + + /** + * Single token authority: owns the access token + credentials and the + * refresh/retry-on-401 logic, shared byte-for-byte with the web app. + */ + readonly tokens: RuntimeTokenManager = createRuntimeTokenManager({ + login: async (credentials) => { + if (!this.runtimeIp) return { success: false, error: 'No runtime address configured' } + const result = await this.performAuthentication(this.runtimeIp, credentials.username, credentials.password) + return { success: result.success, token: result.accessToken, error: result.error } + }, + }) + + constructor(options: RuntimeApiClientOptions = {}) { + if (options.onTokenChanged) this.tokens.onTokenChanged(options.onTokenChanged) + } + + /** The address the token authority will re-authenticate against. */ + setAddress(ipAddress: string): void { + this.runtimeIp = ipAddress + } + + getAddress(): string | null { + return this.runtimeIp + } + + /** + * Log in and adopt the session, so later calls can self-heal on expiry. + * The one place that turns credentials into a live session. + */ + async login( + ipAddress: string, + username: string, + password: string, + ): Promise<{ success: boolean; accessToken?: string; error?: string }> { + const result = await this.performAuthentication(ipAddress, username, password) + if (result.success && result.accessToken) { + this.runtimeIp = ipAddress + this.tokens.setSession(result.accessToken, { username, password }) + } + return result + } + + /** Forget the session (logout / disconnect). */ + clearSession(): void { + this.tokens.clear() + } + + /** + * Low-level HTTP helper that handles data accumulation, timeout, and error handling. + * Returns the raw status code, response body, and headers for the caller to interpret. + */ + httpRequest(options: { + method: 'GET' | 'POST' + url: string + body?: string + headers?: Record + timeoutMs?: number + }): Promise<{ statusCode: number; data: string; headers: IncomingHttpHeaders }> { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(options.url) + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + method: options.method, + headers: { + ...options.headers, + ...(options.body + ? { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(options.body)), + } + : {}), + }, + ...getRuntimeHttpsOptions(), + } + + const req = https.request(reqOptions as https.RequestOptions, (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + resolve({ statusCode: res.statusCode ?? 0, data, headers: res.headers }) + }) + }) + req.setTimeout(options.timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('Connection timeout')) + }) + req.on('error', (error: Error) => { + reject(error) + }) + if (options.body) { + req.write(options.body) + } + req.end() + }) + } + + /** Full URL for a runtime endpoint. Public because handlers build their own requests. */ + runtimeUrl(ipAddress: string, endpoint: string): string { + return `https://${ipAddress}:${this.RUNTIME_API_PORT}${endpoint}` + } + + private async performAuthentication( + ipAddress: string, + username: string, + password: string, + ): Promise<{ success: boolean; accessToken?: string; error?: string }> { + try { + const res = await this.httpRequest({ + method: 'POST', + url: this.runtimeUrl(ipAddress, '/api/login'), + body: JSON.stringify({ username, password }), + timeoutMs: this.RUNTIME_LOGIN_TIMEOUT_MS, + }) + if (res.statusCode === 200) { + try { + const response = JSON.parse(res.data) as { access_token: string } + return { success: true, accessToken: response.access_token } + } catch { + return { success: false, error: 'Invalid response format' } + } + } + return { success: false, error: res.data } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + private isTokenExpiredError(statusCode: number | undefined, errorMessage: string): boolean { + if (statusCode === 401 || statusCode === 403) { + return true + } + const lowerError = errorMessage.toLowerCase() + return ( + lowerError.includes('unauthorized') || + lowerError.includes('token') || + lowerError.includes('expired') || + lowerError.includes('invalid token') + ) + } + + private parseApiResponse( + data: string, + responseParser?: (data: string) => T, + ): { success: true; data?: T } | { success: false; error: string } { + if (responseParser) { + try { + return { success: true, data: responseParser(data) } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Invalid response format' } + } + } + return { success: true } + } + + async makeRuntimeApiRequest( + ipAddress: string, + endpoint: string, + responseParser?: (data: string) => T, + ): Promise<{ success: true; data?: T } | { success: false; error: string }> { + // The token authority owns the live token + refresh. + type Raw = { success: true; data?: T } | { success: false; error: string; statusCode?: number } + const url = this.runtimeUrl(ipAddress, endpoint) + const result = await this.tokens.withAuth( + async (token) => { + try { + const res = await this.httpRequest({ method: 'GET', url, headers: { Authorization: `Bearer ${token}` } }) + if (res.statusCode === 200) return this.parseApiResponse(res.data, responseParser) + return { success: false, error: res.data, statusCode: res.statusCode } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + }, + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + return result.success ? result : { success: false, error: result.error } + } + + /** + * Make an authenticated POST request to the runtime API with automatic token refresh on 401/403. + */ + makeRuntimeApiPostRequest( + ipAddress: string, + endpoint: string, + body: string, + responseParser: (data: string) => T, + timeoutMs?: number, + ): Promise<{ success: true; data: T } | { success: false; error: string }> { + // Token + refresh owned by the authority. + type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => { + return new Promise((resolve) => { + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + resolve({ success: true, data: responseParser(data) }) + } catch (err) { + resolve({ success: false, error: err instanceof Error ? err.message : 'Invalid response format' }) + } + } else { + // Propagate HTTP status so the caller can detect 401/403 for + // token-refresh without relying on brittle message parsing. + resolve({ + success: false, + error: data || `Unexpected status: ${res.statusCode}`, + statusCode: res.statusCode, + }) + } + }) + }, + ) + req.setTimeout(timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + req.write(body) + req.end() + }) + } + + const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => + r.success ? r : { success: false, error: r.error } + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then(stripStatus) + } + + /** + * Authenticated PUT/DELETE against the runtime API, going through the token + * authority. Unlike the GET/POST helpers this retries only on 401 (a genuine + * expired token): the user-management endpoints use 403 as a legitimate + * business response (e.g. "current password incorrect", "admin required"), + * so retrying on 403 would trigger a pointless re-authentication. Any 2xx is + * success; the raw body is returned so callers can surface error messages. + */ + makeRuntimeApiMutation( + method: 'POST' | 'PUT' | 'DELETE', + ipAddress: string, + endpoint: string, + body?: string, + ): Promise<{ success: true; data: string } | { success: false; error: string }> { + type R = { success: true; data: string } | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const headers: Record = { Authorization: `Bearer ${token}` } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = Buffer.byteLength(body) + } + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method, + headers, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + const statusCode = res.statusCode ?? 0 + if (statusCode >= 200 && statusCode < 300) { + resolve({ success: true, data }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${statusCode}`, statusCode }) + } + }) + }, + ) + req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + if (body !== undefined) req.write(body) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && r.statusCode === 401, + ) + .then((r) => (r.success ? { success: true, data: r.data } : { success: false, error: r.error })) + } + + /** + * Upload a compiled program (multipart) to the runtime, going through the + * token authority so an expired token is transparently refreshed and the + * upload retried — the same self-healing every other runtime call gets. This + * is the path that previously had no refresh, so a long session's upload 401'd + * while status polling kept working. + */ + makeRuntimeApiUpload(opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }): Promise<{ success: true; data: string } | { success: false; error: string }> { + type UploadResult = { success: true; data: string } | { success: false; error: string; statusCode?: number } + const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + + `Content-Type: ${opts.contentType}\r\n\r\n`, + ) + const footer = Buffer.from(`\r\n--${boundary}--\r\n`) + const reqBody = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) + const path = opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file' + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const req = https.request( + { + hostname: opts.ipAddress, + port: this.RUNTIME_API_PORT, + path, + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': reqBody.length, + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + } as https.RequestOptions, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) resolve({ success: true, data }) + else resolve({ success: false, error: data || `HTTP ${res.statusCode}`, statusCode: res.statusCode }) + }) + }, + ) + req.setTimeout(300_000, () => { + req.destroy() + resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) + }) + req.on('error', (err: Error) => resolve({ success: false, error: err.message })) + req.write(reqBody) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then((result) => { + if (result.success) { + opts.onUploadAccepted?.(result.data) + return { success: true as const, data: result.data } + } + return { success: false as const, error: result.error } + }) + } + + /** The `/api/start-plc` call, shared by the session router and the IPC handler. */ + async startPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + // The body is parsed because the runtime answers `COMMAND:BUSY` while it is + // still unloading a previous program after an upload, and callers drive a + // retry loop on that. See `backend/shared/library/start-plc-after-build.ts`. + const result = await this.makeRuntimeApiRequest<{ status?: string }>( + address, + '/api/start-plc', + (data: string) => JSON.parse(data) as { status?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: (result.data?.status ?? '').trim() } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * Stop the PLC. GET, like `startPlc` — see this module's docblock for why the + * verb and the body-vs-status distinction both matter. + */ + async stopPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + const result = await this.makeRuntimeApiRequest<{ status?: string }>( + address, + '/api/stop-plc', + (data: string) => JSON.parse(data) as { status?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: (result.data?.status ?? '').trim() } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** Run state and mode-switch position. */ + async getStatus( + address: string, + ): Promise<{ success: boolean; status?: string; switchPosition?: string; error?: string }> { + try { + const result = await this.makeRuntimeApiRequest<{ status?: string; switchPosition?: string }>( + address, + '/api/status', + (data: string) => JSON.parse(data) as { status?: string; switchPosition?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: result.data?.status, switchPosition: result.data?.switchPosition } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * Run/stop over the REST control channel, reported in the same shape the + * Modbus path returns — so callers handle one result type, not two. + * + * `ERROR_SWITCH_STOP` in the runtime's reply is its way of saying the hardware + * mode switch refused a start, which is exactly what `refusedBySwitch` means + * on the Modbus side (FC 0x4b status 0x86). This is also why HTTP 200 cannot + * be read as success on these routes. + */ + async setPlcState(address: string, action: 'run' | 'stop'): Promise { + const result = action === 'run' ? await this.startPlc(address) : await this.stopPlc(address) + if (!result.success) return { success: false, error: result.error } + + const status = result.status ?? '' + if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } + + // The runtime settles into the new state on its next scan; report the state + // the command asked for so a caller can reflect it without a second round trip. + return { success: true, state: action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED } + } +} diff --git a/src/backend/editor/services/project-service/index.ts b/src/backend/editor/services/project-service/index.ts index 38c53ade0..6f851e3ed 100644 --- a/src/backend/editor/services/project-service/index.ts +++ b/src/backend/editor/services/project-service/index.ts @@ -16,7 +16,15 @@ import { fileOrDirectoryExists } from '../../utils' import { createProjectDefaultStructure, readProjectFiles } from './utils' class ProjectService { - constructor(private serviceManager: InstanceType) {} + /** + * `serviceManager` is the window native dialogs are parented to, and only + * `openProject` — the interactive directory picker — needs one. It is + * optional so the headless CLI can use the file-level operations + * (`createProject`, `readRawProjectFiles`) without an Electron window, which + * is what keeps a CLI-created project byte-compatible with a GUI-created one + * instead of coming from a second writer. + */ + constructor(private serviceManager: InstanceType | null = null) {} public getHistoryProjectsFilePath(): string { const pathToUserDataFolder = join(app.getPath('userData'), 'User') @@ -377,10 +385,16 @@ class ProjectService { } async openProject(): Promise { - const { canceled, filePaths } = await dialog.showOpenDialog(this.serviceManager, { + const dialogOptions = { title: 'Select a PLC project to open', - properties: ['openDirectory'], - }) + properties: ['openDirectory' as const], + } + // Parented to the window when there is one. There is no window in the + // headless CLI, but the CLI never reaches an interactive picker either — + // it is given a path. + const { canceled, filePaths } = this.serviceManager + ? await dialog.showOpenDialog(this.serviceManager, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) if (canceled) { return { diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts new file mode 100644 index 000000000..d16e2e35b --- /dev/null +++ b/src/cli/commands/build.ts @@ -0,0 +1,280 @@ +/** + * `openplc compile` and `openplc upload` — the same pipeline, one flag apart. + * + * Both run `CompilerModule.compileProgram`, the exact call the GUI's build + * button makes. `compile` passes `compileOnly: true` and no runtime address, so + * the pipeline stops after producing artifacts; `upload` logs in first and + * hands the pipeline the address and token, so its existing upload step runs. + * + * There is deliberately no separate upload implementation. Framing the + * multipart body, choosing the bundle, and the post-upload restart are all + * pipeline concerns already solved once, and a second copy would only be + * exercised by the CLI — where a mistake would surface as a device that quietly + * runs the wrong program. + */ + +import { CompilerModule } from '@root/backend/editor/compiler' +import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import { preprocessPous } from '@root/backend/shared/utils/PLC/preprocess-pous' +import { injectLibraryCppBlocks, toIpcProjectData } from '@root/middleware/adapters/editor/compiler-adapter' + +import { boolFlag, type ParsedArgs, stringFlag } from '../args' +import { createHeadlessCompileBridge, createProgressChannel } from '../compile/headless-bridge' +import { ErrorCode, ExitCode } from '../exit-codes' +import type { CliResult, Reporter } from '../output' +import { loadProject } from '../project/load' + +/** One line of compiler output, as posted to the progress channel. */ +interface CompileEvent { + logLevel?: 'info' | 'warning' | 'error' + message?: string + closePort?: boolean +} + +export interface BuildOptions { + /** True for `upload`: connect to a runtime and let the pipeline flash it. */ + withUpload: boolean +} + +export async function runBuild(args: ParsedArgs, reporter: Reporter, options: BuildOptions): Promise { + const projectPath = args.positionals[0] ?? stringFlag(args, 'project') + if (!projectPath) { + return reporter.failure( + { code: ErrorCode.MissingArgument, message: 'Give the project directory, e.g. `openplc compile ./my-project`' }, + ExitCode.Usage, + ) + } + + const loaded = await loadProject(projectPath) + if (!loaded.success) { + return reporter.failure({ code: ErrorCode.ProjectNotFound, message: loaded.error }, ExitCode.NotFound) + } + const project = loaded.project + for (const warning of project.warnings) reporter.progress(`warning: ${warning}`) + + // The project remembers its own board; --target overrides it so one fixture + // can be built for several targets in a test matrix. + const target = stringFlag(args, 'target') ?? project.board + if (!target) { + return reporter.failure( + { + code: ErrorCode.MissingArgument, + message: 'This project names no board — pass --target, e.g. --target "OpenPLC Runtime v4"', + }, + ExitCode.Usage, + ) + } + + let runtime: RuntimeApiClient | null = null + if (options.withUpload) { + const host = stringFlag(args, 'host') ?? stringFlag(args, 'address') + if (!host) { + return reporter.failure( + { code: ErrorCode.MissingArgument, message: 'upload needs --host
(see `openplc devices`)' }, + ExitCode.Usage, + ) + } + const credentials = resolveCredentials(args) + if ('error' in credentials) { + return reporter.failure({ code: ErrorCode.MissingArgument, message: credentials.error }, ExitCode.Usage) + } + + reporter.progress(`Authenticating with ${host}…`) + runtime = new RuntimeApiClient() + const login = await runtime.login(host, credentials.username, credentials.password) + if (!login.success) { + return reporter.failure( + { code: ErrorCode.AuthRejected, message: login.error ?? 'The runtime rejected the credentials' }, + ExitCode.Auth, + ) + } + } + + const host = options.withUpload ? (stringFlag(args, 'host') ?? stringFlag(args, 'address') ?? null) : null + const cleanBuild = boolFlag(args, 'clean') + + reporter.progress(`${options.withUpload ? 'Building and uploading' : 'Building'} "${project.name}" for ${target}…`) + + // The renderer's pre-compile chain, in the same order and with the same + // functions: graft library-supplied C++ blocks in, preprocess POUs (comment + // wrapping, Python -> ST stubs, C++ validation), then convert to the + // schema shape the pipeline consumes. Skipping any of it would compile a + // DIFFERENT program from the same sources — a project with a Python function + // block would silently lose it. + const archives = new LibraryManagerModule().loadAll() + const withLibraryCpp = injectLibraryCppBlocks(project.compileReady, archives) + const isSimulator = target.toLowerCase().includes('simulator') + const { projectData: processed, validationFailed } = preprocessPous(withLibraryCpp, isSimulator, (level, message) => { + reporter.progress(level === 'error' ? `error: ${message}` : message) + }) + if (validationFailed) { + return reporter.failure( + { + code: ErrorCode.CompileFailed, + message: 'POU validation failed — check C/C++ POUs for missing setup()/loop() functions', + }, + ExitCode.CompileFailed, + ) + } + + const outcome = await runCompilePipeline({ + projectPath: project.projectPath, + target, + boardCore: null, + compileOnly: !options.withUpload, + projectData: toIpcProjectData(processed), + runtimeIpAddress: host, + runtimeJwtToken: runtime?.tokens.getToken() ?? null, + cleanBuild, + communicationPort: project.communicationPort ?? null, + vendorScreenData: project.vendorScreenData, + runtime, + onLine: (line, level) => { + if (level === 'error') reporter.progress(`error: ${line}`) + else reporter.progress(line) + }, + }) + + if (!outcome.success) { + return reporter.failure( + { + code: options.withUpload && outcome.stage === 'upload' ? ErrorCode.UploadRejected : ErrorCode.CompileFailed, + message: outcome.error, + details: { diagnostics: outcome.diagnostics }, + }, + options.withUpload && outcome.stage === 'upload' ? ExitCode.TargetError : ExitCode.CompileFailed, + ) + } + + return reporter.success( + { + project: project.name, + projectPath: project.projectPath, + target, + uploaded: options.withUpload, + buildDirectory: `${project.projectPath}/build/${target}`, + warnings: outcome.diagnostics.filter((line) => line.level === 'warning').map((line) => line.message), + }, + () => + options.withUpload + ? `Uploaded "${project.name}" to ${host ?? 'the target'} (${target}).` + : `Built "${project.name}" for ${target}.\nArtifacts: ${project.projectPath}/build/${target}`, + ) +} + +/** Credentials from flags or the environment, with a clear message when absent. */ +export function resolveCredentials(args: ParsedArgs): { username: string; password: string } | { error: string } { + // `--credentials user:pass` is convenient; the environment form exists + // because a flag lands in shell history and CI logs. + const combined = stringFlag(args, 'credentials') ?? process.env.OPENPLC_CREDENTIALS + if (combined) { + const separator = combined.indexOf(':') + if (separator <= 0 || separator === combined.length - 1) { + return { error: 'Credentials must look like user:password' } + } + return { username: combined.slice(0, separator), password: combined.slice(separator + 1) } + } + const username = stringFlag(args, 'user') ?? process.env.OPENPLC_USER + const password = stringFlag(args, 'password') ?? process.env.OPENPLC_PASSWORD + if (!username || !password) { + return { + error: + 'Runtime credentials are required: pass --credentials user:pass (or --user/--password), ' + + 'or set OPENPLC_CREDENTIALS / OPENPLC_USER + OPENPLC_PASSWORD', + } + } + return { username, password } +} + +export interface CompilePipelineResult { + success: boolean + error: string + stage: 'compile' | 'upload' + diagnostics: Array<{ level: 'info' | 'warning' | 'error'; message: string }> +} + +/** + * Drive `compileProgram` to completion. + * + * The pipeline reports asynchronously and signals the end by closing the + * channel, so success is decided from what it said before closing rather than + * from a return value — it has none. An `error` line is what a failed build + * looks like from out here. + */ +export async function runCompilePipeline(options: { + projectPath: string + target: string + boardCore: string | null + compileOnly: boolean + /** Schema-shape project data, as produced by `toIpcProjectData`. */ + projectData: ReturnType + runtimeIpAddress: string | null + runtimeJwtToken: string | null + cleanBuild: boolean + communicationPort: string | null + vendorScreenData: Record | undefined + runtime: RuntimeApiClient | null + onLine: (message: string, level: 'info' | 'warning' | 'error') => void +}): Promise { + const diagnostics: Array<{ level: 'info' | 'warning' | 'error'; message: string }> = [] + let sawUploadStage = false + + return new Promise((resolve) => { + const finish = () => { + const errors = diagnostics.filter((line) => line.level === 'error') + resolve({ + success: errors.length === 0, + error: errors.length === 0 ? '' : errors[errors.length - 1].message, + stage: sawUploadStage ? 'upload' : 'compile', + diagnostics, + }) + } + + const channel = createProgressChannel({ + onMessage: (message: unknown) => { + const event = readCompileEvent(message) + if (!event?.message) return + const level = event.logLevel ?? 'info' + // Anything the pipeline says after it starts uploading belongs to the + // upload stage, which the caller reports with a different exit code. + if (/upload/i.test(event.message)) sawUploadStage = true + diagnostics.push({ level, message: event.message }) + options.onLine(event.message, level) + }, + onClose: finish, + }) + + const compiler = new CompilerModule() + const compileArgs: Array = [ + options.projectPath, + options.target, + options.boardCore, + options.compileOnly, + options.projectData, + options.runtimeIpAddress, + options.runtimeJwtToken, + options.cleanBuild, + options.communicationPort, + options.vendorScreenData, + ] + void compiler + .compileProgram(compileArgs, channel, createHeadlessCompileBridge(options.runtime)) + .catch((error: unknown) => { + diagnostics.push({ level: 'error', message: error instanceof Error ? error.message : String(error) }) + channel.close() + }) + }) +} + +/** Validate a progress payload instead of trusting its shape. */ +function readCompileEvent(message: unknown): CompileEvent | undefined { + if (typeof message !== 'object' || message === null) return undefined + const record: Record = { ...message } + const level = record.logLevel + return { + logLevel: level === 'info' || level === 'warning' || level === 'error' ? level : undefined, + message: typeof record.message === 'string' ? record.message : undefined, + closePort: typeof record.closePort === 'boolean' ? record.closePort : undefined, + } +} diff --git a/src/cli/commands/debug.ts b/src/cli/commands/debug.ts new file mode 100644 index 000000000..0eb5a987c --- /dev/null +++ b/src/cli/commands/debug.ts @@ -0,0 +1,597 @@ +/** + * `openplc debug …` — the session-first debugger. + * + * Every subcommand here is a CLIENT of the session protocol. `open` forks a + * daemon and registers its `session_id`; everything else dials that session's + * socket, sends one request, prints one reply. The REPL is the same thing in a + * loop over readline. + * + * That is what stops the REPL and the scripted path from drifting: there is no + * code path a human can reach that a test cannot, because both produce + * `Request`s and neither touches the debug channel directly. + */ + +import { userInfo } from 'node:os' +import { createInterface } from 'node:readline' + +import { boolFlag, listFlag, type ParsedArgs, stringFlag } from '../args' +import { formatValue, formatVariableList } from '../debug/format' +import { ErrorCode, ExitCode, type ExitCodeValue } from '../exit-codes' +import type { CliResult, Reporter } from '../output' +import { sendRequest } from '../session/client' +import type { OkResponse, Request, Response } from '../session/protocol' +import { type SessionRecord,SessionRegistry } from '../session/registry' +import { renderTable } from './devices' + +export interface DebugContext { + registry: SessionRegistry + /** Spawns the daemon process for `debug open`. */ + spawnSession: (options: SpawnSessionOptions) => Promise +} + +export interface SpawnSessionOptions { + projectPath: string + target: string + host: string + username: string + password: string + uploadIfNeeded: boolean + idleTimeoutMs: number + onProgress: (message: string) => void +} + +export type SpawnSessionResult = + | { success: true; record: SessionRecord } + | { success: false; code: 'auth' | 'connection' | 'md5' | 'not-compiled' | 'internal'; error: string } + +/** Map a session-side error code onto the process exit code a caller branches on. */ +function exitCodeForError(code: string): ExitCodeValue { + switch (code) { + case ErrorCode.SessionNotFound: + case ErrorCode.VariableNotFound: + return ExitCode.NotFound + case ErrorCode.NotConnected: + return ExitCode.Connection + case ErrorCode.AuthRejected: + case ErrorCode.AuthRequired: + return ExitCode.Auth + case ErrorCode.Timeout: + return ExitCode.Timeout + case ErrorCode.InvalidArgument: + case ErrorCode.ValueInvalid: + case ErrorCode.MissingArgument: + return ExitCode.Usage + case ErrorCode.TargetError: + case ErrorCode.UploadRejected: + case ErrorCode.Md5Mismatch: + return ExitCode.TargetError + default: + return ExitCode.Internal + } +} + +export async function runDebug(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { + switch (args.subcommand) { + case 'open': + return runOpen(args, reporter, context) + case 'list': + return runList(reporter, context) + case 'close': + return runClose(args, reporter, context) + case 'repl': + return runRepl(args, reporter, context) + case 'status': + case 'list-vars': + case 'read': + case 'write': + case 'force': + case 'unforce': + case 'start': + case 'stop': + case 'watch': + case 'poll': + case 'unwatch': + return runOneShot(args.subcommand, args, reporter, context) + case undefined: + return reporter.failure( + { + code: ErrorCode.MissingArgument, + message: + 'Name a debug subcommand: open, list, close, status, list-vars, read, write, force, unforce, start, stop, watch, poll, unwatch, repl', + }, + ExitCode.Usage, + ) + default: + return reporter.failure( + { code: ErrorCode.UnknownCommand, message: `Unknown debug subcommand "${args.subcommand}"` }, + ExitCode.Usage, + ) + } +} + +// --------------------------------------------------------------------------- +// open / list / close — session lifecycle +// --------------------------------------------------------------------------- + +async function runOpen(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { + const projectPath = args.positionals[0] ?? stringFlag(args, 'project') + const host = stringFlag(args, 'host') ?? stringFlag(args, 'address') + const target = stringFlag(args, 'target') + if (!projectPath || !host) { + return reporter.failure( + { + code: ErrorCode.MissingArgument, + message: + 'debug open needs a project path and --host
, e.g. `openplc debug open ./proj --host 192.168.1.50`', + }, + ExitCode.Usage, + ) + } + + const credentials = resolveDebugCredentials(args) + if ('error' in credentials) { + return reporter.failure({ code: ErrorCode.MissingArgument, message: credentials.error }, ExitCode.Usage) + } + + // Reuse before opening: a target that serves one client at a time simply + // never answers a second connection, and the failure reads as a bare timeout + // while a perfectly good session sits idle. + if (target && !boolFlag(args, 'force-new')) { + const existing = context.registry.findReusable(projectPath, target) + if (existing) { + reporter.progress(`Reusing session ${existing.sessionId} for the same project and target`) + return reporter.success( + { sessionId: existing.sessionId, reused: true, target: existing.target, projectPath: existing.projectPath }, + () => `${existing.sessionId} (reused)`, + ) + } + } + + const spawned = await context.spawnSession({ + projectPath, + target: target ?? '', + host, + username: credentials.username, + password: credentials.password, + uploadIfNeeded: boolFlag(args, 'upload-if-needed'), + idleTimeoutMs: Number(stringFlag(args, 'idle-timeout') ?? '') || DEFAULT_IDLE_TIMEOUT_MS, + onProgress: (message) => reporter.progress(message), + }) + + if (!spawned.success) { + const code = + spawned.code === 'auth' + ? ErrorCode.AuthRejected + : spawned.code === 'connection' + ? ErrorCode.NotConnected + : spawned.code === 'md5' + ? ErrorCode.Md5Mismatch + : spawned.code === 'not-compiled' + ? ErrorCode.ProjectInvalid + : ErrorCode.Internal + return reporter.failure({ code, message: spawned.error }, exitCodeForError(code)) + } + + const record = spawned.record + return reporter.success( + { + sessionId: record.sessionId, + reused: false, + target: record.target, + projectPath: record.projectPath, + programMd5: record.programMd5, + socketPath: record.socketPath, + }, + () => record.sessionId, + ) +} + +export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 + +function runList(reporter: Reporter, context: DebugContext): Promise { + // Reaping first means the listing never advertises a session nobody is + // listening on — an operator reads this to find forces that need clearing. + const reaped = context.registry.reapStale() + const sessions = context.registry.list() + + return Promise.resolve( + reporter.success({ sessions, reaped }, () => { + const lines: string[] = [] + if (reaped.length > 0) { + lines.push(`Cleaned ${reaped.length} stale session(s): ${reaped.join(', ')}`) + lines.push('(a session that died may have left variables forced on its target)') + lines.push('') + } + if (sessions.length === 0) { + lines.push('No open debug sessions.') + return lines.join('\n') + } + lines.push( + renderTable( + ['SESSION', 'TARGET', 'PROJECT', 'MD5', 'STARTED'], + sessions.map((session) => [ + session.sessionId, + session.target || '-', + session.projectPath, + (session.programMd5 ?? '-').slice(0, 8), + session.startedAt, + ]), + ), + ) + return lines.join('\n') + }), + ) +} + +async function runClose(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { + const releaseForces = !boolFlag(args, 'keep-forces') + const closeAll = boolFlag(args, 'all') + + const targets = closeAll + ? context.registry.list() + : (() => { + const sessionId = stringFlag(args, 'session') ?? args.positionals[0] + if (!sessionId) return undefined + const record = context.registry.get(sessionId) + return record ? [record] : [] + })() + + if (targets === undefined) { + return reporter.failure( + { code: ErrorCode.MissingArgument, message: 'debug close needs --session or --all' }, + ExitCode.Usage, + ) + } + + const closed: Array<{ sessionId: string; released: string[] }> = [] + const failed: Array<{ sessionId: string; error: string }> = [] + + for (const record of targets) { + const result = await sendRequest(record.socketPath, { id: 1, kind: 'close', releaseForces }) + if (!result.success) { + failed.push({ sessionId: record.sessionId, error: result.error }) + // The socket is unreachable, so the daemon is gone; drop the record + // rather than leaving a listing entry that can never be dialled. + context.registry.unregister(record.sessionId) + continue + } + const released = result.response.ok && result.response.data.kind === 'close' ? result.response.data.released : [] + context.registry.unregister(record.sessionId) + closed.push({ sessionId: record.sessionId, released }) + } + + const reaped = context.registry.reapStale() + + return reporter.success({ closed, failed, reaped }, () => { + const lines: string[] = [] + for (const entry of closed) { + lines.push( + entry.released.length > 0 + ? `Closed ${entry.sessionId}, released ${entry.released.length} force(s): ${entry.released.join(', ')}` + : `Closed ${entry.sessionId}`, + ) + } + for (const entry of failed) lines.push(`${entry.sessionId}: ${entry.error} (record removed)`) + if (lines.length === 0) lines.push('Nothing to close.') + return lines.join('\n') + }) +} + +// --------------------------------------------------------------------------- +// One-shot commands +// --------------------------------------------------------------------------- + +async function runOneShot( + kind: Exclude, + args: ParsedArgs, + reporter: Reporter, + context: DebugContext, +): Promise { + const resolved = resolveSession(args, context) + if ('error' in resolved) { + return reporter.failure({ code: ErrorCode.SessionNotFound, message: resolved.error }, ExitCode.NotFound) + } + + const request = buildRequest(kind, args) + if ('error' in request) { + return reporter.failure({ code: ErrorCode.MissingArgument, message: request.error }, ExitCode.Usage) + } + + const result = await sendRequest(resolved.record.socketPath, request.request) + if (!result.success) { + return reporter.failure({ code: ErrorCode.NotConnected, message: result.error }, ExitCode.Connection) + } + return report(reporter, result.response) +} + +/** + * Which session a command talks to: `--session`, or the only open one. + * + * Defaulting to the sole session is what makes an interactive sequence bearable + * without making a multi-session script ambiguous — with more than one open, the + * id becomes required rather than guessed. + */ +export function resolveSession(args: ParsedArgs, context: DebugContext): { record: SessionRecord } | { error: string } { + const sessionId = stringFlag(args, 'session') + if (sessionId) { + const record = context.registry.get(sessionId) + return record ? { record } : { error: `No live session "${sessionId}" (it may have exited; run \`debug list\`)` } + } + const sessions = context.registry.list() + if (sessions.length === 1) return { record: sessions[0] } + if (sessions.length === 0) return { error: 'No open debug sessions — run `openplc debug open` first' } + return { + error: `${sessions.length} sessions are open; name one with --session (${sessions.map((s) => s.sessionId).join(', ')})`, + } +} + +/** Turn CLI flags into a protocol request. */ +export function buildRequest( + kind: Exclude, + args: ParsedArgs, +): { request: Request } | { error: string } { + const id = 1 + const names = [...args.positionals, ...listFlag(args, 'var')] + + switch (kind) { + case 'status': + return { request: { id, kind } } + case 'list-vars': + return { request: { id, kind, filter: stringFlag(args, 'filter') ?? args.positionals[0] } } + case 'read': + if (names.length === 0) return { error: 'read needs at least one variable name' } + return { request: { id, kind, names } } + case 'write': + case 'force': { + const name = args.positionals[0] ?? stringFlag(args, 'var') + const value = args.positionals[1] ?? stringFlag(args, 'value') + if (!name || value === undefined) return { error: `${kind} needs a variable name and a value` } + return { request: { id, kind, name, value } } + } + case 'unforce': { + const name = args.positionals[0] ?? stringFlag(args, 'var') + if (!name) return { error: 'unforce needs a variable name' } + return { request: { id, kind, name } } + } + case 'start': + case 'stop': + return { request: { id, kind } } + case 'watch': { + if (names.length === 0) return { error: 'watch needs at least one variable name' } + const interval = stringFlag(args, 'interval') + return { request: { id, kind, names, intervalMs: interval ? Number(interval) : undefined } } + } + case 'poll': { + const since = stringFlag(args, 'since') + return { request: { id, kind, since: since ? Number(since) : undefined } } + } + case 'unwatch': + return { request: { id, kind, names: names.length > 0 ? names : undefined } } + } +} + +/** Render a response in both modes. */ +function report(reporter: Reporter, response: Response): CliResult { + if (!response.ok) { + return reporter.failure( + { code: coerceErrorCode(response.error.code), message: response.error.message, details: response.error.details }, + exitCodeForError(response.error.code), + ) + } + return reporter.success(payloadOf(response), () => renderOk(response)) +} + +function coerceErrorCode(code: string): (typeof ErrorCode)[keyof typeof ErrorCode] { + const known = Object.values(ErrorCode).find((value) => value === code) + return known ?? ErrorCode.Internal +} + +function payloadOf(response: OkResponse): Record { + const { kind, ...rest } = response.data + return { kind, ...rest } +} + +export function renderOk(response: OkResponse): string { + const data = response.data + switch (data.kind) { + case 'status': { + const status = data.status + const md5 = status.programMd5 ? `${status.programMd5.slice(0, 8)}${status.md5Matches ? '' : ' (MISMATCH)'}` : '-' + return [ + `session ${status.sessionId}`, + `target ${status.target || '-'} via ${status.transport} ${status.descriptor}`, + `project ${status.projectPath}`, + `program ${md5}`, + `plc ${status.plcState}`, + `forced ${status.forced.length > 0 ? status.forced.join(', ') : '(none)'}`, + `watching ${status.watching.length > 0 ? status.watching.join(', ') : '(none)'}`, + `since ${status.startedAt}`, + ].join('\n') + } + case 'list-vars': + return data.variables.length === 0 + ? '(no variables match)' + : renderTable( + ['NAME', 'TYPE', 'BYTES'], + data.variables.map((variable) => [variable.name, variable.type, String(variable.size)]), + ) + case 'read': + return formatVariableList(data.values) + case 'write': + case 'force': + case 'unforce': + return `${data.value.name} : ${data.value.type} = ${formatValue(data.value)}${data.value.forced ? ' [FORCED]' : ''}` + case 'plc-state': + return `PLC is now ${data.plcState}` + case 'watch': + return `Recording ${data.watching.length} variable(s) every ${data.intervalMs} ms: ${data.watching.join(', ')}` + case 'poll': { + if (data.samples.length === 0) + return data.dropped > 0 ? `(no new samples; ${data.dropped} dropped)` : '(no new samples)' + const lines = data.samples.map( + (sample) => + `[${String(sample.atMs).padStart(7)} ms] ` + + sample.values.map((value) => `${value.name}=${formatValue(value)}`).join(' '), + ) + if (data.dropped > 0) lines.push(`(${data.dropped} sample(s) dropped — the buffer filled)`) + return lines.join('\n') + } + case 'unwatch': + return data.watching.length === 0 ? 'Stopped recording.' : `Still recording: ${data.watching.join(', ')}` + case 'close': + return data.released.length > 0 ? `Closed, released: ${data.released.join(', ')}` : 'Closed.' + } +} + +/** Credentials for `debug open`. Same precedence as the build commands. */ +export function resolveDebugCredentials(args: ParsedArgs): { username: string; password: string } | { error: string } { + const combined = stringFlag(args, 'credentials') ?? process.env.OPENPLC_CREDENTIALS + if (combined) { + const separator = combined.indexOf(':') + if (separator <= 0 || separator === combined.length - 1) + return { error: 'Credentials must look like user:password' } + return { username: combined.slice(0, separator), password: combined.slice(separator + 1) } + } + const username = stringFlag(args, 'user') ?? process.env.OPENPLC_USER + const password = stringFlag(args, 'password') ?? process.env.OPENPLC_PASSWORD + if (!username || !password) { + return { + error: + 'Runtime credentials are required: pass --credentials user:pass (or --user/--password), ' + + 'or set OPENPLC_CREDENTIALS / OPENPLC_USER + OPENPLC_PASSWORD', + } + } + return { username, password } +} + +// --------------------------------------------------------------------------- +// REPL — a client of the same protocol, nothing more +// --------------------------------------------------------------------------- + +/** + * Command vocabulary mirroring `strucpp`'s REPL, so moving between the two does + * not mean relearning the verbs. `run`/`step`/`code` are absent on purpose: + * they single-step a compiled binary, which a live PLC cannot do — `start` and + * `stop` are the equivalents here. + */ +const REPL_HELP = `Commands + vars [filter] list variables in the program + get [name...] read one or more variables + set write a variable (program may overwrite next scan) + force pin a variable until unforced + unforce release a pinned variable + watch [name...] start recording; use poll to drain + poll show what has been recorded since the last poll + unwatch [name...] stop recording (all, or the named ones) + start | stop run/stop the PLC + status connection, program md5, plc state, forced list + help this list + quit | exit leave the REPL (the session stays open)` + +/** Map a typed REPL line onto a protocol request. */ +export function parseReplLine( + line: string, + id: number, +): { request: Request } | { error: string } | 'quit' | 'help' | null { + const trimmed = line.trim() + if (trimmed.length === 0) return null + const [verb, ...rest] = trimmed.split(/\s+/) + + switch (verb.toLowerCase()) { + case 'quit': + case 'exit': + return 'quit' + case 'help': + case '?': + return 'help' + case 'vars': + return { request: { id, kind: 'list-vars', filter: rest[0] } } + case 'get': + if (rest.length === 0) return { error: 'get needs at least one variable name' } + return { request: { id, kind: 'read', names: rest } } + case 'set': + if (rest.length < 2) return { error: 'set needs a variable name and a value' } + return { request: { id, kind: 'write', name: rest[0], value: rest.slice(1).join(' ') } } + case 'force': + if (rest.length < 2) return { error: 'force needs a variable name and a value' } + return { request: { id, kind: 'force', name: rest[0], value: rest.slice(1).join(' ') } } + case 'unforce': + if (rest.length === 0) return { error: 'unforce needs a variable name' } + return { request: { id, kind: 'unforce', name: rest[0] } } + case 'watch': + if (rest.length === 0) return { error: 'watch needs at least one variable name' } + return { request: { id, kind: 'watch', names: rest } } + case 'poll': + return { request: { id, kind: 'poll' } } + case 'unwatch': + return { request: { id, kind: 'unwatch', names: rest.length > 0 ? rest : undefined } } + case 'start': + return { request: { id, kind: 'start' } } + case 'stop': + return { request: { id, kind: 'stop' } } + case 'status': + return { request: { id, kind: 'status' } } + default: + return { error: `Unknown command "${verb}" — type help` } + } +} + +async function runRepl(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { + const resolved = resolveSession(args, context) + if ('error' in resolved) { + return reporter.failure({ code: ErrorCode.SessionNotFound, message: resolved.error }, ExitCode.NotFound) + } + const record = resolved.record + + process.stdout.write( + `OpenPLC debug session ${record.sessionId}\n` + + `target ${record.target || '-'} project ${record.projectPath}\n` + + `Type help for commands. Leaving the REPL does not close the session.\n\n`, + ) + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: `openplc[${userInfo().username}]> `, + }) + let nextId = 1 + + await new Promise((resolve) => { + rl.prompt() + rl.on('line', (line) => { + const parsed = parseReplLine(line, nextId++) + if (parsed === null) { + rl.prompt() + return + } + if (parsed === 'quit') { + rl.close() + return + } + if (parsed === 'help') { + process.stdout.write(`${REPL_HELP}\n`) + rl.prompt() + return + } + if ('error' in parsed) { + process.stdout.write(`${parsed.error}\n`) + rl.prompt() + return + } + // Pause while the request is in flight so a fast typist cannot interleave + // two commands on one debug channel. + rl.pause() + void sendRequest(record.socketPath, parsed.request).then((result) => { + if (!result.success) process.stdout.write(`error: ${result.error}\n`) + else if (!result.response.ok) + process.stdout.write(`error [${result.response.error.code}]: ${result.response.error.message}\n`) + else process.stdout.write(`${renderOk(result.response)}\n`) + rl.resume() + rl.prompt() + }) + }) + rl.on('close', () => resolve()) + }) + + return reporter.success({ sessionId: record.sessionId, left: true }, () => `Left session ${record.sessionId}.`) +} diff --git a/src/cli/commands/devices.ts b/src/cli/commands/devices.ts new file mode 100644 index 000000000..4f281ae18 --- /dev/null +++ b/src/cli/commands/devices.ts @@ -0,0 +1,67 @@ +/** + * `openplc devices` — list OpenPLC Runtime v4 targets on the local network. + * + * The same UDP scan the editor's "Search" button runs, via + * `discoverRuntimes`, which covers bare v4 runtimes and v4 behind a VPP + * package because both advertise the same service. + */ + +import { discoverRuntimes } from '@root/backend/editor/hardware/discover-runtimes' + +import { boolFlag, type ParsedArgs, stringFlag } from '../args' +import { ErrorCode } from '../exit-codes' +import { ExitCode } from '../exit-codes' +import type { CliResult, Reporter } from '../output' + +export async function runDevices(args: ParsedArgs, reporter: Reporter): Promise { + const rawTimeout = stringFlag(args, 'timeout') + const durationMs = rawTimeout === undefined ? undefined : Number(rawTimeout) + if (durationMs !== undefined && !Number.isFinite(durationMs)) { + return reporter.failure( + { code: ErrorCode.InvalidArgument, message: `--timeout must be a number of milliseconds, got "${rawTimeout}"` }, + ExitCode.Usage, + ) + } + + reporter.progress('Scanning the local network for OpenPLC runtimes…') + + const result = await discoverRuntimes({ + durationMs, + onDevice: (device) => reporter.progress(` found ${device.ipAddress} (${device.hostname || 'no hostname'})`), + onDiagnostic: (message) => { + if (boolFlag(args, 'verbose')) reporter.progress(` ${message}`) + }, + }) + + if (!result.success) { + return reporter.failure({ code: ErrorCode.Internal, message: result.error }, ExitCode.Internal) + } + + const devices = [...result.devices].sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + + return reporter.success({ devices }, () => { + if (devices.length === 0) { + return 'No runtimes answered. They must be powered on and on this subnet.' + } + const rows = devices.map((device) => [ + device.ipAddress, + device.hostname || '-', + device.runtimeVersion || '-', + String(device.apiPort), + ]) + return renderTable(['ADDRESS', 'HOSTNAME', 'VERSION', 'PORT'], rows) + }) +} + +/** Column-aligned plain text — no box drawing, so it survives a narrow terminal. */ +export function renderTable(headers: string[], rows: string[][]): string { + const widths = headers.map((header, column) => + Math.max(header.length, ...rows.map((row) => (row[column] ?? '').length)), + ) + const line = (cells: string[]) => + cells + .map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column]))) + .join(' ') + .trimEnd() + return [line(headers), ...rows.map(line)].join('\n') +} diff --git a/src/cli/compile/headless-bridge.ts b/src/cli/compile/headless-bridge.ts new file mode 100644 index 000000000..97bd2a4a4 --- /dev/null +++ b/src/cli/compile/headless-bridge.ts @@ -0,0 +1,96 @@ +/** + * The bridge `CompilerModule.compileProgram` expects, implemented for the CLI. + * + * The compiler asks its host for three things: authenticated runtime GETs, the + * multipart program upload, and resolution of project-enabled library names to + * parsed `.stlib` archives. In the GUI those come off `MainProcessBridge`; here + * they come off `RuntimeRestClient` and the real `LibraryManagerModule`. + * + * Using the actual library manager matters more than it looks: it decides which + * archives a compile links, and a CLI that resolved libraries differently would + * compile a *different program* from the same sources — the kind of divergence + * that makes a green test worthless. + */ + +import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import type { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' + +/** + * What the compile pipeline actually needs from its progress channel. + * + * `compileProgram` only ever calls `start`, `postMessage` and `close` on the + * Electron `MessagePortMain` it is handed, so a plain object satisfies the + * contract structurally — no message channel and no cast required. + */ +export interface CompileProgressChannel { + start(): void + postMessage(message: unknown): void + close(): void +} + +export function createProgressChannel(options: { + onMessage: (message: unknown) => void + onClose: () => void +}): CompileProgressChannel { + let closed = false + return { + start: () => undefined, + postMessage: (message: unknown) => { + if (!closed) options.onMessage(message) + }, + close: () => { + if (closed) return + closed = true + options.onClose() + }, + } +} + +export interface HeadlessCompileBridge { + makeRuntimeApiRequest: ( + ipAddress: string, + endpoint: string, + responseParser?: (data: string) => T, + ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> + loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } +} + +/** + * `runtime` is null for a compile-only run: with no runtime address the compiler + * never reaches the upload or status calls, and demanding credentials to build + * artifacts would make `compile` require a device it does not touch. + * + * Every method below is a straight forward into `RuntimeApiClient` — the same + * object `MainProcessBridge` hands the compiler in the GUI — so the compile + * pipeline cannot tell which front end drove it. + */ +export function createHeadlessCompileBridge(runtime: RuntimeApiClient | null): HeadlessCompileBridge { + const libraries = new LibraryManagerModule() + + const noRuntime = (): { success: false; error: string } => ({ + success: false, + error: 'This compile was started without a runtime connection, so it cannot talk to a device', + }) + + return { + makeRuntimeApiRequest(ipAddress, endpoint, responseParser) { + if (!runtime) return Promise.resolve(noRuntime()) + return runtime.makeRuntimeApiRequest(ipAddress, endpoint, responseParser) + }, + + makeRuntimeApiUpload(opts) { + if (!runtime) return Promise.resolve(noRuntime()) + return runtime.makeRuntimeApiUpload(opts) + }, + + loadEnabledArchives: (enabledNames) => libraries.loadEnabledArchives(enabledNames), + } +} diff --git a/src/cli/daemon-entry.ts b/src/cli/daemon-entry.ts new file mode 100644 index 000000000..57eee5993 --- /dev/null +++ b/src/cli/daemon-entry.ts @@ -0,0 +1,62 @@ +/** + * Daemon bootstrap: read the config line from stdin, then serve. + * + * Config arrives on stdin rather than argv because it carries the runtime + * password, and argv is readable by any process on the machine via `ps`. + */ + +import { app } from 'electron' + +import { type DaemonConfig, runDaemon } from './session/daemon-main' + +export async function runDaemonFromStdin(): Promise { + const raw = await readFirstLine() + const config = readConfig(raw) + if (!config) { + process.stdout.write(`${JSON.stringify({ event: 'failed', code: 'internal', error: 'Malformed daemon config' })}\n`) + app.exit(1) + return + } + await runDaemon(config) +} + +function readFirstLine(): Promise { + return new Promise((resolve) => { + let buffered = '' + const onData = (chunk: Buffer) => { + buffered += chunk.toString('utf-8') + const newline = buffered.indexOf('\n') + if (newline === -1) return + process.stdin.off('data', onData) + resolve(buffered.slice(0, newline)) + } + process.stdin.on('data', onData) + process.stdin.on('end', () => resolve(buffered)) + }) +} + +/** Validate the config instead of trusting the pipe. */ +function readConfig(line: string): DaemonConfig | undefined { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const record: Record = { ...parsed } + const strings = ['registryDir', 'projectPath', 'target', 'host', 'username', 'password'] as const + for (const key of strings) { + if (typeof record[key] !== 'string') return undefined + } + return { + registryDir: String(record.registryDir), + projectPath: String(record.projectPath), + target: String(record.target), + host: String(record.host), + username: String(record.username), + password: String(record.password), + uploadIfNeeded: record.uploadIfNeeded === true, + idleTimeoutMs: typeof record.idleTimeoutMs === 'number' ? record.idleTimeoutMs : 0, + } +} diff --git a/src/cli/debug/format.ts b/src/cli/debug/format.ts new file mode 100644 index 000000000..7e43c15aa --- /dev/null +++ b/src/cli/debug/format.ts @@ -0,0 +1,37 @@ +/** + * Human rendering for debug values, mirroring the STruC++ REPL. + * + * Deliberately only the HUMAN side. JSON output carries the canonical value + * (a number for BYTE, a boolean for BOOL); this adds the conventions a person + * reading a terminal expects and the REPL already established: bit-string types + * in hex, booleans as TRUE/FALSE, a `[FORCED]` marker. Someone moving between + * `strucpp`'s REPL and this one should not have to relearn the display. + */ + +import type { VariableValue } from '../session/protocol' + +/** Types the STruC++ REPL shows in hex, because they are bit patterns. */ +const BIT_STRING_TYPES = new Set(['BYTE', 'WORD', 'DWORD', 'LWORD']) + +export function formatValue(value: VariableValue): string { + if (value.value === null) return '' + if (typeof value.value === 'boolean') return value.value ? 'TRUE' : 'FALSE' + if (typeof value.value === 'number' && BIT_STRING_TYPES.has(value.type.toUpperCase())) { + return `16#${value.value.toString(16).toUpperCase()}` + } + return String(value.value) +} + +/** `MAIN.counter : INT = 42 [FORCED]` — one variable, REPL style. */ +export function formatVariableLine(value: VariableValue, namePad = 0): string { + const name = namePad > 0 ? value.name.padEnd(namePad) : value.name + const forced = value.forced ? ' [FORCED]' : '' + return `${name} : ${value.type} = ${formatValue(value)}${forced}` +} + +/** A block of variables, names column-aligned. */ +export function formatVariableList(values: readonly VariableValue[]): string { + if (values.length === 0) return '(no variables)' + const width = Math.max(...values.map((value) => value.name.length)) + return values.map((value) => ` ${formatVariableLine(value, width)}`).join('\n') +} diff --git a/src/cli/debug/open-session.ts b/src/cli/debug/open-session.ts new file mode 100644 index 000000000..16126f778 --- /dev/null +++ b/src/cli/debug/open-session.ts @@ -0,0 +1,203 @@ +/** + * Opening a debug session against a real target. + * + * Establishes the same channel the GUI does — for a Runtime v3/v4 that means a + * REST login for control plus the debug WebSocket for variables, built from the + * same `WebSocketDebugTransport` the editor's main process instantiates. + * + * The MD5 gate is the important part. The debug map addresses variables by + * (arr, elem) positions that are only meaningful for the exact program that was + * compiled; pointing them at a target running something else does not fail + * loudly, it reads the WRONG VARIABLES and reports plausible numbers. So a + * mismatch aborts unless the caller asked for an upload, and after uploading it + * is re-verified rather than assumed. + */ + +import { RUNTIME_API_PORT, RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import type { DeviceDebugChannel } from '@root/backend/shared/debug/types' +import { WebSocketDebugTransport } from '@root/backend/shared/debug/websocket-debug-transport' +import { DEBUG_MEDIUM_PROFILE } from '@root/frontend/utils/debug-medium-profile' +import type { TargetEndian } from '@root/frontend/utils/endian' + +import { restPlcControl, SessionCore } from '../session/session-core' +import { loadDebugIndex } from './variables' + +export interface OpenSessionOptions { + sessionId: string + projectPath: string + target: string + host: string + username: string + password: string + /** + * Called when the target's program does not match the local build. Returning + * true means "an upload happened, re-verify"; false aborts. + */ + onMd5Mismatch?: (details: { targetMd5: string | null; localMd5: string }) => Promise + onProgress?: (message: string) => void +} + +export type OpenSessionResult = + | { success: true; core: SessionCore; runtime: RuntimeApiClient; programMd5: string | null } + | { success: false; code: 'auth' | 'connection' | 'md5' | 'not-compiled'; error: string } + +export async function openRuntimeSession(options: OpenSessionOptions): Promise { + const progress = options.onProgress ?? (() => undefined) + + const indexResult = await loadDebugIndex(options.projectPath, options.target) + if (!indexResult.success) { + return { success: false, code: 'not-compiled', error: indexResult.error } + } + const index = indexResult.index + + progress(`Authenticating with ${options.host}…`) + const runtime = new RuntimeApiClient() + const login = await runtime.login(options.host, options.username, options.password) + if (!login.success) { + return { success: false, code: 'auth', error: login.error ?? 'Runtime rejected the credentials' } + } + + const token = runtime.tokens.getToken() + /* istanbul ignore if -- a successful login always yields a token */ + if (!token) return { success: false, code: 'auth', error: 'Login succeeded but produced no token' } + + progress('Opening the debug channel…') + const channel: DeviceDebugChannel = new WebSocketDebugTransport({ + host: options.host, + port: RUNTIME_API_PORT, + token, + rejectUnauthorized: false, + }) + + try { + await channel.connect() + } catch (error) { + return { + success: false, + code: 'connection', + error: `Could not open the debug channel: ${error instanceof Error ? error.message : String(error)}`, + } + } + + let endian: TargetEndian = 'le' + let targetMd5: string | null = null + try { + const probe = await channel.getMd5Hash() + targetMd5 = probe.md5 + endian = probe.targetEndian + } catch (error) { + channel.disconnect() + // The debug surface only answers for a program that is actually scanning, so + // the usual cause is a stopped PLC — and on hardware with a mode switch the + // user cannot fix that from here. Asking the runtime turns an opaque + // `Unknown error code: 0x83` into the one sentence that resolves it. + const status = await runtime.getStatus(options.host) + const reason = describeStoppedTarget(status) + return { + success: false, + code: 'connection', + error: + reason ?? + `The target did not answer the program MD5 probe: ${error instanceof Error ? error.message : String(error)}`, + } + } + + if (!md5Matches(targetMd5, index.md5)) { + const uploaded = options.onMd5Mismatch ? await options.onMd5Mismatch({ targetMd5, localMd5: index.md5 }) : false + if (!uploaded) { + channel.disconnect() + return { + success: false, + code: 'md5', + error: + `The target is running a different program (target ${targetMd5 ?? 'unknown'}, ` + + `local ${index.md5}). Pass --upload-if-needed to flash the local build first.`, + } + } + // Re-verify rather than trust the upload: the runtime restarts the program + // asynchronously, and reading variables against a stale map is silent + // corruption, not an error. + progress('Re-verifying the program MD5 after upload…') + const reverified = await reverifyMd5(channel, index.md5) + if (!reverified.ok) { + channel.disconnect() + return { success: false, code: 'md5', error: reverified.error } + } + targetMd5 = reverified.md5 + } + + const core = new SessionCore({ + sessionId: options.sessionId, + projectPath: options.projectPath, + target: options.target, + transport: 'websocket', + descriptor: `websocket ${options.host}`, + channel, + index, + plc: restPlcControl(runtime, options.host), + programMd5: targetMd5, + endian, + batchSize: DEBUG_MEDIUM_PROFILE.websocket.batchSize, + }) + + return { success: true, core, runtime, programMd5: targetMd5 } +} + +function md5Matches(targetMd5: string | null, localMd5: string): boolean { + if (!targetMd5 || !localMd5) return false + return targetMd5.toLowerCase() === localMd5.toLowerCase() +} + +/** Poll the MD5 for a short window after an upload, since the restart is async. */ +async function reverifyMd5( + channel: DeviceDebugChannel, + expected: string, +): Promise<{ ok: true; md5: string } | { ok: false; error: string }> { + const deadline = Date.now() + 30_000 + let last: string | null = null + while (Date.now() < deadline) { + try { + const probe = await channel.getMd5Hash() + last = probe.md5 + if (md5Matches(probe.md5, expected)) return { ok: true, md5: probe.md5 } + } catch { + // The debug socket drops while the runtime reloads the program; keep + // trying until the deadline rather than treating the first gap as fatal. + } + await sleep(1000) + } + return { + ok: false, + error: `Uploaded, but the target still reports a different program (target ${last ?? 'unknown'}, local ${expected})`, + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Why a target with no debug surface is unreachable, when the runtime can say. + * + * Returns undefined when the PLC looks fine, so the caller keeps the original + * transport error rather than replacing a real fault with a guess. + */ +function describeStoppedTarget(status: { + success: boolean + status?: string + switchPosition?: string +}): string | undefined { + if (!status.success) return undefined + const running = (status.status ?? '').toUpperCase().includes('RUNNING') + if (running) return undefined + if ((status.switchPosition ?? '').toLowerCase() === 'stop') { + return ( + 'The PLC is stopped and its physical mode switch is in STOP, so no program is scanning and ' + + 'the debug interface has nothing to serve. Move the switch to RUN, then retry.' + ) + } + return ( + 'The PLC is stopped, so no program is scanning and the debug interface has nothing to serve. ' + + 'Start it (`openplc debug start`, or the runtime UI) and retry.' + ) +} diff --git a/src/cli/debug/variables.ts b/src/cli/debug/variables.ts new file mode 100644 index 000000000..d1c5074a5 --- /dev/null +++ b/src/cli/debug/variables.ts @@ -0,0 +1,220 @@ +/** + * Variables in a debug session: name → address, and bytes → value. + * + * Everything here is a thin arrangement of code the editor already runs. The + * debug map is parsed by `debug-parser`, addresses are packed by + * `packDebugAddr`, values are decoded by `parseValueByTypeName` and encoded by + * `encodeForceValue`, and byte order is normalised by `applySwapToVariableBytes`. + * Reimplementing any of those would mean the CLI could read a different value + * from the same bytes than the watch panel does — which would make a passing + * test meaningless. + * + * The decode loop mirrors `useDebugPolling`'s: the runtime replies with raw + * type-sized values packed in request order, plus a `lastIndex` saying how far + * it actually got. Trusting the request length instead of `lastIndex` is how + * you end up reading the next variable's bytes as this one's value. + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { + buildLeafInfoMap, + type DebugLeafInfo, + type DebugMap, + packDebugAddr, + parseDebugMap, +} from '@root/frontend/utils/debug-parser' +import { walkDebugResponse } from '@root/frontend/utils/debug-response-walker' +import type { TargetEndian } from '@root/frontend/utils/endian' +import { encodeForceValue } from '@root/frontend/utils/variable-sizes' + +import type { VariableValue } from '../session/protocol' + +/** One resolved variable: its canonical path plus everything needed to talk about it. */ +export interface ResolvedVariable extends DebugLeafInfo { + /** Canonical path, in the casing `debug-map.json` declares. */ + name: string + /** Packed (arr << 16 | elem) — the flat index the transports carry. */ + index: number +} + +export interface DebugVariableIndex { + /** MD5 of the compiled program this map belongs to. */ + md5: string + /** Canonical order, as the compiler emitted it. */ + all: ResolvedVariable[] + /** UPPERCASE path → variable. */ + byName: Map + /** Packed index → variable, for decoding a reply. */ + byIndex: Map +} + +/** Where the compiler leaves the debug map for a given target. */ +export function debugMapPath(projectPath: string, boardTarget: string): string { + return join(projectPath, 'build', boardTarget, 'src', 'debug-map.json') +} + +export type LoadDebugIndexResult = { success: true; index: DebugVariableIndex } | { success: false; error: string } + +/** + * Read and index the debug map produced by the last compile for this target. + * + * A missing file is the normal "you have not compiled for this target yet" + * case, and it is reported as such rather than as a parse failure — the two + * have completely different fixes. + */ +export async function loadDebugIndex(projectPath: string, boardTarget: string): Promise { + const path = debugMapPath(projectPath, boardTarget) + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch { + return { + success: false, + error: `No debug map at ${path}. Compile this project for "${boardTarget}" first.`, + } + } + const map = parseDebugMap(raw) + if (!map) return { success: false, error: `Malformed or unsupported debug map at ${path}` } + return { success: true, index: indexDebugMap(map) } +} + +export function indexDebugMap(map: DebugMap): DebugVariableIndex { + const leafInfo = buildLeafInfoMap(map) + const all: ResolvedVariable[] = [] + const byName = new Map() + const byIndex = new Map() + + for (const leaf of map.leaves) { + const info = leafInfo.get(leaf.path.toUpperCase()) + /* istanbul ignore if -- buildLeafInfoMap is built from these same leaves */ + if (!info) continue + const resolved: ResolvedVariable = { + ...info, + name: leaf.path, + index: packDebugAddr({ arrayIdx: leaf.arrayIdx, elemIdx: leaf.elemIdx }), + } + all.push(resolved) + // First declaration wins, matching `buildLeafPathMap`. + if (!byName.has(leaf.path.toUpperCase())) byName.set(leaf.path.toUpperCase(), resolved) + if (!byIndex.has(resolved.index)) byIndex.set(resolved.index, resolved) + } + + return { md5: map.md5, all, byName, byIndex } +} + +/** + * Look a variable up the way a person types it: case-insensitively. + * + * IEC identifiers are case-insensitive, and a caller that has to match the + * compiler's exact casing would be guessing. + */ +export function findVariable(index: DebugVariableIndex, name: string): ResolvedVariable | undefined { + return index.byName.get(name.trim().toUpperCase()) +} + +/** Case-insensitive substring filter, preserving the compiler's order. */ +export function filterVariables(index: DebugVariableIndex, filter: string | undefined): ResolvedVariable[] { + if (!filter) return index.all + const needle = filter.toUpperCase() + return index.all.filter((variable) => variable.name.toUpperCase().includes(needle)) +} + +/** + * Decode a `getVariablesList` reply into typed values. + * + * The positional walk is `walkDebugResponse`, shared with the GUI's + * `useDebugPolling` — `lastIndex` handling, consumed-but-undecodable slots, the + * short-buffer stop and the endian swap all mean the same thing here as they do + * in the watch panel, because they are the same code. What is specific to this + * caller is only the JSON typing of the value. + */ +export function decodeVariableValues(options: { + requested: readonly ResolvedVariable[] + payload: Uint8Array + lastIndex: number | undefined + endian: TargetEndian + forced: ReadonlySet +}): VariableValue[] { + const { requested, payload, lastIndex, endian, forced } = options + const byIndex = new Map(requested.map((variable) => [variable.index, variable])) + const values: VariableValue[] = [] + + walkDebugResponse({ + requested: requested.map((variable) => variable.index), + payload, + lastIndex, + endian, + typeOf: (index) => byIndex.get(index)?.type, + emit: ({ index, type, value }) => { + const variable = byIndex.get(index) + /* istanbul ignore if -- typeOf resolved this index a moment ago */ + if (!variable) return + values.push({ + name: variable.name, + type, + value: normaliseValue(value, type), + forced: forced.has(variable.name.toUpperCase()), + }) + }, + onError: ({ index, type }) => { + const variable = byIndex.get(index) + /* istanbul ignore if -- typeOf resolved this index a moment ago */ + if (!variable) return + // `null` rather than a sentinel string: a caller checking the value must + // not have to know that "ERR" means unreadable. + values.push({ name: variable.name, type, value: null, forced: forced.has(variable.name.toUpperCase()) }) + }, + }) + + return values +} + +/** + * Turn the codec's display string into a JSON-typed value. + * + * The codec returns strings for every type because the watch panel renders + * text. A machine caller needs types: a BOOL should be `true`, not `"TRUE"`, + * and an INT `42`, not `"42"`. 64-bit integers stay strings deliberately — + * they do not survive an IEEE double, and silently losing precision on a LINT + * is worse than making the caller parse a decimal string. + */ +export function normaliseValue(displayValue: string, typeName: string): boolean | number | string { + const type = typeName.toUpperCase() + if (type === 'BOOL') return displayValue === 'TRUE' + if (type === 'LINT' || type === 'ULINT') return displayValue + if (type === 'STRING' || type === 'WSTRING') { + // The codec wraps strings in quotes for display; the value itself does not + // include them. + return displayValue.startsWith('"') && displayValue.endsWith('"') ? displayValue.slice(1, -1) : displayValue + } + if (INTEGER_TYPES.has(type) || FLOAT_TYPES.has(type)) { + const parsed = Number(displayValue) + return Number.isNaN(parsed) ? displayValue : parsed + } + // TIME / DATE / TOD / DT keep their formatted IEC literal — it is the useful + // form, and re-deriving nanoseconds from it is lossy. + return displayValue +} + +const INTEGER_TYPES = new Set(['SINT', 'USINT', 'INT', 'UINT', 'DINT', 'UDINT', 'BYTE', 'WORD', 'DWORD', 'LWORD']) +const FLOAT_TYPES = new Set(['REAL', 'LREAL']) + +/** + * Encode a user-supplied value for a write or a force. + * + * Delegates to `encodeForceValue`, the same encoder the watch panel's force + * dialog uses, so `16#FF`, `TRUE` and `T#5s` are accepted identically here and + * in the GUI. + */ +export function encodeValue( + variable: ResolvedVariable, + input: string, +): { success: true; bytes: Uint8Array } | { success: false; error: string } { + try { + return { success: true, bytes: encodeForceValue(input, variable.type) } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index d130a4788..9fcd68d08 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -54,6 +54,7 @@ export const ErrorCode = { NotConnected: 'not_connected', AuthRequired: 'auth_required', AuthRejected: 'auth_rejected', + TargetError: 'target_error', UploadRejected: 'upload_rejected', Md5Mismatch: 'md5_mismatch', Timeout: 'timeout', diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 000000000..4aa5fa939 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,307 @@ +/** + * The headless CLI's entry point. + * + * Runs inside Electron's main process with NO window, because that is the only + * way to reuse the editor's real pipeline: `CompilerModule` resolves the + * arduino-cli config, the strucpp runtime includes, the licence store and the + * installed VPP packages through `app.getPath('userData')` / `app.getAppPath()`, + * and ten other modules under `backend/editor` import `electron` directly. + * Making this a literal plain-Node process would mean de-Electroning that whole + * layer — a large refactor of GUI code paths, with a new risk surface on + * package integrity, for no GUI benefit. + * + * Running as Electron main gives what "headless" was actually for (no window, + * no renderer, scriptable, CI-runnable) and is strictly better for the testing + * goal: the CLI resolves the SAME paths, packages and licence store the GUI + * does. A separate path resolver for the CLI would be exactly the divergence + * this tool exists to detect. + * + * No `app.whenReady()` is awaited: nothing here needs the GUI subsystems, and + * not waiting is what lets the CLI run on a machine with no display. + */ + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import { APP_VERSION } from '@root/frontend/data/constants/app-version' +import { app } from 'electron' + +import { boolFlag, parseArgs, type ParsedArgs, stringFlag } from './args' +import { runDaemonFromStdin } from './daemon-entry' +import { runBuild } from './commands/build' +import { type DebugContext, runDebug } from './commands/debug' +import { runDevices } from './commands/devices' +import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' +import { createProcessReporter, Reporter } from './output' +import { SessionRegistry } from './session/registry' +import { createSessionSpawner } from './spawn-session' + +/** + * Flags that must never consume the following token. Missing one here is how + * `--upload-if-needed --target x` silently parses `--target` as a value. + */ +const BOOLEAN_FLAGS = [ + 'json', + 'quiet', + 'verbose', + 'help', + 'version', + 'clean', + 'upload-if-needed', + 'force-new', + 'keep-forces', + 'all', +] as const + +const COMMANDS_WITH_SUBCOMMANDS = ['debug'] as const + +const USAGE = `openplc — headless OpenPLC Editor + +Usage + openplc devices [--timeout ] + openplc compile [--target ] [--clean] + openplc upload --host
[--target ] [--clean] + openplc debug open --host
--target [--upload-if-needed] + openplc debug list + openplc debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch + openplc debug close --session | --all [--keep-forces] + openplc debug repl [--session ] + +Credentials (upload, debug) + --credentials user:pass, or --user + --password + or OPENPLC_CREDENTIALS / OPENPLC_USER + OPENPLC_PASSWORD (keeps them out of shell history) + +Paths + --user-data override the editor state directory (arduino-cli config, licence store, + installed VPP packages). Defaults to the same directory the GUI uses. + +Output + JSON when stdout is not a terminal, human-readable when it is; --json / --no-json override. + Progress goes to stderr, so stdout carries exactly one JSON document. + +Exit codes + 0 ok · 2 usage · 3 not found · 4 compile failed · 5 connection · 6 auth · 7 target error · 8 timeout · 70 internal` + +/** + * Point this process at the SAME `userData` directory the editor GUI uses. + * + * Electron derives `userData` from `app.getName()`, and it only picks the app's + * own name up when it is handed an app DIRECTORY (or is packaged). Launched as + * `electron path/to/cli.js` it keeps the default "Electron", so `userData` + * becomes `…/Application Support/Electron` — a directory with no arduino-cli + * config, no licence store and, decisively, no installed VPP packages. The + * first symptom is a compile failing with `Board "SLM-RP4" not found in + * hals.json or installed VPP packages` for a board the GUI builds happily. + * + * The name is READ from the app's package.json rather than hardcoded, so it + * cannot drift from whatever the GUI resolves. Packaged builds already carry + * electron-builder's `productName`, so they are left alone. + */ +function alignUserDataWithEditor(explicitDir: string | undefined): void { + if (explicitDir) { + app.setPath('userData', explicitDir) + return + } + if (app.isPackaged) return + const name = nearestAppName(app.getAppPath()) + if (!name) return + // `setPath` rather than `setName` alone: Electron resolves `userData` at + // process start, so renaming the app afterwards does not move it. Setting the + // path explicitly does, and it is the value that actually matters here. + app.setName(name) + app.setPath('userData', join(app.getPath('appData'), name)) +} + +/** + * The `name` from the nearest package.json at or above `from`. + * + * Walking up is required, not defensive: launched as `electron + * path/to/build/cli.js`, `app.getAppPath()` is the BUILD directory, which has + * no package.json — reading only there silently finds nothing and leaves the + * process on the default "Electron" userData, which is how a board the GUI + * builds fine comes back as "not found in hals.json or installed VPP packages". + */ +function nearestAppName(from: string): string | undefined { + let directory = from + for (;;) { + try { + const manifest: unknown = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf-8')) + if (typeof manifest === 'object' && manifest !== null) { + const fields: Record = { ...manifest } + if (typeof fields.name === 'string' && fields.name.length > 0) return fields.name + } + } catch { + // No manifest here; try the parent. + } + const parent = dirname(directory) + if (parent === directory) return undefined + directory = parent + } +} + +/** Where session records and sockets live: per-user, beside the editor's own state. */ +export function registryDir(): string { + return join(app.getPath('userData'), 'User', 'cli-sessions') +} + +async function dispatch(args: ParsedArgs, reporter: Reporter): Promise { + // Version before the no-command branch: `openplc --version` has no command, + // and answering it with the usage text (plus a usage exit code) is wrong. + if (boolFlag(args, 'version') || args.command === 'version') { + return reporter.success({ version: APP_VERSION }, () => APP_VERSION).exitCode + } + + if (boolFlag(args, 'help') || args.command === 'help' || args.command === undefined) { + process.stdout.write(`${USAGE}\n`) + // No command at all is a usage error; asking for help is not. + return args.command === undefined && !boolFlag(args, 'help') ? ExitCode.Usage : ExitCode.Ok + } + + switch (args.command) { + case 'devices': + return (await runDevices(args, reporter)).exitCode + case 'compile': + return (await runBuild(args, reporter, { withUpload: false })).exitCode + case 'upload': + return (await runBuild(args, reporter, { withUpload: true })).exitCode + case 'debug': + return (await runDebug(args, reporter, buildDebugContext())).exitCode + default: + return reporter.failure( + { code: ErrorCode.UnknownCommand, message: `Unknown command "${args.command}". Try \`openplc --help\`.` }, + ExitCode.Usage, + ).exitCode + } +} + +function buildDebugContext(): DebugContext { + const dir = registryDir() + return { + registry: new SessionRegistry(dir), + spawnSession: createSessionSpawner({ + registryDir: dir, + execPath: process.execPath, + execArgs: [cliEntryPath()], + uploadProgram: async ({ projectPath, target, host, username, password, onLine }) => { + // The upload path is the ordinary `upload` command, driven in-process so + // there is exactly one implementation of compile-then-flash. + // A reporter whose progress channel forwards to the caller's `onLine` + // and whose result channel is discarded — `debug open` reports the + // outcome itself, so a second result document would be noise. + const uploadReporter = new Reporter({ + mode: 'json', + streams: { out: () => undefined, err: (text) => onLine(text.replace(/\n$/, '')) }, + }) + const result = await runBuild( + { + command: 'upload', + subcommand: undefined, + positionals: [projectPath], + flags: { host, target, credentials: `${username}:${password}` }, + }, + uploadReporter, + { withUpload: true }, + ) + return result.exitCode === ExitCode.Ok + ? { success: true } + : { success: false, error: 'The compile-and-upload step failed (see the progress output above)' } + }, + probeTargetMd5: async ({ host, username, password }) => { + const runtime = new RuntimeApiClient() + const login = await runtime.login(host, username, password) + if (!login.success) return { success: false, error: login.error ?? 'The runtime rejected the credentials' } + const result = await runtime.makeRuntimeApiRequest(host, '/api/compilation-status', (body: string) => { + const parsed: unknown = JSON.parse(body) + const md5 = typeof parsed === 'object' && parsed !== null ? (parsed as { md5?: unknown }).md5 : undefined + return { md5: typeof md5 === 'string' ? md5 : null } + }) + // A runtime that does not publish an MD5 over REST is not an error: the + // session's own debug-channel probe is authoritative, this is only a + // shortcut to avoid a needless upload. + return { success: true, md5: result.success ? (result.data?.md5 ?? null) : null } + }, + }), + } +} + +/** + * The script to hand Electron when re-entering this program as a daemon. + * + * It must be the CLI BUNDLE, never the app directory. Passing + * `app.getAppPath()` here launched the editor GUI: Electron treats a directory + * as an app package, reads `package.json`'s `main`, and starts the window — + * so `debug open` opened the editor instead of a headless session. Anything + * that can resolve to the app entry is therefore refused rather than guessed + * at, because the failure mode is "silently starts the wrong program". + */ +function cliEntryPath(): string { + // Dev: Electron was handed this bundle's path, and webpack leaves __filename + // as the real runtime path (`node: { __filename: false }`). + const candidate = app.isPackaged ? join(app.getAppPath(), 'dist', 'main', 'cli.js') : (process.argv[1] ?? __filename) + if (!candidate.endsWith('.js')) { + throw new Error( + `Cannot locate the CLI bundle to spawn a debug session (resolved "${candidate}"). ` + + 'Refusing to re-launch, because a non-bundle path starts the editor GUI instead.', + ) + } + return candidate +} + +async function main(): Promise { + // The daemon reads its config from stdin and never parses argv. + if (process.argv.includes('--cli-daemon')) { + alignUserDataWithEditor(undefined) + await runDaemonFromStdin() + return + } + + const argv = cliArgv(process.argv) + const args = parseArgs(argv, { + booleanFlags: BOOLEAN_FLAGS, + commandsWithSubcommands: COMMANDS_WITH_SUBCOMMANDS, + }) + + // Before anything reads an app path: the compiler, the licence store and the + // package manager all resolve off `userData`. + alignUserDataWithEditor(stringFlag(args, 'user-data')) + + const reporter = createProcessReporter({ + json: boolFlag(args, 'json'), + noJson: args.flags.json === false, + quiet: boolFlag(args, 'quiet'), + }) + + let exitCode: ExitCodeValue + try { + exitCode = await dispatch(args, reporter) + } catch (error) { + exitCode = reporter.internalError(error).exitCode + } + app.exit(exitCode) +} + +/** + * Strip the launcher's own arguments. + * + * Packaged: `[app, ...userArgs]`. Development: `[electron, scriptPath, + * ...userArgs]`. Getting this wrong makes the first user argument disappear, + * which reads as a missing command rather than as an argv bug. + */ +export function cliArgv(argv: readonly string[]): string[] { + const rest = argv.slice(1) + const withoutMarker = rest.filter((argument) => argument !== '--cli') + if (withoutMarker.length > 0 && !withoutMarker[0].startsWith('-') && looksLikeEntryPath(withoutMarker[0])) { + return withoutMarker.slice(1) + } + return withoutMarker +} + +function looksLikeEntryPath(value: string): boolean { + return value.endsWith('.js') || value.endsWith('.ts') || value.endsWith('.asar') || value.includes('/dist/') +} + +/** `stringFlag` is re-exported for the daemon entry, which shares the parser. */ +export { stringFlag } + +void main() diff --git a/src/cli/project/load.ts b/src/cli/project/load.ts new file mode 100644 index 000000000..574a30fe5 --- /dev/null +++ b/src/cli/project/load.ts @@ -0,0 +1,86 @@ +/** + * Loading a project the way the editor loads it. + * + * The compile-ready snapshot depends on more than `project.json`: alias → + * address resolution is derived from the DEVICE state too (the selected board, + * its pin mapping, VPP screen data, remote devices), which `buildIecRegistry` + * reads straight off the store. Reconstructing those inputs by hand here would + * be a second implementation of address allocation — the exact divergence this + * CLI exists to avoid. + * + * So the CLI reuses the main process's `readProjectFiles`, hands the result to + * the same `parseProjectFiles` the project adapter calls, and drives a real + * store instance through `sharedWorkspaceActions.handleOpenProjectResponse` — + * the single entry point the renderer uses on project open. Zustand is plain + * JavaScript, so this needs no renderer and no window; the store is simply an + * in-process project model. The payoff is that `getCompileReadyProjectData()` + * here is literally the same call the GUI's build button makes. + */ + +import { ProjectService } from '@root/backend/editor/services' +import { parseProjectFiles } from '@root/backend/shared/utils/parse-project-files' +import { createOpenPLCStore } from '@root/frontend/store' +import { isDataTypeFilesEnabled } from '@root/frontend/utils/feature-flags' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' + +export interface LoadedProject { + projectPath: string + name: string + /** Aliases still in their stored form — what the GUI displays. */ + data: PLCProjectData + /** Aliases resolved to concrete IEC addresses — what the compiler consumes. */ + compileReady: PLCProjectData + /** Board the project last selected; the default compile target. */ + board: string + vendorScreenData: Record | undefined + communicationPort: string | undefined + warnings: string[] +} + +export type LoadProjectResult = { success: true; project: LoadedProject } | { success: false; error: string } + +export async function loadProject(projectPath: string): Promise { + // The main process's own reader, so the CLI sees exactly the file set the + // GUI sees — including the defaults it synthesises for missing device files. + const raw = await new ProjectService().readRawProjectFiles(projectPath) + if (!raw.success || !raw.data) { + return { + success: false, + error: raw.error?.description ?? `Could not read a project at ${projectPath}`, + } + } + + const parsed = parseProjectFiles( + raw.data.projectPath, + raw.data.projectJson, + raw.data.deviceConfig, + raw.data.pinMapping, + raw.data.pouFiles, + raw.data.serverFiles, + raw.data.remoteDeviceFiles, + raw.data.libraryManifest, + // Same flag gate the project adapter applies: with the flag off, legacy + // project.json stays the source of truth for datatypes. + isDataTypeFilesEnabled() && Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], + ) + + // A store instance per load, not the shared singleton: two loads in one + // process must not see each other's project. + const store = createOpenPLCStore() + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(parsed) + + const state = store.getState() + return { + success: true, + project: { + projectPath, + name: state.project.meta.name, + data: state.project.data, + compileReady: state.projectActions.getCompileReadyProjectData(), + board: state.deviceDefinitions.configuration.deviceBoard, + vendorScreenData: state.deviceDefinitions.configuration.vendorScreenData, + communicationPort: state.deviceDefinitions.configuration.communicationPort, + warnings: parsed.warnings ?? [], + }, + } +} diff --git a/src/cli/session/client.ts b/src/cli/session/client.ts new file mode 100644 index 000000000..835a768df --- /dev/null +++ b/src/cli/session/client.ts @@ -0,0 +1,67 @@ +/** + * A one-shot client: connect, send one request, read one response, exit. + * + * This is the shape every non-REPL debug command takes, and the reason the + * session exists as a separate process. `openplc debug read x --session ` + * pays a unix-socket round trip, not a connect + MD5 verify + possible + * re-upload — so a test can make fifty assertions without fifty reconnects. + */ + +import { connect } from 'node:net' + +import { decodeResponse, encodeMessage, type Request, type Response, splitLines } from './protocol' + +const DEFAULT_TIMEOUT_MS = 60_000 + +export type SendResult = { success: true; response: Response } | { success: false; error: string } + +/** Send one request to a live session and resolve with its reply. */ +export function sendRequest(socketPath: string, request: Request, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + let settled = false + let buffered = '' + + const finish = (result: SendResult) => { + if (settled) return + settled = true + clearTimeout(timer) + socket.destroy() + resolve(result) + } + + const socket = connect(socketPath) + + const timer = setTimeout( + () => finish({ success: false, error: `The session did not answer within ${timeoutMs} ms` }), + timeoutMs, + ) + + socket.on('connect', () => socket.write(encodeMessage(request))) + + socket.on('data', (chunk: Buffer) => { + const { lines, rest } = splitLines(buffered, chunk.toString('utf-8')) + buffered = rest + for (const line of lines) { + const response = decodeResponse(line) + // Only our own reply settles this — a session serving several clients + // could in principle write something else onto the wire. + if (response && response.id === request.id) { + finish({ success: true, response }) + return + } + } + }) + + socket.on('error', (error: NodeJS.ErrnoException) => { + // ENOENT / ECONNREFUSED means the socket file outlived its process. The + // registry reaps that on read, so the useful advice is to re-open. + const hint = + error.code === 'ENOENT' || error.code === 'ECONNREFUSED' + ? ' (the session is no longer listening — it may have exited; run `debug list`)' + : '' + finish({ success: false, error: `${error.message}${hint}` }) + }) + + socket.on('close', () => finish({ success: false, error: 'The session closed the connection without replying' })) + }) +} diff --git a/src/cli/session/daemon-main.ts b/src/cli/session/daemon-main.ts new file mode 100644 index 000000000..bff64c8c9 --- /dev/null +++ b/src/cli/session/daemon-main.ts @@ -0,0 +1,106 @@ +/** + * The debug session daemon's own entry point. + * + * Spawned by `debug open` as a detached child. It opens the channel, registers + * its `session_id`, and then serves requests until it is closed or goes idle. + * + * The handshake with the parent runs over stdout as one JSON line — `ready` + * with the record, or `failed` with a reason — so `debug open` can report a bad + * password or an MD5 mismatch as its OWN failure instead of returning a session + * id that turns out to be dead on the first read. + */ + +import { app } from 'electron' + +import { openRuntimeSession } from '../debug/open-session' +import { mintSessionId, SessionRegistry, socketPathFor } from './registry' +import { SessionServer } from './server' + +export interface DaemonConfig { + registryDir: string + projectPath: string + target: string + host: string + username: string + password: string + uploadIfNeeded: boolean + idleTimeoutMs: number +} + +/** One JSON line on stdout; the parent reads exactly this. */ +function announce(payload: Record): void { + process.stdout.write(`${JSON.stringify(payload)}\n`) +} + +export async function runDaemon(config: DaemonConfig): Promise { + const sessionId = mintSessionId() + const socketPath = socketPathFor(config.registryDir, sessionId, process.platform) + const registry = new SessionRegistry(config.registryDir) + + const opened = await openRuntimeSession({ + sessionId, + projectPath: config.projectPath, + target: config.target, + host: config.host, + username: config.username, + password: config.password, + // Uploading from inside the daemon would need the whole compile pipeline + // here; `debug open --upload-if-needed` runs it in the PARENT before + // spawning, so by this point a mismatch is genuinely a mismatch. + onMd5Mismatch: undefined, + onProgress: (message) => announce({ event: 'progress', message }), + }) + + if (!opened.success) { + announce({ event: 'failed', code: opened.code, error: opened.error }) + app.exit(1) + return + } + + const server = new SessionServer({ + core: opened.core, + socketPath, + idleTimeoutMs: config.idleTimeoutMs, + onDiagnostic: (message) => announce({ event: 'progress', message }), + onClosed: () => { + registry.unregister(sessionId) + app.exit(0) + }, + }) + + try { + await server.listen() + } catch (error) { + await opened.core.close(true) + announce({ + event: 'failed', + code: 'internal', + error: `Could not listen on ${socketPath}: ${error instanceof Error ? error.message : String(error)}`, + }) + app.exit(1) + return + } + + const record = { + sessionId, + pid: process.pid, + socketPath, + target: config.target, + projectPath: config.projectPath, + programMd5: opened.programMd5, + startedAt: new Date().toISOString(), + } + registry.register(record) + announce({ event: 'ready', record }) + + // A terminated daemon must not leave a record pointing at a dead socket, and + // must not leave variables pinned on the target. + const shutdown = () => { + void opened.core.close(true).finally(() => { + registry.unregister(sessionId) + app.exit(0) + }) + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) +} diff --git a/src/cli/session/protocol.ts b/src/cli/session/protocol.ts index 7e51c5e18..1792e9de4 100644 --- a/src/cli/session/protocol.ts +++ b/src/cli/session/protocol.ts @@ -25,205 +25,186 @@ * keeps "debug from the terminal" and "debug from a script" the same code. */ -import type { ErrorCodeValue } from '../exit-codes' - -/** Every operation a session understands. The REPL's vocabulary is this set. */ -export type RequestKind = - | 'status' - | 'list-vars' - | 'read' - | 'write' - | 'force' - | 'unforce' - | 'start' - | 'stop' - | 'watch' - | 'poll' - | 'unwatch' - | 'close' - -export interface RequestBase { - /** Correlates the response. Unique per connection, not globally. */ - id: number - kind: RequestKind -} - -/** Connection state, target, program MD5, PLC state, and what is forced. */ -export interface StatusRequest extends RequestBase { - kind: 'status' -} - -/** Every leaf in the compiled program's debug map. */ -export interface ListVarsRequest extends RequestBase { - kind: 'list-vars' - /** Case-insensitive substring filter on the variable path. */ - filter?: string -} - -export interface ReadRequest extends RequestBase { - kind: 'read' - /** Variable paths, as they appear in `debug-map.json` (case-insensitive). */ - names: string[] -} - -/** Soft write — the program may overwrite it on the next scan. */ -export interface WriteRequest extends RequestBase { - kind: 'write' - name: string - value: string -} - -/** Force — pinned until unforced; survives the program's own writes. */ -export interface ForceRequest extends RequestBase { - kind: 'force' - name: string - value: string -} - -export interface UnforceRequest extends RequestBase { - kind: 'unforce' - name: string -} - -export interface StartRequest extends RequestBase { - kind: 'start' -} - -export interface StopRequest extends RequestBase { - kind: 'stop' -} - /** - * Begin recording a variable into a bounded server-side buffer. + * Schemas are the single source of truth; the TypeScript types are inferred + * from them. Two things follow, both load-bearing: * - * Recording rather than streaming is the whole point: a stateless caller - * cannot sit and watch a scroll, and a test needs to assert that a transient - * happened between two of its own steps. The session samples; `poll` drains. + * - Every line off the wire is VALIDATED rather than asserted. A malformed + * `names` is refused at the boundary, where the error names the field, + * instead of surfacing later as an unrelated failure deep inside a read. + * - The schema and the type cannot drift, because there is only one + * declaration of each shape. */ -export interface WatchRequest extends RequestBase { - kind: 'watch' - names: string[] - /** Sampling period. Clamped by the session to what the medium can carry. */ - intervalMs?: number -} -/** Drain the recorded window. `since` continues a previous drain. */ -export interface PollRequest extends RequestBase { - kind: 'poll' - since?: number -} - -export interface UnwatchRequest extends RequestBase { - kind: 'unwatch' - /** Omit to stop watching everything. */ - names?: string[] -} +import { z } from 'zod' -/** Tear the session down. See `releaseForces` for the safety-relevant part. */ -export interface CloseRequest extends RequestBase { - kind: 'close' +/** Every operation a session understands. The REPL's vocabulary is this set. */ +export const RequestKindSchema = z.enum([ + 'status', + 'list-vars', + 'read', + 'write', + 'force', + 'unforce', + 'start', + 'stop', + 'watch', + 'poll', + 'unwatch', + 'close', +]) +export type RequestKind = z.infer + +/** Correlates a response with its request; unique per connection. */ +const idField = z.number().finite() + +const nonEmptyNames = z.array(z.string()).min(1) + +export const RequestSchema = z.discriminatedUnion('kind', [ + z.object({ id: idField, kind: z.literal('status') }), + z.object({ id: idField, kind: z.literal('list-vars'), filter: z.string().optional() }), + z.object({ id: idField, kind: z.literal('read'), names: nonEmptyNames }), + /** Soft write — the program may overwrite it on the next scan. */ + z.object({ id: idField, kind: z.literal('write'), name: z.string(), value: z.string() }), + /** Force — pinned until unforced; survives the program's own writes. */ + z.object({ id: idField, kind: z.literal('force'), name: z.string(), value: z.string() }), + z.object({ id: idField, kind: z.literal('unforce'), name: z.string() }), + z.object({ id: idField, kind: z.literal('start') }), + z.object({ id: idField, kind: z.literal('stop') }), /** - * Unforce everything this session forced before disconnecting. + * Begin recording variables into a bounded server-side buffer. * - * Defaults to true, and that default is a safety decision, not a - * convenience: forcing lives in the RUNTIME's forced-slot bitmap, and the - * runtime has no way to notice that a debugger went away — it only clears - * forces on program unload/stop (`debug_write_journal_reset`). A session - * that exits quietly therefore leaves outputs pinned on a live PLC. Tests - * that open and close sessions in a loop would strand forces on real - * hardware. Pass false only when the pin is meant to outlive the session. + * Recording rather than streaming is the whole point: a stateless caller + * cannot sit and watch a scroll, and a test needs to assert that a transient + * happened between two of its own steps. The session samples; `poll` drains. */ - releaseForces?: boolean -} - -export type Request = - | StatusRequest - | ListVarsRequest - | ReadRequest - | WriteRequest - | ForceRequest - | UnforceRequest - | StartRequest - | StopRequest - | WatchRequest - | PollRequest - | UnwatchRequest - | CloseRequest - -/** One variable's current value, typed so `0` is never ambiguous. */ -export interface VariableValue { - /** Path from `debug-map.json`, in its canonical casing. */ - name: string - /** Canonical IEC type straight from the compiler (e.g. `DINT`). */ - type: string + z.object({ + id: idField, + kind: z.literal('watch'), + names: nonEmptyNames, + intervalMs: z.number().finite().optional(), + }), + /** Drain the recorded window. `since` continues a previous drain. */ + z.object({ id: idField, kind: z.literal('poll'), since: z.number().finite().optional() }), + /** Omit `names` to stop watching everything. */ + z.object({ id: idField, kind: z.literal('unwatch'), names: z.array(z.string()).optional() }), /** - * The decoded value. A BOOL is a boolean, an integer a number, a 64-bit - * integer a decimal string (it does not survive an IEEE double), a STRING a - * string. `null` means the leaf was unreadable this sample. + * Tear the session down. + * + * `releaseForces` defaults to true at the call site, and that default is a + * safety decision rather than a convenience: forcing lives in the RUNTIME's + * forced-slot bitmap, and the runtime has no way to notice a debugger going + * away — it clears forces only on program unload/stop + * (`debug_write_journal_reset`). A session that exits quietly therefore + * leaves outputs pinned on a live PLC, and a test loop would strand them on + * real hardware. Pass false only when the pin is meant to outlive the session. */ - value: boolean | number | string | null - /** True when the runtime reports this leaf pinned. */ - forced: boolean -} + z.object({ id: idField, kind: z.literal('close'), releaseForces: z.boolean().optional() }), +]) +export type Request = z.infer -export interface SessionStatus { - sessionId: string - connected: boolean - target: string +/** + * One variable's current value, typed so `0` is never ambiguous. + * + * A 64-bit integer arrives as a decimal string: it does not survive an IEEE + * double, and silently losing precision on a LINT is worse than making the + * caller parse. `null` means the leaf was unreadable in this sample. + */ +export const VariableValueSchema = z.object({ + name: z.string(), + type: z.string(), + value: z.union([z.boolean(), z.number(), z.string(), z.null()]), + forced: z.boolean(), +}) +export type VariableValue = z.infer + +export const PlcStateSchema = z.enum(['running', 'stopped', 'unknown']) + +export const SessionStatusSchema = z.object({ + sessionId: z.string(), + connected: z.boolean(), + target: z.string(), /** Transport actually in use, e.g. `websocket`, `tcp`, `rtu`. */ - transport: string - descriptor: string - projectPath: string - /** MD5 of the program the target is running, per `debugger:verify-md5`. */ - programMd5: string | null + transport: z.string(), + descriptor: z.string(), + projectPath: z.string(), + /** MD5 of the program the target is running. */ + programMd5: z.string().nullable(), /** Whether that MD5 matches the locally compiled artifacts. */ - md5Matches: boolean - plcState: 'running' | 'stopped' | 'unknown' + md5Matches: z.boolean(), + plcState: PlcStateSchema, /** Paths this session has forced and not yet released. */ - forced: string[] - watching: string[] - startedAt: string - lastActivityAt: string -} + forced: z.array(z.string()), + watching: z.array(z.string()), + startedAt: z.string(), + lastActivityAt: z.string(), +}) +export type SessionStatus = z.infer /** One recorded sample from the watch buffer. */ -export interface WatchSample { +export const WatchSampleSchema = z.object({ /** Monotonic sequence number — pass the last one back as `poll --since`. */ - seq: number + seq: z.number(), /** Milliseconds since the session started, not a wall clock. */ - atMs: number - values: VariableValue[] -} + atMs: z.number(), + values: z.array(VariableValueSchema), +}) +export type WatchSample = z.infer + +const ResponseDataSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('status'), status: SessionStatusSchema }), + z.object({ + kind: z.literal('list-vars'), + variables: z.array(z.object({ name: z.string(), type: z.string(), size: z.number() })), + }), + z.object({ kind: z.literal('read'), values: z.array(VariableValueSchema) }), + z.object({ kind: z.literal('write'), value: VariableValueSchema }), + z.object({ kind: z.literal('force'), value: VariableValueSchema }), + z.object({ kind: z.literal('unforce'), value: VariableValueSchema }), + z.object({ kind: z.literal('plc-state'), plcState: z.enum(['running', 'stopped']) }), + z.object({ kind: z.literal('watch'), watching: z.array(z.string()), intervalMs: z.number() }), + z.object({ kind: z.literal('poll'), samples: z.array(WatchSampleSchema), dropped: z.number() }), + z.object({ kind: z.literal('unwatch'), watching: z.array(z.string()) }), + z.object({ kind: z.literal('close'), released: z.array(z.string()) }), +]) + +export const OkResponseSchema = z.object({ id: z.number(), ok: z.literal(true), data: ResponseDataSchema }) +export type OkResponse = z.infer + +export const ErrResponseSchema = z.object({ + id: z.number(), + ok: z.literal(false), + error: z.object({ code: z.string(), message: z.string(), details: z.unknown().optional() }), +}) +export type ErrResponse = z.infer + +export const ResponseSchema = z.discriminatedUnion('ok', [OkResponseSchema, ErrResponseSchema]) +export type Response = z.infer -export interface OkResponse { - id: number - ok: true - /** Shape depends on the request kind; each command knows its own. */ - data: - | { kind: 'status'; status: SessionStatus } - | { kind: 'list-vars'; variables: Array<{ name: string; type: string; size: number }> } - | { kind: 'read'; values: VariableValue[] } - | { kind: 'write'; value: VariableValue } - | { kind: 'force'; value: VariableValue } - | { kind: 'unforce'; value: VariableValue } - | { kind: 'plc-state'; plcState: 'running' | 'stopped' } - | { kind: 'watch'; watching: string[]; intervalMs: number } - | { kind: 'poll'; samples: WatchSample[]; dropped: number } - | { kind: 'unwatch'; watching: string[] } - | { kind: 'close'; released: string[] } +/** Serialize one message as a protocol line (trailing newline included). */ +export function encodeMessage(message: Request | Response): string { + return `${JSON.stringify(message)}\n` } -export interface ErrResponse { - id: number - ok: false - error: { code: ErrorCodeValue; message: string; details?: unknown } +/** Validate one wire line as a request, or undefined when it is not one. */ +export function decodeRequest(line: string): Request | undefined { + return decodeWith(RequestSchema, line) } -export type Response = OkResponse | ErrResponse +/** Validate one wire line as a response, or undefined when it is not one. */ +export function decodeResponse(line: string): Response | undefined { + return decodeWith(ResponseSchema, line) +} -/** Serialize one message as a protocol line (trailing newline included). */ -export function encodeMessage(message: Request | Response): string { - return `${JSON.stringify(message)}\n` +function decodeWith(schema: z.ZodType, line: string): T | undefined { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return undefined + } + const result = schema.safeParse(parsed) + return result.success ? result.data : undefined } /** diff --git a/src/cli/session/server.ts b/src/cli/session/server.ts new file mode 100644 index 000000000..edd991ba4 --- /dev/null +++ b/src/cli/session/server.ts @@ -0,0 +1,123 @@ +/** + * The socket side of a debug session: accept connections, frame NDJSON, hand + * each request to the one `SessionCore`. + * + * Concurrent clients are allowed on purpose — a human in the REPL and a script + * polling the same session are a normal combination, and the alternative + * (exclusive ownership) would make `debug list` useless while a REPL is open. + * Requests are serialised through a promise chain rather than run in parallel: + * the debug channel is a single request/response link, and two overlapping + * reads on it interleave frames and corrupt both replies. + */ + +import { createServer, type Server, type Socket } from 'node:net' + +import { ErrorCode } from '../exit-codes' +import { decodeRequest, encodeMessage, type Response, splitLines } from './protocol' +import type { SessionCore } from './session-core' + +export interface SessionServerOptions { + core: SessionCore + socketPath: string + /** Called after a `close` request has been served, so the process can exit. */ + onClosed: () => void + /** Idle timeout; 0 disables it. */ + idleTimeoutMs?: number + onDiagnostic?: (message: string) => void +} + +export class SessionServer { + private readonly server: Server + private readonly sockets = new Set() + /** Serialises work onto the single debug channel. */ + private queue: Promise = Promise.resolve() + private idleTimer: NodeJS.Timeout | null = null + + constructor(private readonly options: SessionServerOptions) { + this.server = createServer((socket) => this.accept(socket)) + } + + listen(): Promise { + return new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(this.options.socketPath, () => { + this.server.removeListener('error', reject) + this.armIdleTimer() + resolve() + }) + }) + } + + private accept(socket: Socket): void { + this.sockets.add(socket) + let buffered = '' + + socket.on('data', (chunk: Buffer) => { + const { lines, rest } = splitLines(buffered, chunk.toString('utf-8')) + buffered = rest + for (const line of lines) this.enqueue(socket, line) + }) + + socket.on('error', () => socket.destroy()) + socket.on('close', () => { + this.sockets.delete(socket) + this.armIdleTimer() + }) + } + + /** + * Chain each request behind the previous one. + * + * The chain never rejects: a failed request resolves to an error response, so + * one bad call cannot poison the queue for everything after it. + */ + private enqueue(socket: Socket, line: string): void { + this.armIdleTimer() + this.queue = this.queue.then(async () => { + const request = decodeRequest(line) + if (!request) { + this.write(socket, { + id: 0, + ok: false, + error: { code: ErrorCode.InvalidArgument, message: `Malformed request: ${line.slice(0, 200)}` }, + }) + return + } + const response = await this.options.core.handle(request) + this.write(socket, response) + if (request.kind === 'close') { + // Let the reply reach the client before tearing the process down. + setImmediate(() => this.shutdown()) + } + }) + } + + private write(socket: Socket, response: Response): void { + if (socket.destroyed) return + socket.write(encodeMessage(response)) + } + + /** + * An idle session is a leaked session. Without this, a test that crashes + * between `open` and `close` leaves a process holding a debug channel — and + * possibly forced outputs — until the machine reboots. + */ + private armIdleTimer(): void { + if (this.idleTimer) clearTimeout(this.idleTimer) + const timeout = this.options.idleTimeoutMs ?? 0 + if (timeout <= 0) return + this.idleTimer = setTimeout(() => { + this.options.onDiagnostic?.(`Session idle for ${timeout} ms — closing`) + void this.options.core.close(true).then(() => this.shutdown()) + }, timeout) + this.idleTimer.unref() + } + + shutdown(): void { + if (this.idleTimer) clearTimeout(this.idleTimer) + for (const socket of this.sockets) socket.destroy() + this.sockets.clear() + this.server.close() + this.options.onClosed() + } +} diff --git a/src/cli/session/session-core.ts b/src/cli/session/session-core.ts new file mode 100644 index 000000000..1e437e814 --- /dev/null +++ b/src/cli/session/session-core.ts @@ -0,0 +1,434 @@ +/** + * The debug session's behaviour, independent of how requests reach it. + * + * Split from the socket server on purpose: this class is what makes the REPL + * and the one-shot clients the same debugger. Both send `Request`s and get + * `Response`s; neither can reach a code path the other cannot, because there + * is only one `handle()`. + * + * It owns the state a stateless caller cannot keep: + * - the live debug channel and the program MD5 it was verified against; + * - which variables THIS session forced, so `close` can release them; + * - the watch recording buffer. + */ + +import type { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import type { DeviceDebugChannel, PlcControlResult } from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' +import type { TargetEndian } from '@root/frontend/utils/endian' + +import { + type DebugVariableIndex, + decodeVariableValues, + encodeValue, + filterVariables, + findVariable, + type ResolvedVariable, +} from '../debug/variables' +import { ErrorCode } from '../exit-codes' +import type { Request, Response, SessionStatus, VariableValue, WatchSample } from './protocol' + +/** How a session controls run/stop, which differs by target family. */ +export interface PlcControl { + start(): Promise<{ success: boolean; error?: string }> + stop(): Promise<{ success: boolean; error?: string }> + /** Current run state, or 'unknown' when the target cannot be asked. */ + state(): Promise<'running' | 'stopped' | 'unknown'> +} + +export interface SessionCoreOptions { + sessionId: string + projectPath: string + target: string + transport: string + descriptor: string + channel: DeviceDebugChannel + index: DebugVariableIndex + plc: PlcControl + /** MD5 the target reported at connect time. */ + programMd5: string | null + endian: TargetEndian + /** Largest number of variables one request may carry on this medium. */ + batchSize: number + /** Clock, injectable so tests are not wall-clock dependent. */ + now?: () => number +} + +/** Keeps the watch buffer bounded — a long recording must not grow forever. */ +const MAX_WATCH_SAMPLES = 5000 +const MIN_WATCH_INTERVAL_MS = 20 + +export class SessionCore { + private readonly startedAtMs: number + private readonly startedAt: string + private lastActivityAtMs: number + /** UPPERCASE canonical names this session has forced and not released. */ + private readonly forced = new Set() + private watching: ResolvedVariable[] = [] + private watchTimer: NodeJS.Timeout | null = null + private watchIntervalMs = 0 + private samples: WatchSample[] = [] + private nextSeq = 1 + private droppedSamples = 0 + private closed = false + private readonly now: () => number + + constructor(private readonly options: SessionCoreOptions) { + this.now = options.now ?? (() => Date.now()) + this.startedAtMs = this.now() + this.startedAt = new Date(this.startedAtMs).toISOString() + this.lastActivityAtMs = this.startedAtMs + } + + get sessionId(): string { + return this.options.sessionId + } + + get isClosed(): boolean { + return this.closed + } + + /** Single entry point. Everything a client can ask goes through here. */ + async handle(request: Request): Promise { + this.lastActivityAtMs = this.now() + try { + return await this.dispatch(request) + } catch (error) { + return this.fail(request.id, ErrorCode.Internal, error instanceof Error ? error.message : String(error)) + } + } + + private async dispatch(request: Request): Promise { + switch (request.kind) { + case 'status': + return { id: request.id, ok: true, data: { kind: 'status', status: await this.status() } } + + case 'list-vars': { + const variables = filterVariables(this.options.index, request.filter).map((variable) => ({ + name: variable.name, + type: variable.type, + size: variable.size, + })) + return { id: request.id, ok: true, data: { kind: 'list-vars', variables } } + } + + case 'read': { + const resolved = this.resolveAll(request.names) + if ('error' in resolved) return this.fail(request.id, ErrorCode.VariableNotFound, resolved.error) + const values = await this.readValues(resolved.variables) + if ('error' in values) return this.fail(request.id, ErrorCode.NotConnected, values.error) + return { id: request.id, ok: true, data: { kind: 'read', values: values.values } } + } + + case 'write': + case 'force': + return this.applyWrite(request.id, request.name, request.value, request.kind === 'force') + + case 'unforce': + return this.applyUnforce(request.id, request.name) + + case 'start': + case 'stop': { + const control = request.kind === 'start' ? this.options.plc.start() : this.options.plc.stop() + const result = await control + if (!result.success) { + return this.fail(request.id, ErrorCode.TargetError, result.error ?? `Could not ${request.kind} the PLC`) + } + return { + id: request.id, + ok: true, + data: { kind: 'plc-state', plcState: request.kind === 'start' ? 'running' : 'stopped' }, + } + } + + case 'watch': { + const resolved = this.resolveAll(request.names) + if ('error' in resolved) return this.fail(request.id, ErrorCode.VariableNotFound, resolved.error) + this.startWatching(resolved.variables, request.intervalMs) + return { + id: request.id, + ok: true, + data: { + kind: 'watch', + watching: this.watching.map((variable) => variable.name), + intervalMs: this.watchIntervalMs, + }, + } + } + + case 'poll': { + const since = request.since ?? 0 + const samples = this.samples.filter((sample) => sample.seq > since) + const dropped = this.droppedSamples + // Drained samples are released; a caller that wants them again should + // have kept them. Holding everything would make a long watch unbounded. + this.samples = [] + this.droppedSamples = 0 + return { id: request.id, ok: true, data: { kind: 'poll', samples, dropped } } + } + + case 'unwatch': { + if (!request.names || request.names.length === 0) this.stopWatching() + else { + const drop = new Set(request.names.map((name) => name.toUpperCase())) + this.watching = this.watching.filter((variable) => !drop.has(variable.name.toUpperCase())) + if (this.watching.length === 0) this.stopWatching() + } + return { + id: request.id, + ok: true, + data: { kind: 'unwatch', watching: this.watching.map((variable) => variable.name) }, + } + } + + case 'close': { + const released = await this.close(request.releaseForces ?? true) + return { id: request.id, ok: true, data: { kind: 'close', released } } + } + } + } + + private resolveAll(names: readonly string[]): { variables: ResolvedVariable[] } | { error: string } { + if (names.length === 0) return { error: 'No variable named' } + const variables: ResolvedVariable[] = [] + for (const name of names) { + const variable = findVariable(this.options.index, name) + if (!variable) return { error: `No variable "${name}" in this program's debug map` } + variables.push(variable) + } + return { variables } + } + + /** + * Read values, splitting into medium-sized batches. + * + * The batch cap is a property of the far end's frame budget, not a tuning + * knob: overrunning it on RTU produces a request the firmware silently drops. + */ + private async readValues( + variables: readonly ResolvedVariable[], + ): Promise<{ values: VariableValue[] } | { error: string }> { + const values: VariableValue[] = [] + for (let start = 0; start < variables.length; start += this.options.batchSize) { + const batch = variables.slice(start, start + this.options.batchSize) + const result = await this.options.channel.getVariablesList(batch.map((variable) => variable.index)) + if (!result.success || !result.data) { + return { error: result.error ?? 'The target did not answer the variable read' } + } + values.push( + ...decodeVariableValues({ + requested: batch, + // `data` is declared `Uint8Array | Buffer` because the Node Modbus + // clients return the latter and the shared WebSocket transport the + // former. Normalising here keeps the decoder on one type. + payload: new Uint8Array(result.data), + lastIndex: result.lastIndex, + endian: this.options.endian, + forced: this.forced, + }), + ) + } + return { values } + } + + private async applyWrite(id: number, name: string, input: string, force: boolean): Promise { + const variable = findVariable(this.options.index, name) + if (!variable) { + return this.fail(id, ErrorCode.VariableNotFound, `No variable "${name}" in this program's debug map`) + } + const encoded = encodeValue(variable, input) + if (!encoded.success) return this.fail(id, ErrorCode.ValueInvalid, encoded.error) + + const result = await this.options.channel.setVariable(variable.index, force, encoded.bytes) + if (!result.success) { + return this.fail(id, ErrorCode.TargetError, result.error ?? `The target refused the ${force ? 'force' : 'write'}`) + } + if (force) this.forced.add(variable.name.toUpperCase()) + + const readBack = await this.readValues([variable]) + if ('error' in readBack) return this.fail(id, ErrorCode.NotConnected, readBack.error) + const value = readBack.values[0] ?? { + name: variable.name, + type: variable.type, + value: null, + forced: this.forced.has(variable.name.toUpperCase()), + } + return { id, ok: true, data: { kind: force ? 'force' : 'write', value } } + } + + private async applyUnforce(id: number, name: string): Promise { + const variable = findVariable(this.options.index, name) + if (!variable) { + return this.fail(id, ErrorCode.VariableNotFound, `No variable "${name}" in this program's debug map`) + } + // force=false with no payload is the unforce PDU — see `buildSetVariableRequest`. + const result = await this.options.channel.setVariable(variable.index, false) + if (!result.success) { + return this.fail(id, ErrorCode.TargetError, result.error ?? 'The target refused the unforce') + } + this.forced.delete(variable.name.toUpperCase()) + + const readBack = await this.readValues([variable]) + if ('error' in readBack) return this.fail(id, ErrorCode.NotConnected, readBack.error) + const value = readBack.values[0] ?? { name: variable.name, type: variable.type, value: null, forced: false } + return { id, ok: true, data: { kind: 'unforce', value } } + } + + private startWatching(variables: ResolvedVariable[], intervalMs: number | undefined): void { + this.stopWatching() + this.watching = variables + this.watchIntervalMs = Math.max(MIN_WATCH_INTERVAL_MS, intervalMs ?? 100) + this.watchTimer = setInterval(() => { + void this.recordSample() + }, this.watchIntervalMs) + // Do not hold the process open on the timer alone; the socket server does that. + this.watchTimer.unref() + } + + private stopWatching(): void { + if (this.watchTimer) clearInterval(this.watchTimer) + this.watchTimer = null + this.watching = [] + this.watchIntervalMs = 0 + } + + private async recordSample(): Promise { + if (this.watching.length === 0 || this.closed) return + const result = await this.readValues(this.watching) + if ('error' in result) return + if (this.samples.length >= MAX_WATCH_SAMPLES) { + // Drop the oldest and COUNT it. A silently truncated recording would let + // a test conclude a transient never happened when it was simply evicted. + this.samples.shift() + this.droppedSamples += 1 + } + this.samples.push({ seq: this.nextSeq++, atMs: this.now() - this.startedAtMs, values: result.values }) + } + + private async status(): Promise { + const md5 = await this.readMd5() + return { + sessionId: this.options.sessionId, + connected: !this.closed, + target: this.options.target, + transport: this.options.transport, + descriptor: this.options.descriptor, + projectPath: this.options.projectPath, + programMd5: md5 ?? this.options.programMd5, + md5Matches: (md5 ?? this.options.programMd5)?.toLowerCase() === this.options.index.md5.toLowerCase(), + plcState: await this.options.plc.state(), + forced: [...this.forced].sort(), + watching: this.watching.map((variable) => variable.name), + startedAt: this.startedAt, + lastActivityAt: new Date(this.lastActivityAtMs).toISOString(), + } + } + + private async readMd5(): Promise { + try { + const probe = await this.options.channel.getMd5Hash() + return probe.md5 ?? null + } catch { + return null + } + } + + /** + * Release forces (by default), stop recording, drop the channel. + * + * The default matters: forcing lives in the runtime's forced-slot bitmap, and + * the runtime cannot notice a debugger going away — it clears forces only on + * program unload/stop. A session that exited quietly would leave outputs + * pinned on a live PLC, and a test loop would strand them on real hardware. + */ + async close(releaseForces: boolean): Promise { + this.stopWatching() + const released: string[] = [] + if (releaseForces) { + for (const upperName of [...this.forced]) { + const variable = findVariable(this.options.index, upperName) + /* istanbul ignore if -- every entry was resolved before being forced */ + if (!variable) continue + try { + const result = await this.options.channel.setVariable(variable.index, false) + if (result.success) released.push(variable.name) + } catch { + // Best effort: a channel that is already gone cannot be told to + // unforce, and failing the close would leave the session registered. + } + } + } + this.forced.clear() + this.closed = true + try { + this.options.channel.disconnect() + } catch { + /* already disconnected */ + } + return released + } + + private fail(id: number, code: (typeof ErrorCode)[keyof typeof ErrorCode], message: string): Response { + return { id, ok: false, error: { code, message } } + } +} + +/** + * Run/stop for a Runtime v3/v4. + * + * Delegates to `RuntimeApiClient.setPlcState`, the same method the GUI's + * Start/Stop button reaches through `MainProcessBridge.restSetPlcState`. That + * matters for more than tidiness: the runtime answers these routes over GET and + * reports refusal in the BODY (`ERROR_SWITCH_STOP` when a hardware mode switch + * gates a start), and a CLI that reimplemented the call got both wrong. + */ +export function restPlcControl(client: RuntimeApiClient, address: string): PlcControl { + const describe = (result: PlcControlResult, action: string): { success: boolean; error?: string } => { + if (result.success) return { success: true } + if (result.refusedBySwitch) { + return { + success: false, + error: `The PLC cannot be ${action}: its physical mode switch is in STOP. Move it to RUN and retry.`, + } + } + return { success: false, error: result.error ?? `The PLC could not be ${action}` } + } + + return { + start: async () => describe(await client.setPlcState(address, 'run'), 'started'), + stop: async () => describe(await client.setPlcState(address, 'stop'), 'stopped'), + async state() { + const result = await client.getStatus(address) + if (!result.success || !result.status) return 'unknown' + const status = result.status.toLowerCase() + if (status.includes('running')) return 'running' + if (status.includes('stopped')) return 'stopped' + return 'unknown' + }, + } +} + +/** Run/stop for a baremetal target, which answers it on the debug channel itself. */ +export function channelPlcControl(channel: DeviceDebugChannel): PlcControl { + return { + async start() { + if (!channel.setPlcState) return { success: false, error: 'This target does not support run/stop control' } + const result = await channel.setPlcState(PlcRuntimeState.RUNNING) + return result.success ? { success: true } : { success: false, error: result.error } + }, + async stop() { + if (!channel.setPlcState) return { success: false, error: 'This target does not support run/stop control' } + const result = await channel.setPlcState(PlcRuntimeState.STOPPED) + return result.success ? { success: true } : { success: false, error: result.error } + }, + async state() { + if (!channel.getStatus) return 'unknown' + const result = await channel.getStatus() + if (!result.success) return 'unknown' + if (result.plcState === undefined) return result.running ? 'running' : 'unknown' + // Compared as a number: `plcState` arrives as a raw byte off the wire, not + // as a member of the enum, so an enum-typed comparison would be a lie. + return result.plcState === Number(PlcRuntimeState.RUNNING) ? 'running' : 'stopped' + }, + } +} diff --git a/src/cli/spawn-session.ts b/src/cli/spawn-session.ts new file mode 100644 index 000000000..41fcba6db --- /dev/null +++ b/src/cli/spawn-session.ts @@ -0,0 +1,244 @@ +/** + * Spawning the debug-session daemon from `debug open`. + * + * The child is the same executable re-invoked with `--cli-daemon`, detached and + * with its stdio piped only long enough to read the handshake line. After that + * the pipes are unref'd so the parent can exit while the session keeps running + * — which is the entire point of a session that survives across commands. + * + * The MD5-mismatch upload runs HERE, in the parent, rather than in the daemon: + * uploading needs the whole compile pipeline, and putting that inside the + * long-lived process would make every session carry the compiler. So `open` + * compiles and uploads first if asked, and by the time the daemon probes the + * target a mismatch is a real mismatch. + */ + +import { spawn } from 'node:child_process' + +import type { SpawnSessionOptions, SpawnSessionResult } from './commands/debug' +import { loadDebugIndex } from './debug/variables' +import { splitLines } from './session/protocol' + +export interface SpawnDependencies { + registryDir: string + /** argv[0] and the fixed leading args needed to re-enter this program. */ + execPath: string + execArgs: string[] + /** Runs a compile + upload for the MD5-mismatch path. */ + uploadProgram: (options: { + projectPath: string + target: string + host: string + username: string + password: string + onLine: (message: string) => void + }) => Promise<{ success: boolean; error?: string }> + /** Probes the target's program MD5 without opening a full session. */ + probeTargetMd5: (options: { + host: string + username: string + password: string + }) => Promise<{ success: true; md5: string | null } | { success: false; error: string }> +} + +const HANDSHAKE_TIMEOUT_MS = 120_000 + +export function createSessionSpawner(deps: SpawnDependencies) { + return async function spawnSession(options: SpawnSessionOptions): Promise { + if (options.uploadIfNeeded) { + const prepared = await ensureProgramMatches(deps, options) + if (!prepared.success) return prepared + } + + const config = { + registryDir: deps.registryDir, + projectPath: options.projectPath, + target: options.target, + host: options.host, + username: options.username, + password: options.password, + uploadIfNeeded: false, + idleTimeoutMs: options.idleTimeoutMs, + } + + // Credentials go over stdin, not argv: argv is world-readable in `ps`. + const child = spawn(deps.execPath, [...deps.execArgs, '--cli-daemon'], { + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, OPENPLC_CLI_DAEMON: '1' }, + }) + + child.stdin.write(`${JSON.stringify(config)}\n`) + child.stdin.end() + + return await new Promise((resolve) => { + let buffered = '' + let settled = false + let stderr = '' + + const finish = (result: SpawnSessionResult) => { + if (settled) return + settled = true + clearTimeout(timer) + child.stdout.removeAllListeners('data') + // Let go of the child so this process can exit while it keeps serving. + // The streams are destroyed rather than unref'd: `unref` is a socket + // method and these are plain Readables, so keeping them merely open + // would hold the event loop. + child.stdout.destroy() + child.stderr.destroy() + child.unref() + resolve(result) + } + + const timer = setTimeout( + () => + finish({ + success: false, + code: 'connection', + error: `The session did not report ready within ${HANDSHAKE_TIMEOUT_MS} ms${stderr ? `: ${stderr.trim()}` : ''}`, + }), + HANDSHAKE_TIMEOUT_MS, + ) + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8') + }) + + child.stdout.on('data', (chunk: Buffer) => { + const { lines, rest } = splitLines(buffered, chunk.toString('utf-8')) + buffered = rest + for (const line of lines) { + const message = readHandshake(line) + if (!message) continue + if (message.event === 'progress' && message.message) { + options.onProgress(message.message) + continue + } + if (message.event === 'ready' && message.record) { + finish({ success: true, record: message.record }) + return + } + if (message.event === 'failed') { + finish({ + success: false, + code: message.code ?? 'internal', + error: message.error ?? 'The session failed to open', + }) + return + } + } + }) + + child.on('error', (error) => + finish({ success: false, code: 'internal', error: `Could not start the session process: ${error.message}` }), + ) + + child.on('exit', (code) => { + finish({ + success: false, + code: 'internal', + error: `The session process exited with code ${code ?? 'unknown'}${stderr ? `: ${stderr.trim()}` : ''}`, + }) + }) + }) + } +} + +/** + * Bring the target's program in line with the local build before the session + * opens, so the daemon never has to decide whether to flash a PLC. + */ +async function ensureProgramMatches( + deps: SpawnDependencies, + options: SpawnSessionOptions, +): Promise<{ success: true } | SpawnSessionResult> { + const index = await loadDebugIndex(options.projectPath, options.target) + if (!index.success) return { success: false, code: 'not-compiled', error: index.error } + + const probe = await deps.probeTargetMd5({ + host: options.host, + username: options.username, + password: options.password, + }) + if (!probe.success) return { success: false, code: 'connection', error: probe.error } + + if (probe.md5 && probe.md5.toLowerCase() === index.index.md5.toLowerCase()) return { success: true } + + options.onProgress( + `The target runs a different program (${probe.md5 ?? 'unknown'} vs ${index.index.md5}); uploading the local build…`, + ) + const uploaded = await deps.uploadProgram({ + projectPath: options.projectPath, + target: options.target, + host: options.host, + username: options.username, + password: options.password, + onLine: options.onProgress, + }) + if (!uploaded.success) { + return { success: false, code: 'md5', error: uploaded.error ?? 'The upload failed' } + } + return { success: true } +} + +interface HandshakeMessage { + event?: 'progress' | 'ready' | 'failed' + message?: string + code?: 'auth' | 'connection' | 'md5' | 'not-compiled' | 'internal' + error?: string + record?: { + sessionId: string + pid: number + socketPath: string + target: string + projectPath: string + programMd5: string | null + startedAt: string + } +} + +/** Validate the handshake line rather than trusting the child's output. */ +function readHandshake(line: string): HandshakeMessage | undefined { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const record: Record = { ...parsed } + const event = record.event + if (event !== 'progress' && event !== 'ready' && event !== 'failed') return undefined + + const message: HandshakeMessage = { event } + if (typeof record.message === 'string') message.message = record.message + if (typeof record.error === 'string') message.error = record.error + const code = record.code + if (code === 'auth' || code === 'connection' || code === 'md5' || code === 'not-compiled' || code === 'internal') { + message.code = code + } + if (typeof record.record === 'object' && record.record !== null) { + const raw: Record = { ...record.record } + if ( + typeof raw.sessionId === 'string' && + typeof raw.pid === 'number' && + typeof raw.socketPath === 'string' && + typeof raw.target === 'string' && + typeof raw.projectPath === 'string' && + typeof raw.startedAt === 'string' && + (raw.programMd5 === null || typeof raw.programMd5 === 'string') + ) { + message.record = { + sessionId: raw.sessionId, + pid: raw.pid, + socketPath: raw.socketPath, + target: raw.target, + projectPath: raw.projectPath, + programMd5: raw.programMd5, + startedAt: raw.startedAt, + } + } + } + return message +} diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index c5d699fd0..2268b3725 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -23,65 +23,24 @@ import { useCallback, useEffect, useRef } from 'react' -import type { DebugMedium, DebugTreeNode } from '../../middleware/shared/ports/types' +import type { DebugTreeNode } from '../../middleware/shared/ports/types' import { useCapabilities, useDebugger } from '../../middleware/shared/providers' import { openPLCStoreBase, useOpenPLCStore } from '../store' +import { DEBUG_MEDIUM_PROFILE, debugProfileFor, DEFAULT_DEBUG_MEDIUM } from '../utils/debug-medium-profile' import { buildActiveIndexSet } from '../utils/debug-polling-filter' -import { applySwapToVariableBytes } from '../utils/endian' -import { getTypeSizeByName, parseValueByTypeName } from '../utils/variable-sizes' +import { walkDebugResponse } from '../utils/debug-response-walker' /** - * How to pace and size the debug poll, per medium. - * - * These are two INDEPENDENT physical limits, which is why they live in one table - * rather than being derived from each other: - * - * `batchSize` — the frame budget at the far end. The request packs 3 bytes per - * variable (arr:u8 + elem:u16) and the response packs raw type-sized values after - * a small header. It is a property of the TARGET, never of the board the user - * picked, since the same board can be reached over RTU or TCP. - * rtu / simulator : 19, so the request stays ≤63 bytes and fits one 64-byte - * USB-CDC packet (6 + 3·19 = 63). A 20-variable request is 66 - * bytes, which a SAMD21 / P1AM-100 receives split across two - * packets — older firmware whose serial framer cannot - * reassemble then drops it. The simulator's virtual serial - * port mirrors the same framing. - * tcp : the Arduino sketch's MAX_MB_FRAME caps it; 60 has headroom. - * websocket / : the Linux runtime's MAX_DEBUG_FRAME=4096 — ~500 variables - * webrtc / with room for value bytes. All three reach the SAME debug - * http-relay socket on the runtime, so they share its budget; only the - * number of hops in front of it differs. - * - * `pollIntervalMs` — round-trip latency of the link. - * rtu / simulator : 50ms, no network in the way; keep the UI responsive. - * tcp / websocket : 200ms, one network hop. - * webrtc : 200ms, peer-to-peer to the agent — as direct as it gets. - * http-relay : 1000ms. Every poll is browser -> Edge -> agent websocket -> - * runtime and back. Polling this at the direct rate buries the - * relay in requests for data that cannot arrive any faster. - * Overridable per deployment via - * `capabilities.debugRelayPollIntervalMs`. - * - * A medium the caller has not published yet reads as `tcp` — the middle of the - * range, and what this defaulted to before the media were named. + * Poll pacing and batching per medium now live in + * `utils/debug-medium-profile`, so non-React callers (the headless CLI's debug + * session) can size their reads from the same table. Re-exported here because + * this module has been the import site for both since they were introduced. */ -export const DEBUG_MEDIUM_PROFILE: Record = { - rtu: { batchSize: 19, pollIntervalMs: 50 }, - simulator: { batchSize: 19, pollIntervalMs: 50 }, - tcp: { batchSize: 60, pollIntervalMs: 200 }, - websocket: { batchSize: 500, pollIntervalMs: 200 }, - webrtc: { batchSize: 500, pollIntervalMs: 200 }, - 'http-relay': { batchSize: 500, pollIntervalMs: 1000 }, -} +export { DEBUG_MEDIUM_PROFILE, debugProfileFor } from '../utils/debug-medium-profile' -const DEFAULT_MEDIUM: DebugMedium = 'tcp' +/** Floor for the adaptive batch shrink below — a batch of one still makes progress. */ const MIN_BATCH_SIZE = 2 -/** The profile for a medium, tolerating one not yet published. */ -export function debugProfileFor(medium: DebugMedium | null): { batchSize: number; pollIntervalMs: number } { - return DEBUG_MEDIUM_PROFILE[medium ?? DEFAULT_MEDIUM] -} - interface LeafMeta { compositeKey: string type: string @@ -152,7 +111,7 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void // Dynamic batch size — overwritten with the medium's ceiling on session start; // halves on ERROR_OUT_OF_MEMORY and resets on the next session start. - const batchSizeRef = useRef(DEBUG_MEDIUM_PROFILE[DEFAULT_MEDIUM].batchSize) + const batchSizeRef = useRef(DEBUG_MEDIUM_PROFILE[DEFAULT_DEBUG_MEDIUM].batchSize) // Full leaf index→metadata map — computed once when debugger starts. // One index → many leaves (a shared global appears under each POU's key). @@ -267,7 +226,6 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void } = openPLCStoreBase.getState().workspace const changedBool = new Map() const changedNonBool = new Map() - let bufferOffset = 0 // Wire format note: result.lastIndex is the runtime's last_req_idx — // a 0-based POSITION INTO THE REQUEST LIST, not a variable index. @@ -284,73 +242,48 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void // positions the editor actually consumed. See the offset-advancement // block below this loop for why naive use of `lastIndex+1` strands // the tail of the active set. - let positionsConsumed = 0 - let loopReachedEnd = true - for (let pos = 0; pos < batch.length; pos++) { - if (result.lastIndex !== undefined && pos > result.lastIndex) { - // Runtime processed fewer positions than the request; subsequent - // slots are valid but unread by the runtime, so they are also - // unread by us. `positionsConsumed = pos` reflects exactly how - // far the runtime got. - loopReachedEnd = false - break - } - - const index = batch[pos] - const metas = allLeaves.get(index) - if (!metas || metas.length === 0) { - // No leaf metadata — the runtime still consumed the position - // (it doesn't know our index→type map). Count it consumed and - // press on; the value bytes for this slot are forfeit. - positionsConsumed = pos + 1 - continue - } - - // Every leaf at one index is the same underlying variable/address, so - // they share type/size; parse once off the first, then fan the value - // out to every composite key (a shared global lives under each POU's). - const meta = metas[0] - const typeSize = getTypeSizeByName(meta.type) - if (bufferOffset + typeSize > responseBuffer.length) { - // Response buffer ran out before we reached every position the - // runtime claims to have processed. Stop here; do NOT advance - // past `pos`. The next poll cycle retries from this same slot - // (round-robin offset += positionsConsumed below) so variables - // sitting at the tail of the active set still get their reads - // — which is the entire reason this is structured around - // `positionsConsumed` rather than `lastIndex+1`. - loopReachedEnd = false - break - } - - const isBool = meta.type === 'BOOL' - - // Wire bytes arrive in the target's native byte order; the - // internal codec below (parseValueByTypeName → DataView.getFloat32 - // with littleEndian=true) is LE-only. Normalise here when - // talking to a BE target. No-op on LE (the common case). - applySwapToVariableBytes(responseBuffer, bufferOffset, typeSize, meta.type, debugTargetEndian) - - try { - const { value, bytesRead } = parseValueByTypeName(responseBuffer, bufferOffset, meta.type) - // Translate enum integers to member names so every consumer - // (watch panel, ladder, FBD, hover) reads the same display value. + // The positional walk itself lives in `utils/debug-response-walker`, shared + // with the headless CLI's debug session: `lastIndex` handling, the + // consumed-but-undecodable slot, the short-buffer stop and the endian swap + // are all silent-corruption bugs when they differ between callers. What + // stays here is what is genuinely this caller's: enum member names and the + // fan-out to every composite key sharing an address. + const walk = walkDebugResponse({ + requested: batch, + payload: responseBuffer, + lastIndex: result.lastIndex, + endian: debugTargetEndian, + typeOf: (index) => { + const metas = allLeaves.get(index) + // Every leaf at one index is the same underlying variable/address, so + // they share type/size. + return metas && metas.length > 0 ? metas[0].type : undefined + }, + emit: ({ index, type, value }) => { + const metas = allLeaves.get(index) + /* istanbul ignore if -- typeOf already resolved metadata for this index */ + if (!metas || metas.length === 0) return + // Translate enum integers to member names so every consumer (watch + // panel, ladder, FBD, hover) reads the same display value. // Out-of-range falls back to the raw integer. - const stored = meta.enumValues !== undefined ? (meta.enumValues[Number(value)] ?? value) : value - const current = isBool ? currentBool : currentNonBool - const changed = isBool ? changedBool : changedNonBool - // Fan out to every composite key sharing this address (shared globals). + const enumValues = metas[0].enumValues + const stored = enumValues !== undefined ? (enumValues[Number(value)] ?? value) : value + const changed = type === 'BOOL' ? changedBool : changedNonBool + const current = type === 'BOOL' ? currentBool : currentNonBool for (const m of metas) { if (current.get(m.compositeKey) !== stored) changed.set(m.compositeKey, stored) } - bufferOffset += bytesRead - } catch { - const changed = isBool ? changedBool : changedNonBool + }, + onError: ({ index, type }) => { + const metas = allLeaves.get(index) + /* istanbul ignore if -- typeOf already resolved metadata for this index */ + if (!metas || metas.length === 0) return + const changed = type === 'BOOL' ? changedBool : changedNonBool for (const m of metas) changed.set(m.compositeKey, 'ERR') - bufferOffset += typeSize - } - positionsConsumed = pos + 1 - } + }, + }) + const positionsConsumed = walk.positionsConsumed + const loopReachedEnd = walk.reachedEnd // Advance round-robin offset by what we actually consumed. // diff --git a/src/frontend/utils/__tests__/debug-response-walker.test.ts b/src/frontend/utils/__tests__/debug-response-walker.test.ts new file mode 100644 index 000000000..bb4400669 --- /dev/null +++ b/src/frontend/utils/__tests__/debug-response-walker.test.ts @@ -0,0 +1,116 @@ +import { walkDebugResponse } from '../debug-response-walker' + +type Decoded = { index: number; position: number; type: string; value: string } + +function walk(options: { + requested: number[] + payload: number[] + lastIndex?: number + types: Record + endian?: 'le' | 'be' +}) { + const decoded: Decoded[] = [] + const failed: Array<{ index: number; type: string }> = [] + const result = walkDebugResponse({ + requested: options.requested, + payload: new Uint8Array(options.payload), + lastIndex: options.lastIndex, + endian: options.endian ?? 'le', + typeOf: (index) => options.types[index], + emit: (entry) => decoded.push(entry), + onError: (entry) => failed.push({ index: entry.index, type: entry.type }), + }) + return { ...result, decoded, failed } +} + +describe('walkDebugResponse', () => { + it('decodes positions in request order, sized by each type', () => { + // BOOL(1) then INT(2, LE) then BOOL(1) + const out = walk({ + requested: [10, 11, 12], + payload: [1, 0x2a, 0x00, 0], + types: { 10: 'BOOL', 11: 'INT', 12: 'BOOL' }, + }) + + expect(out.decoded.map((d) => [d.index, d.value])).toEqual([ + [10, 'TRUE'], + [11, '42'], + [12, 'FALSE'], + ]) + expect(out.positionsConsumed).toBe(3) + expect(out.reachedEnd).toBe(true) + }) + + it('stops at lastIndex, because later positions were never read by the runtime', () => { + // Decoding position 2 from trailing bytes would produce a plausible wrong + // value rather than an error — the reason lastIndex is honoured. + const out = walk({ + requested: [1, 2, 3], + payload: [1, 1, 1], + lastIndex: 1, + types: { 1: 'BOOL', 2: 'BOOL', 3: 'BOOL' }, + }) + + expect(out.decoded).toHaveLength(2) + expect(out.positionsConsumed).toBe(2) + expect(out.reachedEnd).toBe(false) + }) + + it('counts an unknown-type position as consumed without decoding it', () => { + // The runtime consumed the slot; it does not know our index->type map. + const out = walk({ + requested: [1, 2], + payload: [7], + types: { 1: undefined, 2: 'USINT' }, + }) + + expect(out.decoded.map((d) => d.index)).toEqual([2]) + expect(out.positionsConsumed).toBe(2) + }) + + it('does NOT count a position it could not fit in the buffer', () => { + // A round-robin caller advances by positionsConsumed, so counting a + // truncated position here would strand it for the life of the session. + const out = walk({ + requested: [1, 2], + payload: [1], + types: { 1: 'BOOL', 2: 'DINT' }, + }) + + expect(out.decoded.map((d) => d.index)).toEqual([1]) + expect(out.positionsConsumed).toBe(1) + expect(out.reachedEnd).toBe(false) + }) + + it('reports a codec failure and still advances past its bytes', () => { + // Misaligning every later position is worse than one unreadable value. + const out = walk({ + requested: [1, 2], + payload: [0, 0, 0, 0, 5], + types: { 1: 'NOT_A_TYPE', 2: 'USINT' }, + }) + + expect(out.positionsConsumed).toBe(2) + expect(out.decoded.some((d) => d.index === 2)).toBe(true) + }) + + it('swaps bytes for a big-endian target so the LE-only codec reads correctly', () => { + const le = walk({ requested: [1], payload: [0x2a, 0x00], types: { 1: 'INT' } }) + const be = walk({ requested: [1], payload: [0x00, 0x2a], types: { 1: 'INT' }, endian: 'be' }) + + expect(le.decoded[0].value).toBe('42') + expect(be.decoded[0].value).toBe('42') + }) + + it('treats an undefined lastIndex as "the runtime processed everything"', () => { + const out = walk({ requested: [1, 2], payload: [1, 0], types: { 1: 'BOOL', 2: 'BOOL' } }) + expect(out.positionsConsumed).toBe(2) + expect(out.reachedEnd).toBe(true) + }) + + it('handles an empty request without touching the payload', () => { + const out = walk({ requested: [], payload: [1, 2, 3], types: {} }) + expect(out).toMatchObject({ positionsConsumed: 0, reachedEnd: true }) + expect(out.decoded).toEqual([]) + }) +}) diff --git a/src/frontend/utils/debug-medium-profile.ts b/src/frontend/utils/debug-medium-profile.ts new file mode 100644 index 000000000..9602f443c --- /dev/null +++ b/src/frontend/utils/debug-medium-profile.ts @@ -0,0 +1,64 @@ +/** + * Debug poll pacing and batching, per medium — pure data plus its resolver. + * + * Split out of `useDebugPolling` so callers that are not React can reach it. + * The headless CLI's debug session sizes its variable reads from this table, + * and importing it from the hook would drag React and the Zustand store into a + * Node process. Duplicating the numbers instead was the other option, and the + * worse one: they encode physical frame budgets at the far end, so a CLI copy + * that drifted would produce requests real firmware silently drops. + */ + +import type { DebugMedium } from '../../middleware/shared/ports/types' + +/** + * How to pace and size the debug poll, per medium. + * + * These are two INDEPENDENT physical limits, which is why they live in one table + * rather than being derived from each other: + * + * `batchSize` — the frame budget at the far end. The request packs 3 bytes per + * variable (arr:u8 + elem:u16) and the response packs raw type-sized values after + * a small header. It is a property of the TARGET, never of the board the user + * picked, since the same board can be reached over RTU or TCP. + * rtu / simulator : 19, so the request stays ≤63 bytes and fits one 64-byte + * USB-CDC packet (6 + 3·19 = 63). A 20-variable request is 66 + * bytes, which a SAMD21 / P1AM-100 receives split across two + * packets — older firmware whose serial framer cannot + * reassemble then drops it. The simulator's virtual serial + * port mirrors the same framing. + * tcp : the Arduino sketch's MAX_MB_FRAME caps it; 60 has headroom. + * websocket / : the Linux runtime's MAX_DEBUG_FRAME=4096 — ~500 variables + * webrtc / with room for value bytes. All three reach the SAME debug + * http-relay socket on the runtime, so they share its budget; only the + * number of hops in front of it differs. + * + * `pollIntervalMs` — round-trip latency of the link. + * rtu / simulator : 50ms, no network in the way; keep the UI responsive. + * tcp / websocket : 200ms, one network hop. + * webrtc : 200ms, peer-to-peer to the agent — as direct as it gets. + * http-relay : 1000ms. Every poll is browser -> Edge -> agent websocket -> + * runtime and back. Polling this at the direct rate buries the + * relay in requests for data that cannot arrive any faster. + * Overridable per deployment via + * `capabilities.debugRelayPollIntervalMs`. + * + * A medium the caller has not published yet reads as `tcp` — the middle of the + * range, and what this defaulted to before the media were named. + */ +export const DEBUG_MEDIUM_PROFILE: Record = { + rtu: { batchSize: 19, pollIntervalMs: 50 }, + simulator: { batchSize: 19, pollIntervalMs: 50 }, + tcp: { batchSize: 60, pollIntervalMs: 200 }, + websocket: { batchSize: 500, pollIntervalMs: 200 }, + webrtc: { batchSize: 500, pollIntervalMs: 200 }, + 'http-relay': { batchSize: 500, pollIntervalMs: 1000 }, +} + +/** Assumed medium before a session publishes its own. */ +export const DEFAULT_DEBUG_MEDIUM: DebugMedium = 'tcp' + +/** The profile for a medium, tolerating one not yet published. */ +export function debugProfileFor(medium: DebugMedium | null): { batchSize: number; pollIntervalMs: number } { + return DEBUG_MEDIUM_PROFILE[medium ?? DEFAULT_DEBUG_MEDIUM] +} diff --git a/src/frontend/utils/debug-response-walker.ts b/src/frontend/utils/debug-response-walker.ts new file mode 100644 index 000000000..93a101430 --- /dev/null +++ b/src/frontend/utils/debug-response-walker.ts @@ -0,0 +1,109 @@ +/** + * Walking a `getVariablesList` reply — the one implementation. + * + * The runtime answers a batched read with raw type-sized values packed in + * REQUEST order and nothing else: no indexes, no lengths, no delimiters. The + * association between bytes and variables is therefore purely positional, and + * three details decide whether you read the right value or a plausible wrong + * one: + * + * - `lastIndex` is how far the runtime actually got. Positions past it were + * never read, and decoding them from whatever bytes follow yields numbers + * that look fine. + * - a position whose type is unknown to the caller still CONSUMED a slot at + * the runtime (it does not know our index→type map), so it must be counted + * as processed even though its bytes are forfeit. + * - running out of buffer mid-position must stop WITHOUT counting that + * position, so a round-robin caller retries the same slot next cycle rather + * than stranding the tail of its active set. + * + * Every one of those is a silent-corruption bug rather than an error, which is + * why the walk lives here instead of being restated per caller. `useDebugPolling` + * drives it for the GUI watch panel; the headless CLI's debug session drives it + * for `read`/`watch`. They cannot disagree about what a reply means. + */ + +import type { TargetEndian } from './endian' +import { applySwapToVariableBytes } from './endian' +import { getTypeSizeByName, parseValueByTypeName } from './variable-sizes' + +export interface DebugResponseWalkOptions { + /** Requested indexes, in the exact order the request packed them. */ + requested: readonly number[] + /** The value bytes from `parseGetListResponse`. Mutated in place when the target is BE. */ + payload: Uint8Array + /** Last position the runtime processed; `undefined` means "assume all of them". */ + lastIndex: number | undefined + endian: TargetEndian + /** + * Canonical IEC type for a requested index, or undefined when the caller has + * no metadata for it. Undefined consumes the position without decoding. + */ + typeOf: (index: number, position: number) => string | undefined + /** Called once per successfully decoded position. */ + emit: (decoded: { index: number; position: number; type: string; value: string }) => void + /** + * Called when the codec throws for a position. The walk still advances by the + * type's size and counts the position consumed: the runtime wrote those bytes + * whether or not we could read them, so skipping the advance would misalign + * every position after it. + */ + onError?: (failed: { index: number; position: number; type: string }) => void +} + +export interface DebugResponseWalkResult { + /** + * How many positions the runtime is known to have processed. A round-robin + * caller advances its offset by THIS, not by `lastIndex + 1`. + */ + positionsConsumed: number + /** False when the walk stopped early (short buffer, or a truncated reply). */ + reachedEnd: boolean +} + +export function walkDebugResponse(options: DebugResponseWalkOptions): DebugResponseWalkResult { + const { requested, payload, lastIndex, endian, typeOf, emit, onError } = options + let offset = 0 + let positionsConsumed = 0 + let reachedEnd = true + + for (let position = 0; position < requested.length; position += 1) { + if (lastIndex !== undefined && position > lastIndex) { + // The runtime processed fewer positions than we asked for; the rest are + // valid slots it simply never read. + reachedEnd = false + break + } + + const index = requested[position] + const type = typeOf(index, position) + if (type === undefined) { + // No metadata, but the runtime still consumed the slot. + positionsConsumed = position + 1 + continue + } + + const size = getTypeSizeByName(type) + if (offset + size > payload.length) { + // Do NOT count this position: the caller must retry it. + reachedEnd = false + break + } + + // Wire bytes arrive in the target's native order; the codec is LE-only. + // A no-op on the common LE target. + applySwapToVariableBytes(payload, offset, size, type, endian) + + try { + const { value, bytesRead } = parseValueByTypeName(payload, offset, type) + emit({ index, position, type, value }) + offset += bytesRead + } catch { + onError?.({ index, position, type }) + offset += size + } + positionsConsumed = position + 1 + } + + return { positionsConsumed, reachedEnd } +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index e75958d49..eb995e1d6 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,6 +1,5 @@ import { ESIService } from '@root/backend/editor/ethercat' import { createDesktopCatalogTransport } from '@root/backend/editor/library-manager/desktop-catalog-transport' -import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' import type { DebugStatusResult, DeviceDebugChannel, @@ -32,18 +31,13 @@ import type { } from '@root/middleware/shared/ports/public-catalog-types' import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' -import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' import { randomUUID } from 'crypto' -import dgram from 'dgram' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' import { app, dialog, nativeTheme, shell } from 'electron' import { readFile, realpathSync, stat, statSync, unwatchFile, watchFile } from 'fs' import { unlink, writeFile } from 'fs/promises' -import type { IncomingHttpHeaders, IncomingMessage } from 'http' -import https from 'https' -import { networkInterfaces } from 'os' import { join, resolve, sep } from 'path' import { platform } from 'process' @@ -67,9 +61,11 @@ import { buildDeviceModbusTransport, modbusTransportKind, } from '../../../backend/editor/hardware/device-transport-factory' +import { type DiscoveredRuntime, discoverRuntimes } from '../../../backend/editor/hardware/discover-runtimes' import { LibraryManagerModule } from '../../../backend/editor/library-manager' import { inspectDeviceLicense, resolveDeviceLicense } from '../../../backend/editor/license/license-flow' import { PackageManagerModule } from '../../../backend/editor/package-manager' +import { RuntimeApiClient } from '../../../backend/editor/runtime/runtime-api-client' import { logger } from '../../../backend/editor/services' import { getOpenProjectPath, @@ -139,20 +135,21 @@ class MainProcessBridge implements MainIpcModule { /** Classification of the candidate the held link came from. */ private deviceLinkProbe: DeviceProbeOutcome | null = null private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' | null = null - // Address of the runtime this session is authenticated against. Captured at - // login so the token authority can re-authenticate against the same device. - private runtimeIp: string | null = null - // Single token authority for the editor: owns the access token + credentials - // and the refresh/retry-on-401 logic, shared byte-for-byte with the web app. - // Every runtime HTTP call (GET, POST, and the project upload) goes through it, - // so they all self-heal identically when the 15-min JWT expires. - private tokens = createRuntimeTokenManager({ - login: async (credentials) => { - if (!this.runtimeIp) return { success: false, error: 'No runtime address configured' } - const result = await this.performAuthentication(this.runtimeIp, credentials.username, credentials.password) - return { success: result.success, token: result.accessToken, error: result.error } + /** + * The runtime REST API, extracted to `backend/editor/runtime` so the headless + * CLI makes the same calls rather than carrying its own copy — see that + * module's docblock for the two bugs a second copy produced. + * + * When the token authority transparently refreshes an expired token, the fresh + * token is pushed to the renderer so its store connection flag tracks it. + */ + private runtimeApi = new RuntimeApiClient({ + onTokenChanged: (newToken) => { + this.mainWindow?.webContents?.send('runtime:token-refreshed', newToken) }, }) + // Address of the runtime this session is authenticated against. Captured at + // login so the token authority can re-authenticate against the same device. // Current project root path used to validate file-watcher IPC calls private currentProjectPath: string | null = null // File watchers for auto-reload functionality (using watchFile for better macOS compatibility) @@ -202,63 +199,6 @@ class MainProcessBridge implements MainIpcModule { private readonly RUNTIME_CONNECTION_TIMEOUT_MS = 5000 // 5 seconds (important-comment) private readonly RUNTIME_LOGIN_TIMEOUT_MS = 15000 // 15 seconds - /** - * Low-level HTTP helper that handles data accumulation, timeout, and error handling. - * Returns the raw status code, response body, and headers for the caller to interpret. - */ - private httpRequest(options: { - method: 'GET' | 'POST' - url: string - body?: string - headers?: Record - timeoutMs?: number - }): Promise<{ statusCode: number; data: string; headers: IncomingHttpHeaders }> { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(options.url) - const reqOptions = { - hostname: parsedUrl.hostname, - port: parsedUrl.port, - path: parsedUrl.pathname + parsedUrl.search, - method: options.method, - headers: { - ...options.headers, - ...(options.body - ? { - 'Content-Type': 'application/json', - 'Content-Length': String(Buffer.byteLength(options.body)), - } - : {}), - }, - ...getRuntimeHttpsOptions(), - } - - const req = https.request(reqOptions as https.RequestOptions, (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - resolve({ statusCode: res.statusCode ?? 0, data, headers: res.headers }) - }) - }) - req.setTimeout(options.timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { - req.destroy() - reject(new Error('Connection timeout')) - }) - req.on('error', (error: Error) => { - reject(error) - }) - if (options.body) { - req.write(options.body) - } - req.end() - }) - } - - private runtimeUrl(ipAddress: string, endpoint: string): string { - return `https://${ipAddress}:${this.RUNTIME_API_PORT}${endpoint}` - } - handleRuntimeGetUsersInfo = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { const res = await this.httpRequest({ @@ -356,92 +296,10 @@ class MainProcessBridge implements MainIpcModule { return res.success ? { success: true } : { success: false, error: res.error } } - private async performAuthentication( - ipAddress: string, - username: string, - password: string, - ): Promise<{ success: boolean; accessToken?: string; error?: string }> { - try { - const res = await this.httpRequest({ - method: 'POST', - url: this.runtimeUrl(ipAddress, '/api/login'), - body: JSON.stringify({ username, password }), - timeoutMs: this.RUNTIME_LOGIN_TIMEOUT_MS, - }) - if (res.statusCode === 200) { - try { - const response = JSON.parse(res.data) as { access_token: string } - return { success: true, accessToken: response.access_token } - } catch { - return { success: false, error: 'Invalid response format' } - } - } - return { success: false, error: res.data } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - } - - handleRuntimeLogin = async (_event: IpcMainInvokeEvent, ipAddress: string, username: string, password: string) => { - const result = await this.performAuthentication(ipAddress, username, password) - if (result.success && result.accessToken) { - // Hand the session to the token authority so it can transparently - // re-authenticate against this device when the token expires. - this.runtimeIp = ipAddress - this.tokens.setSession(result.accessToken, { username, password }) - } - return result - } - - private isTokenExpiredError(statusCode: number | undefined, errorMessage: string): boolean { - if (statusCode === 401 || statusCode === 403) { - return true - } - const lowerError = errorMessage.toLowerCase() - return ( - lowerError.includes('unauthorized') || - lowerError.includes('token') || - lowerError.includes('expired') || - lowerError.includes('invalid token') - ) - } - - private parseApiResponse( - data: string, - responseParser?: (data: string) => T, - ): { success: true; data?: T } | { success: false; error: string } { - if (responseParser) { - try { - return { success: true, data: responseParser(data) } - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : 'Invalid response format' } - } - } - return { success: true } - } - - async makeRuntimeApiRequest( - ipAddress: string, - endpoint: string, - responseParser?: (data: string) => T, - ): Promise<{ success: true; data?: T } | { success: false; error: string }> { - // The token authority owns the live token + refresh. - type Raw = { success: true; data?: T } | { success: false; error: string; statusCode?: number } - const url = this.runtimeUrl(ipAddress, endpoint) - const result = await this.tokens.withAuth( - async (token) => { - try { - const res = await this.httpRequest({ method: 'GET', url, headers: { Authorization: `Bearer ${token}` } }) - if (res.statusCode === 200) return this.parseApiResponse(res.data, responseParser) - return { success: false, error: res.data, statusCode: res.statusCode } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - }, - (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), - ) - return result.success ? result : { success: false, error: result.error } - } + handleRuntimeLogin = (_event: IpcMainInvokeEvent, ipAddress: string, username: string, password: string) => + // The client adopts the session so it can transparently re-authenticate + // against this device when the token expires. + this.runtimeApi.login(ipAddress, username, password) /** * Wrap a service call with standardized error handling. @@ -454,222 +312,6 @@ class MainProcessBridge implements MainIpcModule { } } - /** - * Make an authenticated POST request to the runtime API with automatic token refresh on 401/403. - */ - makeRuntimeApiPostRequest( - ipAddress: string, - endpoint: string, - body: string, - responseParser: (data: string) => T, - timeoutMs?: number, - ): Promise<{ success: true; data: T } | { success: false; error: string }> { - // Token + refresh owned by the authority. - type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } - - const doRequest = (token: string): Promise => { - return new Promise((resolve) => { - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: endpoint, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - Authorization: `Bearer ${token}`, - }, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - try { - resolve({ success: true, data: responseParser(data) }) - } catch (err) { - resolve({ success: false, error: err instanceof Error ? err.message : 'Invalid response format' }) - } - } else { - // Propagate HTTP status so the caller can detect 401/403 for - // token-refresh without relying on brittle message parsing. - resolve({ - success: false, - error: data || `Unexpected status: ${res.statusCode}`, - statusCode: res.statusCode, - }) - } - }) - }, - ) - req.setTimeout(timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { - req.destroy() - resolve({ success: false, error: 'Connection timeout' }) - }) - req.on('error', (error: Error) => { - resolve({ success: false, error: error.message }) - }) - req.write(body) - req.end() - }) - } - - const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => - r.success ? r : { success: false, error: r.error } - - return this.tokens - .withAuth( - (token) => doRequest(token), - (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), - ) - .then(stripStatus) - } - - /** - * Authenticated PUT/DELETE against the runtime API, going through the token - * authority. Unlike the GET/POST helpers this retries only on 401 (a genuine - * expired token): the user-management endpoints use 403 as a legitimate - * business response (e.g. "current password incorrect", "admin required"), - * so retrying on 403 would trigger a pointless re-authentication. Any 2xx is - * success; the raw body is returned so callers can surface error messages. - */ - private makeRuntimeApiMutation( - method: 'POST' | 'PUT' | 'DELETE', - ipAddress: string, - endpoint: string, - body?: string, - ): Promise<{ success: true; data: string } | { success: false; error: string }> { - type R = { success: true; data: string } | { success: false; error: string; statusCode?: number } - - const doRequest = (token: string): Promise => - new Promise((resolve) => { - const headers: Record = { Authorization: `Bearer ${token}` } - if (body !== undefined) { - headers['Content-Type'] = 'application/json' - headers['Content-Length'] = Buffer.byteLength(body) - } - const req = https.request( - { - hostname: ipAddress, - port: this.RUNTIME_API_PORT, - path: endpoint, - method, - headers, - ...getRuntimeHttpsOptions(), - }, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - const statusCode = res.statusCode ?? 0 - if (statusCode >= 200 && statusCode < 300) { - resolve({ success: true, data }) - } else { - resolve({ success: false, error: data || `Unexpected status: ${statusCode}`, statusCode }) - } - }) - }, - ) - req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { - req.destroy() - resolve({ success: false, error: 'Connection timeout' }) - }) - req.on('error', (error: Error) => { - resolve({ success: false, error: error.message }) - }) - if (body !== undefined) req.write(body) - req.end() - }) - - return this.tokens - .withAuth( - (token) => doRequest(token), - (r) => !r.success && r.statusCode === 401, - ) - .then((r) => (r.success ? { success: true, data: r.data } : { success: false, error: r.error })) - } - - /** - * Upload a compiled program (multipart) to the runtime, going through the - * token authority so an expired token is transparently refreshed and the - * upload retried — the same self-healing every other runtime call gets. This - * is the path that previously had no refresh, so a long session's upload 401'd - * while status polling kept working. - */ - makeRuntimeApiUpload(opts: { - ipAddress: string - fileBuffer: Buffer - filename: string - contentType: string - cleanBuild: boolean - onUploadAccepted?: (responseBody: string) => void - }): Promise<{ success: true; data: string } | { success: false; error: string }> { - type UploadResult = { success: true; data: string } | { success: false; error: string; statusCode?: number } - const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) - const header = Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + - `Content-Type: ${opts.contentType}\r\n\r\n`, - ) - const footer = Buffer.from(`\r\n--${boundary}--\r\n`) - const reqBody = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) - const path = opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file' - - const doRequest = (token: string): Promise => - new Promise((resolve) => { - const req = https.request( - { - hostname: opts.ipAddress, - port: this.RUNTIME_API_PORT, - path, - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': reqBody.length, - Authorization: `Bearer ${token}`, - }, - ...getRuntimeHttpsOptions(), - } as https.RequestOptions, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) resolve({ success: true, data }) - else resolve({ success: false, error: data || `HTTP ${res.statusCode}`, statusCode: res.statusCode }) - }) - }, - ) - req.setTimeout(300_000, () => { - req.destroy() - resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) - }) - req.on('error', (err: Error) => resolve({ success: false, error: err.message })) - req.write(reqBody) - req.end() - }) - - return this.tokens - .withAuth( - (token) => doRequest(token), - (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), - ) - .then((result) => { - if (result.success) { - opts.onUploadAccepted?.(result.data) - return { success: true as const, data: result.data } - } - return { success: false as const, error: result.error } - }) - } - handleRuntimeGetStatus = async (_event: IpcMainInvokeEvent, ipAddress: string, includeStats?: boolean) => { try { // Build the endpoint path with optional include_stats query parameter @@ -797,146 +439,37 @@ class MainProcessBridge implements MainIpcModule { handleRuntimeClearCredentials = (_event: IpcMainInvokeEvent) => { this.tokens.clear() - this.runtimeIp = null + this.runtimeApi.clearSession() return { success: true } } // ===================== RUNTIME LAN DISCOVERY ===================== - private readonly DISCOVERY_PORT = 33333 - private readonly DISCOVERY_MAGIC = 'OPENPLC_DISCOVER_V1' - private readonly DISCOVERY_DEFAULT_DURATION_MS = 3000 - /** - * Compute the directed broadcast address for an IPv4 interface - * given its address and netmask in dotted-quad form. Returns - * `255.255.255.255` for /32 or otherwise-degenerate masks where a - * meaningful broadcast cannot be derived. - */ - private computeBroadcastAddress(address: string, netmask: string): string { - const toOctets = (s: string): number[] | null => { - const parts = s.split('.').map((p) => Number(p)) - if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { - return null - } - return parts - } - const addr = toOctets(address) - const mask = toOctets(netmask) - if (!addr || !mask) { - return '255.255.255.255' - } - const broadcast = addr.map((octet, i) => (octet & mask[i]) | (~mask[i] & 0xff)) - return broadcast.join('.') - } - - handleRuntimeDiscoverDevices = ( + handleRuntimeDiscoverDevices = async ( event: IpcMainInvokeEvent, opts?: { durationMs?: number }, ): Promise<{ success: boolean - devices?: Array<{ ipAddress: string; hostname: string; runtimeVersion: string; apiPort: number }> + devices?: DiscoveredRuntime[] error?: string }> => { - const duration = Math.max(500, Math.min(10000, opts?.durationMs ?? this.DISCOVERY_DEFAULT_DURATION_MS)) + // The scan itself lives in `backend/editor/hardware/discover-runtimes` so + // the headless CLI runs exactly this code — which interfaces get probed and + // how replies are deduplicated is what decides whether a device is found, + // and a second copy would drift on precisely those details. const senderWebContents = event.sender - - return new Promise((resolveOuter) => { - const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true }) - // Dedup by source IP; last reply wins so a runtime updating its - // hostname mid-scan still settles on fresh data. - const discovered = new Map< - string, - { ipAddress: string; hostname: string; runtimeVersion: string; apiPort: number } - >() - let settled = false - let timer: NodeJS.Timeout | null = null - - const finish = (err?: Error) => { - if (settled) return - settled = true - if (timer) clearTimeout(timer) - try { - sock.close() - } catch { - /* socket already closed */ - } - if (err) { - resolveOuter({ success: false, error: err.message }) - } else { - resolveOuter({ success: true, devices: Array.from(discovered.values()) }) - } - } - - sock.on('error', (err) => finish(err)) - - sock.on('message', (msg, rinfo) => { - let parsed: unknown - try { - parsed = JSON.parse(msg.toString('utf-8')) - } catch { - return - } - if ( - typeof parsed !== 'object' || - parsed === null || - (parsed as { service?: unknown }).service !== 'openplc-runtime' - ) { - return - } - const p = parsed as { - runtime_version?: unknown - hostname?: unknown - api_port?: unknown - } - const device = { - ipAddress: rinfo.address, - hostname: typeof p.hostname === 'string' ? p.hostname : '', - runtimeVersion: typeof p.runtime_version === 'string' ? p.runtime_version : '', - apiPort: typeof p.api_port === 'number' ? p.api_port : 8443, - } - discovered.set(device.ipAddress, device) - // Stream the live update to the renderer so the modal can - // append rows as devices come in, instead of waiting for the - // full timeout. + const result = await discoverRuntimes({ + durationMs: opts?.durationMs, + onDevice: (device) => { + // Stream live so the modal appends rows as devices come in rather than + // waiting out the whole window. if (!senderWebContents.isDestroyed()) { senderWebContents.send('runtime:device-discovered', device) } - }) - - sock.bind(0, () => { - try { - sock.setBroadcast(true) - } catch (err) { - finish(err as Error) - return - } - - const magic = new Uint8Array(Buffer.from(this.DISCOVERY_MAGIC, 'utf-8')) - const targets = new Set(['255.255.255.255']) - const ifaces = networkInterfaces() - for (const list of Object.values(ifaces)) { - if (!list) continue - for (const ifaceInfo of list) { - if (ifaceInfo.family !== 'IPv4' || ifaceInfo.internal) continue - const broadcast = this.computeBroadcastAddress(ifaceInfo.address, ifaceInfo.netmask) - targets.add(broadcast) - } - } - - for (const target of targets) { - sock.send(magic, this.DISCOVERY_PORT, target, (sendErr) => { - // Per-target send errors are logged but don't abort the - // scan; some interfaces (e.g. VPN tun adapters) reject - // broadcast and that's fine. - if (sendErr) { - logger.debug(`Discovery send to ${target} failed: ${sendErr.message}`) - } - }) - } - - timer = setTimeout(() => finish(), duration) - }) + }, + onDiagnostic: (message) => logger.debug(message), }) + return result.success ? { success: true, devices: result.devices } : { success: false, error: result.error } } handleRuntimeGetSerialPorts = async ( @@ -968,6 +501,68 @@ class MainProcessBridge implements MainIpcModule { } } + // ===================== RUNTIME API (delegated) ===================== + // Thin pass-throughs to `RuntimeApiClient`. They stay on this class because + // `CompilerModule`'s bridge contract and several handlers call them by name. + + makeRuntimeApiRequest = ( + ipAddress: string, + endpoint: string, + responseParser?: (data: string) => T, + ): Promise<{ success: true; data?: T } | { success: false; error: string }> => + this.runtimeApi.makeRuntimeApiRequest(ipAddress, endpoint, responseParser) + + makeRuntimeApiPostRequest = ( + ipAddress: string, + endpoint: string, + body: string, + responseParser: (data: string) => T, + timeoutMs?: number, + ): Promise<{ success: true; data: T } | { success: false; error: string }> => + this.runtimeApi.makeRuntimeApiPostRequest(ipAddress, endpoint, body, responseParser, timeoutMs) + + makeRuntimeApiUpload = (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }): Promise<{ success: true; data: string } | { success: false; error: string }> => + this.runtimeApi.makeRuntimeApiUpload(opts) + + private makeRuntimeApiMutation = ( + method: 'POST' | 'PUT' | 'DELETE', + ipAddress: string, + endpoint: string, + body?: string, + ): Promise<{ success: true; data: string } | { success: false; error: string }> => + this.runtimeApi.makeRuntimeApiMutation(method, ipAddress, endpoint, body) + + private httpRequest = (options: { + method: 'GET' | 'POST' + url: string + body?: string + headers?: Record + timeoutMs?: number + }) => this.runtimeApi.httpRequest(options) + + private restStartPlc = (address: string) => this.runtimeApi.startPlc(address) + + private runtimeUrl = (ipAddress: string, endpoint: string) => this.runtimeApi.runtimeUrl(ipAddress, endpoint) + + private performAuthentication = (ipAddress: string, username: string, password: string) => + this.runtimeApi.login(ipAddress, username, password) + + /** The token authority, so existing call sites keep reading `this.tokens`. */ + private get tokens() { + return this.runtimeApi.tokens + } + + private get runtimeIp(): string | null { + return this.runtimeApi.getAddress() + } + // ===================== IPC HANDLER REGISTRATION ===================== /** @@ -1781,43 +1376,13 @@ class MainProcessBridge implements MainIpcModule { } /** - * Run/stop over a REST control channel, reported in the same shape the Modbus - * path returns — so the caller handles one result type, not two. - * - * `ERROR_SWITCH_STOP` in the runtime's reply is its way of saying the hardware - * mode switch refused a start, which is exactly what `refusedBySwitch` means on - * the Modbus side (FC 0x4b status 0x86). + * Run/stop over a REST control channel. Lives on `RuntimeApiClient` so the + * headless CLI gets the same semantics — notably the `ERROR_SWITCH_STOP` + * translation, which is the runtime's way of saying a hardware mode switch + * refused a start. */ - private async restSetPlcState(address: string, action: 'run' | 'stop'): Promise { - const result = - action === 'run' ? await this.restStartPlc(address) : await this.makeRuntimeApiRequest(address, '/api/stop-plc') - if (!result.success) return { success: false, error: result.error } - - const status = 'status' in result ? (result.status ?? '') : '' - if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } - - // The runtime settles into the new state on its next scan; report the state the - // command asked for so the button can reflect it without a second round trip. - return { success: true, state: action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED } - } - - /** The `/api/start-plc` call, shared by the session router and the IPC handler. */ - private async restStartPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { - try { - // The body is parsed because the runtime answers `COMMAND:BUSY` while it is - // still unloading a previous program after an upload, and callers drive a - // retry loop on that. See `backend/shared/library/start-plc-after-build.ts`. - const result = await this.makeRuntimeApiRequest<{ status?: string }>( - address, - '/api/start-plc', - (data: string) => JSON.parse(data) as { status?: string }, - ) - if (!result.success) return { success: false, error: result.error } - return { success: true, status: (result.data?.status ?? '').trim() } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - } + private restSetPlcState = (address: string, action: 'run' | 'stop'): Promise => + this.runtimeApi.setPlcState(address, action) handleDebuggerGetVariablesList = async ( _event: IpcMainInvokeEvent, diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index d899ea006..b2ca8a799 100644 --- a/src/middleware/adapters/editor/compiler-adapter.ts +++ b/src/middleware/adapters/editor/compiler-adapter.ts @@ -40,7 +40,7 @@ import type { * `as unknown as SchemaProjectData`, which is why the omission is not a compile * error. Adding a field to the project model means adding it here too. */ -interface IpcProjectData { +export interface IpcProjectData { dataTypes: PLCProjectData['dataTypes'] globalVariableLists?: PLCProjectData['globalVariableLists'] pous: Array<{ @@ -75,6 +75,9 @@ function portPouToIpcPou(pou: PLCPou) { } /** Converts PLCProjectData (port format) to the editor's IPC format. */ +// Exported so the headless CLI can run the SAME pre-compile chain the renderer +// runs (inject library C++ blocks -> preprocess POUs -> convert to the IPC/schema +// shape). Skipping any step compiles a different program from the same sources. function toIpcProjectData(data: PLCProjectData & { originalCppPous?: unknown[] }): IpcProjectData { return { dataTypes: data.dataTypes, @@ -462,4 +465,4 @@ export function createEditorCompilerAdapter(): CompilerPort { } } -export { decodeMessage, inferStage, portPouToIpcPou, toIpcProjectData } +export { decodeMessage, inferStage, injectLibraryCppBlocks, portPouToIpcPou, toIpcProjectData } From 319e189fa2fe5f4cd7f5b482b95686a5a23d6125 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 16:38:47 -0400 Subject: [PATCH 04/25] style(cli): sort imports in the CLI entry point Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index 4aa5fa939..dfb627ea9 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -28,10 +28,10 @@ import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { app } from 'electron' import { boolFlag, parseArgs, type ParsedArgs, stringFlag } from './args' -import { runDaemonFromStdin } from './daemon-entry' import { runBuild } from './commands/build' import { type DebugContext, runDebug } from './commands/debug' import { runDevices } from './commands/devices' +import { runDaemonFromStdin } from './daemon-entry' import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' import { createProcessReporter, Reporter } from './output' import { SessionRegistry } from './session/registry' From 36efce7cdd916116c99e06ed52ceaa609da89c9b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 16:58:03 -0400 Subject: [PATCH 05/25] fix(compile): write runtime-v4 build artifacts on the compile-only path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compile-only build for a runtime-v4 target left almost nothing in `build//`. The bundle is composed in memory by `composeRuntimeV4Bundle`, and the only thing that ever wrote it to disk was `uploadRuntimeV4` — which the compile-only branch returns before reaching. The folder was left holding just the files `packageVppPlugin` writes directly (`conf/`, `vpp_plugin/`, `vpp_plugins.conf`): 17 files instead of 51, missing `program.st`, the generated C++, `defines.h`, `configuration.cpp`, the strucpp runtime headers and — the one a debugger needs — `debug-map.json`. It hid well. Any earlier build-and-upload leaves a complete folder behind, so a compile-only run over a dirty build directory looks correct; only wiping the folder first shows it. Split the write into `CompilerPlatformPort.materializeRuntimeV4Bundle` and call it for every v4 compile, before the compile-only branch, so `compile` and `upload` leave byte-identical artifacts. `uploadRuntimeV4` no longer writes the bundle itself — the files it zips are already there. The port method is optional so a platform without a project build directory is unaffected. Fixes the editor's Build -> compile-only as well as the CLI's `compile`. Co-Authored-By: Claude Opus 5 (1M context) --- .../compiler/editor-compiler-platform-port.ts | 43 +++++++++++++++---- src/backend/shared/compile/pipeline.ts | 19 ++++++++ src/cli/commands/build.ts | 26 ++++++++++- src/cli/session/daemon-main.ts | 3 ++ src/cli/session/registry.ts | 13 +++++- .../shared/ports/compiler-platform-port.ts | 33 ++++++++++++++ 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index c85622d40..f365af655 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -39,6 +39,8 @@ import type { CompilerPlatformPort, InstallArduinoCoreArgs, InstallArduinoLibArgs, + MaterializeRuntimeV4BundleArgs, + MaterializeRuntimeV4BundleResult, PackageVppPluginArgs, PackageVppPluginResult, PlatformDeviceContext, @@ -351,15 +353,10 @@ export function createEditorCompilerPlatformPort( async uploadRuntimeV4(args: UploadRuntimeV4Args, log: PlatformLog): Promise { const deviceContext = assertEditorHttpsContext(args.context) try { - // Materialise the bundle to disk under sourceTargetFolderPath - // so the existing `compressSourceFolder` can zip it. - await Promise.all( - Object.entries(args.bundle).map(async ([relPath, content]) => { - const absPath = join(context.sourceTargetFolderPath, relPath) - await fs.mkdir(dirname(absPath), { recursive: true }) - await fs.writeFile(absPath, content, 'utf-8') - }), - ) + // The bundle is already on disk: the pipeline calls + // `materializeRuntimeV4Bundle` for every v4 compile, upload or not. This + // used to write it here, which is precisely why a compile-only build + // produced no artifacts. const fileBuffer = await context.compressSourceFolder(context.sourceTargetFolderPath) const deployOutcome = await deployRuntimeProgram({ @@ -581,6 +578,34 @@ export function createEditorCompilerPlatformPort( * disk layer is already the source of truth for the editor; web's * adapter will need to surface them in the returned map instead. */ + /** + * Write the composed v4 bundle into `build//src`. + * + * The same directory `compressSourceFolder` zips for the upload, so a + * compile-only build and a build-and-upload leave byte-identical artifacts — + * which is what lets a test inspect a compile without touching a device. + */ + async materializeRuntimeV4Bundle( + args: MaterializeRuntimeV4BundleArgs, + log: PlatformLog, + ): Promise { + try { + const entries: Array<[string, string]> = Object.entries(args.bundle) + await Promise.all( + entries.map(async ([relPath, content]: [string, string]) => { + const absPath = join(context.sourceTargetFolderPath, relPath) + await fs.mkdir(dirname(absPath), { recursive: true }) + await fs.writeFile(absPath, content, 'utf-8') + }), + ) + return { written: entries.length } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Could not write build artifacts: ${message}`, 'error') + return { written: 0, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + }, + async packageVppPlugin(args: PackageVppPluginArgs, log: PlatformLog): Promise { try { await handlers.handleVendorPluginPackaging( diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 9100be5ec..e43f9d26c 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -587,6 +587,25 @@ async function runCompilePipelineInner( }) } + // Write the bundle out BEFORE the compile-only branch, so `compile` and + // `upload` leave the same artifacts on disk. Until this existed, the bundle + // only ever reached disk as a side effect of the upload, and a compile-only + // v4 build left a build folder holding nothing but the VPP files. + if (port.materializeRuntimeV4Bundle) { + const materialized = await port.materializeRuntimeV4Bundle( + { bundle }, + makePlatformLog(emit, 'runtime-v4-bundle'), + ) + if (materialized.errors && materialized.errors.length > 0) { + return bailError(emit, 'runtime-v4-bundle', 'Could not write the Runtime v4 build artifacts.', materialized.errors) + } + emit({ + stage: 'runtime-v4-bundle', + message: `Wrote ${materialized.written} build artifact(s) to the project build folder`, + level: 'info', + }) + } + if (compileOnly) { emit({ stage: 'done', message: 'Compile only mode — skipping upload to runtime.', level: 'info' }) return { success: true, md5, uploaded: false } diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index d16e2e35b..5ccf5cd2c 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -15,6 +15,7 @@ import { CompilerModule } from '@root/backend/editor/compiler' import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import { HardwareModule } from '@root/backend/editor/hardware' import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' import { preprocessPous } from '@root/backend/shared/utils/PLC/preprocess-pous' import { injectLibraryCppBlocks, toIpcProjectData } from '@root/middleware/adapters/editor/compiler-adapter' @@ -102,9 +103,30 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu // schema shape the pipeline consumes. Skipping any of it would compile a // DIFFERENT program from the same sources — a project with a Python function // block would silently lose it. + // Board info comes from `HardwareModule.getAvailableBoards()` — the same + // source the compiler adapter reads before a GUI build. `boardCore` and + // `isSimulator` are both derived from it rather than guessed: the adapter + // passes `boardInfo.core` to the pipeline, and infers the simulator from + // `compiler === 'simulator'`. Matching the target name against "simulator" + // instead would be a second, weaker rule that a renamed board silently breaks. + const boards = await new HardwareModule().getAvailableBoards() + const boardInfo = boards.get(target) + if (!boardInfo) { + return reporter.failure( + { + code: ErrorCode.TargetUnknown, + message: + `Board "${target}" is not available. It is neither in hals.json nor an installed VPP package — ` + + 'check `openplc devices` for runtimes, or install the board package in the editor.', + }, + ExitCode.NotFound, + ) + } + const boardCore = boardInfo.core ?? null + const isSimulator = boardInfo.compiler === 'simulator' + const archives = new LibraryManagerModule().loadAll() const withLibraryCpp = injectLibraryCppBlocks(project.compileReady, archives) - const isSimulator = target.toLowerCase().includes('simulator') const { projectData: processed, validationFailed } = preprocessPous(withLibraryCpp, isSimulator, (level, message) => { reporter.progress(level === 'error' ? `error: ${message}` : message) }) @@ -121,7 +143,7 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu const outcome = await runCompilePipeline({ projectPath: project.projectPath, target, - boardCore: null, + boardCore, compileOnly: !options.withUpload, projectData: toIpcProjectData(processed), runtimeIpAddress: host, diff --git a/src/cli/session/daemon-main.ts b/src/cli/session/daemon-main.ts index bff64c8c9..ef1670a3b 100644 --- a/src/cli/session/daemon-main.ts +++ b/src/cli/session/daemon-main.ts @@ -69,6 +69,9 @@ export async function runDaemon(config: DaemonConfig): Promise { }) try { + // Before binding, not after: the socket lives in the registry directory, and + // registering only happens once listening succeeds. + registry.ensureDirectory() await server.listen() } catch (error) { await opened.core.close(true) diff --git a/src/cli/session/registry.ts b/src/cli/session/registry.ts index 3ce7886a0..050e4de41 100644 --- a/src/cli/session/registry.ts +++ b/src/cli/session/registry.ts @@ -77,12 +77,21 @@ export class SessionRegistry { return join(this.dir, `${sessionId}.json`) } - private ensureDir(): void { + /** + * Create the registry directory. + * + * Public because the socket has to be bound BEFORE the record is written — + * there is no point registering a session that failed to listen — and binding + * into a directory that does not exist fails. macOS reports that as `EACCES` + * rather than `ENOENT`, which reads like a permissions problem and sends you + * looking in entirely the wrong place. + */ + ensureDirectory(): void { if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true }) } register(record: SessionRecord): void { - this.ensureDir() + this.ensureDirectory() writeFileSync(this.recordPath(record.sessionId), `${JSON.stringify(record, null, 2)}\n`, 'utf-8') } diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index 349caa6e0..60af8db42 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -372,4 +372,37 @@ export interface CompilerPlatformPort { * remote VPP packages). The pipeline calls this between * `composeRuntimeV4Bundle` and `uploadRuntimeV4`. */ packageVppPlugin(args: PackageVppPluginArgs, log: PlatformLog): Promise + + /** + * Persist a composed runtime-v4 bundle to the platform's build location. + * + * Split out of `uploadRuntimeV4`, which used to be the only thing that wrote + * the bundle anywhere. That made `compileOnly` produce almost nothing on disk + * for a v4 target: the bundle is composed in memory, and the compile-only path + * returned before the upload that happened to materialise it. A build folder + * left holding only the VPP files (which `packageVppPlugin` writes directly) + * looked plausible enough to be mistaken for a complete build — and stale + * files from an earlier upload made it look complete outright. + * + * Called for every v4 compile, upload or not, so `compile` and `upload` leave + * byte-identical artifacts. + * + * Optional: a platform with no project build directory can omit it, and the + * pipeline simply skips the write. + */ + materializeRuntimeV4Bundle?( + args: MaterializeRuntimeV4BundleArgs, + log: PlatformLog, + ): Promise +} + +export interface MaterializeRuntimeV4BundleArgs { + /** Path → file content, as composed by `composeRuntimeV4Bundle`. */ + bundle: Record +} + +export interface MaterializeRuntimeV4BundleResult { + /** Number of files written, for the progress line. */ + written: number + errors?: StructuredCompileError[] } From a770afaf7d3611c9dccf8ff884b57ad49a3afcc0 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 17:08:59 -0400 Subject: [PATCH 06/25] refactor(cli): map each command onto the flow its button click starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI command should kick off the same orchestrated flow as the GUI control it mirrors, not reassemble the steps. `compile` / `upload` were reassembling them — board lookup, the library C++ graft, POU preprocessing, pipeline argument shaping — which is how they ended up passing `boardCore: null` and guessing `isSimulator` from the target name while the GUI derived both from board info. Extract that orchestration out of the editor's `CompilerPort.compileProgram` into `compileProgramFlow`, parameterised by a three-call transport. The renderer adapter supplies a `window.bridge` transport; the CLI supplies one backed by the main-process modules. Same flow, two front ends: `build.ts` drops from 280 lines to 161 and no longer knows what a board core is. Also adds `debug exec`, the scriptable counterpart to the REPL. Piping a script into `debug repl` silently dropped commands — readline over a non-TTY delivers buffered lines in one burst, so pausing between them cannot hold them back; a seven-command script ran the first and the last. `exec` reads the whole input and runs it strictly in sequence, and the REPL now refuses a pipe and points at it rather than degrading. Two bugs found by running against a live SLM-RP4: - `force` reported the pre-write value. External writes land on the runtime's debug-write journal and drain once per cycle, so the immediate read-back races the drain: `force x 3.5` answered `0`. It now polls briefly for the value to settle, and gives up quietly (a soft `write` the program overwrites next scan is not an error). - A session kept reporting `[FORCED]` after stopping the PLC. The runtime clears forces on program unload/stop, so the list was stale — a forced BOOL read back as the program's own value while still flagged, and `close` tried to release pins that no longer existed. Stopping the PLC now clears the session's list. Verified end to end on the device: open, status, list-vars, read, write, force, unforce, watch + poll (capturing transitions between separate invocations), start, stop, exec, close. A fresh session confirms `close` really released the pin on the runtime, not just in bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/commands/build.ts | 233 ++++-------------- src/cli/commands/debug.ts | 130 +++++++++- src/cli/compile/cli-transport.ts | 51 ++++ src/cli/main.ts | 4 +- src/cli/session/protocol.ts | 9 +- src/cli/session/session-core.ts | 97 +++++++- .../adapters/editor/compile-program-flow.ts | 162 ++++++++++++ .../adapters/editor/compiler-adapter.ts | 131 ++-------- 8 files changed, 507 insertions(+), 310 deletions(-) create mode 100644 src/cli/compile/cli-transport.ts create mode 100644 src/middleware/adapters/editor/compile-program-flow.ts diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 5ccf5cd2c..b1c25d90f 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -1,38 +1,28 @@ /** - * `openplc compile` and `openplc upload` — the same pipeline, one flag apart. + * `openplc compile` and `openplc upload` — the Build and Build & Upload clicks. * - * Both run `CompilerModule.compileProgram`, the exact call the GUI's build - * button makes. `compile` passes `compileOnly: true` and no runtime address, so - * the pipeline stops after producing artifacts; `upload` logs in first and - * hands the pipeline the address and token, so its existing upload step runs. + * Both call `compileProgramFlow`, the orchestration behind the editor's + * `CompilerPort.compileProgram`, through a CLI transport. Everything the flow + * does — resolving the board from the catalogue, grafting library C++ blocks, + * preprocessing POUs, shaping the pipeline arguments, interpreting the message + * stream — is therefore the same code the button runs. The only difference + * between the two commands is `compileOnly` and whether a runtime address and + * token are supplied, exactly as it is between the two menu items. * - * There is deliberately no separate upload implementation. Framing the - * multipart body, choosing the bundle, and the post-upload restart are all - * pipeline concerns already solved once, and a second copy would only be - * exercised by the CLI — where a mistake would surface as a device that quietly - * runs the wrong program. + * What stays here is the CLI's own part: reading the target and credentials off + * argv, loading the project from disk, and rendering the result. */ -import { CompilerModule } from '@root/backend/editor/compiler' -import { LibraryManagerModule } from '@root/backend/editor/library-manager' -import { HardwareModule } from '@root/backend/editor/hardware' import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' -import { preprocessPous } from '@root/backend/shared/utils/PLC/preprocess-pous' -import { injectLibraryCppBlocks, toIpcProjectData } from '@root/middleware/adapters/editor/compiler-adapter' +import { compileProgramFlow } from '@root/middleware/adapters/editor/compile-program-flow' +import type { CompileProgressEvent } from '@root/middleware/shared/ports/types' import { boolFlag, type ParsedArgs, stringFlag } from '../args' -import { createHeadlessCompileBridge, createProgressChannel } from '../compile/headless-bridge' +import { createCliCompileTransport } from '../compile/cli-transport' import { ErrorCode, ExitCode } from '../exit-codes' import type { CliResult, Reporter } from '../output' import { loadProject } from '../project/load' -/** One line of compiler output, as posted to the progress channel. */ -interface CompileEvent { - logLevel?: 'info' | 'warning' | 'error' - message?: string - closePort?: boolean -} - export interface BuildOptions { /** True for `upload`: connect to a runtime and let the pipeline flash it. */ withUpload: boolean @@ -54,8 +44,8 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu const project = loaded.project for (const warning of project.warnings) reporter.progress(`warning: ${warning}`) - // The project remembers its own board; --target overrides it so one fixture - // can be built for several targets in a test matrix. + // The project remembers the board its dropdown was left on; `--target` + // overrides it so one fixture can be built for several targets in a matrix. const target = stringFlag(args, 'target') ?? project.board if (!target) { return reporter.failure( @@ -68,8 +58,9 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu } let runtime: RuntimeApiClient | null = null + let host: string | null = null if (options.withUpload) { - const host = stringFlag(args, 'host') ?? stringFlag(args, 'address') + host = stringFlag(args, 'host') ?? stringFlag(args, 'address') ?? null if (!host) { return reporter.failure( { code: ErrorCode.MissingArgument, message: 'upload needs --host
(see `openplc devices`)' }, @@ -92,80 +83,39 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu } } - const host = options.withUpload ? (stringFlag(args, 'host') ?? stringFlag(args, 'address') ?? null) : null - const cleanBuild = boolFlag(args, 'clean') - reporter.progress(`${options.withUpload ? 'Building and uploading' : 'Building'} "${project.name}" for ${target}…`) - // The renderer's pre-compile chain, in the same order and with the same - // functions: graft library-supplied C++ blocks in, preprocess POUs (comment - // wrapping, Python -> ST stubs, C++ validation), then convert to the - // schema shape the pipeline consumes. Skipping any of it would compile a - // DIFFERENT program from the same sources — a project with a Python function - // block would silently lose it. - // Board info comes from `HardwareModule.getAvailableBoards()` — the same - // source the compiler adapter reads before a GUI build. `boardCore` and - // `isSimulator` are both derived from it rather than guessed: the adapter - // passes `boardInfo.core` to the pipeline, and infers the simulator from - // `compiler === 'simulator'`. Matching the target name against "simulator" - // instead would be a second, weaker rule that a renamed board silently breaks. - const boards = await new HardwareModule().getAvailableBoards() - const boardInfo = boards.get(target) - if (!boardInfo) { - return reporter.failure( - { - code: ErrorCode.TargetUnknown, - message: - `Board "${target}" is not available. It is neither in hals.json nor an installed VPP package — ` + - 'check `openplc devices` for runtimes, or install the board package in the editor.', - }, - ExitCode.NotFound, - ) - } - const boardCore = boardInfo.core ?? null - const isSimulator = boardInfo.compiler === 'simulator' + let streamedError = false + const warnings: string[] = [] - const archives = new LibraryManagerModule().loadAll() - const withLibraryCpp = injectLibraryCppBlocks(project.compileReady, archives) - const { projectData: processed, validationFailed } = preprocessPous(withLibraryCpp, isSimulator, (level, message) => { - reporter.progress(level === 'error' ? `error: ${message}` : message) - }) - if (validationFailed) { - return reporter.failure( - { - code: ErrorCode.CompileFailed, - message: 'POU validation failed — check C/C++ POUs for missing setup()/loop() functions', - }, - ExitCode.CompileFailed, - ) - } - - const outcome = await runCompilePipeline({ - projectPath: project.projectPath, - target, - boardCore, - compileOnly: !options.withUpload, - projectData: toIpcProjectData(processed), - runtimeIpAddress: host, - runtimeJwtToken: runtime?.tokens.getToken() ?? null, - cleanBuild, - communicationPort: project.communicationPort ?? null, - vendorScreenData: project.vendorScreenData, - runtime, - onLine: (line, level) => { - if (level === 'error') reporter.progress(`error: ${line}`) - else reporter.progress(line) + const result = await compileProgramFlow( + { + projectPath: project.projectPath, + boardTarget: target, + // The alias-resolved snapshot, from the same store action the button uses. + projectData: project.compileReady, + compileOnly: !options.withUpload, + cleanBuild: boolFlag(args, 'clean'), + runtimeIpAddress: host, + runtimeJwtToken: runtime?.tokens.getToken() ?? null, + communicationPort: project.communicationPort || undefined, + vendorScreenData: project.vendorScreenData, + }, + createCliCompileTransport(runtime), + (event: CompileProgressEvent) => { + if (event.level === 'error' || event.stage === 'error') streamedError = true + if (event.level === 'warning' && event.message) warnings.push(event.message) + if (event.message) reporter.progress(event.level === 'error' ? `error: ${event.message}` : event.message) }, - }) + ) - if (!outcome.success) { + if (!result.success) { return reporter.failure( { - code: options.withUpload && outcome.stage === 'upload' ? ErrorCode.UploadRejected : ErrorCode.CompileFailed, - message: outcome.error, - details: { diagnostics: outcome.diagnostics }, + code: options.withUpload && streamedError ? ErrorCode.UploadRejected : ErrorCode.CompileFailed, + message: result.error ?? 'Compilation failed', }, - options.withUpload && outcome.stage === 'upload' ? ExitCode.TargetError : ExitCode.CompileFailed, + options.withUpload && streamedError ? ExitCode.TargetError : ExitCode.CompileFailed, ) } @@ -176,7 +126,8 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu target, uploaded: options.withUpload, buildDirectory: `${project.projectPath}/build/${target}`, - warnings: outcome.diagnostics.filter((line) => line.level === 'warning').map((line) => line.message), + firmwarePath: result.hexPath, + warnings, }, () => options.withUpload @@ -187,8 +138,8 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu /** Credentials from flags or the environment, with a clear message when absent. */ export function resolveCredentials(args: ParsedArgs): { username: string; password: string } | { error: string } { - // `--credentials user:pass` is convenient; the environment form exists - // because a flag lands in shell history and CI logs. + // `--credentials user:pass` is convenient; the environment form exists because + // a flag lands in shell history and CI logs. const combined = stringFlag(args, 'credentials') ?? process.env.OPENPLC_CREDENTIALS if (combined) { const separator = combined.indexOf(':') @@ -208,95 +159,3 @@ export function resolveCredentials(args: ParsedArgs): { username: string; passwo } return { username, password } } - -export interface CompilePipelineResult { - success: boolean - error: string - stage: 'compile' | 'upload' - diagnostics: Array<{ level: 'info' | 'warning' | 'error'; message: string }> -} - -/** - * Drive `compileProgram` to completion. - * - * The pipeline reports asynchronously and signals the end by closing the - * channel, so success is decided from what it said before closing rather than - * from a return value — it has none. An `error` line is what a failed build - * looks like from out here. - */ -export async function runCompilePipeline(options: { - projectPath: string - target: string - boardCore: string | null - compileOnly: boolean - /** Schema-shape project data, as produced by `toIpcProjectData`. */ - projectData: ReturnType - runtimeIpAddress: string | null - runtimeJwtToken: string | null - cleanBuild: boolean - communicationPort: string | null - vendorScreenData: Record | undefined - runtime: RuntimeApiClient | null - onLine: (message: string, level: 'info' | 'warning' | 'error') => void -}): Promise { - const diagnostics: Array<{ level: 'info' | 'warning' | 'error'; message: string }> = [] - let sawUploadStage = false - - return new Promise((resolve) => { - const finish = () => { - const errors = diagnostics.filter((line) => line.level === 'error') - resolve({ - success: errors.length === 0, - error: errors.length === 0 ? '' : errors[errors.length - 1].message, - stage: sawUploadStage ? 'upload' : 'compile', - diagnostics, - }) - } - - const channel = createProgressChannel({ - onMessage: (message: unknown) => { - const event = readCompileEvent(message) - if (!event?.message) return - const level = event.logLevel ?? 'info' - // Anything the pipeline says after it starts uploading belongs to the - // upload stage, which the caller reports with a different exit code. - if (/upload/i.test(event.message)) sawUploadStage = true - diagnostics.push({ level, message: event.message }) - options.onLine(event.message, level) - }, - onClose: finish, - }) - - const compiler = new CompilerModule() - const compileArgs: Array = [ - options.projectPath, - options.target, - options.boardCore, - options.compileOnly, - options.projectData, - options.runtimeIpAddress, - options.runtimeJwtToken, - options.cleanBuild, - options.communicationPort, - options.vendorScreenData, - ] - void compiler - .compileProgram(compileArgs, channel, createHeadlessCompileBridge(options.runtime)) - .catch((error: unknown) => { - diagnostics.push({ level: 'error', message: error instanceof Error ? error.message : String(error) }) - channel.close() - }) - }) -} - -/** Validate a progress payload instead of trusting its shape. */ -function readCompileEvent(message: unknown): CompileEvent | undefined { - if (typeof message !== 'object' || message === null) return undefined - const record: Record = { ...message } - const level = record.logLevel - return { - logLevel: level === 'info' || level === 'warning' || level === 'error' ? level : undefined, - message: typeof record.message === 'string' ? record.message : undefined, - closePort: typeof record.closePort === 'boolean' ? record.closePort : undefined, - } -} diff --git a/src/cli/commands/debug.ts b/src/cli/commands/debug.ts index 0eb5a987c..f9fe4a8d8 100644 --- a/src/cli/commands/debug.ts +++ b/src/cli/commands/debug.ts @@ -11,6 +11,7 @@ * `Request`s and neither touches the debug channel directly. */ +import { readFile } from 'node:fs/promises' import { userInfo } from 'node:os' import { createInterface } from 'node:readline' @@ -20,7 +21,7 @@ import { ErrorCode, ExitCode, type ExitCodeValue } from '../exit-codes' import type { CliResult, Reporter } from '../output' import { sendRequest } from '../session/client' import type { OkResponse, Request, Response } from '../session/protocol' -import { type SessionRecord,SessionRegistry } from '../session/registry' +import { type SessionRecord, SessionRegistry } from '../session/registry' import { renderTable } from './devices' export interface DebugContext { @@ -80,6 +81,8 @@ export async function runDebug(args: ParsedArgs, reporter: Reporter, context: De return runClose(args, reporter, context) case 'repl': return runRepl(args, reporter, context) + case 'exec': + return runExec(args, reporter, context) case 'status': case 'list-vars': case 'read': @@ -543,6 +546,20 @@ async function runRepl(args: ParsedArgs, reporter: Reporter, context: DebugConte } const record = resolved.record + // Refused rather than degraded: readline over a pipe delivers buffered lines + // in one burst, so a scripted REPL silently drops commands. `exec` is the + // deterministic path and the error names it. + if (!process.stdin.isTTY) { + return reporter.failure( + { + code: ErrorCode.InvalidArgument, + message: + 'debug repl needs a terminal. For a script, use `openplc debug exec -` (reads commands from stdin, one per line).', + }, + ExitCode.Usage, + ) + } + process.stdout.write( `OpenPLC debug session ${record.sessionId}\n` + `target ${record.target || '-'} project ${record.projectPath}\n` + @@ -595,3 +612,114 @@ async function runRepl(args: ParsedArgs, reporter: Reporter, context: DebugConte return reporter.success({ sessionId: record.sessionId, left: true }, () => `Left session ${record.sessionId}.`) } + +/** + * `debug exec` — run a script of REPL commands, one per line. + * + * Separate from the REPL rather than "the REPL with piped stdin", because + * readline is the wrong tool for a script: with a non-TTY input it delivers + * every buffered line in one synchronous burst, so pausing between commands + * cannot hold them back and lines get dropped. Observed exactly that — a piped + * seven-command script ran the first and the last. + * + * This reads the whole input, then runs the commands strictly in sequence over + * one connection each, which is also what makes the output deterministic enough + * to assert on. Stops at the first failure unless `--keep-going`, so a script + * cannot keep issuing writes after something went wrong. + */ +async function runExec(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { + const resolved = resolveSession(args, context) + if ('error' in resolved) { + return reporter.failure({ code: ErrorCode.SessionNotFound, message: resolved.error }, ExitCode.NotFound) + } + + const script = await readScript(args) + if ('error' in script) { + return reporter.failure({ code: ErrorCode.InvalidArgument, message: script.error }, ExitCode.Usage) + } + + const keepGoing = boolFlag(args, 'keep-going') + const steps: Array<{ command: string; ok: boolean; output?: string; error?: string }> = [] + let failures = 0 + let id = 1 + + for (const line of script.lines) { + const parsed = parseReplLine(line, id++) + // `help` and blank lines are no-ops in a script; `quit` ends it early. + if (parsed === null || parsed === 'help') continue + if (parsed === 'quit') break + + if ('error' in parsed) { + steps.push({ command: line, ok: false, error: parsed.error }) + failures += 1 + if (!keepGoing) break + continue + } + + const result = await sendRequest(resolved.record.socketPath, parsed.request) + if (!result.success) { + steps.push({ command: line, ok: false, error: result.error }) + failures += 1 + if (!keepGoing) break + continue + } + if (!result.response.ok) { + steps.push({ + command: line, + ok: false, + error: `[${result.response.error.code}] ${result.response.error.message}`, + }) + failures += 1 + if (!keepGoing) break + continue + } + const rendered = renderOk(result.response) + steps.push({ command: line, ok: true, output: rendered }) + reporter.progress(`${line}`) + } + + if (failures > 0) { + return reporter.failure( + { + code: ErrorCode.TargetError, + message: `${failures} of ${steps.length} command(s) failed`, + details: { steps }, + }, + ExitCode.TargetError, + ) + } + + return reporter.success({ steps }, () => steps.map((step) => `> ${step.command}\n${step.output ?? ''}`).join('\n')) +} + +/** Command lines from a file argument, or from stdin when given `-`. */ +async function readScript(args: ParsedArgs): Promise<{ lines: string[] } | { error: string }> { + const source = args.positionals[0] ?? stringFlag(args, 'script') ?? '-' + let text: string + if (source === '-') { + text = await readAllStdin() + } else { + try { + text = await readFile(source, 'utf-8') + } catch { + return { error: `Could not read the command script at ${source}` } + } + } + const lines = text + .split('\n') + .map((line) => line.replace(/#.*$/, '').trim()) + .filter((line) => line.length > 0) + if (lines.length === 0) return { error: 'The command script is empty' } + return { lines } +} + +function readAllStdin(): Promise { + return new Promise((resolve) => { + let buffered = '' + process.stdin.setEncoding('utf-8') + process.stdin.on('data', (chunk: string) => { + buffered += chunk + }) + process.stdin.on('end', () => resolve(buffered)) + }) +} diff --git a/src/cli/compile/cli-transport.ts b/src/cli/compile/cli-transport.ts new file mode 100644 index 000000000..8db09ab48 --- /dev/null +++ b/src/cli/compile/cli-transport.ts @@ -0,0 +1,51 @@ +/** + * The `CompileProgramTransport` for the headless CLI. + * + * The renderer's transport is backed by `window.bridge`; this one is backed by + * the main-process modules the bridge itself delegates to. Same three calls, + * same flow above them — so `openplc compile` enters the orchestration a Build + * click enters, board resolution and POU preprocessing included, rather than + * reassembling the steps and drifting on them. + */ + +import { CompilerModule } from '@root/backend/editor/compiler' +import { HardwareModule } from '@root/backend/editor/hardware' +import { LibraryManagerModule } from '@root/backend/editor/library-manager' +import type { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import type { CompileProgramTransport } from '@root/middleware/adapters/editor/compile-program-flow' +import type { StlibArchiveDTO } from '@root/middleware/shared/ports/library-port' + +import { createHeadlessCompileBridge, createProgressChannel } from './headless-bridge' + +/** + * `runtime` is null for a compile-only run: with no address the pipeline never + * reaches the upload or status calls, so `compile` needs no credentials and no + * device. + */ +export function createCliCompileTransport(runtime: RuntimeApiClient | null): CompileProgramTransport { + return { + getAvailableBoards: () => new HardwareModule().getAvailableBoards(), + + loadAllLibraries: () => Promise.resolve(new LibraryManagerModule().loadAll()), + + runCompileProgram: (compileArgs, onMessage) => { + // The renderer hands the pipeline a `MessagePortMain`; here a plain + // object satisfies the same narrow contract. Completion is signalled by + // the `closePort` message the flow already watches for, so the channel's + // own close is forwarded as one. + const channel = createProgressChannel({ + onMessage: (message: unknown) => { + if (typeof message === 'object' && message !== null) onMessage({ ...message }) + }, + onClose: () => onMessage({ closePort: true }), + }) + + void new CompilerModule() + .compileProgram(compileArgs, channel, createHeadlessCompileBridge(runtime)) + .catch((error: unknown) => { + onMessage({ logLevel: 'error', message: error instanceof Error ? error.message : String(error) }) + channel.close() + }) + }, + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index dfb627ea9..b2060222b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -51,6 +51,7 @@ const BOOLEAN_FLAGS = [ 'upload-if-needed', 'force-new', 'keep-forces', + 'keep-going', 'all', ] as const @@ -66,7 +67,8 @@ Usage openplc debug list openplc debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch openplc debug close --session | --all [--keep-forces] - openplc debug repl [--session ] + openplc debug repl [--session ] (interactive; needs a terminal) + openplc debug exec [script|-] [--session ] [--keep-going] (one command per line) Credentials (upload, debug) --credentials user:pass, or --user + --password diff --git a/src/cli/session/protocol.ts b/src/cli/session/protocol.ts index 1792e9de4..28763c022 100644 --- a/src/cli/session/protocol.ts +++ b/src/cli/session/protocol.ts @@ -133,7 +133,14 @@ export const SessionStatusSchema = z.object({ /** Whether that MD5 matches the locally compiled artifacts. */ md5Matches: z.boolean(), plcState: PlcStateSchema, - /** Paths this session has forced and not yet released. */ + /** + * Paths this session forced and has not released — what `close` will unforce. + * + * The session's own bookkeeping, because the debug protocol exposes no read + * of the runtime's forced-slot bitmap. It is cleared when this session stops + * the PLC (the runtime drops its forces then); a stop issued from elsewhere + * can leave it reporting a force the runtime has already released. + */ forced: z.array(z.string()), watching: z.array(z.string()), startedAt: z.string(), diff --git a/src/cli/session/session-core.ts b/src/cli/session/session-core.ts index 1e437e814..7398e6d08 100644 --- a/src/cli/session/session-core.ts +++ b/src/cli/session/session-core.ts @@ -56,6 +56,9 @@ export interface SessionCoreOptions { /** Keeps the watch buffer bounded — a long recording must not grow forever. */ const MAX_WATCH_SAMPLES = 5000 +/** How long to wait for an external write to survive a dispatcher drain. */ +const WRITE_SETTLE_TIMEOUT_MS = 1500 +const WRITE_SETTLE_POLL_MS = 50 const MIN_WATCH_INTERVAL_MS = 20 export class SessionCore { @@ -134,6 +137,22 @@ export class SessionCore { if (!result.success) { return this.fail(request.id, ErrorCode.TargetError, result.error ?? `Could not ${request.kind} the PLC`) } + if (request.kind === 'stop') { + // Stopping the program clears the runtime's forces: + // `debug_write_journal_reset()` runs on program unload/stop. Keeping + // this session's list afterwards reports `[FORCED]` on variables the + // program is freely writing again — observed on hardware, where a + // forced BOOL read back as the program's own value while still + // flagged. It also makes `close` try to release forces that no longer + // exist. + // + // A stop this session did not issue (the runtime UI, a mode switch) + // still leaves the list stale: the debug protocol has no read for the + // forced-slot bitmap, so local bookkeeping is the only source there is. + // `status` therefore reports what this session forced and has not + // released, which is what `close` acts on. + this.forced.clear() + } return { id: request.id, ok: true, @@ -245,15 +264,49 @@ export class SessionCore { } if (force) this.forced.add(variable.name.toUpperCase()) - const readBack = await this.readValues([variable]) + const readBack = await this.readBackAfterWrite(variable, input) if ('error' in readBack) return this.fail(id, ErrorCode.NotConnected, readBack.error) - const value = readBack.values[0] ?? { - name: variable.name, - type: variable.type, - value: null, - forced: this.forced.has(variable.name.toUpperCase()), + return { id, ok: true, data: { kind: force ? 'force' : 'write', value: readBack.value } } + } + + /** + * Read a variable back after writing it, waiting for the write to land. + * + * An external write does NOT take effect immediately: the runtime enqueues it + * on the debug-write journal and the dispatcher drains it once per cycle, at + * the no-task-running window. A single read straight after the write therefore + * races the drain and usually returns the OLD value — so `force x 3.5` would + * report `0`, and a test asserting on that reply would fail against a PLC that + * had done exactly what it was told. + * + * Polls briefly for the value to match what was asked, and gives up quietly + * after that: a mismatch is legitimate for a soft `write` the program + * overwrites on the next scan, so a timeout here is not an error. + */ + private async readBackAfterWrite( + variable: ResolvedVariable, + requested: string, + ): Promise<{ value: VariableValue } | { error: string }> { + const deadline = this.now() + WRITE_SETTLE_TIMEOUT_MS + let last: VariableValue | undefined + + for (;;) { + const result = await this.readValues([variable]) + if ('error' in result) return { error: result.error } + last = result.values[0] + if (last && valueMatchesRequest(last, requested)) break + if (this.now() >= deadline) break + await delay(WRITE_SETTLE_POLL_MS) + } + + return { + value: last ?? { + name: variable.name, + type: variable.type, + value: null, + forced: this.forced.has(variable.name.toUpperCase()), + }, } - return { id, ok: true, data: { kind: force ? 'force' : 'write', value } } } private async applyUnforce(id: number, name: string): Promise { @@ -268,6 +321,8 @@ export class SessionCore { } this.forced.delete(variable.name.toUpperCase()) + // No expected value to wait for: unforcing hands the variable back to the + // program, so whatever it reads next is legitimate. const readBack = await this.readValues([variable]) if ('error' in readBack) return this.fail(id, ErrorCode.NotConnected, readBack.error) const value = readBack.values[0] ?? { name: variable.name, type: variable.type, value: null, forced: false } @@ -432,3 +487,31 @@ export function channelPlcControl(channel: DeviceDebugChannel): PlcControl { }, } } + +/** + * Did a read-back land on what the caller asked for? + * + * Compared as text after normalising, because the request is a user string + * (`TRUE`, `3.5`, `16#FF`) and the reply is a typed value. Exact float equality + * is deliberately avoided: `3.5` written to a REAL reads back as `3.5`, but a + * value the target rounds would spin the poll for its whole timeout. + */ +function valueMatchesRequest(value: VariableValue, requested: string): boolean { + const wanted = requested.trim().toUpperCase() + if (typeof value.value === 'boolean') { + const asTrue = wanted === 'TRUE' || wanted === '1' + const asFalse = wanted === 'FALSE' || wanted === '0' + if (!asTrue && !asFalse) return true + return value.value === asTrue + } + if (typeof value.value === 'number') { + const parsed = Number(wanted.replace(/^16#/, '0x')) + return Number.isNaN(parsed) ? true : Math.abs(value.value - parsed) < 1e-6 + } + if (typeof value.value === 'string') return value.value.toUpperCase() === wanted + return true +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/middleware/adapters/editor/compile-program-flow.ts b/src/middleware/adapters/editor/compile-program-flow.ts new file mode 100644 index 000000000..25c5f4602 --- /dev/null +++ b/src/middleware/adapters/editor/compile-program-flow.ts @@ -0,0 +1,162 @@ +/** + * The build flow a Build / Build & Upload click starts. + * + * This is the orchestration that used to live inside the editor's + * `CompilerPort.compileProgram`: resolve the board, graft library-supplied C++ + * blocks in, preprocess POUs, convert to the IPC/schema shape, then drive the + * compile pipeline and translate its message stream into `CompileProgressEvent`s. + * + * It is a standalone function taking a `CompileProgramTransport` so the SAME + * flow runs from two front ends: the renderer adapter supplies a transport + * backed by `window.bridge`, and the headless CLI supplies one backed by the + * main-process modules directly. A CLI command therefore kicks off exactly the + * sequence the button click does — board resolution included — instead of + * reassembling the steps and drifting on the details (which core the board + * declares, whether a POU needs preprocessing, what shape the pipeline expects). + */ + +import { preprocessPous } from '../../../backend/shared/utils/PLC/preprocess-pous' +import type { CompileProgramArgs } from '../../shared/ports/compiler-port' +import type { StlibArchiveDTO } from '../../shared/ports/library-port' +import type { BoardInfo, CompileProgressEvent, CompileResult } from '../../shared/ports/types' +import { decodeMessage, inferStage, injectLibraryCppBlocks, toIpcProjectData } from './compiler-adapter' + +/** + * What the flow needs from its platform. Three calls, deliberately: anything + * more and the flow would be describing a platform rather than a build. + */ +export interface CompileProgramTransport { + /** Board catalogue — hals.json entries plus installed VPP packages. */ + getAvailableBoards: () => Promise> + /** Every installed library archive, for the C++-block graft. */ + loadAllLibraries: () => Promise + /** + * Start the compile pipeline and stream its messages back. Fire-and-forget: + * completion is signalled by a `closePort` message, not by resolution. + */ + runCompileProgram: ( + compileArgs: Array, + onMessage: (data: Record) => void, + ) => void +} + +export async function compileProgramFlow( + args: CompileProgramArgs, + transport: CompileProgramTransport, + onProgress: (event: CompileProgressEvent) => void, +): Promise { + const boards = await transport.getAvailableBoards() + const boardInfo = boards.get(args.boardTarget) + const boardCore = boardInfo?.core ?? null + const isSimulator = args.isSimulator ?? boardInfo?.compiler === 'simulator' + + // Graft library-supplied C++ blocks into the project's POU + // list before preprocessing. They behave like user-defined + // C++ POUs from this point on — same `preprocessPous` branch, + // same `c_blocks.h` / `c_blocks_code.cpp` generation + // downstream. See `injectLibraryCppBlocks` for the renaming + // contract. + const archives = await transport.loadAllLibraries() + const dataWithLibCpp = injectLibraryCppBlocks(args.projectData, archives) + + // Preprocess POUs (comment wrapping, Python->ST stubs, C++ validation/ST generation) + const { projectData: processedData, validationFailed } = preprocessPous( + dataWithLibCpp, + isSimulator, + (level, message) => { + onProgress({ stage: 'st', message, level }) + }, + ) + + if (validationFailed) { + return { + success: false, + error: 'POU validation failed. Check C/C++ code for missing setup()/loop() functions.', + } + } + + const ipcData = toIpcProjectData(processedData) + + return new Promise((resolve) => { + let hasError = false + let lastError = '' + let hexPath: string | undefined + let settled = false + + transport.runCompileProgram( + [ + args.projectPath, + args.boardTarget, + boardCore, + args.compileOnly ?? false, + ipcData as never, + args.runtimeIpAddress ?? null, + args.runtimeJwtToken ?? null, + args.cleanBuild ?? false, + args.communicationPort ?? null, + // User-authored configuration-screen data — threaded + // through to the shared compile pipeline so it can emit + // `vpp_config.h` for arduino-cli VPP boards (Arduino + // Opta, P1AM). The pipeline gates emission on the + // board's resolved `vppIo` capability; non-VPP boards + // ignore this argument and the field is a no-op. + args.vendorScreenData ?? null, + ], + (data: Record) => { + // Extract simulator firmware path BEFORE the closePort early return, + // because the backend sends both fields in the same message. + if (data.simulatorFirmwarePath) { + hexPath = data.simulatorFirmwarePath as string + onProgress({ stage: 'done', message: 'Simulator firmware ready', firmwarePath: hexPath }) + } + + if (data.closePort) { + if (settled) return + settled = true + if (!hasError) { + onProgress({ stage: 'done', message: 'Compilation complete' }) + } + resolve( + hasError + ? { success: false, error: lastError } + : { success: true, message: 'Compilation complete', hexPath }, + ) + return + } + + // Forward plcStatus for runtime status updates + if (data.plcStatus) { + onProgress({ stage: 'arduino', message: '', plcStatus: data.plcStatus as string }) + } + + if (data.message) { + const message = decodeMessage(data.message) + // Structured CompileError travels alongside the formatted + // text whenever the compiler-module's strucpp failure + // path emits a per-error log entry. Forward it as-is + // so the console can drive click-to-open from the + // structured fields rather than parsing text. + const compileError = data.compileError as CompileProgressEvent['compileError'] | undefined + + if (data.logLevel === 'error') { + hasError = true + lastError = message + onProgress({ + stage: 'error', + message, + level: 'error', + ...(compileError ? { compileError } : {}), + }) + } else { + onProgress({ + stage: inferStage(message), + message, + level: (data.logLevel as string) ?? 'info', + ...(compileError ? { compileError } : {}), + }) + } + } + }, + ) + }) +} diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index b2ca8a799..92c6ddf00 100644 --- a/src/middleware/adapters/editor/compiler-adapter.ts +++ b/src/middleware/adapters/editor/compiler-adapter.ts @@ -30,6 +30,7 @@ import type { PLCVariable, Result, } from '../../shared/ports/types' +import { compileProgramFlow } from './compile-program-flow' /** * Shape of the project data expected by the editor's IPC bridge. @@ -181,124 +182,28 @@ function inferStage(message: string): CompileProgressEvent['stage'] { export function createEditorCompilerAdapter(): CompilerPort { return { - async compileProgram( + /** + * The Build / Build & Upload flow. + * + * The orchestration lives in `compileProgramFlow` so the headless CLI enters + * the same sequence through its own transport — board resolution, the C++ + * block graft, POU preprocessing and the pipeline call are shared, not + * restated per front end. + */ + compileProgram( args: CompileProgramArgs, onProgress: (event: CompileProgressEvent) => void, ): Promise { - const boards = await window.bridge.getAvailableBoards() - const boardInfo = boards.get(args.boardTarget) - const boardCore = boardInfo?.core ?? null - const isSimulator = args.isSimulator ?? boardInfo?.compiler === 'simulator' - - // Graft library-supplied C++ blocks into the project's POU - // list before preprocessing. They behave like user-defined - // C++ POUs from this point on — same `preprocessPous` branch, - // same `c_blocks.h` / `c_blocks_code.cpp` generation - // downstream. See `injectLibraryCppBlocks` for the renaming - // contract. - const archives = (await window.bridge.loadAllLibraries()) as StlibArchiveDTO[] - const dataWithLibCpp = injectLibraryCppBlocks(args.projectData, archives) - - // Preprocess POUs (comment wrapping, Python->ST stubs, C++ validation/ST generation) - const { projectData: processedData, validationFailed } = preprocessPous( - dataWithLibCpp, - isSimulator, - (level, message) => { - onProgress({ stage: 'st', message, level }) + return compileProgramFlow( + args, + { + getAvailableBoards: () => window.bridge.getAvailableBoards(), + loadAllLibraries: async () => (await window.bridge.loadAllLibraries()) as StlibArchiveDTO[], + runCompileProgram: (compileArgs, onMessage) => + window.bridge.runCompileProgram(compileArgs as never, onMessage), }, + onProgress, ) - - if (validationFailed) { - return { - success: false, - error: 'POU validation failed. Check C/C++ code for missing setup()/loop() functions.', - } - } - - const ipcData = toIpcProjectData(processedData) - - return new Promise((resolve) => { - let hasError = false - let lastError = '' - let hexPath: string | undefined - let settled = false - - window.bridge.runCompileProgram( - [ - args.projectPath, - args.boardTarget, - boardCore, - args.compileOnly ?? false, - ipcData as never, - args.runtimeIpAddress ?? null, - args.runtimeJwtToken ?? null, - args.cleanBuild ?? false, - args.communicationPort ?? null, - // User-authored configuration-screen data — threaded - // through to the shared compile pipeline so it can emit - // `vpp_config.h` for arduino-cli VPP boards (Arduino - // Opta, P1AM). The pipeline gates emission on the - // board's resolved `vppIo` capability; non-VPP boards - // ignore this argument and the field is a no-op. - args.vendorScreenData ?? null, - ], - (data: Record) => { - // Extract simulator firmware path BEFORE the closePort early return, - // because the backend sends both fields in the same message. - if (data.simulatorFirmwarePath) { - hexPath = data.simulatorFirmwarePath as string - onProgress({ stage: 'done', message: 'Simulator firmware ready', firmwarePath: hexPath }) - } - - if (data.closePort) { - if (settled) return - settled = true - if (!hasError) { - onProgress({ stage: 'done', message: 'Compilation complete' }) - } - resolve( - hasError - ? { success: false, error: lastError } - : { success: true, message: 'Compilation complete', hexPath }, - ) - return - } - - // Forward plcStatus for runtime status updates - if (data.plcStatus) { - onProgress({ stage: 'arduino', message: '', plcStatus: data.plcStatus as string }) - } - - if (data.message) { - const message = decodeMessage(data.message) - // Structured CompileError travels alongside the formatted - // text whenever the compiler-module's strucpp failure - // path emits a per-error log entry. Forward it as-is - // so the console can drive click-to-open from the - // structured fields rather than parsing text. - const compileError = data.compileError as CompileProgressEvent['compileError'] | undefined - - if (data.logLevel === 'error') { - hasError = true - lastError = message - onProgress({ - stage: 'error', - message, - level: 'error', - ...(compileError ? { compileError } : {}), - }) - } else { - onProgress({ - stage: inferStage(message), - message, - level: (data.logLevel as string) ?? 'info', - ...(compileError ? { compileError } : {}), - }) - } - } - }, - ) - }) }, async compileForDebug( From 343b34c18b6378e366e6c7925d237f61256aa9fc Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 17:10:17 -0400 Subject: [PATCH 07/25] style(compile): format the pipeline's new bundle-write block Co-Authored-By: Claude Opus 5 (1M context) --- src/backend/shared/compile/pipeline.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index e43f9d26c..c13566bf9 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -592,12 +592,14 @@ async function runCompilePipelineInner( // only ever reached disk as a side effect of the upload, and a compile-only // v4 build left a build folder holding nothing but the VPP files. if (port.materializeRuntimeV4Bundle) { - const materialized = await port.materializeRuntimeV4Bundle( - { bundle }, - makePlatformLog(emit, 'runtime-v4-bundle'), - ) + const materialized = await port.materializeRuntimeV4Bundle({ bundle }, makePlatformLog(emit, 'runtime-v4-bundle')) if (materialized.errors && materialized.errors.length > 0) { - return bailError(emit, 'runtime-v4-bundle', 'Could not write the Runtime v4 build artifacts.', materialized.errors) + return bailError( + emit, + 'runtime-v4-bundle', + 'Could not write the Runtime v4 build artifacts.', + materialized.errors, + ) } emit({ stage: 'runtime-v4-bundle', From 687d576c75f313f7922a860c77e8103e1033e239 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 18:07:01 -0400 Subject: [PATCH 08/25] fix(cli): stop deciding things the target already declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three drifts, one cause — the CLI holding an opinion the editor already has. **Debug transport was hardcoded to the v4 WebSocket.** It worked against a runtime v4 and silently made every baremetal board undebuggable from the terminal, though the editor debugs them fine. Transport is a property of the TARGET: it lives in `hals.json` / VPP manifests and is read by `resolveRuntimeDebugChannel`, with `toDebugCandidate` building the channel. `debug open` now calls both, so Modbus RTU, Modbus TCP, the v4 WebSocket and the in-process simulator all come from the same resolution the GUI uses. `toDebugCandidate` / `toDeviceLinkCandidates` are extracted out of `MainProcessBridge` (which now delegates) with the simulator's virtual serial port injected, since that belongs to whoever hosts the emulator. **Variable identity was the raw debug-map path.** It is now the COMPOSITE KEY the GUI shows — `main:SL1_AO1`, from `buildDebugVariableTreeMap` + `deriveVariableIndexMap`. A test asserting on names nobody sees in the editor was the wrong contract. Raw paths still resolve, because `deriveVariableIndexMap` keys them as a fallback for leaves the tree does not surface. **Build did not warn like the GUI.** Targets that build on the device now refuse while the PLC is RUNNING, with the same reasoning the editor's dialog gives, and `--yes` / `-y` approves stopping it first the way `apt -y` does. Silently halting someone's running PLC is not a default. Making the editor's resolvers usable meant hydrating the SHARED store rather than a private instance: `buildDeviceResolverContext` and the debug tree builder both read `useOpenPLCStore.getState()`. `loadProject` now seeds `availableBoards` too, exactly as the workspace screen does on load, and the daemon hydrates before resolving. One project per process is the assumption the editor already makes. Also keeps forced names in their canonical casing, so `status` reports `main:SL1_AO1` and not an uppercased form. Verified on the SLM-RP4: channel resolved from the spec ("Opening the debug channel (websocket 192.168.2.4)"), reads and forces by composite key, raw-path fallback, upload refused while RUNNING (exit 7) and accepted with -y after stopping the PLC. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/hardware/debug-channel-factory.ts | 141 +++++++++++ src/cli/args.ts | 7 + src/cli/commands/build.ts | 74 +++++- src/cli/debug/open-session.ts | 237 +++++++++--------- src/cli/debug/variables.ts | 74 +++++- src/cli/main.ts | 7 +- src/cli/project/load.ts | 25 +- src/cli/session/daemon-main.ts | 30 ++- src/cli/session/session-core.ts | 33 ++- src/main/modules/ipc/main.ts | 114 ++------- 10 files changed, 494 insertions(+), 248 deletions(-) create mode 100644 src/backend/editor/hardware/debug-channel-factory.ts diff --git a/src/backend/editor/hardware/debug-channel-factory.ts b/src/backend/editor/hardware/debug-channel-factory.ts new file mode 100644 index 000000000..fc2ba09e3 --- /dev/null +++ b/src/backend/editor/hardware/debug-channel-factory.ts @@ -0,0 +1,141 @@ +/** + * Turning a resolved debug-channel config into something openable. + * + * Extracted out of `MainProcessBridge` so the headless CLI opens debug sessions + * the same way the editor does: every transport a target can declare — Modbus + * RTU over serial, Modbus TCP, the runtime-v4 debug WebSocket, the in-process + * simulator — is built here, chosen from the board's declarative `debug` spec + * rather than assumed by the caller. + * + * That is the whole point. The CLI briefly hardcoded the v4 WebSocket, which + * silently made every baremetal target undebuggable from the terminal even + * though the editor debugs them fine. Transport selection is a property of the + * TARGET, and it already lives in data (`hals.json` / VPP manifests) plus the + * resolver that reads it; a second opinion in a front end can only be wrong. + */ + +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' +import { describeDebugEndpoint } from '@root/middleware/shared/utils/debug-endpoint' + +import { WebSocketDebugTransport } from '../../shared/debug/websocket-debug-transport' +import { planBaudAttempts } from './device-probe' +import type { DeviceDebugCandidate, DeviceLinkCandidate } from './device-session-manager' +import { buildDeviceModbusTransport, modbusTransportKind } from './device-transport-factory' + +/** The runtime's HTTPS port, shared by its REST API and its debug WebSocket. */ +const RUNTIME_DEBUG_PORT = 8443 + +export interface DebugChannelFactoryDeps { + /** + * Builds the in-process serial port the simulator answers on. Injected because + * the simulator instance belongs to whoever is hosting it — the main process + * has one for the GUI, and a CLI session has its own. + * + * Omit it and simulator configs are simply not built, which is the right + * outcome for a caller that cannot host an emulator. + */ + createVirtualSerialPort?: () => object +} + +/** + * Turn a resolved channel config into something the link manager can try. + * The only transport-specific step left in the flow; a config that names a + * transport this build cannot speak is dropped rather than half-built. + */ +export function toDeviceLinkCandidates( + configs: DebugConnectionConfig[], + opts: { probeBaudRates?: boolean } = {}, + deps: DebugChannelFactoryDeps = {}, +): DeviceLinkCandidate[] { + const declared: DeviceLinkCandidate[] = [] + // Baud guesses go AFTER everything the project declared: a configured Modbus + // TCP address is a better next try than a rate nobody asked for. + const speculative: DeviceLinkCandidate[] = [] + + const build = (config: DebugConnectionConfig, baudRate: number | undefined, isGuess: boolean): void => { + const kind = modbusTransportKind(config.connectionType) + if (kind === null) return + const params = { + connectionType: config.connectionType, + port: config.connectionParams.port, + baudRate, + slaveId: config.connectionParams.slaveId, + host: config.connectionParams.ipAddress, + } + // Only the simulator needs an in-process serial port; building one for a real + // transport would allocate a virtual port nobody reads. + const options = + kind === 'simulator' && deps.createVirtualSerialPort ? { virtualSerialPort: deps.createVirtualSerialPort() } : {} + // A simulator config with no host to run it is not buildable; dropping it + // beats returning a candidate whose `create()` always throws. + if (kind === 'simulator' && !deps.createVirtualSerialPort) return + // Probe the params now so a malformed config fails resolution rather than + // becoming a candidate that always throws on `create()`. + if ('error' in buildDeviceModbusTransport(params, options)) return + ;(isGuess ? speculative : declared).push({ + transport: kind, + // The endpoint ONLY. It is matched against the OS port list and against + // the port an upload asks to borrow, so the baud travels beside it rather + // than inside it — decorating this string made every swept candidate match + // no port and be skipped in 1ms. + descriptor: describeDebugEndpoint(config), + baudRate, + speculative: isGuess, + create: () => { + const built = buildDeviceModbusTransport(params, options) + if ('error' in built) throw new Error(built.error) + return built.client + }, + }) + } + + for (const config of configs) { + // A wrong baud is the one misconfiguration that looks like healthy silence: + // the port opens, so it is not "no response", and nothing decodes, so it + // reads as "no firmware" — and the user gets told to reflash a board that is + // running fine. Sweeping the rates OpenPLC is ever built with turns that dead + // end into a connection. Serial only; a TCP address is either right or not. + for (const attempt of planBaudAttempts(config.connectionParams.baudRate, { sweep: opts.probeBaudRates })) { + build(config, attempt.baudRate, attempt.speculative) + } + } + + // The patient budget belongs to the last DECLARED endpoint, not to the last + // candidate overall. Without this the baud sweep would silently take that + // patience away from the configured endpoint and hand it to a guess — and a + // board that was just flashed, still booting on the right rate, would be ruled + // out in ~10s instead of the ~32s it sometimes needs. + const lastDeclared = declared[declared.length - 1] + if (lastDeclared) lastDeclared.patient = true + + return [...declared, ...speculative] +} + +/** + * Turn a resolved channel config into an openable DEBUG channel. The one place + * that knows a WebSocket is a debug channel too. + */ +export function toDebugCandidate( + config: DebugConnectionConfig, + deps: DebugChannelFactoryDeps = {}, +): DeviceDebugCandidate | null { + if (config.connectionType === 'websocket') { + const host = config.connectionParams.ipAddress + const token = config.connectionParams.jwtToken + if (!host || !token) return null + return { + transport: 'websocket', + descriptor: `websocket ${host}`, + create: () => new WebSocketDebugTransport({ host, port: RUNTIME_DEBUG_PORT, token, rejectUnauthorized: false }), + } + } + // One config in, one candidate out: this builds the DEBUG channel for a + // session that already exists, so the rate is settled and guessing is wrong. + const [candidate] = toDeviceLinkCandidates([config], { probeBaudRates: false }, deps) + if (!candidate) return null + return { + transport: candidate.transport, + descriptor: `${candidate.transport} ${candidate.descriptor}`, + create: candidate.create, + } +} diff --git a/src/cli/args.ts b/src/cli/args.ts index b76f88456..f4ee48f9e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -76,6 +76,13 @@ export function parseArgs(argv: readonly string[], options: ParseOptions = {}): continue } + // `-y` is the one short flag, because the confirmation prompt it answers is + // the one people type most and `apt -y` set the expectation. + if (token === '-y') { + setFlag(flags, 'yes', true) + continue + } + if (!token.startsWith('--')) { positionals.push(token) continue diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index b1c25d90f..ed50e279e 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -19,8 +19,8 @@ import type { CompileProgressEvent } from '@root/middleware/shared/ports/types' import { boolFlag, type ParsedArgs, stringFlag } from '../args' import { createCliCompileTransport } from '../compile/cli-transport' -import { ErrorCode, ExitCode } from '../exit-codes' -import type { CliResult, Reporter } from '../output' +import { ErrorCode, ExitCode, type ExitCodeValue } from '../exit-codes' +import type { CliFailure, CliResult, Reporter } from '../output' import { loadProject } from '../project/load' export interface BuildOptions { @@ -83,6 +83,24 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu } } + // The same gate the GUI's build puts up. Targets that are not flashed over USB + // run the FINAL build step ON the device, and doing that while the PLC is + // scanning can stall the build or make the running program miss deadlines. The + // GUI asks "Stop PLC and Continue?"; a CLI cannot ask, so it refuses and names + // the flag — silently stopping someone's running PLC is not a default. + if (runtime && host) { + const gate = await ensurePlcStoppedForBuild({ + runtime, + host, + target, + autoApprove: boolFlag(args, 'yes'), + reporter, + }) + if ('error' in gate) { + return reporter.failure(gate.error, gate.exitCode) + } + } + reporter.progress(`${options.withUpload ? 'Building and uploading' : 'Building'} "${project.name}" for ${target}…`) let streamedError = false @@ -159,3 +177,55 @@ export function resolveCredentials(args: ParsedArgs): { username: string; passwo } return { username, password } } + +/** + * Stop the PLC before a build that runs on the device, or refuse. + * + * Mirrors the GUI's pre-build gate: when the target is reached through a runtime + * (rather than flashed over USB) and that runtime is RUNNING, the editor warns + * and stops it on the user's consent. `--yes` is that consent, the way `apt -y` + * is; without it the build stops and says so, because a scripted run must not + * silently halt a live PLC. + */ +async function ensurePlcStoppedForBuild(input: { + runtime: RuntimeApiClient + host: string + target: string + autoApprove: boolean + reporter: Reporter +}): Promise<{ ok: true } | { error: CliFailure; exitCode: ExitCodeValue }> { + const status = await input.runtime.getStatus(input.host) + // Unknown state is not a reason to block: the build's own upload step reports + // a target it cannot reach far better than a pre-flight guess does. + if (!status.success) return { ok: true } + if (!(status.status ?? '').toUpperCase().includes('RUNNING')) return { ok: true } + + if (!input.autoApprove) { + return { + error: { + code: ErrorCode.TargetError, + message: + `The PLC on ${input.host} is RUNNING and "${input.target}" builds on the device, which can stall ` + + 'the build or make the running program miss scan deadlines. Stop it first, or pass --yes to have ' + + 'this command stop it for you.', + }, + exitCode: ExitCode.TargetError, + } + } + + input.reporter.progress('--yes given: stopping the PLC before the build…') + const stopped = await input.runtime.setPlcState(input.host, 'stop') + if (!stopped.success) { + return { + error: { + code: ErrorCode.TargetError, + message: stopped.refusedBySwitch + ? 'The PLC refused to stop: its physical mode switch is in RUN. Move it to STOP and retry.' + : `Could not stop the PLC before building: ${stopped.error ?? 'unknown error'}`, + }, + exitCode: ExitCode.TargetError, + } + } + input.reporter.progress('PLC stopped before build.') + return { ok: true } +} diff --git a/src/cli/debug/open-session.ts b/src/cli/debug/open-session.ts index 16126f778..e5e309d03 100644 --- a/src/cli/debug/open-session.ts +++ b/src/cli/debug/open-session.ts @@ -1,81 +1,132 @@ /** - * Opening a debug session against a real target. + * Opening a debug session — for whatever transport the target declares. * - * Establishes the same channel the GUI does — for a Runtime v3/v4 that means a - * REST login for control plus the debug WebSocket for variables, built from the - * same `WebSocketDebugTransport` the editor's main process instantiates. + * The channel is NOT chosen here. `resolveRuntimeDebugChannel` reads the + * board's declarative `debug` spec (from `hals.json` or its VPP manifest) and + * answers with a `DebugConnectionConfig`; `toDebugCandidate` builds the matching + * channel — Modbus RTU over serial, Modbus TCP, the runtime-v4 WebSocket, or the + * in-process simulator. Both are the editor's own, so a target the GUI can debug + * is a target the CLI can debug, and neither has an opinion the other lacks. * - * The MD5 gate is the important part. The debug map addresses variables by - * (arr, elem) positions that are only meaningful for the exact program that was + * An earlier version constructed a `WebSocketDebugTransport` directly. It worked + * against a runtime v4 and silently made every baremetal board undebuggable from + * the terminal — the exact drift that comes from a front end deciding something + * the target already declares. + * + * The MD5 gate is the other load-bearing part. The debug map addresses variables + * by (arr, elem) positions that mean something only for the program that was * compiled; pointing them at a target running something else does not fail - * loudly, it reads the WRONG VARIABLES and reports plausible numbers. So a - * mismatch aborts unless the caller asked for an upload, and after uploading it - * is re-verified rather than assumed. + * loudly, it reads the WRONG VARIABLES and reports plausible numbers. */ -import { RUNTIME_API_PORT, RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import { toDebugCandidate } from '@root/backend/editor/hardware/debug-channel-factory' +import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' import type { DeviceDebugChannel } from '@root/backend/shared/debug/types' -import { WebSocketDebugTransport } from '@root/backend/shared/debug/websocket-debug-transport' +import { resolveRuntimeDebugChannel } from '@root/frontend/services/device-link-resolution' +import { openPLCStoreBase } from '@root/frontend/store' import { DEBUG_MEDIUM_PROFILE } from '@root/frontend/utils/debug-medium-profile' import type { TargetEndian } from '@root/frontend/utils/endian' +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { restPlcControl, SessionCore } from '../session/session-core' +import { channelPlcControl, restPlcControl, SessionCore } from '../session/session-core' import { loadDebugIndex } from './variables' export interface OpenSessionOptions { sessionId: string projectPath: string target: string - host: string - username: string - password: string - /** - * Called when the target's program does not match the local build. Returning - * true means "an upload happened, re-verify"; false aborts. - */ - onMd5Mismatch?: (details: { targetMd5: string | null; localMd5: string }) => Promise + /** Runtime address. Optional: a baremetal target needs none. */ + host?: string + username?: string + password?: string onProgress?: (message: string) => void } export type OpenSessionResult = - | { success: true; core: SessionCore; runtime: RuntimeApiClient; programMd5: string | null } - | { success: false; code: 'auth' | 'connection' | 'md5' | 'not-compiled'; error: string } + | { success: true; core: SessionCore; programMd5: string | null } + | { success: false; code: 'auth' | 'connection' | 'md5' | 'not-compiled' | 'unsupported'; error: string } -export async function openRuntimeSession(options: OpenSessionOptions): Promise { +export async function openDebugSession(options: OpenSessionOptions): Promise { const progress = options.onProgress ?? (() => undefined) const indexResult = await loadDebugIndex(options.projectPath, options.target) - if (!indexResult.success) { - return { success: false, code: 'not-compiled', error: indexResult.error } - } + if (!indexResult.success) return { success: false, code: 'not-compiled', error: indexResult.error } const index = indexResult.index + // The tree walk reports variables it could not resolve (unknown datatypes, + // library FBs without externals). Silence there would look like a variable + // that simply does not exist. + for (const warning of index.warnings) progress(`warning: ${warning}`) + + const boards = openPLCStoreBase.getState().deviceAvailableOptions.availableBoards + const boardInfo: BoardInfo | undefined = boards.get(options.target) + if (!boardInfo) { + return { + success: false, + code: 'unsupported', + error: `Board "${options.target}" is not available (not in hals.json, and no installed VPP package declares it)`, + } + } - progress(`Authenticating with ${options.host}…`) - const runtime = new RuntimeApiClient() - const login = await runtime.login(options.host, options.username, options.password) - if (!login.success) { - return { success: false, code: 'auth', error: login.error ?? 'Runtime rejected the credentials' } + const capabilities = resolveTargetCapabilities(boardInfo) + + // Targets controlled over REST need an authenticated session before the debug + // spec can even resolve: its preconditions include `runtimeConnected` and + // `jwtToken`, which is how the resolver knows a v4 WebSocket is reachable. + let runtime: RuntimeApiClient | null = null + // `directUsbUpload` is the editor's own discriminator between a board it + // flashes over USB and a target it reaches through a runtime API — the same + // check the build flow's stop-PLC gate uses. + if (!capabilities.directUsbUpload && !capabilities.isInProcessSimulator) { + if (!options.host || !options.username || !options.password) { + return { + success: false, + code: 'auth', + error: `Target "${options.target}" is controlled over its runtime API — pass --host and credentials`, + } + } + progress(`Authenticating with ${options.host}…`) + runtime = new RuntimeApiClient() + const login = await runtime.login(options.host, options.username, options.password) + if (!login.success) { + return { success: false, code: 'auth', error: login.error ?? 'The runtime rejected the credentials' } + } + // The same store updates the login modal makes, because the resolver reads + // the connection state from the store rather than being told. + const deviceActions = openPLCStoreBase.getState().deviceActions + deviceActions.setRuntimeJwtToken(login.accessToken ?? '') + deviceActions.setRuntimeConnectionStatus('connected') + deviceActions.setStoredCredentials({ username: options.username, password: options.password }) } - const token = runtime.tokens.getToken() - /* istanbul ignore if -- a successful login always yields a token */ - if (!token) return { success: false, code: 'auth', error: 'Login succeeded but produced no token' } + progress('Resolving the debug channel from the target’s spec…') + const config = resolveRuntimeDebugChannel(options.target, boardInfo) + if (!config) { + return { + success: false, + code: 'unsupported', + error: `Could not resolve a debug channel for "${options.target}" — the target declares none this build can open`, + } + } - progress('Opening the debug channel…') - const channel: DeviceDebugChannel = new WebSocketDebugTransport({ - host: options.host, - port: RUNTIME_API_PORT, - token, - rejectUnauthorized: false, - }) + const candidate = toDebugCandidate(config) + if (!candidate) { + return { + success: false, + code: 'unsupported', + error: `The debug channel "${config.connectionType}" for "${options.target}" cannot be opened headlessly`, + } + } + progress(`Opening the debug channel (${candidate.descriptor})…`) + const channel: DeviceDebugChannel = candidate.create() try { await channel.connect() } catch (error) { return { success: false, code: 'connection', - error: `Could not open the debug channel: ${error instanceof Error ? error.message : String(error)}`, + error: `Could not open ${candidate.descriptor}: ${error instanceof Error ? error.message : String(error)}`, } } @@ -87,60 +138,49 @@ export async function openRuntimeSession(options: OpenSessionOptions): Promise { - const deadline = Date.now() + 30_000 - let last: string | null = null - while (Date.now() < deadline) { - try { - const probe = await channel.getMd5Hash() - last = probe.md5 - if (md5Matches(probe.md5, expected)) return { ok: true, md5: probe.md5 } - } catch { - // The debug socket drops while the runtime reloads the program; keep - // trying until the deadline rather than treating the first gap as fatal. - } - await sleep(1000) - } - return { - ok: false, - error: `Uploaded, but the target still reports a different program (target ${last ?? 'unknown'}, local ${expected})`, - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -/** - * Why a target with no debug surface is unreachable, when the runtime can say. - * - * Returns undefined when the PLC looks fine, so the caller keeps the original - * transport error rather than replacing a real fault with a guess. - */ -function describeStoppedTarget(status: { - success: boolean - status?: string - switchPosition?: string -}): string | undefined { +/** Why a target with no debug surface is unreachable, when the runtime can say. */ +async function describeStoppedTarget(runtime: RuntimeApiClient, host: string): Promise { + const status = await runtime.getStatus(host) if (!status.success) return undefined - const running = (status.status ?? '').toUpperCase().includes('RUNNING') - if (running) return undefined + if ((status.status ?? '').toUpperCase().includes('RUNNING')) return undefined if ((status.switchPosition ?? '').toLowerCase() === 'stop') { return ( 'The PLC is stopped and its physical mode switch is in STOP, so no program is scanning and ' + @@ -198,6 +201,6 @@ function describeStoppedTarget(status: { } return ( 'The PLC is stopped, so no program is scanning and the debug interface has nothing to serve. ' + - 'Start it (`openplc debug start`, or the runtime UI) and retry.' + 'Start it (`openplc debug start`) and retry.' ) } diff --git a/src/cli/debug/variables.ts b/src/cli/debug/variables.ts index d1c5074a5..272326039 100644 --- a/src/cli/debug/variables.ts +++ b/src/cli/debug/variables.ts @@ -18,6 +18,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' +import { openPLCStoreBase } from '@root/frontend/store' import { buildLeafInfoMap, type DebugLeafInfo, @@ -26,6 +27,11 @@ import { parseDebugMap, } from '@root/frontend/utils/debug-parser' import { walkDebugResponse } from '@root/frontend/utils/debug-response-walker' +import { + buildDebugVariableTreeMap, + debugMapToEntries, + deriveVariableIndexMap, +} from '@root/frontend/utils/debugger-session' import type { TargetEndian } from '@root/frontend/utils/endian' import { encodeForceValue } from '@root/frontend/utils/variable-sizes' @@ -42,6 +48,8 @@ export interface ResolvedVariable extends DebugLeafInfo { export interface DebugVariableIndex { /** MD5 of the compiled program this map belongs to. */ md5: string + /** Anything the tree walk could not resolve, surfaced by `debug open`. */ + warnings: string[] /** Canonical order, as the compiler emitted it. */ all: ResolvedVariable[] /** UPPERCASE path → variable. */ @@ -80,28 +88,70 @@ export async function loadDebugIndex(projectPath: string, boardTarget: string): return { success: true, index: indexDebugMap(map) } } +/** + * Index the debug map the way the editor's Debug button does. + * + * Identity is the COMPOSITE KEY (`main:counter`, `main:pid0.output`) that + * `buildDebugVariableTreeMap` mints and `deriveVariableIndexMap` maps to a + * packed address — the same names the watch panel, the ladder view and the FBD + * view use. Addressing variables by raw `debug-map.json` paths instead would + * mean a test asserting on names nobody sees in the GUI. + * + * `deriveVariableIndexMap` also keys every leaf by its raw path as a fallback, + * so library-FB internals the tree does not surface stay reachable. Both forms + * therefore resolve, with the composite key as the primary. + * + * Type and byte width come from the leaf at each address — straight from the + * compiler, never from the stored project model, which can drift from the + * compiled layout. + */ export function indexDebugMap(map: DebugMap): DebugVariableIndex { const leafInfo = buildLeafInfoMap(map) + const byPackedIndex = new Map() + for (const leaf of map.leaves) { + const packed = packDebugAddr({ arrayIdx: leaf.arrayIdx, elemIdx: leaf.elemIdx }) + if (!byPackedIndex.has(packed)) { + byPackedIndex.set( + packed, + leafInfo.get(leaf.path.toUpperCase()) ?? { + arr: leaf.arrayIdx, + elem: leaf.elemIdx, + type: leaf.type, + size: leaf.size, + }, + ) + } + } + + // The editor's own tree walk, off the hydrated store — same POUs, instances, + // datatypes and system libraries the GUI passes. + const state = openPLCStoreBase.getState() + const { treeMap, warnings } = buildDebugVariableTreeMap( + state.project.data.pous, + state.project.data.configurations.resource.instances, + debugMapToEntries(map), + state.project.data, + state.libraries.system, + ) + const nameToIndex = deriveVariableIndexMap(treeMap, map) + const all: ResolvedVariable[] = [] const byName = new Map() const byIndex = new Map() - for (const leaf of map.leaves) { - const info = leafInfo.get(leaf.path.toUpperCase()) - /* istanbul ignore if -- buildLeafInfoMap is built from these same leaves */ + for (const [name, index] of nameToIndex) { + const info = byPackedIndex.get(index) + /* istanbul ignore if -- every index in the map came from a leaf */ if (!info) continue - const resolved: ResolvedVariable = { - ...info, - name: leaf.path, - index: packDebugAddr({ arrayIdx: leaf.arrayIdx, elemIdx: leaf.elemIdx }), - } + const resolved: ResolvedVariable = { ...info, name, index } all.push(resolved) - // First declaration wins, matching `buildLeafPathMap`. - if (!byName.has(leaf.path.toUpperCase())) byName.set(leaf.path.toUpperCase(), resolved) - if (!byIndex.has(resolved.index)) byIndex.set(resolved.index, resolved) + if (!byName.has(name.toUpperCase())) byName.set(name.toUpperCase(), resolved) + // First name wins for decoding: a shared global appears under several + // composite keys at one address, and a reply carries the address only. + if (!byIndex.has(index)) byIndex.set(index, resolved) } - return { md5: map.md5, all, byName, byIndex } + return { md5: map.md5, all, byName, byIndex, warnings } } /** diff --git a/src/cli/main.ts b/src/cli/main.ts index b2060222b..f78bc1bc1 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -48,6 +48,7 @@ const BOOLEAN_FLAGS = [ 'help', 'version', 'clean', + 'yes', 'upload-if-needed', 'force-new', 'keep-forces', @@ -62,7 +63,7 @@ const USAGE = `openplc — headless OpenPLC Editor Usage openplc devices [--timeout ] openplc compile [--target ] [--clean] - openplc upload --host
[--target ] [--clean] + openplc upload --host
[--target ] [--clean] [-y|--yes] openplc debug open --host
--target [--upload-if-needed] openplc debug list openplc debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch @@ -70,6 +71,10 @@ Usage openplc debug repl [--session ] (interactive; needs a terminal) openplc debug exec [script|-] [--session ] [--keep-going] (one command per line) +Confirmation + Builds on a device-side target refuse while its PLC is RUNNING, as the editor warns. + --yes (-y) approves stopping it first, the way 'apt install -y' does. + Credentials (upload, debug) --credentials user:pass, or --user + --password or OPENPLC_CREDENTIALS / OPENPLC_USER + OPENPLC_PASSWORD (keeps them out of shell history) diff --git a/src/cli/project/load.ts b/src/cli/project/load.ts index 574a30fe5..46edccc9c 100644 --- a/src/cli/project/load.ts +++ b/src/cli/project/load.ts @@ -17,9 +17,10 @@ * here is literally the same call the GUI's build button makes. */ +import { HardwareModule } from '@root/backend/editor/hardware' import { ProjectService } from '@root/backend/editor/services' import { parseProjectFiles } from '@root/backend/shared/utils/parse-project-files' -import { createOpenPLCStore } from '@root/frontend/store' +import { openPLCStoreBase } from '@root/frontend/store' import { isDataTypeFilesEnabled } from '@root/frontend/utils/feature-flags' import type { PLCProjectData } from '@root/middleware/shared/ports/types' @@ -64,12 +65,24 @@ export async function loadProject(projectPath: string): Promise { const socketPath = socketPathFor(config.registryDir, sessionId, process.platform) const registry = new SessionRegistry(config.registryDir) - const opened = await openRuntimeSession({ + // Hydrate the editor state this process will resolve against: the debug-spec + // resolver reads the device configuration and available boards off the store, + // the same way it does behind the GUI's Debug button. + const loaded = await loadProject(config.projectPath) + if (!loaded.success) { + announce({ event: 'failed', code: 'not-compiled', error: loaded.error }) + app.exit(1) + return + } + + // Uploading from inside the daemon would need the whole compile pipeline here; + // `debug open --upload-if-needed` runs it in the PARENT before spawning, so by + // this point an MD5 mismatch is genuinely a mismatch. + const opened = await openDebugSession({ sessionId, projectPath: config.projectPath, target: config.target, - host: config.host, - username: config.username, - password: config.password, - // Uploading from inside the daemon would need the whole compile pipeline - // here; `debug open --upload-if-needed` runs it in the PARENT before - // spawning, so by this point a mismatch is genuinely a mismatch. - onMd5Mismatch: undefined, - onProgress: (message) => announce({ event: 'progress', message }), + host: config.host || undefined, + username: config.username || undefined, + password: config.password || undefined, + onProgress: (message: string) => announce({ event: 'progress', message }), }) if (!opened.success) { diff --git a/src/cli/session/session-core.ts b/src/cli/session/session-core.ts index 7398e6d08..b88d66678 100644 --- a/src/cli/session/session-core.ts +++ b/src/cli/session/session-core.ts @@ -65,7 +65,11 @@ export class SessionCore { private readonly startedAtMs: number private readonly startedAt: string private lastActivityAtMs: number - /** UPPERCASE canonical names this session has forced and not released. */ + /** + * Names this session has forced and not released, in their CANONICAL casing — + * the composite key the GUI shows (`main:SL1_AO1`), not an uppercased form. + * Lookups go through `isForced`, which compares case-insensitively. + */ private readonly forced = new Set() private watching: ResolvedVariable[] = [] private watchTimer: NodeJS.Timeout | null = null @@ -243,7 +247,7 @@ export class SessionCore { payload: new Uint8Array(result.data), lastIndex: result.lastIndex, endian: this.options.endian, - forced: this.forced, + forced: this.forcedUpper(), }), ) } @@ -262,7 +266,7 @@ export class SessionCore { if (!result.success) { return this.fail(id, ErrorCode.TargetError, result.error ?? `The target refused the ${force ? 'force' : 'write'}`) } - if (force) this.forced.add(variable.name.toUpperCase()) + if (force) this.forced.add(variable.name) const readBack = await this.readBackAfterWrite(variable, input) if ('error' in readBack) return this.fail(id, ErrorCode.NotConnected, readBack.error) @@ -304,7 +308,7 @@ export class SessionCore { name: variable.name, type: variable.type, value: null, - forced: this.forced.has(variable.name.toUpperCase()), + forced: this.isForced(variable.name), }, } } @@ -319,7 +323,7 @@ export class SessionCore { if (!result.success) { return this.fail(id, ErrorCode.TargetError, result.error ?? 'The target refused the unforce') } - this.forced.delete(variable.name.toUpperCase()) + this.unmarkForced(variable.name) // No expected value to wait for: unforcing hands the variable back to the // program, so whatever it reads next is legitimate. @@ -400,8 +404,8 @@ export class SessionCore { this.stopWatching() const released: string[] = [] if (releaseForces) { - for (const upperName of [...this.forced]) { - const variable = findVariable(this.options.index, upperName) + for (const heldName of [...this.forced]) { + const variable = findVariable(this.options.index, heldName) /* istanbul ignore if -- every entry was resolved before being forced */ if (!variable) continue try { @@ -423,6 +427,21 @@ export class SessionCore { return released } + /** Case-insensitive membership, so a caller's casing never changes the answer. */ + private isForced(name: string): boolean { + return this.forcedUpper().has(name.toUpperCase()) + } + + private forcedUpper(): Set { + return new Set([...this.forced].map((name) => name.toUpperCase())) + } + + private unmarkForced(name: string): void { + for (const held of this.forced) { + if (held.toUpperCase() === name.toUpperCase()) this.forced.delete(held) + } + } + private fail(id: number, code: (typeof ErrorCode)[keyof typeof ErrorCode], message: string): Response { return { id, ok: false, error: { code, message } } } diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index eb995e1d6..69b61caee 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -42,11 +42,14 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + toDebugCandidate, + toDeviceLinkCandidates, +} from '../../../backend/editor/hardware/debug-channel-factory' import { classifyDeviceLink, type DeviceProbeOutcome, PATIENT_BOARD_ID_PROBE, - planBaudAttempts, QUICK_BOARD_ID_PROBE, SPECULATIVE_BOARD_ID_PROBE, } from '../../../backend/editor/hardware/device-probe' @@ -57,10 +60,6 @@ import { type DeviceLinkStatus, DeviceSessionManager, } from '../../../backend/editor/hardware/device-session-manager' -import { - buildDeviceModbusTransport, - modbusTransportKind, -} from '../../../backend/editor/hardware/device-transport-factory' import { type DiscoveredRuntime, discoverRuntimes } from '../../../backend/editor/hardware/discover-runtimes' import { LibraryManagerModule } from '../../../backend/editor/library-manager' import { inspectDeviceLicense, resolveDeviceLicense } from '../../../backend/editor/license/license-flow' @@ -73,7 +72,6 @@ import { getPlcopenImportFilePath, getProjectPath, } from '../../../backend/editor/utils' -import { WebSocketDebugTransport } from '../../../backend/shared/debug/websocket-debug-transport' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' @@ -1568,74 +1566,22 @@ class MainProcessBridge implements MainIpcModule { } /** - * Turn a resolved channel config into something the link manager can try. - * The only transport-specific step left in the flow; a config that names a - * transport this build cannot speak is dropped rather than half-built. + * Turn resolved channel configs into things the link manager can try. + * + * Delegates to `debug-channel-factory` — see `toDebugCandidate` above. */ - private toDeviceLinkCandidates( + private toDeviceLinkCandidates = ( configs: DebugConnectionConfig[], opts: { probeBaudRates?: boolean } = {}, - ): DeviceLinkCandidate[] { - const declared: DeviceLinkCandidate[] = [] - // Baud guesses go AFTER everything the project declared: a configured Modbus - // TCP address is a better next try than a rate nobody asked for. - const speculative: DeviceLinkCandidate[] = [] - - const build = (config: DebugConnectionConfig, baudRate: number | undefined, isGuess: boolean): void => { - const kind = modbusTransportKind(config.connectionType) - if (kind === null) return - const params = { - connectionType: config.connectionType, - port: config.connectionParams.port, - baudRate, - slaveId: config.connectionParams.slaveId, - host: config.connectionParams.ipAddress, - } - // Only the simulator needs an in-process serial port; building one for a real - // transport would allocate a virtual port nobody reads. - const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} - // Probe the params now so a malformed config fails resolution rather than - // becoming a candidate that always throws on `create()`. - if ('error' in buildDeviceModbusTransport(params, options)) return - ;(isGuess ? speculative : declared).push({ - transport: kind, - // The endpoint ONLY. It is matched against the OS port list and against - // the port an upload asks to borrow, so the baud travels beside it rather - // than inside it — decorating this string made every swept candidate match - // no port and be skipped in 1ms. - descriptor: describeDebugEndpoint(config), - baudRate, - speculative: isGuess, - create: () => { - const built = buildDeviceModbusTransport(params, options) - if ('error' in built) throw new Error(built.error) - return built.client - }, - }) - } - - for (const config of configs) { - // A wrong baud is the one misconfiguration that looks like healthy silence: - // the port opens, so it is not "no response", and nothing decodes, so it - // reads as "no firmware" — and the user gets told to reflash a board that is - // running fine. Sweeping the rates OpenPLC is ever built with turns that dead - // end into a connection. Serial only; a TCP address is either right or not. - for (const attempt of planBaudAttempts(config.connectionParams.baudRate, { sweep: opts.probeBaudRates })) { - build(config, attempt.baudRate, attempt.speculative) - } - } - - // The patient budget belongs to the last DECLARED endpoint, not to the last - // candidate overall. Without this the baud sweep would silently take that - // patience away from the configured endpoint and hand it to a guess — and a - // board that was just flashed, still booting on the right rate, would be ruled - // out in ~10s instead of the ~32s it sometimes needs. - const lastDeclared = declared[declared.length - 1] - if (lastDeclared) lastDeclared.patient = true + ): DeviceLinkCandidate[] => toDeviceLinkCandidates(configs, opts, this.debugChannelDeps) - return [...declared, ...speculative] + /** + * The simulator lives in this process, so the factory is given a way to build + * the in-process serial port it answers on. + */ + private readonly debugChannelDeps = { + createVirtualSerialPort: () => new VirtualSerialPort(this.simulatorModule), } - /** Consume the classification the last verified candidate produced. */ private takeDeviceLinkProbe(): DeviceProbeOutcome | null { const probe = this.deviceLinkProbe @@ -1674,32 +1620,14 @@ class MainProcessBridge implements MainIpcModule { } return Promise.resolve({ success: true }) } - /** - * Turn a resolved channel config into an openable DEBUG channel. The one place - * that knows a WebSocket is a debug channel too. + * Turn a resolved channel config into an openable DEBUG channel. + * + * Delegates to `debug-channel-factory`, shared with the headless CLI so both + * build every declared transport from the same code. */ - private toDebugCandidate(config: DebugConnectionConfig): DeviceDebugCandidate | null { - if (config.connectionType === 'websocket') { - const host = config.connectionParams.ipAddress - const token = config.connectionParams.jwtToken - if (!host || !token) return null - return { - transport: 'websocket', - descriptor: `websocket ${host}`, - create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), - } - } - // One config in, one candidate out: this builds the DEBUG channel for a - // session that already exists, so the rate is settled and guessing is wrong. - const [candidate] = this.toDeviceLinkCandidates([config], { probeBaudRates: false }) - if (!candidate) return null - return { - transport: candidate.transport, - descriptor: `${candidate.transport} ${candidate.descriptor}`, - create: candidate.create, - } - } + private toDebugCandidate = (config: DebugConnectionConfig): DeviceDebugCandidate | null => + toDebugCandidate(config, this.debugChannelDeps) /** * Is this freshly opened candidate a device we can work with? Runs the From 5145e62a4790bf3b0d1f4c0b33849be2054eeb64 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 21:17:02 -0400 Subject: [PATCH 09/25] =?UTF-8?q?feat(cli):=20serial=20targets=20=E2=80=94?= =?UTF-8?q?=20devices=20lists=20ports,=20and=20upload/debug=20use=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the transport-agnostic debug work against a P1AM-100 on /dev/cu.usbmodem11301, which surfaced three things the network-only testing could not. `devices` listed network runtimes only. It now also lists serial ports, from `HardwareModule.getAvailableSerialPorts` — the same call behind the editor's port dropdown, labels included (the P1AM-100 identifies as "Arduino MKR Zero", sharing the SAMD bootloader's USB id). The command exists to answer "what do I pass to --host or --port", and half the answer was missing. `--port` is that dropdown, and it has to land in the STORE, not just in the compile arguments: the debug-spec resolver reads `configuration.communicationPort` from there, which is how a board's declared serial channel learns which port it is on. `applyConnectionOverrides` mirrors what the device screen does. `upload` demanded `--host` unconditionally, which made it unusable for every Arduino-class board. Which arguments an upload needs is a property of the target and the editor already answers it — `directUsbUpload` distinguishes a board flashed over USB (arduino-cli, needs a port) from one reached through a runtime API (needs an address and credentials). Credentials are no longer demanded from a target that has nothing to log in to. End to end on the P1AM-100: compile (hex/bin + debug-map), upload over USB (flash verified), `debug open --port` with no host resolving to `rtu /dev/cu.usbmodem11301` from the board's spec, reads by composite key, run/stop over FC 0x4b on the debug channel rather than REST, and a force on `en_sinal` starting the program's pulse generator with the ~500ms blink captured in the watch buffer and drained by `poll` from a separate process. `close` released the pin, confirmed from a fresh session. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/commands/build.ts | 56 ++++++++++++++++++++++++++--- src/cli/commands/debug.ts | 21 ++++++++--- src/cli/commands/devices.ts | 65 ++++++++++++++++++++++++++-------- src/cli/daemon-entry.ts | 3 +- src/cli/main.ts | 6 ++-- src/cli/project/load.ts | 19 ++++++++++ src/cli/session/daemon-main.ts | 5 ++- src/cli/spawn-session.ts | 1 + 8 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index ed50e279e..147ccb068 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -13,15 +13,18 @@ * argv, loading the project from disk, and rendering the result. */ +import { HardwareModule } from '@root/backend/editor/hardware' import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import { openPLCStoreBase } from '@root/frontend/store' import { compileProgramFlow } from '@root/middleware/adapters/editor/compile-program-flow' import type { CompileProgressEvent } from '@root/middleware/shared/ports/types' +import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { boolFlag, type ParsedArgs, stringFlag } from '../args' import { createCliCompileTransport } from '../compile/cli-transport' import { ErrorCode, ExitCode, type ExitCodeValue } from '../exit-codes' import type { CliFailure, CliResult, Reporter } from '../output' -import { loadProject } from '../project/load' +import { applyConnectionOverrides, loadProject } from '../project/load' export interface BuildOptions { /** True for `upload`: connect to a runtime and let the pipeline flash it. */ @@ -44,6 +47,9 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu const project = loaded.project for (const warning of project.warnings) reporter.progress(`warning: ${warning}`) + // The port dropdown and the address field, from argv. + applyConnectionOverrides({ port: stringFlag(args, 'port'), host: stringFlag(args, 'host') }) + // The project remembers the board its dropdown was left on; `--target` // overrides it so one fixture can be built for several targets in a matrix. const target = stringFlag(args, 'target') ?? project.board @@ -57,13 +63,48 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu ) } + // Which arguments an upload needs is a property of the TARGET, and the editor + // already answers it: `directUsbUpload` is the difference between a board it + // flashes over USB (arduino-cli, needs a serial port) and one it reaches + // through a runtime API (needs an address and credentials). Asking for a host + // unconditionally made `upload` impossible for every Arduino-class board. + const boards = await new HardwareModule().getAvailableBoards() + const boardInfo = boards.get(target) + if (!boardInfo) { + return reporter.failure( + { + code: ErrorCode.TargetUnknown, + message: + `Board "${target}" is not available — it is neither in hals.json nor declared by an installed VPP ` + + 'package. Install its package in the editor, or check the name.', + }, + ExitCode.NotFound, + ) + } + const capabilities = resolveTargetCapabilities(boardInfo) + let runtime: RuntimeApiClient | null = null let host: string | null = null - if (options.withUpload) { + + if (options.withUpload && capabilities.directUsbUpload) { + // arduino-cli needs the port; the project may already remember it. + if (!currentCommunicationPort()) { + return reporter.failure( + { + code: ErrorCode.MissingArgument, + message: `"${target}" is flashed over USB — pass --port (see \`openplc devices\`)`, + }, + ExitCode.Usage, + ) + } + } else if (options.withUpload) { host = stringFlag(args, 'host') ?? stringFlag(args, 'address') ?? null if (!host) { return reporter.failure( - { code: ErrorCode.MissingArgument, message: 'upload needs --host
(see `openplc devices`)' }, + { + code: ErrorCode.MissingArgument, + message: `"${target}" is reached through its runtime API — pass --host
(see \`openplc devices\`)`, + }, ExitCode.Usage, ) } @@ -116,7 +157,7 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu cleanBuild: boolFlag(args, 'clean'), runtimeIpAddress: host, runtimeJwtToken: runtime?.tokens.getToken() ?? null, - communicationPort: project.communicationPort || undefined, + communicationPort: currentCommunicationPort() || undefined, vendorScreenData: project.vendorScreenData, }, createCliCompileTransport(runtime), @@ -149,7 +190,7 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu }, () => options.withUpload - ? `Uploaded "${project.name}" to ${host ?? 'the target'} (${target}).` + ? `Uploaded "${project.name}" to ${host ?? currentCommunicationPort() ?? 'the target'} (${target}).` : `Built "${project.name}" for ${target}.\nArtifacts: ${project.projectPath}/build/${target}`, ) } @@ -229,3 +270,8 @@ async function ensurePlcStoppedForBuild(input: { input.reporter.progress('PLC stopped before build.') return { ok: true } } + +/** The port the store now holds — after `--port` has been applied. */ +function currentCommunicationPort(): string | undefined { + return openPLCStoreBase.getState().deviceDefinitions.configuration.communicationPort || undefined +} diff --git a/src/cli/commands/debug.ts b/src/cli/commands/debug.ts index f9fe4a8d8..ed1a71593 100644 --- a/src/cli/commands/debug.ts +++ b/src/cli/commands/debug.ts @@ -33,7 +33,10 @@ export interface DebugContext { export interface SpawnSessionOptions { projectPath: string target: string + /** Runtime address. Empty for a target reached over serial. */ host: string + /** Serial port. Empty for a target reached over the network. */ + port: string username: string password: string uploadIfNeeded: boolean @@ -119,19 +122,24 @@ export async function runDebug(args: ParsedArgs, reporter: Reporter, context: De async function runOpen(args: ParsedArgs, reporter: Reporter, context: DebugContext): Promise { const projectPath = args.positionals[0] ?? stringFlag(args, 'project') const host = stringFlag(args, 'host') ?? stringFlag(args, 'address') + const port = stringFlag(args, 'port') const target = stringFlag(args, 'target') - if (!projectPath || !host) { + if (!projectPath) { return reporter.failure( { code: ErrorCode.MissingArgument, message: - 'debug open needs a project path and --host
, e.g. `openplc debug open ./proj --host 192.168.1.50`', + 'debug open needs a project path, plus --host
for a runtime target or --port for a ' + + 'board (see `openplc devices`)', }, ExitCode.Usage, ) } - const credentials = resolveDebugCredentials(args) + // Credentials are required only by targets controlled over a runtime API. A + // board on a serial port has nothing to log in to, and demanding a password + // for it would be a rule the editor does not have. + const credentials = host ? resolveDebugCredentials(args) : { username: '', password: '' } if ('error' in credentials) { return reporter.failure({ code: ErrorCode.MissingArgument, message: credentials.error }, ExitCode.Usage) } @@ -153,7 +161,8 @@ async function runOpen(args: ParsedArgs, reporter: Reporter, context: DebugConte const spawned = await context.spawnSession({ projectPath, target: target ?? '', - host, + host: host ?? '', + port: port ?? '', username: credentials.username, password: credentials.password, uploadIfNeeded: boolFlag(args, 'upload-if-needed'), @@ -402,7 +411,9 @@ export function renderOk(response: OkResponse): string { const md5 = status.programMd5 ? `${status.programMd5.slice(0, 8)}${status.md5Matches ? '' : ' (MISMATCH)'}` : '-' return [ `session ${status.sessionId}`, - `target ${status.target || '-'} via ${status.transport} ${status.descriptor}`, + // `descriptor` already begins with the transport (the factory builds it as + // `${transport} ${endpoint}`), so printing both reads "via rtu rtu /dev/…". + `target ${status.target || '-'} via ${status.descriptor}`, `project ${status.projectPath}`, `program ${md5}`, `plc ${status.plcState}`, diff --git a/src/cli/commands/devices.ts b/src/cli/commands/devices.ts index 4f281ae18..010701466 100644 --- a/src/cli/commands/devices.ts +++ b/src/cli/commands/devices.ts @@ -1,11 +1,17 @@ /** - * `openplc devices` — list OpenPLC Runtime v4 targets on the local network. + * `openplc devices` — everything this machine can reach a PLC through. * - * The same UDP scan the editor's "Search" button runs, via - * `discoverRuntimes`, which covers bare v4 runtimes and v4 behind a VPP - * package because both advertise the same service. + * Two lists, because the editor has two: the "Search" button's UDP scan for + * Runtime v4 targets on the network (`discoverRuntimes`, covering bare v4 and v4 + * behind a VPP package, since both advertise the same service), and the serial + * port dropdown's list (`HardwareModule.getAvailableSerialPorts`, whose labels + * carry the arduino-cli-identified board name when it knows one). + * + * Both are needed to answer "what do I pass to --host or --port", which is the + * question this command exists for. */ +import { HardwareModule } from '@root/backend/editor/hardware' import { discoverRuntimes } from '@root/backend/editor/hardware/discover-runtimes' import { boolFlag, type ParsedArgs, stringFlag } from '../args' @@ -23,6 +29,13 @@ export async function runDevices(args: ParsedArgs, reporter: Reporter): Promise< ) } + // Serial first: it is local and instant, so the network scan's few seconds do + // not delay the half of the answer that needs no waiting. + const serialPorts = await new HardwareModule().getAvailableSerialPorts() + for (const port of serialPorts) { + reporter.progress(` serial ${port.address}${describePort(port)}`) + } + reporter.progress('Scanning the local network for OpenPLC runtimes…') const result = await discoverRuntimes({ @@ -39,17 +52,33 @@ export async function runDevices(args: ParsedArgs, reporter: Reporter): Promise< const devices = [...result.devices].sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) - return reporter.success({ devices }, () => { - if (devices.length === 0) { - return 'No runtimes answered. They must be powered on and on this subnet.' - } - const rows = devices.map((device) => [ - device.ipAddress, - device.hostname || '-', - device.runtimeVersion || '-', - String(device.apiPort), - ]) - return renderTable(['ADDRESS', 'HOSTNAME', 'VERSION', 'PORT'], rows) + return reporter.success({ devices, serialPorts }, () => { + const sections: string[] = [] + + sections.push( + serialPorts.length === 0 + ? 'Serial ports: none found.' + : `Serial ports (pass with --port):\n${renderTable( + ['PORT', 'BOARD'], + serialPorts.map((port) => [port.address, port.boardName ?? port.manufacturer ?? '-']), + )}`, + ) + + sections.push( + devices.length === 0 + ? 'Network runtimes: none answered. They must be powered on and on this subnet.' + : `Network runtimes (pass with --host):\n${renderTable( + ['ADDRESS', 'HOSTNAME', 'VERSION', 'API PORT'], + devices.map((device) => [ + device.ipAddress, + device.hostname || '-', + device.runtimeVersion || '-', + String(device.apiPort), + ]), + )}`, + ) + + return sections.join('\n\n') }) } @@ -65,3 +94,9 @@ export function renderTable(headers: string[], rows: string[][]): string { .trimEnd() return [line(headers), ...rows.map(line)].join('\n') } + +/** The parenthetical the port dropdown shows: board name when arduino-cli knows it. */ +function describePort(port: { boardName?: string; manufacturer?: string }): string { + const label = port.boardName ?? port.manufacturer + return label ? ` (${label})` : '' +} diff --git a/src/cli/daemon-entry.ts b/src/cli/daemon-entry.ts index 57eee5993..5f832495b 100644 --- a/src/cli/daemon-entry.ts +++ b/src/cli/daemon-entry.ts @@ -45,7 +45,7 @@ function readConfig(line: string): DaemonConfig | undefined { } if (typeof parsed !== 'object' || parsed === null) return undefined const record: Record = { ...parsed } - const strings = ['registryDir', 'projectPath', 'target', 'host', 'username', 'password'] as const + const strings = ['registryDir', 'projectPath', 'target', 'host', 'port', 'username', 'password'] as const for (const key of strings) { if (typeof record[key] !== 'string') return undefined } @@ -54,6 +54,7 @@ function readConfig(line: string): DaemonConfig | undefined { projectPath: String(record.projectPath), target: String(record.target), host: String(record.host), + port: String(record.port), username: String(record.username), password: String(record.password), uploadIfNeeded: record.uploadIfNeeded === true, diff --git a/src/cli/main.ts b/src/cli/main.ts index f78bc1bc1..1db24ad43 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -62,9 +62,9 @@ const USAGE = `openplc — headless OpenPLC Editor Usage openplc devices [--timeout ] - openplc compile [--target ] [--clean] - openplc upload --host
[--target ] [--clean] [-y|--yes] - openplc debug open --host
--target [--upload-if-needed] + openplc compile [--target ] [--port ] [--clean] + openplc upload (--host
| --port ) [--target ] [--clean] [-y|--yes] + openplc debug open --target (--host
| --port ) [--upload-if-needed] openplc debug list openplc debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch openplc debug close --session | --all [--keep-forces] diff --git a/src/cli/project/load.ts b/src/cli/project/load.ts index 46edccc9c..9f0ee086c 100644 --- a/src/cli/project/load.ts +++ b/src/cli/project/load.ts @@ -97,3 +97,22 @@ export async function loadProject(projectPath: string): Promise { app.exit(1) return } + applyConnectionOverrides({ port: config.port, host: config.host }) // Uploading from inside the daemon would need the whole compile pipeline here; // `debug open --upload-if-needed` runs it in the PARENT before spawning, so by diff --git a/src/cli/spawn-session.ts b/src/cli/spawn-session.ts index 41fcba6db..0a91d42aa 100644 --- a/src/cli/spawn-session.ts +++ b/src/cli/spawn-session.ts @@ -55,6 +55,7 @@ export function createSessionSpawner(deps: SpawnDependencies) { projectPath: options.projectPath, target: options.target, host: options.host, + port: options.port, username: options.username, password: options.password, uploadIfNeeded: false, From bb27bcfaf510f24b73bd9b326985dd48cb56d5bb Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 20 Aug 2026 21:40:03 -0400 Subject: [PATCH 10/25] fix(cli,modbus): wait for the serial handle to release before exiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Electron quit unexpectedly" was a real bug of mine, not a transient. macOS recorded SIGABRT with an uncaught C++ exception thrown from `node.napi.node` inside `node::Environment::CleanupHandles` — `@serialport/bindings-cpp` releases its handle asynchronously and registers a NAPI async cleanup hook, and tearing the Node environment down mid-close makes that hook throw. An uncaught C++ exception aborts the process, so nothing of ours appears in any log. `ModbusRtuClient.disconnect()` called `serialPort.close()` fire-and-forget and dropped the reference, which is harmless in the editor — a GUI keeps running afterwards. The CLI's whole job is to disconnect and exit, so it hit the window every time: `debug close` awaited nothing and called `app.exit(0)` in the same tick. - `disconnect()` now passes the close callback (so a failure is handled instead of surfacing as an `error` event on a port already dropped) and exposes `closed()`, resolving when the handle is actually released. - `disconnectAndWait()` awaits that under a 2s bound — a handle that never closes must not hang teardown — and is used by every path that closes a channel before exiting, including `openDebugSession`'s failure paths, which had the same window. Proven causal rather than assumed: reverting just this fix reproduced the crash with an identical `node.napi.node` -> `__cxa_throw` stack, and also hung the next `close`, because the aborted daemon left the port held and its socket stale. Restored, six serial open/close cycles produce no crash report. The GUI was never affected — same binary, different process. 402 GUI-path tests and 4642 overall still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/modbus/modbus-rtu-client.ts | 32 +++++++++++- src/cli/debug/close-channel.ts | 49 +++++++++++++++++++ src/cli/debug/open-session.ts | 5 +- src/cli/session/session-core.ts | 8 ++- 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 src/cli/debug/close-channel.ts diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts index 4a6720f43..802023d72 100644 --- a/src/backend/editor/modbus/modbus-rtu-client.ts +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -76,6 +76,8 @@ export class ModbusRtuClient implements DeviceModbusTransport { private serialPort: any = null // eslint-disable-next-line @typescript-eslint/no-explicit-any private injectedSerialPort: any = null + /** In-flight close, awaited via `closed()`. */ + private closing: Promise | null = null private static readonly CRC_HI_TABLE = [ 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, @@ -182,11 +184,39 @@ export class ModbusRtuClient implements DeviceModbusTransport { }) } + /** + * Close the port. + * + * `close()` is asynchronous in the native binding, and the callback is passed + * so a failure is HANDLED rather than surfacing as an unhandled `error` event + * on a port we have already dropped. + * + * `closed` resolves when the native handle is actually released. A caller that + * is about to end the process must await it: `@serialport/bindings-cpp` + * registers a NAPI async cleanup hook, and tearing the Node environment down + * mid-close makes that hook throw a C++ exception, which aborts the process + * (SIGABRT, "Electron quit unexpectedly"). Long-lived hosts like the editor + * never noticed, because they keep running after a disconnect. + */ disconnect(): void { if (this.serialPort && this.serialPort.isOpen) { - this.serialPort.close() + const port = this.serialPort this.serialPort = null + this.closing = new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + port.close((error: Error | null) => { + if (error) console.warn(`Warning: failed to close serial port: ${error.message}`) + resolve() + }) + }) + return } + this.serialPort = null + } + + /** Resolves once a `disconnect()` has released the native handle. */ + closed(): Promise { + return this.closing ?? Promise.resolve() } private flushInputBuffer(): Promise { diff --git a/src/cli/debug/close-channel.ts b/src/cli/debug/close-channel.ts new file mode 100644 index 000000000..dbbccb43d --- /dev/null +++ b/src/cli/debug/close-channel.ts @@ -0,0 +1,49 @@ +/** + * Closing a debug channel and waiting for it to really let go. + * + * `DeviceDebugChannel.disconnect()` is synchronous by contract, but the serial + * transport's underlying `close()` is not: `@serialport/bindings-cpp` releases + * the handle asynchronously and registers a NAPI async cleanup hook. Ending the + * process inside that window makes the hook throw a C++ exception during + * `node::Environment::CleanupHandles`, and an uncaught C++ exception aborts — + * SIGABRT, which macOS reports as "Electron quit unexpectedly" with nothing of + * ours in the log. + * + * The editor never hit this because a GUI keeps running after a disconnect. A + * CLI whose whole job is to disconnect and exit hits it every time, so every + * path that closes a channel before exiting goes through here. + */ + +import type { DeviceDebugChannel } from '@root/backend/shared/debug/types' + +/** Upper bound on the wait: a handle that never closes must not hang teardown. */ +const CHANNEL_CLOSE_TIMEOUT_MS = 2000 + +export async function disconnectAndWait(channel: DeviceDebugChannel): Promise { + try { + channel.disconnect() + } catch { + /* already disconnected */ + } + + // Probed rather than declared on `DeviceDebugChannel`: only transports with a + // native handle have anything to wait for, and widening the interface would + // oblige the WebSocket and the simulator to implement a no-op. + const closed = readCloseSignal(channel) + if (!closed) { + // Still yield once, so any synchronous teardown the transport queued runs + // before the caller exits. + await delay(0) + return + } + await Promise.race([closed(), delay(CHANNEL_CLOSE_TIMEOUT_MS)]) +} + +function readCloseSignal(channel: DeviceDebugChannel): (() => Promise) | undefined { + const candidate: unknown = Reflect.get(channel, 'closed') + return typeof candidate === 'function' ? () => Promise.resolve(Reflect.apply(candidate, channel, [])) : undefined +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/cli/debug/open-session.ts b/src/cli/debug/open-session.ts index e5e309d03..e28da3c93 100644 --- a/src/cli/debug/open-session.ts +++ b/src/cli/debug/open-session.ts @@ -30,6 +30,7 @@ import type { BoardInfo } from '@root/middleware/shared/ports/types' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { channelPlcControl, restPlcControl, SessionCore } from '../session/session-core' +import { disconnectAndWait } from './close-channel' import { loadDebugIndex } from './variables' export interface OpenSessionOptions { @@ -137,7 +138,7 @@ export async function openDebugSession(options: OpenSessionOptions): Promise Date: Fri, 21 Aug 2026 07:06:05 -0400 Subject: [PATCH 11/25] feat(cli): make the CLI reachable from a packaged build, as openplc-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaged story did not work. `cli.js` was emitted into `app.asar` at `dist/main/cli.js`, but a packaged Electron app always runs `package.json.main` — its binary cannot be handed a different script — so nothing could start it. The debug daemon's respawn had the same hole: for a packaged build it passed the app binary a script path, which Electron ignores, so it would have launched the GUI in the middle of a headless run (the failure already fixed for dev). Both roles now ship in one binary and argv decides. `src/main/entry.ts` is the app entry and imports the GUI only when this is not a `--cli` / `--cli-daemon` run — both modules do their work on import, so importing both would start both. OpenPLC Editor.app/Contents/MacOS/OpenPLC\ Editor --cli devices `daemonSpawnArgs()` passes the marker alone when packaged, and the script path only in development. Renamed to `openplc-cli` throughout the usage text and messages: `openplc` reads like the runtime or the app, and the name should say what it is. `--help` behaviour completed: - `-h` works. It previously fell through to positionals, so `-h` was read as the command "-h" and exited 2 instead of printing help and exiting 0. Short flags are now an explicit two-entry map (`-y`, `-h`) rather than a general `-x` rule. - An unknown command prints the usage as well as the error, since a mistyped command is exactly when the list of real commands is useful. - Exit codes: `--help` / `-h` → 0, no arguments → 2 (a script invoked with an empty argument must not read as success), unknown command → 2. Verified on the production bundle: `--cli` runs headless with no window, the GUI still boots and exits cleanly through the new entry, and a serial debug session opens, reports status and closes with the daemon respawning through the dispatcher and spawning no window. Co-Authored-By: Claude Opus 5 (1M context) --- configs/webpack/webpack.config.main.prod.ts | 12 +++--- package.json | 2 +- src/cli/__tests__/args.test.ts | 19 ++++++++ src/cli/args.ts | 14 ++++-- src/cli/commands/build.ts | 11 +++-- src/cli/commands/debug.ts | 8 ++-- src/cli/commands/devices.ts | 2 +- src/cli/compile/cli-transport.ts | 2 +- src/cli/debug/open-session.ts | 2 +- src/cli/main.ts | 48 +++++++++++++-------- src/cli/output.ts | 2 +- src/cli/session/client.ts | 2 +- src/main/entry.ts | 36 ++++++++++++++++ 13 files changed, 117 insertions(+), 43 deletions(-) create mode 100644 src/main/entry.ts diff --git a/configs/webpack/webpack.config.main.prod.ts b/configs/webpack/webpack.config.main.prod.ts index 40e40b3f9..c57b40f27 100644 --- a/configs/webpack/webpack.config.main.prod.ts +++ b/configs/webpack/webpack.config.main.prod.ts @@ -25,14 +25,12 @@ const configuration: webpack.Configuration = { target: 'electron-main', entry: { - main: join(webpackPaths.srcMainPath, 'main.ts'), + // `entry.ts`, not `main.ts`: a packaged Electron app always runs + // `package.json.main`, so the GUI and the headless CLI (DOPE-567) have to + // ship in one binary with argv deciding which starts. It imports the GUI + // only when this is not a `--cli` run. + main: join(webpackPaths.srcMainPath, 'entry.ts'), preload: join(webpackPaths.srcMainPath, 'modules/preload/preload.ts'), - // The headless CLI (DOPE-567). Built with the main-process target because - // it IS an Electron main process — one that never opens a window — so it can - // reuse CompilerModule and the rest of `backend/editor`, which reach the - // arduino-cli config, strucpp includes, licence store and installed VPP - // packages through Electron's `app` paths. - cli: join(webpackPaths.srcPath, 'cli/main.ts'), }, output: { diff --git a/package.json b/package.json index d720eb6e1..d9e657d43 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.prod.ts", "build:cli": "npm run build:main", "build:cli:dev": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.cli.dev.ts", - "cli": "electron ./release/app/dist/main/cli.js", + "cli": "electron ./release/app/dist/main/main.js --cli", "cli:dev": "electron ./openplc-cli.dev.js", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.renderer.prod.ts", "lint": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx}", diff --git a/src/cli/__tests__/args.test.ts b/src/cli/__tests__/args.test.ts index 8fe0c479b..2c3120f0a 100644 --- a/src/cli/__tests__/args.test.ts +++ b/src/cli/__tests__/args.test.ts @@ -78,3 +78,22 @@ describe('flag readers', () => { expect(boolFlag(parse(['compile']), 'json')).toBe(false) }) }) + +describe('short flags', () => { + it('maps the two accepted single-dash forms to their long names', () => { + expect(parse(['upload', './p', '-y']).flags.yes).toBe(true) + expect(parse(['-h']).flags.help).toBe(true) + }) + + it('does not treat a short flag as a positional', () => { + // `-h` used to fall through to `positionals`, so `openplc-cli -h` was read + // as the command "-h" and exited 2 instead of printing help and exiting 0. + expect(parse(['-h']).command).toBeUndefined() + expect(parse(['-h']).positionals).toEqual([]) + }) + + it('leaves an unknown single-dash token alone rather than guessing', () => { + expect(parse(['compile', '-x']).positionals).toEqual(['-x']) + expect(parse(['compile', '-x']).flags.x).toBeUndefined() + }) +}) diff --git a/src/cli/args.ts b/src/cli/args.ts index f4ee48f9e..84a57d7cc 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -37,6 +37,9 @@ export interface ParseOptions { commandsWithSubcommands?: readonly string[] } +/** The only single-dash forms accepted, mapped to their long names. */ +const SHORT_FLAGS: Record = { '-y': 'yes', '-h': 'help' } + function setFlag(flags: ParsedArgs['flags'], name: string, value: string | boolean): void { const existing = flags[name] if (existing === undefined) { @@ -76,10 +79,13 @@ export function parseArgs(argv: readonly string[], options: ParseOptions = {}): continue } - // `-y` is the one short flag, because the confirmation prompt it answers is - // the one people type most and `apt -y` set the expectation. - if (token === '-y') { - setFlag(flags, 'yes', true) + // Short flags are an explicit, closed map rather than a general `-x` rule: + // these two are the ones people reach for without reading anything + // (`apt -y` set that expectation, and `-h` is universal), while inventing + // short forms for the rest would make argv harder to read, not easier. + const shortFlag = SHORT_FLAGS[token] + if (shortFlag) { + setFlag(flags, shortFlag, true) continue } diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 147ccb068..d666ef595 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -1,5 +1,5 @@ /** - * `openplc compile` and `openplc upload` — the Build and Build & Upload clicks. + * `openplc-cli compile` and `openplc-cli upload` — the Build and Build & Upload clicks. * * Both call `compileProgramFlow`, the orchestration behind the editor's * `CompilerPort.compileProgram`, through a CLI transport. Everything the flow @@ -35,7 +35,10 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu const projectPath = args.positionals[0] ?? stringFlag(args, 'project') if (!projectPath) { return reporter.failure( - { code: ErrorCode.MissingArgument, message: 'Give the project directory, e.g. `openplc compile ./my-project`' }, + { + code: ErrorCode.MissingArgument, + message: 'Give the project directory, e.g. `openplc-cli compile ./my-project`', + }, ExitCode.Usage, ) } @@ -92,7 +95,7 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu return reporter.failure( { code: ErrorCode.MissingArgument, - message: `"${target}" is flashed over USB — pass --port (see \`openplc devices\`)`, + message: `"${target}" is flashed over USB — pass --port (see \`openplc-cli devices\`)`, }, ExitCode.Usage, ) @@ -103,7 +106,7 @@ export async function runBuild(args: ParsedArgs, reporter: Reporter, options: Bu return reporter.failure( { code: ErrorCode.MissingArgument, - message: `"${target}" is reached through its runtime API — pass --host
(see \`openplc devices\`)`, + message: `"${target}" is reached through its runtime API — pass --host
(see \`openplc-cli devices\`)`, }, ExitCode.Usage, ) diff --git a/src/cli/commands/debug.ts b/src/cli/commands/debug.ts index ed1a71593..903908d3d 100644 --- a/src/cli/commands/debug.ts +++ b/src/cli/commands/debug.ts @@ -1,5 +1,5 @@ /** - * `openplc debug …` — the session-first debugger. + * `openplc-cli debug …` — the session-first debugger. * * Every subcommand here is a CLIENT of the session protocol. `open` forks a * daemon and registers its `session_id`; everything else dials that session's @@ -130,7 +130,7 @@ async function runOpen(args: ParsedArgs, reporter: Reporter, context: DebugConte code: ErrorCode.MissingArgument, message: 'debug open needs a project path, plus --host
for a runtime target or --port for a ' + - 'board (see `openplc devices`)', + 'board (see `openplc-cli devices`)', }, ExitCode.Usage, ) @@ -331,7 +331,7 @@ export function resolveSession(args: ParsedArgs, context: DebugContext): { recor } const sessions = context.registry.list() if (sessions.length === 1) return { record: sessions[0] } - if (sessions.length === 0) return { error: 'No open debug sessions — run `openplc debug open` first' } + if (sessions.length === 0) return { error: 'No open debug sessions — run `openplc-cli debug open` first' } return { error: `${sessions.length} sessions are open; name one with --session (${sessions.map((s) => s.sessionId).join(', ')})`, } @@ -565,7 +565,7 @@ async function runRepl(args: ParsedArgs, reporter: Reporter, context: DebugConte { code: ErrorCode.InvalidArgument, message: - 'debug repl needs a terminal. For a script, use `openplc debug exec -` (reads commands from stdin, one per line).', + 'debug repl needs a terminal. For a script, use `openplc-cli debug exec -` (reads commands from stdin, one per line).', }, ExitCode.Usage, ) diff --git a/src/cli/commands/devices.ts b/src/cli/commands/devices.ts index 010701466..e1cc7dfa1 100644 --- a/src/cli/commands/devices.ts +++ b/src/cli/commands/devices.ts @@ -1,5 +1,5 @@ /** - * `openplc devices` — everything this machine can reach a PLC through. + * `openplc-cli devices` — everything this machine can reach a PLC through. * * Two lists, because the editor has two: the "Search" button's UDP scan for * Runtime v4 targets on the network (`discoverRuntimes`, covering bare v4 and v4 diff --git a/src/cli/compile/cli-transport.ts b/src/cli/compile/cli-transport.ts index 8db09ab48..1c1c50cda 100644 --- a/src/cli/compile/cli-transport.ts +++ b/src/cli/compile/cli-transport.ts @@ -3,7 +3,7 @@ * * The renderer's transport is backed by `window.bridge`; this one is backed by * the main-process modules the bridge itself delegates to. Same three calls, - * same flow above them — so `openplc compile` enters the orchestration a Build + * same flow above them — so `openplc-cli compile` enters the orchestration a Build * click enters, board resolution and POU preprocessing included, rather than * reassembling the steps and drifting on them. */ diff --git a/src/cli/debug/open-session.ts b/src/cli/debug/open-session.ts index e28da3c93..ad7edb744 100644 --- a/src/cli/debug/open-session.ts +++ b/src/cli/debug/open-session.ts @@ -202,6 +202,6 @@ async function describeStoppedTarget(runtime: RuntimeApiClient, host: string): P } return ( 'The PLC is stopped, so no program is scanning and the debug interface has nothing to serve. ' + - 'Start it (`openplc debug start`) and retry.' + 'Start it (`openplc-cli debug start`) and retry.' ) } diff --git a/src/cli/main.ts b/src/cli/main.ts index 1db24ad43..71bb211ab 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -58,18 +58,18 @@ const BOOLEAN_FLAGS = [ const COMMANDS_WITH_SUBCOMMANDS = ['debug'] as const -const USAGE = `openplc — headless OpenPLC Editor +const USAGE = `openplc-cli — headless OpenPLC Editor Usage - openplc devices [--timeout ] - openplc compile [--target ] [--port ] [--clean] - openplc upload (--host
| --port ) [--target ] [--clean] [-y|--yes] - openplc debug open --target (--host
| --port ) [--upload-if-needed] - openplc debug list - openplc debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch - openplc debug close --session | --all [--keep-forces] - openplc debug repl [--session ] (interactive; needs a terminal) - openplc debug exec [script|-] [--session ] [--keep-going] (one command per line) + openplc-cli devices [--timeout ] + openplc-cli compile [--target ] [--port ] [--clean] + openplc-cli upload (--host
| --port ) [--target ] [--clean] [-y|--yes] + openplc-cli debug open --target (--host
| --port ) [--upload-if-needed] + openplc-cli debug list + openplc-cli debug status | list-vars | read | write | force | unforce | start | stop | watch | poll | unwatch + openplc-cli debug close --session | --all [--keep-forces] + openplc-cli debug repl [--session ] (interactive; needs a terminal) + openplc-cli debug exec [script|-] [--session ] [--keep-going] (one command per line) Confirmation Builds on a device-side target refuse while its PLC is RUNNING, as the editor warns. @@ -162,7 +162,10 @@ async function dispatch(args: ParsedArgs, reporter: Reporter): Promise { // The upload path is the ordinary `upload` command, driven in-process so // there is exactly one implementation of compile-then-flash. @@ -242,17 +249,22 @@ function buildDebugContext(): DebugContext { * that can resolve to the app entry is therefore refused rather than guessed * at, because the failure mode is "silently starts the wrong program". */ -function cliEntryPath(): string { +function daemonSpawnArgs(): string[] { + // Packaged: the app binary cannot be handed a script — it always runs + // `package.json.main`. `src/main/entry.ts` dispatches on argv instead, so the + // marker alone is the whole instruction. + if (app.isPackaged) return ['--cli-daemon'] + // Dev: Electron was handed this bundle's path, and webpack leaves __filename // as the real runtime path (`node: { __filename: false }`). - const candidate = app.isPackaged ? join(app.getAppPath(), 'dist', 'main', 'cli.js') : (process.argv[1] ?? __filename) - if (!candidate.endsWith('.js')) { + const script = process.argv[1] ?? __filename + if (!script.endsWith('.js')) { throw new Error( - `Cannot locate the CLI bundle to spawn a debug session (resolved "${candidate}"). ` + + `Cannot locate the CLI bundle to spawn a debug session (resolved "${script}"). ` + 'Refusing to re-launch, because a non-bundle path starts the editor GUI instead.', ) } - return candidate + return [script, '--cli-daemon'] } async function main(): Promise { diff --git a/src/cli/output.ts b/src/cli/output.ts index fc15382ad..dfd1039a6 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -79,7 +79,7 @@ export class Reporter { /** * A progress line. Goes to stderr in BOTH modes — in human mode because - * that keeps `openplc compile > log` behaving, in JSON mode because stdout + * that keeps `openplc-cli compile > log` behaving, in JSON mode because stdout * is reserved for the single result document. */ progress(message: string): void { diff --git a/src/cli/session/client.ts b/src/cli/session/client.ts index 835a768df..8521f783a 100644 --- a/src/cli/session/client.ts +++ b/src/cli/session/client.ts @@ -2,7 +2,7 @@ * A one-shot client: connect, send one request, read one response, exit. * * This is the shape every non-REPL debug command takes, and the reason the - * session exists as a separate process. `openplc debug read x --session ` + * session exists as a separate process. `openplc-cli debug read x --session ` * pays a unix-socket round trip, not a connect + MD5 verify + possible * re-upload — so a test can make fifty assertions without fifty reconnects. */ diff --git a/src/main/entry.ts b/src/main/entry.ts new file mode 100644 index 000000000..5a00e3038 --- /dev/null +++ b/src/main/entry.ts @@ -0,0 +1,36 @@ +/** + * The application entry point, which decides what this process is. + * + * A packaged Electron app always runs `package.json.main`; you cannot ask its + * binary to execute a different script. Without a dispatcher here, the headless + * CLI would be unreachable from a packaged build — `cli.js` would sit inside + * `app.asar` with nothing able to start it — and the debug daemon's respawn + * would silently launch the GUI instead, which is exactly the failure this + * guard prevents (a window opening in the middle of a headless test run). + * + * So both roles ship in one binary and the argv decides: + * + * OpenPLC Editor.app/Contents/MacOS/OpenPLC\ Editor --cli devices + * OpenPLC Editor.app/Contents/MacOS/OpenPLC\ Editor --cli-daemon (internal) + * + * The GUI module is imported ONLY when this is not a CLI run. Both modules do + * their work on import (windows, menus, handlers on one side; argv parsing and + * command dispatch on the other), so importing both would start both. + */ + +/** + * Is this process a CLI invocation? + * + * `--cli-daemon` is the debug session daemon re-entering itself; `--cli` is a + * user command. Matched on exact tokens rather than a prefix, so a project path + * that happens to contain "--cli" cannot turn a GUI launch into a CLI one. + */ +export function isCliInvocation(argv: readonly string[]): boolean { + return argv.includes('--cli') || argv.includes('--cli-daemon') +} + +if (isCliInvocation(process.argv)) { + void import('../cli/main') +} else { + void import('./main') +} From f8e754c37613db67822d78992ee89b1c2fbbf9c1 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 21 Aug 2026 07:50:18 -0400 Subject: [PATCH 12/25] feat(cli): install an openplc-cli shim on PATH, on first run, on every OS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--cli` worked but nobody would type `OpenPLC Editor.app/Contents/MacOS/OpenPLC Editor --cli`. The app now puts an `openplc-cli` command on PATH on first run, and `openplc-cli install-cli` does it explicitly for a CI image that never launches the GUI. **User-writable locations only** — `~/.local/bin`, `~/bin`, or `%LOCALAPPDATA%\Programs\openplc-cli`, preferring whichever is already on PATH. No `/usr/local/bin`, no Program Files: a convenience command is not worth an elevation prompt at launch, and an install needing root fails on locked-down machines and succeeds inconsistently elsewhere. Windows gets its per-user PATH updated via PowerShell's `SetEnvironmentVariable` (never `setx`, which truncates at 1024 characters and would silently eat a developer's PATH); POSIX gets the one line to add, because editing someone's `.zshrc` unasked is not an install step. An existing `openplc-cli` that is not ours is left alone. **Ephemeral app locations are refused, with a reason.** A shim is only as durable as the path inside it: - macOS disk image (`/Volumes/…`) — the app is not installed yet; the shim would break on eject. The GUI shows a dialog saying to move it to Applications. - macOS app translocation (`…/AppTranslocation/…`) — Gatekeeper runs a quarantined app from a randomised path that changes every launch. This looks like a normal launch, which is why it needs naming. - Linux AppImage — the MOUNT is ephemeral, but the `.AppImage` file is not, and the runtime exports it as `$APPIMAGE`. The shim targets that (the same mechanism electron-builder's updater relies on). Only a temporary mount with `$APPIMAGE` unset is refused. Three things a Linux container test caught, none of which macOS could show: 1. **Electron cannot start without a display, even with no window.** Ozone initialises during startup: "Missing X server or $DISPLAY. The platform failed to initialize." The shim passes `--ozone-platform=headless`, so callers need no `xvfb-run`. 2. **Chromium's SUID sandbox aborts in containers.** The shim passes `--no-sandbox`, safe for this process alone — it creates no renderer and loads no web content. The GUI never takes that path. Both switches must be on the command line: set from JS they are too late, which is why they live in the shim rather than in `app.commandLine`. 3. **The CLI's own parser swallowed its command.** `--disable-gpu` is not a declared boolean flag, so it consumed the following token: `--cli install-cli` arrived as `disable-gpu=install-cli` with no command, printing the usage and looking like a typo. `cliArgv` now slices at the `--cli` marker — everything before it belongs to Electron. Also: `-h` (which previously became the *command* "-h" and exited 2), usage printed on an unknown command, and exit codes settled — help 0, no arguments 2, unknown command 2. Verified in a debian:12 arm64 container against a packaged build: headless run, install-cli, the generated shim, `openplc-cli` as a plain PATH command in both output modes, exit codes, `devices` exercising the serialport binding, an idempotent re-install, `$APPIMAGE` targeting the file, and a temporary mount being refused with an explanation and no shim written. Note for release: cross-building Linux from macOS packages the *macOS* `@serialport/bindings-cpp` binary ("invalid ELF header"). A Linux artifact must be built on Linux; the container test used the correct prebuild. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli-shim/__tests__/shim-plan.test.ts | 250 ++++++++++++++ src/backend/editor/cli-shim/first-run.ts | 105 ++++++ src/backend/editor/cli-shim/install-shim.ts | 198 +++++++++++ src/backend/editor/cli-shim/shim-plan.ts | 307 ++++++++++++++++++ src/cli/__tests__/cli-argv.test.ts | 36 ++ src/cli/argv.ts | 38 +++ src/cli/commands/install-cli.ts | 88 +++++ src/cli/main.ts | 54 +-- src/main/main.ts | 53 ++- 9 files changed, 1107 insertions(+), 22 deletions(-) create mode 100644 src/backend/editor/cli-shim/__tests__/shim-plan.test.ts create mode 100644 src/backend/editor/cli-shim/first-run.ts create mode 100644 src/backend/editor/cli-shim/install-shim.ts create mode 100644 src/backend/editor/cli-shim/shim-plan.ts create mode 100644 src/cli/__tests__/cli-argv.test.ts create mode 100644 src/cli/argv.ts create mode 100644 src/cli/commands/install-cli.ts diff --git a/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts b/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts new file mode 100644 index 000000000..5abeed8d0 --- /dev/null +++ b/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts @@ -0,0 +1,250 @@ +import { + candidateDirectories, + describeUnstableLocation, + isOnPath, + mayReplace, + pathHint, + planShimInstall, + renderShim, + resolveShimTarget, + SHIM_MARKER, + platformSwitches, + shimFileName, + type ShimEnvironment, +} from '../shim-plan' + +const posix = (overrides: Partial = {}): ShimEnvironment => ({ + platform: 'linux', + home: '/home/dev', + pathVariable: '/usr/bin:/bin', + ...overrides, +}) + +const windows = (overrides: Partial = {}): ShimEnvironment => ({ + platform: 'win32', + home: 'C:\\Users\\dev', + pathVariable: 'C:\\Windows\\system32', + localAppData: 'C:\\Users\\dev\\AppData\\Local', + ...overrides, +}) + +const allWritable = { isWritable: () => true } +const noneWritable = { isWritable: () => false } + +describe('shimFileName', () => { + it('gives Windows an extension the shell will execute', () => { + expect(shimFileName('win32')).toBe('openplc-cli.cmd') + expect(shimFileName('darwin')).toBe('openplc-cli') + expect(shimFileName('linux')).toBe('openplc-cli') + }) +}) + +describe('candidateDirectories', () => { + it('offers only user-writable locations — never /usr/local/bin or Program Files', () => { + // A convenience command must not require an elevation prompt at first launch. + const linux = candidateDirectories(posix()) + expect(linux).toEqual(['/home/dev/.local/bin', '/home/dev/bin']) + expect(linux.join(' ')).not.toContain('/usr/local') + + const win = candidateDirectories(windows()) + expect(win).toEqual(['C:\\Users\\dev\\AppData\\Local\\Programs\\openplc-cli']) + expect(win.join(' ')).not.toMatch(/Program Files/) + }) + + it('falls back to a derived LOCALAPPDATA when the variable is missing', () => { + expect(candidateDirectories(windows({ localAppData: undefined }))).toEqual([ + 'C:\\Users\\dev\\AppData\\Local\\Programs\\openplc-cli', + ]) + }) +}) + +describe('isOnPath', () => { + it('matches ignoring a trailing separator, so no duplicate entry is added', () => { + expect(isOnPath('/home/dev/bin', posix({ pathVariable: '/usr/bin:/home/dev/bin/' }))).toBe(true) + }) + + it('is case-insensitive on Windows only', () => { + expect(isOnPath('C:\\Tools', windows({ pathVariable: 'c:\\tools' }))).toBe(true) + expect(isOnPath('/home/Dev/bin', posix({ pathVariable: '/home/dev/bin' }))).toBe(false) + }) + + it('ignores empty entries and an empty directory', () => { + expect(isOnPath('/home/dev/bin', posix({ pathVariable: '::/home/dev/bin' }))).toBe(true) + expect(isOnPath('', posix({ pathVariable: '::' }))).toBe(false) + }) +}) + +describe('planShimInstall', () => { + it('prefers a writable directory that is already on PATH', () => { + // ~/bin is second in preference but already on PATH, so it wins — the user + // gets a working command with no profile edit. + const plan = planShimInstall(posix({ pathVariable: '/usr/bin:/home/dev/bin' }), allWritable) + expect(plan?.directory).toBe('/home/dev/bin') + expect(plan?.onPath).toBe(true) + expect(plan?.shimPath).toBe('/home/dev/bin/openplc-cli') + }) + + it('falls back to the first writable directory and reports it is not on PATH', () => { + const plan = planShimInstall(posix(), allWritable) + expect(plan?.directory).toBe('/home/dev/.local/bin') + expect(plan?.onPath).toBe(false) + }) + + it('skips a directory it cannot write to', () => { + const plan = planShimInstall(posix(), { isWritable: (d) => d === '/home/dev/bin' }) + expect(plan?.directory).toBe('/home/dev/bin') + }) + + it('returns undefined when nothing is writable, rather than pretending', () => { + expect(planShimInstall(posix(), noneWritable)).toBeUndefined() + }) + + it('reports PATH as editable only on Windows', () => { + expect(planShimInstall(windows(), allWritable)?.canUpdatePath).toBe(true) + expect(planShimInstall(posix(), allWritable)?.canUpdatePath).toBe(false) + }) + + it('joins Windows paths with a backslash', () => { + expect(planShimInstall(windows(), allWritable)?.shimPath).toBe( + 'C:\\Users\\dev\\AppData\\Local\\Programs\\openplc-cli\\openplc-cli.cmd', + ) + }) +}) + +describe('renderShim', () => { + it('execs the target and forwards arguments intact on POSIX', () => { + const shim = renderShim( + { command: '/Applications/OpenPLC Editor.app/Contents/MacOS/OpenPLC Editor', leadingArgs: ['--cli'] }, + 'darwin', + ) + expect(shim).toContain('#!/bin/sh') + // `exec` so the shim does not linger and the exit code passes through; + // `"$@"` so a project path containing spaces survives. + expect(shim).toContain('exec "/Applications/OpenPLC Editor.app/Contents/MacOS/OpenPLC Editor" "--cli" "$@"') + expect(shim).toContain(SHIM_MARKER) + }) + + it('quotes the target and forwards %* on Windows, with CRLF endings', () => { + const shim = renderShim( + { command: 'C:\\Program Files\\OpenPLC Editor\\OpenPLC Editor.exe', leadingArgs: ['--cli'] }, + 'win32', + ) + expect(shim).toContain('@echo off') + expect(shim).toContain('"C:\\Program Files\\OpenPLC Editor\\OpenPLC Editor.exe" "--cli" %*') + expect(shim).toContain('\r\n') + }) + + describe('resolveShimTarget', () => { + it('points at the AppImage FILE, not the ephemeral mount', () => { + // The mount path changes every launch; $APPIMAGE is where the user keeps the + // file, and the AppImage runtime forwards arguments to the app. + const target = resolveShimTarget('/tmp/.mount_OpenPLxYz/openplc-editor', { + ...posix(), + appImagePath: '/home/dev/Applications/OpenPLC-Editor.AppImage', + }) + expect(target).toBe('/home/dev/Applications/OpenPLC-Editor.AppImage') + }) + + it('uses the running executable when there is no AppImage', () => { + expect(resolveShimTarget('/opt/openplc/openplc-editor', posix())).toBe('/opt/openplc/openplc-editor') + }) + + it('ignores $APPIMAGE off Linux, where it means nothing', () => { + const target = resolveShimTarget('/Applications/X.app/Contents/MacOS/X', { + ...posix({ platform: 'darwin' }), + appImagePath: '/somewhere/Weird.AppImage', + }) + expect(target).toBe('/Applications/X.app/Contents/MacOS/X') + }) + }) + + describe('describeUnstableLocation', () => { + it('refuses a macOS disk image and says to install to Applications', () => { + const reason = describeUnstableLocation('/Volumes/OpenPLC Editor/OpenPLC Editor.app/Contents/MacOS/x', 'darwin') + expect(reason).toMatch(/disk image/i) + expect(reason).toMatch(/Applications/) + }) + + it('refuses a Gatekeeper-translocated app, which looks like a normal launch', () => { + const reason = describeUnstableLocation( + '/private/var/folders/ab/AppTranslocation/UUID/d/OpenPLC Editor.app/Contents/MacOS/x', + 'darwin', + ) + expect(reason).toMatch(/quarantined|randomised/i) + }) + + it('refuses a temporary AppImage mount only when $APPIMAGE gave nothing', () => { + expect(describeUnstableLocation('/tmp/.mount_abc/openplc', 'linux')).toMatch(/APPIMAGE/) + // With the file path resolved, the location is stable and allowed. + expect(describeUnstableLocation('/home/dev/Apps/OpenPLC.AppImage', 'linux')).toBeUndefined() + }) + + it('allows an installed app, and never blocks Windows', () => { + expect(describeUnstableLocation('/Applications/OpenPLC Editor.app/Contents/MacOS/x', 'darwin')).toBeUndefined() + expect(describeUnstableLocation('C:\\Program Files\\OpenPLC\\x.exe', 'win32')).toBeUndefined() + }) + }) + + describe('mayReplace', () => { + it('writes when nothing is there, and replaces only our own shim', () => { + expect(mayReplace(undefined)).toBe(true) + expect(mayReplace(`#!/bin/sh\n# ${SHIM_MARKER}\n`)).toBe(true) + // Someone else's openplc-cli on PATH is not ours to overwrite. + expect(mayReplace('#!/bin/sh\nexec /opt/mine/openplc-cli "$@"\n')).toBe(false) + }) + }) + + describe('pathHint', () => { + it('says nothing when the directory is already on PATH', () => { + const plan = planShimInstall(posix({ pathVariable: '/home/dev/.local/bin' }), allWritable) + expect(plan && pathHint(plan, 'linux')).toBeUndefined() + }) + + it('gives a copyable line on POSIX instead of editing a shell profile', () => { + const plan = planShimInstall(posix(), allWritable) + const hint = plan && pathHint(plan, 'linux') + expect(hint).toContain('/home/dev/.local/bin') + expect(hint).toContain('~/.profile') + }) + + it('tells Windows users a new terminal is needed', () => { + const plan = planShimInstall(windows(), allWritable) + expect(plan && pathHint(plan, 'win32')).toMatch(/new terminal/i) + }) + }) + + it('repeats a development script path, or the shim runs plain Electron', () => { + // Without the script, `openplc-cli --version` answered with Electron's own + // version and looked like it had worked. + const shim = renderShim( + { command: '/repo/node_modules/electron/dist/Electron', leadingArgs: ['/repo/openplc-cli.dev.js', '--cli'] }, + 'linux', + ) + // Switches precede the script path, which is where Electron expects them. + expect(shim).toContain( + 'exec "/repo/node_modules/electron/dist/Electron" "--no-sandbox" "--ozone-platform=headless" ' + + '"--disable-gpu" "/repo/openplc-cli.dev.js" "--cli" "$@"', + ) + }) +}) + +describe('platformSwitches', () => { + it('passes the Linux switches Chromium reads before our script runs', () => { + // Set from JS they are too late: the SUID sandbox and Ozone both initialise + // during startup, so `appendSwitch` never gets a chance. The shim IS the + // command line, which is why they live here. + expect(platformSwitches('linux')).toEqual(['--no-sandbox', '--ozone-platform=headless', '--disable-gpu']) + }) + + it('adds nothing on macOS or Windows, which have neither problem', () => { + expect(platformSwitches('darwin')).toEqual([]) + expect(platformSwitches('win32')).toEqual([]) + }) + + it('renders them into the Linux shim ahead of the CLI marker', () => { + const shim = renderShim({ command: '/home/dev/App.AppImage', leadingArgs: ['--cli'] }, 'linux') + expect(shim).toContain( + 'exec "/home/dev/App.AppImage" "--no-sandbox" "--ozone-platform=headless" "--disable-gpu" "--cli" "$@"', + ) + }) +}) diff --git a/src/backend/editor/cli-shim/first-run.ts b/src/backend/editor/cli-shim/first-run.ts new file mode 100644 index 000000000..e0638848b --- /dev/null +++ b/src/backend/editor/cli-shim/first-run.ts @@ -0,0 +1,105 @@ +/** + * Installing the `openplc-cli` shim once, on first run. + * + * Kept separate from `installCliShim` because "should we do this at all right + * now" is a different question from "how". Two things it owns: + * + * - **Idempotence across launches.** A marker file records what was installed + * and for which app path, so a normal launch does no filesystem work and a + * MOVED app (dragged from the disk image to Applications, say) re-installs + * rather than leaving a shim pointing at a path that no longer exists. + * - **Telling the user when it could not.** The disk-image case is the one + * where the user has to act, and silence there means an `openplc-cli` that + * never appears with no explanation. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { installCliShim, type InstallShimResult } from './install-shim' +import type { ShimEnvironment } from './shim-plan' + +/** What the last attempt did, so a later launch can tell whether to redo it. */ +interface ShimState { + /** The app path the installed shim points at. */ + target: string + shimPath: string + installedAt: string +} + +export interface FirstRunOptions { + /** `userData/User/cli-shim.json` normally; injected for tests. */ + statePath: string + appBinaryPath: string + /** See `InstallShimOptions.leadingArgs`. */ + leadingArgs: string[] + environment: ShimEnvironment & { appImagePath?: string } + /** Shown only when the user has to act (running from a disk image). */ + warn: (message: string) => void + onDiagnostic?: (message: string) => void +} + +export type FirstRunOutcome = InstallShimResult | { status: 'already-current'; shimPath: string } + +export async function ensureCliShimInstalled(options: FirstRunOptions): Promise { + const previous = readState(options.statePath) + const expectedTarget = options.environment.appImagePath ?? options.appBinaryPath + + // Nothing to do: the recorded shim points at this same app and is still there. + if (previous && previous.target === expectedTarget && existsSync(previous.shimPath)) { + return { status: 'already-current', shimPath: previous.shimPath } + } + + const result = await installCliShim({ + appBinaryPath: options.appBinaryPath, + leadingArgs: options.leadingArgs, + environment: options.environment, + onDiagnostic: options.onDiagnostic, + }) + + if (result.status === 'installed' || result.status === 'unchanged') { + writeState(options.statePath, { + target: expectedTarget, + shimPath: result.shimPath, + installedAt: new Date().toISOString(), + }) + return result + } + + // `skipped` is the actionable case — the app is somewhere a shim cannot point + // at (a mounted disk image, a translocated copy). Deliberately NOT recorded, + // so moving the app to Applications retries on the next launch. + if (result.status === 'skipped') options.warn(result.reason) + return result +} + +function readState(path: string): ShimState | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + if (typeof parsed !== 'object' || parsed === null) return undefined + const record: Record = { ...parsed } + if (typeof record.target !== 'string' || typeof record.shimPath !== 'string') return undefined + return { + target: record.target, + shimPath: record.shimPath, + installedAt: typeof record.installedAt === 'string' ? record.installedAt : '', + } + } catch { + return undefined + } +} + +function writeState(path: string, state: ShimState): void { + try { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf-8') + } catch { + // A missing marker only costs a redundant install next launch, which is + // idempotent — not worth failing a launch over. + } +} + +/** Where the marker lives, given the app's userData directory. */ +export function shimStatePath(userDataPath: string): string { + return join(userDataPath, 'User', 'cli-shim.json') +} diff --git a/src/backend/editor/cli-shim/install-shim.ts b/src/backend/editor/cli-shim/install-shim.ts new file mode 100644 index 000000000..6928e4f14 --- /dev/null +++ b/src/backend/editor/cli-shim/install-shim.ts @@ -0,0 +1,198 @@ +/** + * Installing the `openplc-cli` shim. + * + * The policy is in `shim-plan.ts` (pure); this is the filesystem and the + * Windows PATH edit. Two rules shape it: + * + * - **Never elevate.** A first-run install that asks for an admin password + * gets declined, and an IDE should not need root to add a convenience + * command. Everything here works as the logged-in user or reports why it + * could not. + * - **Never surprise.** An existing `openplc-cli` that is not ours is left + * alone, and on POSIX we do not edit shell profiles — we print the one line + * the user can add themselves. + */ + +import { execFile } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import { + describeUnstableLocation, + mayReplace, + pathHint, + planShimInstall, + renderShim, + resolveShimTarget, + type ShimEnvironment, +} from './shim-plan' + +const run = promisify(execFile) + +export type InstallShimResult = + | { status: 'installed'; shimPath: string; onPath: boolean; hint?: string } + | { status: 'unchanged'; shimPath: string; onPath: boolean; hint?: string } + | { status: 'skipped'; reason: string } + | { status: 'failed'; reason: string } + +export interface InstallShimOptions { + /** The running app executable. On a Linux AppImage this is the mount point, + * and `environment.appImagePath` supersedes it — see `resolveShimTarget`. */ + appBinaryPath: string + /** + * Arguments the shim must pass before the user's own. `['--cli']` for a + * packaged build; a development build also needs its script path first, or the + * shim runs plain Electron. + */ + leadingArgs: string[] + environment: ShimEnvironment & { appImagePath?: string } + /** Write even when a same-content shim already exists. */ + force?: boolean + onDiagnostic?: (message: string) => void +} + +/** Can we create files here? Tested by creating the directory, not by guessing at modes. */ +function directoryIsWritable(directory: string): boolean { + try { + mkdirSync(directory, { recursive: true }) + } catch { + return false + } + // `access(W_OK)` lies on some network and container filesystems, so probe by + // actually writing — the only answer that matters is whether a write works. + const probe = join(directory, `.openplc-cli-probe-${process.pid}`) + try { + writeFileSync(probe, '') + return true + } catch { + return false + } finally { + try { + rmSync(probe, { force: true }) + } catch { + /* the probe is disposable */ + } + } +} + +export async function installCliShim(options: InstallShimOptions): Promise { + const { appBinaryPath, environment } = options + const diagnostic = options.onDiagnostic ?? (() => undefined) + + // What the shim will actually invoke: the AppImage FILE on Linux when the + // runtime told us where it is, otherwise the running executable. + const target = resolveShimTarget(appBinaryPath, environment) + + const unstable = describeUnstableLocation(target, environment.platform) + if (unstable) return { status: 'skipped', reason: unstable } + + const plan = planShimInstall(environment, { isWritable: directoryIsWritable }) + if (!plan) { + return { + status: 'failed', + reason: + 'No writable directory was found for the openplc-cli command. Tried: ' + + `${candidatesFor(environment).join(', ')}.`, + } + } + + const contents = renderShim({ command: target, leadingArgs: options.leadingArgs }, environment.platform) + const existing = existsSync(plan.shimPath) ? readSafely(plan.shimPath) : undefined + + if (!mayReplace(existing)) { + return { + status: 'skipped', + reason: + `${plan.shimPath} already exists and was not created by OpenPLC Editor, so it was left untouched. ` + + 'Remove it and restart if you want the editor to manage it.', + } + } + + if (existing === contents && !options.force) { + const hint = await ensureOnPath(plan, environment, diagnostic) + return { status: 'unchanged', shimPath: plan.shimPath, onPath: plan.onPath, hint } + } + + try { + writeFileSync(plan.shimPath, contents, 'utf-8') + // 0o755: executable by everyone, writable only by the owner. Windows infers + // executability from the .cmd extension and has no mode to set. + if (environment.platform !== 'win32') chmodSync(plan.shimPath, 0o755) + } catch (error) { + return { + status: 'failed', + reason: `Could not write ${plan.shimPath}: ${error instanceof Error ? error.message : String(error)}`, + } + } + + const hint = await ensureOnPath(plan, environment, diagnostic) + diagnostic(`Installed openplc-cli at ${plan.shimPath}`) + return { status: 'installed', shimPath: plan.shimPath, onPath: plan.onPath, hint } +} + +function candidatesFor(environment: ShimEnvironment): string[] { + // Re-derived for the message only; `planShimInstall` already probed them. + return environment.platform === 'win32' + ? [`${environment.localAppData ?? environment.home}\\Programs\\openplc-cli`] + : [`${environment.home}/.local/bin`, `${environment.home}/bin`] +} + +function readSafely(path: string): string | undefined { + try { + return readFileSync(path, 'utf-8') + } catch { + return undefined + } +} + +/** + * Put the directory on PATH where the platform allows it. + * + * Windows has a per-user PATH we can edit; POSIX PATH comes from the user's + * shell profile, and editing someone's `.zshrc` unasked is not an install step — + * so there we return the line for them to add. + */ +async function ensureOnPath( + plan: ReturnType & object, + environment: ShimEnvironment, + diagnostic: (message: string) => void, +): Promise { + if (plan.onPath) return undefined + if (environment.platform !== 'win32') return pathHint(plan, environment.platform) + + try { + await appendToWindowsUserPath(plan.directory) + diagnostic(`Added ${plan.directory} to the user PATH`) + return pathHint(plan, environment.platform) + } catch (error) { + return ( + `Installed the command, but could not add ${plan.directory} to your PATH ` + + `(${error instanceof Error ? error.message : String(error)}). Add it manually.` + ) + } +} + +/** + * Append a directory to the *user* PATH on Windows. + * + * PowerShell's `SetEnvironmentVariable`, deliberately not `setx`: setx truncates + * the value at 1024 characters, and a developer's PATH is routinely longer than + * that — it would silently destroy entries. Reads the current user-scope value + * (not the process one, which is the user and machine values already merged) so + * the machine PATH is never copied into the user's. + */ +async function appendToWindowsUserPath(directory: string): Promise { + const script = [ + '$dir = $args[0]', + "$current = [Environment]::GetEnvironmentVariable('Path', 'User')", + "if ([string]::IsNullOrEmpty($current)) { $current = '' }", + "$entries = $current.Split(';') | Where-Object { $_ -ne '' }", + 'if ($entries -notcontains $dir) {', + " $updated = (@($entries) + $dir) -join ';'", + " [Environment]::SetEnvironmentVariable('Path', $updated, 'User')", + '}', + ].join('; ') + + await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script, directory]) +} diff --git a/src/backend/editor/cli-shim/shim-plan.ts b/src/backend/editor/cli-shim/shim-plan.ts new file mode 100644 index 000000000..be3e3fc76 --- /dev/null +++ b/src/backend/editor/cli-shim/shim-plan.ts @@ -0,0 +1,307 @@ +/** + * Deciding WHERE the `openplc-cli` shim goes and WHAT it contains. + * + * Pure: every filesystem and environment fact is passed in, so the policy can be + * tested for all three platforms from any one of them. The IO lives in + * `install-shim.ts`. + * + * The policy exists because the obvious answer — "write to /usr/local/bin" — is + * wrong on a clean machine. That directory is root-owned on stock macOS and on + * most Linux distributions, so a first-run install there either fails or has to + * ask for an admin password at launch. Users decline that, reasonably, and an + * IDE that demands elevation to install a convenience command has mispriced the + * convenience. + * + * So the order is: a directory that is already on PATH and writable without + * elevation, then `/usr/local/bin` when it happens to be writable (common on a + * developer's Mac with Homebrew, where it is also certainly on PATH), then a + * user directory we create ourselves — reporting that it is not on PATH rather + * than pretending the install succeeded silently. + */ + +/** Marks the file as ours, so an install never clobbers someone else's shim. */ +export const SHIM_MARKER = 'generated by OpenPLC Editor' + +export type ShimPlatform = 'darwin' | 'linux' | 'win32' + +export interface ShimEnvironment { + platform: ShimPlatform + /** `$HOME` / `%USERPROFILE%`. */ + home: string + /** `$PATH` / `%Path%`, raw. */ + pathVariable: string + /** `%LOCALAPPDATA%`, Windows only. */ + localAppData?: string +} + +export interface ShimProbes { + /** Can we create or replace files in this directory (creating it if absent)? */ + isWritable: (directory: string) => boolean +} + +export interface ShimCandidate { + directory: string + /** True when the directory already appears in PATH. */ + onPath: boolean + writable: boolean +} + +/** The shim's filename — Windows needs an extension the shell will execute. */ +export function shimFileName(platform: ShimPlatform): string { + return platform === 'win32' ? 'openplc-cli.cmd' : 'openplc-cli' +} + +/** PATH separator for the platform, since PATH is parsed here, not spawned. */ +export function pathSeparator(platform: ShimPlatform): string { + return platform === 'win32' ? ';' : ':' +} + +/** + * Is `directory` on PATH? Compared case-insensitively on Windows, and with any + * trailing separator stripped, because `C:\bin\` and `C:\bin` are the same + * place and a mismatch here would add a duplicate PATH entry on every launch. + */ +export function isOnPath(directory: string, environment: ShimEnvironment): boolean { + const normalise = (value: string): string => { + const trimmed = value.trim().replace(/[/\\]+$/, '') + return environment.platform === 'win32' ? trimmed.toLowerCase() : trimmed + } + const target = normalise(directory) + if (target.length === 0) return false + return environment.pathVariable + .split(pathSeparator(environment.platform)) + .some((entry) => entry.length > 0 && normalise(entry) === target) +} + +/** + * Candidate directories, best first, before writability is known. + * + * User-writable locations only. No `/usr/local/bin`, no Program Files: a + * convenience command is not worth an elevation prompt at first launch, and an + * install that needs root is one that fails on a locked-down machine and + * succeeds inconsistently everywhere else. + */ +export function candidateDirectories(environment: ShimEnvironment): string[] { + if (environment.platform === 'win32') { + // The convention per-user installers use; `%LOCALAPPDATA%\Programs` needs no + // elevation, unlike Program Files. + const base = environment.localAppData ?? `${environment.home}\\AppData\\Local` + return [`${base}\\Programs\\openplc-cli`] + } + // `~/.local/bin` is the XDG convention most modern distributions already put + // on PATH; `~/bin` is the older one Debian's default profile still adds when + // it exists, so a user who has that instead is not forced to grow a new + // directory. Whichever is already on PATH wins in `planShimInstall`. + return [`${environment.home}/.local/bin`, `${environment.home}/bin`] +} + +/** + * The path a shim should invoke — which is not always the running executable. + * + * A Linux AppImage runs from a fresh mount point every launch + * (`/tmp/.mount_…/…`), so a shim pointing at the running executable is stale by + * the next run. The AppImage runtime exports the path of the `.AppImage` FILE it + * booted from as `$APPIMAGE`, and that path IS stable — it is wherever the user + * keeps the file. The AppImage runtime forwards arguments to the app, so + * `$APPIMAGE --cli devices` works exactly like the mounted binary would. + * + * (This is the same mechanism electron-builder's updater uses to find itself, + * for the same reason.) + */ +export function resolveShimTarget( + appBinaryPath: string, + environment: ShimEnvironment & { appImagePath?: string }, +): string { + if (environment.platform === 'linux' && environment.appImagePath) return environment.appImagePath + return appBinaryPath +} + +export interface ShimPlan { + directory: string + fileName: string + /** Absolute path of the shim to write. */ + shimPath: string + /** Whether the chosen directory is already on PATH. */ + onPath: boolean + /** + * True when the platform lets us put the directory on PATH ourselves. + * Windows has a per-user PATH we can edit; on POSIX, PATH comes from the + * user's shell profile, and silently editing someone's `.zshrc` is not + * something an IDE should do behind their back. + */ + canUpdatePath: boolean + candidates: ShimCandidate[] +} + +/** + * Choose where the shim goes. Returns undefined only when nothing is writable, + * which is a real failure worth reporting rather than papering over. + */ +export function planShimInstall(environment: ShimEnvironment, probes: ShimProbes): ShimPlan | undefined { + const candidates: ShimCandidate[] = candidateDirectories(environment).map((directory) => ({ + directory, + onPath: isOnPath(directory, environment), + writable: probes.isWritable(directory), + })) + + const writable = candidates.filter((candidate) => candidate.writable) + if (writable.length === 0) return undefined + + // Prefer a writable directory already on PATH; otherwise the first writable + // one, and the caller tells the user how to add it. + const chosen = writable.find((candidate) => candidate.onPath) ?? writable[0] + const fileName = shimFileName(environment.platform) + + return { + directory: chosen.directory, + fileName, + shimPath: environment.platform === 'win32' ? `${chosen.directory}\\${fileName}` : `${chosen.directory}/${fileName}`, + onPath: chosen.onPath, + canUpdatePath: environment.platform === 'win32', + candidates, + } +} + +/** + * How the shim invokes the app. + * + * `leadingArgs` exists because "the app" is not always one path. A packaged + * build's `process.execPath` IS the app, so `--cli` alone is the whole + * instruction. In development Electron is a generic binary handed a script, so + * the script path has to come first — a shim that omitted it ran plain Electron, + * which cheerfully answered `--version` with ITS version and looked like it + * worked. + */ +export interface ShimInvocation { + command: string + leadingArgs: string[] +} + +/** + * Chromium switches the shim must pass on Linux. + * + * These cannot be set from JavaScript: Chromium reads them while starting up, + * before the main script runs, so `app.commandLine.appendSwitch` is too late. + * The shim is a command line, which is exactly what they need to be on. + * + * - `--no-sandbox`: the SUID sandbox helper must be root-owned and mode 4755, + * which it is not inside a container — the environment CI runs in. Safe for + * this process specifically: it creates no renderer and loads no web + * content, so the sandbox has nothing to isolate. The GUI never gets this. + * - `--ozone-platform=headless`: Electron initialises Ozone before any window + * exists and exits when it finds neither X11 nor Wayland ("Missing X server + * or $DISPLAY. The platform failed to initialize."). A CLI that never opens + * a window still needs this, and it saves callers wrapping everything in + * `xvfb-run`. + */ +export function platformSwitches(platform: ShimPlatform): string[] { + if (platform !== 'linux') return [] + return ['--no-sandbox', '--ozone-platform=headless', '--disable-gpu'] +} + +/** + * The shim itself. + * + * `exec` on POSIX so the shim does not linger as a parent process, and signals + * and the exit code pass straight through — a test harness reads that exit code. + * `"$@"` (not `$*`) keeps arguments with spaces intact, which matters because + * project paths routinely contain them. + */ +export function renderShim(invocation: ShimInvocation, platform: ShimPlatform): string { + const quoted = [invocation.command, ...platformSwitches(platform), ...invocation.leadingArgs] + .map((part) => `"${part}"`) + .join(' ') + if (platform === 'win32') { + return [ + '@echo off', + `rem openplc-cli — ${SHIM_MARKER}. Regenerated on update; edits will be lost.`, + `${quoted} %*`, + '', + ].join('\r\n') + } + return [ + '#!/bin/sh', + `# openplc-cli — ${SHIM_MARKER}. Regenerated on update; edits will be lost.`, + `exec ${quoted} "$@"`, + '', + ].join('\n') +} + +/** + * May we write over what is already at `shimPath`? + * + * Only if we recognise it as ours. Someone else's `openplc-cli` on PATH is not + * ours to replace, and overwriting it would be the kind of surprise that makes + * people distrust an installer. + */ +export function mayReplace(existingContent: string | undefined): boolean { + if (existingContent === undefined) return true + return existingContent.includes(SHIM_MARKER) +} + +/** + * Why this app's location is too ephemeral to point a shim at. + * + * A shim is a path written into a file that outlives the process, so it is only + * as durable as the path. Three cases where it is not durable at all: + * + * - **macOS, running from a mounted disk image** (`/Volumes/…`). The app has + * not been installed yet; the shim would break the moment the image is + * ejected, and the user would be left with an `openplc-cli` on PATH that + * fails with a confusing "no such file". + * - **macOS app translocation** (`…/AppTranslocation/…`). Gatekeeper runs a + * quarantined app from a randomised read-only path that changes on every + * launch, so even a shim written now would be stale by the next one. This is + * what happens when someone runs the app straight out of Downloads, and it + * looks like a normal launch — which is exactly why it needs naming. + * - **Linux AppImage** (`/tmp/.mount_…`). The image mounts itself at a fresh + * temporary path per run. + * + * Returns undefined when the location is fine. + */ +export function describeUnstableLocation(appBinaryPath: string, platform: ShimPlatform): string | undefined { + if (platform === 'win32') return undefined + + if (appBinaryPath.includes('/AppTranslocation/')) { + return ( + 'OpenPLC Editor is running from a temporary, randomised location because macOS has quarantined it ' + + '(this happens when the app is launched straight from Downloads). Move OpenPLC Editor to your ' + + 'Applications folder and open it from there to install the openplc-cli command.' + ) + } + + if (platform === 'darwin' && appBinaryPath.startsWith('/Volumes/')) { + return ( + 'OpenPLC Editor is running from a disk image, so the openplc-cli command cannot be installed — it ' + + 'would stop working as soon as the image is ejected. Drag OpenPLC Editor into your Applications ' + + 'folder, then open it from there.' + ) + } + + if (platform === 'linux' && appBinaryPath.startsWith('/tmp/.mount_')) { + // Reached only when `$APPIMAGE` was absent — `resolveShimTarget` prefers it, + // and it is the stable path to the .AppImage file. Missing it means the app + // is running from an extracted or unpacked image with nothing durable to + // point at. + return ( + 'OpenPLC Editor is running from a temporary mount and the AppImage file path is not available ' + + '($APPIMAGE is unset), so the openplc-cli command cannot point at it reliably. Run the .AppImage ' + + 'file directly, or install the .deb / .rpm package.' + ) + } + + return undefined +} + +/** What to tell a user whose chosen directory is not on PATH. */ +export function pathHint(plan: ShimPlan, platform: ShimPlatform): string | undefined { + if (plan.onPath) return undefined + if (platform === 'win32') { + return `Added ${plan.directory} to your user PATH. Open a new terminal for it to take effect.` + } + return ( + `${plan.directory} is not on your PATH. Add it with:\n` + + ` echo 'export PATH="${plan.directory}:$PATH"' >> ~/.profile\n` + + 'then open a new terminal.' + ) +} diff --git a/src/cli/__tests__/cli-argv.test.ts b/src/cli/__tests__/cli-argv.test.ts new file mode 100644 index 000000000..540d8354a --- /dev/null +++ b/src/cli/__tests__/cli-argv.test.ts @@ -0,0 +1,36 @@ +import { cliArgv } from '../argv' + +describe('cliArgv', () => { + it('takes everything after the --cli marker, discarding launcher switches', () => { + // The shim passes Chromium switches on Linux. Feeding them to the CLI's own + // parser broke it: `--disable-gpu` is not a declared boolean, so it consumed + // the next token and `install-cli` vanished as its value. + const argv = [ + '/opt/app/open-plc-editor', + '--no-sandbox', + '--ozone-platform=headless', + '--disable-gpu', + '--cli', + 'install-cli', + '--no-json', + ] + expect(cliArgv(argv)).toEqual(['install-cli', '--no-json']) + }) + + it('keeps the user arguments intact, including ones that look like switches', () => { + const argv = ['/opt/app/x', '--cli', 'debug', 'read', '--var', 'main:counter'] + expect(cliArgv(argv)).toEqual(['debug', 'read', '--var', 'main:counter']) + }) + + it('drops a leading script path when there is no marker (development bundle)', () => { + expect(cliArgv(['/path/electron', '/repo/openplc-cli.dev.js', 'devices'])).toEqual(['devices']) + }) + + it('keeps the first argument when it is not a script path', () => { + expect(cliArgv(['/path/electron', 'devices', '--timeout', '2000'])).toEqual(['devices', '--timeout', '2000']) + }) + + it('returns nothing for a bare launch', () => { + expect(cliArgv(['/opt/app/x', '--cli'])).toEqual([]) + }) +}) diff --git a/src/cli/argv.ts b/src/cli/argv.ts new file mode 100644 index 000000000..27f41dd84 --- /dev/null +++ b/src/cli/argv.ts @@ -0,0 +1,38 @@ +/** + * Separating the CLI's own arguments from the launcher's. + * + * Its own module because it is pure and needs testing: importing `main.ts` + * starts the CLI and reaches for Electron's `app`, so the rule could not be + * covered where it used to live — and it is a rule with a sharp edge, below. + */ + +/** + * The arguments that are OURS, separated from the launcher's. + * + * Everything before the `--cli` marker belongs to Electron: the executable, a + * script path in development, and the Chromium switches the shim passes + * (`--no-sandbox`, `--ozone-platform=headless`, …). Slicing at the marker is not + * tidiness — feeding those switches to this parser actively breaks it. Found in + * a container: `--disable-gpu` is not a declared boolean flag, so it consumed + * the following token as its value and `--cli install-cli` arrived as the flag + * `disable-gpu=install-cli` with no command at all, which printed the usage and + * looked like a mis-typed command. + * + * Without the marker (a development bundle invoked directly) fall back to + * dropping argv[0] and a leading script path. + */ +export function cliArgv(argv: readonly string[]): string[] { + const marker = argv.indexOf('--cli') + if (marker !== -1) return argv.slice(marker + 1) + + const rest = argv.slice(1) + if (rest.length > 0 && !rest[0].startsWith('-') && looksLikeEntryPath(rest[0])) { + return rest.slice(1) + } + return rest +} + +/** A script path Electron was handed, as opposed to the user's first argument. */ +function looksLikeEntryPath(value: string): boolean { + return value.endsWith('.js') || value.endsWith('.ts') || value.endsWith('.asar') || value.includes('/dist/') +} diff --git a/src/cli/commands/install-cli.ts b/src/cli/commands/install-cli.ts new file mode 100644 index 000000000..1665e9cbb --- /dev/null +++ b/src/cli/commands/install-cli.ts @@ -0,0 +1,88 @@ +/** + * `openplc-cli install-cli` — put (or refresh) the `openplc-cli` shim on PATH. + * + * The GUI does this on first run; this is the explicit form, for a CI image that + * never launches the GUI and for re-running it after the app moves. Same code + * path either way. + */ + +import { homedir } from 'node:os' +import { resolve } from 'node:path' + +import { installCliShim } from '@root/backend/editor/cli-shim/install-shim' +import type { ShimPlatform } from '@root/backend/editor/cli-shim/shim-plan' +import { app } from 'electron' + +import type { ParsedArgs } from '../args' +import { ErrorCode, ExitCode } from '../exit-codes' +import type { CliResult, Reporter } from '../output' + +export async function runInstallCli(_args: ParsedArgs, reporter: Reporter): Promise { + const platform = currentPlatform() + if (!platform) { + return reporter.failure( + { code: ErrorCode.Internal, message: `Unsupported platform "${process.platform}"` }, + ExitCode.Internal, + ) + } + + const result = await installCliShim({ + appBinaryPath: process.execPath, + leadingArgs: cliLeadingArgs(), + environment: { + platform, + home: homedir(), + pathVariable: process.env.PATH ?? '', + localAppData: process.env.LOCALAPPDATA, + // Set by the AppImage runtime to the path of the .AppImage file itself, + // which is stable — unlike the mount point `process.execPath` reports. + appImagePath: process.env.APPIMAGE, + }, + // Explicit invocation means "make it so", so an identical existing shim is + // rewritten rather than reported as a no-op. + force: true, + onDiagnostic: (message) => reporter.progress(message), + }) + + switch (result.status) { + case 'installed': + case 'unchanged': + return reporter.success( + { shimPath: result.shimPath, onPath: result.onPath, hint: result.hint }, + () => `openplc-cli installed at ${result.shimPath}${result.hint ? `\n\n${result.hint}` : ''}`, + ) + case 'skipped': + // Not a failure of the command: the app is somewhere a shim cannot point + // at, and the message says what to do about it. + return reporter.failure({ code: ErrorCode.InvalidArgument, message: result.reason }, ExitCode.Usage) + case 'failed': + return reporter.failure({ code: ErrorCode.Internal, message: result.reason }, ExitCode.Internal) + } +} + +/** Narrow `process.platform` to the three the shim supports. */ +function currentPlatform(): ShimPlatform | undefined { + if (process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32') { + return process.platform + } + return undefined +} + +/** + * What the shim must pass before the user's arguments. + * + * A packaged build dispatches on `--cli` inside its own binary. A development + * build is Electron plus a script, and the script path has to be repeated or the + * shim launches plain Electron — which answers `--version` with Electron's own + * and looks like it worked. + */ +function cliLeadingArgs(): string[] { + if (app.isPackaged) return ['--cli'] + const script = process.argv[1] + // Absolutised: argv[1] is whatever the caller typed, often relative + // (`./openplc-cli.dev.js`). A shim carrying a relative path only works from + // the directory it was installed from — which is exactly what a shim on PATH + // is meant to free you from, and it fails by HANGING rather than erroring + // (Electron given a missing script waits instead of exiting). + return script && script.endsWith('.js') ? [resolve(script), '--cli'] : ['--cli'] +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 71bb211ab..9fd683ed8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -28,9 +28,11 @@ import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { app } from 'electron' import { boolFlag, parseArgs, type ParsedArgs, stringFlag } from './args' +import { cliArgv } from './argv' import { runBuild } from './commands/build' import { type DebugContext, runDebug } from './commands/debug' import { runDevices } from './commands/devices' +import { runInstallCli } from './commands/install-cli' import { runDaemonFromStdin } from './daemon-entry' import { ErrorCode, ExitCode, type ExitCodeValue } from './exit-codes' import { createProcessReporter, Reporter } from './output' @@ -61,6 +63,7 @@ const COMMANDS_WITH_SUBCOMMANDS = ['debug'] as const const USAGE = `openplc-cli — headless OpenPLC Editor Usage + openplc-cli install-cli (put openplc-cli on your PATH) openplc-cli devices [--timeout ] openplc-cli compile [--target ] [--port ] [--clean] openplc-cli upload (--host
| --port ) [--target ] [--clean] [-y|--yes] @@ -171,6 +174,8 @@ async function dispatch(args: ParsedArgs, reporter: Reporter): Promise { + enableHeadlessPlatform() + // The daemon reads its config from stdin and never parses argv. if (process.argv.includes('--cli-daemon')) { alignUserDataWithEditor(undefined) @@ -300,26 +334,6 @@ async function main(): Promise { app.exit(exitCode) } -/** - * Strip the launcher's own arguments. - * - * Packaged: `[app, ...userArgs]`. Development: `[electron, scriptPath, - * ...userArgs]`. Getting this wrong makes the first user argument disappear, - * which reads as a missing command rather than as an argv bug. - */ -export function cliArgv(argv: readonly string[]): string[] { - const rest = argv.slice(1) - const withoutMarker = rest.filter((argument) => argument !== '--cli') - if (withoutMarker.length > 0 && !withoutMarker[0].startsWith('-') && looksLikeEntryPath(withoutMarker[0])) { - return withoutMarker.slice(1) - } - return withoutMarker -} - -function looksLikeEntryPath(value: string): boolean { - return value.endsWith('.js') || value.endsWith('.ts') || value.endsWith('.asar') || value.includes('/dist/') -} - /** `stringFlag` is re-exported for the daemon entry, which shares the parser. */ export { stringFlag } diff --git a/src/main/main.ts b/src/main/main.ts index 4199bdd88..b90b8398c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,14 +6,15 @@ * When running `npm run build` or `npm run build:main`, this file is compiled to * `./src/main.js` using webpack. This gives us some performance wins. */ -import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron' import Installer from 'electron-devtools-installer' import log from 'electron-log' import { autoUpdater } from 'electron-updater' import { enableMapSet } from 'immer' -import { platform, release } from 'os' +import { homedir, platform, release } from 'os' import { join, resolve } from 'path' +import { ensureCliShimInstalled, shimStatePath } from '../backend/editor/cli-shim/first-run' import { CompilerModule } from '../backend/editor/compiler' // TODO: Refactor this type declaration import { MainIpcModuleConstructor } from '../backend/editor/contracts/types/modules/ipc/main' @@ -407,6 +408,10 @@ app .whenReady() .then(() => { void createMainWindow() + // Put `openplc-cli` on PATH, once. After the window, so a slow filesystem + // never delays the app appearing, and best-effort: a convenience command + // failing to install is not a reason for the editor not to start. + void installCliShimOnFirstRun() // Handle the app activation event; app.on('activate', () => { // On macOS it's common to re-create a window in the app when the @@ -415,3 +420,47 @@ app }) }) .catch((err: unknown) => logger.error(getErrorMessage(err))) + +/** + * Install the `openplc-cli` shim on first run. + * + * Only the disk-image / translocation case reaches the user: it is the one where + * they have to act (move the app to Applications) and where silence would leave + * them with a command that never appears and no reason why. Everything else goes + * to the log — a PATH hint is useful but not worth a modal at launch. + */ +async function installCliShimOnFirstRun(): Promise { + try { + const outcome = await ensureCliShimInstalled({ + statePath: shimStatePath(app.getPath('userData')), + appBinaryPath: process.execPath, + // A packaged build dispatches on `--cli` in its own binary; the GUI only + // runs first-run install when packaged-shaped, so the marker suffices. + leadingArgs: ['--cli'], + environment: { + platform: process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux', + home: homedir(), + pathVariable: process.env.PATH ?? '', + localAppData: process.env.LOCALAPPDATA, + // The AppImage runtime exports the path of the .AppImage FILE, which is + // stable; `process.execPath` is a per-launch mount point. + appImagePath: process.env.APPIMAGE, + }, + warn: (message) => { + logger.info(`[cli-shim] ${message}`) + void dialog.showMessageBox({ + type: 'info', + title: 'Command line tool not installed', + message: 'The openplc-cli command could not be installed', + detail: message, + buttons: ['OK'], + }) + }, + onDiagnostic: (message) => logger.info(`[cli-shim] ${message}`), + }) + logger.info(`[cli-shim] ${outcome.status}`) + if ('hint' in outcome && outcome.hint) logger.info(`[cli-shim] ${outcome.hint}`) + } catch (error) { + logger.error(`[cli-shim] install failed: ${getErrorMessage(error)}`) + } +} From fe489a3a77c1691dbb0b3cbc7915a7440eb4b167 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 21 Aug 2026 08:22:51 -0400 Subject: [PATCH 13/25] feat(cli): no switches needed to call openplc-cli, and document installing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim already carried the Linux switches, but reaching `install-cli` in the first place meant calling the app directly — and that call needed them too. A chicken-and-egg the install instructions would have had to explain. A direct `--cli` run now re-executes itself with the switches it is missing. That fixes the case that actually bites: `--ozone-platform=headless`. Electron initialises its display layer AFTER this script runs, so without a DISPLAY (an SSH session, a CI runner) a direct call could not start; with the relaunch it does. Verified in a container with no DISPLAY. `--no-sandbox` is not fixable that way and the comment now says so rather than implying otherwise: Chromium's SUID sandbox check runs before any JS, so a launch that fails it has already aborted. It only fails where unprivileged user namespaces are unavailable — Docker's default seccomp profile — because with them Chromium uses the namespace sandbox and needs no helper. Confirmed by running the same image with `--security-opt seccomp=unconfined`, where the plain call succeeds. Those callers pass `--no-sandbox` once, to create the shim, which carries it from then on. Adds `docs/CLI.md`: the button-to-command mapping, install procedure per OS (including why a macOS disk image and a moved AppImage are refused), the output contract and exit codes, credential handling, debug sessions and the force-release rule, and the `--yes` gate. Both documented Linux paths verified end to end in a debian:12 arm64 container against a packaged build: the plain call where user namespaces are available, and `--no-sandbox` once where they are not — after which `openplc-cli --version`, `devices` and `-h` all work with no switches. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CLI.md | 181 ++++++++++++++++++++++++++++++++++++++++++++++ src/main/entry.ts | 46 +++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 docs/CLI.md diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 000000000..963d353d3 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,181 @@ +# openplc-cli + +The editor's operations, headless: create a project, compile it, upload it to a +target, and drive a live debug session. Every command runs the same code the GUI +control it mirrors runs, so a test that passes here is testing the editor and not +a parallel implementation. + +| GUI control | Command | +| ----------------------------- | ---------------------------------- | +| Build | `openplc-cli compile ` | +| Build & Upload | `openplc-cli upload ` | +| Search / serial-port dropdown | `openplc-cli devices` | +| Debug | `openplc-cli debug open …` | +| Start / Stop | `openplc-cli debug start` / `stop` | +| Variable poll, force dialog | `openplc-cli debug read` / `force` | + +## Installing + +The command is a small shim on your PATH that runs the app with `--cli`. The app +installs it **on first run**, so launching OpenPLC Editor once is usually all it +takes. `openplc-cli install-cli` does it explicitly — for a CI image that never +opens the GUI, or after the app moves. + +It goes in the first **user-writable** directory it finds, preferring one already +on your PATH: + +| Platform | Directories tried | +| ------------ | ------------------------------------- | +| macOS, Linux | `~/.local/bin`, then `~/bin` | +| Windows | `%LOCALAPPDATA%\Programs\openplc-cli` | + +Nothing is installed to a privileged location, so no administrator password is +ever requested. If the chosen directory is not on your PATH, the command prints +the one line to add — on Windows the per-user PATH is updated for you, and a new +terminal picks it up. + +### macOS + +Install OpenPLC Editor into `/Applications` first, then open it once. + +Running from the mounted `.dmg` cannot work: the shim would point inside the disk +image and break the moment it is ejected, so the app says so instead of +installing something that will fail later. The same applies when macOS has +quarantined the app — launching it straight out of `Downloads` makes Gatekeeper +run it from a randomised temporary path that changes every launch. + +### Linux + +The editor ships as an AppImage. The image is mounted at a fresh temporary path +on every launch, so the shim points at the **`.AppImage` file** instead, which is +wherever you keep it. Move the file to its final location before installing; if +you move it later, run `install-cli` again (or just launch the app, which +notices the change). + +```sh +chmod +x 'OpenPLC Editor-4.2.2.AppImage' +./'OpenPLC Editor-4.2.2.AppImage' --cli install-cli +openplc-cli --version +``` + +Launching the GUI once does the same thing, and is the simplest route on a +desktop. + +You do **not** need `xvfb-run`, and you do not need to pass Chromium switches. +A CLI run needs `--ozone-platform=headless` (Electron initialises its display +layer during startup and exits without one) and, on some systems, +`--no-sandbox`. The generated shim passes both, and a direct `--cli` call +re-executes itself with the headless switch — so an SSH session or a CI runner +with no display works as-is. + +The one exception is the **first** call in an environment where unprivileged user +namespaces are unavailable, which is Docker's default. Chromium then falls back +to its SUID sandbox helper and aborts before any of our code runs, so that call +needs the switch itself — once, to create the shim: + +```sh +./'OpenPLC Editor-4.2.2.AppImage' --no-sandbox --cli install-cli +openplc-cli devices # no switches needed from here on +``` + +Everywhere with user namespaces enabled — which is any current desktop kernel — +the plain form above is enough. + +### Windows + +Run the installer, then launch the editor once. `openplc-cli.cmd` is placed in +`%LOCALAPPDATA%\Programs\openplc-cli` and that directory is added to your user +PATH; open a new terminal afterwards. + +> Console output from a GUI-subsystem executable is not attached to an +> interactive terminal on Windows. Redirection and piping work +> (`openplc-cli devices > devices.json`), which is what a test harness does, but +> this needs verifying on Windows before being relied on interactively. + +## Output contract + +Machine-readable when stdout is not a terminal, human-readable when it is; +`--json` / `--no-json` override. + +- In JSON mode stdout carries **exactly one** JSON document — the result. Progress + and diagnostics go to stderr, so `JSON.parse(stdout)` needs no filtering. +- No ANSI, spinners or progress bars in JSON mode. +- Values carry their type, so `0` is unambiguously `BOOL FALSE` or `INT 0`. + 64-bit integers arrive as decimal strings, which an IEEE double cannot hold. +- Errors are objects with a stable `code`. The prose may be reworded; the code + will not. + +### Exit codes + +| Code | Meaning | +| ---- | ------------------------------------------------------ | +| 0 | ok | +| 2 | usage — unknown command, missing or malformed argument | +| 3 | not found — project, file or session | +| 4 | compile failed | +| 5 | connection — could not reach the target, or lost it | +| 6 | auth — credentials refused | +| 7 | target error — the device reported failure | +| 8 | timeout | +| 70 | internal — a bug in the CLI | + +## Credentials + +Targets reached through a runtime API need them; a board flashed over USB does +not. + +```sh +--credentials user:pass # or --user / --password +OPENPLC_CREDENTIALS=user:pass # or OPENPLC_USER + OPENPLC_PASSWORD +``` + +Prefer the environment form in CI: a flag lands in shell history and job logs. + +## Debug sessions + +A debug session is long-lived; a test step is one process. So `debug open` starts +a background session and returns a `session_id`, and every other command is a +cheap one-shot that attaches to it — no reconnect, no re-verify, no re-upload per +command. + +```sh +openplc-cli debug open ./my-project --target "OpenPLC Runtime v4" \ + --host 192.168.2.4 --credentials op:op # -> 8df020af1234 + +openplc-cli debug list # every live session +openplc-cli debug read main:counter +openplc-cli debug force main:enable TRUE +openplc-cli debug watch main:counter --interval 100 +openplc-cli debug poll # what was recorded meanwhile +openplc-cli debug close --all +``` + +With one session open, `--session` is optional. With several, it is required. + +`watch` **records** into a buffer inside the session rather than streaming, so a +transient that happens between two of your own commands is still there when you +`poll`. + +`debug repl` is the same protocol with a prompt, for a human at a terminal. For a +script use `debug exec`, which reads one command per line — the REPL refuses a +pipe rather than dropping commands, which is what readline does with buffered +input. + +### Forcing + +`close` releases the variables the session forced, unless you pass +`--keep-forces`. This is deliberate: forcing lives in the runtime's forced-slot +bitmap and the runtime cannot tell that a debugger went away — it clears forces +only on program unload or stop. A session that exited quietly would leave outputs +pinned on a live PLC. + +`status` reports what this session has forced, which is what `close` will +release. A stop issued from elsewhere (the runtime UI, a mode switch) clears the +runtime's forces without the session knowing, so that list can be stale. + +## Building on a running PLC + +Targets that build on the device refuse while its PLC is RUNNING, exactly as the +editor warns — on-device compilation can stall the build or make the running +program miss scan deadlines. `--yes` / `-y` approves stopping it first, the way +`apt install -y` does. Nothing stops a running PLC without being asked. diff --git a/src/main/entry.ts b/src/main/entry.ts index 5a00e3038..563e2bd3f 100644 --- a/src/main/entry.ts +++ b/src/main/entry.ts @@ -18,6 +18,8 @@ * command dispatch on the other), so importing both would start both. */ +import { spawnSync } from 'node:child_process' + /** * Is this process a CLI invocation? * @@ -29,8 +31,50 @@ export function isCliInvocation(argv: readonly string[]): boolean { return argv.includes('--cli') || argv.includes('--cli-daemon') } +/** + * Chromium switches a Linux CLI run cannot start without. + * + * Kept in step with `platformSwitches` in `backend/editor/cli-shim/shim-plan`, + * which puts the same list in the generated shim. + */ +const LINUX_CLI_SWITCHES = ['--no-sandbox', '--ozone-platform=headless', '--disable-gpu'] + +/** + * Re-exec ourselves with the switches Linux needs, when they are missing. + * + * They cannot be applied from JavaScript: Chromium reads them while starting up, + * so `app.commandLine.appendSwitch` is too late. Re-exec is early enough for + * SOME of them and not others, and the distinction matters: + * + * - `--ozone-platform=headless` **is** fixed here. Electron initialises its + * display layer after this script runs, so without a DISPLAY (an SSH + * session, a CI runner) the relaunch is what makes a direct `--cli` call + * work at all. Verified: a container with no DISPLAY runs the CLI fine. + * - `--no-sandbox` is **not** fixable here. Chromium's SUID sandbox check + * happens before any JS runs, so a launch that fails it has already aborted. + * In practice this only bites where unprivileged user namespaces are + * unavailable — Docker's default seccomp profile, notably — because with + * them Chromium uses the namespace sandbox and needs no helper. Those + * callers pass `--no-sandbox` once, for the install; the generated shim + * carries it from then on. + * + * Synchronous and stdio-inherited, so the child's output IS this process's + * output and its exit code becomes ours — a caller cannot tell a relaunch + * happened. Guarded on the switches already being present, so it cannot recurse. + */ +function relaunchForLinuxCli(): boolean { + if (process.platform !== 'linux') return false + if (LINUX_CLI_SWITCHES.every((flag) => process.argv.includes(flag))) return false + + const result = spawnSync(process.execPath, [...LINUX_CLI_SWITCHES, ...process.argv.slice(1)], { + stdio: 'inherit', + env: process.env, + }) + process.exit(result.status ?? 1) +} + if (isCliInvocation(process.argv)) { - void import('../cli/main') + if (!relaunchForLinuxCli()) void import('../cli/main') } else { void import('./main') } From a386ca22078246a49fe0367de1f65b93b97248d4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 21 Aug 2026 08:28:19 -0400 Subject: [PATCH 14/25] fix(cli): initialise the editor's user data, so a clean machine can compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compile` failed on any machine the editor had never run on: `ENOENT … User/Runtime/arduino-core-control.json`, exit 70. `UserService` creates that scaffolding (settings, history, the arduino-cli config) and the GUI instantiates it at startup; the CLI never did. It went unnoticed because every machine I had tested on had run the GUI before, so the files were already there — a fresh CI container is the first place it shows. The CLI now runs the same initialisation, AWAITED rather than fire-and-forget. `UserService`'s constructor starts the work without exposing a way to wait for it, which is harmless for a GUI (a window takes far longer to appear) and not for a CLI that can reach the compiler in the same tick. Added `UserService.initialize()` for that; the constructor still starts it, so the GUI is unchanged. Fixing it also corrected an exit code: a bad `--target` returned 70 (internal) because the ENOENT fired before target resolution. It now returns 3 (not found), which is what a pipeline should branch on. Verified in a debian:12 arm64 container, as root AND as an unprivileged user, with no DISPLAY, no TTY and stdout piped: `compile` exits 0, stdout is exactly one parseable JSON document, progress and Chromium's D-Bus noise stay on stderr, 36 artifacts land including `debug-map.json`, and failures exit 3. Documents the pipeline setup in `docs/CLI.md`: the shared libraries a slim image needs, why `--no-sandbox` is required for the one install call in a container but not on a desktop, and reading the result with `jq` off a single-document stdout. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CLI.md | 54 +++++++++++++++++++ .../editor/services/user-service/index.ts | 15 ++++++ src/cli/main.ts | 9 ++++ 3 files changed, 78 insertions(+) diff --git a/docs/CLI.md b/docs/CLI.md index 963d353d3..bfef32d03 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -92,6 +92,60 @@ PATH; open a new terminal afterwards. > (`openplc-cli devices > devices.json`), which is what a test harness does, but > this needs verifying on Windows before being relied on interactively. +## In a build pipeline + +Verified in a `debian:12` container as root and as an unprivileged user, with no +`DISPLAY`, no TTY and stdout piped. + +You do not need a display server or `xvfb-run`. You do need Electron's shared +libraries, which a slim base image will not have: + +```dockerfile +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgtk-3-0 libnss3 libasound2 libgbm1 libxss1 libxtst6 \ + libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \ + libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libpango-1.0-0 libcairo2 libatspi2.0-0 \ + && rm -rf /var/lib/apt/lists/* +``` + +Then, once per image: + +```sh +./OpenPLC-Editor.AppImage --no-sandbox --cli install-cli +``` + +`--no-sandbox` is needed here and not on a desktop: container runtimes usually +block unprivileged user namespaces, so Chromium falls back to its SUID sandbox +helper and refuses to start. The generated shim carries the switch, so nothing +after this call needs it. + +A build step then looks like any other: + +```sh +openplc-cli compile ./my-project --target "OpenPLC Runtime v4" # exit 0, or 4 on a compile error +openplc-cli upload ./my-project --host "$PLC_HOST" --yes # --yes stops a RUNNING PLC first +``` + +Reading the result: + +```sh +BUILD=$(openplc-cli compile ./my-project --target "OpenPLC Runtime v4") || exit $? +echo "$BUILD" | jq -r '.buildDirectory' +``` + +`stdout` is one JSON document, so `jq` needs no filtering; progress and Chromium's +own D-Bus complaints go to `stderr`. Gate on the exit code — 4 is a compile +error, 5 a connection problem, 7 the device refusing — rather than on log text. + +### First run on a clean machine + +The CLI creates the editor's user-data scaffolding itself (settings, history, the +arduino-cli config), so a fresh container needs no warm-up step. Board packages +are a different matter: a target from an installed `.vpp` package is only +available if that package is installed in the image's user-data directory, which +`--user-data ` can point at a prepared one. + ## Output contract Machine-readable when stdout is not a terminal, human-readable when it is; diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index ee32c2742..3278e6ed4 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -20,6 +20,21 @@ class UserService { void this.#initializeUserSettingsAndHistory() } + /** + * The same scaffolding the constructor starts, but awaitable. + * + * The constructor is fire-and-forget, which is harmless for the GUI — a window + * takes far longer to appear than these few files take to create. A CLI has no + * such gap: it can reach the compiler in the same tick, and the compiler reads + * `User/Runtime/arduino-core-control.json` eagerly. On a machine where the + * editor had run before, the files already existed and nothing showed; in a + * fresh CI container the compile failed with a bare + * `ENOENT … arduino-core-control.json`. + */ + async initialize(): Promise { + await this.#initializeUserSettingsAndHistory() + } + /** * Static methods and properties. */ diff --git a/src/cli/main.ts b/src/cli/main.ts index 9fd683ed8..3f88905f5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { RuntimeApiClient } from '@root/backend/editor/runtime/runtime-api-client' +import { UserService } from '@root/backend/editor/services' import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { app } from 'electron' @@ -319,6 +320,14 @@ async function main(): Promise { // package manager all resolve off `userData`. alignUserDataWithEditor(stringFlag(args, 'user-data')) + // Create the editor's user-data scaffolding — settings, history, the + // arduino-cli config, `User/Runtime/arduino-core-control.json` — exactly as + // the GUI does at startup. AWAITED, unlike the GUI's fire-and-forget + // constructor, because a command can reach the compiler in the same tick and + // the compiler reads that file eagerly. Without it a first run on a clean + // machine (any CI container) failed with a bare ENOENT. + await new UserService().initialize() + const reporter = createProcessReporter({ json: boolFlag(args, 'json'), noJson: args.flags.json === false, From c474f9c9259c6684aacc5b8e7905f67dd765a2b1 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 21 Aug 2026 13:34:19 -0400 Subject: [PATCH 15/25] wip: review fixes before merge --- src/__architecture__/validate.ts | 124 ++++++++++- .../cli-shim/__tests__/shim-plan.test.ts | 38 +++- src/backend/editor/cli-shim/install-shim.ts | 79 +++++-- src/backend/editor/cli-shim/shim-plan.ts | 23 +- .../editor/hardware/debug-channel-factory.ts | 25 ++- .../editor/runtime/runtime-api-client.ts | 144 ++++++++---- .../shared/compile/__tests__/pipeline.test.ts | 93 ++++++++ src/backend/shared/debug/types.ts | 10 + src/cli/args.ts | 5 +- src/cli/commands/build.ts | 129 +++++++---- src/cli/commands/create.ts | 137 ++++++++++++ src/cli/commands/debug.ts | 210 ++++++++++++------ src/cli/commands/devices.ts | 15 +- src/cli/commands/install-cli.ts | 7 + src/cli/compile/headless-bridge.ts | 16 +- src/cli/connect-runtime.ts | 119 ++++++++++ src/cli/credentials.ts | 47 ++++ src/cli/daemon-entry.ts | 9 +- src/cli/debug/close-channel.ts | 15 +- src/cli/debug/open-session.ts | 13 +- src/cli/debug/variables.ts | 77 +++++-- src/cli/main.ts | 91 ++++++-- src/cli/output.ts | 19 ++ src/cli/session/client.ts | 22 +- src/cli/session/daemon-main.ts | 37 ++- src/cli/session/server.ts | 26 ++- src/cli/session/session-core.ts | 19 +- src/cli/spawn-session.ts | 59 +++-- .../workspace-activity-bar/default.tsx | 12 +- .../__tests__/debug-medium-profile.test.ts | 2 +- src/frontend/hooks/useDebugPolling.ts | 12 +- src/main/modules/ipc/main.ts | 73 +----- .../__tests__/pre-build-plc-gate.test.ts | 30 +++ .../utils/build-gate/pre-build-plc-gate.ts | 58 +++++ 34 files changed, 1419 insertions(+), 376 deletions(-) create mode 100644 src/cli/commands/create.ts create mode 100644 src/cli/connect-runtime.ts create mode 100644 src/cli/credentials.ts create mode 100644 src/middleware/shared/utils/build-gate/__tests__/pre-build-plc-gate.test.ts create mode 100644 src/middleware/shared/utils/build-gate/pre-build-plc-gate.ts diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 333812800..3e8a6067c 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -17,6 +17,9 @@ import { fileURLToPath } from 'node:url' // --------------------------------------------------------------------------- type LayerName = + | 'cli' + | 'backend-editor' + | 'main' | 'assets' | 'utils' | 'data' @@ -109,6 +112,68 @@ const LAYER_RULES: Record = { name: 'Components (frontend/components/)', allowedDeps: ['ports', 'provider', 'store', 'hooks', 'services', 'components', 'data', 'utils', 'assets'], }, + main: { + name: 'Main (main/) — the Electron main process entry point', + /** + * A process entry point, so it may reach every desktop layer. Mapped for the + * same reason `cli` is: unmapped directories are skipped, and leaving the two + * entry points invisible meant nothing checked what they reached into. + */ + allowedDeps: [ + 'ports', + 'provider', + 'store', + 'services', + 'utils', + 'data', + 'assets', + 'types', + 'backend-shared', + 'backend-editor', + 'adapters', + 'cli', + 'main', + ], + }, + 'backend-editor': { + name: 'Backend Editor (backend/editor/) — desktop main-process modules', + /** + * Mapped so the CLI's imports of it can be checked. It was unmapped, and an + * unmapped directory is SKIPPED — which is why 36 new CLI files sailed + * through this gate. `main/` is still unmapped for the same historical + * reason; mapping that too is a follow-up, not this change. + */ + allowedDeps: ['ports', 'provider', 'utils', 'data', 'types', 'backend-shared', 'backend-editor', 'main'], + }, + cli: { + name: 'CLI (cli/) — the headless entry point', + /** + * The CLI is a process entry point, like `main/`, so it may reach the + * platform layers a main process reaches. It is listed rather than left + * unmapped because unmapped files are SKIPPED: 36 new files were invisible + * to this gate, and the import it should have flagged was right there — a + * Node/Electron-main process importing the renderer's Zustand singleton. + * + * `store` is allowed deliberately and narrowly: the CLI hydrates the real + * store so the editor's own resolvers (alias resolution, the debug-spec + * resolver) run against the same state the GUI gives them. Reimplementing + * those is the drift this whole effort exists to prevent. + */ + allowedDeps: [ + 'ports', + 'provider', + 'store', + 'services', + 'utils', + 'data', + 'assets', + 'types', + 'backend-shared', + 'backend-editor', + 'adapters', + 'cli', + ], + }, architecture: { name: 'Architecture (__architecture__/)', allowedDeps: [], @@ -172,6 +237,11 @@ function getLayer(filePath: string): LayerName | null { if (rel.startsWith('backend/shared/')) return 'backend-shared' if (rel.startsWith('backend/web/')) return 'backend-web' + // The headless CLI entry point (DOPE-567). + if (rel.startsWith('cli/')) return 'cli' + if (rel.startsWith('backend/editor/')) return 'backend-editor' + if (rel.startsWith('main/')) return 'main' + // Frontend layers if (rel.startsWith('frontend/store/')) return 'store' if (rel.startsWith('frontend/services/')) return 'services' @@ -242,7 +312,16 @@ function tryResolveFile(base: string): string | null { } function resolveImport(importPath: string, fromFile: string): string | null { - // Only check relative imports (within src/) + // `@root/*` is the project's alias for `src/*`. Resolving it matters as much as + // resolving a relative path: skipping it made every aliased import invisible to + // this gate, and newer code uses the alias far more than `../..` chains — so a + // layer violation written as `@root/frontend/store` passed silently. + if (importPath.startsWith('@root/')) { + const resolved = join(SRC_ROOT, importPath.slice('@root/'.length)) + return tryResolveFile(resolved) + } + + // Otherwise only relative imports are within src/. if (!importPath.startsWith('.')) return null const dir = dirname(fromFile) @@ -268,6 +347,49 @@ function resolveImport(importPath: string, fromFile: string): string | null { * layer rule permits. */ const KNOWN_EXCEPTIONS: Record = { + // --------------------------------------------------------------------------- + // Pre-existing, surfaced by resolving `@root/*` (DOPE-567) + // + // This gate only ever resolved RELATIVE imports, so every `@root/...` import + // was invisible to it — and newer code uses the alias far more than `../..` + // chains. Teaching `resolveImport` the alias was needed to check the CLI at + // all, and it revealed these 22 files, none of them touched by that work. + // + // Listed rather than silently re-hidden: each one is a real layer crossing + // that predates the alias being resolved, and they belong in a focused + // follow-up (mostly the XML generators reaching into `store`/`components`, the + // EtherCAT screens reaching into `backend/shared`, and `types/IPC/*` importing + // schema types from `backend/shared`). + // --------------------------------------------------------------------------- + 'frontend/components/_features/[workspace]/editor/device/ethercat/components/advanced-tab.tsx': ['backend-shared'], + 'frontend/components/_features/[workspace]/editor/device/ethercat/components/device-configuration-form.tsx': [ + 'backend-shared', + ], + 'frontend/components/_features/[workspace]/editor/device/ethercat/components/discovered-device-table.tsx': [ + 'backend-shared', + ], + 'frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-device-info.tsx': ['backend-shared'], + 'frontend/components/_features/[workspace]/editor/device/ethercat/components/global-settings-tab.tsx': [ + 'backend-shared', + ], + 'frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx': ['backend-shared'], + 'frontend/hooks/use-device-configuration.ts': ['backend-shared', 'components'], + 'frontend/utils/PLC/xml-generator/codesys/language/fbd-xml.ts': ['components', 'store'], + 'frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts': ['components', 'store'], + 'frontend/utils/PLC/xml-generator/codesys/pou-xml.ts': ['store'], + 'frontend/utils/PLC/xml-generator/old-editor/language/fbd-xml.ts': ['components', 'store'], + 'frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts': ['components', 'store'], + 'frontend/utils/PLC/xml-generator/old-editor/pou-xml.ts': ['store'], + 'frontend/utils/PLC/xml-parser/language/fbd-xml.ts': ['components', 'store'], + 'frontend/utils/PLC/xml-parser/language/ladder-xml.ts': ['components', 'store'], + 'frontend/utils/device.ts': ['backend-shared'], + 'types/IPC/pou-service/create-pou-file.ts': ['backend-shared'], + 'types/IPC/pou-service/index.ts': ['backend-shared'], + 'types/IPC/project-service/create-project.ts': ['backend-shared'], + 'types/IPC/project-service/index.ts': ['backend-shared'], + 'types/IPC/project-service/read-project.ts': ['backend-shared'], + 'backend/editor/contracts/types/modules/ipc/main.ts': ['store'], + // FBD paste/duplicate helpers — needs molecule-level buildGenericNode from components 'frontend/store/slices/fbd/utils/index.ts': ['components'], // Ladder paste/duplicate helpers — needs nodesBuilder from component atoms diff --git a/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts b/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts index 5abeed8d0..cf8e37930 100644 --- a/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts +++ b/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts @@ -1,3 +1,4 @@ +import { toPowerShellLiteral } from '../install-shim' import { candidateDirectories, describeUnstableLocation, @@ -9,6 +10,7 @@ import { resolveShimTarget, SHIM_MARKER, platformSwitches, + quoteForShell, shimFileName, type ShimEnvironment, } from '../shim-plan' @@ -120,7 +122,7 @@ describe('renderShim', () => { expect(shim).toContain('#!/bin/sh') // `exec` so the shim does not linger and the exit code passes through; // `"$@"` so a project path containing spaces survives. - expect(shim).toContain('exec "/Applications/OpenPLC Editor.app/Contents/MacOS/OpenPLC Editor" "--cli" "$@"') + expect(shim).toContain(`exec '/Applications/OpenPLC Editor.app/Contents/MacOS/OpenPLC Editor' '--cli' "$@"`) expect(shim).toContain(SHIM_MARKER) }) @@ -222,8 +224,8 @@ describe('renderShim', () => { ) // Switches precede the script path, which is where Electron expects them. expect(shim).toContain( - 'exec "/repo/node_modules/electron/dist/Electron" "--no-sandbox" "--ozone-platform=headless" ' + - '"--disable-gpu" "/repo/openplc-cli.dev.js" "--cli" "$@"', + `exec '/repo/node_modules/electron/dist/Electron' '--no-sandbox' '--ozone-platform=headless' ` + + `'--disable-gpu' '/repo/openplc-cli.dev.js' '--cli' "$@"`, ) }) }) @@ -244,7 +246,35 @@ describe('platformSwitches', () => { it('renders them into the Linux shim ahead of the CLI marker', () => { const shim = renderShim({ command: '/home/dev/App.AppImage', leadingArgs: ['--cli'] }, 'linux') expect(shim).toContain( - 'exec "/home/dev/App.AppImage" "--no-sandbox" "--ozone-platform=headless" "--disable-gpu" "--cli" "$@"', + `exec '/home/dev/App.AppImage' '--no-sandbox' '--ozone-platform=headless' '--disable-gpu' '--cli' "$@"`, ) }) }) + +describe('quoteForShell', () => { + it('suppresses POSIX expansion, so a path with $ or a backtick runs literally', () => { + // Double quotes would still expand these — the shim would run something else. + expect(quoteForShell('/home/$USER/bin/app', 'linux')).toBe("'/home/$USER/bin/app'") + expect(quoteForShell('/home/`whoami`/app', 'darwin')).toBe("'/home/`whoami`/app'") + }) + + it('escapes an embedded single quote by closing, escaping and reopening', () => { + expect(quoteForShell("/home/o'brien/app", 'linux')).toBe("'/home/o'\\''brien/app'") + }) + + it('doubles % on Windows so cmd reads it literally, keeping spaces quoted', () => { + expect(quoteForShell('C:\\Program Files\\%APPDATA%\\app.exe', 'win32')).toBe( + '"C:\\Program Files\\%%APPDATA%%\\app.exe"', + ) + }) +}) + +describe('toPowerShellLiteral', () => { + it('single-quotes so nothing is expanded, doubling an embedded quote', () => { + // `powershell -Command