braeburn 1.5.1 → 1.5.2

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,6 +10,12 @@ 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>;
14
20
  type CheckCommandOptions = {
15
21
  shellCommand: string;
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 [];
@@ -45,6 +65,7 @@ export async function runShellCommand(options) {
45
65
  all: true,
46
66
  reject: true,
47
67
  });
68
+ activeShellCommandSubprocess = subprocess;
48
69
  subprocess.stdout?.on("data", (chunk) => {
49
70
  const lines = String(chunk).split(/\r?\n|\r/).filter(Boolean);
50
71
  for (const line of lines) {
@@ -63,12 +84,24 @@ export async function runShellCommand(options) {
63
84
  await subprocess;
64
85
  }
65
86
  catch (error) {
87
+ const commandWasCanceledByUser = userCanceledSubprocesses.has(subprocess);
66
88
  const failureSummaryLines = buildFailureSummaryLines(options.shellCommand, error);
89
+ if (commandWasCanceledByUser) {
90
+ failureSummaryLines.push("[braeburn] Command canceled by user input (q).");
91
+ }
67
92
  for (const line of failureSummaryLines) {
68
93
  await options.logWriter(line);
69
94
  }
95
+ if (commandWasCanceledByUser) {
96
+ throw new ShellCommandCanceledError(options.shellCommand, error);
97
+ }
70
98
  throw error;
71
99
  }
100
+ finally {
101
+ if (activeShellCommandSubprocess === subprocess) {
102
+ activeShellCommandSubprocess = undefined;
103
+ }
104
+ }
72
105
  }
73
106
  export async function doesShellCommandSucceed(options) {
74
107
  const result = await execa("bash", ["-c", options.shellCommand], {
@@ -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 { 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.5.2",
4
4
  "description": "macOS system updater CLI",
5
5
  "type": "module",
6
6
  "bin": {