nixamp 0.22.0 → 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;
@@ -4582,8 +4609,7 @@ export async function serve(argv, version = "0.1.0") {
4582
4609
  const speech = accounts && process.env["NIXAMP_STT"] !== "off" ? new Speech() : undefined;
4583
4610
  if (speech) {
4584
4611
  void speech.warm().then((ready) => {
4585
- if (ready)
4586
- console.error(`nixamp: hearing with ${speech.model}`);
4612
+ console.error(ready ? `nixamp: hearing with ${speech.model}` : `nixamp: not hearing: ${speech.lastFailure}`);
4587
4613
  });
4588
4614
  }
4589
4615
  // nixamp as an OAuth 2.1 authorization server, and the watch parties a
@@ -4969,6 +4995,7 @@ export async function serve(argv, version = "0.1.0") {
4969
4995
  ...(directory ? { directory } : {}),
4970
4996
  ...(follows ? { follows, vapidPublicKey } : {}),
4971
4997
  ...(favorites ? { favorites } : {}),
4998
+ ...(settingsSync ? { settingsSync } : {}),
4972
4999
  ...(events ? { events } : {}),
4973
5000
  ...(layouts ? { layouts } : {}),
4974
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/speech.d.ts CHANGED
@@ -83,6 +83,8 @@ export declare class Speech {
83
83
  * waits for the download. Says whether it could; never throws.
84
84
  */
85
85
  warm(): Promise<boolean>;
86
+ /** Why the last warm() could not load the ear, for the boot log. */
87
+ lastFailure: string;
86
88
  /** Whether this account may have this much heard now, and the bookkeeping if so. */
87
89
  allow(accountId: string, seconds: number): void;
88
90
  /**
package/dist/speech.js CHANGED
@@ -204,8 +204,12 @@ async function loadWhisper(model, cacheDir) {
204
204
  try {
205
205
  transformers = (await import(__rewriteRelativeImportExtension(name)));
206
206
  }
207
- catch {
208
- throw new SpeechError("this nixamp cannot hear: @huggingface/transformers is not installed here. nixamp.com can.", 503);
207
+ catch (error) {
208
+ // The reason travels with the refusal: "not installed" and "installed
209
+ // but its native runtime would not load" need different fixes, and the
210
+ // one place this is read is a deployment's log.
211
+ const why = String(error.message ?? error).split("\n")[0] ?? "";
212
+ throw new SpeechError(`this nixamp cannot hear: @huggingface/transformers did not load here (${why}). nixamp.com can.`, 503);
209
213
  }
210
214
  transformers.env.cacheDir = cacheDir;
211
215
  const recognize = await transformers.pipeline("automatic-speech-recognition", model, { dtype: "q8" });
@@ -252,10 +256,13 @@ export class Speech {
252
256
  await this.ear();
253
257
  return true;
254
258
  }
255
- catch {
259
+ catch (error) {
260
+ this.lastFailure = error.message;
256
261
  return false;
257
262
  }
258
263
  }
264
+ /** Why the last warm() could not load the ear, for the boot log. */
265
+ lastFailure = "";
259
266
  /** Whether this account may have this much heard now, and the bookkeeping if so. */
260
267
  allow(accountId, seconds) {
261
268
  const minute = Math.floor(this.now() / 60_000);
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.0",
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;
@@ -5186,7 +5215,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5186
5215
  const speech = accounts && process.env["NIXAMP_STT"] !== "off" ? new Speech() : undefined;
5187
5216
  if (speech) {
5188
5217
  void speech.warm().then((ready) => {
5189
- if (ready) console.error(`nixamp: hearing with ${speech.model}`);
5218
+ console.error(ready ? `nixamp: hearing with ${speech.model}` : `nixamp: not hearing: ${speech.lastFailure}`);
5190
5219
  });
5191
5220
  }
5192
5221
 
@@ -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/speech.ts CHANGED
@@ -220,8 +220,12 @@ async function loadWhisper(model: string, cacheDir: string): Promise<Recognizer>
220
220
  let transformers: Transformers;
221
221
  try {
222
222
  transformers = (await import(name)) as Transformers;
223
- } catch {
224
- throw new SpeechError("this nixamp cannot hear: @huggingface/transformers is not installed here. nixamp.com can.", 503);
223
+ } catch (error) {
224
+ // The reason travels with the refusal: "not installed" and "installed
225
+ // but its native runtime would not load" need different fixes, and the
226
+ // one place this is read is a deployment's log.
227
+ const why = String((error as Error).message ?? error).split("\n")[0] ?? "";
228
+ throw new SpeechError(`this nixamp cannot hear: @huggingface/transformers did not load here (${why}). nixamp.com can.`, 503);
225
229
  }
226
230
  transformers.env.cacheDir = cacheDir;
227
231
  const recognize = await transformers.pipeline("automatic-speech-recognition", model, { dtype: "q8" });
@@ -271,11 +275,15 @@ export class Speech {
271
275
  try {
272
276
  await this.ear();
273
277
  return true;
274
- } catch {
278
+ } catch (error) {
279
+ this.lastFailure = (error as Error).message;
275
280
  return false;
276
281
  }
277
282
  }
278
283
 
284
+ /** Why the last warm() could not load the ear, for the boot log. */
285
+ lastFailure = "";
286
+
279
287
  /** Whether this account may have this much heard now, and the bookkeeping if so. */
280
288
  allow(accountId: string, seconds: number): void {
281
289
  const minute = Math.floor(this.now() / 60_000);
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-1789264533383";
2
+ const CACHE = "nixamp-1789266472824";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/.well-known/openaccess.json",