nixamp 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -56,6 +56,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
56
56
  nixamp token create|list|revoke tokens for a machine that cannot sign in
57
57
  nixamp dns [set|rm] names under your handle, for your servers
58
58
  nixamp library [folder] where the media is; the daemon serves this and nothing outside it
59
+ nixamp sync [save|load|status] your settings on every machine, against your account (--force, --dry-run)
59
60
  nixamp server list|add|remove the machines you run, kept against your account
60
61
  nixamp party list|join|host watch parties, here and on the sites nixamp is connected to
61
62
  nixamp mcp speak Model Context Protocol on stdin, for an agent
@@ -497,6 +498,11 @@ export async function main() {
497
498
  process.exitCode = await dns(rest);
498
499
  return;
499
500
  }
501
+ if (first === "sync") {
502
+ const { syncCommand } = await import("./sync.js");
503
+ process.exitCode = await syncCommand(rest);
504
+ return;
505
+ }
500
506
  if (first === "library") {
501
507
  const { libraryCommand } = await import("./library.js");
502
508
  process.exitCode = await libraryCommand(rest);
package/dist/server.d.ts CHANGED
@@ -18,6 +18,7 @@ import { CompressionService } from "./compression/service.ts";
18
18
  import { Enricher } from "./enrich.ts";
19
19
  import { Follows } from "./follows.ts";
20
20
  import { Favorites } from "./favorites.ts";
21
+ import { SettingsSync } from "./settings-sync.ts";
21
22
  import { Catalogs } from "./catalogs.ts";
22
23
  import { Names } from "./names.ts";
23
24
  import { Certs } from "./certs.ts";
@@ -654,6 +655,8 @@ export interface HandlerOptions {
654
655
  follows?: Follows;
655
656
  /** The servers an account hearted. nixamp.com only, like follows. */
656
657
  favorites?: Favorites;
658
+ /** Settings sync, on the directory: a member's settings under revisions, reached by the same session. */
659
+ settingsSync?: SettingsSync;
657
660
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
658
661
  events?: LiveEvents;
659
662
  /** Versioned panel layouts, including event and user overrides. */
package/dist/server.js CHANGED
@@ -47,6 +47,7 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
47
47
  import pg from "pg";
48
48
  import { Follows, phoneFrom } from "./follows.js";
49
49
  import { Favorites, favoriteUrl } from "./favorites.js";
50
+ import { SettingsSync, SETTINGS_BODY_LIMIT } from "./settings-sync.js";
50
51
  import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
51
52
  import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
52
53
  import { NameError, Names } from "./names.js";
@@ -1562,6 +1563,31 @@ export function createHandler(engine, options) {
1562
1563
  json(response, 405, { error: "GET, PUT or DELETE" });
1563
1564
  return;
1564
1565
  }
1566
+ // --- settings sync: your settings on every machine, against the account ----
1567
+ //
1568
+ // The compression policy and the remembered channels, as one snapshot
1569
+ // under a revision (@profullstack/synconfig). The directory stores what
1570
+ // the client sent and hands it back; it never reads the files inside.
1571
+ if ((path === "/api/v1/settings" || path === "/api/v1/settings/revisions") && options.settingsSync && options.accounts) {
1572
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
1573
+ if (me === null) {
1574
+ json(response, 401, { error: "sign in to sync settings" });
1575
+ return;
1576
+ }
1577
+ let body;
1578
+ if (request.method === "PUT") {
1579
+ try {
1580
+ body = JSON.parse(await readBody(request, SETTINGS_BODY_LIMIT));
1581
+ }
1582
+ catch {
1583
+ json(response, 400, { error: "bad JSON" });
1584
+ return;
1585
+ }
1586
+ }
1587
+ const reply = await options.settingsSync.handle(request.method ?? "GET", path, me.id, body);
1588
+ json(response, reply.status, reply.body);
1589
+ return;
1590
+ }
1565
1591
  if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
1566
1592
  const me = await options.accounts.whoIs(tokenFrom(request.headers));
1567
1593
  if (me === null) {
@@ -4554,6 +4580,7 @@ export async function serve(argv, version = "0.1.0") {
4554
4580
  : undefined;
4555
4581
  const follows = pool ? new Follows(pool) : undefined;
4556
4582
  const favorites = pool ? new Favorites(pool) : undefined;
4583
+ const settingsSync = pool ? new SettingsSync(pool) : undefined;
4557
4584
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
4558
4585
  const events = pool ? new LiveEvents(pool) : undefined;
4559
4586
  const layouts = pool ? new Layouts(pool) : undefined;
@@ -4968,6 +4995,7 @@ export async function serve(argv, version = "0.1.0") {
4968
4995
  ...(directory ? { directory } : {}),
4969
4996
  ...(follows ? { follows, vapidPublicKey } : {}),
4970
4997
  ...(favorites ? { favorites } : {}),
4998
+ ...(settingsSync ? { settingsSync } : {}),
4971
4999
  ...(events ? { events } : {}),
4972
5000
  ...(layouts ? { layouts } : {}),
4973
5001
  ...(rooms ? { rooms } : {}),
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Settings sync, the account half: a member's nixamp settings as one snapshot
3
+ * under a revision, kept on nixamp.com against the account like favourites
4
+ * and layouts are.
5
+ *
6
+ * The rules (one digest both sides compute, a conflict rather than a merge
7
+ * when two machines both saved, ten revisions kept) are @profullstack/synconfig's;
8
+ * this file is the store over the directory's Postgres and the three routes,
9
+ * answered the way the rest of the API answers.
10
+ *
11
+ * GET /api/v1/settings the latest snapshot, or { empty: true }
12
+ * PUT /api/v1/settings { snapshot, ifRevision } → a revision, or 409
13
+ * GET /api/v1/settings/revisions what is kept
14
+ */
15
+ import { type HandlerReply, type Snapshot, type SnapshotStore, type StoredSnapshot } from "@profullstack/synconfig/server";
16
+ import type { Queryable } from "./follows.ts";
17
+ /** A snapshot PUT may be this large; the rest of the API reads 64 KB bodies. */
18
+ export declare const SETTINGS_BODY_LIMIT: number;
19
+ export declare class SettingsSync {
20
+ private readonly db;
21
+ private ready;
22
+ readonly store: SnapshotStore;
23
+ constructor(db: Queryable);
24
+ private ensure;
25
+ latest(accountId: string): Promise<StoredSnapshot | null>;
26
+ /**
27
+ * The revision is chosen inside the INSERT and the precondition is checked
28
+ * there, by a HAVING on the same aggregate, so two machines saving at once
29
+ * produce one revision and one conflict. The primary key is the backstop.
30
+ */
31
+ insert(accountId: string, entry: {
32
+ digest: string;
33
+ host: string | null;
34
+ version: string | null;
35
+ size: number;
36
+ body: Snapshot;
37
+ }, ifRevision: number | null): Promise<{
38
+ conflict: true;
39
+ revision: number;
40
+ savedAt?: undefined;
41
+ } | {
42
+ conflict?: undefined;
43
+ revision: number;
44
+ savedAt: string;
45
+ }>;
46
+ list(accountId: string, limit: number): Promise<{
47
+ revision: number;
48
+ digest: string;
49
+ host: string | null;
50
+ version: string | null;
51
+ size: number;
52
+ savedAt: string;
53
+ }[]>;
54
+ /** One request, once the account is known. Pure over the store, so the route test needs no socket. */
55
+ handle(method: string, path: string, accountId: string, body: unknown): Promise<HandlerReply>;
56
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Settings sync, the account half: a member's nixamp settings as one snapshot
3
+ * under a revision, kept on nixamp.com against the account like favourites
4
+ * and layouts are.
5
+ *
6
+ * The rules (one digest both sides compute, a conflict rather than a merge
7
+ * when two machines both saved, ten revisions kept) are @profullstack/synconfig's;
8
+ * this file is the store over the directory's Postgres and the three routes,
9
+ * answered the way the rest of the API answers.
10
+ *
11
+ * GET /api/v1/settings the latest snapshot, or { empty: true }
12
+ * PUT /api/v1/settings { snapshot, ifRevision } → a revision, or 409
13
+ * GET /api/v1/settings/revisions what is kept
14
+ */
15
+ import { KEEP_REVISIONS, handleGet, handlePut, handleRevisions } from "@profullstack/synconfig/server";
16
+ const SCHEMA = `
17
+ CREATE TABLE IF NOT EXISTS settings_snapshots (
18
+ account_id TEXT NOT NULL,
19
+ revision INTEGER NOT NULL,
20
+ digest TEXT NOT NULL,
21
+ host TEXT,
22
+ version TEXT,
23
+ size INTEGER NOT NULL,
24
+ body JSONB NOT NULL,
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
26
+ PRIMARY KEY (account_id, revision)
27
+ );
28
+ `;
29
+ /** A snapshot PUT may be this large; the rest of the API reads 64 KB bodies. */
30
+ export const SETTINGS_BODY_LIMIT = 300 * 1024;
31
+ const shape = (row) => ({
32
+ revision: Number(row["revision"]),
33
+ digest: String(row["digest"]),
34
+ host: row["host"] ?? null,
35
+ version: row["version"] ?? null,
36
+ size: Number(row["size"]),
37
+ body: (typeof row["body"] === "string" ? JSON.parse(row["body"]) : row["body"]),
38
+ savedAt: new Date(row["created_at"]).toISOString(),
39
+ });
40
+ export class SettingsSync {
41
+ db;
42
+ ready = null;
43
+ store;
44
+ constructor(db) {
45
+ this.db = db;
46
+ this.store = {
47
+ latest: (accountId) => this.latest(accountId),
48
+ insert: (accountId, entry, ifRevision) => this.insert(accountId, entry, ifRevision),
49
+ list: (accountId, limit) => this.list(accountId, limit),
50
+ };
51
+ }
52
+ async ensure() {
53
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
54
+ await this.ready;
55
+ }
56
+ async latest(accountId) {
57
+ await this.ensure();
58
+ const { rows } = await this.db.query(`SELECT revision, digest, host, version, size, body, created_at FROM settings_snapshots
59
+ WHERE account_id = $1 ORDER BY revision DESC LIMIT 1`, [accountId]);
60
+ return rows[0] ? shape(rows[0]) : null;
61
+ }
62
+ /**
63
+ * The revision is chosen inside the INSERT and the precondition is checked
64
+ * there, by a HAVING on the same aggregate, so two machines saving at once
65
+ * produce one revision and one conflict. The primary key is the backstop.
66
+ */
67
+ async insert(accountId, entry, ifRevision) {
68
+ await this.ensure();
69
+ let rows;
70
+ try {
71
+ ({ rows } = await this.db.query(`INSERT INTO settings_snapshots (account_id, revision, digest, host, version, size, body)
72
+ SELECT $1, COALESCE(MAX(revision), 0) + 1, $2, $3, $4, $5, $6::jsonb
73
+ FROM settings_snapshots WHERE account_id = $1
74
+ HAVING $7::int IS NULL OR COALESCE(MAX(revision), 0) = $7::int
75
+ RETURNING revision, created_at`, [accountId, entry.digest, entry.host, entry.version, entry.size, JSON.stringify(entry.body), ifRevision]));
76
+ }
77
+ catch (error) {
78
+ if (/duplicate key|unique/i.test(String(error.message)))
79
+ rows = [];
80
+ else
81
+ throw error;
82
+ }
83
+ if (!rows[0]) {
84
+ const current = await this.latest(accountId);
85
+ return { conflict: true, revision: current?.revision ?? 0 };
86
+ }
87
+ const revision = Number(rows[0]["revision"]);
88
+ await this.db.query(`DELETE FROM settings_snapshots WHERE account_id = $1 AND revision <= $2`, [accountId, revision - KEEP_REVISIONS]);
89
+ return { revision, savedAt: new Date(rows[0]["created_at"]).toISOString() };
90
+ }
91
+ async list(accountId, limit) {
92
+ await this.ensure();
93
+ const { rows } = await this.db.query(`SELECT revision, digest, host, version, size, created_at FROM settings_snapshots
94
+ WHERE account_id = $1 ORDER BY revision DESC LIMIT $2`, [accountId, limit]);
95
+ return rows.map((row) => {
96
+ const { body: _body, ...rest } = shape({ ...row, body: "{}" });
97
+ void _body;
98
+ return rest;
99
+ });
100
+ }
101
+ /** One request, once the account is known. Pure over the store, so the route test needs no socket. */
102
+ async handle(method, path, accountId, body) {
103
+ if (path === "/api/v1/settings/revisions") {
104
+ if (method !== "GET")
105
+ return { status: 405, body: { error: "GET only" } };
106
+ return handleRevisions(this.store, accountId);
107
+ }
108
+ if (method === "GET")
109
+ return handleGet(this.store, accountId, { emptyStatus: 200 });
110
+ if (method === "PUT")
111
+ return handlePut(this.store, accountId, body);
112
+ return { status: 405, body: { error: "GET or PUT" } };
113
+ }
114
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type SyncContext, type SyncPolicy } from "@profullstack/synconfig";
2
+ export declare const SYNC_POLICY: SyncPolicy;
3
+ /** The account the snapshot lives at. Null when not signed in. */
4
+ export declare function syncContext(fetcher?: typeof fetch): SyncContext | null;
5
+ /** `nixamp sync [status|save|load|revisions] [--force] [--dry-run]`. Returns the exit code. */
6
+ export declare function syncCommand(argv: string[], fetcher?: typeof fetch): Promise<number>;
package/dist/sync.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `nixamp sync`: your settings on every machine, through your nixamp.com account.
3
+ *
4
+ * What syncs is what you decided, not what a machine is: the compression
5
+ * policy (compression.json) and the channels you remembered (channels.json).
6
+ * The library path is a place on one disk, the session and the share keys
7
+ * are credentials, the catalogs carry provider URLs with credentials in
8
+ * them, and the daemon's state belongs to one box; none of those leave.
9
+ *
10
+ * The mechanism is @profullstack/synconfig: one snapshot under a revision,
11
+ * a conflict rather than a merge when two machines both saved, and a marker
12
+ * so a load never overwrites an unsynced local edit.
13
+ */
14
+ import { hostname } from "node:os";
15
+ import { createClient, load, save, status } from "@profullstack/synconfig";
16
+ import { stateDir } from "./daemon.js";
17
+ import { readSession } from "./session.js";
18
+ import { version } from "./meta.js";
19
+ export const SYNC_POLICY = {
20
+ files: [
21
+ { path: "compression.json", json: true, label: "compression policy" },
22
+ { path: "channels.json", json: true, label: "remembered channels" },
23
+ ],
24
+ never: ["config.json", "session.json", "keys.json", "cookies.txt", "daemon.json", "index.json", "enrich.json", "sync.json"],
25
+ neverPrefixes: ["tls", "catalogs", "relay-cache"],
26
+ neverSuffixes: [".log", ".pem", ".pid", ".sock"],
27
+ };
28
+ /** The account the snapshot lives at. Null when not signed in. */
29
+ export function syncContext(fetcher = fetch) {
30
+ const session = readSession();
31
+ if (session === null)
32
+ return null;
33
+ return {
34
+ rootDir: stateDir(),
35
+ policy: SYNC_POLICY,
36
+ client: createClient({ baseUrl: session.site, path: "/api/v1/settings", token: session.token, fetchImpl: fetcher }),
37
+ api: session.site,
38
+ host: hostname(),
39
+ app: `nixamp ${version()}`,
40
+ };
41
+ }
42
+ const when = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") : "never");
43
+ /** `nixamp sync [status|save|load|revisions] [--force] [--dry-run]`. Returns the exit code. */
44
+ export async function syncCommand(argv, fetcher = fetch) {
45
+ const [command = "status", ...rest] = argv;
46
+ const force = rest.includes("--force");
47
+ const dryRun = rest.includes("--dry-run");
48
+ const ctx = syncContext(fetcher);
49
+ if (ctx === null) {
50
+ console.error("nixamp: not signed in. Try `nixamp login`; settings sync keeps them on your account.");
51
+ return 1;
52
+ }
53
+ try {
54
+ if (command === "status") {
55
+ const state = await status(ctx);
56
+ console.log(`here ${state.marker ? `revision ${state.marker.revision}, synced ${when(state.marker.at)}` : "never synced"}`);
57
+ console.log(`account ${state.serverRevision !== undefined ? `revision ${state.serverRevision}, saved ${when(state.serverSavedAt)}${state.serverHost ? ` from ${state.serverHost}` : ""}` : "nothing yet"}`);
58
+ if (state.drifted.length)
59
+ console.log(`changed here: ${state.drifted.join(", ")} (nixamp sync save)`);
60
+ if (state.behind)
61
+ console.log("the account is newer (nixamp sync load)");
62
+ if (state.marker && !state.drifted.length && !state.behind)
63
+ console.log("in sync.");
64
+ return 0;
65
+ }
66
+ if (command === "save") {
67
+ const result = await save(ctx, { force });
68
+ for (const skip of result.skipped)
69
+ console.error(` skipped ${skip.path}: ${skip.reason}`);
70
+ if (result.status === "saved")
71
+ console.log(`saved revision ${result.revision}: ${result.files} file${result.files === 1 ? "" : "s"}`);
72
+ else if (result.status === "unchanged")
73
+ console.log(`nothing changed since revision ${result.revision}`);
74
+ else if (result.status === "empty")
75
+ console.log("nothing to save: no compression policy or remembered channels here yet");
76
+ else {
77
+ console.error(`nixamp: not saved; another machine saved revision ${result.serverRevision} first. \`nixamp sync load\` to take theirs, or \`nixamp sync save --force\`.`);
78
+ return 1;
79
+ }
80
+ return 0;
81
+ }
82
+ if (command === "load") {
83
+ const result = await load(ctx, { force, dryRun });
84
+ for (const reject of result.rejected)
85
+ console.error(` ignored ${reject.path}: ${reject.reason}`);
86
+ switch (result.status) {
87
+ case "empty":
88
+ console.log("nothing on the account yet. `nixamp sync save` on the machine whose settings you want.");
89
+ return 0;
90
+ case "same":
91
+ console.log(`already at revision ${result.revision}`);
92
+ return 0;
93
+ case "planned":
94
+ for (const entry of result.plan)
95
+ console.log(` ${entry.status.padEnd(8)} ${entry.path}`);
96
+ console.log(`would take revision ${result.revision}; nothing written`);
97
+ return 0;
98
+ case "newer":
99
+ console.error("nixamp: the account holds settings saved by a newer nixamp. `nixamp update` first.");
100
+ return 1;
101
+ case "local_changes":
102
+ console.error(`nixamp: not loaded; ${result.drifted.join(", ")} changed here since the last sync. \`nixamp sync save\` to keep yours, \`nixamp sync load --force\` to replace them.`);
103
+ return 1;
104
+ case "loaded":
105
+ console.log(`loaded revision ${result.revision}: ${result.written.join(", ")}`);
106
+ return 0;
107
+ }
108
+ }
109
+ if (command === "revisions") {
110
+ const revisions = await ctx.client.revisions();
111
+ if (!revisions.length)
112
+ console.log("nothing saved yet");
113
+ for (const entry of revisions)
114
+ console.log(`${String(entry.revision).padStart(4)} ${when(entry.savedAt)} ${(entry.host ?? "").padEnd(16)} ${entry.version ?? ""} ${entry.size} bytes`);
115
+ return 0;
116
+ }
117
+ console.error(`nixamp sync: unknown action ${command}. Try status, save, load or revisions.`);
118
+ return 64;
119
+ }
120
+ catch (error) {
121
+ console.error(`nixamp: ${error.message}`);
122
+ return 1;
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,6 +46,7 @@
46
46
  "dependencies": {
47
47
  "@profullstack/auth-system": "^0.6.0",
48
48
  "@profullstack/hqtui": "^0.5.0",
49
+ "@profullstack/synconfig": "^0.1.2",
49
50
  "@profullstack/throttle": "0.2.2",
50
51
  "@profullstack/x402-gateway": "0.6.0",
51
52
  "acme-client": "5.4.0",
package/src/main.ts CHANGED
@@ -80,6 +80,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
80
80
  nixamp token create|list|revoke tokens for a machine that cannot sign in
81
81
  nixamp dns [set|rm] names under your handle, for your servers
82
82
  nixamp library [folder] where the media is; the daemon serves this and nothing outside it
83
+ nixamp sync [save|load|status] your settings on every machine, against your account (--force, --dry-run)
83
84
  nixamp server list|add|remove the machines you run, kept against your account
84
85
  nixamp party list|join|host watch parties, here and on the sites nixamp is connected to
85
86
  nixamp mcp speak Model Context Protocol on stdin, for an agent
@@ -526,6 +527,11 @@ export async function main(): Promise<void> {
526
527
  process.exitCode = await dns(rest);
527
528
  return;
528
529
  }
530
+ if (first === "sync") {
531
+ const { syncCommand } = await import("./sync.ts");
532
+ process.exitCode = await syncCommand(rest);
533
+ return;
534
+ }
529
535
  if (first === "library") {
530
536
  const { libraryCommand } = await import("./library.ts");
531
537
  process.exitCode = await libraryCommand(rest);
package/src/server.ts CHANGED
@@ -68,6 +68,7 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
68
68
  import pg from "pg";
69
69
  import { Follows, phoneFrom } from "./follows.ts";
70
70
  import { Favorites, favoriteUrl } from "./favorites.ts";
71
+ import { SettingsSync, SETTINGS_BODY_LIMIT } from "./settings-sync.ts";
71
72
  import { Catalogs, shownCatalog, shownEntry } from "./catalogs.ts";
72
73
  import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
73
74
  import { NameError, Names } from "./names.ts";
@@ -1593,6 +1594,8 @@ export interface HandlerOptions {
1593
1594
  follows?: Follows;
1594
1595
  /** The servers an account hearted. nixamp.com only, like follows. */
1595
1596
  favorites?: Favorites;
1597
+ /** Settings sync, on the directory: a member's settings under revisions, reached by the same session. */
1598
+ settingsSync?: SettingsSync;
1596
1599
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
1597
1600
  events?: LiveEvents;
1598
1601
  /** Versioned panel layouts, including event and user overrides. */
@@ -2013,6 +2016,31 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2013
2016
  return;
2014
2017
  }
2015
2018
 
2019
+ // --- settings sync: your settings on every machine, against the account ----
2020
+ //
2021
+ // The compression policy and the remembered channels, as one snapshot
2022
+ // under a revision (@profullstack/synconfig). The directory stores what
2023
+ // the client sent and hands it back; it never reads the files inside.
2024
+ if ((path === "/api/v1/settings" || path === "/api/v1/settings/revisions") && options.settingsSync && options.accounts) {
2025
+ const me = await options.accounts.whoIs(tokenFrom(request.headers));
2026
+ if (me === null) {
2027
+ json(response, 401, { error: "sign in to sync settings" });
2028
+ return;
2029
+ }
2030
+ let body: unknown;
2031
+ if (request.method === "PUT") {
2032
+ try {
2033
+ body = JSON.parse(await readBody(request, SETTINGS_BODY_LIMIT)) as unknown;
2034
+ } catch {
2035
+ json(response, 400, { error: "bad JSON" });
2036
+ return;
2037
+ }
2038
+ }
2039
+ const reply = await options.settingsSync.handle(request.method ?? "GET", path, me.id, body);
2040
+ json(response, reply.status, reply.body);
2041
+ return;
2042
+ }
2043
+
2016
2044
  if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
2017
2045
  const me = await options.accounts.whoIs(tokenFrom(request.headers));
2018
2046
  if (me === null) {
@@ -5156,6 +5184,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5156
5184
  : undefined;
5157
5185
  const follows = pool ? new Follows(pool) : undefined;
5158
5186
  const favorites = pool ? new Favorites(pool) : undefined;
5187
+ const settingsSync = pool ? new SettingsSync(pool) : undefined;
5159
5188
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
5160
5189
  const events = pool ? new LiveEvents(pool) : undefined;
5161
5190
  const layouts = pool ? new Layouts(pool) : undefined;
@@ -5587,6 +5616,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5587
5616
  ...(directory ? { directory } : {}),
5588
5617
  ...(follows ? { follows, vapidPublicKey } : {}),
5589
5618
  ...(favorites ? { favorites } : {}),
5619
+ ...(settingsSync ? { settingsSync } : {}),
5590
5620
  ...(events ? { events } : {}),
5591
5621
  ...(layouts ? { layouts } : {}),
5592
5622
  ...(rooms ? { rooms } : {}),
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Settings sync, the account half: a member's nixamp settings as one snapshot
3
+ * under a revision, kept on nixamp.com against the account like favourites
4
+ * and layouts are.
5
+ *
6
+ * The rules (one digest both sides compute, a conflict rather than a merge
7
+ * when two machines both saved, ten revisions kept) are @profullstack/synconfig's;
8
+ * this file is the store over the directory's Postgres and the three routes,
9
+ * answered the way the rest of the API answers.
10
+ *
11
+ * GET /api/v1/settings the latest snapshot, or { empty: true }
12
+ * PUT /api/v1/settings { snapshot, ifRevision } → a revision, or 409
13
+ * GET /api/v1/settings/revisions what is kept
14
+ */
15
+ import { KEEP_REVISIONS, handleGet, handlePut, handleRevisions, type HandlerReply, type Snapshot, type SnapshotStore, type StoredSnapshot } from "@profullstack/synconfig/server";
16
+ import type { Queryable } from "./follows.ts";
17
+
18
+ const SCHEMA = `
19
+ CREATE TABLE IF NOT EXISTS settings_snapshots (
20
+ account_id TEXT NOT NULL,
21
+ revision INTEGER NOT NULL,
22
+ digest TEXT NOT NULL,
23
+ host TEXT,
24
+ version TEXT,
25
+ size INTEGER NOT NULL,
26
+ body JSONB NOT NULL,
27
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
28
+ PRIMARY KEY (account_id, revision)
29
+ );
30
+ `;
31
+
32
+ /** A snapshot PUT may be this large; the rest of the API reads 64 KB bodies. */
33
+ export const SETTINGS_BODY_LIMIT = 300 * 1024;
34
+
35
+ const shape = (row: Record<string, unknown>): StoredSnapshot => ({
36
+ revision: Number(row["revision"]),
37
+ digest: String(row["digest"]),
38
+ host: (row["host"] as string | null) ?? null,
39
+ version: (row["version"] as string | null) ?? null,
40
+ size: Number(row["size"]),
41
+ body: (typeof row["body"] === "string" ? JSON.parse(row["body"] as string) : row["body"]) as Snapshot,
42
+ savedAt: new Date(row["created_at"] as string | Date).toISOString(),
43
+ });
44
+
45
+ export class SettingsSync {
46
+ private ready: Promise<void> | null = null;
47
+ readonly store: SnapshotStore;
48
+
49
+ constructor(private readonly db: Queryable) {
50
+ this.store = {
51
+ latest: (accountId) => this.latest(accountId),
52
+ insert: (accountId, entry, ifRevision) => this.insert(accountId, entry, ifRevision),
53
+ list: (accountId, limit) => this.list(accountId, limit),
54
+ };
55
+ }
56
+
57
+ private async ensure(): Promise<void> {
58
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
59
+ await this.ready;
60
+ }
61
+
62
+ async latest(accountId: string): Promise<StoredSnapshot | null> {
63
+ await this.ensure();
64
+ const { rows } = await this.db.query(
65
+ `SELECT revision, digest, host, version, size, body, created_at FROM settings_snapshots
66
+ WHERE account_id = $1 ORDER BY revision DESC LIMIT 1`,
67
+ [accountId],
68
+ );
69
+ return rows[0] ? shape(rows[0]) : null;
70
+ }
71
+
72
+ /**
73
+ * The revision is chosen inside the INSERT and the precondition is checked
74
+ * there, by a HAVING on the same aggregate, so two machines saving at once
75
+ * produce one revision and one conflict. The primary key is the backstop.
76
+ */
77
+ async insert(accountId: string, entry: { digest: string; host: string | null; version: string | null; size: number; body: Snapshot }, ifRevision: number | null) {
78
+ await this.ensure();
79
+ let rows: Record<string, unknown>[];
80
+ try {
81
+ ({ rows } = await this.db.query(
82
+ `INSERT INTO settings_snapshots (account_id, revision, digest, host, version, size, body)
83
+ SELECT $1, COALESCE(MAX(revision), 0) + 1, $2, $3, $4, $5, $6::jsonb
84
+ FROM settings_snapshots WHERE account_id = $1
85
+ HAVING $7::int IS NULL OR COALESCE(MAX(revision), 0) = $7::int
86
+ RETURNING revision, created_at`,
87
+ [accountId, entry.digest, entry.host, entry.version, entry.size, JSON.stringify(entry.body), ifRevision],
88
+ ));
89
+ } catch (error) {
90
+ if (/duplicate key|unique/i.test(String((error as Error).message))) rows = [];
91
+ else throw error;
92
+ }
93
+ if (!rows[0]) {
94
+ const current = await this.latest(accountId);
95
+ return { conflict: true as const, revision: current?.revision ?? 0 };
96
+ }
97
+ const revision = Number(rows[0]["revision"]);
98
+ await this.db.query(`DELETE FROM settings_snapshots WHERE account_id = $1 AND revision <= $2`, [accountId, revision - KEEP_REVISIONS]);
99
+ return { revision, savedAt: new Date(rows[0]["created_at"] as string | Date).toISOString() };
100
+ }
101
+
102
+ async list(accountId: string, limit: number) {
103
+ await this.ensure();
104
+ const { rows } = await this.db.query(
105
+ `SELECT revision, digest, host, version, size, created_at FROM settings_snapshots
106
+ WHERE account_id = $1 ORDER BY revision DESC LIMIT $2`,
107
+ [accountId, limit],
108
+ );
109
+ return rows.map((row) => {
110
+ const { body: _body, ...rest } = shape({ ...row, body: "{}" });
111
+ void _body;
112
+ return rest;
113
+ });
114
+ }
115
+
116
+ /** One request, once the account is known. Pure over the store, so the route test needs no socket. */
117
+ async handle(method: string, path: string, accountId: string, body: unknown): Promise<HandlerReply> {
118
+ if (path === "/api/v1/settings/revisions") {
119
+ if (method !== "GET") return { status: 405, body: { error: "GET only" } };
120
+ return handleRevisions(this.store, accountId);
121
+ }
122
+ if (method === "GET") return handleGet(this.store, accountId, { emptyStatus: 200 });
123
+ if (method === "PUT") return handlePut(this.store, accountId, body);
124
+ return { status: 405, body: { error: "GET or PUT" } };
125
+ }
126
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `nixamp sync`: your settings on every machine, through your nixamp.com account.
3
+ *
4
+ * What syncs is what you decided, not what a machine is: the compression
5
+ * policy (compression.json) and the channels you remembered (channels.json).
6
+ * The library path is a place on one disk, the session and the share keys
7
+ * are credentials, the catalogs carry provider URLs with credentials in
8
+ * them, and the daemon's state belongs to one box; none of those leave.
9
+ *
10
+ * The mechanism is @profullstack/synconfig: one snapshot under a revision,
11
+ * a conflict rather than a merge when two machines both saved, and a marker
12
+ * so a load never overwrites an unsynced local edit.
13
+ */
14
+ import { hostname } from "node:os";
15
+ import { createClient, load, save, status, type SyncContext, type SyncPolicy } from "@profullstack/synconfig";
16
+ import { stateDir } from "./daemon.ts";
17
+ import { readSession } from "./session.ts";
18
+ import { version } from "./meta.ts";
19
+
20
+ export const SYNC_POLICY: SyncPolicy = {
21
+ files: [
22
+ { path: "compression.json", json: true, label: "compression policy" },
23
+ { path: "channels.json", json: true, label: "remembered channels" },
24
+ ],
25
+ never: ["config.json", "session.json", "keys.json", "cookies.txt", "daemon.json", "index.json", "enrich.json", "sync.json"],
26
+ neverPrefixes: ["tls", "catalogs", "relay-cache"],
27
+ neverSuffixes: [".log", ".pem", ".pid", ".sock"],
28
+ };
29
+
30
+ /** The account the snapshot lives at. Null when not signed in. */
31
+ export function syncContext(fetcher: typeof fetch = fetch): SyncContext | null {
32
+ const session = readSession();
33
+ if (session === null) return null;
34
+ return {
35
+ rootDir: stateDir(),
36
+ policy: SYNC_POLICY,
37
+ client: createClient({ baseUrl: session.site, path: "/api/v1/settings", token: session.token, fetchImpl: fetcher }),
38
+ api: session.site,
39
+ host: hostname(),
40
+ app: `nixamp ${version()}`,
41
+ };
42
+ }
43
+
44
+ const when = (iso?: string): string => (iso ? iso.slice(0, 16).replace("T", " ") : "never");
45
+
46
+ /** `nixamp sync [status|save|load|revisions] [--force] [--dry-run]`. Returns the exit code. */
47
+ export async function syncCommand(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
48
+ const [command = "status", ...rest] = argv;
49
+ const force = rest.includes("--force");
50
+ const dryRun = rest.includes("--dry-run");
51
+ const ctx = syncContext(fetcher);
52
+ if (ctx === null) {
53
+ console.error("nixamp: not signed in. Try `nixamp login`; settings sync keeps them on your account.");
54
+ return 1;
55
+ }
56
+
57
+ try {
58
+ if (command === "status") {
59
+ const state = await status(ctx);
60
+ console.log(`here ${state.marker ? `revision ${state.marker.revision}, synced ${when(state.marker.at)}` : "never synced"}`);
61
+ console.log(`account ${state.serverRevision !== undefined ? `revision ${state.serverRevision}, saved ${when(state.serverSavedAt)}${state.serverHost ? ` from ${state.serverHost}` : ""}` : "nothing yet"}`);
62
+ if (state.drifted.length) console.log(`changed here: ${state.drifted.join(", ")} (nixamp sync save)`);
63
+ if (state.behind) console.log("the account is newer (nixamp sync load)");
64
+ if (state.marker && !state.drifted.length && !state.behind) console.log("in sync.");
65
+ return 0;
66
+ }
67
+ if (command === "save") {
68
+ const result = await save(ctx, { force });
69
+ for (const skip of result.skipped) console.error(` skipped ${skip.path}: ${skip.reason}`);
70
+ if (result.status === "saved") console.log(`saved revision ${result.revision}: ${result.files} file${result.files === 1 ? "" : "s"}`);
71
+ else if (result.status === "unchanged") console.log(`nothing changed since revision ${result.revision}`);
72
+ else if (result.status === "empty") console.log("nothing to save: no compression policy or remembered channels here yet");
73
+ else {
74
+ console.error(`nixamp: not saved; another machine saved revision ${result.serverRevision} first. \`nixamp sync load\` to take theirs, or \`nixamp sync save --force\`.`);
75
+ return 1;
76
+ }
77
+ return 0;
78
+ }
79
+ if (command === "load") {
80
+ const result = await load(ctx, { force, dryRun });
81
+ for (const reject of result.rejected) console.error(` ignored ${reject.path}: ${reject.reason}`);
82
+ switch (result.status) {
83
+ case "empty":
84
+ console.log("nothing on the account yet. `nixamp sync save` on the machine whose settings you want.");
85
+ return 0;
86
+ case "same":
87
+ console.log(`already at revision ${result.revision}`);
88
+ return 0;
89
+ case "planned":
90
+ for (const entry of result.plan) console.log(` ${entry.status.padEnd(8)} ${entry.path}`);
91
+ console.log(`would take revision ${result.revision}; nothing written`);
92
+ return 0;
93
+ case "newer":
94
+ console.error("nixamp: the account holds settings saved by a newer nixamp. `nixamp update` first.");
95
+ return 1;
96
+ case "local_changes":
97
+ console.error(`nixamp: not loaded; ${result.drifted.join(", ")} changed here since the last sync. \`nixamp sync save\` to keep yours, \`nixamp sync load --force\` to replace them.`);
98
+ return 1;
99
+ case "loaded":
100
+ console.log(`loaded revision ${result.revision}: ${result.written.join(", ")}`);
101
+ return 0;
102
+ }
103
+ }
104
+ if (command === "revisions") {
105
+ const revisions = await ctx.client.revisions();
106
+ if (!revisions.length) console.log("nothing saved yet");
107
+ for (const entry of revisions) console.log(`${String(entry.revision).padStart(4)} ${when(entry.savedAt)} ${(entry.host ?? "").padEnd(16)} ${entry.version ?? ""} ${entry.size} bytes`);
108
+ return 0;
109
+ }
110
+ console.error(`nixamp sync: unknown action ${command}. Try status, save, load or revisions.`);
111
+ return 64;
112
+ } catch (error) {
113
+ console.error(`nixamp: ${(error as Error).message}`);
114
+ return 1;
115
+ }
116
+ }
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1789264893886";
2
+ const CACHE = "nixamp-1789266472824";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/.well-known/openaccess.json",