create-paratix 0.0.1 → 0.2.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/README.md +218 -43
- package/dist/index.d.ts +57 -0
- package/dist/index.js +1183 -0
- package/package.json +25 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
5
|
+
import { basename as basename2, join as join2, resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// src/interactivePrompts.ts
|
|
8
|
+
import { createInterface } from "readline/promises";
|
|
9
|
+
|
|
10
|
+
// src/hostFingerprintBootstrap.ts
|
|
11
|
+
import { createHash } from "crypto";
|
|
12
|
+
import { Client } from "ssh2";
|
|
13
|
+
var DEFAULT_HOST_FINGERPRINT_PORT = 22;
|
|
14
|
+
var DEFAULT_READY_TIMEOUT_MS = 1e4;
|
|
15
|
+
function computeFingerprint(key) {
|
|
16
|
+
const hash = createHash("sha256").update(key).digest("base64");
|
|
17
|
+
return `SHA256:${hash.replaceAll("=", "")}`;
|
|
18
|
+
}
|
|
19
|
+
function toError(error, host, port) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
return new Error(`Failed to read the host key from ${host}:${port}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
function createConnectionConfig(parameters) {
|
|
24
|
+
const { captureFingerprint, host, port, readyTimeoutMs } = parameters;
|
|
25
|
+
return {
|
|
26
|
+
host,
|
|
27
|
+
hostVerifier: (key) => {
|
|
28
|
+
captureFingerprint(computeFingerprint(Buffer.from(key)));
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
port,
|
|
32
|
+
readyTimeout: readyTimeoutMs,
|
|
33
|
+
username: "paratix-hostkey-scan"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function resolveBootstrapOptions(options) {
|
|
37
|
+
const clientFactory = options.clientFactory ?? (() => new Client());
|
|
38
|
+
return {
|
|
39
|
+
client: clientFactory(),
|
|
40
|
+
port: options.port ?? DEFAULT_HOST_FINGERPRINT_PORT,
|
|
41
|
+
readyTimeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function cleanupClient(client) {
|
|
45
|
+
client.removeAllListeners();
|
|
46
|
+
try {
|
|
47
|
+
client.end();
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function createSettlementHandlers(parameters) {
|
|
52
|
+
const { cleanup, host, port, reject, resolve: resolve2 } = parameters;
|
|
53
|
+
let settled = false;
|
|
54
|
+
return {
|
|
55
|
+
rejectOnce: (error) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(toError(error, host, port));
|
|
60
|
+
},
|
|
61
|
+
resolveOnce: (fingerprint) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
cleanup();
|
|
65
|
+
resolve2(fingerprint);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function registerFingerprintListeners(parameters) {
|
|
70
|
+
const { client, onFingerprint, rejectOnce, resolveOnce } = parameters;
|
|
71
|
+
client.on("close", () => {
|
|
72
|
+
const capturedFingerprint = onFingerprint();
|
|
73
|
+
if (capturedFingerprint != null) {
|
|
74
|
+
resolveOnce(capturedFingerprint);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
client.on("error", (error) => {
|
|
78
|
+
const capturedFingerprint = onFingerprint();
|
|
79
|
+
if (capturedFingerprint != null) {
|
|
80
|
+
resolveOnce(capturedFingerprint);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
rejectOnce(error);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function readFingerprintFromClient(client, parameters) {
|
|
87
|
+
const { host, port, readyTimeoutMs } = parameters;
|
|
88
|
+
let capturedFingerprint = null;
|
|
89
|
+
return new Promise((resolve2, reject) => {
|
|
90
|
+
const cleanup = () => {
|
|
91
|
+
cleanupClient(client);
|
|
92
|
+
};
|
|
93
|
+
const { rejectOnce, resolveOnce } = createSettlementHandlers({
|
|
94
|
+
cleanup,
|
|
95
|
+
host,
|
|
96
|
+
port,
|
|
97
|
+
reject,
|
|
98
|
+
resolve: resolve2
|
|
99
|
+
});
|
|
100
|
+
registerFingerprintListeners({
|
|
101
|
+
client,
|
|
102
|
+
onFingerprint: () => capturedFingerprint,
|
|
103
|
+
rejectOnce,
|
|
104
|
+
resolveOnce
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
client.connect(
|
|
108
|
+
createConnectionConfig({
|
|
109
|
+
captureFingerprint: (fingerprint) => {
|
|
110
|
+
capturedFingerprint = fingerprint;
|
|
111
|
+
},
|
|
112
|
+
host,
|
|
113
|
+
port,
|
|
114
|
+
readyTimeoutMs
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
rejectOnce(error);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function readHostFingerprintViaSsh2(host, options = {}) {
|
|
123
|
+
const { client, port, readyTimeoutMs } = resolveBootstrapOptions(options);
|
|
124
|
+
return readFingerprintFromClient(client, { host, port, readyTimeoutMs });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/promptUi.ts
|
|
128
|
+
import { emitKeypressEvents } from "readline";
|
|
129
|
+
function createSelectLines(prompt, options, selectedIndex) {
|
|
130
|
+
return [
|
|
131
|
+
prompt,
|
|
132
|
+
"",
|
|
133
|
+
"Use the arrow keys to choose an option:",
|
|
134
|
+
...options.flatMap((option, index) => {
|
|
135
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
136
|
+
return [`${prefix} ${option.label}`, ` ${option.description}`];
|
|
137
|
+
}),
|
|
138
|
+
"",
|
|
139
|
+
"Press Enter to confirm."
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
function redrawSelect(lines, renderedLines) {
|
|
143
|
+
if (renderedLines > 0) {
|
|
144
|
+
for (let index = 0; index < renderedLines; index++) {
|
|
145
|
+
process.stdout.write("\x1B[1A\x1B[2K");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(`${lines.join("\n")}
|
|
149
|
+
`);
|
|
150
|
+
return lines.length;
|
|
151
|
+
}
|
|
152
|
+
function ensureInteractiveTerminal() {
|
|
153
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Interactive selection requires a TTY. Use --initial-user <root|name> in non-interactive environments."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function prepareSelectInput() {
|
|
160
|
+
emitKeypressEvents(process.stdin);
|
|
161
|
+
const previousRawMode = process.stdin.isRaw;
|
|
162
|
+
process.stdin.setRawMode(true);
|
|
163
|
+
process.stdin.resume();
|
|
164
|
+
return { previousRawMode };
|
|
165
|
+
}
|
|
166
|
+
function cleanupSelectInput(previousRawMode) {
|
|
167
|
+
process.stdin.setRawMode(previousRawMode ?? false);
|
|
168
|
+
process.stdin.pause();
|
|
169
|
+
process.stdout.write("\x1B[?25h");
|
|
170
|
+
}
|
|
171
|
+
function createSelectRenderer(prompt, options) {
|
|
172
|
+
let renderedLines = 0;
|
|
173
|
+
let selectedIndex = 0;
|
|
174
|
+
return {
|
|
175
|
+
moveDown: () => {
|
|
176
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
177
|
+
},
|
|
178
|
+
moveUp: () => {
|
|
179
|
+
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
180
|
+
},
|
|
181
|
+
render: () => {
|
|
182
|
+
renderedLines = redrawSelect(createSelectLines(prompt, options, selectedIndex), renderedLines);
|
|
183
|
+
},
|
|
184
|
+
selectedValue: () => options[selectedIndex].value
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function finishSelection(cleanup, resolver, value) {
|
|
188
|
+
cleanup();
|
|
189
|
+
resolver(value);
|
|
190
|
+
}
|
|
191
|
+
function handleNavigationKey(keyName, renderer) {
|
|
192
|
+
if (keyName === "down") {
|
|
193
|
+
renderer.moveDown();
|
|
194
|
+
renderer.render();
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (keyName === "up") {
|
|
198
|
+
renderer.moveUp();
|
|
199
|
+
renderer.render();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
function handleConfirmationKey(keyName, renderer, confirmSelection) {
|
|
205
|
+
if (keyName !== "enter" && keyName !== "return") {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
process.stdout.write("\n");
|
|
209
|
+
confirmSelection(renderer.selectedValue());
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
function handleCancelKey(key, cleanup, reject) {
|
|
213
|
+
if (key.ctrl && key.name === "c") {
|
|
214
|
+
process.stdout.write("\n");
|
|
215
|
+
cleanup();
|
|
216
|
+
reject(new Error("Prompt cancelled."));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function runTerminalSelect(prompt, options) {
|
|
220
|
+
ensureInteractiveTerminal();
|
|
221
|
+
const { previousRawMode } = prepareSelectInput();
|
|
222
|
+
const renderer = createSelectRenderer(prompt, options);
|
|
223
|
+
return new Promise((resolve2, reject) => {
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
226
|
+
cleanupSelectInput(previousRawMode);
|
|
227
|
+
};
|
|
228
|
+
const onKeypress = (_character, key) => {
|
|
229
|
+
if (handleNavigationKey(key.name, renderer)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (handleConfirmationKey(key.name, renderer, (value) => {
|
|
233
|
+
finishSelection(cleanup, resolve2, value);
|
|
234
|
+
})) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
handleCancelKey(key, cleanup, reject);
|
|
238
|
+
};
|
|
239
|
+
process.stdout.write("\x1B[?25l");
|
|
240
|
+
renderer.render();
|
|
241
|
+
process.stdin.on("keypress", onKeypress);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function createTerminalSelect() {
|
|
245
|
+
return {
|
|
246
|
+
close: () => void 0,
|
|
247
|
+
select: async (prompt, options) => runTerminalSelect(prompt, options)
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/publicKeySelection.ts
|
|
252
|
+
import { readdirSync, readFileSync } from "fs";
|
|
253
|
+
import { homedir } from "os";
|
|
254
|
+
import { basename, join } from "path";
|
|
255
|
+
var PUBLIC_KEY_PROMPT_OPTIONS = [
|
|
256
|
+
{
|
|
257
|
+
description: "Read a public key from ~/.ssh and embed it directly into server.ts for the bootstrap admin user.",
|
|
258
|
+
label: "Use local public key",
|
|
259
|
+
value: "local"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
description: "Keep the placeholder in server.ts and paste your public key manually before the first apply.",
|
|
263
|
+
label: "Keep placeholder",
|
|
264
|
+
value: "placeholder"
|
|
265
|
+
}
|
|
266
|
+
];
|
|
267
|
+
var supportedOpenSshAlgorithms = /* @__PURE__ */ new Set([
|
|
268
|
+
"ecdsa-sha2-nistp256",
|
|
269
|
+
"ecdsa-sha2-nistp384",
|
|
270
|
+
"ecdsa-sha2-nistp521",
|
|
271
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
272
|
+
"sk-ssh-ed25519@openssh.com",
|
|
273
|
+
"ssh-ed25519",
|
|
274
|
+
"ssh-rsa"
|
|
275
|
+
]);
|
|
276
|
+
function parseOpenSshPublicKey(value) {
|
|
277
|
+
if (value.length === 0 || value.includes("\n")) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const parts = value.split(new RegExp("\\s+", "v"));
|
|
281
|
+
if (parts.length < 2) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const algorithm = parts[0];
|
|
285
|
+
const encodedKey = parts[1];
|
|
286
|
+
if (!supportedOpenSshAlgorithms.has(algorithm)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
algorithm,
|
|
291
|
+
encodedKey
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function trimBase64Padding(value) {
|
|
295
|
+
let endIndex = value.length;
|
|
296
|
+
while (endIndex > 0 && value[endIndex - 1] === "=") {
|
|
297
|
+
endIndex--;
|
|
298
|
+
}
|
|
299
|
+
return value.slice(0, endIndex);
|
|
300
|
+
}
|
|
301
|
+
function isBase64AlphaNumeric(character) {
|
|
302
|
+
return character >= "A" && character <= "Z" || character >= "a" && character <= "z" || character >= "0" && character <= "9";
|
|
303
|
+
}
|
|
304
|
+
function isBase64DataCharacter(character) {
|
|
305
|
+
return isBase64AlphaNumeric(character) || character === "+" || character === "/";
|
|
306
|
+
}
|
|
307
|
+
function updatePaddingState(character, state) {
|
|
308
|
+
if (character !== "=") {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
const nextState = {
|
|
312
|
+
paddingCount: state.paddingCount + 1,
|
|
313
|
+
sawPadding: true
|
|
314
|
+
};
|
|
315
|
+
return nextState.paddingCount <= 2 ? nextState : null;
|
|
316
|
+
}
|
|
317
|
+
function hasValidBase64Alphabet(value) {
|
|
318
|
+
if (value.length === 0) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const state = { paddingCount: 0, sawPadding: false };
|
|
322
|
+
for (const character of value) {
|
|
323
|
+
if (isBase64DataCharacter(character)) {
|
|
324
|
+
if (state.sawPadding) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const nextState = updatePaddingState(character, state);
|
|
330
|
+
if (nextState != null) {
|
|
331
|
+
state.paddingCount = nextState.paddingCount;
|
|
332
|
+
state.sawPadding = nextState.sawPadding;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
function isCanonicalBase64(value) {
|
|
340
|
+
if (!hasValidBase64Alphabet(value)) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const decoded = Buffer.from(value, "base64");
|
|
345
|
+
if (decoded.length === 0) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
const normalizedValue = trimBase64Padding(value);
|
|
349
|
+
const encodedAgain = trimBase64Padding(decoded.toString("base64"));
|
|
350
|
+
return encodedAgain === normalizedValue;
|
|
351
|
+
} catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function isValidAdminPublicKey(value) {
|
|
356
|
+
const parsedKey = parseOpenSshPublicKey(value.trim());
|
|
357
|
+
return parsedKey != null && isCanonicalBase64(parsedKey.encodedKey);
|
|
358
|
+
}
|
|
359
|
+
function validateAdminPublicKey(exitWithMessage2, value, optionName = "--admin-public-key") {
|
|
360
|
+
const normalizedValue = value.trim();
|
|
361
|
+
if (!isValidAdminPublicKey(normalizedValue)) {
|
|
362
|
+
exitWithMessage2(
|
|
363
|
+
`Error: Invalid value for "${optionName}" \u2014 provide a valid single-line OpenSSH public key.`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return normalizedValue;
|
|
367
|
+
}
|
|
368
|
+
function readAdminPublicKeyFile(exitWithMessage2, path) {
|
|
369
|
+
let value;
|
|
370
|
+
try {
|
|
371
|
+
value = readFileSync(path, "utf8");
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
+
exitWithMessage2(`Error: Failed to read "--admin-public-key-file" from "${path}": ${message}`);
|
|
375
|
+
}
|
|
376
|
+
return validateAdminPublicKey(exitWithMessage2, value, "--admin-public-key-file");
|
|
377
|
+
}
|
|
378
|
+
function discoverLocalPublicKeys(sshDirectory = join(homedir(), ".ssh")) {
|
|
379
|
+
try {
|
|
380
|
+
return readdirSync(sshDirectory).filter((entry) => entry.endsWith(".pub")).sort((left, right) => left.localeCompare(right)).flatMap((entry) => {
|
|
381
|
+
const path = join(sshDirectory, entry);
|
|
382
|
+
try {
|
|
383
|
+
const key = readFileSync(path, "utf8").trim();
|
|
384
|
+
if (!isValidAdminPublicKey(key)) {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
return [{ key, label: basename(entry), path }];
|
|
388
|
+
} catch {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
} catch {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function createPublicKeyOptions(publicKeys) {
|
|
397
|
+
return publicKeys.map((publicKey) => ({
|
|
398
|
+
description: publicKey.path,
|
|
399
|
+
label: publicKey.label,
|
|
400
|
+
value: publicKey.path
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
async function promptForAdminPublicKey(select, publicKeys = discoverLocalPublicKeys()) {
|
|
404
|
+
const publicKeyMode = await select(
|
|
405
|
+
"How should create-paratix configure the admin SSH public key?",
|
|
406
|
+
PUBLIC_KEY_PROMPT_OPTIONS
|
|
407
|
+
);
|
|
408
|
+
if (publicKeyMode !== "local") {
|
|
409
|
+
return void 0;
|
|
410
|
+
}
|
|
411
|
+
if (publicKeys.length === 0) {
|
|
412
|
+
console.error(
|
|
413
|
+
"No readable public keys were found in ~/.ssh. Keeping the placeholder in server.ts."
|
|
414
|
+
);
|
|
415
|
+
return void 0;
|
|
416
|
+
}
|
|
417
|
+
if (publicKeys.length === 1) {
|
|
418
|
+
return publicKeys[0]?.key;
|
|
419
|
+
}
|
|
420
|
+
const selectedPath = await select(
|
|
421
|
+
"Select the public key to embed into server.ts:",
|
|
422
|
+
createPublicKeyOptions(publicKeys)
|
|
423
|
+
);
|
|
424
|
+
return publicKeys.find((publicKey) => publicKey.path === selectedPath)?.key;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/scaffoldConfig.ts
|
|
428
|
+
var CLI_USAGE = "Usage: create-paratix <project-name> [--host <domain-or-ip>] [--initial-user <root|name>] [--admin-public-key <ssh-public-key>] [--admin-public-key-file <path>]";
|
|
429
|
+
function normalizeInitialUserName(name) {
|
|
430
|
+
return name.trim();
|
|
431
|
+
}
|
|
432
|
+
function isValidInitialUserName(name) {
|
|
433
|
+
return new RegExp("^(?:root|[a-z_][a-z0-9_\\x2d]*\\$?)$", "v").test(name);
|
|
434
|
+
}
|
|
435
|
+
function parseInitialUserConfig(exitWithMessage2, value) {
|
|
436
|
+
const normalizedValue = normalizeInitialUserName(value);
|
|
437
|
+
if (!isValidInitialUserName(normalizedValue)) {
|
|
438
|
+
exitWithMessage2(
|
|
439
|
+
`Error: Invalid initial user "${value}" \u2014 use "root" or a valid lowercase Linux username.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return normalizedValue === "root" ? { kind: "root" } : { kind: "admin", user: normalizedValue };
|
|
443
|
+
}
|
|
444
|
+
function normalizeHost(value) {
|
|
445
|
+
return value.trim();
|
|
446
|
+
}
|
|
447
|
+
function isValidHost(value) {
|
|
448
|
+
const normalizedValue = normalizeHost(value);
|
|
449
|
+
return normalizedValue.length > 0 && !new RegExp("\\s", "v").test(normalizedValue);
|
|
450
|
+
}
|
|
451
|
+
function validateHost(exitWithMessage2, value) {
|
|
452
|
+
const normalizedValue = normalizeHost(value);
|
|
453
|
+
if (!isValidHost(normalizedValue)) {
|
|
454
|
+
exitWithMessage2(
|
|
455
|
+
`Error: Invalid host "${value}" \u2014 use a domain name, IPv4, or IPv6 address without spaces.`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return normalizedValue;
|
|
459
|
+
}
|
|
460
|
+
async function promptForHost(prompt) {
|
|
461
|
+
for (; ; ) {
|
|
462
|
+
const host = normalizeHost(await prompt("Server host (domain or IP): "));
|
|
463
|
+
if (isValidHost(host)) {
|
|
464
|
+
return host;
|
|
465
|
+
}
|
|
466
|
+
console.error("Error: Please enter a domain name, IPv4, or IPv6 address without spaces.");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function parseArgumentValue(argv, index, parameters) {
|
|
470
|
+
const value = argv.at(index + 1);
|
|
471
|
+
if (value == null || value.startsWith("--")) {
|
|
472
|
+
parameters.exitWithMessage(`Error: Missing value for "${parameters.optionName}".`);
|
|
473
|
+
}
|
|
474
|
+
return value;
|
|
475
|
+
}
|
|
476
|
+
function handleUnknownOption(argument, exitWithMessage2) {
|
|
477
|
+
if (argument === "--bootstrap-root") {
|
|
478
|
+
exitWithMessage2('Error: "--bootstrap-root" was removed. Use "--initial-user root" instead.');
|
|
479
|
+
}
|
|
480
|
+
exitWithMessage2(`Error: Unknown option "${argument}".`);
|
|
481
|
+
}
|
|
482
|
+
function parseOptionAssignment(parameters) {
|
|
483
|
+
if (parameters.argument === "--host") {
|
|
484
|
+
return {
|
|
485
|
+
host: parseArgumentValue(parameters.argv, parameters.index, {
|
|
486
|
+
exitWithMessage: parameters.exitWithMessage,
|
|
487
|
+
optionName: "--host"
|
|
488
|
+
})
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (parameters.argument === "--initial-user") {
|
|
492
|
+
return {
|
|
493
|
+
initialUser: parseArgumentValue(parameters.argv, parameters.index, {
|
|
494
|
+
exitWithMessage: parameters.exitWithMessage,
|
|
495
|
+
optionName: "--initial-user"
|
|
496
|
+
})
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (parameters.argument === "--admin-public-key") {
|
|
500
|
+
return {
|
|
501
|
+
adminPublicKey: parseArgumentValue(parameters.argv, parameters.index, {
|
|
502
|
+
exitWithMessage: parameters.exitWithMessage,
|
|
503
|
+
optionName: "--admin-public-key"
|
|
504
|
+
})
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (parameters.argument === "--admin-public-key-file") {
|
|
508
|
+
return {
|
|
509
|
+
adminPublicKeyFile: parseArgumentValue(parameters.argv, parameters.index, {
|
|
510
|
+
exitWithMessage: parameters.exitWithMessage,
|
|
511
|
+
optionName: "--admin-public-key-file"
|
|
512
|
+
})
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
function parseCliArguments(argv, exitWithMessage2) {
|
|
518
|
+
const parsed = {
|
|
519
|
+
adminPublicKey: void 0,
|
|
520
|
+
adminPublicKeyFile: void 0,
|
|
521
|
+
host: void 0,
|
|
522
|
+
initialUser: void 0,
|
|
523
|
+
projectName: void 0
|
|
524
|
+
};
|
|
525
|
+
for (let index = 0; index < argv.length; index++) {
|
|
526
|
+
const argument = argv[index];
|
|
527
|
+
const optionAssignment = parseOptionAssignment({ argument, argv, exitWithMessage: exitWithMessage2, index });
|
|
528
|
+
if (optionAssignment != null) {
|
|
529
|
+
Object.assign(parsed, optionAssignment);
|
|
530
|
+
index++;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
parsed.projectName = handlePositionalOrUnknownArgument(
|
|
534
|
+
parsed.projectName,
|
|
535
|
+
argument,
|
|
536
|
+
exitWithMessage2
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
validatePublicKeyOptions(parsed, exitWithMessage2);
|
|
540
|
+
return parsed;
|
|
541
|
+
}
|
|
542
|
+
function handlePositionalOrUnknownArgument(projectName, argument, exitWithMessage2) {
|
|
543
|
+
if (argument.startsWith("--")) {
|
|
544
|
+
handleUnknownOption(argument, exitWithMessage2);
|
|
545
|
+
}
|
|
546
|
+
if (projectName == null) {
|
|
547
|
+
return argument;
|
|
548
|
+
}
|
|
549
|
+
exitWithMessage2(CLI_USAGE);
|
|
550
|
+
}
|
|
551
|
+
function validatePublicKeyOptions(parsed, exitWithMessage2) {
|
|
552
|
+
if (parsed.adminPublicKey == null || parsed.adminPublicKeyFile == null) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
exitWithMessage2('Error: Use either "--admin-public-key" or "--admin-public-key-file", not both.');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/interactivePrompts.ts
|
|
559
|
+
var NOOP = () => void 0;
|
|
560
|
+
var INTERACTIVE_SELECTION_UNAVAILABLE = "Interactive selection is unavailable.";
|
|
561
|
+
var UNAVAILABLE_SELECT = (() => {
|
|
562
|
+
throw new Error(INTERACTIVE_SELECTION_UNAVAILABLE);
|
|
563
|
+
});
|
|
564
|
+
var INITIAL_USER_OPTIONS = [
|
|
565
|
+
{
|
|
566
|
+
description: "Fresh server with SSH access only as root. Paratix bootstraps a dedicated admin user first.",
|
|
567
|
+
label: "Root user",
|
|
568
|
+
value: "root"
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
description: "A named admin user already exists. Paratix connects directly as that user and skips root bootstrap.",
|
|
572
|
+
label: "Admin user",
|
|
573
|
+
value: "admin"
|
|
574
|
+
}
|
|
575
|
+
];
|
|
576
|
+
var HOST_FINGERPRINT_OPTIONS = [
|
|
577
|
+
{
|
|
578
|
+
description: "Read the currently presented host key from SSH port 22 via ssh2 and pin its fingerprint in server.ts.",
|
|
579
|
+
label: "Scan host key",
|
|
580
|
+
value: "scan"
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
description: "Keep the expectedHostFingerprint placeholder in server.ts and verify the host key manually later.",
|
|
584
|
+
label: "Keep placeholder",
|
|
585
|
+
value: "placeholder"
|
|
586
|
+
}
|
|
587
|
+
];
|
|
588
|
+
function createTerminalPrompt() {
|
|
589
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
590
|
+
return {
|
|
591
|
+
close: () => {
|
|
592
|
+
readline.close();
|
|
593
|
+
},
|
|
594
|
+
prompt: async (question) => readline.question(question)
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function createPromptSession(prompt) {
|
|
598
|
+
const terminalPrompt = prompt == null ? createTerminalPrompt() : null;
|
|
599
|
+
const terminalSelect = prompt == null ? createTerminalSelect() : null;
|
|
600
|
+
if (terminalPrompt != null && terminalSelect != null) {
|
|
601
|
+
return {
|
|
602
|
+
ask: terminalPrompt.prompt,
|
|
603
|
+
chooseInitialUser: terminalSelect.select,
|
|
604
|
+
closePrompt: terminalPrompt.close,
|
|
605
|
+
closeSelect: terminalSelect.close
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (prompt == null) {
|
|
609
|
+
throw new Error("Interactive prompt is unavailable.");
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
ask: prompt,
|
|
613
|
+
chooseInitialUser: UNAVAILABLE_SELECT,
|
|
614
|
+
closePrompt: NOOP,
|
|
615
|
+
closeSelect: NOOP
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async function promptForAdminUser(ask, closePrompt) {
|
|
619
|
+
for (; ; ) {
|
|
620
|
+
const adminUser = normalizeInitialUserName(await ask("Admin username: "));
|
|
621
|
+
if (isValidInitialUserName(adminUser) && adminUser !== "root") {
|
|
622
|
+
closePrompt();
|
|
623
|
+
return { kind: "admin", user: adminUser };
|
|
624
|
+
}
|
|
625
|
+
console.error(
|
|
626
|
+
'Error: Invalid admin username. Use a valid lowercase Linux username other than "root".'
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function promptForHost2(prompt, closePrompt = NOOP) {
|
|
631
|
+
if (prompt != null) {
|
|
632
|
+
try {
|
|
633
|
+
return await promptForHost(prompt);
|
|
634
|
+
} finally {
|
|
635
|
+
closePrompt();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const terminalPrompt = createTerminalPrompt();
|
|
639
|
+
try {
|
|
640
|
+
return await promptForHost(terminalPrompt.prompt);
|
|
641
|
+
} finally {
|
|
642
|
+
terminalPrompt.close();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async function promptForInitialUserConfig(prompt, select) {
|
|
646
|
+
const promptSession = createPromptSession(prompt);
|
|
647
|
+
const chooseInitialUser = select ?? promptSession.chooseInitialUser;
|
|
648
|
+
try {
|
|
649
|
+
const initialUserType = await chooseInitialUser(
|
|
650
|
+
"Which SSH user already works for the first connection to this server?",
|
|
651
|
+
INITIAL_USER_OPTIONS
|
|
652
|
+
);
|
|
653
|
+
if (initialUserType === "root") {
|
|
654
|
+
promptSession.closeSelect();
|
|
655
|
+
promptSession.closePrompt();
|
|
656
|
+
return { kind: "root" };
|
|
657
|
+
}
|
|
658
|
+
return await promptForAdminUser(promptSession.ask, promptSession.closePrompt);
|
|
659
|
+
} finally {
|
|
660
|
+
promptSession.closeSelect();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function promptForAdminPublicKey2(select, publicKeys) {
|
|
664
|
+
const terminalSelect = select == null ? createTerminalSelect() : null;
|
|
665
|
+
const choose = select ?? terminalSelect?.select;
|
|
666
|
+
if (choose == null) {
|
|
667
|
+
throw new Error(INTERACTIVE_SELECTION_UNAVAILABLE);
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
return await promptForAdminPublicKey(choose, publicKeys);
|
|
671
|
+
} finally {
|
|
672
|
+
terminalSelect?.close();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function promptForHostFingerprint(host, select, scanner = readHostFingerprintViaSsh2) {
|
|
676
|
+
const terminalSelect = select == null ? createTerminalSelect() : null;
|
|
677
|
+
const choose = select ?? terminalSelect?.select;
|
|
678
|
+
if (choose == null) {
|
|
679
|
+
throw new Error(INTERACTIVE_SELECTION_UNAVAILABLE);
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
const hostKeyMode = await choose(
|
|
683
|
+
`How should create-paratix bootstrap the SSH host key for ${host}?`,
|
|
684
|
+
HOST_FINGERPRINT_OPTIONS
|
|
685
|
+
);
|
|
686
|
+
if (hostKeyMode !== "scan") {
|
|
687
|
+
return void 0;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
return await scanner(host);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error(
|
|
693
|
+
`${error instanceof Error ? error.message : String(error)} Keeping the expectedHostFingerprint placeholder in server.ts.`
|
|
694
|
+
);
|
|
695
|
+
return void 0;
|
|
696
|
+
}
|
|
697
|
+
} finally {
|
|
698
|
+
terminalSelect?.close();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/scaffoldRuntime.ts
|
|
703
|
+
import { execSync } from "child_process";
|
|
704
|
+
var MS_PER_MINUTE = 6e4;
|
|
705
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
706
|
+
function detectPackageManager() {
|
|
707
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
708
|
+
if (agent.startsWith("pnpm")) {
|
|
709
|
+
return { command: "pnpm install", name: "pnpm" };
|
|
710
|
+
}
|
|
711
|
+
if (agent.startsWith("yarn")) {
|
|
712
|
+
return { command: "yarn install", name: "yarn" };
|
|
713
|
+
}
|
|
714
|
+
if (agent.startsWith("bun")) {
|
|
715
|
+
return { command: "bun install", name: "bun" };
|
|
716
|
+
}
|
|
717
|
+
return { command: "npm install", name: "npm" };
|
|
718
|
+
}
|
|
719
|
+
function installDependencies(projectDirectory, pm) {
|
|
720
|
+
console.log(`Installing dependencies with ${pm.name}...`);
|
|
721
|
+
try {
|
|
722
|
+
execSync(pm.command, { cwd: projectDirectory, stdio: "inherit", timeout: INSTALL_TIMEOUT_MS });
|
|
723
|
+
return true;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof Error && "signal" in error && error.signal === "SIGTERM") {
|
|
726
|
+
console.error(
|
|
727
|
+
`Installation timed out after ${Math.round(INSTALL_TIMEOUT_MS / MS_PER_MINUTE)} minutes.`
|
|
728
|
+
);
|
|
729
|
+
} else {
|
|
730
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
731
|
+
console.error(`Failed to install dependencies: ${message}`);
|
|
732
|
+
}
|
|
733
|
+
console.error("Run install manually.");
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function getCommandPrefix(pm) {
|
|
738
|
+
return pm.name === "npm" ? "npm run" : pm.name;
|
|
739
|
+
}
|
|
740
|
+
function printSuccessMessage(projectName, pm) {
|
|
741
|
+
const prefix = getCommandPrefix(pm);
|
|
742
|
+
console.log(`
|
|
743
|
+
Project created successfully!
|
|
744
|
+
|
|
745
|
+
cd ${projectName}
|
|
746
|
+
|
|
747
|
+
Edit server.ts with your server details, then:
|
|
748
|
+
|
|
749
|
+
${prefix} apply:dry
|
|
750
|
+
${prefix} apply
|
|
751
|
+
`);
|
|
752
|
+
}
|
|
753
|
+
function printPartialSuccessMessage(projectName, pm) {
|
|
754
|
+
const prefix = getCommandPrefix(pm);
|
|
755
|
+
console.log(`
|
|
756
|
+
Project files created, but dependency installation failed.
|
|
757
|
+
|
|
758
|
+
cd ${projectName}
|
|
759
|
+
|
|
760
|
+
Install dependencies manually, then run:
|
|
761
|
+
|
|
762
|
+
${prefix} apply:dry
|
|
763
|
+
${prefix} apply
|
|
764
|
+
`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/templates.ts
|
|
768
|
+
var TSCONFIG_TEMPLATE = `{
|
|
769
|
+
"compilerOptions": {
|
|
770
|
+
"target": "ES2024",
|
|
771
|
+
"module": "ESNext",
|
|
772
|
+
"moduleResolution": "Bundler",
|
|
773
|
+
"types": ["node"],
|
|
774
|
+
"strict": true,
|
|
775
|
+
"esModuleInterop": true,
|
|
776
|
+
"skipLibCheck": true
|
|
777
|
+
},
|
|
778
|
+
"include": ["**/*.ts"]
|
|
779
|
+
}
|
|
780
|
+
`;
|
|
781
|
+
var GITIGNORE_TEMPLATE = `node_modules/
|
|
782
|
+
dist/
|
|
783
|
+
.env
|
|
784
|
+
*.log
|
|
785
|
+
`;
|
|
786
|
+
var PRETTIER_RC_TEMPLATE = `{
|
|
787
|
+
"semi": false,
|
|
788
|
+
"singleQuote": false,
|
|
789
|
+
"bracketSpacing": true,
|
|
790
|
+
"arrowParens": "always",
|
|
791
|
+
"tabWidth": 2,
|
|
792
|
+
"trailingComma": "es5",
|
|
793
|
+
"printWidth": 100
|
|
794
|
+
}
|
|
795
|
+
`;
|
|
796
|
+
var PRETTIER_IGNORE_TEMPLATE = `pnpm-lock.yaml
|
|
797
|
+
package-lock.json
|
|
798
|
+
yarn.lock
|
|
799
|
+
bun.lockb
|
|
800
|
+
`;
|
|
801
|
+
var ESLINT_CONFIG_TEMPLATE = `import { getEslintConfig } from "eslint-config-setup"
|
|
802
|
+
|
|
803
|
+
export default await getEslintConfig({ node: true })
|
|
804
|
+
`;
|
|
805
|
+
var ENV_EXAMPLE_TEMPLATE = `# Server configuration
|
|
806
|
+
# SUDO_PASSWORD=your-sudo-password
|
|
807
|
+
# SSH_KEY_PATH=~/.ssh/id_ed25519
|
|
808
|
+
`;
|
|
809
|
+
var AUTO_UPGRADES_20_TEMPLATE = `APT::Periodic::Update-Package-Lists "1";
|
|
810
|
+
APT::Periodic::Unattended-Upgrade "1";
|
|
811
|
+
`;
|
|
812
|
+
var UNATTENDED_UPGRADES_50_TEMPLATE = `Unattended-Upgrade::Origins-Pattern {
|
|
813
|
+
"origin=\${distro_id},archive=\${distro_codename}-security";
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
Unattended-Upgrade::Automatic-Reboot "true";
|
|
817
|
+
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
|
|
818
|
+
`;
|
|
819
|
+
function createAdminNopasswdSudoersContent(adminUser) {
|
|
820
|
+
return `# Bootstrap default: dedicated admin user with passwordless sudo.
|
|
821
|
+
# This keeps the post-bootstrap Paratix workflow non-interactive after the
|
|
822
|
+
# initial root run. If you prefer password-protected sudo later, replace this
|
|
823
|
+
# with a stricter policy after the bootstrap is complete.
|
|
824
|
+
${adminUser} ALL=(ALL:ALL) NOPASSWD:ALL
|
|
825
|
+
`;
|
|
826
|
+
}
|
|
827
|
+
function createBaseServerHeader({
|
|
828
|
+
adminPublicKey,
|
|
829
|
+
adminUserDeclaration,
|
|
830
|
+
expectedHostFingerprint,
|
|
831
|
+
host,
|
|
832
|
+
sshUser
|
|
833
|
+
}) {
|
|
834
|
+
const strictHostKeyCheckingDeclaration = expectedHostFingerprint == null ? 'const strictHostKeyChecking = FIRST_RUN ? "accept-new" : "yes";' : 'const strictHostKeyChecking = "yes";';
|
|
835
|
+
const expectedHostFingerprintLine = expectedHostFingerprint == null ? ' // expectedHostFingerprint: "SHA256:REPLACE_ME_WITH_YOUR_HOST_FINGERPRINT",' : ` expectedHostFingerprint: ${JSON.stringify(expectedHostFingerprint)}, // captured from port 22 during scaffolding`;
|
|
836
|
+
return `import { firstRun, recipe, server } from "paratix";
|
|
837
|
+
import { file, hostname, net, package as packages, ssh, sshd, sysctl, ufw, user } from "paratix/modules";
|
|
838
|
+
|
|
839
|
+
${adminUserDeclaration}
|
|
840
|
+
const adminPublicKey = ${JSON.stringify(adminPublicKey ?? "ssh-ed25519 REPLACE_ME_WITH_YOUR_PUBLIC_KEY")};
|
|
841
|
+
const serverName = "my-server";
|
|
842
|
+
const FIRST_RUN = process.env["PARATIX_FIRST_RUN"] === "true";
|
|
843
|
+
const sshPorts = FIRST_RUN ? [22] : [2222];
|
|
844
|
+
const firewallTcpPorts = FIRST_RUN ? [22, 2222, 80, 443] : [2222, 80, 443];
|
|
845
|
+
${strictHostKeyCheckingDeclaration}
|
|
846
|
+
|
|
847
|
+
export default server({
|
|
848
|
+
name: serverName,
|
|
849
|
+
host: ${JSON.stringify(host)},
|
|
850
|
+
ssh: {
|
|
851
|
+
ports: sshPorts,
|
|
852
|
+
privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
|
|
853
|
+
// FIRST_RUN keeps the bootstrap path explicit:
|
|
854
|
+
// - pass "paratix apply ... --first-run" for the bootstrap run
|
|
855
|
+
// - later runs omit that flag and go through port 2222 with strict host-key checking again
|
|
856
|
+
strictHostKeyChecking,
|
|
857
|
+
user: ${sshUser},
|
|
858
|
+
${expectedHostFingerprintLine}
|
|
859
|
+
// expectedHostPublicKey: "ssh-ed25519 REPLACE_ME_WITH_YOUR_HOST_PUBLIC_KEY",
|
|
860
|
+
},
|
|
861
|
+
env: {
|
|
862
|
+
FIRST_RUN,
|
|
863
|
+
SERVER_NAME: serverName,
|
|
864
|
+
SSH_PORT: 2222,
|
|
865
|
+
},
|
|
866
|
+
run: [
|
|
867
|
+
net.hosts("127.0.1.1", [serverName]),
|
|
868
|
+
hostname.set(serverName),
|
|
869
|
+
packages.upgrade("2026-03-01"),
|
|
870
|
+
packages.installed("curl", "htop", "ufw"),
|
|
871
|
+
`;
|
|
872
|
+
}
|
|
873
|
+
function createFirewallRecipe() {
|
|
874
|
+
return `
|
|
875
|
+
recipe("firewall", [
|
|
876
|
+
ufw.rule("allow", firewallTcpPorts),
|
|
877
|
+
ufw.enabled(),
|
|
878
|
+
]),
|
|
879
|
+
`;
|
|
880
|
+
}
|
|
881
|
+
function createKernelHardeningRecipe() {
|
|
882
|
+
return `
|
|
883
|
+
recipe("kernel-hardening", [
|
|
884
|
+
sysctl.set("fs.protected_hardlinks", "1"),
|
|
885
|
+
sysctl.set("fs.protected_symlinks", "1"),
|
|
886
|
+
sysctl.set("kernel.dmesg_restrict", "1"),
|
|
887
|
+
sysctl.set("kernel.kptr_restrict", "2"),
|
|
888
|
+
sysctl.set("net.ipv4.conf.all.rp_filter", "1"),
|
|
889
|
+
sysctl.set("net.ipv4.conf.default.rp_filter", "1"),
|
|
890
|
+
sysctl.set("net.ipv4.tcp_syncookies", "1"),
|
|
891
|
+
]),
|
|
892
|
+
`;
|
|
893
|
+
}
|
|
894
|
+
function createAutomaticSecurityUpgradesRecipe() {
|
|
895
|
+
return `
|
|
896
|
+
recipe("automatic-security-upgrades", [
|
|
897
|
+
packages.installed("unattended-upgrades"),
|
|
898
|
+
file.copy("/etc/apt/apt.conf.d/20auto-upgrades", "./files/20auto-upgrades", {
|
|
899
|
+
mode: "0644",
|
|
900
|
+
owner: "root:root",
|
|
901
|
+
}),
|
|
902
|
+
file.copy("/etc/apt/apt.conf.d/50unattended-upgrades", "./files/50unattended-upgrades", {
|
|
903
|
+
mode: "0644",
|
|
904
|
+
owner: "root:root",
|
|
905
|
+
}),
|
|
906
|
+
]),
|
|
907
|
+
`;
|
|
908
|
+
}
|
|
909
|
+
function createFirstRunStopModule() {
|
|
910
|
+
return `
|
|
911
|
+
firstRun.stop("Bootstrap foundation complete; rerun without --first-run to continue."),
|
|
912
|
+
|
|
913
|
+
// Add application and user-facing services below this line.
|
|
914
|
+
`;
|
|
915
|
+
}
|
|
916
|
+
function createAdminRecipe(recipeName) {
|
|
917
|
+
return `
|
|
918
|
+
recipe("${recipeName}", [
|
|
919
|
+
user.present(adminUser, {
|
|
920
|
+
groups: ["sudo"],
|
|
921
|
+
shell: "/bin/bash",
|
|
922
|
+
}),
|
|
923
|
+
ssh.authorizedKeys(adminUser, adminPublicKey),
|
|
924
|
+
]),
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
function createHardenedAdminServerTemplate(parameters) {
|
|
928
|
+
const { adminPublicKey, expectedHostFingerprint, host, initialAdminUser } = parameters;
|
|
929
|
+
const adminUserDeclaration = `const adminUser = "${initialAdminUser}";`;
|
|
930
|
+
return `${createBaseServerHeader({
|
|
931
|
+
adminPublicKey,
|
|
932
|
+
adminUserDeclaration,
|
|
933
|
+
expectedHostFingerprint,
|
|
934
|
+
host,
|
|
935
|
+
sshUser: "adminUser"
|
|
936
|
+
})}
|
|
937
|
+
${createAdminRecipe("admin-access")}
|
|
938
|
+
${createFirewallRecipe()}
|
|
939
|
+
recipe("ssh-hardening", [
|
|
940
|
+
sshd.port(2222),
|
|
941
|
+
sshd.config({
|
|
942
|
+
PasswordAuthentication: "no",
|
|
943
|
+
PermitRootLogin: "no",
|
|
944
|
+
}),
|
|
945
|
+
]),
|
|
946
|
+
${createKernelHardeningRecipe()}
|
|
947
|
+
${createAutomaticSecurityUpgradesRecipe()}
|
|
948
|
+
${createFirstRunStopModule()}
|
|
949
|
+
],
|
|
950
|
+
});
|
|
951
|
+
`;
|
|
952
|
+
}
|
|
953
|
+
function createBootstrapRootServerTemplate(host, adminPublicKey, expectedHostFingerprint) {
|
|
954
|
+
const adminUserDeclaration = 'const adminUser = "paratix";';
|
|
955
|
+
return `${createBaseServerHeader({
|
|
956
|
+
adminPublicKey,
|
|
957
|
+
adminUserDeclaration,
|
|
958
|
+
expectedHostFingerprint,
|
|
959
|
+
host,
|
|
960
|
+
sshUser: 'FIRST_RUN ? "root" : adminUser'
|
|
961
|
+
})}
|
|
962
|
+
${createAdminRecipe("bootstrap-admin-user")}
|
|
963
|
+
recipe("bootstrap-admin-sudo", [
|
|
964
|
+
file.copy(
|
|
965
|
+
"/etc/sudoers.d/90-paratix-admin-nopasswd",
|
|
966
|
+
"./files/admin-nopasswd-sudoers",
|
|
967
|
+
{
|
|
968
|
+
mode: "0440",
|
|
969
|
+
owner: "root:root",
|
|
970
|
+
}
|
|
971
|
+
),
|
|
972
|
+
]),
|
|
973
|
+
${createFirewallRecipe()}
|
|
974
|
+
// Transitional bootstrap mode:
|
|
975
|
+
// 1. Run this once as root with "--first-run" to create the dedicated admin user.
|
|
976
|
+
// 2. The generated sudoers drop-in keeps the new admin path non-interactive via NOPASSWD sudo.
|
|
977
|
+
// 3. Later runs omit "--first-run" and connect as the dedicated admin user on port 2222.
|
|
978
|
+
// 4. The next regular run disables root login completely.
|
|
979
|
+
recipe("ssh-hardening-transition", [
|
|
980
|
+
sshd.port(2222),
|
|
981
|
+
sshd.config({
|
|
982
|
+
PasswordAuthentication: "no",
|
|
983
|
+
PermitRootLogin: FIRST_RUN ? "prohibit-password" : "no",
|
|
984
|
+
}),
|
|
985
|
+
]),
|
|
986
|
+
${createKernelHardeningRecipe()}
|
|
987
|
+
${createAutomaticSecurityUpgradesRecipe()}
|
|
988
|
+
${createFirstRunStopModule()}
|
|
989
|
+
],
|
|
990
|
+
});
|
|
991
|
+
`;
|
|
992
|
+
}
|
|
993
|
+
function createServerTemplate(options) {
|
|
994
|
+
return options.initialUser.kind === "root" ? createBootstrapRootServerTemplate(
|
|
995
|
+
options.host,
|
|
996
|
+
options.adminPublicKey,
|
|
997
|
+
options.expectedHostFingerprint
|
|
998
|
+
) : createHardenedAdminServerTemplate({
|
|
999
|
+
adminPublicKey: options.adminPublicKey,
|
|
1000
|
+
expectedHostFingerprint: options.expectedHostFingerprint,
|
|
1001
|
+
host: options.host,
|
|
1002
|
+
initialAdminUser: options.initialUser.user
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/index.ts
|
|
1007
|
+
function writeSharedScaffoldFiles(projectDirectory) {
|
|
1008
|
+
writeFileSync(join2(projectDirectory, "tsconfig.json"), TSCONFIG_TEMPLATE);
|
|
1009
|
+
writeFileSync(join2(projectDirectory, ".gitignore"), GITIGNORE_TEMPLATE);
|
|
1010
|
+
writeFileSync(join2(projectDirectory, ".prettierrc"), PRETTIER_RC_TEMPLATE);
|
|
1011
|
+
writeFileSync(join2(projectDirectory, ".prettierignore"), PRETTIER_IGNORE_TEMPLATE);
|
|
1012
|
+
writeFileSync(join2(projectDirectory, "eslint.config.ts"), ESLINT_CONFIG_TEMPLATE);
|
|
1013
|
+
writeFileSync(join2(projectDirectory, ".env.example"), ENV_EXAMPLE_TEMPLATE);
|
|
1014
|
+
}
|
|
1015
|
+
function writeScaffoldSupportFiles(projectDirectory, initialUser) {
|
|
1016
|
+
writeFileSync(join2(projectDirectory, "files", ".gitkeep"), "");
|
|
1017
|
+
writeFileSync(join2(projectDirectory, "files", "20auto-upgrades"), AUTO_UPGRADES_20_TEMPLATE);
|
|
1018
|
+
writeFileSync(
|
|
1019
|
+
join2(projectDirectory, "files", "50unattended-upgrades"),
|
|
1020
|
+
UNATTENDED_UPGRADES_50_TEMPLATE
|
|
1021
|
+
);
|
|
1022
|
+
if (initialUser.kind !== "root") return;
|
|
1023
|
+
writeFileSync(
|
|
1024
|
+
join2(projectDirectory, "files", "admin-nopasswd-sudoers"),
|
|
1025
|
+
createAdminNopasswdSudoersContent("paratix")
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
function writeProjectFiles(projectDirectory, options) {
|
|
1029
|
+
mkdirSync(projectDirectory, { recursive: true });
|
|
1030
|
+
mkdirSync(join2(projectDirectory, "files"), { recursive: true });
|
|
1031
|
+
const host = options?.host ?? "1.2.3.4";
|
|
1032
|
+
const initialUser = options?.initialUser ?? { kind: "admin", user: "paratix" };
|
|
1033
|
+
const adminPublicKey = options?.adminPublicKey;
|
|
1034
|
+
const expectedHostFingerprint = options?.expectedHostFingerprint;
|
|
1035
|
+
const packageJson = {
|
|
1036
|
+
dependencies: {
|
|
1037
|
+
paratix: "^0.1.0"
|
|
1038
|
+
},
|
|
1039
|
+
devDependencies: {
|
|
1040
|
+
"@types/node": "^24.5.2",
|
|
1041
|
+
eslint: "^10.0.3",
|
|
1042
|
+
"eslint-config-setup": "^0.3.3",
|
|
1043
|
+
prettier: "^3.6.2",
|
|
1044
|
+
tsx: "^4.20.6"
|
|
1045
|
+
},
|
|
1046
|
+
engines: {
|
|
1047
|
+
node: ">=24.0.0"
|
|
1048
|
+
},
|
|
1049
|
+
name: derivePackageName(projectDirectory),
|
|
1050
|
+
private: true,
|
|
1051
|
+
scripts: {
|
|
1052
|
+
apply: "paratix apply server.ts",
|
|
1053
|
+
"apply:dry": "paratix apply server.ts --dry-run",
|
|
1054
|
+
"format:check": "prettier --check .",
|
|
1055
|
+
"format:fix": "prettier --write .",
|
|
1056
|
+
lint: "eslint ."
|
|
1057
|
+
},
|
|
1058
|
+
type: "module"
|
|
1059
|
+
};
|
|
1060
|
+
writeFileSync(join2(projectDirectory, "package.json"), `${JSON.stringify(packageJson, null, 2)}
|
|
1061
|
+
`);
|
|
1062
|
+
writeFileSync(
|
|
1063
|
+
join2(projectDirectory, "server.ts"),
|
|
1064
|
+
createServerTemplate({ adminPublicKey, expectedHostFingerprint, host, initialUser })
|
|
1065
|
+
);
|
|
1066
|
+
writeSharedScaffoldFiles(projectDirectory);
|
|
1067
|
+
writeScaffoldSupportFiles(projectDirectory, initialUser);
|
|
1068
|
+
}
|
|
1069
|
+
function isValidProjectName(name) {
|
|
1070
|
+
const trimmed = name.trim();
|
|
1071
|
+
return new RegExp("^[a-z0-9][a-z0-9\\x2d]*$", "v").test(trimmed);
|
|
1072
|
+
}
|
|
1073
|
+
function normalizeProjectName(name) {
|
|
1074
|
+
return name.trim();
|
|
1075
|
+
}
|
|
1076
|
+
function derivePackageName(projectDirectory) {
|
|
1077
|
+
return basename2(projectDirectory.replaceAll("\\", "/"));
|
|
1078
|
+
}
|
|
1079
|
+
function exitWithMessage(message) {
|
|
1080
|
+
console.error(message);
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
function parseCliArguments2(argv) {
|
|
1084
|
+
return parseCliArguments(argv, exitWithMessage);
|
|
1085
|
+
}
|
|
1086
|
+
function parseInitialUserConfig2(value) {
|
|
1087
|
+
return parseInitialUserConfig(exitWithMessage, value);
|
|
1088
|
+
}
|
|
1089
|
+
function validateHost2(value) {
|
|
1090
|
+
return validateHost(exitWithMessage, value);
|
|
1091
|
+
}
|
|
1092
|
+
function validateProjectName(name) {
|
|
1093
|
+
if (name == null || name === "") {
|
|
1094
|
+
exitWithMessage("Usage: create-paratix <project-name>");
|
|
1095
|
+
}
|
|
1096
|
+
const normalizedName = normalizeProjectName(name);
|
|
1097
|
+
if (!isValidProjectName(normalizedName)) {
|
|
1098
|
+
exitWithMessage(
|
|
1099
|
+
`Error: Invalid project name "${name}" \u2014 use only lowercase letters, numbers, and hyphens.`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return normalizedName;
|
|
1103
|
+
}
|
|
1104
|
+
function scaffoldProject(projectName, pm, options) {
|
|
1105
|
+
const normalizedProjectName = normalizeProjectName(projectName);
|
|
1106
|
+
const projectDirectory = resolve(normalizedProjectName);
|
|
1107
|
+
if (existsSync(projectDirectory)) {
|
|
1108
|
+
exitWithMessage(`Error: Directory "${normalizedProjectName}" already exists.`);
|
|
1109
|
+
}
|
|
1110
|
+
console.log(`Creating Paratix project in ${projectDirectory}...`);
|
|
1111
|
+
writeProjectFiles(projectDirectory, options);
|
|
1112
|
+
const installer = options?.installer ?? installDependencies;
|
|
1113
|
+
const installed = installer(projectDirectory, pm);
|
|
1114
|
+
if (!installed) {
|
|
1115
|
+
process.exitCode = 1;
|
|
1116
|
+
printPartialSuccessMessage(normalizedProjectName, pm);
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
printSuccessMessage(normalizedProjectName, pm);
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
async function resolveCliOrPromptAdminPublicKey(parameters) {
|
|
1123
|
+
const { adminPublicKey, adminPublicKeyFile } = parameters;
|
|
1124
|
+
if (adminPublicKey !== void 0) {
|
|
1125
|
+
return validateAdminPublicKey(exitWithMessage, adminPublicKey);
|
|
1126
|
+
}
|
|
1127
|
+
if (adminPublicKeyFile !== void 0) {
|
|
1128
|
+
return readAdminPublicKeyFile(exitWithMessage, adminPublicKeyFile);
|
|
1129
|
+
}
|
|
1130
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
1131
|
+
return promptForAdminPublicKey2();
|
|
1132
|
+
}
|
|
1133
|
+
return void 0;
|
|
1134
|
+
}
|
|
1135
|
+
function main() {
|
|
1136
|
+
const { adminPublicKey, adminPublicKeyFile, host, initialUser, projectName } = parseCliArguments2(
|
|
1137
|
+
process.argv.slice(2)
|
|
1138
|
+
);
|
|
1139
|
+
const normalizedProjectName = validateProjectName(projectName);
|
|
1140
|
+
const pm = detectPackageManager();
|
|
1141
|
+
void (async () => {
|
|
1142
|
+
const validatedHost = host == null ? await promptForHost2() : validateHost2(host);
|
|
1143
|
+
const resolvedExpectedHostFingerprint = process.stdin.isTTY && process.stdout.isTTY ? await promptForHostFingerprint(validatedHost) : void 0;
|
|
1144
|
+
const initialUserConfig = initialUser == null ? await promptForInitialUserConfig() : parseInitialUserConfig2(initialUser);
|
|
1145
|
+
const resolvedAdminPublicKey = await resolveCliOrPromptAdminPublicKey({
|
|
1146
|
+
adminPublicKey,
|
|
1147
|
+
adminPublicKeyFile
|
|
1148
|
+
});
|
|
1149
|
+
scaffoldProject(normalizedProjectName, pm, {
|
|
1150
|
+
adminPublicKey: resolvedAdminPublicKey,
|
|
1151
|
+
expectedHostFingerprint: resolvedExpectedHostFingerprint,
|
|
1152
|
+
host: validatedHost,
|
|
1153
|
+
initialUser: initialUserConfig
|
|
1154
|
+
});
|
|
1155
|
+
})().catch((error) => {
|
|
1156
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1157
|
+
process.exitCode = 1;
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
function isDirectExecution(moduleUrl, argv1) {
|
|
1161
|
+
return argv1 != null && moduleUrl.endsWith(argv1.replaceAll("\\", "/"));
|
|
1162
|
+
}
|
|
1163
|
+
if (isDirectExecution(import.meta.url, process.argv[1])) {
|
|
1164
|
+
main();
|
|
1165
|
+
}
|
|
1166
|
+
export {
|
|
1167
|
+
isDirectExecution,
|
|
1168
|
+
isValidHost,
|
|
1169
|
+
isValidInitialUserName,
|
|
1170
|
+
isValidProjectName,
|
|
1171
|
+
normalizeHost,
|
|
1172
|
+
normalizeInitialUserName,
|
|
1173
|
+
normalizeProjectName,
|
|
1174
|
+
parseCliArguments2 as parseCliArguments,
|
|
1175
|
+
parseInitialUserConfig2 as parseInitialUserConfig,
|
|
1176
|
+
promptForAdminPublicKey2 as promptForAdminPublicKey,
|
|
1177
|
+
promptForHost2 as promptForHost,
|
|
1178
|
+
promptForHostFingerprint,
|
|
1179
|
+
promptForInitialUserConfig,
|
|
1180
|
+
scaffoldProject,
|
|
1181
|
+
validateHost2 as validateHost,
|
|
1182
|
+
writeProjectFiles
|
|
1183
|
+
};
|