B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-26 10:39:24 +02:00
committed by GitHub
co-authored by Claude
parent 7365a31b45
commit 70473e8e10
21 changed files with 740 additions and 54 deletions
+6 -4
View File
@@ -314,10 +314,12 @@ finding must be excised from history (amend + rebase onto the amended
commit), not merely removed by a follow-up commit.
Then review the branch against the merge-base — `git diff
origin/main...HEAD` (three-dot), never two-dot: a concurrent merge plus a
background fetch can move origin/main mid-run and turn the two-dot diff into
phantom deletions. If origin moved, confirm zero file overlap and a clean
`git merge-tree --write-tree origin/main HEAD` before opening the PR by
origin/dev...HEAD` (three-dot), never two-dot: a concurrent merge plus a
background fetch can move the base mid-run and turn the two-dot diff into
phantom deletions. `dev` is the integration branch every PR targets
(docs/contributing.md#branch-and-pr-model); use `origin/main` only for a
release PR cut from `dev`. If origin moved, confirm zero file overlap and a
clean `git merge-tree --write-tree origin/dev HEAD` before opening the PR by
hand. The workflow never
pushes and never opens a PR.
+27 -4
View File
@@ -9,6 +9,13 @@ description: Run the local mirror of OwnCord's CI gates before pushing. Use when
Run only the sections your change touches. Server and client are independent.
From the repository root, `npm run check` runs all of it, and
`check:server` / `check:client` / `check:rust` run one stack. `node
scripts/run.mjs --list` prints the exact command each step runs and the
directory it runs in — the per-stack commands below are those commands, and
staying with them is fine. Nothing here needs `make`, and server work needs no
Node.
## Server (from `Server/`)
All four build-tag variants must compile — the tags gate whole files, so a
@@ -20,7 +27,11 @@ go vet ./...
go test -race ./...
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
golangci-lint run # CI pins v2.11.3
make sqlc-verify protocol-verify # generated output must not be stale
# Generated output must not be stale. These are what `make sqlc-verify` and
# `make protocol-verify` reduce to — make is not on PATH on a stock Windows box.
sqlc generate && git diff --exit-code db/dbgen
go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
@@ -37,20 +48,23 @@ still in progress.
## Client (from `Client/`)
```bash
NODE_OPTIONS=--no-experimental-webstorage npm test
npm test
npm run typecheck
npm run lint
npm run format:check
```
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
`NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not
any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs
Node 24 without the flag (`ci.yml`), and the full suite was measured passing
without it — 192 files / 5257 tests, identical to the flagged run.
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
## Rust (from `Client/src-tauri/`)
```bash
cargo test
cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target
cargo clippy --all-targets -- -D warnings
```
@@ -67,3 +81,12 @@ CI on PRs to `main` and pulls heavy system dependencies.
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
enforces everything.
**`core.hooksPath` is exclusive, not additive.** Once set, Git resolves every
hook against `.githooks/` and stops consulting `.git/hooks/` entirely.
`.githooks/` holds only `pre-commit` and `pre-push`, so running
`hooks:install` **silently disables any locally installed `post-commit`**
including the one `graphify hook install` writes (`CLAUDE.md`). Nothing warns
you. If you want both, either re-install graphify's hook as
`.githooks/post-commit` (untracked, and it stays yours), or skip
`hooks:install` and run the checks through `npm run check` instead.
+16 -7
View File
@@ -22,31 +22,40 @@ fail() {
# ---------- Server (Go) ----------
go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/')
if [ -n "$go_staged" ]; then
if command -v go >/dev/null 2>&1; then
if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then
# shellcheck disable=SC2086 — repo paths contain no spaces
unformatted=$(gofmt -l $go_staged)
[ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted"
(cd Server && go vet ./...) || fail "go vet"
else
printf 'pre-commit: WARNING: go not installed; skipping Go checks.\n' >&2
printf 'pre-commit: WARNING: go/gofmt not installed; skipping Go checks.\n' >&2
fi
fi
# These two blocks inline what `make sqlc-verify` / `make protocol-verify` reduce
# to (Server/Makefile), rather than shelling out to make. `make` is not on PATH on
# a stock Windows box, and a guard on `go`/`sqlc` does not imply it: the old code
# probed one command and invoked another, so a contributor with Go but no make was
# told "protocol constants are stale" when nothing had been generated or compared.
# sqlc inputs changed -> regenerated db/dbgen must be part of the same commit.
if printf '%s\n' "$staged" | grep -qE '^Server/(db/queries/|migrations/|sqlc\.yaml|sqlc\.version)'; then
if command -v sqlc >/dev/null 2>&1; then
(cd Server && make sqlc-verify) \
|| fail "db/dbgen is stale — run 'make sqlc-generate' in Server/ and stage the result"
(cd Server && sqlc generate && git diff --exit-code db/dbgen) \
|| fail "db/dbgen is stale — run 'sqlc generate' in Server/ and stage the result"
else
printf 'pre-commit: WARNING: sqlc not installed (make sqlc-install); CI will run sqlc-verify.\n' >&2
printf 'pre-commit: WARNING: sqlc not installed; skipping the db/dbgen check. Install the version pinned in Server/sqlc.version. CI will run it.\n' >&2
fi
fi
# Protocol schema changed -> regenerated Go + TS constants must be in the same commit.
if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scripts/genprotocol/)'; then
if command -v go >/dev/null 2>&1; then
(cd Server && make protocol-verify) \
|| fail "protocol constants are stale — run 'make protocol-generate' in Server/ and stage the result"
(cd Server && go run ./scripts/genprotocol \
&& git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts) \
|| fail "protocol constants are stale — run 'go run ./scripts/genprotocol' in Server/ and stage the result"
else
printf 'pre-commit: WARNING: go not installed; skipping the protocol-constants check. CI will run it.\n' >&2
fi
fi
+27 -2
View File
@@ -15,9 +15,34 @@ fail() {
exit 1
}
# What changed relative to origin/main decides which side's gates run.
# What changed relative to this branch's base decides which side's gates run.
#
# The base is whichever of origin/dev, origin/main is NEAREST — the one with the
# fewest commits between its merge-base and HEAD. A feature branch cut from dev
# picks dev; dev itself scores 0 against dev (nothing to compare) and so picks
# main, which is right for a dev -> main release PR. Hardcoding origin/main got
# the first case wrong once dev became the integration branch: everything on dev
# and not yet on main counted as "changed", so both sides' gates ran every time.
#
# Markdown/docs changes never trigger builds.
changed=$(git diff --name-only origin/main...HEAD 2>/dev/null) || changed="__all__"
base=""
best=""
for cand in origin/dev origin/main; do
git rev-parse --verify -q "$cand" >/dev/null 2>&1 || continue
mb=$(git merge-base "$cand" HEAD 2>/dev/null) || continue
n=$(git rev-list --count "$mb..HEAD" 2>/dev/null) || continue
[ "$n" -eq 0 ] && continue
if [ -z "$best" ] || [ "$n" -lt "$best" ]; then
base=$cand
best=$n
fi
done
if [ -n "$base" ]; then
changed=$(git diff --name-only "$base...HEAD" 2>/dev/null) || changed="__all__"
else
changed="__all__"
fi
[ "$changed" = "__all__" ] || changed=$(printf '%s\n' "$changed" | grep -v '\.md$')
[ -z "$changed" ] && exit 0
+5
View File
@@ -1,5 +1,10 @@
# Pull Request
<!-- Base branch: PRs target `dev`, not `main`. `main` carries releases only.
See docs/contributing.md#branch-and-pr-model. The Docker and Tauri Full
Build jobs are gated on `main` and report as skipped here — that is
expected. -->
## Summary
<!-- What does this PR do? 1-3 bullet points -->
+24
View File
@@ -172,6 +172,30 @@ jobs:
# and must stay green — never "fix" a failing test by editing its assertions.
# ubuntu-latest for the same reason as client-check above: jsdom-only vitest
# with no platform-conditional code under test.
# The automated half of G-04: a planning document that states a finding count
# the ledger contradicts fails here instead of quietly misleading a reader.
# Deliberately tiny — no npm ci, because the script imports nothing outside
# node:. It also runs its own selftest, since the whole check rests on
# patterns narrow enough not to cry wolf.
docs-consistency:
name: Docs & Ledger Consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Self-test the count matcher
run: node scripts/check-doc-counts.mjs --selftest
- name: Documents must agree with the findings ledger
run: node scripts/check-doc-counts.mjs
- name: Ledger schema is valid
run: node .superpowers/render-ledger.mjs --check
client-tests:
name: Client Unit Tests
runs-on: ubuntu-latest
+5
View File
@@ -0,0 +1,5 @@
# engine-strict makes the engines block in package.json a hard error rather
# than an npm warning. npm reads the project .npmrc from the package
# directory and does not walk parent directories, so this file has to exist
# in every package root or the gate silently downgrades to a warning there.
engine-strict=true
+3 -1
View File
@@ -60,4 +60,6 @@ else under `.superpowers/` is per-session scratch and stays local.
- Security issues go through GitHub Security Advisories, never public issues
(`docs/security.md`). This repo is public — unfixed defects do not belong in
commits, issues, or PR descriptions.
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
- Branch from `dev` and PR to `dev``dev` is the integration branch and is
PR-only; `main` carries releases. Squash merge, conventional commit subjects.
Full model: [docs/contributing.md](docs/contributing.md#branch-and-pr-model).
+24
View File
@@ -0,0 +1,24 @@
# Contributing to OwnCord
The full guide lives in **[docs/contributing.md](docs/contributing.md)** —
environment setup, the branch model, coding standards, and how to run the
checks CI runs.
This file exists so GitHub can find it: the contributing-guidelines link that
appears on new issues and pull requests only resolves `CONTRIBUTING.md` at the
repository root, in `.github/`, or in `docs/`.
Three things worth knowing before you open a pull request:
- **Branch from `dev` and target `dev`.** `main` carries releases only. See
[Branch and PR model](docs/contributing.md#branch-and-pr-model).
- **Run the checks first.** `npm run check` from the repository root, or the
per-stack commands in [docs/contributing.md](docs/contributing.md). CI takes
about 15 minutes and enforces more than a plain build and test.
- **Report security issues privately**, through GitHub Security Advisories —
never a public issue or pull request. See [SECURITY.md](SECURITY.md) and
[docs/security.md](docs/security.md).
New to the codebase? [docs/README.md](docs/README.md) indexes everything, and
[docs/architecture/](docs/architecture/README.md) explains how the server and
client fit together.
+5
View File
@@ -0,0 +1,5 @@
# engine-strict makes the engines block in package.json a hard error rather
# than an npm warning. npm reads the project .npmrc from the package
# directory and does not walk parent directories, so this file has to exist
# in every package root or the gate silently downgrades to a warning there.
engine-strict=true
+4
View File
@@ -3,6 +3,10 @@
"private": true,
"version": "1.2.0-alpha.3",
"type": "module",
"engines": {
"node": ">=24",
"npm": ">=10"
},
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json && vite build",
+26 -21
View File
@@ -130,7 +130,7 @@ Two main components:
### Prerequisites
- Go 1.26+
- Node.js 20+
- Node.js 24+ (see `Client/.nvmrc`)
- Rust stable (client builds)
### Build from source
@@ -152,6 +152,17 @@ npm run tauri build
### Core verification commands
Everything CI gates on, from the repository root:
```bash
npm run check # server + client + Rust
npm run check:server # or one stack at a time
node scripts/run.mjs --list # exactly what each task runs, and where
```
Or run the stacks directly — the facade is a convenience, not the only path,
and **server work needs no Node at all**:
```bash
# Server
cd Server
@@ -202,28 +213,22 @@ When rotating the server updater key, update [Server/updater/server_update_publi
## Docs Index
- [docs/quick-start.md](docs/quick-start.md)
- [docs/deployment.md](docs/deployment.md)
- [docs/livekit-setup.md](docs/livekit-setup.md)
- [docs/port-forwarding.md](docs/port-forwarding.md)
- [docs/tailscale.md](docs/tailscale.md)
**[docs/README.md](docs/README.md) is the complete index** — every document in
`docs/`, grouped by whether it is guidance, a reference contract, a dated audit,
or a plan. The most-used entries:
- [docs/quick-start.md](docs/quick-start.md) — get a server running
- [docs/deployment.md](docs/deployment.md) — production deployment
- [docs/contributing.md](docs/contributing.md) — setup, branch model, how to run the checks CI runs
- [docs/security.md](docs/security.md) — reporting a vulnerability
- [docs/architecture/](docs/architecture/README.md) — system blueprints (diagrams + flows)
- [docs/audit-2026-08-04-docs-and-coverage.md](docs/audit-2026-08-04-docs-and-coverage.md) — latest full audit (docs accuracy, UX flow coverage, test runs)
- [docs/audit-2026-08-04.md](docs/audit-2026-08-04.md) — latest security review
- [docs/audit-2026-07-19.md](docs/audit-2026-07-19.md) — architecture & spec-conformance audit
- [docs/api.md](docs/api.md)
- [docs/protocol.md](docs/protocol.md)
- [docs/schema.md](docs/schema.md)
- [docs/architecture/client.md](docs/architecture/client.md) — client architecture (replaces client-architecture.md)
- [docs/architecture/ux/](docs/architecture/ux/README.md) — client UX specification (target-state flows, per-view states, event→reaction maps)
- [docs/server-configuration.md](docs/server-configuration.md)
- [docs/credential-storage.md](docs/credential-storage.md)
- [docs/mcp-introspect.md](docs/mcp-introspect.md) — dev-only MCP server for introspecting a running instance
- [docs/audit-test-coverage-2026-07-25.md](docs/audit-test-coverage-2026-07-25.md) — test-coverage audit
- [docs/audit-2026-04-07.md](docs/audit-2026-04-07.md) — first comprehensive audit
- [docs/plans/](docs/plans/) — design plans and decision records (each carries a verified status header)
- [docs/contributing.md](docs/contributing.md)
- [docs/security.md](docs/security.md)
- [docs/api.md](docs/api.md), [docs/protocol.md](docs/protocol.md), [docs/schema.md](docs/schema.md), [docs/server-configuration.md](docs/server-configuration.md) — reference contracts
- [docs/plans/README.md](docs/plans/README.md) — plan index; records each plan's state and is the authority over a plan's own header
Audits are dated snapshots and are not maintained after the fact — read them as
history. [docs/README.md](docs/README.md#audits--dated-not-maintained) lists all
nine, newest first.
## Contributing
+102
View File
@@ -0,0 +1,102 @@
# Documentation index
Every document in `docs/` is listed here. If it is not on this page it is not
current guidance.
Docs fall into four kinds, and the difference matters when you are deciding
whether to trust one: **guidance** tells you how to do something,
**reference** describes a contract the code actually implements, **audits** are
dated snapshots that were true when written and were never updated, and
**plans** record intent. Read an audit as history, not as status.
## Start here
| I want to… | Read |
| --- | --- |
| Run a server | [quick-start.md](quick-start.md) |
| Deploy for real | [deployment.md](deployment.md) |
| Contribute a change | [contributing.md](contributing.md) |
| Understand the system | [architecture/](architecture/README.md) |
| Report a vulnerability | [security.md](security.md) |
## Guidance
| Document | Covers |
| --- | --- |
| [quick-start.md](quick-start.md) | Getting a server running with the fewest steps. |
| [deployment.md](deployment.md) | Production deployment on Windows and Linux. |
| [contributing.md](contributing.md) | Environment setup, **the branch and PR model**, coding standards, how to run the checks CI runs. |
| [security.md](security.md) | How to report a vulnerability, and how findings are handled in public vs private. |
| [livekit-setup.md](livekit-setup.md) | Standing up the LiveKit SFU for voice and video. |
| [port-forwarding.md](port-forwarding.md) | Making a server reachable from outside the LAN. |
| [tailscale.md](tailscale.md) | Remote access without port forwarding. |
| [mcp-introspect.md](mcp-introspect.md) | Dev-only MCP server for introspecting a running instance. |
## Reference
These describe contracts the code implements. If one disagrees with the code,
the code is right and the document is a bug.
| Document | Covers |
| --- | --- |
| [api.md](api.md) | REST API under `/api/v1`. |
| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. |
| [schema.md](schema.md) | SQLite schema and migrations. |
| [server-configuration.md](server-configuration.md) | Every server configuration option. |
| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. |
| [protocol-schema.json](protocol-schema.json) | **Generated-code source of truth.** `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. |
## Architecture
[architecture/README.md](architecture/README.md) indexes the blueprints and
carries the maintenance rule: each blueprint names its source-of-truth files,
and a PR touching those updates the blueprint in the same change.
- [system-overview.md](architecture/system-overview.md), [server.md](architecture/server.md), [client.md](architecture/client.md)
- [data-model.md](architecture/data-model.md), [websocket.md](architecture/websocket.md), [voice-e2ee.md](architecture/voice-e2ee.md)
- [ux/](architecture/ux/README.md) — target-state UX spec, per-view states and event→reaction maps
[client-architecture.md](client-architecture.md) is a redirect stub; the live
document is [architecture/client.md](architecture/client.md).
## Audits — dated, not maintained
Point-in-time snapshots. They are **not** updated as the code moves, and they
are deliberately left alone when paths change, so links from commit messages
keep resolving. Anything here may be stale; the ledger and the plan index carry
current status.
| Audit | Scope |
| --- | --- |
| [audit-2026-08-23-repository-layout.md](audit-2026-08-23-repository-layout.md) | Repository layout and contributor experience (`RL-01``RL-22`). |
| [audit-2026-08-23-repository-health.md](audit-2026-08-23-repository-health.md) | Full repository health. |
| [audit-2026-08-19.md](audit-2026-08-19.md) | Repo health. **States "0 open findings" — untrue since; see the ledger.** |
| [audit-test-coverage-2026-08-19.md](audit-test-coverage-2026-08-19.md) | Test audit (`T-*`, a separate register from the `OC-*` ledger). |
| [audit-2026-08-04-docs-and-coverage.md](audit-2026-08-04-docs-and-coverage.md) | Documentation accuracy and UI/UX test coverage. |
| [audit-2026-08-04.md](audit-2026-08-04.md) | Security review. |
| [audit-test-coverage-2026-07-25.md](audit-test-coverage-2026-07-25.md) | Test-coverage audit. |
| [audit-2026-07-19.md](audit-2026-07-19.md) | Architecture and spec-conformance review. |
| [audit-2026-04-07.md](audit-2026-04-07.md) | First comprehensive audit. |
## Plans
[plans/README.md](plans/README.md) indexes every plan with a recorded state —
active, partially implemented, design-only, or shipped — and **is the authority
over a plan's own header**, which can drift.
## Where status actually lives
Do not read a defect count, or a "what works" claim, out of a document on this
page. Status has owners:
| Concern | Source of truth |
| --- | --- |
| Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) |
| Security-sensitive defects | Private GitHub Security Advisories |
| Phase order and gates | [plans/repo-health-roadmap-2026-08-23.md](plans/repo-health-roadmap-2026-08-23.md) |
| Current measured baseline | [plans/b0-baseline-2026-08-25.md](plans/b0-baseline-2026-08-25.md) |
| Generated-code contracts | `CLAUDE.md`, "Generated code — never hand-edit" |
A CI job checks that documents on this page do not contradict the ledger's
counts. Adding a count to a document means adding it to that check's allow-list
in `scripts/check-doc-counts.mjs`.
+71 -12
View File
@@ -13,12 +13,39 @@ How to set up the development environment and contribute to OwnCord.
| Linux ARM64 | ✅ | ✅ (CI only) |
- **Go 1.26+** (server)
- **Node.js 20+** (client)
- **Node.js 24+** (client) — pinned in `Client/.nvmrc`; `engine-strict` makes a
wrong major a hard failure, not a warning
- **Rust / Cargo** (Tauri client — not needed for server-only work)
- **Docker + Compose v2** (optional — alternative to building the server locally)
### Available Commands
#### Root facade — one entry point
From the repository root. These orchestrate the per-stack commands below; they
are a convenience, not a replacement. Nothing here needs `make`, and everything
works the same on Windows, macOS and Linux.
| Command | Description |
|---------|-------------|
| `npm run bootstrap` | `npm ci` in all three package roots |
| `npm run check` | Everything CI gates on: server, client, Rust |
| `npm run check:server` | Server only — build variants, vet, race, deadlock, lint, generated-output drift |
| `npm run check:client` | Client only — typecheck, lint, format, unit + integration tests |
| `npm run check:rust` | Tauri backend — `cargo test --lib` and clippy |
| `npm run check:docs` | Fail if a watched document states a finding count the ledger contradicts |
| `npm run format` | Prettier over the client, `gofmt -w` over the server |
| `npm run generate` | Regenerate protocol constants and the sqlc query layer |
| `npm run release:preflight` | `check` plus a client production build |
| `node scripts/run.mjs --list` | Print the exact command every task runs, and where |
Tools CI installs but you may not have — `golangci-lint`, `sqlc` — are skipped
with a printed reason rather than failing the run.
**Working on the server only? You never need Node.** The facade prints each
command it runs and the directory it runs it in; those are the commands in the
next section, and using them directly is equally correct.
#### Server (Go)
| Command | Description |
@@ -103,6 +130,15 @@ npm run hooks:install # = git config core.hooksPath .githooks
Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` when needed — CI still enforces everything.
Neither hook needs `make`, and neither needs Node for the Go checks.
**`core.hooksPath` is exclusive.** Once set, Git resolves every hook against
`.githooks/` and never looks in `.git/hooks/` again. `.githooks/` holds only
`pre-commit` and `pre-push`, so `hooks:install` silently disables any
`post-commit` you installed there — `graphify hook install` writes one. Nothing
warns you. Put it at `.githooks/post-commit` instead (untracked, so it stays
yours), or skip `hooks:install` and use `npm run check` before pushing.
## Plugin Development
Plugins are WASM modules loaded at runtime when the server is built with `-tags wazero`.
@@ -121,10 +157,27 @@ functions is equally valid — TinyGo is just the example toolchain used by `exa
---
## Active Branches
## Branch and PR model
- `main` -- stable releases
- `dev` -- active development
This section is the single source of truth for the branch model. Everywhere
else -- the root `README.md`, `CLAUDE.md`, the PR template -- summarises it and
links here rather than restating it.
- `dev` -- the integration branch. **All contributions target `dev`.**
- `main` -- releases only. `dev` is merged to `main` for a release, and release
tags are cut from `main`.
`dev` is protected and PR-only: direct pushes are rejected, ten status checks
are required, `required_approving_review_count` is 0, and the rule is enforced
on admins. So a PR is self-mergeable once CI is green, but no commit reaches
`dev` without CI having run on it. Settings and rationale live in
[`docs/plans/b0-dev-branch-protection.sh`](plans/b0-dev-branch-protection.sh).
Two consequences worth knowing before you open a PR:
- The Docker and Tauri Full Build jobs are gated on `main` and report as
*skipped* on a PR into `dev`. That is expected, not a failure.
- Squash merge, and a conventional commit subject on the squashed commit.
## Branch Naming
@@ -149,11 +202,15 @@ ci: add lint step to GitHub Actions
## Pull Request Process
1. Branch from `dev` (the active development branch)
2. PRs target `dev`; `dev` is merged to `main` for releases, which are cut from tagged commits on `main`
3. CI must pass (build + test + lint)
See [Branch and PR model](#branch-and-pr-model) above for what to branch from
and target.
1. Branch from `dev`
2. Open the PR against `dev`
3. All ten required checks must pass -- `dev` is protected, so a red PR cannot
merge
4. Request code review
5. Squash merge preferred
5. Squash merge, conventional commit subject
## Testing
@@ -189,7 +246,9 @@ closing audit findings 2026-04-07 #8 / DC-11):
triaged in the workflow comment instead of blocking on unfixable pins),
`govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused
client dependencies outright.
- **Version skew is pinned at the toolchain level** too: `.nvmrc` + CI both
say Node 20, `Server/sqlc.version` pins sqlc, Go pins via `go.mod`
(`GOTOOLCHAIN=auto`), and GitHub Actions are SHA-pinned with Dependabot
bumping the pins.
- **Version skew is pinned at the toolchain level** too: `Client/.nvmrc`, every
`actions/setup-node` in CI, and an `engines` block in all three
`package.json` files say Node 24 — with `engine-strict=true` in each
package's `.npmrc`, so a wrong major fails the install instead of warning.
`Server/sqlc.version` pins sqlc, Go pins via `go.mod` (`GOTOOLCHAIN=auto`),
and GitHub Actions are SHA-pinned with Dependabot bumping the pins.
+1 -1
View File
@@ -9,7 +9,7 @@ adds nothing to the server binary — it is a thin wrapper over OwnCord's existi
the client's on-disk log.
- **Code:** `tools/mcp-introspect/index.mjs` (one file, ~270 lines)
- **Runtime:** Node ≥ 20, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`)
- **Runtime:** Node ≥ 24, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`)
- **Registration:** `/.mcp.json` (committed) and `.claude/settings.local.json` (local)
---
+1 -1
View File
@@ -20,7 +20,7 @@ Get OwnCord running with the fewest possible steps.
## Prerequisites
- Go 1.26+ (only if building server from source)
- Node.js 20+ and Rust (only if building client from source)
- Node.js 24+ and Rust (only if building client from source)
- Docker + Compose v2 (Docker path only)
- LiveKit (optional, required for voice/video)
+13
View File
@@ -1,6 +1,19 @@
{
"private": true,
"engines": {
"node": ">=24",
"npm": ">=10"
},
"scripts": {
"bootstrap": "node scripts/run.mjs bootstrap",
"check": "node scripts/run.mjs check",
"check:server": "node scripts/run.mjs check:server",
"check:client": "node scripts/run.mjs check:client",
"check:rust": "node scripts/run.mjs check:rust",
"check:docs": "node scripts/run.mjs check:docs",
"format": "node scripts/run.mjs format",
"generate": "node scripts/run.mjs generate",
"release:preflight": "node scripts/run.mjs release:preflight",
"changelog": "changelogen",
"release": "changelogen --release",
"hooks:install": "git config core.hooksPath .githooks"
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
// Fail when an active document states a finding count the ledger contradicts
// (the automated half of G-04).
//
// node scripts/check-doc-counts.mjs
// node scripts/check-doc-counts.mjs --selftest
//
// Scope, deliberately small: this counts ledger statuses and compares them to
// the numbers active documents assert. It is not a document-status framework,
// and it does not check that FINDINGS.md is in sync with the ledger — that is a
// different check (RL-07) with a different owner.
//
// It reads findings-ledger.json directly and does NOT import render-ledger.mjs.
// That module has no `import.meta.main` guard, so importing it to reuse
// `validate`/`render` runs `main()` and rewrites FINDINGS.md as a side effect.
//
// ── Why the patterns are narrow ──────────────────────────────────────────────
// "open" is overloaded in this repository. The issue register has 45 open P1
// *rows*; a security scan closed 8 *findings* F1F8; `G-05 **refuted**` puts a
// digit next to a status word. None of those are ledger counts, and a loose
// pattern flags all of them — a check that cries wolf gets ignored, which is
// the failure mode G-04 already describes.
//
// So a number is only read as a ledger claim in three unambiguous shapes:
//
// 1. An enumeration — two or more "<n> <status>" pairs on one line, e.g.
// "306 fixed / 38 open / 3 declined / 1 duplicate = 348". A lone
// "45 open" is never enough.
// 2. A status table row "| open | **38** |", but only in a table that also
// carries a "| Total | 348 |" row nearby.
// 3. "<n> records" / "<n> findings", but only where the ledger is named
// within the preceding few lines.
//
// Dated docs/audit-*.md are reported, never failed: they are point-in-time
// snapshots that are deliberately not maintained, and editing them is out of
// scope for the repository-layout work. audit-2026-08-19.md does claim zero
// open findings — true when written, false now, and left alone on purpose.
import { readFileSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Active documents that assert a count. Adding a count to a document means
// adding it here — an unlisted document is not checked.
const WATCHED = [
'docs/README.md',
'docs/plans/README.md',
'docs/plans/hp-0-scorecard-2026-08-25.md',
'docs/plans/repo-health-issue-register-2026-08-23.md',
'docs/plans/b0-baseline-2026-08-25.md',
'docs/plans/b1-repository-foundation-2026-08-25.md',
'.superpowers/FINDINGS.md',
'CLAUDE.md',
'README.md',
]
// Reported but never failed — dated snapshots, see the header.
const REPORT_ONLY = ['docs/audit-']
const STATUSES = ['open', 'fixed', 'declined', 'duplicate', 'refuted', 'blocked']
const S = STATUSES.join('|')
// Never let a digit that belongs to an identifier or comparison start a claim:
// G-05, >=20, CGO_ENABLED=0, version=1.2.0-alpha.3.
const LEAD = '(?<![\\w.\\-=<>/])'
const PAIR = new RegExp(`${LEAD}(\\d+)\\*{0,2}\\s+\\*{0,2}(${S})\\b`, 'gi')
const LEDGER_CONTEXT = /ledger|OC-\d|findings-ledger|FINDINGS\.md/i
export function tally(ledger) {
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]))
for (const f of ledger.findings) if (f.status in counts) counts[f.status]++
counts.total = ledger.findings.length
return counts
}
export function claimsIn(text) {
const out = []
const lines = text.split('\n')
// Which lines sit in a status table that has a Total row within 10 lines?
const totalRowAt = new Set()
lines.forEach((l, i) => {
if (/^\|\s*\*{0,2}total\*{0,2}\s*\|\s*\*{0,2}\d+\*{0,2}\s*\|/i.test(l)) totalRowAt.add(i)
})
const nearTotalRow = (i) => [...totalRowAt].some((t) => Math.abs(t - i) <= 10)
lines.forEach((line, i) => {
const at = i + 1
// 1. Enumeration: two or more "<n> <status>" pairs on one line.
const pairs = [...line.matchAll(PAIR)]
if (pairs.length >= 2) {
for (const m of pairs) {
out.push({ line: at, kind: m[2].toLowerCase(), value: Number(m[1]), text: m[0].trim() })
}
// "... = 348" closing an enumeration is the total.
const eq = line.match(/=\s*\*{0,2}(\d+)\*{0,2}/)
if (eq) out.push({ line: at, kind: 'total', value: Number(eq[1]), text: eq[0].trim() })
}
// 2. Status table row, only inside a table that totals itself.
const row = line.match(new RegExp(`^\\|\\s*\\*{0,2}(${S})\\*{0,2}\\s*\\|\\s*\\*{0,2}(\\d+)\\*{0,2}\\s*\\|`, 'i'))
if (row && nearTotalRow(i)) {
out.push({ line: at, kind: row[1].toLowerCase(), value: Number(row[2]), text: row[0].trim() })
}
const totalRow = line.match(/^\|\s*\*{0,2}total\*{0,2}\s*\|\s*\*{0,2}(\d+)\*{0,2}\s*\|/i)
if (totalRow) out.push({ line: at, kind: 'total', value: Number(totalRow[1]), text: totalRow[0].trim() })
// 3. "<n> records"/"<n> findings", only near an explicit mention of the ledger.
const ctx = lines.slice(Math.max(0, i - 3), i + 1).join('\n')
if (LEDGER_CONTEXT.test(ctx)) {
for (const m of line.matchAll(new RegExp(`${LEAD}(\\d+)\\*{0,2}\\s+(?:records?|findings?)\\b`, 'gi'))) {
out.push({ line: at, kind: 'total', value: Number(m[1]), text: m[0].trim() })
}
}
})
return out
}
function main() {
const ledgerPath = join(ROOT, '.superpowers/findings-ledger.json')
if (!existsSync(ledgerPath)) {
console.error(`missing ${ledgerPath}`)
process.exit(1)
}
const counts = tally(JSON.parse(readFileSync(ledgerPath, 'utf8')))
console.log(`ledger: ${STATUSES.map((s) => `${counts[s]} ${s}`).join(' / ')} = ${counts.total}`)
const failures = []
const notes = []
let claimCount = 0
for (const rel of WATCHED) {
const p = join(ROOT, rel)
if (!existsSync(p)) {
failures.push(`${rel}: watched file does not exist — fix the list in scripts/check-doc-counts.mjs`)
continue
}
for (const c of claimsIn(readFileSync(p, 'utf8'))) {
const actual = counts[c.kind]
if (actual === undefined) continue
claimCount++
if (c.value === actual) continue
const entry = `${rel}:${c.line} claims "${c.text}" — ledger says ${c.kind} = ${actual}`
if (REPORT_ONLY.some((prefix) => rel.startsWith(prefix))) notes.push(entry)
else failures.push(entry)
}
}
for (const n of notes) console.log(`NOTE ${n}`)
if (failures.length) {
console.error(`\n${failures.length} document claim(s) contradict the ledger:\n`)
for (const f of failures) console.error(` ${f}`)
console.error(
'\nThe ledger is the source of truth. Update the document, or if the ledger is\n' +
'wrong, fix .superpowers/findings-ledger.json and re-render FINDINGS.md.',
)
process.exit(1)
}
console.log(`\n${claimCount} claim(s) across ${WATCHED.length} watched document(s) agree with the ledger.`)
}
function selftest() {
let failed = 0
const assert = (cond, msg) => {
console.log(`${cond ? 'PASS' : 'FAIL'} ${msg}`)
if (!cond) failed++
}
const t = tally({ findings: [{ status: 'open' }, { status: 'open' }, { status: 'fixed' }] })
assert(t.open === 2 && t.fixed === 1 && t.total === 3, 'tally counts by status and total')
assert(t.refuted === 0, 'a declared-but-unused status counts 0, not undefined')
const c = claimsIn
const has = (s, kind, value) => c(s).some((x) => x.kind === kind && x.value === value)
assert(has('Ledger: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.', 'open', 38), 'enumeration: reads each pair')
assert(has('Ledger: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.', 'total', 348), 'enumeration: reads the = total')
assert(has('**38 open** · 0 blocked · 306 fixed · 3 declined', 'fixed', 306), 'enumeration: FINDINGS.md header shape')
assert(has('| open | **38** |\n| **Total** | **348** |', 'open', 38), 'status table with a Total row')
assert(has('the ledger holds\n348 records', 'total', 348), '"N records" near a ledger mention')
// The false positives that made a looser version unusable.
assert(c('The 45 open P1 rows are tracked in the register.').length === 0, 'a lone "45 open" is not a ledger claim')
assert(c('| All 8 findings F1-F8 closed |').length === 0, 'a different register is not a ledger claim')
assert(c('| `golangci-lint` | claimed broken (G-05) | G-05 **refuted** |').length === 0, '"G-05 refuted" is an id, not a count')
assert(c('`tools/mcp-introspect/package.json` (`">=20"`)').length === 0, '">=20" is not a count')
assert(c('go build -ldflags "-X main.version=1.2.0-alpha.3"').length === 0, 'a version string is not a count')
assert(c('11 medium, 27 low').length === 0, 'severities are not statuses')
assert(c('22 sit under Client/').length === 0, 'a bare number is not a claim')
assert(c('348 records in some unrelated table').length === 0, '"N records" without ledger context is ignored')
console.log(failed ? `\nselftest: ${failed} assertion(s) failed` : '\nselftest: all assertions pass')
process.exit(failed ? 1 : 0)
}
if (process.argv.includes('--selftest')) selftest()
else main()
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env node
// Root command facade (RL-04 / L-04).
//
// One entry point for the checks CI runs, so a contributor does not have to
// know which directory each stack lives in. `node scripts/run.mjs --list`
// prints every task and the exact commands it runs.
//
// Two rules this file exists to keep:
//
// 1. Cross-platform. No `make`, no shell syntax, no `cd &&`. Every step is
// spawned directly with an explicit `cwd`, so there is no shell to quote
// for and nothing that behaves differently on Windows.
// 2. The facade orchestrates, it never becomes the only path. Each step
// prints the command it runs, in the directory it runs it in, so a
// Go-only contributor can read the output and type those commands
// instead — and never needs Node to work on the server.
//
// Dependency-free by design: Node's standard library only, like
// .superpowers/render-ledger.mjs. Adding a dependency here would mean
// `npm run check` could not run until `npm install` had.
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const WIN = process.platform === 'win32'
// npm and npx are batch shims on Windows; everything else is a real binary.
const bin = (c) => (WIN && (c === 'npm' || c === 'npx') ? `${c}.cmd` : c)
/** A step that always runs. */
const step = (cmd, args, cwd = '.') => ({ cmd, args, cwd })
/**
* A step that is skipped, with a printed reason, when `probe` is not on PATH.
* Used for tools CI installs but a contributor may not have: golangci-lint has
* no wrapper in this repo at all, and sqlc is pinned by Server/sqlc.version.
*/
const optional = (probe, cmd, args, cwd, why) => ({ cmd, args, cwd, probe, why })
// `git diff --exit-code` after regenerating is what `make protocol-verify` and
// `make sqlc-verify` reduce to. Inlined so neither needs make.
const PROTOCOL_VERIFY = [
step('go', ['run', './scripts/genprotocol'], 'Server'),
step('git', ['diff', '--exit-code', 'ws/message_types.go', '../Client/src/lib/protocolTypes.ts'], 'Server'),
]
const SQLC_VERIFY = [
optional('sqlc', 'sqlc', ['generate'], 'Server', 'sqlc not on PATH — install the version in Server/sqlc.version'),
optional('sqlc', 'git', ['diff', '--exit-code', 'db/dbgen'], 'Server', 'sqlc not on PATH'),
]
const CHECK_SERVER = [
step('go', ['build', './...'], 'Server'),
step('go', ['build', '-tags', 'otel', './...'], 'Server'),
step('go', ['build', '-tags', 'wazero', './...'], 'Server'),
step('go', ['build', '-tags', 'otel,wazero', './...'], 'Server'),
step('go', ['vet', './...'], 'Server'),
step('go', ['test', '-race', './...'], 'Server'),
step('go', ['test', '-tags', 'deadlock', '-count=1', './ws/'], 'Server'),
optional('golangci-lint', 'golangci-lint', ['run', './...'], 'Server', 'golangci-lint not on PATH — CI pins v2.11.3'),
...PROTOCOL_VERIFY,
...SQLC_VERIFY,
]
const CHECK_CLIENT = [
step('npm', ['run', 'typecheck'], 'Client'),
step('npm', ['run', 'lint'], 'Client'),
step('npm', ['run', 'format:check'], 'Client'),
step('npm', ['test'], 'Client'),
]
// Matches ci.yml's Rust Unit Tests job exactly: --lib for tests, --all-targets
// for clippy. They differ deliberately; do not "align" them.
const CHECK_RUST = [
step('cargo', ['test', '--lib'], 'Client/src-tauri'),
step('cargo', ['clippy', '--all-targets', '--', '-D', 'warnings'], 'Client/src-tauri'),
]
// Fast and dependency-free, so it goes first: a contradicted count should not
// wait behind ten minutes of -race.
const CHECK_DOCS = [step('node', ['scripts/check-doc-counts.mjs'], '.')]
const TASKS = {
bootstrap: [
step('npm', ['ci'], '.'),
step('npm', ['ci'], 'Client'),
step('npm', ['ci'], 'tools/mcp-introspect'),
],
'check:server': CHECK_SERVER,
'check:client': CHECK_CLIENT,
'check:rust': CHECK_RUST,
'check:docs': CHECK_DOCS,
check: [...CHECK_DOCS, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST],
generate: [
step('go', ['run', './scripts/genprotocol'], 'Server'),
optional('sqlc', 'sqlc', ['generate'], 'Server', 'sqlc not on PATH — install the version in Server/sqlc.version'),
],
format: [
step('npm', ['run', 'format'], 'Client'),
optional('gofmt', 'gofmt', ['-w', '.'], 'Server', 'gofmt not on PATH'),
],
'release:preflight': [
...CHECK_DOCS,
...CHECK_SERVER,
...CHECK_CLIENT,
...CHECK_RUST,
step('npm', ['run', 'build'], 'Client'),
],
}
function onPath(cmd) {
const probe = spawnSync(WIN ? 'where' : 'command', WIN ? [cmd] : ['-v', cmd], {
stdio: 'ignore',
shell: !WIN, // `command` is a shell builtin; `where` is a real binary
})
return probe.status === 0
}
function runTask(name) {
const steps = TASKS[name]
if (!steps) {
console.error(`unknown task: ${name}\nknown: ${Object.keys(TASKS).join(', ')}`)
process.exit(2)
}
const skipped = []
for (const s of steps) {
if (s.probe && !onPath(s.probe)) {
console.log(`\n--- SKIP ${s.cmd} ${s.args.join(' ')} (${s.why})`)
skipped.push(s.probe)
continue
}
const where = s.cwd === '.' ? '' : ` [in ${s.cwd}]`
console.log(`\n--- ${s.cmd} ${s.args.join(' ')}${where}`)
const r = spawnSync(bin(s.cmd), s.args, {
cwd: join(ROOT, s.cwd),
stdio: 'inherit',
shell: false,
})
if (r.error && r.error.code === 'ENOENT') {
console.error(`\nFAILED: ${s.cmd} is not installed or not on PATH.`)
process.exit(1)
}
if (r.status !== 0) {
console.error(`\nFAILED: ${s.cmd} ${s.args.join(' ')}${where} exited ${r.status}`)
process.exit(r.status ?? 1)
}
}
if (skipped.length) {
console.log(`\n${name}: passed, with ${[...new Set(skipped)].join(', ')} skipped (not installed). CI runs them.`)
} else {
console.log(`\n${name}: passed`)
}
}
const arg = process.argv[2]
if (!arg || arg === '--list') {
for (const [name, steps] of Object.entries(TASKS)) {
console.log(`\n${name}`)
for (const s of steps) {
const where = s.cwd === '.' ? '' : ` (in ${s.cwd})`
console.log(` ${s.probe ? '[optional] ' : ''}${s.cmd} ${s.args.join(' ')}${where}`)
}
}
console.log('')
process.exit(0)
}
if (!existsSync(join(ROOT, 'Server')) || !existsSync(join(ROOT, 'Client'))) {
console.error('run this from the repository root')
process.exit(2)
}
runTask(arg)
+5
View File
@@ -0,0 +1,5 @@
# engine-strict makes the engines block in package.json a hard error rather
# than an npm warning. npm reads the project .npmrc from the package
# directory and does not walk parent directories, so this file has to exist
# in every package root or the gate silently downgrades to a warning there.
engine-strict=true
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"description": "Local MCP server to introspect a running OwnCord instance (dev tool for Claude Code).",
"engines": {
"node": ">=20"
"node": ">=24",
"npm": ">=10"
},
"scripts": {
"start": "node index.mjs"