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 +6 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +28 -0
- package/dist/settings-sync.d.ts +56 -0
- package/dist/settings-sync.js +114 -0
- package/dist/sync.d.ts +6 -0
- package/dist/sync.js +124 -0
- package/package.json +2 -1
- package/src/main.ts +6 -0
- package/src/server.ts +30 -0
- package/src/settings-sync.ts +126 -0
- package/src/sync.ts +116 -0
- package/web/dist/sw.js +1 -1
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.
|
|
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