braeburn 1.5.1 → 1.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.
@@ -1,14 +1,54 @@
1
+ import readline from "node:readline";
1
2
  import { collectVersions } from "../update/versionCollector.js";
2
3
  import { captureYesNo } from "../ui/prompt.js";
3
4
  import { buildScreen, buildScreenWithAnimationFrame, createScreenRenderer } from "../ui/screen.js";
4
5
  import { hideCursorDuringExecution } from "../ui/terminal.js";
5
6
  import { runUpdateEngine } from "../update/engine.js";
7
+ import { cancelActiveShellCommand } from "../runner.js";
8
+ function shouldCaptureRuntimeAbortKey(state) {
9
+ return state?.currentPhase === "running" || state?.currentPhase === "installing";
10
+ }
6
11
  export async function runUpdateCommand(options) {
7
12
  const renderScreen = createScreenRenderer();
8
13
  const restoreCursor = hideCursorDuringExecution({ screenBuffer: "alternate" });
9
14
  let finalScreen = "";
10
15
  let latestState = undefined;
16
+ let runtimeAbortKeyCaptureEnabled = false;
11
17
  let animationFrameIndex = 0;
18
+ const handleRuntimeKeypress = (typedCharacter, key) => {
19
+ if (key?.ctrl && key?.name === "c") {
20
+ process.stdout.write("\x1b[?25h\n");
21
+ process.exit(130);
22
+ }
23
+ const isQuitRequest = typedCharacter === "q" || typedCharacter === "Q";
24
+ if (!isQuitRequest) {
25
+ return;
26
+ }
27
+ cancelActiveShellCommand();
28
+ };
29
+ const enableRuntimeAbortKeyCapture = () => {
30
+ if (runtimeAbortKeyCaptureEnabled) {
31
+ return;
32
+ }
33
+ readline.emitKeypressEvents(process.stdin);
34
+ process.stdin.on("keypress", handleRuntimeKeypress);
35
+ if (process.stdin.isTTY) {
36
+ process.stdin.setRawMode(true);
37
+ }
38
+ process.stdin.resume();
39
+ runtimeAbortKeyCaptureEnabled = true;
40
+ };
41
+ const disableRuntimeAbortKeyCapture = () => {
42
+ if (!runtimeAbortKeyCaptureEnabled) {
43
+ return;
44
+ }
45
+ process.stdin.removeListener("keypress", handleRuntimeKeypress);
46
+ if (process.stdin.isTTY) {
47
+ process.stdin.setRawMode(false);
48
+ }
49
+ process.stdin.pause();
50
+ runtimeAbortKeyCaptureEnabled = false;
51
+ };
12
52
  const animationTimer = setInterval(() => {
13
53
  if (!latestState) {
14
54
  return;
@@ -32,12 +72,19 @@ export async function runUpdateCommand(options) {
32
72
  collectVersions,
33
73
  onStateChanged: (state) => {
34
74
  latestState = state;
75
+ if (shouldCaptureRuntimeAbortKey(state)) {
76
+ enableRuntimeAbortKeyCapture();
77
+ }
78
+ else {
79
+ disableRuntimeAbortKeyCapture();
80
+ }
35
81
  renderScreen(buildScreenWithAnimationFrame(state, animationFrameIndex));
36
82
  },
37
83
  });
38
84
  finalScreen = buildScreen(finalState);
39
85
  }
40
86
  finally {
87
+ disableRuntimeAbortKeyCapture();
41
88
  clearInterval(animationTimer);
42
89
  restoreCursor();
43
90
  }
package/dist/runner.d.ts CHANGED
@@ -10,7 +10,14 @@ type RunCommandOptions = {
10
10
  onOutputLine: OutputLineCallback;
11
11
  logWriter: StepLogWriter;
12
12
  };
13
+ export declare class ShellCommandCanceledError extends Error {
14
+ readonly shellCommand: string;
15
+ readonly originalError: unknown;
16
+ constructor(shellCommand: string, originalError: unknown);
17
+ }
18
+ export declare function cancelActiveShellCommand(): boolean;
13
19
  export declare function runShellCommand(options: RunCommandOptions): Promise<void>;
20
+ export declare function runShellCommandAndCaptureOutput(options: RunCommandOptions): Promise<string>;
14
21
  type CheckCommandOptions = {
15
22
  shellCommand: string;
16
23
  };
package/dist/runner.js CHANGED
@@ -1,5 +1,25 @@
1
1
  import { execa } from "execa";
2
2
  const FAILURE_OUTPUT_TAIL_LINE_LIMIT = 20;
3
+ let activeShellCommandSubprocess;
4
+ const userCanceledSubprocesses = new WeakSet();
5
+ export class ShellCommandCanceledError extends Error {
6
+ shellCommand;
7
+ originalError;
8
+ constructor(shellCommand, originalError) {
9
+ super(`Command canceled by user: ${shellCommand}`);
10
+ this.name = "ShellCommandCanceledError";
11
+ this.shellCommand = shellCommand;
12
+ this.originalError = originalError;
13
+ }
14
+ }
15
+ export function cancelActiveShellCommand() {
16
+ if (!activeShellCommandSubprocess) {
17
+ return false;
18
+ }
19
+ userCanceledSubprocesses.add(activeShellCommandSubprocess);
20
+ activeShellCommandSubprocess.kill("SIGTERM");
21
+ return true;
22
+ }
3
23
  function splitNonEmptyLines(text) {
4
24
  if (!text) {
5
25
  return [];
@@ -40,35 +60,87 @@ function buildFailureSummaryLines(shellCommand, error) {
40
60
  }
41
61
  return summaryLines;
42
62
  }
43
- export async function runShellCommand(options) {
63
+ function createBufferedLineEmitter(emitLine) {
64
+ let pendingText = "";
65
+ return {
66
+ appendChunk(chunk) {
67
+ pendingText += String(chunk);
68
+ const lineParts = pendingText.split(/\r?\n|\r/);
69
+ pendingText = lineParts.pop() ?? "";
70
+ for (const linePart of lineParts) {
71
+ if (linePart.length === 0) {
72
+ continue;
73
+ }
74
+ emitLine(linePart);
75
+ }
76
+ },
77
+ flushPendingLine() {
78
+ if (pendingText.length === 0) {
79
+ return;
80
+ }
81
+ emitLine(pendingText);
82
+ pendingText = "";
83
+ },
84
+ };
85
+ }
86
+ async function executeShellCommand(options) {
87
+ const capturedOutputLines = [];
44
88
  const subprocess = execa("bash", ["-c", options.shellCommand], {
45
89
  all: true,
46
90
  reject: true,
47
91
  });
92
+ activeShellCommandSubprocess = subprocess;
93
+ const emitOutputLine = (line) => {
94
+ capturedOutputLines.push(line.text);
95
+ options.onOutputLine(line);
96
+ options.logWriter(line.text);
97
+ };
98
+ const stdoutEmitter = createBufferedLineEmitter((line) => {
99
+ emitOutputLine({ text: line, source: "stdout" });
100
+ });
101
+ const stderrEmitter = createBufferedLineEmitter((line) => {
102
+ emitOutputLine({ text: line, source: "stderr" });
103
+ });
48
104
  subprocess.stdout?.on("data", (chunk) => {
49
- const lines = String(chunk).split(/\r?\n|\r/).filter(Boolean);
50
- for (const line of lines) {
51
- options.onOutputLine({ text: line, source: "stdout" });
52
- options.logWriter(line);
53
- }
105
+ stdoutEmitter.appendChunk(chunk);
54
106
  });
55
107
  subprocess.stderr?.on("data", (chunk) => {
56
- const lines = String(chunk).split(/\r?\n|\r/).filter(Boolean);
57
- for (const line of lines) {
58
- options.onOutputLine({ text: line, source: "stderr" });
59
- options.logWriter(line);
60
- }
108
+ stderrEmitter.appendChunk(chunk);
61
109
  });
62
110
  try {
63
111
  await subprocess;
112
+ stdoutEmitter.flushPendingLine();
113
+ stderrEmitter.flushPendingLine();
114
+ return { capturedOutput: capturedOutputLines.join("\n") };
64
115
  }
65
116
  catch (error) {
117
+ stdoutEmitter.flushPendingLine();
118
+ stderrEmitter.flushPendingLine();
119
+ const commandWasCanceledByUser = userCanceledSubprocesses.has(subprocess);
66
120
  const failureSummaryLines = buildFailureSummaryLines(options.shellCommand, error);
121
+ if (commandWasCanceledByUser) {
122
+ failureSummaryLines.push("[braeburn] Command canceled by user input (q).");
123
+ }
67
124
  for (const line of failureSummaryLines) {
68
125
  await options.logWriter(line);
69
126
  }
127
+ if (commandWasCanceledByUser) {
128
+ throw new ShellCommandCanceledError(options.shellCommand, error);
129
+ }
70
130
  throw error;
71
131
  }
132
+ finally {
133
+ if (activeShellCommandSubprocess === subprocess) {
134
+ activeShellCommandSubprocess = undefined;
135
+ }
136
+ }
137
+ }
138
+ export async function runShellCommand(options) {
139
+ await executeShellCommand(options);
140
+ }
141
+ export async function runShellCommandAndCaptureOutput(options) {
142
+ const result = await executeShellCommand(options);
143
+ return result.capturedOutput;
72
144
  }
73
145
  export async function doesShellCommandSucceed(options) {
74
146
  const result = await execa("bash", ["-c", options.shellCommand], {
@@ -3,7 +3,7 @@ export type { StepCategoryId, StepCategoryDefinition, } from "./categories.js";
3
3
  export { listStepCategoryDefinitions, getStepCategoryLabel, } from "./categories.js";
4
4
  export type { CategorySection, } from "./grouping.js";
5
5
  export { buildCategorySectionsInOrder, } from "./grouping.js";
6
- export { checkCommandExists, checkPathExists, runStep, createDefaultStepRunContext, } from "./runtime.js";
6
+ export { checkCommandExists, checkPathExists, runStep, runStepAndCaptureOutput, createDefaultStepRunContext, } from "./runtime.js";
7
7
  export { default as homebrewStep } from "./homebrew.js";
8
8
  export { default as masStep } from "./mas.js";
9
9
  export { default as ohmyzshStep } from "./ohmyzsh.js";
@@ -1,6 +1,6 @@
1
1
  export { listStepCategoryDefinitions, getStepCategoryLabel, } from "./categories.js";
2
2
  export { buildCategorySectionsInOrder, } from "./grouping.js";
3
- export { checkCommandExists, checkPathExists, runStep, createDefaultStepRunContext, } from "./runtime.js";
3
+ export { checkCommandExists, checkPathExists, runStep, runStepAndCaptureOutput, createDefaultStepRunContext, } from "./runtime.js";
4
4
  export { default as homebrewStep } from "./homebrew.js";
5
5
  export { default as masStep } from "./mas.js";
6
6
  export { default as ohmyzshStep } from "./ohmyzsh.js";
@@ -7,10 +7,11 @@ const macosStep = {
7
7
  return true;
8
8
  },
9
9
  async run(context) {
10
- const updateListOutput = await context.captureOutput({
11
- shellCommand: "softwareupdate -l 2>&1",
10
+ context.onOutputLine({
11
+ text: "Checking for macOS updates...",
12
+ source: "stdout",
12
13
  });
13
- context.logWriter(updateListOutput);
14
+ const updateListOutput = await context.runStepAndCaptureOutput("softwareupdate -l 2>&1");
14
15
  const noUpdatesAvailable = updateListOutput.includes("No new software available");
15
16
  if (noUpdatesAvailable) {
16
17
  context.onOutputLine({
@@ -19,7 +20,6 @@ const macosStep = {
19
20
  });
20
21
  return;
21
22
  }
22
- context.onOutputLine({ text: updateListOutput, source: "stdout" });
23
23
  context.onOutputLine({
24
24
  text: "Updates found — installing now...",
25
25
  source: "stdout",
@@ -4,4 +4,5 @@ import type { StepRunContext } from "./types.js";
4
4
  export declare function checkCommandExists(command: string): Promise<boolean>;
5
5
  export declare function checkPathExists(filePath: string): Promise<boolean>;
6
6
  export declare function runStep(shellCommand: string, context: StepRunContext): Promise<void>;
7
+ export declare function runStepAndCaptureOutput(shellCommand: string, context: StepRunContext): Promise<string>;
7
8
  export declare function createDefaultStepRunContext(onOutputLine: OutputLineCallback, logWriter: StepLogWriter): StepRunContext;
@@ -1,4 +1,4 @@
1
- import { doesShellCommandSucceed, runShellCommand, captureShellCommandOutput, } from "../runner.js";
1
+ import { doesShellCommandSucceed, runShellCommand, captureShellCommandOutput, runShellCommandAndCaptureOutput, } from "../runner.js";
2
2
  export async function checkCommandExists(command) {
3
3
  return doesShellCommandSucceed({ shellCommand: `command -v ${command}` });
4
4
  }
@@ -12,11 +12,19 @@ export async function runStep(shellCommand, context) {
12
12
  logWriter: context.logWriter,
13
13
  });
14
14
  }
15
+ export async function runStepAndCaptureOutput(shellCommand, context) {
16
+ return runShellCommandAndCaptureOutput({
17
+ shellCommand,
18
+ onOutputLine: context.onOutputLine,
19
+ logWriter: context.logWriter,
20
+ });
21
+ }
15
22
  export function createDefaultStepRunContext(onOutputLine, logWriter) {
16
23
  const context = {
17
24
  onOutputLine,
18
25
  logWriter,
19
26
  runStep: (shellCommand) => runStep(shellCommand, context),
27
+ runStepAndCaptureOutput: (shellCommand) => runStepAndCaptureOutput(shellCommand, context),
20
28
  captureOutput: captureShellCommandOutput,
21
29
  };
22
30
  return context;
@@ -5,6 +5,7 @@ export type StepRunContext = {
5
5
  onOutputLine: OutputLineCallback;
6
6
  logWriter: StepLogWriter;
7
7
  runStep: (shellCommand: string) => Promise<void>;
8
+ runStepAndCaptureOutput: (shellCommand: string) => Promise<string>;
8
9
  captureOutput: (options: {
9
10
  shellCommand: string;
10
11
  }) => Promise<string>;
@@ -11,7 +11,9 @@ export function buildActiveStepLines(options) {
11
11
  ` ${chalk.dim("·")} ${chalk.dim.italic(step.description)}`,
12
12
  ];
13
13
  if (isRunning) {
14
- const label = phase === "installing" ? "Installing..." : "Running...";
14
+ const label = phase === "installing"
15
+ ? "Installing... press q to end this step"
16
+ : "Running... press q to end this step";
15
17
  lines.push(` ${chalk.blue(getActivityIndicatorFrame(activityFrameIndex))} ${label}`);
16
18
  }
17
19
  return lines;
@@ -1,5 +1,5 @@
1
1
  import type { DisplayStep, StepPhase, CompletedStepRecord, LogoVisibility } from "./state.js";
2
- import type { TerminalDimensions } from "./outputBox.js";
2
+ import type { TerminalDimensions } from "./outputLines.js";
3
3
  type LogoLayout = "side-by-side" | "stacked" | "none";
4
4
  export declare function determineLogoLayout(logoLines: string[], dimensions?: TerminalDimensions): LogoLayout;
5
5
  export declare function stepTrackerIcon(phase: StepPhase, activityFrameIndex?: number): string;
@@ -0,0 +1,6 @@
1
+ import type { CommandOutputLine } from "../runner.js";
2
+ export type TerminalDimensions = {
3
+ columns: number;
4
+ rows: number;
5
+ };
6
+ export declare function buildStepOutputLines(lines: CommandOutputLine[], dimensions?: TerminalDimensions): string[];
@@ -0,0 +1,33 @@
1
+ import chalk from "chalk";
2
+ const INDENT = " ";
3
+ const HEADER_LINES_APPROXIMATE = 18;
4
+ const MINIMUM_VISIBLE_LINES = 5;
5
+ function maxVisibleLines(rows) {
6
+ const available = rows - HEADER_LINES_APPROXIMATE;
7
+ return Math.max(MINIMUM_VISIBLE_LINES, available);
8
+ }
9
+ function maxLineWidth(columns) {
10
+ return Math.max(0, Math.min(columns, 120) - INDENT.length);
11
+ }
12
+ function resolveTerminalDimensions(dimensions) {
13
+ return dimensions ?? {
14
+ columns: process.stdout.columns ?? 80,
15
+ rows: process.stdout.rows ?? 40,
16
+ };
17
+ }
18
+ function expandRenderedLines(lines) {
19
+ return lines.flatMap((line) => line.text.split(/\r?\n|\r/).map((text) => ({
20
+ text,
21
+ source: line.source,
22
+ })));
23
+ }
24
+ export function buildStepOutputLines(lines, dimensions) {
25
+ const resolved = resolveTerminalDimensions(dimensions);
26
+ const visibleLines = expandRenderedLines(lines).slice(-maxVisibleLines(resolved.rows));
27
+ const width = maxLineWidth(resolved.columns);
28
+ return visibleLines.map((line) => {
29
+ const truncated = line.text.slice(0, width);
30
+ const colored = line.source === "stderr" ? chalk.yellow(truncated) : chalk.dim(truncated);
31
+ return `${INDENT}${colored}`;
32
+ });
33
+ }
@@ -1,4 +1,4 @@
1
- import { type TerminalDimensions } from "./outputBox.js";
1
+ import { type TerminalDimensions } from "./outputLines.js";
2
2
  import type { AppState } from "./state.js";
3
3
  export type ScreenRenderer = (content: string) => void;
4
4
  export declare function createScreenRenderer(output?: NodeJS.WritableStream): ScreenRenderer;
package/dist/ui/screen.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { buildHeaderLines } from "./header.js";
2
2
  import { buildActiveStepLines } from "./currentStep.js";
3
- import { buildOutputBoxLines } from "./outputBox.js";
3
+ import { buildStepOutputLines } from "./outputLines.js";
4
4
  import { buildPromptLines } from "./prompt.js";
5
5
  import { buildFailedStepLogHintLines, buildVersionReportLines } from "./versionReport.js";
6
6
  export function createScreenRenderer(output = process.stdout) {
@@ -60,7 +60,7 @@ export function buildScreenWithAnimationFrame(state, activityFrameIndex, termina
60
60
  state.currentOutputLines.length > 0;
61
61
  if (isShowingOutput) {
62
62
  lines.push("");
63
- lines.push(...buildOutputBoxLines(state.currentOutputLines, currentStep.name, terminalDimensions));
63
+ lines.push(...buildStepOutputLines(state.currentOutputLines, terminalDimensions));
64
64
  }
65
65
  if (state.currentPrompt) {
66
66
  lines.push("");
@@ -1,5 +1,5 @@
1
1
  import { createLogWriterForStep } from "../logger.js";
2
- import { runShellCommand } from "../runner.js";
2
+ import { runShellCommand, ShellCommandCanceledError } from "../runner.js";
3
3
  import { createDefaultStepRunContext } from "../steps/index.js";
4
4
  import { toDisplaySteps } from "./displayStep.js";
5
5
  import { createInitialUpdateState, } from "./state.js";
@@ -26,6 +26,9 @@ async function resolvePrompt(promptMode, askForConfirmation) {
26
26
  function reportState(state, onStateChanged) {
27
27
  onStateChanged(state);
28
28
  }
29
+ function wasStepCanceledByUser(error) {
30
+ return error instanceof ShellCommandCanceledError;
31
+ }
29
32
  export async function runUpdateEngine(options) {
30
33
  const dependencies = resolveDependencies(options.dependencies);
31
34
  const state = createInitialUpdateState(toDisplaySteps(options.steps), options.version, options.logoVisibility);
@@ -75,10 +78,16 @@ export async function runUpdateEngine(options) {
75
78
  logWriter: installLogWriter,
76
79
  });
77
80
  }
78
- catch {
81
+ catch (error) {
82
+ if (wasStepCanceledByUser(error)) {
83
+ promptMode = "interactive";
84
+ }
79
85
  state.currentPhase = "failed";
80
86
  reportState(state, options.onStateChanged);
81
- state.completedStepRecords.push({ phase: "failed", summaryNote: "install failed" });
87
+ state.completedStepRecords.push({
88
+ phase: "failed",
89
+ summaryNote: wasStepCanceledByUser(error) ? "canceled by user" : "install failed",
90
+ });
82
91
  continue;
83
92
  }
84
93
  }
@@ -109,11 +118,17 @@ export async function runUpdateEngine(options) {
109
118
  state.completedStepRecords.push({ phase: "complete", summaryNote: "updated" });
110
119
  }
111
120
  catch (error) {
121
+ if (wasStepCanceledByUser(error)) {
122
+ promptMode = "interactive";
123
+ }
112
124
  const errorMessage = error instanceof Error ? error.message : String(error);
113
125
  state.currentPhase = "failed";
114
126
  state.currentOutputLines = [];
115
127
  reportState(state, options.onStateChanged);
116
- state.completedStepRecords.push({ phase: "failed", summaryNote: errorMessage });
128
+ state.completedStepRecords.push({
129
+ phase: "failed",
130
+ summaryNote: wasStepCanceledByUser(error) ? "canceled by user" : errorMessage,
131
+ });
117
132
  }
118
133
  }
119
134
  state.runCompletion = "finished";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braeburn",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "macOS system updater CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +0,0 @@
1
- import type { CommandOutputLine } from "../runner.js";
2
- export type TerminalDimensions = {
3
- columns: number;
4
- rows: number;
5
- };
6
- export declare function buildOutputBoxLines(lines: CommandOutputLine[], stepName: string, dimensions?: TerminalDimensions): string[];
@@ -1,35 +0,0 @@
1
- import chalk from "chalk";
2
- const INDENT = " ";
3
- const HEADER_LINES_APPROXIMATE = 18;
4
- const OUTPUT_BOX_CHROME_LINES = 3;
5
- const MINIMUM_VISIBLE_LINES = 5;
6
- function maxVisibleLines(rows) {
7
- const available = rows - HEADER_LINES_APPROXIMATE - OUTPUT_BOX_CHROME_LINES;
8
- return Math.max(MINIMUM_VISIBLE_LINES, available);
9
- }
10
- function boxWidth(columns) {
11
- return Math.min(columns, 120) - INDENT.length * 2;
12
- }
13
- function resolveTerminalDimensions(dimensions) {
14
- return dimensions ?? {
15
- columns: process.stdout.columns ?? 80,
16
- rows: process.stdout.rows ?? 40,
17
- };
18
- }
19
- export function buildOutputBoxLines(lines, stepName, dimensions) {
20
- const resolved = resolveTerminalDimensions(dimensions);
21
- const visibleLines = lines.slice(-maxVisibleLines(resolved.rows));
22
- const width = boxWidth(resolved.columns);
23
- const headerLabel = `─ ${stepName} output `;
24
- const topDashes = "─".repeat(Math.max(0, width - headerLabel.length - 2));
25
- const topBorder = chalk.dim(`${INDENT}┌${headerLabel}${topDashes}┐`);
26
- const bottomBorder = chalk.dim(`${INDENT}└${"─".repeat(width - 2)}┘`);
27
- const result = [topBorder];
28
- for (const line of visibleLines) {
29
- const truncated = line.text.slice(0, width - 4);
30
- const colored = line.source === "stderr" ? chalk.yellow(truncated) : chalk.dim(truncated);
31
- result.push(`${INDENT}${chalk.dim("│")} ${colored}`);
32
- }
33
- result.push(bottomBorder);
34
- return result;
35
- }