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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
# Wait a week before proposing a new release: a compromised or broken
# version is usually caught and yanked within days, and nothing here is
# urgent enough to want a same-day bump.
cooldown:
default-days: 7
groups:
# Batch routine dev-dependency bumps into one PR to cut noise.
dev-dependencies:
Expand All @@ -16,3 +21,5 @@ updates:
directory: "/"
schedule:
interval: weekly
cooldown:
default-days: 7
61 changes: 48 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
name: CI

# When to run: on every push to main and on every pull request.
# When to run: on every push to main, on every pull request, and on demand.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# Least privilege: this workflow only ever reads the repo.
permissions:
contents: read

# A new push to the same branch cancels the previous, still-running check.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: Lint, typecheck, test, build
runs-on: ubuntu-latest
steps:
# 1. Get your code onto the runner.
# 1. Get the code onto the runner.
# Actions are pinned to a full commit SHA (org policy); comment tracks the version.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No checks here push anything, so don't leave a credential in .git/config.
persist-credentials: false

# 2. Install Node 20 and cache npm downloads for faster runs.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# 2. Install the toolchain from .mise.toml — Node is pinned there to the
# same version packaging/assemble.sh bundles into the .deb, so CI tests
# the runtime that actually ships.
- uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4
with:
node-version: 20
cache: npm
install: true
cache: true

# 3. Cache npm downloads across runs, keyed on the lockfile.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-

# 4. Install from the lockfile (clean, reproducible).
- run: mise run install

# 3. Install dependencies from the lockfile (clean, reproducible).
- run: npm ci
# 5. The gate. Identical to `mise run ci` locally — format check,
# both typechecks, tests with the coverage floor, and the GUI build.
- run: mise run ci

# 4. The same checks you run locally — if any fail, the build goes red.
- run: npm run typecheck
- run: npm run typecheck:node
- run: npm test
- run: npx prettier --check "src/**/*.{ts,tsx}" "gateway/**/*.ts"
zizmor:
name: Zizmor (GitHub Actions audit)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Audit GitHub Actions workflows (zizmor)
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
advanced-security: false
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ jobs:
steps:
# Actions pinned to a full commit SHA (org policy); comment tracks the version.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing here pushes with git — the release upload below authenticates
# with an explicit GH_TOKEN — so don't leave a credential in .git/config
# where the docker build step could pick it up.
persist-credentials: false

# The package version comes from package.json (nfpm ${SEMVER}); keep the tag
# in lockstep so the Release, the .deb, and the in-app version banner agree.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist
dist-gateway
build
dist-deb
coverage
*.local
.DS_Store
gateway/.plotter-state.json
Expand Down
74 changes: 74 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# mise: https://mise.jdx.dev/
#
# Single source of truth for the toolchain and the task list. CI runs the same
# `mise run ci` gate (see .github/workflows/ci.yml), so local and CI cannot drift.
#
# Node is pinned to the exact version packaging/assemble.sh bundles into the
# .deb, so what CI tests is the runtime that actually ships to the Pi. If you
# bump NODE_VERSION there, bump it here too.

[tools]
node = "22.20.0"
zizmor = "1.29.0" # GitHub Actions security audit
actionlint = "1.7.12" # GitHub Actions schema + expression lint

[tasks.install]
description = "Install dependencies from the lockfile"
run = "npm ci"

[tasks.dev]
description = "Vite dev server for UI work on :5173"
run = "npm run dev"

[tasks.gateway]
description = "Run the plotter gateway daemon on :8717"
run = "npm run gateway"

[tasks.build]
description = "Typecheck + build the GUI into dist/"
run = "npm run build"

[tasks.test]
description = "Run the unit tests, enforcing the coverage floor"
run = "npm test"

[tasks.typecheck]
description = "Type-check the browser sources"
run = "npm run typecheck"

[tasks.typecheck-node]
description = "Type-check the gateway sources"
run = "npm run typecheck:node"

[tasks.format]
description = "Format with Prettier"
run = "npm run format"

[tasks.format-check]
description = "Check formatting without rewriting files"
run = "npm run format:check"

[tasks.audit]
description = "Security-audit the workflows + dependabot config (zizmor)"
# The token enables the online audits (impostor-commit, known-vulnerable-actions).
# Without one zizmor falls back to offline and those two simply do not run.
run = "GH_TOKEN=$(gh auth token 2>/dev/null) zizmor --collect=all --strict-collection ."

[tasks.lint-actions]
description = "Lint the workflows for schema + expression errors (actionlint)"
run = "actionlint"

[tasks.ci]
description = "Full CI gate — exactly what CI runs"
depends = ["format-check", "typecheck", "typecheck-node", "test", "build"]

[tasks.ci-watch]
description = "Watch GitHub Actions CI for the current branch; exits non-zero on failure"
run = """
sleep 5
remote_url=$(git remote get-url upstream 2>/dev/null || git remote get-url origin)
repo=$(echo "$remote_url" | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||')
run_id=$(gh run list --repo "$repo" --branch "$(git branch --show-current)" --limit 1 --json databaseId --jq '.[0].databaseId')
if [ -z "$run_id" ]; then echo "No run found for current branch on $repo" >&2; exit 1; fi
gh run watch --repo "$repo" --exit-status "$run_id"
"""
120 changes: 120 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# AGENTS.md

This file (`AGENTS.md`) is the canonical agent configuration. `CLAUDE.md` is a symlink to this file.

Browser-based control app for a GRBL-style pen plotter (a UUNA TEK 3.0 with an A0
bed). A long-running gateway daemon owns the serial port and streams plots
autonomously; the browser is a thin WebSocket client. See [README.md](README.md)
for what it does and how to run it.

## Safety — this code drives a physical machine

These are not style preferences. Getting them wrong wastes a sheet of paper, or
drives the gantry into the frame.

**Never interrupt a running plot.** A plot is a one-shot physical job and there is
**no resume** — a client cannot re-attach to a plot it did not start. While the
machine is moving (the state file's mtime is within ~3 s), do not:

- `systemctl restart plotter-gateway`, run `deploy.sh`, or install an update — restarting the daemon aborts the plot.
- Open a WebSocket to the gateway. When no client holds control, the next client to connect inherits it.
- Suggest pressing **Plot**. `streamProgram` has no in-progress guard, so a second program interleaves into the running queue.

To check progress without touching the plot, read
`/var/lib/penplotter271/.plotter-state.json` over SSH: `wpos` is the live work
position, `z: 0` is pen-down (drawing), `z: 2` is pen-up (travel). That connects
no client and transfers no control. Defer every fix and restart until the plot
finishes.

**No limit switches** (`$22=0`, homing disabled). There is no `$H`. The operator
sets work zero by hand at the paper's top-left corner each session. After any
power cycle the restored origin can be ~1 cm off — and if it is wrong, nothing
stops the machine.

**Machine conventions**, baked into the G-code generator:

- **Inverted Z:** `Z+` moves the pen **down**. Pen-down Z is positive (default `3`), pen-up is `0`.
- **Origin = the paper's top-left corner**, and the SVG→G-code mapping is **identity — no Y flip**. Machine `+Y` runs physically *down* the page. Drawing fills the `+X`/`+Y` quadrant.
- **The daemon opens the serial port exactly once.** Repeated reopen wedges the macOS CH340 driver (errno 22) and only a physical replug recovers it. Never add a reopen path.

## Architecture

The GRBL engine depends only on a `Transport` interface — never on Web Serial, the
DOM, or React — so the same engine runs on the Pi behind a Node serial adapter.
Keep that seam intact.

```
src/grbl/ Portable GRBL protocol engine: streaming, status, alarms (no UI deps)
src/transport/ The seam — Transport interface + the browser's WebSocket client
src/gateway/ Shared WebSocket protocol (commands, snapshot, forwarded events)
src/plot/ Pure pipeline: SVG/PNG → polylines → placement → G-code
src/ui/ React app (the only DOM-aware layer)
gateway/ Raspberry Pi / dev daemon
```

`src/plot/` and `src/grbl/` are the pure, unit-tested core and the only code the
coverage floor measures. New logic belongs there rather than in `src/ui/` wherever
that is a real choice.

## Development commands

Use `mise`. It pins the toolchain — including Node `22.20.0`, the same version
`packaging/assemble.sh` bundles into the `.deb` — and CI runs the same tasks, so
local and CI cannot drift.

```bash
mise install # install the pinned toolchain
mise run install # npm ci
mise run ci # the full gate: format-check, both typechecks, test, build
```

| Task | What it does |
| --- | --- |
| `mise run dev` | Vite dev server for UI work on :5173 |
| `mise run gateway` | Run the plotter gateway daemon on :8717 |
| `mise run build` | Typecheck + build the GUI into `dist/` |
| `mise run test` | Unit tests, enforcing the coverage floor |
| `mise run typecheck` | Type-check the browser sources |
| `mise run typecheck-node` | Type-check the gateway sources |
| `mise run format` / `format-check` | Prettier write / check |
| `mise run audit` | Security-audit the workflows + dependabot config (zizmor) |
| `mise run lint-actions` | Lint the workflows (actionlint) |
| `mise run ci-watch` | Watch the GitHub Actions run for the current branch |

Coverage is measured over `src/plot` and `src/grbl` only, with per-metric floors in
`vite.config.ts`. Raise them as coverage improves; never lower one to make CI pass.

## Spec-driven changes

Non-trivial work goes through OpenSpec: proposals and tasks under
`openspec/changes/`, capability specs under `openspec/specs/`, completed changes in
`openspec/changes/archive/`. Use the `/opsx:*` skills (`propose`, `apply`, `archive`,
`sync`, `explore`). There are currently no active changes.

Hardware-dependent tasks are **not** done when the code typechecks. Several
position-restore bugs passed review and failed on the actual Pi. Leave hardware
verification tasks unchecked until the operator confirms them on the machine.

## Agent skills

### Git remote

GitHub, via the `gh` CLI. The repo is `LAB271/labs-pen-plotter`.

**The maintainer handles all git operations themselves.** Do not commit, push, or
open PRs unless explicitly asked in that instance.

### Issue tracker

GitHub Issues, via `gh`. See [`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md).

### Triage labels

`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See
[`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md).

### Commits

[Conventional Commits](https://conventionalcommits.org/) — see
[CONTRIBUTING.md](CONTRIBUTING.md). Explain *why*, not *what*: this codebase carries
unusually detailed inline rationale and that is deliberate.
1 change: 1 addition & 0 deletions CLAUDE.md
Loading