Repository navigation
Expand file tree
/
Copy pathbootstrap
More file actions
executable file
·260 lines (233 loc) · 9.26 KB
/
Copy pathbootstrap
File metadata and controls
executable file
·260 lines (233 loc) · 9.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import process from "node:process";
import { deriveSiteHost, escapeForCompose, generateSecret, renderEnv, renderSiteJson } from "./bootstrap.lib.mjs";
const force = process.argv.includes("--force");
const fail = (message) => {
console.error(message);
process.exit(1);
};
if (!existsSync("compose.yml")) {
fail("Run ./bootstrap from the indiekit-quickstart repo root (no compose.yml here).");
}
// Both halves matter, and only one of them is obvious. A new SECRET signs you
// out; a new PASSWORD_SECRET means the password you had no longer opens the
// site. Warning about sessions alone reads as "you will need to sign in
// again", which is exactly the wrong thing to believe here.
const REPLACE_WARNING =
"Replacing .env writes a new SECRET and a new password hash: every session and token already issued stops working, and the admin password becomes the one you enter now.";
if (existsSync(".env")) {
if (!force) {
fail(`.env already exists. Bootstrap will not overwrite it.\n${REPLACE_WARNING}\nPass --force to replace it.`);
}
console.log(`--force given. ${REPLACE_WARNING}`);
}
// Docker is needed to hash the password, so check before asking for anything.
try {
execFileSync("docker", ["compose", "version"], { stdio: "pipe" });
} catch {
fail("Docker does not appear to be running. Start it and try again.");
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
// Every prompt goes through rawQuestion, so there is exactly one place that
// turns Ctrl-D into something other than an uncaught error and a raw stack
// trace: on a real TTY, reaching EOF mid-prompt rejects the *pending*
// rl.question() with AbortError, and also closes rl — every later
// rl.question() then rejects with ERR_USE_AFTER_CLOSE. Both map to the same
// Eof, so Ctrl-D anywhere behaves the same way at every later prompt: ask()
// falls back to its default, askList() returns whatever it already has, and
// readPassword() gives up cleanly. (A non-TTY stdin behaves differently
// again — it never resolves at all, which is why ask() and askList() below
// never call this without checking isTTY first.)
class Eof extends Error {}
const rawQuestion = async (prompt) => {
try {
return await rl.question(prompt);
} catch (error) {
if (error.name === "AbortError" || error.code === "ERR_USE_AFTER_CLOSE") throw new Eof();
throw error;
}
};
// The one way to ask a single question with a default. Owns both the
// non-TTY fallback and the Ctrl-D fallback — no bare rl.question outside
// this and askList()/readPassword() below.
const ask = async (question, fallback) => {
if (!process.stdin.isTTY) return fallback;
try {
const answer = (await rawQuestion(`${question} [${fallback}] `)).trim();
return answer === "" ? fallback : answer;
} catch (error) {
if (error instanceof Eof) return fallback;
throw error;
}
};
// Same shape as ask(), for a repeated free-form list (rel=me links). There
// is no single fallback value to hand back unattended or on Ctrl-D, so both
// mean "none".
const askList = async (question) => {
if (!process.stdin.isTTY) return [];
console.log(` ${question}, one per line, blank to finish:`);
const items = [];
try {
for (;;) {
const line = (await rawQuestion(" ")).trim();
if (line === "") break;
items.push(line);
}
} catch (error) {
if (!(error instanceof Eof)) throw error;
}
return items;
};
const readPassword = async () => {
if (!process.stdin.isTTY) {
const fromEnvironment = process.env.INDIEKIT_PASSWORD;
if (!fromEnvironment || fromEnvironment.length < 8) {
fail(
"No terminal to prompt on. Set INDIEKIT_PASSWORD (at least 8 characters)\n" +
"to run bootstrap unattended.",
);
}
return fromEnvironment;
}
// Mute keystroke echo for one prompt. readline/promises has no
// _writeToOutput hook to override (that trick is classic callback-style
// readline only) so intercept the output stream's write() instead,
// restoring it in a finally — including on throw, e.g. Ctrl-D mid-prompt.
const askHidden = async (prompt) => {
const output = rl.output;
const originalWrite = output.write.bind(output);
originalWrite(prompt);
let muted = true;
output.write = (chunk, ...args) => (muted ? true : originalWrite(chunk, ...args));
try {
return await rawQuestion("");
} finally {
muted = false;
output.write = originalWrite;
originalWrite("\n");
}
};
try {
for (;;) {
const first = await askHidden("Password for the admin interface: ");
if (first.length < 8) {
console.error(" At least 8 characters, please.");
continue;
}
const second = await askHidden("Again: ");
if (first !== second) {
console.error(" Those did not match.");
continue;
}
return first;
}
} catch (error) {
if (error instanceof Eof) fail("\nNo password entered.");
throw error;
}
};
const hashPassword = (password, composeEnv) => {
try {
// Password travels on stdin, never argv: /proc/<pid>/cmdline is
// world-readable, so an argv password would leak to any local user.
return execFileSync(
"docker",
["compose", "run", "--rm", "--no-deps", "-T", "--entrypoint", "node", "indiekit",
"-e", `let d = "";
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () =>
import("bcrypt").then((m) => m.default.hash(d, 10)).then((h) => process.stdout.write(h)),
);`],
{
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
input: password,
// compose.yml interpolates ${SITE_HOST} etc. to parse the file at
// all, even for --no-deps on an unrelated service. There is no .env
// yet — writing it is what this call is a step towards — so those
// variables must be supplied here directly.
env: { ...process.env, ...composeEnv },
},
).trim();
} catch (error) {
fail(
`Could not run the Indiekit image to hash the password: ${error.stderr?.trim() || error.message}\n` +
"If ghcr.io/getindiekit/indiekit:beta is not published yet, build it\n" +
"locally and use compose.local.yml — see the README.",
);
}
};
// --- Collect every answer first. Nothing is written to disk until every
// answer (site URL, password, identity) is in hand and the password is
// hashed — a failed or abandoned run must never leave .env written without
// a matching site.json, or vice versa.
let siteUrl;
while (siteUrl === undefined) {
const answer = await ask("Site URL", process.env.INDIEKIT_SITE_URL || "http://quickstart.localhost");
try {
deriveSiteHost(answer);
siteUrl = answer;
} catch {
console.error(` ${answer} is not a URL. Include the scheme, e.g. https://example.com`);
}
}
const password = await readPassword();
// Identity: the h-card, rel="author" and the author meta tag all come from
// site.json. An edited site.json is left alone, same as without --force.
const siteTemplate = readFileSync("site.json", "utf8");
const siteUntouched = siteTemplate === readFileSync("site.example.json", "utf8");
const askIdentity = siteUntouched || force;
let identity;
if (askIdentity) {
const template = JSON.parse(siteTemplate);
const name = await ask("Site name", template.name);
const description = await ask("Description", template.description);
const timezone = await ask("Timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
const authorName = await ask("Your name", process.env.INDIEKIT_AUTHOR_NAME || "");
const authorUrl = await ask("Your URL", siteUrl);
const authorNote = await ask("A line about you (optional)", "");
const authorMe = await askList("rel=me links");
identity = { name, description, timezone, authorName, authorUrl, authorNote, authorMe };
if (!authorName) {
console.error(
"Warning: no author name set, so the site will publish with no discoverable\n" +
"identity (h-card, rel=author and the author meta tag all come from\n" +
"site.json's author.name). Set INDIEKIT_AUTHOR_NAME and re-run, or edit\n" +
"site.json's author.name afterwards.",
);
}
}
rl.close();
// --- Everything needed is now in hand. Hash the password — the one
// remaining step that can fail — then write both files.
const uid = execFileSync("id", ["-u"]).toString().trim();
const gid = execFileSync("id", ["-g"]).toString().trim();
const secret = generateSecret();
const siteHost = deriveSiteHost(siteUrl);
const passwordSecret = hashPassword(password, {
SITE_URL: siteUrl,
SITE_HOST: siteHost,
SECRET: secret,
UID_GID: `${uid}:${gid}`,
});
writeFileSync(
".env",
renderEnv(readFileSync(".env.example", "utf8"), {
SITE_URL: siteUrl,
SITE_HOST: siteHost,
SECRET: secret,
// Compose eats `$` in a .env value; a bcrypt hash is full of them.
PASSWORD_SECRET: escapeForCompose(passwordSecret),
UID_GID: `${uid}:${gid}`,
}),
);
console.log("Wrote .env");
if (identity) {
writeFileSync("site.json", renderSiteJson(siteTemplate, identity));
console.log("Wrote site.json");
} else {
console.log("site.json has been edited; leaving it alone.");
}