nixamp 0.7.41 → 0.9.3
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/catalogs.d.ts +100 -0
- package/dist/catalogs.js +0 -0
- package/dist/certs.d.ts +112 -0
- package/dist/certs.js +217 -0
- package/dist/channels.d.ts +17 -0
- package/dist/channels.js +45 -0
- package/dist/directory.d.ts +10 -0
- package/dist/directory.js +6 -0
- package/dist/dns.d.ts +63 -0
- package/dist/dns.js +171 -0
- package/dist/library.d.ts +36 -0
- package/dist/library.js +167 -0
- package/dist/main.js +92 -4
- package/dist/names.d.ts +66 -0
- package/dist/names.js +184 -0
- package/dist/naming.d.ts +57 -0
- package/dist/naming.js +150 -0
- package/dist/owner.js +4 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +3 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.js +418 -5
- package/dist/session.d.ts +8 -0
- package/dist/session.js +103 -0
- package/package.json +8 -2
- package/src/catalogs.ts +0 -0
- package/src/certs.ts +294 -0
- package/src/channels.ts +41 -0
- package/src/directory.ts +16 -0
- package/src/dns.ts +207 -0
- package/src/library.ts +175 -0
- package/src/main.ts +88 -4
- package/src/names.ts +239 -0
- package/src/naming.ts +197 -0
- package/src/owner.ts +3 -0
- package/src/publish.ts +5 -0
- package/src/server.ts +446 -5
- package/src/session.ts +117 -0
- package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-C88rYdXy.js} +1 -1
- package/web/dist/assets/index-BB3VT0Ks.js +1 -0
- package/web/dist/assets/index-DrXbwjOa.css +1 -0
- package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-BMDK3Ac9.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-C06vXzyy.js} +1 -1
- package/web/dist/index.html +23 -2
- package/web/dist/install.sh +3 -1
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-ComwKkzf.js +0 -1
- package/web/dist/assets/index-D3xGDAOd.css +0 -1
package/src/dns.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The zone: where a server's name is written down.
|
|
3
|
+
*
|
|
4
|
+
* nixamp.com hands every signed-in account's servers a name under
|
|
5
|
+
* `<label>.<handle>.nixamp.com`, and a name is only a name once it resolves.
|
|
6
|
+
* The records live at Porkbun, behind an API key that stays on nixamp.com:
|
|
7
|
+
* a server asks nixamp.com for its name, and nixamp.com is the only thing
|
|
8
|
+
* that ever talks to the registrar. The certificate module needs the same
|
|
9
|
+
* zone for its `_acme-challenge` TXT records, which is why TXT is here too.
|
|
10
|
+
*
|
|
11
|
+
* Behind an interface, because a test wants a zone it can read back without
|
|
12
|
+
* a network, and a nixamp started without keys still wants the rest of the
|
|
13
|
+
* code to run.
|
|
14
|
+
*/
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
|
|
17
|
+
export type RecordType = "A" | "AAAA" | "TXT";
|
|
18
|
+
|
|
19
|
+
export interface DnsRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
/** The full hostname, e.g. "server2.chovy.nixamp.com". */
|
|
22
|
+
host: string;
|
|
23
|
+
type: RecordType;
|
|
24
|
+
content: string;
|
|
25
|
+
ttl: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DnsZone {
|
|
29
|
+
/** The apex this zone answers for: "nixamp.com". */
|
|
30
|
+
readonly zone: string;
|
|
31
|
+
list(host: string, type: RecordType): Promise<DnsRecord[]>;
|
|
32
|
+
/** Afterwards exactly one record of that host and type exists, with this content. */
|
|
33
|
+
set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
|
|
34
|
+
/** One more. An ACME order for a wildcard and its apex wants two TXT at once. */
|
|
35
|
+
add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
|
|
36
|
+
/** All of host and type, or only those whose content matches. */
|
|
37
|
+
remove(host: string, type: RecordType, content?: string): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 2026, not 1998: an address is either family, and both are first class. */
|
|
41
|
+
export function isIPv4(value: unknown): boolean {
|
|
42
|
+
return typeof value === "string" && isIP(value) === 4;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isIPv6(value: unknown): boolean {
|
|
46
|
+
return typeof value === "string" && isIP(value) === 6;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Porkbun's floor. Anything lower is silently raised, so it is raised here, loudly. */
|
|
50
|
+
export const MIN_TTL = 600;
|
|
51
|
+
|
|
52
|
+
function ttlOf(ttl: number | undefined): number {
|
|
53
|
+
return Math.max(MIN_TTL, Math.floor(ttl ?? MIN_TTL));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A zone kept in memory. For tests, and for a nixamp started without registrar
|
|
58
|
+
* keys, where a name can be handed out and simply will not resolve -- which the
|
|
59
|
+
* operator is told about elsewhere, rather than crashing here.
|
|
60
|
+
*/
|
|
61
|
+
export class MemoryZone implements DnsZone {
|
|
62
|
+
readonly records: DnsRecord[] = [];
|
|
63
|
+
private sequence = 0;
|
|
64
|
+
|
|
65
|
+
constructor(readonly zone: string) {}
|
|
66
|
+
|
|
67
|
+
async list(host: string, type: RecordType): Promise<DnsRecord[]> {
|
|
68
|
+
return this.records.filter((record) => record.host === host && record.type === type);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async set(host: string, type: RecordType, content: string, ttl?: number): Promise<void> {
|
|
72
|
+
await this.remove(host, type);
|
|
73
|
+
await this.add(host, type, content, ttl);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async add(host: string, type: RecordType, content: string, ttl?: number): Promise<void> {
|
|
77
|
+
this.records.push({ id: String(++this.sequence), host, type, content, ttl: ttlOf(ttl) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async remove(host: string, type: RecordType, content?: string): Promise<void> {
|
|
81
|
+
for (let i = this.records.length - 1; i >= 0; i--) {
|
|
82
|
+
const record = this.records[i] as DnsRecord;
|
|
83
|
+
if (record.host !== host || record.type !== type) continue;
|
|
84
|
+
if (content !== undefined && record.content !== content) continue;
|
|
85
|
+
this.records.splice(i, 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How long to wait on the registrar before deciding it is not answering. */
|
|
91
|
+
const PORKBUN_TIMEOUT_MS = 20_000;
|
|
92
|
+
|
|
93
|
+
interface PorkbunRecord {
|
|
94
|
+
id?: string | number;
|
|
95
|
+
name?: string;
|
|
96
|
+
type?: string;
|
|
97
|
+
content?: string;
|
|
98
|
+
ttl?: string | number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Porkbun's v3 DNS API.
|
|
103
|
+
*
|
|
104
|
+
* Every call is a POST carrying both halves of the key in the body -- the
|
|
105
|
+
* field is `secretapikey`, which the API itself names as the usual mistake --
|
|
106
|
+
* and the subdomain is the host with the zone taken off: "server2.chovy" for
|
|
107
|
+
* "server2.chovy.nixamp.com", and nothing at all for the apex.
|
|
108
|
+
*/
|
|
109
|
+
export class Porkbun implements DnsZone {
|
|
110
|
+
constructor(
|
|
111
|
+
readonly zone: string,
|
|
112
|
+
private readonly apiKey: string,
|
|
113
|
+
private readonly secretApiKey: string,
|
|
114
|
+
private readonly send: typeof fetch = fetch,
|
|
115
|
+
private readonly base = "https://api.porkbun.com/api/json/v3",
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
/** The part before the zone, or "" for the zone itself. */
|
|
119
|
+
private sub(host: string): string {
|
|
120
|
+
const suffix = `.${this.zone}`;
|
|
121
|
+
if (host === this.zone) return "";
|
|
122
|
+
if (!host.endsWith(suffix)) throw new Error(`${host} is not in ${this.zone}`);
|
|
123
|
+
return host.slice(0, -suffix.length);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async call<T>(path: string, fields: Record<string, unknown> = {}): Promise<T> {
|
|
127
|
+
let answer: Response;
|
|
128
|
+
try {
|
|
129
|
+
answer = await this.send(`${this.base}${path}`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ apikey: this.apiKey, secretapikey: this.secretApiKey, ...fields }),
|
|
133
|
+
signal: AbortSignal.timeout(PORKBUN_TIMEOUT_MS),
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`Porkbun did not answer: ${(error as Error).message}`);
|
|
137
|
+
}
|
|
138
|
+
let body: { status?: string; message?: string } & T;
|
|
139
|
+
try {
|
|
140
|
+
body = (await answer.json()) as typeof body;
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error(`Porkbun answered ${answer.status} with something that is not JSON`);
|
|
143
|
+
}
|
|
144
|
+
if (!answer.ok || body.status !== "SUCCESS") {
|
|
145
|
+
throw new Error(`Porkbun refused: ${body.message ?? `HTTP ${answer.status}`}`);
|
|
146
|
+
}
|
|
147
|
+
return body;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async list(host: string, type: RecordType): Promise<DnsRecord[]> {
|
|
151
|
+
// A trailing slash with nothing after it is how the apex is asked for.
|
|
152
|
+
const body = await this.call<{ records?: PorkbunRecord[] }>(
|
|
153
|
+
`/dns/retrieveByNameType/${this.zone}/${type}/${this.sub(host)}`,
|
|
154
|
+
);
|
|
155
|
+
return (body.records ?? [])
|
|
156
|
+
.filter((record) => (record.type ?? type) === type)
|
|
157
|
+
.map((record) => ({
|
|
158
|
+
id: String(record.id ?? ""),
|
|
159
|
+
host: record.name ?? host,
|
|
160
|
+
type,
|
|
161
|
+
content: record.content ?? "",
|
|
162
|
+
ttl: Number(record.ttl ?? MIN_TTL) || MIN_TTL,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async add(host: string, type: RecordType, content: string, ttl?: number): Promise<void> {
|
|
167
|
+
await this.call(`/dns/create/${this.zone}`, {
|
|
168
|
+
name: this.sub(host),
|
|
169
|
+
type,
|
|
170
|
+
content,
|
|
171
|
+
ttl: String(ttlOf(ttl)),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async set(host: string, type: RecordType, content: string, ttl?: number): Promise<void> {
|
|
176
|
+
const existing = await this.list(host, type);
|
|
177
|
+
const [first, ...extras] = existing;
|
|
178
|
+
if (first === undefined) {
|
|
179
|
+
await this.add(host, type, content, ttl);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Edit rather than delete-and-create: the name never has a moment with no
|
|
183
|
+
// record at all, which for an A record is a moment nobody can connect.
|
|
184
|
+
// Unless nothing would change: Porkbun refuses an edit that edits nothing
|
|
185
|
+
// ("We were unable to edit the DNS record"), and a server announcing the
|
|
186
|
+
// address it already has is the ordinary case, not an error.
|
|
187
|
+
if (first.content !== content || first.ttl !== ttlOf(ttl)) {
|
|
188
|
+
await this.call(`/dns/edit/${this.zone}/${first.id}`, {
|
|
189
|
+
name: this.sub(host),
|
|
190
|
+
type,
|
|
191
|
+
content,
|
|
192
|
+
ttl: String(ttlOf(ttl)),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
for (const extra of extras) {
|
|
196
|
+
await this.call(`/dns/delete/${this.zone}/${extra.id}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async remove(host: string, type: RecordType, content?: string): Promise<void> {
|
|
201
|
+
const existing = await this.list(host, type);
|
|
202
|
+
for (const record of existing) {
|
|
203
|
+
if (content !== undefined && record.content !== content) continue;
|
|
204
|
+
await this.call(`/dns/delete/${this.zone}/${record.id}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/library.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the media is.
|
|
3
|
+
*
|
|
4
|
+
* A server that is not told serves the directory it was started in, and a
|
|
5
|
+
* daemon restarted from a home directory served the home directory: every
|
|
6
|
+
* key, session and download on the machine, listed in a public directory
|
|
7
|
+
* under a link anybody could be handed. That is not a default anyone chose,
|
|
8
|
+
* so it is not a default any more.
|
|
9
|
+
*
|
|
10
|
+
* The library is a setting. It is asked for once -- at sign-in, or the first
|
|
11
|
+
* time the daemon is started without being told -- and kept beside the keys,
|
|
12
|
+
* so `nixamp daemon start` on its own means the same folder every time. And
|
|
13
|
+
* some folders are refused however they are asked for: the whole filesystem,
|
|
14
|
+
* the home directory, anything above it, and the hidden folders where keys
|
|
15
|
+
* and sessions live.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
21
|
+
import { stateDir } from "./daemon.ts";
|
|
22
|
+
|
|
23
|
+
export interface Config {
|
|
24
|
+
/** The folder the daemon serves when it is not told which. */
|
|
25
|
+
library?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function configPath(): string {
|
|
29
|
+
return join(stateDir(), "config.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readConfig(): Config {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
|
|
35
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
36
|
+
const record = parsed as Record<string, unknown>;
|
|
37
|
+
return { ...(typeof record["library"] === "string" ? { library: record["library"] } : {}) };
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeConfig(patch: Partial<Config>): void {
|
|
44
|
+
const next = { ...readConfig(), ...patch };
|
|
45
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
46
|
+
writeFileSync(configPath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The saved library, or "" when nobody has said yet. */
|
|
50
|
+
export function readLibrary(): string {
|
|
51
|
+
return readConfig().library ?? "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeLibrary(path: string): void {
|
|
55
|
+
writeConfig({ library: resolve(path) });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why a folder must not be served, or "" when it may be.
|
|
60
|
+
*
|
|
61
|
+
* Judged on the resolved path, so `~/..` and `/home/me/./` do not slip past.
|
|
62
|
+
* A hidden folder inside home is refused because that is where `.ssh`,
|
|
63
|
+
* `.config` and nixamp's own keys are; a hidden folder elsewhere is somebody's
|
|
64
|
+
* deliberate choice.
|
|
65
|
+
*/
|
|
66
|
+
export function forbiddenLibrary(path: string, home = homedir()): string {
|
|
67
|
+
const full = resolve(path);
|
|
68
|
+
const house = resolve(home);
|
|
69
|
+
if (full === "/" || /^[A-Za-z]:\\?$/.test(full)) return "the whole filesystem";
|
|
70
|
+
if (full === house) return "your whole home directory";
|
|
71
|
+
if (house.startsWith(`${full}/`)) return "a folder above your home directory";
|
|
72
|
+
if (full.startsWith(`${house}/`)) {
|
|
73
|
+
// Any dotted segment on the way, not only the last: ~/.local/state is
|
|
74
|
+
// where nixamp's own keys are, and its basename says nothing.
|
|
75
|
+
const hidden = full.slice(house.length + 1).split("/").find((segment) => segment.startsWith("."));
|
|
76
|
+
if (hidden) return `a hidden folder (${hidden}), which is where keys and sessions live`;
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Folders a person is likely to mean, in the order they are likely to mean them. */
|
|
82
|
+
export function suggestedLibraries(home = homedir()): string[] {
|
|
83
|
+
return ["Music", "Videos", "Movies", "Downloads"]
|
|
84
|
+
.map((name) => join(home, name))
|
|
85
|
+
.filter((path) => {
|
|
86
|
+
try {
|
|
87
|
+
return statSync(path).isDirectory();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One line from the terminal. Split out so the question can be tested. */
|
|
95
|
+
export async function askLine(question: string): Promise<string> {
|
|
96
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
97
|
+
try {
|
|
98
|
+
return (await rl.question(question)).trim();
|
|
99
|
+
} finally {
|
|
100
|
+
rl.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Ask where the media is, refusing the answers that must be refused, and
|
|
106
|
+
* keep the answer. Empty when the person gave up or gave nothing usable
|
|
107
|
+
* three times.
|
|
108
|
+
*/
|
|
109
|
+
export async function askLibrary(
|
|
110
|
+
ask: (question: string) => Promise<string> = askLine,
|
|
111
|
+
home = homedir(),
|
|
112
|
+
say: (line: string) => void = (line) => console.log(line),
|
|
113
|
+
): Promise<string> {
|
|
114
|
+
const suggestions = suggestedLibraries(home);
|
|
115
|
+
say("Where is your media? nixamp serves one folder and nothing outside it.");
|
|
116
|
+
for (const path of suggestions) say(` ${path}`);
|
|
117
|
+
const fallback = suggestions[0] ?? "";
|
|
118
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
119
|
+
const typed = await ask(fallback ? `Folder [${fallback}]: ` : "Folder: ");
|
|
120
|
+
const chosen = (typed || fallback).replace(/^~(?=$|\/)/, home);
|
|
121
|
+
if (!chosen) continue;
|
|
122
|
+
const full = resolve(chosen);
|
|
123
|
+
const why = forbiddenLibrary(full, home);
|
|
124
|
+
if (why) {
|
|
125
|
+
say(` nixamp will not serve ${why}. Pick a folder with your media in it.`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!existsSync(full) || !statSync(full).isDirectory()) {
|
|
129
|
+
say(` ${full} is not a folder here.`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
writeLibrary(full);
|
|
133
|
+
say(` Serving ${full}. Change it any time with \`nixamp library <folder>\`.`);
|
|
134
|
+
return full;
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The library to use: the saved one, or -- when there is somebody to ask --
|
|
141
|
+
* the one they name now. "" when there is neither.
|
|
142
|
+
*/
|
|
143
|
+
export async function chooseLibrary(interactive: boolean, ask?: (question: string) => Promise<string>): Promise<string> {
|
|
144
|
+
const saved = readLibrary();
|
|
145
|
+
if (saved) return saved;
|
|
146
|
+
if (!interactive) return "";
|
|
147
|
+
return askLibrary(ask);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `nixamp library [folder]`: say where the media is, or set it. */
|
|
151
|
+
export async function libraryCommand(argv: string[]): Promise<number> {
|
|
152
|
+
const [given] = argv;
|
|
153
|
+
if (!given) {
|
|
154
|
+
const saved = readLibrary();
|
|
155
|
+
if (!saved) {
|
|
156
|
+
console.log("nixamp: no library set. `nixamp library ~/Music` sets it, and `nixamp daemon start` serves it.");
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
console.log(saved);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
const full = resolve(given.replace(/^~(?=$|\/)/, homedir()));
|
|
163
|
+
const why = forbiddenLibrary(full);
|
|
164
|
+
if (why) {
|
|
165
|
+
console.error(`nixamp: will not serve ${why}. Pick a folder with your media in it.`);
|
|
166
|
+
return 64;
|
|
167
|
+
}
|
|
168
|
+
if (!existsSync(full) || !statSync(full).isDirectory()) {
|
|
169
|
+
console.error(`nixamp: ${full} is not a folder here.`);
|
|
170
|
+
return 66;
|
|
171
|
+
}
|
|
172
|
+
writeLibrary(full);
|
|
173
|
+
console.log(`nixamp will serve ${full}. Start it with \`nixamp daemon start\`.`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -77,6 +77,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
77
77
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
78
78
|
nixamp logout / whoami forget it, or check it
|
|
79
79
|
nixamp token create|list|revoke tokens for a machine that cannot sign in
|
|
80
|
+
nixamp dns [set|rm] names under your handle, for your servers
|
|
81
|
+
nixamp library [folder] where the media is; the daemon serves this and nothing outside it
|
|
80
82
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
81
83
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
82
84
|
nixamp update [version] re-run the installer, keeping your choices
|
|
@@ -190,6 +192,18 @@ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all.
|
|
|
190
192
|
A token is shown once because the server keeps only its hash. Put it in the
|
|
191
193
|
environment as NIXAMP_TOKEN, or keep it here with \`nixamp login --token\`.
|
|
192
194
|
Signing out does not touch it: that is what it is for.
|
|
195
|
+
`,
|
|
196
|
+
dns: `nixamp dns — names under your handle, for your servers.
|
|
197
|
+
|
|
198
|
+
nixamp dns every name on your account, and where it points
|
|
199
|
+
nixamp dns set NAME NAME.<handle>.nixamp.com, pointed at this machine
|
|
200
|
+
nixamp dns set NAME --a IP --aaaa IP pointed somewhere you name; "off" clears one
|
|
201
|
+
nixamp dns set NAME --ttl N how long resolvers may keep it (seconds)
|
|
202
|
+
nixamp dns rm NAME take the name away
|
|
203
|
+
|
|
204
|
+
The DNS keys stay on nixamp.com. A signed-in \`nixamp serve\` names itself
|
|
205
|
+
this way on start and picks up the handle's certificate, so a server is
|
|
206
|
+
https://NAME.<handle>.nixamp.com with nothing typed here.
|
|
193
207
|
`,
|
|
194
208
|
daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
|
|
195
209
|
|
|
@@ -250,9 +264,44 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
250
264
|
const [action = "status", ...rest] = argv;
|
|
251
265
|
const entry = fileURLToPath(new URL("./main.js", import.meta.url));
|
|
252
266
|
|
|
267
|
+
// The daemon has to know where the media is. Told a folder, that folder is
|
|
268
|
+
// kept as the library so the next start needs no telling; told nothing, the
|
|
269
|
+
// saved library is used, or asked for when there is somebody to ask.
|
|
270
|
+
const withLibrary = async (args: string[]): Promise<string[] | null> => {
|
|
271
|
+
const { forbiddenLibrary, chooseLibrary, writeLibrary } = await import("./library.ts");
|
|
272
|
+
const { parseServeArgs } = await import("./server.ts");
|
|
273
|
+
const { isRemote } = await import("./sources.ts");
|
|
274
|
+
let given = "";
|
|
275
|
+
try {
|
|
276
|
+
given = parseServeArgs(args).root;
|
|
277
|
+
} catch {
|
|
278
|
+
// A bad flag is the daemon's to report, in its own words.
|
|
279
|
+
return args;
|
|
280
|
+
}
|
|
281
|
+
if (given) {
|
|
282
|
+
if (!isRemote(given)) {
|
|
283
|
+
const why = forbiddenLibrary(given);
|
|
284
|
+
if (why) {
|
|
285
|
+
console.error(`nixamp: will not serve ${why}. Pick a folder with your media in it.`);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
writeLibrary(given);
|
|
289
|
+
}
|
|
290
|
+
return args;
|
|
291
|
+
}
|
|
292
|
+
const library = await chooseLibrary(process.stdin.isTTY === true);
|
|
293
|
+
if (!library) {
|
|
294
|
+
console.error("nixamp: which folder? `nixamp library ~/Music` once, then `nixamp daemon start`.");
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
return [library, ...args];
|
|
298
|
+
};
|
|
299
|
+
|
|
253
300
|
if (action === "start") {
|
|
301
|
+
const args = await withLibrary(rest);
|
|
302
|
+
if (args === null) return 64;
|
|
254
303
|
try {
|
|
255
|
-
const state = await d.start(
|
|
304
|
+
const state = await d.start(args, entry);
|
|
256
305
|
for (const line of d.daemonLines(state)) console.log(line);
|
|
257
306
|
return 0;
|
|
258
307
|
} catch (error) {
|
|
@@ -268,8 +317,12 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
268
317
|
}
|
|
269
318
|
|
|
270
319
|
if (action === "restart") {
|
|
320
|
+
// With no arguments the flags it was started with are replayed, library
|
|
321
|
+
// included; with arguments, the library rule applies as for start.
|
|
322
|
+
const args = rest.length === 0 ? rest : await withLibrary(rest);
|
|
323
|
+
if (args === null) return 64;
|
|
271
324
|
try {
|
|
272
|
-
const state = await d.restart(
|
|
325
|
+
const state = await d.restart(args, entry);
|
|
273
326
|
for (const line of d.daemonLines(state)) console.log(line);
|
|
274
327
|
return 0;
|
|
275
328
|
} catch (error) {
|
|
@@ -331,7 +384,13 @@ export async function main(): Promise<void> {
|
|
|
331
384
|
|
|
332
385
|
if (first === "serve") {
|
|
333
386
|
const { serve } = await import("./server.ts");
|
|
334
|
-
|
|
387
|
+
try {
|
|
388
|
+
await serve(rest, version());
|
|
389
|
+
} catch (error) {
|
|
390
|
+
// A refusal is a sentence, not a stack trace.
|
|
391
|
+
console.error((error as Error).message);
|
|
392
|
+
process.exitCode = 64;
|
|
393
|
+
}
|
|
335
394
|
return;
|
|
336
395
|
}
|
|
337
396
|
if (first === "daemon") {
|
|
@@ -346,6 +405,13 @@ export async function main(): Promise<void> {
|
|
|
346
405
|
if (first === "login" || first === "signup") {
|
|
347
406
|
const { login } = await import("./session.ts");
|
|
348
407
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
408
|
+
// Signed in is the moment a machine is about to be a server, so it is
|
|
409
|
+
// the moment to ask where the media is -- once, kept beside the keys.
|
|
410
|
+
if (process.exitCode === 0 && process.stdin.isTTY) {
|
|
411
|
+
const { chooseLibrary } = await import("./library.ts");
|
|
412
|
+
const library = await chooseLibrary(true);
|
|
413
|
+
if (library) console.log(" Start serving it with `nixamp daemon start`.");
|
|
414
|
+
}
|
|
349
415
|
return;
|
|
350
416
|
}
|
|
351
417
|
if (first === "opendir" || first === "opendirs") {
|
|
@@ -363,6 +429,16 @@ export async function main(): Promise<void> {
|
|
|
363
429
|
process.exitCode = await tokens(rest);
|
|
364
430
|
return;
|
|
365
431
|
}
|
|
432
|
+
if (first === "dns") {
|
|
433
|
+
const { dns } = await import("./session.ts");
|
|
434
|
+
process.exitCode = await dns(rest);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (first === "library") {
|
|
438
|
+
const { libraryCommand } = await import("./library.ts");
|
|
439
|
+
process.exitCode = await libraryCommand(rest);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
366
442
|
if (first === "logout" || first === "whoami") {
|
|
367
443
|
const session = await import("./session.ts");
|
|
368
444
|
process.exitCode = first === "logout" ? session.logout() : await session.whoami();
|
|
@@ -377,8 +453,16 @@ export async function main(): Promise<void> {
|
|
|
377
453
|
|
|
378
454
|
// resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
|
|
379
455
|
// left exactly as it was typed.
|
|
380
|
-
|
|
456
|
+
// The saved library before the current directory, and never a folder the
|
|
457
|
+
// server would refuse: `d` hands this same folder to a daemon.
|
|
458
|
+
const { forbiddenLibrary, readLibrary } = await import("./library.ts");
|
|
459
|
+
const asked = first ?? (readLibrary() || ".");
|
|
381
460
|
const target = isRemote(asked) ? asked : resolve(asked);
|
|
461
|
+
const why = isRemote(target) ? "" : forbiddenLibrary(target);
|
|
462
|
+
if (why) {
|
|
463
|
+
console.error(`nixamp: will not play ${why}. Pick a folder with your media in it, or set one: nixamp library ~/Music`);
|
|
464
|
+
process.exit(64);
|
|
465
|
+
}
|
|
382
466
|
const tools = detectTools();
|
|
383
467
|
// The noise it makes when it wakes up. Started before the library is walked
|
|
384
468
|
// so it plays over the wait rather than after it, and never awaited: a
|