braeburn 1.2.1 → 1.2.3

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,12 +1,23 @@
1
+ import { type BraeburnConfig } from "../config.js";
1
2
  import type { Step } from "../steps/index.js";
2
3
  type RunConfigCommandOptions = {
3
4
  allSteps: Step[];
4
5
  };
6
+ type DesiredState = "enable" | "disable";
5
7
  type RunConfigUpdateCommandOptions = {
6
- stepUpdates: Record<string, boolean>;
7
- logoUpdate: boolean | undefined;
8
+ settingUpdates: Record<string, DesiredState>;
8
9
  allSteps: Step[];
9
10
  };
10
11
  export declare function runConfigCommand(options: RunConfigCommandOptions): Promise<void>;
12
+ type ConfigChange = {
13
+ label: string;
14
+ from: DesiredState;
15
+ to: DesiredState;
16
+ };
17
+ type ConfigUpdateResult = {
18
+ updatedConfig: BraeburnConfig;
19
+ changes: ConfigChange[];
20
+ };
21
+ export declare function applyConfigUpdates(config: BraeburnConfig, settingUpdates: Record<string, DesiredState>): ConfigUpdateResult;
11
22
  export declare function runConfigUpdateCommand(options: RunConfigUpdateCommandOptions): Promise<void>;
12
23
  export {};
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { readConfig, writeConfig, resolveConfigPath, isStepEnabled, isLogoEnabled, PROTECTED_STEP_IDS, } from "../config.js";
2
+ import { readConfig, writeConfig, resolveConfigPath, isSettingEnabled, isStepEnabled, isLogoEnabled, applySettingToConfig, PROTECTED_STEP_IDS, } from "../config.js";
3
3
  export async function runConfigCommand(options) {
4
4
  const { allSteps } = options;
5
5
  const config = await readConfig();
@@ -29,10 +29,22 @@ export async function runConfigCommand(options) {
29
29
  }
30
30
  process.stdout.write(`\n`);
31
31
  }
32
+ export function applyConfigUpdates(config, settingUpdates) {
33
+ let updatedConfig = config;
34
+ const changes = [];
35
+ for (const [settingId, desiredState] of Object.entries(settingUpdates)) {
36
+ const currentState = isSettingEnabled(config, settingId) ? "enable" : "disable";
37
+ if (currentState !== desiredState) {
38
+ changes.push({ label: settingId, from: currentState, to: desiredState });
39
+ }
40
+ updatedConfig = applySettingToConfig(updatedConfig, settingId, desiredState);
41
+ }
42
+ return { updatedConfig, changes };
43
+ }
32
44
  export async function runConfigUpdateCommand(options) {
33
- const { stepUpdates, logoUpdate, allSteps } = options;
34
- if (Object.keys(stepUpdates).length === 0 && logoUpdate === undefined) {
35
- const configurableSteps = allSteps.filter((s) => !PROTECTED_STEP_IDS.has(s.id));
45
+ const { settingUpdates, allSteps } = options;
46
+ if (Object.keys(settingUpdates).length === 0) {
47
+ const configurableSteps = allSteps.filter((step) => !PROTECTED_STEP_IDS.has(step.id));
36
48
  process.stdout.write("No changes — pass flags to enable or disable steps:\n\n");
37
49
  process.stdout.write(` ${"--no-logo".padEnd(18)} hide the logo\n`);
38
50
  process.stdout.write(` ${"--logo".padEnd(18)} show the logo\n`);
@@ -48,41 +60,15 @@ export async function runConfigUpdateCommand(options) {
48
60
  return;
49
61
  }
50
62
  const config = await readConfig();
51
- const changes = [];
52
- if (logoUpdate !== undefined) {
53
- const currentlyEnabled = isLogoEnabled(config);
54
- if (currentlyEnabled !== logoUpdate) {
55
- changes.push({ label: "logo", from: currentlyEnabled, to: logoUpdate });
56
- }
57
- if (logoUpdate) {
58
- delete config.logo;
59
- }
60
- else {
61
- config.logo = false;
62
- }
63
- }
64
- for (const [stepId, newEnabled] of Object.entries(stepUpdates)) {
65
- const currentlyEnabled = isStepEnabled(config, stepId);
66
- if (currentlyEnabled !== newEnabled) {
67
- changes.push({ label: stepId, from: currentlyEnabled, to: newEnabled });
68
- }
69
- if (newEnabled) {
70
- // Re-enabling: remove from config so absent = enabled (keeps file minimal)
71
- delete config.steps[stepId];
72
- }
73
- else {
74
- config.steps[stepId] = false;
75
- }
76
- }
77
- // Write even if no visible changes, in case the user is re-confirming state
78
- await writeCleanConfig(config);
63
+ const { updatedConfig, changes } = applyConfigUpdates(config, settingUpdates);
64
+ await writeCleanConfig(updatedConfig);
79
65
  if (changes.length === 0) {
80
66
  process.stdout.write("No changes — already set as requested.\n");
81
67
  return;
82
68
  }
83
69
  for (const { label, from, to } of changes) {
84
- const fromLabel = from ? chalk.green("enabled") : chalk.red("disabled");
85
- const toLabel = to ? chalk.green("enabled") : chalk.red("disabled");
70
+ const fromLabel = from === "enable" ? chalk.green("enabled") : chalk.red("disabled");
71
+ const toLabel = to === "enable" ? chalk.green("enabled") : chalk.red("disabled");
86
72
  process.stdout.write(` ${label.padEnd(12)} ${fromLabel} → ${toLabel}\n`);
87
73
  }
88
74
  const configPath = await resolveConfigPath();
@@ -1,2 +1,13 @@
1
1
  import type { Step } from "../steps/index.js";
2
+ export type SelectionState = "selected" | "deselected";
3
+ export type ProtectionStatus = "protected" | "configurable";
4
+ export type AvailabilityStatus = "available" | "unavailable";
5
+ export type SelectableStep = {
6
+ step: Step;
7
+ selection: SelectionState;
8
+ protection: ProtectionStatus;
9
+ availability: AvailabilityStatus;
10
+ };
11
+ export declare function buildLoadingScreen(): string;
12
+ export declare function buildSetupScreen(items: SelectableStep[], cursorIndex: number): string;
2
13
  export declare function runSetupCommand(allSteps: Step[]): Promise<void>;
@@ -2,16 +2,9 @@ import readline from "node:readline";
2
2
  import chalk from "chalk";
3
3
  import { writeConfig, PROTECTED_STEP_IDS } from "../config.js";
4
4
  import { LOGO_ART } from "../logo.js";
5
- // Module-level render state (scoped to this screen, separate from update screen)
6
- let prevLines = 0;
7
- function render(content) {
8
- if (prevLines > 0) {
9
- process.stdout.write(`\x1b[${prevLines}A\x1b[J`);
10
- }
11
- process.stdout.write(content);
12
- prevLines = (content.match(/\n/g) ?? []).length;
13
- }
14
- function buildLoadingScreen() {
5
+ import { createScreenRenderer } from "../ui/screen.js";
6
+ import { hideCursorDuringExecution } from "../ui/terminal.js";
7
+ export function buildLoadingScreen() {
15
8
  const lines = [
16
9
  chalk.yellow(LOGO_ART),
17
10
  "",
@@ -22,7 +15,7 @@ function buildLoadingScreen() {
22
15
  ];
23
16
  return lines.join("\n") + "\n";
24
17
  }
25
- function buildSetupScreen(items, cursorIndex) {
18
+ export function buildSetupScreen(items, cursorIndex) {
26
19
  const lines = [
27
20
  chalk.yellow(LOGO_ART),
28
21
  "",
@@ -34,18 +27,18 @@ function buildSetupScreen(items, cursorIndex) {
34
27
  ` ${chalk.dim("\u2191\u2193 navigate Space toggle Return confirm")}`,
35
28
  "",
36
29
  ];
37
- for (let i = 0; i < items.length; i++) {
38
- const item = items[i];
39
- const isCursor = i === cursorIndex;
30
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
31
+ const item = items[itemIndex];
32
+ const isCursor = itemIndex === cursorIndex;
40
33
  const cursor = isCursor ? chalk.cyan("\u203a") : " ";
41
- const checkbox = item.selected ? chalk.green("\u25cf") : chalk.dim("\u25cb");
34
+ const checkbox = item.selection === "selected" ? chalk.green("\u25cf") : chalk.dim("\u25cb");
42
35
  const namePadded = item.step.name.padEnd(18);
43
36
  const name = isCursor ? chalk.bold.white(namePadded) : chalk.white(namePadded);
44
37
  let status;
45
- if (item.isProtected) {
38
+ if (item.protection === "protected") {
46
39
  status = chalk.dim("required");
47
40
  }
48
- else if (item.isAvailable) {
41
+ else if (item.availability === "available") {
49
42
  status = chalk.green("installed");
50
43
  }
51
44
  else if (item.step.brewPackageToInstall) {
@@ -59,39 +52,31 @@ function buildSetupScreen(items, cursorIndex) {
59
52
  lines.push(` ${chalk.dim(item.step.description)}`);
60
53
  }
61
54
  }
62
- const enabledCount = items.filter((i) => i.selected).length;
55
+ const enabledCount = items.filter((item) => item.selection === "selected").length;
63
56
  lines.push("");
64
57
  lines.push(` ${chalk.dim(`${enabledCount} of ${items.length} tools selected`)}`);
65
58
  lines.push("");
66
59
  return lines.join("\n") + "\n";
67
60
  }
68
61
  export async function runSetupCommand(allSteps) {
69
- // Hide cursor; restore on exit
70
- process.stdout.write("\x1b[?25l");
71
- process.on("exit", () => process.stdout.write("\x1b[?25h"));
72
- process.on("SIGINT", () => {
73
- process.stdout.write("\x1b[?25h\n");
74
- process.exit(130);
75
- });
76
- // Show loading screen while we check availability in parallel
62
+ const render = createScreenRenderer();
63
+ hideCursorDuringExecution();
77
64
  render(buildLoadingScreen());
78
65
  const availabilityResults = await Promise.all(allSteps.map((step) => step.checkIsAvailable()));
79
- const items = allSteps.map((step, i) => ({
66
+ const items = allSteps.map((step, stepIndex) => ({
80
67
  step,
81
- selected: true, // all enabled by default — user opts out
82
- isProtected: PROTECTED_STEP_IDS.has(step.id),
83
- isAvailable: availabilityResults[i],
68
+ selection: "selected",
69
+ protection: PROTECTED_STEP_IDS.has(step.id) ? "protected" : "configurable",
70
+ availability: availabilityResults[stepIndex] ? "available" : "unavailable",
84
71
  }));
85
72
  let cursorIndex = 0;
86
73
  render(buildSetupScreen(items, cursorIndex));
87
- // Interactive selection loop
88
74
  await new Promise((resolve) => {
89
75
  readline.emitKeypressEvents(process.stdin);
90
76
  if (process.stdin.isTTY)
91
77
  process.stdin.setRawMode(true);
92
78
  const handleKeypress = (_char, key) => {
93
79
  if (key?.ctrl && key?.name === "c") {
94
- process.stdout.write("\x1b[?25h\n");
95
80
  process.exit(130);
96
81
  }
97
82
  if (key?.name === "up" || key?.name === "k") {
@@ -104,8 +89,8 @@ export async function runSetupCommand(allSteps) {
104
89
  }
105
90
  else if (key?.name === "space") {
106
91
  const item = items[cursorIndex];
107
- if (!item.isProtected) {
108
- item.selected = !item.selected;
92
+ if (item.protection === "configurable") {
93
+ item.selection = item.selection === "selected" ? "deselected" : "selected";
109
94
  render(buildSetupScreen(items, cursorIndex));
110
95
  }
111
96
  }
@@ -120,25 +105,19 @@ export async function runSetupCommand(allSteps) {
120
105
  process.stdin.on("keypress", handleKeypress);
121
106
  process.stdin.resume();
122
107
  });
123
- // Restore cursor
124
- process.stdout.write("\x1b[?25h");
125
- // Persist choices — only write explicit false entries (keeps file minimal, matches
126
- // the opt-out convention used everywhere else in the codebase)
127
108
  const stepsConfig = {};
128
109
  for (const item of items) {
129
- if (!item.isProtected && !item.selected) {
110
+ if (item.protection === "configurable" && item.selection === "deselected") {
130
111
  stepsConfig[item.step.id] = false;
131
112
  }
132
113
  }
133
114
  await writeConfig({ steps: stepsConfig });
134
- // Clear the setup screen and print a brief confirmation before the update starts
135
- if (prevLines > 0) {
136
- process.stdout.write(`\x1b[${prevLines}A\x1b[J`);
137
- }
138
- process.stdout.write(chalk.yellow(LOGO_ART) + "\n");
139
- process.stdout.write("\n");
140
- process.stdout.write(` ${chalk.green("\u2713")} Setup complete! Starting your first update\u2026\n`);
141
- process.stdout.write("\n");
142
- // Small pause so the confirmation is readable before the update screen takes over
143
- await new Promise((res) => setTimeout(res, 800));
115
+ const confirmationLines = [
116
+ chalk.yellow(LOGO_ART),
117
+ "",
118
+ ` ${chalk.green("\u2713")} Setup complete! Starting your first update\u2026`,
119
+ "",
120
+ ];
121
+ render(confirmationLines.join("\n") + "\n");
122
+ await new Promise((resolve) => setTimeout(resolve, 800));
144
123
  }
@@ -1,8 +1,10 @@
1
1
  import type { Step } from "../steps/index.js";
2
+ type PromptMode = "interactive" | "auto-accept";
3
+ type LogoVisibility = "visible" | "hidden";
2
4
  type RunUpdateCommandOptions = {
3
5
  steps: Step[];
4
- autoYes: boolean;
5
- showLogo: boolean;
6
+ promptMode: PromptMode;
7
+ logoVisibility: LogoVisibility;
6
8
  version: string;
7
9
  };
8
10
  export declare function runUpdateCommand(options: RunUpdateCommandOptions): Promise<void>;
@@ -3,21 +3,19 @@ import { createLogWriterForStep } from "../logger.js";
3
3
  import { collectVersions } from "../ui/versionReport.js";
4
4
  import { captureYesNo } from "../ui/prompt.js";
5
5
  import { createInitialAppState } from "../ui/state.js";
6
- import { buildScreen, renderScreen } from "../ui/screen.js";
6
+ import { buildScreen, createScreenRenderer } from "../ui/screen.js";
7
+ import { hideCursorDuringExecution } from "../ui/terminal.js";
8
+ import { createDefaultStepRunContext } from "../steps/index.js";
7
9
  export async function runUpdateCommand(options) {
8
10
  const { steps, version } = options;
9
- let autoYes = options.autoYes;
10
- const state = createInitialAppState(steps, version, options.showLogo);
11
- process.stdout.write("\x1b[?25l");
12
- process.on("exit", () => process.stdout.write("\x1b[?25h"));
13
- process.on("SIGINT", () => {
14
- process.stdout.write("\x1b[?25h\n");
15
- process.exit(130);
16
- });
11
+ let autoAccept = options.promptMode === "auto-accept";
12
+ const state = createInitialAppState(steps, version, options.logoVisibility);
13
+ const renderScreen = createScreenRenderer();
14
+ hideCursorDuringExecution();
17
15
  renderScreen(buildScreen(state));
18
- for (let i = 0; i < steps.length; i++) {
19
- const step = steps[i];
20
- state.currentStepIndex = i;
16
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
17
+ const step = steps[stepIndex];
18
+ state.currentStepIndex = stepIndex;
21
19
  state.currentPhase = "checking-availability";
22
20
  state.currentOutputLines = [];
23
21
  state.currentPrompt = undefined;
@@ -35,9 +33,9 @@ export async function runUpdateCommand(options) {
35
33
  question: `Install ${step.name} via Homebrew? (brew install ${step.brewPackageToInstall})`,
36
34
  };
37
35
  renderScreen(buildScreen(state));
38
- const installAnswer = autoYes ? "yes" : await captureYesNo();
36
+ const installAnswer = autoAccept ? "yes" : await captureYesNo();
39
37
  if (installAnswer === "force")
40
- autoYes = true;
38
+ autoAccept = true;
41
39
  const shouldInstall = installAnswer !== "no";
42
40
  state.currentPrompt = undefined;
43
41
  if (!shouldInstall) {
@@ -66,15 +64,12 @@ export async function runUpdateCommand(options) {
66
64
  continue;
67
65
  }
68
66
  }
69
- const pipWarning = step.id === "pip"
70
- ? "This updates all global pip3 packages, which can occasionally break tools."
71
- : undefined;
72
67
  state.currentPhase = "prompting-to-run";
73
- state.currentPrompt = { question: `Run ${step.name} update?`, warning: pipWarning };
68
+ state.currentPrompt = { question: `Run ${step.name} update?`, warning: step.warning };
74
69
  renderScreen(buildScreen(state));
75
- const runAnswer = autoYes ? "yes" : await captureYesNo();
70
+ const runAnswer = autoAccept ? "yes" : await captureYesNo();
76
71
  if (runAnswer === "force")
77
- autoYes = true;
72
+ autoAccept = true;
78
73
  const shouldRun = runAnswer !== "no";
79
74
  state.currentPrompt = undefined;
80
75
  if (!shouldRun) {
@@ -88,13 +83,10 @@ export async function runUpdateCommand(options) {
88
83
  renderScreen(buildScreen(state));
89
84
  const stepLogWriter = await createLogWriterForStep(step.id);
90
85
  try {
91
- await step.run({
92
- onOutputLine: (line) => {
93
- state.currentOutputLines.push(line);
94
- renderScreen(buildScreen(state));
95
- },
96
- logWriter: stepLogWriter,
97
- });
86
+ await step.run(createDefaultStepRunContext((line) => {
87
+ state.currentOutputLines.push(line);
88
+ renderScreen(buildScreen(state));
89
+ }, stepLogWriter));
98
90
  state.currentPhase = "complete";
99
91
  state.currentOutputLines = [];
100
92
  renderScreen(buildScreen(state));
@@ -108,7 +100,7 @@ export async function runUpdateCommand(options) {
108
100
  state.completedStepRecords.push({ phase: "failed", summaryNote: errorMessage });
109
101
  }
110
102
  }
111
- state.isFinished = true;
103
+ state.runCompletion = "finished";
112
104
  state.currentOutputLines = [];
113
105
  state.currentPrompt = undefined;
114
106
  renderScreen(buildScreen(state));
package/dist/config.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** Steps that cannot be disabled — brew is a hard runtime dependency. */
2
1
  export declare const PROTECTED_STEP_IDS: Set<string>;
3
2
  export type BraeburnConfig = {
4
3
  steps: Record<string, boolean>;
@@ -8,5 +7,7 @@ export declare function resolveConfigPath(): Promise<string>;
8
7
  export declare function configFileExists(): Promise<boolean>;
9
8
  export declare function readConfig(): Promise<BraeburnConfig>;
10
9
  export declare function writeConfig(config: BraeburnConfig): Promise<void>;
10
+ export declare function isSettingEnabled(config: BraeburnConfig, settingId: string): boolean;
11
11
  export declare function isStepEnabled(config: BraeburnConfig, stepId: string): boolean;
12
12
  export declare function isLogoEnabled(config: BraeburnConfig): boolean;
13
+ export declare function applySettingToConfig(config: BraeburnConfig, settingId: string, desiredState: "enable" | "disable"): BraeburnConfig;
package/dist/config.js CHANGED
@@ -2,12 +2,12 @@ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { parse, stringify } from "smol-toml";
5
- /** Steps that cannot be disabled — brew is a hard runtime dependency. */
6
5
  export const PROTECTED_STEP_IDS = new Set(["homebrew"]);
7
6
  const EMPTY_CONFIG = { steps: {} };
8
- async function pathExists(p) {
7
+ const LOGO_SETTING_ID = "logo";
8
+ async function pathExists(targetPath) {
9
9
  try {
10
- await access(p);
10
+ await access(targetPath);
11
11
  return true;
12
12
  }
13
13
  catch {
@@ -41,13 +41,35 @@ export async function writeConfig(config) {
41
41
  await mkdir(join(configPath, ".."), { recursive: true });
42
42
  await writeFile(configPath, stringify(config), "utf-8");
43
43
  }
44
- export function isStepEnabled(config, stepId) {
45
- if (PROTECTED_STEP_IDS.has(stepId))
44
+ export function isSettingEnabled(config, settingId) {
45
+ if (PROTECTED_STEP_IDS.has(settingId))
46
46
  return true;
47
- // Absent from config means enabled (opt-out model)
48
- return config.steps[stepId] !== false;
47
+ if (settingId === LOGO_SETTING_ID)
48
+ return config.logo !== false;
49
+ return config.steps[settingId] !== false;
50
+ }
51
+ export function isStepEnabled(config, stepId) {
52
+ return isSettingEnabled(config, stepId);
49
53
  }
50
54
  export function isLogoEnabled(config) {
51
- // Absent from config means enabled (opt-out model)
52
- return config.logo !== false;
55
+ return isSettingEnabled(config, LOGO_SETTING_ID);
56
+ }
57
+ export function applySettingToConfig(config, settingId, desiredState) {
58
+ const updatedConfig = structuredClone(config);
59
+ if (settingId === LOGO_SETTING_ID) {
60
+ if (desiredState === "enable") {
61
+ delete updatedConfig.logo;
62
+ }
63
+ else {
64
+ updatedConfig.logo = false;
65
+ }
66
+ return updatedConfig;
67
+ }
68
+ if (desiredState === "enable") {
69
+ delete updatedConfig.steps[settingId];
70
+ }
71
+ else {
72
+ updatedConfig.steps[settingId] = false;
73
+ }
74
+ return updatedConfig;
53
75
  }
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ program
34
34
  program
35
35
  .command("update", { isDefault: true })
36
36
  .description("Run system update steps (default command)")
37
- .argument("[steps...]", `Steps to run — omit to run all.\nAvailable: ${ALL_STEPS.map((s) => s.id).join(", ")}`)
37
+ .argument("[steps...]", `Steps to run — omit to run all.\nAvailable: ${ALL_STEPS.map((step) => step.id).join(", ")}`)
38
38
  .option("-y, --yes", "Auto-accept all prompts (default yes to everything)")
39
39
  .option("-f, --force", "Alias for --yes")
40
40
  .option("--no-logo", "Hide the logo")
@@ -60,7 +60,6 @@ Examples:
60
60
  `)
61
61
  .action(async (stepArguments, options) => {
62
62
  const autoYes = options.yes === true || options.force === true;
63
- // First-run: if no config file exists yet, show the setup wizard.
64
63
  if (!(await configFileExists())) {
65
64
  await runSetupCommand(ALL_STEPS);
66
65
  }
@@ -68,17 +67,14 @@ Examples:
68
67
  let stepsToRun = stepArguments.length === 0
69
68
  ? ALL_STEPS
70
69
  : resolveStepsByIds(stepArguments);
71
- // When no explicit steps are requested, filter out steps disabled in config.
72
- // Explicit step arguments always bypass config (user knows what they want).
73
70
  if (stepArguments.length === 0) {
74
71
  stepsToRun = stepsToRun.filter((step) => isStepEnabled(config, step.id));
75
72
  }
76
- // CLI --no-logo overrides config; otherwise defer to config preference.
77
- const showLogo = options.logo !== false && isLogoEnabled(config);
73
+ const logoIsEnabled = options.logo !== false && isLogoEnabled(config);
78
74
  await runUpdateCommand({
79
75
  steps: stepsToRun,
80
- autoYes,
81
- showLogo,
76
+ promptMode: autoYes ? "auto-accept" : "interactive",
77
+ logoVisibility: logoIsEnabled ? "visible" : "hidden",
82
78
  version: BRAEBURN_VERSION,
83
79
  });
84
80
  });
@@ -118,7 +114,7 @@ const configCommand = program
118
114
  .action(async () => {
119
115
  await runConfigCommand({ allSteps: ALL_STEPS });
120
116
  });
121
- const configurableSteps = ALL_STEPS.filter((s) => !PROTECTED_STEP_IDS.has(s.id));
117
+ const configurableSteps = ALL_STEPS.filter((step) => !PROTECTED_STEP_IDS.has(step.id));
122
118
  const configUpdateCommand = configCommand
123
119
  .command("update")
124
120
  .description("Enable or disable individual update steps")
@@ -136,20 +132,19 @@ for (const step of configurableSteps) {
136
132
  configUpdateCommand.option(`--${step.id}`, `Enable ${step.name} updates`);
137
133
  }
138
134
  configUpdateCommand.action(function () {
139
- // Use getOptionValueSource to detect only flags explicitly passed on the CLI.
140
- // Commander defaults --no-* to true, so we can't rely on option values alone.
141
- const stepUpdates = {};
135
+ // Commander defaults --no-* to true, so we use getOptionValueSource to detect explicit CLI flags.
136
+ const settingUpdates = {};
142
137
  for (const step of configurableSteps) {
143
138
  const source = configUpdateCommand.getOptionValueSource(step.id);
144
139
  if (source === "cli") {
145
- stepUpdates[step.id] = configUpdateCommand.opts()[step.id];
140
+ settingUpdates[step.id] = configUpdateCommand.opts()[step.id] ? "enable" : "disable";
146
141
  }
147
142
  }
148
143
  const logoSource = configUpdateCommand.getOptionValueSource("logo");
149
- const logoUpdate = logoSource === "cli"
150
- ? configUpdateCommand.opts().logo
151
- : undefined;
152
- runConfigUpdateCommand({ stepUpdates, logoUpdate, allSteps: ALL_STEPS });
144
+ if (logoSource === "cli") {
145
+ settingUpdates["logo"] = configUpdateCommand.opts().logo ? "enable" : "disable";
146
+ }
147
+ runConfigUpdateCommand({ settingUpdates, allSteps: ALL_STEPS });
153
148
  });
154
149
  function resolveStepsByIds(stepIds) {
155
150
  const resolvedSteps = [];
package/dist/logger.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export type StepLogWriter = (line: string) => Promise<void>;
2
- export declare function createLogWriterForStep(stepId: string): Promise<StepLogWriter>;
3
- export declare function findLatestLogFileForStep(stepId: string): string | null;
4
- export declare function listAllStepIdsWithLogs(): string[];
2
+ export declare function createLogWriterForStep(stepId: string, logDirectory?: string): Promise<StepLogWriter>;
3
+ export declare function findLatestLogFileForStep(stepId: string, logDirectory?: string): string | null;
4
+ export declare function listAllStepIdsWithLogs(logDirectory?: string): string[];
package/dist/logger.js CHANGED
@@ -2,41 +2,41 @@ import { mkdir, appendFile } from "node:fs/promises";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- const BRAEBURN_LOG_DIRECTORY = join(homedir(), ".braeburn", "logs");
6
- async function ensureLogDirectoryExists() {
7
- if (existsSync(BRAEBURN_LOG_DIRECTORY)) {
5
+ const DEFAULT_LOG_DIRECTORY = join(homedir(), ".braeburn", "logs");
6
+ async function ensureDirectoryExists(directoryPath) {
7
+ if (existsSync(directoryPath)) {
8
8
  return;
9
9
  }
10
- await mkdir(BRAEBURN_LOG_DIRECTORY, { recursive: true });
10
+ await mkdir(directoryPath, { recursive: true });
11
11
  }
12
- export async function createLogWriterForStep(stepId) {
13
- await ensureLogDirectoryExists();
12
+ export async function createLogWriterForStep(stepId, logDirectory = DEFAULT_LOG_DIRECTORY) {
13
+ await ensureDirectoryExists(logDirectory);
14
14
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
15
- const logFilePath = join(BRAEBURN_LOG_DIRECTORY, `${stepId}-${timestamp}.log`);
15
+ const logFilePath = join(logDirectory, `${stepId}-${timestamp}.log`);
16
16
  const writeLineToLog = async (line) => {
17
17
  await appendFile(logFilePath, line + "\n", "utf-8");
18
18
  };
19
19
  return writeLineToLog;
20
20
  }
21
- export function findLatestLogFileForStep(stepId) {
22
- if (!existsSync(BRAEBURN_LOG_DIRECTORY)) {
21
+ export function findLatestLogFileForStep(stepId, logDirectory = DEFAULT_LOG_DIRECTORY) {
22
+ if (!existsSync(logDirectory)) {
23
23
  return null;
24
24
  }
25
- const allFiles = readdirSync(BRAEBURN_LOG_DIRECTORY);
25
+ const allFiles = readdirSync(logDirectory);
26
26
  const filesForThisStep = allFiles
27
27
  .filter((fileName) => fileName.startsWith(`${stepId}-`))
28
28
  .sort()
29
- .reverse(); // most recent first
29
+ .reverse();
30
30
  if (filesForThisStep.length === 0) {
31
31
  return null;
32
32
  }
33
- return join(BRAEBURN_LOG_DIRECTORY, filesForThisStep[0]);
33
+ return join(logDirectory, filesForThisStep[0]);
34
34
  }
35
- export function listAllStepIdsWithLogs() {
36
- if (!existsSync(BRAEBURN_LOG_DIRECTORY)) {
35
+ export function listAllStepIdsWithLogs(logDirectory = DEFAULT_LOG_DIRECTORY) {
36
+ if (!existsSync(logDirectory)) {
37
37
  return [];
38
38
  }
39
- const allFiles = readdirSync(BRAEBURN_LOG_DIRECTORY);
39
+ const allFiles = readdirSync(logDirectory);
40
40
  const stepIds = new Set(allFiles
41
41
  .map((fileName) => fileName.split("-")[0])
42
42
  .filter((maybeStepId) => Boolean(maybeStepId)));
package/dist/logo.js CHANGED
@@ -1,5 +1,3 @@
1
- // The ASCII art is intentionally left-aligned with spaces as part of the design.
2
- // Keep the template literal formatting exactly as-is.
3
1
  export const LOGO_ART = ` ;
4
2
  :x :
5
3
  .x\$+x
package/dist/runner.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { StepLogWriter } from "./logger.js";
2
+ export type OutputSource = "stdout" | "stderr";
2
3
  export type CommandOutputLine = {
3
4
  text: string;
4
- isError: boolean;
5
+ source: OutputSource;
5
6
  };
6
7
  export type OutputLineCallback = (line: CommandOutputLine) => void;
7
8
  type RunCommandOptions = {
package/dist/runner.js CHANGED
@@ -7,14 +7,14 @@ export async function runShellCommand(options) {
7
7
  subprocess.stdout?.on("data", (chunk) => {
8
8
  const lines = String(chunk).split("\n").filter(Boolean);
9
9
  for (const line of lines) {
10
- options.onOutputLine({ text: line, isError: false });
10
+ options.onOutputLine({ text: line, source: "stdout" });
11
11
  options.logWriter(line);
12
12
  }
13
13
  });
14
14
  subprocess.stderr?.on("data", (chunk) => {
15
15
  const lines = String(chunk).split("\n").filter(Boolean);
16
16
  for (const line of lines) {
17
- options.onOutputLine({ text: line, isError: true });
17
+ options.onOutputLine({ text: line, source: "stderr" });
18
18
  options.logWriter(line);
19
19
  }
20
20
  });
@@ -1,4 +1,4 @@
1
- import { checkCommandExists, runStep } from "./index.js";
1
+ import { checkCommandExists } from "./index.js";
2
2
  const cleanupStep = {
3
3
  id: "cleanup",
4
4
  name: "Cleanup",
@@ -7,7 +7,7 @@ const cleanupStep = {
7
7
  return checkCommandExists("brew");
8
8
  },
9
9
  async run(context) {
10
- await runStep("brew cleanup", context);
10
+ await context.runStep("brew cleanup");
11
11
  },
12
12
  };
13
13
  export default cleanupStep;
@@ -1,14 +1,13 @@
1
- import { checkCommandExists, runStep } from "./index.js";
1
+ import { checkCommandExists } from "./index.js";
2
2
  const dotnetStep = {
3
3
  id: "dotnet",
4
4
  name: ".NET",
5
5
  description: "Update all globally installed .NET tools",
6
- // No brewPackageToInstall — .NET has its own installer
7
6
  async checkIsAvailable() {
8
7
  return checkCommandExists("dotnet");
9
8
  },
10
9
  async run(context) {
11
- await runStep("dotnet tool update --global --all", context);
10
+ await context.runStep("dotnet tool update --global --all");
12
11
  },
13
12
  };
14
13
  export default dotnetStep;
@@ -1,4 +1,4 @@
1
- import { checkCommandExists, runStep } from "./index.js";
1
+ import { checkCommandExists } from "./index.js";
2
2
  const homebrewStep = {
3
3
  id: "homebrew",
4
4
  name: "Homebrew",
@@ -7,7 +7,7 @@ const homebrewStep = {
7
7
  return checkCommandExists("brew");
8
8
  },
9
9
  async run(context) {
10
- await runStep("brew update && brew upgrade", context);
10
+ await context.runStep("brew update && brew upgrade");
11
11
  },
12
12
  };
13
13
  export default homebrewStep;
@@ -3,11 +3,16 @@ import type { StepLogWriter } from "../logger.js";
3
3
  export type StepRunContext = {
4
4
  onOutputLine: OutputLineCallback;
5
5
  logWriter: StepLogWriter;
6
+ runStep: (shellCommand: string) => Promise<void>;
7
+ captureOutput: (options: {
8
+ shellCommand: string;
9
+ }) => Promise<string>;
6
10
  };
7
11
  export type Step = {
8
12
  id: string;
9
13
  name: string;
10
14
  description: string;
15
+ warning?: string;
11
16
  brewPackageToInstall?: string;
12
17
  checkIsAvailable: () => Promise<boolean>;
13
18
  run: (context: StepRunContext) => Promise<void>;
@@ -25,3 +30,4 @@ export { default as cleanupStep } from "./cleanup.js";
25
30
  export declare function checkCommandExists(command: string): Promise<boolean>;
26
31
  export declare function checkPathExists(filePath: string): Promise<boolean>;
27
32
  export declare function runStep(shellCommand: string, context: StepRunContext): Promise<void>;
33
+ export declare function createDefaultStepRunContext(onOutputLine: OutputLineCallback, logWriter: StepLogWriter): StepRunContext;
@@ -1,4 +1,4 @@
1
- import { doesShellCommandSucceed, runShellCommand, } from "../runner.js";
1
+ import { doesShellCommandSucceed, runShellCommand, captureShellCommandOutput, } from "../runner.js";
2
2
  export { default as homebrewStep } from "./homebrew.js";
3
3
  export { default as masStep } from "./mas.js";
4
4
  export { default as ohmyzshStep } from "./ohmyzsh.js";
@@ -22,3 +22,12 @@ export async function runStep(shellCommand, context) {
22
22
  logWriter: context.logWriter,
23
23
  });
24
24
  }
25
+ export function createDefaultStepRunContext(onOutputLine, logWriter) {
26
+ const context = {
27
+ onOutputLine,
28
+ logWriter,
29
+ runStep: (shellCommand) => runStep(shellCommand, context),
30
+ captureOutput: captureShellCommandOutput,
31
+ };
32
+ return context;
33
+ }
@@ -1,6 +1,3 @@
1
- import { runStep, } from "./index.js";
2
- import { captureShellCommandOutput } from "../runner.js";
3
- // softwareupdate always exists on macOS — no availability check needed
4
1
  const macosStep = {
5
2
  id: "macos",
6
3
  name: "macOS",
@@ -9,7 +6,7 @@ const macosStep = {
9
6
  return true;
10
7
  },
11
8
  async run(context) {
12
- const updateListOutput = await captureShellCommandOutput({
9
+ const updateListOutput = await context.captureOutput({
13
10
  shellCommand: "softwareupdate -l 2>&1",
14
11
  });
15
12
  context.logWriter(updateListOutput);
@@ -17,16 +14,16 @@ const macosStep = {
17
14
  if (noUpdatesAvailable) {
18
15
  context.onOutputLine({
19
16
  text: "macOS is already up to date.",
20
- isError: false,
17
+ source: "stdout",
21
18
  });
22
19
  return;
23
20
  }
24
- context.onOutputLine({ text: updateListOutput, isError: false });
21
+ context.onOutputLine({ text: updateListOutput, source: "stdout" });
25
22
  context.onOutputLine({
26
23
  text: "Updates found — installing now...",
27
- isError: false,
24
+ source: "stdout",
28
25
  });
29
- await runStep("softwareupdate -ia --verbose", context);
26
+ await context.runStep("softwareupdate -ia --verbose");
30
27
  },
31
28
  };
32
29
  export default macosStep;
package/dist/steps/mas.js CHANGED
@@ -1,4 +1,4 @@
1
- import { checkCommandExists, runStep } from "./index.js";
1
+ import { checkCommandExists } from "./index.js";
2
2
  const masStep = {
3
3
  id: "mas",
4
4
  name: "Mac App Store",
@@ -8,7 +8,7 @@ const masStep = {
8
8
  return checkCommandExists("mas");
9
9
  },
10
10
  async run(context) {
11
- await runStep("mas upgrade", context);
11
+ await context.runStep("mas upgrade");
12
12
  },
13
13
  };
14
14
  export default masStep;
package/dist/steps/npm.js CHANGED
@@ -1,14 +1,13 @@
1
- import { checkCommandExists, runStep } from "./index.js";
1
+ import { checkCommandExists } from "./index.js";
2
2
  const npmStep = {
3
3
  id: "npm",
4
4
  name: "npm",
5
5
  description: "Update all globally installed npm packages",
6
- // No brewPackageToInstall — npm comes bundled with Node.js
7
6
  async checkIsAvailable() {
8
7
  return checkCommandExists("npm");
9
8
  },
10
9
  async run(context) {
11
- await runStep("npm update -g", context);
10
+ await context.runStep("npm update -g");
12
11
  },
13
12
  };
14
13
  export default npmStep;
package/dist/steps/nvm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { checkPathExists, runStep } from "./index.js";
3
+ import { checkPathExists } from "./index.js";
4
4
  const NVM_DIRECTORY = join(homedir(), ".nvm");
5
5
  // nvm is a shell function sourced from nvm.sh — it cannot be invoked as a
6
6
  // standalone binary, so we source it explicitly inside each bash invocation.
@@ -9,12 +9,11 @@ const nvmStep = {
9
9
  id: "nvm",
10
10
  name: "Node.js (nvm)",
11
11
  description: "Install the latest Node.js via nvm, migrating packages from the current version",
12
- // No brewPackageToInstall — nvm is installed via a curl script, not Homebrew
13
12
  async checkIsAvailable() {
14
13
  return checkPathExists(NVM_DIRECTORY);
15
14
  },
16
15
  async run(context) {
17
- await runStep(`${NVM_SOURCE_PREFIX} && nvm install node --reinstall-packages-from=node`, context);
16
+ await context.runStep(`${NVM_SOURCE_PREFIX} && nvm install node --reinstall-packages-from=node`);
18
17
  },
19
18
  };
20
19
  export default nvmStep;
@@ -1,17 +1,16 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { checkPathExists, runStep } from "./index.js";
3
+ import { checkPathExists } from "./index.js";
4
4
  const OH_MY_ZSH_UPGRADE_SCRIPT_PATH = join(homedir(), ".oh-my-zsh", "tools", "upgrade.sh");
5
5
  const ohmyzshStep = {
6
6
  id: "ohmyzsh",
7
7
  name: "Oh My Zsh",
8
8
  description: "Update Oh My Zsh to the latest version",
9
- // No brewPackageToInstall — Oh My Zsh is installed via a curl script, not Homebrew
10
9
  async checkIsAvailable() {
11
10
  return checkPathExists(OH_MY_ZSH_UPGRADE_SCRIPT_PATH);
12
11
  },
13
12
  async run(context) {
14
- await runStep(`zsh "${OH_MY_ZSH_UPGRADE_SCRIPT_PATH}"`, context);
13
+ await context.runStep(`zsh "${OH_MY_ZSH_UPGRADE_SCRIPT_PATH}"`);
15
14
  },
16
15
  };
17
16
  export default ohmyzshStep;
package/dist/steps/pip.js CHANGED
@@ -1,17 +1,15 @@
1
- import { checkCommandExists, runStep } from "./index.js";
2
- // Updating all global pip3 packages can occasionally break tools that depend
3
- // on specific versions. The warning is surfaced in the UI before running.
1
+ import { checkCommandExists } from "./index.js";
4
2
  const PIP_UPDATE_ALL_OUTDATED_SHELL_COMMAND = "pip3 list --outdated --format=columns | tail -n +3 | awk '{print $1}' | xargs -n1 pip3 install -U";
5
3
  const pipStep = {
6
4
  id: "pip",
7
5
  name: "pip3",
8
6
  description: "Update all globally installed pip3 packages",
9
- // No brewPackageToInstall pip3 comes with Python
7
+ warning: "This updates all global pip3 packages, which can occasionally break tools.",
10
8
  async checkIsAvailable() {
11
9
  return checkCommandExists("pip3");
12
10
  },
13
11
  async run(context) {
14
- await runStep(PIP_UPDATE_ALL_OUTDATED_SHELL_COMMAND, context);
12
+ await context.runStep(PIP_UPDATE_ALL_OUTDATED_SHELL_COMMAND);
15
13
  },
16
14
  };
17
15
  export default pipStep;
@@ -1,7 +1,4 @@
1
- import { checkCommandExists, runStep, } from "./index.js";
2
- import { captureShellCommandOutput } from "../runner.js";
3
- // Dynamically finds the latest stable Python 3.x.y release known to pyenv,
4
- // excluding pre-releases (alpha, beta, release candidates, dev builds).
1
+ import { checkCommandExists, } from "./index.js";
5
2
  const FIND_LATEST_STABLE_PYTHON_SHELL_COMMAND = "pyenv install -l | grep -E '^\\s+3\\.[0-9]+\\.[0-9]+$' | grep -vE 'dev|a[0-9]|b[0-9]|rc[0-9]' | tail -1 | tr -d ' '";
6
3
  const pyenvStep = {
7
4
  id: "pyenv",
@@ -12,18 +9,18 @@ const pyenvStep = {
12
9
  return checkCommandExists("pyenv");
13
10
  },
14
11
  async run(context) {
15
- await runStep("brew upgrade pyenv", context);
16
- const latestPythonVersion = await captureShellCommandOutput({
12
+ await context.runStep("brew upgrade pyenv");
13
+ const latestPythonVersion = await context.captureOutput({
17
14
  shellCommand: FIND_LATEST_STABLE_PYTHON_SHELL_COMMAND,
18
15
  });
19
16
  if (!latestPythonVersion) {
20
17
  context.onOutputLine({
21
18
  text: "Could not determine latest Python version — skipping pyenv install.",
22
- isError: true,
19
+ source: "stderr",
23
20
  });
24
21
  return;
25
22
  }
26
- await runStep(`pyenv install --skip-existing ${latestPythonVersion}`, context);
23
+ await context.runStep(`pyenv install --skip-existing ${latestPythonVersion}`);
27
24
  },
28
25
  };
29
26
  export default pyenvStep;
@@ -1,12 +1,19 @@
1
1
  import type { Step } from "../steps/index.js";
2
- import type { StepPhase, CompletedStepRecord } from "./state.js";
2
+ import type { StepPhase, CompletedStepRecord, LogoVisibility } from "./state.js";
3
+ import type { TerminalDimensions } from "./outputBox.js";
4
+ type LogoLayout = "side-by-side" | "stacked" | "none";
5
+ export declare function determineLogoLayout(logoLines: string[], dimensions?: TerminalDimensions): LogoLayout;
6
+ export declare function stepTrackerIcon(phase: StepPhase): string;
7
+ export declare function isActivePhase(phase: StepPhase): boolean;
8
+ export declare function deriveAllStepPhases(steps: Step[], currentStepIndex: number, currentPhase: StepPhase, completedStepRecords: CompletedStepRecord[]): StepPhase[];
3
9
  type BuildHeaderOptions = {
4
10
  steps: Step[];
5
11
  version: string;
6
- showLogo: boolean;
12
+ logoVisibility: LogoVisibility;
7
13
  currentStepIndex: number;
8
14
  currentPhase: StepPhase;
9
15
  completedStepRecords: CompletedStepRecord[];
16
+ terminalDimensions?: TerminalDimensions;
10
17
  };
11
18
  export declare function buildHeaderLines(options: BuildHeaderOptions): string[];
12
19
  export {};
package/dist/ui/header.js CHANGED
@@ -2,7 +2,19 @@ import chalk from "chalk";
2
2
  import { LOGO_ART } from "../logo.js";
3
3
  const LOGO_COLUMN_WIDTH = 32;
4
4
  const LOGO_SEPARATOR = " ";
5
- function stepTrackerIcon(phase) {
5
+ const MIN_SIDE_BY_SIDE_COLS = LOGO_COLUMN_WIDTH + LOGO_SEPARATOR.length + 20; // 56
6
+ export function determineLogoLayout(logoLines, dimensions) {
7
+ const cols = dimensions?.columns ?? process.stdout.columns ?? 80;
8
+ const rows = dimensions?.rows ?? process.stdout.rows ?? 24;
9
+ if (cols >= MIN_SIDE_BY_SIDE_COLS) {
10
+ return "side-by-side";
11
+ }
12
+ if (rows >= logoLines.length + 6) {
13
+ return "stacked";
14
+ }
15
+ return "none";
16
+ }
17
+ export function stepTrackerIcon(phase) {
6
18
  if (phase === "complete")
7
19
  return chalk.green("✓ ");
8
20
  if (phase === "failed")
@@ -17,15 +29,15 @@ function stepTrackerIcon(phase) {
17
29
  return chalk.cyan("→ ");
18
30
  return chalk.dim("· ");
19
31
  }
20
- function isActivePhase(phase) {
32
+ export function isActivePhase(phase) {
21
33
  return (phase === "running" ||
22
34
  phase === "installing" ||
23
35
  phase === "prompting-to-run" ||
24
36
  phase === "prompting-to-install" ||
25
37
  phase === "checking-availability");
26
38
  }
27
- function deriveAllStepPhases(steps, currentStepIndex, currentPhase, completedStepRecords) {
28
- return steps.map((_, index) => {
39
+ export function deriveAllStepPhases(steps, currentStepIndex, currentPhase, completedStepRecords) {
40
+ return steps.map((_step, index) => {
29
41
  if (index < completedStepRecords.length)
30
42
  return completedStepRecords[index].phase;
31
43
  if (index === currentStepIndex)
@@ -34,7 +46,7 @@ function deriveAllStepPhases(steps, currentStepIndex, currentPhase, completedSte
34
46
  });
35
47
  }
36
48
  export function buildHeaderLines(options) {
37
- const { steps, version, showLogo, currentStepIndex, currentPhase, completedStepRecords } = options;
49
+ const { steps, version, logoVisibility, currentStepIndex, currentPhase, completedStepRecords } = options;
38
50
  const phases = deriveAllStepPhases(steps, currentStepIndex, currentPhase, completedStepRecords);
39
51
  const rightColumnLines = [
40
52
  `${chalk.bold.white("braeburn")} ${chalk.dim("v" + version)}`,
@@ -46,18 +58,28 @@ export function buildHeaderLines(options) {
46
58
  return `${icon}${name}`;
47
59
  }),
48
60
  ];
49
- if (!showLogo) {
61
+ if (logoVisibility === "hidden") {
50
62
  return rightColumnLines;
51
63
  }
52
64
  const logoLines = LOGO_ART.split("\n");
65
+ const layout = determineLogoLayout(logoLines, options.terminalDimensions);
66
+ if (layout === "none") {
67
+ return rightColumnLines;
68
+ }
69
+ if (layout === "stacked") {
70
+ return [
71
+ ...logoLines.map((line) => chalk.yellow(line)),
72
+ "",
73
+ ...rightColumnLines,
74
+ ];
75
+ }
53
76
  const totalLines = Math.max(logoLines.length, rightColumnLines.length);
54
77
  const result = [];
55
- for (let i = 0; i < totalLines; i++) {
56
- // Pad the raw logo line to a fixed width before applying colour, so the
57
- // ANSI escape codes don't affect visual alignment.
58
- const rawLogoLine = (logoLines[i] ?? "").padEnd(LOGO_COLUMN_WIDTH);
78
+ for (let lineIndex = 0; lineIndex < totalLines; lineIndex++) {
79
+ // Padding must happen before chalk; ANSI escape codes break .padEnd() alignment.
80
+ const rawLogoLine = (logoLines[lineIndex] ?? "").padEnd(LOGO_COLUMN_WIDTH);
59
81
  const logoColumn = chalk.yellow(rawLogoLine);
60
- const rightColumn = rightColumnLines[i] ?? "";
82
+ const rightColumn = rightColumnLines[lineIndex] ?? "";
61
83
  result.push(`${logoColumn}${LOGO_SEPARATOR}${rightColumn}`);
62
84
  }
63
85
  return result;
@@ -1,2 +1,6 @@
1
1
  import type { CommandOutputLine } from "../runner.js";
2
- export declare function buildOutputBoxLines(lines: CommandOutputLine[], stepName: string): string[];
2
+ export type TerminalDimensions = {
3
+ columns: number;
4
+ rows: number;
5
+ };
6
+ export declare function buildOutputBoxLines(lines: CommandOutputLine[], stepName: string, dimensions?: TerminalDimensions): string[];
@@ -3,17 +3,23 @@ const INDENT = " ";
3
3
  const HEADER_LINES_APPROXIMATE = 18;
4
4
  const OUTPUT_BOX_CHROME_LINES = 3;
5
5
  const MINIMUM_VISIBLE_LINES = 5;
6
- function maxVisibleLines() {
7
- const rows = process.stdout.rows ?? 40;
6
+ function maxVisibleLines(rows) {
8
7
  const available = rows - HEADER_LINES_APPROXIMATE - OUTPUT_BOX_CHROME_LINES;
9
8
  return Math.max(MINIMUM_VISIBLE_LINES, available);
10
9
  }
11
- function boxWidth() {
12
- return Math.min(process.stdout.columns ?? 80, 120) - INDENT.length * 2;
10
+ function boxWidth(columns) {
11
+ return Math.min(columns, 120) - INDENT.length * 2;
13
12
  }
14
- export function buildOutputBoxLines(lines, stepName) {
15
- const visibleLines = lines.slice(-maxVisibleLines());
16
- const width = boxWidth();
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);
17
23
  const headerLabel = `─ ${stepName} output `;
18
24
  const topDashes = "─".repeat(Math.max(0, width - headerLabel.length - 2));
19
25
  const topBorder = chalk.dim(`${INDENT}┌${headerLabel}${topDashes}┐`);
@@ -21,7 +27,7 @@ export function buildOutputBoxLines(lines, stepName) {
21
27
  const result = [topBorder];
22
28
  for (const line of visibleLines) {
23
29
  const truncated = line.text.slice(0, width - 4);
24
- const colored = line.isError ? chalk.yellow(truncated) : chalk.dim(truncated);
30
+ const colored = line.source === "stderr" ? chalk.yellow(truncated) : chalk.dim(truncated);
25
31
  result.push(`${INDENT}${chalk.dim("│")} ${colored}`);
26
32
  }
27
33
  result.push(bottomBorder);
@@ -1,3 +1,5 @@
1
+ import { type TerminalDimensions } from "./outputBox.js";
1
2
  import type { AppState } from "./state.js";
2
- export declare function renderScreen(content: string): void;
3
- export declare function buildScreen(state: AppState): string;
3
+ export type ScreenRenderer = (content: string) => void;
4
+ export declare function createScreenRenderer(output?: NodeJS.WritableStream): ScreenRenderer;
5
+ export declare function buildScreen(state: AppState, terminalDimensions?: TerminalDimensions): string;
package/dist/ui/screen.js CHANGED
@@ -3,26 +3,29 @@ import { buildActiveStepLines } from "./currentStep.js";
3
3
  import { buildOutputBoxLines } from "./outputBox.js";
4
4
  import { buildPromptLines } from "./prompt.js";
5
5
  import { buildVersionReportLines } from "./versionReport.js";
6
- let previousLineCount = 0;
7
- export function renderScreen(content) {
8
- if (previousLineCount > 0) {
9
- process.stdout.write(`\x1b[${previousLineCount}A\x1b[J`);
10
- }
11
- process.stdout.write(content);
12
- previousLineCount = (content.match(/\n/g) ?? []).length;
6
+ export function createScreenRenderer(output = process.stdout) {
7
+ let previousLineCount = 0;
8
+ return (content) => {
9
+ if (previousLineCount > 0) {
10
+ output.write(`\x1b[${previousLineCount}A\x1b[J`);
11
+ }
12
+ output.write(content);
13
+ previousLineCount = (content.match(/\n/g) ?? []).length;
14
+ };
13
15
  }
14
- export function buildScreen(state) {
16
+ export function buildScreen(state, terminalDimensions) {
15
17
  const lines = [];
16
18
  lines.push(...buildHeaderLines({
17
19
  steps: state.steps,
18
20
  version: state.version,
19
- showLogo: state.showLogo,
21
+ logoVisibility: state.logoVisibility,
20
22
  currentStepIndex: state.currentStepIndex,
21
23
  currentPhase: state.currentPhase,
22
24
  completedStepRecords: state.completedStepRecords,
25
+ terminalDimensions,
23
26
  }));
24
27
  lines.push("");
25
- if (state.isFinished) {
28
+ if (state.runCompletion === "finished") {
26
29
  if (state.versionReport) {
27
30
  lines.push("");
28
31
  lines.push(...buildVersionReportLines(state.versionReport));
@@ -42,7 +45,7 @@ export function buildScreen(state) {
42
45
  state.currentOutputLines.length > 0;
43
46
  if (isShowingOutput) {
44
47
  lines.push("");
45
- lines.push(...buildOutputBoxLines(state.currentOutputLines, currentStep.name));
48
+ lines.push(...buildOutputBoxLines(state.currentOutputLines, currentStep.name, terminalDimensions));
46
49
  }
47
50
  if (state.currentPrompt) {
48
51
  lines.push("");
@@ -13,16 +13,18 @@ export type ResolvedVersion = {
13
13
  label: string;
14
14
  value: string;
15
15
  };
16
+ export type LogoVisibility = "visible" | "hidden";
17
+ export type RunCompletion = "in-progress" | "finished";
16
18
  export type AppState = {
17
19
  steps: Step[];
18
20
  version: string;
19
- showLogo: boolean;
21
+ logoVisibility: LogoVisibility;
20
22
  currentStepIndex: number;
21
23
  currentPhase: StepPhase;
22
24
  completedStepRecords: CompletedStepRecord[];
23
25
  currentOutputLines: CommandOutputLine[];
24
26
  currentPrompt: CurrentPrompt | undefined;
25
- isFinished: boolean;
27
+ runCompletion: RunCompletion;
26
28
  versionReport: ResolvedVersion[] | undefined;
27
29
  };
28
- export declare function createInitialAppState(steps: Step[], version: string, showLogo: boolean): AppState;
30
+ export declare function createInitialAppState(steps: Step[], version: string, logoVisibility: LogoVisibility): AppState;
package/dist/ui/state.js CHANGED
@@ -1,14 +1,14 @@
1
- export function createInitialAppState(steps, version, showLogo) {
1
+ export function createInitialAppState(steps, version, logoVisibility) {
2
2
  return {
3
3
  steps,
4
4
  version,
5
- showLogo,
5
+ logoVisibility,
6
6
  currentStepIndex: 0,
7
7
  currentPhase: "checking-availability",
8
8
  completedStepRecords: [],
9
9
  currentOutputLines: [],
10
10
  currentPrompt: undefined,
11
- isFinished: false,
11
+ runCompletion: "in-progress",
12
12
  versionReport: undefined,
13
13
  };
14
14
  }
@@ -0,0 +1,6 @@
1
+ type TerminalOptions = {
2
+ output?: NodeJS.WritableStream;
3
+ };
4
+ type CursorCleanup = () => void;
5
+ export declare function hideCursorDuringExecution(options?: TerminalOptions): CursorCleanup;
6
+ export {};
@@ -0,0 +1,16 @@
1
+ export function hideCursorDuringExecution(options = {}) {
2
+ const output = options.output ?? process.stdout;
3
+ output.write("\x1b[?25l");
4
+ const restoreOnExit = () => output.write("\x1b[?25h");
5
+ const restoreAndExitOnInterrupt = () => {
6
+ output.write("\x1b[?25h\n");
7
+ process.exit(130);
8
+ };
9
+ process.on("exit", restoreOnExit);
10
+ process.on("SIGINT", restoreAndExitOnInterrupt);
11
+ return () => {
12
+ process.removeListener("exit", restoreOnExit);
13
+ process.removeListener("SIGINT", restoreAndExitOnInterrupt);
14
+ output.write("\x1b[?25h");
15
+ };
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braeburn",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "macOS system updater CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@
17
17
  "postbuild": "chmod +x dist/index.js",
18
18
  "dev": "tsx src/index.ts",
19
19
  "start": "node dist/index.js",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
20
22
  "prepublishOnly": "npm run build"
21
23
  },
22
24
  "dependencies": {
@@ -28,6 +30,7 @@
28
30
  "devDependencies": {
29
31
  "@types/node": "^24.0.0",
30
32
  "tsx": "^4.7.0",
31
- "typescript": "^5.5.0"
33
+ "typescript": "^5.5.0",
34
+ "vitest": "^4.0.18"
32
35
  }
33
36
  }