Skip to content

feat: workspace split, the self-hosted sync server, and one-command install - #36

Merged
scheron merged 24 commits into
mainfrom
feat/workspace-packages
Sep 7, 2026
Merged

feat: workspace split, the self-hosted sync server, and one-command install#36
scheron merged 24 commits into
mainfrom
feat/workspace-packages

Conversation

@scheron

@scheron scheron commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Brings main up to the workspace layout and the self-hosted sync server. 15 commits, 1014 files.

Read this before reviewing: it is two distinct bodies of work, and only the last two were verified by the run that produced them.

1. The workspace split and the sync server — 13 commits, predating this run

e7138700 through f8a8e424: src/ split into seven packages behind a driver, the CLI removed, each artifact shipped from the package that owns it, the Daily Sync Server stood up with protocol v1, sync through a self-hosted server, the server delivered as a Docker image, the SSH provider removed.

These were the base this run built on. They did not pass through its gates — no code review or runtime verification here covers them. They need reviewing on their own terms.

2. One-command install — 2 commits, gated

  • feat(server): install the sync server with one commanddeploy/install.sh and its three topology generators, the generated daily.sh with eight management verbs, a daily-server healthcheck subcommand, the transport cut from three modes to two (own-certificate removed), and the server README rewritten from 302 lines to 123.
  • ci: move workflow scripts out of YAML and pin the protocol tag — nine inline run: blocks extracted to scripts/ci/, a test that fails any multi-line run: step, and a guard tying install.sh's pinned p<N> to SYNC_PROTOCOL_VERSION.

All three install topologies, the healthcheck and the management verbs were driven on a real Docker host, not only unit-tested. Per-case evidence is in .ai-workflow/run/2026-09-07-one-command-server-deploy/gate-b/.

The image was built from this branch for those runs — the published ghcr.io/scheron/daily-server:p1 does not exist yet and would predate the healthcheck subcommand the generated compose depends on. So those results prove the code, not the distribution.

Two things block a working release, neither of them code

  1. No server-v* tag has ever been cut, so release-server.yml has never run and the image was never published.
  2. The GHCR package must be made public after the first publish — packages from Actions are private by default and there is no YAML setting for it.

Until both hold, install.sh fails at docker compose pull for every operator, not just on upgrade. That is the one runtime case that did not pass; every step after the fetch was verified by rebuilding the tag locally.

The README's install URL also 404s until this merges, since it points at main.

Known and not fixed

  • daily.sh backup writes the database at the process umask — 0644 beside daily.sh on a root install. One umask 077 closes it.
  • A failed backup leaves the server stopped: under set -eu a full disk aborts before the restart, silently.
  • shellcheck has never run against install.sh locally — it is not installed there, so the new CI job runs for the first time on this PR. SC2154 is plausible on the five scripts reading CI-supplied variables; the usual # shellcheck disable remedy is a comment, which this project forbids, so a structural form would be needed.
  • The convention test counts lines, so run: cmd-a && cmd-b on one line still passes it. Nothing here does that.

Full decision log for the gated part, including two plan conflicts resolved mid-run, is in .ai-workflow/plans/2026-09-07-one-command-server-deploy.md under ## Corrections during execution.

scheron added 24 commits August 17, 2026 02:03
…l writes

Strips the SSH sync provider out of Daily entirely and reshapes the
remote-storage contract so a provider can declare that it supports
revision-aware conditional writes.

SyncEngine gains a read-merge-write-retry loop that runs only against a
provider declaring the capability: it reads the revision, merges, writes
conditionally, and on conflict re-reads, re-merges and rewrites, bounded
by SYNC_CONFIG.conditionalWriteMaxAttempts. Every attempt merges through
_pull, so a successful retry leaves the other device's work in the local
database and not only in the snapshot it pushed. Exhausting the attempts
is a deferral rather than a failed cycle — it is recorded in that
remote's state and the status stays active, because a lost race is
ordinary in multi-device sync and a red status has to keep meaning that
sync is broken. Against a provider that does not declare the capability —
iCloud and the CLI's sync folder — behaviour is unchanged.

Revision support extends IRemoteStorage rather than adding optional
members to it, so an adapter cannot half-implement it. Snapshot.version
is not bumped: a revision is transport metadata held by the provider,
never a field inside the snapshot.

Removing SSH takes the adapter, the three renderer settings fragments,
SshSyncSettings and the ssh field of SyncSettings, leaving sync
iCloud-only with no compatibility shim. Migration v009 strips the dead
ssh key from stored device_settings, guarded by json_valid so an
unreadable row is skipped rather than aborting the migration.

No provider declaring the capability exists yet; the contract and the
engine loop are verified against a fake.
Adds src/server/ as a fourth source root — its own tsconfig, vite build and
vitest project, and the daily-server console — and gives it the half of the
Daily Sync Protocol that decides who is talking.

A server identifies itself over an unauthenticated handshake and reports
whether anyone has claimed it. A six-digit code its console prints binds the
first device exactly once, behind an attempt cap that locks the code and a
console command that regenerates it. Every device after that is bound by peer
approval: the new device asks, any already-bound device reads the code and the
name it was shown and confirms or declines, and the approved device collects
its credential inside a bounded pickup window. When no device survives, the
console issues a single-use token that binds a fresh one.

Device credentials are stored only as sha256 hashes and authenticate every
request. Revoking one sets revoked_at, leaves the row in place, and refuses
that device from its next request onwards — distinctly from a token that was
never issued.

The protocol's request and response shapes, its error codes and their HTTP
statuses live in src/shared/, so the desktop client can import the same
contract later.

No task data crosses this protocol yet: there is no snapshot endpoint, no
asset channel and no revision probe, and nothing in the Daily app changes.
…rotocol

Give the Daily Sync Server the half of the protocol that carries the data: one
snapshot it stores and hands back with the revision it was read at, a conditional
write that accepts an overwrite only from a client that had read the current
state, a cheap probe that says whether that revision moved, and an asset channel
for attachment bytes. Plus one console command that shows the owner what the
server is holding.

A bound device can now read the snapshot, merge locally, write it back
conditionally, lose the race and be told so, poll a two-field endpoint, and push
and pull attachment bytes — all over the authenticated request the enrollment
work delivered. The server originates nothing, merges nothing, and resolves no
conflict: it stores what a client pushes and hands back what it stored.

The conditional write is one compare-and-swap inside a single immediate
transaction, so two connections racing at the same expected revision produce
exactly one winner. Assets stream to a temp file under a byte cap checked ahead
of each chunk, hashed as they go and renamed into place only on a clean end, so
a half-received upload is never visible. Snapshot bodies may be gzipped in both
directions, bounded compressed and decompressed.

Route matching now runs before the request body is read, which is what makes the
per-route body cap possible. One consequence, taken deliberately: a POST to a
GET-only path answers 405 rather than reporting a problem with a body that route
would never have accepted.

The desktop app is untouched — no renderer, main-process, IPC, storage or CLI
change. src/server stays free of Electron and of src/main.
Daily gains a second bidirectional sync provider beside iCloud: the person's own
Daily Sync Server. A remote adapter declares revision support and speaks the wire
contract entries 2 and 3 built, so the engine drives it through the conditional-write
loop that already exists and nothing about merging changes.

A connection wizard binds this Mac with the claim code a fresh server printed, or by
showing a code that a peer approves; that peer sees the request in a dialog of its
own, carrying the same code and the asking Mac's name, with Approve and Decline. A
self-signed certificate's fingerprint is shown once and pinned, and a later
certificate that does not match is refused inside the connector before any request
body is written, so the device credential never reaches an impostor. Plain HTTP is
free on a private address and takes a deliberate confirmation on a public one, and
either way the binding is labelled insecure for as long as it lasts. A twelve-second
revision probe turns a change made on another Mac into a sync cycle on this one, and
a revoked credential stops both the probe and auto-sync while leaving the binding in
place, so the section can say what happened.

Two invariants hold by construction rather than by convention. At most one
bidirectional provider is ever built: binding is refused while iCloud sync is on, and
enabling iCloud is refused while a server binding is active. And no device credential
crosses the IPC boundary — every renderer-facing type is a view that omits it,
including the settings channel that predates this work.

The three-position provider control and the migration flow are entry 5 and are not
built here; the server provider is reachable from a section of its own beside the
iCloud switch, and refuses to bind while iCloud is on rather than inventing that
migration.
Replace the two independent sync switches with a single three-position
control — Off, iCloud, Self-hosted Daily — so the two can no longer both
be writable at once. A single guard on StorageController.saveSettings
enforces that on every path a settings write actually takes, and holding
a server credential is now separate from the server being active, so a
credential can be obtained while iCloud is still the provider.

Changing position is a migration, not a settings write. It verifies the
target, reports what each side holds and how they differ, takes a
direction for records changed in both places, merges through the one
existing SyncEngine, and moves the provider only once that merge has
landed on both sides. A migration that fails rolls back and leaves the
person on the provider they started on, still syncing.

The direction decides ties and nothing else: both sides converge to the
union, and it is asked about only when the preview finds a record the
two sides genuinely contest. Merging such a tie in the target's favour
never reached the local database — hasChanges gated the write on
updated_at, which a tie has in common by definition — so keep-target
silently did nothing. The merge now carries those documents through, and
a converged pair still writes nothing.

Also: revocation is a typed fact learned where the probe already learns
it, rather than a substring matched in an error message, and it reaches
the open settings pane as a broadcast; one narrowing produces the
renderer's view of a binding, so the device token cannot leak through a
second one that disagrees; and the six-digit code length is one constant.
Ship the Daily Sync Server as one artifact: a multi-arch image on GHCR, brought
up with a copied compose.yaml and `docker compose up -d`. Configuration comes
only from the environment — there is no configuration file, no first-run wizard,
no install script, no systemd unit and no self-update.

What the wizard used to be worth is kept as behaviour rather than as a
conversation: while the server is unclaimed it verifies its own public address on
every start and prints the result beside the claim code, naming the reason when
it fails and never blocking the listen. `daily-server verify` performs the same
check on demand.

A server told to use a self-signed certificate mints one for the public URL's
host, reuses it across restarts, and refuses to start rather than replace a
certificate covering a different host.

Pushing a server-v* tag publishes the image under three tags: the exact version,
latest, and a rolling p<N> whose N is read from SYNC_PROTOCOL_VERSION in the
source. The client refuses a protocol mismatch on exact inequality, so the tag is
derived and never typed.

The Daily app on the Mac stays the administrative surface. Recovery and
inspection stay where they already are — status, claim-code, device
enroll|list|revoke — reached through `docker compose exec`.

The compose stand under tests/server/stand/ drives both topologies: a proxy
stand running deploy/compose.yaml verbatim behind caddy, and a vps stand with
the commented ports and self-signed pair uncommented. Key material is
regenerated by mkcerts.sh and stays out of the tree.
The install path sent people into a macOS app repository to fetch one file.
Nothing about the delivery needs the repository, so it no longer appears in it:
the compose file is inlined in the README to save as compose.yaml, and a single
`docker run` covers trying the server out and running it on a bare VPS.

The Dockerfile carries OCI labels, so the GHCR package page has a title, a
description and a link back to the source instead of being an orphan. The
version label comes from DAILY_SERVER_PACKAGE_VERSION, like the package version
already does.

Inlining the compose file makes a second copy of it, so a test pins the README's
yaml block to deploy/compose.yaml byte for byte — the copy people paste cannot
drift from the one the stand drives.
Adds the Daily Sync Protocol v1 server under src/server, the desktop
provider that talks to it, and the one-provider settings migration.
Removes the SSH provider in favour of revision-aware conditional writes.

The server ships as a multi-arch image on GHCR, configured entirely from
the environment, and is brought up with a single docker run.
The boundaries between the protocol, the generic utilities, the storage core
and the four programs are package dependencies now, not path aliases anyone
could follow. The desktop app, the CLI and the server each declare what they
use, so none of them can reach into another's internals and each installs on
its own.

The storage core no longer knows about SQLite or Electron. It takes a driver
through a port, and every runtime brings its own: better-sqlite3 in the app,
node:sqlite in the CLI, and the server's own copy widened for BEGIN IMMEDIATE.

Nothing the product does changes.
Drops apps/cli and every seam it left behind: the mutation-signal file and
its watcher, StorageController.handleExternalDataChange, FolderRemoteAdapter,
the launcher the packaged app wrote into Resources and the settings row that
installed it, and the npm publish step in the release workflow.

The two-node convergence tests move onto ICloudRemoteAdapter, which takes the
same directory argument and keeps all three last-write-wins cases intact.
The workspace split left every shipping path aimed at the pre-split
tree: CI type-checked directories that no longer existed, the image
copied src/, and the packager read a stale root out/. The app, the
server and the website now build, version and release from their own
packages.
Behind a reverse proxy the server starts before the proxy joins its network, so the
first check always failed. It now retries while the server is unclaimed, so the address
is confirmed without a restart.
The topology was prose, so both files were left to the reader. The order matters because
Compose refuses a network it did not label. The nginx route sets client_max_body_size:
its 1 MB default breaks attachments while the rest looks healthy.
The address step asked for a server address and said nothing about where to get a server.
Self-hosting meant pasting a compose file, following a proxy recipe and
choosing between three TLS modes. One script now asks for a domain and
picks the topology from the machine. Dropping own-certificate leaves the
server two transports instead of three.
Inline run: blocks cannot be linted or tested, and get forgotten. Nine
move to scripts/ci/, and a test now fails any multi-line run: step. A
second test ties install.sh's pinned p<N> to SYNC_PROTOCOL_VERSION, so
a protocol bump can no longer leave new installs on the old image.
pnpm-workspace.yaml uses allowBuilds, added in pnpm 10.26, but CI pinned
10.12.1. That version ignores the key, so no package got build approval,
electron's postinstall never ran and the test job died on a missing
binary. Local pnpm is 10.30.1, which is why this only failed on CI.
deepMerge assigned every source key straight onto the target, so a
payload with a __proto__ key polluted Object.prototype. It merges
settings read back from the sync server, so the payload is not always
ours. Skip __proto__, constructor and prototype instead.
Imports in the AI tool registry reached back four levels. vite-tsconfig-paths
was already wired into all three builds, so only the tsconfig paths were
missing. Adds @main/* and @/*, and moves the 22 formatters imports over.
Relative imports still work, so the rest can follow later.
Rewrites the remaining 422 deep relative imports. vite-tsconfig-paths does
not resolve aliases imported from .vue files, so main, preload, renderer and
vitest now declare the aliases explicitly. Adds @shared for the cross-process
directory. ESLint ignored only the root out/, not apps/desktop/out/.
Packages ship raw TypeScript, so an alias used in src has to resolve in
every consumer's build too, and typecheck:main broke on it. Tests are not
consumed by anyone, so the alias stays inside the package. Source keeps
relative imports.
The earlier pass only rewrote imports three levels up or more, so one file
could mix @/stores/theme.store with ../../base/BaseButton and nothing
explained the difference. The rule is now whole: ./ stays relative,
anything above it goes through an alias.
The workspace split moved dev into apps/desktop, so pnpm dev from the root
stopped working. The root already proxies build:server, test and lint the
same way, so dev was the odd one out.
App.vue asks for a pending approval on every start, so a device with sync
off logged a NO_BINDING error each time. Asking whether anyone waits is a
question, and "no server" is a fair answer to it. approve and deny still
throw, because calling those unbound is a mistake.
@scheron
scheron merged commit e851749 into main Sep 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant