@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.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.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -2
  4. package/bin/snowglobe.mjs +71 -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 +40 -0
  13. package/lib/commands/env.mjs +146 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/ls.mjs +72 -0
  17. package/lib/commands/render.mjs +63 -0
  18. package/lib/commands/run.mjs +109 -0
  19. package/lib/commands/trust.mjs +45 -0
  20. package/lib/commands/up.mjs +301 -0
  21. package/lib/config.mjs +514 -0
  22. package/lib/env.mjs +526 -0
  23. package/lib/hosts.mjs +36 -0
  24. package/lib/prebuilt.mjs +56 -0
  25. package/lib/render.mjs +601 -0
  26. package/lib/settings.mjs +156 -0
  27. package/lib/state.mjs +109 -0
  28. package/lib/trust.mjs +288 -0
  29. package/lib/up.mjs +54 -0
  30. package/lib/xrpc.mjs +49 -0
  31. package/package.json +55 -4
  32. package/snowglobe.default.yml +54 -0
  33. package/stack/docker-compose.relay.yml +105 -0
  34. package/stack/docker-compose.test.yml +41 -0
  35. package/stack/docker-compose.yml +179 -0
  36. package/stack/https/app-names.cjs +31 -0
  37. package/stack/https/dns/Dockerfile +10 -0
  38. package/stack/https/dns/dns.sh +40 -0
  39. package/stack/https/nginx.conf.template +104 -0
  40. package/stack/https/proxy/Dockerfile +8 -0
  41. package/stack/https/proxy/server.mjs +213 -0
  42. package/stack/init/init.sh +118 -0
  43. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  44. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  45. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  46. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  47. package/stack/page/index.html +409 -0
  48. package/stack/page/snowglobe.conf +59 -0
  49. package/stack/pdsls/Dockerfile +27 -0
  50. package/stack/pdsls/network.patch +294 -0
  51. package/stack/pdsls/server.mjs +192 -0
  52. package/stack/relay/Dockerfile +30 -0
  53. package/stack/relay/allow-private-hosts.patch +55 -0
  54. package/stack/slingshot/Dockerfile +36 -0
@@ -0,0 +1,63 @@
1
+ // bin/snowglobe.mjs render [<file>] [--out <dir>] [--domain <domain>]
2
+ //
3
+ // render checks the file as check does (exit 1 with one line per problem), then writes the https
4
+ // globe it describes into <dir> (globe/ in the snowglobe's state folder by default, see
5
+ // lib/state.mjs): compose.yml, nginx's servers, the leaf certificate's names and globe.env, the
6
+ // facts the other commands read (lib/render.mjs). Nothing in them is secret. --domain replaces the file's domain, for a copy beside another globe. A group
7
+ // host with no image or build folder in the file is built from the folder its kind's variable
8
+ // names (SG_OPENSOCIAL_BUILD, a checkout of atproto-group-host); with neither, render fails.
9
+
10
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
12
+ import { withGroupHostSources } from '../config.mjs';
13
+ import { ROOT, paths } from '../env.mjs';
14
+ import { loadBase, renderGlobe } from '../render.mjs';
15
+ import { claimState, session } from '../state.mjs';
16
+ import { checkedGlobe } from './check.mjs';
17
+
18
+ export const command = {
19
+ name: 'render',
20
+ usage: 'render [<file>] [--out <dir>] [--domain <domain>]',
21
+ options: { out: { type: 'string' }, domain: { type: 'string' } },
22
+ positionals: [0, 1],
23
+ };
24
+
25
+ export function run({ values, positionals }) {
26
+ const { path, globe } = checkedGlobe(positionals[0], { domain: values.domain });
27
+ if (!globe) return 1;
28
+ let out;
29
+ try {
30
+ // Into the state folder, the file takes it over; into --out, it only reads where the state is.
31
+ const s = session({ file: path });
32
+ if (values.out === undefined) claimState(s);
33
+ out = writeGlobe(globe, path, values.out ?? paths(ROOT, s.state).globe, process.env, s.state);
34
+ } catch (e) {
35
+ for (const line of e.message.split('\n')) console.error(`${path}: ${line}`);
36
+ return 1;
37
+ }
38
+ console.log(`${path}: wrote ${out}: globe ${globe.globe} at ${globe.domain}, PDSs ${globe.pds.map((p) => p.name).join(' ')}`);
39
+ return 0;
40
+ }
41
+
42
+ /**
43
+ * Write the checked `globe`, read from `path`, into `out`, a folder; returns it. `state` is the
44
+ * snowglobe's state folder, which the compose file mounts from. Throws, before writing anything,
45
+ * when a group host has nowhere to get its image from.
46
+ */
47
+ export function writeGlobe(globe, path, out, env, state) {
48
+ globe = withGroupHostSources(globe, env);
49
+ // The compose file mounts nginx's part of the output, by a path from the repo root, which is
50
+ // the compose project directory (lib/env.mjs dc).
51
+ out = resolve(out);
52
+ const rel = relative(ROOT, out);
53
+ const globeDir = rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : out;
54
+ const files = renderGlobe(globe, loadBase(ROOT), { stateDir: state, globeDir, source: basename(path) });
55
+ for (const [name, text] of Object.entries(files)) {
56
+ const target = join(out, name);
57
+ mkdirSync(dirname(target), { recursive: true });
58
+ // Written aside and renamed, so nothing reads half a file.
59
+ writeFileSync(`${target}.tmp`, text);
60
+ renameSync(`${target}.tmp`, target);
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,109 @@
1
+ // bin/snowglobe.mjs run [--app <name>] [--] <cmd> [args...]
2
+ //
3
+ // run runs a command with the snowglobe around it, for that command's process tree only:
4
+ // - it is handed the app's settings, the apps[].env of the snowglobe.yml (lib/commands/env.mjs;
5
+ // the same as snowglobe env, secrets included, none printed). They win over a value already in
6
+ // the environment, and one line names the ones they replaced. --app picks the app when the
7
+ // file lists several;
8
+ // - the globe's names (its page, each PDS, its services and plc.directory, from the rendered
9
+ // globe), every handle in https/names (account and up add them) and every app in
10
+ // https/apps, as <name>.app.<domain>, resolve to SG_BIND, where nginx answers on
11
+ // 443 (lib/hosts.mjs). HTTPS_RUN_EXTRA_NAMES adds names (space-separated);
12
+ // - Node trusts the globe's CA (NODE_EXTRA_CA_CERTS=<state root>/ca/ca.crt), on top of its own
13
+ // roots. Miniflare passes the same file on to workerd.
14
+ //
15
+ // The globe is the one the snowglobe.yml in this folder (or above) describes, as for up.
16
+ //
17
+ // e.g. bin/snowglobe.mjs run node scripts/https-probe.mjs
18
+ // (cd ../myapp && ../snowglobe/bin/snowglobe.mjs run npm run dev)
19
+ //
20
+ // How: bubblewrap gives the command its own mount namespace with a generated hosts file bound
21
+ // over /etc/hosts. Nothing else changes: same filesystem, same network, same user. Outside,
22
+ // every name resolves as usual. The names are read when the command starts, so a handle or app
23
+ // added later needs a restart of the command. Linux only: a Mac has no bubblewrap, and reaches
24
+ // the globe through snowglobe env instead (the names resolve through public DNS, and the CA is in
25
+ // NODE_EXTRA_CA_CERTS).
26
+
27
+ import { spawn } from 'node:child_process';
28
+ import { accessSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
29
+ import { constants as os } from 'node:os';
30
+ import { delimiter, join } from 'node:path';
31
+ import { readApps } from '../apps.mjs';
32
+ import { ROOT, paths, resolveBind, resolveDomain } from '../env.mjs';
33
+ import { hostsFile, runNames } from '../hosts.mjs';
34
+ import { session } from '../state.mjs';
35
+ import { appSettings } from './env.mjs';
36
+
37
+ export const command = {
38
+ name: 'run',
39
+ usage: 'run [--app <name>] [--] <cmd> [args...]',
40
+ // Every word after run is the command's own, dashes and all, except an --app <name> first of
41
+ // all; none is a usage mistake.
42
+ parse: (args) => {
43
+ const named = args[0] === '--app';
44
+ if (named && args.length < 2) return null;
45
+ const rest = named ? args.slice(2) : args;
46
+ const cmd = rest[0] === '--' ? rest.slice(1) : rest;
47
+ return cmd.length ? { cmd, app: named ? args[1] : undefined } : null;
48
+ },
49
+ };
50
+
51
+ // The first `name` on PATH that can be run, or null.
52
+ function onPath(name, env) {
53
+ for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
54
+ try {
55
+ accessSync(join(dir, name), constants.X_OK);
56
+ return join(dir, name);
57
+ } catch {}
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export const run = ({ cmd, app }) => runUnder(cmd, { settings: appSettings({ app, secrets: true }).vars });
63
+
64
+ /**
65
+ * Run `cmd` (a word list) under the globe's names with `settings` ([{ name, value }], from
66
+ * appSettings) in its environment; resolves to its exit code. A setting replaces a value of the same
67
+ * name already in `env`, and `log` is told which.
68
+ */
69
+ export function runUnder(cmd, { root = ROOT, env = process.env, state = session({ env }).state, settings = [], log = (m) => console.error(m) } = {}) {
70
+ if (process.platform !== 'linux')
71
+ throw new Error('run needs Linux (bubblewrap) for now; on this system eval "$(snowglobe env)" hands the app the same settings');
72
+ const p = paths(root, state);
73
+ if (!existsSync(p.ca)) throw new Error(`no ${p.ca}; run snowglobe up (or certs) first`);
74
+ if (!existsSync(p.leafNames)) throw new Error(`no ${p.leafNames}; run snowglobe up first`);
75
+ const bwrap = onPath('bwrap', env);
76
+ if (!bwrap) throw new Error('bwrap (bubblewrap) is not installed, or not on PATH');
77
+ // Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
78
+ const { domain } = resolveDomain({ root, state, env });
79
+ const bind = resolveBind({ root, state, env });
80
+
81
+ const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
82
+ const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
83
+ // Written aside and renamed, so a run already holding the old file is undisturbed.
84
+ const hosts = join(p.https, 'hosts');
85
+ writeFileSync(`${hosts}.tmp.${process.pid}`, hostsFile(read('/etc/hosts'), names, bind), { mode: 0o644 });
86
+ renameSync(`${hosts}.tmp.${process.pid}`, hosts);
87
+
88
+ // Never the values: a setting may be a login.
89
+ const replaced = settings.filter((x) => !x.auto && env[x.name] !== undefined && env[x.name] !== x.value).map((x) => x.name);
90
+ if (replaced.length) log(`snowglobe run: the snowglobe's settings replaced ${replaced.join(', ')} from the environment`);
91
+
92
+ const child = spawn(bwrap, ['--dev-bind', '/', '/', '--ro-bind', hosts, '/etc/hosts', '--die-with-parent', '--', ...cmd], {
93
+ stdio: 'inherit',
94
+ env: { ...env, NODE_EXTRA_CA_CERTS: p.ca, ...Object.fromEntries(settings.map((x) => [x.name, x.value])) },
95
+ });
96
+ // A terminal's ^C reaches the whole process group, the command included, so it is left to the
97
+ // command; a signal sent to run alone is passed on.
98
+ process.on('SIGINT', () => {});
99
+ for (const sig of ['SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig));
100
+ return new Promise((done, fail) => {
101
+ // A spawn error can quote an environment value; a setting may be a login, so it is named instead.
102
+ child.on('error', (e) => {
103
+ let msg = e.message;
104
+ for (const x of settings) if (x.value) msg = msg.split(x.value).join(`<${x.name}>`);
105
+ fail(Object.assign(new Error(msg), { code: e.code }));
106
+ });
107
+ child.on('exit', (code, signal) => done(signal ? 128 + (os.signals[signal] ?? 0) : code));
108
+ });
109
+ }
@@ -0,0 +1,45 @@
1
+ // bin/snowglobe.mjs trust [--remove] [--dry-run] [--no-sudo]
2
+ //
3
+ // trust puts your snowglobe CA (one per user, <state root>/ca/ca.crt, made by certs or up) where a
4
+ // browser or program looks for the CAs it trusts, so https://local.snowglobe.at opens without a
5
+ // warning: the system store and the NSS databases Chrome, Edge and Firefox use on Linux; on WSL
6
+ // also the Windows store, whose command it prints for PowerShell; the login keychain on macOS; the
7
+ // user store on Windows (lib/trust.mjs). Every command is printed as it runs, and the ones that
8
+ // need sudo are marked. Running it again adds nothing. --remove takes the CA out of all of them.
9
+ // --dry-run prints what it would run and changes nothing. --no-sudo skips the steps that need sudo
10
+ // and prints them for you. The CA is limited to the snowglobe's names (lib/certs.mjs), so trusting
11
+ // it does not let it vouch for other sites.
12
+ //
13
+ // Exit 0 when every step is done or in place, 1 when one could not be (a tool is missing, a command
14
+ // failed, sudo skipped), 2 when there is no CA yet. Steps left for you to run (Windows from WSL,
15
+ // Firefox on macOS) do not change the exit code.
16
+
17
+ import { existsSync } from 'node:fs';
18
+ import { stateRoot } from '../env.mjs';
19
+ import { join } from 'node:path';
20
+
21
+ export const command = {
22
+ name: 'trust',
23
+ usage: 'trust [--remove] [--dry-run] [--no-sudo]',
24
+ options: { remove: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, 'no-sudo': { type: 'boolean' } },
25
+ positionals: [0, 0],
26
+ // Each flag once at most.
27
+ accepts: ({ tokens }) => new Set(tokens.filter((t) => t.kind === 'option').map((t) => t.name)).size === tokens.length,
28
+ };
29
+
30
+ export async function run({ values }, { ctx } = {}) {
31
+ const { applySteps, realContext, trustSteps } = await import('../trust.mjs');
32
+ const ca = join(stateRoot(), 'ca', 'ca.crt');
33
+ if (!existsSync(ca)) {
34
+ console.error(`snowglobe trust: no CA at ${ca}. Run snowglobe up (or snowglobe certs) first, which makes it`);
35
+ return 2;
36
+ }
37
+ const c = ctx ?? realContext();
38
+ const remove = Boolean(values.remove);
39
+ console.log(`${remove ? 'Removing' : 'Trusting'} ${ca}${values['dry-run'] ? ' (dry run: nothing is changed)' : ''}`);
40
+ const r = applySteps(trustSteps(ca, c), { remove, dryRun: Boolean(values['dry-run']), noSudo: Boolean(values['no-sudo']), ctx: c });
41
+ console.log(r.failed ? `${r.failed} step(s) not done.` : 'Done.');
42
+ if (r.manual) console.log(`${r.manual} step(s) are for you to run, as printed above.`);
43
+ if (!remove && !r.failed) console.log('Restart a browser that is open for it to see the CA.');
44
+ return r.failed ? 1 : 0;
45
+ }
@@ -0,0 +1,301 @@
1
+ // bin/snowglobe.mjs up [<file> | <name>] [-f <file>] [--default]
2
+ //
3
+ // up brings up the globe a snowglobe.yml describes, as the compose project named for the file's
4
+ // `globe:` (or SG_PROJECT). Safe to rerun: each step keeps what an earlier run made, and a rerun
5
+ // is one `docker compose up`.
6
+ //
7
+ // Everything it makes is kept in the snowglobe's state folder, <state root>/<globe> (lib/env.mjs
8
+ // stateRoot: ~/.local/share/snowglobe, or under XDG_DATA_HOME; %LOCALAPPDATA% on Windows), which
9
+ // remembers the file that owns it and refuses another file with the same `globe:`. Below, data/
10
+ // means that folder. A checkout that still holds a globe in its own data/ is refused (nothing
11
+ // started, nothing moved) with the command that moves it.
12
+ //
13
+ // 1. render: data/globe/ from <file> (or -f <file>), or the file that owns the snowglobe called
14
+ // <name> (snowglobe ls lists them), else SG_FILE, else the nearest snowglobe.yml here or above
15
+ // (none: up refuses; `up --default` runs the default globe, snowglobe.default.yml: one PDS,
16
+ // a PLC directory and Jetstream).
17
+ // SG_DOMAIN, from the environment or .env, replaces the file's domain.
18
+ // A group host with no image in the file is built from its build folder, else from
19
+ // SG_OPENSOCIAL_BUILD (a checkout of atproto-group-host); with neither, up stops here.
20
+ // 2. certs, if data/https has no leaf yet, or it lacks a name (the user's CA, in ca/ beside the
21
+ // snowglobes' folders, is made once and kept)
22
+ // 3. nginx's app routes and the network page, from data/https/apps (app --render), then each
23
+ // group host built from a folder is built again (Docker's cache keeps that quick)
24
+ // 4. pass 1: up. The base init seeds alice, bob and SG_INVITE_CODE into
25
+ // data/accounts.env on the PDS with bare handles (else the first), as it does for the
26
+ // http stacks.
27
+ // 5. the lexicon authority, lex-authority on the file's lexicons.authority PDS
28
+ // (LEX_AUTHORITY_* in data/accounts.env), then pass 2: up with
29
+ // SG_LEXICON_AUTHORITY_DID, which recreates the PDSes with it
30
+ // 6. what the file seeds: each of its accounts (snowglobe account; the login under <NAME>_* in
31
+ // data/accounts.env, <NAME>_<PDS>_* on a PDS other than the one with bare handles, else the
32
+ // first), and its lexicons.publish folder into the authority (snowglobe lexicons). Its apps'
33
+ // local routes (port, handle ending) are set before step 2, so the leaf and nginx have them.
34
+ // 7. an old data/devnet.env, from before snowglobe env, is removed; nothing writes one now
35
+ //
36
+ // A rerun finds the accounts and leaves unchanged lexicons alone. Anything else an app makes
37
+ // (more accounts, invite codes) is its own, with account, invite and lexicons. Nothing secret is
38
+ // printed. The tool ports (SG_PLC_PORT, SG_JETSTREAM_PORT, SG_JETSTREAM_METRICS_PORT,
39
+ // SG_MAILDEV_WEB_PORT, SG_MAILDEV_SMTP_PORT, SG_PROXY_PORT) are published on the snowglobe's own
40
+ // loopback address (SG_BIND, else the one chosen at its first up and kept in its state folder), so
41
+ // two snowglobes need no port settings; a port variable moves one, in the environment or .env. devnet-spaces, the shared devnet, is refused (exit 3) as
42
+ // the project unless SG_RESET_OK=1, before anything else is done.
43
+
44
+ import { createHash } from 'node:crypto';
45
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
46
+ import { dirname, join, resolve } from 'node:path';
47
+ import { setTimeout as sleep } from 'node:timers/promises';
48
+ import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
49
+ import { loadGlobe } from '../config.mjs';
50
+ import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
51
+ import { prebuilt } from '../prebuilt.mjs';
52
+ import { byName, claimState, pointedAt, session } from '../state.mjs';
53
+ import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
54
+ import { account } from './account.mjs';
55
+ import { lexicons } from './lexicons.mjs';
56
+ import { checkedGlobe } from './check.mjs';
57
+ import { writeGlobe } from './render.mjs';
58
+
59
+ export const command = {
60
+ name: 'up',
61
+ usage: 'up [<file> | <name>] [-f <file>] [--default]',
62
+ // --default is the only way up runs the repo's default snowglobe; with no file and no
63
+ // snowglobe.yml in this folder or above, up refuses.
64
+ options: { default: { type: 'boolean' }, file: { type: 'string', short: 'f' } },
65
+ positionals: [0, 1],
66
+ accepts: ({ values, positionals }) => !(values.default && (positionals.length || values.file)),
67
+ };
68
+
69
+ // How long a one-shot (init, relay-init) may take, and nginx to answer again after a restart.
70
+ const ONESHOT_SECONDS = 300;
71
+ const NGINX_SECONDS = 30;
72
+
73
+ // Until nginx answers the globe's page over TLS, with any status.
74
+ async function waitServing(domain, { root, state, env }) {
75
+ const seconds = Number(env.SG_NGINX_WAIT) || NGINX_SECONDS;
76
+ for (let n = 0; ; n++) {
77
+ try {
78
+ return await snowglobeRequest(`https://${domain}/`, { root, state, env });
79
+ } catch (e) {
80
+ if (n >= seconds) throw new Error(`nginx did not answer https://${domain}/ within ${seconds} s after its restart: ${e.message}`);
81
+ await sleep(1000);
82
+ }
83
+ }
84
+ }
85
+
86
+ export async function run({ values, positionals: [word] }) {
87
+ let file;
88
+ if (values.default) file = join(ROOT, 'snowglobe.default.yml');
89
+ else {
90
+ const at = pointedAt({ file: values.file, word });
91
+ file = at.file;
92
+ if (at.name) {
93
+ // A snowglobe by its name is brought up from the file that owns it.
94
+ file = byName(at.name).path;
95
+ if (!file) throw new Error(`the file that owned the snowglobe ${at.name} is gone; bring it up from a file: snowglobe up -f <file>`);
96
+ // The file may have been renamed since (globe: changed): up would then start the new name.
97
+ const { globe } = loadGlobe(file);
98
+ if (globe && globe.globe !== at.name)
99
+ throw new Error(`${file} says globe: ${globe.globe} now, not ${at.name}; put globe: ${at.name} back to bring ${at.name} up, or snowglobe up ${file} to start ${globe.globe} as a new snowglobe`);
100
+ }
101
+ }
102
+ await up({ file, log: (m) => console.error(m) });
103
+ }
104
+
105
+ export async function up({ file, root = ROOT, env = process.env, log = () => {} }) {
106
+ // The shared devnet is refused before anything is looked up, even by a file that is not there.
107
+ if (env.SG_PROJECT) guardProject(env);
108
+ const found = session({ file, env, allowDefault: false });
109
+ const project = guardProject(env, found.name);
110
+ const state = found.state;
111
+ const old = oldDataNotice(root, state);
112
+ if (old) throw new Error(old);
113
+ const renamed = resolve(found.path) === join(root, 'snowglobe.default.yml') && renamedDefaultNotice(root, state);
114
+ if (renamed) throw new Error(renamed);
115
+ const p = paths(root, state);
116
+ // Both are read as compose reads them, and refused here when they are not a domain and a
117
+ // loopback address.
118
+ const { set } = resolveDomain({ root, state, env, rendering: true });
119
+ resolveBind({ root, state, env });
120
+
121
+ const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
122
+ if (!globe) throw new Error(`${path} has problems (above); ${p.globe} is as it was`);
123
+ // From here on the file's globe is the one this state folder is for.
124
+ const took = claimState(found);
125
+ if (took) log(took);
126
+ // Its own loopback address for every published port, chosen once, so a second snowglobe starts beside this one.
127
+ const bind = await chooseBind({ root, state, project, env });
128
+ if (bind !== '127.0.0.1') log(`${found.name} publishes its ports on ${bind}`);
129
+ // Checked before the render changes, so a refused file leaves the running globe's render.
130
+ const kept = readApps(p);
131
+ const badEnding = appEnding(kept);
132
+ if (badEnding) throw new Error(appEndingMessage(p.apps, badEnding));
133
+ const routes = mergeRoutes(kept, globe.apps);
134
+ const home = (globe.pds.find((x) => x.bare_handles) ?? globe.pds[0]).name;
135
+ const seedAccounts = accountPrefixes(globe.accounts, home);
136
+ const clash = endingClash(
137
+ [...globe.pds, ...globe.grouphosts].map((x) => x.name),
138
+ routes,
139
+ );
140
+ if (clash)
141
+ throw new Error(
142
+ `the server ${clash.pds} would take the handles under ${clash.pds}.<domain>, the ending app ${clash.app} claimed; rename the server, or snowglobe app --remove ${clash.app}`,
143
+ );
144
+ if (formatApps(routes) !== formatApps(kept)) {
145
+ mkdirSync(p.https, { recursive: true });
146
+ writeApps(p, routes);
147
+ }
148
+ writeGlobe(globe, path, p.globe, env, state);
149
+ const domain = globe.domain;
150
+ const ghs = globe.grouphosts.length ? `, group hosts ${globe.grouphosts.map((x) => x.name).join(' ')}` : '';
151
+ log(`${path}: globe ${globe.globe} at ${domain}, PDSs ${globe.pds.map((x) => x.name).join(' ')}${ghs}`);
152
+
153
+ // The leaf must carry every name (lib/certs.mjs): a leaf from before the app routes moved lacks
154
+ // *.app.<domain>, and one from before an app claimed a handle ending lacks its wildcard.
155
+ const { ensureCerts, leafLacks, leafNames } = await import('../certs.mjs');
156
+ const names = leafNames(p, domain, env.HTTPS_EXTRA_NAMES);
157
+ const newCerts = leafLacks(p.https, names, p.caDir);
158
+ if (newCerts) await ensureCerts({ dir: p.https, caDir: p.caDir, domain, names, log });
159
+ renderRoutes({ root, state, env });
160
+
161
+ const facts = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
162
+ // compose fills the file's ${SG_LEXICON_AUTHORITY_DID} from this.
163
+ const secrets = ensureSecrets({ root, state, env, hasData: () => projectHasData(project, env) });
164
+ if (secrets.kept.length)
165
+ log(`${found.name} has data made before its secrets were random, so it keeps the fixed ${secrets.kept.join(', ')}. snowglobe down -v, then up, starts it over with random ones (every account is lost).`);
166
+ const composeEnv = { ...env, ...secrets.values };
167
+ const compose = (args, opts = {}) => {
168
+ const res = dc(project, args, { root, state, env: composeEnv, ...opts });
169
+ if (res.error) throw res.error;
170
+ if (res.status !== 0)
171
+ throw new Error(`docker compose ${args.join(' ')} failed (exit ${res.status}). If a container crash-loops after you updated snowglobe, an image built earlier may be stale (the slingshot image is tagged by its commit, so a changed stack/slingshot/ folder alone does not rebuild it): run snowglobe down (without -v, which keeps your data), remove the image with docker rmi, then run up again`);
172
+ return res;
173
+ };
174
+ // The nginx template as the running nginx last started with it (stamped after a restart here).
175
+ const templateFile = join(root, 'stack/https/nginx.conf.template');
176
+ const templateStamp = join(p.https, 'nginx-template.sha256');
177
+ const templateHash = createHash('sha256').update(existsSync(templateFile) ? readFileSync(templateFile) : '').digest('hex');
178
+ const templateChanged = !existsSync(templateStamp) || readFileSync(templateStamp, 'utf8').trim() !== templateHash;
179
+ const put = (file, text) => {
180
+ writeFileSync(`${file}.tmp`, `${text}\n`);
181
+ renameSync(`${file}.tmp`, file);
182
+ };
183
+ // Container ids, none when compose cannot say (a project not made yet).
184
+ const ids = (args) => (dc(project, ['ps', ...args], { root, state, stdio: 'pipe', encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean);
185
+
186
+ // `up --wait` alone fails as soon as a one-shot service exits, even with 0. So: up, then wait
187
+ // for the one-shots (init seeds data/accounts.env; relay-init registers every PDS with the
188
+ // relay), then for every long-running service to be healthy.
189
+ async function waitDone(service) {
190
+ for (let n = 0; n < ONESHOT_SECONDS; n++) {
191
+ const st = compose(['ps', '-a', '--format', '{{.State}} {{.ExitCode}}', service], { stdio: 'pipe', encoding: 'utf8' }).stdout.split('\n')[0];
192
+ if (st === 'exited 0') return;
193
+ if (st.startsWith('exited')) throw new Error(`${service} failed (exit ${st.slice('exited '.length)})`);
194
+ await sleep(1000);
195
+ }
196
+ throw new Error(`${service} did not finish within ${ONESHOT_SECONDS} s`);
197
+ }
198
+ const waitServices = () => compose(['up', '-d', '--wait', ...facts('SG_SERVICES')]);
199
+ async function upStack() {
200
+ // A service the file no longer runs (a PDS taken out, a service turned off) is stopped.
201
+ compose(['up', '-d', '--remove-orphans']);
202
+ for (const s of facts('SG_ONESHOTS')) await waitDone(s);
203
+ waitServices();
204
+ }
205
+
206
+ // What runs before this run: the relay, and every container, to notice a PDS this run makes.
207
+ const relayBefore = ids(['-q', 'relay']);
208
+ const before = new Set(ids(['-aq']));
209
+
210
+ // Every image the file builds is built before anything starts, so `up` never builds while
211
+ // containers are being made (on a runner with no images, the relay and the other built services
212
+ // were made, then made again by the next `up`, and Jetstream lost the relay between the two).
213
+ // A group host built from a folder is the folder as it is now; Docker's cache makes an
214
+ // unchanged image quick.
215
+ const builds = facts('SG_BUILDS');
216
+ // The images snowglobe publishes are pulled where the registry has the build of these very files
217
+ // (lib/prebuilt.mjs); only the rest are built. Compose fills in the ${...} defaults (an image's
218
+ // tag follows its commit); if it cannot say, everything is built, as before.
219
+ let toBuild = [];
220
+ try {
221
+ const services = JSON.parse(compose(['config', '--format', 'json'], { stdio: 'pipe', encoding: 'utf8' }).stdout).services;
222
+ const have = prebuilt({ root, services, env, log });
223
+ toBuild = Object.keys(services).filter((s) => services[s].build && !have.includes(s));
224
+ } catch {
225
+ toBuild = [];
226
+ compose(['build', '--quiet']);
227
+ }
228
+ if (toBuild.length) {
229
+ log(`Building images${builds.length ? ` (${builds.join(' ')})` : ''}`);
230
+ compose(['build', '--quiet', ...toBuild]);
231
+ }
232
+
233
+ // A rerun starts the PDSes with the authority they already have, so pass 1 changes nothing.
234
+ composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
235
+ log(`Pass 1: up (project ${project})`);
236
+ await upStack();
237
+ // A running nginx keeps the certificate files it started with, and the template it was started
238
+ // with (the image fills it in at start); new ones need a restart, whether or not the leaf was
239
+ // reissued. The globe's servers may have changed with the render, so a running nginx reloads them.
240
+ if (newCerts || templateChanged) {
241
+ compose(['restart', 'nginx'], { stdio: 'ignore' });
242
+ if (templateChanged) put(templateStamp, templateHash);
243
+ // Its health check is nginx -t, which passes before it listens again, and the next step
244
+ // reaches the PDSes through it.
245
+ await waitServing(domain, { root, state, env });
246
+ } else compose(['exec', '-T', 'nginx', 'nginx', '-s', 'reload'], { stdio: 'ignore' });
247
+
248
+ const authority = envGet('SG_AUTHORITY_PDS', p.globeEnv);
249
+ const did = await account({ name: 'lex-authority', prefix: 'LEX_AUTHORITY', pds: authority, file: p.accounts, root, state, env, log });
250
+ if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
251
+ composeEnv.SG_LEXICON_AUTHORITY_DID = did;
252
+ log(`Pass 2: up with the lexicon authority ${did}`);
253
+ // Pass 2 recreates every PDS. Jetstream with no relay reads the first PDS itself and exits
254
+ // while it is gone; on a slow machine (a CI runner) it was still restarting when compose
255
+ // checked it, and up failed with "jetstream is unhealthy". Stopped first, it is started
256
+ // again only once the PDS is healthy.
257
+ const running = facts('SG_SERVICES');
258
+ if (running.includes('jetstream') && !running.includes('relay')) compose(['stop', 'jetstream'], { stdio: 'ignore' });
259
+ await upStack();
260
+ }
261
+
262
+ // A relay that was running when a PDS went away redials it with a growing backoff; a PDS
263
+ // added back under that name was not crawled again until the relay restarted (2026-10-20: no
264
+ // dial in the nine minutes after it came back). So when this run made a PDS container beside a
265
+ // relay that was already running, the relay restarts, and the services wait for it again.
266
+ if (relayBefore.length) {
267
+ const fresh = facts('SG_PDS').find((x) => ids(['-q', `pds-${x}`]).some((id) => !before.has(id)));
268
+ if (fresh) {
269
+ log(`Restarting the relay: pds-${fresh} is new beside it`);
270
+ compose(['restart', 'relay'], { stdio: 'ignore' });
271
+ waitServices();
272
+ }
273
+ }
274
+
275
+ // init's accounts are on the seed PDS; run maps their handles.
276
+ for (const prefix of ['ALICE', 'BOB']) {
277
+ const handle = envGet(`${prefix}_HANDLE`, p.accounts);
278
+ if (handle) addName(handle, { root, state });
279
+ }
280
+
281
+ // What the file seeds. A rerun finds each account by its kept login.
282
+ for (const a of seedAccounts) await account({ ...a, file: p.accounts, root, state, env, log });
283
+ if (globe.lexicons.publish) await lexicons({ dir: resolve(dirname(path), globe.lexicons.publish), root, state, env, log });
284
+
285
+ // Written by snowglobe up until snowglobe env replaced it; an app that still sources it would
286
+ // read addresses that no longer follow the file.
287
+ const denv = join(p.data, 'devnet.env');
288
+ if (existsSync(denv)) {
289
+ rmSync(denv);
290
+ log(`Removed the old ${denv}; snowglobe env hands an app these settings now`);
291
+ }
292
+
293
+ // init leaves data/accounts.env and data/accounts.json world-readable on every up; both hold
294
+ // logins.
295
+ makePrivate(p.accounts);
296
+ makePrivate(join(p.data, 'accounts.json'));
297
+
298
+ log('');
299
+ const ps = dc(project, ['ps', '--format', '{{.Service}} {{.State}} {{.Ports}}'], { root, state, stdio: 'pipe', encoding: 'utf8' });
300
+ log(ps.stdout.trimEnd());
301
+ }