braeburn 1.6.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { Command } from "commander";
2
+ import type { Step } from "./steps/index.js";
3
+ import { applyUpdateCommandResult, runUpdateCommand } from "./commands/update.js";
4
+ import { runLogCommand, runLogListCommand } from "./commands/log.js";
5
+ import { runConfigCommand, runConfigUpdateCommand } from "./commands/config.js";
6
+ import { runSetupCommand } from "./commands/setup.js";
7
+ import { configFileExists, readConfig } from "./config.js";
8
+ type CliProcess = Pick<NodeJS.Process, "stderr"> & {
9
+ exitCode: string | number | null | undefined;
10
+ };
11
+ type CliCommandDependencies = {
12
+ applyUpdateCommandResult: typeof applyUpdateCommandResult;
13
+ configFileExists: typeof configFileExists;
14
+ readConfig: typeof readConfig;
15
+ runConfigCommand: typeof runConfigCommand;
16
+ runConfigUpdateCommand: typeof runConfigUpdateCommand;
17
+ runLogCommand: typeof runLogCommand;
18
+ runLogListCommand: typeof runLogListCommand;
19
+ runSetupCommand: typeof runSetupCommand;
20
+ runUpdateCommand: typeof runUpdateCommand;
21
+ };
22
+ type CreateBraeburnProgramOptions = {
23
+ allSteps?: Step[];
24
+ dependencies?: Partial<CliCommandDependencies>;
25
+ processLike?: CliProcess;
26
+ version?: string;
27
+ };
28
+ export type StepResolution = {
29
+ status: "resolved";
30
+ steps: Step[];
31
+ } | {
32
+ status: "unknown-step";
33
+ stepId: string;
34
+ };
35
+ export declare function resolveStepsByIds(stepIds: string[], allSteps: Step[]): StepResolution;
36
+ export declare function reportCliError(error: unknown, processLike?: CliProcess): void;
37
+ export declare function createBraeburnProgram(options?: CreateBraeburnProgramOptions): Command;
38
+ export declare function runBraeburnCli(argv?: string[]): Promise<void>;
39
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,226 @@
1
+ import { Command } from "commander";
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { ALL_STEPS } from "./steps/catalog.js";
6
+ import { applyUpdateCommandResult, runUpdateCommand } from "./commands/update.js";
7
+ import { runLogCommand, runLogListCommand } from "./commands/log.js";
8
+ import { runConfigCommand, runConfigUpdateCommand } from "./commands/config.js";
9
+ import { runSetupCommand } from "./commands/setup.js";
10
+ import { ConfigReadError, PROTECTED_STEP_IDS, configFileExists, isLogoEnabled, isStepEnabled, readConfig, } from "./config.js";
11
+ const requireFromThis = createRequire(import.meta.url);
12
+ function resolveBraeburnVersion() {
13
+ const packageJson = requireFromThis(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"));
14
+ return packageJson.version;
15
+ }
16
+ function resolveDependencies(overrides) {
17
+ return {
18
+ applyUpdateCommandResult: overrides?.applyUpdateCommandResult ?? applyUpdateCommandResult,
19
+ configFileExists: overrides?.configFileExists ?? configFileExists,
20
+ readConfig: overrides?.readConfig ?? readConfig,
21
+ runConfigCommand: overrides?.runConfigCommand ?? runConfigCommand,
22
+ runConfigUpdateCommand: overrides?.runConfigUpdateCommand ?? runConfigUpdateCommand,
23
+ runLogCommand: overrides?.runLogCommand ?? runLogCommand,
24
+ runLogListCommand: overrides?.runLogListCommand ?? runLogListCommand,
25
+ runSetupCommand: overrides?.runSetupCommand ?? runSetupCommand,
26
+ runUpdateCommand: overrides?.runUpdateCommand ?? runUpdateCommand,
27
+ };
28
+ }
29
+ export function resolveStepsByIds(stepIds, allSteps) {
30
+ const stepsById = new Map(allSteps.map((step) => [step.id, step]));
31
+ const resolvedSteps = [];
32
+ for (const stepId of stepIds) {
33
+ const step = stepsById.get(stepId);
34
+ if (!step) {
35
+ return { status: "unknown-step", stepId };
36
+ }
37
+ resolvedSteps.push(step);
38
+ }
39
+ return { status: "resolved", steps: resolvedSteps };
40
+ }
41
+ export function reportCliError(error, processLike = process) {
42
+ if (error instanceof ConfigReadError) {
43
+ processLike.stderr.write(`${error.message}\n`);
44
+ processLike.exitCode = 1;
45
+ return;
46
+ }
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ processLike.stderr.write(`${message}\n`);
49
+ processLike.exitCode = 1;
50
+ }
51
+ export function createBraeburnProgram(options = {}) {
52
+ const allSteps = options.allSteps ?? ALL_STEPS;
53
+ const dependencies = resolveDependencies(options.dependencies);
54
+ const processLike = options.processLike ?? process;
55
+ const braeburnVersion = options.version ?? resolveBraeburnVersion();
56
+ const program = new Command();
57
+ program
58
+ .name("braeburn")
59
+ .description("macOS system updater")
60
+ .version(braeburnVersion)
61
+ .helpCommand(false);
62
+ program
63
+ .command("update", { isDefault: true })
64
+ .description("Run system update steps (default command)")
65
+ .argument("[steps...]", `Steps to run — omit to run all.\nAvailable: ${allSteps.map((step) => step.id).join(", ")}`)
66
+ .option("-y, --yes", "Auto-accept all prompts (default yes to everything)")
67
+ .option("-f, --force", "Alias for --yes")
68
+ .option("--no-logo", "Hide the logo")
69
+ .addHelpText("after", `
70
+ Step descriptions:
71
+ System / Runtimes (default: off — larger changes, enabled intentionally):
72
+ pyenv Upgrade pyenv, install latest Python 3.x (requires: pyenv or brew)
73
+ nvm Install latest Node.js via nvm (requires: ~/.nvm)
74
+
75
+ System / Apps & Packages:
76
+ homebrew Update Homebrew itself and all installed formulae
77
+ mas Upgrade Mac App Store apps (requires: mas)
78
+ macos Check for macOS updates, prompt to install
79
+
80
+ System / CLI Tools:
81
+ npm Update global npm packages (requires: npm)
82
+ braeburn Update braeburn CLI itself (requires: npm)
83
+ pip Update global pip3 packages (requires: pip3) ⚠ may be fragile
84
+ dotnet Update .NET global tools (requires: dotnet)
85
+
86
+ System / Shell:
87
+ ohmyzsh Update Oh My Zsh (requires: ~/.oh-my-zsh)
88
+
89
+ System / Maintenance:
90
+ cleanup homebrew cleanup (remove outdated Homebrew cache/downloads)
91
+
92
+ Examples:
93
+ braeburn Run all enabled steps interactively
94
+ braeburn -y Run all enabled steps, auto-accept everything
95
+ braeburn -fy Same as above
96
+ braeburn homebrew npm Run only the homebrew and npm steps
97
+ braeburn homebrew -y Run only homebrew, auto-accept
98
+ braeburn nvm pyenv Run only the runtime steps
99
+ `)
100
+ .action(async (stepArguments, updateOptions) => {
101
+ const autoYes = updateOptions.yes === true || updateOptions.force === true;
102
+ if (!(await dependencies.configFileExists())) {
103
+ await dependencies.runSetupCommand(allSteps);
104
+ }
105
+ const config = await dependencies.readConfig();
106
+ let stepsToRun = allSteps;
107
+ if (stepArguments.length > 0) {
108
+ const stepResolution = resolveStepsByIds(stepArguments, allSteps);
109
+ if (stepResolution.status === "unknown-step") {
110
+ processLike.stderr.write(`Unknown step: "${stepResolution.stepId}". Run braeburn --help to see available steps.\n`);
111
+ processLike.exitCode = 1;
112
+ return;
113
+ }
114
+ stepsToRun = stepResolution.steps;
115
+ }
116
+ if (stepArguments.length === 0) {
117
+ stepsToRun = stepsToRun.filter((step) => isStepEnabled(config, step.id));
118
+ }
119
+ const logoIsEnabled = updateOptions.logo !== false && isLogoEnabled(config);
120
+ const updateCommandResult = await dependencies.runUpdateCommand({
121
+ steps: stepsToRun,
122
+ promptMode: autoYes ? "auto-accept" : "interactive",
123
+ logoVisibility: logoIsEnabled ? "visible" : "hidden",
124
+ version: braeburnVersion,
125
+ });
126
+ dependencies.applyUpdateCommandResult(updateCommandResult, processLike);
127
+ });
128
+ program
129
+ .command("log")
130
+ .description("View the most recent output log for a given step")
131
+ .argument("[step]", "Step ID to view logs for (e.g. homebrew, npm, pip)")
132
+ .option("--homebrew", "Show latest Homebrew log")
133
+ .option("--brew", "Alias for --homebrew")
134
+ .option("--mas", "Show latest Mac App Store log")
135
+ .option("--ohmyzsh", "Show latest Oh My Zsh log")
136
+ .option("--npm", "Show latest npm log")
137
+ .option("--braeburn", "Show latest braeburn log")
138
+ .option("--pip", "Show latest pip3 log")
139
+ .option("--pyenv", "Show latest pyenv log")
140
+ .option("--nvm", "Show latest nvm log")
141
+ .option("--dotnet", "Show latest .NET log")
142
+ .option("--macos", "Show latest macOS update log")
143
+ .option("--cleanup", "Show latest homebrew cleanup log")
144
+ .addHelpText("after", `
145
+ Examples:
146
+ braeburn log List all available step logs
147
+ braeburn log homebrew Show the latest Homebrew run log
148
+ braeburn log --brew Same as above
149
+ braeburn log npm | less Pipe log output through less
150
+ `)
151
+ .action(async (stepArgument, logOptions) => {
152
+ const stepIdFromFlag = logOptions.brew === true
153
+ ? "homebrew"
154
+ : allSteps.map((step) => step.id).find((stepId) => logOptions[stepId] === true);
155
+ const resolvedStepId = stepArgument ?? stepIdFromFlag;
156
+ if (!resolvedStepId) {
157
+ dependencies.runLogListCommand();
158
+ return;
159
+ }
160
+ await dependencies.runLogCommand({ stepId: resolvedStepId });
161
+ });
162
+ const configCommand = program
163
+ .command("config")
164
+ .description("View or edit braeburn configuration")
165
+ .action(() => {
166
+ configCommand.outputHelp();
167
+ });
168
+ configCommand
169
+ .command("list")
170
+ .description("Print current configuration")
171
+ .action(async () => {
172
+ await dependencies.runConfigCommand({
173
+ allSteps,
174
+ outputMode: "non-interactive",
175
+ });
176
+ });
177
+ const configurableSteps = allSteps.filter((step) => !PROTECTED_STEP_IDS.has(step.id));
178
+ const configUpdateCommand = configCommand
179
+ .command("update")
180
+ .description("Edit configuration (interactive by default, flags for direct updates)")
181
+ .addHelpText("after", `
182
+ Examples:
183
+ braeburn config update Open interactive config editor
184
+ braeburn config update --no-logo Hide the logo
185
+ braeburn config update --no-ohmyzsh Disable Oh My Zsh updates
186
+ braeburn config update --no-pip --no-nvm Disable pip and nvm updates
187
+ braeburn config update --ohmyzsh Re-enable Oh My Zsh updates
188
+ `);
189
+ configUpdateCommand.option("--no-logo", "Hide the logo");
190
+ configUpdateCommand.option("--logo", "Show the logo");
191
+ for (const step of configurableSteps) {
192
+ configUpdateCommand.option(`--no-${step.id}`, `Disable ${step.name} updates`);
193
+ configUpdateCommand.option(`--${step.id}`, `Enable ${step.name} updates`);
194
+ }
195
+ configUpdateCommand.action(async function () {
196
+ // Commander defaults --no-* to true, so we use getOptionValueSource to detect explicit CLI flags.
197
+ const settingUpdates = {};
198
+ for (const step of configurableSteps) {
199
+ const source = configUpdateCommand.getOptionValueSource(step.id);
200
+ if (source === "cli") {
201
+ settingUpdates[step.id] = configUpdateCommand.opts()[step.id] ? "enable" : "disable";
202
+ }
203
+ }
204
+ const logoSource = configUpdateCommand.getOptionValueSource("logo");
205
+ if (logoSource === "cli") {
206
+ settingUpdates.logo = configUpdateCommand.opts().logo ? "enable" : "disable";
207
+ }
208
+ if (Object.keys(settingUpdates).length === 0) {
209
+ await dependencies.runConfigCommand({
210
+ allSteps,
211
+ outputMode: "interactive",
212
+ });
213
+ return;
214
+ }
215
+ await dependencies.runConfigUpdateCommand({ settingUpdates, allSteps });
216
+ });
217
+ return program;
218
+ }
219
+ export async function runBraeburnCli(argv = process.argv) {
220
+ try {
221
+ await createBraeburnProgram().parseAsync(argv);
222
+ }
223
+ catch (error) {
224
+ reportCliError(error);
225
+ }
226
+ }
@@ -1,3 +1,4 @@
1
+ import { type BraeburnConfig } from "../config.js";
1
2
  import { type Step, type StepCategoryId } from "../steps/index.js";
2
3
  export type SelectionState = "selected" | "deselected";
3
4
  export type ProtectionStatus = "protected" | "configurable";
@@ -17,4 +18,6 @@ export type SelectableStep = {
17
18
  };
18
19
  export declare function buildLoadingScreen(): string;
19
20
  export declare function buildSetupScreen(items: SelectableStep[], cursorIndex: number): string;
21
+ export declare function buildSetupItems(allSteps: Step[], availabilityResults: boolean[]): SelectableStep[];
22
+ export declare function buildConfigFromSetupItems(items: SelectableStep[]): BraeburnConfig;
20
23
  export declare function runSetupCommand(allSteps: Step[]): Promise<void>;
@@ -64,26 +64,42 @@ export function buildSetupScreen(items, cursorIndex) {
64
64
  lines.push("");
65
65
  return lines.join("\n") + "\n";
66
66
  }
67
+ export function buildSetupItems(allSteps, availabilityResults) {
68
+ return allSteps.map((step, stepIndex) => ({
69
+ step: {
70
+ id: step.id,
71
+ name: step.name,
72
+ description: step.description,
73
+ categoryId: step.categoryId,
74
+ brewPackageToInstall: step.brewPackageToInstall,
75
+ },
76
+ selection: PROTECTED_STEP_IDS.has(step.id) || CONSERVATIVE_DEFAULT_ON_STEP_IDS.has(step.id)
77
+ ? "selected"
78
+ : "deselected",
79
+ protection: PROTECTED_STEP_IDS.has(step.id) ? "protected" : "configurable",
80
+ availability: availabilityResults[stepIndex] ? "available" : "unavailable",
81
+ }));
82
+ }
83
+ export function buildConfigFromSetupItems(items) {
84
+ const draftConfig = {
85
+ defaultsProfile: "conservative-v2",
86
+ steps: {},
87
+ };
88
+ for (const item of items) {
89
+ if (item.protection === "protected") {
90
+ continue;
91
+ }
92
+ draftConfig.steps[item.step.id] = item.selection === "selected";
93
+ }
94
+ return cleanConfigForWrite(draftConfig);
95
+ }
67
96
  export async function runSetupCommand(allSteps) {
68
97
  const render = createScreenRenderer();
69
98
  const restoreCursor = hideCursorDuringExecution({ screenBuffer: "alternate" });
70
99
  try {
71
100
  render(buildLoadingScreen());
72
101
  const availabilityResults = await Promise.all(allSteps.map((step) => step.checkIsAvailable()));
73
- const items = allSteps.map((step, stepIndex) => ({
74
- step: {
75
- id: step.id,
76
- name: step.name,
77
- description: step.description,
78
- categoryId: step.categoryId,
79
- brewPackageToInstall: step.brewPackageToInstall,
80
- },
81
- selection: PROTECTED_STEP_IDS.has(step.id) || CONSERVATIVE_DEFAULT_ON_STEP_IDS.has(step.id)
82
- ? "selected"
83
- : "deselected",
84
- protection: PROTECTED_STEP_IDS.has(step.id) ? "protected" : "configurable",
85
- availability: availabilityResults[stepIndex] ? "available" : "unavailable",
86
- }));
102
+ const items = buildSetupItems(allSteps, availabilityResults);
87
103
  let cursorIndex = 0;
88
104
  render(buildSetupScreen(items, cursorIndex));
89
105
  await new Promise((resolve) => {
@@ -120,17 +136,7 @@ export async function runSetupCommand(allSteps) {
120
136
  process.stdin.on("keypress", handleKeypress);
121
137
  process.stdin.resume();
122
138
  });
123
- const draftConfig = {
124
- defaultsProfile: "conservative-v2",
125
- steps: {},
126
- };
127
- for (const item of items) {
128
- if (item.protection === "protected") {
129
- continue;
130
- }
131
- draftConfig.steps[item.step.id] = item.selection === "selected";
132
- }
133
- await writeConfig(cleanConfigForWrite(draftConfig));
139
+ await writeConfig(buildConfigFromSetupItems(items));
134
140
  const confirmationLines = [
135
141
  chalk.yellow(LOGO_ART),
136
142
  "",
@@ -1,5 +1,5 @@
1
1
  import { type PromptMode } from "../update/engine.js";
2
- import type { LogoVisibility } from "../update/state.js";
2
+ import { type LogoVisibility, type UpdateState } from "../update/state.js";
3
3
  import type { Step } from "../steps/index.js";
4
4
  type RunUpdateCommandOptions = {
5
5
  steps: Step[];
@@ -7,5 +7,13 @@ type RunUpdateCommandOptions = {
7
7
  logoVisibility: LogoVisibility;
8
8
  version: string;
9
9
  };
10
- export declare function runUpdateCommand(options: RunUpdateCommandOptions): Promise<void>;
10
+ export type UpdateCommandResult = {
11
+ failedStepCount: number;
12
+ };
13
+ type ExitCodeWritable = {
14
+ exitCode: string | number | null | undefined;
15
+ };
16
+ export declare function shouldRenderRuntimeStateImmediately(state: UpdateState, lastRuntimeRenderTime: number, currentTime: number): boolean;
17
+ export declare function applyUpdateCommandResult(updateCommandResult: UpdateCommandResult, processWithExitCode: ExitCodeWritable): void;
18
+ export declare function runUpdateCommand(options: RunUpdateCommandOptions): Promise<UpdateCommandResult>;
11
19
  export {};
@@ -4,10 +4,26 @@ import { captureYesNo } from "../ui/prompt.js";
4
4
  import { buildScreen, buildScreenWithAnimationFrame, createScreenRenderer } from "../ui/screen.js";
5
5
  import { hideCursorDuringExecution } from "../ui/terminal.js";
6
6
  import { runUpdateEngine } from "../update/engine.js";
7
+ import { countFailedSteps } from "../update/state.js";
7
8
  import { cancelActiveShellCommand } from "../runner.js";
9
+ const RUNTIME_RENDER_INTERVAL_MS = 100;
8
10
  function shouldCaptureRuntimeAbortKey(state) {
9
11
  return state?.currentPhase === "running" || state?.currentPhase === "installing";
10
12
  }
13
+ function shouldThrottleRuntimeRender(state) {
14
+ return state.runCompletion !== "finished" && shouldCaptureRuntimeAbortKey(state);
15
+ }
16
+ export function shouldRenderRuntimeStateImmediately(state, lastRuntimeRenderTime, currentTime) {
17
+ if (!shouldThrottleRuntimeRender(state)) {
18
+ return true;
19
+ }
20
+ return currentTime - lastRuntimeRenderTime >= RUNTIME_RENDER_INTERVAL_MS;
21
+ }
22
+ export function applyUpdateCommandResult(updateCommandResult, processWithExitCode) {
23
+ if (updateCommandResult.failedStepCount > 0) {
24
+ processWithExitCode.exitCode = 1;
25
+ }
26
+ }
11
27
  export async function runUpdateCommand(options) {
12
28
  const renderScreen = createScreenRenderer();
13
29
  const restoreCursor = hideCursorDuringExecution({ screenBuffer: "alternate" });
@@ -15,6 +31,26 @@ export async function runUpdateCommand(options) {
15
31
  let latestState = undefined;
16
32
  let runtimeAbortKeyCaptureEnabled = false;
17
33
  let animationFrameIndex = 0;
34
+ let updateCommandResult = { failedStepCount: 0 };
35
+ let lastRuntimeRenderTime = 0;
36
+ const renderState = (state) => {
37
+ renderScreen(buildScreenWithAnimationFrame(state, animationFrameIndex));
38
+ if (shouldThrottleRuntimeRender(state)) {
39
+ lastRuntimeRenderTime = Date.now();
40
+ }
41
+ };
42
+ const renderStateWithRuntimeThrottle = (state) => {
43
+ if (!shouldThrottleRuntimeRender(state)) {
44
+ renderState(state);
45
+ return;
46
+ }
47
+ const currentTime = Date.now();
48
+ if (shouldRenderRuntimeStateImmediately(state, lastRuntimeRenderTime, currentTime)) {
49
+ renderState(state);
50
+ return;
51
+ }
52
+ // The animation timer renders the latest runtime state at the next frame.
53
+ };
18
54
  const handleRuntimeKeypress = (typedCharacter, key) => {
19
55
  if (key?.ctrl && key?.name === "c") {
20
56
  process.stdout.write("\x1b[?25h\n");
@@ -60,8 +96,8 @@ export async function runUpdateCommand(options) {
60
96
  return;
61
97
  }
62
98
  animationFrameIndex += 1;
63
- renderScreen(buildScreenWithAnimationFrame(latestState, animationFrameIndex));
64
- }, 100);
99
+ renderState(latestState);
100
+ }, RUNTIME_RENDER_INTERVAL_MS);
65
101
  try {
66
102
  const finalState = await runUpdateEngine({
67
103
  steps: options.steps,
@@ -78,10 +114,13 @@ export async function runUpdateCommand(options) {
78
114
  else {
79
115
  disableRuntimeAbortKeyCapture();
80
116
  }
81
- renderScreen(buildScreenWithAnimationFrame(state, animationFrameIndex));
117
+ renderStateWithRuntimeThrottle(state);
82
118
  },
83
119
  });
84
120
  finalScreen = buildScreen(finalState);
121
+ updateCommandResult = {
122
+ failedStepCount: countFailedSteps(finalState.completedStepRecords),
123
+ };
85
124
  }
86
125
  finally {
87
126
  disableRuntimeAbortKeyCapture();
@@ -91,4 +130,5 @@ export async function runUpdateCommand(options) {
91
130
  if (finalScreen) {
92
131
  process.stdout.write(finalScreen);
93
132
  }
133
+ return updateCommandResult;
94
134
  }
package/dist/config.d.ts CHANGED
@@ -7,10 +7,18 @@ export type BraeburnConfig = {
7
7
  logo?: boolean;
8
8
  defaultsProfile?: ConfigDefaultsProfile;
9
9
  };
10
+ export declare class ConfigReadError extends Error {
11
+ readonly configPath: string;
12
+ readonly originalError: unknown;
13
+ constructor(configPath: string, originalError: unknown);
14
+ }
15
+ export declare function parseConfig(rawConfig: string): BraeburnConfig;
10
16
  export declare function resolveConfigPath(): Promise<string>;
11
17
  export declare function configFileExists(): Promise<boolean>;
12
18
  export declare function readConfig(): Promise<BraeburnConfig>;
19
+ export declare function readConfigFromPath(configPath: string): Promise<BraeburnConfig>;
13
20
  export declare function writeConfig(config: BraeburnConfig): Promise<void>;
21
+ export declare function writeConfigToPath(configPath: string, config: BraeburnConfig): Promise<void>;
14
22
  export declare function isSettingEnabled(config: BraeburnConfig, settingId: string): boolean;
15
23
  export declare function isStepEnabled(config: BraeburnConfig, stepId: string): boolean;
16
24
  export declare function isLogoEnabled(config: BraeburnConfig): boolean;
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile, writeFile, mkdir, access } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { parse, stringify } from "smol-toml";
5
5
  export const PROTECTED_STEP_IDS = new Set(["homebrew"]);
@@ -9,14 +9,67 @@ const EMPTY_CONFIG = { steps: {} };
9
9
  const LOGO_SETTING_ID = "logo";
10
10
  const DEFAULTS_PROFILE_SETTING_ID = "defaultsProfile";
11
11
  const LEGACY_PROFILE = "legacy";
12
+ export class ConfigReadError extends Error {
13
+ configPath;
14
+ originalError;
15
+ constructor(configPath, originalError) {
16
+ const reason = originalError instanceof Error ? originalError.message : String(originalError);
17
+ super(`Could not read braeburn config at ${configPath}: ${reason}`);
18
+ this.name = "ConfigReadError";
19
+ this.configPath = configPath;
20
+ this.originalError = originalError;
21
+ }
22
+ }
12
23
  function resolveDefaultsProfile(config) {
13
24
  return config.defaultsProfile ?? LEGACY_PROFILE;
14
25
  }
15
26
  function parseDefaultsProfile(rawValue) {
27
+ if (rawValue === undefined) {
28
+ return undefined;
29
+ }
16
30
  if (rawValue === "legacy" || rawValue === "conservative-v2") {
17
31
  return rawValue;
18
32
  }
19
- return undefined;
33
+ throw new Error(`"${DEFAULTS_PROFILE_SETTING_ID}" must be "legacy" or "conservative-v2".`);
34
+ }
35
+ function parseSteps(rawValue) {
36
+ if (rawValue === undefined) {
37
+ return {};
38
+ }
39
+ if (typeof rawValue !== "object" || rawValue === null || Array.isArray(rawValue)) {
40
+ throw new Error('"steps" must be a table of step IDs to boolean values.');
41
+ }
42
+ const steps = {};
43
+ for (const [stepId, enabled] of Object.entries(rawValue)) {
44
+ if (typeof enabled !== "boolean") {
45
+ throw new Error(`"steps.${stepId}" must be true or false.`);
46
+ }
47
+ steps[stepId] = enabled;
48
+ }
49
+ return steps;
50
+ }
51
+ function parseLogo(rawValue) {
52
+ if (rawValue === undefined || typeof rawValue === "boolean") {
53
+ return rawValue;
54
+ }
55
+ throw new Error('"logo" must be true or false.');
56
+ }
57
+ function isMissingFileError(error) {
58
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
59
+ }
60
+ export function parseConfig(rawConfig) {
61
+ const parsed = parse(rawConfig);
62
+ const steps = parseSteps(parsed.steps);
63
+ const logo = parseLogo(parsed.logo);
64
+ const defaultsProfile = parseDefaultsProfile(parsed[DEFAULTS_PROFILE_SETTING_ID]);
65
+ const config = { steps };
66
+ if (logo !== undefined) {
67
+ config.logo = logo;
68
+ }
69
+ if (defaultsProfile !== undefined) {
70
+ config.defaultsProfile = defaultsProfile;
71
+ }
72
+ return config;
20
73
  }
21
74
  function isStepEnabledByDefault(profile, stepId) {
22
75
  if (profile === "conservative-v2") {
@@ -49,20 +102,26 @@ export async function configFileExists() {
49
102
  }
50
103
  export async function readConfig() {
51
104
  const configPath = await resolveConfigPath();
105
+ return readConfigFromPath(configPath);
106
+ }
107
+ export async function readConfigFromPath(configPath) {
52
108
  try {
53
109
  const raw = await readFile(configPath, "utf-8");
54
- const parsed = parse(raw);
55
- const parsedSteps = parsed.steps;
56
- const defaultsProfile = parseDefaultsProfile(parsed[DEFAULTS_PROFILE_SETTING_ID]);
57
- return { steps: parsedSteps ?? {}, logo: parsed.logo, defaultsProfile };
110
+ return parseConfig(raw);
58
111
  }
59
- catch {
60
- return structuredClone(EMPTY_CONFIG);
112
+ catch (error) {
113
+ if (isMissingFileError(error)) {
114
+ return structuredClone(EMPTY_CONFIG);
115
+ }
116
+ throw new ConfigReadError(configPath, error);
61
117
  }
62
118
  }
63
119
  export async function writeConfig(config) {
64
120
  const configPath = await resolveConfigPath();
65
- await mkdir(join(configPath, ".."), { recursive: true });
121
+ await writeConfigToPath(configPath, config);
122
+ }
123
+ export async function writeConfigToPath(configPath, config) {
124
+ await mkdir(dirname(configPath), { recursive: true });
66
125
  await writeFile(configPath, stringify(config), "utf-8");
67
126
  }
68
127
  export function isSettingEnabled(config, settingId) {
package/dist/index.js CHANGED
@@ -1,178 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import { createRequire } from "node:module";
4
- import { fileURLToPath } from "node:url";
5
- import { dirname, join } from "node:path";
6
- import { ALL_STEPS } from "./steps/catalog.js";
7
- import { runUpdateCommand } from "./commands/update.js";
8
- import { runLogCommand, runLogListCommand } from "./commands/log.js";
9
- import { runConfigCommand, runConfigUpdateCommand } from "./commands/config.js";
10
- import { runSetupCommand } from "./commands/setup.js";
11
- import { readConfig, isStepEnabled, isLogoEnabled, PROTECTED_STEP_IDS, configFileExists } from "./config.js";
12
- const STEP_IDS_BY_NAME = new Map(ALL_STEPS.map((step) => [step.id, step]));
13
- const requireFromThis = createRequire(import.meta.url);
14
- const packageJson = requireFromThis(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"));
15
- const BRAEBURN_VERSION = packageJson.version;
16
- const program = new Command();
17
- program
18
- .name("braeburn")
19
- .description("macOS system updater")
20
- .version(BRAEBURN_VERSION)
21
- .helpCommand(false);
22
- program
23
- .command("update", { isDefault: true })
24
- .description("Run system update steps (default command)")
25
- .argument("[steps...]", `Steps to run — omit to run all.\nAvailable: ${ALL_STEPS.map((step) => step.id).join(", ")}`)
26
- .option("-y, --yes", "Auto-accept all prompts (default yes to everything)")
27
- .option("-f, --force", "Alias for --yes")
28
- .option("--no-logo", "Hide the logo")
29
- .addHelpText("after", `
30
- Step descriptions:
31
- System / Runtimes (default: off — larger changes, enabled intentionally):
32
- pyenv Upgrade pyenv, install latest Python 3.x (requires: pyenv or brew)
33
- nvm Install latest Node.js via nvm (requires: ~/.nvm)
34
-
35
- System / Apps & Packages:
36
- homebrew Update Homebrew itself and all installed formulae
37
- mas Upgrade Mac App Store apps (requires: mas)
38
- macos Check for macOS updates, prompt to install
39
-
40
- System / CLI Tools:
41
- npm Update global npm packages (requires: npm)
42
- braeburn Update braeburn CLI itself (requires: npm)
43
- pip Update global pip3 packages (requires: pip3) ⚠ may be fragile
44
- dotnet Update .NET global tools (requires: dotnet)
45
-
46
- System / Shell:
47
- ohmyzsh Update Oh My Zsh (requires: ~/.oh-my-zsh)
48
-
49
- System / Maintenance:
50
- cleanup homebrew cleanup (remove outdated Homebrew cache/downloads)
51
-
52
- Examples:
53
- braeburn Run all enabled steps interactively
54
- braeburn -y Run all enabled steps, auto-accept everything
55
- braeburn -fy Same as above
56
- braeburn homebrew npm Run only the homebrew and npm steps
57
- braeburn homebrew -y Run only homebrew, auto-accept
58
- braeburn nvm pyenv Run only the runtime steps
59
- `)
60
- .action(async (stepArguments, options) => {
61
- const autoYes = options.yes === true || options.force === true;
62
- if (!(await configFileExists())) {
63
- await runSetupCommand(ALL_STEPS);
64
- }
65
- const config = await readConfig();
66
- let stepsToRun = stepArguments.length === 0
67
- ? ALL_STEPS
68
- : resolveStepsByIds(stepArguments);
69
- if (stepArguments.length === 0) {
70
- stepsToRun = stepsToRun.filter((step) => isStepEnabled(config, step.id));
71
- }
72
- const logoIsEnabled = options.logo !== false && isLogoEnabled(config);
73
- await runUpdateCommand({
74
- steps: stepsToRun,
75
- promptMode: autoYes ? "auto-accept" : "interactive",
76
- logoVisibility: logoIsEnabled ? "visible" : "hidden",
77
- version: BRAEBURN_VERSION,
78
- });
79
- });
80
- program
81
- .command("log")
82
- .description("View the most recent output log for a given step")
83
- .argument("[step]", "Step ID to view logs for (e.g. homebrew, npm, pip)")
84
- .option("--homebrew", "Show latest Homebrew log")
85
- .option("--mas", "Show latest Mac App Store log")
86
- .option("--ohmyzsh", "Show latest Oh My Zsh log")
87
- .option("--npm", "Show latest npm log")
88
- .option("--braeburn", "Show latest braeburn log")
89
- .option("--pip", "Show latest pip3 log")
90
- .option("--pyenv", "Show latest pyenv log")
91
- .option("--nvm", "Show latest nvm log")
92
- .option("--dotnet", "Show latest .NET log")
93
- .option("--macos", "Show latest macOS update log")
94
- .option("--cleanup", "Show latest homebrew cleanup log")
95
- .addHelpText("after", `
96
- Examples:
97
- braeburn log List all available step logs
98
- braeburn log homebrew Show the latest Homebrew run log
99
- braeburn log --brew Same as above
100
- braeburn log npm | less Pipe log output through less
101
- `)
102
- .action((stepArgument, options) => {
103
- const stepIdFromFlag = ALL_STEPS.map((step) => step.id).find((stepId) => options[stepId] === true);
104
- const resolvedStepId = stepArgument ?? stepIdFromFlag;
105
- if (!resolvedStepId) {
106
- runLogListCommand();
107
- return;
108
- }
109
- runLogCommand({ stepId: resolvedStepId });
110
- });
111
- const configCommand = program
112
- .command("config")
113
- .description("View or edit braeburn configuration")
114
- .action(() => {
115
- configCommand.outputHelp();
116
- });
117
- configCommand
118
- .command("list")
119
- .description("Print current configuration")
120
- .action(async () => {
121
- await runConfigCommand({
122
- allSteps: ALL_STEPS,
123
- outputMode: "non-interactive",
124
- });
125
- });
126
- const configurableSteps = ALL_STEPS.filter((step) => !PROTECTED_STEP_IDS.has(step.id));
127
- const configUpdateCommand = configCommand
128
- .command("update")
129
- .description("Edit configuration (interactive by default, flags for direct updates)")
130
- .addHelpText("after", `
131
- Examples:
132
- braeburn config update Open interactive config editor
133
- braeburn config update --no-logo Hide the logo
134
- braeburn config update --no-ohmyzsh Disable Oh My Zsh updates
135
- braeburn config update --no-pip --no-nvm Disable pip and nvm updates
136
- braeburn config update --ohmyzsh Re-enable Oh My Zsh updates
137
- `);
138
- configUpdateCommand.option(`--no-logo`, `Hide the logo`);
139
- configUpdateCommand.option(`--logo`, `Show the logo`);
140
- for (const step of configurableSteps) {
141
- configUpdateCommand.option(`--no-${step.id}`, `Disable ${step.name} updates`);
142
- configUpdateCommand.option(`--${step.id}`, `Enable ${step.name} updates`);
143
- }
144
- configUpdateCommand.action(async function () {
145
- // Commander defaults --no-* to true, so we use getOptionValueSource to detect explicit CLI flags.
146
- const settingUpdates = {};
147
- for (const step of configurableSteps) {
148
- const source = configUpdateCommand.getOptionValueSource(step.id);
149
- if (source === "cli") {
150
- settingUpdates[step.id] = configUpdateCommand.opts()[step.id] ? "enable" : "disable";
151
- }
152
- }
153
- const logoSource = configUpdateCommand.getOptionValueSource("logo");
154
- if (logoSource === "cli") {
155
- settingUpdates["logo"] = configUpdateCommand.opts().logo ? "enable" : "disable";
156
- }
157
- if (Object.keys(settingUpdates).length === 0) {
158
- await runConfigCommand({
159
- allSteps: ALL_STEPS,
160
- outputMode: "interactive",
161
- });
162
- return;
163
- }
164
- await runConfigUpdateCommand({ settingUpdates, allSteps: ALL_STEPS });
165
- });
166
- function resolveStepsByIds(stepIds) {
167
- const resolvedSteps = [];
168
- for (const stepId of stepIds) {
169
- const step = STEP_IDS_BY_NAME.get(stepId);
170
- if (!step) {
171
- process.stderr.write(`Unknown step: "${stepId}". Run braeburn --help to see available steps.\n`);
172
- process.exit(1);
173
- }
174
- resolvedSteps.push(step);
175
- }
176
- return resolvedSteps;
177
- }
178
- program.parse();
2
+ import { runBraeburnCli } from "./cli.js";
3
+ await runBraeburnCli();
package/dist/runner.d.ts CHANGED
@@ -10,11 +10,17 @@ type RunCommandOptions = {
10
10
  onOutputLine: OutputLineCallback;
11
11
  logWriter: StepLogWriter;
12
12
  };
13
+ export type ShellCommandRunner = {
14
+ cancelActiveShellCommand: () => boolean;
15
+ runShellCommand: (options: RunCommandOptions) => Promise<void>;
16
+ runShellCommandAndCaptureOutput: (options: RunCommandOptions) => Promise<string>;
17
+ };
13
18
  export declare class ShellCommandCanceledError extends Error {
14
19
  readonly shellCommand: string;
15
20
  readonly originalError: unknown;
16
21
  constructor(shellCommand: string, originalError: unknown);
17
22
  }
23
+ export declare function createShellCommandRunner(): ShellCommandRunner;
18
24
  export declare function cancelActiveShellCommand(): boolean;
19
25
  export declare function runShellCommand(options: RunCommandOptions): Promise<void>;
20
26
  export declare function runShellCommandAndCaptureOutput(options: RunCommandOptions): Promise<string>;
package/dist/runner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execa } from "execa";
2
2
  const FAILURE_OUTPUT_TAIL_LINE_LIMIT = 20;
3
- let activeShellCommandSubprocess;
4
- const userCanceledSubprocesses = new WeakSet();
3
+ const DEFAULT_CAPTURE_MODE = "disabled";
4
+ const MAX_BUFFERED_OUTPUT_LINE_LENGTH = 16_384;
5
5
  export class ShellCommandCanceledError extends Error {
6
6
  shellCommand;
7
7
  originalError;
@@ -12,21 +12,30 @@ export class ShellCommandCanceledError extends Error {
12
12
  this.originalError = originalError;
13
13
  }
14
14
  }
15
- export function cancelActiveShellCommand() {
16
- if (!activeShellCommandSubprocess) {
15
+ function createLimitedLineBuffer(lineLimit) {
16
+ const lines = [];
17
+ return {
18
+ add(line) {
19
+ lines.push(line);
20
+ if (lines.length > lineLimit) {
21
+ lines.shift();
22
+ }
23
+ },
24
+ lines() {
25
+ return [...lines];
26
+ },
27
+ };
28
+ }
29
+ function cancelActiveShellCommandForState(state) {
30
+ if (!state.activeShellCommandSubprocess) {
17
31
  return false;
18
32
  }
19
- userCanceledSubprocesses.add(activeShellCommandSubprocess);
20
- activeShellCommandSubprocess.kill("SIGTERM");
33
+ state.userCanceledSubprocesses.add(state.activeShellCommandSubprocess);
34
+ state.activeShellCommandSubprocess.kill("SIGTERM");
21
35
  return true;
22
36
  }
23
- function splitNonEmptyLines(text) {
24
- if (!text) {
25
- return [];
26
- }
27
- return text.split(/\r?\n|\r/).filter(Boolean);
28
- }
29
- function buildFailureSummaryLines(shellCommand, error) {
37
+ function buildFailureSummaryLines(details) {
38
+ const { shellCommand, error, stdoutTailLines, stderrTailLines } = details;
30
39
  const defaultMessage = error instanceof Error ? error.message : String(error);
31
40
  if (typeof error !== "object" || error === null) {
32
41
  return [
@@ -48,12 +57,10 @@ function buildFailureSummaryLines(shellCommand, error) {
48
57
  else {
49
58
  summaryLines.push(`[braeburn] Error: ${defaultMessage}`);
50
59
  }
51
- const stderrTailLines = splitNonEmptyLines(errorDetails.stderr).slice(-FAILURE_OUTPUT_TAIL_LINE_LIMIT);
52
60
  if (stderrTailLines.length > 0) {
53
61
  summaryLines.push(`[braeburn] stderr tail (${stderrTailLines.length}):`);
54
62
  summaryLines.push(...stderrTailLines.map((line) => ` ${line}`));
55
63
  }
56
- const stdoutTailLines = splitNonEmptyLines(errorDetails.stdout).slice(-FAILURE_OUTPUT_TAIL_LINE_LIMIT);
57
64
  if (stdoutTailLines.length > 0) {
58
65
  summaryLines.push(`[braeburn] stdout tail (${stdoutTailLines.length}):`);
59
66
  summaryLines.push(...stdoutTailLines.map((line) => ` ${line}`));
@@ -73,6 +80,10 @@ function createBufferedLineEmitter(emitLine) {
73
80
  }
74
81
  emitLine(linePart);
75
82
  }
83
+ while (pendingText.length > MAX_BUFFERED_OUTPUT_LINE_LENGTH) {
84
+ emitLine(pendingText.slice(0, MAX_BUFFERED_OUTPUT_LINE_LENGTH));
85
+ pendingText = pendingText.slice(MAX_BUFFERED_OUTPUT_LINE_LENGTH);
86
+ }
76
87
  },
77
88
  flushPendingLine() {
78
89
  if (pendingText.length === 0) {
@@ -83,17 +94,46 @@ function createBufferedLineEmitter(emitLine) {
83
94
  },
84
95
  };
85
96
  }
86
- async function executeShellCommand(options) {
87
- const capturedOutputLines = [];
97
+ async function executeShellCommand(state, options, captureMode = DEFAULT_CAPTURE_MODE) {
98
+ const capturedOutputLines = captureMode === "enabled" ? [] : undefined;
99
+ const stdoutTailLines = createLimitedLineBuffer(FAILURE_OUTPUT_TAIL_LINE_LIMIT);
100
+ const stderrTailLines = createLimitedLineBuffer(FAILURE_OUTPUT_TAIL_LINE_LIMIT);
101
+ let logWriteQueue = Promise.resolve();
102
+ let logWriteFailure;
88
103
  const subprocess = execa("bash", ["-c", options.shellCommand], {
89
- all: true,
104
+ buffer: false,
90
105
  reject: true,
91
106
  });
92
- activeShellCommandSubprocess = subprocess;
107
+ state.activeShellCommandSubprocess = subprocess;
108
+ const queueLogWrite = (line) => {
109
+ logWriteQueue = logWriteQueue.then(async () => {
110
+ if (logWriteFailure) {
111
+ return;
112
+ }
113
+ try {
114
+ await options.logWriter(line);
115
+ }
116
+ catch (error) {
117
+ logWriteFailure = error;
118
+ }
119
+ });
120
+ };
121
+ const waitForQueuedLogWrites = async () => {
122
+ await logWriteQueue;
123
+ if (logWriteFailure) {
124
+ throw logWriteFailure;
125
+ }
126
+ };
93
127
  const emitOutputLine = (line) => {
94
- capturedOutputLines.push(line.text);
128
+ if (line.source === "stdout") {
129
+ stdoutTailLines.add(line.text);
130
+ }
131
+ else {
132
+ stderrTailLines.add(line.text);
133
+ }
134
+ capturedOutputLines?.push(line.text);
95
135
  options.onOutputLine(line);
96
- options.logWriter(line.text);
136
+ queueLogWrite(line.text);
97
137
  };
98
138
  const stdoutEmitter = createBufferedLineEmitter((line) => {
99
139
  emitOutputLine({ text: line, source: "stdout" });
@@ -111,36 +151,64 @@ async function executeShellCommand(options) {
111
151
  await subprocess;
112
152
  stdoutEmitter.flushPendingLine();
113
153
  stderrEmitter.flushPendingLine();
114
- return { capturedOutput: capturedOutputLines.join("\n") };
154
+ await waitForQueuedLogWrites();
155
+ return { capturedOutput: capturedOutputLines?.join("\n") };
115
156
  }
116
157
  catch (error) {
117
158
  stdoutEmitter.flushPendingLine();
118
159
  stderrEmitter.flushPendingLine();
119
- const commandWasCanceledByUser = userCanceledSubprocesses.has(subprocess);
120
- const failureSummaryLines = buildFailureSummaryLines(options.shellCommand, error);
160
+ const commandWasCanceledByUser = state.userCanceledSubprocesses.has(subprocess);
161
+ const failureSummaryLines = buildFailureSummaryLines({
162
+ shellCommand: options.shellCommand,
163
+ error,
164
+ stdoutTailLines: stdoutTailLines.lines(),
165
+ stderrTailLines: stderrTailLines.lines(),
166
+ });
121
167
  if (commandWasCanceledByUser) {
122
168
  failureSummaryLines.push("[braeburn] Command canceled by user input (q).");
123
169
  }
124
170
  for (const line of failureSummaryLines) {
125
- await options.logWriter(line);
171
+ queueLogWrite(line);
126
172
  }
173
+ await waitForQueuedLogWrites();
127
174
  if (commandWasCanceledByUser) {
128
175
  throw new ShellCommandCanceledError(options.shellCommand, error);
129
176
  }
130
177
  throw error;
131
178
  }
132
179
  finally {
133
- if (activeShellCommandSubprocess === subprocess) {
134
- activeShellCommandSubprocess = undefined;
180
+ if (state.activeShellCommandSubprocess === subprocess) {
181
+ state.activeShellCommandSubprocess = undefined;
135
182
  }
136
183
  }
137
184
  }
185
+ export function createShellCommandRunner() {
186
+ const state = {
187
+ activeShellCommandSubprocess: undefined,
188
+ userCanceledSubprocesses: new WeakSet(),
189
+ };
190
+ return {
191
+ cancelActiveShellCommand() {
192
+ return cancelActiveShellCommandForState(state);
193
+ },
194
+ async runShellCommand(options) {
195
+ await executeShellCommand(state, options);
196
+ },
197
+ async runShellCommandAndCaptureOutput(options) {
198
+ const result = await executeShellCommand(state, options, "enabled");
199
+ return result.capturedOutput ?? "";
200
+ },
201
+ };
202
+ }
203
+ const defaultShellCommandRunner = createShellCommandRunner();
204
+ export function cancelActiveShellCommand() {
205
+ return defaultShellCommandRunner.cancelActiveShellCommand();
206
+ }
138
207
  export async function runShellCommand(options) {
139
- await executeShellCommand(options);
208
+ await defaultShellCommandRunner.runShellCommand(options);
140
209
  }
141
210
  export async function runShellCommandAndCaptureOutput(options) {
142
- const result = await executeShellCommand(options);
143
- return result.capturedOutput;
211
+ return defaultShellCommandRunner.runShellCommandAndCaptureOutput(options);
144
212
  }
145
213
  export async function doesShellCommandSucceed(options) {
146
214
  const result = await execa("bash", ["-c", options.shellCommand], {
@@ -1,5 +1,5 @@
1
1
  import { type TerminalDimensions } from "./outputLines.js";
2
- import type { AppState } from "./state.js";
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;
5
5
  export declare function buildScreen(state: AppState, terminalDimensions?: TerminalDimensions): string;
package/dist/ui/screen.js CHANGED
@@ -3,6 +3,7 @@ import { buildActiveStepLines } from "./currentStep.js";
3
3
  import { buildStepOutputLines } from "./outputLines.js";
4
4
  import { buildPromptLines } from "./prompt.js";
5
5
  import { buildFailedStepLogHintLines, buildVersionReportLines } from "./versionReport.js";
6
+ import { countFailedSteps } from "./state.js";
6
7
  export function createScreenRenderer(output = process.stdout) {
7
8
  return (content) => {
8
9
  output.write("\x1b[H\x1b[2J");
@@ -12,18 +13,48 @@ export function createScreenRenderer(output = process.stdout) {
12
13
  export function buildScreen(state, terminalDimensions) {
13
14
  return buildScreenWithAnimationFrame(state, 0, terminalDimensions);
14
15
  }
15
- export function buildScreenWithAnimationFrame(state, activityFrameIndex, terminalDimensions) {
16
- const lines = [];
17
- const failedStepIds = state.completedStepRecords.flatMap((record, stepIndex) => {
18
- if (record.phase !== "failed") {
16
+ function buildFailedStepLogHints(state) {
17
+ return state.completedStepRecords.flatMap((completedStepRecord, stepIndex) => {
18
+ if (completedStepRecord.phase !== "failed") {
19
19
  return [];
20
20
  }
21
21
  const failedStep = state.steps[stepIndex];
22
22
  if (!failedStep) {
23
23
  return [];
24
24
  }
25
- return [failedStep.id];
25
+ return [{
26
+ stepId: failedStep.id,
27
+ logStepId: completedStepRecord.logStepId ?? failedStep.id,
28
+ }];
26
29
  });
30
+ }
31
+ function findLatestFailedStepDisplay(state) {
32
+ for (let stepIndex = state.completedStepRecords.length - 1; stepIndex >= 0; stepIndex -= 1) {
33
+ const completedStepRecord = state.completedStepRecords[stepIndex];
34
+ if (completedStepRecord?.phase !== "failed") {
35
+ continue;
36
+ }
37
+ const failedStep = state.steps[stepIndex];
38
+ if (!failedStep) {
39
+ continue;
40
+ }
41
+ const failureOutputLines = completedStepRecord.failureOutputLines;
42
+ if (!failureOutputLines || failureOutputLines.length === 0) {
43
+ continue;
44
+ }
45
+ return {
46
+ step: failedStep,
47
+ stepNumber: stepIndex + 1,
48
+ failureOutputLines,
49
+ };
50
+ }
51
+ return undefined;
52
+ }
53
+ export function buildScreenWithAnimationFrame(state, activityFrameIndex, terminalDimensions) {
54
+ const lines = [];
55
+ const failedStepCount = countFailedSteps(state.completedStepRecords);
56
+ const failedStepLogHints = buildFailedStepLogHints(state);
57
+ const latestFailedStepDisplay = findLatestFailedStepDisplay(state);
27
58
  lines.push(...buildHeaderLines({
28
59
  steps: state.steps,
29
60
  version: state.version,
@@ -38,11 +69,26 @@ export function buildScreenWithAnimationFrame(state, activityFrameIndex, termina
38
69
  if (state.runCompletion === "finished") {
39
70
  if (state.versionReport) {
40
71
  lines.push("");
41
- lines.push(...buildVersionReportLines(state.versionReport));
72
+ lines.push(...buildVersionReportLines({
73
+ versions: state.versionReport,
74
+ failedStepCount,
75
+ }));
76
+ }
77
+ if (failedStepLogHints.length > 0) {
78
+ lines.push("");
79
+ lines.push(...buildFailedStepLogHintLines(failedStepLogHints));
42
80
  }
43
- if (failedStepIds.length > 0) {
81
+ if (latestFailedStepDisplay) {
82
+ lines.push("");
83
+ lines.push(...buildActiveStepLines({
84
+ step: latestFailedStepDisplay.step,
85
+ stepNumber: latestFailedStepDisplay.stepNumber,
86
+ totalSteps: state.steps.length,
87
+ phase: "failed",
88
+ activityFrameIndex,
89
+ }));
44
90
  lines.push("");
45
- lines.push(...buildFailedStepLogHintLines(failedStepIds));
91
+ lines.push(...buildStepOutputLines(latestFailedStepDisplay.failureOutputLines, terminalDimensions));
46
92
  }
47
93
  }
48
94
  else {
@@ -56,7 +102,9 @@ export function buildScreenWithAnimationFrame(state, activityFrameIndex, termina
56
102
  phase: state.currentPhase,
57
103
  activityFrameIndex,
58
104
  }));
59
- const isShowingOutput = (state.currentPhase === "running" || state.currentPhase === "installing") &&
105
+ const isShowingOutput = (state.currentPhase === "running" ||
106
+ state.currentPhase === "installing" ||
107
+ state.currentPhase === "failed") &&
60
108
  state.currentOutputLines.length > 0;
61
109
  if (isShowingOutput) {
62
110
  lines.push("");
@@ -1 +1 @@
1
- export { type DisplayStep, type StepPhase, type CompletedStepRecord, type CurrentPrompt, type ResolvedVersion, type LogoVisibility, type RunCompletion, type UpdateState, type AppState, createInitialUpdateState, createInitialAppState, } from "../update/state.js";
1
+ export { type DisplayStep, type StepPhase, type CompletedStepRecord, type CurrentPrompt, type ResolvedVersion, type LogoVisibility, type RunCompletion, type UpdateState, type AppState, createInitialUpdateState, createInitialAppState, countFailedSteps, } from "../update/state.js";
package/dist/ui/state.js CHANGED
@@ -1 +1 @@
1
- export { createInitialUpdateState, createInitialAppState, } from "../update/state.js";
1
+ export { createInitialUpdateState, createInitialAppState, countFailedSteps, } from "../update/state.js";
@@ -1,3 +1,11 @@
1
1
  import type { ResolvedVersion } from "./state.js";
2
- export declare function buildVersionReportLines(versions: ResolvedVersion[]): string[];
3
- export declare function buildFailedStepLogHintLines(failedStepIds: string[]): string[];
2
+ export type VersionReportOptions = {
3
+ versions: ResolvedVersion[];
4
+ failedStepCount: number;
5
+ };
6
+ export type FailedStepLogHint = {
7
+ stepId: string;
8
+ logStepId: string;
9
+ };
10
+ export declare function buildVersionReportLines(options: VersionReportOptions): string[];
11
+ export declare function buildFailedStepLogHintLines(failedStepLogHints: FailedStepLogHint[]): string[];
@@ -1,12 +1,19 @@
1
1
  import chalk from "chalk";
2
- export function buildVersionReportLines(versions) {
2
+ function buildCompletionSummaryLine(failedStepCount) {
3
+ if (failedStepCount === 0) {
4
+ return ` ${chalk.green.bold("✓")} ${chalk.bold("All done!")}`;
5
+ }
6
+ const failureLabel = failedStepCount === 1 ? "1 step failed" : `${failedStepCount} steps failed`;
7
+ return ` ${chalk.red.bold("✗")} ${chalk.bold(`Done (${failureLabel})`)}`;
8
+ }
9
+ export function buildVersionReportLines(options) {
3
10
  return [
4
11
  chalk.dim(" ─── Versions ─────────────────────────"),
5
- ...versions.map(({ label, value }) => ` ${chalk.dim("·")} ${chalk.bold(label + ":")} ${chalk.dim(value)}`),
12
+ ...options.versions.map(({ label, value }) => ` ${chalk.dim("·")} ${chalk.bold(label + ":")} ${chalk.dim(value)}`),
6
13
  "",
7
- ` ${chalk.green.bold("✓")} ${chalk.bold("All done!")}`,
14
+ buildCompletionSummaryLine(options.failedStepCount),
8
15
  ];
9
16
  }
10
- export function buildFailedStepLogHintLines(failedStepIds) {
11
- return failedStepIds.map((stepId) => ` ${chalk.red.bold("✗")} ${chalk.bold(`Step ${stepId} failed.`)} ${chalk.dim(`Please run braeburn log --${stepId} to see what happened.`)}`);
17
+ export function buildFailedStepLogHintLines(failedStepLogHints) {
18
+ return failedStepLogHints.map(({ stepId, logStepId }) => ` ${chalk.red.bold("✗")} ${chalk.bold(`Step ${stepId} failed.`)} ${chalk.dim(`Please run braeburn log ${logStepId} to see what happened.`)}`);
12
19
  }
@@ -9,6 +9,7 @@ type UpdateEngineDependencies = {
9
9
  runCommand: typeof runShellCommand;
10
10
  createStepRunContext: typeof createDefaultStepRunContext;
11
11
  };
12
+ export declare const CURRENT_OUTPUT_LINE_LIMIT = 200;
12
13
  type RunUpdateEngineOptions = {
13
14
  steps: Step[];
14
15
  promptMode: PromptMode;
@@ -1,8 +1,9 @@
1
1
  import { createLogWriterForStep } from "../logger.js";
2
- import { runShellCommand, ShellCommandCanceledError } 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";
6
+ export const CURRENT_OUTPUT_LINE_LIMIT = 200;
6
7
  function resolveDependencies(dependencyOverrides) {
7
8
  return {
8
9
  createLogWriter: dependencyOverrides?.createLogWriter ?? createLogWriterForStep,
@@ -26,9 +27,33 @@ async function resolvePrompt(promptMode, askForConfirmation) {
26
27
  function reportState(state, onStateChanged) {
27
28
  onStateChanged(state);
28
29
  }
30
+ function appendCurrentOutputLine(state, line) {
31
+ state.currentOutputLines.push(line);
32
+ if (state.currentOutputLines.length > CURRENT_OUTPUT_LINE_LIMIT) {
33
+ state.currentOutputLines.splice(0, state.currentOutputLines.length - CURRENT_OUTPUT_LINE_LIMIT);
34
+ }
35
+ }
29
36
  function wasStepCanceledByUser(error) {
30
37
  return error instanceof ShellCommandCanceledError;
31
38
  }
39
+ function createFailureOutputLines(currentOutputLines, error) {
40
+ if (currentOutputLines.length > 0) {
41
+ return [...currentOutputLines];
42
+ }
43
+ const errorMessage = error instanceof Error && error.message.length > 0
44
+ ? error.message
45
+ : "Command failed.";
46
+ return [{ text: errorMessage, source: "stderr" }];
47
+ }
48
+ function createFailedStepRecord(options) {
49
+ const errorMessage = options.error instanceof Error ? options.error.message : String(options.error);
50
+ return {
51
+ phase: "failed",
52
+ summaryNote: wasStepCanceledByUser(options.error) ? "canceled by user" : errorMessage,
53
+ logStepId: options.logStepId,
54
+ failureOutputLines: [...options.failureOutputLines],
55
+ };
56
+ }
32
57
  export async function runUpdateEngine(options) {
33
58
  const dependencies = resolveDependencies(options.dependencies);
34
59
  const state = createInitialUpdateState(toDisplaySteps(options.steps), options.version, options.logoVisibility);
@@ -72,7 +97,7 @@ export async function runUpdateEngine(options) {
72
97
  await dependencies.runCommand({
73
98
  shellCommand: `brew install ${step.brewPackageToInstall}`,
74
99
  onOutputLine: (line) => {
75
- state.currentOutputLines.push(line);
100
+ appendCurrentOutputLine(state, line);
76
101
  reportState(state, options.onStateChanged);
77
102
  },
78
103
  logWriter: installLogWriter,
@@ -82,12 +107,15 @@ export async function runUpdateEngine(options) {
82
107
  if (wasStepCanceledByUser(error)) {
83
108
  promptMode = "interactive";
84
109
  }
110
+ const failureOutputLines = createFailureOutputLines(state.currentOutputLines, error);
85
111
  state.currentPhase = "failed";
112
+ state.currentOutputLines = failureOutputLines;
86
113
  reportState(state, options.onStateChanged);
87
- state.completedStepRecords.push({
88
- phase: "failed",
89
- summaryNote: wasStepCanceledByUser(error) ? "canceled by user" : "install failed",
90
- });
114
+ state.completedStepRecords.push(createFailedStepRecord({
115
+ error,
116
+ failureOutputLines,
117
+ logStepId: `${step.id}-install`,
118
+ }));
91
119
  continue;
92
120
  }
93
121
  }
@@ -109,7 +137,7 @@ export async function runUpdateEngine(options) {
109
137
  const stepLogWriter = await dependencies.createLogWriter(step.id);
110
138
  try {
111
139
  await step.run(dependencies.createStepRunContext((line) => {
112
- state.currentOutputLines.push(line);
140
+ appendCurrentOutputLine(state, line);
113
141
  reportState(state, options.onStateChanged);
114
142
  }, stepLogWriter));
115
143
  state.currentPhase = "complete";
@@ -121,14 +149,15 @@ export async function runUpdateEngine(options) {
121
149
  if (wasStepCanceledByUser(error)) {
122
150
  promptMode = "interactive";
123
151
  }
124
- const errorMessage = error instanceof Error ? error.message : String(error);
152
+ const failureOutputLines = createFailureOutputLines(state.currentOutputLines, error);
125
153
  state.currentPhase = "failed";
126
- state.currentOutputLines = [];
154
+ state.currentOutputLines = failureOutputLines;
127
155
  reportState(state, options.onStateChanged);
128
- state.completedStepRecords.push({
129
- phase: "failed",
130
- summaryNote: wasStepCanceledByUser(error) ? "canceled by user" : errorMessage,
131
- });
156
+ state.completedStepRecords.push(createFailedStepRecord({
157
+ error,
158
+ failureOutputLines,
159
+ logStepId: step.id,
160
+ }));
132
161
  }
133
162
  }
134
163
  state.runCompletion = "finished";
@@ -10,6 +10,8 @@ export type StepPhase = "pending" | "checking-availability" | "prompting-to-inst
10
10
  export type CompletedStepRecord = {
11
11
  phase: StepPhase;
12
12
  summaryNote?: string;
13
+ logStepId?: string;
14
+ failureOutputLines?: CommandOutputLine[];
13
15
  };
14
16
  export type CurrentPrompt = {
15
17
  question: string;
@@ -36,3 +38,4 @@ export type UpdateState = {
36
38
  export type AppState = UpdateState;
37
39
  export declare function createInitialUpdateState(steps: DisplayStep[], version: string, logoVisibility: LogoVisibility): UpdateState;
38
40
  export declare const createInitialAppState: typeof createInitialUpdateState;
41
+ export declare function countFailedSteps(completedStepRecords: CompletedStepRecord[]): number;
@@ -13,3 +13,6 @@ export function createInitialUpdateState(steps, version, logoVisibility) {
13
13
  };
14
14
  }
15
15
  export const createInitialAppState = createInitialUpdateState;
16
+ export function countFailedSteps(completedStepRecords) {
17
+ return completedStepRecords.filter((completedStepRecord) => completedStepRecord.phase === "failed").length;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braeburn",
3
- "version": "1.6.0",
3
+ "version": "2.1.0",
4
4
  "description": "macOS system updater CLI",
5
5
  "type": "module",
6
6
  "bin": {