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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode login` | account | authenticate with app.moshcode.sh |
| `moshcode whoami` | account | show the logged-in account |
| `moshcode logout` | account | clear the logged-in account |
| `moshcode save` | account | save this machine's pit settings to your account |
| `moshcode load` | account | bring your saved pit settings onto this machine |
| `moshcode console` | account | serve or connect to the browser terminal |
| `moshcode dns` | hosting | resolve Moshpit names on this machine |
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
Expand Down Expand Up @@ -579,6 +581,52 @@ event, and publishes it to the displayed relays. Both flows leave the final
confirmation in the browser. If the pit is remote or headless, `/post` prints
the composer URL instead.

## Settings sync (`/save` and `/load`)

Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until
the roster stopped lying to you. All of it lives in `~/.moshcode` on one machine,
which is why every new laptop, container and droplet used to feel like someone
else's prompt.

`/save` pushes that configuration to your `app.moshcode.sh` account. `/load`
brings it down onto any machine you have run `/login` on.

```sh
moshcode save # push this machine's settings (pit: /save)
moshcode save --dry-run # what would go up, and stop

# on the new box
moshcode login
moshcode load # pull them down (pit: /load)
moshcode load --dry-run # the per-file plan, changing nothing
```

What syncs is an allowlist, not a directory walk:

| file | what it is |
|---|---|
| `~/.moshcode/aliases.json` | your pit aliases (`/alias`) |
| `~/.moshcode/herd/rules.json` | herd state-detection overrides |

What never syncs, by name: `credentials.json` (the account token this very
feature authenticates with), `herd/sessions.json` (live state pinned to one tmux
server), `sync.json`, and the `pkg/` binary cache. Engine configuration
(`~/.claude.json` and friends) is deliberately left alone — those files carry
provider API keys.

Nothing is overwritten quietly:

- Each save is a numbered **revision**. `/save` sends the revision it last agreed
on, and the app refuses the write if another machine has saved since — you get
told, with `/load` and `/save --force` as the two ways out.
- `/load` refuses to replace a settings file you edited since this machine last
synced, and names it. `--force` overrides.
- The last ten revisions are kept. See them, and which machine each came from, at
[app.moshcode.sh/settings/sync](https://app.moshcode.sh/settings/sync) — where
you can also promote an older revision or delete the lot.

Both verbs take `--json`, so a provisioning script can act on the result.

## Browser terminal (`moshcode console`)

A real terminal in the browser — arrow keys, history, full-screen TUIs — because
Expand Down
31 changes: 31 additions & 0 deletions apps/pwa/src/migrations/012_settings_sync.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Cloud sync for the pit's settings (`/save` and `/load` in moshcode).
--
-- One row per save, not one row per account, because the interesting failure is
-- a good configuration replaced by a bad one: a person runs `/save` from the
-- machine they were mid-experiment on and the aliases they had built up for a
-- year are now the thing every other machine pulls down. Keeping the last few
-- revisions makes that recoverable from the web without a backup story.
--
-- `revision` is per-user and monotonic, and it is also the concurrency token:
-- `/save` sends the revision it last agreed on and the write is refused if the
-- account has moved past it, so two machines saving cannot silently erase one
-- another.
CREATE TABLE IF NOT EXISTS settings_snapshots (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision INTEGER NOT NULL,
digest TEXT NOT NULL, -- sha256 over the file names + contents
host TEXT, -- the machine that saved it
version TEXT, -- its moshcode version
size INTEGER NOT NULL, -- bytes of `body`
body TEXT NOT NULL, -- the snapshot, as the CLI sent it
created_at INTEGER NOT NULL
);

-- The unique index is load-bearing, not housekeeping: the insert picks its own
-- revision with MAX(revision) + 1, and against a network database two saves in
-- flight at once can both read the same maximum. This is what turns the second
-- one into an error instead of a duplicate revision that `/load` would resolve
-- arbitrarily.
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_snapshots_revision
ON settings_snapshots(user_id, revision);
19 changes: 18 additions & 1 deletion apps/pwa/src/routes/pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { requireAuth, csrfInput, setCeremony, getCeremony, clearCeremony } from
import { balance, ledger, CHANNEL_COST } from "../lib/credits.mjs";
import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs";
import { PACKS } from "./credits.mjs";
import { latestSnapshotMeta } from "./settings-sync.mjs";
import { config } from "../config.mjs";

export const pagesRouter = Router();
Expand Down Expand Up @@ -105,10 +106,11 @@ const CHANNEL_KINDS = ["push", "email", "slack", "telegram", "sms", "webhook"];

pagesRouter.get("/settings", requireAuth, async (req, res) => {
const uid = req.user.id;
const [bal, chans, keys] = await Promise.all([
const [bal, chans, keys, synced] = await Promise.all([
balance(uid),
all(`SELECT * FROM channels WHERE user_id = ?`, [uid]),
listApiKeys(uid),
latestSnapshotMeta(uid),
]);
const byKind = Object.fromEntries(chans.map((c) => [c.kind, c]));
const newKey = getCeremony(req, "newkey") || "";
Expand Down Expand Up @@ -204,6 +206,21 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => {
</form>
</div>
</div>

<div class="card"><div class="card-head"><span class="h">Settings sync · your pit's config</span><a class="pill" href="/settings/sync">revisions</a></div>
<div class="card-body">
<p class="dim mono" style="font-size:.78rem;margin-top:0">
${synced
? `Currently holding <b>revision ${synced.revision}</b>, saved from ${esc(synced.host || "an unknown machine")} ${timeago(synced.savedAt)}.`
: `Nothing saved yet. In the pit: <span class="acid">/save</span>.`}
</p>
<p class="faint mono" style="font-size:.74rem;margin-bottom:0">
<span class="acid">/save</span> pushes your aliases and herd rules here;
<span class="acid">/load</span> brings them onto any machine you have run
<span class="acid">/login</span> on. Credentials are never included.
</p>
</div>
</div>
</main>${footer}`;
res.type("html").send(page({ title: "moshcode ▸ settings", body }));
});
Expand Down
Loading