paratix 0.4.0 → 0.6.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.
@@ -2,7 +2,7 @@ import {
2
2
  CommandError,
3
3
  assertValidModuleMetaEntries,
4
4
  mergeEnvironmentFromMeta
5
- } from "./chunk-G3BMCQKU.js";
5
+ } from "./chunk-JJRF37BP.js";
6
6
 
7
7
  // src/output.ts
8
8
  import pc from "picocolors";
@@ -102,6 +102,8 @@ var CLI_HEADER_LINES = [
102
102
  " |_| "
103
103
  ];
104
104
  var activeSpinner = null;
105
+ var activeRecipeGuideDepths = [];
106
+ var pendingRecipeClosureGuideDepths = [];
105
107
  var recipeOutputDepth = -1;
106
108
  function renderCliHeader(version) {
107
109
  const versionText = pc.dim(`v${version}`);
@@ -139,6 +141,25 @@ function getModuleStatusText(status) {
139
141
  function getCurrentOutputDepth() {
140
142
  return Math.max(recipeOutputDepth, 0);
141
143
  }
144
+ function getGuideDot(depth) {
145
+ return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
146
+ }
147
+ function buildGuideIndent(baseIndent, options) {
148
+ const indentCharacters = Array.from(baseIndent);
149
+ const guideDepths = [
150
+ ...options?.activeGuideDepths ?? activeRecipeGuideDepths,
151
+ ...options?.extraGuideDepths ?? []
152
+ ];
153
+ for (const guideDepth of guideDepths) {
154
+ const guideCharacterIndex = OUTPUT_INDENT_UNIT.length * (guideDepth + 1);
155
+ if (guideCharacterIndex >= indentCharacters.length) continue;
156
+ indentCharacters[guideCharacterIndex] = options?.colorize === false ? "\xB7" : getGuideDot(guideDepth);
157
+ }
158
+ return indentCharacters.join("");
159
+ }
160
+ function clearPendingRecipeClosureGuides() {
161
+ pendingRecipeClosureGuideDepths = [];
162
+ }
142
163
  function getModuleIndent() {
143
164
  if (recipeOutputDepth < 0) {
144
165
  return OUTPUT_INDENT_UNIT;
@@ -146,7 +167,8 @@ function getModuleIndent() {
146
167
  return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
147
168
  }
148
169
  function getRecipeHeaderIndent() {
149
- return recipeOutputDepth < 0 ? "" : OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
170
+ if (recipeOutputDepth < 0) return "";
171
+ return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
150
172
  }
151
173
  function getErrorIndent() {
152
174
  return `${getModuleIndent()}\u2502 `;
@@ -159,16 +181,19 @@ async function withRecipeOutputScope(scopedOperation) {
159
181
  try {
160
182
  return await scopedOperation();
161
183
  } finally {
184
+ activeRecipeGuideDepths = activeRecipeGuideDepths.filter((depth) => depth !== recipeOutputDepth);
185
+ pendingRecipeClosureGuideDepths = [recipeOutputDepth];
162
186
  recipeOutputDepth -= 1;
163
187
  }
164
188
  }
165
189
  function renderModuleLine(parameters) {
166
- const { detail, name, status, waitingFrame } = parameters;
167
- const indent = getModuleIndent();
190
+ const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters;
191
+ const baseIndent = getModuleIndent();
192
+ const indent = buildGuideIndent(baseIndent, { extraGuideDepths });
168
193
  const icon = getModuleIcon(status, waitingFrame);
169
194
  const statusText = getModuleStatusText(status);
170
195
  const detailSuffix = detail == null ? "" : ` ${pc.dim(detail)}`;
171
- const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - indent.length, MIN_MODULE_NAME_WIDTH);
196
+ const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
172
197
  return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
173
198
  }
174
199
  function writeAnimatedModuleLine(line) {
@@ -187,6 +212,7 @@ function stopAnimatedModuleLine(clearCurrentLine = false) {
187
212
  }
188
213
  function startModuleSpinner(name, detail) {
189
214
  if (!supportsAnimatedModuleOutput()) return;
215
+ clearPendingRecipeClosureGuides();
190
216
  stopAnimatedModuleLine();
191
217
  const displayModule = formatDisplayModule({
192
218
  continuationIndentWidth: getContinuationIndent().length,
@@ -222,8 +248,12 @@ function startModuleSpinner(name, detail) {
222
248
  }
223
249
  function printRecipeHeader(name) {
224
250
  stopAnimatedModuleLine(true);
251
+ clearPendingRecipeClosureGuides();
225
252
  const header = pc.bold(pc.blue(`[${name}]`));
226
- console.log(`${getRecipeHeaderIndent()}${header}`);
253
+ console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
254
+ if (recipeOutputDepth >= 0) {
255
+ activeRecipeGuideDepths = [...activeRecipeGuideDepths, recipeOutputDepth];
256
+ }
227
257
  }
228
258
  function printRunContext(parameters) {
229
259
  const mode = parameters.dryRun ? pc.yellow("dry-run") : pc.green("apply");
@@ -232,34 +262,56 @@ function printRunContext(parameters) {
232
262
  pc.dim(`Run ${parameters.name} \xB7 host ${parameters.host} \xB7 ports ${ports} \xB7 mode ${mode}`)
233
263
  );
234
264
  }
235
- function printModuleResult(name, status, detail) {
265
+ function printRenderedModuleResult(parameters) {
266
+ const extraGuideDepths = parameters.extraGuideDepths ?? [];
236
267
  const displayModule = formatDisplayModule({
237
- continuationIndentWidth: getContinuationIndent().length,
238
- detail,
239
- name,
240
- status,
268
+ continuationIndentWidth: `${buildGuideIndent(
269
+ OUTPUT_INDENT_UNIT.repeat(Math.max(getCurrentOutputDepth() + 2, 1)),
270
+ { extraGuideDepths }
271
+ )} `.length,
272
+ detail: parameters.detail,
273
+ name: parameters.name,
274
+ status: parameters.status,
241
275
  terminalColumns: process.stdout.columns
242
276
  });
243
277
  const line = renderModuleLine({
244
278
  detail: displayModule.detail,
279
+ extraGuideDepths,
245
280
  name: displayModule.name,
246
- status
281
+ status: parameters.status
247
282
  });
248
283
  if (supportsAnimatedModuleOutput() && activeSpinner != null) {
249
284
  stopAnimatedModuleLine();
250
285
  writeAnimatedModuleLine(line);
251
286
  process.stdout.write("\n");
252
287
  for (const detailLine of displayModule.detailLines) {
253
- process.stdout.write(`${getContinuationIndent()}${pc.dim(detailLine)}
254
- `);
288
+ process.stdout.write(
289
+ `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${pc.dim(detailLine)}
290
+ `
291
+ );
255
292
  }
256
293
  return;
257
294
  }
258
295
  console.log(line);
259
296
  for (const detailLine of displayModule.detailLines) {
260
- console.log(`${getContinuationIndent()}${pc.dim(detailLine)}`);
297
+ console.log(
298
+ `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${pc.dim(detailLine)}`
299
+ );
261
300
  }
262
301
  }
302
+ function printModuleResult(name, status, detail) {
303
+ clearPendingRecipeClosureGuides();
304
+ printRenderedModuleResult({ detail, name, status });
305
+ }
306
+ function printRecipeModuleResult(name, status, detail) {
307
+ printRenderedModuleResult({
308
+ detail,
309
+ extraGuideDepths: pendingRecipeClosureGuideDepths,
310
+ name,
311
+ status
312
+ });
313
+ clearPendingRecipeClosureGuides();
314
+ }
263
315
  function printCommandError(stdout, stderr) {
264
316
  const lines = [];
265
317
  if (stderr.trim()) {
@@ -435,8 +487,9 @@ export {
435
487
  printRecipeHeader,
436
488
  printRunContext,
437
489
  printModuleResult,
490
+ printRecipeModuleResult,
438
491
  printCommandFailure,
439
492
  printSummary,
440
493
  runSignalModules
441
494
  };
442
- //# sourceMappingURL=chunk-EGP3QRLV.js.map
495
+ //# sourceMappingURL=chunk-IUY5BJHA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/output.ts","../src/outputFormatting.ts","../src/signalOrchestration.ts"],"sourcesContent":["/* eslint-disable max-lines -- CLI output rendering is intentionally kept together */\nimport pc from \"picocolors\"\n\nimport type { ModuleStatus } from \"./types.js\"\n\nimport { fitAnimatedModuleLine, formatDisplayModule } from \"./outputFormatting.js\"\nimport { CommandError } from \"./sshHelpers.js\"\n\nconst MODULE_NAME_WIDTH = 56\nconst MIN_MODULE_NAME_WIDTH = 12\nconst OUTPUT_INDENT_UNIT = \" \"\nconst SPINNER_FRAME_INTERVAL_MS = 80\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"]\ntype DisplayStatus = \"waiting\" | ModuleStatus\n\nconst STATUS_ICONS: Record<DisplayStatus, string> = {\n changed: pc.yellow(\"\\u21ba\"),\n failed: pc.red(\"\\u2717\"),\n ok: pc.green(\"\\u2713\"),\n skipped: pc.dim(\"\\u2298\"),\n waiting: pc.cyan(\"\\u23f8\"),\n}\n\nconst CLI_HEADER_LINES = [\n \" _ _ \",\n \" | | (_) \",\n \" _ __ __ _ _ __ __ _| |_ ___ __\",\n \" | '_ \\\\ / _` | '__/ _` | __| \\\\ \\\\/ /\",\n \" | |_) | (_| | | | (_| | |_| |> < \",\n \" | .__/ \\\\__,_|_| \\\\__,_|\\\\__|_/_/\\\\_\\\\\",\n \" | | \",\n \" |_| \",\n]\n\ntype ActiveSpinner = {\n detail?: string\n frameIndex: number\n interval: NodeJS.Timeout\n}\n\nlet activeSpinner: ActiveSpinner | null = null\nlet activeRecipeGuideDepths: number[] = []\nlet pendingRecipeClosureGuideDepths: number[] = []\nlet recipeOutputDepth = -1\n\nexport function renderCliHeader(version: string): string {\n const versionText = pc.dim(`v${version}`)\n return `${pc.cyan(CLI_HEADER_LINES.join(\"\\n\"))}${versionText}\\n`\n}\n\nexport function printCliHeader(version: string): void {\n console.log(renderCliHeader(version))\n}\n\nfunction supportsAnimatedModuleOutput(): boolean {\n return (\n process.stdout.isTTY &&\n typeof process.stdout.clearLine === \"function\" &&\n typeof process.stdout.cursorTo === \"function\"\n )\n}\n\nfunction getModuleIcon(status: DisplayStatus, waitingFrame?: string): string {\n return status === \"waiting\" ? pc.cyan(waitingFrame ?? \"|\") : STATUS_ICONS[status]\n}\n\nfunction getModuleStatusText(status: DisplayStatus): string {\n switch (status) {\n case \"changed\": {\n return pc.yellow(status)\n }\n case \"failed\": {\n return pc.red(status)\n }\n case \"ok\": {\n return pc.green(status)\n }\n case \"skipped\": {\n return pc.dim(status)\n }\n case \"waiting\": {\n return pc.cyan(\"running\")\n }\n }\n}\n\nfunction getCurrentOutputDepth(): number {\n return Math.max(recipeOutputDepth, 0)\n}\n\nfunction getGuideDot(depth: number): string {\n return depth % 2 === 0 ? pc.gray(\"·\") : pc.cyan(\"·\")\n}\n\nfunction buildGuideIndent(\n baseIndent: string,\n options?: {\n activeGuideDepths?: number[]\n colorize?: boolean\n extraGuideDepths?: number[]\n }\n): string {\n const indentCharacters = Array.from(baseIndent)\n const guideDepths = [\n ...(options?.activeGuideDepths ?? activeRecipeGuideDepths),\n ...(options?.extraGuideDepths ?? []),\n ]\n\n for (const guideDepth of guideDepths) {\n const guideCharacterIndex = OUTPUT_INDENT_UNIT.length * (guideDepth + 1)\n if (guideCharacterIndex >= indentCharacters.length) continue\n indentCharacters[guideCharacterIndex] =\n options?.colorize === false ? \"·\" : getGuideDot(guideDepth)\n }\n\n return indentCharacters.join(\"\")\n}\n\nfunction clearPendingRecipeClosureGuides(): void {\n pendingRecipeClosureGuideDepths = []\n}\n\nfunction getModuleIndent(): string {\n if (recipeOutputDepth < 0) {\n return OUTPUT_INDENT_UNIT\n }\n\n return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2)\n}\n\nfunction getRecipeHeaderIndent(): string {\n if (recipeOutputDepth < 0) return \"\"\n return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1)\n}\n\nfunction getErrorIndent(): string {\n return `${getModuleIndent()}│ `\n}\n\nfunction getContinuationIndent(): string {\n return `${getModuleIndent()} `\n}\n\nexport async function withRecipeOutputScope<T>(\n scopedOperation: () => Promise<T> | T\n): Promise<T> {\n recipeOutputDepth += 1\n try {\n return await scopedOperation()\n } finally {\n activeRecipeGuideDepths = activeRecipeGuideDepths.filter((depth) => depth !== recipeOutputDepth)\n pendingRecipeClosureGuideDepths = [recipeOutputDepth]\n recipeOutputDepth -= 1\n }\n}\n\nfunction renderModuleLine(parameters: {\n detail?: string\n extraGuideDepths?: number[]\n name: string\n status: DisplayStatus\n waitingFrame?: string\n}): string {\n const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters\n const baseIndent = getModuleIndent()\n const indent = buildGuideIndent(baseIndent, { extraGuideDepths })\n const icon = getModuleIcon(status, waitingFrame)\n const statusText = getModuleStatusText(status)\n const detailSuffix = detail == null ? \"\" : ` ${pc.dim(detail)}`\n const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH)\n return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`\n}\n\nfunction writeAnimatedModuleLine(line: string): void {\n process.stdout.clearLine(0)\n process.stdout.cursorTo(0)\n process.stdout.write(fitAnimatedModuleLine(line, process.stdout.columns))\n}\n\nfunction stopAnimatedModuleLine(clearCurrentLine = false): void {\n if (activeSpinner == null) return\n\n clearInterval(activeSpinner.interval)\n activeSpinner = null\n\n if (clearCurrentLine && supportsAnimatedModuleOutput()) {\n process.stdout.clearLine(0)\n process.stdout.cursorTo(0)\n }\n}\n\nexport function startModuleSpinner(name: string, detail?: string): void {\n if (!supportsAnimatedModuleOutput()) return\n\n clearPendingRecipeClosureGuides()\n stopAnimatedModuleLine()\n const displayModule = formatDisplayModule({\n continuationIndentWidth: getContinuationIndent().length,\n detail,\n name,\n status: \"waiting\",\n terminalColumns: process.stdout.columns,\n })\n\n const spinner: ActiveSpinner = {\n detail: displayModule.detail,\n frameIndex: 0,\n interval: setInterval(() => {\n spinner.frameIndex = (spinner.frameIndex + 1) % SPINNER_FRAMES.length\n writeAnimatedModuleLine(\n renderModuleLine({\n detail: spinner.detail,\n name: displayModule.name,\n status: \"waiting\",\n waitingFrame: SPINNER_FRAMES[spinner.frameIndex],\n })\n )\n }, SPINNER_FRAME_INTERVAL_MS),\n }\n\n activeSpinner = spinner\n writeAnimatedModuleLine(\n renderModuleLine({\n detail: displayModule.detail,\n name: displayModule.name,\n status: \"waiting\",\n waitingFrame: SPINNER_FRAMES[0],\n })\n )\n}\n\nexport function resetLiveOutputForTests(): void {\n stopAnimatedModuleLine()\n activeRecipeGuideDepths = []\n clearPendingRecipeClosureGuides()\n recipeOutputDepth = -1\n}\n\n/**\n * Print a bold, colored header line marking the start of a recipe run.\n * @param name - The recipe or server name to display.\n */\nexport function printRecipeHeader(name: string): void {\n stopAnimatedModuleLine(true)\n clearPendingRecipeClosureGuides()\n const header = pc.bold(pc.blue(`[${name}]`))\n console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`)\n if (recipeOutputDepth >= 0) {\n activeRecipeGuideDepths = [...activeRecipeGuideDepths, recipeOutputDepth]\n }\n}\n\nexport function printRunContext(parameters: {\n dryRun: boolean\n host: string\n name: string\n ports: number[]\n}): void {\n const mode = parameters.dryRun ? pc.yellow(\"dry-run\") : pc.green(\"apply\")\n const ports = parameters.ports.join(\", \")\n console.log(\n pc.dim(`Run ${parameters.name} · host ${parameters.host} · ports ${ports} · mode ${mode}`)\n )\n}\n\nfunction printRenderedModuleResult(parameters: {\n detail?: string\n extraGuideDepths?: number[]\n name: string\n status: DisplayStatus\n}): void {\n const extraGuideDepths = parameters.extraGuideDepths ?? []\n const displayModule = formatDisplayModule({\n continuationIndentWidth: `${buildGuideIndent(\n OUTPUT_INDENT_UNIT.repeat(Math.max(getCurrentOutputDepth() + 2, 1)),\n { extraGuideDepths }\n )} `.length,\n detail: parameters.detail,\n name: parameters.name,\n status: parameters.status,\n terminalColumns: process.stdout.columns,\n })\n const line = renderModuleLine({\n detail: displayModule.detail,\n extraGuideDepths,\n name: displayModule.name,\n status: parameters.status,\n })\n\n if (supportsAnimatedModuleOutput() && activeSpinner != null) {\n stopAnimatedModuleLine()\n writeAnimatedModuleLine(line)\n process.stdout.write(\"\\n\")\n for (const detailLine of displayModule.detailLines) {\n process.stdout.write(\n `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${pc.dim(detailLine)}\\n`\n )\n }\n return\n }\n\n console.log(line)\n for (const detailLine of displayModule.detailLines) {\n console.log(\n `${buildGuideIndent(getContinuationIndent(), { extraGuideDepths })}${pc.dim(detailLine)}`\n )\n }\n}\n\n/**\n * Print a single module result row with a status icon, name, and colored status label.\n *\n * @param name - The module name shown in the left column.\n * @param status - One of the known status strings (`ok`, `changed`, `skipped`, `failed`).\n * @param detail - Optional short detail appended in dim text after the status.\n */\nexport function printModuleResult(name: string, status: DisplayStatus, detail?: string): void {\n clearPendingRecipeClosureGuides()\n printRenderedModuleResult({ detail, name, status })\n}\n\nexport function printRecipeModuleResult(name: string, status: DisplayStatus, detail?: string): void {\n printRenderedModuleResult({\n detail,\n extraGuideDepths: pendingRecipeClosureGuideDepths,\n name,\n status,\n })\n clearPendingRecipeClosureGuides()\n}\n\n/**\n * Print captured stderr and stdout from a failed command in a red bordered block.\n * Outputs nothing if both streams are empty.\n *\n * @param stdout - Captured standard output of the failed command.\n * @param stderr - Captured standard error of the failed command.\n */\nexport function printCommandError(stdout: string, stderr: string): void {\n const lines: string[] = []\n if (stderr.trim()) {\n lines.push(...stderr.trim().split(\"\\n\"))\n }\n if (stdout.trim()) {\n lines.push(...stdout.trim().split(\"\\n\"))\n }\n if (lines.length > 0) {\n console.error(pc.red(`${getErrorIndent()}Error output:`))\n for (const line of lines) {\n console.error(pc.red(`${getErrorIndent()}${line}`))\n }\n }\n}\n\n/**\n * Print the full (untruncated) stdout and stderr of a failed command.\n * Used in verbose mode to show the complete output that was truncated in the error message.\n *\n * @param stdout - Full standard output of the failed command.\n * @param stderr - Full standard error of the failed command.\n */\nexport function printVerboseCommandError(stdout: string, stderr: string): void {\n if (stderr.trim()) {\n console.error(pc.red(`${getErrorIndent()}Full stderr:`))\n for (const line of stderr.trim().split(\"\\n\")) {\n console.error(pc.red(`${getErrorIndent()}${line}`))\n }\n }\n if (stdout.trim()) {\n console.error(pc.red(`${getErrorIndent()}Full stdout:`))\n for (const line of stdout.trim().split(\"\\n\")) {\n console.error(pc.red(`${getErrorIndent()}${line}`))\n }\n }\n}\n\nfunction printVerboseErrorBlock(label: string, content: string): void {\n if (!content.trim()) {\n return\n }\n\n console.error(pc.red(`${getErrorIndent()}${label}`))\n for (const line of content.trim().split(\"\\n\")) {\n console.error(pc.red(`${getErrorIndent()}${line}`))\n }\n}\n\nfunction getErrorCause(error: Error): unknown {\n return (error as { cause?: unknown } & Error).cause\n}\n\nfunction printVerboseErrorCause(cause: unknown, depth: number): void {\n const label = `Cause ${depth}:`\n if (cause instanceof Error) {\n const stack = cause.stack?.trim() ?? \"\"\n const stackOrMessage = stack.length > 0 ? stack : String(cause)\n printVerboseErrorBlock(label, stackOrMessage)\n const nestedCause = getErrorCause(cause)\n if (nestedCause !== undefined) {\n printVerboseErrorCause(nestedCause, depth + 1)\n }\n return\n }\n\n printVerboseErrorBlock(label, String(cause))\n}\n\nfunction printVerboseGenericError(error: Error): void {\n const stack = error.stack?.trim() ?? \"\"\n const stackOrMessage = stack.length > 0 ? stack : String(error)\n printVerboseErrorBlock(\"Full stack:\", stackOrMessage)\n const cause = getErrorCause(error)\n if (cause !== undefined) {\n printVerboseErrorCause(cause, 1)\n }\n}\n\n/**\n * Print the error message of a failed command and, when verbose mode is active\n * and the error is a {@link CommandError}, the full untruncated output.\n *\n * @param error - The caught error value.\n * @param verbose - Whether to show full stdout/stderr.\n */\nexport function printCommandFailure(error: unknown, verbose: boolean): void {\n if (verbose && error instanceof CommandError) {\n // Print only the exit-code line, skip the truncated output and hint\n const summaryLine = error.message.split(\"\\n\")[0]\n printCommandError(\"\", summaryLine)\n printVerboseCommandError(error.fullStdout, error.fullStderr)\n return\n }\n\n printCommandError(\"\", String(error))\n if (verbose && error instanceof Error) {\n printVerboseGenericError(error)\n }\n}\n\n/**\n * Print a run summary line with counts for each status category.\n *\n * @param stats - Aggregated counts from the completed run.\n * @param stats.changed - Number of modules that changed state.\n * @param stats.ok - Number of modules already in desired state.\n * @param stats.skipped - Number of modules skipped.\n * @param stats.failed - Number of modules that failed.\n * @param stats.signals - Number of signals triggered.\n */\nexport function printSummary(stats: {\n changed: number\n failed: number\n ok: number\n signals: number\n skipped: number\n}): void {\n const parts = [\n pc.yellow(`${stats.changed} changed`),\n pc.green(`${stats.ok} ok`),\n pc.dim(`${stats.skipped} skipped`),\n stats.failed > 0 ? pc.red(`${stats.failed} failed`) : `${stats.failed} failed`,\n pc.cyan(`${stats.signals} signals triggered`),\n ]\n console.log(`\\n${parts.join(pc.dim(\" \\u00b7 \"))}`)\n}\n","import { stripVTControlCharacters } from \"node:util\"\n\nimport type { ModuleStatus } from \"./types.js\"\n\ntype DisplayStatus = \"waiting\" | ModuleStatus\n\nconst PACKAGE_MODULE_SUFFIX_LENGTH = 2\nconst PACKAGE_COLUMN_GAP_WIDTH = 2\nconst DEFAULT_TERMINAL_COLUMNS = 100\nconst MIN_PACKAGE_COLUMNS_WIDTH = 24\nconst MIN_PACKAGE_COLUMN_WIDTH = 18\nconst MIN_ANIMATED_LINE_COLUMNS = 8\n\nexport type DisplayModule = {\n detail?: string\n detailLines: string[]\n name: string\n}\n\nfunction splitPackageModuleName(name: string): { packages: string[]; summaryName: string } | null {\n for (const prefix of [\"package.installed: \", \"package.absent: \"]) {\n if (!name.startsWith(prefix)) continue\n\n const packages = name\n .slice(prefix.length)\n .split(\",\")\n .map((entry) => entry.trim())\n .filter(Boolean)\n if (packages.length === 0) return null\n\n return {\n packages,\n summaryName: prefix.slice(0, -PACKAGE_MODULE_SUFFIX_LENGTH),\n }\n }\n\n return null\n}\n\nfunction getPackageColumns(parameters: {\n continuationIndentWidth: number\n packages: string[]\n terminalColumns?: number\n}): string[] {\n if (parameters.packages.length <= 1) return parameters.packages\n\n const availableWidth = Math.max(\n (parameters.terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) - parameters.continuationIndentWidth,\n MIN_PACKAGE_COLUMNS_WIDTH\n )\n const widestPackage = Math.max(...parameters.packages.map((entry) => entry.length))\n const columnWidth = Math.max(widestPackage + PACKAGE_COLUMN_GAP_WIDTH, MIN_PACKAGE_COLUMN_WIDTH)\n const columnCount = Math.max(Math.floor(availableWidth / columnWidth), 1)\n const rowCount = Math.ceil(parameters.packages.length / columnCount)\n\n return Array.from({ length: rowCount }, (_row, rowIndex) =>\n Array.from({ length: columnCount }, (_column, columnIndex) => {\n const packageIndex = rowIndex + columnIndex * rowCount\n const packageName = parameters.packages.at(packageIndex)\n if (packageName == null) return \"\"\n const isLastVisibleColumn =\n columnIndex === columnCount - 1 || packageIndex + rowCount >= parameters.packages.length\n return isLastVisibleColumn ? packageName : packageName.padEnd(columnWidth)\n })\n .filter(Boolean)\n .join(\"\")\n )\n}\n\nfunction getPackageSummaryDetail(packageCount: number, detail?: string): string {\n const packageLabel = packageCount === 1 ? \"1 package\" : `${packageCount} packages`\n return detail == null ? packageLabel : `${packageLabel} · ${detail}`\n}\n\nexport function fitAnimatedModuleLine(line: string, columns?: number): string {\n if (columns === undefined || columns < MIN_ANIMATED_LINE_COLUMNS) return line\n\n const plainLine = stripVTControlCharacters(line)\n if (plainLine.length < columns) return line\n\n return `${plainLine.slice(0, columns - 1)}\\u2026`\n}\n\nexport function formatDisplayModule(parameters: {\n continuationIndentWidth: number\n detail?: string\n name: string\n status: DisplayStatus\n terminalColumns?: number\n}): DisplayModule {\n const packageModule = splitPackageModuleName(parameters.name)\n if (packageModule == null) {\n return {\n detail: parameters.detail,\n detailLines: [],\n name: parameters.name,\n }\n }\n\n return {\n detail: getPackageSummaryDetail(packageModule.packages.length, parameters.detail),\n detailLines:\n parameters.status === \"waiting\"\n ? []\n : getPackageColumns({\n continuationIndentWidth: parameters.continuationIndentWidth,\n packages: packageModule.packages,\n terminalColumns: parameters.terminalColumns,\n }),\n name: packageModule.summaryName,\n }\n}\n","import type {\n Environment,\n Module,\n ModuleStatus,\n OrchestrationStep,\n SshConnection,\n} from \"./types.js\"\n\nimport { assertValidModuleMetaEntries, mergeEnvironmentFromMeta } from \"./meta.js\"\nimport { printCommandFailure, printModuleResult, startModuleSpinner } from \"./output.js\"\n\nexport type SignalHooks = {\n onSignalFinished?: (status: ModuleStatus) => void\n onSignalStarted?: () => void\n}\n\nexport type SignalRunStatus = \"changed\" | \"failed\"\n\ntype SignalRunParameters = {\n environment: Environment\n hooks?: SignalHooks\n onSignalStep?: (step: OrchestrationStep) => Promise<void>\n shutdownSignal?: () => NodeJS.Signals | null\n signals: Module[]\n ssh: null | SshConnection\n verbose?: boolean\n}\n\nfunction handleSignalResult(parameters: {\n hooks?: SignalHooks\n result: Awaited<ReturnType<Module[\"apply\"]>>\n signalName: string\n verbose: boolean\n}): SignalRunStatus {\n const { hooks, result, signalName, verbose } = parameters\n printModuleResult(`signal: ${signalName}`, result.status)\n if (result.status === \"failed\" && result.error != null) {\n printCommandFailure(result.error, verbose)\n }\n hooks?.onSignalFinished?.(result.status)\n return result.status === \"failed\" ? \"failed\" : \"changed\"\n}\n\nfunction handleSignalFailure(parameters: {\n error: unknown\n hooks?: SignalHooks\n signalName: string\n verbose: boolean\n}): SignalRunStatus {\n const { error, hooks, signalName, verbose } = parameters\n printModuleResult(`signal: ${signalName}`, \"failed\")\n printCommandFailure(error, verbose)\n hooks?.onSignalFinished?.(\"failed\")\n return \"failed\"\n}\n\nasync function applySignalMeta(parameters: {\n currentEnvironment: Environment\n onSignalStep?: (step: OrchestrationStep) => Promise<void>\n result: Awaited<ReturnType<Module[\"apply\"]>>\n}): Promise<Environment> {\n assertValidModuleMetaEntries(parameters.result.meta)\n const nextEnvironment = await mergeEnvironmentFromMeta(\n parameters.currentEnvironment,\n parameters.result.meta\n )\n await parameters.onSignalStep?.({\n env: nextEnvironment,\n meta: parameters.result.meta,\n status: parameters.result.status,\n })\n return nextEnvironment\n}\n\nasync function runOneSignal(parameters: {\n currentEnvironment: Environment\n hooks?: SignalHooks\n onSignalStep?: (step: OrchestrationStep) => Promise<void>\n signal: Module\n ssh: null | SshConnection\n verbose: boolean\n}): Promise<{ nextEnvironment: Environment; status: SignalRunStatus }> {\n const connection = parameters.signal.local === true ? null : parameters.ssh\n startModuleSpinner(`signal: ${parameters.signal.name}`)\n const result = await parameters.signal.apply(connection, parameters.currentEnvironment)\n const nextEnvironment = await applySignalMeta({\n currentEnvironment: parameters.currentEnvironment,\n onSignalStep: parameters.onSignalStep,\n result,\n })\n return {\n nextEnvironment,\n status: handleSignalResult({\n hooks: parameters.hooks,\n result,\n signalName: parameters.signal.name,\n verbose: parameters.verbose,\n }),\n }\n}\n\nexport async function runSignalModules(parameters: SignalRunParameters): Promise<SignalRunStatus> {\n const getShutdownSignal = parameters.shutdownSignal ?? (() => null)\n const verbose = parameters.verbose ?? false\n let currentEnvironment = parameters.environment\n let status: SignalRunStatus = \"changed\"\n\n for (const signal of parameters.signals) {\n if (getShutdownSignal() != null) break\n parameters.hooks?.onSignalStarted?.()\n try {\n // eslint-disable-next-line no-await-in-loop\n const signalStep = await runOneSignal({\n currentEnvironment,\n hooks: parameters.hooks,\n onSignalStep: parameters.onSignalStep,\n signal,\n ssh: parameters.ssh,\n verbose,\n })\n currentEnvironment = signalStep.nextEnvironment\n if (signalStep.status === \"failed\") status = \"failed\"\n } catch (error) {\n status = handleSignalFailure({\n error,\n hooks: parameters.hooks,\n signalName: signal.name,\n verbose,\n })\n }\n }\n\n return status\n}\n"],"mappings":"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","names":[]}
@@ -747,41 +747,63 @@ import { randomUUID } from "crypto";
747
747
  import { createReadStream, createWriteStream, renameSync, unlinkSync } from "fs";
748
748
  import { dirname, join as join2 } from "path";
749
749
  var SFTP_TIMEOUT = 12e4;
750
- function wireStreams(options) {
751
- const { readStream, reject, resolve, sftp, timeout, timeoutMessage, writeStream } = options;
750
+ function createTransferSettlement(options) {
752
751
  let settled = false;
752
+ return {
753
+ rejectOnce(reason) {
754
+ options.clearTimer();
755
+ if (settled) return;
756
+ settled = true;
757
+ options.sftp.end();
758
+ options.reject(reason);
759
+ },
760
+ resolveOnce() {
761
+ options.clearTimer();
762
+ if (settled) return;
763
+ settled = true;
764
+ options.sftp.end();
765
+ options.resolve();
766
+ }
767
+ };
768
+ }
769
+ function wireStreams(options) {
770
+ const {
771
+ completionEvents = ["finish"],
772
+ readStream,
773
+ reject,
774
+ resolve,
775
+ sftp,
776
+ timeout,
777
+ timeoutMessage,
778
+ writeStream
779
+ } = options;
753
780
  const timer = setTimeout(() => {
754
- if (settled) return;
755
- settled = true;
756
781
  readStream.destroy();
757
782
  writeStream.destroy();
758
- sftp.end();
759
- reject(new Error(timeoutMessage));
783
+ settlement.rejectOnce(new Error(timeoutMessage));
760
784
  }, timeout);
761
- writeStream.on("close", () => {
762
- clearTimeout(timer);
763
- if (settled) return;
764
- settled = true;
765
- sftp.end();
766
- resolve();
785
+ const settlement = createTransferSettlement({
786
+ clearTimer: () => {
787
+ clearTimeout(timer);
788
+ },
789
+ reject,
790
+ resolve,
791
+ sftp
767
792
  });
793
+ for (const completionEvent of completionEvents) {
794
+ writeStream.on(completionEvent, () => {
795
+ settlement.resolveOnce();
796
+ });
797
+ }
768
798
  writeStream.on("error", (writeError) => {
769
- clearTimeout(timer);
770
- if (settled) return;
771
- settled = true;
772
799
  readStream.destroy();
773
800
  if (typeof writeStream.destroy === "function") writeStream.destroy();
774
- sftp.end();
775
- reject(writeError);
801
+ settlement.rejectOnce(writeError);
776
802
  });
777
803
  readStream.on("error", (readError) => {
778
- clearTimeout(timer);
779
- if (settled) return;
780
- settled = true;
781
804
  if (typeof readStream.destroy === "function") readStream.destroy();
782
805
  if (typeof writeStream.destroy === "function") writeStream.destroy();
783
- sftp.end();
784
- reject(readError);
806
+ settlement.rejectOnce(readError);
785
807
  });
786
808
  readStream.pipe(writeStream);
787
809
  }
@@ -807,6 +829,7 @@ async function sftpDownload(client, remotePath, localPath, timeout = SFTP_TIMEOU
807
829
  const writeStream = createWriteStream(temporaryPath, { mode: 384 });
808
830
  shouldCleanupTemporaryFile = true;
809
831
  wireStreams({
832
+ completionEvents: ["finish"],
810
833
  readStream,
811
834
  reject: rejectWithCleanup,
812
835
  resolve: () => {
@@ -837,6 +860,7 @@ async function sftpUpload(client, localPath, remotePath, timeout = SFTP_TIMEOUT)
837
860
  const readStream = createReadStream(localPath);
838
861
  const writeStream = sftp.createWriteStream(remotePath, { mode: 384 });
839
862
  wireStreams({
863
+ completionEvents: ["close", "finish"],
840
864
  readStream,
841
865
  reject,
842
866
  resolve,
@@ -1142,12 +1166,15 @@ var SshConnectionImpl = class {
1142
1166
  }
1143
1167
  async uploadFile(localPath, remotePath, options) {
1144
1168
  const client = this.ensureClient();
1169
+ const localFileStats = await stat(localPath);
1170
+ const localFileSize = localFileStats.size;
1145
1171
  const temporaryPath = await this.createRemoteWritableTempPath(remotePath, "paratix-upload");
1146
1172
  const temporaryMode = options?.mode ?? "0600";
1147
1173
  try {
1148
1174
  await sftpUpload(client, localPath, temporaryPath);
1149
1175
  await this.setRemoteTempMode(temporaryPath, temporaryMode);
1150
1176
  await this.finalizeRemoteTempFile(temporaryPath, remotePath, temporaryMode);
1177
+ await this.assertRemoteFileSize(remotePath, localFileSize);
1151
1178
  } finally {
1152
1179
  try {
1153
1180
  await this.cleanupRemoteTempFile(temporaryPath);
@@ -1179,25 +1206,113 @@ var SshConnectionImpl = class {
1179
1206
  remotePath,
1180
1207
  options
1181
1208
  );
1209
+ const expectedSize = Buffer.byteLength(content, "utf8");
1182
1210
  try {
1183
1211
  writeFileSync(localTemporary, content, { mode: 384 });
1184
1212
  await sftpUpload(client, localTemporary, remoteTemporary);
1185
1213
  await this.setRemoteTempMode(remoteTemporary, temporaryMode);
1186
1214
  await this.finalizeRemoteTempFile(remoteTemporary, remotePath, temporaryMode);
1215
+ await this.ensureRemoteWriteFile({
1216
+ content,
1217
+ expectedSize,
1218
+ mode: temporaryMode,
1219
+ remotePath
1220
+ });
1187
1221
  } finally {
1188
- try {
1189
- unlinkSync2(localTemporary);
1190
- } catch {
1222
+ await this.cleanupWriteFileTemporaryPaths(localTemporary, remoteTemporary);
1223
+ }
1224
+ }
1225
+ async assertRemoteFileSize(remotePath, expectedSize) {
1226
+ const rawSize = await this.output(`stat -c '%s' ${shellQuote(remotePath)}`);
1227
+ const actualSize = Number(rawSize.trim());
1228
+ if (!Number.isFinite(actualSize)) {
1229
+ throw new TypeError(
1230
+ `[ssh.uploadFile: ${remotePath}] could not determine remote file size after upload`
1231
+ );
1232
+ }
1233
+ if (actualSize === 0 && expectedSize > 0) {
1234
+ const diskInfo = await this.checkRemoteDiskSpace(remotePath);
1235
+ if (diskInfo != null && diskInfo.availableBytes < expectedSize) {
1236
+ throw new Error(
1237
+ `[ssh.uploadFile: ${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`
1238
+ );
1191
1239
  }
1192
- try {
1193
- await this.cleanupRemoteTempFile(remoteTemporary);
1194
- } catch (cleanupError) {
1195
- process.stderr.write(
1196
- `Warning: failed to remove temp file ${remoteTemporary}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
1240
+ }
1241
+ if (actualSize !== expectedSize) {
1242
+ throw new Error(
1243
+ `[ssh.uploadFile: ${remotePath}] remote file size mismatch after upload/finalize; expected ${expectedSize} bytes, got ${actualSize}`
1244
+ );
1245
+ }
1246
+ }
1247
+ async checkRemoteDiskSpace(remotePath) {
1248
+ const DF_MIN_COLUMNS = 6;
1249
+ const DF_AVAILABLE_INDEX = 3;
1250
+ const DF_MOUNTPOINT_INDEX = 5;
1251
+ const KB_TO_BYTES = 1024;
1252
+ try {
1253
+ const directory = remotePath.includes("/") ? remotePath.slice(0, remotePath.lastIndexOf("/")) || "/" : ".";
1254
+ const dfOutput = await this.output(`df -P ${shellQuote(directory)}`);
1255
+ const lines = dfOutput.trim().split("\n");
1256
+ if (lines.length < 2) return null;
1257
+ const columns = lines[1].split(new RegExp("\\s+", "v"));
1258
+ if (columns.length < DF_MIN_COLUMNS) return null;
1259
+ const availableKb = Number(columns[DF_AVAILABLE_INDEX]);
1260
+ if (!Number.isFinite(availableKb)) return null;
1261
+ return { availableBytes: availableKb * KB_TO_BYTES, mountpoint: columns[DF_MOUNTPOINT_INDEX] };
1262
+ } catch {
1263
+ return null;
1264
+ }
1265
+ }
1266
+ /* eslint-disable perfectionist/sort-classes -- writeFile recovery helpers stay grouped for this fix */
1267
+ async cleanupWriteFileTemporaryPaths(localTemporary, remoteTemporary) {
1268
+ try {
1269
+ unlinkSync2(localTemporary);
1270
+ } catch {
1271
+ }
1272
+ try {
1273
+ await this.cleanupRemoteTempFile(remoteTemporary);
1274
+ } catch (cleanupError) {
1275
+ process.stderr.write(
1276
+ `Warning: failed to remove temp file ${remoteTemporary}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
1197
1277
  `
1278
+ );
1279
+ }
1280
+ }
1281
+ async cleanupPrivilegedRemoteTempFile(remotePath) {
1282
+ await this.exec(`rm -f ${shellQuote(remotePath)}`, {
1283
+ ignoreExitCode: true,
1284
+ silent: true
1285
+ });
1286
+ }
1287
+ async createRemotePrivilegedTempPathInDestination(remotePath, prefix) {
1288
+ const directory = posix.dirname(remotePath);
1289
+ const template = `${directory}/${prefix}.XXXXXX`;
1290
+ const path = await this.output(`mktemp ${shellQuote(template)}`);
1291
+ return validateMktempPath(directory, path, prefix);
1292
+ }
1293
+ async ensureRemoteWriteFile(options) {
1294
+ const verification = await this.verifyRemoteWriteFile(options.remotePath, options.expectedSize);
1295
+ if (verification === "matches") return;
1296
+ await this.rewriteRemoteFileViaShell(options.remotePath, options.content, options.mode);
1297
+ const fallbackVerification = await this.verifyRemoteWriteFile(
1298
+ options.remotePath,
1299
+ options.expectedSize
1300
+ );
1301
+ if (fallbackVerification === "matches") return;
1302
+ if (fallbackVerification === "empty") {
1303
+ const diskInfo = await this.checkRemoteDiskSpace(options.remotePath);
1304
+ if (diskInfo != null && diskInfo.availableBytes < options.expectedSize) {
1305
+ throw new Error(
1306
+ `[ssh.writeFile: ${options.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`
1198
1307
  );
1199
1308
  }
1309
+ throw new Error(
1310
+ `[ssh.writeFile: ${options.remotePath}] remote file is empty after upload/finalize and shell fallback; refusing successful write result`
1311
+ );
1200
1312
  }
1313
+ throw new Error(
1314
+ `[ssh.writeFile: ${options.remotePath}] remote file size mismatch after upload/finalize and shell fallback; expected ${options.expectedSize} bytes`
1315
+ );
1201
1316
  }
1202
1317
  buildEnvPrefix(environment) {
1203
1318
  if (environment == null) return "";
@@ -1591,6 +1706,48 @@ trap - EXIT
1591
1706
  this.authMethod = "password";
1592
1707
  return true;
1593
1708
  }
1709
+ async rewriteRemoteFileViaShell(remotePath, content, mode) {
1710
+ const remoteTemporary = await this.createRemotePrivilegedTempPathInDestination(
1711
+ remotePath,
1712
+ "paratix-write"
1713
+ );
1714
+ const encodedContent = Buffer.from(content, "utf8").toString("base64");
1715
+ try {
1716
+ await this.exec(
1717
+ `printf '%s' ${shellQuote(encodedContent)} | base64 -d > ${shellQuote(remoteTemporary)}`,
1718
+ { silent: true }
1719
+ );
1720
+ await this.exec(`chmod ${shellQuote(mode)} ${shellQuote(remoteTemporary)}`, { silent: true });
1721
+ await this.finalizeRemoteTempFile(remoteTemporary, remotePath, mode);
1722
+ } catch (error) {
1723
+ const reason = error instanceof Error ? error.message : String(error);
1724
+ throw new Error(`[ssh.writeFile: ${remotePath}] shell fallback write failed: ${reason}`, {
1725
+ cause: error
1726
+ });
1727
+ } finally {
1728
+ try {
1729
+ await this.cleanupPrivilegedRemoteTempFile(remoteTemporary);
1730
+ } catch (cleanupError) {
1731
+ process.stderr.write(
1732
+ `Warning: failed to remove temp file ${remoteTemporary}: ${maskSecrets(String(cleanupError), this.buildSecrets())}
1733
+ `
1734
+ );
1735
+ }
1736
+ }
1737
+ }
1738
+ async verifyRemoteWriteFile(remotePath, expectedSize) {
1739
+ const rawSize = await this.output(`stat -c '%s' ${shellQuote(remotePath)}`);
1740
+ const actualSize = Number(rawSize.trim());
1741
+ if (!Number.isFinite(actualSize)) {
1742
+ throw new TypeError(
1743
+ `[ssh.writeFile: ${remotePath}] could not determine remote file size after upload/finalize`
1744
+ );
1745
+ }
1746
+ if (expectedSize === 0 && actualSize === 0) return "matches";
1747
+ if (actualSize === 0) return "empty";
1748
+ return actualSize === expectedSize ? "matches" : "size-mismatch";
1749
+ }
1750
+ /* eslint-enable perfectionist/sort-classes */
1594
1751
  /**
1595
1752
  * Wrap a host-key verifier to pin the accepted key on first connection and
1596
1753
  * reject key changes on subsequent connections (reconnects).
@@ -1703,4 +1860,4 @@ export {
1703
1860
  computeFingerprint,
1704
1861
  SshConnectionImpl
1705
1862
  };
1706
- //# sourceMappingURL=chunk-G3BMCQKU.js.map
1863
+ //# sourceMappingURL=chunk-JJRF37BP.js.map