@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.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.
Files changed (52) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -2
  4. package/bin/snowglobe.mjs +69 -0
  5. package/lib/apps.mjs +97 -0
  6. package/lib/args.mjs +29 -0
  7. package/lib/certs.mjs +211 -0
  8. package/lib/commands/account.mjs +85 -0
  9. package/lib/commands/app.mjs +86 -0
  10. package/lib/commands/certs.mjs +36 -0
  11. package/lib/commands/check.mjs +37 -0
  12. package/lib/commands/down.mjs +37 -0
  13. package/lib/commands/env.mjs +145 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/render.mjs +63 -0
  17. package/lib/commands/run.mjs +109 -0
  18. package/lib/commands/trust.mjs +45 -0
  19. package/lib/commands/up.mjs +257 -0
  20. package/lib/config.mjs +485 -0
  21. package/lib/env.mjs +364 -0
  22. package/lib/hosts.mjs +36 -0
  23. package/lib/render.mjs +570 -0
  24. package/lib/settings.mjs +146 -0
  25. package/lib/state.mjs +75 -0
  26. package/lib/trust.mjs +288 -0
  27. package/lib/up.mjs +54 -0
  28. package/lib/xrpc.mjs +49 -0
  29. package/package.json +55 -4
  30. package/snowglobe.default.yml +54 -0
  31. package/stack/docker-compose.relay.yml +105 -0
  32. package/stack/docker-compose.test.yml +41 -0
  33. package/stack/docker-compose.yml +178 -0
  34. package/stack/https/app-names.cjs +31 -0
  35. package/stack/https/dns/Dockerfile +10 -0
  36. package/stack/https/dns/dns.sh +40 -0
  37. package/stack/https/nginx.conf.template +104 -0
  38. package/stack/https/proxy/Dockerfile +8 -0
  39. package/stack/https/proxy/server.mjs +213 -0
  40. package/stack/init/init.sh +118 -0
  41. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  42. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  43. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  44. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  45. package/stack/page/index.html +409 -0
  46. package/stack/page/snowglobe.conf +59 -0
  47. package/stack/pdsls/Dockerfile +27 -0
  48. package/stack/pdsls/network.patch +294 -0
  49. package/stack/pdsls/server.mjs +192 -0
  50. package/stack/relay/Dockerfile +30 -0
  51. package/stack/relay/allow-private-hosts.patch +55 -0
  52. package/stack/slingshot/Dockerfile +36 -0
package/lib/env.mjs ADDED
@@ -0,0 +1,364 @@
1
+ // What every snowglobe subcommand shares: where the files are, the domain and the address the
2
+ // globe is under (read as compose reads them), env files that hold logins, docker compose on the
3
+ // rendered globe, and https to a globe's names. Nothing here prints a secret.
4
+ //
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { request } from 'node:https';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { DEFAULT_DOMAIN } from './config.mjs';
14
+
15
+ // A mistake in how a command was called, as opposed to one the globe answered with.
16
+ export class UsageError extends Error {}
17
+
18
+ // A refusal with its own exit code (3: the shared devnet, guarded).
19
+ export class Refused extends Error {
20
+ constructor(message, exitCode) {
21
+ super(message);
22
+ this.exitCode = exitCode;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The compose project up and down act on: SG_PROJECT if set, else `name`, the globe's name in
28
+ * its snowglobe.yml. Refused before anything else is done: none (a usage mistake), or
29
+ * devnet-spaces, the shared devnet, without SG_RESET_OK=1 (exit 3).
30
+ */
31
+ export function guardProject(env = process.env, name) {
32
+ const project = env.SG_PROJECT || name;
33
+ if (!project) throw new UsageError('no compose project: name one with SG_PROJECT, or run in a folder with a snowglobe.yml');
34
+ if (project === 'devnet-spaces' && env.SG_RESET_OK !== '1')
35
+ throw new Refused('refusing devnet-spaces, the shared devnet; set SG_RESET_OK=1 only for a planned reset', 3);
36
+ return project;
37
+ }
38
+
39
+ // The repo root, which is also the compose project directory, where compose reads .env.
40
+ export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
41
+
42
+ /**
43
+ * The folder all snowglobe state is under, for this user and not this checkout:
44
+ * $XDG_DATA_HOME/snowglobe, else %LOCALAPPDATA%\\snowglobe on Windows, else
45
+ * ~/.local/share/snowglobe. Each snowglobe has a folder of its own in it, named for the file's
46
+ * `globe:`, and ca/ holds the one certificate authority they all use.
47
+ */
48
+ export function stateRoot(env = process.env, { platform = process.platform, home = homedir() } = {}) {
49
+ // XDG's rule: a relative XDG_DATA_HOME is ignored.
50
+ if (env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME)) return join(env.XDG_DATA_HOME, 'snowglobe');
51
+ if (platform === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'snowglobe');
52
+ return join(home, '.local', 'share', 'snowglobe');
53
+ }
54
+
55
+ // The settings file compose reads beside the compose files (the repo root's .env).
56
+ export const dotenvFile = (root = ROOT) => join(root, '.env');
57
+
58
+ /**
59
+ * The files a snowglobe keeps in `state`, its folder under stateRoot(), and the user's CA beside
60
+ * it (<stateRoot>/ca). Nothing is kept in the checkout.
61
+ */
62
+ export function paths(root = ROOT, state) {
63
+ if (!state) throw new Error('no state folder given (a snowglobe keeps its files in one: lib/state.mjs session)');
64
+ const data = state;
65
+ const https = join(data, 'https');
66
+ const globe = join(data, 'globe');
67
+ const caDir = join(dirname(state), 'ca');
68
+ return {
69
+ root,
70
+ data,
71
+ https,
72
+ // The CA, one per user, which run's processes and snowglobeRequest trust.
73
+ caDir,
74
+ ca: join(caDir, 'ca.crt'),
75
+ // The snowglobe's own logins: the base init's alice, bob and invite code, the lexicon
76
+ // authority, and accounts made by snowglobe account.
77
+ accounts: join(data, 'accounts.env'),
78
+ // The PDSes' admin password, made by up (random, once) and read by account and invite.
79
+ // A file of its own: the base init rewrites accounts.env.
80
+ admin: join(data, 'admin.env'),
81
+ // App routes, "<name> <port> [<ending>]" per line (snowglobe app).
82
+ apps: join(https, 'apps'),
83
+ // Handles run maps to nginx, one per line (snowglobe account and up).
84
+ names: join(https, 'names'),
85
+ // The rendered globe (bin/snowglobe.mjs render). Nothing in it is secret.
86
+ globe,
87
+ globeEnv: join(globe, 'globe.env'),
88
+ leafNames: join(globe, 'leaf-names'),
89
+ compose: join(globe, 'compose.yml'),
90
+ dotenv: dotenvFile(root),
91
+ };
92
+ }
93
+
94
+ // Files an older snowglobe kept in <root>/data, before state moved to the user's folder.
95
+ const OLD_FILES = ['accounts.env', 'accounts.json', 'devnet.env', 'globe', 'https/apps', 'https/names'];
96
+
97
+ /**
98
+ * The text that says a checkout still holds a snowglobe's files from before state moved to the
99
+ * user's folder (<root>/data/globe or data/devnet.env) while `state` has none, with the command
100
+ * that moves them; null when it does not. Nothing is moved by snowglobe itself.
101
+ */
102
+ export function oldDataNotice(root, state, platform = process.platform) {
103
+ const data = join(root, 'data');
104
+ if (!(existsSync(join(data, 'globe', 'compose.yml')) || existsSync(join(data, 'devnet.env')))) return null;
105
+ if (existsSync(join(state, 'globe', 'compose.yml'))) return null;
106
+ const project = envGet('SG_PROJECT', join(data, 'devnet.env'));
107
+ const found = OLD_FILES.filter((f) => existsSync(join(data, f)));
108
+ const win = platform === 'win32';
109
+ const q = (x) => `"${x}"`;
110
+ const move = win
111
+ ? [`New-Item -ItemType Directory -Force ${q(join(state, 'https'))}`, ...found.map((f) => `Move-Item ${q(join(data, f))} ${q(join(state, f))}`)]
112
+ : [`mkdir -p ${q(join(state, 'https'))}`, ...found.map((f) => `mv ${q(join(data, f))} ${q(join(state, f))}`)];
113
+ return [
114
+ `${data} holds a snowglobe from before state moved out of the checkout (into ${state}). Nothing was changed.`,
115
+ `To keep it, move its files${project ? ` and keep its compose project (its containers and volumes belong to ${project})` : ''}:`,
116
+ ...move.map((l) => ` ${l}`),
117
+ project ? ` ${win ? '$env:SG_PROJECT' : 'SG_PROJECT'}=${project} snowglobe up` : ' snowglobe up',
118
+ "Either way it trusts a new CA, once: snowglobe now makes one for you in place of the checkout's old one.",
119
+ ].join('\n');
120
+ }
121
+
122
+ /**
123
+ * The text that says the default snowglobe was named devnet before it was named snowglobe, and an
124
+ * install made under the old name is still in the state root while `state` has none, with the
125
+ * commands to keep it or drop it; null when it does not. Nothing is moved by snowglobe itself.
126
+ * A snowglobe made from a file of your own is not affected: it keeps the file's `globe:` name.
127
+ */
128
+ export function renamedDefaultNotice(root, state, platform = process.platform) {
129
+ const old = join(dirname(state), 'devnet');
130
+ if (!existsSync(join(old, 'globe', 'compose.yml')) || existsSync(join(state, 'globe', 'compose.yml'))) return null;
131
+ const win = platform === 'win32';
132
+ const q = (x) => `"${x}"`;
133
+ const keep = win
134
+ ? [`Move-Item ${q(old)} ${q(state)}`, "$env:SG_PROJECT='devnet'", 'snowglobe up --default']
135
+ : [`mv ${q(old)} ${q(state)}`, 'SG_PROJECT=devnet snowglobe up --default'];
136
+ return [
137
+ `${old} holds the default snowglobe from before it was renamed (it was called devnet, and is called snowglobe now). Nothing was changed.`,
138
+ 'To keep it, with its accounts, move its folder and keep its compose project (its containers and volumes belong to devnet; their network and images are recreated under the new names):',
139
+ ...keep.map((l) => ` ${l}`),
140
+ 'To drop it, remove its containers and volumes, then delete the folder:',
141
+ ` docker compose -p devnet --project-directory ${q(root)} -f ${q(join(old, 'globe', 'compose.yml'))} down -v`,
142
+ ` ${win ? 'Remove-Item -Recurse' : 'rm -r'} ${q(old)}`,
143
+ ].join('\n');
144
+ }
145
+
146
+ const lines = (file) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n') : []);
147
+
148
+ // Until 2026-10-20 the settings were DEVNET_* and SNOWGLOBE_*; every one is SG_* now. The old
149
+ // names still work for a while: adoptOldNames copies each one to its SG_ name, and envGet falls
150
+ // back to them in a file written before the change.
151
+ const OLD_NAME = /^(?:DEVNET|SNOWGLOBE)_([A-Za-z0-9_]+)$/;
152
+ const oldNames = (name) => (name.startsWith('SG_') ? [`DEVNET_${name.slice(3)}`, `SNOWGLOBE_${name.slice(3)}`] : []);
153
+
154
+ // The last value of NAME in an env file the scripts write (NAME=value, nothing else), or ''.
155
+ // An SG_ name the file lacks is looked up under its old names.
156
+ export function envGet(name, file) {
157
+ const ls = lines(file);
158
+ for (const n of [name, ...oldNames(name)]) {
159
+ let value;
160
+ for (const l of ls) if (l.startsWith(`${n}=`)) value = l.slice(n.length + 1);
161
+ if (value !== undefined) return value;
162
+ }
163
+ return '';
164
+ }
165
+
166
+ /**
167
+ * Each old setting (DEVNET_*, SNOWGLOBE_*) in the environment or .env whose SG_ name neither sets
168
+ * is copied into env under the SG_ name, so the commands and compose see it; one warning names
169
+ * them. Returns the old names taken.
170
+ */
171
+ export function adoptOldNames({ root = ROOT, env = process.env, warn = (m) => console.error(m) } = {}) {
172
+ const dotenv = dotenvFile(root);
173
+ const taken = new Map();
174
+ for (const [k, v] of Object.entries(env)) {
175
+ const m = k.match(OLD_NAME);
176
+ if (m && env[`SG_${m[1]}`] === undefined) taken.set(`SG_${m[1]}`, { old: k, value: v });
177
+ }
178
+ for (const l of lines(dotenv)) {
179
+ const k = l.match(/^\s*(?:export\s+)?([A-Za-z0-9_]+)\s*=/)?.[1];
180
+ const m = k?.match(OLD_NAME);
181
+ const name = m && `SG_${m[1]}`;
182
+ if (m && env[name] === undefined && !taken.has(name) && envCompose(name, dotenv) === undefined)
183
+ taken.set(name, { old: `${k} (in .env)`, value: envCompose(k, dotenv) });
184
+ }
185
+ for (const [name, { value }] of taken) env[name] = value;
186
+ if (taken.size)
187
+ warn(
188
+ `snowglobe: old setting names, still read for now: ${[...taken].map(([name, { old }]) => `${old} is now ${name}`).join('; ')}`,
189
+ );
190
+ return [...taken.values()].map((t) => t.old);
191
+ }
192
+
193
+ // NAME's last value in an env file read as compose reads it: an export prefix, spaces around
194
+ // the =, quotes, a trailing " # comment" and a CRLF line end are allowed. undefined when the
195
+ // file does not set it.
196
+ export function envCompose(name, file) {
197
+ const re = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`);
198
+ let raw;
199
+ for (const l of lines(file)) {
200
+ const m = l.replace(/\r$/, '').match(re);
201
+ if (m) raw = m[1];
202
+ }
203
+ if (raw === undefined) return undefined;
204
+ const quoted = raw.match(/^"([^"]*)"/) ?? raw.match(/^'([^']*)'/);
205
+ if (quoted) return quoted[1];
206
+ return raw.replace(/\s+#.*/, '').replace(/\s+$/, '');
207
+ }
208
+
209
+ // A setting from the environment, where set (even empty, which then wins), else from .env, as
210
+ // compose reads it; '' when neither sets it.
211
+ export function setting(name, { root = ROOT, env = process.env } = {}) {
212
+ return env[name] !== undefined ? env[name] : (envCompose(name, dotenvFile(root)) ?? '');
213
+ }
214
+
215
+ // The domain every globe name sits under: SG_DOMAIN from the environment or .env; else the
216
+ // rendered globe's, which is the file's unless one of those replaced it; empty means DEFAULT_DOMAIN.
217
+ // A setting that differs from the rendered globe's means the render is stale: refused, unless
218
+ // this run is about to render again (rendering). `set` is the setting alone, for render --domain.
219
+ export function resolveDomain({ root = ROOT, state, env = process.env, rendering = false } = {}) {
220
+ const p = paths(root, state);
221
+ const set = setting('SG_DOMAIN', { root, env });
222
+ const rendered = envGet('SG_DOMAIN', p.globeEnv);
223
+ if (set && rendered && set !== rendered && !rendering)
224
+ throw new Error(`SG_DOMAIN=${set}, but the globe in ${p.globe} was rendered for ${rendered}; run snowglobe up`);
225
+ const domain = set || rendered || DEFAULT_DOMAIN;
226
+ // A domain name and nothing else. This also refuses ${...} in .env, which compose would
227
+ // expand and these scripts do not.
228
+ if (/[^A-Za-z0-9.-]/.test(domain) || domain.startsWith('.') || domain.endsWith('.') || domain.includes('..'))
229
+ throw new Error(`SG_DOMAIN=${domain} is not a domain name (set in the environment or .env)`);
230
+ return { domain, set };
231
+ }
232
+
233
+ // The rendered globe's facts (globe.env): its domain, its PDSes by name, the one with bare
234
+ // handles (<name>.<domain>, '' if none), those that take an invite code, the seed and authority
235
+ // PDSes, and the names under the domain a bare handle would take.
236
+ export function globeFacts({ root = ROOT, state, env = process.env } = {}) {
237
+ const p = paths(root, state);
238
+ if (!existsSync(p.globeEnv)) throw new Error(`No ${p.globeEnv}: run snowglobe up, which renders it`);
239
+ const list = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
240
+ return {
241
+ domain: resolveDomain({ root, state, env }).domain,
242
+ pds: list('SG_PDS'),
243
+ bare: envGet('SG_BARE_PDS', p.globeEnv),
244
+ invites: list('SG_INVITE_PDS'),
245
+ seed: envGet('SG_SEED_PDS', p.globeEnv),
246
+ authority: envGet('SG_AUTHORITY_PDS', p.globeEnv),
247
+ taken: list('SG_TAKEN'),
248
+ };
249
+ }
250
+
251
+ // The address nginx publishes 443 on, worked out as compose works out ${SG_BIND:-127.0.0.1}.
252
+ // A loopback address only (127.x.y.z, one per globe run side by side): the globe's logins are
253
+ // throwaway, so it is never published beyond this machine.
254
+ export function resolveBind({ root = ROOT, env = process.env } = {}) {
255
+ const bind = setting('SG_BIND', { root, env }) || '127.0.0.1';
256
+ const parts = bind.split('.');
257
+ if (parts.length !== 4 || parts[0] !== '127' || parts.slice(1).some((x) => !/^[0-9]+$/.test(x) || Number(x) > 255))
258
+ throw new Error(`SG_BIND=${bind} is not a loopback address 127.x.y.z (set in the environment or .env)`);
259
+ return bind;
260
+ }
261
+
262
+ // Replace the lines of an env file matching `drop` with `add` (lines, no newlines). The file is
263
+ // mode 600, and never readable by anyone else, even briefly: it is written aside at 600, then
264
+ // renamed over the old one.
265
+ export function envPut(file, drop, add = []) {
266
+ const kept = lines(file).filter((l) => l !== '' && !drop.test(l));
267
+ const text = [...kept, ...add].map((l) => `${l}\n`).join('');
268
+ const tmp = `${file}.tmp.${process.pid}`;
269
+ try {
270
+ writeFileSync(tmp, text, { mode: 0o600, flag: 'wx' });
271
+ chmodSync(tmp, 0o600);
272
+ renameSync(tmp, file);
273
+ } catch (e) {
274
+ if (existsSync(tmp)) unlinkSync(tmp);
275
+ throw e;
276
+ }
277
+ }
278
+
279
+ // Mode 600, even if a container wrote the file: it then belongs to root, and only a rewrite
280
+ // makes it ours.
281
+ export function makePrivate(file) {
282
+ if (!existsSync(file)) return;
283
+ if (statSync(file).uid === process.getuid?.()) chmodSync(file, 0o600);
284
+ else envPut(file, /^$/);
285
+ }
286
+
287
+ // The env file for logins: made if missing, and private either way.
288
+ export function loginsFile(file) {
289
+ if (!existsSync(file)) envPut(file, /^$/);
290
+ makePrivate(file);
291
+ return file;
292
+ }
293
+
294
+ // Add a handle to the names run maps to nginx; a process already running under it picks a
295
+ // new one up when it restarts.
296
+ export function addName(host, { root = ROOT, state } = {}) {
297
+ const p = paths(root, state);
298
+ mkdirSync(p.https, { recursive: true });
299
+ if (!lines(p.names).includes(host)) appendFileSync(p.names, `${host}\n`);
300
+ }
301
+
302
+ // The PDSes' admin password: SG_PDS_ADMIN_PASSWORD from the environment or .env (a globe on the
303
+ // cluster has its own), else the one up made for this snowglobe in its admin.env; '' when neither.
304
+ export function adminPassword({ root = ROOT, state, env = process.env } = {}) {
305
+ return setting('SG_PDS_ADMIN_PASSWORD', { root, env }) || (state ? envGet('SG_PDS_ADMIN_PASSWORD', paths(root, state).admin) : '');
306
+ }
307
+
308
+ // adminPassword, made first when there is none: 24 random bytes, kept in admin.env (mode 600) so
309
+ // the same one is used on every later up and read by account and invite. Never printed.
310
+ export function ensureAdminPassword({ root = ROOT, state, env = process.env } = {}) {
311
+ const have = adminPassword({ root, state, env });
312
+ if (have) return have;
313
+ const p = paths(root, state);
314
+ mkdirSync(p.data, { recursive: true });
315
+ const password = randomBytes(24).toString('hex');
316
+ envPut(loginsFile(p.admin), /^SG_PDS_ADMIN_PASSWORD=/, [`SG_PDS_ADMIN_PASSWORD=${password}`]);
317
+ return password;
318
+ }
319
+
320
+ // docker compose on `project` with the rendered globe. Its paths are from the repo root, which is
321
+ // also where compose reads .env. Returns spawnSync's result; stdio as given (inherited by default),
322
+ // env as given (this process's by default), for compose to fill the file's ${...} from.
323
+ export function dc(project, args, { root = ROOT, state, stdio = 'inherit', encoding, env } = {}) {
324
+ const p = paths(root, state);
325
+ if (!existsSync(p.compose)) {
326
+ const old = oldDataNotice(root, state);
327
+ throw new Error(`No ${p.compose}: run snowglobe up, which renders it${old ? `\n${old}` : ''}`);
328
+ }
329
+ // compose fills ${SG_PDS_ADMIN_PASSWORD} in the file from this, and refuses to read the file
330
+ // without it. up makes the real one first; down and ps on a snowglobe from before it have none,
331
+ // and need only a value for compose to read the file.
332
+ const base = env ?? process.env;
333
+ const password = adminPassword({ root, state, env: base }) || 'unset-run-snowglobe-up';
334
+ return spawnSync('docker', ['compose', '-p', project, '--project-directory', root, '-f', p.compose, ...args], {
335
+ stdio,
336
+ encoding,
337
+ env: { ...base, SG_PDS_ADMIN_PASSWORD: password },
338
+ });
339
+ }
340
+
341
+ // An https request to a globe's name: the name goes to nginx on SG_BIND, and TLS is verified
342
+ // against the globe's CA. With SG_PUBLIC=1 the names are public ones with a real certificate
343
+ // (a globe on the cluster), so the URL is used as it stands, through public DNS and the system's
344
+ // CAs. Resolves to { status, body } (body a string); never logs the request or the answer.
345
+ export function snowglobeRequest(url, { method = 'GET', headers = {}, body, root = ROOT, state, env = process.env } = {}) {
346
+ const pub = env.SG_PUBLIC === '1';
347
+ const options = { method, headers };
348
+ if (!pub) {
349
+ const bind = resolveBind({ root, env });
350
+ options.ca = readFileSync(paths(root, state).ca);
351
+ options.lookup = (_host, opts, cb) =>
352
+ opts?.all ? cb(null, [{ address: bind, family: 4 }]) : cb(null, bind, 4);
353
+ }
354
+ return new Promise((done, fail) => {
355
+ const req = request(url, options, (res) => {
356
+ const chunks = [];
357
+ res.on('data', (c) => chunks.push(c));
358
+ res.on('end', () => done({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
359
+ res.on('error', fail);
360
+ });
361
+ req.on('error', fail);
362
+ req.end(body);
363
+ });
364
+ }
package/lib/hosts.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // The names snowglobe run maps to nginx, and the hosts file it binds over /etc/hosts. Pure:
2
+ // lib/commands/run.mjs reads the files and runs the command.
3
+
4
+ const NAME_RE = /^[A-Za-z0-9.-]+$/;
5
+ const words = (text) => (text ?? '').split(/\s+/).filter(Boolean);
6
+
7
+ /**
8
+ * Every name a process under run should reach nginx at: the rendered globe's leaf names that are
9
+ * not wildcards (its page, each PDS, its services, plc.directory), each handle in `handles`
10
+ * (data/https/names), <name>.app.<domain> for each app in `apps`, and `extra`
11
+ * (HTTPS_RUN_EXTRA_NAMES, space-separated). In that order, each once.
12
+ */
13
+ export function runNames({ leafNames, handles, apps, domain, extra }) {
14
+ return [
15
+ ...new Set([
16
+ ...words(leafNames).filter((n) => !n.startsWith('*')),
17
+ // One per line; a line that is not a name is skipped whole.
18
+ ...(handles ?? '').split('\n').filter((n) => NAME_RE.test(n)),
19
+ ...apps.map((a) => `${a.name}.app.${domain}`),
20
+ ...words(extra),
21
+ ]),
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * The hosts file: `etcHosts` (the machine's) without any line naming one of `names`, then each
27
+ * name on `bind`.
28
+ */
29
+ export function hostsFile(etcHosts, names, bind) {
30
+ const ours = new Set(names);
31
+ const kept = etcHosts
32
+ .split('\n')
33
+ .filter((l, i, all) => i < all.length - 1 || l !== '')
34
+ .filter((l) => !l.trim().split(/\s+/).slice(1).some((n) => ours.has(n)));
35
+ return [...kept, '# snowglobe (bin/snowglobe.mjs run)', ...names.map((n) => `${bind} ${n}`), ''].join('\n');
36
+ }