paratix 0.12.6 → 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/README.md +5 -1
- package/dist/{chunk-CWGQ2MYN.js → chunk-XIRORNO3.js} +252 -25
- package/dist/cli.js +2259 -1505
- package/dist/{index-udpAybq3.d.ts → index-DwKdTyHo.d.ts} +25 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.js +56 -29
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.js +1 -2
- package/llm-guide.md +17 -17
- package/package.json +5 -3
- package/dist/chunk-CWGQ2MYN.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/modules/index.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -307,83 +307,82 @@ function describeHostValidationFailure(failure) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
// src/
|
|
311
|
-
|
|
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/
|
|
314
|
-
import {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
337
|
+
return matchedPositivePattern;
|
|
332
338
|
}
|
|
333
|
-
function
|
|
334
|
-
if (
|
|
335
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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,1292 +910,1330 @@ async function tryConnectOnPort(parameters) {
|
|
|
911
910
|
});
|
|
912
911
|
}
|
|
913
912
|
|
|
914
|
-
// src/
|
|
915
|
-
var
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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
|
|
995
|
-
|
|
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
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
return
|
|
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
|
|
1016
|
-
if (
|
|
1017
|
-
|
|
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
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
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
|
|
1032
|
+
function describeAlgoForDiagnostics(keyBuffer) {
|
|
1054
1033
|
try {
|
|
1055
|
-
|
|
1056
|
-
return serialized ?? Object.prototype.toString.call(cause);
|
|
1034
|
+
return extractAlgoFromKey(keyBuffer);
|
|
1057
1035
|
} catch {
|
|
1058
|
-
return
|
|
1036
|
+
return "<unknown>";
|
|
1059
1037
|
}
|
|
1060
1038
|
}
|
|
1061
|
-
function
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1106
|
+
throw new HostKeyVerificationError(
|
|
1107
|
+
`Could not read known_hosts at ${filePath}: ${String(error)}`
|
|
1108
|
+
);
|
|
1081
1109
|
}
|
|
1082
1110
|
}
|
|
1083
|
-
function
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
|
1093
|
-
|
|
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
|
|
1096
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
var OUTPUT_INDENT_UNIT = " ";
|
|
1110
|
-
var SPINNER_FRAME_INTERVAL_MS = 80;
|
|
1111
|
-
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1112
|
-
var STATUS_ICONS = {
|
|
1113
|
-
changed: pc.yellow("\u21BA"),
|
|
1114
|
-
failed: pc.red("\u2717"),
|
|
1115
|
-
ok: pc.green("\u2713"),
|
|
1116
|
-
skipped: pc.dim("\u2298"),
|
|
1117
|
-
waiting: pc.cyan("\u23F8")
|
|
1118
|
-
};
|
|
1119
|
-
var CLI_HEADER_LINES = [
|
|
1120
|
-
" _ _ ",
|
|
1121
|
-
" | | (_) ",
|
|
1122
|
-
" _ __ __ _ _ __ __ _| |_ ___ __",
|
|
1123
|
-
" | '_ \\ / _` | '__/ _` | __| \\ \\/ /",
|
|
1124
|
-
" | |_) | (_| | | | (_| | |_| |> < ",
|
|
1125
|
-
" | .__/ \\__,_|_| \\__,_|\\__|_/_/\\_\\",
|
|
1126
|
-
" | | ",
|
|
1127
|
-
" |_| "
|
|
1128
|
-
];
|
|
1129
|
-
var activeSpinner = null;
|
|
1130
|
-
var activeRecipeGuideDepths = [];
|
|
1131
|
-
var pendingRecipeClosureGuideDepths = [];
|
|
1132
|
-
var recipeOutputDepth = -1;
|
|
1133
|
-
function renderCliHeader(version) {
|
|
1134
|
-
const versionText = pc.dim(`v${version}`);
|
|
1135
|
-
return `${pc.cyan(CLI_HEADER_LINES.join("\n"))}${versionText}
|
|
1136
|
-
`;
|
|
1137
|
-
}
|
|
1138
|
-
function printCliHeader(version) {
|
|
1139
|
-
console.log(renderCliHeader(version));
|
|
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
|
+
}
|
|
1140
1172
|
}
|
|
1141
|
-
function
|
|
1142
|
-
|
|
1173
|
+
function formatPresentedPublicKey(key) {
|
|
1174
|
+
try {
|
|
1175
|
+
return `${extractAlgoFromKey(key)} ${key.toString("base64")}`;
|
|
1176
|
+
} catch {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1143
1179
|
}
|
|
1144
|
-
function
|
|
1145
|
-
return
|
|
1180
|
+
function hasPinnedHostTrustAnchor(options) {
|
|
1181
|
+
return options?.expectedHostFingerprint != null || options?.expectedHostPublicKey != null;
|
|
1146
1182
|
}
|
|
1147
|
-
function
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
return pc.green(status);
|
|
1157
|
-
}
|
|
1158
|
-
case "skipped": {
|
|
1159
|
-
return pc.dim(status);
|
|
1160
|
-
}
|
|
1161
|
-
case "waiting": {
|
|
1162
|
-
return pc.cyan("running");
|
|
1163
|
-
}
|
|
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;
|
|
1164
1192
|
}
|
|
1193
|
+
throw new HostKeyVerificationError(
|
|
1194
|
+
`HOST KEY VERIFICATION FAILED for ${host}: the remote host key does not match the configured trust anchor.`
|
|
1195
|
+
);
|
|
1165
1196
|
}
|
|
1166
|
-
function
|
|
1167
|
-
|
|
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;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
return result;
|
|
1168
1229
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
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;
|
|
1171
1236
|
}
|
|
1172
|
-
function
|
|
1173
|
-
|
|
1174
|
-
const guideDepths = [
|
|
1175
|
-
...options?.activeGuideDepths ?? activeRecipeGuideDepths,
|
|
1176
|
-
...options?.extraGuideDepths ?? []
|
|
1177
|
-
];
|
|
1178
|
-
for (const guideDepth of guideDepths) {
|
|
1179
|
-
const guideCharacterIndex = OUTPUT_INDENT_UNIT.length * (guideDepth + 1);
|
|
1180
|
-
if (guideCharacterIndex >= indentCharacters.length) continue;
|
|
1181
|
-
indentCharacters[guideCharacterIndex] = options?.colorize === false ? "\xB7" : getGuideDot(guideDepth);
|
|
1182
|
-
}
|
|
1183
|
-
return indentCharacters.join("");
|
|
1237
|
+
function isHostKeyMode(value) {
|
|
1238
|
+
return value === "accept-new" || value === "no" || value === "yes";
|
|
1184
1239
|
}
|
|
1185
|
-
function
|
|
1186
|
-
|
|
1240
|
+
function isRecord(value) {
|
|
1241
|
+
return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
|
|
1187
1242
|
}
|
|
1188
|
-
function
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
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
|
+
);
|
|
1191
1249
|
}
|
|
1192
|
-
return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
|
|
1193
1250
|
}
|
|
1194
|
-
function
|
|
1195
|
-
|
|
1196
|
-
|
|
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`);
|
|
1266
|
+
}
|
|
1197
1267
|
}
|
|
1198
|
-
function
|
|
1199
|
-
return
|
|
1268
|
+
function validateNumberValue(value) {
|
|
1269
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1200
1270
|
}
|
|
1201
|
-
function
|
|
1202
|
-
return
|
|
1271
|
+
function isValidTcpPort(value) {
|
|
1272
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_TCP_PORT;
|
|
1203
1273
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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`);
|
|
1212
1282
|
}
|
|
1213
1283
|
}
|
|
1214
|
-
function
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
+
}
|
|
1301
|
+
}
|
|
1223
1302
|
}
|
|
1224
|
-
function
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
+
}
|
|
1228
1315
|
}
|
|
1229
|
-
function
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
if (clearCurrentLine && supportsAnimatedModuleOutput()) {
|
|
1234
|
-
process.stdout.clearLine(0);
|
|
1235
|
-
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);
|
|
1236
1320
|
}
|
|
1237
1321
|
}
|
|
1238
|
-
function
|
|
1239
|
-
|
|
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}`);
|
|
1240
1326
|
}
|
|
1241
|
-
function
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
name: displayModule.name,
|
|
1263
|
-
status: "waiting",
|
|
1264
|
-
waitingFrame: SPINNER_FRAMES[spinner.frameIndex]
|
|
1265
|
-
})
|
|
1266
|
-
);
|
|
1267
|
-
}, SPINNER_FRAME_INTERVAL_MS)
|
|
1268
|
-
};
|
|
1269
|
-
if (typeof spinner.interval.unref === "function") spinner.interval.unref();
|
|
1270
|
-
activeSpinner = spinner;
|
|
1271
|
-
writeAnimatedModuleLine(
|
|
1272
|
-
renderModuleLine({
|
|
1273
|
-
detail: displayModule.detail,
|
|
1274
|
-
name: displayModule.name,
|
|
1275
|
-
status: "waiting",
|
|
1276
|
-
waitingFrame: SPINNER_FRAMES[0]
|
|
1277
|
-
})
|
|
1278
|
-
);
|
|
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 }
|
|
1340
|
+
});
|
|
1341
|
+
collectOptionalNumberErrors({
|
|
1342
|
+
errors,
|
|
1343
|
+
key: "maxReconnectAttempts",
|
|
1344
|
+
object: ssh,
|
|
1345
|
+
options: { integer: true, positive: true }
|
|
1346
|
+
});
|
|
1347
|
+
collectStrictHostKeyCheckingErrors(ssh, errors);
|
|
1279
1348
|
}
|
|
1280
|
-
function
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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;
|
|
1287
1362
|
}
|
|
1363
|
+
const ssh = value;
|
|
1364
|
+
collectPortsErrors(ssh, errors);
|
|
1365
|
+
collectRequiredUserErrors(ssh, errors);
|
|
1366
|
+
collectOptionalSshFieldErrors(ssh, errors);
|
|
1367
|
+
return errors;
|
|
1288
1368
|
}
|
|
1289
|
-
function
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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;
|
|
1295
1380
|
}
|
|
1296
|
-
function
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
if (line.startsWith("+")) return pc.green(line);
|
|
1302
|
-
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}`);
|
|
1303
1386
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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;
|
|
1308
1393
|
}
|
|
1309
|
-
function
|
|
1310
|
-
|
|
1311
|
-
if (via === "stdout") {
|
|
1312
|
-
process.stdout.write(`${composed}
|
|
1313
|
-
`);
|
|
1314
|
-
} else {
|
|
1315
|
-
console.log(composed);
|
|
1316
|
-
}
|
|
1394
|
+
function isMetaValueType(value) {
|
|
1395
|
+
return value === "boolean" || value === "number" || value === "string";
|
|
1317
1396
|
}
|
|
1318
|
-
function
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
+
);
|
|
1324
1403
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
const maskedDetail = parameters.detail == null ? void 0 : maskRegisteredSecrets(parameters.detail);
|
|
1330
|
-
const displayModule = formatDisplayModule({
|
|
1331
|
-
continuationIndentWidth: `${buildGuideIndent(
|
|
1332
|
-
OUTPUT_INDENT_UNIT.repeat(Math.max(getCurrentOutputDepth() + 2, 1)),
|
|
1333
|
-
{ extraGuideDepths }
|
|
1334
|
-
)} `.length,
|
|
1335
|
-
detail: maskedDetail,
|
|
1336
|
-
name: maskedName,
|
|
1337
|
-
status: parameters.status,
|
|
1338
|
-
terminalColumns: process.stdout.columns
|
|
1339
|
-
});
|
|
1340
|
-
const line = renderModuleLine({
|
|
1341
|
-
detail: displayModule.detail,
|
|
1342
|
-
extraGuideDepths,
|
|
1343
|
-
name: displayModule.name,
|
|
1344
|
-
status: parameters.status
|
|
1345
|
-
});
|
|
1346
|
-
const diffLines = parameters.diff == null ? [] : renderDiffLines(parameters.diff);
|
|
1347
|
-
const usesSpinner = supportsAnimatedModuleOutput() && activeSpinner != null;
|
|
1348
|
-
if (usesSpinner) {
|
|
1349
|
-
stopAnimatedModuleLine();
|
|
1350
|
-
writeAnimatedModuleLine(line);
|
|
1351
|
-
process.stdout.write("\n");
|
|
1352
|
-
} else {
|
|
1353
|
-
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
|
+
);
|
|
1354
1408
|
}
|
|
1355
|
-
writeContinuationBlock({
|
|
1356
|
-
detailLines: displayModule.detailLines,
|
|
1357
|
-
diffLines,
|
|
1358
|
-
extraGuideDepths,
|
|
1359
|
-
via: usesSpinner ? "stdout" : "console"
|
|
1360
|
-
});
|
|
1361
1409
|
}
|
|
1362
|
-
function
|
|
1363
|
-
|
|
1364
|
-
printRenderedModuleResult({ detail, diff, name, status });
|
|
1410
|
+
function isRecord2(value) {
|
|
1411
|
+
return typeof value === "object" && value !== null;
|
|
1365
1412
|
}
|
|
1366
|
-
function
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const lines = [];
|
|
1370
|
-
if (maskedStderr.trim()) {
|
|
1371
|
-
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");
|
|
1372
1416
|
}
|
|
1373
|
-
if (
|
|
1374
|
-
|
|
1417
|
+
if (typeof candidate.resolve !== "function") {
|
|
1418
|
+
throw new TypeError("Invalid env meta entry: resolve must be a function returning a Promise");
|
|
1375
1419
|
}
|
|
1376
|
-
if (
|
|
1377
|
-
|
|
1378
|
-
for (const line of lines) {
|
|
1379
|
-
console.error(pc.red(`${getErrorIndent()}${line}`));
|
|
1380
|
-
}
|
|
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");
|
|
1381
1422
|
}
|
|
1382
1423
|
}
|
|
1383
|
-
function
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
if (maskedStderr.trim()) {
|
|
1387
|
-
console.error(pc.red(`${getErrorIndent()}Full stderr:`));
|
|
1388
|
-
for (const line of maskedStderr.trim().split("\n")) {
|
|
1389
|
-
console.error(pc.red(`${getErrorIndent()}${line}`));
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
if (maskedStdout.trim()) {
|
|
1393
|
-
console.error(pc.red(`${getErrorIndent()}Full stdout:`));
|
|
1394
|
-
for (const line of maskedStdout.trim().split("\n")) {
|
|
1395
|
-
console.error(pc.red(`${getErrorIndent()}${line}`));
|
|
1396
|
-
}
|
|
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");
|
|
1397
1427
|
}
|
|
1398
1428
|
}
|
|
1399
|
-
function
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
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
|
+
);
|
|
1406
1435
|
}
|
|
1407
1436
|
}
|
|
1408
|
-
function
|
|
1409
|
-
return
|
|
1437
|
+
function isEnvironmentMetaEntry(entry) {
|
|
1438
|
+
return entry.kind === "env";
|
|
1410
1439
|
}
|
|
1411
|
-
function
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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);
|
|
1416
1456
|
return;
|
|
1417
1457
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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)}`);
|
|
1425
1471
|
}
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
printVerboseErrorBlock(label, maskRegisteredSecrets(formatCauseValue(cause)));
|
|
1429
|
-
}
|
|
1430
|
-
function printVerboseGenericError(error) {
|
|
1431
|
-
const stack = error.stack?.trim() ?? "";
|
|
1432
|
-
const stackOrMessage = stack.length > 0 ? stack : String(error);
|
|
1433
|
-
printVerboseErrorBlock("Full stack:", maskRegisteredSecrets(stackOrMessage));
|
|
1434
|
-
const cause = getErrorCause(error);
|
|
1435
|
-
if (cause !== void 0) {
|
|
1436
|
-
const visitedCauses = /* @__PURE__ */ new WeakSet();
|
|
1437
|
-
visitedCauses.add(error);
|
|
1438
|
-
printVerboseErrorCause(cause, 1, visitedCauses);
|
|
1439
1472
|
}
|
|
1440
1473
|
}
|
|
1441
|
-
function
|
|
1442
|
-
if (
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
redactMaxDepth: CAUSE_REDACT_BINARY_MAX_DEPTH
|
|
1447
|
-
});
|
|
1474
|
+
function assertValidModuleMetaEntries(entries) {
|
|
1475
|
+
if (entries == null) return;
|
|
1476
|
+
for (const entry of entries) {
|
|
1477
|
+
assertValidModuleMetaEntry(entry);
|
|
1478
|
+
}
|
|
1448
1479
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
if (
|
|
1455
|
-
if (visited.has(cause)) return;
|
|
1456
|
-
visited.add(cause);
|
|
1457
|
-
}
|
|
1458
|
-
console.error(pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`));
|
|
1459
|
-
if (cause instanceof CommandError) {
|
|
1460
|
-
printVerboseCommandError(
|
|
1461
|
-
maskRegisteredSecrets(cause.fullStdout),
|
|
1462
|
-
maskRegisteredSecrets(cause.fullStderr)
|
|
1463
|
-
);
|
|
1464
|
-
}
|
|
1465
|
-
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;
|
|
1466
1486
|
}
|
|
1487
|
+
return true;
|
|
1467
1488
|
}
|
|
1468
|
-
function
|
|
1469
|
-
if (
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
printVerboseCommandError(
|
|
1473
|
-
maskRegisteredSecrets(error.fullStdout),
|
|
1474
|
-
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*)*`
|
|
1475
1493
|
);
|
|
1476
|
-
return;
|
|
1477
1494
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
}
|
|
1483
|
-
if (verbose) {
|
|
1484
|
-
printVerboseGenericError(error);
|
|
1485
|
-
}
|
|
1495
|
+
if (ENVIRONMENT_FORBIDDEN_KEYS.has(name)) {
|
|
1496
|
+
throw new Error(
|
|
1497
|
+
`Forbidden env meta entry name: ${JSON.stringify(name)} (reserved JavaScript identifier)`
|
|
1498
|
+
);
|
|
1486
1499
|
}
|
|
1487
1500
|
}
|
|
1488
|
-
function
|
|
1489
|
-
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
+
};
|
|
1499
1523
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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;
|
|
1506
1537
|
}
|
|
1507
1538
|
|
|
1508
|
-
// src/
|
|
1509
|
-
import
|
|
1510
|
-
import { appendFile, mkdir, readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
1511
|
-
import { homedir } from "os";
|
|
1512
|
-
import { join } from "path";
|
|
1539
|
+
// src/output.ts
|
|
1540
|
+
import pc from "picocolors";
|
|
1513
1541
|
|
|
1514
|
-
// src/
|
|
1515
|
-
import {
|
|
1516
|
-
var
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
if (!
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
+
};
|
|
1527
1559
|
}
|
|
1528
|
-
return
|
|
1560
|
+
return null;
|
|
1529
1561
|
}
|
|
1530
|
-
function
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
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
|
+
);
|
|
1533
1582
|
}
|
|
1534
|
-
function
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
if (parts.length !== HASHED_HOST_PARTS || parts[1] !== "1") return false;
|
|
1538
|
-
const salt = Buffer.from(parts[2] ?? "", "base64");
|
|
1539
|
-
const expectedHash = Buffer.from(parts[3] ?? "", "base64");
|
|
1540
|
-
if (salt.length === 0 || expectedHash.length === 0) return false;
|
|
1541
|
-
const actualHash = createHmac("sha1", salt).update(needle).digest();
|
|
1542
|
-
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}`;
|
|
1543
1586
|
}
|
|
1544
|
-
function
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
state = nextState;
|
|
1550
|
-
}
|
|
1551
|
-
while (pattern[state.patternIndex] === "*") state.patternIndex += 1;
|
|
1552
|
-
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`;
|
|
1553
1592
|
}
|
|
1554
|
-
function
|
|
1555
|
-
const
|
|
1556
|
-
if (
|
|
1557
|
-
return { ...state, needleIndex: state.needleIndex + 1, patternIndex: state.patternIndex + 1 };
|
|
1558
|
-
}
|
|
1559
|
-
if (patternCharacter === "*") {
|
|
1593
|
+
function formatDisplayModule(parameters) {
|
|
1594
|
+
const packageModule = splitPackageModuleName(parameters.name);
|
|
1595
|
+
if (packageModule == null) {
|
|
1560
1596
|
return {
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
starNeedleIndex: state.needleIndex
|
|
1597
|
+
detail: parameters.detail,
|
|
1598
|
+
detailLines: [],
|
|
1599
|
+
name: parameters.name
|
|
1565
1600
|
};
|
|
1566
1601
|
}
|
|
1567
|
-
if (state.starIndex === -1) return null;
|
|
1568
|
-
const starNeedleIndex = state.starNeedleIndex + 1;
|
|
1569
1602
|
return {
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
|
1574
1610
|
};
|
|
1575
1611
|
}
|
|
1576
1612
|
|
|
1577
|
-
// src/
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
constructor(message) {
|
|
1587
|
-
super(message);
|
|
1588
|
-
this.name = "HostKeyVerificationError";
|
|
1589
|
-
Error.captureStackTrace(this, _HostKeyVerificationError);
|
|
1590
|
-
}
|
|
1591
|
-
};
|
|
1592
|
-
var KnownHostsValidationError = class _KnownHostsValidationError extends Error {
|
|
1593
|
-
constructor(message) {
|
|
1594
|
-
super(message);
|
|
1595
|
-
this.name = "KnownHostsValidationError";
|
|
1596
|
-
Error.captureStackTrace(this, _KnownHostsValidationError);
|
|
1597
|
-
}
|
|
1598
|
-
};
|
|
1599
|
-
var MIN_KNOWN_HOSTS_FIELDS = 3;
|
|
1600
|
-
var DEFAULT_SSH_PORT = 22;
|
|
1601
|
-
var UINT32_SIZE = 4;
|
|
1602
|
-
var STRICT_BASE64_PATTERN = new RegExp("^[A-Za-z0-9+\\/]+=*$", "v");
|
|
1603
|
-
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.";
|
|
1604
|
-
var defaultHostKeyCache = /* @__PURE__ */ new Map();
|
|
1605
|
-
function createHostKeyCache() {
|
|
1606
|
-
return /* @__PURE__ */ new Map();
|
|
1607
|
-
}
|
|
1608
|
-
function parseKnownHostsLine(line) {
|
|
1609
|
-
const parts = line.split(new RegExp("\\s+", "v"));
|
|
1610
|
-
const offset = parts[0]?.startsWith("@") ? 1 : 0;
|
|
1611
|
-
if (parts.length < MIN_KNOWN_HOSTS_FIELDS + offset) return [];
|
|
1612
|
-
const marker = offset === 1 ? parts[0] : void 0;
|
|
1613
|
-
const hostsPart = parts[offset];
|
|
1614
|
-
const algo = parts[offset + 1];
|
|
1615
|
-
const base64Key = parts[offset + 2];
|
|
1616
|
-
if (!STRICT_BASE64_PATTERN.test(base64Key)) return [];
|
|
1617
|
-
const key = Buffer.from(base64Key, "base64");
|
|
1618
|
-
const hostPatterns = hostsPart.split(",");
|
|
1619
|
-
return hostPatterns.map((host) => ({ algo, host, hostPatterns, key, marker }));
|
|
1620
|
-
}
|
|
1621
|
-
function parseKnownHosts(content) {
|
|
1622
|
-
const entries = [];
|
|
1623
|
-
for (const raw of content.split("\n")) {
|
|
1624
|
-
const line = raw.trim();
|
|
1625
|
-
if (line.length === 0 || line.startsWith("#")) continue;
|
|
1626
|
-
entries.push(...parseKnownHostsLine(line));
|
|
1627
|
-
}
|
|
1628
|
-
return entries;
|
|
1629
|
-
}
|
|
1630
|
-
function formatHostNeedle(host, port) {
|
|
1631
|
-
return port === DEFAULT_SSH_PORT ? host : `[${host}]:${port}`;
|
|
1632
|
-
}
|
|
1633
|
-
function assertSafeHostForKnownHosts(host) {
|
|
1634
|
-
const hostValidationFailure = validateHostLabel(host);
|
|
1635
|
-
if (hostValidationFailure == null) return;
|
|
1636
|
-
throw new KnownHostsValidationError(
|
|
1637
|
-
`Refusing to persist known_hosts entry: host label ${describeHostValidationFailure(hostValidationFailure)}`
|
|
1638
|
-
);
|
|
1639
|
-
}
|
|
1640
|
-
function matchesKnownHostEntry(entry, needle) {
|
|
1641
|
-
return matchesKnownHostPatternList(entry.hostPatterns ?? [entry.host], needle);
|
|
1642
|
-
}
|
|
1643
|
-
function findMatchingEntries(entries, host, port) {
|
|
1644
|
-
const needle = formatHostNeedle(host, port);
|
|
1645
|
-
return entries.filter((entry) => matchesKnownHostEntry(entry, needle));
|
|
1646
|
-
}
|
|
1647
|
-
function findRevokedEntry(entries, key) {
|
|
1648
|
-
return entries.find(
|
|
1649
|
-
(entry) => entry.marker === "@revoked" && entry.key.length === key.length && timingSafeEqual2(entry.key, key)
|
|
1650
|
-
);
|
|
1651
|
-
}
|
|
1652
|
-
function throwHostKeyMismatch(host, presentedKey, existingKey) {
|
|
1653
|
-
const presentedAlgo = describeAlgoForDiagnostics(presentedKey);
|
|
1654
|
-
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)}). `;
|
|
1655
|
-
throw new HostKeyVerificationError(
|
|
1656
|
-
`HOST KEY VERIFICATION FAILED for ${host}: ${knownHostsDetails}This could indicate a man-in-the-middle attack.`
|
|
1657
|
-
);
|
|
1658
|
-
}
|
|
1659
|
-
function verifyHostKeyAgainstKnownEntries(parameters) {
|
|
1660
|
-
const { cachedKey, fileEntries, host, key } = parameters;
|
|
1661
|
-
const revokedKey = findRevokedEntry(fileEntries, key);
|
|
1662
|
-
if (revokedKey != null) {
|
|
1663
|
-
throw new HostKeyVerificationError(
|
|
1664
|
-
`HOST KEY VERIFICATION FAILED for ${host}: remote host key (${describeAlgoForDiagnostics(revokedKey.key)}) is marked as revoked in known_hosts.`
|
|
1665
|
-
);
|
|
1666
|
-
}
|
|
1667
|
-
if (fileEntries.some((entry) => entry.marker === "@cert-authority"))
|
|
1668
|
-
throw new HostKeyVerificationError(`${host}: ${CERT_AUTHORITY_MESSAGE}`);
|
|
1669
|
-
const rawHostKeyEntries = fileEntries.filter((entry) => entry.marker == null);
|
|
1670
|
-
const matchingEntry = rawHostKeyEntries.find(
|
|
1671
|
-
(entry) => entry.key.length === key.length && timingSafeEqual2(entry.key, key)
|
|
1672
|
-
);
|
|
1673
|
-
if (matchingEntry != null) return true;
|
|
1674
|
-
if (cachedKey?.length === key.length && timingSafeEqual2(cachedKey, key)) {
|
|
1675
|
-
return true;
|
|
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");
|
|
1676
1622
|
}
|
|
1677
|
-
const firstRawHostKey = rawHostKeyEntries.at(0)?.key;
|
|
1678
|
-
if (firstRawHostKey != null) throwHostKeyMismatch(host, key, firstRawHostKey);
|
|
1679
|
-
if (cachedKey != null) throwHostKeyMismatch(host, key, cachedKey);
|
|
1680
|
-
return false;
|
|
1681
1623
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
if (algoLength === 0 || UINT32_SIZE + algoLength > keyBuffer.length) {
|
|
1688
|
-
throw new Error("Invalid SSH key buffer: algorithm length exceeds buffer size");
|
|
1689
|
-
}
|
|
1690
|
-
const algo = keyBuffer.subarray(UINT32_SIZE, UINT32_SIZE + algoLength).toString("ascii");
|
|
1691
|
-
if (!PINNED_HOST_KEY_ALGORITHM_PATTERN.test(algo)) {
|
|
1692
|
-
throw new KnownHostsValidationError(`Invalid SSH key buffer: unsupported algorithm '${algo}'`);
|
|
1693
|
-
}
|
|
1694
|
-
return algo;
|
|
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);
|
|
1695
1629
|
}
|
|
1696
|
-
function
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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;
|
|
1701
1637
|
}
|
|
1638
|
+
secretCounts.set(secret, current - 1);
|
|
1702
1639
|
}
|
|
1703
|
-
function
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
assertSafeHostForKnownHosts(host);
|
|
1709
|
-
const hostLabel = formatHostNeedle(host, port);
|
|
1710
|
-
const algo = extractAlgoFromKey(keyBuffer);
|
|
1711
|
-
const base64Key = keyBuffer.toString("base64");
|
|
1712
|
-
if (new RegExp("\\s", "v").test(hostLabel)) {
|
|
1713
|
-
throw new KnownHostsValidationError(
|
|
1714
|
-
`Refusing to persist known_hosts entry: host label contains whitespace`
|
|
1715
|
-
);
|
|
1716
|
-
}
|
|
1717
|
-
if (new RegExp("\\s", "v").test(algo)) {
|
|
1718
|
-
throw new KnownHostsValidationError(
|
|
1719
|
-
`Refusing to persist known_hosts entry: algorithm contains whitespace`
|
|
1720
|
-
);
|
|
1721
|
-
}
|
|
1722
|
-
if (new RegExp("\\s", "v").test(base64Key)) {
|
|
1723
|
-
throw new KnownHostsValidationError(
|
|
1724
|
-
`Refusing to persist known_hosts entry: base64 key contains whitespace`
|
|
1725
|
-
);
|
|
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);
|
|
1726
1645
|
}
|
|
1727
|
-
const line = `${hostLabel} ${algo} ${base64Key}
|
|
1728
|
-
`;
|
|
1729
|
-
const sshDirectory = join(homedir(), ".ssh");
|
|
1730
|
-
const filePath = join(sshDirectory, "known_hosts");
|
|
1731
|
-
await withKnownHostsLock(async () => {
|
|
1732
|
-
await mkdir(sshDirectory, { mode: 448, recursive: true });
|
|
1733
|
-
const existingEntries = await loadKnownHostEntries();
|
|
1734
|
-
const matchingRawHostEntries = existingEntries.filter(
|
|
1735
|
-
(entry) => entry.marker == null && matchesKnownHostEntry(entry, hostLabel)
|
|
1736
|
-
);
|
|
1737
|
-
const alreadyTrusted = matchingRawHostEntries.some(
|
|
1738
|
-
(entry) => entry.algo === algo && entry.key.length === keyBuffer.length && timingSafeEqual2(entry.key, keyBuffer)
|
|
1739
|
-
);
|
|
1740
|
-
if (alreadyTrusted) return;
|
|
1741
|
-
const conflictingEntry = matchingRawHostEntries.find(
|
|
1742
|
-
(entry) => entry.key.length !== keyBuffer.length || !timingSafeEqual2(entry.key, keyBuffer)
|
|
1743
|
-
);
|
|
1744
|
-
if (conflictingEntry != null) throwHostKeyMismatch(host, keyBuffer, conflictingEntry.key);
|
|
1745
|
-
await appendFile(filePath, line, { mode: 384 });
|
|
1746
|
-
});
|
|
1747
|
-
}
|
|
1748
|
-
function getFileSystemErrorCode(error) {
|
|
1749
|
-
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
1750
|
-
}
|
|
1751
|
-
var KIBIBYTE_BYTES = 1024;
|
|
1752
|
-
var MEBIBYTE_BYTES = KIBIBYTE_BYTES * KIBIBYTE_BYTES;
|
|
1753
|
-
var KNOWN_HOSTS_FILE_LIMIT_MIB = 16;
|
|
1754
|
-
var KNOWN_HOSTS_FILE_BYTE_LIMIT = KNOWN_HOSTS_FILE_LIMIT_MIB * MEBIBYTE_BYTES;
|
|
1755
|
-
async function loadKnownHostEntries() {
|
|
1756
|
-
const filePath = join(homedir(), ".ssh", "known_hosts");
|
|
1757
1646
|
try {
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
`Refusing to read known_hosts at ${filePath}: size ${stats.size} bytes exceeds the ${KNOWN_HOSTS_FILE_BYTE_LIMIT}-byte cap`
|
|
1762
|
-
);
|
|
1647
|
+
for (const secret of registerableSecrets) {
|
|
1648
|
+
registerSecret(secret);
|
|
1649
|
+
registered.push(secret);
|
|
1763
1650
|
}
|
|
1764
|
-
|
|
1651
|
+
const result = await body();
|
|
1652
|
+
return maskScopedResult(result, secrets);
|
|
1765
1653
|
} catch (error) {
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1654
|
+
throw maskScopedError(error, secrets);
|
|
1655
|
+
} finally {
|
|
1656
|
+
for (const secret of registered) {
|
|
1657
|
+
unregisterSecret(secret);
|
|
1769
1658
|
}
|
|
1770
|
-
throw new HostKeyVerificationError(
|
|
1771
|
-
`Could not read known_hosts at ${filePath}: ${String(error)}`
|
|
1772
|
-
);
|
|
1773
1659
|
}
|
|
1774
1660
|
}
|
|
1775
|
-
function
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const fingerprint = computeFingerprint(key);
|
|
1779
|
-
return `WARNING: Permanently added '${host}' (${algo}) to the list of known hosts. Fingerprint: ${fingerprint}
|
|
1780
|
-
`;
|
|
1781
|
-
} catch {
|
|
1782
|
-
return `WARNING: Permanently added '${host}' to the list of known hosts.
|
|
1783
|
-
`;
|
|
1661
|
+
async function withRunScopedSecrets(body) {
|
|
1662
|
+
if (runScopedSecretCounts.getStore() != null) {
|
|
1663
|
+
return body();
|
|
1784
1664
|
}
|
|
1785
|
-
|
|
1786
|
-
function writeHostKeyPersistFallbackWarning(host, port, error) {
|
|
1787
|
-
const keyscanArguments = port === DEFAULT_SSH_PORT ? shellQuote(host) : `-p ${port} ${shellQuote(host)}`;
|
|
1788
|
-
process.stderr.write(
|
|
1789
|
-
`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)}
|
|
1790
|
-
`
|
|
1791
|
-
);
|
|
1792
|
-
}
|
|
1793
|
-
async function acceptAndPersistHostKey(location, key, cache) {
|
|
1794
|
-
const { host, port } = location;
|
|
1795
|
-
const acceptedWarning = formatAcceptedHostKeyWarning(host, key);
|
|
1665
|
+
const scopedSecrets = /* @__PURE__ */ new Map();
|
|
1796
1666
|
try {
|
|
1797
|
-
await
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
+
}
|
|
1801
1673
|
}
|
|
1802
|
-
cache.set(formatHostNeedle(host, port), key);
|
|
1803
|
-
process.stderr.write(acceptedWarning);
|
|
1804
|
-
writeHostKeyPersistFallbackWarning(host, port, error);
|
|
1805
|
-
return;
|
|
1806
1674
|
}
|
|
1807
|
-
cache.set(formatHostNeedle(host, port), key);
|
|
1808
|
-
process.stderr.write(acceptedWarning);
|
|
1809
1675
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
const
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
+
}
|
|
1815
1685
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
throw new Error(
|
|
1819
|
-
`Expected host public key uses unsupported algorithm '${algorithm}'. Supported algorithms: ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521.`
|
|
1820
|
-
);
|
|
1686
|
+
if (isModuleResult(next) && next.status === "failed" && next.error != null) {
|
|
1687
|
+
next = { ...next, error: maskScopedError(next.error, secrets) };
|
|
1821
1688
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1689
|
+
return next;
|
|
1690
|
+
}
|
|
1691
|
+
function isModuleResult(value) {
|
|
1692
|
+
return typeof value === "object" && value !== null && "status" in value && typeof value.status === "string";
|
|
1693
|
+
}
|
|
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)
|
|
1825
1705
|
);
|
|
1826
1706
|
}
|
|
1827
|
-
|
|
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;
|
|
1828
1714
|
}
|
|
1829
|
-
function
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
return null;
|
|
1833
|
-
} catch (error) {
|
|
1834
|
-
return error instanceof Error ? error.message : String(error);
|
|
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]));
|
|
1835
1718
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
if (maskedCause !== void 0) {
|
|
1733
|
+
Object.defineProperty(clone, "cause", {
|
|
1734
|
+
configurable: true,
|
|
1735
|
+
value: maskedCause,
|
|
1736
|
+
writable: true
|
|
1737
|
+
});
|
|
1842
1738
|
}
|
|
1739
|
+
return clone;
|
|
1843
1740
|
}
|
|
1844
|
-
function
|
|
1845
|
-
|
|
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);
|
|
1846
1752
|
}
|
|
1847
|
-
function
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
const fingerprintMatches = expectedFingerprint == null || expectedFingerprint === presentedFingerprint;
|
|
1854
|
-
if (publicKeyMatches && fingerprintMatches) {
|
|
1855
|
-
return;
|
|
1753
|
+
function stringifyObjectCause(cause, secretList) {
|
|
1754
|
+
try {
|
|
1755
|
+
const serialized = JSON.stringify(normalizeObjectCause(cause, secretList, /* @__PURE__ */ new WeakSet()));
|
|
1756
|
+
return serialized ?? Object.prototype.toString.call(cause);
|
|
1757
|
+
} catch {
|
|
1758
|
+
return Object.prototype.toString.call(cause);
|
|
1856
1759
|
}
|
|
1857
|
-
throw new HostKeyVerificationError(
|
|
1858
|
-
`HOST KEY VERIFICATION FAILED for ${host}: the remote host key does not match the configured trust anchor.`
|
|
1859
|
-
);
|
|
1860
1760
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
if (
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
if (hasPinnedHostTrustAnchor(options)) verifyPinnedHostKey(host, key, options);
|
|
1876
|
-
return true;
|
|
1877
|
-
}
|
|
1878
|
-
if (hasPinnedHostTrustAnchor(options)) {
|
|
1879
|
-
verifyPinnedHostKey(host, key, options);
|
|
1880
|
-
return true;
|
|
1881
|
-
}
|
|
1882
|
-
if (mode === "yes") {
|
|
1883
|
-
throw new HostKeyVerificationError(
|
|
1884
|
-
`Host key for ${host} not found in known_hosts. Set strictHostKeyChecking to "accept-new" for explicit TOFU or configure ssh.expectedHostFingerprint / ssh.expectedHostPublicKey.`
|
|
1885
|
-
);
|
|
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);
|
|
1766
|
+
try {
|
|
1767
|
+
if (Array.isArray(cause)) {
|
|
1768
|
+
return cause.map((value) => normalizeCausePropertyValue(value, secretList, seen));
|
|
1769
|
+
}
|
|
1770
|
+
const normalized = {};
|
|
1771
|
+
for (const [key, value] of Object.entries(cause)) {
|
|
1772
|
+
if (isSecretDiagnosticField(key)) {
|
|
1773
|
+
normalized[key] = REDACTED_PLACEHOLDER;
|
|
1774
|
+
continue;
|
|
1886
1775
|
}
|
|
1887
|
-
|
|
1888
|
-
acceptedHostKey = Buffer.from(key);
|
|
1889
|
-
return true;
|
|
1776
|
+
normalized[key] = normalizeCausePropertyValue(value, secretList, seen);
|
|
1890
1777
|
}
|
|
1778
|
+
return normalized;
|
|
1779
|
+
} finally {
|
|
1780
|
+
seen.delete(cause);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
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);
|
|
1788
|
+
}
|
|
1789
|
+
function isBinaryCauseValue(value) {
|
|
1790
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
1791
|
+
}
|
|
1792
|
+
function getRegisteredSecrets() {
|
|
1793
|
+
return [...secretCounts.keys()];
|
|
1794
|
+
}
|
|
1795
|
+
function clearRegisteredSecrets() {
|
|
1796
|
+
secretCounts.clear();
|
|
1797
|
+
}
|
|
1798
|
+
function maskRegisteredSecrets(text) {
|
|
1799
|
+
if (secretCounts.size === 0) return text;
|
|
1800
|
+
return maskSecrets(text, getRegisteredSecrets());
|
|
1801
|
+
}
|
|
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
|
|
1891
1843
|
};
|
|
1892
|
-
|
|
1844
|
+
registry[LIVE_OUTPUT_STATE_KEY] = created;
|
|
1845
|
+
return created;
|
|
1893
1846
|
}
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
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
|
+
`;
|
|
1900
1852
|
}
|
|
1901
|
-
function
|
|
1902
|
-
|
|
1853
|
+
function printCliHeader(version) {
|
|
1854
|
+
console.log(renderCliHeader(version));
|
|
1903
1855
|
}
|
|
1904
|
-
function
|
|
1905
|
-
return
|
|
1856
|
+
function supportsAnimatedModuleOutput() {
|
|
1857
|
+
return process.stdout.isTTY && typeof process.stdout.clearLine === "function" && typeof process.stdout.cursorTo === "function";
|
|
1906
1858
|
}
|
|
1907
|
-
function
|
|
1908
|
-
|
|
1909
|
-
if (typeof object[key] !== "boolean") {
|
|
1910
|
-
errors.push(
|
|
1911
|
-
`Invalid property 'ssh.${key}' (expected boolean, got ${describeType(object[key])})`
|
|
1912
|
-
);
|
|
1913
|
-
}
|
|
1859
|
+
function getModuleIcon(status, waitingFrame) {
|
|
1860
|
+
return status === "waiting" ? pc.cyan(waitingFrame ?? "|") : STATUS_ICONS[status];
|
|
1914
1861
|
}
|
|
1915
|
-
function
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
+
}
|
|
1930
1879
|
}
|
|
1931
1880
|
}
|
|
1932
|
-
function
|
|
1933
|
-
return
|
|
1881
|
+
function getCurrentOutputDepth() {
|
|
1882
|
+
return Math.max(liveOutputState.recipeOutputDepth, 0);
|
|
1934
1883
|
}
|
|
1935
|
-
function
|
|
1936
|
-
return
|
|
1884
|
+
function getGuideDot(depth) {
|
|
1885
|
+
return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
|
|
1937
1886
|
}
|
|
1938
|
-
function
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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);
|
|
1946
1897
|
}
|
|
1898
|
+
return indentCharacters.join("");
|
|
1947
1899
|
}
|
|
1948
|
-
function
|
|
1949
|
-
|
|
1950
|
-
errors.push("Missing property 'ssh.ports' (expected array)");
|
|
1951
|
-
return;
|
|
1952
|
-
}
|
|
1953
|
-
if (!Array.isArray(ssh.ports)) {
|
|
1954
|
-
errors.push(`Invalid property 'ssh.ports' (expected array, got ${describeType(ssh.ports)})`);
|
|
1955
|
-
return;
|
|
1956
|
-
}
|
|
1957
|
-
if (ssh.ports.length === 0) {
|
|
1958
|
-
errors.push("Property 'ssh.ports' must not be empty");
|
|
1959
|
-
return;
|
|
1960
|
-
}
|
|
1961
|
-
for (const [index, port] of ssh.ports.entries()) {
|
|
1962
|
-
if (!isValidTcpPort(port)) {
|
|
1963
|
-
errors.push(`Property 'ssh.ports[${index}]' must be an integer between 1 and 65535`);
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1900
|
+
function clearPendingRecipeClosureGuides() {
|
|
1901
|
+
liveOutputState.pendingRecipeClosureGuideDepths = [];
|
|
1966
1902
|
}
|
|
1967
|
-
function
|
|
1968
|
-
if (
|
|
1969
|
-
|
|
1970
|
-
return;
|
|
1971
|
-
}
|
|
1972
|
-
if (typeof ssh.user !== "string") {
|
|
1973
|
-
errors.push(`Invalid property 'ssh.user' (expected string, got ${describeType(ssh.user)})`);
|
|
1974
|
-
return;
|
|
1975
|
-
}
|
|
1976
|
-
if (ssh.user.length === 0) {
|
|
1977
|
-
errors.push("Property 'ssh.user' must not be an empty string");
|
|
1903
|
+
function getModuleIndent() {
|
|
1904
|
+
if (liveOutputState.recipeOutputDepth < 0) {
|
|
1905
|
+
return OUTPUT_INDENT_UNIT;
|
|
1978
1906
|
}
|
|
1907
|
+
return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
|
|
1979
1908
|
}
|
|
1980
|
-
function
|
|
1981
|
-
if (
|
|
1982
|
-
|
|
1983
|
-
errors.push(STRICT_HOST_KEY_ERROR);
|
|
1984
|
-
}
|
|
1909
|
+
function getRecipeHeaderIndent() {
|
|
1910
|
+
if (liveOutputState.recipeOutputDepth < 0) return "";
|
|
1911
|
+
return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
|
|
1985
1912
|
}
|
|
1986
|
-
function
|
|
1987
|
-
|
|
1988
|
-
const error = validateExpectedHostPublicKey(ssh.expectedHostPublicKey);
|
|
1989
|
-
if (error != null) errors.push(`Invalid property 'ssh.expectedHostPublicKey': ${error}`);
|
|
1913
|
+
function getErrorIndent() {
|
|
1914
|
+
return `${getModuleIndent()}\u2502 `;
|
|
1990
1915
|
}
|
|
1991
|
-
function
|
|
1992
|
-
|
|
1993
|
-
collectOptionalStringErrors(ssh, "sudoPassword", errors);
|
|
1994
|
-
collectOptionalStringErrors(ssh, "expectedHostFingerprint", errors);
|
|
1995
|
-
collectOptionalStringErrors(ssh, "expectedHostPublicKey", errors);
|
|
1996
|
-
collectExpectedHostPublicKeyErrors(ssh, errors);
|
|
1997
|
-
collectOptionalBooleanErrors(ssh, "agentForward", errors);
|
|
1998
|
-
collectOptionalBooleanErrors(ssh, "passwordFallback", errors);
|
|
1999
|
-
collectOptionalNumberErrors({
|
|
2000
|
-
errors,
|
|
2001
|
-
key: "reconnectTimeout",
|
|
2002
|
-
object: ssh,
|
|
2003
|
-
options: { positive: true }
|
|
2004
|
-
});
|
|
2005
|
-
collectOptionalNumberErrors({
|
|
2006
|
-
errors,
|
|
2007
|
-
key: "maxReconnectAttempts",
|
|
2008
|
-
object: ssh,
|
|
2009
|
-
options: { integer: true, positive: true }
|
|
2010
|
-
});
|
|
2011
|
-
collectStrictHostKeyCheckingErrors(ssh, errors);
|
|
1916
|
+
function getContinuationIndent() {
|
|
1917
|
+
return `${getModuleIndent()} `;
|
|
2012
1918
|
}
|
|
2013
|
-
function
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
return errors;
|
|
2022
|
-
}
|
|
2023
|
-
if (!isRecord(value)) {
|
|
2024
|
-
errors.push("Invalid property 'ssh' (expected object, got object)");
|
|
2025
|
-
return errors;
|
|
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;
|
|
2026
1927
|
}
|
|
2027
|
-
const ssh = value;
|
|
2028
|
-
collectPortsErrors(ssh, errors);
|
|
2029
|
-
collectRequiredUserErrors(ssh, errors);
|
|
2030
|
-
collectOptionalSshFieldErrors(ssh, errors);
|
|
2031
|
-
return errors;
|
|
2032
1928
|
}
|
|
2033
|
-
function
|
|
2034
|
-
const
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
};
|
|
2043
|
-
return mappedErrors[error] ?? error;
|
|
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}`;
|
|
2044
1938
|
}
|
|
2045
|
-
function
|
|
2046
|
-
|
|
2047
|
-
if (
|
|
2048
|
-
|
|
1939
|
+
function hideCursor() {
|
|
1940
|
+
if (liveOutputState.cursorHidden) return;
|
|
1941
|
+
if (!supportsAnimatedModuleOutput()) return;
|
|
1942
|
+
process.stdout.write(ANSI_HIDE_CURSOR);
|
|
1943
|
+
liveOutputState.cursorHidden = true;
|
|
2049
1944
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
function hasValidMetaName(name) {
|
|
2055
|
-
return typeof name === "string" && name.length > 0;
|
|
1945
|
+
function showCursor() {
|
|
1946
|
+
if (!liveOutputState.cursorHidden) return;
|
|
1947
|
+
process.stdout.write(ANSI_SHOW_CURSOR);
|
|
1948
|
+
liveOutputState.cursorHidden = false;
|
|
2056
1949
|
}
|
|
2057
|
-
function
|
|
2058
|
-
|
|
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));
|
|
1955
|
+
}
|
|
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);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function stopLiveModuleOutput(clearCurrentLine = false) {
|
|
1967
|
+
stopAnimatedModuleLine(clearCurrentLine);
|
|
1968
|
+
}
|
|
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
|
+
);
|
|
2059
2007
|
}
|
|
2060
|
-
function
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
if (enforceDeclaredValueType && actualType !== declaredValueType) {
|
|
2068
|
-
throw new TypeError(
|
|
2069
|
-
`Env meta entry ${JSON.stringify(entryKey)} resolved to typeof ${actualType}, expected ${declaredValueType}`
|
|
2070
|
-
);
|
|
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];
|
|
2071
2015
|
}
|
|
2072
2016
|
}
|
|
2073
|
-
function
|
|
2074
|
-
|
|
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
|
+
);
|
|
2075
2023
|
}
|
|
2076
|
-
function
|
|
2077
|
-
if (
|
|
2078
|
-
|
|
2079
|
-
}
|
|
2080
|
-
if (typeof candidate.resolve !== "function") {
|
|
2081
|
-
throw new TypeError("Invalid env meta entry: resolve must be a function returning a Promise");
|
|
2082
|
-
}
|
|
2083
|
-
if (candidate.valueType !== "boolean" && candidate.valueType !== "number" && candidate.valueType !== "string") {
|
|
2084
|
-
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);
|
|
2085
2027
|
}
|
|
2028
|
+
if (line.startsWith("-")) return pc.red(line);
|
|
2029
|
+
if (line.startsWith("+")) return pc.green(line);
|
|
2030
|
+
return pc.dim(line);
|
|
2086
2031
|
}
|
|
2087
|
-
function
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2090
|
-
}
|
|
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)}`);
|
|
2091
2036
|
}
|
|
2092
|
-
function
|
|
2093
|
-
const
|
|
2094
|
-
if (
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
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);
|
|
2098
2044
|
}
|
|
2099
2045
|
}
|
|
2100
|
-
function
|
|
2101
|
-
|
|
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
|
+
}
|
|
2102
2053
|
}
|
|
2103
|
-
function
|
|
2104
|
-
|
|
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
|
+
});
|
|
2105
2089
|
}
|
|
2106
|
-
function
|
|
2107
|
-
|
|
2090
|
+
function printModuleResult(name, status, detail, diff) {
|
|
2091
|
+
clearPendingRecipeClosureGuides();
|
|
2092
|
+
printRenderedModuleResult({ detail, diff, name, status });
|
|
2108
2093
|
}
|
|
2109
|
-
function
|
|
2110
|
-
|
|
2094
|
+
function printRecipeModuleResult(name, status, detail, diff) {
|
|
2095
|
+
printRenderedModuleResult({
|
|
2096
|
+
detail,
|
|
2097
|
+
diff,
|
|
2098
|
+
extraGuideDepths: liveOutputState.pendingRecipeClosureGuideDepths,
|
|
2099
|
+
name,
|
|
2100
|
+
status
|
|
2101
|
+
});
|
|
2102
|
+
clearPendingRecipeClosureGuides();
|
|
2111
2103
|
}
|
|
2112
|
-
function
|
|
2113
|
-
|
|
2114
|
-
|
|
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"));
|
|
2115
2110
|
}
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
case SYSTEM_HOST_KIND: {
|
|
2126
|
-
assertValidSystemHostMetaEntry(entry);
|
|
2127
|
-
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}`));
|
|
2128
2118
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
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}`));
|
|
2131
2128
|
}
|
|
2132
|
-
|
|
2133
|
-
|
|
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}`));
|
|
2134
2134
|
}
|
|
2135
2135
|
}
|
|
2136
2136
|
}
|
|
2137
|
-
function
|
|
2138
|
-
if (
|
|
2139
|
-
|
|
2140
|
-
assertValidModuleMetaEntry(entry);
|
|
2137
|
+
function printVerboseErrorBlock(label, content) {
|
|
2138
|
+
if (!content.trim()) {
|
|
2139
|
+
return;
|
|
2141
2140
|
}
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
if (name.length === 0) return false;
|
|
2146
|
-
const segments = name.split(".");
|
|
2147
|
-
for (const segment of segments) {
|
|
2148
|
-
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}`));
|
|
2149
2144
|
}
|
|
2150
|
-
return true;
|
|
2151
2145
|
}
|
|
2152
|
-
function
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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;
|
|
2157
2165
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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);
|
|
2162
2177
|
}
|
|
2163
2178
|
}
|
|
2164
|
-
function
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
return
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
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
|
+
}
|
|
2186
2205
|
}
|
|
2187
|
-
|
|
2188
|
-
if (
|
|
2189
|
-
|
|
2190
|
-
|
|
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;
|
|
2191
2215
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
if (!
|
|
2195
|
-
|
|
2196
|
-
|
|
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
|
+
}
|
|
2197
2224
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
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 "))}`);
|
|
2200
2237
|
}
|
|
2201
2238
|
|
|
2202
2239
|
// src/dryRunRecipe.ts
|
|
@@ -2215,109 +2252,601 @@ async function executeDryRunBlockingModule(parameters) {
|
|
|
2215
2252
|
const result = childModule._applyDryRun == null ? await childModule.apply(connection, environment) : await childModule._applyDryRun(connection, environment, {
|
|
2216
2253
|
shutdownSignal: parameters.shutdownSignal
|
|
2217
2254
|
});
|
|
2218
|
-
const nextEnvironment = result.meta == null ? environment : await mergeEnvironmentFromMeta(environment, result.meta);
|
|
2219
|
-
const diffOutput = diff ? result.diff : void 0;
|
|
2220
|
-
printModuleResult(
|
|
2221
|
-
childModule.name,
|
|
2222
|
-
result.status,
|
|
2223
|
-
result._dryRunDetail ?? "(dry-run)",
|
|
2224
|
-
diffOutput
|
|
2225
|
-
);
|
|
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);
|
|
2226
2502
|
if (result.status === "failed" && result.error != null) {
|
|
2227
2503
|
printCommandFailure(result.error, verbose);
|
|
2228
2504
|
}
|
|
2505
|
+
const environment = result.status === "failed" ? currentEnvironment : await mergeEnvironmentFromMeta(currentEnvironment, result.meta);
|
|
2229
2506
|
return {
|
|
2230
|
-
|
|
2507
|
+
_flushSignals: result._flushSignals,
|
|
2508
|
+
_stopRun: result._stopRun,
|
|
2509
|
+
env: environment,
|
|
2231
2510
|
meta: result.meta,
|
|
2232
|
-
|
|
2233
|
-
status: result.status,
|
|
2234
|
-
stopRun: result._stopRun
|
|
2511
|
+
status: result.status
|
|
2235
2512
|
};
|
|
2236
2513
|
}
|
|
2237
|
-
async function
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
childModule,
|
|
2247
|
-
connection,
|
|
2248
|
-
diff,
|
|
2249
|
-
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,
|
|
2250
2523
|
shutdownSignal: parameters.shutdownSignal,
|
|
2251
|
-
|
|
2524
|
+
signalHooks: parameters.signalHooks,
|
|
2525
|
+
verbose: parameters.verbose
|
|
2252
2526
|
});
|
|
2253
2527
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
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);
|
|
2258
2535
|
}
|
|
2259
|
-
function
|
|
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) {
|
|
2260
2549
|
return {
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2550
|
+
env: environment,
|
|
2551
|
+
meta: void 0,
|
|
2552
|
+
signalsPending: false,
|
|
2553
|
+
status: "failed"
|
|
2264
2554
|
};
|
|
2265
2555
|
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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) {
|
|
2269
2570
|
if (parameters.shutdownSignal() != null) {
|
|
2270
|
-
return
|
|
2271
|
-
aggregatedMeta: accumulator.aggregatedMeta,
|
|
2272
|
-
aggregatedStatus: accumulator.aggregatedStatus,
|
|
2273
|
-
currentEnvironment: accumulator.currentEnvironment
|
|
2274
|
-
});
|
|
2571
|
+
return { kind: "step", step: INTERRUPTED_BEFORE_APPLY };
|
|
2275
2572
|
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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,
|
|
2280
2596
|
shutdownSignal: parameters.shutdownSignal,
|
|
2597
|
+
signalHooks: parameters.signalHooks,
|
|
2598
|
+
signals: parameters.signals,
|
|
2281
2599
|
ssh: parameters.ssh,
|
|
2282
2600
|
verbose: parameters.verbose
|
|
2283
2601
|
});
|
|
2284
|
-
|
|
2285
|
-
accumulator = applyDryRunChildResult(accumulator, result);
|
|
2602
|
+
state = applyRecipeSignalStatus(state, signalStatus);
|
|
2286
2603
|
}
|
|
2287
|
-
|
|
2604
|
+
if (state.status === "failed" || state.stopRun === true) {
|
|
2605
|
+
return { kind: "break", state };
|
|
2606
|
+
}
|
|
2607
|
+
return { kind: "continue", state };
|
|
2288
2608
|
}
|
|
2289
|
-
async function
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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,
|
|
2302
2632
|
shutdownSignal,
|
|
2633
|
+
signalHooks: parameters.signalHooks,
|
|
2303
2634
|
ssh,
|
|
2304
|
-
|
|
2635
|
+
targetModule: currentModule,
|
|
2636
|
+
verbose
|
|
2305
2637
|
});
|
|
2306
|
-
|
|
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
|
+
}
|
|
2307
2704
|
return {
|
|
2308
|
-
|
|
2309
|
-
meta:
|
|
2310
|
-
|
|
2311
|
-
status: loopResult.aggregatedStatus
|
|
2705
|
+
_stopRun: state.stopRun,
|
|
2706
|
+
meta: state.meta,
|
|
2707
|
+
status: state.status
|
|
2312
2708
|
};
|
|
2313
2709
|
});
|
|
2314
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
|
+
}
|
|
2315
2786
|
|
|
2316
|
-
// src/
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
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));
|
|
2321
2850
|
}
|
|
2322
2851
|
|
|
2323
2852
|
// src/runnerHelpers.ts
|
|
@@ -2397,86 +2926,6 @@ function getSignalBus() {
|
|
|
2397
2926
|
return readActiveBus() ?? defaultProcessSignalBus;
|
|
2398
2927
|
}
|
|
2399
2928
|
|
|
2400
|
-
// src/signalOrchestration.ts
|
|
2401
|
-
function handleSignalResult(parameters) {
|
|
2402
|
-
const { hooks, result, signalName, verbose } = parameters;
|
|
2403
|
-
printModuleResult(`signal: ${signalName}`, result.status);
|
|
2404
|
-
if (result.status === "failed" && result.error != null) {
|
|
2405
|
-
printCommandFailure(result.error, verbose);
|
|
2406
|
-
}
|
|
2407
|
-
hooks?.onSignalFinished?.(result.status);
|
|
2408
|
-
return result.status === "failed" ? "failed" : "changed";
|
|
2409
|
-
}
|
|
2410
|
-
function handleSignalFailure(parameters) {
|
|
2411
|
-
const { error, hooks, signalName, verbose } = parameters;
|
|
2412
|
-
printModuleResult(`signal: ${signalName}`, "failed");
|
|
2413
|
-
printCommandFailure(error, verbose);
|
|
2414
|
-
hooks?.onSignalFinished?.("failed");
|
|
2415
|
-
return "failed";
|
|
2416
|
-
}
|
|
2417
|
-
async function applySignalMeta(parameters) {
|
|
2418
|
-
assertValidModuleMetaEntries(parameters.result.meta);
|
|
2419
|
-
const nextEnvironment = parameters.result.status === "failed" ? parameters.currentEnvironment : await mergeEnvironmentFromMeta(parameters.currentEnvironment, parameters.result.meta);
|
|
2420
|
-
await parameters.onSignalStep?.({
|
|
2421
|
-
env: nextEnvironment,
|
|
2422
|
-
meta: parameters.result.meta,
|
|
2423
|
-
status: parameters.result.status
|
|
2424
|
-
});
|
|
2425
|
-
return nextEnvironment;
|
|
2426
|
-
}
|
|
2427
|
-
async function runOneSignal(parameters) {
|
|
2428
|
-
const connection = parameters.signal.local === true ? null : parameters.ssh;
|
|
2429
|
-
startModuleSpinner(`signal: ${parameters.signal.name}`);
|
|
2430
|
-
const result = parameters.shutdownSignal == null ? await parameters.signal.apply(connection, parameters.currentEnvironment) : await parameters.signal.apply(connection, parameters.currentEnvironment, {
|
|
2431
|
-
shutdownSignal: parameters.shutdownSignal
|
|
2432
|
-
});
|
|
2433
|
-
const nextEnvironment = await applySignalMeta({
|
|
2434
|
-
currentEnvironment: parameters.currentEnvironment,
|
|
2435
|
-
onSignalStep: parameters.onSignalStep,
|
|
2436
|
-
result
|
|
2437
|
-
});
|
|
2438
|
-
return {
|
|
2439
|
-
nextEnvironment,
|
|
2440
|
-
status: handleSignalResult({
|
|
2441
|
-
hooks: parameters.hooks,
|
|
2442
|
-
result,
|
|
2443
|
-
signalName: parameters.signal.name,
|
|
2444
|
-
verbose: parameters.verbose
|
|
2445
|
-
})
|
|
2446
|
-
};
|
|
2447
|
-
}
|
|
2448
|
-
async function runSignalModules(parameters) {
|
|
2449
|
-
const getShutdownSignal = parameters.shutdownSignal ?? (() => null);
|
|
2450
|
-
const verbose = parameters.verbose ?? false;
|
|
2451
|
-
let currentEnvironment = parameters.environment;
|
|
2452
|
-
let status = "changed";
|
|
2453
|
-
for (const signal of parameters.signals) {
|
|
2454
|
-
if (getShutdownSignal() != null) break;
|
|
2455
|
-
parameters.hooks?.onSignalStarted?.();
|
|
2456
|
-
try {
|
|
2457
|
-
const signalStep = await runOneSignal({
|
|
2458
|
-
currentEnvironment,
|
|
2459
|
-
hooks: parameters.hooks,
|
|
2460
|
-
onSignalStep: parameters.onSignalStep,
|
|
2461
|
-
shutdownSignal: parameters.shutdownSignal,
|
|
2462
|
-
signal,
|
|
2463
|
-
ssh: parameters.ssh,
|
|
2464
|
-
verbose
|
|
2465
|
-
});
|
|
2466
|
-
currentEnvironment = signalStep.nextEnvironment;
|
|
2467
|
-
if (signalStep.status === "failed") status = "failed";
|
|
2468
|
-
} catch (error) {
|
|
2469
|
-
status = handleSignalFailure({
|
|
2470
|
-
error,
|
|
2471
|
-
hooks: parameters.hooks,
|
|
2472
|
-
signalName: signal.name,
|
|
2473
|
-
verbose
|
|
2474
|
-
});
|
|
2475
|
-
}
|
|
2476
|
-
}
|
|
2477
|
-
return status;
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
2929
|
// src/ssh.ts
|
|
2481
2930
|
import { createHash as createHash2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
2482
2931
|
import { createReadStream as createReadStream2 } from "fs";
|
|
@@ -2878,6 +3327,8 @@ async function sftpUploadContent(client, content, remotePath, timeout = SFTP_TIM
|
|
|
2878
3327
|
// src/terminal.ts
|
|
2879
3328
|
import { createInterface } from "readline";
|
|
2880
3329
|
import { Writable } from "stream";
|
|
3330
|
+
var ASCII_ESC2 = 27;
|
|
3331
|
+
var ANSI_SHOW_CURSOR2 = `${String.fromCharCode(ASCII_ESC2)}[?25h`;
|
|
2881
3332
|
function normalizePromptAbortReason(reason) {
|
|
2882
3333
|
return reason instanceof Error ? reason : new Error(String(reason));
|
|
2883
3334
|
}
|
|
@@ -2934,6 +3385,9 @@ async function promptTerminal(question, hidden = false, options) {
|
|
|
2934
3385
|
const output = hidden ? createHiddenPromptOutput(process.stderr) : process.stderr;
|
|
2935
3386
|
const rl = createInterface({ input: process.stdin, output });
|
|
2936
3387
|
const abortSignal = options?.abortSignal;
|
|
3388
|
+
if (process.stderr.isTTY) {
|
|
3389
|
+
process.stderr.write(ANSI_SHOW_CURSOR2);
|
|
3390
|
+
}
|
|
2937
3391
|
if (hidden) {
|
|
2938
3392
|
process.stderr.write(question);
|
|
2939
3393
|
}
|
|
@@ -3011,48 +3465,36 @@ function expandHomePath(path) {
|
|
|
3011
3465
|
if (path.startsWith("~/")) return join3(homedir2(), path.slice(2));
|
|
3012
3466
|
return path;
|
|
3013
3467
|
}
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
`[ssh.writeFile: ${remotePath}] missing options.mode; pass { mode: "0644" } or another explicit file mode`
|
|
3018
|
-
);
|
|
3019
|
-
}
|
|
3468
|
+
var DEFAULT_FILE_MODE = "0600";
|
|
3469
|
+
function resolveFileMode(operation, remotePath, options) {
|
|
3470
|
+
const mode = options?.mode ?? DEFAULT_FILE_MODE;
|
|
3020
3471
|
try {
|
|
3021
|
-
validateMode(
|
|
3472
|
+
validateMode(mode);
|
|
3022
3473
|
} catch (error) {
|
|
3023
3474
|
const reason = error instanceof Error ? error.message : String(error);
|
|
3024
|
-
throw new Error(
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
);
|
|
3475
|
+
throw new Error(`[ssh.${operation}: ${remotePath}] invalid options.mode "${mode}": ${reason}`, {
|
|
3476
|
+
cause: error
|
|
3477
|
+
});
|
|
3028
3478
|
}
|
|
3029
|
-
return
|
|
3479
|
+
return mode;
|
|
3030
3480
|
}
|
|
3031
3481
|
var COMMAND_TIMEOUT = 12e4;
|
|
3032
|
-
var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
|
|
3033
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
|
+
}
|
|
3034
3489
|
var EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
3035
3490
|
var DISCONNECT_DESTROY_FALLBACK_MS = 5e3;
|
|
3036
3491
|
var JITTER_BASE = 0.75;
|
|
3037
3492
|
var JITTER_RANGE = 0.5;
|
|
3038
3493
|
var RECONNECT_BASE_DELAY = 1e3;
|
|
3039
3494
|
var RECONNECT_MAX_DELAY = 3e4;
|
|
3040
|
-
var RAW_OUTPUT_ERROR_SNIPPET_LENGTH = 500;
|
|
3041
3495
|
function getAbortReason(signal) {
|
|
3042
3496
|
return signal.reason instanceof Error ? signal.reason : new Error("SSH operation aborted");
|
|
3043
3497
|
}
|
|
3044
|
-
function truncateRawOutputErrorSnippet(text) {
|
|
3045
|
-
let count = 0;
|
|
3046
|
-
let sliceEnd = 0;
|
|
3047
|
-
for (const char of text) {
|
|
3048
|
-
if (count >= RAW_OUTPUT_ERROR_SNIPPET_LENGTH) {
|
|
3049
|
-
return `${text.slice(0, sliceEnd)}\u2026(truncated)`;
|
|
3050
|
-
}
|
|
3051
|
-
sliceEnd += char.length;
|
|
3052
|
-
count++;
|
|
3053
|
-
}
|
|
3054
|
-
return text;
|
|
3055
|
-
}
|
|
3056
3498
|
function toError(error) {
|
|
3057
3499
|
return error instanceof Error ? error : new Error(String(error));
|
|
3058
3500
|
}
|
|
@@ -3330,9 +3772,8 @@ var SshConnectionImpl = class {
|
|
|
3330
3772
|
}
|
|
3331
3773
|
return result.stdout;
|
|
3332
3774
|
}
|
|
3333
|
-
async reconnect() {
|
|
3334
|
-
const timeout = this.config
|
|
3335
|
-
const maxAttempts = this.config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
|
|
3775
|
+
async reconnect(options) {
|
|
3776
|
+
const { maxAttempts, timeout } = resolveReconnectBudget(this.config, options?.defaultTimeout);
|
|
3336
3777
|
const deadline = Date.now() + timeout;
|
|
3337
3778
|
let attempt = 0;
|
|
3338
3779
|
while (Date.now() < deadline && attempt < maxAttempts) {
|
|
@@ -3374,13 +3815,25 @@ var SshConnectionImpl = class {
|
|
|
3374
3815
|
updateHost(host) {
|
|
3375
3816
|
this.runtime.host = host;
|
|
3376
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
|
+
*/
|
|
3377
3829
|
async uploadFile(localPath, remotePath, options) {
|
|
3378
3830
|
const client = this.ensureClient();
|
|
3379
3831
|
const localFileStats = await statLocalFile(localPath);
|
|
3380
3832
|
const localFileSize = localFileStats.size;
|
|
3381
3833
|
const expectedHash = await computeLocalFileSha256(localPath);
|
|
3382
3834
|
const temporaryPath = await this.createRemoteWritableTempPath(remotePath, "paratix-upload");
|
|
3383
|
-
const temporaryMode =
|
|
3835
|
+
const temporaryMode = resolveFileMode("uploadFile", remotePath, options);
|
|
3836
|
+
let finalized = false;
|
|
3384
3837
|
try {
|
|
3385
3838
|
await sftpUpload(
|
|
3386
3839
|
client,
|
|
@@ -3392,23 +3845,26 @@ var SshConnectionImpl = class {
|
|
|
3392
3845
|
// remote temp path before they reach an operator-visible error.
|
|
3393
3846
|
prepareSecrets(this.buildSecrets())
|
|
3394
3847
|
);
|
|
3395
|
-
await this.
|
|
3396
|
-
|
|
3397
|
-
|
|
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;
|
|
3398
3860
|
await this.ensureRemoteUploadFile({
|
|
3399
3861
|
expectedHash,
|
|
3400
3862
|
expectedSize: localFileSize,
|
|
3863
|
+
remoteHash,
|
|
3401
3864
|
remotePath
|
|
3402
3865
|
});
|
|
3403
3866
|
} finally {
|
|
3404
|
-
|
|
3405
|
-
await this.cleanupRemoteTempFile(temporaryPath);
|
|
3406
|
-
} catch (cleanupError) {
|
|
3407
|
-
process.stderr.write(
|
|
3408
|
-
`Warning: failed to remove temp file ${temporaryPath}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
|
|
3409
|
-
`
|
|
3410
|
-
);
|
|
3411
|
-
}
|
|
3867
|
+
if (!finalized) await this.cleanupUploadTemporaryPath(temporaryPath);
|
|
3412
3868
|
}
|
|
3413
3869
|
}
|
|
3414
3870
|
/**
|
|
@@ -3420,15 +3876,18 @@ var SshConnectionImpl = class {
|
|
|
3420
3876
|
*
|
|
3421
3877
|
* @param remotePath - Destination path on the remote host.
|
|
3422
3878
|
* @param content - The string content to write.
|
|
3423
|
-
* @param options -
|
|
3424
|
-
* @param options.mode - File mode to set via `chmod` on the temp file before
|
|
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}.
|
|
3425
3883
|
*/
|
|
3426
3884
|
async writeFile(remotePath, content, options) {
|
|
3427
3885
|
const client = this.ensureClient();
|
|
3428
3886
|
const remoteTemporary = await this.createRemoteWritableTempPath(remotePath, "paratix-write");
|
|
3429
|
-
const temporaryMode =
|
|
3887
|
+
const temporaryMode = resolveFileMode("writeFile", remotePath, options);
|
|
3430
3888
|
const expectedSize = Buffer.byteLength(content, "utf8");
|
|
3431
3889
|
const expectedHash = createHash2("sha256").update(content, "utf8").digest("hex");
|
|
3890
|
+
let finalized = false;
|
|
3432
3891
|
try {
|
|
3433
3892
|
await sftpUploadContent(
|
|
3434
3893
|
client,
|
|
@@ -3441,18 +3900,28 @@ var SshConnectionImpl = class {
|
|
|
3441
3900
|
// with values registered in the secret sink.
|
|
3442
3901
|
prepareSecrets(this.buildSecrets())
|
|
3443
3902
|
);
|
|
3444
|
-
await this.
|
|
3445
|
-
|
|
3446
|
-
|
|
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;
|
|
3447
3915
|
await this.ensureRemoteWriteFile({
|
|
3448
3916
|
content,
|
|
3449
3917
|
expectedHash,
|
|
3450
3918
|
expectedSize,
|
|
3451
3919
|
mode: temporaryMode,
|
|
3920
|
+
remoteHash,
|
|
3452
3921
|
remotePath
|
|
3453
3922
|
});
|
|
3454
3923
|
} finally {
|
|
3455
|
-
await this.cleanupWriteFileTemporaryPath(remoteTemporary);
|
|
3924
|
+
if (!finalized) await this.cleanupWriteFileTemporaryPath(remoteTemporary);
|
|
3456
3925
|
}
|
|
3457
3926
|
}
|
|
3458
3927
|
async agentSocketExists(agent) {
|
|
@@ -3483,29 +3952,6 @@ var SshConnectionImpl = class {
|
|
|
3483
3952
|
);
|
|
3484
3953
|
}
|
|
3485
3954
|
}
|
|
3486
|
-
async assertRemoteFileSize(remotePath, expectedSize, operation = "uploadFile") {
|
|
3487
|
-
const statCommand = `stat -c '%s' ${shellQuote(remotePath)}`;
|
|
3488
|
-
const rawSize = this.config.user === "root" ? await this.output(statCommand) : await this.outputWithoutSudo(statCommand);
|
|
3489
|
-
const actualSize = Number(rawSize.trim());
|
|
3490
|
-
if (!Number.isFinite(actualSize)) {
|
|
3491
|
-
throw new TypeError(
|
|
3492
|
-
`[ssh.${operation}: ${remotePath}] could not determine remote file size after upload`
|
|
3493
|
-
);
|
|
3494
|
-
}
|
|
3495
|
-
if (actualSize === 0 && expectedSize > 0) {
|
|
3496
|
-
const diskInfo = await this.checkRemoteDiskSpace(remotePath);
|
|
3497
|
-
if (diskInfo != null && diskInfo.availableBytes < expectedSize) {
|
|
3498
|
-
throw new Error(
|
|
3499
|
-
`[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`
|
|
3500
|
-
);
|
|
3501
|
-
}
|
|
3502
|
-
}
|
|
3503
|
-
if (actualSize !== expectedSize) {
|
|
3504
|
-
throw new Error(
|
|
3505
|
-
`[ssh.${operation}: ${remotePath}] remote file size mismatch after upload; expected ${expectedSize} bytes, got ${actualSize}`
|
|
3506
|
-
);
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
3955
|
/**
|
|
3510
3956
|
* R-0000669: build the abort signal that {@link tryConnectOnPort} subscribes
|
|
3511
3957
|
* to. Combines the prompt-level abort signal (which fires from the SIGINT
|
|
@@ -3524,6 +3970,32 @@ var SshConnectionImpl = class {
|
|
|
3524
3970
|
if (this.promptAbortSignal != null) signals.push(this.promptAbortSignal);
|
|
3525
3971
|
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
3526
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
|
+
}
|
|
3527
3999
|
buildEnvPrefix(environment) {
|
|
3528
4000
|
if (environment == null) return "";
|
|
3529
4001
|
for (const key of Object.keys(environment)) {
|
|
@@ -3534,6 +4006,62 @@ var SshConnectionImpl = class {
|
|
|
3534
4006
|
const pairs = Object.entries(environment).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
3535
4007
|
return `${pairs.join(" ")} `;
|
|
3536
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
|
+
}
|
|
3537
4065
|
buildSecrets(extra) {
|
|
3538
4066
|
const cachedPasswordSecret = this.cachedSudoPassword == null ? [] : [() => this.cachedSudoPassword?.toString("utf8") ?? ""];
|
|
3539
4067
|
const registeredSecrets = getRegisteredSecrets();
|
|
@@ -3570,7 +4098,7 @@ var SshConnectionImpl = class {
|
|
|
3570
4098
|
const KB_TO_BYTES = 1024;
|
|
3571
4099
|
try {
|
|
3572
4100
|
const directory = remotePath.includes("/") ? remotePath.slice(0, remotePath.lastIndexOf("/")) || "/" : ".";
|
|
3573
|
-
const dfOutput = await this.output(`df -
|
|
4101
|
+
const dfOutput = await this.output(`df -Pk ${shellQuote(directory)}`);
|
|
3574
4102
|
const lines = dfOutput.trim().split("\n");
|
|
3575
4103
|
if (lines.length < 2) return null;
|
|
3576
4104
|
const columns = lines[1].split(new RegExp("\\s+", "v"));
|
|
@@ -3582,6 +4110,39 @@ var SshConnectionImpl = class {
|
|
|
3582
4110
|
return null;
|
|
3583
4111
|
}
|
|
3584
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
|
+
}
|
|
3585
4146
|
async cleanupPrivilegedRemoteTempFile(remotePath) {
|
|
3586
4147
|
await this.exec(`rm -f -- ${shellQuote(remotePath)}`, {
|
|
3587
4148
|
ignoreExitCode: true,
|
|
@@ -3600,6 +4161,23 @@ var SshConnectionImpl = class {
|
|
|
3600
4161
|
} catch (cleanupError) {
|
|
3601
4162
|
process.stderr.write(
|
|
3602
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())}
|
|
3603
4181
|
`
|
|
3604
4182
|
);
|
|
3605
4183
|
}
|
|
@@ -3743,10 +4321,17 @@ var SshConnectionImpl = class {
|
|
|
3743
4321
|
}
|
|
3744
4322
|
async createRemoteTempPathInDestination(remotePath, prefix) {
|
|
3745
4323
|
const directory = posix.dirname(remotePath);
|
|
3746
|
-
await this.assertDirnameHasNoSymlinkComponent(directory, remotePath);
|
|
3747
4324
|
const template = `${prefix}.XXXXXX`;
|
|
3748
|
-
const
|
|
3749
|
-
|
|
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
|
+
}
|
|
3750
4335
|
return validateMktempPath(directory, path, prefix);
|
|
3751
4336
|
}
|
|
3752
4337
|
async createRemoteWritableTempPath(remotePath, prefix) {
|
|
@@ -3835,14 +4420,24 @@ var SshConnectionImpl = class {
|
|
|
3835
4420
|
* propagates as {@link RemoteStatTransientError} so the caller can retry
|
|
3836
4421
|
* instead of acting on a non-verdict result.
|
|
3837
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
|
+
*
|
|
3838
4428
|
* @param options - Verification inputs.
|
|
3839
4429
|
* @param options.expectedHash - Lowercase hex SHA-256 digest of the local file.
|
|
3840
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.
|
|
3841
4432
|
* @param options.remotePath - The finalized destination path on the remote host.
|
|
3842
4433
|
* @throws {Error} When the remote file is empty (disk-full) or its hash does not match.
|
|
3843
4434
|
*/
|
|
3844
4435
|
async ensureRemoteUploadFile(options) {
|
|
3845
|
-
const verification =
|
|
4436
|
+
const verification = this.classifyRemoteHash(
|
|
4437
|
+
options.remotePath,
|
|
4438
|
+
options.expectedHash,
|
|
4439
|
+
options.remoteHash
|
|
4440
|
+
);
|
|
3846
4441
|
if (verification === "matches") return;
|
|
3847
4442
|
if (verification === "empty") {
|
|
3848
4443
|
const diskInfo = await this.checkRemoteDiskSpace(options.remotePath);
|
|
@@ -3860,7 +4455,11 @@ var SshConnectionImpl = class {
|
|
|
3860
4455
|
);
|
|
3861
4456
|
}
|
|
3862
4457
|
async ensureRemoteWriteFile(options) {
|
|
3863
|
-
const verification =
|
|
4458
|
+
const verification = this.classifyRemoteHash(
|
|
4459
|
+
options.remotePath,
|
|
4460
|
+
options.expectedHash,
|
|
4461
|
+
options.remoteHash
|
|
4462
|
+
);
|
|
3864
4463
|
if (verification === "matches") return;
|
|
3865
4464
|
await this.rewriteRemoteFileViaShell(options.remotePath, options.content, options.mode);
|
|
3866
4465
|
const fallbackVerification = await this.verifyRemoteWriteFile(
|
|
@@ -3913,6 +4512,41 @@ var SshConnectionImpl = class {
|
|
|
3913
4512
|
throw error;
|
|
3914
4513
|
}
|
|
3915
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
|
+
}
|
|
3916
4550
|
async execPrepared(command, options = {}) {
|
|
3917
4551
|
const client = this.ensureClient();
|
|
3918
4552
|
const environmentPrefix = this.buildEnvPrefix(options.env);
|
|
@@ -3969,19 +4603,34 @@ var SshConnectionImpl = class {
|
|
|
3969
4603
|
}
|
|
3970
4604
|
/**
|
|
3971
4605
|
* Execute a command directly over the SSH transport without sudo wrapping.
|
|
3972
|
-
* Used
|
|
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.
|
|
3973
4619
|
*
|
|
3974
4620
|
* @param command - The raw shell command to run.
|
|
3975
4621
|
* @returns The exit code and captured stdout.
|
|
3976
4622
|
*/
|
|
3977
4623
|
async execRaw(command) {
|
|
3978
4624
|
const client = this.ensureClient();
|
|
3979
|
-
|
|
3980
|
-
|
|
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
|
+
);
|
|
3981
4631
|
let activeStream = null;
|
|
3982
4632
|
const timer = setTimeout(() => {
|
|
3983
4633
|
activeStream?.close();
|
|
3984
|
-
const secrets = prepareSecrets(this.buildSecrets());
|
|
3985
4634
|
wrappedReject(
|
|
3986
4635
|
new Error(
|
|
3987
4636
|
`Command timed out after ${COMMAND_TIMEOUT}ms: ${maskPreparedSecrets(command, secrets)}`
|
|
@@ -4000,62 +4649,14 @@ var SshConnectionImpl = class {
|
|
|
4000
4649
|
return;
|
|
4001
4650
|
}
|
|
4002
4651
|
activeStream = stream;
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
DEFAULT_MAX_OUTPUT_BYTES - (stdoutBytes - chunk.length)
|
|
4012
|
-
);
|
|
4013
|
-
if (remainingBytes > 0) {
|
|
4014
|
-
chunks.push(chunk.subarray(0, remainingBytes));
|
|
4015
|
-
}
|
|
4016
|
-
const capturedStdout = Buffer.concat(chunks).toString("utf8");
|
|
4017
|
-
const secrets = prepareSecrets(this.buildSecrets());
|
|
4018
|
-
activeStream?.close();
|
|
4019
|
-
wrappedReject(
|
|
4020
|
-
new Error(
|
|
4021
|
-
`Command stdout exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes: ${maskPreparedSecrets(
|
|
4022
|
-
command,
|
|
4023
|
-
secrets
|
|
4024
|
-
)}
|
|
4025
|
-
stdout: ${truncateRawOutputErrorSnippet(
|
|
4026
|
-
maskPreparedSecrets(capturedStdout, secrets)
|
|
4027
|
-
)}`
|
|
4028
|
-
)
|
|
4029
|
-
);
|
|
4030
|
-
return;
|
|
4031
|
-
}
|
|
4032
|
-
chunks.push(chunk);
|
|
4033
|
-
});
|
|
4034
|
-
stream.on("close", (code, signal) => {
|
|
4035
|
-
clearTimeout(timer);
|
|
4036
|
-
if (signal != null && signal !== "") {
|
|
4037
|
-
const secrets = prepareSecrets(this.buildSecrets());
|
|
4038
|
-
wrappedReject(
|
|
4039
|
-
new Error(
|
|
4040
|
-
`Command failed with signal ${signal}: ${maskPreparedSecrets(command, secrets)}`
|
|
4041
|
-
)
|
|
4042
|
-
);
|
|
4043
|
-
return;
|
|
4044
|
-
}
|
|
4045
|
-
wrappedResolve({
|
|
4046
|
-
exitCode: normalizeSshCloseCode(code),
|
|
4047
|
-
stdout: Buffer.concat(chunks).toString("utf8")
|
|
4048
|
-
});
|
|
4049
|
-
});
|
|
4050
|
-
stream.on("error", (error2) => {
|
|
4051
|
-
clearTimeout(timer);
|
|
4052
|
-
wrappedReject(error2);
|
|
4053
|
-
});
|
|
4054
|
-
stream.stderr.on("data", () => {
|
|
4055
|
-
});
|
|
4056
|
-
stream.stderr.on("error", (error2) => {
|
|
4057
|
-
clearTimeout(timer);
|
|
4058
|
-
wrappedReject(error2);
|
|
4652
|
+
collectStreamOutput({
|
|
4653
|
+
command,
|
|
4654
|
+
options: { ignoreExitCode: true, silent: true },
|
|
4655
|
+
reject: wrappedReject,
|
|
4656
|
+
resolve: wrappedResolve,
|
|
4657
|
+
secrets,
|
|
4658
|
+
stream,
|
|
4659
|
+
timer
|
|
4059
4660
|
});
|
|
4060
4661
|
});
|
|
4061
4662
|
} catch (error) {
|
|
@@ -4064,14 +4665,46 @@ stdout: ${truncateRawOutputErrorSnippet(
|
|
|
4064
4665
|
wrappedReject(toError(error));
|
|
4065
4666
|
}
|
|
4066
4667
|
});
|
|
4668
|
+
return { exitCode: result.code, stdout: result.stdout };
|
|
4067
4669
|
}
|
|
4068
4670
|
async execWithoutSudo(command) {
|
|
4069
4671
|
const result = await this.execRaw(command);
|
|
4070
4672
|
if (result.exitCode !== 0) {
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
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;
|
|
4075
4708
|
}
|
|
4076
4709
|
}
|
|
4077
4710
|
async finalizeRemoteTempFile(temporaryPath, remotePath, mode) {
|
|
@@ -4178,13 +4811,24 @@ trap - EXIT
|
|
|
4178
4811
|
isSudoReadyWithoutProbe() {
|
|
4179
4812
|
return this.config.user === "root" || this.cachedSudoPassword != null;
|
|
4180
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
|
+
}
|
|
4181
4828
|
async outputWithoutSudo(command) {
|
|
4182
4829
|
const result = await this.execRaw(command);
|
|
4183
4830
|
if (result.exitCode !== 0) {
|
|
4184
|
-
|
|
4185
|
-
throw new Error(
|
|
4186
|
-
`Command failed (exit code ${result.exitCode}): ${maskPreparedSecrets(command, secrets)}`
|
|
4187
|
-
);
|
|
4831
|
+
throw new Error(`Command failed (exit code ${result.exitCode}): ${this.maskCommand(command)}`);
|
|
4188
4832
|
}
|
|
4189
4833
|
return result.stdout.trim();
|
|
4190
4834
|
}
|
|
@@ -4352,14 +4996,39 @@ trap - EXIT
|
|
|
4352
4996
|
} catch {
|
|
4353
4997
|
}
|
|
4354
4998
|
}
|
|
4355
|
-
|
|
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;
|
|
4356
5023
|
validateMode(mode);
|
|
4357
|
-
const command = `chmod ${shellQuote(mode)} ${shellQuote(
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
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
|
+
});
|
|
4363
5032
|
}
|
|
4364
5033
|
/**
|
|
4365
5034
|
* Build the sudo-wrapped command string for execution.
|
|
@@ -4409,6 +5078,26 @@ trap - EXIT
|
|
|
4409
5078
|
clearTimeout(fallback);
|
|
4410
5079
|
});
|
|
4411
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
|
+
}
|
|
4412
5101
|
/**
|
|
4413
5102
|
* Iterate over `config.ports` and attempt a connection on each one.
|
|
4414
5103
|
*
|
|
@@ -4492,21 +5181,7 @@ trap - EXIT
|
|
|
4492
5181
|
{ cause: error }
|
|
4493
5182
|
);
|
|
4494
5183
|
}
|
|
4495
|
-
|
|
4496
|
-
throw new RemoteStatTransientError(
|
|
4497
|
-
`[ssh.writeFile: ${remotePath}] sha256sum output exceeds the captured-output cap of ${DEFAULT_MAX_OUTPUT_BYTES} bytes; refusing to derive a verdict from truncated output`
|
|
4498
|
-
);
|
|
4499
|
-
}
|
|
4500
|
-
const actualHash = rawHash.trim().split(new RegExp("\\s+", "v"))[0]?.toLowerCase() ?? "";
|
|
4501
|
-
if (!new RegExp("^[0-9a-f]{64}$", "v").test(actualHash)) {
|
|
4502
|
-
throw new RemoteStatTransientError(
|
|
4503
|
-
`[ssh.writeFile: ${remotePath}] could not determine remote file hash after upload/finalize`
|
|
4504
|
-
);
|
|
4505
|
-
}
|
|
4506
|
-
const expected = expectedHash.toLowerCase();
|
|
4507
|
-
if (actualHash === expected) return "matches";
|
|
4508
|
-
if (actualHash === EMPTY_FILE_SHA256 && expected !== EMPTY_FILE_SHA256) return "empty";
|
|
4509
|
-
return "hash-mismatch";
|
|
5184
|
+
return this.classifyRemoteHash(remotePath, expectedHash, rawHash);
|
|
4510
5185
|
}
|
|
4511
5186
|
/**
|
|
4512
5187
|
* Forward sudo password / `options.input` to a stream while tolerating
|
|
@@ -4551,8 +5226,15 @@ trap - EXIT
|
|
|
4551
5226
|
};
|
|
4552
5227
|
|
|
4553
5228
|
// src/runner.ts
|
|
4554
|
-
var
|
|
4555
|
-
var
|
|
5229
|
+
var ASCII_ESC3 = 27;
|
|
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
|
+
}
|
|
4556
5238
|
function performShutdownBestEffortCleanup() {
|
|
4557
5239
|
try {
|
|
4558
5240
|
stopLiveModuleOutput(true);
|
|
@@ -4565,7 +5247,7 @@ function performShutdownBestEffortCleanup() {
|
|
|
4565
5247
|
} catch {
|
|
4566
5248
|
}
|
|
4567
5249
|
try {
|
|
4568
|
-
process.stdout.write(
|
|
5250
|
+
process.stdout.write(ANSI_SHOW_CURSOR3);
|
|
4569
5251
|
} catch {
|
|
4570
5252
|
}
|
|
4571
5253
|
try {
|
|
@@ -4573,6 +5255,16 @@ function performShutdownBestEffortCleanup() {
|
|
|
4573
5255
|
} catch {
|
|
4574
5256
|
}
|
|
4575
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
|
+
}
|
|
4576
5268
|
function setupShutdownHandlers() {
|
|
4577
5269
|
let receivedSignal = null;
|
|
4578
5270
|
let ssh = null;
|
|
@@ -4602,12 +5294,14 @@ Received ${signal}, shutting down\u2026`);
|
|
|
4602
5294
|
promptAbortSignal: promptAbortController.signal,
|
|
4603
5295
|
setSsh(connection) {
|
|
4604
5296
|
ssh = connection;
|
|
5297
|
+
registerActiveSsh(connection);
|
|
4605
5298
|
},
|
|
4606
5299
|
shutdownAbortSignal: shutdownAbortController.signal,
|
|
4607
5300
|
shutdownSignal: () => receivedSignal
|
|
4608
5301
|
};
|
|
4609
5302
|
}
|
|
4610
5303
|
var DEFAULT_REBOOT_GRACE_SECONDS = 15;
|
|
5304
|
+
var DEFAULT_REBOOT_RECONNECT_TIMEOUT = 3e5;
|
|
4611
5305
|
var REBOOT_GRACE_SECONDS_TO_MS = 1e3;
|
|
4612
5306
|
function createRebootGraceContext(options, shutdownSignal, abortSignal) {
|
|
4613
5307
|
const seconds = options.rebootGraceSeconds ?? DEFAULT_REBOOT_GRACE_SECONDS;
|
|
@@ -4704,9 +5398,6 @@ function handleCaughtStepError(parameters) {
|
|
|
4704
5398
|
printCommandFailure(parameters.error, parameters.verbose);
|
|
4705
5399
|
return { env: parameters.environment, shouldBreak: true, status: "failed" };
|
|
4706
5400
|
}
|
|
4707
|
-
function isRecipe(target) {
|
|
4708
|
-
return "_isRecipe" in target && target._isRecipe;
|
|
4709
|
-
}
|
|
4710
5401
|
function addSshdPorts(ssh, metaEntries) {
|
|
4711
5402
|
const portEntries = metaEntries?.filter((entry) => isSshdPortMetaEntry(entry)) ?? [];
|
|
4712
5403
|
const addedPorts = [];
|
|
@@ -4763,7 +5454,7 @@ async function handleReboot(ssh, metaEntries, rebootGrace) {
|
|
|
4763
5454
|
keepAlive: true
|
|
4764
5455
|
});
|
|
4765
5456
|
try {
|
|
4766
|
-
await ssh.reconnect();
|
|
5457
|
+
await ssh.reconnect({ defaultTimeout: DEFAULT_REBOOT_RECONNECT_TIMEOUT });
|
|
4767
5458
|
} catch (error) {
|
|
4768
5459
|
console.error(`Failed to reconnect after reboot: ${String(error)}`);
|
|
4769
5460
|
throw error;
|
|
@@ -5199,6 +5890,7 @@ function teardownPlaybookResources(parameters) {
|
|
|
5199
5890
|
stopLiveModuleOutput(true);
|
|
5200
5891
|
for (const signal of ["SIGINT", "SIGTERM"])
|
|
5201
5892
|
getSignalBus().off(signal, parameters.handleShutdownSignal);
|
|
5893
|
+
if (parameters.ssh != null) unregisterActiveSsh(parameters.ssh);
|
|
5202
5894
|
parameters.ssh?.disconnect();
|
|
5203
5895
|
}
|
|
5204
5896
|
function initializeRunPlaybookContext(options) {
|
|
@@ -5517,17 +6209,35 @@ var CliUsageError = class extends Error {
|
|
|
5517
6209
|
this.exitCode = exitCode;
|
|
5518
6210
|
}
|
|
5519
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
|
+
}
|
|
5520
6228
|
async function runApplyCommand(file, options, run = runPlaybook) {
|
|
5521
6229
|
if (options.diff && !options.dryRun) {
|
|
5522
6230
|
throw new CliUsageError("--diff requires --dry-run");
|
|
5523
6231
|
}
|
|
5524
|
-
printCliHeader("0.
|
|
6232
|
+
printCliHeader("0.13.0-33ef8ce");
|
|
5525
6233
|
const environmentOverrides = applyCliEnvironmentOverrides(options.env, {
|
|
5526
6234
|
firstRun: options.firstRun
|
|
5527
6235
|
});
|
|
5528
6236
|
const definition = await loadServerDefinitionFromFile(file, {
|
|
5529
6237
|
firstRun: options.firstRun
|
|
5530
6238
|
});
|
|
6239
|
+
const runModules = resolveFilteredRun(definition, options.filter);
|
|
6240
|
+
const targetDefinition = runModules === definition.run ? definition : { ...definition, run: runModules };
|
|
5531
6241
|
const runOptions = {
|
|
5532
6242
|
diff: options.diff,
|
|
5533
6243
|
dryRun: options.dryRun,
|
|
@@ -5538,7 +6248,7 @@ async function runApplyCommand(file, options, run = runPlaybook) {
|
|
|
5538
6248
|
if (options.reconnectTimeout !== void 0) {
|
|
5539
6249
|
runOptions.reconnectTimeout = options.reconnectTimeout * SECONDS_TO_MS;
|
|
5540
6250
|
}
|
|
5541
|
-
await run(
|
|
6251
|
+
await run(targetDefinition, runOptions);
|
|
5542
6252
|
}
|
|
5543
6253
|
function exitAfterApplyError(error, verbose) {
|
|
5544
6254
|
if (error instanceof CliUsageError) {
|
|
@@ -5550,7 +6260,7 @@ function exitAfterApplyError(error, verbose) {
|
|
|
5550
6260
|
process.exit(exitCode);
|
|
5551
6261
|
}
|
|
5552
6262
|
var program = new Command();
|
|
5553
|
-
program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.
|
|
6263
|
+
program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.13.0-33ef8ce");
|
|
5554
6264
|
program.command("apply <file>").description("Apply a server definition").option(
|
|
5555
6265
|
"--diff",
|
|
5556
6266
|
"When combined with --dry-run, show a unified diff per module that would change.",
|
|
@@ -5559,9 +6269,14 @@ program.command("apply <file>").description("Apply a server definition").option(
|
|
|
5559
6269
|
"--dry-run",
|
|
5560
6270
|
"Only check, do not apply. Some modules validate prospective config but cannot verify runtime restarts.",
|
|
5561
6271
|
false
|
|
5562
|
-
).option("--env <key=value...>", "Set env values", collectEnvironment, {}).option("--env-file <path>", "Load dotenv file").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(
|
|
5563
6278
|
"--reconnect-timeout <seconds>",
|
|
5564
|
-
"SSH reconnect timeout (seconds, max 86400)",
|
|
6279
|
+
"SSH reconnect timeout override for reboots and port changes (seconds, max 86400; reboot default 300)",
|
|
5565
6280
|
parseReconnectTimeoutSeconds
|
|
5566
6281
|
).option("--verbose", "Show full stack traces on error", false).action(async (file, options) => {
|
|
5567
6282
|
try {
|
|
@@ -5575,6 +6290,8 @@ program.command("apply <file>").description("Apply a server definition").option(
|
|
|
5575
6290
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
|
|
5576
6291
|
envFile: options.envFile,
|
|
5577
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>
|
|
5578
6295
|
firstRun: options.firstRun,
|
|
5579
6296
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
|
|
5580
6297
|
reconnectTimeout: options.reconnectTimeout,
|
|
@@ -5604,6 +6321,9 @@ function parsePositiveNumber(value, options = {}) {
|
|
|
5604
6321
|
function parseReconnectTimeoutSeconds(value) {
|
|
5605
6322
|
return parsePositiveNumber(value, { max: RECONNECT_TIMEOUT_MAX_SECONDS });
|
|
5606
6323
|
}
|
|
6324
|
+
function collectFilter(value, previous) {
|
|
6325
|
+
return [...previous, value];
|
|
6326
|
+
}
|
|
5607
6327
|
function collectEnvironment(value, previous) {
|
|
5608
6328
|
const eqIndex = value.indexOf("=");
|
|
5609
6329
|
if (eqIndex === -1) {
|
|
@@ -5625,8 +6345,38 @@ function collectEnvironment(value, previous) {
|
|
|
5625
6345
|
const accumulator = /* @__PURE__ */ Object.create(null);
|
|
5626
6346
|
return Object.assign(accumulator, previous, { [key]: value_ });
|
|
5627
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
|
+
}
|
|
5628
6377
|
var entryScript = process.argv[1];
|
|
5629
6378
|
if (isDirectCliExecution(import.meta.url, entryScript)) {
|
|
6379
|
+
installLastResortErrorHandlers();
|
|
5630
6380
|
await program.parseAsync();
|
|
5631
6381
|
}
|
|
5632
6382
|
export {
|
|
@@ -5634,8 +6384,11 @@ export {
|
|
|
5634
6384
|
applyCliEnvironmentOverrides,
|
|
5635
6385
|
collectDefinitionErrors,
|
|
5636
6386
|
collectEnvironment,
|
|
6387
|
+
collectFilter,
|
|
5637
6388
|
exitAfterApplyError,
|
|
6389
|
+
handleLastResortError,
|
|
5638
6390
|
handleTsxLoadFailure,
|
|
6391
|
+
installLastResortErrorHandlers,
|
|
5639
6392
|
isDirectCliExecution,
|
|
5640
6393
|
isFirstRun,
|
|
5641
6394
|
isServerDefinitionLike,
|
|
@@ -5643,9 +6396,10 @@ export {
|
|
|
5643
6396
|
parsePositiveNumber,
|
|
5644
6397
|
parseReconnectTimeoutSeconds,
|
|
5645
6398
|
printExceptionError,
|
|
6399
|
+
resetLastResortHandlerForTests,
|
|
5646
6400
|
resetTsxRegistrationForTests,
|
|
6401
|
+
resolveFilteredRun,
|
|
5647
6402
|
runApplyCommand,
|
|
5648
6403
|
withCliProcessEnvironment,
|
|
5649
6404
|
withSerializedPlaybookImport
|
|
5650
6405
|
};
|
|
5651
|
-
//# sourceMappingURL=cli.js.map
|