nixamp 0.5.12 → 0.6.1

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/admin.d.ts CHANGED
@@ -46,6 +46,19 @@ export interface AdminOptions {
46
46
  export declare function resolveTarget(argv: string[]): AdminOptions;
47
47
  /** Seconds as something a person reads at a glance. */
48
48
  export declare function since(ms: number): string;
49
+ /**
50
+ * What a keypress contributes to a field being typed into.
51
+ *
52
+ * A paste is one event carrying the whole string, not a burst of single
53
+ * characters, so a handler that only accepted `key.length === 1` accepted
54
+ * nothing at all from a paste -- which is how you find you cannot put a URL in
55
+ * the box by any means except typing it out.
56
+ *
57
+ * The ambiguity is real and unavoidable: a pasted word of bare letters is
58
+ * indistinguishable from a key name, and loses. A URL or a path never is,
59
+ * because neither is spelled with letters alone.
60
+ */
61
+ export declare function typed(key: string): string;
49
62
  export declare function bytes(value: number): string;
50
63
  export declare function admin(argv: string[]): Promise<void>;
51
64
  export interface View {
package/dist/admin.js CHANGED
@@ -44,6 +44,31 @@ export function since(ms) {
44
44
  return `${hours}h ${minutes % 60}m`;
45
45
  return `${Math.floor(hours / 24)}d ${hours % 24}h`;
46
46
  }
47
+ /**
48
+ * A key name rather than something somebody typed: "up", "f7", "ctrl+c".
49
+ * Letters and digits only, so anything with a colon or a slash in it is text.
50
+ */
51
+ const NAMED_KEY = /^(?:[a-z]+\d*|(?:ctrl|alt|shift|meta)\+.+)$/;
52
+ /**
53
+ * What a keypress contributes to a field being typed into.
54
+ *
55
+ * A paste is one event carrying the whole string, not a burst of single
56
+ * characters, so a handler that only accepted `key.length === 1` accepted
57
+ * nothing at all from a paste -- which is how you find you cannot put a URL in
58
+ * the box by any means except typing it out.
59
+ *
60
+ * The ambiguity is real and unavoidable: a pasted word of bare letters is
61
+ * indistinguishable from a key name, and loses. A URL or a path never is,
62
+ * because neither is spelled with letters alone.
63
+ */
64
+ export function typed(key) {
65
+ if (key.length === 1)
66
+ return key >= " " && key !== "\u007f" ? key : "";
67
+ if (NAMED_KEY.test(key))
68
+ return "";
69
+ // A paste. Control characters and newlines are not part of an address.
70
+ return key.replace(/[\u0000-\u001f\u007f]/g, "");
71
+ }
47
72
  export function bytes(value) {
48
73
  const units = ["B", "KiB", "MiB", "GiB"];
49
74
  let n = value;
@@ -109,9 +134,8 @@ export async function admin(argv) {
109
134
  }
110
135
  else if (key === "backspace")
111
136
  restreaming = restreaming.slice(0, -1);
112
- // A printable key is a character; everything else is a name like "f1".
113
- else if (key.length === 1)
114
- restreaming += key;
137
+ else
138
+ restreaming += typed(key);
115
139
  app.invalidate();
116
140
  return;
117
141
  }
package/dist/main.js CHANGED
@@ -51,6 +51,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
51
51
  nixamp login [--with github] sign in to nixamp.com, in a browser or here
52
52
  nixamp logout / whoami forget it, or check it
53
53
  nixamp token create|list|revoke tokens for a machine that cannot sign in
54
+ nixamp server list|add|remove the machines you run, kept against your account
54
55
  nixamp update [version] re-run the installer, keeping your choices
55
56
  nixamp uninstall [--yes] remove everything the installer created
56
57
 
@@ -170,6 +171,18 @@ keeps serving its browser remote. One per user.
170
171
  nixamp admin who is connected, and re-stream to them
171
172
 
172
173
  From inside the player, d hands the music to a daemon without stopping it.
174
+ `,
175
+ server: `nixamp server -- the machines you run.
176
+
177
+ nixamp server list every server on your account
178
+ nixamp server add --here remember the daemon on this machine
179
+ nixamp server add URL --name x remember one somewhere else
180
+ nixamp server remove ID forget it
181
+
182
+ A share link printed in a terminal you have since closed is a server you have
183
+ lost. This keeps the address against your account, so the answer is the same
184
+ here, in the browser and in the desktop app. The share key is kept with it only
185
+ if you pass one, since it is the secret that opens the machine.
173
186
  `,
174
187
  attach: `nixamp attach — the player, in front of the running daemon.
175
188
 
@@ -279,6 +292,11 @@ export async function main() {
279
292
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
280
293
  return;
281
294
  }
295
+ if (first === "server" || first === "servers") {
296
+ const { servers } = await import("./session.js");
297
+ process.exitCode = await servers(rest);
298
+ return;
299
+ }
282
300
  if (first === "token" || first === "tokens") {
283
301
  const { tokens } = await import("./session.js");
284
302
  process.exitCode = await tokens(rest);
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
4
4
  import { Ingest } from "./ingest.ts";
5
5
  import { Channels } from "./channels.ts";
6
6
  import { Accounts } from "./accounts.ts";
7
+ import { Servers } from "./servers.ts";
7
8
  import { SignIn } from "./oauth.ts";
8
9
  import { Owner } from "./owner.ts";
9
10
  import { Directory } from "./directory.ts";
@@ -238,6 +239,12 @@ export interface HandlerOptions {
238
239
  * imported so the handler stays a plain function of a request.
239
240
  */
240
241
  load: (source: string) => Promise<Track[]>;
242
+ /**
243
+ * The same source, with its tags, read without holding the event loop. Called
244
+ * after `load` and never awaited: the titles arrive into a player that is
245
+ * already playing.
246
+ */
247
+ tag?: (source: string) => Promise<Track[]>;
241
248
  /**
242
249
  * The public directory, on the instance that hosts one. Only nixamp.com
243
250
  * passes this; a nixamp on your laptop is a publisher, not a registry.
@@ -260,6 +267,8 @@ export interface HandlerOptions {
260
267
  accounts?: Accounts;
261
268
  /** Providers to sign in with, and the terminals waiting to be connected. */
262
269
  signIn?: SignIn;
270
+ /** The servers each account runs, on the instance that keeps accounts. */
271
+ servers?: Servers;
263
272
  /** True when this instance is reached over https, for the cookie's Secure. */
264
273
  secureCookies?: boolean;
265
274
  /** A certificate and key in PEM, when this server is to speak https itself. */
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { Ingest, normaliseFormat } from "./ingest.js";
21
21
  import { Channels, cleanId } from "./channels.js";
22
22
  import { RtmpListeners } from "./rtmp-in.js";
23
23
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
24
+ import { Servers } from "./servers.js";
24
25
  import { DeviceGrants } from "./device.js";
25
26
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
26
27
  import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
@@ -549,7 +550,12 @@ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
549
550
  * the share-key check rather than behind it.
550
551
  */
551
552
  export function isSignInPath(path) {
552
- return path.startsWith("/api/v1/auth/") || OAUTH_ROUTE.test(path);
553
+ return (path.startsWith("/api/v1/auth/") ||
554
+ // The account's own server list: nixamp.com's API, gated by the session
555
+ // rather than by a share key it has nothing to do with.
556
+ path === "/api/v1/servers" ||
557
+ path.startsWith("/api/v1/servers/") ||
558
+ OAUTH_ROUTE.test(path));
553
559
  }
554
560
  function html(response, code, body) {
555
561
  response.writeHead(code, {
@@ -988,6 +994,73 @@ export function createHandler(engine, options) {
988
994
  json(response, 404, { error: "no such endpoint" });
989
995
  return;
990
996
  }
997
+ // --- the servers this account runs ----------------------------------
998
+ //
999
+ // Kept against the account rather than the machine, so the list reads the
1000
+ // same from the CLI, the PWA and the desktop app -- which is the whole
1001
+ // point: a share link in a terminal you closed is a server you have lost.
1002
+ if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
1003
+ const servers = options.servers;
1004
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1005
+ if (who === null) {
1006
+ json(response, 401, { error: "not signed in" });
1007
+ return;
1008
+ }
1009
+ if (path === "/api/v1/servers" && request.method === "GET") {
1010
+ json(response, 200, { servers: await servers.list(who.id) });
1011
+ return;
1012
+ }
1013
+ if (path === "/api/v1/servers" && request.method === "POST") {
1014
+ let body;
1015
+ try {
1016
+ body = JSON.parse(await readBody(request));
1017
+ }
1018
+ catch {
1019
+ json(response, 400, { error: "bad JSON" });
1020
+ return;
1021
+ }
1022
+ const made = await servers.add(who, {
1023
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1024
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1025
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1026
+ });
1027
+ if (made === null) {
1028
+ json(response, 422, { error: "that needs an http or https address" });
1029
+ return;
1030
+ }
1031
+ json(response, 201, { server: made });
1032
+ return;
1033
+ }
1034
+ const id = path.slice("/api/v1/servers/".length);
1035
+ if (id && request.method === "DELETE") {
1036
+ const gone = await servers.remove(who.id, id);
1037
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1038
+ return;
1039
+ }
1040
+ if (id && (request.method === "PATCH" || request.method === "PUT")) {
1041
+ let body;
1042
+ try {
1043
+ body = JSON.parse(await readBody(request));
1044
+ }
1045
+ catch {
1046
+ json(response, 400, { error: "bad JSON" });
1047
+ return;
1048
+ }
1049
+ const changed = await servers.update(who.id, id, {
1050
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1051
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1052
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1053
+ });
1054
+ if (changed === null) {
1055
+ json(response, 404, { error: "no such server, or a bad address" });
1056
+ return;
1057
+ }
1058
+ json(response, 200, { server: changed });
1059
+ return;
1060
+ }
1061
+ json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1062
+ return;
1063
+ }
991
1064
  // --- tokens a person made on purpose --------------------------------
992
1065
  if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
993
1066
  const who = await accounts.whoIs(tokenFrom(request.headers));
@@ -1550,6 +1623,15 @@ export function createHandler(engine, options) {
1550
1623
  return;
1551
1624
  }
1552
1625
  engine.replace(tracks, source);
1626
+ // Names now, tags later, here as much as at startup: re-streaming a
1627
+ // directory of five thousand files used to read every tag before it
1628
+ // answered, with the event loop held the whole time.
1629
+ if (options.tag) {
1630
+ void options
1631
+ .tag(source)
1632
+ .then((tagged) => engine.retag(tagged, source))
1633
+ .catch(() => { });
1634
+ }
1553
1635
  json(response, 200, engine.snapshot());
1554
1636
  }
1555
1637
  catch (error) {
@@ -2161,7 +2243,10 @@ export async function serve(argv, version = "0.1.0") {
2161
2243
  ffmpeg: tools.ffmpeg,
2162
2244
  ffprobe: tools.ffprobe,
2163
2245
  ...(tls ? { tls } : {}),
2164
- load: (next) => loadSource(tools, next),
2246
+ // Untagged, so a directory of five thousand files answers at once; the
2247
+ // tags follow through `tag` below.
2248
+ load: (next) => loadSource(tools, next, false),
2249
+ tag: (next) => loadTagged(tools, next),
2165
2250
  ...(directory ? { directory } : {}),
2166
2251
  ...(follows ? { follows, vapidPublicKey } : {}),
2167
2252
  ...(partyLine ? { partyLine } : {}),
@@ -2182,6 +2267,9 @@ export async function serve(argv, version = "0.1.0") {
2182
2267
  // Whichever providers this deployment was given both halves of, plus
2183
2268
  // the device grant, which is worth having even with no provider at
2184
2269
  // all: a browser already signed in can approve a terminal.
2270
+ // The same pool the follows and reminders use: three small tables in
2271
+ // one database do not want three sets of connections.
2272
+ ...(pool ? { servers: new Servers(pool) } : {}),
2185
2273
  signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
2186
2274
  }
2187
2275
  : {}),
@@ -0,0 +1,46 @@
1
+ import type { Account } from "./accounts.ts";
2
+ import type { Queryable } from "./follows.ts";
3
+ export interface ServerEntry {
4
+ id: string;
5
+ name: string;
6
+ url: string;
7
+ /** Empty when the owner chose not to keep it here. */
8
+ key: string;
9
+ createdAt: number;
10
+ updatedAt: number;
11
+ lastSeenAt: number | null;
12
+ }
13
+ /** What a caller may set. Anything else is the server's business. */
14
+ export interface ServerInput {
15
+ name?: string;
16
+ url?: string;
17
+ key?: string;
18
+ }
19
+ /**
20
+ * An address a browser could open, with nothing else smuggled in.
21
+ *
22
+ * A stored URL is handed back to other clients and put in an href, so a
23
+ * javascript: or data: entry here is a link somebody else clicks. Only http
24
+ * and https, and nothing after the origin: a nixamp is a host and a port.
25
+ */
26
+ export declare function cleanUrl(value: unknown): string;
27
+ /** A name is a label, not an essay, and never empty on screen. */
28
+ export declare function cleanName(value: unknown, url: string): string;
29
+ export declare class Servers {
30
+ private readonly db;
31
+ private ready;
32
+ constructor(db: Queryable);
33
+ private ensure;
34
+ list(userId: string): Promise<ServerEntry[]>;
35
+ /**
36
+ * Remember a server, or update the one already at that address.
37
+ *
38
+ * Upserted on the address rather than refused, because the common way to
39
+ * call this twice is a daemon announcing itself after a restart, and that
40
+ * should move the entry forward rather than fail.
41
+ */
42
+ add(account: Account, input: ServerInput): Promise<ServerEntry | null>;
43
+ /** Scoped to the owner, so somebody else's id changes nothing. */
44
+ update(userId: string, id: string, input: ServerInput): Promise<ServerEntry | null>;
45
+ remove(userId: string, id: string): Promise<boolean>;
46
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The servers a person runs.
3
+ *
4
+ * A nixamp is a machine somewhere with an address and a key, and until now the
5
+ * only record of one was the share link in whatever terminal printed it. Lose
6
+ * the terminal and you have lost the server: the daemon is still playing, and
7
+ * nothing anywhere can tell you where. This is the list, kept against the
8
+ * account rather than the machine, so it reads the same from the CLI, the PWA
9
+ * and the desktop app.
10
+ *
11
+ * The share key is optional and, when given, is the listen-or-control secret
12
+ * for somebody else's machine sitting in our database. It is stored because a
13
+ * list you cannot click is half a feature, and it is optional because that is
14
+ * a choice which belongs to the person whose server it is.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ const TABLE = "nixamp_servers";
18
+ const SCHEMA = `
19
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
20
+ id TEXT PRIMARY KEY,
21
+ user_id TEXT NOT NULL,
22
+ name TEXT NOT NULL DEFAULT '',
23
+ url TEXT NOT NULL,
24
+ share_key TEXT NOT NULL DEFAULT '',
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
27
+ last_seen_at TIMESTAMPTZ
28
+ );
29
+ CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
30
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_user_url ON ${TABLE} (user_id, url);
31
+ `;
32
+ function asTime(value) {
33
+ if (value instanceof Date)
34
+ return value.getTime();
35
+ if (typeof value === "string") {
36
+ const at = Date.parse(value);
37
+ return Number.isNaN(at) ? null : at;
38
+ }
39
+ return typeof value === "number" ? value : null;
40
+ }
41
+ function toEntry(row) {
42
+ return {
43
+ id: String(row["id"] ?? ""),
44
+ name: String(row["name"] ?? ""),
45
+ url: String(row["url"] ?? ""),
46
+ key: String(row["share_key"] ?? ""),
47
+ createdAt: asTime(row["created_at"]) ?? 0,
48
+ updatedAt: asTime(row["updated_at"]) ?? 0,
49
+ lastSeenAt: asTime(row["last_seen_at"]),
50
+ };
51
+ }
52
+ /**
53
+ * An address a browser could open, with nothing else smuggled in.
54
+ *
55
+ * A stored URL is handed back to other clients and put in an href, so a
56
+ * javascript: or data: entry here is a link somebody else clicks. Only http
57
+ * and https, and nothing after the origin: a nixamp is a host and a port.
58
+ */
59
+ export function cleanUrl(value) {
60
+ if (typeof value !== "string" || value.trim() === "")
61
+ return "";
62
+ let parsed;
63
+ try {
64
+ parsed = new URL(value.trim());
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
70
+ return "";
71
+ return parsed.origin;
72
+ }
73
+ /** A name is a label, not an essay, and never empty on screen. */
74
+ export function cleanName(value, url) {
75
+ const given = typeof value === "string" ? value.trim().slice(0, 60) : "";
76
+ if (given)
77
+ return given;
78
+ // The host is a better default than "untitled", and it is what somebody
79
+ // would have typed anyway.
80
+ try {
81
+ return new URL(url).host;
82
+ }
83
+ catch {
84
+ return "a nixamp";
85
+ }
86
+ }
87
+ export class Servers {
88
+ db;
89
+ ready = null;
90
+ constructor(db) {
91
+ this.db = db;
92
+ }
93
+ async ensure() {
94
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
95
+ await this.ready;
96
+ }
97
+ async list(userId) {
98
+ await this.ensure();
99
+ const { rows } = await this.db.query(`SELECT id, name, url, share_key, created_at, updated_at, last_seen_at FROM ${TABLE}
100
+ WHERE user_id = $1 ORDER BY created_at`, [userId]);
101
+ return rows.map(toEntry);
102
+ }
103
+ /**
104
+ * Remember a server, or update the one already at that address.
105
+ *
106
+ * Upserted on the address rather than refused, because the common way to
107
+ * call this twice is a daemon announcing itself after a restart, and that
108
+ * should move the entry forward rather than fail.
109
+ */
110
+ async add(account, input) {
111
+ const url = cleanUrl(input.url);
112
+ if (!url)
113
+ return null;
114
+ await this.ensure();
115
+ const { rows } = await this.db.query(`INSERT INTO ${TABLE} (id, user_id, name, url, share_key, last_seen_at)
116
+ VALUES ($1, $2, $3, $4, $5, NOW())
117
+ ON CONFLICT (user_id, url) DO UPDATE
118
+ SET name = EXCLUDED.name, share_key = EXCLUDED.share_key,
119
+ updated_at = NOW(), last_seen_at = NOW()
120
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`, [
121
+ randomBytes(8).toString("hex"),
122
+ account.id,
123
+ cleanName(input.name, url),
124
+ url,
125
+ typeof input.key === "string" ? input.key.slice(0, 200) : "",
126
+ ]);
127
+ return rows[0] ? toEntry(rows[0]) : null;
128
+ }
129
+ /** Scoped to the owner, so somebody else's id changes nothing. */
130
+ async update(userId, id, input) {
131
+ await this.ensure();
132
+ const url = input.url === undefined ? undefined : cleanUrl(input.url);
133
+ if (input.url !== undefined && !url)
134
+ return null;
135
+ const { rows } = await this.db.query(`UPDATE ${TABLE}
136
+ SET name = COALESCE($3, name),
137
+ url = COALESCE($4, url),
138
+ share_key = COALESCE($5, share_key),
139
+ updated_at = NOW()
140
+ WHERE user_id = $1 AND id = $2
141
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`, [
142
+ userId,
143
+ id,
144
+ input.name === undefined ? null : cleanName(input.name, url ?? ""),
145
+ url ?? null,
146
+ input.key === undefined ? null : input.key.slice(0, 200),
147
+ ]);
148
+ return rows[0] ? toEntry(rows[0]) : null;
149
+ }
150
+ async remove(userId, id) {
151
+ await this.ensure();
152
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`, [userId, id]);
153
+ return rows.length > 0;
154
+ }
155
+ }
package/dist/session.d.ts CHANGED
@@ -98,3 +98,12 @@ export declare function tokens(argv: string[], fetcher?: typeof fetch): Promise<
98
98
  export declare function logout(): number;
99
99
  /** `nixamp whoami`, which asks the server rather than trusting the file. */
100
100
  export declare function whoami(fetcher?: typeof fetch): Promise<number>;
101
+ /**
102
+ * `nixamp server list|add|remove`, the account's own list of machines.
103
+ *
104
+ * A share link printed in a terminal you have since closed is a server you
105
+ * have lost: the daemon is still playing and nothing can tell you where. This
106
+ * keeps the address against the account, so the answer is the same here, in
107
+ * the browser and in the desktop app.
108
+ */
109
+ export declare function servers(argv: string[], fetcher?: typeof fetch): Promise<number>;
package/dist/session.js CHANGED
@@ -481,3 +481,100 @@ export async function whoami(fetcher = fetch) {
481
481
  return 0;
482
482
  }
483
483
  }
484
+ /**
485
+ * `nixamp server list|add|remove`, the account's own list of machines.
486
+ *
487
+ * A share link printed in a terminal you have since closed is a server you
488
+ * have lost: the daemon is still playing and nothing can tell you where. This
489
+ * keeps the address against the account, so the answer is the same here, in
490
+ * the browser and in the desktop app.
491
+ */
492
+ export async function servers(argv, fetcher = fetch) {
493
+ const session = readSession();
494
+ if (session === null) {
495
+ console.error("nixamp: not signed in. Try `nixamp login`.");
496
+ return 1;
497
+ }
498
+ const [command = "list", ...rest] = argv;
499
+ const where = `${session.site}/api/v1/servers`;
500
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
501
+ const at = (flag) => {
502
+ const index = rest.indexOf(flag);
503
+ return index === -1 ? undefined : rest[index + 1];
504
+ };
505
+ try {
506
+ if (command === "add" || command === "register") {
507
+ // `--here` is the common case: the daemon on this machine, with the
508
+ // address and key it already printed, rather than retyped by hand.
509
+ let url = rest.find((a) => /^https?:\/\//.test(a)) ?? at("--url") ?? "";
510
+ let key = at("--key") ?? "";
511
+ if (rest.includes("--here")) {
512
+ const daemon = await import("./daemon.js");
513
+ const state = daemon.readState();
514
+ if (state === null) {
515
+ console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
516
+ return 1;
517
+ }
518
+ // The address worth remembering is the one somebody else can open.
519
+ const reachable = state.urls?.find((u) => u.label === "on the internet") ?? state.urls?.[0];
520
+ url = reachable?.url ?? "";
521
+ key = key || (state.key ?? "");
522
+ }
523
+ if (!url) {
524
+ console.error("nixamp: which server? Give a URL, or --here for the daemon on this machine.");
525
+ return 64;
526
+ }
527
+ const answer = await fetcher(where, {
528
+ method: "POST",
529
+ headers,
530
+ body: JSON.stringify({ url, name: at("--name") ?? "", ...(key ? { key } : {}) }),
531
+ });
532
+ const body = (await answer.json().catch(() => ({})));
533
+ if (!answer.ok || !body.server) {
534
+ console.error(`nixamp: ${body.error ?? `could not add it (${answer.status})`}`);
535
+ return 1;
536
+ }
537
+ console.log(`${body.server.id} ${body.server.name} ${body.server.url}`);
538
+ return 0;
539
+ }
540
+ if (command === "remove" || command === "rm" || command === "forget") {
541
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
542
+ if (!id) {
543
+ console.error("nixamp: which one? `nixamp server list` shows their ids.");
544
+ return 64;
545
+ }
546
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
547
+ if (!answer.ok) {
548
+ console.error(`nixamp: ${answer.status === 404 ? "no server with that id" : "could not remove it"}`);
549
+ return 1;
550
+ }
551
+ console.log(`Forgot ${id}.`);
552
+ return 0;
553
+ }
554
+ if (command === "list" || command === "ls") {
555
+ const answer = await fetcher(where, { headers });
556
+ const body = (await answer.json().catch(() => ({})));
557
+ if (!answer.ok) {
558
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
559
+ return 1;
560
+ }
561
+ const rows = body.servers ?? [];
562
+ if (rows.length === 0) {
563
+ console.log("No servers yet. `nixamp server add --here` remembers the one on this machine.");
564
+ return 0;
565
+ }
566
+ const width = Math.max(...rows.map((row) => row.name.length));
567
+ for (const row of rows) {
568
+ const link = row.key ? `${row.url}/s/${row.key}` : row.url;
569
+ console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
570
+ }
571
+ return 0;
572
+ }
573
+ console.error(`nixamp: no such server command: ${command}`);
574
+ return 64;
575
+ }
576
+ catch (error) {
577
+ console.error(`nixamp: could not reach ${session.site}: ${error.message}`);
578
+ return 69;
579
+ }
580
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.12",
3
+ "version": "0.6.1",
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",
package/src/admin.ts CHANGED
@@ -79,6 +79,31 @@ export function since(ms: number): string {
79
79
  return `${Math.floor(hours / 24)}d ${hours % 24}h`;
80
80
  }
81
81
 
82
+ /**
83
+ * A key name rather than something somebody typed: "up", "f7", "ctrl+c".
84
+ * Letters and digits only, so anything with a colon or a slash in it is text.
85
+ */
86
+ const NAMED_KEY = /^(?:[a-z]+\d*|(?:ctrl|alt|shift|meta)\+.+)$/;
87
+
88
+ /**
89
+ * What a keypress contributes to a field being typed into.
90
+ *
91
+ * A paste is one event carrying the whole string, not a burst of single
92
+ * characters, so a handler that only accepted `key.length === 1` accepted
93
+ * nothing at all from a paste -- which is how you find you cannot put a URL in
94
+ * the box by any means except typing it out.
95
+ *
96
+ * The ambiguity is real and unavoidable: a pasted word of bare letters is
97
+ * indistinguishable from a key name, and loses. A URL or a path never is,
98
+ * because neither is spelled with letters alone.
99
+ */
100
+ export function typed(key: string): string {
101
+ if (key.length === 1) return key >= " " && key !== "\u007f" ? key : "";
102
+ if (NAMED_KEY.test(key)) return "";
103
+ // A paste. Control characters and newlines are not part of an address.
104
+ return key.replace(/[\u0000-\u001f\u007f]/g, "");
105
+ }
106
+
82
107
  export function bytes(value: number): string {
83
108
  const units = ["B", "KiB", "MiB", "GiB"];
84
109
  let n = value;
@@ -142,8 +167,7 @@ export async function admin(argv: string[]): Promise<void> {
142
167
  restreaming = "";
143
168
  if (url) void restream(target, headers, url).then(() => refresh());
144
169
  } else if (key === "backspace") restreaming = restreaming.slice(0, -1);
145
- // A printable key is a character; everything else is a name like "f1".
146
- else if (key.length === 1) restreaming += key;
170
+ else restreaming += typed(key);
147
171
  app.invalidate();
148
172
  return;
149
173
  }
package/src/main.ts CHANGED
@@ -75,6 +75,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
75
75
  nixamp login [--with github] sign in to nixamp.com, in a browser or here
76
76
  nixamp logout / whoami forget it, or check it
77
77
  nixamp token create|list|revoke tokens for a machine that cannot sign in
78
+ nixamp server list|add|remove the machines you run, kept against your account
78
79
  nixamp update [version] re-run the installer, keeping your choices
79
80
  nixamp uninstall [--yes] remove everything the installer created
80
81
 
@@ -196,6 +197,18 @@ keeps serving its browser remote. One per user.
196
197
  nixamp admin who is connected, and re-stream to them
197
198
 
198
199
  From inside the player, d hands the music to a daemon without stopping it.
200
+ `,
201
+ server: `nixamp server -- the machines you run.
202
+
203
+ nixamp server list every server on your account
204
+ nixamp server add --here remember the daemon on this machine
205
+ nixamp server add URL --name x remember one somewhere else
206
+ nixamp server remove ID forget it
207
+
208
+ A share link printed in a terminal you have since closed is a server you have
209
+ lost. This keeps the address against your account, so the answer is the same
210
+ here, in the browser and in the desktop app. The share key is kept with it only
211
+ if you pass one, since it is the secret that opens the machine.
199
212
  `,
200
213
  attach: `nixamp attach — the player, in front of the running daemon.
201
214
 
@@ -313,6 +326,11 @@ export async function main(): Promise<void> {
313
326
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
314
327
  return;
315
328
  }
329
+ if (first === "server" || first === "servers") {
330
+ const { servers } = await import("./session.ts");
331
+ process.exitCode = await servers(rest);
332
+ return;
333
+ }
316
334
  if (first === "token" || first === "tokens") {
317
335
  const { tokens } = await import("./session.ts");
318
336
  process.exitCode = await tokens(rest);