create-paratix 0.10.0 → 0.12.3
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 +5 -3
- package/dist/index.d.ts +90 -24
- package/dist/index.js +1634 -266
- package/package.json +14 -5
package/dist/index.js
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { rmSync as rmSync2 } from "fs";
|
|
5
|
+
import { posix as pathPosix, win32 as pathWin32, resolve as resolve3 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/cliFormat.ts
|
|
8
|
+
var HEX_RADIX = 16;
|
|
9
|
+
var MINIMUM_ESCAPE_WIDTH = 4;
|
|
10
|
+
var TERMINAL_CONTROL_CODEPOINTS = new RegExp(
|
|
11
|
+
// oxlint-disable-next-line no-control-regex
|
|
12
|
+
"[\\u0000-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
|
|
13
|
+
"gu"
|
|
14
|
+
);
|
|
15
|
+
function formatCodePointEscape(character) {
|
|
16
|
+
const codePoint = character.codePointAt(0);
|
|
17
|
+
if (codePoint == null) return "";
|
|
18
|
+
return `\\u{${codePoint.toString(HEX_RADIX).toUpperCase().padStart(MINIMUM_ESCAPE_WIDTH, "0")}}`;
|
|
19
|
+
}
|
|
20
|
+
function escapeCliControlCharacters(value) {
|
|
21
|
+
return value.replace(TERMINAL_CONTROL_CODEPOINTS, formatCodePointEscape);
|
|
22
|
+
}
|
|
23
|
+
function formatCliValue(value) {
|
|
24
|
+
return `"${escapeCliControlCharacters(value)}"`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/cliExitError.ts
|
|
28
|
+
var CliExitError = class extends Error {
|
|
29
|
+
cliMessage;
|
|
30
|
+
exitCode;
|
|
31
|
+
reported;
|
|
32
|
+
constructor(message, exitCode = 1, options) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "CliExitError";
|
|
35
|
+
this.cliMessage = message;
|
|
36
|
+
this.exitCode = exitCode;
|
|
37
|
+
this.reported = options?.reported ?? false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
6
40
|
|
|
7
41
|
// src/interactivePrompts.ts
|
|
8
42
|
import { createInterface } from "readline/promises";
|
|
@@ -10,8 +44,225 @@ import { createInterface } from "readline/promises";
|
|
|
10
44
|
// src/hostFingerprintBootstrap.ts
|
|
11
45
|
import { createHash } from "crypto";
|
|
12
46
|
import { Client } from "ssh2";
|
|
47
|
+
|
|
48
|
+
// src/hostKeyBlobValidation.ts
|
|
49
|
+
import { createPublicKey } from "crypto";
|
|
50
|
+
var SSH_WIRE_LENGTH_FIELD_BYTES = 4;
|
|
51
|
+
var ED25519_PUBLIC_KEY_BYTE_LENGTH = 32;
|
|
52
|
+
var NISTP256_UNCOMPRESSED_POINT_BYTE_LENGTH = 65;
|
|
53
|
+
var NISTP384_UNCOMPRESSED_POINT_BYTE_LENGTH = 97;
|
|
54
|
+
var NISTP521_UNCOMPRESSED_POINT_BYTE_LENGTH = 133;
|
|
55
|
+
var UNCOMPRESSED_EC_POINT_PREFIX = 4;
|
|
56
|
+
var PRINTABLE_ASCII_MIN = 32;
|
|
57
|
+
var PRINTABLE_ASCII_MAX = 126;
|
|
58
|
+
var ECDSA_CURVE_NAMES = /* @__PURE__ */ new Map([
|
|
59
|
+
["ecdsa-sha2-nistp256", "nistp256"],
|
|
60
|
+
["ecdsa-sha2-nistp384", "nistp384"],
|
|
61
|
+
["ecdsa-sha2-nistp521", "nistp521"]
|
|
62
|
+
]);
|
|
63
|
+
var ECDSA_POINT_LENGTHS = /* @__PURE__ */ new Map([
|
|
64
|
+
["nistp256", NISTP256_UNCOMPRESSED_POINT_BYTE_LENGTH],
|
|
65
|
+
["nistp384", NISTP384_UNCOMPRESSED_POINT_BYTE_LENGTH],
|
|
66
|
+
["nistp521", NISTP521_UNCOMPRESSED_POINT_BYTE_LENGTH]
|
|
67
|
+
]);
|
|
68
|
+
var ECDSA_JWK_CURVE_NAMES = /* @__PURE__ */ new Map([
|
|
69
|
+
["nistp256", "P-256"],
|
|
70
|
+
["nistp384", "P-384"],
|
|
71
|
+
["nistp521", "P-521"]
|
|
72
|
+
]);
|
|
73
|
+
function readWireString(value, offset) {
|
|
74
|
+
if (offset + SSH_WIRE_LENGTH_FIELD_BYTES > value.length) return null;
|
|
75
|
+
const length = value.readUInt32BE(offset);
|
|
76
|
+
const start = offset + SSH_WIRE_LENGTH_FIELD_BYTES;
|
|
77
|
+
const end = start + length;
|
|
78
|
+
if (end > value.length) return null;
|
|
79
|
+
return { nextOffset: end, value: value.subarray(start, end) };
|
|
80
|
+
}
|
|
81
|
+
function throwInvalidHostKeyBlob(algorithm, detail) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Refusing to capture host fingerprint: malformed "${algorithm}" host key blob (${detail}). This may indicate a man-in-the-middle attack or a broken SSH peer.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
function isPrintableAsciiByte(byte) {
|
|
87
|
+
return byte >= PRINTABLE_ASCII_MIN && byte <= PRINTABLE_ASCII_MAX;
|
|
88
|
+
}
|
|
89
|
+
function validateEd25519HostKeyBlob(keyBuffer, offset, algorithm) {
|
|
90
|
+
const publicKey = readWireString(keyBuffer, offset);
|
|
91
|
+
if (publicKey == null) {
|
|
92
|
+
throwInvalidHostKeyBlob(algorithm, "missing or truncated public-key field");
|
|
93
|
+
}
|
|
94
|
+
if (publicKey.value.length !== ED25519_PUBLIC_KEY_BYTE_LENGTH) {
|
|
95
|
+
throwInvalidHostKeyBlob(
|
|
96
|
+
algorithm,
|
|
97
|
+
`expected ${String(ED25519_PUBLIC_KEY_BYTE_LENGTH)}-byte public key, got ${String(publicKey.value.length)}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (publicKey.nextOffset !== keyBuffer.length) {
|
|
101
|
+
throwInvalidHostKeyBlob(algorithm, "trailing data after public key");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function assertEcdsaPointIsOnCurve(point, curveName, algorithm) {
|
|
105
|
+
const jwkCurveName = ECDSA_JWK_CURVE_NAMES.get(curveName);
|
|
106
|
+
if (jwkCurveName == null) {
|
|
107
|
+
throwInvalidHostKeyBlob(algorithm, `unknown ECDSA curve "${curveName}"`);
|
|
108
|
+
}
|
|
109
|
+
const coordinateLength = (point.length - 1) / 2;
|
|
110
|
+
const x = point.subarray(1, 1 + coordinateLength);
|
|
111
|
+
const y = point.subarray(1 + coordinateLength);
|
|
112
|
+
try {
|
|
113
|
+
createPublicKey({
|
|
114
|
+
format: "jwk",
|
|
115
|
+
key: {
|
|
116
|
+
crv: jwkCurveName,
|
|
117
|
+
kty: "EC",
|
|
118
|
+
x: x.toString("base64url"),
|
|
119
|
+
y: y.toString("base64url")
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
throwInvalidHostKeyBlob(algorithm, "EC point is not on the expected curve");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function readEcdsaCurveName(parameters) {
|
|
127
|
+
const { algorithm, expectedCurveName, keyBuffer, offset } = parameters;
|
|
128
|
+
const curveName = readWireString(keyBuffer, offset);
|
|
129
|
+
if (curveName == null) {
|
|
130
|
+
throwInvalidHostKeyBlob(algorithm, "missing or truncated curve identifier");
|
|
131
|
+
}
|
|
132
|
+
for (const byte of curveName.value) {
|
|
133
|
+
if (!isPrintableAsciiByte(byte)) {
|
|
134
|
+
throwInvalidHostKeyBlob(algorithm, "curve identifier contains non-printable bytes");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const curveNameValue = curveName.value.toString("ascii");
|
|
138
|
+
if (curveNameValue !== expectedCurveName) {
|
|
139
|
+
throwInvalidHostKeyBlob(
|
|
140
|
+
algorithm,
|
|
141
|
+
`curve identifier "${curveNameValue}" does not match "${expectedCurveName}"`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return curveName;
|
|
145
|
+
}
|
|
146
|
+
function readEcdsaPoint(parameters) {
|
|
147
|
+
const { algorithm, expectedPointLength, keyBuffer, offset } = parameters;
|
|
148
|
+
const point = readWireString(keyBuffer, offset);
|
|
149
|
+
if (point == null) {
|
|
150
|
+
throwInvalidHostKeyBlob(algorithm, "missing or truncated EC point field");
|
|
151
|
+
}
|
|
152
|
+
if (point.value.length !== expectedPointLength) {
|
|
153
|
+
throwInvalidHostKeyBlob(
|
|
154
|
+
algorithm,
|
|
155
|
+
`expected ${String(expectedPointLength)}-byte EC point, got ${String(point.value.length)}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (point.value[0] !== UNCOMPRESSED_EC_POINT_PREFIX) {
|
|
159
|
+
throwInvalidHostKeyBlob(algorithm, "EC point is not in uncompressed form");
|
|
160
|
+
}
|
|
161
|
+
if (point.nextOffset !== keyBuffer.length) {
|
|
162
|
+
throwInvalidHostKeyBlob(algorithm, "trailing data after EC point");
|
|
163
|
+
}
|
|
164
|
+
return point;
|
|
165
|
+
}
|
|
166
|
+
function validateEcdsaHostKeyBlob(keyBuffer, offset, algorithm) {
|
|
167
|
+
const expectedCurveName = ECDSA_CURVE_NAMES.get(algorithm);
|
|
168
|
+
const expectedPointLength = expectedCurveName == null ? void 0 : ECDSA_POINT_LENGTHS.get(expectedCurveName);
|
|
169
|
+
if (expectedCurveName == null || expectedPointLength === void 0) {
|
|
170
|
+
throwInvalidHostKeyBlob(algorithm, "no curve mapping for algorithm");
|
|
171
|
+
}
|
|
172
|
+
const curveName = readEcdsaCurveName({
|
|
173
|
+
algorithm,
|
|
174
|
+
expectedCurveName,
|
|
175
|
+
keyBuffer,
|
|
176
|
+
offset
|
|
177
|
+
});
|
|
178
|
+
const point = readEcdsaPoint({
|
|
179
|
+
algorithm,
|
|
180
|
+
expectedPointLength,
|
|
181
|
+
keyBuffer,
|
|
182
|
+
offset: curveName.nextOffset
|
|
183
|
+
});
|
|
184
|
+
assertEcdsaPointIsOnCurve(point.value, expectedCurveName, algorithm);
|
|
185
|
+
}
|
|
186
|
+
function resolvePayloadOffset(keyBuffer, algorithm, payloadOffsetHint) {
|
|
187
|
+
const algorithmField = readWireString(keyBuffer, 0);
|
|
188
|
+
if (algorithmField == null) {
|
|
189
|
+
throwInvalidHostKeyBlob(algorithm, "missing or truncated algorithm field");
|
|
190
|
+
}
|
|
191
|
+
const wireAlgorithm = algorithmField.value.toString("ascii");
|
|
192
|
+
if (wireAlgorithm !== algorithm) {
|
|
193
|
+
throwInvalidHostKeyBlob(
|
|
194
|
+
algorithm,
|
|
195
|
+
`algorithm field "${wireAlgorithm}" does not match expected "${algorithm}"`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const payloadOffset = algorithmField.nextOffset;
|
|
199
|
+
if (payloadOffsetHint !== void 0 && payloadOffsetHint !== payloadOffset) {
|
|
200
|
+
throwInvalidHostKeyBlob(
|
|
201
|
+
algorithm,
|
|
202
|
+
`payload offset hint ${String(payloadOffsetHint)} does not match parsed offset ${String(payloadOffset)}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return payloadOffset;
|
|
206
|
+
}
|
|
207
|
+
function validateHostKeyBlob(keyBuffer, algorithm, payloadOffsetHint) {
|
|
208
|
+
const payloadOffset = resolvePayloadOffset(keyBuffer, algorithm, payloadOffsetHint);
|
|
209
|
+
if (algorithm === "ssh-ed25519") {
|
|
210
|
+
validateEd25519HostKeyBlob(keyBuffer, payloadOffset, algorithm);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (algorithm === "ecdsa-sha2-nistp256" || algorithm === "ecdsa-sha2-nistp384" || algorithm === "ecdsa-sha2-nistp521") {
|
|
214
|
+
validateEcdsaHostKeyBlob(keyBuffer, payloadOffset, algorithm);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
throwInvalidHostKeyBlob(algorithm, "no validator registered for algorithm");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/hostFingerprintBootstrap.ts
|
|
13
221
|
var DEFAULT_HOST_FINGERPRINT_PORT = 22;
|
|
14
222
|
var DEFAULT_READY_TIMEOUT_MS = 1e4;
|
|
223
|
+
var SSH_KEY_ALGO_LENGTH_FIELD_BYTES = 4;
|
|
224
|
+
var ACCEPTED_HOST_KEY_ALGORITHMS = /* @__PURE__ */ new Set([
|
|
225
|
+
"ecdsa-sha2-nistp256",
|
|
226
|
+
"ecdsa-sha2-nistp384",
|
|
227
|
+
"ecdsa-sha2-nistp521",
|
|
228
|
+
"ssh-ed25519"
|
|
229
|
+
]);
|
|
230
|
+
var PRINTABLE_ASCII_MIN2 = 32;
|
|
231
|
+
var PRINTABLE_ASCII_MAX2 = 126;
|
|
232
|
+
function isPrintableAsciiByte2(byte) {
|
|
233
|
+
return byte >= PRINTABLE_ASCII_MIN2 && byte <= PRINTABLE_ASCII_MAX2;
|
|
234
|
+
}
|
|
235
|
+
function extractHostKeyAlgorithm(keyBuffer) {
|
|
236
|
+
if (keyBuffer.length < SSH_KEY_ALGO_LENGTH_FIELD_BYTES) {
|
|
237
|
+
throw new Error("Invalid SSH host key buffer: too short to contain an algorithm length field");
|
|
238
|
+
}
|
|
239
|
+
const algoLength = keyBuffer.readUInt32BE(0);
|
|
240
|
+
if (algoLength === 0 || SSH_KEY_ALGO_LENGTH_FIELD_BYTES + algoLength > keyBuffer.length) {
|
|
241
|
+
throw new Error("Invalid SSH host key buffer: algorithm length exceeds buffer size");
|
|
242
|
+
}
|
|
243
|
+
const algorithmBytes = keyBuffer.subarray(
|
|
244
|
+
SSH_KEY_ALGO_LENGTH_FIELD_BYTES,
|
|
245
|
+
SSH_KEY_ALGO_LENGTH_FIELD_BYTES + algoLength
|
|
246
|
+
);
|
|
247
|
+
for (const byte of algorithmBytes) {
|
|
248
|
+
if (!isPrintableAsciiByte2(byte)) {
|
|
249
|
+
throw new Error("Invalid SSH host key buffer: algorithm field contains non-printable bytes");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
algorithm: algorithmBytes.toString("ascii"),
|
|
254
|
+
nextOffset: SSH_KEY_ALGO_LENGTH_FIELD_BYTES + algoLength
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function assertSupportedHostKeyAlgorithm(keyBuffer) {
|
|
258
|
+
const extraction = extractHostKeyAlgorithm(keyBuffer);
|
|
259
|
+
if (!ACCEPTED_HOST_KEY_ALGORITHMS.has(extraction.algorithm)) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Refusing to capture host fingerprint: unsupported SSH host key algorithm "${extraction.algorithm}". This may indicate a man-in-the-middle attack. Expected one of: ${[...ACCEPTED_HOST_KEY_ALGORITHMS].sort().join(", ")}.`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return extraction;
|
|
265
|
+
}
|
|
15
266
|
function computeFingerprint(key) {
|
|
16
267
|
const hash = createHash("sha256").update(key).digest("base64");
|
|
17
268
|
return `SHA256:${hash.replaceAll("=", "")}`;
|
|
@@ -21,11 +272,18 @@ function toError(error, host, port) {
|
|
|
21
272
|
return new Error(`Failed to read the host key from ${host}:${port}: ${message}`);
|
|
22
273
|
}
|
|
23
274
|
function createConnectionConfig(parameters) {
|
|
24
|
-
const {
|
|
275
|
+
const { captureHostVerifierError, captureScanResult, host, port, readyTimeoutMs } = parameters;
|
|
25
276
|
return {
|
|
26
277
|
host,
|
|
27
|
-
hostVerifier
|
|
28
|
-
|
|
278
|
+
hostVerifier(key) {
|
|
279
|
+
const buffer = Buffer.from(key);
|
|
280
|
+
try {
|
|
281
|
+
const { algorithm, nextOffset } = assertSupportedHostKeyAlgorithm(buffer);
|
|
282
|
+
validateHostKeyBlob(buffer, algorithm, nextOffset);
|
|
283
|
+
captureScanResult({ algorithm, fingerprint: computeFingerprint(buffer) });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
captureHostVerifierError(error);
|
|
286
|
+
}
|
|
29
287
|
return false;
|
|
30
288
|
},
|
|
31
289
|
port,
|
|
@@ -43,51 +301,86 @@ function resolveBootstrapOptions(options) {
|
|
|
43
301
|
}
|
|
44
302
|
function cleanupClient(client) {
|
|
45
303
|
client.removeAllListeners();
|
|
304
|
+
client.on("error", () => {
|
|
305
|
+
});
|
|
46
306
|
try {
|
|
47
307
|
client.end();
|
|
48
308
|
} catch {
|
|
49
309
|
}
|
|
50
310
|
}
|
|
51
311
|
function createSettlementHandlers(parameters) {
|
|
52
|
-
const { cleanup, host, port, reject, resolve:
|
|
312
|
+
const { cleanup, host, port, reject, resolve: resolve4 } = parameters;
|
|
53
313
|
let settled = false;
|
|
54
314
|
return {
|
|
55
|
-
rejectOnce
|
|
315
|
+
rejectOnce(error) {
|
|
56
316
|
if (settled) return;
|
|
57
317
|
settled = true;
|
|
58
318
|
cleanup();
|
|
59
319
|
reject(toError(error, host, port));
|
|
60
320
|
},
|
|
61
|
-
resolveOnce
|
|
321
|
+
resolveOnce(result) {
|
|
62
322
|
if (settled) return;
|
|
63
323
|
settled = true;
|
|
64
324
|
cleanup();
|
|
65
|
-
|
|
325
|
+
resolve4(result);
|
|
66
326
|
}
|
|
67
327
|
};
|
|
68
328
|
}
|
|
69
329
|
function registerFingerprintListeners(parameters) {
|
|
70
|
-
const { client,
|
|
330
|
+
const { client, onHostVerifierError, onScanResult, rejectOnce, resolveOnce } = parameters;
|
|
71
331
|
client.on("close", () => {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
332
|
+
const hostVerifierError = onHostVerifierError();
|
|
333
|
+
if (hostVerifierError != null) {
|
|
334
|
+
rejectOnce(hostVerifierError);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const capturedResult = onScanResult();
|
|
338
|
+
if (capturedResult != null) {
|
|
339
|
+
resolveOnce(capturedResult);
|
|
75
340
|
}
|
|
76
341
|
});
|
|
77
342
|
client.on("error", (error) => {
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
343
|
+
const hostVerifierError = onHostVerifierError();
|
|
344
|
+
if (hostVerifierError != null) {
|
|
345
|
+
rejectOnce(hostVerifierError);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const capturedResult = onScanResult();
|
|
349
|
+
if (capturedResult != null) {
|
|
350
|
+
resolveOnce(capturedResult);
|
|
81
351
|
return;
|
|
82
352
|
}
|
|
83
353
|
rejectOnce(error);
|
|
84
354
|
});
|
|
85
355
|
}
|
|
356
|
+
function armHalfOpenWatchdog(parameters) {
|
|
357
|
+
const { readyTimeoutMs, rejectOnce } = parameters;
|
|
358
|
+
const tcpConnectTimeout = setTimeout(() => {
|
|
359
|
+
rejectOnce(
|
|
360
|
+
new Error(
|
|
361
|
+
`host key scan TCP connect timed out after ${String(readyTimeoutMs)}ms (no SYN-ACK?)`
|
|
362
|
+
)
|
|
363
|
+
);
|
|
364
|
+
}, readyTimeoutMs);
|
|
365
|
+
const halfOpenWatchdog = setTimeout(() => {
|
|
366
|
+
rejectOnce(
|
|
367
|
+
new Error(`host key scan timed out after ${String(readyTimeoutMs * 2)}ms (TCP half-open?)`)
|
|
368
|
+
);
|
|
369
|
+
}, readyTimeoutMs * 2);
|
|
370
|
+
return () => {
|
|
371
|
+
clearTimeout(tcpConnectTimeout);
|
|
372
|
+
clearTimeout(halfOpenWatchdog);
|
|
373
|
+
};
|
|
374
|
+
}
|
|
86
375
|
async function readFingerprintFromClient(client, parameters) {
|
|
87
376
|
const { host, port, readyTimeoutMs } = parameters;
|
|
88
|
-
let
|
|
89
|
-
|
|
377
|
+
let capturedResult = null;
|
|
378
|
+
let hostVerifierError = null;
|
|
379
|
+
return new Promise((resolve4, reject) => {
|
|
380
|
+
let disarmWatchdog = null;
|
|
90
381
|
const cleanup = () => {
|
|
382
|
+
disarmWatchdog?.();
|
|
383
|
+
disarmWatchdog = null;
|
|
91
384
|
cleanupClient(client);
|
|
92
385
|
};
|
|
93
386
|
const { rejectOnce, resolveOnce } = createSettlementHandlers({
|
|
@@ -95,19 +388,24 @@ async function readFingerprintFromClient(client, parameters) {
|
|
|
95
388
|
host,
|
|
96
389
|
port,
|
|
97
390
|
reject,
|
|
98
|
-
resolve:
|
|
391
|
+
resolve: resolve4
|
|
99
392
|
});
|
|
393
|
+
disarmWatchdog = armHalfOpenWatchdog({ readyTimeoutMs, rejectOnce });
|
|
100
394
|
registerFingerprintListeners({
|
|
101
395
|
client,
|
|
102
|
-
|
|
396
|
+
onHostVerifierError: () => hostVerifierError,
|
|
397
|
+
onScanResult: () => capturedResult,
|
|
103
398
|
rejectOnce,
|
|
104
399
|
resolveOnce
|
|
105
400
|
});
|
|
106
401
|
try {
|
|
107
402
|
client.connect(
|
|
108
403
|
createConnectionConfig({
|
|
109
|
-
|
|
110
|
-
|
|
404
|
+
captureHostVerifierError(error) {
|
|
405
|
+
hostVerifierError = error;
|
|
406
|
+
},
|
|
407
|
+
captureScanResult(result) {
|
|
408
|
+
capturedResult = result;
|
|
111
409
|
},
|
|
112
410
|
host,
|
|
113
411
|
port,
|
|
@@ -120,6 +418,11 @@ async function readFingerprintFromClient(client, parameters) {
|
|
|
120
418
|
});
|
|
121
419
|
}
|
|
122
420
|
async function readHostFingerprintViaSsh2(host, options = {}) {
|
|
421
|
+
if (options.allowSsh2HostKeyScan !== true) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"Refusing to start ssh2 host-key scan without explicit opt-in. Pass allowSsh2HostKeyScan: true only after the operator requested a live SSH scan."
|
|
424
|
+
);
|
|
425
|
+
}
|
|
123
426
|
const { client, port, readyTimeoutMs } = resolveBootstrapOptions(options);
|
|
124
427
|
return readFingerprintFromClient(client, { host, port, readyTimeoutMs });
|
|
125
428
|
}
|
|
@@ -127,13 +430,17 @@ async function readHostFingerprintViaSsh2(host, options = {}) {
|
|
|
127
430
|
// src/promptUi.ts
|
|
128
431
|
import { emitKeypressEvents } from "readline";
|
|
129
432
|
function createSelectLines(prompt, options, selectedIndex) {
|
|
433
|
+
const escapedPrompt = escapeCliControlCharacters(prompt);
|
|
130
434
|
return [
|
|
131
|
-
|
|
435
|
+
escapedPrompt,
|
|
132
436
|
"",
|
|
133
437
|
"Use the arrow keys to choose an option:",
|
|
134
438
|
...options.flatMap((option, index) => {
|
|
135
439
|
const prefix = index === selectedIndex ? ">" : " ";
|
|
136
|
-
return [
|
|
440
|
+
return [
|
|
441
|
+
`${prefix} ${escapeCliControlCharacters(option.label)}`,
|
|
442
|
+
` ${escapeCliControlCharacters(option.description)}`
|
|
443
|
+
];
|
|
137
444
|
}),
|
|
138
445
|
"",
|
|
139
446
|
"Press Enter to confirm."
|
|
@@ -164,7 +471,9 @@ function prepareSelectInput() {
|
|
|
164
471
|
return { previousRawMode };
|
|
165
472
|
}
|
|
166
473
|
function cleanupSelectInput(previousRawMode) {
|
|
167
|
-
|
|
474
|
+
if (typeof previousRawMode === "boolean") {
|
|
475
|
+
process.stdin.setRawMode(previousRawMode);
|
|
476
|
+
}
|
|
168
477
|
process.stdin.pause();
|
|
169
478
|
process.stdout.write("\x1B[?25h");
|
|
170
479
|
}
|
|
@@ -172,13 +481,13 @@ function createSelectRenderer(prompt, options) {
|
|
|
172
481
|
let renderedLines = 0;
|
|
173
482
|
let selectedIndex = 0;
|
|
174
483
|
return {
|
|
175
|
-
moveDown
|
|
484
|
+
moveDown() {
|
|
176
485
|
selectedIndex = (selectedIndex + 1) % options.length;
|
|
177
486
|
},
|
|
178
|
-
moveUp
|
|
487
|
+
moveUp() {
|
|
179
488
|
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
180
489
|
},
|
|
181
|
-
render
|
|
490
|
+
render() {
|
|
182
491
|
renderedLines = redrawSelect(createSelectLines(prompt, options, selectedIndex), renderedLines);
|
|
183
492
|
},
|
|
184
493
|
selectedValue: () => options[selectedIndex].value
|
|
@@ -220,9 +529,10 @@ async function runTerminalSelect(prompt, options) {
|
|
|
220
529
|
ensureInteractiveTerminal();
|
|
221
530
|
const { previousRawMode } = prepareSelectInput();
|
|
222
531
|
const renderer = createSelectRenderer(prompt, options);
|
|
223
|
-
return new Promise((
|
|
532
|
+
return new Promise((resolve4, reject) => {
|
|
224
533
|
const cleanup = () => {
|
|
225
534
|
process.stdin.removeListener("keypress", onKeypress);
|
|
535
|
+
process.stdin.removeListener("error", onStdinError);
|
|
226
536
|
cleanupSelectInput(previousRawMode);
|
|
227
537
|
};
|
|
228
538
|
const onKeypress = (_character, key) => {
|
|
@@ -230,15 +540,20 @@ async function runTerminalSelect(prompt, options) {
|
|
|
230
540
|
return;
|
|
231
541
|
}
|
|
232
542
|
if (handleConfirmationKey(key.name, renderer, (value) => {
|
|
233
|
-
finishSelection(cleanup,
|
|
543
|
+
finishSelection(cleanup, resolve4, value);
|
|
234
544
|
})) {
|
|
235
545
|
return;
|
|
236
546
|
}
|
|
237
547
|
handleCancelKey(key, cleanup, reject);
|
|
238
548
|
};
|
|
549
|
+
const onStdinError = (error) => {
|
|
550
|
+
cleanup();
|
|
551
|
+
reject(error);
|
|
552
|
+
};
|
|
239
553
|
process.stdout.write("\x1B[?25l");
|
|
240
554
|
renderer.render();
|
|
241
555
|
process.stdin.on("keypress", onKeypress);
|
|
556
|
+
process.stdin.on("error", onStdinError);
|
|
242
557
|
});
|
|
243
558
|
}
|
|
244
559
|
function createTerminalSelect() {
|
|
@@ -249,48 +564,192 @@ function createTerminalSelect() {
|
|
|
249
564
|
}
|
|
250
565
|
|
|
251
566
|
// src/publicKeySelection.ts
|
|
252
|
-
import {
|
|
567
|
+
import {
|
|
568
|
+
closeSync,
|
|
569
|
+
fstatSync,
|
|
570
|
+
lstatSync,
|
|
571
|
+
openSync,
|
|
572
|
+
readdirSync,
|
|
573
|
+
readSync,
|
|
574
|
+
realpathSync
|
|
575
|
+
} from "fs";
|
|
253
576
|
import { homedir } from "os";
|
|
254
|
-
import { basename, join } from "path";
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
var
|
|
268
|
-
|
|
269
|
-
"ecdsa-sha2-
|
|
270
|
-
"ecdsa-sha2-
|
|
271
|
-
"
|
|
272
|
-
"sk-
|
|
273
|
-
"ssh-ed25519",
|
|
274
|
-
"ssh-rsa"
|
|
577
|
+
import { basename, join, resolve } from "path";
|
|
578
|
+
|
|
579
|
+
// src/openSshPublicKeyWire.ts
|
|
580
|
+
import { createPublicKey as createPublicKey2 } from "crypto";
|
|
581
|
+
var UINT32_BYTE_LENGTH = 4;
|
|
582
|
+
var ED25519_PUBLIC_KEY_BYTE_LENGTH2 = 32;
|
|
583
|
+
var NISTP256_UNCOMPRESSED_POINT_BYTE_LENGTH2 = 65;
|
|
584
|
+
var NISTP384_UNCOMPRESSED_POINT_BYTE_LENGTH2 = 97;
|
|
585
|
+
var NISTP521_UNCOMPRESSED_POINT_BYTE_LENGTH2 = 133;
|
|
586
|
+
var MPINT_SIGN_BIT = 128;
|
|
587
|
+
var UNCOMPRESSED_EC_POINT_PREFIX2 = 4;
|
|
588
|
+
var MINIMUM_RSA_MODULUS_BITS = 2048;
|
|
589
|
+
var MINIMUM_RSA_EXPONENT = 3n;
|
|
590
|
+
var BITS_PER_BYTE = 8;
|
|
591
|
+
var ecdsaCurveNames = /* @__PURE__ */ new Map([
|
|
592
|
+
["ecdsa-sha2-nistp256", "nistp256"],
|
|
593
|
+
["ecdsa-sha2-nistp384", "nistp384"],
|
|
594
|
+
["ecdsa-sha2-nistp521", "nistp521"],
|
|
595
|
+
["sk-ecdsa-sha2-nistp256@openssh.com", "nistp256"]
|
|
275
596
|
]);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
597
|
+
var ecdsaPointLengths = /* @__PURE__ */ new Map([
|
|
598
|
+
["nistp256", NISTP256_UNCOMPRESSED_POINT_BYTE_LENGTH2],
|
|
599
|
+
["nistp384", NISTP384_UNCOMPRESSED_POINT_BYTE_LENGTH2],
|
|
600
|
+
["nistp521", NISTP521_UNCOMPRESSED_POINT_BYTE_LENGTH2]
|
|
601
|
+
]);
|
|
602
|
+
var ecdsaJwkCurveNames = /* @__PURE__ */ new Map([
|
|
603
|
+
["nistp256", "P-256"],
|
|
604
|
+
["nistp384", "P-384"],
|
|
605
|
+
["nistp521", "P-521"]
|
|
606
|
+
]);
|
|
607
|
+
function readUint32(value, offset) {
|
|
608
|
+
if (offset + UINT32_BYTE_LENGTH > value.length) return null;
|
|
609
|
+
return {
|
|
610
|
+
nextOffset: offset + UINT32_BYTE_LENGTH,
|
|
611
|
+
value: value.readUInt32BE(offset)
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function readWireString2(value, offset) {
|
|
615
|
+
const lengthResult = readUint32(value, offset);
|
|
616
|
+
if (lengthResult == null) return null;
|
|
617
|
+
const endOffset = lengthResult.nextOffset + lengthResult.value;
|
|
618
|
+
if (endOffset > value.length) return null;
|
|
619
|
+
return {
|
|
620
|
+
nextOffset: endOffset,
|
|
621
|
+
value: value.subarray(lengthResult.nextOffset, endOffset)
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function readWireStringUtf8(value, offset) {
|
|
625
|
+
const result = readWireString2(value, offset);
|
|
626
|
+
return result == null ? null : {
|
|
627
|
+
nextOffset: result.nextOffset,
|
|
628
|
+
value: result.value.toString("utf8")
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function readMpint(value, offset) {
|
|
632
|
+
const result = readWireString2(value, offset);
|
|
633
|
+
if (result == null || result.value.length === 0) return null;
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
function isAtEnd(value, offset) {
|
|
637
|
+
return offset === value.length;
|
|
638
|
+
}
|
|
639
|
+
function isPositiveMpint(value) {
|
|
640
|
+
return value[0] < MPINT_SIGN_BIT;
|
|
641
|
+
}
|
|
642
|
+
function hasCanonicalMpintEncoding(value) {
|
|
643
|
+
if (value.length === 0) return false;
|
|
644
|
+
if (value[0] !== 0) return true;
|
|
645
|
+
if (value.length < 2) return false;
|
|
646
|
+
return value[1] >= MPINT_SIGN_BIT;
|
|
647
|
+
}
|
|
648
|
+
function removeMpintSignPadding(value) {
|
|
649
|
+
let offset = 0;
|
|
650
|
+
while (offset < value.length - 1 && value[offset] === 0) {
|
|
651
|
+
offset++;
|
|
279
652
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
653
|
+
return value.subarray(offset);
|
|
654
|
+
}
|
|
655
|
+
function mpintToBigInt(value) {
|
|
656
|
+
const normalizedValue = removeMpintSignPadding(value);
|
|
657
|
+
let result = 0n;
|
|
658
|
+
for (const byte of normalizedValue) {
|
|
659
|
+
result = (result << 8n) + BigInt(byte);
|
|
283
660
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
function getMpintBitLength(value) {
|
|
664
|
+
const normalizedValue = removeMpintSignPadding(value);
|
|
665
|
+
const firstByte = normalizedValue[0];
|
|
666
|
+
if (firstByte === 0) return 0;
|
|
667
|
+
return (normalizedValue.length - 1) * BITS_PER_BYTE + firstByte.toString(2).length;
|
|
668
|
+
}
|
|
669
|
+
function hasValidRsaExponent(value) {
|
|
670
|
+
const exponent = mpintToBigInt(value);
|
|
671
|
+
return exponent >= MINIMUM_RSA_EXPONENT && (exponent & 1n) === 1n;
|
|
672
|
+
}
|
|
673
|
+
function hasValidRsaModulus(value) {
|
|
674
|
+
return getMpintBitLength(value) >= MINIMUM_RSA_MODULUS_BITS;
|
|
675
|
+
}
|
|
676
|
+
function validateRsaWireKey(value, offset) {
|
|
677
|
+
const exponent = readMpint(value, offset);
|
|
678
|
+
if (exponent == null || !isPositiveMpint(exponent.value) || !hasCanonicalMpintEncoding(exponent.value) || !hasValidRsaExponent(exponent.value)) {
|
|
679
|
+
return false;
|
|
288
680
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
681
|
+
const modulus = readMpint(value, exponent.nextOffset);
|
|
682
|
+
return modulus != null && isPositiveMpint(modulus.value) && hasCanonicalMpintEncoding(modulus.value) && hasValidRsaModulus(modulus.value) && isAtEnd(value, modulus.nextOffset);
|
|
683
|
+
}
|
|
684
|
+
function hasValidEcdsaPoint(point, expectedPointLength) {
|
|
685
|
+
if (point == null) return false;
|
|
686
|
+
return point.value.length === expectedPointLength && point.value[0] === UNCOMPRESSED_EC_POINT_PREFIX2;
|
|
687
|
+
}
|
|
688
|
+
function toBase64Url(value) {
|
|
689
|
+
return value.toString("base64url");
|
|
690
|
+
}
|
|
691
|
+
function hasCryptographicallyValidEcdsaPoint(point, curveName) {
|
|
692
|
+
const jwkCurveName = ecdsaJwkCurveNames.get(curveName);
|
|
693
|
+
if (jwkCurveName == null) return false;
|
|
694
|
+
const coordinateLength = (point.length - 1) / 2;
|
|
695
|
+
const x = point.subarray(1, 1 + coordinateLength);
|
|
696
|
+
const y = point.subarray(1 + coordinateLength);
|
|
697
|
+
try {
|
|
698
|
+
createPublicKey2({
|
|
699
|
+
format: "jwk",
|
|
700
|
+
key: {
|
|
701
|
+
crv: jwkCurveName,
|
|
702
|
+
kty: "EC",
|
|
703
|
+
x: toBase64Url(x),
|
|
704
|
+
y: toBase64Url(y)
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
return true;
|
|
708
|
+
} catch {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function validateEcdsaWireKey(value, offset, algorithm) {
|
|
713
|
+
const expectedCurveName = ecdsaCurveNames.get(algorithm);
|
|
714
|
+
if (expectedCurveName == null) return false;
|
|
715
|
+
const curveName = readWireStringUtf8(value, offset);
|
|
716
|
+
if (curveName?.value !== expectedCurveName) return false;
|
|
717
|
+
const point = readWireString2(value, curveName.nextOffset);
|
|
718
|
+
const expectedPointLength = ecdsaPointLengths.get(curveName.value);
|
|
719
|
+
if (!hasValidEcdsaPoint(point, expectedPointLength)) return false;
|
|
720
|
+
if (!hasCryptographicallyValidEcdsaPoint(point.value, curveName.value)) return false;
|
|
721
|
+
if (algorithm !== "sk-ecdsa-sha2-nistp256@openssh.com") {
|
|
722
|
+
return isAtEnd(value, point.nextOffset);
|
|
723
|
+
}
|
|
724
|
+
const application = readWireString2(value, point.nextOffset);
|
|
725
|
+
if (application?.value.length === void 0 || application.value.length === 0) return false;
|
|
726
|
+
return isAtEnd(value, application.nextOffset);
|
|
727
|
+
}
|
|
728
|
+
function validateEd25519WireKey(value, offset, algorithm) {
|
|
729
|
+
const publicKey = readWireString2(value, offset);
|
|
730
|
+
if (publicKey?.value.length !== ED25519_PUBLIC_KEY_BYTE_LENGTH2) return false;
|
|
731
|
+
if (algorithm !== "sk-ssh-ed25519@openssh.com") {
|
|
732
|
+
return isAtEnd(value, publicKey.nextOffset);
|
|
733
|
+
}
|
|
734
|
+
const application = readWireString2(value, publicKey.nextOffset);
|
|
735
|
+
if (application?.value.length === void 0 || application.value.length === 0) return false;
|
|
736
|
+
return isAtEnd(value, application.nextOffset);
|
|
737
|
+
}
|
|
738
|
+
function hasValidOpenSshPublicKeyWireBlob(algorithm, encodedKey) {
|
|
739
|
+
const decodedKey = Buffer.from(encodedKey, "base64");
|
|
740
|
+
const wireAlgorithm = readWireStringUtf8(decodedKey, 0);
|
|
741
|
+
if (wireAlgorithm?.value !== algorithm) return false;
|
|
742
|
+
if (algorithm === "ssh-rsa") return validateRsaWireKey(decodedKey, wireAlgorithm.nextOffset);
|
|
743
|
+
if (algorithm.startsWith("ecdsa-") || algorithm.startsWith("sk-ecdsa-")) {
|
|
744
|
+
return validateEcdsaWireKey(decodedKey, wireAlgorithm.nextOffset, algorithm);
|
|
745
|
+
}
|
|
746
|
+
if (algorithm === "ssh-ed25519" || algorithm === "sk-ssh-ed25519@openssh.com") {
|
|
747
|
+
return validateEd25519WireKey(decodedKey, wireAlgorithm.nextOffset, algorithm);
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
293
750
|
}
|
|
751
|
+
|
|
752
|
+
// src/publicKeyBase64.ts
|
|
294
753
|
function trimBase64Padding(value) {
|
|
295
754
|
let endIndex = value.length;
|
|
296
755
|
while (endIndex > 0 && value[endIndex - 1] === "=") {
|
|
@@ -352,11 +811,130 @@ function isCanonicalBase64(value) {
|
|
|
352
811
|
return false;
|
|
353
812
|
}
|
|
354
813
|
}
|
|
814
|
+
|
|
815
|
+
// src/publicKeyProvenanceLog.ts
|
|
816
|
+
function logAdminPublicKeyRealpath(parameters) {
|
|
817
|
+
const escapedRealPath = escapeCliControlCharacters(parameters.realPath);
|
|
818
|
+
const escapedResolvedPath = escapeCliControlCharacters(parameters.resolvedPath);
|
|
819
|
+
if (parameters.isLeafSymlink) {
|
|
820
|
+
console.log(
|
|
821
|
+
`Reading public key from ${escapedRealPath} (symlink target of ${escapedResolvedPath}).`
|
|
822
|
+
);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
console.log(
|
|
826
|
+
`Reading public key from ${escapedRealPath} (resolved via ancestor symlink of ${escapedResolvedPath}).`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/unsafeCodepoints.ts
|
|
831
|
+
var UNSAFE_CODEPOINTS_PATTERN = new RegExp(
|
|
832
|
+
// oxlint-disable-next-line no-control-regex
|
|
833
|
+
"[\\u0000-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
|
|
834
|
+
"u"
|
|
835
|
+
);
|
|
836
|
+
function containsUnsafeCodepoint(value) {
|
|
837
|
+
return UNSAFE_CODEPOINTS_PATTERN.test(value);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/publicKeySelection.ts
|
|
841
|
+
var adminPublicKeyFileSystem = {
|
|
842
|
+
closeSync,
|
|
843
|
+
fstatSync,
|
|
844
|
+
lstatSync,
|
|
845
|
+
openSync,
|
|
846
|
+
readSync,
|
|
847
|
+
realpathSync
|
|
848
|
+
};
|
|
849
|
+
var PUBLIC_KEY_PROMPT_OPTIONS = [
|
|
850
|
+
{
|
|
851
|
+
description: "Read a public key from ~/.ssh and embed it directly into server.ts for the bootstrap admin user.",
|
|
852
|
+
label: "Use local public key",
|
|
853
|
+
value: "local"
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
description: "Keep the placeholder in server.ts and paste your public key manually before the first apply.",
|
|
857
|
+
label: "Keep placeholder",
|
|
858
|
+
value: "placeholder"
|
|
859
|
+
}
|
|
860
|
+
];
|
|
861
|
+
var supportedOpenSshAlgorithms = /* @__PURE__ */ new Set([
|
|
862
|
+
"ecdsa-sha2-nistp256",
|
|
863
|
+
"ecdsa-sha2-nistp384",
|
|
864
|
+
"ecdsa-sha2-nistp521",
|
|
865
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
866
|
+
"sk-ssh-ed25519@openssh.com",
|
|
867
|
+
"ssh-ed25519",
|
|
868
|
+
"ssh-rsa"
|
|
869
|
+
]);
|
|
870
|
+
var PRIVATE_KEY_MARKERS = [
|
|
871
|
+
"BEGIN OPENSSH PRIVATE KEY",
|
|
872
|
+
"BEGIN RSA PRIVATE KEY",
|
|
873
|
+
"BEGIN DSA PRIVATE KEY",
|
|
874
|
+
"BEGIN EC PRIVATE KEY",
|
|
875
|
+
"BEGIN PRIVATE KEY",
|
|
876
|
+
"BEGIN ENCRYPTED PRIVATE KEY"
|
|
877
|
+
];
|
|
878
|
+
var MAX_PUBLIC_KEY_FILE_BYTES = 16384;
|
|
879
|
+
function readPublicKeyFileContents(path, fileSystem = adminPublicKeyFileSystem) {
|
|
880
|
+
const fd = fileSystem.openSync(path, "r");
|
|
881
|
+
try {
|
|
882
|
+
const stat = fileSystem.fstatSync(fd);
|
|
883
|
+
if (!stat.isFile() || stat.size > MAX_PUBLIC_KEY_FILE_BYTES) {
|
|
884
|
+
throw new Error("Invalid public key file");
|
|
885
|
+
}
|
|
886
|
+
const buffer = Buffer.alloc(stat.size);
|
|
887
|
+
let bytesRead = 0;
|
|
888
|
+
while (bytesRead < buffer.length) {
|
|
889
|
+
const chunkBytesRead = fileSystem.readSync(
|
|
890
|
+
fd,
|
|
891
|
+
buffer,
|
|
892
|
+
bytesRead,
|
|
893
|
+
buffer.length - bytesRead,
|
|
894
|
+
bytesRead
|
|
895
|
+
);
|
|
896
|
+
if (chunkBytesRead === 0) break;
|
|
897
|
+
bytesRead += chunkBytesRead;
|
|
898
|
+
}
|
|
899
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
900
|
+
} finally {
|
|
901
|
+
fileSystem.closeSync(fd);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function containsPrivateKeyMarker(value) {
|
|
905
|
+
return PRIVATE_KEY_MARKERS.some((marker) => value.includes(marker));
|
|
906
|
+
}
|
|
907
|
+
function parseOpenSshPublicKey(value) {
|
|
908
|
+
if (value.length === 0 || new RegExp("[\\r\\n]", "v").test(value)) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
if (containsUnsafeCodepoint(value)) {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
const parts = value.split(new RegExp("\\s+", "v"));
|
|
915
|
+
if (parts.length < 2) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
const algorithm = parts[0];
|
|
919
|
+
const encodedKey = parts[1];
|
|
920
|
+
if (!supportedOpenSshAlgorithms.has(algorithm)) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
algorithm,
|
|
925
|
+
encodedKey
|
|
926
|
+
};
|
|
927
|
+
}
|
|
355
928
|
function isValidAdminPublicKey(value) {
|
|
356
929
|
const parsedKey = parseOpenSshPublicKey(value.trim());
|
|
357
|
-
return parsedKey != null && isCanonicalBase64(parsedKey.encodedKey);
|
|
930
|
+
return parsedKey != null && isCanonicalBase64(parsedKey.encodedKey) && hasValidOpenSshPublicKeyWireBlob(parsedKey.algorithm, parsedKey.encodedKey);
|
|
358
931
|
}
|
|
359
932
|
function validateAdminPublicKey(exitWithMessage2, value, optionName = "--admin-public-key") {
|
|
933
|
+
if (containsPrivateKeyMarker(value)) {
|
|
934
|
+
exitWithMessage2(
|
|
935
|
+
`Error: "${optionName}" contains a private key marker. Provide the matching OpenSSH public key (.pub) instead.`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
360
938
|
const normalizedValue = value.trim();
|
|
361
939
|
if (!isValidAdminPublicKey(normalizedValue)) {
|
|
362
940
|
exitWithMessage2(
|
|
@@ -365,26 +943,82 @@ function validateAdminPublicKey(exitWithMessage2, value, optionName = "--admin-p
|
|
|
365
943
|
}
|
|
366
944
|
return normalizedValue;
|
|
367
945
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
946
|
+
var ADMIN_PUBLIC_KEY_FILE_PATH_MAX_BYTES = 4096;
|
|
947
|
+
function preValidateAdminPublicKeyFilePath(exitWithMessage2, path) {
|
|
948
|
+
if (path.includes("\0")) {
|
|
949
|
+
exitWithMessage2(
|
|
950
|
+
"Error: admin public key file path contains a NUL byte; provide a clean filesystem path."
|
|
951
|
+
);
|
|
952
|
+
throw new Error("admin public key file path contains a NUL byte");
|
|
375
953
|
}
|
|
954
|
+
const pathByteLength = Buffer.byteLength(path, "utf8");
|
|
955
|
+
if (pathByteLength > ADMIN_PUBLIC_KEY_FILE_PATH_MAX_BYTES) {
|
|
956
|
+
exitWithMessage2(
|
|
957
|
+
`Error: admin public key file path is too long (${String(pathByteLength)} bytes, limit ${String(ADMIN_PUBLIC_KEY_FILE_PATH_MAX_BYTES)}); provide a shorter path.`
|
|
958
|
+
);
|
|
959
|
+
throw new Error("admin public key file path exceeds PATH_MAX");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function readAdminPublicKeyFile(exitWithMessage2, path, fileSystem = adminPublicKeyFileSystem) {
|
|
963
|
+
preValidateAdminPublicKeyFilePath(exitWithMessage2, path);
|
|
964
|
+
const resolvedPath = resolve(path);
|
|
965
|
+
const failWithReadError = () => {
|
|
966
|
+
exitWithMessage2(`Error: Failed to read admin public key file.`);
|
|
967
|
+
throw new Error(`Error: Failed to read admin public key file.`);
|
|
968
|
+
};
|
|
969
|
+
const linkStat = (() => {
|
|
970
|
+
try {
|
|
971
|
+
return fileSystem.lstatSync(resolvedPath);
|
|
972
|
+
} catch {
|
|
973
|
+
return failWithReadError();
|
|
974
|
+
}
|
|
975
|
+
})();
|
|
976
|
+
const materialisedPath = (() => {
|
|
977
|
+
try {
|
|
978
|
+
const realPath = fileSystem.realpathSync(resolvedPath);
|
|
979
|
+
if (realPath !== resolvedPath) {
|
|
980
|
+
logAdminPublicKeyRealpath({
|
|
981
|
+
isLeafSymlink: linkStat.isSymbolicLink(),
|
|
982
|
+
realPath,
|
|
983
|
+
resolvedPath
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return realPath;
|
|
987
|
+
} catch {
|
|
988
|
+
return resolvedPath;
|
|
989
|
+
}
|
|
990
|
+
})();
|
|
991
|
+
const value = (() => {
|
|
992
|
+
try {
|
|
993
|
+
return readPublicKeyFileContents(materialisedPath, fileSystem);
|
|
994
|
+
} catch {
|
|
995
|
+
return failWithReadError();
|
|
996
|
+
}
|
|
997
|
+
})();
|
|
376
998
|
return validateAdminPublicKey(exitWithMessage2, value, "--admin-public-key-file");
|
|
377
999
|
}
|
|
378
|
-
function
|
|
1000
|
+
function buildLocalPublicKeyLabel(entry, path, realPath) {
|
|
1001
|
+
if (realPath === path) return basename(entry);
|
|
1002
|
+
return `${basename(entry)} -> ${realPath}`;
|
|
1003
|
+
}
|
|
1004
|
+
function resolveDiscoveredEntryPath(path, fileSystem) {
|
|
1005
|
+
try {
|
|
1006
|
+
return fileSystem.realpathSync(path);
|
|
1007
|
+
} catch {
|
|
1008
|
+
return path;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function discoverLocalPublicKeys(sshDirectory = join(homedir(), ".ssh"), fileSystem = adminPublicKeyFileSystem) {
|
|
379
1012
|
try {
|
|
380
1013
|
return readdirSync(sshDirectory).filter((entry) => entry.endsWith(".pub")).sort((left, right) => left.localeCompare(right)).flatMap((entry) => {
|
|
381
1014
|
const path = join(sshDirectory, entry);
|
|
382
1015
|
try {
|
|
383
|
-
const
|
|
1016
|
+
const resolvedPath = resolveDiscoveredEntryPath(path, fileSystem);
|
|
1017
|
+
const key = readPublicKeyFileContents(resolvedPath, fileSystem).trim();
|
|
384
1018
|
if (!isValidAdminPublicKey(key)) {
|
|
385
1019
|
return [];
|
|
386
1020
|
}
|
|
387
|
-
return [{ key, label:
|
|
1021
|
+
return [{ key, label: buildLocalPublicKeyLabel(entry, path, resolvedPath), path }];
|
|
388
1022
|
} catch {
|
|
389
1023
|
return [];
|
|
390
1024
|
}
|
|
@@ -400,23 +1034,33 @@ function createPublicKeyOptions(publicKeys) {
|
|
|
400
1034
|
value: publicKey.path
|
|
401
1035
|
}));
|
|
402
1036
|
}
|
|
403
|
-
|
|
1037
|
+
function createPublicKeyModeOptions(allowPlaceholder) {
|
|
1038
|
+
if (allowPlaceholder) return PUBLIC_KEY_PROMPT_OPTIONS;
|
|
1039
|
+
return PUBLIC_KEY_PROMPT_OPTIONS.filter((option) => option.value !== "placeholder");
|
|
1040
|
+
}
|
|
1041
|
+
function printNoLocalPublicKeysMessage() {
|
|
1042
|
+
console.error(
|
|
1043
|
+
"No readable public keys were found in ~/.ssh. Keeping the placeholder in server.ts."
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
async function promptForAdminPublicKey(select, publicKeys = discoverLocalPublicKeys(), options) {
|
|
1047
|
+
const allowPlaceholder = options?.allowPlaceholder ?? true;
|
|
404
1048
|
const publicKeyMode = await select(
|
|
405
1049
|
"How should create-paratix configure the admin SSH public key?",
|
|
406
|
-
|
|
1050
|
+
createPublicKeyModeOptions(allowPlaceholder)
|
|
407
1051
|
);
|
|
408
1052
|
if (publicKeyMode !== "local") {
|
|
409
1053
|
return void 0;
|
|
410
1054
|
}
|
|
411
1055
|
if (publicKeys.length === 0) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1056
|
+
if (!allowPlaceholder) {
|
|
1057
|
+
throw new CliExitError(
|
|
1058
|
+
"Error: Root bootstrap requires an admin SSH public key, but no readable public keys were found in ~/.ssh. Provide one via --admin-public-key or --admin-public-key-file."
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
printNoLocalPublicKeysMessage();
|
|
415
1062
|
return void 0;
|
|
416
1063
|
}
|
|
417
|
-
if (publicKeys.length === 1) {
|
|
418
|
-
return publicKeys[0]?.key;
|
|
419
|
-
}
|
|
420
1064
|
const selectedPath = await select(
|
|
421
1065
|
"Select the public key to embed into server.ts:",
|
|
422
1066
|
createPublicKeyOptions(publicKeys)
|
|
@@ -425,7 +1069,10 @@ async function promptForAdminPublicKey(select, publicKeys = discoverLocalPublicK
|
|
|
425
1069
|
}
|
|
426
1070
|
|
|
427
1071
|
// 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>]";
|
|
1072
|
+
var CLI_USAGE = "Usage: create-paratix <project-name> [--host <domain-or-ip>] [--initial-user <root|name>] [--expected-host-fingerprint <fingerprint>] [--admin-public-key <ssh-public-key>] [--admin-public-key-file <path>]";
|
|
1073
|
+
var OPENSSH_SHA256_FINGERPRINT_PATTERN = new RegExp("^SHA256:[A-Za-z0-9+\\/]{43}$", "v");
|
|
1074
|
+
var SHA256_FINGERPRINT_PREFIX = "SHA256:";
|
|
1075
|
+
var SHA256_FINGERPRINT_RAW_BYTE_LENGTH = 32;
|
|
429
1076
|
function normalizeInitialUserName(name) {
|
|
430
1077
|
return name.trim();
|
|
431
1078
|
}
|
|
@@ -436,7 +1083,7 @@ function parseInitialUserConfig(exitWithMessage2, value) {
|
|
|
436
1083
|
const normalizedValue = normalizeInitialUserName(value);
|
|
437
1084
|
if (!isValidInitialUserName(normalizedValue)) {
|
|
438
1085
|
exitWithMessage2(
|
|
439
|
-
`Error: Invalid initial user
|
|
1086
|
+
`Error: Invalid initial user ${formatCliValue(value)} \u2014 use "root" or a valid lowercase Linux username.`
|
|
440
1087
|
);
|
|
441
1088
|
}
|
|
442
1089
|
return normalizedValue === "root" ? { kind: "root" } : { kind: "admin", user: normalizedValue };
|
|
@@ -446,30 +1093,94 @@ function normalizeHost(value) {
|
|
|
446
1093
|
}
|
|
447
1094
|
function isValidHost(value) {
|
|
448
1095
|
const normalizedValue = normalizeHost(value);
|
|
449
|
-
|
|
1096
|
+
if (normalizedValue.length === 0) return false;
|
|
1097
|
+
if (new RegExp("\\s", "v").test(normalizedValue)) return false;
|
|
1098
|
+
if (containsUnsafeCodepoint(normalizedValue)) return false;
|
|
1099
|
+
return true;
|
|
450
1100
|
}
|
|
451
1101
|
function validateHost(exitWithMessage2, value) {
|
|
452
1102
|
const normalizedValue = normalizeHost(value);
|
|
453
1103
|
if (!isValidHost(normalizedValue)) {
|
|
454
1104
|
exitWithMessage2(
|
|
455
|
-
`Error: Invalid host
|
|
1105
|
+
`Error: Invalid host ${formatCliValue(value)} \u2014 use a domain name, IPv4, or IPv6 address without spaces.`
|
|
456
1106
|
);
|
|
457
1107
|
}
|
|
458
1108
|
return normalizedValue;
|
|
459
1109
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (isValidHost(host)) {
|
|
464
|
-
return host;
|
|
465
|
-
}
|
|
466
|
-
console.error("Error: Please enter a domain name, IPv4, or IPv6 address without spaces.");
|
|
1110
|
+
function isValidExpectedHostFingerprint(value) {
|
|
1111
|
+
if (!OPENSSH_SHA256_FINGERPRINT_PATTERN.test(value)) {
|
|
1112
|
+
return false;
|
|
467
1113
|
}
|
|
1114
|
+
const encoded = value.slice(SHA256_FINGERPRINT_PREFIX.length);
|
|
1115
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
1116
|
+
if (decoded.length !== SHA256_FINGERPRINT_RAW_BYTE_LENGTH) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
const canonical = decoded.toString("base64");
|
|
1120
|
+
let trimmedEnd = canonical.length;
|
|
1121
|
+
while (trimmedEnd > 0 && canonical[trimmedEnd - 1] === "=") {
|
|
1122
|
+
trimmedEnd--;
|
|
1123
|
+
}
|
|
1124
|
+
return canonical.slice(0, trimmedEnd) === encoded;
|
|
1125
|
+
}
|
|
1126
|
+
function validateExpectedHostFingerprint(exitWithMessage2, value) {
|
|
1127
|
+
if (!isValidExpectedHostFingerprint(value)) {
|
|
1128
|
+
exitWithMessage2(
|
|
1129
|
+
`Error: Invalid expected host fingerprint ${formatCliValue(value)} \u2014 use an OpenSSH SHA256 fingerprint.`
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
return value;
|
|
1133
|
+
}
|
|
1134
|
+
function throwValidationError(message) {
|
|
1135
|
+
throw new Error(message);
|
|
1136
|
+
}
|
|
1137
|
+
function normalizeProgrammaticScaffoldStringOptions(options) {
|
|
1138
|
+
return {
|
|
1139
|
+
adminPublicKey: options?.adminPublicKey == null ? void 0 : validateAdminPublicKey(throwValidationError, options.adminPublicKey),
|
|
1140
|
+
expectedHostFingerprint: options?.expectedHostFingerprint == null ? void 0 : validateExpectedHostFingerprint(throwValidationError, options.expectedHostFingerprint),
|
|
1141
|
+
host: options?.host == null ? "1.2.3.4" : validateHost(throwValidationError, options.host)
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
var MAX_PROMPT_ATTEMPTS = 5;
|
|
1145
|
+
async function promptForHost(prompt) {
|
|
1146
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
|
|
1147
|
+
const rawAnswer = await prompt("Server host (domain or IP): ");
|
|
1148
|
+
const host = normalizeHost(rawAnswer);
|
|
1149
|
+
if (isValidHost(host)) {
|
|
1150
|
+
return host;
|
|
1151
|
+
}
|
|
1152
|
+
if (rawAnswer === "") {
|
|
1153
|
+
throw new CliExitError(
|
|
1154
|
+
"Error: No host provided \u2014 stdin is closed or empty. Pass --host <domain-or-ip>."
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
console.error("Error: Please enter a domain name, IPv4, or IPv6 address without spaces.");
|
|
1158
|
+
}
|
|
1159
|
+
throw new CliExitError(
|
|
1160
|
+
`Error: Too many invalid host entries (${String(MAX_PROMPT_ATTEMPTS)}). Pass --host <domain-or-ip> instead.`
|
|
1161
|
+
);
|
|
468
1162
|
}
|
|
469
1163
|
function parseArgumentValue(argv, index, parameters) {
|
|
1164
|
+
function failMissing(message) {
|
|
1165
|
+
parameters.exitWithMessage(message);
|
|
1166
|
+
throw new Error(message);
|
|
1167
|
+
}
|
|
470
1168
|
const value = argv.at(index + 1);
|
|
471
|
-
if (value == null
|
|
472
|
-
|
|
1169
|
+
if (value == null) {
|
|
1170
|
+
failMissing(`Error: Missing value for "${parameters.optionName}".`);
|
|
1171
|
+
}
|
|
1172
|
+
if (value.startsWith("--")) {
|
|
1173
|
+
failMissing(
|
|
1174
|
+
`Error: Expected a value for "${parameters.optionName}" but got the flag "${value}". If the value really starts with "--", separate it from the option with "--" or quote it explicitly.`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (value.trim() === "") {
|
|
1178
|
+
failMissing(`Error: Empty value for "${parameters.optionName}" \u2014 provide a non-empty value.`);
|
|
1179
|
+
}
|
|
1180
|
+
if (new RegExp("[\\r\\n]", "v").test(value)) {
|
|
1181
|
+
failMissing(
|
|
1182
|
+
`Error: Multi-line value for "${parameters.optionName}" \u2014 provide a single-line value.`
|
|
1183
|
+
);
|
|
473
1184
|
}
|
|
474
1185
|
return value;
|
|
475
1186
|
}
|
|
@@ -477,7 +1188,7 @@ function handleUnknownOption(argument, exitWithMessage2) {
|
|
|
477
1188
|
if (argument === "--bootstrap-root") {
|
|
478
1189
|
exitWithMessage2('Error: "--bootstrap-root" was removed. Use "--initial-user root" instead.');
|
|
479
1190
|
}
|
|
480
|
-
exitWithMessage2(`Error: Unknown option
|
|
1191
|
+
exitWithMessage2(`Error: Unknown option ${formatCliValue(argument)}.`);
|
|
481
1192
|
}
|
|
482
1193
|
function parseOptionAssignment(parameters) {
|
|
483
1194
|
if (parameters.argument === "--host") {
|
|
@@ -496,6 +1207,18 @@ function parseOptionAssignment(parameters) {
|
|
|
496
1207
|
})
|
|
497
1208
|
};
|
|
498
1209
|
}
|
|
1210
|
+
if (parameters.argument === "--expected-host-fingerprint") {
|
|
1211
|
+
const expectedHostFingerprint = parseArgumentValue(parameters.argv, parameters.index, {
|
|
1212
|
+
exitWithMessage: parameters.exitWithMessage,
|
|
1213
|
+
optionName: "--expected-host-fingerprint"
|
|
1214
|
+
});
|
|
1215
|
+
return {
|
|
1216
|
+
expectedHostFingerprint: validateExpectedHostFingerprint(
|
|
1217
|
+
parameters.exitWithMessage,
|
|
1218
|
+
expectedHostFingerprint
|
|
1219
|
+
)
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
499
1222
|
if (parameters.argument === "--admin-public-key") {
|
|
500
1223
|
return {
|
|
501
1224
|
adminPublicKey: parseArgumentValue(parameters.argv, parameters.index, {
|
|
@@ -518,6 +1241,7 @@ function parseCliArguments(argv, exitWithMessage2) {
|
|
|
518
1241
|
const parsed = {
|
|
519
1242
|
adminPublicKey: void 0,
|
|
520
1243
|
adminPublicKeyFile: void 0,
|
|
1244
|
+
expectedHostFingerprint: void 0,
|
|
521
1245
|
host: void 0,
|
|
522
1246
|
initialUser: void 0,
|
|
523
1247
|
projectName: void 0
|
|
@@ -557,10 +1281,19 @@ function validatePublicKeyOptions(parsed, exitWithMessage2) {
|
|
|
557
1281
|
|
|
558
1282
|
// src/interactivePrompts.ts
|
|
559
1283
|
var NOOP = () => void 0;
|
|
560
|
-
var INTERACTIVE_SELECTION_UNAVAILABLE = "Interactive selection is unavailable.";
|
|
1284
|
+
var INTERACTIVE_SELECTION_UNAVAILABLE = "Interactive selection is unavailable. Pass --initial-user <root|name> to skip the prompt.";
|
|
561
1285
|
var UNAVAILABLE_SELECT = (() => {
|
|
562
|
-
throw new
|
|
1286
|
+
throw new CliExitError(INTERACTIVE_SELECTION_UNAVAILABLE, 1);
|
|
563
1287
|
});
|
|
1288
|
+
var GENERIC_NON_TTY_HINT = "Pass --admin-public-key/--admin-public-key-file and --expected-host-fingerprint (or run create-paratix from an interactive shell) to skip the prompt.";
|
|
1289
|
+
var INITIAL_USER_NON_TTY_HINT = "Pass --initial-user <root|name> (or run create-paratix from an interactive shell) to skip the prompt.";
|
|
1290
|
+
function enforceInteractivePromptTty(remediationHint) {
|
|
1291
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return;
|
|
1292
|
+
throw new CliExitError(`Interactive prompt requires a TTY. ${remediationHint}`, 1);
|
|
1293
|
+
}
|
|
1294
|
+
function ensureInteractivePromptTty() {
|
|
1295
|
+
enforceInteractivePromptTty(GENERIC_NON_TTY_HINT);
|
|
1296
|
+
}
|
|
564
1297
|
var INITIAL_USER_OPTIONS = [
|
|
565
1298
|
{
|
|
566
1299
|
description: "Fresh server with SSH access only as root. Paratix bootstraps a dedicated admin user first.",
|
|
@@ -580,15 +1313,40 @@ var HOST_FINGERPRINT_OPTIONS = [
|
|
|
580
1313
|
value: "scan"
|
|
581
1314
|
},
|
|
582
1315
|
{
|
|
583
|
-
description: "
|
|
584
|
-
label: "
|
|
1316
|
+
description: "Skip pinning now. The generated project will fail closed until known_hosts is prepared or a verified expectedHostFingerprint/PublicKey is added.",
|
|
1317
|
+
label: "Skip pinning",
|
|
585
1318
|
value: "placeholder"
|
|
586
1319
|
}
|
|
587
1320
|
];
|
|
1321
|
+
var HOST_FINGERPRINT_CONFIRM_OPTIONS = [
|
|
1322
|
+
{
|
|
1323
|
+
description: "Skip pinning now. The generated project will fail closed until known_hosts is prepared or a verified host-key pin is added.",
|
|
1324
|
+
label: "Discard and skip",
|
|
1325
|
+
value: "discard"
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
description: "Pin the scanned fingerprint into server.ts. Only choose this if the algorithm and fingerprint match an out-of-band reference (server console, provider dashboard, ssh-keyscan over a trusted network).",
|
|
1329
|
+
label: "Pin this fingerprint",
|
|
1330
|
+
value: "pin"
|
|
1331
|
+
}
|
|
1332
|
+
];
|
|
1333
|
+
function describeScanResult(host, result) {
|
|
1334
|
+
const escapedHost = escapeCliControlCharacters(host);
|
|
1335
|
+
return [
|
|
1336
|
+
"",
|
|
1337
|
+
`Scanned SSH host key for ${escapedHost}:22`,
|
|
1338
|
+
` algorithm: ${escapeCliControlCharacters(result.algorithm)}`,
|
|
1339
|
+
` fingerprint:`,
|
|
1340
|
+
` ${escapeCliControlCharacters(result.fingerprint)}`,
|
|
1341
|
+
"",
|
|
1342
|
+
"Compare this value against an out-of-band reference before pinning it.",
|
|
1343
|
+
""
|
|
1344
|
+
].join("\n");
|
|
1345
|
+
}
|
|
588
1346
|
function createTerminalPrompt() {
|
|
589
1347
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
590
1348
|
return {
|
|
591
|
-
close
|
|
1349
|
+
close() {
|
|
592
1350
|
readline.close();
|
|
593
1351
|
},
|
|
594
1352
|
prompt: async (question) => readline.question(question)
|
|
@@ -615,17 +1373,25 @@ function createPromptSession(prompt) {
|
|
|
615
1373
|
closeSelect: NOOP
|
|
616
1374
|
};
|
|
617
1375
|
}
|
|
618
|
-
async function promptForAdminUser(ask
|
|
619
|
-
for (; ; ) {
|
|
620
|
-
const
|
|
1376
|
+
async function promptForAdminUser(ask) {
|
|
1377
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
|
|
1378
|
+
const rawAnswer = await ask("Admin username: ");
|
|
1379
|
+
const adminUser = normalizeInitialUserName(rawAnswer);
|
|
621
1380
|
if (isValidInitialUserName(adminUser) && adminUser !== "root") {
|
|
622
|
-
closePrompt();
|
|
623
1381
|
return { kind: "admin", user: adminUser };
|
|
624
1382
|
}
|
|
1383
|
+
if (rawAnswer === "") {
|
|
1384
|
+
throw new CliExitError(
|
|
1385
|
+
"Error: No admin username provided \u2014 stdin is closed or empty. Pass --initial-user <root|name>."
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
625
1388
|
console.error(
|
|
626
1389
|
'Error: Invalid admin username. Use a valid lowercase Linux username other than "root".'
|
|
627
1390
|
);
|
|
628
1391
|
}
|
|
1392
|
+
throw new CliExitError(
|
|
1393
|
+
`Error: Too many invalid admin username entries (${String(MAX_PROMPT_ATTEMPTS)}). Pass --initial-user <root|name> instead.`
|
|
1394
|
+
);
|
|
629
1395
|
}
|
|
630
1396
|
async function promptForHost2(prompt, closePrompt = NOOP) {
|
|
631
1397
|
if (prompt != null) {
|
|
@@ -635,6 +1401,7 @@ async function promptForHost2(prompt, closePrompt = NOOP) {
|
|
|
635
1401
|
closePrompt();
|
|
636
1402
|
}
|
|
637
1403
|
}
|
|
1404
|
+
ensureInteractivePromptTty();
|
|
638
1405
|
const terminalPrompt = createTerminalPrompt();
|
|
639
1406
|
try {
|
|
640
1407
|
return await promptForHost(terminalPrompt.prompt);
|
|
@@ -642,8 +1409,11 @@ async function promptForHost2(prompt, closePrompt = NOOP) {
|
|
|
642
1409
|
terminalPrompt.close();
|
|
643
1410
|
}
|
|
644
1411
|
}
|
|
645
|
-
async function promptForInitialUserConfig(prompt, select) {
|
|
646
|
-
|
|
1412
|
+
async function promptForInitialUserConfig(prompt, select, createSession = createPromptSession) {
|
|
1413
|
+
if (prompt == null && createSession === createPromptSession) {
|
|
1414
|
+
enforceInteractivePromptTty(INITIAL_USER_NON_TTY_HINT);
|
|
1415
|
+
}
|
|
1416
|
+
const promptSession = createSession(prompt);
|
|
647
1417
|
const chooseInitialUser = select ?? promptSession.chooseInitialUser;
|
|
648
1418
|
try {
|
|
649
1419
|
const initialUserType = await chooseInitialUser(
|
|
@@ -651,117 +1421,458 @@ async function promptForInitialUserConfig(prompt, select) {
|
|
|
651
1421
|
INITIAL_USER_OPTIONS
|
|
652
1422
|
);
|
|
653
1423
|
if (initialUserType === "root") {
|
|
654
|
-
promptSession.closeSelect();
|
|
655
|
-
promptSession.closePrompt();
|
|
656
1424
|
return { kind: "root" };
|
|
657
1425
|
}
|
|
658
|
-
return await promptForAdminUser(promptSession.ask
|
|
1426
|
+
return await promptForAdminUser(promptSession.ask);
|
|
659
1427
|
} finally {
|
|
660
1428
|
promptSession.closeSelect();
|
|
1429
|
+
promptSession.closePrompt();
|
|
661
1430
|
}
|
|
662
1431
|
}
|
|
663
|
-
async function promptForAdminPublicKey2(select, publicKeys) {
|
|
1432
|
+
async function promptForAdminPublicKey2(select, publicKeys, options) {
|
|
1433
|
+
if (select == null) ensureInteractivePromptTty();
|
|
664
1434
|
const terminalSelect = select == null ? createTerminalSelect() : null;
|
|
665
1435
|
const choose = select ?? terminalSelect?.select;
|
|
666
1436
|
if (choose == null) {
|
|
667
1437
|
throw new Error(INTERACTIVE_SELECTION_UNAVAILABLE);
|
|
668
1438
|
}
|
|
669
1439
|
try {
|
|
670
|
-
return await promptForAdminPublicKey(choose, publicKeys);
|
|
1440
|
+
return await promptForAdminPublicKey(choose, publicKeys, options);
|
|
671
1441
|
} finally {
|
|
672
1442
|
terminalSelect?.close();
|
|
673
1443
|
}
|
|
674
1444
|
}
|
|
675
|
-
async function
|
|
1445
|
+
async function scanHostFingerprint(host) {
|
|
1446
|
+
return readHostFingerprintViaSsh2(host, { allowSsh2HostKeyScan: true });
|
|
1447
|
+
}
|
|
1448
|
+
async function chooseFrom(choose, prompt, options) {
|
|
1449
|
+
const result = await choose(prompt, options);
|
|
1450
|
+
const matchedOption = options.find((option) => option.value === result);
|
|
1451
|
+
if (matchedOption == null) {
|
|
1452
|
+
throw new Error(
|
|
1453
|
+
`Internal error: select returned an unexpected option for prompt ${JSON.stringify(prompt)}.`
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
return matchedOption.value;
|
|
1457
|
+
}
|
|
1458
|
+
function failAfterScanFailure(host, error) {
|
|
1459
|
+
const reason = escapeCliControlCharacters(error instanceof Error ? error.message : String(error));
|
|
1460
|
+
const escapedHost = escapeCliControlCharacters(host);
|
|
1461
|
+
console.error(
|
|
1462
|
+
[
|
|
1463
|
+
"",
|
|
1464
|
+
`Warning: failed to scan SSH host key for ${escapedHost}.`,
|
|
1465
|
+
` reason: ${reason}`,
|
|
1466
|
+
" This may indicate a man-in-the-middle attempt or a firewall blocking",
|
|
1467
|
+
" the SSH handshake. Verify the host key out of band before pinning",
|
|
1468
|
+
" any fingerprint into server.ts.",
|
|
1469
|
+
""
|
|
1470
|
+
].join("\n")
|
|
1471
|
+
);
|
|
1472
|
+
throw new Error(
|
|
1473
|
+
`Aborting scaffolding: host-key scan for ${escapedHost} failed (${reason}). Re-run create-paratix once the SSH handshake to ${escapedHost}:22 succeeds, or pass --expected-host-fingerprint with an out-of-band verified fingerprint.`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
async function scanAndConfirmFingerprint(parameters) {
|
|
1477
|
+
const { choose, host, scanner } = parameters;
|
|
1478
|
+
let result;
|
|
1479
|
+
try {
|
|
1480
|
+
result = await scanner(host);
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
failAfterScanFailure(host, error);
|
|
1483
|
+
}
|
|
1484
|
+
console.log(describeScanResult(host, result));
|
|
1485
|
+
const confirmation = await chooseFrom(
|
|
1486
|
+
choose,
|
|
1487
|
+
`Pin the scanned host fingerprint for ${escapeCliControlCharacters(host)}?`,
|
|
1488
|
+
HOST_FINGERPRINT_CONFIRM_OPTIONS
|
|
1489
|
+
);
|
|
1490
|
+
if (confirmation !== "pin") {
|
|
1491
|
+
const escapedHost = escapeCliControlCharacters(host);
|
|
1492
|
+
throw new Error(
|
|
1493
|
+
`Aborting scaffolding: scanned host fingerprint for ${escapedHost} was not pinned. Re-run create-paratix and pin a verified fingerprint, or pass --expected-host-fingerprint.`
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
return result.fingerprint;
|
|
1497
|
+
}
|
|
1498
|
+
async function promptForHostFingerprint(host, select, scanner = scanHostFingerprint) {
|
|
1499
|
+
if (select == null) ensureInteractivePromptTty();
|
|
676
1500
|
const terminalSelect = select == null ? createTerminalSelect() : null;
|
|
677
1501
|
const choose = select ?? terminalSelect?.select;
|
|
678
1502
|
if (choose == null) {
|
|
679
1503
|
throw new Error(INTERACTIVE_SELECTION_UNAVAILABLE);
|
|
680
1504
|
}
|
|
681
1505
|
try {
|
|
682
|
-
const hostKeyMode = await
|
|
683
|
-
|
|
1506
|
+
const hostKeyMode = await chooseFrom(
|
|
1507
|
+
choose,
|
|
1508
|
+
`How should create-paratix bootstrap the SSH host key for ${escapeCliControlCharacters(host)}?`,
|
|
684
1509
|
HOST_FINGERPRINT_OPTIONS
|
|
685
1510
|
);
|
|
686
1511
|
if (hostKeyMode !== "scan") {
|
|
687
1512
|
return void 0;
|
|
688
1513
|
}
|
|
689
|
-
|
|
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
|
-
}
|
|
1514
|
+
return await scanAndConfirmFingerprint({ choose, host, scanner });
|
|
697
1515
|
} finally {
|
|
698
1516
|
terminalSelect?.close();
|
|
699
1517
|
}
|
|
700
1518
|
}
|
|
701
1519
|
|
|
702
|
-
// src/
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (
|
|
709
|
-
|
|
1520
|
+
// src/cliValidation.ts
|
|
1521
|
+
function exitWithMessage(message) {
|
|
1522
|
+
console.error(escapeCliControlCharacters(message));
|
|
1523
|
+
throw new CliExitError(message, 1, { reported: true });
|
|
1524
|
+
}
|
|
1525
|
+
function restoreInteractiveTerminal() {
|
|
1526
|
+
if (process.stdout.isTTY) {
|
|
1527
|
+
process.stdout.write("\x1B[?25h");
|
|
710
1528
|
}
|
|
711
|
-
if (
|
|
712
|
-
|
|
1529
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
1530
|
+
try {
|
|
1531
|
+
process.stdin.setRawMode(false);
|
|
1532
|
+
} catch {
|
|
1533
|
+
}
|
|
713
1534
|
}
|
|
714
|
-
|
|
715
|
-
|
|
1535
|
+
}
|
|
1536
|
+
function handleCliExit(error) {
|
|
1537
|
+
restoreInteractiveTerminal();
|
|
1538
|
+
if (error instanceof CliExitError) {
|
|
1539
|
+
if (!error.reported) {
|
|
1540
|
+
console.error(escapeCliControlCharacters(error.cliMessage));
|
|
1541
|
+
}
|
|
1542
|
+
process.exitCode = error.exitCode;
|
|
1543
|
+
return;
|
|
716
1544
|
}
|
|
717
|
-
|
|
1545
|
+
console.error(escapeCliControlCharacters(error instanceof Error ? error.message : String(error)));
|
|
1546
|
+
process.exitCode = 1;
|
|
718
1547
|
}
|
|
719
|
-
function
|
|
720
|
-
|
|
1548
|
+
function parseCliArguments2(argv) {
|
|
1549
|
+
return parseCliArguments(argv, exitWithMessage);
|
|
1550
|
+
}
|
|
1551
|
+
function parseInitialUserConfig2(value) {
|
|
1552
|
+
return parseInitialUserConfig(exitWithMessage, value);
|
|
1553
|
+
}
|
|
1554
|
+
function validateHost2(value) {
|
|
1555
|
+
return validateHost(exitWithMessage, value);
|
|
1556
|
+
}
|
|
1557
|
+
function validateExpectedHostFingerprint2(value) {
|
|
1558
|
+
return validateExpectedHostFingerprint(exitWithMessage, value);
|
|
1559
|
+
}
|
|
1560
|
+
async function resolveCliOrPromptAdminPublicKey(parameters) {
|
|
1561
|
+
const { adminPublicKey, adminPublicKeyFile, allowPlaceholder = true } = parameters;
|
|
1562
|
+
if (adminPublicKey !== void 0) {
|
|
1563
|
+
return validateAdminPublicKey(exitWithMessage, adminPublicKey);
|
|
1564
|
+
}
|
|
1565
|
+
if (adminPublicKeyFile !== void 0) {
|
|
1566
|
+
return readAdminPublicKeyFile(exitWithMessage, adminPublicKeyFile);
|
|
1567
|
+
}
|
|
1568
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
1569
|
+
return promptForAdminPublicKey2(void 0, void 0, { allowPlaceholder });
|
|
1570
|
+
}
|
|
1571
|
+
return void 0;
|
|
1572
|
+
}
|
|
1573
|
+
async function resolveCliOrPromptHost(host, prompt = promptForHost2) {
|
|
1574
|
+
if (host !== void 0) return validateHost2(host);
|
|
1575
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return prompt();
|
|
1576
|
+
exitWithMessage("Missing --host in non-interactive environment. Pass --host <domain-or-ip>.");
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// src/directExecution.ts
|
|
1580
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
1581
|
+
import { resolve as resolve2 } from "path";
|
|
1582
|
+
import { fileURLToPath } from "url";
|
|
1583
|
+
function normalizeExecutionPath(path) {
|
|
1584
|
+
const resolvedPath = resolve2(path);
|
|
721
1585
|
try {
|
|
722
|
-
|
|
723
|
-
|
|
1586
|
+
return realpathSync2.native(resolvedPath);
|
|
1587
|
+
} catch {
|
|
1588
|
+
return resolvedPath;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function isDirectExecution(moduleUrl, argv1) {
|
|
1592
|
+
if (argv1 == null) return false;
|
|
1593
|
+
try {
|
|
1594
|
+
return normalizeExecutionPath(fileURLToPath(moduleUrl)) === normalizeExecutionPath(argv1);
|
|
1595
|
+
} catch {
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/projectDirectory.ts
|
|
1601
|
+
import { randomBytes } from "crypto";
|
|
1602
|
+
import {
|
|
1603
|
+
existsSync,
|
|
1604
|
+
lstatSync as lstatSync2,
|
|
1605
|
+
mkdirSync,
|
|
1606
|
+
mkdtempSync,
|
|
1607
|
+
readdirSync as readdirSync2,
|
|
1608
|
+
renameSync,
|
|
1609
|
+
rmdirSync,
|
|
1610
|
+
rmSync,
|
|
1611
|
+
writeFileSync
|
|
1612
|
+
} from "fs";
|
|
1613
|
+
import { dirname, join as join2 } from "path";
|
|
1614
|
+
var RESERVATION_SENTINEL_PREFIX = ".paratix-reservation-";
|
|
1615
|
+
var RESERVATION_SENTINEL_TOKEN_BYTES = 16;
|
|
1616
|
+
function isErrnoException(error) {
|
|
1617
|
+
return error instanceof Error && "code" in error && typeof error.code === "string";
|
|
1618
|
+
}
|
|
1619
|
+
function exitWithDirectoryAlreadyExists(normalizedProjectName) {
|
|
1620
|
+
exitWithMessage(`Error: Directory ${formatCliValue(normalizedProjectName)} already exists.`);
|
|
1621
|
+
throw new Error(`Error: Directory ${formatCliValue(normalizedProjectName)} already exists.`);
|
|
1622
|
+
}
|
|
1623
|
+
function readProjectDirectoryIdentity(projectDirectory) {
|
|
1624
|
+
const stats = lstatSync2(projectDirectory);
|
|
1625
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
1626
|
+
}
|
|
1627
|
+
function reserveProjectDirectoryWithSentinel(projectDirectory, normalizedProjectName) {
|
|
1628
|
+
try {
|
|
1629
|
+
mkdirSync(projectDirectory, { recursive: false });
|
|
724
1630
|
} catch (error) {
|
|
725
|
-
if (error
|
|
726
|
-
|
|
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}`);
|
|
1631
|
+
if (isErrnoException(error) && error.code === "EEXIST") {
|
|
1632
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
732
1633
|
}
|
|
733
|
-
|
|
734
|
-
return false;
|
|
1634
|
+
throw error;
|
|
735
1635
|
}
|
|
1636
|
+
const reservationSentinel = `${RESERVATION_SENTINEL_PREFIX}${randomBytes(
|
|
1637
|
+
RESERVATION_SENTINEL_TOKEN_BYTES
|
|
1638
|
+
).toString("hex")}`;
|
|
1639
|
+
try {
|
|
1640
|
+
writeFileSync(join2(projectDirectory, reservationSentinel), "");
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
rmdirSync(projectDirectory);
|
|
1643
|
+
throw error;
|
|
1644
|
+
}
|
|
1645
|
+
return reservationSentinel;
|
|
736
1646
|
}
|
|
737
|
-
function
|
|
738
|
-
|
|
1647
|
+
function createStagedProjectDirectory(projectDirectory, normalizedProjectName) {
|
|
1648
|
+
const reservationSentinel = reserveProjectDirectoryWithSentinel(
|
|
1649
|
+
projectDirectory,
|
|
1650
|
+
normalizedProjectName
|
|
1651
|
+
);
|
|
1652
|
+
const projectDirectoryIdentity = readProjectDirectoryIdentity(projectDirectory);
|
|
1653
|
+
const stagingParentDirectory = dirname(projectDirectory);
|
|
1654
|
+
const stagingPrefix = join2(stagingParentDirectory, `.${normalizedProjectName}-staging-`);
|
|
1655
|
+
try {
|
|
1656
|
+
return {
|
|
1657
|
+
projectDirectory,
|
|
1658
|
+
projectDirectoryIdentity,
|
|
1659
|
+
reservationSentinel,
|
|
1660
|
+
stagingDirectory: mkdtempSync(stagingPrefix)
|
|
1661
|
+
};
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
removeReservedProjectDirectoryIfEmpty({
|
|
1664
|
+
projectDirectory,
|
|
1665
|
+
projectDirectoryIdentity,
|
|
1666
|
+
reservationSentinel
|
|
1667
|
+
});
|
|
1668
|
+
throw error;
|
|
1669
|
+
}
|
|
739
1670
|
}
|
|
740
|
-
function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1671
|
+
function assertReservedProjectDirectory({ projectDirectory, projectDirectoryIdentity, reservationSentinel }, normalizedProjectName) {
|
|
1672
|
+
if (!isSameProjectDirectoryIdentity(projectDirectory, projectDirectoryIdentity)) {
|
|
1673
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1674
|
+
}
|
|
1675
|
+
if (!existsSync(join2(projectDirectory, reservationSentinel))) {
|
|
1676
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1677
|
+
}
|
|
1678
|
+
const foreignEntries = readdirSync2(projectDirectory).filter(
|
|
1679
|
+
(name) => name !== reservationSentinel
|
|
1680
|
+
);
|
|
1681
|
+
if (foreignEntries.length > 0) {
|
|
1682
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function createQuarantinePath(parentDirectory, normalizedProjectName) {
|
|
1686
|
+
const quarantineDirectory = mkdtempSync(
|
|
1687
|
+
join2(parentDirectory, `.${normalizedProjectName}-quarantine-`)
|
|
1688
|
+
);
|
|
1689
|
+
rmdirSync(quarantineDirectory);
|
|
1690
|
+
return quarantineDirectory;
|
|
1691
|
+
}
|
|
1692
|
+
function restoreQuarantinedDirectory(quarantineDirectory, projectDirectory) {
|
|
1693
|
+
try {
|
|
1694
|
+
renameSync(quarantineDirectory, projectDirectory);
|
|
1695
|
+
} catch {
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
function quarantineReservedProjectDirectory({
|
|
1699
|
+
projectDirectory,
|
|
1700
|
+
projectDirectoryIdentity,
|
|
1701
|
+
reservationSentinel
|
|
1702
|
+
}, normalizedProjectName) {
|
|
1703
|
+
const quarantineDirectory = createQuarantinePath(dirname(projectDirectory), normalizedProjectName);
|
|
1704
|
+
renameSync(projectDirectory, quarantineDirectory);
|
|
1705
|
+
if (!isSameProjectDirectoryIdentity(quarantineDirectory, projectDirectoryIdentity)) {
|
|
1706
|
+
restoreQuarantinedDirectory(quarantineDirectory, projectDirectory);
|
|
1707
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1708
|
+
}
|
|
1709
|
+
if (!existsSync(join2(quarantineDirectory, reservationSentinel))) {
|
|
1710
|
+
restoreQuarantinedDirectory(quarantineDirectory, projectDirectory);
|
|
1711
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1712
|
+
}
|
|
1713
|
+
const foreignEntries = readdirSync2(quarantineDirectory).filter(
|
|
1714
|
+
(name) => name !== reservationSentinel
|
|
1715
|
+
);
|
|
1716
|
+
if (foreignEntries.length > 0) {
|
|
1717
|
+
restoreQuarantinedDirectory(quarantineDirectory, projectDirectory);
|
|
1718
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1719
|
+
}
|
|
1720
|
+
return quarantineDirectory;
|
|
1721
|
+
}
|
|
1722
|
+
function publishStagedProjectDirectory(stagedProjectDirectory, normalizedProjectName) {
|
|
1723
|
+
const { projectDirectory, reservationSentinel, stagingDirectory } = stagedProjectDirectory;
|
|
1724
|
+
const quarantineDirectory = quarantineReservedProjectDirectory(
|
|
1725
|
+
stagedProjectDirectory,
|
|
1726
|
+
normalizedProjectName
|
|
1727
|
+
);
|
|
1728
|
+
try {
|
|
1729
|
+
renameSync(stagingDirectory, projectDirectory);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
restoreQuarantinedDirectory(quarantineDirectory, projectDirectory);
|
|
1732
|
+
throw error;
|
|
1733
|
+
}
|
|
1734
|
+
rmSync(join2(quarantineDirectory, reservationSentinel), { force: true });
|
|
1735
|
+
rmdirSync(quarantineDirectory);
|
|
1736
|
+
}
|
|
1737
|
+
function finalizeStagedProjectDirectory(stagedProjectDirectory, normalizedProjectName) {
|
|
1738
|
+
try {
|
|
1739
|
+
assertReservedProjectDirectory(stagedProjectDirectory, normalizedProjectName);
|
|
1740
|
+
publishStagedProjectDirectory(stagedProjectDirectory, normalizedProjectName);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
const errorCode = isErrnoException(error) ? error.code : void 0;
|
|
1743
|
+
if (errorCode === "EEXIST" || errorCode === "ENOTEMPTY") {
|
|
1744
|
+
exitWithDirectoryAlreadyExists(normalizedProjectName);
|
|
1745
|
+
}
|
|
1746
|
+
throw error;
|
|
1747
|
+
}
|
|
1748
|
+
return readProjectDirectoryIdentity(stagedProjectDirectory.projectDirectory);
|
|
1749
|
+
}
|
|
1750
|
+
function isSameProjectDirectoryIdentity(projectDirectory, identity) {
|
|
1751
|
+
try {
|
|
1752
|
+
const currentIdentity = readProjectDirectoryIdentity(projectDirectory);
|
|
1753
|
+
return currentIdentity.dev === identity.dev && currentIdentity.ino === identity.ino;
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
throw error;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function readForeignReservationEntries(projectDirectory, reservationSentinel) {
|
|
1762
|
+
try {
|
|
1763
|
+
return readdirSync2(projectDirectory).filter((name) => name !== reservationSentinel);
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
throw error;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
function removeReservedProjectDirectoryIfEmpty({
|
|
1772
|
+
projectDirectory,
|
|
1773
|
+
projectDirectoryIdentity,
|
|
1774
|
+
reservationSentinel
|
|
1775
|
+
}) {
|
|
1776
|
+
if (!isSameProjectDirectoryIdentity(projectDirectory, projectDirectoryIdentity)) {
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const foreignEntries = readForeignReservationEntries(projectDirectory, reservationSentinel);
|
|
1780
|
+
if (foreignEntries === null || foreignEntries.length > 0) {
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
rmSync(join2(projectDirectory, reservationSentinel), { force: true });
|
|
1785
|
+
rmdirSync(projectDirectory);
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
const errorCode = isErrnoException(error) ? error.code : void 0;
|
|
1788
|
+
if (errorCode === "ENOENT" || errorCode === "ENOTEMPTY") {
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
throw error;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
function removePublishedProjectDirectory(projectDirectory, projectDirectoryIdentity, normalizedProjectName) {
|
|
1795
|
+
const quarantineDirectory = createQuarantinePath(dirname(projectDirectory), normalizedProjectName);
|
|
1796
|
+
try {
|
|
1797
|
+
renameSync(projectDirectory, quarantineDirectory);
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
throw error;
|
|
1803
|
+
}
|
|
1804
|
+
if (!isSameProjectDirectoryIdentity(quarantineDirectory, projectDirectoryIdentity)) {
|
|
1805
|
+
restoreQuarantinedDirectory(quarantineDirectory, projectDirectory);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
rmSync(quarantineDirectory, { force: true, recursive: true });
|
|
752
1809
|
}
|
|
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
1810
|
|
|
760
|
-
|
|
1811
|
+
// src/scaffoldFiles.ts
|
|
1812
|
+
import { lstatSync as lstatSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1813
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
761
1814
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1815
|
+
// src/dependencyRange.ts
|
|
1816
|
+
import { readFileSync } from "fs";
|
|
1817
|
+
var SEMVER_CORE_IDENTIFIER_COUNT = 3;
|
|
1818
|
+
function isDigit(value) {
|
|
1819
|
+
return value >= "0" && value <= "9";
|
|
1820
|
+
}
|
|
1821
|
+
function isIdentifierCharacter(value) {
|
|
1822
|
+
return value >= "a" && value <= "z" || value >= "A" && value <= "Z" || isDigit(value);
|
|
1823
|
+
}
|
|
1824
|
+
function isNumericIdentifier(value) {
|
|
1825
|
+
if (value === "") return false;
|
|
1826
|
+
for (const character of value) {
|
|
1827
|
+
if (!isDigit(character)) return false;
|
|
1828
|
+
}
|
|
1829
|
+
return value === "0" || !value.startsWith("0");
|
|
1830
|
+
}
|
|
1831
|
+
function isValidLabel(value) {
|
|
1832
|
+
if (value === "") return false;
|
|
1833
|
+
for (const character of value) {
|
|
1834
|
+
if (character !== "-" && !isIdentifierCharacter(character)) return false;
|
|
1835
|
+
}
|
|
1836
|
+
return true;
|
|
1837
|
+
}
|
|
1838
|
+
function isValidPrereleaseLabel(value) {
|
|
1839
|
+
if (!isValidLabel(value)) return false;
|
|
1840
|
+
for (const character of value) {
|
|
1841
|
+
if (!isDigit(character)) return true;
|
|
1842
|
+
}
|
|
1843
|
+
return isNumericIdentifier(value);
|
|
1844
|
+
}
|
|
1845
|
+
function splitSemverSuffix(version, separator) {
|
|
1846
|
+
const firstIndex = version.indexOf(separator);
|
|
1847
|
+
if (firstIndex === -1) return [version, void 0];
|
|
1848
|
+
if (separator === "+" && version.includes(separator, firstIndex + 1)) {
|
|
1849
|
+
return [version, ""];
|
|
1850
|
+
}
|
|
1851
|
+
return [version.slice(0, firstIndex), version.slice(firstIndex + 1)];
|
|
1852
|
+
}
|
|
1853
|
+
function areDotSeparatedLabelsValid(value, validateLabel) {
|
|
1854
|
+
if (value === void 0) return true;
|
|
1855
|
+
return value.split(".").every((label) => validateLabel(label));
|
|
1856
|
+
}
|
|
1857
|
+
function isValidSemverVersion(version) {
|
|
1858
|
+
const [withoutBuild, build] = splitSemverSuffix(version, "+");
|
|
1859
|
+
const [coreVersion, prerelease] = splitSemverSuffix(withoutBuild, "-");
|
|
1860
|
+
const coreIdentifiers = coreVersion.split(".");
|
|
1861
|
+
return coreIdentifiers.length === SEMVER_CORE_IDENTIFIER_COUNT && coreIdentifiers.every((identifier) => isNumericIdentifier(identifier)) && areDotSeparatedLabelsValid(prerelease, isValidPrereleaseLabel) && areDotSeparatedLabelsValid(build, isValidLabel);
|
|
1862
|
+
}
|
|
1863
|
+
function isPackageJsonWithVersion(value) {
|
|
1864
|
+
return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string" && isValidSemverVersion(value.version);
|
|
1865
|
+
}
|
|
1866
|
+
function readCreateParatixPackageVersion() {
|
|
1867
|
+
const packageJsonUrl = new URL("../package.json", import.meta.url);
|
|
1868
|
+
const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
|
|
1869
|
+
if (!isPackageJsonWithVersion(packageJson)) {
|
|
1870
|
+
throw new Error("create-paratix package.json must contain a valid semver version.");
|
|
1871
|
+
}
|
|
1872
|
+
return packageJson.version;
|
|
1873
|
+
}
|
|
1874
|
+
function deriveParatixDependencyRange() {
|
|
1875
|
+
return `^${readCreateParatixPackageVersion()}`;
|
|
765
1876
|
}
|
|
766
1877
|
|
|
767
1878
|
// src/templates.ts
|
|
@@ -831,15 +1942,15 @@ function createBaseServerHeader({
|
|
|
831
1942
|
host,
|
|
832
1943
|
sshUser
|
|
833
1944
|
}) {
|
|
834
|
-
const strictHostKeyCheckingDeclaration =
|
|
1945
|
+
const strictHostKeyCheckingDeclaration = 'const strictHostKeyChecking = "yes";';
|
|
835
1946
|
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";
|
|
1947
|
+
return `import { firstRun, isFirstRun, recipe, server, when } from "paratix";
|
|
1948
|
+
import { command, file, hostname, net, package as packages, ssh, sshd, sysctl, ufw, user } from "paratix/modules";
|
|
838
1949
|
|
|
839
1950
|
${adminUserDeclaration}
|
|
840
1951
|
const adminPublicKey = ${JSON.stringify(adminPublicKey ?? "ssh-ed25519 REPLACE_ME_WITH_YOUR_PUBLIC_KEY")};
|
|
841
1952
|
const serverName = "my-server";
|
|
842
|
-
const FIRST_RUN =
|
|
1953
|
+
const FIRST_RUN = isFirstRun();
|
|
843
1954
|
const sshPorts = FIRST_RUN ? [22] : [2222];
|
|
844
1955
|
const firewallTcpPorts = FIRST_RUN ? [22, 2222, 80, 443] : [2222, 80, 443];
|
|
845
1956
|
${strictHostKeyCheckingDeclaration}
|
|
@@ -850,9 +1961,10 @@ export default server({
|
|
|
850
1961
|
ssh: {
|
|
851
1962
|
ports: sshPorts,
|
|
852
1963
|
privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
|
|
853
|
-
// FIRST_RUN keeps the bootstrap path explicit:
|
|
1964
|
+
// FIRST_RUN keeps the bootstrap path explicit and fail-closed:
|
|
854
1965
|
// - pass "paratix apply ... --first-run" for the bootstrap run
|
|
855
|
-
// -
|
|
1966
|
+
// - pin expectedHostFingerprint/PublicKey or pre-populate known_hosts before connecting
|
|
1967
|
+
// - later runs omit that flag and go through port 2222 with the same strict host-key checking
|
|
856
1968
|
strictHostKeyChecking,
|
|
857
1969
|
user: ${sshUser},
|
|
858
1970
|
${expectedHostFingerprintLine}
|
|
@@ -874,6 +1986,13 @@ function createFirewallRecipe() {
|
|
|
874
1986
|
return `
|
|
875
1987
|
recipe("firewall", [
|
|
876
1988
|
ufw.rule("allow", firewallTcpPorts),
|
|
1989
|
+
when(
|
|
1990
|
+
(env) => env["FIRST_RUN"] !== true,
|
|
1991
|
+
command.shell("ufw --force delete allow 22 || true; ufw --force delete allow 22/tcp || true; ! ufw status | grep -Eq '^22(/tcp)?[[:space:]]+(\\\\(v6\\\\)[[:space:]]+)?ALLOW'", {
|
|
1992
|
+
check: "! ufw status | grep -Eq '^22(/tcp)?[[:space:]]+(\\\\(v6\\\\)[[:space:]]+)?ALLOW'",
|
|
1993
|
+
name: "remove bootstrap ssh firewall rule",
|
|
1994
|
+
})
|
|
1995
|
+
),
|
|
877
1996
|
ufw.enabled(),
|
|
878
1997
|
]),
|
|
879
1998
|
`;
|
|
@@ -913,20 +2032,24 @@ function createFirstRunStopModule() {
|
|
|
913
2032
|
// Add application and user-facing services below this line.
|
|
914
2033
|
`;
|
|
915
2034
|
}
|
|
916
|
-
function createAdminRecipe(recipeName) {
|
|
2035
|
+
function createAdminRecipe(recipeName, adminPublicKey) {
|
|
2036
|
+
const authorizedKeysLine = adminPublicKey == null ? [
|
|
2037
|
+
" // Add a valid OpenSSH public key before enabling this line:",
|
|
2038
|
+
" // ssh.authorizedKeys(adminUser, adminPublicKey),"
|
|
2039
|
+
].join("\n") : " ssh.authorizedKeys(adminUser, adminPublicKey),";
|
|
917
2040
|
return `
|
|
918
2041
|
recipe("${recipeName}", [
|
|
919
2042
|
user.present(adminUser, {
|
|
920
2043
|
groups: ["sudo"],
|
|
921
2044
|
shell: "/bin/bash",
|
|
922
2045
|
}),
|
|
923
|
-
|
|
2046
|
+
${authorizedKeysLine}
|
|
924
2047
|
]),
|
|
925
2048
|
`;
|
|
926
2049
|
}
|
|
927
2050
|
function createHardenedAdminServerTemplate(parameters) {
|
|
928
2051
|
const { adminPublicKey, expectedHostFingerprint, host, initialAdminUser } = parameters;
|
|
929
|
-
const adminUserDeclaration = `const adminUser =
|
|
2052
|
+
const adminUserDeclaration = `const adminUser = ${JSON.stringify(initialAdminUser)};`;
|
|
930
2053
|
return `${createBaseServerHeader({
|
|
931
2054
|
adminPublicKey,
|
|
932
2055
|
adminUserDeclaration,
|
|
@@ -934,7 +2057,7 @@ function createHardenedAdminServerTemplate(parameters) {
|
|
|
934
2057
|
host,
|
|
935
2058
|
sshUser: "adminUser"
|
|
936
2059
|
})}
|
|
937
|
-
${createAdminRecipe("admin-access")}
|
|
2060
|
+
${createAdminRecipe("admin-access", adminPublicKey)}
|
|
938
2061
|
${createFirewallRecipe()}
|
|
939
2062
|
recipe("ssh-hardening", [
|
|
940
2063
|
sshd.port(2222),
|
|
@@ -959,7 +2082,7 @@ function createBootstrapRootServerTemplate(host, adminPublicKey, expectedHostFin
|
|
|
959
2082
|
host,
|
|
960
2083
|
sshUser: 'FIRST_RUN ? "root" : adminUser'
|
|
961
2084
|
})}
|
|
962
|
-
${createAdminRecipe("bootstrap-admin-user")}
|
|
2085
|
+
${createAdminRecipe("bootstrap-admin-user", adminPublicKey)}
|
|
963
2086
|
recipe("bootstrap-admin-sudo", [
|
|
964
2087
|
file.copy(
|
|
965
2088
|
"/etc/sudoers.d/90-paratix-admin-nopasswd",
|
|
@@ -1003,69 +2126,260 @@ function createServerTemplate(options) {
|
|
|
1003
2126
|
});
|
|
1004
2127
|
}
|
|
1005
2128
|
|
|
1006
|
-
// src/
|
|
2129
|
+
// src/scaffoldFiles.ts
|
|
2130
|
+
function lstatPathIfExists(path) {
|
|
2131
|
+
try {
|
|
2132
|
+
return lstatSync3(path);
|
|
2133
|
+
} catch (error) {
|
|
2134
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
2135
|
+
return void 0;
|
|
2136
|
+
}
|
|
2137
|
+
throw error;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function hasErrorCode(error, code) {
|
|
2141
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
2142
|
+
}
|
|
2143
|
+
function throwScaffoldPathAlreadyExists(path) {
|
|
2144
|
+
throw new Error(
|
|
2145
|
+
`Error: Scaffold file ${formatCliValue(path)} already exists; refusing to overwrite it.`
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
function assertWritableScaffoldDirectory(path) {
|
|
2149
|
+
try {
|
|
2150
|
+
mkdirSync2(path, { recursive: false });
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
2153
|
+
throw error;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
const stats = lstatPathIfExists(path);
|
|
2157
|
+
if (stats == null || !stats.isDirectory() || stats.isSymbolicLink()) {
|
|
2158
|
+
throwScaffoldPathAlreadyExists(path);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function writeManagedScaffoldFile(projectDirectory, relativePath, content) {
|
|
2162
|
+
const targetPath = join3(projectDirectory, relativePath);
|
|
2163
|
+
const targetStats = lstatPathIfExists(targetPath);
|
|
2164
|
+
if (targetStats != null) {
|
|
2165
|
+
throwScaffoldPathAlreadyExists(targetPath);
|
|
2166
|
+
}
|
|
2167
|
+
assertWritableScaffoldDirectory(dirname2(targetPath));
|
|
2168
|
+
try {
|
|
2169
|
+
writeFileSync2(targetPath, content, { flag: "wx" });
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
2172
|
+
throwScaffoldPathAlreadyExists(targetPath);
|
|
2173
|
+
}
|
|
2174
|
+
throw error;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
1007
2177
|
function writeSharedScaffoldFiles(projectDirectory) {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
2178
|
+
writeManagedScaffoldFile(projectDirectory, "tsconfig.json", TSCONFIG_TEMPLATE);
|
|
2179
|
+
writeManagedScaffoldFile(projectDirectory, ".gitignore", GITIGNORE_TEMPLATE);
|
|
2180
|
+
writeManagedScaffoldFile(projectDirectory, ".prettierrc", PRETTIER_RC_TEMPLATE);
|
|
2181
|
+
writeManagedScaffoldFile(projectDirectory, ".prettierignore", PRETTIER_IGNORE_TEMPLATE);
|
|
2182
|
+
writeManagedScaffoldFile(projectDirectory, "eslint.config.ts", ESLINT_CONFIG_TEMPLATE);
|
|
2183
|
+
writeManagedScaffoldFile(projectDirectory, ".env.example", ENV_EXAMPLE_TEMPLATE);
|
|
1014
2184
|
}
|
|
1015
2185
|
function writeScaffoldSupportFiles(projectDirectory, initialUser) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
2186
|
+
writeManagedScaffoldFile(projectDirectory, join3("files", ".gitkeep"), "");
|
|
2187
|
+
writeManagedScaffoldFile(
|
|
2188
|
+
projectDirectory,
|
|
2189
|
+
join3("files", "20auto-upgrades"),
|
|
2190
|
+
AUTO_UPGRADES_20_TEMPLATE
|
|
2191
|
+
);
|
|
2192
|
+
writeManagedScaffoldFile(
|
|
2193
|
+
projectDirectory,
|
|
2194
|
+
join3("files", "50unattended-upgrades"),
|
|
1020
2195
|
UNATTENDED_UPGRADES_50_TEMPLATE
|
|
1021
2196
|
);
|
|
1022
2197
|
if (initialUser.kind !== "root") return;
|
|
1023
|
-
|
|
1024
|
-
|
|
2198
|
+
writeManagedScaffoldFile(
|
|
2199
|
+
projectDirectory,
|
|
2200
|
+
join3("files", "admin-nopasswd-sudoers"),
|
|
1025
2201
|
createAdminNopasswdSudoersContent("paratix")
|
|
1026
2202
|
);
|
|
1027
2203
|
}
|
|
1028
|
-
function
|
|
1029
|
-
|
|
1030
|
-
|
|
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;
|
|
2204
|
+
function writeScaffoldFiles(projectDirectory, { adminPublicKey, expectedHostFingerprint, host, initialUser, packageName }) {
|
|
2205
|
+
assertWritableScaffoldDirectory(projectDirectory);
|
|
2206
|
+
assertWritableScaffoldDirectory(join3(projectDirectory, "files"));
|
|
1035
2207
|
const packageJson = {
|
|
1036
2208
|
dependencies: {
|
|
1037
|
-
paratix:
|
|
2209
|
+
paratix: deriveParatixDependencyRange()
|
|
1038
2210
|
},
|
|
1039
2211
|
devDependencies: {
|
|
1040
2212
|
"@types/node": "^24.5.2",
|
|
1041
2213
|
eslint: "^10.0.3",
|
|
1042
2214
|
"eslint-config-setup": "^0.3.3",
|
|
2215
|
+
jiti: "^2.6.1",
|
|
1043
2216
|
prettier: "^3.6.2",
|
|
1044
|
-
tsx: "^4.20.6"
|
|
2217
|
+
tsx: "^4.20.6",
|
|
2218
|
+
typescript: "^5.9.2"
|
|
1045
2219
|
},
|
|
1046
2220
|
engines: {
|
|
1047
2221
|
node: ">=24.0.0"
|
|
1048
2222
|
},
|
|
1049
|
-
name:
|
|
2223
|
+
name: packageName,
|
|
1050
2224
|
private: true,
|
|
1051
2225
|
scripts: {
|
|
1052
2226
|
apply: "paratix apply server.ts",
|
|
1053
2227
|
"apply:dry": "paratix apply server.ts --dry-run",
|
|
2228
|
+
"apply:first-run": "paratix apply server.ts --first-run",
|
|
2229
|
+
"apply:first-run:dry": "paratix apply server.ts --dry-run --first-run",
|
|
1054
2230
|
"format:check": "prettier --check .",
|
|
1055
2231
|
"format:fix": "prettier --write .",
|
|
1056
2232
|
lint: "eslint ."
|
|
1057
2233
|
},
|
|
1058
2234
|
type: "module"
|
|
1059
2235
|
};
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
2236
|
+
writeManagedScaffoldFile(
|
|
2237
|
+
projectDirectory,
|
|
2238
|
+
"package.json",
|
|
2239
|
+
`${JSON.stringify(packageJson, null, 2)}
|
|
2240
|
+
`
|
|
2241
|
+
);
|
|
2242
|
+
writeManagedScaffoldFile(
|
|
2243
|
+
projectDirectory,
|
|
2244
|
+
"server.ts",
|
|
1064
2245
|
createServerTemplate({ adminPublicKey, expectedHostFingerprint, host, initialUser })
|
|
1065
2246
|
);
|
|
1066
2247
|
writeSharedScaffoldFiles(projectDirectory);
|
|
1067
2248
|
writeScaffoldSupportFiles(projectDirectory, initialUser);
|
|
1068
2249
|
}
|
|
2250
|
+
|
|
2251
|
+
// src/scaffoldRuntime.ts
|
|
2252
|
+
import { spawnSync } from "child_process";
|
|
2253
|
+
var MS_PER_MINUTE = 6e4;
|
|
2254
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
2255
|
+
var PNPM_INSTALL = { args: ["install"], executable: "pnpm" };
|
|
2256
|
+
var YARN_INSTALL = { args: ["install"], executable: "yarn" };
|
|
2257
|
+
var BUN_INSTALL = { args: ["install"], executable: "bun" };
|
|
2258
|
+
var NPM_INSTALL = { args: ["install"], executable: "npm" };
|
|
2259
|
+
function detectPackageManager() {
|
|
2260
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
2261
|
+
if (agent.startsWith("pnpm")) {
|
|
2262
|
+
return { command: PNPM_INSTALL, name: "pnpm" };
|
|
2263
|
+
}
|
|
2264
|
+
if (agent.startsWith("yarn")) {
|
|
2265
|
+
return { command: YARN_INSTALL, name: "yarn" };
|
|
2266
|
+
}
|
|
2267
|
+
if (agent.startsWith("bun")) {
|
|
2268
|
+
return { command: BUN_INSTALL, name: "bun" };
|
|
2269
|
+
}
|
|
2270
|
+
return { command: NPM_INSTALL, name: "npm" };
|
|
2271
|
+
}
|
|
2272
|
+
var RUN_INSTALL_MANUALLY_HINT = "Run install manually.";
|
|
2273
|
+
function reportInstallFailure(message) {
|
|
2274
|
+
console.error(`Failed to install dependencies: ${message}`);
|
|
2275
|
+
console.error(RUN_INSTALL_MANUALLY_HINT);
|
|
2276
|
+
return false;
|
|
2277
|
+
}
|
|
2278
|
+
function reportInstallTimeout() {
|
|
2279
|
+
console.error(
|
|
2280
|
+
`Installation timed out after ${Math.round(INSTALL_TIMEOUT_MS / MS_PER_MINUTE)} minutes.`
|
|
2281
|
+
);
|
|
2282
|
+
console.error(RUN_INSTALL_MANUALLY_HINT);
|
|
2283
|
+
return false;
|
|
2284
|
+
}
|
|
2285
|
+
function installDependencies(projectDirectory, pm) {
|
|
2286
|
+
console.log(`Installing dependencies with ${pm.name}...`);
|
|
2287
|
+
const result = spawnSync(pm.command.executable, [...pm.command.args], {
|
|
2288
|
+
cwd: projectDirectory,
|
|
2289
|
+
shell: false,
|
|
2290
|
+
stdio: "inherit",
|
|
2291
|
+
timeout: INSTALL_TIMEOUT_MS
|
|
2292
|
+
});
|
|
2293
|
+
if (result.signal === "SIGTERM") {
|
|
2294
|
+
return reportInstallTimeout();
|
|
2295
|
+
}
|
|
2296
|
+
if (result.error) {
|
|
2297
|
+
return reportInstallFailure(
|
|
2298
|
+
result.error instanceof Error ? result.error.message : String(result.error)
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
if (result.status !== 0) {
|
|
2302
|
+
return reportInstallFailure(
|
|
2303
|
+
`${pm.command.executable} exited with status ${String(result.status)}`
|
|
2304
|
+
);
|
|
2305
|
+
}
|
|
2306
|
+
return true;
|
|
2307
|
+
}
|
|
2308
|
+
function getCommandPrefix(pm) {
|
|
2309
|
+
return pm.name === "npm" ? "npm run" : pm.name;
|
|
2310
|
+
}
|
|
2311
|
+
function printSuccessMessage(projectName, pm) {
|
|
2312
|
+
const prefix = getCommandPrefix(pm);
|
|
2313
|
+
const escapedProjectName = escapeCliControlCharacters(projectName);
|
|
2314
|
+
console.log(`
|
|
2315
|
+
Project created successfully!
|
|
2316
|
+
|
|
2317
|
+
cd ${escapedProjectName}
|
|
2318
|
+
|
|
2319
|
+
Edit server.ts with your server details, then:
|
|
2320
|
+
|
|
2321
|
+
${prefix} apply:first-run:dry
|
|
2322
|
+
${prefix} apply:first-run
|
|
2323
|
+
${prefix} apply:dry
|
|
2324
|
+
${prefix} apply
|
|
2325
|
+
`);
|
|
2326
|
+
}
|
|
2327
|
+
function printPartialSuccessMessage(projectName, pm) {
|
|
2328
|
+
const prefix = getCommandPrefix(pm);
|
|
2329
|
+
const escapedProjectName = escapeCliControlCharacters(projectName);
|
|
2330
|
+
console.log(`
|
|
2331
|
+
Project files created, but dependency installation failed.
|
|
2332
|
+
|
|
2333
|
+
cd ${escapedProjectName}
|
|
2334
|
+
|
|
2335
|
+
Install dependencies manually, then run:
|
|
2336
|
+
|
|
2337
|
+
${prefix} apply:first-run:dry
|
|
2338
|
+
${prefix} apply:first-run
|
|
2339
|
+
${prefix} apply:dry
|
|
2340
|
+
${prefix} apply
|
|
2341
|
+
`);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// src/index.ts
|
|
2345
|
+
function normalizeProgrammaticInitialUserConfig(initialUser) {
|
|
2346
|
+
if (initialUser == null) return { kind: "admin", user: "paratix" };
|
|
2347
|
+
if (initialUser.kind === "root") return { kind: "root" };
|
|
2348
|
+
const parsedInitialUser = parseInitialUserConfig((message) => {
|
|
2349
|
+
throw new Error(message);
|
|
2350
|
+
}, initialUser.user);
|
|
2351
|
+
if (parsedInitialUser.kind !== "admin") {
|
|
2352
|
+
throw new Error(
|
|
2353
|
+
`Error: Invalid initial user ${formatCliValue(initialUser.user)} \u2014 use a non-root lowercase Linux username for admin mode.`
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
return parsedInitialUser;
|
|
2357
|
+
}
|
|
2358
|
+
function validateRootBootstrapConfiguration(initialUser, adminPublicKey) {
|
|
2359
|
+
if (initialUser.kind === "root" && adminPublicKey == null) {
|
|
2360
|
+
throw new Error(
|
|
2361
|
+
"Root bootstrap requires --admin-public-key or --admin-public-key-file so the generated admin user can log in after the first run."
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
function writeProjectFiles(projectDirectory, options) {
|
|
2366
|
+
const initialUser = normalizeProgrammaticInitialUserConfig(options?.initialUser);
|
|
2367
|
+
validateRootBootstrapConfiguration(initialUser, options?.adminPublicKey);
|
|
2368
|
+
const { adminPublicKey, expectedHostFingerprint, host } = normalizeProgrammaticScaffoldStringOptions(options);
|
|
2369
|
+
const packageName = derivePackageName(projectDirectory);
|
|
2370
|
+
if (!isSecureDerivedPackageName(packageName)) {
|
|
2371
|
+
throw new Error(
|
|
2372
|
+
`Error: Invalid project directory ${formatCliValue(projectDirectory)} \u2014 the derived package name contains control or bidi codepoints.`
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
writeScaffoldFiles(projectDirectory, {
|
|
2376
|
+
adminPublicKey,
|
|
2377
|
+
expectedHostFingerprint,
|
|
2378
|
+
host,
|
|
2379
|
+
initialUser,
|
|
2380
|
+
packageName
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
1069
2383
|
function isValidProjectName(name) {
|
|
1070
2384
|
const trimmed = name.trim();
|
|
1071
2385
|
return new RegExp("^[a-z0-9][a-z0-9\\x2d]*$", "v").test(trimmed);
|
|
@@ -1074,77 +2388,128 @@ function normalizeProjectName(name) {
|
|
|
1074
2388
|
return name.trim();
|
|
1075
2389
|
}
|
|
1076
2390
|
function derivePackageName(projectDirectory) {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
process.exit(1);
|
|
2391
|
+
if (process.platform === "win32") {
|
|
2392
|
+
return pathWin32.basename(projectDirectory);
|
|
2393
|
+
}
|
|
2394
|
+
return pathPosix.basename(projectDirectory);
|
|
1082
2395
|
}
|
|
1083
|
-
function
|
|
1084
|
-
|
|
2396
|
+
function isSecureDerivedPackageName(name) {
|
|
2397
|
+
if (name.length === 0) return false;
|
|
2398
|
+
if (name.trim().length === 0) return false;
|
|
2399
|
+
if (new RegExp("[\\r\\n]", "v").test(name)) return false;
|
|
2400
|
+
if (containsUnsafeCodepoint(name)) return false;
|
|
2401
|
+
return true;
|
|
1085
2402
|
}
|
|
1086
|
-
function
|
|
1087
|
-
|
|
2403
|
+
function failWithUsage() {
|
|
2404
|
+
exitWithMessage("Usage: create-paratix <project-name>");
|
|
2405
|
+
throw new Error("Usage: create-paratix <project-name>");
|
|
1088
2406
|
}
|
|
1089
|
-
function
|
|
1090
|
-
|
|
2407
|
+
function failWithInvalidName(rawName) {
|
|
2408
|
+
const message = `Error: Invalid project name ${formatCliValue(rawName)} \u2014 use only lowercase letters, numbers, and hyphens.`;
|
|
2409
|
+
exitWithMessage(message);
|
|
2410
|
+
throw new Error(message);
|
|
1091
2411
|
}
|
|
1092
2412
|
function validateProjectName(name) {
|
|
1093
2413
|
if (name == null || name === "") {
|
|
1094
|
-
|
|
2414
|
+
return failWithUsage();
|
|
1095
2415
|
}
|
|
1096
2416
|
const normalizedName = normalizeProjectName(name);
|
|
1097
2417
|
if (!isValidProjectName(normalizedName)) {
|
|
1098
|
-
|
|
1099
|
-
`Error: Invalid project name "${name}" \u2014 use only lowercase letters, numbers, and hyphens.`
|
|
1100
|
-
);
|
|
2418
|
+
return failWithInvalidName(name);
|
|
1101
2419
|
}
|
|
1102
2420
|
return normalizedName;
|
|
1103
2421
|
}
|
|
2422
|
+
function prepareScaffold(projectName, options) {
|
|
2423
|
+
const normalizedProjectName = validateProjectName(projectName);
|
|
2424
|
+
const projectDirectory = resolve3(normalizedProjectName);
|
|
2425
|
+
const initialUser = normalizeProgrammaticInitialUserConfig(options?.initialUser);
|
|
2426
|
+
const normalizedStringOptions = normalizeProgrammaticScaffoldStringOptions(options);
|
|
2427
|
+
validateRootBootstrapConfiguration(initialUser, normalizedStringOptions.adminPublicKey);
|
|
2428
|
+
const stagedProjectDirectory = createStagedProjectDirectory(
|
|
2429
|
+
projectDirectory,
|
|
2430
|
+
normalizedProjectName
|
|
2431
|
+
);
|
|
2432
|
+
return {
|
|
2433
|
+
initialUser,
|
|
2434
|
+
normalizedProjectName,
|
|
2435
|
+
normalizedStringOptions,
|
|
2436
|
+
projectDirectory,
|
|
2437
|
+
stagedProjectDirectory
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
function runScaffoldOrCleanup(prepared, pm, options) {
|
|
2441
|
+
const { stagingDirectory } = prepared.stagedProjectDirectory;
|
|
2442
|
+
let publishedProjectIdentity;
|
|
2443
|
+
try {
|
|
2444
|
+
writeScaffoldFiles(stagingDirectory, {
|
|
2445
|
+
...prepared.normalizedStringOptions,
|
|
2446
|
+
initialUser: prepared.initialUser,
|
|
2447
|
+
packageName: prepared.normalizedProjectName
|
|
2448
|
+
});
|
|
2449
|
+
publishedProjectIdentity = finalizeStagedProjectDirectory(
|
|
2450
|
+
prepared.stagedProjectDirectory,
|
|
2451
|
+
prepared.normalizedProjectName
|
|
2452
|
+
);
|
|
2453
|
+
const installer = options?.installer ?? installDependencies;
|
|
2454
|
+
return installer(prepared.projectDirectory, pm);
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
rmSync2(stagingDirectory, { force: true, recursive: true });
|
|
2457
|
+
if (publishedProjectIdentity == null) {
|
|
2458
|
+
removeReservedProjectDirectoryIfEmpty(prepared.stagedProjectDirectory);
|
|
2459
|
+
} else {
|
|
2460
|
+
removePublishedProjectDirectory(
|
|
2461
|
+
prepared.projectDirectory,
|
|
2462
|
+
publishedProjectIdentity,
|
|
2463
|
+
prepared.normalizedProjectName
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
throw error;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
1104
2469
|
function scaffoldProject(projectName, pm, options) {
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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) {
|
|
2470
|
+
const prepared = prepareScaffold(projectName, options);
|
|
2471
|
+
console.log(`Creating Paratix project in ${prepared.projectDirectory}...`);
|
|
2472
|
+
const installerSucceeded = runScaffoldOrCleanup(prepared, pm, options);
|
|
2473
|
+
if (!installerSucceeded) {
|
|
1115
2474
|
process.exitCode = 1;
|
|
1116
|
-
printPartialSuccessMessage(normalizedProjectName, pm);
|
|
2475
|
+
printPartialSuccessMessage(prepared.normalizedProjectName, pm);
|
|
1117
2476
|
return false;
|
|
1118
2477
|
}
|
|
1119
|
-
printSuccessMessage(normalizedProjectName, pm);
|
|
2478
|
+
printSuccessMessage(prepared.normalizedProjectName, pm);
|
|
1120
2479
|
return true;
|
|
1121
2480
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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;
|
|
2481
|
+
function forceExitAfterHandling() {
|
|
2482
|
+
setImmediate(() => {
|
|
2483
|
+
process.exit(process.exitCode ?? 1);
|
|
2484
|
+
});
|
|
1134
2485
|
}
|
|
1135
2486
|
function main() {
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
2487
|
+
process.on("unhandledRejection", (reason) => {
|
|
2488
|
+
handleCliExit(reason);
|
|
2489
|
+
forceExitAfterHandling();
|
|
2490
|
+
});
|
|
2491
|
+
process.on("uncaughtException", (error) => {
|
|
2492
|
+
handleCliExit(error);
|
|
2493
|
+
forceExitAfterHandling();
|
|
2494
|
+
});
|
|
1141
2495
|
void (async () => {
|
|
1142
|
-
const
|
|
1143
|
-
|
|
2496
|
+
const {
|
|
2497
|
+
adminPublicKey,
|
|
2498
|
+
adminPublicKeyFile,
|
|
2499
|
+
expectedHostFingerprint,
|
|
2500
|
+
host,
|
|
2501
|
+
initialUser,
|
|
2502
|
+
projectName
|
|
2503
|
+
} = parseCliArguments2(process.argv.slice(2));
|
|
2504
|
+
const normalizedProjectName = validateProjectName(projectName);
|
|
2505
|
+
const pm = detectPackageManager();
|
|
2506
|
+
const validatedHost = await resolveCliOrPromptHost(host);
|
|
2507
|
+
const resolvedExpectedHostFingerprint = expectedHostFingerprint ?? (process.stdin.isTTY && process.stdout.isTTY ? await promptForHostFingerprint(validatedHost) : void 0);
|
|
1144
2508
|
const initialUserConfig = initialUser == null ? await promptForInitialUserConfig() : parseInitialUserConfig2(initialUser);
|
|
1145
2509
|
const resolvedAdminPublicKey = await resolveCliOrPromptAdminPublicKey({
|
|
1146
2510
|
adminPublicKey,
|
|
1147
|
-
adminPublicKeyFile
|
|
2511
|
+
adminPublicKeyFile,
|
|
2512
|
+
allowPlaceholder: initialUserConfig.kind !== "root"
|
|
1148
2513
|
});
|
|
1149
2514
|
scaffoldProject(normalizedProjectName, pm, {
|
|
1150
2515
|
adminPublicKey: resolvedAdminPublicKey,
|
|
@@ -1153,18 +2518,18 @@ function main() {
|
|
|
1153
2518
|
initialUser: initialUserConfig
|
|
1154
2519
|
});
|
|
1155
2520
|
})().catch((error) => {
|
|
1156
|
-
|
|
1157
|
-
process.exitCode = 1;
|
|
2521
|
+
handleCliExit(error);
|
|
1158
2522
|
});
|
|
1159
2523
|
}
|
|
1160
|
-
function isDirectExecution(moduleUrl, argv1) {
|
|
1161
|
-
return argv1 != null && moduleUrl.endsWith(argv1.replaceAll("\\", "/"));
|
|
1162
|
-
}
|
|
1163
2524
|
if (isDirectExecution(import.meta.url, process.argv[1])) {
|
|
1164
2525
|
main();
|
|
1165
2526
|
}
|
|
1166
2527
|
export {
|
|
2528
|
+
CliExitError,
|
|
2529
|
+
deriveParatixDependencyRange,
|
|
2530
|
+
handleCliExit,
|
|
1167
2531
|
isDirectExecution,
|
|
2532
|
+
isValidExpectedHostFingerprint,
|
|
1168
2533
|
isValidHost,
|
|
1169
2534
|
isValidInitialUserName,
|
|
1170
2535
|
isValidProjectName,
|
|
@@ -1177,7 +2542,10 @@ export {
|
|
|
1177
2542
|
promptForHost2 as promptForHost,
|
|
1178
2543
|
promptForHostFingerprint,
|
|
1179
2544
|
promptForInitialUserConfig,
|
|
2545
|
+
resolveCliOrPromptHost,
|
|
2546
|
+
restoreInteractiveTerminal,
|
|
1180
2547
|
scaffoldProject,
|
|
2548
|
+
validateExpectedHostFingerprint2 as validateExpectedHostFingerprint,
|
|
1181
2549
|
validateHost2 as validateHost,
|
|
1182
2550
|
writeProjectFiles
|
|
1183
2551
|
};
|