@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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/bin/snowglobe.mjs +69 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +37 -0
- package/lib/commands/env.mjs +145 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +257 -0
- package/lib/config.mjs +485 -0
- package/lib/env.mjs +364 -0
- package/lib/hosts.mjs +36 -0
- package/lib/render.mjs +570 -0
- package/lib/settings.mjs +146 -0
- package/lib/state.mjs +75 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +178 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// bin/snowglobe.mjs account <name> <PREFIX> [<pds>]
|
|
2
|
+
//
|
|
3
|
+
// account creates or finds <name> on <pds> (the PDS with bare handles, else the seed PDS):
|
|
4
|
+
// <name>.<domain> there, <name>.<pds>.<domain> on the others. Its login goes into ACCOUNTS_FILE
|
|
5
|
+
// (accounts.env in the snowglobe's state folder by default, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS, its
|
|
6
|
+
// handle into the names bin/snowglobe.mjs run maps (restart a process under it to pick one up), and
|
|
7
|
+
// its DID alone to stdout. A rerun finds it. It reads the rendered globe, and prints no secret
|
|
8
|
+
// (lib/xrpc.mjs).
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { ROOT, UsageError, addName, envGet, envPut, globeFacts, loginsFile, paths } from '../env.mjs';
|
|
12
|
+
import { session } from '../state.mjs';
|
|
13
|
+
import { mintInvite, why, xrpc } from '../xrpc.mjs';
|
|
14
|
+
|
|
15
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
16
|
+
// The authority's own account, which snowglobe up makes under this prefix. Its name is one the
|
|
17
|
+
// globe keeps for itself, so no other account takes it.
|
|
18
|
+
const AUTHORITY = { name: 'lex-authority', prefix: 'LEX_AUTHORITY' };
|
|
19
|
+
|
|
20
|
+
export const command = {
|
|
21
|
+
name: 'account',
|
|
22
|
+
usage: 'account <name> <PREFIX> [<pds>]',
|
|
23
|
+
options: {},
|
|
24
|
+
positionals: [2, 3],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function run({ positionals: [name, prefix, pds] }) {
|
|
28
|
+
console.log(await account({ name, prefix, pds, state: session().state, log: (m) => console.error(m) }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create, or find, the account <name> on the globe's PDS `pds` (the one with bare handles, else
|
|
33
|
+
* the seed PDS, by default), and keep its login in `file` as <prefix>_HANDLE, _DID, _PASSWORD and
|
|
34
|
+
* _PDS. Its handle is added to the names snowglobe run maps. Resolves to the DID. A rerun signs in
|
|
35
|
+
* with the kept password and finds the account; a PDS that takes invite codes is sent one
|
|
36
|
+
* (SG_INVITE_CODE, else a single-use code minted for this account), the others none.
|
|
37
|
+
*/
|
|
38
|
+
export async function account({ name, prefix, pds, file, root = ROOT, state, env = process.env, log = () => {} }) {
|
|
39
|
+
if (!LABEL_RE.test(name ?? '')) throw new UsageError(`<name> must be one lowercase DNS label: ${name}`);
|
|
40
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(prefix ?? '')) throw new UsageError(`<PREFIX> must be an env var prefix (A-Z, 0-9, _): ${prefix}`);
|
|
41
|
+
const g = globeFacts({ root, state, env });
|
|
42
|
+
const where = pds || g.bare || g.seed;
|
|
43
|
+
if (!g.pds.includes(where)) throw new UsageError(`${where} is not a PDS in this globe (${g.pds.join(', ')})`);
|
|
44
|
+
const bare = where === g.bare;
|
|
45
|
+
const handle = bare ? `${name}.${g.domain}` : `${name}.${where}.${g.domain}`;
|
|
46
|
+
// A bare handle is <name>.<domain>, the same space as the globe's own names.
|
|
47
|
+
if (bare && g.taken.includes(name) && !(name === AUTHORITY.name && prefix === AUTHORITY.prefix))
|
|
48
|
+
throw new UsageError(`${handle} is a name the globe uses itself; pick another <name>`);
|
|
49
|
+
if (handle.length > 253) throw new UsageError(`${handle} is longer than 253 characters`);
|
|
50
|
+
const pdsUrl = `https://${where}.${g.domain}`;
|
|
51
|
+
const accounts = loginsFile(file || env.ACCOUNTS_FILE || paths(root, state).accounts);
|
|
52
|
+
const opts = { root, state, env };
|
|
53
|
+
|
|
54
|
+
const save = (did, password) =>
|
|
55
|
+
envPut(accounts, new RegExp(`^${prefix}_(HANDLE|DID|PASSWORD|PDS)=`), [
|
|
56
|
+
`${prefix}_HANDLE=${handle}`,
|
|
57
|
+
`${prefix}_DID=${did}`,
|
|
58
|
+
`${prefix}_PASSWORD=${password}`,
|
|
59
|
+
`${prefix}_PDS=${pdsUrl}`,
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const kept = envGet(`${prefix}_PASSWORD`, accounts);
|
|
63
|
+
if (kept && envGet(`${prefix}_HANDLE`, accounts) === handle) {
|
|
64
|
+
const res = await xrpc(pdsUrl, 'com.atproto.server.createSession', { body: { identifier: handle, password: kept }, ...opts });
|
|
65
|
+
if (res.ok && typeof res.json.did === 'string') {
|
|
66
|
+
if (envGet(`${prefix}_DID`, accounts) !== res.json.did) save(res.json.did, kept);
|
|
67
|
+
addName(handle, { root, state });
|
|
68
|
+
log(`Found ${handle} -> ${res.json.did}`);
|
|
69
|
+
return res.json.did;
|
|
70
|
+
}
|
|
71
|
+
log(`${handle} is in ${accounts} but cannot sign in; creating it`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let inviteCode;
|
|
75
|
+
if (g.invites.includes(where)) inviteCode = env.SG_INVITE_CODE || (await mintInvite(pdsUrl, 1, opts));
|
|
76
|
+
const password = randomBytes(16).toString('hex');
|
|
77
|
+
const body = { handle, email: `${name}@${bare ? g.domain : `${where}.${g.domain}`}`, password };
|
|
78
|
+
if (inviteCode) body.inviteCode = inviteCode;
|
|
79
|
+
const res = await xrpc(pdsUrl, 'com.atproto.server.createAccount', { body, ...opts });
|
|
80
|
+
if (!res.ok || typeof res.json.did !== 'string') throw new Error(`createAccount ${handle} on ${pdsUrl} failed: ${why(res)}`);
|
|
81
|
+
save(res.json.did, password);
|
|
82
|
+
addName(handle, { root, state });
|
|
83
|
+
log(`Created ${handle} on ${pdsUrl} -> ${res.json.did}`);
|
|
84
|
+
return res.json.did;
|
|
85
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// bin/snowglobe.mjs app <name> <port> [<ending>] | --remove <name> | --render
|
|
2
|
+
//
|
|
3
|
+
// app routes https://<name>.app.<domain> to <port> on this machine through the globe's nginx
|
|
4
|
+
// (lib/apps.mjs), and with <ending> every name under <ending>.<domain> too, for an app that hands
|
|
5
|
+
// out handles; the leaf gains that wildcard from the same CA. Kept in https/apps in the
|
|
6
|
+
// snowglobe's state folder (a rerun changes the port, and drops the ending unless given again);
|
|
7
|
+
// nginx's routes and the network page are rendered from it, and nginx reloads after nginx -t if
|
|
8
|
+
// the globe runs (its compose project, see lib/state.mjs). --remove drops a route; --render only
|
|
9
|
+
// renders, as snowglobe up does before starting the globe.
|
|
10
|
+
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { readApps, renderRoutes, writeApps } from '../apps.mjs';
|
|
14
|
+
import { ROOT, UsageError, dc, globeFacts, paths } from '../env.mjs';
|
|
15
|
+
import { session } from '../state.mjs';
|
|
16
|
+
|
|
17
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
18
|
+
|
|
19
|
+
export const command = {
|
|
20
|
+
name: 'app',
|
|
21
|
+
usage: 'app <name> <port> [<ending>] | --remove <name> | --render',
|
|
22
|
+
options: { remove: { type: 'string' }, render: { type: 'boolean' } },
|
|
23
|
+
positionals: [0, 3],
|
|
24
|
+
// --render alone, --remove <name> alone, or a route (<name> <port> [<ending>]) and no option.
|
|
25
|
+
accepts: ({ positionals, tokens }) => (tokens.some((t) => t.kind === 'option') ? tokens.length === 1 : positionals.length >= 2),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function run({ values, positionals: [name, port, ending] }) {
|
|
29
|
+
const log = (m) => console.error(m);
|
|
30
|
+
const found = session();
|
|
31
|
+
if (values.render) renderRoutes({ state: found.state });
|
|
32
|
+
else if (values.remove !== undefined) await app({ name: values.remove, remove: true, state: found.state, project: found.project, log });
|
|
33
|
+
else await app({ name, port, ending, state: found.state, project: found.project, log });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Add, change or (remove) drop the route for `name`, issue the leaf if it now lacks a name (an
|
|
38
|
+
* ending's wildcard, from the same CA), render, and reload nginx in the running globe after
|
|
39
|
+
* nginx -t. A globe that is not running keeps the route; snowglobe up loads it.
|
|
40
|
+
*/
|
|
41
|
+
export async function app({ name, port, ending, remove = false, root = ROOT, state, project, env = process.env, log = () => {} }) {
|
|
42
|
+
if (!LABEL_RE.test(name ?? '')) throw new UsageError(`<name> must be one lowercase DNS label: ${name}`);
|
|
43
|
+
const p = paths(root, state);
|
|
44
|
+
const g = globeFacts({ root, state, env });
|
|
45
|
+
mkdirSync(p.https, { recursive: true });
|
|
46
|
+
const apps = readApps(p);
|
|
47
|
+
const others = apps.filter((a) => a.name !== name);
|
|
48
|
+
|
|
49
|
+
let newLeaf = false;
|
|
50
|
+
if (remove) {
|
|
51
|
+
if (others.length === apps.length)
|
|
52
|
+
throw new Error(`no route named ${name} in ${p.apps}; routed: ${apps.map((a) => a.name).join(' ') || 'none'}`);
|
|
53
|
+
writeApps(p, others);
|
|
54
|
+
renderRoutes({ root, state, env });
|
|
55
|
+
// The leaf keeps a removed ending's name; an extra name on a certificate is harmless.
|
|
56
|
+
log(`Removed https://${name}.app.${g.domain} (${p.apps})`);
|
|
57
|
+
} else {
|
|
58
|
+
if (!/^[1-9][0-9]{0,4}$/.test(String(port ?? '')) || Number(port) > 65535) throw new UsageError(`<port> must be a port number: ${port}`);
|
|
59
|
+
if (ending) {
|
|
60
|
+
if (!LABEL_RE.test(ending)) throw new UsageError(`<ending> must be one lowercase DNS label: ${ending}`);
|
|
61
|
+
if (ending === 'app') throw new UsageError(`<ending> app is where the apps are: ${name}.app.${g.domain}`);
|
|
62
|
+
// A PDS's handles are its own, and an ending is one app's.
|
|
63
|
+
if (g.pds.includes(ending)) throw new Error(`${ending}.${g.domain} is the PDS ${ending}'s own`);
|
|
64
|
+
const owner = others.find((a) => a.ending === ending);
|
|
65
|
+
if (owner) throw new Error(`handles under ${ending}.${g.domain} already go to ${owner.name} (${p.apps})`);
|
|
66
|
+
}
|
|
67
|
+
// A rerun with the same name changes its port, and drops its ending unless given again.
|
|
68
|
+
writeApps(p, [...others, { name, port: Number(port), ending: ending || null }]);
|
|
69
|
+
// Loaded here, so the usage, which loads every command, does without the certificate library.
|
|
70
|
+
const { ensureCerts, leafLacks, leafNames } = await import('../certs.mjs');
|
|
71
|
+
const names = leafNames(p, g.domain, env.HTTPS_EXTRA_NAMES);
|
|
72
|
+
if (leafLacks(p.https, names, p.caDir)) ({ newLeaf } = await ensureCerts({ dir: p.https, caDir: p.caDir, domain: g.domain, names, log }));
|
|
73
|
+
renderRoutes({ root, state, env });
|
|
74
|
+
log(`https://${name}.app.${g.domain} -> port ${port} on this machine (${p.apps})`);
|
|
75
|
+
if (ending) log(`https://<handle>.${ending}.${g.domain} -> the same port`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const running = dc(project, ['ps', '-q', '--status', 'running', 'nginx'], { root, state, stdio: 'pipe', encoding: 'utf8' });
|
|
79
|
+
if (running.status !== 0 || !running.stdout.trim())
|
|
80
|
+
return log(`nginx is not running in ${project}; it loads the route when snowglobe up starts it`);
|
|
81
|
+
if (dc(project, ['exec', '-T', 'nginx', 'nginx', '-t', '-q'], { root, state }).status !== 0)
|
|
82
|
+
throw new Error(`nginx refused the new routes; ${join(p.https, 'nginx')} holds what it read`);
|
|
83
|
+
// A reload rereads the certificate too.
|
|
84
|
+
if (dc(project, ['exec', '-T', 'nginx', 'nginx', '-s', 'reload'], { root, state }).status !== 0) throw new Error('nginx did not reload');
|
|
85
|
+
log(`Reloaded nginx in ${project}${newLeaf ? ', with the new leaf' : ''}`);
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// bin/snowglobe.mjs certs [--check]
|
|
2
|
+
//
|
|
3
|
+
// certs makes the CA if there is none (one per user, kept in the state root's ca/ folder, so it is
|
|
4
|
+
// trusted once for every snowglobe), and issues the leaf nginx serves when it lacks one of the
|
|
5
|
+
// rendered globe's names, or another CA signed it (lib/certs.mjs). --check only says whether it would: exit 0 when
|
|
6
|
+
// the leaf carries every name, 1 when it lacks one. HTTPS_EXTRA_NAMES adds names
|
|
7
|
+
// (space-separated), on every run. Any failure exits 2, since 1 is --check's answer.
|
|
8
|
+
|
|
9
|
+
import { ROOT, paths, resolveDomain } from '../env.mjs';
|
|
10
|
+
import { session } from '../state.mjs';
|
|
11
|
+
|
|
12
|
+
export const command = {
|
|
13
|
+
name: 'certs',
|
|
14
|
+
usage: 'certs [--check]',
|
|
15
|
+
options: { check: { type: 'boolean' } },
|
|
16
|
+
positionals: [0, 0],
|
|
17
|
+
// --check once at most.
|
|
18
|
+
accepts: ({ tokens }) => tokens.length <= 1,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function run({ values }) {
|
|
22
|
+
try {
|
|
23
|
+
// Loaded here, so the usage, which loads every command, does without the certificate library.
|
|
24
|
+
const { ensureCerts, leafLacks, leafNames } = await import('../certs.mjs');
|
|
25
|
+
const { state } = session();
|
|
26
|
+
const p = paths(ROOT, state);
|
|
27
|
+
const { domain } = resolveDomain({ root: ROOT, state });
|
|
28
|
+
const names = leafNames(p, domain, process.env.HTTPS_EXTRA_NAMES);
|
|
29
|
+
if (values.check) return leafLacks(p.https, names, p.caDir) ? 1 : 0;
|
|
30
|
+
await ensureCerts({ dir: p.https, caDir: p.caDir, domain, names, log: (m) => console.error(m) });
|
|
31
|
+
return 0;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`snowglobe certs: ${e.message}`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// bin/snowglobe.mjs check [<file>]
|
|
2
|
+
//
|
|
3
|
+
// check checks the file and exits 0 if it is sound, 1 with one line per problem if not
|
|
4
|
+
// (lib/config.mjs has the rules). It touches nothing, and needs no Docker.
|
|
5
|
+
//
|
|
6
|
+
// With no <file>, SG_FILE, else ./snowglobe.yml or the nearest one in a folder above, else the
|
|
7
|
+
// default globe at the repo root (snowglobe.default.yml: one PDS, a PLC directory and Jetstream). render, up and the
|
|
8
|
+
// other commands find their file the same way (lib/state.mjs).
|
|
9
|
+
|
|
10
|
+
import { loadGlobe } from '../config.mjs';
|
|
11
|
+
import { findFile } from '../state.mjs';
|
|
12
|
+
|
|
13
|
+
export const command = {
|
|
14
|
+
name: 'check',
|
|
15
|
+
usage: 'check [<file>]',
|
|
16
|
+
options: {},
|
|
17
|
+
positionals: [0, 1],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load and check `file` (or the file check reads with none), with `domain` in place of its own
|
|
22
|
+
* when given. Each problem goes to stderr as one line; `globe` is null when there is one.
|
|
23
|
+
*/
|
|
24
|
+
export function checkedGlobe(file, { domain } = {}) {
|
|
25
|
+
const path = findFile({ file });
|
|
26
|
+
const { globe, errors } = loadGlobe(path, { domain });
|
|
27
|
+
for (const e of errors) console.error(`${path}: ${e}`);
|
|
28
|
+
return { path, globe: errors.length ? null : globe };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function run({ positionals }) {
|
|
32
|
+
const { path, globe } = checkedGlobe(positionals[0]);
|
|
33
|
+
if (!globe) return 1;
|
|
34
|
+
const extra = globe.grouphosts.length ? `, ${globe.grouphosts.length} group host(s)` : '';
|
|
35
|
+
console.log(`${path}: ok: globe ${globe.globe} at ${globe.domain}, PDSs ${globe.pds.map((p) => p.name).join(' ')}${extra}`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// bin/snowglobe.mjs down [-v]
|
|
2
|
+
//
|
|
3
|
+
// down stops the compose project of the snowglobe in this folder (or above, see lib/state.mjs;
|
|
4
|
+
// SG_PROJECT if set), on the globe up last rendered (globe/ in its state folder), and a service an
|
|
5
|
+
// earlier render ran and this one does not with it.
|
|
6
|
+
// -v also drop the project's volumes: every account, DID and record is gone. The PLC's
|
|
7
|
+
// database is a named volume, so without -v the DIDs survive with the PDSes.
|
|
8
|
+
//
|
|
9
|
+
// The state folder is never deleted. After -v, the next up starts over: the base init reseeds
|
|
10
|
+
// alice and bob and rewrites accounts.env from scratch, dropping the logins the tools wrote there
|
|
11
|
+
// (their accounts are gone too), and the lexicon authority is made again. The CA, the leaf and
|
|
12
|
+
// the app routes stay either way. The project is guarded as up guards it.
|
|
13
|
+
|
|
14
|
+
import { ROOT, dc, guardProject } from '../env.mjs';
|
|
15
|
+
import { session } from '../state.mjs';
|
|
16
|
+
|
|
17
|
+
export const command = {
|
|
18
|
+
name: 'down',
|
|
19
|
+
usage: 'down [-v]',
|
|
20
|
+
options: { volumes: { type: 'boolean', short: 'v' } },
|
|
21
|
+
positionals: [0, 0],
|
|
22
|
+
accepts: ({ tokens }) => tokens.length <= 1,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function run({ values }) {
|
|
26
|
+
return down({ volumes: values.volumes });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function down({ volumes = false, file, root = ROOT, env = process.env } = {}) {
|
|
30
|
+
// The shared devnet is refused before anything is looked up, even by a file that is not there.
|
|
31
|
+
if (env.SG_PROJECT) guardProject(env);
|
|
32
|
+
const found = session({ file, env });
|
|
33
|
+
const project = guardProject(env, found.name);
|
|
34
|
+
const res = dc(project, ['down', '--remove-orphans', ...(volumes ? ['-v'] : [])], { root, state: found.state, env });
|
|
35
|
+
if (res.error) throw res.error;
|
|
36
|
+
return res.status ?? 1;
|
|
37
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// bin/snowglobe.mjs env [--app <name>] [--secrets] [--dotenv]
|
|
2
|
+
//
|
|
3
|
+
// env prints the settings an app is handed, from the apps[].env of the snowglobe.yml in this folder
|
|
4
|
+
// (or above), as `export NAME='value'` lines for `eval "$(snowglobe env)"`, plus
|
|
5
|
+
// NODE_EXTRA_CA_CERTS, the CA that signs the snowglobe's certificates. --dotenv prints NAME=value
|
|
6
|
+
// instead, for a framework's own file (.env.local, .dev.vars) or PowerShell. With one app in the
|
|
7
|
+
// file it is that app's; with several --app says which; with none only the CA is set.
|
|
8
|
+
//
|
|
9
|
+
// A secret (accounts.<name>.password) is left out unless --secrets is given, and a comment line
|
|
10
|
+
// names each one left out. snowglobe run hands the same settings over, secrets included. Values
|
|
11
|
+
// known only after up are read from the state folder; before then env says which are missing.
|
|
12
|
+
// lib/settings.mjs has the names.
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { ROOT, UsageError, envGet, paths, resolveDomain, setting } from '../env.mjs';
|
|
16
|
+
import { session } from '../state.mjs';
|
|
17
|
+
import { isSecret, lookup, parseValue } from '../settings.mjs';
|
|
18
|
+
import { accountPrefixes } from '../up.mjs';
|
|
19
|
+
import { checkedGlobe } from './check.mjs';
|
|
20
|
+
|
|
21
|
+
export const command = {
|
|
22
|
+
name: 'env',
|
|
23
|
+
usage: 'env [--app <name>] [--secrets] [--dotenv]',
|
|
24
|
+
options: { app: { type: 'string' }, secrets: { type: 'boolean' }, dotenv: { type: 'boolean' } },
|
|
25
|
+
positionals: [0, 0],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function run({ values }) {
|
|
29
|
+
const s = appSettings({ app: values.app, secrets: values.secrets });
|
|
30
|
+
process.stdout.write(formatEnv(s, { dotenv: values.dotenv }));
|
|
31
|
+
if (!s.caExists) console.error(`snowglobe env: ${s.ca} does not exist yet; snowglobe up (or certs) makes it`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The snowglobe a command reads settings from: { path, globe, state, p, later }. `later` gives the
|
|
36
|
+
* names known only after up, from the state folder (undefined where it has none yet). Throws,
|
|
37
|
+
* after printing each problem, when the file has any.
|
|
38
|
+
*/
|
|
39
|
+
function load({ file, root = ROOT, env = process.env } = {}) {
|
|
40
|
+
const found = session({ file, env });
|
|
41
|
+
const { set } = resolveDomain({ root, state: found.state, env });
|
|
42
|
+
const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
|
|
43
|
+
if (!globe) throw new Error(`${path} has problems (above)`);
|
|
44
|
+
const p = paths(root, found.state);
|
|
45
|
+
const home = (globe.pds.find((x) => x.bare_handles) ?? globe.pds[0]).name;
|
|
46
|
+
const prefixes = new Map(accountPrefixes(globe.accounts, home).map((a) => [a.name, a.prefix]));
|
|
47
|
+
const later = (name) => {
|
|
48
|
+
const [head, ...rest] = name.split('.');
|
|
49
|
+
const kept = (key) => envGet(key, p.accounts) || undefined;
|
|
50
|
+
// Each exists only once up has run; a path or port guessed before then would be wrong.
|
|
51
|
+
if (name === 'ca.file') return existsSync(p.ca) ? p.ca : undefined;
|
|
52
|
+
if (name === 'accounts.file') return existsSync(p.accounts) ? p.accounts : undefined;
|
|
53
|
+
// The mail page's port is a setting, not recorded in the state; the rendered globe says up has run.
|
|
54
|
+
if (name === 'services.mail.url') return !existsSync(p.compose) ? undefined : `http://127.0.0.1:${setting('SG_MAILDEV_WEB_PORT', { root, env }) || '1081'}`;
|
|
55
|
+
if (name === 'lexicons.authority.did') return kept('LEX_AUTHORITY_DID');
|
|
56
|
+
if (head === 'accounts') return kept(`${prefixes.get(rest[0])}_${rest[1].toUpperCase()}`);
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
return { path, globe, state: found.state, p, later };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The text of a value with its placeholders filled, or the names that could not be.
|
|
63
|
+
function fill(globe, text, later) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const part of parseValue(text).parts) {
|
|
66
|
+
if (part.text !== undefined) out.push(part.text);
|
|
67
|
+
else {
|
|
68
|
+
const r = lookup(globe, part.name, later);
|
|
69
|
+
if (r.error) throw new Error(r.error);
|
|
70
|
+
out.push(r.value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out.join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The settings of one app of the file: { vars, left, ca, caExists }. vars is [{ name, value }] in the
|
|
78
|
+
* order the file gives them; left is [{ name, via }] for the secrets not included (without
|
|
79
|
+
* `secrets`), via the secret names in the value. The app is `app`, or the file's one app; with
|
|
80
|
+
* several `app` is required, with none vars is empty. NODE_EXTRA_CA_CERTS joins vars unless the
|
|
81
|
+
* app sets it itself.
|
|
82
|
+
*/
|
|
83
|
+
export function appSettings({ app, secrets = false, file, root = ROOT, env = process.env } = {}) {
|
|
84
|
+
const { path, globe, p, later } = load({ file, root, env });
|
|
85
|
+
const names = globe.apps.map((a) => a.name);
|
|
86
|
+
let chosen = null;
|
|
87
|
+
if (app !== undefined) {
|
|
88
|
+
chosen = globe.apps.find((a) => a.name === app);
|
|
89
|
+
if (!chosen) throw new UsageError(`${path} has no app named ${app} (${names.join(', ') || 'it lists none'})`);
|
|
90
|
+
} else if (globe.apps.length === 1) chosen = globe.apps[0];
|
|
91
|
+
else if (globe.apps.length > 1) throw new UsageError(`${path} lists several apps (${names.join(', ')}); say which with --app <name>`);
|
|
92
|
+
|
|
93
|
+
const vars = [];
|
|
94
|
+
const left = [];
|
|
95
|
+
for (const [name, text] of Object.entries(chosen?.env ?? {})) {
|
|
96
|
+
const via = parseValue(text).parts.filter((x) => x.name && isSecret(x.name)).map((x) => x.name);
|
|
97
|
+
if (via.length && !secrets) left.push({ name, via });
|
|
98
|
+
else {
|
|
99
|
+
try {
|
|
100
|
+
const value = fill(globe, text, later);
|
|
101
|
+
// Said by name only: the value may hold a login.
|
|
102
|
+
if (value.includes('\0')) throw new Error('the value contains a NUL character, which a process cannot be given');
|
|
103
|
+
vars.push({ name, value });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
throw new Error(`${path}: apps[${globe.apps.indexOf(chosen)}].env.${name}: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!vars.some((v) => v.name === 'NODE_EXTRA_CA_CERTS')) vars.push({ name: 'NODE_EXTRA_CA_CERTS', value: p.ca, auto: true });
|
|
110
|
+
return { vars, left, ca: p.ca, caExists: existsSync(p.ca) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The values of `names` (such as 'domain' or 'lexicons.authority.did') in the snowglobe file reads,
|
|
115
|
+
* for a script that wants a fact without an app to map it. Throws on a name the file has no value for.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveNames(names, { file, root = ROOT, env = process.env } = {}) {
|
|
118
|
+
const { globe, later } = load({ file, root, env });
|
|
119
|
+
return names.map((n) => fill(globe, `\${${n}}`, later));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const quoteSh = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
|
|
123
|
+
// A dotenv value as dotenv (16) reads it, and Vite, which expands $NAME and ${NAME} after it. Bare
|
|
124
|
+
// when plain; else in the first quote the value does not hold, single first because dotenv takes
|
|
125
|
+
// that literally (double quotes expand \n and \r). null when no form reads back as the value: a
|
|
126
|
+
// $ that starts a name, a carriage return, or a value with every quote in it.
|
|
127
|
+
function quoteDotenv(v) {
|
|
128
|
+
if (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v)) return v;
|
|
129
|
+
if (/\$[A-Za-z_{]/.test(v) || v.includes('\r')) return null;
|
|
130
|
+
for (const q of ["'", '`', '"']) if (!v.includes(q) && !(q === '"' && /\\[nr]/.test(v))) return `${q}${v}${q}`;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The lines env prints for `settings` (appSettings): export lines, or NAME=value with `dotenv`. */
|
|
135
|
+
export function formatEnv({ vars, left }, { dotenv = false } = {}) {
|
|
136
|
+
const line = (v) => {
|
|
137
|
+
if (!dotenv) return `export ${v.name}=${quoteSh(v.value)}`;
|
|
138
|
+
const q = quoteDotenv(v.value);
|
|
139
|
+
if (q === null) throw new Error(`${v.name} cannot be written as a dotenv value (it holds a $ that starts a name, a carriage return, or every kind of quote); use snowglobe env without --dotenv`);
|
|
140
|
+
return `${v.name}=${q}`;
|
|
141
|
+
};
|
|
142
|
+
const note = (l) => `# ${l.name} left out: ${l.via.join(', ')} ${l.via.length > 1 ? 'are secrets' : 'is a secret'}; --secrets includes it`;
|
|
143
|
+
const lines = [...vars.filter((v) => v.name !== 'NODE_EXTRA_CA_CERTS').map(line), ...left.map(note), ...vars.filter((v) => v.name === 'NODE_EXTRA_CA_CERTS').map(line)];
|
|
144
|
+
return lines.map((l) => `${l}\n`).join('');
|
|
145
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// bin/snowglobe.mjs invite <VAR> [<file>] [--pds <pds>]
|
|
2
|
+
//
|
|
3
|
+
// invite mints an invite code, INVITE_USES uses (100), on a PDS with invites: required (the only
|
|
4
|
+
// one, unless --pds names it) and writes <VAR>=<code> to <file> (accounts.env in the snowglobe's state folder).
|
|
5
|
+
// SG_PDS_ADMIN_PASSWORD as the globe was started with. It reads the rendered globe, and
|
|
6
|
+
// prints no secret (lib/xrpc.mjs).
|
|
7
|
+
|
|
8
|
+
import { ROOT, UsageError, envPut, globeFacts, loginsFile, paths } from '../env.mjs';
|
|
9
|
+
import { session } from '../state.mjs';
|
|
10
|
+
import { mintInvite } from '../xrpc.mjs';
|
|
11
|
+
|
|
12
|
+
export const command = {
|
|
13
|
+
name: 'invite',
|
|
14
|
+
usage: 'invite <VAR> [<file>] [--pds <pds>]',
|
|
15
|
+
options: { pds: { type: 'string' } },
|
|
16
|
+
positionals: [1, 2],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function run({ values, positionals: [variable, file] }) {
|
|
20
|
+
await invite({ variable, file, pds: values.pds, state: session().state, log: (m) => console.error(m) });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mint an invite code on `pds`, a PDS of the globe that takes them (the only one, by default),
|
|
25
|
+
* with INVITE_USES uses (100 by default), and keep it as <variable>=<code> in `file`
|
|
26
|
+
* (accounts.env in the snowglobe's state folder by default). The code is never printed.
|
|
27
|
+
*/
|
|
28
|
+
export async function invite({ variable, pds, file, root = ROOT, state, env = process.env, log = () => {} }) {
|
|
29
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(variable ?? '')) throw new UsageError(`<VAR> must be an env var name (A-Z, 0-9, _): ${variable}`);
|
|
30
|
+
const uses = env.INVITE_USES ?? '100';
|
|
31
|
+
if (!/^[1-9][0-9]*$/.test(uses)) throw new UsageError('INVITE_USES must be a positive number');
|
|
32
|
+
const g = globeFacts({ root, state, env });
|
|
33
|
+
let where = pds;
|
|
34
|
+
if (!where) {
|
|
35
|
+
if (g.invites.length !== 1)
|
|
36
|
+
throw new UsageError(
|
|
37
|
+
g.invites.length
|
|
38
|
+
? `more than one PDS takes invite codes (${g.invites.join(', ')}); name one with --pds`
|
|
39
|
+
: 'no PDS in this globe takes invite codes (invites: required)',
|
|
40
|
+
);
|
|
41
|
+
where = g.invites[0];
|
|
42
|
+
} else if (!g.pds.includes(where)) throw new UsageError(`${where} is not a PDS in this globe (${g.pds.join(', ')})`);
|
|
43
|
+
else if (!g.invites.includes(where)) throw new UsageError(`${where} takes no invite codes (its invites are open)`);
|
|
44
|
+
const code = await mintInvite(`https://${where}.${g.domain}`, Number(uses), { root, state, env });
|
|
45
|
+
const target = loginsFile(file || paths(root, state).accounts);
|
|
46
|
+
envPut(target, new RegExp(`^${variable}=`), [`${variable}=${code}`]);
|
|
47
|
+
log(`Wrote ${variable} to ${target}: an invite code for ${where}, ${uses} uses`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// bin/snowglobe.mjs lexicons <dir>
|
|
2
|
+
//
|
|
3
|
+
// lexicons publishes every lexicon document under <dir> into the lexicon authority
|
|
4
|
+
// (LEX_AUTHORITY_* in ACCOUNTS_FILE), leaving one that holds the same document alone, and prints
|
|
5
|
+
// each NSID. SG_PUBLIC=1 for a globe on the cluster, with ACCOUNTS_FILE its operator logins.
|
|
6
|
+
// It prints no secret (lib/xrpc.mjs).
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { ROOT, UsageError, envGet, paths } from '../env.mjs';
|
|
11
|
+
import { session } from '../state.mjs';
|
|
12
|
+
import { why, xrpc } from '../xrpc.mjs';
|
|
13
|
+
|
|
14
|
+
const NSID_RE = /^[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}$/;
|
|
15
|
+
|
|
16
|
+
export const command = {
|
|
17
|
+
name: 'lexicons',
|
|
18
|
+
usage: 'lexicons <dir>',
|
|
19
|
+
options: {},
|
|
20
|
+
positionals: [1, 1],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function run({ positionals: [dir] }) {
|
|
24
|
+
await lexicons({ dir, state: session().state, log: (m) => console.error(m), out: (nsid) => console.log(nsid) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// JSON with its keys sorted at every depth, so two equal documents compare equal as text.
|
|
28
|
+
function canonical(v) {
|
|
29
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
30
|
+
if (v && typeof v === 'object')
|
|
31
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
32
|
+
return JSON.stringify(v);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every *.json file under dir, at any depth, in byte order of their paths.
|
|
36
|
+
function jsonFiles(dir) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const walk = (d) => {
|
|
39
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
40
|
+
const f = join(d, e.name);
|
|
41
|
+
if (e.isDirectory()) walk(f);
|
|
42
|
+
else if (e.isFile() && e.name.endsWith('.json')) out.push(f);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(dir);
|
|
46
|
+
return out.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Publish the lexicons under `dir` (each *.json whose "lexicon" is 1 and whose "id" is an NSID;
|
|
51
|
+
* other JSON is skipped) into the lexicon authority as com.atproto.lexicon.schema records keyed by
|
|
52
|
+
* NSID. A record already holding the same document is left alone. `out` gets each NSID. The login
|
|
53
|
+
* is LEX_AUTHORITY_* in ACCOUNTS_FILE, else accounts.env in the snowglobe's state folder, which snowglobe up writes. With
|
|
54
|
+
* SG_PUBLIC=1 the authority's PDS is a public name (a globe on the cluster).
|
|
55
|
+
*/
|
|
56
|
+
export async function lexicons({ dir, root = ROOT, state, env = process.env, log = () => {}, out = () => {} }) {
|
|
57
|
+
if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) throw new UsageError(`no folder ${dir}`);
|
|
58
|
+
const accounts = env.ACCOUNTS_FILE || paths(root, state).accounts;
|
|
59
|
+
const did = envGet('LEX_AUTHORITY_DID', accounts);
|
|
60
|
+
const pdsUrl = envGet('LEX_AUTHORITY_PDS', accounts);
|
|
61
|
+
const password = envGet('LEX_AUTHORITY_PASSWORD', accounts);
|
|
62
|
+
if (!did || !pdsUrl || !password) throw new Error(`no LEX_AUTHORITY_* login in ${accounts}; run snowglobe up`);
|
|
63
|
+
const opts = { root, state, env };
|
|
64
|
+
|
|
65
|
+
const session = await xrpc(pdsUrl, 'com.atproto.server.createSession', { body: { identifier: did, password }, ...opts });
|
|
66
|
+
if (!session.ok || typeof session.json.accessJwt !== 'string') throw new Error(`the authority cannot sign in: ${why(session)}`);
|
|
67
|
+
const auth = `Bearer ${session.json.accessJwt}`;
|
|
68
|
+
|
|
69
|
+
const n = { published: 0, unchanged: 0, skipped: 0 };
|
|
70
|
+
for (const f of jsonFiles(dir)) {
|
|
71
|
+
let doc;
|
|
72
|
+
try {
|
|
73
|
+
doc = JSON.parse(readFileSync(f, 'utf8'));
|
|
74
|
+
} catch {}
|
|
75
|
+
const nsid = doc?.lexicon === 1 && typeof doc.id === 'string' ? doc.id : '';
|
|
76
|
+
if (!NSID_RE.test(nsid)) {
|
|
77
|
+
log(`skipped (not a lexicon document): ${f}`);
|
|
78
|
+
n.skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const record = { ...doc, $type: 'com.atproto.lexicon.schema' };
|
|
82
|
+
const key = { repo: did, collection: 'com.atproto.lexicon.schema', rkey: nsid };
|
|
83
|
+
const current = await xrpc(pdsUrl, 'com.atproto.repo.getRecord', { query: key, ...opts });
|
|
84
|
+
if (current.ok && current.json.value && canonical(current.json.value) === canonical(record)) n.unchanged++;
|
|
85
|
+
else {
|
|
86
|
+
const put = await xrpc(pdsUrl, 'com.atproto.repo.putRecord', { body: { ...key, record }, auth, ...opts });
|
|
87
|
+
if (!put.ok || typeof put.json.uri !== 'string') throw new Error(`putRecord ${nsid} failed: ${why(put)}`);
|
|
88
|
+
n.published++;
|
|
89
|
+
}
|
|
90
|
+
out(nsid);
|
|
91
|
+
}
|
|
92
|
+
log(`Authority ${did}: ${n.published} published, ${n.unchanged} unchanged, ${n.skipped} skipped`);
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|