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..932301a39 --- /dev/null +++ b/configs/webpack/webpack.config.cli.dev.ts @@ -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` }, +} diff --git a/configs/webpack/webpack.config.main.prod.ts b/configs/webpack/webpack.config.main.prod.ts index c47641dca..c57b40f27 100644 --- a/configs/webpack/webpack.config.main.prod.ts +++ b/configs/webpack/webpack.config.main.prod.ts @@ -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'), }, diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 000000000..af0502cdd --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,362 @@ +# 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`, because Electron initialises its +display layer during startup and exits without one. The generated shim passes it, +and a direct `--cli` call re-executes itself with it — so an SSH session or a CI +runner with no display works as-is. + +`--no-sandbox` is a separate matter, and it is **not** passed for you. Chromium's +sandbox is left on wherever it can start, which is everywhere unprivileged user +namespaces are available — any current desktop kernel. The exception is an +environment where they are not, Docker's default being the notable one: Chromium +falls back to its SUID sandbox helper and aborts _before any of our code runs_, +so no relaunch can rescue it and the call has to carry the switch itself. + +Pass it once, to install, and it is remembered: + +```sh +./'OpenPLC Editor-4.2.2.AppImage' --no-sandbox --cli install-cli +openplc-cli devices # no switches needed from here on +``` + +`install-cli` records that the installing call ran without the sandbox and writes +`--no-sandbox` into the shim, so later `openplc-cli` calls in that container keep +working. A desktop install writes a shim **without** it, and keeps the sandbox — +which is the point: the switch follows the environment that needs it instead of +being handed to every Linux user. Both cases are verified in a container, with +and without `--security-opt seccomp=unconfined`. + +### 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. + +Verified on Windows 11 (24H2): `--help` exits 0, no arguments and an unknown +command exit 2, a missing project exits 3, `--version` and `devices` each write +one JSON document to stdout, the usage text goes to stderr, and a closed pipe +returns in ~3s instead of hanging. The shim is invoked by name from a new shell: + +```bat +openplc-cli --version +openplc-cli devices > devices.json +``` + +> **Calling it from a `.bat` / `.cmd` script needs `call`.** The shim is a batch +> file, and batch invoking batch without `call` TRANSFERS control instead of +> returning — the rest of your script silently never runs, and you never see +> `%ERRORLEVEL%`. This is how every `.cmd` wrapper behaves (`npm.cmd` included), +> not something specific to this one: +> +> ```bat +> call openplc-cli compile "C:\path\to\project" +> if errorlevel 1 exit /b %ERRORLEVEL% +> ``` +> +> From PowerShell, `cmd`'s interactive prompt, or any non-batch caller, the plain +> form is correct and `$LASTEXITCODE` / `%ERRORLEVEL%` is set as expected. + +> 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. + +## 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; +`--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. + +### A session closes itself after 30 minutes idle + +This is the one fact a long-running harness has to know, because closing +**releases the session's forces** — mid-test, on live hardware, if the run is +quiet for long enough. + +| | | +| --------------------- | ----------------------------------- | +| default | 30 minutes with no command | +| `--idle-timeout ` | on `debug open`; a different budget | +| `--idle-timeout 0` | never close on idle | + +"Idle" means no command reached the session. Two things deliberately do **not** +count: `watch` sampling in the background (the session is busy, but nobody asked +for anything) and `debug list`, which dials each session to report its state — +polling a list must not keep sessions alive for ever. + +A value that is not a number is a usage error rather than a silent fallback: the +point of naming a timeout is that the default was wrong for this run. + +### Flags, by command + +| Flag | Command | Meaning | +| ---------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------- | +| `--session ` | any `debug` subcommand | which session, when several are open | +| `--idle-timeout ` | `debug open` | idle budget; `0` disables (see above) | +| `--force-new` | `debug open` | start a session even if one is already open for this project and target | +| `--upload-if-needed` | `debug open` | upload first when the target's program does not match | +| `--var ` | `read`, `write`, `force`, `unforce` | the variable, when you would rather not pass it positionally | +| `--value ` | `write`, `force` | the value — `16#FF`, `TRUE`, `T#5s`, all as the GUI accepts them | +| `--filter ` | `list-vars` | only variables whose path contains it | +| `--interval ` | `watch` | sampling cadence; floor 20 ms | +| `--since ` | `poll` | only samples after this sequence number | +| `--keep-forces` | `close` | leave forced variables pinned | +| `--all` | `close` | every session, not just one | +| `--keep-going` | `exec` | run the remaining lines after one fails | +| `--force` | `create` | overwrite an existing destination | +| `--clean` | `compile`, `upload` | discard the build directory first | +| `-y`, `--yes` | `upload` | skip the confirmation | +| `--create-user` | `upload`, `debug open` | permission to create the FIRST user on a fresh runtime v4, using the credentials you already passed | + +`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. + +### The session protocol + +`debug exec` and `debug repl` are two front ends over one protocol, and it is +open for a third. A session listens on a per-session socket: + +| | | +| ------- | ---------------------------------------------------------------- | +| POSIX | `/User/cli-sessions/.sock` (a unix socket) | +| Windows | `\\.\pipe\openplc-debug-` (a named pipe) | + +`debug list` prints the ids; the registry file beside the socket +(`.json`) holds the pid, target and project path. + +**Framing.** One JSON object per line, UTF-8, `\n`-terminated — NDJSON in both +directions. Nothing is streamed unsolicited: every line the session sends answers +a line it received. + +**Requests** carry an `id` you choose and a `kind`: + +```json +{"id":1,"kind":"read","names":["main:counter","main:enable"]} +{"id":2,"kind":"force","name":"main:enable","value":"TRUE"} +{"id":3,"kind":"watch","names":["main:counter"],"intervalMs":100} +{"id":4,"kind":"poll","since":42} +{"id":5,"kind":"close","releaseForces":true} +``` + +| `kind` | fields | answers with | +| ------------------- | ---------------------------------------------------------------------- | ------------------------------- | +| `status` | `probe?` — true means "only listing", which does not count as activity | `status` | +| `list-vars` | `filter?` | `list-vars` | +| `read` | `names[]` | `read` — `values[]` | +| `write` | `name`, `value` | `write` — the value read back | +| `force` / `unforce` | `name`, plus `value` for `force` | `force` / `unforce` | +| `start` / `stop` | — | `plc-state` | +| `watch` | `names[]`, `intervalMs?` | `watch` — what is recording | +| `poll` | `since?` (sequence number) | `poll` — `samples[]`, `dropped` | +| `unwatch` | `names?` (all when omitted) | `unwatch` | +| `close` | `releaseForces?` (default true) | `close` — `released[]` | + +**Responses** echo the `id` and discriminate on `ok`: + +```json +{"id":1,"ok":true,"data":{"kind":"read","values":[{"name":"main:counter","type":"INT","value":7,"forced":false}]}} +{"id":2,"ok":false,"error":{"code":"variable_not_found","message":"..."}} +``` + +The `code` values are the stable set the CLI prints (see **Exit codes**); the +prose beside them is not stable. Requests are answered **one at a time, in +order** — the channel underneath is a single request/response link, and the +session serialises its own watch sampling against your commands for that reason. + +A line that is not valid JSON, or not a valid request, is answered with an error +carrying the `id` recovered from the raw text where possible, so a malformed +request fails immediately instead of leaving a client waiting out its timeout. + +### 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/package.json b/package.json index d33f3a36d..56a3f9ca4 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/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", 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 new file mode 100644 index 000000000..f92c7559c --- /dev/null +++ b/src/backend/editor/cli-shim/__tests__/shim-plan.test.ts @@ -0,0 +1,310 @@ +import { toPowerShellLiteral } from '../install-shim' +import { + candidateDirectories, + describeUnstableLocation, + isOnPath, + mayReplace, + pathHint, + planShimInstall, + renderShim, + resolveShimTarget, + SHIM_MARKER, + platformSwitches, + quoteForShell, + 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' '--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: Ozone initialises during startup, so + // `appendSwitch` never gets a chance. The shim IS the command line, which is + // why they live here. + expect(platformSwitches('linux')).toEqual(['--ozone-platform=headless', '--disable-gpu']) + }) + + it('leaves the Chromium sandbox ON unless the installing call had it off', () => { + // Baking `--no-sandbox` in unconditionally handed every Linux user a command + // that permanently disables the sandbox, to accommodate the containers that + // are the only place it cannot start. + expect(platformSwitches('linux')).not.toContain('--no-sandbox') + expect(platformSwitches('linux', { sandboxDisabled: false })).not.toContain('--no-sandbox') + }) + + it('carries --no-sandbox forward for the caller that needed it, first', () => { + // First, because Chromium reads it during startup. + expect(platformSwitches('linux', { sandboxDisabled: true })).toEqual([ + '--no-sandbox', + '--ozone-platform=headless', + '--disable-gpu', + ]) + }) + + it('adds no sandbox switch off Linux, even when asked', () => { + expect(platformSwitches('darwin', { sandboxDisabled: true })).toEqual([]) + expect(platformSwitches('win32', { sandboxDisabled: true })).toEqual([]) + }) + + 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' '--ozone-platform=headless' '--disable-gpu' '--cli' "$@"`) + }) + + it('renders the sandbox switch into the shim for a container install', () => { + const shim = renderShim( + { command: '/home/dev/App.AppImage', leadingArgs: ['--cli'], sandboxDisabled: true }, + 'linux', + ) + expect(shim).toContain( + `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