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/dist/index.js CHANGED
@@ -1,8 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync, mkdirSync, writeFileSync } from "fs";
5
- import { basename as basename2, join as join2, resolve } from "path";
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 { captureFingerprint, host, port, readyTimeoutMs } = parameters;
275
+ const { captureHostVerifierError, captureScanResult, host, port, readyTimeoutMs } = parameters;
25
276
  return {
26
277
  host,
27
- hostVerifier: (key) => {
28
- captureFingerprint(computeFingerprint(Buffer.from(key)));
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: resolve2 } = parameters;
312
+ const { cleanup, host, port, reject, resolve: resolve4 } = parameters;
53
313
  let settled = false;
54
314
  return {
55
- rejectOnce: (error) => {
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: (fingerprint) => {
321
+ resolveOnce(result) {
62
322
  if (settled) return;
63
323
  settled = true;
64
324
  cleanup();
65
- resolve2(fingerprint);
325
+ resolve4(result);
66
326
  }
67
327
  };
68
328
  }
69
329
  function registerFingerprintListeners(parameters) {
70
- const { client, onFingerprint, rejectOnce, resolveOnce } = parameters;
330
+ const { client, onHostVerifierError, onScanResult, rejectOnce, resolveOnce } = parameters;
71
331
  client.on("close", () => {
72
- const capturedFingerprint = onFingerprint();
73
- if (capturedFingerprint != null) {
74
- resolveOnce(capturedFingerprint);
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 capturedFingerprint = onFingerprint();
79
- if (capturedFingerprint != null) {
80
- resolveOnce(capturedFingerprint);
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 capturedFingerprint = null;
89
- return new Promise((resolve2, reject) => {
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: resolve2
391
+ resolve: resolve4
99
392
  });
393
+ disarmWatchdog = armHalfOpenWatchdog({ readyTimeoutMs, rejectOnce });
100
394
  registerFingerprintListeners({
101
395
  client,
102
- onFingerprint: () => capturedFingerprint,
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
- captureFingerprint: (fingerprint) => {
110
- capturedFingerprint = fingerprint;
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
- prompt,
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 [`${prefix} ${option.label}`, ` ${option.description}`];
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
- process.stdin.setRawMode(previousRawMode ?? false);
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((resolve2, reject) => {
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, resolve2, value);
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 { readdirSync, readFileSync } from "fs";
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
- var PUBLIC_KEY_PROMPT_OPTIONS = [
256
- {
257
- description: "Read a public key from ~/.ssh and embed it directly into server.ts for the bootstrap admin user.",
258
- label: "Use local public key",
259
- value: "local"
260
- },
261
- {
262
- description: "Keep the placeholder in server.ts and paste your public key manually before the first apply.",
263
- label: "Keep placeholder",
264
- value: "placeholder"
265
- }
266
- ];
267
- var supportedOpenSshAlgorithms = /* @__PURE__ */ new Set([
268
- "ecdsa-sha2-nistp256",
269
- "ecdsa-sha2-nistp384",
270
- "ecdsa-sha2-nistp521",
271
- "sk-ecdsa-sha2-nistp256@openssh.com",
272
- "sk-ssh-ed25519@openssh.com",
273
- "ssh-ed25519",
274
- "ssh-rsa"
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
- function parseOpenSshPublicKey(value) {
277
- if (value.length === 0 || value.includes("\n")) {
278
- return null;
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
- const parts = value.split(new RegExp("\\s+", "v"));
281
- if (parts.length < 2) {
282
- return null;
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
- const algorithm = parts[0];
285
- const encodedKey = parts[1];
286
- if (!supportedOpenSshAlgorithms.has(algorithm)) {
287
- return null;
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
- return {
290
- algorithm,
291
- encodedKey
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
- function readAdminPublicKeyFile(exitWithMessage2, path) {
369
- let value;
370
- try {
371
- value = readFileSync(path, "utf8");
372
- } catch (error) {
373
- const message = error instanceof Error ? error.message : String(error);
374
- exitWithMessage2(`Error: Failed to read "--admin-public-key-file" from "${path}": ${message}`);
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 discoverLocalPublicKeys(sshDirectory = join(homedir(), ".ssh")) {
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 key = readFileSync(path, "utf8").trim();
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: basename(entry), path }];
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
- async function promptForAdminPublicKey(select, publicKeys = discoverLocalPublicKeys()) {
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
- PUBLIC_KEY_PROMPT_OPTIONS
1050
+ createPublicKeyModeOptions(allowPlaceholder)
407
1051
  );
408
1052
  if (publicKeyMode !== "local") {
409
1053
  return void 0;
410
1054
  }
411
1055
  if (publicKeys.length === 0) {
412
- console.error(
413
- "No readable public keys were found in ~/.ssh. Keeping the placeholder in server.ts."
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 "${value}" \u2014 use "root" or a valid lowercase Linux username.`
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
- return normalizedValue.length > 0 && !new RegExp("\\s", "v").test(normalizedValue);
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 "${value}" \u2014 use a domain name, IPv4, or IPv6 address without spaces.`
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
- async function promptForHost(prompt) {
461
- for (; ; ) {
462
- const host = normalizeHost(await prompt("Server host (domain or IP): "));
463
- if (isValidHost(host)) {
464
- return host;
465
- }
466
- console.error("Error: Please enter a domain name, IPv4, or IPv6 address without spaces.");
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 || value.startsWith("--")) {
472
- parameters.exitWithMessage(`Error: Missing value for "${parameters.optionName}".`);
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 "${argument}".`);
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 Error(INTERACTIVE_SELECTION_UNAVAILABLE);
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: "Keep the expectedHostFingerprint placeholder in server.ts and verify the host key manually later.",
584
- label: "Keep placeholder",
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, closePrompt) {
619
- for (; ; ) {
620
- const adminUser = normalizeInitialUserName(await ask("Admin username: "));
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
- const promptSession = createPromptSession(prompt);
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, promptSession.closePrompt);
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 promptForHostFingerprint(host, select, scanner = readHostFingerprintViaSsh2) {
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 choose(
683
- `How should create-paratix bootstrap the SSH host key for ${host}?`,
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
- try {
690
- return await scanner(host);
691
- } catch (error) {
692
- console.error(
693
- `${error instanceof Error ? error.message : String(error)} Keeping the expectedHostFingerprint placeholder in server.ts.`
694
- );
695
- return void 0;
696
- }
1514
+ return await scanAndConfirmFingerprint({ choose, host, scanner });
697
1515
  } finally {
698
1516
  terminalSelect?.close();
699
1517
  }
700
1518
  }
701
1519
 
702
- // src/scaffoldRuntime.ts
703
- import { execSync } from "child_process";
704
- var MS_PER_MINUTE = 6e4;
705
- var INSTALL_TIMEOUT_MS = 12e4;
706
- function detectPackageManager() {
707
- const agent = process.env.npm_config_user_agent ?? "";
708
- if (agent.startsWith("pnpm")) {
709
- return { command: "pnpm install", name: "pnpm" };
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 (agent.startsWith("yarn")) {
712
- return { command: "yarn install", name: "yarn" };
1529
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
1530
+ try {
1531
+ process.stdin.setRawMode(false);
1532
+ } catch {
1533
+ }
713
1534
  }
714
- if (agent.startsWith("bun")) {
715
- return { command: "bun install", name: "bun" };
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
- return { command: "npm install", name: "npm" };
1545
+ console.error(escapeCliControlCharacters(error instanceof Error ? error.message : String(error)));
1546
+ process.exitCode = 1;
718
1547
  }
719
- function installDependencies(projectDirectory, pm) {
720
- console.log(`Installing dependencies with ${pm.name}...`);
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
- execSync(pm.command, { cwd: projectDirectory, stdio: "inherit", timeout: INSTALL_TIMEOUT_MS });
723
- return true;
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 instanceof Error && "signal" in error && error.signal === "SIGTERM") {
726
- console.error(
727
- `Installation timed out after ${Math.round(INSTALL_TIMEOUT_MS / MS_PER_MINUTE)} minutes.`
728
- );
729
- } else {
730
- const message = error instanceof Error ? error.message : String(error);
731
- console.error(`Failed to install dependencies: ${message}`);
1631
+ if (isErrnoException(error) && error.code === "EEXIST") {
1632
+ exitWithDirectoryAlreadyExists(normalizedProjectName);
732
1633
  }
733
- console.error("Run install manually.");
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 getCommandPrefix(pm) {
738
- return pm.name === "npm" ? "npm run" : pm.name;
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 printSuccessMessage(projectName, pm) {
741
- const prefix = getCommandPrefix(pm);
742
- console.log(`
743
- Project created successfully!
744
-
745
- cd ${projectName}
746
-
747
- Edit server.ts with your server details, then:
748
-
749
- ${prefix} apply:dry
750
- ${prefix} apply
751
- `);
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
- Install dependencies manually, then run:
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
- ${prefix} apply:dry
763
- ${prefix} apply
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 = expectedHostFingerprint == null ? 'const strictHostKeyChecking = FIRST_RUN ? "accept-new" : "yes";' : 'const strictHostKeyChecking = "yes";';
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 = process.env["PARATIX_FIRST_RUN"] === "true";
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
- // - later runs omit that flag and go through port 2222 with strict host-key checking again
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
- ssh.authorizedKeys(adminUser, adminPublicKey),
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 = "${initialAdminUser}";`;
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/index.ts
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
- writeFileSync(join2(projectDirectory, "tsconfig.json"), TSCONFIG_TEMPLATE);
1009
- writeFileSync(join2(projectDirectory, ".gitignore"), GITIGNORE_TEMPLATE);
1010
- writeFileSync(join2(projectDirectory, ".prettierrc"), PRETTIER_RC_TEMPLATE);
1011
- writeFileSync(join2(projectDirectory, ".prettierignore"), PRETTIER_IGNORE_TEMPLATE);
1012
- writeFileSync(join2(projectDirectory, "eslint.config.ts"), ESLINT_CONFIG_TEMPLATE);
1013
- writeFileSync(join2(projectDirectory, ".env.example"), ENV_EXAMPLE_TEMPLATE);
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
- writeFileSync(join2(projectDirectory, "files", ".gitkeep"), "");
1017
- writeFileSync(join2(projectDirectory, "files", "20auto-upgrades"), AUTO_UPGRADES_20_TEMPLATE);
1018
- writeFileSync(
1019
- join2(projectDirectory, "files", "50unattended-upgrades"),
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
- writeFileSync(
1024
- join2(projectDirectory, "files", "admin-nopasswd-sudoers"),
2198
+ writeManagedScaffoldFile(
2199
+ projectDirectory,
2200
+ join3("files", "admin-nopasswd-sudoers"),
1025
2201
  createAdminNopasswdSudoersContent("paratix")
1026
2202
  );
1027
2203
  }
1028
- function writeProjectFiles(projectDirectory, options) {
1029
- mkdirSync(projectDirectory, { recursive: true });
1030
- mkdirSync(join2(projectDirectory, "files"), { recursive: true });
1031
- const host = options?.host ?? "1.2.3.4";
1032
- const initialUser = options?.initialUser ?? { kind: "admin", user: "paratix" };
1033
- const adminPublicKey = options?.adminPublicKey;
1034
- const expectedHostFingerprint = options?.expectedHostFingerprint;
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: "^0.1.0"
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: derivePackageName(projectDirectory),
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
- writeFileSync(join2(projectDirectory, "package.json"), `${JSON.stringify(packageJson, null, 2)}
1061
- `);
1062
- writeFileSync(
1063
- join2(projectDirectory, "server.ts"),
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
- return basename2(projectDirectory.replaceAll("\\", "/"));
1078
- }
1079
- function exitWithMessage(message) {
1080
- console.error(message);
1081
- process.exit(1);
2391
+ if (process.platform === "win32") {
2392
+ return pathWin32.basename(projectDirectory);
2393
+ }
2394
+ return pathPosix.basename(projectDirectory);
1082
2395
  }
1083
- function parseCliArguments2(argv) {
1084
- return parseCliArguments(argv, exitWithMessage);
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 parseInitialUserConfig2(value) {
1087
- return parseInitialUserConfig(exitWithMessage, value);
2403
+ function failWithUsage() {
2404
+ exitWithMessage("Usage: create-paratix <project-name>");
2405
+ throw new Error("Usage: create-paratix <project-name>");
1088
2406
  }
1089
- function validateHost2(value) {
1090
- return validateHost(exitWithMessage, value);
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
- exitWithMessage("Usage: create-paratix <project-name>");
2414
+ return failWithUsage();
1095
2415
  }
1096
2416
  const normalizedName = normalizeProjectName(name);
1097
2417
  if (!isValidProjectName(normalizedName)) {
1098
- exitWithMessage(
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 normalizedProjectName = normalizeProjectName(projectName);
1106
- const projectDirectory = resolve(normalizedProjectName);
1107
- if (existsSync(projectDirectory)) {
1108
- exitWithMessage(`Error: Directory "${normalizedProjectName}" already exists.`);
1109
- }
1110
- console.log(`Creating Paratix project in ${projectDirectory}...`);
1111
- writeProjectFiles(projectDirectory, options);
1112
- const installer = options?.installer ?? installDependencies;
1113
- const installed = installer(projectDirectory, pm);
1114
- if (!installed) {
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
- async function resolveCliOrPromptAdminPublicKey(parameters) {
1123
- const { adminPublicKey, adminPublicKeyFile } = parameters;
1124
- if (adminPublicKey !== void 0) {
1125
- return validateAdminPublicKey(exitWithMessage, adminPublicKey);
1126
- }
1127
- if (adminPublicKeyFile !== void 0) {
1128
- return readAdminPublicKeyFile(exitWithMessage, adminPublicKeyFile);
1129
- }
1130
- if (process.stdin.isTTY && process.stdout.isTTY) {
1131
- return promptForAdminPublicKey2();
1132
- }
1133
- return void 0;
2481
+ function forceExitAfterHandling() {
2482
+ setImmediate(() => {
2483
+ process.exit(process.exitCode ?? 1);
2484
+ });
1134
2485
  }
1135
2486
  function main() {
1136
- const { adminPublicKey, adminPublicKeyFile, host, initialUser, projectName } = parseCliArguments2(
1137
- process.argv.slice(2)
1138
- );
1139
- const normalizedProjectName = validateProjectName(projectName);
1140
- const pm = detectPackageManager();
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 validatedHost = host == null ? await promptForHost2() : validateHost2(host);
1143
- const resolvedExpectedHostFingerprint = process.stdin.isTTY && process.stdout.isTTY ? await promptForHostFingerprint(validatedHost) : void 0;
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
- console.error(error instanceof Error ? error.message : String(error));
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
  };