paratix 0.12.8 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -307,83 +307,82 @@ function describeHostValidationFailure(failure) {
307
307
  }
308
308
  }
309
309
 
310
- // src/output.ts
311
- import pc from "picocolors";
310
+ // src/dryRunDispatch.ts
311
+ function shouldExecuteApplyDuringDryRun(module, diffEnabled) {
312
+ if (module._dryRunBlocker === true || module._dryRunMetaProducer === true) return true;
313
+ if (diffEnabled && module._dryRunDiffProducer === true && module._applyDryRun != null) return true;
314
+ return module._applyDryRun != null && module._dryRunDiffProducer !== true;
315
+ }
312
316
 
313
- // src/outputFormatting.ts
314
- import { stripVTControlCharacters } from "util";
315
- var PACKAGE_MODULE_SUFFIX_LENGTH = 2;
316
- var PACKAGE_COLUMN_GAP_WIDTH = 2;
317
- var DEFAULT_TERMINAL_COLUMNS = 100;
318
- var MIN_PACKAGE_COLUMNS_WIDTH = 24;
319
- var MIN_PACKAGE_COLUMN_WIDTH = 18;
320
- var MIN_ANIMATED_LINE_COLUMNS = 8;
321
- function splitPackageModuleName(name) {
322
- for (const prefix of ["package.installed: ", "package.absent: "]) {
323
- if (!name.startsWith(prefix)) continue;
324
- const packages = name.slice(prefix.length).split(",").map((entry) => entry.trim()).filter(Boolean);
325
- if (packages.length === 0) return null;
326
- return {
327
- packages,
328
- summaryName: prefix.slice(0, -PACKAGE_MODULE_SUFFIX_LENGTH)
329
- };
317
+ // src/knownHosts.ts
318
+ import { createHash, timingSafeEqual as timingSafeEqual2 } from "crypto";
319
+ import { appendFile, mkdir, readFile as readFile2, stat as stat2 } from "fs/promises";
320
+ import { homedir } from "os";
321
+ import { join } from "path";
322
+
323
+ // src/knownHostPatterns.ts
324
+ import { createHmac, timingSafeEqual } from "crypto";
325
+ var HASHED_HOST_PARTS = 4;
326
+ function matchesKnownHostPatternList(patterns, needle) {
327
+ let matchedPositivePattern = false;
328
+ for (const rawPattern of patterns) {
329
+ const negated = rawPattern.startsWith("!");
330
+ const pattern = negated ? rawPattern.slice(1) : rawPattern;
331
+ if (pattern.length === 0) continue;
332
+ const matched = matchesHashedHost(pattern, needle) || matchesPlainHostPattern(pattern, needle);
333
+ if (!matched) continue;
334
+ if (negated) return false;
335
+ matchedPositivePattern = true;
330
336
  }
331
- return null;
337
+ return matchedPositivePattern;
332
338
  }
333
- function getPackageColumns(parameters) {
334
- if (parameters.packages.length <= 1) return parameters.packages;
335
- const availableWidth = Math.max(
336
- (parameters.terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) - parameters.continuationIndentWidth,
337
- MIN_PACKAGE_COLUMNS_WIDTH
338
- );
339
- const widestPackage = Math.max(...parameters.packages.map((entry) => entry.length));
340
- const columnWidth = Math.max(widestPackage + PACKAGE_COLUMN_GAP_WIDTH, MIN_PACKAGE_COLUMN_WIDTH);
341
- const columnCount = Math.max(Math.floor(availableWidth / columnWidth), 1);
342
- const rowCount = Math.ceil(parameters.packages.length / columnCount);
343
- return Array.from(
344
- { length: rowCount },
345
- (_row, rowIndex) => Array.from({ length: columnCount }, (_column, columnIndex) => {
346
- const packageIndex = rowIndex + columnIndex * rowCount;
347
- const packageName = parameters.packages.at(packageIndex);
348
- if (packageName == null) return "";
349
- const isLastVisibleColumn = columnIndex === columnCount - 1 || packageIndex + rowCount >= parameters.packages.length;
350
- return isLastVisibleColumn ? packageName : packageName.padEnd(columnWidth);
351
- }).filter(Boolean).join("")
352
- );
339
+ function matchesPlainHostPattern(pattern, needle) {
340
+ if (pattern.startsWith("|1|")) return false;
341
+ return matchesHostPattern(pattern.toLowerCase(), needle.toLowerCase());
353
342
  }
354
- function getPackageSummaryDetail(packageCount, detail) {
355
- const packageLabel = packageCount === 1 ? "1 package" : `${packageCount} packages`;
356
- return detail == null ? packageLabel : `${packageLabel} \xB7 ${detail}`;
343
+ function matchesHashedHost(pattern, needle) {
344
+ if (!pattern.startsWith("|1|")) return false;
345
+ const parts = pattern.split("|");
346
+ if (parts.length !== HASHED_HOST_PARTS || parts[1] !== "1") return false;
347
+ const salt = Buffer.from(parts[2] ?? "", "base64");
348
+ const expectedHash = Buffer.from(parts[3] ?? "", "base64");
349
+ if (salt.length === 0 || expectedHash.length === 0) return false;
350
+ const actualHash = createHmac("sha1", salt).update(needle).digest();
351
+ return actualHash.length === expectedHash.length && timingSafeEqual(actualHash, expectedHash);
357
352
  }
358
- function fitAnimatedModuleLine(line, columns) {
359
- if (columns === void 0 || columns < MIN_ANIMATED_LINE_COLUMNS) return line;
360
- const plainLine = stripVTControlCharacters(line);
361
- if (plainLine.length < columns) return line;
362
- return `${plainLine.slice(0, columns - 1)}\u2026`;
353
+ function matchesHostPattern(pattern, needle) {
354
+ let state = { needleIndex: 0, patternIndex: 0, starIndex: -1, starNeedleIndex: 0 };
355
+ while (state.needleIndex < needle.length) {
356
+ const nextState = advanceHostPatternMatch(pattern, needle, state);
357
+ if (nextState === null) return false;
358
+ state = nextState;
359
+ }
360
+ while (pattern[state.patternIndex] === "*") state.patternIndex += 1;
361
+ return state.patternIndex === pattern.length;
363
362
  }
364
- function formatDisplayModule(parameters) {
365
- const packageModule = splitPackageModuleName(parameters.name);
366
- if (packageModule == null) {
363
+ function advanceHostPatternMatch(pattern, needle, state) {
364
+ const patternCharacter = pattern[state.patternIndex];
365
+ if (patternCharacter === "?" || patternCharacter === needle[state.needleIndex]) {
366
+ return { ...state, needleIndex: state.needleIndex + 1, patternIndex: state.patternIndex + 1 };
367
+ }
368
+ if (patternCharacter === "*") {
367
369
  return {
368
- detail: parameters.detail,
369
- detailLines: [],
370
- name: parameters.name
370
+ ...state,
371
+ patternIndex: state.patternIndex + 1,
372
+ starIndex: state.patternIndex,
373
+ starNeedleIndex: state.needleIndex
371
374
  };
372
375
  }
376
+ if (state.starIndex === -1) return null;
377
+ const starNeedleIndex = state.starNeedleIndex + 1;
373
378
  return {
374
- detail: getPackageSummaryDetail(packageModule.packages.length, parameters.detail),
375
- detailLines: parameters.status === "waiting" ? [] : getPackageColumns({
376
- continuationIndentWidth: parameters.continuationIndentWidth,
377
- packages: packageModule.packages,
378
- terminalColumns: parameters.terminalColumns
379
- }),
380
- name: packageModule.summaryName
379
+ ...state,
380
+ needleIndex: starNeedleIndex,
381
+ patternIndex: state.starIndex + 1,
382
+ starNeedleIndex
381
383
  };
382
384
  }
383
385
 
384
- // src/secretSink.ts
385
- import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
386
-
387
386
  // src/sshHelpers.ts
388
387
  import { StringDecoder } from "string_decoder";
389
388
 
@@ -911,1320 +910,1330 @@ async function tryConnectOnPort(parameters) {
911
910
  });
912
911
  }
913
912
 
914
- // src/secretSink.ts
915
- var secretCounts = /* @__PURE__ */ new Map();
916
- var runScopedSecretCounts = new AsyncLocalStorage2();
917
- var REDACTED_PLACEHOLDER = REDACTED_SECRET_FIELD_PLACEHOLDER;
918
- var CIRCULAR_PLACEHOLDER = "[Circular]";
919
- function assertRegistrableSecret(secret) {
920
- if (secret.includes(REDACTED_PLACEHOLDER)) {
921
- throw new Error("Secret registrations must not contain the redaction placeholder");
922
- }
923
- }
924
- var MINIMUM_SECRET_LENGTH = 8;
925
- function registerSecret(secret) {
926
- if (secret.length < MINIMUM_SECRET_LENGTH) return;
927
- assertRegistrableSecret(secret);
928
- secretCounts.set(secret, (secretCounts.get(secret) ?? 0) + 1);
929
- }
930
- function unregisterSecret(secret) {
931
- if (secret.length < MINIMUM_SECRET_LENGTH) return;
932
- const current = secretCounts.get(secret);
933
- if (current == null) return;
934
- if (current <= 1) {
935
- secretCounts.delete(secret);
936
- return;
937
- }
938
- secretCounts.set(secret, current - 1);
913
+ // src/knownHosts.ts
914
+ var knownHostsLock = Promise.resolve();
915
+ async function withKnownHostsLock(operation) {
916
+ const previous = knownHostsLock;
917
+ const next = previous.then(async () => operation());
918
+ knownHostsLock = next.catch(() => null);
919
+ return next;
939
920
  }
940
- async function withRegisteredSecrets(secrets, body) {
941
- const registered = [];
942
- const registerableSecrets = secrets.filter((secret) => secret.length >= MINIMUM_SECRET_LENGTH);
943
- for (const secret of secrets) {
944
- assertRegistrableSecret(secret);
921
+ var HostKeyVerificationError = class _HostKeyVerificationError extends Error {
922
+ constructor(message) {
923
+ super(message);
924
+ this.name = "HostKeyVerificationError";
925
+ Error.captureStackTrace(this, _HostKeyVerificationError);
945
926
  }
946
- try {
947
- for (const secret of registerableSecrets) {
948
- registerSecret(secret);
949
- registered.push(secret);
950
- }
951
- const result = await body();
952
- return maskScopedResult(result, secrets);
953
- } catch (error) {
954
- throw maskScopedError(error, secrets);
955
- } finally {
956
- for (const secret of registered) {
957
- unregisterSecret(secret);
958
- }
927
+ };
928
+ var KnownHostsValidationError = class _KnownHostsValidationError extends Error {
929
+ constructor(message) {
930
+ super(message);
931
+ this.name = "KnownHostsValidationError";
932
+ Error.captureStackTrace(this, _KnownHostsValidationError);
959
933
  }
934
+ };
935
+ var MIN_KNOWN_HOSTS_FIELDS = 3;
936
+ var DEFAULT_SSH_PORT = 22;
937
+ var UINT32_SIZE = 4;
938
+ var STRICT_BASE64_PATTERN = new RegExp("^[A-Za-z0-9+\\/]+=*$", "v");
939
+ var CERT_AUTHORITY_MESSAGE = "HOST KEY VERIFICATION FAILED \u2014 known_hosts contains a @cert-authority entry, but paratix does not validate certificate-authority host keys. Remove the @cert-authority marker or pin the host with ssh.expectedHostFingerprint / ssh.expectedHostPublicKey instead.";
940
+ var defaultHostKeyCache = /* @__PURE__ */ new Map();
941
+ function createHostKeyCache() {
942
+ return /* @__PURE__ */ new Map();
960
943
  }
961
- async function withRunScopedSecrets(body) {
962
- if (runScopedSecretCounts.getStore() != null) {
963
- return body();
964
- }
965
- const scopedSecrets = /* @__PURE__ */ new Map();
966
- try {
967
- return await runScopedSecretCounts.run(scopedSecrets, body);
968
- } finally {
969
- for (const [secret, count] of scopedSecrets) {
970
- for (let index = 0; index < count; index += 1) {
971
- unregisterSecret(secret);
972
- }
973
- }
974
- }
944
+ function parseKnownHostsLine(line) {
945
+ const parts = line.split(new RegExp("\\s+", "v"));
946
+ const offset = parts[0]?.startsWith("@") ? 1 : 0;
947
+ if (parts.length < MIN_KNOWN_HOSTS_FIELDS + offset) return [];
948
+ const marker = offset === 1 ? parts[0] : void 0;
949
+ const hostsPart = parts[offset];
950
+ const algo = parts[offset + 1];
951
+ const base64Key = parts[offset + 2];
952
+ if (!STRICT_BASE64_PATTERN.test(base64Key)) return [];
953
+ const key = Buffer.from(base64Key, "base64");
954
+ const hostPatterns = hostsPart.split(",");
955
+ return hostPatterns.map((host) => ({ algo, host, hostPatterns, key, marker }));
975
956
  }
976
- function maskScopedResult(result, secrets) {
977
- if (secrets.length === 0 || !isModuleResult(result)) return result;
978
- const secretList = [...secrets];
979
- let next = result;
980
- if (result.detail != null) {
981
- const maskedDetail = maskSecrets(result.detail, secretList);
982
- if (maskedDetail !== result.detail) {
983
- next = { ...result, detail: maskedDetail };
984
- }
985
- }
986
- if (isModuleResult(next) && next.status === "failed" && next.error != null) {
987
- next = { ...next, error: maskScopedError(next.error, secrets) };
957
+ function parseKnownHosts(content) {
958
+ const entries = [];
959
+ for (const raw of content.split("\n")) {
960
+ const line = raw.trim();
961
+ if (line.length === 0 || line.startsWith("#")) continue;
962
+ entries.push(...parseKnownHostsLine(line));
988
963
  }
989
- return next;
990
- }
991
- function isModuleResult(value) {
992
- return typeof value === "object" && value !== null && "status" in value && typeof value.status === "string";
964
+ return entries;
993
965
  }
994
- function maskCauseValue(cause, secrets, secretList) {
995
- if (cause === void 0) return void 0;
996
- if (cause instanceof Error) return maskScopedError(cause, secrets);
997
- return maskSecrets(stringifyCause(cause, secretList), secretList);
966
+ function formatHostNeedle(host, port) {
967
+ return port === DEFAULT_SSH_PORT ? host : `[${host}]:${port}`;
998
968
  }
999
- function buildMaskedErrorClone(error, maskedMessage, secretList) {
1000
- if (error instanceof CommandError) {
1001
- return new CommandError(
1002
- maskedMessage,
1003
- maskSecrets(error.fullStdout, secretList),
1004
- maskSecrets(error.fullStderr, secretList)
969
+ function assertSafeHostForKnownHosts(host) {
970
+ const hostValidationFailure = validateHostLabel(host);
971
+ if (hostValidationFailure == null) return;
972
+ throw new KnownHostsValidationError(
973
+ `Refusing to persist known_hosts entry: host label ${describeHostValidationFailure(hostValidationFailure)}`
974
+ );
975
+ }
976
+ function matchesKnownHostEntry(entry, needle) {
977
+ return matchesKnownHostPatternList(entry.hostPatterns ?? [entry.host], needle);
978
+ }
979
+ function findMatchingEntries(entries, host, port) {
980
+ const needle = formatHostNeedle(host, port);
981
+ return entries.filter((entry) => matchesKnownHostEntry(entry, needle));
982
+ }
983
+ function findRevokedEntry(entries, key) {
984
+ return entries.find(
985
+ (entry) => entry.marker === "@revoked" && entry.key.length === key.length && timingSafeEqual2(entry.key, key)
986
+ );
987
+ }
988
+ function throwHostKeyMismatch(host, presentedKey, existingKey) {
989
+ const presentedAlgo = describeAlgoForDiagnostics(presentedKey);
990
+ const knownHostsDetails = existingKey == null ? "remote host key does not match the key in known_hosts. " : `remote host key (${presentedAlgo}) does not match the key in known_hosts (${describeAlgoForDiagnostics(existingKey)}). `;
991
+ throw new HostKeyVerificationError(
992
+ `HOST KEY VERIFICATION FAILED for ${host}: ${knownHostsDetails}This could indicate a man-in-the-middle attack.`
993
+ );
994
+ }
995
+ function verifyHostKeyAgainstKnownEntries(parameters) {
996
+ const { cachedKey, fileEntries, host, key } = parameters;
997
+ const revokedKey = findRevokedEntry(fileEntries, key);
998
+ if (revokedKey != null) {
999
+ throw new HostKeyVerificationError(
1000
+ `HOST KEY VERIFICATION FAILED for ${host}: remote host key (${describeAlgoForDiagnostics(revokedKey.key)}) is marked as revoked in known_hosts.`
1005
1001
  );
1006
1002
  }
1007
- const clone = Object.create(Object.getPrototypeOf(error));
1008
- Object.defineProperty(clone, "message", {
1009
- configurable: true,
1010
- value: maskedMessage,
1011
- writable: true
1012
- });
1013
- return clone;
1003
+ if (fileEntries.some((entry) => entry.marker === "@cert-authority"))
1004
+ throw new HostKeyVerificationError(`${host}: ${CERT_AUTHORITY_MESSAGE}`);
1005
+ const rawHostKeyEntries = fileEntries.filter((entry) => entry.marker == null);
1006
+ const matchingEntry = rawHostKeyEntries.find(
1007
+ (entry) => entry.key.length === key.length && timingSafeEqual2(entry.key, key)
1008
+ );
1009
+ if (matchingEntry != null) return true;
1010
+ if (cachedKey?.length === key.length && timingSafeEqual2(cachedKey, key)) {
1011
+ return true;
1012
+ }
1013
+ const firstRawHostKey = rawHostKeyEntries.at(0)?.key;
1014
+ if (firstRawHostKey != null) throwHostKeyMismatch(host, key, firstRawHostKey);
1015
+ if (cachedKey != null) throwHostKeyMismatch(host, key, cachedKey);
1016
+ return false;
1014
1017
  }
1015
- function maskScopedError(error, secrets) {
1016
- if (!(error instanceof Error) || secrets.length === 0) {
1017
- return error instanceof Error ? error : new Error(maskSecrets(String(error), [...secrets]));
1018
+ function extractAlgoFromKey(keyBuffer) {
1019
+ if (keyBuffer.length < UINT32_SIZE) {
1020
+ throw new Error("Invalid SSH key buffer: too short to contain algorithm length");
1018
1021
  }
1019
- const secretList = [...secrets];
1020
- const maskedMessage = maskSecrets(error.message, secretList);
1021
- const maskedStack = error.stack == null ? void 0 : maskSecrets(error.stack, secretList);
1022
- const maskedCause = maskCauseValue(error.cause, secrets, secretList);
1023
- const clone = buildMaskedErrorClone(error, maskedMessage, secretList);
1024
- clone.name = error.name;
1025
- if (maskedStack != null) {
1026
- Object.defineProperty(clone, "stack", {
1027
- configurable: true,
1028
- value: maskedStack,
1029
- writable: true
1030
- });
1022
+ const algoLength = keyBuffer.readUInt32BE(0);
1023
+ if (algoLength === 0 || UINT32_SIZE + algoLength > keyBuffer.length) {
1024
+ throw new Error("Invalid SSH key buffer: algorithm length exceeds buffer size");
1031
1025
  }
1032
- if (maskedCause !== void 0) {
1033
- Object.defineProperty(clone, "cause", {
1034
- configurable: true,
1035
- value: maskedCause,
1036
- writable: true
1037
- });
1026
+ const algo = keyBuffer.subarray(UINT32_SIZE, UINT32_SIZE + algoLength).toString("ascii");
1027
+ if (!PINNED_HOST_KEY_ALGORITHM_PATTERN.test(algo)) {
1028
+ throw new KnownHostsValidationError(`Invalid SSH key buffer: unsupported algorithm '${algo}'`);
1038
1029
  }
1039
- return clone;
1040
- }
1041
- function stringifyCause(cause, secretList) {
1042
- if (typeof cause === "string") return cause;
1043
- if (typeof cause === "function")
1044
- return cause.name.length > 0 ? `[Function: ${cause.name}]` : "[Function]";
1045
- if (typeof cause === "number") return String(cause);
1046
- if (typeof cause === "boolean") return String(cause);
1047
- if (typeof cause === "bigint") return String(cause);
1048
- if (typeof cause === "symbol") return String(cause);
1049
- if (cause === void 0) return String(cause);
1050
- if (cause === null) return "null";
1051
- return stringifyObjectCause(cause, secretList);
1030
+ return algo;
1052
1031
  }
1053
- function stringifyObjectCause(cause, secretList) {
1032
+ function describeAlgoForDiagnostics(keyBuffer) {
1054
1033
  try {
1055
- const serialized = JSON.stringify(normalizeObjectCause(cause, secretList, /* @__PURE__ */ new WeakSet()));
1056
- return serialized ?? Object.prototype.toString.call(cause);
1034
+ return extractAlgoFromKey(keyBuffer);
1057
1035
  } catch {
1058
- return Object.prototype.toString.call(cause);
1036
+ return "<unknown>";
1059
1037
  }
1060
1038
  }
1061
- function normalizeObjectCause(cause, secretList, seen) {
1062
- if (isBinaryCauseValue(cause)) return REDACTED_PLACEHOLDER;
1063
- if (cause instanceof Date) return cause.toJSON();
1064
- if (seen.has(cause)) return CIRCULAR_PLACEHOLDER;
1065
- seen.add(cause);
1039
+ function computeFingerprint(key) {
1040
+ const hash = createHash("sha256").update(key).digest("base64");
1041
+ return `SHA256:${hash.replaceAll("=", "")}`;
1042
+ }
1043
+ async function appendHostKey(host, port, keyBuffer) {
1044
+ assertSafeHostForKnownHosts(host);
1045
+ const hostLabel = formatHostNeedle(host, port);
1046
+ const algo = extractAlgoFromKey(keyBuffer);
1047
+ const base64Key = keyBuffer.toString("base64");
1048
+ if (new RegExp("\\s", "v").test(hostLabel)) {
1049
+ throw new KnownHostsValidationError(
1050
+ `Refusing to persist known_hosts entry: host label contains whitespace`
1051
+ );
1052
+ }
1053
+ if (new RegExp("\\s", "v").test(algo)) {
1054
+ throw new KnownHostsValidationError(
1055
+ `Refusing to persist known_hosts entry: algorithm contains whitespace`
1056
+ );
1057
+ }
1058
+ if (new RegExp("\\s", "v").test(base64Key)) {
1059
+ throw new KnownHostsValidationError(
1060
+ `Refusing to persist known_hosts entry: base64 key contains whitespace`
1061
+ );
1062
+ }
1063
+ const line = `${hostLabel} ${algo} ${base64Key}
1064
+ `;
1065
+ const sshDirectory = join(homedir(), ".ssh");
1066
+ const filePath = join(sshDirectory, "known_hosts");
1067
+ await withKnownHostsLock(async () => {
1068
+ await mkdir(sshDirectory, { mode: 448, recursive: true });
1069
+ const existingEntries = await loadKnownHostEntries();
1070
+ const matchingRawHostEntries = existingEntries.filter(
1071
+ (entry) => entry.marker == null && matchesKnownHostEntry(entry, hostLabel)
1072
+ );
1073
+ const alreadyTrusted = matchingRawHostEntries.some(
1074
+ (entry) => entry.algo === algo && entry.key.length === keyBuffer.length && timingSafeEqual2(entry.key, keyBuffer)
1075
+ );
1076
+ if (alreadyTrusted) return;
1077
+ const conflictingEntry = matchingRawHostEntries.find(
1078
+ (entry) => entry.key.length !== keyBuffer.length || !timingSafeEqual2(entry.key, keyBuffer)
1079
+ );
1080
+ if (conflictingEntry != null) throwHostKeyMismatch(host, keyBuffer, conflictingEntry.key);
1081
+ await appendFile(filePath, line, { mode: 384 });
1082
+ });
1083
+ }
1084
+ function getFileSystemErrorCode(error) {
1085
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
1086
+ }
1087
+ var KIBIBYTE_BYTES = 1024;
1088
+ var MEBIBYTE_BYTES = KIBIBYTE_BYTES * KIBIBYTE_BYTES;
1089
+ var KNOWN_HOSTS_FILE_LIMIT_MIB = 16;
1090
+ var KNOWN_HOSTS_FILE_BYTE_LIMIT = KNOWN_HOSTS_FILE_LIMIT_MIB * MEBIBYTE_BYTES;
1091
+ async function loadKnownHostEntries() {
1092
+ const filePath = join(homedir(), ".ssh", "known_hosts");
1066
1093
  try {
1067
- if (Array.isArray(cause)) {
1068
- return cause.map((value) => normalizeCausePropertyValue(value, secretList, seen));
1094
+ const stats = await stat2(filePath);
1095
+ if (stats.size > KNOWN_HOSTS_FILE_BYTE_LIMIT) {
1096
+ throw new HostKeyVerificationError(
1097
+ `Refusing to read known_hosts at ${filePath}: size ${stats.size} bytes exceeds the ${KNOWN_HOSTS_FILE_BYTE_LIMIT}-byte cap`
1098
+ );
1069
1099
  }
1070
- const normalized = {};
1071
- for (const [key, value] of Object.entries(cause)) {
1072
- if (isSecretDiagnosticField(key)) {
1073
- normalized[key] = REDACTED_PLACEHOLDER;
1074
- continue;
1075
- }
1076
- normalized[key] = normalizeCausePropertyValue(value, secretList, seen);
1100
+ return parseKnownHosts(await readFile2(filePath, "utf8"));
1101
+ } catch (error) {
1102
+ if (error instanceof HostKeyVerificationError) throw error;
1103
+ if (getFileSystemErrorCode(error) === "ENOENT") {
1104
+ return [];
1077
1105
  }
1078
- return normalized;
1079
- } finally {
1080
- seen.delete(cause);
1106
+ throw new HostKeyVerificationError(
1107
+ `Could not read known_hosts at ${filePath}: ${String(error)}`
1108
+ );
1081
1109
  }
1082
1110
  }
1083
- function normalizeCausePropertyValue(value, secretList, seen) {
1084
- if (value === null) return null;
1085
- if (typeof value === "bigint") return String(value);
1086
- if (typeof value !== "object") return value;
1087
- return normalizeObjectCause(value, secretList, seen);
1088
- }
1089
- function isBinaryCauseValue(value) {
1090
- return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
1111
+ function formatAcceptedHostKeyWarning(host, key) {
1112
+ try {
1113
+ const algo = extractAlgoFromKey(key);
1114
+ const fingerprint = computeFingerprint(key);
1115
+ return `WARNING: Permanently added '${host}' (${algo}) to the list of known hosts. Fingerprint: ${fingerprint}
1116
+ `;
1117
+ } catch {
1118
+ return `WARNING: Permanently added '${host}' to the list of known hosts.
1119
+ `;
1120
+ }
1091
1121
  }
1092
- function getRegisteredSecrets() {
1093
- return [...secretCounts.keys()];
1122
+ function writeHostKeyPersistFallbackWarning(host, port, error) {
1123
+ const keyscanArguments = port === DEFAULT_SSH_PORT ? shellQuote(host) : `-p ${port} ${shellQuote(host)}`;
1124
+ process.stderr.write(
1125
+ `WARNING: Could not persist host key for ${host} \u2014 the key is cached in memory for this session. To persist it, ensure ~/.ssh/ is writable or run: ssh-keyscan ${keyscanArguments} >> ~/.ssh/known_hosts. ${String(error)}
1126
+ `
1127
+ );
1094
1128
  }
1095
- function clearRegisteredSecrets() {
1096
- secretCounts.clear();
1129
+ async function acceptAndPersistHostKey(location, key, cache) {
1130
+ const { host, port } = location;
1131
+ const acceptedWarning = formatAcceptedHostKeyWarning(host, key);
1132
+ try {
1133
+ await appendHostKey(host, port, key);
1134
+ } catch (error) {
1135
+ if (error instanceof HostKeyVerificationError || error instanceof KnownHostsValidationError) {
1136
+ throw error;
1137
+ }
1138
+ cache.set(formatHostNeedle(host, port), key);
1139
+ process.stderr.write(acceptedWarning);
1140
+ writeHostKeyPersistFallbackWarning(host, port, error);
1141
+ return;
1142
+ }
1143
+ cache.set(formatHostNeedle(host, port), key);
1144
+ process.stderr.write(acceptedWarning);
1097
1145
  }
1098
- function maskRegisteredSecrets(text) {
1099
- if (secretCounts.size === 0) return text;
1100
- return maskSecrets(text, getRegisteredSecrets());
1146
+ var PINNED_HOST_KEY_ALGORITHM_PATTERN = new RegExp("^(?:ssh-ed25519|ssh-rsa|ecdsa-sha2-(?:nistp256|nistp384|nistp521))$", "v");
1147
+ function normalizePinnedPublicKey(publicKey) {
1148
+ const parts = publicKey.trim().split(new RegExp("\\s+", "v"));
1149
+ if (parts.length < 2) {
1150
+ throw new Error("Expected host public key must use the format '<algorithm> <base64>'");
1151
+ }
1152
+ const [algorithm, key] = parts;
1153
+ if (!PINNED_HOST_KEY_ALGORITHM_PATTERN.test(algorithm)) {
1154
+ throw new Error(
1155
+ `Expected host public key uses unsupported algorithm '${algorithm}'. Supported algorithms: ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521.`
1156
+ );
1157
+ }
1158
+ if (!STRICT_BASE64_PATTERN.test(key)) {
1159
+ throw new Error(
1160
+ "Expected host public key contains invalid base64 in the key field. Use the exact value from `ssh-keygen -y` or the second field of an OpenSSH known_hosts line."
1161
+ );
1162
+ }
1163
+ return `${algorithm} ${key}`;
1101
1164
  }
1102
-
1103
- // src/output.ts
1104
- var CAUSE_INSPECT_DEPTH = 2;
1105
- var CAUSE_INSPECT_MAX_STRING_LENGTH = 1024;
1106
- var CAUSE_REDACT_BINARY_MAX_DEPTH = CAUSE_INSPECT_DEPTH + 1;
1107
- var MODULE_NAME_WIDTH = 56;
1108
- var MIN_MODULE_NAME_WIDTH = 12;
1109
- var OUTPUT_INDENT_UNIT = " ";
1110
- var SPINNER_FRAME_INTERVAL_MS = 80;
1111
- var ASCII_ESC = 27;
1112
- var ANSI_HIDE_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25l`;
1113
- var ANSI_SHOW_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25h`;
1114
- var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1115
- var STATUS_ICONS = {
1116
- changed: pc.yellow("\u21BA"),
1117
- failed: pc.red("\u2717"),
1118
- ok: pc.green("\u2713"),
1119
- skipped: pc.dim("\u2298"),
1120
- waiting: pc.cyan("\u23F8")
1121
- };
1122
- var CLI_HEADER_LINES = [
1123
- " _ _ ",
1124
- " | | (_) ",
1125
- " _ __ __ _ _ __ __ _| |_ ___ __",
1126
- " | '_ \\ / _` | '__/ _` | __| \\ \\/ /",
1127
- " | |_) | (_| | | | (_| | |_| |> < ",
1128
- " | .__/ \\__,_|_| \\__,_|\\__|_/_/\\_\\",
1129
- " | | ",
1130
- " |_| "
1131
- ];
1132
- var LIVE_OUTPUT_STATE_KEY = /* @__PURE__ */ Symbol.for("paratix.output.liveState");
1133
- function getSharedLiveOutputState() {
1134
- const registry = globalThis;
1135
- const existing = registry[LIVE_OUTPUT_STATE_KEY];
1136
- if (existing != null) return existing;
1137
- const created = {
1138
- activeRecipeGuideDepths: [],
1139
- activeSpinner: null,
1140
- cursorHidden: false,
1141
- pendingRecipeClosureGuideDepths: [],
1142
- recipeOutputDepth: -1
1143
- };
1144
- registry[LIVE_OUTPUT_STATE_KEY] = created;
1145
- return created;
1146
- }
1147
- var liveOutputState = getSharedLiveOutputState();
1148
- function renderCliHeader(version) {
1149
- const versionText = pc.dim(`v${version}`);
1150
- return `${pc.cyan(CLI_HEADER_LINES.join("\n"))}${versionText}
1151
- `;
1165
+ function validateExpectedHostPublicKey(publicKey) {
1166
+ try {
1167
+ normalizePinnedPublicKey(publicKey);
1168
+ return null;
1169
+ } catch (error) {
1170
+ return error instanceof Error ? error.message : String(error);
1171
+ }
1152
1172
  }
1153
- function printCliHeader(version) {
1154
- console.log(renderCliHeader(version));
1173
+ function formatPresentedPublicKey(key) {
1174
+ try {
1175
+ return `${extractAlgoFromKey(key)} ${key.toString("base64")}`;
1176
+ } catch {
1177
+ return null;
1178
+ }
1155
1179
  }
1156
- function supportsAnimatedModuleOutput() {
1157
- return process.stdout.isTTY && typeof process.stdout.clearLine === "function" && typeof process.stdout.cursorTo === "function";
1180
+ function hasPinnedHostTrustAnchor(options) {
1181
+ return options?.expectedHostFingerprint != null || options?.expectedHostPublicKey != null;
1158
1182
  }
1159
- function getModuleIcon(status, waitingFrame) {
1160
- return status === "waiting" ? pc.cyan(waitingFrame ?? "|") : STATUS_ICONS[status];
1183
+ function verifyPinnedHostKey(host, key, options) {
1184
+ const normalizedExpectedPublicKey = options.expectedHostPublicKey == null ? null : normalizePinnedPublicKey(options.expectedHostPublicKey);
1185
+ const expectedFingerprint = options.expectedHostFingerprint ?? null;
1186
+ const presentedPublicKey = formatPresentedPublicKey(key);
1187
+ const presentedFingerprint = computeFingerprint(key);
1188
+ const publicKeyMatches = normalizedExpectedPublicKey == null || presentedPublicKey != null && normalizedExpectedPublicKey === presentedPublicKey;
1189
+ const fingerprintMatches = expectedFingerprint == null || expectedFingerprint === presentedFingerprint;
1190
+ if (publicKeyMatches && fingerprintMatches) {
1191
+ return;
1192
+ }
1193
+ throw new HostKeyVerificationError(
1194
+ `HOST KEY VERIFICATION FAILED for ${host}: the remote host key does not match the configured trust anchor.`
1195
+ );
1161
1196
  }
1162
- function getModuleStatusText(status) {
1163
- switch (status) {
1164
- case "changed": {
1165
- return pc.yellow(status);
1166
- }
1167
- case "failed": {
1168
- return pc.red(status);
1169
- }
1170
- case "ok": {
1171
- return pc.green(status);
1172
- }
1173
- case "skipped": {
1174
- return pc.dim(status);
1175
- }
1176
- case "waiting": {
1177
- return pc.cyan("running");
1197
+ async function buildHostVerifier(mode, location, options = {}) {
1198
+ const cache = options.cache ?? defaultHostKeyCache;
1199
+ const { host, port } = location;
1200
+ if (mode === "no" && !hasPinnedHostTrustAnchor(options)) return {};
1201
+ const entries = await withKnownHostsLock(loadKnownHostEntries);
1202
+ const fileEntries = findMatchingEntries(entries, host, port);
1203
+ const cachedKey = cache.get(formatHostNeedle(host, port)) ?? null;
1204
+ let acceptedHostKey = null;
1205
+ const result = {
1206
+ async commitAcceptedHostKey() {
1207
+ if (acceptedHostKey != null) await acceptAndPersistHostKey(location, acceptedHostKey, cache);
1208
+ },
1209
+ hostVerifier(key) {
1210
+ if (mode !== "no" && verifyHostKeyAgainstKnownEntries({ cachedKey, fileEntries, host, key })) {
1211
+ if (hasPinnedHostTrustAnchor(options)) verifyPinnedHostKey(host, key, options);
1212
+ return true;
1213
+ }
1214
+ if (hasPinnedHostTrustAnchor(options)) {
1215
+ verifyPinnedHostKey(host, key, options);
1216
+ return true;
1217
+ }
1218
+ if (mode === "yes") {
1219
+ throw new HostKeyVerificationError(
1220
+ `Host key for ${host} not found in known_hosts. Set strictHostKeyChecking to "accept-new" for explicit TOFU or configure ssh.expectedHostFingerprint / ssh.expectedHostPublicKey.`
1221
+ );
1222
+ }
1223
+ if (mode === "no") return true;
1224
+ acceptedHostKey = Buffer.from(key);
1225
+ return true;
1178
1226
  }
1179
- }
1227
+ };
1228
+ return result;
1180
1229
  }
1181
- function getCurrentOutputDepth() {
1182
- return Math.max(liveOutputState.recipeOutputDepth, 0);
1230
+
1231
+ // src/serverDefinitionValidation.ts
1232
+ var STRICT_HOST_KEY_ERROR = `Invalid property 'ssh.strictHostKeyChecking' (expected "accept-new", "no", or "yes")`;
1233
+ var MAX_TCP_PORT = 65535;
1234
+ function describeType(value) {
1235
+ return value === null ? "null" : typeof value;
1183
1236
  }
1184
- function getGuideDot(depth) {
1185
- return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
1237
+ function isHostKeyMode(value) {
1238
+ return value === "accept-new" || value === "no" || value === "yes";
1186
1239
  }
1187
- function buildGuideIndent(baseIndent, options) {
1188
- const indentCharacters = Array.from(baseIndent);
1189
- const guideDepths = [
1190
- ...options?.activeGuideDepths ?? liveOutputState.activeRecipeGuideDepths,
1191
- ...options?.extraGuideDepths ?? []
1192
- ];
1193
- for (const guideDepth of guideDepths) {
1194
- const guideCharacterIndex = OUTPUT_INDENT_UNIT.length * (guideDepth + 1);
1195
- if (guideCharacterIndex >= indentCharacters.length) continue;
1196
- indentCharacters[guideCharacterIndex] = options?.colorize === false ? "\xB7" : getGuideDot(guideDepth);
1197
- }
1198
- return indentCharacters.join("");
1240
+ function isRecord(value) {
1241
+ return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
1199
1242
  }
1200
- function clearPendingRecipeClosureGuides() {
1201
- liveOutputState.pendingRecipeClosureGuideDepths = [];
1243
+ function collectOptionalBooleanErrors(object, key, errors) {
1244
+ if (!(key in object) || object[key] == null) return;
1245
+ if (typeof object[key] !== "boolean") {
1246
+ errors.push(
1247
+ `Invalid property 'ssh.${key}' (expected boolean, got ${describeType(object[key])})`
1248
+ );
1249
+ }
1202
1250
  }
1203
- function getModuleIndent() {
1204
- if (liveOutputState.recipeOutputDepth < 0) {
1205
- return OUTPUT_INDENT_UNIT;
1251
+ function collectOptionalNumberErrors(parameters) {
1252
+ const { errors, key, object, options } = parameters;
1253
+ if (!(key in object) || object[key] == null) return;
1254
+ const value = object[key];
1255
+ const validatedNumber = validateNumberValue(value);
1256
+ if (validatedNumber == null) {
1257
+ errors.push(`Invalid property 'ssh.${key}' (expected number, got ${describeType(value)})`);
1258
+ return;
1259
+ }
1260
+ if (options?.integer === true && !Number.isInteger(validatedNumber)) {
1261
+ errors.push(`Property 'ssh.${key}' must be an integer`);
1262
+ return;
1263
+ }
1264
+ if (options?.positive === true && validatedNumber <= 0) {
1265
+ errors.push(`Property 'ssh.${key}' must be greater than 0`);
1206
1266
  }
1207
- return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
1208
1267
  }
1209
- function getRecipeHeaderIndent() {
1210
- if (liveOutputState.recipeOutputDepth < 0) return "";
1211
- return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
1268
+ function validateNumberValue(value) {
1269
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1212
1270
  }
1213
- function getErrorIndent() {
1214
- return `${getModuleIndent()}\u2502 `;
1271
+ function isValidTcpPort(value) {
1272
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_TCP_PORT;
1215
1273
  }
1216
- function getContinuationIndent() {
1217
- return `${getModuleIndent()} `;
1274
+ function collectOptionalStringErrors(object, key, errors) {
1275
+ if (!(key in object) || object[key] == null) return;
1276
+ if (typeof object[key] !== "string") {
1277
+ errors.push(`Invalid property 'ssh.${key}' (expected string, got ${describeType(object[key])})`);
1278
+ return;
1279
+ }
1280
+ if (object[key].length === 0) {
1281
+ errors.push(`Property 'ssh.${key}' must not be an empty string`);
1282
+ }
1218
1283
  }
1219
- async function withRecipeOutputScope(scopedOperation) {
1220
- liveOutputState.recipeOutputDepth += 1;
1221
- try {
1222
- return await scopedOperation();
1223
- } finally {
1224
- liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter((depth) => depth !== liveOutputState.recipeOutputDepth);
1225
- liveOutputState.pendingRecipeClosureGuideDepths = [liveOutputState.recipeOutputDepth];
1226
- liveOutputState.recipeOutputDepth -= 1;
1284
+ function collectPortsErrors(ssh, errors) {
1285
+ if (!("ports" in ssh)) {
1286
+ errors.push("Missing property 'ssh.ports' (expected array)");
1287
+ return;
1288
+ }
1289
+ if (!Array.isArray(ssh.ports)) {
1290
+ errors.push(`Invalid property 'ssh.ports' (expected array, got ${describeType(ssh.ports)})`);
1291
+ return;
1292
+ }
1293
+ if (ssh.ports.length === 0) {
1294
+ errors.push("Property 'ssh.ports' must not be empty");
1295
+ return;
1296
+ }
1297
+ for (const [index, port] of ssh.ports.entries()) {
1298
+ if (!isValidTcpPort(port)) {
1299
+ errors.push(`Property 'ssh.ports[${index}]' must be an integer between 1 and 65535`);
1300
+ }
1227
1301
  }
1228
1302
  }
1229
- function renderModuleLine(parameters) {
1230
- const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters;
1231
- const baseIndent = getModuleIndent();
1232
- const indent = buildGuideIndent(baseIndent, { extraGuideDepths });
1233
- const icon = getModuleIcon(status, waitingFrame);
1234
- const statusText = getModuleStatusText(status);
1235
- const detailSuffix = detail == null ? "" : ` ${pc.dim(detail)}`;
1236
- const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
1237
- return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
1303
+ function collectRequiredUserErrors(ssh, errors) {
1304
+ if (!("user" in ssh)) {
1305
+ errors.push("Missing property 'ssh.user' (expected string)");
1306
+ return;
1307
+ }
1308
+ if (typeof ssh.user !== "string") {
1309
+ errors.push(`Invalid property 'ssh.user' (expected string, got ${describeType(ssh.user)})`);
1310
+ return;
1311
+ }
1312
+ if (ssh.user.length === 0) {
1313
+ errors.push("Property 'ssh.user' must not be an empty string");
1314
+ }
1238
1315
  }
1239
- function hideCursor() {
1240
- if (liveOutputState.cursorHidden) return;
1241
- if (!supportsAnimatedModuleOutput()) return;
1242
- process.stdout.write(ANSI_HIDE_CURSOR);
1243
- liveOutputState.cursorHidden = true;
1244
- }
1245
- function showCursor() {
1246
- if (!liveOutputState.cursorHidden) return;
1247
- process.stdout.write(ANSI_SHOW_CURSOR);
1248
- liveOutputState.cursorHidden = false;
1249
- }
1250
- function writeAnimatedModuleLine(line) {
1251
- hideCursor();
1252
- process.stdout.clearLine(0);
1253
- process.stdout.cursorTo(0);
1254
- process.stdout.write(fitAnimatedModuleLine(line, process.stdout.columns));
1255
- }
1256
- function stopAnimatedModuleLine(clearCurrentLine = false) {
1257
- showCursor();
1258
- if (liveOutputState.activeSpinner == null) return;
1259
- clearInterval(liveOutputState.activeSpinner.interval);
1260
- liveOutputState.activeSpinner = null;
1261
- if (clearCurrentLine && supportsAnimatedModuleOutput()) {
1262
- process.stdout.clearLine(0);
1263
- process.stdout.cursorTo(0);
1316
+ function collectStrictHostKeyCheckingErrors(ssh, errors) {
1317
+ if (!("strictHostKeyChecking" in ssh) || ssh.strictHostKeyChecking == null) return;
1318
+ if (typeof ssh.strictHostKeyChecking !== "string" || !isHostKeyMode(ssh.strictHostKeyChecking)) {
1319
+ errors.push(STRICT_HOST_KEY_ERROR);
1264
1320
  }
1265
1321
  }
1266
- function stopLiveModuleOutput(clearCurrentLine = false) {
1267
- stopAnimatedModuleLine(clearCurrentLine);
1322
+ function collectExpectedHostPublicKeyErrors(ssh, errors) {
1323
+ if (typeof ssh.expectedHostPublicKey !== "string") return;
1324
+ const error = validateExpectedHostPublicKey(ssh.expectedHostPublicKey);
1325
+ if (error != null) errors.push(`Invalid property 'ssh.expectedHostPublicKey': ${error}`);
1268
1326
  }
1269
- function startModuleSpinner(name, detail) {
1270
- if (!supportsAnimatedModuleOutput()) return;
1271
- clearPendingRecipeClosureGuides();
1272
- stopAnimatedModuleLine();
1273
- const maskedName = maskRegisteredSecrets(name);
1274
- const maskedDetail = detail == null ? void 0 : maskRegisteredSecrets(detail);
1275
- const displayModule = formatDisplayModule({
1276
- continuationIndentWidth: getContinuationIndent().length,
1277
- detail: maskedDetail,
1278
- name: maskedName,
1279
- status: "waiting",
1280
- terminalColumns: process.stdout.columns
1327
+ function collectOptionalSshFieldErrors(ssh, errors) {
1328
+ collectOptionalStringErrors(ssh, "privateKey", errors);
1329
+ collectOptionalStringErrors(ssh, "sudoPassword", errors);
1330
+ collectOptionalStringErrors(ssh, "expectedHostFingerprint", errors);
1331
+ collectOptionalStringErrors(ssh, "expectedHostPublicKey", errors);
1332
+ collectExpectedHostPublicKeyErrors(ssh, errors);
1333
+ collectOptionalBooleanErrors(ssh, "agentForward", errors);
1334
+ collectOptionalBooleanErrors(ssh, "passwordFallback", errors);
1335
+ collectOptionalNumberErrors({
1336
+ errors,
1337
+ key: "reconnectTimeout",
1338
+ object: ssh,
1339
+ options: { positive: true }
1281
1340
  });
1282
- const spinner = {
1283
- detail: displayModule.detail,
1284
- frameIndex: 0,
1285
- interval: setInterval(() => {
1286
- spinner.frameIndex = (spinner.frameIndex + 1) % SPINNER_FRAMES.length;
1287
- writeAnimatedModuleLine(
1288
- renderModuleLine({
1289
- detail: spinner.detail,
1290
- name: displayModule.name,
1291
- status: "waiting",
1292
- waitingFrame: SPINNER_FRAMES[spinner.frameIndex]
1293
- })
1294
- );
1295
- }, SPINNER_FRAME_INTERVAL_MS)
1296
- };
1297
- if (typeof spinner.interval.unref === "function") spinner.interval.unref();
1298
- liveOutputState.activeSpinner = spinner;
1299
- writeAnimatedModuleLine(
1300
- renderModuleLine({
1301
- detail: displayModule.detail,
1302
- name: displayModule.name,
1303
- status: "waiting",
1304
- waitingFrame: SPINNER_FRAMES[0]
1305
- })
1306
- );
1341
+ collectOptionalNumberErrors({
1342
+ errors,
1343
+ key: "maxReconnectAttempts",
1344
+ object: ssh,
1345
+ options: { integer: true, positive: true }
1346
+ });
1347
+ collectStrictHostKeyCheckingErrors(ssh, errors);
1307
1348
  }
1308
- function printRecipeHeader(name) {
1309
- stopAnimatedModuleLine(true);
1310
- clearPendingRecipeClosureGuides();
1311
- const header = pc.bold(pc.blue(`[${name}]`));
1312
- console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
1313
- if (liveOutputState.recipeOutputDepth >= 0) {
1314
- liveOutputState.activeRecipeGuideDepths = [...liveOutputState.activeRecipeGuideDepths, liveOutputState.recipeOutputDepth];
1349
+ function collectSshConfigErrors(value) {
1350
+ const errors = [];
1351
+ if (value === null) {
1352
+ errors.push("Invalid property 'ssh' (expected object, got null)");
1353
+ return errors;
1354
+ }
1355
+ if (typeof value !== "object") {
1356
+ errors.push(`Invalid property 'ssh' (expected object, got ${describeType(value)})`);
1357
+ return errors;
1358
+ }
1359
+ if (!isRecord(value)) {
1360
+ errors.push("Invalid property 'ssh' (expected object, got object)");
1361
+ return errors;
1315
1362
  }
1363
+ const ssh = value;
1364
+ collectPortsErrors(ssh, errors);
1365
+ collectRequiredUserErrors(ssh, errors);
1366
+ collectOptionalSshFieldErrors(ssh, errors);
1367
+ return errors;
1316
1368
  }
1317
- function printRunContext(parameters) {
1318
- const mode = parameters.dryRun ? pc.yellow("dry-run") : pc.green("apply");
1319
- const ports = parameters.ports.join(", ");
1320
- console.log(
1321
- pc.dim(`Run ${parameters.name} \xB7 host ${parameters.host} \xB7 ports ${ports} \xB7 mode ${mode}`)
1322
- );
1369
+ function normalizeServerDefinitionSshError(error) {
1370
+ const mappedErrors = {
1371
+ "Missing property 'ssh.user' (expected string)": "ssh.user is required",
1372
+ "Property 'ssh.expectedHostFingerprint' must not be an empty string": "ssh.expectedHostFingerprint must not be an empty string",
1373
+ "Property 'ssh.expectedHostPublicKey' must not be an empty string": "ssh.expectedHostPublicKey must not be an empty string",
1374
+ "Property 'ssh.ports' must not be empty": "ssh.ports must not be empty",
1375
+ "Property 'ssh.privateKey' must not be an empty string": "ssh.privateKey must not be an empty string",
1376
+ "Property 'ssh.user' must not be an empty string": "ssh.user is required",
1377
+ [STRICT_HOST_KEY_ERROR]: "ssh.strictHostKeyChecking must be one of accept-new, no, yes"
1378
+ };
1379
+ return mappedErrors[error] ?? error;
1323
1380
  }
1324
- function colorizeDiffLine(line) {
1325
- if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
1326
- return pc.dim(line);
1327
- }
1328
- if (line.startsWith("-")) return pc.red(line);
1329
- if (line.startsWith("+")) return pc.green(line);
1330
- return pc.dim(line);
1381
+ function validateSshConfig(ssh) {
1382
+ const errors = collectSshConfigErrors(ssh);
1383
+ if (errors.length === 0) return;
1384
+ const message = errors.map((error) => normalizeServerDefinitionSshError(error)).join("; ");
1385
+ throw new Error(`ServerDefinition: ${message}`);
1331
1386
  }
1332
- function renderDiffLines(diff) {
1333
- if (diff.trim() === "") return [];
1334
- const sanitized = sanitizeTerminalText(maskRegisteredSecrets(diff));
1335
- return sanitized.split("\n").map((line) => `\u2502 ${colorizeDiffLine(line)}`);
1387
+
1388
+ // src/meta.ts
1389
+ var SYSTEM_HOST_KIND = "system.host";
1390
+ var SYSTEM_REBOOT_KIND = "system.reboot";
1391
+ function hasValidMetaName(name) {
1392
+ return typeof name === "string" && name.length > 0;
1336
1393
  }
1337
- function writeContinuationLine(line, extraGuideDepths, via) {
1338
- const composed = `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${line}`;
1339
- if (via === "stdout") {
1340
- process.stdout.write(`${composed}
1341
- `);
1342
- } else {
1343
- console.log(composed);
1344
- }
1394
+ function isMetaValueType(value) {
1395
+ return value === "boolean" || value === "number" || value === "string";
1345
1396
  }
1346
- function writeContinuationBlock(parameters) {
1347
- for (const detailLine of parameters.detailLines) {
1348
- writeContinuationLine(pc.dim(detailLine), parameters.extraGuideDepths, parameters.via);
1349
- }
1350
- for (const diffLine of parameters.diffLines) {
1351
- writeContinuationLine(diffLine, parameters.extraGuideDepths, parameters.via);
1397
+ function validateResolvedMetaValue(parameters) {
1398
+ const { actualType, declaredValueType, enforceDeclaredValueType, entryKey } = parameters;
1399
+ if (!isMetaValueType(actualType)) {
1400
+ throw new TypeError(
1401
+ `Env meta entry ${JSON.stringify(entryKey)} resolved to typeof ${actualType}, expected boolean, number, or string`
1402
+ );
1352
1403
  }
1353
- }
1354
- function printRenderedModuleResult(parameters) {
1355
- const extraGuideDepths = parameters.extraGuideDepths ?? [];
1356
- const maskedName = maskRegisteredSecrets(parameters.name);
1357
- const maskedDetail = parameters.detail == null ? void 0 : maskRegisteredSecrets(parameters.detail);
1358
- const displayModule = formatDisplayModule({
1359
- continuationIndentWidth: `${buildGuideIndent(
1360
- OUTPUT_INDENT_UNIT.repeat(Math.max(getCurrentOutputDepth() + 2, 1)),
1361
- { extraGuideDepths }
1362
- )} `.length,
1363
- detail: maskedDetail,
1364
- name: maskedName,
1365
- status: parameters.status,
1366
- terminalColumns: process.stdout.columns
1367
- });
1368
- const line = renderModuleLine({
1369
- detail: displayModule.detail,
1370
- extraGuideDepths,
1371
- name: displayModule.name,
1372
- status: parameters.status
1373
- });
1374
- const diffLines = parameters.diff == null ? [] : renderDiffLines(parameters.diff);
1375
- const usesSpinner = supportsAnimatedModuleOutput() && liveOutputState.activeSpinner != null;
1376
- if (usesSpinner) {
1377
- stopAnimatedModuleLine();
1378
- writeAnimatedModuleLine(line);
1379
- process.stdout.write("\n");
1380
- } else {
1381
- console.log(line);
1404
+ if (enforceDeclaredValueType && actualType !== declaredValueType) {
1405
+ throw new TypeError(
1406
+ `Env meta entry ${JSON.stringify(entryKey)} resolved to typeof ${actualType}, expected ${declaredValueType}`
1407
+ );
1382
1408
  }
1383
- writeContinuationBlock({
1384
- detailLines: displayModule.detailLines,
1385
- diffLines,
1386
- extraGuideDepths,
1387
- via: usesSpinner ? "stdout" : "console"
1388
- });
1389
1409
  }
1390
- function printModuleResult(name, status, detail, diff) {
1391
- clearPendingRecipeClosureGuides();
1392
- printRenderedModuleResult({ detail, diff, name, status });
1410
+ function isRecord2(value) {
1411
+ return typeof value === "object" && value !== null;
1393
1412
  }
1394
- function printCommandError(stdout, stderr) {
1395
- const maskedStdout = sanitizeTerminalText(maskRegisteredSecrets(stdout));
1396
- const maskedStderr = sanitizeTerminalText(maskRegisteredSecrets(stderr));
1397
- const lines = [];
1398
- if (maskedStderr.trim()) {
1399
- lines.push(...maskedStderr.trim().split("\n"));
1413
+ function assertValidEnvironmentMetaEntry(candidate) {
1414
+ if (!hasValidMetaName(candidate.name)) {
1415
+ throw new TypeError("Invalid env meta entry: name must be a non-empty string");
1400
1416
  }
1401
- if (maskedStdout.trim()) {
1402
- lines.push(...maskedStdout.trim().split("\n"));
1417
+ if (typeof candidate.resolve !== "function") {
1418
+ throw new TypeError("Invalid env meta entry: resolve must be a function returning a Promise");
1403
1419
  }
1404
- if (lines.length > 0) {
1405
- console.error(pc.red(`${getErrorIndent()}Error output:`));
1406
- for (const line of lines) {
1407
- console.error(pc.red(`${getErrorIndent()}${line}`));
1408
- }
1420
+ if (candidate.valueType !== "boolean" && candidate.valueType !== "number" && candidate.valueType !== "string") {
1421
+ throw new TypeError("Invalid env meta entry: valueType must be boolean, number, or string");
1409
1422
  }
1410
1423
  }
1411
- function printVerboseCommandError(stdout, stderr) {
1412
- const maskedStdout = sanitizeTerminalText(maskRegisteredSecrets(stdout));
1413
- const maskedStderr = sanitizeTerminalText(maskRegisteredSecrets(stderr));
1414
- if (maskedStderr.trim()) {
1415
- console.error(pc.red(`${getErrorIndent()}Full stderr:`));
1416
- for (const line of maskedStderr.trim().split("\n")) {
1417
- console.error(pc.red(`${getErrorIndent()}${line}`));
1418
- }
1419
- }
1420
- if (maskedStdout.trim()) {
1421
- console.error(pc.red(`${getErrorIndent()}Full stdout:`));
1422
- for (const line of maskedStdout.trim().split("\n")) {
1423
- console.error(pc.red(`${getErrorIndent()}${line}`));
1424
- }
1424
+ function assertValidSshdPortMetaEntry(candidate) {
1425
+ if (!isValidTcpPort(candidate.port)) {
1426
+ throw new TypeError("Invalid sshd.port meta entry: port must be an integer between 1 and 65535");
1425
1427
  }
1426
1428
  }
1427
- function printVerboseErrorBlock(label, content) {
1428
- if (!content.trim()) {
1429
- return;
1430
- }
1431
- console.error(pc.red(`${getErrorIndent()}${label}`));
1432
- for (const line of content.trim().split("\n")) {
1433
- console.error(pc.red(`${getErrorIndent()}${line}`));
1429
+ function assertValidSystemHostMetaEntry(candidate) {
1430
+ const hostValidationFailure = validateHostLabel(candidate.host);
1431
+ if (hostValidationFailure != null) {
1432
+ throw new TypeError(
1433
+ `Invalid system.host meta entry: host ${describeHostValidationFailure(hostValidationFailure)}`
1434
+ );
1434
1435
  }
1435
1436
  }
1436
- function getErrorCause(error) {
1437
- return error.cause;
1437
+ function isEnvironmentMetaEntry(entry) {
1438
+ return entry.kind === "env";
1438
1439
  }
1439
- function printVerboseErrorCause(cause, depth, visitedCauses) {
1440
- const label = `Cause ${depth}:`;
1441
- if (cause instanceof Error) {
1442
- if (visitedCauses.has(cause)) {
1443
- printVerboseErrorBlock(label, "<cycle detected>");
1440
+ function isSshdPortMetaEntry(entry) {
1441
+ return entry.kind === "sshd.port";
1442
+ }
1443
+ function isSystemHostMetaEntry(entry) {
1444
+ return entry.kind === SYSTEM_HOST_KIND;
1445
+ }
1446
+ function isSystemRebootMetaEntry(entry) {
1447
+ return entry.kind === SYSTEM_REBOOT_KIND;
1448
+ }
1449
+ function assertValidModuleMetaEntry(entry) {
1450
+ if (!isRecord2(entry)) {
1451
+ throw new TypeError(`Invalid meta entry: expected object, got ${typeof entry}`);
1452
+ }
1453
+ switch (entry.kind) {
1454
+ case "env": {
1455
+ assertValidEnvironmentMetaEntry(entry);
1444
1456
  return;
1445
1457
  }
1446
- visitedCauses.add(cause);
1447
- const stack = cause.stack?.trim() ?? "";
1448
- const stackOrMessage = stack.length > 0 ? stack : String(cause);
1449
- printVerboseErrorBlock(label, maskRegisteredSecrets(stackOrMessage));
1450
- const nestedCause = getErrorCause(cause);
1451
- if (nestedCause !== void 0) {
1452
- printVerboseErrorCause(nestedCause, depth + 1, visitedCauses);
1458
+ case "sshd.port": {
1459
+ assertValidSshdPortMetaEntry(entry);
1460
+ return;
1461
+ }
1462
+ case SYSTEM_HOST_KIND: {
1463
+ assertValidSystemHostMetaEntry(entry);
1464
+ return;
1465
+ }
1466
+ case SYSTEM_REBOOT_KIND: {
1467
+ return;
1468
+ }
1469
+ default: {
1470
+ throw new TypeError(`Invalid meta entry kind: ${String(entry.kind)}`);
1453
1471
  }
1454
- return;
1455
1472
  }
1456
- printVerboseErrorBlock(label, maskRegisteredSecrets(formatCauseValue(cause)));
1457
1473
  }
1458
- function printVerboseGenericError(error) {
1459
- const stack = error.stack?.trim() ?? "";
1460
- const stackOrMessage = stack.length > 0 ? stack : String(error);
1461
- printVerboseErrorBlock("Full stack:", maskRegisteredSecrets(stackOrMessage));
1462
- const cause = getErrorCause(error);
1463
- if (cause !== void 0) {
1464
- const visitedCauses = /* @__PURE__ */ new WeakSet();
1465
- visitedCauses.add(error);
1466
- printVerboseErrorCause(cause, 1, visitedCauses);
1474
+ function assertValidModuleMetaEntries(entries) {
1475
+ if (entries == null) return;
1476
+ for (const entry of entries) {
1477
+ assertValidModuleMetaEntry(entry);
1467
1478
  }
1468
1479
  }
1469
- function formatCauseValue(cause) {
1470
- if (cause instanceof Error) return cause.message;
1471
- return inspectRedactedDiagnosticValue(cause, {
1472
- depth: CAUSE_INSPECT_DEPTH,
1473
- maxStringLength: CAUSE_INSPECT_MAX_STRING_LENGTH,
1474
- redactMaxDepth: CAUSE_REDACT_BINARY_MAX_DEPTH
1475
- });
1476
- }
1477
- function printCauseChain(error) {
1478
- const visited = /* @__PURE__ */ new WeakSet();
1479
- visited.add(error);
1480
- let cause = getErrorCause(error);
1481
- while (cause !== void 0) {
1482
- if (cause instanceof Error) {
1483
- if (visited.has(cause)) return;
1484
- visited.add(cause);
1485
- }
1486
- console.error(pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`));
1487
- if (cause instanceof CommandError) {
1488
- printVerboseCommandError(
1489
- maskRegisteredSecrets(cause.fullStdout),
1490
- maskRegisteredSecrets(cause.fullStderr)
1491
- );
1492
- }
1493
- cause = cause instanceof Error ? getErrorCause(cause) : void 0;
1480
+ var META_ENV_SEGMENT_PATTERN = new RegExp("^[A-Za-z_]\\w*$", "v");
1481
+ function isAllowedMetaEnvironmentName(name) {
1482
+ if (name.length === 0) return false;
1483
+ const segments = name.split(".");
1484
+ for (const segment of segments) {
1485
+ if (!META_ENV_SEGMENT_PATTERN.test(segment)) return false;
1494
1486
  }
1487
+ return true;
1495
1488
  }
1496
- function printCommandFailure(error, verbose) {
1497
- if (verbose && error instanceof CommandError) {
1498
- const summaryLine = error.message.split("\n")[0];
1499
- printCommandError("", maskRegisteredSecrets(summaryLine));
1500
- printVerboseCommandError(
1501
- maskRegisteredSecrets(error.fullStdout),
1502
- maskRegisteredSecrets(error.fullStderr)
1489
+ function assertAllowedEnvironmentMetaName(name) {
1490
+ if (!isAllowedMetaEnvironmentName(name)) {
1491
+ throw new Error(
1492
+ `Invalid env meta name ${JSON.stringify(name)}: expected [A-Za-z_]\\w*(\\.[A-Za-z_]\\w*)*`
1503
1493
  );
1504
- return;
1505
1494
  }
1506
- printCommandError("", maskRegisteredSecrets(String(error)));
1507
- if (error instanceof Error) {
1508
- if (!(error instanceof CommandError)) {
1509
- printCauseChain(error);
1510
- }
1511
- if (verbose) {
1512
- printVerboseGenericError(error);
1513
- }
1495
+ if (ENVIRONMENT_FORBIDDEN_KEYS.has(name)) {
1496
+ throw new Error(
1497
+ `Forbidden env meta entry name: ${JSON.stringify(name)} (reserved JavaScript identifier)`
1498
+ );
1514
1499
  }
1515
1500
  }
1516
- function printSummary(stats) {
1517
- stopAnimatedModuleLine(true);
1518
- const parts = [
1519
- pc.yellow(`${stats.changed} changed`),
1520
- pc.green(`${stats.ok} ok`),
1521
- pc.dim(`${stats.skipped} skipped`),
1522
- stats.failed > 0 ? pc.red(`${stats.failed} failed`) : `${stats.failed} failed`,
1523
- pc.cyan(`${stats.signals} signals triggered`)
1524
- ];
1525
- console.log(`
1526
- ${parts.join(pc.dim(" \xB7 "))}`);
1501
+ function createMemoizedEnvironmentResolver(entry) {
1502
+ let cachedResolution = null;
1503
+ const declaredValueType = entry.valueType;
1504
+ const enforceDeclaredValueType = entry.valueTypeExplicit !== false;
1505
+ const entryKey = entry.name;
1506
+ return async () => {
1507
+ if (cachedResolution != null) return cachedResolution;
1508
+ const pending = entry.resolve().then((resolved) => {
1509
+ validateResolvedMetaValue({
1510
+ actualType: typeof resolved,
1511
+ declaredValueType,
1512
+ enforceDeclaredValueType,
1513
+ entryKey
1514
+ });
1515
+ return resolved;
1516
+ }).catch((error) => {
1517
+ cachedResolution = null;
1518
+ throw error;
1519
+ });
1520
+ cachedResolution = pending;
1521
+ return pending;
1522
+ };
1527
1523
  }
1528
-
1529
- // src/dryRunDispatch.ts
1530
- function shouldExecuteApplyDuringDryRun(module, diffEnabled) {
1531
- if (module._dryRunBlocker === true || module._dryRunMetaProducer === true) return true;
1532
- if (diffEnabled && module._dryRunDiffProducer === true && module._applyDryRun != null) return true;
1533
- return module._applyDryRun != null && module._dryRunDiffProducer !== true;
1524
+ async function mergeEnvironmentFromMeta(environment, entries) {
1525
+ if (entries == null || entries.length === 0) {
1526
+ await Promise.resolve();
1527
+ return environment;
1528
+ }
1529
+ const nextEnvironment = Object.assign(createNullPrototypeEnvironment(), environment);
1530
+ for (const entry of entries) {
1531
+ if (!isEnvironmentMetaEntry(entry)) continue;
1532
+ assertAllowedEnvironmentMetaName(entry.name);
1533
+ nextEnvironment[entry.name] = createMemoizedEnvironmentResolver(entry);
1534
+ }
1535
+ await Promise.resolve();
1536
+ return nextEnvironment;
1534
1537
  }
1535
1538
 
1536
- // src/knownHosts.ts
1537
- import { createHash, timingSafeEqual as timingSafeEqual2 } from "crypto";
1538
- import { appendFile, mkdir, readFile as readFile2, stat as stat2 } from "fs/promises";
1539
- import { homedir } from "os";
1540
- import { join } from "path";
1539
+ // src/output.ts
1540
+ import pc from "picocolors";
1541
1541
 
1542
- // src/knownHostPatterns.ts
1543
- import { createHmac, timingSafeEqual } from "crypto";
1544
- var HASHED_HOST_PARTS = 4;
1545
- function matchesKnownHostPatternList(patterns, needle) {
1546
- let matchedPositivePattern = false;
1547
- for (const rawPattern of patterns) {
1548
- const negated = rawPattern.startsWith("!");
1549
- const pattern = negated ? rawPattern.slice(1) : rawPattern;
1550
- if (pattern.length === 0) continue;
1551
- const matched = matchesHashedHost(pattern, needle) || matchesPlainHostPattern(pattern, needle);
1552
- if (!matched) continue;
1553
- if (negated) return false;
1554
- matchedPositivePattern = true;
1542
+ // src/outputFormatting.ts
1543
+ import { stripVTControlCharacters } from "util";
1544
+ var PACKAGE_MODULE_SUFFIX_LENGTH = 2;
1545
+ var PACKAGE_COLUMN_GAP_WIDTH = 2;
1546
+ var DEFAULT_TERMINAL_COLUMNS = 100;
1547
+ var MIN_PACKAGE_COLUMNS_WIDTH = 24;
1548
+ var MIN_PACKAGE_COLUMN_WIDTH = 18;
1549
+ var MIN_ANIMATED_LINE_COLUMNS = 8;
1550
+ function splitPackageModuleName(name) {
1551
+ for (const prefix of ["package.installed: ", "package.absent: "]) {
1552
+ if (!name.startsWith(prefix)) continue;
1553
+ const packages = name.slice(prefix.length).split(",").map((entry) => entry.trim()).filter(Boolean);
1554
+ if (packages.length === 0) return null;
1555
+ return {
1556
+ packages,
1557
+ summaryName: prefix.slice(0, -PACKAGE_MODULE_SUFFIX_LENGTH)
1558
+ };
1555
1559
  }
1556
- return matchedPositivePattern;
1560
+ return null;
1557
1561
  }
1558
- function matchesPlainHostPattern(pattern, needle) {
1559
- if (pattern.startsWith("|1|")) return false;
1560
- return matchesHostPattern(pattern.toLowerCase(), needle.toLowerCase());
1562
+ function getPackageColumns(parameters) {
1563
+ if (parameters.packages.length <= 1) return parameters.packages;
1564
+ const availableWidth = Math.max(
1565
+ (parameters.terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) - parameters.continuationIndentWidth,
1566
+ MIN_PACKAGE_COLUMNS_WIDTH
1567
+ );
1568
+ const widestPackage = Math.max(...parameters.packages.map((entry) => entry.length));
1569
+ const columnWidth = Math.max(widestPackage + PACKAGE_COLUMN_GAP_WIDTH, MIN_PACKAGE_COLUMN_WIDTH);
1570
+ const columnCount = Math.max(Math.floor(availableWidth / columnWidth), 1);
1571
+ const rowCount = Math.ceil(parameters.packages.length / columnCount);
1572
+ return Array.from(
1573
+ { length: rowCount },
1574
+ (_row, rowIndex) => Array.from({ length: columnCount }, (_column, columnIndex) => {
1575
+ const packageIndex = rowIndex + columnIndex * rowCount;
1576
+ const packageName = parameters.packages.at(packageIndex);
1577
+ if (packageName == null) return "";
1578
+ const isLastVisibleColumn = columnIndex === columnCount - 1 || packageIndex + rowCount >= parameters.packages.length;
1579
+ return isLastVisibleColumn ? packageName : packageName.padEnd(columnWidth);
1580
+ }).filter(Boolean).join("")
1581
+ );
1561
1582
  }
1562
- function matchesHashedHost(pattern, needle) {
1563
- if (!pattern.startsWith("|1|")) return false;
1564
- const parts = pattern.split("|");
1565
- if (parts.length !== HASHED_HOST_PARTS || parts[1] !== "1") return false;
1566
- const salt = Buffer.from(parts[2] ?? "", "base64");
1567
- const expectedHash = Buffer.from(parts[3] ?? "", "base64");
1568
- if (salt.length === 0 || expectedHash.length === 0) return false;
1569
- const actualHash = createHmac("sha1", salt).update(needle).digest();
1570
- return actualHash.length === expectedHash.length && timingSafeEqual(actualHash, expectedHash);
1583
+ function getPackageSummaryDetail(packageCount, detail) {
1584
+ const packageLabel = packageCount === 1 ? "1 package" : `${packageCount} packages`;
1585
+ return detail == null ? packageLabel : `${packageLabel} \xB7 ${detail}`;
1571
1586
  }
1572
- function matchesHostPattern(pattern, needle) {
1573
- let state = { needleIndex: 0, patternIndex: 0, starIndex: -1, starNeedleIndex: 0 };
1574
- while (state.needleIndex < needle.length) {
1575
- const nextState = advanceHostPatternMatch(pattern, needle, state);
1576
- if (nextState === null) return false;
1577
- state = nextState;
1578
- }
1579
- while (pattern[state.patternIndex] === "*") state.patternIndex += 1;
1580
- return state.patternIndex === pattern.length;
1587
+ function fitAnimatedModuleLine(line, columns) {
1588
+ if (columns === void 0 || columns < MIN_ANIMATED_LINE_COLUMNS) return line;
1589
+ const plainLine = stripVTControlCharacters(line);
1590
+ if (plainLine.length < columns) return line;
1591
+ return `${plainLine.slice(0, columns - 1)}\u2026`;
1581
1592
  }
1582
- function advanceHostPatternMatch(pattern, needle, state) {
1583
- const patternCharacter = pattern[state.patternIndex];
1584
- if (patternCharacter === "?" || patternCharacter === needle[state.needleIndex]) {
1585
- return { ...state, needleIndex: state.needleIndex + 1, patternIndex: state.patternIndex + 1 };
1586
- }
1587
- if (patternCharacter === "*") {
1593
+ function formatDisplayModule(parameters) {
1594
+ const packageModule = splitPackageModuleName(parameters.name);
1595
+ if (packageModule == null) {
1588
1596
  return {
1589
- ...state,
1590
- patternIndex: state.patternIndex + 1,
1591
- starIndex: state.patternIndex,
1592
- starNeedleIndex: state.needleIndex
1597
+ detail: parameters.detail,
1598
+ detailLines: [],
1599
+ name: parameters.name
1593
1600
  };
1594
1601
  }
1595
- if (state.starIndex === -1) return null;
1596
- const starNeedleIndex = state.starNeedleIndex + 1;
1597
1602
  return {
1598
- ...state,
1599
- needleIndex: starNeedleIndex,
1600
- patternIndex: state.starIndex + 1,
1601
- starNeedleIndex
1603
+ detail: getPackageSummaryDetail(packageModule.packages.length, parameters.detail),
1604
+ detailLines: parameters.status === "waiting" ? [] : getPackageColumns({
1605
+ continuationIndentWidth: parameters.continuationIndentWidth,
1606
+ packages: packageModule.packages,
1607
+ terminalColumns: parameters.terminalColumns
1608
+ }),
1609
+ name: packageModule.summaryName
1602
1610
  };
1603
1611
  }
1604
1612
 
1605
- // src/knownHosts.ts
1606
- var knownHostsLock = Promise.resolve();
1607
- async function withKnownHostsLock(operation) {
1608
- const previous = knownHostsLock;
1609
- const next = previous.then(async () => operation());
1610
- knownHostsLock = next.catch(() => null);
1611
- return next;
1612
- }
1613
- var HostKeyVerificationError = class _HostKeyVerificationError extends Error {
1614
- constructor(message) {
1615
- super(message);
1616
- this.name = "HostKeyVerificationError";
1617
- Error.captureStackTrace(this, _HostKeyVerificationError);
1618
- }
1619
- };
1620
- var KnownHostsValidationError = class _KnownHostsValidationError extends Error {
1621
- constructor(message) {
1622
- super(message);
1623
- this.name = "KnownHostsValidationError";
1624
- Error.captureStackTrace(this, _KnownHostsValidationError);
1613
+ // src/secretSink.ts
1614
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
1615
+ var secretCounts = /* @__PURE__ */ new Map();
1616
+ var runScopedSecretCounts = new AsyncLocalStorage2();
1617
+ var REDACTED_PLACEHOLDER = REDACTED_SECRET_FIELD_PLACEHOLDER;
1618
+ var CIRCULAR_PLACEHOLDER = "[Circular]";
1619
+ function assertRegistrableSecret(secret) {
1620
+ if (secret.includes(REDACTED_PLACEHOLDER)) {
1621
+ throw new Error("Secret registrations must not contain the redaction placeholder");
1625
1622
  }
1626
- };
1627
- var MIN_KNOWN_HOSTS_FIELDS = 3;
1628
- var DEFAULT_SSH_PORT = 22;
1629
- var UINT32_SIZE = 4;
1630
- var STRICT_BASE64_PATTERN = new RegExp("^[A-Za-z0-9+\\/]+=*$", "v");
1631
- var CERT_AUTHORITY_MESSAGE = "HOST KEY VERIFICATION FAILED \u2014 known_hosts contains a @cert-authority entry, but paratix does not validate certificate-authority host keys. Remove the @cert-authority marker or pin the host with ssh.expectedHostFingerprint / ssh.expectedHostPublicKey instead.";
1632
- var defaultHostKeyCache = /* @__PURE__ */ new Map();
1633
- function createHostKeyCache() {
1634
- return /* @__PURE__ */ new Map();
1635
1623
  }
1636
- function parseKnownHostsLine(line) {
1637
- const parts = line.split(new RegExp("\\s+", "v"));
1638
- const offset = parts[0]?.startsWith("@") ? 1 : 0;
1639
- if (parts.length < MIN_KNOWN_HOSTS_FIELDS + offset) return [];
1640
- const marker = offset === 1 ? parts[0] : void 0;
1641
- const hostsPart = parts[offset];
1642
- const algo = parts[offset + 1];
1643
- const base64Key = parts[offset + 2];
1644
- if (!STRICT_BASE64_PATTERN.test(base64Key)) return [];
1645
- const key = Buffer.from(base64Key, "base64");
1646
- const hostPatterns = hostsPart.split(",");
1647
- return hostPatterns.map((host) => ({ algo, host, hostPatterns, key, marker }));
1624
+ var MINIMUM_SECRET_LENGTH = 8;
1625
+ function registerSecret(secret) {
1626
+ if (secret.length < MINIMUM_SECRET_LENGTH) return;
1627
+ assertRegistrableSecret(secret);
1628
+ secretCounts.set(secret, (secretCounts.get(secret) ?? 0) + 1);
1648
1629
  }
1649
- function parseKnownHosts(content) {
1650
- const entries = [];
1651
- for (const raw of content.split("\n")) {
1652
- const line = raw.trim();
1653
- if (line.length === 0 || line.startsWith("#")) continue;
1654
- entries.push(...parseKnownHostsLine(line));
1630
+ function unregisterSecret(secret) {
1631
+ if (secret.length < MINIMUM_SECRET_LENGTH) return;
1632
+ const current = secretCounts.get(secret);
1633
+ if (current == null) return;
1634
+ if (current <= 1) {
1635
+ secretCounts.delete(secret);
1636
+ return;
1655
1637
  }
1656
- return entries;
1657
- }
1658
- function formatHostNeedle(host, port) {
1659
- return port === DEFAULT_SSH_PORT ? host : `[${host}]:${port}`;
1660
- }
1661
- function assertSafeHostForKnownHosts(host) {
1662
- const hostValidationFailure = validateHostLabel(host);
1663
- if (hostValidationFailure == null) return;
1664
- throw new KnownHostsValidationError(
1665
- `Refusing to persist known_hosts entry: host label ${describeHostValidationFailure(hostValidationFailure)}`
1666
- );
1667
- }
1668
- function matchesKnownHostEntry(entry, needle) {
1669
- return matchesKnownHostPatternList(entry.hostPatterns ?? [entry.host], needle);
1670
- }
1671
- function findMatchingEntries(entries, host, port) {
1672
- const needle = formatHostNeedle(host, port);
1673
- return entries.filter((entry) => matchesKnownHostEntry(entry, needle));
1674
- }
1675
- function findRevokedEntry(entries, key) {
1676
- return entries.find(
1677
- (entry) => entry.marker === "@revoked" && entry.key.length === key.length && timingSafeEqual2(entry.key, key)
1678
- );
1679
- }
1680
- function throwHostKeyMismatch(host, presentedKey, existingKey) {
1681
- const presentedAlgo = describeAlgoForDiagnostics(presentedKey);
1682
- const knownHostsDetails = existingKey == null ? "remote host key does not match the key in known_hosts. " : `remote host key (${presentedAlgo}) does not match the key in known_hosts (${describeAlgoForDiagnostics(existingKey)}). `;
1683
- throw new HostKeyVerificationError(
1684
- `HOST KEY VERIFICATION FAILED for ${host}: ${knownHostsDetails}This could indicate a man-in-the-middle attack.`
1685
- );
1638
+ secretCounts.set(secret, current - 1);
1686
1639
  }
1687
- function verifyHostKeyAgainstKnownEntries(parameters) {
1688
- const { cachedKey, fileEntries, host, key } = parameters;
1689
- const revokedKey = findRevokedEntry(fileEntries, key);
1690
- if (revokedKey != null) {
1691
- throw new HostKeyVerificationError(
1692
- `HOST KEY VERIFICATION FAILED for ${host}: remote host key (${describeAlgoForDiagnostics(revokedKey.key)}) is marked as revoked in known_hosts.`
1693
- );
1640
+ async function withRegisteredSecrets(secrets, body) {
1641
+ const registered = [];
1642
+ const registerableSecrets = secrets.filter((secret) => secret.length >= MINIMUM_SECRET_LENGTH);
1643
+ for (const secret of secrets) {
1644
+ assertRegistrableSecret(secret);
1694
1645
  }
1695
- if (fileEntries.some((entry) => entry.marker === "@cert-authority"))
1696
- throw new HostKeyVerificationError(`${host}: ${CERT_AUTHORITY_MESSAGE}`);
1697
- const rawHostKeyEntries = fileEntries.filter((entry) => entry.marker == null);
1698
- const matchingEntry = rawHostKeyEntries.find(
1699
- (entry) => entry.key.length === key.length && timingSafeEqual2(entry.key, key)
1700
- );
1701
- if (matchingEntry != null) return true;
1702
- if (cachedKey?.length === key.length && timingSafeEqual2(cachedKey, key)) {
1703
- return true;
1646
+ try {
1647
+ for (const secret of registerableSecrets) {
1648
+ registerSecret(secret);
1649
+ registered.push(secret);
1650
+ }
1651
+ const result = await body();
1652
+ return maskScopedResult(result, secrets);
1653
+ } catch (error) {
1654
+ throw maskScopedError(error, secrets);
1655
+ } finally {
1656
+ for (const secret of registered) {
1657
+ unregisterSecret(secret);
1658
+ }
1704
1659
  }
1705
- const firstRawHostKey = rawHostKeyEntries.at(0)?.key;
1706
- if (firstRawHostKey != null) throwHostKeyMismatch(host, key, firstRawHostKey);
1707
- if (cachedKey != null) throwHostKeyMismatch(host, key, cachedKey);
1708
- return false;
1709
1660
  }
1710
- function extractAlgoFromKey(keyBuffer) {
1711
- if (keyBuffer.length < UINT32_SIZE) {
1712
- throw new Error("Invalid SSH key buffer: too short to contain algorithm length");
1713
- }
1714
- const algoLength = keyBuffer.readUInt32BE(0);
1715
- if (algoLength === 0 || UINT32_SIZE + algoLength > keyBuffer.length) {
1716
- throw new Error("Invalid SSH key buffer: algorithm length exceeds buffer size");
1661
+ async function withRunScopedSecrets(body) {
1662
+ if (runScopedSecretCounts.getStore() != null) {
1663
+ return body();
1717
1664
  }
1718
- const algo = keyBuffer.subarray(UINT32_SIZE, UINT32_SIZE + algoLength).toString("ascii");
1719
- if (!PINNED_HOST_KEY_ALGORITHM_PATTERN.test(algo)) {
1720
- throw new KnownHostsValidationError(`Invalid SSH key buffer: unsupported algorithm '${algo}'`);
1665
+ const scopedSecrets = /* @__PURE__ */ new Map();
1666
+ try {
1667
+ return await runScopedSecretCounts.run(scopedSecrets, body);
1668
+ } finally {
1669
+ for (const [secret, count] of scopedSecrets) {
1670
+ for (let index = 0; index < count; index += 1) {
1671
+ unregisterSecret(secret);
1672
+ }
1673
+ }
1721
1674
  }
1722
- return algo;
1723
1675
  }
1724
- function describeAlgoForDiagnostics(keyBuffer) {
1725
- try {
1726
- return extractAlgoFromKey(keyBuffer);
1727
- } catch {
1728
- return "<unknown>";
1676
+ function maskScopedResult(result, secrets) {
1677
+ if (secrets.length === 0 || !isModuleResult(result)) return result;
1678
+ const secretList = [...secrets];
1679
+ let next = result;
1680
+ if (result.detail != null) {
1681
+ const maskedDetail = maskSecrets(result.detail, secretList);
1682
+ if (maskedDetail !== result.detail) {
1683
+ next = { ...result, detail: maskedDetail };
1684
+ }
1729
1685
  }
1686
+ if (isModuleResult(next) && next.status === "failed" && next.error != null) {
1687
+ next = { ...next, error: maskScopedError(next.error, secrets) };
1688
+ }
1689
+ return next;
1730
1690
  }
1731
- function computeFingerprint(key) {
1732
- const hash = createHash("sha256").update(key).digest("base64");
1733
- return `SHA256:${hash.replaceAll("=", "")}`;
1691
+ function isModuleResult(value) {
1692
+ return typeof value === "object" && value !== null && "status" in value && typeof value.status === "string";
1734
1693
  }
1735
- async function appendHostKey(host, port, keyBuffer) {
1736
- assertSafeHostForKnownHosts(host);
1737
- const hostLabel = formatHostNeedle(host, port);
1738
- const algo = extractAlgoFromKey(keyBuffer);
1739
- const base64Key = keyBuffer.toString("base64");
1740
- if (new RegExp("\\s", "v").test(hostLabel)) {
1741
- throw new KnownHostsValidationError(
1742
- `Refusing to persist known_hosts entry: host label contains whitespace`
1694
+ function maskCauseValue(cause, secrets, secretList) {
1695
+ if (cause === void 0) return void 0;
1696
+ if (cause instanceof Error) return maskScopedError(cause, secrets);
1697
+ return maskSecrets(stringifyCause(cause, secretList), secretList);
1698
+ }
1699
+ function buildMaskedErrorClone(error, maskedMessage, secretList) {
1700
+ if (error instanceof CommandError) {
1701
+ return new CommandError(
1702
+ maskedMessage,
1703
+ maskSecrets(error.fullStdout, secretList),
1704
+ maskSecrets(error.fullStderr, secretList)
1743
1705
  );
1744
1706
  }
1745
- if (new RegExp("\\s", "v").test(algo)) {
1746
- throw new KnownHostsValidationError(
1747
- `Refusing to persist known_hosts entry: algorithm contains whitespace`
1748
- );
1707
+ const clone = Object.create(Object.getPrototypeOf(error));
1708
+ Object.defineProperty(clone, "message", {
1709
+ configurable: true,
1710
+ value: maskedMessage,
1711
+ writable: true
1712
+ });
1713
+ return clone;
1714
+ }
1715
+ function maskScopedError(error, secrets) {
1716
+ if (!(error instanceof Error) || secrets.length === 0) {
1717
+ return error instanceof Error ? error : new Error(maskSecrets(String(error), [...secrets]));
1749
1718
  }
1750
- if (new RegExp("\\s", "v").test(base64Key)) {
1751
- throw new KnownHostsValidationError(
1752
- `Refusing to persist known_hosts entry: base64 key contains whitespace`
1753
- );
1719
+ const secretList = [...secrets];
1720
+ const maskedMessage = maskSecrets(error.message, secretList);
1721
+ const maskedStack = error.stack == null ? void 0 : maskSecrets(error.stack, secretList);
1722
+ const maskedCause = maskCauseValue(error.cause, secrets, secretList);
1723
+ const clone = buildMaskedErrorClone(error, maskedMessage, secretList);
1724
+ clone.name = error.name;
1725
+ if (maskedStack != null) {
1726
+ Object.defineProperty(clone, "stack", {
1727
+ configurable: true,
1728
+ value: maskedStack,
1729
+ writable: true
1730
+ });
1754
1731
  }
1755
- const line = `${hostLabel} ${algo} ${base64Key}
1756
- `;
1757
- const sshDirectory = join(homedir(), ".ssh");
1758
- const filePath = join(sshDirectory, "known_hosts");
1759
- await withKnownHostsLock(async () => {
1760
- await mkdir(sshDirectory, { mode: 448, recursive: true });
1761
- const existingEntries = await loadKnownHostEntries();
1762
- const matchingRawHostEntries = existingEntries.filter(
1763
- (entry) => entry.marker == null && matchesKnownHostEntry(entry, hostLabel)
1764
- );
1765
- const alreadyTrusted = matchingRawHostEntries.some(
1766
- (entry) => entry.algo === algo && entry.key.length === keyBuffer.length && timingSafeEqual2(entry.key, keyBuffer)
1767
- );
1768
- if (alreadyTrusted) return;
1769
- const conflictingEntry = matchingRawHostEntries.find(
1770
- (entry) => entry.key.length !== keyBuffer.length || !timingSafeEqual2(entry.key, keyBuffer)
1771
- );
1772
- if (conflictingEntry != null) throwHostKeyMismatch(host, keyBuffer, conflictingEntry.key);
1773
- await appendFile(filePath, line, { mode: 384 });
1774
- });
1775
- }
1776
- function getFileSystemErrorCode(error) {
1777
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
1778
- }
1779
- var KIBIBYTE_BYTES = 1024;
1780
- var MEBIBYTE_BYTES = KIBIBYTE_BYTES * KIBIBYTE_BYTES;
1781
- var KNOWN_HOSTS_FILE_LIMIT_MIB = 16;
1782
- var KNOWN_HOSTS_FILE_BYTE_LIMIT = KNOWN_HOSTS_FILE_LIMIT_MIB * MEBIBYTE_BYTES;
1783
- async function loadKnownHostEntries() {
1784
- const filePath = join(homedir(), ".ssh", "known_hosts");
1785
- try {
1786
- const stats = await stat2(filePath);
1787
- if (stats.size > KNOWN_HOSTS_FILE_BYTE_LIMIT) {
1788
- throw new HostKeyVerificationError(
1789
- `Refusing to read known_hosts at ${filePath}: size ${stats.size} bytes exceeds the ${KNOWN_HOSTS_FILE_BYTE_LIMIT}-byte cap`
1790
- );
1791
- }
1792
- return parseKnownHosts(await readFile2(filePath, "utf8"));
1793
- } catch (error) {
1794
- if (error instanceof HostKeyVerificationError) throw error;
1795
- if (getFileSystemErrorCode(error) === "ENOENT") {
1796
- return [];
1797
- }
1798
- throw new HostKeyVerificationError(
1799
- `Could not read known_hosts at ${filePath}: ${String(error)}`
1800
- );
1732
+ if (maskedCause !== void 0) {
1733
+ Object.defineProperty(clone, "cause", {
1734
+ configurable: true,
1735
+ value: maskedCause,
1736
+ writable: true
1737
+ });
1801
1738
  }
1739
+ return clone;
1802
1740
  }
1803
- function formatAcceptedHostKeyWarning(host, key) {
1741
+ function stringifyCause(cause, secretList) {
1742
+ if (typeof cause === "string") return cause;
1743
+ if (typeof cause === "function")
1744
+ return cause.name.length > 0 ? `[Function: ${cause.name}]` : "[Function]";
1745
+ if (typeof cause === "number") return String(cause);
1746
+ if (typeof cause === "boolean") return String(cause);
1747
+ if (typeof cause === "bigint") return String(cause);
1748
+ if (typeof cause === "symbol") return String(cause);
1749
+ if (cause === void 0) return String(cause);
1750
+ if (cause === null) return "null";
1751
+ return stringifyObjectCause(cause, secretList);
1752
+ }
1753
+ function stringifyObjectCause(cause, secretList) {
1804
1754
  try {
1805
- const algo = extractAlgoFromKey(key);
1806
- const fingerprint = computeFingerprint(key);
1807
- return `WARNING: Permanently added '${host}' (${algo}) to the list of known hosts. Fingerprint: ${fingerprint}
1808
- `;
1755
+ const serialized = JSON.stringify(normalizeObjectCause(cause, secretList, /* @__PURE__ */ new WeakSet()));
1756
+ return serialized ?? Object.prototype.toString.call(cause);
1809
1757
  } catch {
1810
- return `WARNING: Permanently added '${host}' to the list of known hosts.
1811
- `;
1758
+ return Object.prototype.toString.call(cause);
1812
1759
  }
1813
1760
  }
1814
- function writeHostKeyPersistFallbackWarning(host, port, error) {
1815
- const keyscanArguments = port === DEFAULT_SSH_PORT ? shellQuote(host) : `-p ${port} ${shellQuote(host)}`;
1816
- process.stderr.write(
1817
- `WARNING: Could not persist host key for ${host} \u2014 the key is cached in memory for this session. To persist it, ensure ~/.ssh/ is writable or run: ssh-keyscan ${keyscanArguments} >> ~/.ssh/known_hosts. ${String(error)}
1818
- `
1819
- );
1820
- }
1821
- async function acceptAndPersistHostKey(location, key, cache) {
1822
- const { host, port } = location;
1823
- const acceptedWarning = formatAcceptedHostKeyWarning(host, key);
1761
+ function normalizeObjectCause(cause, secretList, seen) {
1762
+ if (isBinaryCauseValue(cause)) return REDACTED_PLACEHOLDER;
1763
+ if (cause instanceof Date) return cause.toJSON();
1764
+ if (seen.has(cause)) return CIRCULAR_PLACEHOLDER;
1765
+ seen.add(cause);
1824
1766
  try {
1825
- await appendHostKey(host, port, key);
1826
- } catch (error) {
1827
- if (error instanceof HostKeyVerificationError || error instanceof KnownHostsValidationError) {
1828
- throw error;
1767
+ if (Array.isArray(cause)) {
1768
+ return cause.map((value) => normalizeCausePropertyValue(value, secretList, seen));
1829
1769
  }
1830
- cache.set(formatHostNeedle(host, port), key);
1831
- process.stderr.write(acceptedWarning);
1832
- writeHostKeyPersistFallbackWarning(host, port, error);
1833
- return;
1770
+ const normalized = {};
1771
+ for (const [key, value] of Object.entries(cause)) {
1772
+ if (isSecretDiagnosticField(key)) {
1773
+ normalized[key] = REDACTED_PLACEHOLDER;
1774
+ continue;
1775
+ }
1776
+ normalized[key] = normalizeCausePropertyValue(value, secretList, seen);
1777
+ }
1778
+ return normalized;
1779
+ } finally {
1780
+ seen.delete(cause);
1834
1781
  }
1835
- cache.set(formatHostNeedle(host, port), key);
1836
- process.stderr.write(acceptedWarning);
1837
1782
  }
1838
- var PINNED_HOST_KEY_ALGORITHM_PATTERN = new RegExp("^(?:ssh-ed25519|ssh-rsa|ecdsa-sha2-(?:nistp256|nistp384|nistp521))$", "v");
1839
- function normalizePinnedPublicKey(publicKey) {
1840
- const parts = publicKey.trim().split(new RegExp("\\s+", "v"));
1841
- if (parts.length < 2) {
1842
- throw new Error("Expected host public key must use the format '<algorithm> <base64>'");
1843
- }
1844
- const [algorithm, key] = parts;
1845
- if (!PINNED_HOST_KEY_ALGORITHM_PATTERN.test(algorithm)) {
1846
- throw new Error(
1847
- `Expected host public key uses unsupported algorithm '${algorithm}'. Supported algorithms: ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521.`
1848
- );
1849
- }
1850
- if (!STRICT_BASE64_PATTERN.test(key)) {
1851
- throw new Error(
1852
- "Expected host public key contains invalid base64 in the key field. Use the exact value from `ssh-keygen -y` or the second field of an OpenSSH known_hosts line."
1853
- );
1854
- }
1855
- return `${algorithm} ${key}`;
1783
+ function normalizeCausePropertyValue(value, secretList, seen) {
1784
+ if (value === null) return null;
1785
+ if (typeof value === "bigint") return String(value);
1786
+ if (typeof value !== "object") return value;
1787
+ return normalizeObjectCause(value, secretList, seen);
1856
1788
  }
1857
- function validateExpectedHostPublicKey(publicKey) {
1858
- try {
1859
- normalizePinnedPublicKey(publicKey);
1860
- return null;
1861
- } catch (error) {
1862
- return error instanceof Error ? error.message : String(error);
1863
- }
1789
+ function isBinaryCauseValue(value) {
1790
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
1864
1791
  }
1865
- function formatPresentedPublicKey(key) {
1866
- try {
1867
- return `${extractAlgoFromKey(key)} ${key.toString("base64")}`;
1868
- } catch {
1869
- return null;
1870
- }
1792
+ function getRegisteredSecrets() {
1793
+ return [...secretCounts.keys()];
1871
1794
  }
1872
- function hasPinnedHostTrustAnchor(options) {
1873
- return options?.expectedHostFingerprint != null || options?.expectedHostPublicKey != null;
1795
+ function clearRegisteredSecrets() {
1796
+ secretCounts.clear();
1874
1797
  }
1875
- function verifyPinnedHostKey(host, key, options) {
1876
- const normalizedExpectedPublicKey = options.expectedHostPublicKey == null ? null : normalizePinnedPublicKey(options.expectedHostPublicKey);
1877
- const expectedFingerprint = options.expectedHostFingerprint ?? null;
1878
- const presentedPublicKey = formatPresentedPublicKey(key);
1879
- const presentedFingerprint = computeFingerprint(key);
1880
- const publicKeyMatches = normalizedExpectedPublicKey == null || presentedPublicKey != null && normalizedExpectedPublicKey === presentedPublicKey;
1881
- const fingerprintMatches = expectedFingerprint == null || expectedFingerprint === presentedFingerprint;
1882
- if (publicKeyMatches && fingerprintMatches) {
1883
- return;
1884
- }
1885
- throw new HostKeyVerificationError(
1886
- `HOST KEY VERIFICATION FAILED for ${host}: the remote host key does not match the configured trust anchor.`
1887
- );
1798
+ function maskRegisteredSecrets(text) {
1799
+ if (secretCounts.size === 0) return text;
1800
+ return maskSecrets(text, getRegisteredSecrets());
1888
1801
  }
1889
- async function buildHostVerifier(mode, location, options = {}) {
1890
- const cache = options.cache ?? defaultHostKeyCache;
1891
- const { host, port } = location;
1892
- if (mode === "no" && !hasPinnedHostTrustAnchor(options)) return {};
1893
- const entries = await withKnownHostsLock(loadKnownHostEntries);
1894
- const fileEntries = findMatchingEntries(entries, host, port);
1895
- const cachedKey = cache.get(formatHostNeedle(host, port)) ?? null;
1896
- let acceptedHostKey = null;
1897
- const result = {
1898
- async commitAcceptedHostKey() {
1899
- if (acceptedHostKey != null) await acceptAndPersistHostKey(location, acceptedHostKey, cache);
1900
- },
1901
- hostVerifier(key) {
1902
- if (mode !== "no" && verifyHostKeyAgainstKnownEntries({ cachedKey, fileEntries, host, key })) {
1903
- if (hasPinnedHostTrustAnchor(options)) verifyPinnedHostKey(host, key, options);
1904
- return true;
1905
- }
1906
- if (hasPinnedHostTrustAnchor(options)) {
1907
- verifyPinnedHostKey(host, key, options);
1908
- return true;
1909
- }
1910
- if (mode === "yes") {
1911
- throw new HostKeyVerificationError(
1912
- `Host key for ${host} not found in known_hosts. Set strictHostKeyChecking to "accept-new" for explicit TOFU or configure ssh.expectedHostFingerprint / ssh.expectedHostPublicKey.`
1913
- );
1914
- }
1915
- if (mode === "no") return true;
1916
- acceptedHostKey = Buffer.from(key);
1917
- return true;
1918
- }
1802
+
1803
+ // src/output.ts
1804
+ var CAUSE_INSPECT_DEPTH = 2;
1805
+ var CAUSE_INSPECT_MAX_STRING_LENGTH = 1024;
1806
+ var CAUSE_REDACT_BINARY_MAX_DEPTH = CAUSE_INSPECT_DEPTH + 1;
1807
+ var MODULE_NAME_WIDTH = 56;
1808
+ var MIN_MODULE_NAME_WIDTH = 12;
1809
+ var OUTPUT_INDENT_UNIT = " ";
1810
+ var SPINNER_FRAME_INTERVAL_MS = 80;
1811
+ var ASCII_ESC = 27;
1812
+ var ANSI_HIDE_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25l`;
1813
+ var ANSI_SHOW_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25h`;
1814
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1815
+ var STATUS_ICONS = {
1816
+ changed: pc.yellow("\u21BA"),
1817
+ failed: pc.red("\u2717"),
1818
+ ok: pc.green("\u2713"),
1819
+ skipped: pc.dim("\u2298"),
1820
+ waiting: pc.cyan("\u23F8")
1821
+ };
1822
+ var CLI_HEADER_LINES = [
1823
+ " _ _ ",
1824
+ " | | (_) ",
1825
+ " _ __ __ _ _ __ __ _| |_ ___ __",
1826
+ " | '_ \\ / _` | '__/ _` | __| \\ \\/ /",
1827
+ " | |_) | (_| | | | (_| | |_| |> < ",
1828
+ " | .__/ \\__,_|_| \\__,_|\\__|_/_/\\_\\",
1829
+ " | | ",
1830
+ " |_| "
1831
+ ];
1832
+ var LIVE_OUTPUT_STATE_KEY = /* @__PURE__ */ Symbol.for("paratix.output.liveState");
1833
+ function getSharedLiveOutputState() {
1834
+ const registry = globalThis;
1835
+ const existing = registry[LIVE_OUTPUT_STATE_KEY];
1836
+ if (existing != null) return existing;
1837
+ const created = {
1838
+ activeRecipeGuideDepths: [],
1839
+ activeSpinner: null,
1840
+ cursorHidden: false,
1841
+ pendingRecipeClosureGuideDepths: [],
1842
+ recipeOutputDepth: -1
1919
1843
  };
1920
- return result;
1844
+ registry[LIVE_OUTPUT_STATE_KEY] = created;
1845
+ return created;
1921
1846
  }
1922
-
1923
- // src/serverDefinitionValidation.ts
1924
- var STRICT_HOST_KEY_ERROR = `Invalid property 'ssh.strictHostKeyChecking' (expected "accept-new", "no", or "yes")`;
1925
- var MAX_TCP_PORT = 65535;
1926
- function describeType(value) {
1927
- return value === null ? "null" : typeof value;
1847
+ var liveOutputState = getSharedLiveOutputState();
1848
+ function renderCliHeader(version) {
1849
+ const versionText = pc.dim(`v${version}`);
1850
+ return `${pc.cyan(CLI_HEADER_LINES.join("\n"))}${versionText}
1851
+ `;
1852
+ }
1853
+ function printCliHeader(version) {
1854
+ console.log(renderCliHeader(version));
1855
+ }
1856
+ function supportsAnimatedModuleOutput() {
1857
+ return process.stdout.isTTY && typeof process.stdout.clearLine === "function" && typeof process.stdout.cursorTo === "function";
1858
+ }
1859
+ function getModuleIcon(status, waitingFrame) {
1860
+ return status === "waiting" ? pc.cyan(waitingFrame ?? "|") : STATUS_ICONS[status];
1861
+ }
1862
+ function getModuleStatusText(status) {
1863
+ switch (status) {
1864
+ case "changed": {
1865
+ return pc.yellow(status);
1866
+ }
1867
+ case "failed": {
1868
+ return pc.red(status);
1869
+ }
1870
+ case "ok": {
1871
+ return pc.green(status);
1872
+ }
1873
+ case "skipped": {
1874
+ return pc.dim(status);
1875
+ }
1876
+ case "waiting": {
1877
+ return pc.cyan("running");
1878
+ }
1879
+ }
1928
1880
  }
1929
- function isHostKeyMode(value) {
1930
- return value === "accept-new" || value === "no" || value === "yes";
1881
+ function getCurrentOutputDepth() {
1882
+ return Math.max(liveOutputState.recipeOutputDepth, 0);
1931
1883
  }
1932
- function isRecord(value) {
1933
- return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
1884
+ function getGuideDot(depth) {
1885
+ return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
1934
1886
  }
1935
- function collectOptionalBooleanErrors(object, key, errors) {
1936
- if (!(key in object) || object[key] == null) return;
1937
- if (typeof object[key] !== "boolean") {
1938
- errors.push(
1939
- `Invalid property 'ssh.${key}' (expected boolean, got ${describeType(object[key])})`
1940
- );
1887
+ function buildGuideIndent(baseIndent, options) {
1888
+ const indentCharacters = Array.from(baseIndent);
1889
+ const guideDepths = [
1890
+ ...options?.activeGuideDepths ?? liveOutputState.activeRecipeGuideDepths,
1891
+ ...options?.extraGuideDepths ?? []
1892
+ ];
1893
+ for (const guideDepth of guideDepths) {
1894
+ const guideCharacterIndex = OUTPUT_INDENT_UNIT.length * (guideDepth + 1);
1895
+ if (guideCharacterIndex >= indentCharacters.length) continue;
1896
+ indentCharacters[guideCharacterIndex] = options?.colorize === false ? "\xB7" : getGuideDot(guideDepth);
1941
1897
  }
1898
+ return indentCharacters.join("");
1942
1899
  }
1943
- function collectOptionalNumberErrors(parameters) {
1944
- const { errors, key, object, options } = parameters;
1945
- if (!(key in object) || object[key] == null) return;
1946
- const value = object[key];
1947
- const validatedNumber = validateNumberValue(value);
1948
- if (validatedNumber == null) {
1949
- errors.push(`Invalid property 'ssh.${key}' (expected number, got ${describeType(value)})`);
1950
- return;
1951
- }
1952
- if (options?.integer === true && !Number.isInteger(validatedNumber)) {
1953
- errors.push(`Property 'ssh.${key}' must be an integer`);
1954
- return;
1955
- }
1956
- if (options?.positive === true && validatedNumber <= 0) {
1957
- errors.push(`Property 'ssh.${key}' must be greater than 0`);
1900
+ function clearPendingRecipeClosureGuides() {
1901
+ liveOutputState.pendingRecipeClosureGuideDepths = [];
1902
+ }
1903
+ function getModuleIndent() {
1904
+ if (liveOutputState.recipeOutputDepth < 0) {
1905
+ return OUTPUT_INDENT_UNIT;
1958
1906
  }
1907
+ return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
1959
1908
  }
1960
- function validateNumberValue(value) {
1961
- return typeof value === "number" && Number.isFinite(value) ? value : null;
1909
+ function getRecipeHeaderIndent() {
1910
+ if (liveOutputState.recipeOutputDepth < 0) return "";
1911
+ return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
1962
1912
  }
1963
- function isValidTcpPort(value) {
1964
- return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_TCP_PORT;
1913
+ function getErrorIndent() {
1914
+ return `${getModuleIndent()}\u2502 `;
1965
1915
  }
1966
- function collectOptionalStringErrors(object, key, errors) {
1967
- if (!(key in object) || object[key] == null) return;
1968
- if (typeof object[key] !== "string") {
1969
- errors.push(`Invalid property 'ssh.${key}' (expected string, got ${describeType(object[key])})`);
1970
- return;
1971
- }
1972
- if (object[key].length === 0) {
1973
- errors.push(`Property 'ssh.${key}' must not be an empty string`);
1974
- }
1916
+ function getContinuationIndent() {
1917
+ return `${getModuleIndent()} `;
1975
1918
  }
1976
- function collectPortsErrors(ssh, errors) {
1977
- if (!("ports" in ssh)) {
1978
- errors.push("Missing property 'ssh.ports' (expected array)");
1979
- return;
1980
- }
1981
- if (!Array.isArray(ssh.ports)) {
1982
- errors.push(`Invalid property 'ssh.ports' (expected array, got ${describeType(ssh.ports)})`);
1983
- return;
1984
- }
1985
- if (ssh.ports.length === 0) {
1986
- errors.push("Property 'ssh.ports' must not be empty");
1987
- return;
1988
- }
1989
- for (const [index, port] of ssh.ports.entries()) {
1990
- if (!isValidTcpPort(port)) {
1991
- errors.push(`Property 'ssh.ports[${index}]' must be an integer between 1 and 65535`);
1992
- }
1919
+ async function withRecipeOutputScope(scopedOperation) {
1920
+ liveOutputState.recipeOutputDepth += 1;
1921
+ try {
1922
+ return await scopedOperation();
1923
+ } finally {
1924
+ liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter((depth) => depth !== liveOutputState.recipeOutputDepth);
1925
+ liveOutputState.pendingRecipeClosureGuideDepths = [liveOutputState.recipeOutputDepth];
1926
+ liveOutputState.recipeOutputDepth -= 1;
1993
1927
  }
1994
1928
  }
1995
- function collectRequiredUserErrors(ssh, errors) {
1996
- if (!("user" in ssh)) {
1997
- errors.push("Missing property 'ssh.user' (expected string)");
1998
- return;
1999
- }
2000
- if (typeof ssh.user !== "string") {
2001
- errors.push(`Invalid property 'ssh.user' (expected string, got ${describeType(ssh.user)})`);
2002
- return;
2003
- }
2004
- if (ssh.user.length === 0) {
2005
- errors.push("Property 'ssh.user' must not be an empty string");
2006
- }
1929
+ function renderModuleLine(parameters) {
1930
+ const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters;
1931
+ const baseIndent = getModuleIndent();
1932
+ const indent = buildGuideIndent(baseIndent, { extraGuideDepths });
1933
+ const icon = getModuleIcon(status, waitingFrame);
1934
+ const statusText = getModuleStatusText(status);
1935
+ const detailSuffix = detail == null ? "" : ` ${pc.dim(detail)}`;
1936
+ const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
1937
+ return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
2007
1938
  }
2008
- function collectStrictHostKeyCheckingErrors(ssh, errors) {
2009
- if (!("strictHostKeyChecking" in ssh) || ssh.strictHostKeyChecking == null) return;
2010
- if (typeof ssh.strictHostKeyChecking !== "string" || !isHostKeyMode(ssh.strictHostKeyChecking)) {
2011
- errors.push(STRICT_HOST_KEY_ERROR);
2012
- }
1939
+ function hideCursor() {
1940
+ if (liveOutputState.cursorHidden) return;
1941
+ if (!supportsAnimatedModuleOutput()) return;
1942
+ process.stdout.write(ANSI_HIDE_CURSOR);
1943
+ liveOutputState.cursorHidden = true;
2013
1944
  }
2014
- function collectExpectedHostPublicKeyErrors(ssh, errors) {
2015
- if (typeof ssh.expectedHostPublicKey !== "string") return;
2016
- const error = validateExpectedHostPublicKey(ssh.expectedHostPublicKey);
2017
- if (error != null) errors.push(`Invalid property 'ssh.expectedHostPublicKey': ${error}`);
1945
+ function showCursor() {
1946
+ if (!liveOutputState.cursorHidden) return;
1947
+ process.stdout.write(ANSI_SHOW_CURSOR);
1948
+ liveOutputState.cursorHidden = false;
2018
1949
  }
2019
- function collectOptionalSshFieldErrors(ssh, errors) {
2020
- collectOptionalStringErrors(ssh, "privateKey", errors);
2021
- collectOptionalStringErrors(ssh, "sudoPassword", errors);
2022
- collectOptionalStringErrors(ssh, "expectedHostFingerprint", errors);
2023
- collectOptionalStringErrors(ssh, "expectedHostPublicKey", errors);
2024
- collectExpectedHostPublicKeyErrors(ssh, errors);
2025
- collectOptionalBooleanErrors(ssh, "agentForward", errors);
2026
- collectOptionalBooleanErrors(ssh, "passwordFallback", errors);
2027
- collectOptionalNumberErrors({
2028
- errors,
2029
- key: "reconnectTimeout",
2030
- object: ssh,
2031
- options: { positive: true }
2032
- });
2033
- collectOptionalNumberErrors({
2034
- errors,
2035
- key: "maxReconnectAttempts",
2036
- object: ssh,
2037
- options: { integer: true, positive: true }
2038
- });
2039
- collectStrictHostKeyCheckingErrors(ssh, errors);
1950
+ function writeAnimatedModuleLine(line) {
1951
+ hideCursor();
1952
+ process.stdout.clearLine(0);
1953
+ process.stdout.cursorTo(0);
1954
+ process.stdout.write(fitAnimatedModuleLine(line, process.stdout.columns));
2040
1955
  }
2041
- function collectSshConfigErrors(value) {
2042
- const errors = [];
2043
- if (value === null) {
2044
- errors.push("Invalid property 'ssh' (expected object, got null)");
2045
- return errors;
2046
- }
2047
- if (typeof value !== "object") {
2048
- errors.push(`Invalid property 'ssh' (expected object, got ${describeType(value)})`);
2049
- return errors;
2050
- }
2051
- if (!isRecord(value)) {
2052
- errors.push("Invalid property 'ssh' (expected object, got object)");
2053
- return errors;
1956
+ function stopAnimatedModuleLine(clearCurrentLine = false) {
1957
+ showCursor();
1958
+ if (liveOutputState.activeSpinner == null) return;
1959
+ clearInterval(liveOutputState.activeSpinner.interval);
1960
+ liveOutputState.activeSpinner = null;
1961
+ if (clearCurrentLine && supportsAnimatedModuleOutput()) {
1962
+ process.stdout.clearLine(0);
1963
+ process.stdout.cursorTo(0);
2054
1964
  }
2055
- const ssh = value;
2056
- collectPortsErrors(ssh, errors);
2057
- collectRequiredUserErrors(ssh, errors);
2058
- collectOptionalSshFieldErrors(ssh, errors);
2059
- return errors;
2060
1965
  }
2061
- function normalizeServerDefinitionSshError(error) {
2062
- const mappedErrors = {
2063
- "Missing property 'ssh.user' (expected string)": "ssh.user is required",
2064
- "Property 'ssh.expectedHostFingerprint' must not be an empty string": "ssh.expectedHostFingerprint must not be an empty string",
2065
- "Property 'ssh.expectedHostPublicKey' must not be an empty string": "ssh.expectedHostPublicKey must not be an empty string",
2066
- "Property 'ssh.ports' must not be empty": "ssh.ports must not be empty",
2067
- "Property 'ssh.privateKey' must not be an empty string": "ssh.privateKey must not be an empty string",
2068
- "Property 'ssh.user' must not be an empty string": "ssh.user is required",
2069
- [STRICT_HOST_KEY_ERROR]: "ssh.strictHostKeyChecking must be one of accept-new, no, yes"
2070
- };
2071
- return mappedErrors[error] ?? error;
2072
- }
2073
- function validateSshConfig(ssh) {
2074
- const errors = collectSshConfigErrors(ssh);
2075
- if (errors.length === 0) return;
2076
- throw new Error(`ServerDefinition: ${normalizeServerDefinitionSshError(errors[0])}`);
2077
- }
2078
-
2079
- // src/meta.ts
2080
- var SYSTEM_HOST_KIND = "system.host";
2081
- var SYSTEM_REBOOT_KIND = "system.reboot";
2082
- function hasValidMetaName(name) {
2083
- return typeof name === "string" && name.length > 0;
1966
+ function stopLiveModuleOutput(clearCurrentLine = false) {
1967
+ stopAnimatedModuleLine(clearCurrentLine);
2084
1968
  }
2085
- function isMetaValueType(value) {
2086
- return value === "boolean" || value === "number" || value === "string";
1969
+ function startModuleSpinner(name, detail) {
1970
+ if (!supportsAnimatedModuleOutput()) return;
1971
+ clearPendingRecipeClosureGuides();
1972
+ stopAnimatedModuleLine();
1973
+ const maskedName = maskRegisteredSecrets(name);
1974
+ const maskedDetail = detail == null ? void 0 : maskRegisteredSecrets(detail);
1975
+ const displayModule = formatDisplayModule({
1976
+ continuationIndentWidth: getContinuationIndent().length,
1977
+ detail: maskedDetail,
1978
+ name: maskedName,
1979
+ status: "waiting",
1980
+ terminalColumns: process.stdout.columns
1981
+ });
1982
+ const spinner = {
1983
+ detail: displayModule.detail,
1984
+ frameIndex: 0,
1985
+ interval: setInterval(() => {
1986
+ spinner.frameIndex = (spinner.frameIndex + 1) % SPINNER_FRAMES.length;
1987
+ writeAnimatedModuleLine(
1988
+ renderModuleLine({
1989
+ detail: spinner.detail,
1990
+ name: displayModule.name,
1991
+ status: "waiting",
1992
+ waitingFrame: SPINNER_FRAMES[spinner.frameIndex]
1993
+ })
1994
+ );
1995
+ }, SPINNER_FRAME_INTERVAL_MS)
1996
+ };
1997
+ if (typeof spinner.interval.unref === "function") spinner.interval.unref();
1998
+ liveOutputState.activeSpinner = spinner;
1999
+ writeAnimatedModuleLine(
2000
+ renderModuleLine({
2001
+ detail: displayModule.detail,
2002
+ name: displayModule.name,
2003
+ status: "waiting",
2004
+ waitingFrame: SPINNER_FRAMES[0]
2005
+ })
2006
+ );
2087
2007
  }
2088
- function validateResolvedMetaValue(parameters) {
2089
- const { actualType, declaredValueType, enforceDeclaredValueType, entryKey } = parameters;
2090
- if (!isMetaValueType(actualType)) {
2091
- throw new TypeError(
2092
- `Env meta entry ${JSON.stringify(entryKey)} resolved to typeof ${actualType}, expected boolean, number, or string`
2093
- );
2094
- }
2095
- if (enforceDeclaredValueType && actualType !== declaredValueType) {
2096
- throw new TypeError(
2097
- `Env meta entry ${JSON.stringify(entryKey)} resolved to typeof ${actualType}, expected ${declaredValueType}`
2098
- );
2008
+ function printRecipeHeader(name) {
2009
+ stopAnimatedModuleLine(true);
2010
+ clearPendingRecipeClosureGuides();
2011
+ const header = pc.bold(pc.blue(`[${name}]`));
2012
+ console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
2013
+ if (liveOutputState.recipeOutputDepth >= 0) {
2014
+ liveOutputState.activeRecipeGuideDepths = [...liveOutputState.activeRecipeGuideDepths, liveOutputState.recipeOutputDepth];
2099
2015
  }
2100
2016
  }
2101
- function isRecord2(value) {
2102
- return typeof value === "object" && value !== null;
2017
+ function printRunContext(parameters) {
2018
+ const mode = parameters.dryRun ? pc.yellow("dry-run") : pc.green("apply");
2019
+ const ports = parameters.ports.join(", ");
2020
+ console.log(
2021
+ pc.dim(`Run ${parameters.name} \xB7 host ${parameters.host} \xB7 ports ${ports} \xB7 mode ${mode}`)
2022
+ );
2103
2023
  }
2104
- function assertValidEnvironmentMetaEntry(candidate) {
2105
- if (!hasValidMetaName(candidate.name)) {
2106
- throw new TypeError("Invalid env meta entry: name must be a non-empty string");
2107
- }
2108
- if (typeof candidate.resolve !== "function") {
2109
- throw new TypeError("Invalid env meta entry: resolve must be a function returning a Promise");
2110
- }
2111
- if (candidate.valueType !== "boolean" && candidate.valueType !== "number" && candidate.valueType !== "string") {
2112
- throw new TypeError("Invalid env meta entry: valueType must be boolean, number, or string");
2024
+ function colorizeDiffLine(line) {
2025
+ if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
2026
+ return pc.dim(line);
2113
2027
  }
2028
+ if (line.startsWith("-")) return pc.red(line);
2029
+ if (line.startsWith("+")) return pc.green(line);
2030
+ return pc.dim(line);
2114
2031
  }
2115
- function assertValidSshdPortMetaEntry(candidate) {
2116
- if (!isValidTcpPort(candidate.port)) {
2117
- throw new TypeError("Invalid sshd.port meta entry: port must be an integer between 1 and 65535");
2118
- }
2032
+ function renderDiffLines(diff) {
2033
+ if (diff.trim() === "") return [];
2034
+ const sanitized = sanitizeTerminalText(maskRegisteredSecrets(diff));
2035
+ return sanitized.split("\n").map((line) => `\u2502 ${colorizeDiffLine(line)}`);
2119
2036
  }
2120
- function assertValidSystemHostMetaEntry(candidate) {
2121
- const hostValidationFailure = validateHostLabel(candidate.host);
2122
- if (hostValidationFailure != null) {
2123
- throw new TypeError(
2124
- `Invalid system.host meta entry: host ${describeHostValidationFailure(hostValidationFailure)}`
2125
- );
2037
+ function writeContinuationLine(line, extraGuideDepths, via) {
2038
+ const composed = `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${line}`;
2039
+ if (via === "stdout") {
2040
+ process.stdout.write(`${composed}
2041
+ `);
2042
+ } else {
2043
+ console.log(composed);
2126
2044
  }
2127
2045
  }
2128
- function isEnvironmentMetaEntry(entry) {
2129
- return entry.kind === "env";
2046
+ function writeContinuationBlock(parameters) {
2047
+ for (const detailLine of parameters.detailLines) {
2048
+ writeContinuationLine(pc.dim(detailLine), parameters.extraGuideDepths, parameters.via);
2049
+ }
2050
+ for (const diffLine of parameters.diffLines) {
2051
+ writeContinuationLine(diffLine, parameters.extraGuideDepths, parameters.via);
2052
+ }
2130
2053
  }
2131
- function isSshdPortMetaEntry(entry) {
2132
- return entry.kind === "sshd.port";
2054
+ function printRenderedModuleResult(parameters) {
2055
+ const extraGuideDepths = parameters.extraGuideDepths ?? [];
2056
+ const maskedName = maskRegisteredSecrets(parameters.name);
2057
+ const maskedDetail = parameters.detail == null ? void 0 : maskRegisteredSecrets(parameters.detail);
2058
+ const displayModule = formatDisplayModule({
2059
+ continuationIndentWidth: `${buildGuideIndent(
2060
+ OUTPUT_INDENT_UNIT.repeat(Math.max(getCurrentOutputDepth() + 2, 1)),
2061
+ { extraGuideDepths }
2062
+ )} `.length,
2063
+ detail: maskedDetail,
2064
+ name: maskedName,
2065
+ status: parameters.status,
2066
+ terminalColumns: process.stdout.columns
2067
+ });
2068
+ const line = renderModuleLine({
2069
+ detail: displayModule.detail,
2070
+ extraGuideDepths,
2071
+ name: displayModule.name,
2072
+ status: parameters.status
2073
+ });
2074
+ const diffLines = parameters.diff == null ? [] : renderDiffLines(parameters.diff);
2075
+ const usesSpinner = supportsAnimatedModuleOutput() && liveOutputState.activeSpinner != null;
2076
+ if (usesSpinner) {
2077
+ stopAnimatedModuleLine();
2078
+ writeAnimatedModuleLine(line);
2079
+ process.stdout.write("\n");
2080
+ } else {
2081
+ console.log(line);
2082
+ }
2083
+ writeContinuationBlock({
2084
+ detailLines: displayModule.detailLines,
2085
+ diffLines,
2086
+ extraGuideDepths,
2087
+ via: usesSpinner ? "stdout" : "console"
2088
+ });
2133
2089
  }
2134
- function isSystemHostMetaEntry(entry) {
2135
- return entry.kind === SYSTEM_HOST_KIND;
2090
+ function printModuleResult(name, status, detail, diff) {
2091
+ clearPendingRecipeClosureGuides();
2092
+ printRenderedModuleResult({ detail, diff, name, status });
2136
2093
  }
2137
- function isSystemRebootMetaEntry(entry) {
2138
- return entry.kind === SYSTEM_REBOOT_KIND;
2094
+ function printRecipeModuleResult(name, status, detail, diff) {
2095
+ printRenderedModuleResult({
2096
+ detail,
2097
+ diff,
2098
+ extraGuideDepths: liveOutputState.pendingRecipeClosureGuideDepths,
2099
+ name,
2100
+ status
2101
+ });
2102
+ clearPendingRecipeClosureGuides();
2139
2103
  }
2140
- function assertValidModuleMetaEntry(entry) {
2141
- if (!isRecord2(entry)) {
2142
- throw new TypeError(`Invalid meta entry: expected object, got ${typeof entry}`);
2104
+ function printCommandError(stdout, stderr) {
2105
+ const maskedStdout = sanitizeTerminalText(maskRegisteredSecrets(stdout));
2106
+ const maskedStderr = sanitizeTerminalText(maskRegisteredSecrets(stderr));
2107
+ const lines = [];
2108
+ if (maskedStderr.trim()) {
2109
+ lines.push(...maskedStderr.trim().split("\n"));
2143
2110
  }
2144
- switch (entry.kind) {
2145
- case "env": {
2146
- assertValidEnvironmentMetaEntry(entry);
2147
- return;
2148
- }
2149
- case "sshd.port": {
2150
- assertValidSshdPortMetaEntry(entry);
2151
- return;
2152
- }
2153
- case SYSTEM_HOST_KIND: {
2154
- assertValidSystemHostMetaEntry(entry);
2155
- return;
2111
+ if (maskedStdout.trim()) {
2112
+ lines.push(...maskedStdout.trim().split("\n"));
2113
+ }
2114
+ if (lines.length > 0) {
2115
+ console.error(pc.red(`${getErrorIndent()}Error output:`));
2116
+ for (const line of lines) {
2117
+ console.error(pc.red(`${getErrorIndent()}${line}`));
2156
2118
  }
2157
- case SYSTEM_REBOOT_KIND: {
2158
- return;
2119
+ }
2120
+ }
2121
+ function printVerboseCommandError(stdout, stderr) {
2122
+ const maskedStdout = sanitizeTerminalText(maskRegisteredSecrets(stdout));
2123
+ const maskedStderr = sanitizeTerminalText(maskRegisteredSecrets(stderr));
2124
+ if (maskedStderr.trim()) {
2125
+ console.error(pc.red(`${getErrorIndent()}Full stderr:`));
2126
+ for (const line of maskedStderr.trim().split("\n")) {
2127
+ console.error(pc.red(`${getErrorIndent()}${line}`));
2159
2128
  }
2160
- default: {
2161
- throw new TypeError(`Invalid meta entry kind: ${String(entry.kind)}`);
2129
+ }
2130
+ if (maskedStdout.trim()) {
2131
+ console.error(pc.red(`${getErrorIndent()}Full stdout:`));
2132
+ for (const line of maskedStdout.trim().split("\n")) {
2133
+ console.error(pc.red(`${getErrorIndent()}${line}`));
2162
2134
  }
2163
2135
  }
2164
2136
  }
2165
- function assertValidModuleMetaEntries(entries) {
2166
- if (entries == null) return;
2167
- for (const entry of entries) {
2168
- assertValidModuleMetaEntry(entry);
2137
+ function printVerboseErrorBlock(label, content) {
2138
+ if (!content.trim()) {
2139
+ return;
2169
2140
  }
2170
- }
2171
- var META_ENV_SEGMENT_PATTERN = new RegExp("^[A-Za-z_]\\w*$", "v");
2172
- function isAllowedMetaEnvironmentName(name) {
2173
- if (name.length === 0) return false;
2174
- const segments = name.split(".");
2175
- for (const segment of segments) {
2176
- if (!META_ENV_SEGMENT_PATTERN.test(segment)) return false;
2141
+ console.error(pc.red(`${getErrorIndent()}${label}`));
2142
+ for (const line of content.trim().split("\n")) {
2143
+ console.error(pc.red(`${getErrorIndent()}${line}`));
2177
2144
  }
2178
- return true;
2179
2145
  }
2180
- function assertAllowedEnvironmentMetaName(name) {
2181
- if (!isAllowedMetaEnvironmentName(name)) {
2182
- throw new Error(
2183
- `Invalid env meta name ${JSON.stringify(name)}: expected [A-Za-z_]\\w*(\\.[A-Za-z_]\\w*)*`
2184
- );
2146
+ function getErrorCause(error) {
2147
+ return error.cause;
2148
+ }
2149
+ function printVerboseErrorCause(cause, depth, visitedCauses) {
2150
+ const label = `Cause ${depth}:`;
2151
+ if (cause instanceof Error) {
2152
+ if (visitedCauses.has(cause)) {
2153
+ printVerboseErrorBlock(label, "<cycle detected>");
2154
+ return;
2155
+ }
2156
+ visitedCauses.add(cause);
2157
+ const stack = cause.stack?.trim() ?? "";
2158
+ const stackOrMessage = stack.length > 0 ? stack : String(cause);
2159
+ printVerboseErrorBlock(label, maskRegisteredSecrets(stackOrMessage));
2160
+ const nestedCause = getErrorCause(cause);
2161
+ if (nestedCause !== void 0) {
2162
+ printVerboseErrorCause(nestedCause, depth + 1, visitedCauses);
2163
+ }
2164
+ return;
2185
2165
  }
2186
- if (ENVIRONMENT_FORBIDDEN_KEYS.has(name)) {
2187
- throw new Error(
2188
- `Forbidden env meta entry name: ${JSON.stringify(name)} (reserved JavaScript identifier)`
2189
- );
2166
+ printVerboseErrorBlock(label, maskRegisteredSecrets(formatCauseValue(cause)));
2167
+ }
2168
+ function printVerboseGenericError(error) {
2169
+ const stack = error.stack?.trim() ?? "";
2170
+ const stackOrMessage = stack.length > 0 ? stack : String(error);
2171
+ printVerboseErrorBlock("Full stack:", maskRegisteredSecrets(stackOrMessage));
2172
+ const cause = getErrorCause(error);
2173
+ if (cause !== void 0) {
2174
+ const visitedCauses = /* @__PURE__ */ new WeakSet();
2175
+ visitedCauses.add(error);
2176
+ printVerboseErrorCause(cause, 1, visitedCauses);
2190
2177
  }
2191
2178
  }
2192
- function createMemoizedEnvironmentResolver(entry) {
2193
- let cachedResolution = null;
2194
- const declaredValueType = entry.valueType;
2195
- const enforceDeclaredValueType = entry.valueTypeExplicit !== false;
2196
- const entryKey = entry.name;
2197
- return async () => {
2198
- if (cachedResolution != null) return cachedResolution;
2199
- const pending = entry.resolve().then((resolved) => {
2200
- validateResolvedMetaValue({
2201
- actualType: typeof resolved,
2202
- declaredValueType,
2203
- enforceDeclaredValueType,
2204
- entryKey
2205
- });
2206
- return resolved;
2207
- }).catch((error) => {
2208
- cachedResolution = null;
2209
- throw error;
2210
- });
2211
- cachedResolution = pending;
2212
- return pending;
2213
- };
2179
+ function formatCauseValue(cause) {
2180
+ if (cause instanceof Error) return cause.message;
2181
+ return inspectRedactedDiagnosticValue(cause, {
2182
+ depth: CAUSE_INSPECT_DEPTH,
2183
+ maxStringLength: CAUSE_INSPECT_MAX_STRING_LENGTH,
2184
+ redactMaxDepth: CAUSE_REDACT_BINARY_MAX_DEPTH
2185
+ });
2186
+ }
2187
+ function printCauseChain(error) {
2188
+ const visited = /* @__PURE__ */ new WeakSet();
2189
+ visited.add(error);
2190
+ let cause = getErrorCause(error);
2191
+ while (cause !== void 0) {
2192
+ if (cause instanceof Error) {
2193
+ if (visited.has(cause)) return;
2194
+ visited.add(cause);
2195
+ }
2196
+ console.error(pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`));
2197
+ if (cause instanceof CommandError) {
2198
+ printVerboseCommandError(
2199
+ maskRegisteredSecrets(cause.fullStdout),
2200
+ maskRegisteredSecrets(cause.fullStderr)
2201
+ );
2202
+ }
2203
+ cause = cause instanceof Error ? getErrorCause(cause) : void 0;
2204
+ }
2214
2205
  }
2215
- async function mergeEnvironmentFromMeta(environment, entries) {
2216
- if (entries == null || entries.length === 0) {
2217
- await Promise.resolve();
2218
- return environment;
2206
+ function printCommandFailure(error, verbose) {
2207
+ if (verbose && error instanceof CommandError) {
2208
+ const summaryLine = error.message.split("\n")[0];
2209
+ printCommandError("", maskRegisteredSecrets(summaryLine));
2210
+ printVerboseCommandError(
2211
+ maskRegisteredSecrets(error.fullStdout),
2212
+ maskRegisteredSecrets(error.fullStderr)
2213
+ );
2214
+ return;
2219
2215
  }
2220
- const nextEnvironment = Object.assign(createNullPrototypeEnvironment(), environment);
2221
- for (const entry of entries) {
2222
- if (!isEnvironmentMetaEntry(entry)) continue;
2223
- assertAllowedEnvironmentMetaName(entry.name);
2224
- nextEnvironment[entry.name] = createMemoizedEnvironmentResolver(entry);
2216
+ printCommandError("", maskRegisteredSecrets(String(error)));
2217
+ if (error instanceof Error) {
2218
+ if (!(error instanceof CommandError)) {
2219
+ printCauseChain(error);
2220
+ }
2221
+ if (verbose) {
2222
+ printVerboseGenericError(error);
2223
+ }
2225
2224
  }
2226
- await Promise.resolve();
2227
- return nextEnvironment;
2225
+ }
2226
+ function printSummary(stats) {
2227
+ stopAnimatedModuleLine(true);
2228
+ const parts = [
2229
+ pc.yellow(`${stats.changed} changed`),
2230
+ pc.green(`${stats.ok} ok`),
2231
+ pc.dim(`${stats.skipped} skipped`),
2232
+ stats.failed > 0 ? pc.red(`${stats.failed} failed`) : `${stats.failed} failed`,
2233
+ pc.cyan(`${stats.signals} signals triggered`)
2234
+ ];
2235
+ console.log(`
2236
+ ${parts.join(pc.dim(" \xB7 "))}`);
2228
2237
  }
2229
2238
 
2230
2239
  // src/dryRunRecipe.ts
@@ -2243,109 +2252,601 @@ async function executeDryRunBlockingModule(parameters) {
2243
2252
  const result = childModule._applyDryRun == null ? await childModule.apply(connection, environment) : await childModule._applyDryRun(connection, environment, {
2244
2253
  shutdownSignal: parameters.shutdownSignal
2245
2254
  });
2246
- const nextEnvironment = result.meta == null ? environment : await mergeEnvironmentFromMeta(environment, result.meta);
2247
- const diffOutput = diff ? result.diff : void 0;
2248
- printModuleResult(
2249
- childModule.name,
2250
- result.status,
2251
- result._dryRunDetail ?? "(dry-run)",
2252
- diffOutput
2253
- );
2255
+ const nextEnvironment = result.meta == null ? environment : await mergeEnvironmentFromMeta(environment, result.meta);
2256
+ const diffOutput = diff ? result.diff : void 0;
2257
+ printModuleResult(
2258
+ childModule.name,
2259
+ result.status,
2260
+ result._dryRunDetail ?? "(dry-run)",
2261
+ diffOutput
2262
+ );
2263
+ if (result.status === "failed" && result.error != null) {
2264
+ printCommandFailure(result.error, verbose);
2265
+ }
2266
+ return {
2267
+ env: nextEnvironment,
2268
+ meta: result.meta,
2269
+ shouldBreak: result.status === "failed" || result._stopRun === true,
2270
+ status: result.status,
2271
+ stopRun: result._stopRun
2272
+ };
2273
+ }
2274
+ async function executeDryRunChildModule(parameters) {
2275
+ const { childModule, diff, environment, ssh, verbose } = parameters;
2276
+ if (parameters.shutdownSignal() != null) return { env: environment, shouldBreak: true };
2277
+ const connection = childModule.local === true ? null : ssh;
2278
+ startModuleSpinner(childModule.name);
2279
+ const checkResult = await childModule.check(connection, environment);
2280
+ if (parameters.shutdownSignal() != null) return { env: environment, shouldBreak: true };
2281
+ if (checkResult !== "ok" && shouldExecuteApplyDuringDryRun(childModule, diff)) {
2282
+ return executeDryRunBlockingModule({
2283
+ childModule,
2284
+ connection,
2285
+ diff,
2286
+ environment,
2287
+ shutdownSignal: parameters.shutdownSignal,
2288
+ verbose
2289
+ });
2290
+ }
2291
+ const status = checkResult === "ok" ? "ok" : "changed";
2292
+ const suffix = checkResult === "ok" ? void 0 : "(dry-run)";
2293
+ printModuleResult(childModule.name, status, suffix);
2294
+ return { env: environment, shouldBreak: false, status };
2295
+ }
2296
+ function applyDryRunChildResult(accumulator, result) {
2297
+ return {
2298
+ aggregatedMeta: result.meta == null ? accumulator.aggregatedMeta : [...accumulator.aggregatedMeta, ...result.meta],
2299
+ aggregatedStatus: result.status === "changed" ? "changed" : accumulator.aggregatedStatus,
2300
+ currentEnvironment: result.env
2301
+ };
2302
+ }
2303
+ async function runDryRunChildLoop(parameters) {
2304
+ let accumulator = parameters.accumulator;
2305
+ for (const childModule of parameters.recipeModule._modules) {
2306
+ if (parameters.shutdownSignal() != null) {
2307
+ return interruptedDryRunResult({
2308
+ aggregatedMeta: accumulator.aggregatedMeta,
2309
+ aggregatedStatus: accumulator.aggregatedStatus,
2310
+ currentEnvironment: accumulator.currentEnvironment
2311
+ });
2312
+ }
2313
+ const result = await executeDryRunChildModule({
2314
+ childModule,
2315
+ diff: parameters.diff,
2316
+ environment: accumulator.currentEnvironment,
2317
+ shutdownSignal: parameters.shutdownSignal,
2318
+ ssh: parameters.ssh,
2319
+ verbose: parameters.verbose
2320
+ });
2321
+ if (result.shouldBreak) return result;
2322
+ accumulator = applyDryRunChildResult(accumulator, result);
2323
+ }
2324
+ return accumulator;
2325
+ }
2326
+ async function dryRunRecipeModule(parameters) {
2327
+ return withRecipeOutputScope(async () => {
2328
+ const { environment, recipeModule, ssh } = parameters;
2329
+ printRecipeHeader(recipeModule.name);
2330
+ const shutdownSignal = parameters.shutdownSignal ?? (() => null);
2331
+ const loopResult = await runDryRunChildLoop({
2332
+ accumulator: {
2333
+ aggregatedMeta: [],
2334
+ aggregatedStatus: "ok",
2335
+ currentEnvironment: environment
2336
+ },
2337
+ diff: parameters.options?.diff ?? false,
2338
+ recipeModule,
2339
+ shutdownSignal,
2340
+ ssh,
2341
+ verbose: parameters.options?.verbose ?? false
2342
+ });
2343
+ if ("shouldBreak" in loopResult) return loopResult;
2344
+ return {
2345
+ env: loopResult.currentEnvironment,
2346
+ meta: loopResult.aggregatedMeta.length === 0 ? void 0 : loopResult.aggregatedMeta,
2347
+ shouldBreak: false,
2348
+ status: loopResult.aggregatedStatus
2349
+ };
2350
+ });
2351
+ }
2352
+
2353
+ // src/runnerAbortSignal.ts
2354
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
2355
+ var runnerAbortSignalStorage = new AsyncLocalStorage3();
2356
+ async function withRunnerAbortSignal(signal, body) {
2357
+ return runnerAbortSignalStorage.run(signal, body);
2358
+ }
2359
+ function getRunnerAbortSignal() {
2360
+ return runnerAbortSignalStorage.getStore();
2361
+ }
2362
+
2363
+ // src/signalOrchestration.ts
2364
+ function handleSignalResult(parameters) {
2365
+ const { hooks, result, signalName, verbose } = parameters;
2366
+ printModuleResult(`signal: ${signalName}`, result.status);
2367
+ if (result.status === "failed" && result.error != null) {
2368
+ printCommandFailure(result.error, verbose);
2369
+ }
2370
+ hooks?.onSignalFinished?.(result.status);
2371
+ return result.status === "failed" ? "failed" : "changed";
2372
+ }
2373
+ function handleSignalFailure(parameters) {
2374
+ const { error, hooks, signalName, verbose } = parameters;
2375
+ printModuleResult(`signal: ${signalName}`, "failed");
2376
+ printCommandFailure(error, verbose);
2377
+ hooks?.onSignalFinished?.("failed");
2378
+ return "failed";
2379
+ }
2380
+ async function applySignalMeta(parameters) {
2381
+ assertValidModuleMetaEntries(parameters.result.meta);
2382
+ const nextEnvironment = parameters.result.status === "failed" ? parameters.currentEnvironment : await mergeEnvironmentFromMeta(parameters.currentEnvironment, parameters.result.meta);
2383
+ await parameters.onSignalStep?.({
2384
+ env: nextEnvironment,
2385
+ meta: parameters.result.meta,
2386
+ status: parameters.result.status
2387
+ });
2388
+ return nextEnvironment;
2389
+ }
2390
+ async function runOneSignal(parameters) {
2391
+ const connection = parameters.signal.local === true ? null : parameters.ssh;
2392
+ startModuleSpinner(`signal: ${parameters.signal.name}`);
2393
+ const result = parameters.shutdownSignal == null ? await parameters.signal.apply(connection, parameters.currentEnvironment) : await parameters.signal.apply(connection, parameters.currentEnvironment, {
2394
+ shutdownSignal: parameters.shutdownSignal
2395
+ });
2396
+ const nextEnvironment = await applySignalMeta({
2397
+ currentEnvironment: parameters.currentEnvironment,
2398
+ onSignalStep: parameters.onSignalStep,
2399
+ result
2400
+ });
2401
+ return {
2402
+ nextEnvironment,
2403
+ status: handleSignalResult({
2404
+ hooks: parameters.hooks,
2405
+ result,
2406
+ signalName: parameters.signal.name,
2407
+ verbose: parameters.verbose
2408
+ })
2409
+ };
2410
+ }
2411
+ async function runSignalModules(parameters) {
2412
+ const getShutdownSignal = parameters.shutdownSignal ?? (() => null);
2413
+ const verbose = parameters.verbose ?? false;
2414
+ let currentEnvironment = parameters.environment;
2415
+ let status = "changed";
2416
+ for (const signal of parameters.signals) {
2417
+ if (getShutdownSignal() != null) break;
2418
+ parameters.hooks?.onSignalStarted?.();
2419
+ try {
2420
+ const signalStep = await runOneSignal({
2421
+ currentEnvironment,
2422
+ hooks: parameters.hooks,
2423
+ onSignalStep: parameters.onSignalStep,
2424
+ shutdownSignal: parameters.shutdownSignal,
2425
+ signal,
2426
+ ssh: parameters.ssh,
2427
+ verbose
2428
+ });
2429
+ currentEnvironment = signalStep.nextEnvironment;
2430
+ if (signalStep.status === "failed") status = "failed";
2431
+ } catch (error) {
2432
+ status = handleSignalFailure({
2433
+ error,
2434
+ hooks: parameters.hooks,
2435
+ signalName: signal.name,
2436
+ verbose
2437
+ });
2438
+ }
2439
+ }
2440
+ return status;
2441
+ }
2442
+
2443
+ // src/types.ts
2444
+ var NEEDS_APPLY = "needs-apply";
2445
+
2446
+ // src/recipe.ts
2447
+ var INTERRUPTED_BEFORE_APPLY = /* @__PURE__ */ Symbol("recipe-interrupted-before-apply");
2448
+ function isRecipe(module) {
2449
+ return module.kind === "recipe";
2450
+ }
2451
+ function printRecipeChildResult(module, result) {
2452
+ if (isRecipe(module)) {
2453
+ printRecipeModuleResult(module.name, result.status, result.detail);
2454
+ return;
2455
+ }
2456
+ printModuleResult(module.name, result.status, result.detail);
2457
+ }
2458
+ function applyRecipeStepToState(state, step, preserveControlPlaneMeta) {
2459
+ let stepMeta;
2460
+ if (step.meta == null) {
2461
+ stepMeta = void 0;
2462
+ } else if (preserveControlPlaneMeta) {
2463
+ stepMeta = step.meta;
2464
+ } else {
2465
+ stepMeta = step.meta.filter(isEnvironmentMetaEntry);
2466
+ }
2467
+ const nextMeta = stepMeta == null ? state.meta ?? [] : [...state.meta ?? [], ...stepMeta];
2468
+ let nextStatus = state.status;
2469
+ if (step.status === "failed") nextStatus = "failed";
2470
+ else if (step.status === "changed") nextStatus = "changed";
2471
+ return {
2472
+ env: step.env,
2473
+ meta: nextMeta,
2474
+ signalsPending: step.status === "changed" ? true : state.signalsPending,
2475
+ status: nextStatus,
2476
+ stopRun: step._stopRun === true ? true : state.stopRun
2477
+ };
2478
+ }
2479
+ async function executeOneModule(parameters) {
2480
+ const { currentEnvironment, ssh, targetModule } = parameters;
2481
+ const verbose = parameters.verbose ?? false;
2482
+ const connection = targetModule.local === true ? null : ssh;
2483
+ const checkResult = await checkRecipeChild(targetModule, connection, currentEnvironment);
2484
+ if (checkResult === "ok") {
2485
+ printModuleResult(targetModule.name, "ok");
2486
+ return null;
2487
+ }
2488
+ if ((parameters.shutdownSignal?.() ?? null) != null) {
2489
+ return INTERRUPTED_BEFORE_APPLY;
2490
+ }
2491
+ const result = await applyRecipeChild({
2492
+ connection,
2493
+ currentEnvironment,
2494
+ onChildStep: parameters.onChildStep,
2495
+ onSignalStep: parameters.onSignalStep,
2496
+ shutdownSignal: parameters.shutdownSignal,
2497
+ signalHooks: parameters.signalHooks,
2498
+ targetModule,
2499
+ verbose
2500
+ });
2501
+ printRecipeChildResult(targetModule, result);
2254
2502
  if (result.status === "failed" && result.error != null) {
2255
2503
  printCommandFailure(result.error, verbose);
2256
2504
  }
2505
+ const environment = result.status === "failed" ? currentEnvironment : await mergeEnvironmentFromMeta(currentEnvironment, result.meta);
2257
2506
  return {
2258
- env: nextEnvironment,
2507
+ _flushSignals: result._flushSignals,
2508
+ _stopRun: result._stopRun,
2509
+ env: environment,
2259
2510
  meta: result.meta,
2260
- shouldBreak: result.status === "failed" || result._stopRun === true,
2261
- status: result.status,
2262
- stopRun: result._stopRun
2511
+ status: result.status
2263
2512
  };
2264
2513
  }
2265
- async function executeDryRunChildModule(parameters) {
2266
- const { childModule, diff, environment, ssh, verbose } = parameters;
2267
- if (parameters.shutdownSignal() != null) return { env: environment, shouldBreak: true };
2268
- const connection = childModule.local === true ? null : ssh;
2269
- startModuleSpinner(childModule.name);
2270
- const checkResult = await childModule.check(connection, environment);
2271
- if (parameters.shutdownSignal() != null) return { env: environment, shouldBreak: true };
2272
- if (checkResult !== "ok" && shouldExecuteApplyDuringDryRun(childModule, diff)) {
2273
- return executeDryRunBlockingModule({
2274
- childModule,
2275
- connection,
2276
- diff,
2277
- environment,
2514
+ async function checkRecipeChild(targetModule, connection, currentEnvironment) {
2515
+ startModuleSpinner(targetModule.name);
2516
+ return targetModule.check(connection, currentEnvironment);
2517
+ }
2518
+ async function applyRecipeChild(parameters) {
2519
+ if (isRecipe(parameters.targetModule)) {
2520
+ return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment, {
2521
+ onChildStep: parameters.onChildStep,
2522
+ onSignalStep: parameters.onSignalStep,
2278
2523
  shutdownSignal: parameters.shutdownSignal,
2279
- verbose
2524
+ signalHooks: parameters.signalHooks,
2525
+ verbose: parameters.verbose
2280
2526
  });
2281
2527
  }
2282
- const status = checkResult === "ok" ? "ok" : "changed";
2283
- const suffix = checkResult === "ok" ? void 0 : "(dry-run)";
2284
- printModuleResult(childModule.name, status, suffix);
2285
- return { env: environment, shouldBreak: false, status };
2528
+ if (parameters.targetModule._supportsChildStepHook === true && (parameters.onChildStep != null || parameters.shutdownSignal != null)) {
2529
+ return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment, {
2530
+ onChildStep: parameters.onChildStep,
2531
+ shutdownSignal: parameters.shutdownSignal
2532
+ });
2533
+ }
2534
+ return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment);
2286
2535
  }
2287
- function applyDryRunChildResult(accumulator, result) {
2536
+ async function applyExecutedRecipeStep(parameters) {
2537
+ if (parameters.step == null) return parameters.state;
2538
+ if (parameters.step === INTERRUPTED_BEFORE_APPLY) return null;
2539
+ if (parameters.onChildStep != null) {
2540
+ await parameters.onChildStep(parameters.step);
2541
+ }
2542
+ return applyRecipeStepToState(
2543
+ parameters.state,
2544
+ parameters.step,
2545
+ parameters.preserveControlPlaneMeta
2546
+ );
2547
+ }
2548
+ function failedRecipeState(environment) {
2288
2549
  return {
2289
- aggregatedMeta: result.meta == null ? accumulator.aggregatedMeta : [...accumulator.aggregatedMeta, ...result.meta],
2290
- aggregatedStatus: result.status === "changed" ? "changed" : accumulator.aggregatedStatus,
2291
- currentEnvironment: result.env
2550
+ env: environment,
2551
+ meta: void 0,
2552
+ signalsPending: false,
2553
+ status: "failed"
2292
2554
  };
2293
2555
  }
2294
- async function runDryRunChildLoop(parameters) {
2295
- let accumulator = parameters.accumulator;
2296
- for (const childModule of parameters.recipeModule._modules) {
2556
+ function annotateRecipeChildError(moduleName, error) {
2557
+ const prefix = `[${moduleName}] `;
2558
+ if (error instanceof CommandError) {
2559
+ return new CommandError(`${prefix}${error.message}`, error.fullStdout, error.fullStderr);
2560
+ }
2561
+ if (error instanceof Error) {
2562
+ return new Error(`${prefix}${error.message}`);
2563
+ }
2564
+ return new Error(`${prefix}${String(error)}`);
2565
+ }
2566
+ async function executeRecipeChildStep(parameters) {
2567
+ try {
2568
+ return { kind: "step", step: await executeOneModule(parameters) };
2569
+ } catch (error) {
2297
2570
  if (parameters.shutdownSignal() != null) {
2298
- return interruptedDryRunResult({
2299
- aggregatedMeta: accumulator.aggregatedMeta,
2300
- aggregatedStatus: accumulator.aggregatedStatus,
2301
- currentEnvironment: accumulator.currentEnvironment
2302
- });
2571
+ return { kind: "step", step: INTERRUPTED_BEFORE_APPLY };
2303
2572
  }
2304
- const result = await executeDryRunChildModule({
2305
- childModule,
2306
- diff: parameters.diff,
2307
- environment: accumulator.currentEnvironment,
2573
+ printModuleResult(parameters.targetModule.name, "failed");
2574
+ printCommandFailure(error, parameters.verbose);
2575
+ return { kind: "failed", state: failedRecipeState(parameters.currentEnvironment) };
2576
+ }
2577
+ }
2578
+ async function processRecipeStep(parameters) {
2579
+ if (parameters.step.kind === "failed") {
2580
+ return { kind: "break", state: parameters.step.state };
2581
+ }
2582
+ const nextState = await applyExecutedRecipeStep({
2583
+ onChildStep: parameters.onChildStep,
2584
+ preserveControlPlaneMeta: parameters.preserveControlPlaneMeta,
2585
+ state: parameters.state,
2586
+ step: parameters.step.step
2587
+ });
2588
+ if (nextState == null) {
2589
+ return { kind: "break", state: parameters.state };
2590
+ }
2591
+ let state = nextState;
2592
+ if (shouldFlushRecipeSignals(parameters.step.step, state, parameters.signals)) {
2593
+ const signalStatus = await flushPendingRecipeSignals({
2594
+ environment: state.env,
2595
+ onSignalStep: parameters.onSignalStep,
2308
2596
  shutdownSignal: parameters.shutdownSignal,
2597
+ signalHooks: parameters.signalHooks,
2598
+ signals: parameters.signals,
2309
2599
  ssh: parameters.ssh,
2310
2600
  verbose: parameters.verbose
2311
2601
  });
2312
- if (result.shouldBreak) return result;
2313
- accumulator = applyDryRunChildResult(accumulator, result);
2602
+ state = applyRecipeSignalStatus(state, signalStatus);
2314
2603
  }
2315
- return accumulator;
2604
+ if (state.status === "failed" || state.stopRun === true) {
2605
+ return { kind: "break", state };
2606
+ }
2607
+ return { kind: "continue", state };
2316
2608
  }
2317
- async function dryRunRecipeModule(parameters) {
2318
- return withRecipeOutputScope(async () => {
2319
- const { environment, recipeModule, ssh } = parameters;
2320
- printRecipeHeader(recipeModule.name);
2321
- const shutdownSignal = parameters.shutdownSignal ?? (() => null);
2322
- const loopResult = await runDryRunChildLoop({
2323
- accumulator: {
2324
- aggregatedMeta: [],
2325
- aggregatedStatus: "ok",
2326
- currentEnvironment: environment
2327
- },
2328
- diff: parameters.options?.diff ?? false,
2329
- recipeModule,
2609
+ async function executeModules(modules, ssh, parameters) {
2610
+ const onChildStep = parameters.onChildStep;
2611
+ const preserveControlPlaneMeta = onChildStep == null;
2612
+ const shutdownSignal = parameters.shutdownSignal ?? (() => null);
2613
+ const verbose = parameters.verbose ?? false;
2614
+ let state = {
2615
+ // R-0000079: preserve the null-prototype guarantee that
2616
+ // R-0000069/R-0000070/R-0000074 establish for the runner-level
2617
+ // environment. Plain object spread (`{ ...environment }`) would create a
2618
+ // map with `Object.prototype`, exposing the first child module of every
2619
+ // recipe to a polluted-fähige environment. Mirror the meta.ts:214
2620
+ // approach.
2621
+ env: Object.assign(createNullPrototypeEnvironment(), parameters.environment),
2622
+ meta: void 0,
2623
+ signalsPending: false,
2624
+ status: "ok"
2625
+ };
2626
+ for (const currentModule of modules) {
2627
+ if (shutdownSignal() != null) break;
2628
+ const step = await executeRecipeChildStep({
2629
+ currentEnvironment: state.env,
2630
+ onChildStep,
2631
+ onSignalStep: parameters.onSignalStep,
2330
2632
  shutdownSignal,
2633
+ signalHooks: parameters.signalHooks,
2331
2634
  ssh,
2332
- verbose: parameters.options?.verbose ?? false
2635
+ targetModule: currentModule,
2636
+ verbose
2333
2637
  });
2334
- if ("shouldBreak" in loopResult) return loopResult;
2638
+ const processedStep = await processRecipeStep({
2639
+ onChildStep,
2640
+ onSignalStep: parameters.onSignalStep,
2641
+ preserveControlPlaneMeta,
2642
+ shutdownSignal,
2643
+ signalHooks: parameters.signalHooks,
2644
+ signals: parameters.signals,
2645
+ ssh,
2646
+ state,
2647
+ step,
2648
+ verbose
2649
+ });
2650
+ state = processedStep.state;
2651
+ if (processedStep.kind === "break") return state;
2652
+ }
2653
+ return state;
2654
+ }
2655
+ async function triggerSignals(parameters) {
2656
+ return runSignalModules({
2657
+ environment: parameters.environment,
2658
+ hooks: parameters.signalHooks,
2659
+ onSignalStep: parameters.onSignalStep,
2660
+ shutdownSignal: parameters.shutdownSignal,
2661
+ signals: parameters.signals,
2662
+ ssh: parameters.ssh,
2663
+ verbose: parameters.verbose
2664
+ });
2665
+ }
2666
+ function shouldFlushRecipeSignals(step, state, signals) {
2667
+ return step != null && step !== INTERRUPTED_BEFORE_APPLY && step._flushSignals === true && state.signalsPending && signals != null;
2668
+ }
2669
+ function applyRecipeSignalStatus(state, signalStatus) {
2670
+ return {
2671
+ ...state,
2672
+ signalsPending: false,
2673
+ status: signalStatus === "failed" ? "failed" : state.status
2674
+ };
2675
+ }
2676
+ async function flushPendingRecipeSignals(parameters) {
2677
+ return triggerSignals(parameters);
2678
+ }
2679
+ async function applyRecipe(parameters) {
2680
+ return withRecipeOutputScope(async () => {
2681
+ const shutdownSignal = parameters.options?.shutdownSignal;
2682
+ const verbose = parameters.options?.verbose ?? false;
2683
+ printRecipeHeader(parameters.name);
2684
+ const state = await executeModules(parameters.modules, parameters.ssh, {
2685
+ environment: parameters.environment,
2686
+ onChildStep: parameters.options?.onChildStep,
2687
+ onSignalStep: parameters.options?.onSignalStep,
2688
+ shutdownSignal,
2689
+ signalHooks: parameters.options?.signalHooks,
2690
+ signals: parameters.signals,
2691
+ verbose
2692
+ });
2693
+ if (shouldRunRecipeSignalsAtEnd(state, parameters.signals)) {
2694
+ state.status = await triggerSignals({
2695
+ environment: state.env,
2696
+ onSignalStep: parameters.options?.onSignalStep,
2697
+ shutdownSignal,
2698
+ signalHooks: parameters.options?.signalHooks,
2699
+ signals: parameters.signals,
2700
+ ssh: parameters.ssh,
2701
+ verbose
2702
+ });
2703
+ }
2335
2704
  return {
2336
- env: loopResult.currentEnvironment,
2337
- meta: loopResult.aggregatedMeta.length === 0 ? void 0 : loopResult.aggregatedMeta,
2338
- shouldBreak: false,
2339
- status: loopResult.aggregatedStatus
2705
+ _stopRun: state.stopRun,
2706
+ meta: state.meta,
2707
+ status: state.status
2340
2708
  };
2341
2709
  });
2342
2710
  }
2711
+ function shouldRunRecipeSignalsAtEnd(state, signals) {
2712
+ return state.signalsPending && state.status === "changed" && signals != null;
2713
+ }
2714
+ function shouldExecuteRecipeDryRun(module) {
2715
+ return module._applyDryRun != null || module._dryRunBlocker === true || module._dryRunMetaProducer === true;
2716
+ }
2717
+ function recipeNeedsDryRunApply(modules) {
2718
+ return modules.some((module) => shouldExecuteRecipeDryRun(module));
2719
+ }
2720
+ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
2721
+ if (!needsDryRunApply) return void 0;
2722
+ return async (ssh, environment, parameters) => {
2723
+ const result = await dryRunRecipeModule({
2724
+ environment,
2725
+ recipeModule: {
2726
+ _modules: modules,
2727
+ async apply() {
2728
+ await Promise.resolve();
2729
+ return { status: "ok" };
2730
+ },
2731
+ async check() {
2732
+ await Promise.resolve();
2733
+ return "ok";
2734
+ },
2735
+ kind: "recipe",
2736
+ name
2737
+ },
2738
+ shutdownSignal: parameters?.shutdownSignal,
2739
+ ssh
2740
+ });
2741
+ return {
2742
+ _stopRun: result.stopRun,
2743
+ meta: result.meta,
2744
+ status: result.status ?? "ok"
2745
+ };
2746
+ };
2747
+ }
2748
+ function recipe(name, modules, options) {
2749
+ const needsDryRunApply = recipeNeedsDryRunApply(modules);
2750
+ const applyDryRun = createRecipeDryRunApply(name, modules, needsDryRunApply);
2751
+ return {
2752
+ ...modules.some((module) => module._dryRunBlocker === true) ? { _dryRunBlocker: true } : {},
2753
+ ...modules.some((module) => module._dryRunMetaProducer === true) ? { _dryRunMetaProducer: true } : {},
2754
+ ...applyDryRun == null ? {} : { _applyDryRun: applyDryRun },
2755
+ _modules: modules,
2756
+ _signals: options?.signals,
2757
+ _supportsChildStepHook: true,
2758
+ async apply(ssh, environment, parameters) {
2759
+ return applyRecipe({
2760
+ environment,
2761
+ modules,
2762
+ name,
2763
+ options: parameters,
2764
+ signals: options?.signals,
2765
+ ssh
2766
+ });
2767
+ },
2768
+ async check(ssh, environment) {
2769
+ for (const childModule of modules) {
2770
+ if (getRunnerAbortSignal()?.aborted === true) return NEEDS_APPLY;
2771
+ const connection = childModule.local === true ? null : ssh;
2772
+ let result;
2773
+ try {
2774
+ result = await childModule.check(connection, environment);
2775
+ } catch (error) {
2776
+ throw annotateRecipeChildError(childModule.name, error);
2777
+ }
2778
+ if (result === NEEDS_APPLY) return NEEDS_APPLY;
2779
+ }
2780
+ return "ok";
2781
+ },
2782
+ kind: "recipe",
2783
+ name
2784
+ };
2785
+ }
2343
2786
 
2344
- // src/runnerAbortSignal.ts
2345
- import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
2346
- var runnerAbortSignalStorage = new AsyncLocalStorage3();
2347
- async function withRunnerAbortSignal(signal, body) {
2348
- return runnerAbortSignalStorage.run(signal, body);
2787
+ // src/moduleFilter.ts
2788
+ var SKIP_DRY_RUN_DETAIL = "filtered out";
2789
+ function parseFilterNames(rawValues) {
2790
+ const names = [];
2791
+ const seen = /* @__PURE__ */ new Set();
2792
+ for (const rawValue of rawValues) {
2793
+ for (const part of rawValue.split(",")) {
2794
+ const name = part.trim();
2795
+ if (name === "" || seen.has(name)) continue;
2796
+ seen.add(name);
2797
+ names.push(name);
2798
+ }
2799
+ }
2800
+ return names;
2801
+ }
2802
+ function collectModuleNames(modules) {
2803
+ const names = /* @__PURE__ */ new Set();
2804
+ const visit = (module) => {
2805
+ names.add(module.name);
2806
+ if (isRecipe(module)) {
2807
+ for (const child of module._modules) visit(child);
2808
+ }
2809
+ };
2810
+ for (const module of modules) visit(module);
2811
+ return names;
2812
+ }
2813
+ function subtreeHasFilterMatch(module, filter) {
2814
+ if (filter.has(module.name)) return true;
2815
+ if (isRecipe(module)) {
2816
+ return module._modules.some((child) => subtreeHasFilterMatch(child, filter));
2817
+ }
2818
+ return false;
2819
+ }
2820
+ function createSkipModule(name) {
2821
+ return {
2822
+ async _applyDryRun() {
2823
+ await Promise.resolve();
2824
+ return { _dryRunDetail: SKIP_DRY_RUN_DETAIL, status: "skipped" };
2825
+ },
2826
+ _dryRunBlocker: true,
2827
+ async apply() {
2828
+ await Promise.resolve();
2829
+ return { status: "skipped" };
2830
+ },
2831
+ async check() {
2832
+ await Promise.resolve();
2833
+ return NEEDS_APPLY;
2834
+ },
2835
+ local: true,
2836
+ name
2837
+ };
2838
+ }
2839
+ function filterNode(module, filter, ancestorSelected) {
2840
+ const selfSelected = ancestorSelected || filter.has(module.name);
2841
+ if (selfSelected) return module;
2842
+ if (isRecipe(module) && subtreeHasFilterMatch(module, filter)) {
2843
+ const filteredChildren = module._modules.map((child) => filterNode(child, filter, false));
2844
+ return recipe(module.name, filteredChildren, { signals: module._signals });
2845
+ }
2846
+ return createSkipModule(module.name);
2847
+ }
2848
+ function applyModuleFilter(modules, filter) {
2849
+ return modules.map((module) => filterNode(module, filter, false));
2349
2850
  }
2350
2851
 
2351
2852
  // src/runnerHelpers.ts
@@ -2421,88 +2922,8 @@ function readActiveBus() {
2421
2922
  const value = Reflect.get(globalThis, ACTIVE_BUS_KEY);
2422
2923
  return isSignalBus(value) ? value : void 0;
2423
2924
  }
2424
- function getSignalBus() {
2425
- return readActiveBus() ?? defaultProcessSignalBus;
2426
- }
2427
-
2428
- // src/signalOrchestration.ts
2429
- function handleSignalResult(parameters) {
2430
- const { hooks, result, signalName, verbose } = parameters;
2431
- printModuleResult(`signal: ${signalName}`, result.status);
2432
- if (result.status === "failed" && result.error != null) {
2433
- printCommandFailure(result.error, verbose);
2434
- }
2435
- hooks?.onSignalFinished?.(result.status);
2436
- return result.status === "failed" ? "failed" : "changed";
2437
- }
2438
- function handleSignalFailure(parameters) {
2439
- const { error, hooks, signalName, verbose } = parameters;
2440
- printModuleResult(`signal: ${signalName}`, "failed");
2441
- printCommandFailure(error, verbose);
2442
- hooks?.onSignalFinished?.("failed");
2443
- return "failed";
2444
- }
2445
- async function applySignalMeta(parameters) {
2446
- assertValidModuleMetaEntries(parameters.result.meta);
2447
- const nextEnvironment = parameters.result.status === "failed" ? parameters.currentEnvironment : await mergeEnvironmentFromMeta(parameters.currentEnvironment, parameters.result.meta);
2448
- await parameters.onSignalStep?.({
2449
- env: nextEnvironment,
2450
- meta: parameters.result.meta,
2451
- status: parameters.result.status
2452
- });
2453
- return nextEnvironment;
2454
- }
2455
- async function runOneSignal(parameters) {
2456
- const connection = parameters.signal.local === true ? null : parameters.ssh;
2457
- startModuleSpinner(`signal: ${parameters.signal.name}`);
2458
- const result = parameters.shutdownSignal == null ? await parameters.signal.apply(connection, parameters.currentEnvironment) : await parameters.signal.apply(connection, parameters.currentEnvironment, {
2459
- shutdownSignal: parameters.shutdownSignal
2460
- });
2461
- const nextEnvironment = await applySignalMeta({
2462
- currentEnvironment: parameters.currentEnvironment,
2463
- onSignalStep: parameters.onSignalStep,
2464
- result
2465
- });
2466
- return {
2467
- nextEnvironment,
2468
- status: handleSignalResult({
2469
- hooks: parameters.hooks,
2470
- result,
2471
- signalName: parameters.signal.name,
2472
- verbose: parameters.verbose
2473
- })
2474
- };
2475
- }
2476
- async function runSignalModules(parameters) {
2477
- const getShutdownSignal = parameters.shutdownSignal ?? (() => null);
2478
- const verbose = parameters.verbose ?? false;
2479
- let currentEnvironment = parameters.environment;
2480
- let status = "changed";
2481
- for (const signal of parameters.signals) {
2482
- if (getShutdownSignal() != null) break;
2483
- parameters.hooks?.onSignalStarted?.();
2484
- try {
2485
- const signalStep = await runOneSignal({
2486
- currentEnvironment,
2487
- hooks: parameters.hooks,
2488
- onSignalStep: parameters.onSignalStep,
2489
- shutdownSignal: parameters.shutdownSignal,
2490
- signal,
2491
- ssh: parameters.ssh,
2492
- verbose
2493
- });
2494
- currentEnvironment = signalStep.nextEnvironment;
2495
- if (signalStep.status === "failed") status = "failed";
2496
- } catch (error) {
2497
- status = handleSignalFailure({
2498
- error,
2499
- hooks: parameters.hooks,
2500
- signalName: signal.name,
2501
- verbose
2502
- });
2503
- }
2504
- }
2505
- return status;
2925
+ function getSignalBus() {
2926
+ return readActiveBus() ?? defaultProcessSignalBus;
2506
2927
  }
2507
2928
 
2508
2929
  // src/ssh.ts
@@ -3044,48 +3465,36 @@ function expandHomePath(path) {
3044
3465
  if (path.startsWith("~/")) return join3(homedir2(), path.slice(2));
3045
3466
  return path;
3046
3467
  }
3047
- function resolveWriteFileMode(remotePath, options) {
3048
- if (options?.mode == null) {
3049
- throw new Error(
3050
- `[ssh.writeFile: ${remotePath}] missing options.mode; pass { mode: "0644" } or another explicit file mode`
3051
- );
3052
- }
3468
+ var DEFAULT_FILE_MODE = "0600";
3469
+ function resolveFileMode(operation, remotePath, options) {
3470
+ const mode = options?.mode ?? DEFAULT_FILE_MODE;
3053
3471
  try {
3054
- validateMode(options.mode);
3472
+ validateMode(mode);
3055
3473
  } catch (error) {
3056
3474
  const reason = error instanceof Error ? error.message : String(error);
3057
- throw new Error(
3058
- `[ssh.writeFile: ${remotePath}] invalid options.mode "${options.mode}": ${reason}`,
3059
- { cause: error }
3060
- );
3475
+ throw new Error(`[ssh.${operation}: ${remotePath}] invalid options.mode "${mode}": ${reason}`, {
3476
+ cause: error
3477
+ });
3061
3478
  }
3062
- return options.mode;
3479
+ return mode;
3063
3480
  }
3064
3481
  var COMMAND_TIMEOUT = 12e4;
3065
- var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
3066
3482
  var DEFAULT_RECONNECT_TIMEOUT = 12e4;
3483
+ function resolveReconnectBudget(config, defaultTimeout) {
3484
+ return {
3485
+ maxAttempts: config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY,
3486
+ timeout: config.reconnectTimeout ?? defaultTimeout ?? DEFAULT_RECONNECT_TIMEOUT
3487
+ };
3488
+ }
3067
3489
  var EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
3068
3490
  var DISCONNECT_DESTROY_FALLBACK_MS = 5e3;
3069
3491
  var JITTER_BASE = 0.75;
3070
3492
  var JITTER_RANGE = 0.5;
3071
3493
  var RECONNECT_BASE_DELAY = 1e3;
3072
3494
  var RECONNECT_MAX_DELAY = 3e4;
3073
- var RAW_OUTPUT_ERROR_SNIPPET_LENGTH = 500;
3074
3495
  function getAbortReason(signal) {
3075
3496
  return signal.reason instanceof Error ? signal.reason : new Error("SSH operation aborted");
3076
3497
  }
3077
- function truncateRawOutputErrorSnippet(text) {
3078
- let count = 0;
3079
- let sliceEnd = 0;
3080
- for (const char of text) {
3081
- if (count >= RAW_OUTPUT_ERROR_SNIPPET_LENGTH) {
3082
- return `${text.slice(0, sliceEnd)}\u2026(truncated)`;
3083
- }
3084
- sliceEnd += char.length;
3085
- count++;
3086
- }
3087
- return text;
3088
- }
3089
3498
  function toError(error) {
3090
3499
  return error instanceof Error ? error : new Error(String(error));
3091
3500
  }
@@ -3363,9 +3772,8 @@ var SshConnectionImpl = class {
3363
3772
  }
3364
3773
  return result.stdout;
3365
3774
  }
3366
- async reconnect() {
3367
- const timeout = this.config.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT;
3368
- const maxAttempts = this.config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
3775
+ async reconnect(options) {
3776
+ const { maxAttempts, timeout } = resolveReconnectBudget(this.config, options?.defaultTimeout);
3369
3777
  const deadline = Date.now() + timeout;
3370
3778
  let attempt = 0;
3371
3779
  while (Date.now() < deadline && attempt < maxAttempts) {
@@ -3407,13 +3815,25 @@ var SshConnectionImpl = class {
3407
3815
  updateHost(host) {
3408
3816
  this.runtime.host = host;
3409
3817
  }
3818
+ /**
3819
+ * Upload a local file to the remote host via SFTP, staged to a temp file and
3820
+ * finalized atomically with `mv`.
3821
+ *
3822
+ * @param localPath - Path to the local upload source.
3823
+ * @param remotePath - Destination path on the remote host.
3824
+ * @param options - Optional settings for the remote upload.
3825
+ * @param options.mode - File mode to set via `chmod` on the temp file before
3826
+ * moving (e.g. `"0644"`). Optional; defaults to `"0600"` when omitted, matching
3827
+ * {@link writeFile}.
3828
+ */
3410
3829
  async uploadFile(localPath, remotePath, options) {
3411
3830
  const client = this.ensureClient();
3412
3831
  const localFileStats = await statLocalFile(localPath);
3413
3832
  const localFileSize = localFileStats.size;
3414
3833
  const expectedHash = await computeLocalFileSha256(localPath);
3415
3834
  const temporaryPath = await this.createRemoteWritableTempPath(remotePath, "paratix-upload");
3416
- const temporaryMode = options?.mode ?? "0600";
3835
+ const temporaryMode = resolveFileMode("uploadFile", remotePath, options);
3836
+ let finalized = false;
3417
3837
  try {
3418
3838
  await sftpUpload(
3419
3839
  client,
@@ -3425,23 +3845,26 @@ var SshConnectionImpl = class {
3425
3845
  // remote temp path before they reach an operator-visible error.
3426
3846
  prepareSecrets(this.buildSecrets())
3427
3847
  );
3428
- await this.setRemoteTempMode(temporaryPath, temporaryMode);
3429
- await this.assertRemoteFileSize(temporaryPath, localFileSize);
3430
- await this.finalizeRemoteTempFile(temporaryPath, remotePath, temporaryMode);
3848
+ await this.stageRemoteTempFile({
3849
+ expectedSize: localFileSize,
3850
+ mode: temporaryMode,
3851
+ operation: "uploadFile",
3852
+ temporaryPath
3853
+ });
3854
+ const remoteHash = await this.finalizeAndHashRemoteFile(
3855
+ temporaryPath,
3856
+ remotePath,
3857
+ temporaryMode
3858
+ );
3859
+ finalized = true;
3431
3860
  await this.ensureRemoteUploadFile({
3432
3861
  expectedHash,
3433
3862
  expectedSize: localFileSize,
3863
+ remoteHash,
3434
3864
  remotePath
3435
3865
  });
3436
3866
  } finally {
3437
- try {
3438
- await this.cleanupRemoteTempFile(temporaryPath);
3439
- } catch (cleanupError) {
3440
- process.stderr.write(
3441
- `Warning: failed to remove temp file ${temporaryPath}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
3442
- `
3443
- );
3444
- }
3867
+ if (!finalized) await this.cleanupUploadTemporaryPath(temporaryPath);
3445
3868
  }
3446
3869
  }
3447
3870
  /**
@@ -3453,15 +3876,18 @@ var SshConnectionImpl = class {
3453
3876
  *
3454
3877
  * @param remotePath - Destination path on the remote host.
3455
3878
  * @param content - The string content to write.
3456
- * @param options - Settings for the remote write.
3457
- * @param options.mode - File mode to set via `chmod` on the temp file before moving (e.g. `"0644"`).
3879
+ * @param options - Optional settings for the remote write.
3880
+ * @param options.mode - File mode to set via `chmod` on the temp file before
3881
+ * moving (e.g. `"0644"`). Optional; defaults to `"0600"` when omitted, matching
3882
+ * {@link uploadFile}.
3458
3883
  */
3459
3884
  async writeFile(remotePath, content, options) {
3460
3885
  const client = this.ensureClient();
3461
3886
  const remoteTemporary = await this.createRemoteWritableTempPath(remotePath, "paratix-write");
3462
- const temporaryMode = resolveWriteFileMode(remotePath, options);
3887
+ const temporaryMode = resolveFileMode("writeFile", remotePath, options);
3463
3888
  const expectedSize = Buffer.byteLength(content, "utf8");
3464
3889
  const expectedHash = createHash2("sha256").update(content, "utf8").digest("hex");
3890
+ let finalized = false;
3465
3891
  try {
3466
3892
  await sftpUploadContent(
3467
3893
  client,
@@ -3474,18 +3900,28 @@ var SshConnectionImpl = class {
3474
3900
  // with values registered in the secret sink.
3475
3901
  prepareSecrets(this.buildSecrets())
3476
3902
  );
3477
- await this.setRemoteTempMode(remoteTemporary, temporaryMode);
3478
- await this.assertRemoteFileSize(remoteTemporary, expectedSize, "writeFile");
3479
- await this.finalizeRemoteTempFile(remoteTemporary, remotePath, temporaryMode);
3903
+ await this.stageRemoteTempFile({
3904
+ expectedSize,
3905
+ mode: temporaryMode,
3906
+ operation: "writeFile",
3907
+ temporaryPath: remoteTemporary
3908
+ });
3909
+ const remoteHash = await this.finalizeAndHashRemoteFile(
3910
+ remoteTemporary,
3911
+ remotePath,
3912
+ temporaryMode
3913
+ );
3914
+ finalized = true;
3480
3915
  await this.ensureRemoteWriteFile({
3481
3916
  content,
3482
3917
  expectedHash,
3483
3918
  expectedSize,
3484
3919
  mode: temporaryMode,
3920
+ remoteHash,
3485
3921
  remotePath
3486
3922
  });
3487
3923
  } finally {
3488
- await this.cleanupWriteFileTemporaryPath(remoteTemporary);
3924
+ if (!finalized) await this.cleanupWriteFileTemporaryPath(remoteTemporary);
3489
3925
  }
3490
3926
  }
3491
3927
  async agentSocketExists(agent) {
@@ -3516,29 +3952,6 @@ var SshConnectionImpl = class {
3516
3952
  );
3517
3953
  }
3518
3954
  }
3519
- async assertRemoteFileSize(remotePath, expectedSize, operation = "uploadFile") {
3520
- const statCommand = `stat -c '%s' ${shellQuote(remotePath)}`;
3521
- const rawSize = this.config.user === "root" ? await this.output(statCommand) : await this.outputWithoutSudo(statCommand);
3522
- const actualSize = Number(rawSize.trim());
3523
- if (!Number.isFinite(actualSize)) {
3524
- throw new TypeError(
3525
- `[ssh.${operation}: ${remotePath}] could not determine remote file size after upload`
3526
- );
3527
- }
3528
- if (actualSize === 0 && expectedSize > 0) {
3529
- const diskInfo = await this.checkRemoteDiskSpace(remotePath);
3530
- if (diskInfo != null && diskInfo.availableBytes < expectedSize) {
3531
- throw new Error(
3532
- `[ssh.${operation}: ${remotePath}] disk full \u2013 ${diskInfo.availableBytes} bytes available on ${diskInfo.mountpoint}; the file was written as 0 bytes because there is no space left on the device`
3533
- );
3534
- }
3535
- }
3536
- if (actualSize !== expectedSize) {
3537
- throw new Error(
3538
- `[ssh.${operation}: ${remotePath}] remote file size mismatch after upload; expected ${expectedSize} bytes, got ${actualSize}`
3539
- );
3540
- }
3541
- }
3542
3955
  /**
3543
3956
  * R-0000669: build the abort signal that {@link tryConnectOnPort} subscribes
3544
3957
  * to. Combines the prompt-level abort signal (which fires from the SIGINT
@@ -3557,6 +3970,32 @@ var SshConnectionImpl = class {
3557
3970
  if (this.promptAbortSignal != null) signals.push(this.promptAbortSignal);
3558
3971
  return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
3559
3972
  }
3973
+ /**
3974
+ * R-0000141 / R-0000693: build the shell lines that reject a destination
3975
+ * directory whose resolved path differs from the literal one — i.e. at least
3976
+ * one path component is a symbolic link. Resolving via `command -p realpath
3977
+ * -m` runs the lookup against the POSIX default PATH so a wrapper earlier in
3978
+ * the connected shell's PATH cannot short-circuit the check, and `-m`
3979
+ * tolerates trailing components that do not yet exist.
3980
+ *
3981
+ * The lines are meant to be embedded at the top of a `set -eu` script so the
3982
+ * guard fails the whole round-trip (via the `paratix-symlink-component`
3983
+ * marker + exit 20) before any `mktemp`/`mv` runs. An empty string is
3984
+ * returned for `""`/`"/"`, mirroring the early return in
3985
+ * {@link assertDirnameHasNoSymlinkComponent}.
3986
+ *
3987
+ * @param directory - The destination directory derived from `posix.dirname`.
3988
+ * @returns Shell lines (newline-terminated) or an empty string when no check is needed.
3989
+ */
3990
+ buildDirSymlinkCheckScript(directory) {
3991
+ if (directory === "" || directory === "/") return "";
3992
+ return `dir_resolved=$(command -p realpath -m -- ${shellQuote(directory)})
3993
+ if [ "$dir_resolved" != ${shellQuote(directory)} ]; then
3994
+ printf 'paratix-symlink-component %s\\n' "$dir_resolved" >&2
3995
+ exit 20
3996
+ fi
3997
+ `;
3998
+ }
3560
3999
  buildEnvPrefix(environment) {
3561
4000
  if (environment == null) return "";
3562
4001
  for (const key of Object.keys(environment)) {
@@ -3567,6 +4006,62 @@ var SshConnectionImpl = class {
3567
4006
  const pairs = Object.entries(environment).map(([k, v]) => `${k}=${shellQuote(v)}`);
3568
4007
  return `${pairs.join(" ")} `;
3569
4008
  }
4009
+ /**
4010
+ * Build the non-root finalize+hash script. Mirrors the privileged finalize
4011
+ * in {@link finalizeRemoteTempFile} (guard, intermediate in-destination
4012
+ * `mktemp`, `mv`/`chmod`/`chown`/`mv`) and additionally folds in the
4013
+ * dirname-symlink guard and a trailing `sha256sum` of the destination.
4014
+ *
4015
+ * @param temporaryPath - The staged temp path to move into place.
4016
+ * @param remotePath - The destination path on the remote host.
4017
+ * @param mode - The validated file mode to apply to the finalized file.
4018
+ * @returns The `set -eu` finalize+hash script.
4019
+ */
4020
+ buildNonRootFinalizeAndHashScript(temporaryPath, remotePath, mode) {
4021
+ const directory = posix.dirname(remotePath);
4022
+ const basename = posix.basename(remotePath);
4023
+ const targetGuard = `[ ! -d ${shellQuote(remotePath)} ] && [ ! -L ${shellQuote(remotePath)} ]`;
4024
+ const finalTemplate = `.${basename}.paratix.XXXXXX`;
4025
+ return `set -eu
4026
+ ${this.buildDirSymlinkCheckScript(directory)}if ! ${targetGuard}; then
4027
+ printf '%s
4028
+ ' 'target path must not be a directory or symlink' >&2
4029
+ exit 1
4030
+ fi
4031
+ target_owner=$(stat -c '%u:%g' ${shellQuote(remotePath)} 2>/dev/null || stat -c '%u:%g' ${shellQuote(directory)} 2>/dev/null || printf '0:0')
4032
+ target_temp=''
4033
+ cleanup() {
4034
+ if [ -n "$target_temp" ]; then
4035
+ rm -f -- "$target_temp"
4036
+ fi
4037
+ }
4038
+ trap cleanup EXIT
4039
+ target_temp=$(mktemp -p ${shellQuote(directory)} -- ${shellQuote(finalTemplate)})
4040
+ [ -n "$target_temp" ] || exit 1
4041
+ mv -T -- ${shellQuote(temporaryPath)} "$target_temp"
4042
+ chmod ${shellQuote(mode)} "$target_temp"
4043
+ chown "$target_owner" "$target_temp"
4044
+ mv -T -- "$target_temp" ${shellQuote(remotePath)}
4045
+ trap - EXIT
4046
+ sha256sum -- ${shellQuote(remotePath)} 2>/dev/null || printf 'paratix-hash-failed
4047
+ '
4048
+ `;
4049
+ }
4050
+ /**
4051
+ * Build the root finalize+hash command. The destination is moved into place
4052
+ * only when it is neither a directory nor a symlink, and hashed only after a
4053
+ * successful `mv`; a `mv` failure short-circuits before the hash so the
4054
+ * caller sees a finalize `CommandError` rather than a stale-destination hash.
4055
+ *
4056
+ * @param temporaryPath - The staged temp path to move into place.
4057
+ * @param remotePath - The destination path on the remote host.
4058
+ * @returns The finalize+hash command string.
4059
+ */
4060
+ buildRootFinalizeAndHashScript(temporaryPath, remotePath) {
4061
+ const targetGuard = `[ ! -d ${shellQuote(remotePath)} ] && [ ! -L ${shellQuote(remotePath)} ]`;
4062
+ return `${targetGuard} && mv -T -- ${shellQuote(temporaryPath)} ${shellQuote(remotePath)} && { sha256sum -- ${shellQuote(remotePath)} 2>/dev/null || printf 'paratix-hash-failed
4063
+ '; }`;
4064
+ }
3570
4065
  buildSecrets(extra) {
3571
4066
  const cachedPasswordSecret = this.cachedSudoPassword == null ? [] : [() => this.cachedSudoPassword?.toString("utf8") ?? ""];
3572
4067
  const registeredSecrets = getRegisteredSecrets();
@@ -3603,7 +4098,7 @@ var SshConnectionImpl = class {
3603
4098
  const KB_TO_BYTES = 1024;
3604
4099
  try {
3605
4100
  const directory = remotePath.includes("/") ? remotePath.slice(0, remotePath.lastIndexOf("/")) || "/" : ".";
3606
- const dfOutput = await this.output(`df -P ${shellQuote(directory)}`);
4101
+ const dfOutput = await this.output(`df -Pk ${shellQuote(directory)}`);
3607
4102
  const lines = dfOutput.trim().split("\n");
3608
4103
  if (lines.length < 2) return null;
3609
4104
  const columns = lines[1].split(new RegExp("\\s+", "v"));
@@ -3615,6 +4110,39 @@ var SshConnectionImpl = class {
3615
4110
  return null;
3616
4111
  }
3617
4112
  }
4113
+ /**
4114
+ * Classify a raw `sha256sum` reply for the finalized remote file against the
4115
+ * expected digest. Shared by the combined finalize+hash round-trip and the
4116
+ * shell-fallback re-verification.
4117
+ *
4118
+ * @param remotePath - The finalized destination path (for diagnostics).
4119
+ * @param expectedHash - Lowercase hex SHA-256 digest of the intended content.
4120
+ * @param rawHash - Raw stdout of the finalize+hash or verify command.
4121
+ * @returns `"matches"`, `"empty"` (disk-full indicator) or `"hash-mismatch"`.
4122
+ * @throws {RemoteStatTransientError} When the reply cannot be evaluated into a verdict.
4123
+ */
4124
+ classifyRemoteHash(remotePath, expectedHash, rawHash) {
4125
+ if (rawHash.includes("paratix-hash-failed")) {
4126
+ throw new RemoteStatTransientError(
4127
+ `[ssh.writeFile: ${remotePath}] could not hash remote file after upload/finalize`
4128
+ );
4129
+ }
4130
+ if (rawHash.endsWith(CAPTURE_TRUNCATION_MARKER)) {
4131
+ throw new RemoteStatTransientError(
4132
+ `[ssh.writeFile: ${remotePath}] sha256sum output exceeds the captured-output cap of ${DEFAULT_MAX_OUTPUT_BYTES} bytes; refusing to derive a verdict from truncated output`
4133
+ );
4134
+ }
4135
+ const actualHash = rawHash.trim().split(new RegExp("\\s+", "v"))[0]?.toLowerCase() ?? "";
4136
+ if (!new RegExp("^[0-9a-f]{64}$", "v").test(actualHash)) {
4137
+ throw new RemoteStatTransientError(
4138
+ `[ssh.writeFile: ${remotePath}] could not determine remote file hash after upload/finalize`
4139
+ );
4140
+ }
4141
+ const expected = expectedHash.toLowerCase();
4142
+ if (actualHash === expected) return "matches";
4143
+ if (actualHash === EMPTY_FILE_SHA256 && expected !== EMPTY_FILE_SHA256) return "empty";
4144
+ return "hash-mismatch";
4145
+ }
3618
4146
  async cleanupPrivilegedRemoteTempFile(remotePath) {
3619
4147
  await this.exec(`rm -f -- ${shellQuote(remotePath)}`, {
3620
4148
  ignoreExitCode: true,
@@ -3633,6 +4161,23 @@ var SshConnectionImpl = class {
3633
4161
  } catch (cleanupError) {
3634
4162
  process.stderr.write(
3635
4163
  `Warning: failed to remove temp file ${remotePath}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
4164
+ `
4165
+ );
4166
+ }
4167
+ }
4168
+ /**
4169
+ * Best-effort cleanup of the staged upload temp file. Extracted from
4170
+ * {@link uploadFile}'s `finally` so the primary method stays within the
4171
+ * statement budget; mirrors {@link cleanupWriteFileTemporaryPath}.
4172
+ *
4173
+ * @param temporaryPath - The staged temp path to remove.
4174
+ */
4175
+ async cleanupUploadTemporaryPath(temporaryPath) {
4176
+ try {
4177
+ await this.cleanupRemoteTempFile(temporaryPath);
4178
+ } catch (cleanupError) {
4179
+ process.stderr.write(
4180
+ `Warning: failed to remove temp file ${temporaryPath}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
3636
4181
  `
3637
4182
  );
3638
4183
  }
@@ -3776,10 +4321,17 @@ var SshConnectionImpl = class {
3776
4321
  }
3777
4322
  async createRemoteTempPathInDestination(remotePath, prefix) {
3778
4323
  const directory = posix.dirname(remotePath);
3779
- await this.assertDirnameHasNoSymlinkComponent(directory, remotePath);
3780
4324
  const template = `${prefix}.XXXXXX`;
3781
- const command = `mktemp -p ${shellQuote(directory)} -- ${shellQuote(template)}`;
3782
- const path = this.config.user === "root" ? await this.output(command) : await this.outputWithoutSudo(command);
4325
+ const script = `set -eu
4326
+ ${this.buildDirSymlinkCheckScript(directory)}mktemp -p ${shellQuote(directory)} -- ${shellQuote(template)}
4327
+ `;
4328
+ let path;
4329
+ try {
4330
+ path = this.config.user === "root" ? await this.output(script) : await this.outputWithoutSudo(script);
4331
+ } catch (error) {
4332
+ this.throwIfDirSymlinkMarker(error, directory, remotePath);
4333
+ throw error;
4334
+ }
3783
4335
  return validateMktempPath(directory, path, prefix);
3784
4336
  }
3785
4337
  async createRemoteWritableTempPath(remotePath, prefix) {
@@ -3868,14 +4420,24 @@ var SshConnectionImpl = class {
3868
4420
  * propagates as {@link RemoteStatTransientError} so the caller can retry
3869
4421
  * instead of acting on a non-verdict result.
3870
4422
  *
4423
+ * #82: the finalized-destination hash is now produced by the combined
4424
+ * finalize+hash round-trip ({@link finalizeAndHashRemoteFile}) and passed in
4425
+ * via `options.remoteHash`, so this method only classifies the verdict — it
4426
+ * no longer issues a separate `sha256sum` exec.
4427
+ *
3871
4428
  * @param options - Verification inputs.
3872
4429
  * @param options.expectedHash - Lowercase hex SHA-256 digest of the local file.
3873
4430
  * @param options.expectedSize - Byte length of the local file (used for disk-full diagnostics).
4431
+ * @param options.remoteHash - Raw stdout of the finalize+hash round-trip.
3874
4432
  * @param options.remotePath - The finalized destination path on the remote host.
3875
4433
  * @throws {Error} When the remote file is empty (disk-full) or its hash does not match.
3876
4434
  */
3877
4435
  async ensureRemoteUploadFile(options) {
3878
- const verification = await this.verifyRemoteWriteFile(options.remotePath, options.expectedHash);
4436
+ const verification = this.classifyRemoteHash(
4437
+ options.remotePath,
4438
+ options.expectedHash,
4439
+ options.remoteHash
4440
+ );
3879
4441
  if (verification === "matches") return;
3880
4442
  if (verification === "empty") {
3881
4443
  const diskInfo = await this.checkRemoteDiskSpace(options.remotePath);
@@ -3893,7 +4455,11 @@ var SshConnectionImpl = class {
3893
4455
  );
3894
4456
  }
3895
4457
  async ensureRemoteWriteFile(options) {
3896
- const verification = await this.verifyRemoteWriteFile(options.remotePath, options.expectedHash);
4458
+ const verification = this.classifyRemoteHash(
4459
+ options.remotePath,
4460
+ options.expectedHash,
4461
+ options.remoteHash
4462
+ );
3897
4463
  if (verification === "matches") return;
3898
4464
  await this.rewriteRemoteFileViaShell(options.remotePath, options.content, options.mode);
3899
4465
  const fallbackVerification = await this.verifyRemoteWriteFile(
@@ -3946,6 +4512,41 @@ var SshConnectionImpl = class {
3946
4512
  throw error;
3947
4513
  }
3948
4514
  }
4515
+ /**
4516
+ * Apply the pre-finalize size smoke-test to the `stat`-reported byte count of
4517
+ * the staged temp file. Kept in TypeScript (rather than folded into the
4518
+ * combined shell command) so the disk-full diagnosis and the size-mismatch
4519
+ * error stay identical to the previous `assertRemoteFileSize` behavior.
4520
+ *
4521
+ * @param options - Size-evaluation inputs.
4522
+ * @param options.remotePath - The staged temp path (used for diagnostics).
4523
+ * @param options.expectedSize - The expected byte length of the staged content.
4524
+ * @param options.rawSize - The raw `stat -c '%s'` output captured from the remote.
4525
+ * @param options.operation - Which public method drives this write (for diagnostics).
4526
+ * @throws {Error} When the size is unreadable, indicates disk-full, or mismatches.
4527
+ */
4528
+ async evaluateStagedFileSize(options) {
4529
+ const { expectedSize, operation, rawSize, remotePath } = options;
4530
+ const actualSize = Number(rawSize.trim());
4531
+ if (!Number.isFinite(actualSize)) {
4532
+ throw new TypeError(
4533
+ `[ssh.${operation}: ${remotePath}] could not determine remote file size after upload`
4534
+ );
4535
+ }
4536
+ if (actualSize === 0 && expectedSize > 0) {
4537
+ const diskInfo = await this.checkRemoteDiskSpace(remotePath);
4538
+ if (diskInfo != null && diskInfo.availableBytes < expectedSize) {
4539
+ throw new Error(
4540
+ `[ssh.${operation}: ${remotePath}] disk full \u2013 ${diskInfo.availableBytes} bytes available on ${diskInfo.mountpoint}; the file was written as 0 bytes because there is no space left on the device`
4541
+ );
4542
+ }
4543
+ }
4544
+ if (actualSize !== expectedSize) {
4545
+ throw new Error(
4546
+ `[ssh.${operation}: ${remotePath}] remote file size mismatch after upload; expected ${expectedSize} bytes, got ${actualSize}`
4547
+ );
4548
+ }
4549
+ }
3949
4550
  async execPrepared(command, options = {}) {
3950
4551
  const client = this.ensureClient();
3951
4552
  const environmentPrefix = this.buildEnvPrefix(options.env);
@@ -4002,19 +4603,34 @@ var SshConnectionImpl = class {
4002
4603
  }
4003
4604
  /**
4004
4605
  * Execute a command directly over the SSH transport without sudo wrapping.
4005
- * Used only by {@link probeSudo} to check whether `sudo` is installed.
4606
+ * Used by the sudo probes ({@link ensureSudoInstalled},
4607
+ * {@link hasPasswordlessSudo}) and the non-root finalize/verify paths
4608
+ * ({@link execWithoutSudo}, {@link outputWithoutSudo}).
4609
+ *
4610
+ * Stream capture, the output byte-cap, secret masking and the signal /
4611
+ * exit-code handling are delegated to {@link collectStreamOutput} — the same
4612
+ * helper {@link execPrepared} uses — so the raw path shares one capping and
4613
+ * one truncation implementation with the sudo path. `ignoreExitCode: true`
4614
+ * preserves the historical contract of returning a non-zero exit code to the
4615
+ * caller instead of throwing; a delivered signal still rejects. `silent: true`
4616
+ * keeps the probe/verify output off the live terminal, matching the previous
4617
+ * discard-stderr / buffer-stdout behavior. Only stdout and the exit code are
4618
+ * surfaced to callers; stderr is captured for masking but not returned.
4006
4619
  *
4007
4620
  * @param command - The raw shell command to run.
4008
4621
  * @returns The exit code and captured stdout.
4009
4622
  */
4010
4623
  async execRaw(command) {
4011
4624
  const client = this.ensureClient();
4012
- return new Promise((resolve2, reject) => {
4013
- const { isSettled, wrappedReject, wrappedResolve } = this.createSettledCallbacks(resolve2, reject);
4625
+ const secrets = prepareSecrets(this.buildSecrets());
4626
+ const result = await new Promise((resolve2, reject) => {
4627
+ const { isSettled, wrappedReject, wrappedResolve } = this.createSettledCallbacks(
4628
+ resolve2,
4629
+ reject
4630
+ );
4014
4631
  let activeStream = null;
4015
4632
  const timer = setTimeout(() => {
4016
4633
  activeStream?.close();
4017
- const secrets = prepareSecrets(this.buildSecrets());
4018
4634
  wrappedReject(
4019
4635
  new Error(
4020
4636
  `Command timed out after ${COMMAND_TIMEOUT}ms: ${maskPreparedSecrets(command, secrets)}`
@@ -4033,62 +4649,14 @@ var SshConnectionImpl = class {
4033
4649
  return;
4034
4650
  }
4035
4651
  activeStream = stream;
4036
- const chunks = [];
4037
- let stdoutBytes = 0;
4038
- stream.on("data", (chunk) => {
4039
- if (stdoutBytes > DEFAULT_MAX_OUTPUT_BYTES) return;
4040
- stdoutBytes += chunk.length;
4041
- if (stdoutBytes > DEFAULT_MAX_OUTPUT_BYTES) {
4042
- const remainingBytes = Math.max(
4043
- 0,
4044
- DEFAULT_MAX_OUTPUT_BYTES - (stdoutBytes - chunk.length)
4045
- );
4046
- if (remainingBytes > 0) {
4047
- chunks.push(chunk.subarray(0, remainingBytes));
4048
- }
4049
- const capturedStdout = Buffer.concat(chunks).toString("utf8");
4050
- const secrets = prepareSecrets(this.buildSecrets());
4051
- activeStream?.close();
4052
- wrappedReject(
4053
- new Error(
4054
- `Command stdout exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes: ${maskPreparedSecrets(
4055
- command,
4056
- secrets
4057
- )}
4058
- stdout: ${truncateRawOutputErrorSnippet(
4059
- maskPreparedSecrets(capturedStdout, secrets)
4060
- )}`
4061
- )
4062
- );
4063
- return;
4064
- }
4065
- chunks.push(chunk);
4066
- });
4067
- stream.on("close", (code, signal) => {
4068
- clearTimeout(timer);
4069
- if (signal != null && signal !== "") {
4070
- const secrets = prepareSecrets(this.buildSecrets());
4071
- wrappedReject(
4072
- new Error(
4073
- `Command failed with signal ${signal}: ${maskPreparedSecrets(command, secrets)}`
4074
- )
4075
- );
4076
- return;
4077
- }
4078
- wrappedResolve({
4079
- exitCode: normalizeSshCloseCode(code),
4080
- stdout: Buffer.concat(chunks).toString("utf8")
4081
- });
4082
- });
4083
- stream.on("error", (error2) => {
4084
- clearTimeout(timer);
4085
- wrappedReject(error2);
4086
- });
4087
- stream.stderr.on("data", () => {
4088
- });
4089
- stream.stderr.on("error", (error2) => {
4090
- clearTimeout(timer);
4091
- wrappedReject(error2);
4652
+ collectStreamOutput({
4653
+ command,
4654
+ options: { ignoreExitCode: true, silent: true },
4655
+ reject: wrappedReject,
4656
+ resolve: wrappedResolve,
4657
+ secrets,
4658
+ stream,
4659
+ timer
4092
4660
  });
4093
4661
  });
4094
4662
  } catch (error) {
@@ -4097,14 +4665,46 @@ stdout: ${truncateRawOutputErrorSnippet(
4097
4665
  wrappedReject(toError(error));
4098
4666
  }
4099
4667
  });
4668
+ return { exitCode: result.code, stdout: result.stdout };
4100
4669
  }
4101
4670
  async execWithoutSudo(command) {
4102
4671
  const result = await this.execRaw(command);
4103
4672
  if (result.exitCode !== 0) {
4104
- const secrets = prepareSecrets(this.buildSecrets());
4105
- throw new Error(
4106
- `Command failed (exit code ${result.exitCode}): ${maskPreparedSecrets(command, secrets)}`
4107
- );
4673
+ throw new Error(`Command failed (exit code ${result.exitCode}): ${this.maskCommand(command)}`);
4674
+ }
4675
+ }
4676
+ /**
4677
+ * #82: finalize the staged temp file (`mv -T`) AND hash the finalized
4678
+ * destination in a single privileged exec round-trip, returning the raw
4679
+ * `sha256sum` output for the caller to classify.
4680
+ *
4681
+ * R-0000522 / R-0000599: hashing the finalized destination catches a
4682
+ * same-length TOCTOU swap between the staged temp and the privileged move;
4683
+ * computing the digest inside the same script that performs the move keeps
4684
+ * the window to a single remote command instead of a separate network
4685
+ * round-trip. R-0000141: for non-root users the destination dirname-symlink
4686
+ * guard is folded into the same script (the staged temp lives in `/tmp`, so
4687
+ * unlike the root path there was no earlier in-destination `mktemp` to guard).
4688
+ *
4689
+ * A `sha256sum` failure that occurs *after* a successful move emits the
4690
+ * `paratix-hash-failed` marker and still exits 0, so
4691
+ * {@link classifyRemoteHash} reports a transient verification failure rather
4692
+ * than misclassifying a correctly finalized file as a content mismatch.
4693
+ *
4694
+ * @param temporaryPath - The staged temp path to move into place.
4695
+ * @param remotePath - The destination path on the remote host.
4696
+ * @param mode - The validated file mode applied during the non-root finalize.
4697
+ * @returns The raw stdout of the finalize+hash script (a `sha256sum` line or marker).
4698
+ */
4699
+ async finalizeAndHashRemoteFile(temporaryPath, remotePath, mode) {
4700
+ validateMode(mode);
4701
+ const script = this.config.user === "root" ? this.buildRootFinalizeAndHashScript(temporaryPath, remotePath) : this.buildNonRootFinalizeAndHashScript(temporaryPath, remotePath, mode);
4702
+ try {
4703
+ const result = await this.exec(script, { silent: true });
4704
+ return result.stdout;
4705
+ } catch (error) {
4706
+ this.throwIfDirSymlinkMarker(error, posix.dirname(remotePath), remotePath);
4707
+ throw error;
4108
4708
  }
4109
4709
  }
4110
4710
  async finalizeRemoteTempFile(temporaryPath, remotePath, mode) {
@@ -4211,13 +4811,24 @@ trap - EXIT
4211
4811
  isSudoReadyWithoutProbe() {
4212
4812
  return this.config.user === "root" || this.cachedSudoPassword != null;
4213
4813
  }
4814
+ /**
4815
+ * Single entry point for masking a command string before it is interpolated
4816
+ * into an operator-visible error message. Wraps the repeated
4817
+ * `maskPreparedSecrets(command, prepareSecrets(this.buildSecrets()))` pattern
4818
+ * (R-0000667) so every raw-exec error path redacts the same registered-secret
4819
+ * material — sudo password, op tokens, signed download URLs, … — identically.
4820
+ *
4821
+ * @param command - The command whose secret material must be redacted.
4822
+ * @returns The command with every registered secret variant replaced by the
4823
+ * redaction placeholder.
4824
+ */
4825
+ maskCommand(command) {
4826
+ return maskPreparedSecrets(command, prepareSecrets(this.buildSecrets()));
4827
+ }
4214
4828
  async outputWithoutSudo(command) {
4215
4829
  const result = await this.execRaw(command);
4216
4830
  if (result.exitCode !== 0) {
4217
- const secrets = prepareSecrets(this.buildSecrets());
4218
- throw new Error(
4219
- `Command failed (exit code ${result.exitCode}): ${maskPreparedSecrets(command, secrets)}`
4220
- );
4831
+ throw new Error(`Command failed (exit code ${result.exitCode}): ${this.maskCommand(command)}`);
4221
4832
  }
4222
4833
  return result.stdout.trim();
4223
4834
  }
@@ -4385,14 +4996,39 @@ trap - EXIT
4385
4996
  } catch {
4386
4997
  }
4387
4998
  }
4388
- async setRemoteTempMode(remotePath, mode) {
4999
+ /**
5000
+ * #82: set the staged temp file's mode and read its size back in a single
5001
+ * exec round-trip, then run the pre-finalize size smoke-test. `chmod` and
5002
+ * `stat` are chained with `&&` so a failed `chmod` short-circuits before the
5003
+ * `stat` and surfaces as a `CommandError`, exactly as the previous separate
5004
+ * `setRemoteTempMode` call did.
5005
+ *
5006
+ * R-0000266: the staged temp path is owned by the connecting user (mktemp
5007
+ * staged it under /tmp without sudo). Reading the size through `output` would
5008
+ * funnel the call through `ensureSudoReady` and could fail with a sudo-auth
5009
+ * error after the cached credentials expired. The combined command therefore
5010
+ * runs through the raw (non-sudo) exec path for non-root users so the size
5011
+ * check stays a pure stat call and surfaces a real size mismatch instead of
5012
+ * a sudo prompt failure.
5013
+ *
5014
+ * @param options - Staging inputs.
5015
+ * @param options.temporaryPath - The staged temp path created by `mktemp`.
5016
+ * @param options.mode - The validated file mode to apply before the finalize.
5017
+ * @param options.expectedSize - The expected byte length of the staged content.
5018
+ * @param options.operation - Which public method drives this write (for diagnostics).
5019
+ * @throws {Error} When the staged size does not match `expectedSize`.
5020
+ */
5021
+ async stageRemoteTempFile(options) {
5022
+ const { expectedSize, mode, operation, temporaryPath } = options;
4389
5023
  validateMode(mode);
4390
- const command = `chmod ${shellQuote(mode)} ${shellQuote(remotePath)}`;
4391
- if (this.config.user === "root") {
4392
- await this.exec(command, { silent: true });
4393
- return;
4394
- }
4395
- await this.execWithoutSudo(command);
5024
+ const command = `chmod ${shellQuote(mode)} ${shellQuote(temporaryPath)} && stat -c '%s' ${shellQuote(temporaryPath)}`;
5025
+ const rawSize = this.config.user === "root" ? await this.output(command) : await this.outputWithoutSudo(command);
5026
+ await this.evaluateStagedFileSize({
5027
+ expectedSize,
5028
+ operation,
5029
+ rawSize,
5030
+ remotePath: temporaryPath
5031
+ });
4396
5032
  }
4397
5033
  /**
4398
5034
  * Build the sudo-wrapped command string for execution.
@@ -4442,6 +5078,26 @@ trap - EXIT
4442
5078
  clearTimeout(fallback);
4443
5079
  });
4444
5080
  }
5081
+ /**
5082
+ * Re-throw the folded dirname-symlink guard's failure as the dedicated
5083
+ * R-0000141 diagnostic when the `paratix-symlink-component` marker is present
5084
+ * in a failed command's stderr; otherwise return so the caller re-throws the
5085
+ * original error unchanged.
5086
+ *
5087
+ * @param error - The error raised by a combined mktemp/finalize round-trip.
5088
+ * @param directory - The destination directory that was guarded.
5089
+ * @param remotePath - The original remote path (for the diagnostic message).
5090
+ * @throws {Error} The mapped symlink diagnostic when the marker is present.
5091
+ */
5092
+ throwIfDirSymlinkMarker(error, directory, remotePath) {
5093
+ if (!(error instanceof CommandError)) return;
5094
+ const match = new RegExp("paratix-symlink-component (?<resolved>.*)", "v").exec(error.fullStderr);
5095
+ if (match?.groups?.resolved == null) return;
5096
+ throw new Error(
5097
+ `[ssh.mktemp: ${remotePath}] destination directory ${directory} resolves to ${match.groups.resolved}; refusing to mktemp because at least one path component is a symbolic link`,
5098
+ { cause: error }
5099
+ );
5100
+ }
4445
5101
  /**
4446
5102
  * Iterate over `config.ports` and attempt a connection on each one.
4447
5103
  *
@@ -4525,21 +5181,7 @@ trap - EXIT
4525
5181
  { cause: error }
4526
5182
  );
4527
5183
  }
4528
- if (rawHash.endsWith(CAPTURE_TRUNCATION_MARKER)) {
4529
- throw new RemoteStatTransientError(
4530
- `[ssh.writeFile: ${remotePath}] sha256sum output exceeds the captured-output cap of ${DEFAULT_MAX_OUTPUT_BYTES} bytes; refusing to derive a verdict from truncated output`
4531
- );
4532
- }
4533
- const actualHash = rawHash.trim().split(new RegExp("\\s+", "v"))[0]?.toLowerCase() ?? "";
4534
- if (!new RegExp("^[0-9a-f]{64}$", "v").test(actualHash)) {
4535
- throw new RemoteStatTransientError(
4536
- `[ssh.writeFile: ${remotePath}] could not determine remote file hash after upload/finalize`
4537
- );
4538
- }
4539
- const expected = expectedHash.toLowerCase();
4540
- if (actualHash === expected) return "matches";
4541
- if (actualHash === EMPTY_FILE_SHA256 && expected !== EMPTY_FILE_SHA256) return "empty";
4542
- return "hash-mismatch";
5184
+ return this.classifyRemoteHash(remotePath, expectedHash, rawHash);
4543
5185
  }
4544
5186
  /**
4545
5187
  * Forward sudo password / `options.input` to a stream while tolerating
@@ -4586,6 +5228,13 @@ trap - EXIT
4586
5228
  // src/runner.ts
4587
5229
  var ASCII_ESC3 = 27;
4588
5230
  var ANSI_SHOW_CURSOR3 = `${String.fromCharCode(ASCII_ESC3)}[?25h`;
5231
+ var activeSshConnections = /* @__PURE__ */ new Set();
5232
+ function registerActiveSsh(connection) {
5233
+ activeSshConnections.add(connection);
5234
+ }
5235
+ function unregisterActiveSsh(connection) {
5236
+ activeSshConnections.delete(connection);
5237
+ }
4589
5238
  function performShutdownBestEffortCleanup() {
4590
5239
  try {
4591
5240
  stopLiveModuleOutput(true);
@@ -4606,6 +5255,16 @@ function performShutdownBestEffortCleanup() {
4606
5255
  } catch {
4607
5256
  }
4608
5257
  }
5258
+ function performLastResortCleanup() {
5259
+ performShutdownBestEffortCleanup();
5260
+ for (const connection of activeSshConnections) {
5261
+ try {
5262
+ connection.forceDestroy();
5263
+ } catch {
5264
+ }
5265
+ }
5266
+ activeSshConnections.clear();
5267
+ }
4609
5268
  function setupShutdownHandlers() {
4610
5269
  let receivedSignal = null;
4611
5270
  let ssh = null;
@@ -4635,12 +5294,14 @@ Received ${signal}, shutting down\u2026`);
4635
5294
  promptAbortSignal: promptAbortController.signal,
4636
5295
  setSsh(connection) {
4637
5296
  ssh = connection;
5297
+ registerActiveSsh(connection);
4638
5298
  },
4639
5299
  shutdownAbortSignal: shutdownAbortController.signal,
4640
5300
  shutdownSignal: () => receivedSignal
4641
5301
  };
4642
5302
  }
4643
5303
  var DEFAULT_REBOOT_GRACE_SECONDS = 15;
5304
+ var DEFAULT_REBOOT_RECONNECT_TIMEOUT = 3e5;
4644
5305
  var REBOOT_GRACE_SECONDS_TO_MS = 1e3;
4645
5306
  function createRebootGraceContext(options, shutdownSignal, abortSignal) {
4646
5307
  const seconds = options.rebootGraceSeconds ?? DEFAULT_REBOOT_GRACE_SECONDS;
@@ -4737,9 +5398,6 @@ function handleCaughtStepError(parameters) {
4737
5398
  printCommandFailure(parameters.error, parameters.verbose);
4738
5399
  return { env: parameters.environment, shouldBreak: true, status: "failed" };
4739
5400
  }
4740
- function isRecipe(target) {
4741
- return "_isRecipe" in target && target._isRecipe;
4742
- }
4743
5401
  function addSshdPorts(ssh, metaEntries) {
4744
5402
  const portEntries = metaEntries?.filter((entry) => isSshdPortMetaEntry(entry)) ?? [];
4745
5403
  const addedPorts = [];
@@ -4796,7 +5454,7 @@ async function handleReboot(ssh, metaEntries, rebootGrace) {
4796
5454
  keepAlive: true
4797
5455
  });
4798
5456
  try {
4799
- await ssh.reconnect();
5457
+ await ssh.reconnect({ defaultTimeout: DEFAULT_REBOOT_RECONNECT_TIMEOUT });
4800
5458
  } catch (error) {
4801
5459
  console.error(`Failed to reconnect after reboot: ${String(error)}`);
4802
5460
  throw error;
@@ -5232,6 +5890,7 @@ function teardownPlaybookResources(parameters) {
5232
5890
  stopLiveModuleOutput(true);
5233
5891
  for (const signal of ["SIGINT", "SIGTERM"])
5234
5892
  getSignalBus().off(signal, parameters.handleShutdownSignal);
5893
+ if (parameters.ssh != null) unregisterActiveSsh(parameters.ssh);
5235
5894
  parameters.ssh?.disconnect();
5236
5895
  }
5237
5896
  function initializeRunPlaybookContext(options) {
@@ -5550,17 +6209,35 @@ var CliUsageError = class extends Error {
5550
6209
  this.exitCode = exitCode;
5551
6210
  }
5552
6211
  };
6212
+ function resolveFilteredRun(definition, rawFilter) {
6213
+ if (rawFilter.length === 0) return definition.run;
6214
+ const names = parseFilterNames(rawFilter);
6215
+ if (names.length === 0) {
6216
+ throw new CliUsageError("--filter requires at least one non-empty module name");
6217
+ }
6218
+ const available = collectModuleNames(definition.run);
6219
+ const unknown = names.filter((name) => !available.has(name));
6220
+ if (unknown.length > 0) {
6221
+ const quoted = unknown.map((name) => `"${name}"`).join(", ");
6222
+ throw new CliUsageError(
6223
+ `--filter matched no module: ${quoted}. Available names: ${[...available].join(", ")}`
6224
+ );
6225
+ }
6226
+ return applyModuleFilter(definition.run, new Set(names));
6227
+ }
5553
6228
  async function runApplyCommand(file, options, run = runPlaybook) {
5554
6229
  if (options.diff && !options.dryRun) {
5555
6230
  throw new CliUsageError("--diff requires --dry-run");
5556
6231
  }
5557
- printCliHeader("0.12.8-a236f80");
6232
+ printCliHeader("0.13.0-33ef8ce");
5558
6233
  const environmentOverrides = applyCliEnvironmentOverrides(options.env, {
5559
6234
  firstRun: options.firstRun
5560
6235
  });
5561
6236
  const definition = await loadServerDefinitionFromFile(file, {
5562
6237
  firstRun: options.firstRun
5563
6238
  });
6239
+ const runModules = resolveFilteredRun(definition, options.filter);
6240
+ const targetDefinition = runModules === definition.run ? definition : { ...definition, run: runModules };
5564
6241
  const runOptions = {
5565
6242
  diff: options.diff,
5566
6243
  dryRun: options.dryRun,
@@ -5571,7 +6248,7 @@ async function runApplyCommand(file, options, run = runPlaybook) {
5571
6248
  if (options.reconnectTimeout !== void 0) {
5572
6249
  runOptions.reconnectTimeout = options.reconnectTimeout * SECONDS_TO_MS;
5573
6250
  }
5574
- await run(definition, runOptions);
6251
+ await run(targetDefinition, runOptions);
5575
6252
  }
5576
6253
  function exitAfterApplyError(error, verbose) {
5577
6254
  if (error instanceof CliUsageError) {
@@ -5583,7 +6260,7 @@ function exitAfterApplyError(error, verbose) {
5583
6260
  process.exit(exitCode);
5584
6261
  }
5585
6262
  var program = new Command();
5586
- program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.12.8-a236f80");
6263
+ program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.13.0-33ef8ce");
5587
6264
  program.command("apply <file>").description("Apply a server definition").option(
5588
6265
  "--diff",
5589
6266
  "When combined with --dry-run, show a unified diff per module that would change.",
@@ -5592,9 +6269,14 @@ program.command("apply <file>").description("Apply a server definition").option(
5592
6269
  "--dry-run",
5593
6270
  "Only check, do not apply. Some modules validate prospective config but cannot verify runtime restarts.",
5594
6271
  false
5595
- ).option("--env <key=value...>", "Set env values", collectEnvironment, {}).option("--env-file <path>", "Load dotenv file").option("--first-run", "Set PARATIX_FIRST_RUN=true before loading the playbook", false).option(
6272
+ ).option("--env <key=value...>", "Set env values", collectEnvironment, {}).option("--env-file <path>", "Load dotenv file").option(
6273
+ "--filter <names>",
6274
+ "Run only the named recipes/modules (comma-separated, repeatable). Every other node is shown as skipped.",
6275
+ collectFilter,
6276
+ []
6277
+ ).option("--first-run", "Set PARATIX_FIRST_RUN=true before loading the playbook", false).option(
5596
6278
  "--reconnect-timeout <seconds>",
5597
- "SSH reconnect timeout (seconds, max 86400)",
6279
+ "SSH reconnect timeout override for reboots and port changes (seconds, max 86400; reboot default 300)",
5598
6280
  parseReconnectTimeoutSeconds
5599
6281
  ).option("--verbose", "Show full stack traces on error", false).action(async (file, options) => {
5600
6282
  try {
@@ -5608,6 +6290,8 @@ program.command("apply <file>").description("Apply a server definition").option(
5608
6290
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
5609
6291
  envFile: options.envFile,
5610
6292
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
6293
+ filter: options.filter,
6294
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
5611
6295
  firstRun: options.firstRun,
5612
6296
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
5613
6297
  reconnectTimeout: options.reconnectTimeout,
@@ -5637,6 +6321,9 @@ function parsePositiveNumber(value, options = {}) {
5637
6321
  function parseReconnectTimeoutSeconds(value) {
5638
6322
  return parsePositiveNumber(value, { max: RECONNECT_TIMEOUT_MAX_SECONDS });
5639
6323
  }
6324
+ function collectFilter(value, previous) {
6325
+ return [...previous, value];
6326
+ }
5640
6327
  function collectEnvironment(value, previous) {
5641
6328
  const eqIndex = value.indexOf("=");
5642
6329
  if (eqIndex === -1) {
@@ -5658,8 +6345,38 @@ function collectEnvironment(value, previous) {
5658
6345
  const accumulator = /* @__PURE__ */ Object.create(null);
5659
6346
  return Object.assign(accumulator, previous, { [key]: value_ });
5660
6347
  }
6348
+ var lastResortHandled = false;
6349
+ var LAST_RESORT_EXIT_CODE = 1;
6350
+ function handleLastResortError(error) {
6351
+ if (lastResortHandled) return;
6352
+ lastResortHandled = true;
6353
+ printExceptionError(error, false);
6354
+ performLastResortCleanup();
6355
+ if (process.exitCode === void 0 || process.exitCode === 0) {
6356
+ process.exitCode = LAST_RESORT_EXIT_CODE;
6357
+ }
6358
+ }
6359
+ function resetLastResortHandlerForTests() {
6360
+ lastResortHandled = false;
6361
+ }
6362
+ function forceExitAfterHandling() {
6363
+ setImmediate(() => {
6364
+ process.exit(process.exitCode ?? LAST_RESORT_EXIT_CODE);
6365
+ });
6366
+ }
6367
+ function installLastResortErrorHandlers() {
6368
+ process.on("unhandledRejection", (reason) => {
6369
+ handleLastResortError(reason);
6370
+ forceExitAfterHandling();
6371
+ });
6372
+ process.on("uncaughtException", (error) => {
6373
+ handleLastResortError(error);
6374
+ forceExitAfterHandling();
6375
+ });
6376
+ }
5661
6377
  var entryScript = process.argv[1];
5662
6378
  if (isDirectCliExecution(import.meta.url, entryScript)) {
6379
+ installLastResortErrorHandlers();
5663
6380
  await program.parseAsync();
5664
6381
  }
5665
6382
  export {
@@ -5667,8 +6384,11 @@ export {
5667
6384
  applyCliEnvironmentOverrides,
5668
6385
  collectDefinitionErrors,
5669
6386
  collectEnvironment,
6387
+ collectFilter,
5670
6388
  exitAfterApplyError,
6389
+ handleLastResortError,
5671
6390
  handleTsxLoadFailure,
6391
+ installLastResortErrorHandlers,
5672
6392
  isDirectCliExecution,
5673
6393
  isFirstRun,
5674
6394
  isServerDefinitionLike,
@@ -5676,9 +6396,10 @@ export {
5676
6396
  parsePositiveNumber,
5677
6397
  parseReconnectTimeoutSeconds,
5678
6398
  printExceptionError,
6399
+ resetLastResortHandlerForTests,
5679
6400
  resetTsxRegistrationForTests,
6401
+ resolveFilteredRun,
5680
6402
  runApplyCommand,
5681
6403
  withCliProcessEnvironment,
5682
6404
  withSerializedPlaybookImport
5683
6405
  };
5684
- //# sourceMappingURL=cli.js.map