P2-8: cmd/cairnsync — headless CLI #28

Closed
opened 2026-09-10 17:42:33 +00:00 by Cordy · 2 comments
Owner

Depends on P2-7.

Goal

A headless binary that runs the same engine with no GUI. This is what CI uses for integration tests, what a server administrator can cron, and what you use to debug a customer's problem without a desktop session.

Files

  • Create: cmd/cairnsync/main.go, internal/config/config.go, internal/config/config_test.go

Interface

cairnsync --config <path> [--once] [--interval 30s] [--dry-run] [--verbose]
  • --once — a single pass, then exit. This is the mode CI uses.
  • --interval — poll loop. Default 30s.
  • --dry-run — print the operations Decide produced and exit without executing any of them. This is the single most useful debugging tool you will have; make its output readable.
  • Exit codes: 0 success, 1 operational error, 2 config error, 3 auth failure. CI branches on these.

Config file

{
  "server":     "https://cairn.example.org/dav/",
  "username":   "nikola",
  "password":   "app-password-here",
  "localPath":  "/home/nikola/Cairn",
  "concurrency": 4
}
  • Every field must be overridable by environment variable (CAIRN_SERVER, CAIRN_USERNAME, CAIRN_PASSWORD, CAIRN_LOCAL_PATH), env wins — matching how cairnd itself handles secrets.
  • Never log the password, not even at --verbose. Add a test that runs a full pass with verbose logging and asserts the password string appears nowhere in the output.

Output

Human-readable by default; --json emits one JSON object per line so CI can assert on it. Print a summary at the end: applied, skipped (with reasons), conflicts.

Steps

  • Write failing tests: config load precedence (file → env), validation errors exit 2, --dry-run executes nothing (assert against a counting remote), the password never appears in verbose output.
  • Run; confirm failure.
  • Implement. Wire OSFS + WebDAV Client + state.Store into sync.Engine.
  • Manually smoke-test against the .249 dogfood with a throwaway folder.
  • Commit: git commit -s -m "feat(cmd): cairnsync headless CLI"

Acceptance criteria

  • cairnsync --once syncs a real folder against a real Cairn.
  • --dry-run performs zero mutations.
  • No credential appears in any output stream.
Depends on P2-7. ## Goal A headless binary that runs the same engine with no GUI. This is what CI uses for integration tests, what a server administrator can cron, and what you use to debug a customer's problem without a desktop session. ## Files - Create: `cmd/cairnsync/main.go`, `internal/config/config.go`, `internal/config/config_test.go` ## Interface ``` cairnsync --config <path> [--once] [--interval 30s] [--dry-run] [--verbose] ``` - `--once` — a single pass, then exit. **This is the mode CI uses.** - `--interval` — poll loop. Default 30s. - `--dry-run` — print the operations `Decide` produced and exit **without executing any of them**. This is the single most useful debugging tool you will have; make its output readable. - Exit codes: `0` success, `1` operational error, `2` config error, `3` auth failure. CI branches on these. ## Config file ```json { "server": "https://cairn.example.org/dav/", "username": "nikola", "password": "app-password-here", "localPath": "/home/nikola/Cairn", "concurrency": 4 } ``` - Every field must be overridable by environment variable (`CAIRN_SERVER`, `CAIRN_USERNAME`, `CAIRN_PASSWORD`, `CAIRN_LOCAL_PATH`), **env wins** — matching how `cairnd` itself handles secrets. - **Never log the password**, not even at `--verbose`. Add a test that runs a full pass with verbose logging and asserts the password string appears nowhere in the output. ## Output Human-readable by default; `--json` emits one JSON object per line so CI can assert on it. Print a summary at the end: applied, skipped (with reasons), conflicts. ## Steps - [ ] Write failing tests: config load precedence (file → env), validation errors exit 2, `--dry-run` executes nothing (assert against a counting remote), the password never appears in verbose output. - [ ] Run; confirm failure. - [ ] Implement. Wire `OSFS` + WebDAV `Client` + `state.Store` into `sync.Engine`. - [ ] Manually smoke-test against the `.249` dogfood with a throwaway folder. - [ ] Commit: `git commit -s -m "feat(cmd): cairnsync headless CLI"` ## Acceptance criteria - `cairnsync --once` syncs a real folder against a real Cairn. - `--dry-run` performs zero mutations. - No credential appears in any output stream.
Author
Owner

Done

  • b3c84c3 feat(cmd): cairnsync headless CLI
  • 5d31d21 fix(cmd): open the sync folder afresh on every cairnsync pass

What was built

  • cmd/cairnsync: --config <path> [--once] [--interval 30s] [--dry-run] [--json] [--verbose], exit codes 0/1/2/3
  • internal/config: JSON config with env override (env wins), strict validation, password redaction
  • internal/sync: (*Engine).Plan() — a plan-only path (P2-R9) so --dry-run executes nothing
  • Fix round 1 (F1): each pass now opens the local sync folder afresh and closes it afterward, so a folder removed or moved between passes fails that pass with a named error instead of mass-deleting the server copy

Tests

  • CI run #26 green on linux/arm64 (go vet clean, all packages pass, coverage total 90.2%)
  • 14 mutants killed across 4 TDD cycles; -race clean; manual smoke test against a local throwaway cairnd (dry run, tus upload of a 12 MiB file, exit 3/2 paths, password-leak scan, loop mode under SIGTERM)
  • F1's regression test (TestAFolderGoneBetweenPassesDeletesNothing, both subtests) carries no GOOS skip, so CI run #26 is the Linux evidence (CV5)

Acceptance criteria

  • "cairnsync --once syncs a real folder against a real Cairn." — met by the local-cairnd smoke test (real OSFS, Client, on-disk state DB, 4 files incl. a 12 MiB tus upload) and TestOnceSyncsBothWays; the .249 run is left to the owner (P2-R10), exact command in task-28-report.md.
  • "--dry-run performs zero mutations." — Engine.Plan with counting fakes (every op kind) plus TestDryRunChangesNothing (zero non-idempotent requests, no state DB written).
  • "No credential appears in any output stream." — TestThePasswordAppearsInNoOutput (7 runs × 2 modes, stdout+stderr, raw/URL-escaped/base64 forms) and TestConfigNeverPrintsThePassword.
  • Steps' tests (precedence, validation → exit 2, dry run vs counting remote, password in verbose) — all present and passing.
  • Exit codes 0/1/2/3 — one test per code, including loop-mode auth failure exiting 3 at once.
  • --json one object per line + summary (applied/skipped-with-reasons/conflicts) — asserted by an events helper that fails on any non-JSON-object line.

Rulings

  • P2-R1: push to main after clean review; close only when every Actions run for the pushed head is green; comment then close, never edit the issue body.
  • P2-R9: #28 may add a minimal Engine.Plan() for --dry-run, tested with counting fakes; plus a test that state.Open on a file DSN persists across Close/Open.
  • P2-R10: .249 needs the owner's app password, which Claude never uses; the local-cairnd smoke test stands in, and the .249 command goes in this comment for the owner to run.
  • P2-R13: the sync root is a WebDAV collection URL, <server>/dav/home/ for Cairn's default per-user homes; documented in config.go and the package doc.
  • P2-R18: phase-2 agents needing a live server use cairnd-dev.sh (throwaway local cairnd) — never ~/Cairn, never .249, never the owner's credentials.
  • Task 28 CV5: F1's regression tests carry no GOOS skip so ci.yml's Linux job is the Linux evidence for the fix, since there is no local Linux runtime here.

Deferred

  • Review Minors F2–F7 (loop-mode-continues test, zero-summary-before-error, lexical within() check, no lock/busy_timeout for overlapping runs, first-signal message, curl -u on the command line) — deferred to the final review; see progress.md for the full list.
  • Report Concerns 1–5 (no lock for overlapping processes, second-signal kill untested, zero-summary is a separate code path, D1 PlatformWindows hard-coded is a recorded product decision) — pre-existing or explicitly out of scope for this round.

Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.

**Done** - [b3c84c3](http://192.168.10.245/Cordy/cairn-desktop/commit/b3c84c3411838c849b9a5e2cbd1e31f294e337a1) feat(cmd): cairnsync headless CLI - [5d31d21](http://192.168.10.245/Cordy/cairn-desktop/commit/5d31d2138cc82f022a743e30d15fd7eb684a737f) fix(cmd): open the sync folder afresh on every cairnsync pass **What was built** - `cmd/cairnsync`: `--config <path> [--once] [--interval 30s] [--dry-run] [--json] [--verbose]`, exit codes 0/1/2/3 - `internal/config`: JSON config with env override (env wins), strict validation, password redaction - `internal/sync`: `(*Engine).Plan()` — a plan-only path (P2-R9) so `--dry-run` executes nothing - Fix round 1 (F1): each pass now opens the local sync folder afresh and closes it afterward, so a folder removed or moved between passes fails that pass with a named error instead of mass-deleting the server copy **Tests** - CI run [#26](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/26) green on linux/arm64 (`go vet` clean, all packages pass, coverage total 90.2%) - 14 mutants killed across 4 TDD cycles; `-race` clean; manual smoke test against a local throwaway cairnd (dry run, tus upload of a 12 MiB file, exit 3/2 paths, password-leak scan, loop mode under SIGTERM) - F1's regression test (`TestAFolderGoneBetweenPassesDeletesNothing`, both subtests) carries no `GOOS` skip, so CI run #26 is the Linux evidence (CV5) **Acceptance criteria** - "`cairnsync --once` syncs a real folder against a real Cairn." — met by the local-cairnd smoke test (real OSFS, Client, on-disk state DB, 4 files incl. a 12 MiB tus upload) and `TestOnceSyncsBothWays`; the `.249` run is left to the owner (P2-R10), exact command in task-28-report.md. - "`--dry-run` performs zero mutations." — `Engine.Plan` with counting fakes (every op kind) plus `TestDryRunChangesNothing` (zero non-idempotent requests, no state DB written). - "No credential appears in any output stream." — `TestThePasswordAppearsInNoOutput` (7 runs × 2 modes, stdout+stderr, raw/URL-escaped/base64 forms) and `TestConfigNeverPrintsThePassword`. - Steps' tests (precedence, validation → exit 2, dry run vs counting remote, password in verbose) — all present and passing. - Exit codes 0/1/2/3 — one test per code, including loop-mode auth failure exiting 3 at once. - `--json` one object per line + summary (applied/skipped-with-reasons/conflicts) — asserted by an `events` helper that fails on any non-JSON-object line. **Rulings** - P2-R1: push to main after clean review; close only when every Actions run for the pushed head is green; comment then close, never edit the issue body. - P2-R9: #28 may add a minimal `Engine.Plan()` for `--dry-run`, tested with counting fakes; plus a test that `state.Open` on a file DSN persists across Close/Open. - P2-R10: `.249` needs the owner's app password, which Claude never uses; the local-cairnd smoke test stands in, and the `.249` command goes in this comment for the owner to run. - P2-R13: the sync root is a WebDAV collection URL, `<server>/dav/home/` for Cairn's default per-user homes; documented in `config.go` and the package doc. - P2-R18: phase-2 agents needing a live server use `cairnd-dev.sh` (throwaway local cairnd) — never `~/Cairn`, never `.249`, never the owner's credentials. - Task 28 CV5: F1's regression tests carry no `GOOS` skip so ci.yml's Linux job is the Linux evidence for the fix, since there is no local Linux runtime here. **Deferred** - Review Minors F2–F7 (loop-mode-continues test, zero-summary-before-error, lexical `within()` check, no lock/busy_timeout for overlapping runs, first-signal message, `curl -u` on the command line) — deferred to the final review; see progress.md for the full list. - Report Concerns 1–5 (no lock for overlapping processes, second-signal kill untested, zero-summary is a separate code path, D1 `PlatformWindows` hard-coded is a recorded product decision) — pre-existing or explicitly out of scope for this round. _Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI._
Cordy closed this issue 2026-09-11 12:02:36 +00:00
Author
Owner

The .249 smoke test, for the owner (P2-R10, phase-2 final review X6)

The closing comment above pointed at a local report file; here is the command itself. Run it on your machine, in a cairn-desktop checkout at b3c84c3 or later (v0.2.0-integration is fine). It uses the nikola-test app password minted in .249's UI (the Cordy/Cairn secret CAIRN_ENC_APP_PASSWORD), which Claude never uses. It syncs a throwaway server folder, so nothing else in the home folder is touched:

CGO_ENABLED=0 go build -o /tmp/cairnsync ./cmd/cairnsync
export CAIRN_USERNAME=nikola-test
read -rs CAIRN_PASSWORD && export CAIRN_PASSWORD        # paste the app password, then Enter
printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - -X MKCOL -o /dev/null -w 'MKCOL %{http_code}\n' http://192.168.10.249/dav/home/cairnsync-smoke/   # 201, or 405 if it exists
mkdir -p /tmp/cairnsync-249/local && date > /tmp/cairnsync-249/local/hello.txt && head -c 12582912 /dev/urandom > /tmp/cairnsync-249/local/big.bin
cat > /tmp/cairnsync-249/cairnsync.json <<'EOF'
{"server": "http://192.168.10.249/dav/home/cairnsync-smoke/", "localPath": "/tmp/cairnsync-249/local", "statePath": "/tmp/cairnsync-249/state.db"}
EOF
/tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --dry-run          # exit 0: plans 2 uploads, changes nothing
/tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --once --verbose   # exit 0: "2 applied, 0 skipped, 0 conflicts"
/tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --once --json      # exit 0: summary applied=0 (S3 raw ETags must stay stable)
printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - http://192.168.10.249/dav/home/cairnsync-smoke/big.bin | cmp - /tmp/cairnsync-249/local/big.bin && echo "big.bin matches"
# clean up
printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - -X DELETE -o /dev/null -w 'DELETE %{http_code}\n' http://192.168.10.249/dav/home/cairnsync-smoke/ ; rm -rf /tmp/cairnsync-249

This assumes .249 keeps cairnd's default per-user homes, so /dav/home/ is nikola-test's home. If --once exits 3, the app password or the username is wrong for WebDAV. If the second --once applies anything, suspect the S3 ETags — that is exactly what this run is for.

Every curl reads the credentials from a config on its stdin (-K -, fed by the shell's builtin printf), never from its command line, where ps would show them. A password holding " or \ must be escaped with a \ inside that quoted config value.

Posted by Claude on behalf of @Cordy to complete ruling P2-R10.

## The .249 smoke test, for the owner (P2-R10, phase-2 final review X6) The closing comment above pointed at a local report file; here is the command itself. Run it on your machine, in a cairn-desktop checkout at b3c84c3 or later (v0.2.0-integration is fine). It uses the `nikola-test` app password minted in .249's UI (the Cordy/Cairn secret `CAIRN_ENC_APP_PASSWORD`), which Claude never uses. It syncs a throwaway server folder, so nothing else in the home folder is touched: ```bash CGO_ENABLED=0 go build -o /tmp/cairnsync ./cmd/cairnsync export CAIRN_USERNAME=nikola-test read -rs CAIRN_PASSWORD && export CAIRN_PASSWORD # paste the app password, then Enter printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - -X MKCOL -o /dev/null -w 'MKCOL %{http_code}\n' http://192.168.10.249/dav/home/cairnsync-smoke/ # 201, or 405 if it exists mkdir -p /tmp/cairnsync-249/local && date > /tmp/cairnsync-249/local/hello.txt && head -c 12582912 /dev/urandom > /tmp/cairnsync-249/local/big.bin cat > /tmp/cairnsync-249/cairnsync.json <<'EOF' {"server": "http://192.168.10.249/dav/home/cairnsync-smoke/", "localPath": "/tmp/cairnsync-249/local", "statePath": "/tmp/cairnsync-249/state.db"} EOF /tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --dry-run # exit 0: plans 2 uploads, changes nothing /tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --once --verbose # exit 0: "2 applied, 0 skipped, 0 conflicts" /tmp/cairnsync --config /tmp/cairnsync-249/cairnsync.json --once --json # exit 0: summary applied=0 (S3 raw ETags must stay stable) printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - http://192.168.10.249/dav/home/cairnsync-smoke/big.bin | cmp - /tmp/cairnsync-249/local/big.bin && echo "big.bin matches" # clean up printf 'user = "%s:%s"\n' "$CAIRN_USERNAME" "$CAIRN_PASSWORD" | curl -sS -K - -X DELETE -o /dev/null -w 'DELETE %{http_code}\n' http://192.168.10.249/dav/home/cairnsync-smoke/ ; rm -rf /tmp/cairnsync-249 ``` This assumes .249 keeps cairnd's default per-user homes, so `/dav/home/` is nikola-test's home. If `--once` exits 3, the app password or the username is wrong for WebDAV. If the second `--once` applies anything, suspect the S3 ETags — that is exactly what this run is for. Every curl reads the credentials from a config on its stdin (`-K -`, fed by the shell's builtin `printf`), never from its command line, where `ps` would show them. A password holding `"` or `\` must be escaped with a `\` inside that quoted config value. _Posted by Claude on behalf of @Cordy to complete ruling P2-R10._
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-desktop#28
No description provided.