Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
774902b
feat(bridge): windows-native fallbacks for state and config dirs
kimjunny Aug 22, 2026
ae3b67a
fix(bridge): case-aware path comparisons for win32
kimjunny Aug 22, 2026
ff0d9af
fix(bridge): force-kill shutdown note and service-neutral wording
kimjunny Aug 22, 2026
6c5520e
feat(scripts): cross-platform version gate in typescript
kimjunny Aug 22, 2026
929f578
feat(scripts): cross-platform ctl skeleton
kimjunny Aug 22, 2026
384c06f
feat(scripts): windows task scheduler, systemd and launchd backends
kimjunny Aug 22, 2026
9ba1881
feat(scripts): lifecycle verbs for ctl
kimjunny Aug 22, 2026
1923d9b
feat(scripts): info verbs for ctl
kimjunny Aug 22, 2026
8895d66
feat(scripts): build, serve and push verbs for ctl
kimjunny Aug 22, 2026
f7baed9
test(scripts): ctl integration suite over injected fakes
kimjunny Aug 22, 2026
3d1b63d
feat(plugin): declare windows platform with neutral bun actions
kimjunny Aug 22, 2026
4c8415a
ci: add windows job and keep posix ctl coverage
kimjunny Aug 22, 2026
fa9ebd6
docs: windows install, variant-e fallback and adr-0021
kimjunny Aug 22, 2026
f5831d9
test(bridge): windows-portable fixtures and expectations
kimjunny Aug 22, 2026
bd3f5eb
test(web): storage shim for node 22+ gap and locale-proof date
kimjunny Aug 22, 2026
574c9e8
fix(scripts): wire the verb modules into ctl dispatch
kimjunny Aug 23, 2026
c4600c7
fix(scripts): single-writer bridge log under the windows task
kimjunny Aug 23, 2026
f49f0f6
test(web): raise vitest timeout headroom for loaded windows hosts
kimjunny Aug 23, 2026
7d4c0ef
fix(scripts): complete ctl runtime and service wiring
kimjunny Aug 23, 2026
a00f662
docs: align Windows lifecycle and ingress guidance
kimjunny Aug 23, 2026
a3c698d
style(scripts): remove trailing backend whitespace
kimjunny Aug 23, 2026
3f9dec4
fix(scripts): address lifecycle review findings
kimjunny Aug 23, 2026
58bf526
docs: describe the supported TypeScript ctl path
kimjunny Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .adr/0021-windows-service-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 0021 - Windows service backend for Collie

- **Status:** Accepted
- **Date:** 2026-08-22
- **Shipped in:** Unshipped

## Context

Collie already runs on Windows at the bridge layer, and the launcher now does too. The remaining
problem was the service backend. The old shape split lifecycle logic across bash, per-OS shell glue,
and service supervisor branches. That worked, but it kept the same lifecycle logic in two languages
and made Windows the only host without a supported service path in the same codebase.

The candidate backends were:

- Task Scheduler on Windows
- systemd on Linux
- launchd on macOS

NSSM and WinSW were not chosen because they add a wrapper we would need to ship, test, and explain,
and they move the service contract out of the repo.

There is also one hard operational fact to keep honest: the bridge flush path is async, and the stop
model on Windows is a forced kill. The last debounce window can be lost. That matches the current
bridge code and its own comment in `bridge/activity.ts:300`, and it is better to accept that than to
pretend a graceful shutdown exists when Task Scheduler is used with `Stop-ScheduledTask` plus a
fallback `taskkill`.

## Decision

Use Task Scheduler as the Windows service backend, and keep the lifecycle implementation in
TypeScript under `scripts/ctl/` instead of bash.

`main.ts` is the single entry point for the supported ctl verbs. That lets Windows, macOS and Linux
share one parser, one readiness probe, one backend interface, and one command surface. The old bash
implementation still exists for compatibility, but the supported path is TS.

Windows service termination is a force-kill model. We accept that the final debounce window may be
lost on shutdown, and we rely on the normal save cadence plus the POSIX path elsewhere for the
stronger guarantee.

The manifest also becomes platform-neutral for lifecycle actions. Herdr action ids stay unique, so
we cannot keep separate per-platform action rows with the same ids. The right shape is one bun-based
command row per verb, with platform support declared at the item level.

The baseline deployment scope stays, full Windows host deployment. Alternatives remain noted, but
they are not the default:

- (b) Windows bridge only, with another host still handling ingress
- (c) Windows bridge behind an external reverse proxy or tunnel

The validation host had neither the Tailscale executable nor its Windows service installed, so its
SC4 outcome is an explicit transition to deployment Variant E with `COLLIE_SKIP_SERVE=1`. Task
Scheduler owns the bridge lifecycle; another authenticated mesh or reverse proxy must own ingress.
The loopback URL proves bridge health but is not, by itself, reachable from a phone.

## Consequences

Windows gets a supported service path without a second wrapper layer.

The tradeoff is explicit loss of the final debounce window on forced termination, which is acceptable
for the Windows backend but not a graceful-shutdown guarantee.

The ctl code becomes easier to test and reason about, because the same verbs and readiness checks are
used everywhere.

The manifest is simpler, but less specific per platform. That is the cost of keeping action ids
unique and the command surface uniform.

If a future Windows service backend can prove a better stop model without adding a new wrapper or a
second command path, this ADR can be revisited. Until then, Task Scheduler is the supported route,
and this record stays accepted.
5 changes: 3 additions & 2 deletions .adr/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Architecture decision records

Decisions with a **blast radius wider than the diff that made them** the ones a future
contributor (or a future agent) would otherwise re-derive from scratch, or quietly reverse because
Decisions with a **blast radius wider than the diff that made them**, the ones a future
contributor, or a future agent, would otherwise re-derive from scratch or quietly reverse because
the reasoning lived only in a PR thread.

One file per decision, numbered in the order they were accepted:
Expand Down Expand Up @@ -75,6 +75,7 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar
| [0017](./0017-recognising-a-password-prompt-changes-what-collie-says.md) | Recognising a password prompt changes what Collie says, never what it sends | Accepted |
| [0018](./0018-operator-command-rows-replace-the-catalog.md) | The operator's command rows replace the catalog, never merge into it | Accepted |
| [0020](./0020-a-major-upgrade-is-consented-by-flag.md) | A major upgrade is consented by flag; routine update follows tags within the major | Accepted |
| [0021](./0021-windows-service-backend.md) | Windows uses a per-user Task Scheduler service backend | Accepted |

**0011–0016 and 0019 are not missing** — they are the pack/federation and lint-gate decisions,
accepted on the `v1`
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@ jobs:
- name: Version consistency
run: bash scripts/check-version.sh

- name: Install (root)
run: bun install --frozen-lockfile
- name: Typecheck (root)
run: bun run typecheck
- name: Test (bridge)
run: bun run test
- name: Test (ctl posix)
run: bun run test:ctl-posix

- name: Install (web)
run: cd web && bun install --frozen-lockfile
- name: Typecheck (web)
run: cd web && bun run typecheck
- name: Test (web)
run: cd web && bun run test

windows:
name: typecheck + tests (windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2

- name: Install (root)
run: bun install --frozen-lockfile
- name: Typecheck (root)
Expand Down
18 changes: 9 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ a fork PR does carry a release commit, cherry-pick the functional commits with `
Doc-only changes (`*.md`) don't need a bump. This is enforced two ways, but **you are the first
line — do it as part of the change, not after**:

- `scripts/check-version.sh` runs inside `scripts/collie-ctl.sh build` (a release can't build while
versions disagree).
- `scripts/check-version.sh` and `scripts/check-version.ts` enforce the same invariant;
`bun scripts/ctl/main.ts build` runs the TypeScript gate before a release build.
- A **git pre-commit hook** (`scripts/git-hooks/pre-commit`, activate once with
`scripts/install-hooks.sh`) blocks commits where functional code changed but the version didn't.
Escape hatch for a single commit: `SKIP_VERSION_CHECK=1 git commit …`.
Expand Down Expand Up @@ -93,18 +93,18 @@ the unit name; the Herdr action runs from anywhere.
a rebuild is **immediately live — no restart**.
- **Backend changes** (`bridge/*.ts`): Bun does **not** hot-reload the service — you must
`systemctl --user restart collie`. Forgetting this is the #1 "my change didn't take" trap.
- `bun run build` (root) and `collie-ctl.sh build` **typecheck both sides first** (root tsc + web
- `bun run build` (root) and `bun scripts/ctl/main.ts build` **typecheck both sides first** (root tsc + web
tsc), then build web to `dist-staging` and swap it in atomically — a failed build never empties a
live `web/dist`. Bare `cd web && bun run build` still skips typechecking; don't ship from it.
- **Tests:** frontend `cd web && bun run test` (Vitest + jsdom + Testing Library + MSW; no headless
browser); backend `bun run test` at the root — Bun's own runner over every pure-logic module in
`bridge/` (access checks, state engine, config, journal adapters, notifications, uploads, …) plus
`scripts/collie-ctl.test.sh`, which exercises the ctl lifecycle in a sandboxed HOME.
A **pre-push hook** (`scripts/git-hooks/pre-push`) runs **both** before
browser); `bun run test` at the root runs the bridge and TypeScript script tests.
`bun run test:ctl-posix` separately exercises the legacy POSIX shell lifecycle in a sandboxed HOME.
A **pre-push hook** (`scripts/git-hooks/pre-push`) runs the required suites before
every push — override once with `SKIP_TESTS=1 git push`. The bits that genuinely need `Bun.serve` /
`Bun.connect` (HTTP handlers, the socket client) stay unit-untested — Vitest-on-Node can't run them,
so keep new backend logic pure/injectable enough for `bun test`, or exercise it through `web/`.
- Service: `systemd --user` unit `collie` on the deployment host; logs `journalctl --user -u collie -f`.
- Service: systemd on Linux, launchd on macOS, and Task Scheduler on Windows; direct logs use
`bun scripts/ctl/main.ts logs`.
- **Dependencies must be 7 days old to install** (`bunfig.toml` + `web/bunfig.toml`, mirrored in
`.npmrc` for npm users) — a compromised release is usually pulled within hours. A brand-new
version resolving to an older one is the rule working, not a bug; CI's `--frozen-lockfile` is
Expand Down Expand Up @@ -214,7 +214,7 @@ conforming reverse proxy per DEPLOYMENT.md Variant C (`COLLIE_SKIP_SERVE=1`) ·
optional identity/device gates · strict CSP. A socket call can type into a real terminal — treat the bridge as
remote shell access.

**Collie manages exactly one front door: `tailscale serve`** — `collie-ctl.sh` publishes it, records
**Collie manages exactly one front door: `tailscale serve`** — `scripts/ctl/main.ts` publishes it, records
the mapping in `tailscale-managed-handler`, and only ever tears down a mapping matching that record.
Every other tunnel (NetBird, ZeroTier, Cloudflare Tunnel) is `COLLIE_SKIP_SERVE=1` + DEPLOYMENT.md
Variant E: the operator owns the ingress, Collie publishes nothing. **Don't add a second managed front
Expand Down
22 changes: 15 additions & 7 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# Deployment variants B–E

The bridge always binds **loopback only**; what changes between deployments is *what sits in front
of it* and *how a request proves who it is*. [Variant A](./README.md#variant-a--tailscale-serve--person-identity-default) —
plain `tailscale serve`, identity by tailnet person — is the default and lives in the README. The
four shapes here are for everything else. Pick one.
of it* and *how a request proves who it is*. [Variant A](./README.md#variant-a--tailscale-serve--person-identity-default),
plain `tailscale serve`, identity by tailnet person, is the default and lives in the README. On the
validated Windows host, the Tailscale executable and service were absent, so the run used documented
Variant E with `COLLIE_SKIP_SERVE=1`. That is the Windows fallback shape when another authenticated
mesh or proxy owns ingress. Loopback by itself is not phone-accessible. If you are using the Windows
host path, read the README Windows install section first and then come back here. The four shapes here
are for everything else. Pick one.

- [Variant B — identity-aware proxy + per-device authorisation](#variant-b--identity-aware-proxy--per-device-authorisation)
- [Variant C — reverse proxy as the only front door (no Tailscale)](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)
Expand Down Expand Up @@ -113,8 +117,8 @@ A reverse proxy (Caddy, Nginx, …) is the **sole ingress** — no Tailscale in
when the host isn't on a tailnet, or when you already run a TLS-terminating proxy with its own access
control (SSO, mTLS, a VPN gateway) and want Collie behind it like any other upstream.

Set `COLLIE_SKIP_SERVE=1` so `collie-ctl.sh start` builds, starts and supervises the bridge but
**never touches `tailscale serve`** — the proxy owns ingress. The bridge still binds loopback only;
Set `COLLIE_SKIP_SERVE=1` so the ctl entry point starts, supervises and updates the bridge but
**never touches `tailscale serve`**. The proxy owns ingress. The bridge still binds loopback only;
your proxy reaches it on `127.0.0.1:$COLLIE_PORT`.

The **four proxy requirements from
Expand Down Expand Up @@ -337,15 +341,19 @@ and a convenience in `collie-ctl.sh`; the bridge itself is a loopback HTTP serve
`Host`, `Origin`, and two optional headers. Anything that can reach `127.0.0.1:$COLLIE_PORT` can
front it.

Collie deliberately **manages** only one front door the one this project runs and tests. For every
Collie deliberately **manages** only one front door, the one this project runs and tests. For every
other tunnel you own the ingress and Collie stays out of the way:

```bash
COLLIE_SKIP_SERVE=1 # never run tailscale serve
COLLIE_PUBLIC_HOSTS=collie.example.com # exact public host blocks DNS rebinding
COLLIE_PUBLIC_HOSTS=collie.example.com # exact public host, blocks DNS rebinding
COLLIE_ALLOWED_ORIGINS=https://collie.example.com # exact public origin for the same-origin gate
```

That skip flag is the documented Windows fallback when the Tailscale executable or service is
absent. Use it with another authenticated mesh or proxy that owns ingress. The bridge on loopback
alone can't be reached from a phone.

Then point your tunnel at `127.0.0.1:$COLLIE_PORT` and start it however you start your other
services. `netbird expose 8787`, a ZeroTier-routed reverse proxy and `cloudflared tunnel` all work
this way. `collie-ctl.sh start` will build, launch and supervise the bridge and publish nothing;
Expand Down
Loading