Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bb5c40f
refactor(iec-address): extract compile-time alias resolution out of t…
thiagoralves Aug 20, 2026
c4264d0
feat(cli): session protocol, registry and output contract for the hea…
thiagoralves Aug 20, 2026
ee8fc95
feat(cli): headless CLI driving the editor's own components
thiagoralves Aug 20, 2026
319e189
style(cli): sort imports in the CLI entry point
thiagoralves Aug 20, 2026
36efce7
fix(compile): write runtime-v4 build artifacts on the compile-only path
thiagoralves Aug 20, 2026
a770afa
refactor(cli): map each command onto the flow its button click starts
thiagoralves Aug 20, 2026
343b34c
style(compile): format the pipeline's new bundle-write block
thiagoralves Aug 20, 2026
687d576
fix(cli): stop deciding things the target already declares
thiagoralves Aug 20, 2026
5145e62
feat(cli): serial targets — devices lists ports, and upload/debug use…
thiagoralves Aug 21, 2026
bb27bcf
fix(cli,modbus): wait for the serial handle to release before exiting
thiagoralves Aug 21, 2026
9821e42
feat(cli): make the CLI reachable from a packaged build, as openplc-cli
thiagoralves Aug 21, 2026
f8e754c
feat(cli): install an openplc-cli shim on PATH, on first run, on ever…
thiagoralves Aug 21, 2026
fe489a3
feat(cli): no switches needed to call openplc-cli, and document insta…
thiagoralves Aug 21, 2026
a386ca2
fix(cli): initialise the editor's user data, so a clean machine can c…
thiagoralves Aug 21, 2026
c474f9c
wip: review fixes before merge
thiagoralves Aug 21, 2026
212cdb5
Merge branch 'development' into feature/DOPE-567-headless-cli
thiagoralves Aug 21, 2026
1fda755
fix(cli): never hang, honour the token manager, and unify the debug v…
thiagoralves Aug 21, 2026
8604fbc
fix(cli): keep stdout a data channel, and stop needing a toolchain to…
thiagoralves Aug 21, 2026
0863796
fix(cli): test the startup that ships, and make a failed start exit
thiagoralves Aug 21, 2026
a27b8da
docs(cli): record the Windows validation and the batch 'call' caveat
thiagoralves Aug 21, 2026
fc024b8
fix(cli,runtime): one exchange at a time on the debug channel
thiagoralves Aug 21, 2026
4bbc673
fix(cli-shim,entry): stop disabling the Chromium sandbox for everyone
thiagoralves Aug 21, 2026
e5ef732
Merge remote-tracking branch 'origin/development' into feature/DOPE-5…
thiagoralves Aug 22, 2026
8098feb
fix(cli): the second review round — ten findings
thiagoralves Aug 22, 2026
053ebfd
fix(cli): the verification round — 15 findings
thiagoralves Aug 24, 2026
7a228ed
refactor(cli): type the build seam, and document the session protocol
thiagoralves Aug 24, 2026
83740cd
refactor(debug): let the walk carry what typeOf resolved
thiagoralves Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions configs/webpack/webpack.config.cli.dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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 },

// `entry.ts` reaches both roles through dynamic imports, which webpack would
// split into sibling chunk files next to this bundle in the repo root. Folded
// back into the one file instead — the point of this config is a single
// artifact you can hand to `electron`.
plugins: [new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })],
}

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.ts`, NOT `cli/main.ts` — the same entry the packaged binary runs.
//
// Entering at `cli/main.ts` skipped the argv dispatcher, and with it two
// things that only exist there: the Linux re-exec that supplies the headless
// Chromium switches, and the handler that turns a failure to LOAD the CLI
// into an exit instead of a modal error dialog nobody can click. Both were
// therefore untestable with the bundle used to test everything else — a dev
// build that starts differently from the shipped one is a dev build that can
// pass while the product hangs. Costs `--cli` on every dev invocation, which
// is what the packaged binary needs anyway.
entry: { 'openplc-cli.dev': `${webpackPaths.srcPath}/main/entry.ts` },
}
6 changes: 5 additions & 1 deletion configs/webpack/webpack.config.main.prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ 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'),
},

Expand Down
362 changes: 362 additions & 0 deletions docs/CLI.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/main.js --cli",
"cli:dev": "electron ./openplc-cli.dev.js --cli",
"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",
Expand Down
124 changes: 123 additions & 1 deletion src/__architecture__/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { fileURLToPath } from 'node:url'
// ---------------------------------------------------------------------------

type LayerName =
| 'cli'
| 'backend-editor'
| 'main'
| 'assets'
| 'utils'
| 'data'
Expand Down Expand Up @@ -109,6 +112,68 @@ const LAYER_RULES: Record<LayerName, LayerRule> = {
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: [],
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -268,6 +347,49 @@ function resolveImport(importPath: string, fromFile: string): string | null {
* layer rule permits.
*/
const KNOWN_EXCEPTIONS: Record<string, LayerName[]> = {
// ---------------------------------------------------------------------------
// 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
Expand Down
Loading
Loading