braeburn 1.0.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/commands/config.d.ts +11 -0
  3. package/dist/commands/config.js +81 -0
  4. package/dist/commands/log.d.ts +6 -0
  5. package/dist/commands/log.js +35 -0
  6. package/dist/commands/update.d.ts +8 -0
  7. package/dist/commands/update.js +110 -0
  8. package/dist/config.d.ts +9 -0
  9. package/dist/config.js +45 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +150 -0
  12. package/dist/logger.d.ts +4 -0
  13. package/dist/logger.js +44 -0
  14. package/dist/logo.d.ts +1 -0
  15. package/dist/logo.js +14 -0
  16. package/dist/runner.d.ts +21 -0
  17. package/dist/runner.js +34 -0
  18. package/dist/steps/cleanup.d.ts +3 -0
  19. package/dist/steps/cleanup.js +13 -0
  20. package/dist/steps/dotnet.d.ts +3 -0
  21. package/dist/steps/dotnet.js +14 -0
  22. package/dist/steps/homebrew.d.ts +3 -0
  23. package/dist/steps/homebrew.js +13 -0
  24. package/dist/steps/index.d.ts +27 -0
  25. package/dist/steps/index.js +24 -0
  26. package/dist/steps/macos.d.ts +3 -0
  27. package/dist/steps/macos.js +32 -0
  28. package/dist/steps/mas.d.ts +3 -0
  29. package/dist/steps/mas.js +14 -0
  30. package/dist/steps/npm.d.ts +3 -0
  31. package/dist/steps/npm.js +14 -0
  32. package/dist/steps/nvm.d.ts +3 -0
  33. package/dist/steps/nvm.js +20 -0
  34. package/dist/steps/ohmyzsh.d.ts +3 -0
  35. package/dist/steps/ohmyzsh.js +17 -0
  36. package/dist/steps/pip.d.ts +3 -0
  37. package/dist/steps/pip.js +17 -0
  38. package/dist/steps/pyenv.d.ts +3 -0
  39. package/dist/steps/pyenv.js +29 -0
  40. package/dist/ui/currentStep.d.ts +10 -0
  41. package/dist/ui/currentStep.js +16 -0
  42. package/dist/ui/header.d.ts +11 -0
  43. package/dist/ui/header.js +61 -0
  44. package/dist/ui/outputBox.d.ts +2 -0
  45. package/dist/ui/outputBox.js +29 -0
  46. package/dist/ui/prompt.d.ts +3 -0
  47. package/dist/ui/prompt.js +37 -0
  48. package/dist/ui/screen.d.ts +3 -0
  49. package/dist/ui/screen.js +54 -0
  50. package/dist/ui/state.d.ts +27 -0
  51. package/dist/ui/state.js +13 -0
  52. package/dist/ui/versionReport.d.ts +3 -0
  53. package/dist/ui/versionReport.js +25 -0
  54. package/package.json +32 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Wagner [SSW]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ import type { Step } from "../steps/index.js";
2
+ type RunConfigCommandOptions = {
3
+ allSteps: Step[];
4
+ };
5
+ type RunConfigUpdateCommandOptions = {
6
+ stepUpdates: Record<string, boolean>;
7
+ allSteps: Step[];
8
+ };
9
+ export declare function runConfigCommand(options: RunConfigCommandOptions): Promise<void>;
10
+ export declare function runConfigUpdateCommand(options: RunConfigUpdateCommandOptions): Promise<void>;
11
+ export {};
@@ -0,0 +1,81 @@
1
+ import chalk from "chalk";
2
+ import { readConfig, writeConfig, resolveConfigPath, isStepEnabled, PROTECTED_STEP_IDS, } from "../config.js";
3
+ export async function runConfigCommand(options) {
4
+ const { allSteps } = options;
5
+ const config = await readConfig();
6
+ const configPath = await resolveConfigPath();
7
+ const STEP_COL = 12;
8
+ const DIVIDER = "─".repeat(STEP_COL + 16);
9
+ process.stdout.write(`Config: ${chalk.dim(configPath)}\n\n`);
10
+ process.stdout.write(`${"Step".padEnd(STEP_COL)}Status\n`);
11
+ process.stdout.write(`${DIVIDER}\n`);
12
+ for (const step of allSteps) {
13
+ const isProtected = PROTECTED_STEP_IDS.has(step.id);
14
+ const enabled = isStepEnabled(config, step.id);
15
+ let statusText;
16
+ if (isProtected) {
17
+ statusText = chalk.dim("always enabled");
18
+ }
19
+ else if (enabled) {
20
+ statusText = chalk.green("enabled");
21
+ }
22
+ else {
23
+ statusText = chalk.red("disabled");
24
+ }
25
+ process.stdout.write(`${step.id.padEnd(STEP_COL)}${statusText}\n`);
26
+ }
27
+ process.stdout.write(`\n`);
28
+ }
29
+ export async function runConfigUpdateCommand(options) {
30
+ const { stepUpdates, allSteps } = options;
31
+ if (Object.keys(stepUpdates).length === 0) {
32
+ const configurableSteps = allSteps.filter((s) => !PROTECTED_STEP_IDS.has(s.id));
33
+ process.stdout.write("No changes — pass flags to enable or disable steps:\n\n");
34
+ for (const step of configurableSteps) {
35
+ process.stdout.write(` ${`--no-${step.id}`.padEnd(18)} disable ${step.name}\n`);
36
+ }
37
+ process.stdout.write("\n");
38
+ for (const step of configurableSteps) {
39
+ process.stdout.write(` ${`--${step.id}`.padEnd(18)} re-enable ${step.name}\n`);
40
+ }
41
+ process.stdout.write("\n");
42
+ return;
43
+ }
44
+ const config = await readConfig();
45
+ const changes = [];
46
+ for (const [stepId, newEnabled] of Object.entries(stepUpdates)) {
47
+ const currentlyEnabled = isStepEnabled(config, stepId);
48
+ if (currentlyEnabled !== newEnabled) {
49
+ changes.push({ stepId, from: currentlyEnabled, to: newEnabled });
50
+ }
51
+ if (newEnabled) {
52
+ // Re-enabling: remove from config so absent = enabled (keeps file minimal)
53
+ delete config.steps[stepId];
54
+ }
55
+ else {
56
+ config.steps[stepId] = false;
57
+ }
58
+ }
59
+ // Write even if no visible changes, in case the user is re-confirming state
60
+ await writeCleanConfig(config);
61
+ if (changes.length === 0) {
62
+ process.stdout.write("No changes — already set as requested.\n");
63
+ return;
64
+ }
65
+ for (const { stepId, from, to } of changes) {
66
+ const fromLabel = from ? chalk.green("enabled") : chalk.red("disabled");
67
+ const toLabel = to ? chalk.green("enabled") : chalk.red("disabled");
68
+ process.stdout.write(` ${stepId.padEnd(12)} ${fromLabel} → ${toLabel}\n`);
69
+ }
70
+ const configPath = await resolveConfigPath();
71
+ process.stdout.write(`\nConfig saved to ${chalk.dim(configPath)}\n`);
72
+ }
73
+ async function writeCleanConfig(config) {
74
+ const cleaned = { steps: {} };
75
+ for (const [stepId, enabled] of Object.entries(config.steps)) {
76
+ if (enabled === false) {
77
+ cleaned.steps[stepId] = false;
78
+ }
79
+ }
80
+ await writeConfig(cleaned);
81
+ }
@@ -0,0 +1,6 @@
1
+ type ShowLogOptions = {
2
+ stepId: string;
3
+ };
4
+ export declare function runLogCommand(options: ShowLogOptions): Promise<void>;
5
+ export declare function runLogListCommand(): void;
6
+ export {};
@@ -0,0 +1,35 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { findLatestLogFileForStep, listAllStepIdsWithLogs, } from "../logger.js";
5
+ const BRAEBURN_LOG_DIRECTORY = join(homedir(), ".braeburn", "logs");
6
+ function streamFileToStdout(filePath) {
7
+ return new Promise((resolve, reject) => {
8
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
9
+ stream.on("data", (chunk) => process.stdout.write(String(chunk)));
10
+ stream.on("end", resolve);
11
+ stream.on("error", reject);
12
+ });
13
+ }
14
+ export async function runLogCommand(options) {
15
+ const logFilePath = findLatestLogFileForStep(options.stepId);
16
+ if (!logFilePath) {
17
+ process.stderr.write(`No logs found for step "${options.stepId}".\n` +
18
+ `Log files are stored in: ${BRAEBURN_LOG_DIRECTORY}\n`);
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ await streamFileToStdout(logFilePath);
23
+ }
24
+ export function runLogListCommand() {
25
+ const stepIdsWithLogs = listAllStepIdsWithLogs();
26
+ if (stepIdsWithLogs.length === 0) {
27
+ process.stdout.write(`No logs found yet. Run braeburn to generate logs.\n`);
28
+ return;
29
+ }
30
+ process.stdout.write("Available step logs:\n\n");
31
+ for (const stepId of stepIdsWithLogs) {
32
+ process.stdout.write(` ${stepId}\n`);
33
+ }
34
+ process.stdout.write(`\nUsage: braeburn log <step> (e.g. braeburn log homebrew)\n`);
35
+ }
@@ -0,0 +1,8 @@
1
+ import type { Step } from "../steps/index.js";
2
+ type RunUpdateCommandOptions = {
3
+ steps: Step[];
4
+ autoYes: boolean;
5
+ version: string;
6
+ };
7
+ export declare function runUpdateCommand(options: RunUpdateCommandOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,110 @@
1
+ import { runShellCommand } from "../runner.js";
2
+ import { createLogWriterForStep } from "../logger.js";
3
+ import { collectVersions } from "../ui/versionReport.js";
4
+ import { captureYesNo } from "../ui/prompt.js";
5
+ import { createInitialAppState } from "../ui/state.js";
6
+ import { buildScreen, renderScreen } from "../ui/screen.js";
7
+ export async function runUpdateCommand(options) {
8
+ const { steps, autoYes, version } = options;
9
+ const state = createInitialAppState(steps, version);
10
+ process.stdout.write("\x1b[?25l");
11
+ process.on("exit", () => process.stdout.write("\x1b[?25h"));
12
+ process.on("SIGINT", () => {
13
+ process.stdout.write("\x1b[?25h\n");
14
+ process.exit(130);
15
+ });
16
+ renderScreen(buildScreen(state));
17
+ for (let i = 0; i < steps.length; i++) {
18
+ const step = steps[i];
19
+ state.currentStepIndex = i;
20
+ state.currentPhase = "checking-availability";
21
+ state.currentOutputLines = [];
22
+ state.currentPrompt = undefined;
23
+ renderScreen(buildScreen(state));
24
+ const isAvailable = await step.checkIsAvailable();
25
+ if (!isAvailable && !step.brewPackageToInstall) {
26
+ state.currentPhase = "not-available";
27
+ renderScreen(buildScreen(state));
28
+ state.completedStepRecords.push({ phase: "not-available", summaryNote: "not installed" });
29
+ continue;
30
+ }
31
+ if (!isAvailable && step.brewPackageToInstall) {
32
+ state.currentPhase = "prompting-to-install";
33
+ state.currentPrompt = {
34
+ question: `Install ${step.name} via Homebrew? (brew install ${step.brewPackageToInstall})`,
35
+ };
36
+ renderScreen(buildScreen(state));
37
+ const shouldInstall = autoYes || (await captureYesNo());
38
+ state.currentPrompt = undefined;
39
+ if (!shouldInstall) {
40
+ state.currentPhase = "skipped";
41
+ renderScreen(buildScreen(state));
42
+ state.completedStepRecords.push({ phase: "skipped" });
43
+ continue;
44
+ }
45
+ state.currentPhase = "installing";
46
+ renderScreen(buildScreen(state));
47
+ try {
48
+ const installLogWriter = await createLogWriterForStep(`${step.id}-install`);
49
+ await runShellCommand({
50
+ shellCommand: `brew install ${step.brewPackageToInstall}`,
51
+ onOutputLine: (line) => {
52
+ state.currentOutputLines.push(line);
53
+ renderScreen(buildScreen(state));
54
+ },
55
+ logWriter: installLogWriter,
56
+ });
57
+ }
58
+ catch {
59
+ state.currentPhase = "failed";
60
+ renderScreen(buildScreen(state));
61
+ state.completedStepRecords.push({ phase: "failed", summaryNote: "install failed" });
62
+ continue;
63
+ }
64
+ }
65
+ const pipWarning = step.id === "pip"
66
+ ? "This updates all global pip3 packages, which can occasionally break tools."
67
+ : undefined;
68
+ state.currentPhase = "prompting-to-run";
69
+ state.currentPrompt = { question: `Run ${step.name} update?`, warning: pipWarning };
70
+ renderScreen(buildScreen(state));
71
+ const shouldRun = autoYes || (await captureYesNo());
72
+ state.currentPrompt = undefined;
73
+ if (!shouldRun) {
74
+ state.currentPhase = "skipped";
75
+ renderScreen(buildScreen(state));
76
+ state.completedStepRecords.push({ phase: "skipped" });
77
+ continue;
78
+ }
79
+ state.currentPhase = "running";
80
+ state.currentOutputLines = [];
81
+ renderScreen(buildScreen(state));
82
+ const stepLogWriter = await createLogWriterForStep(step.id);
83
+ try {
84
+ await step.run({
85
+ onOutputLine: (line) => {
86
+ state.currentOutputLines.push(line);
87
+ renderScreen(buildScreen(state));
88
+ },
89
+ logWriter: stepLogWriter,
90
+ });
91
+ state.currentPhase = "complete";
92
+ state.currentOutputLines = [];
93
+ renderScreen(buildScreen(state));
94
+ state.completedStepRecords.push({ phase: "complete", summaryNote: "updated" });
95
+ }
96
+ catch (error) {
97
+ const errorMessage = error instanceof Error ? error.message : String(error);
98
+ state.currentPhase = "failed";
99
+ state.currentOutputLines = [];
100
+ renderScreen(buildScreen(state));
101
+ state.completedStepRecords.push({ phase: "failed", summaryNote: errorMessage });
102
+ }
103
+ }
104
+ state.isFinished = true;
105
+ state.currentOutputLines = [];
106
+ state.currentPrompt = undefined;
107
+ renderScreen(buildScreen(state));
108
+ state.versionReport = await collectVersions();
109
+ renderScreen(buildScreen(state));
110
+ }
@@ -0,0 +1,9 @@
1
+ /** Steps that cannot be disabled — brew is a hard runtime dependency. */
2
+ export declare const PROTECTED_STEP_IDS: Set<string>;
3
+ export type BraeburnConfig = {
4
+ steps: Record<string, boolean>;
5
+ };
6
+ export declare function resolveConfigPath(): Promise<string>;
7
+ export declare function readConfig(): Promise<BraeburnConfig>;
8
+ export declare function writeConfig(config: BraeburnConfig): Promise<void>;
9
+ export declare function isStepEnabled(config: BraeburnConfig, stepId: string): boolean;
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { parse, stringify } from "smol-toml";
5
+ /** Steps that cannot be disabled — brew is a hard runtime dependency. */
6
+ export const PROTECTED_STEP_IDS = new Set(["homebrew"]);
7
+ const EMPTY_CONFIG = { steps: {} };
8
+ async function pathExists(p) {
9
+ try {
10
+ await access(p);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export async function resolveConfigPath() {
18
+ const xdgConfig = join(homedir(), ".config");
19
+ if (await pathExists(xdgConfig)) {
20
+ return join(xdgConfig, "braeburn", "config");
21
+ }
22
+ return join(homedir(), ".braeburn", "config");
23
+ }
24
+ export async function readConfig() {
25
+ const configPath = await resolveConfigPath();
26
+ try {
27
+ const raw = await readFile(configPath, "utf-8");
28
+ const parsed = parse(raw);
29
+ return { steps: parsed.steps ?? {} };
30
+ }
31
+ catch {
32
+ return structuredClone(EMPTY_CONFIG);
33
+ }
34
+ }
35
+ export async function writeConfig(config) {
36
+ const configPath = await resolveConfigPath();
37
+ await mkdir(join(configPath, ".."), { recursive: true });
38
+ await writeFile(configPath, stringify(config), "utf-8");
39
+ }
40
+ export function isStepEnabled(config, stepId) {
41
+ if (PROTECTED_STEP_IDS.has(stepId))
42
+ return true;
43
+ // Absent from config means enabled (opt-out model)
44
+ return config.steps[stepId] !== false;
45
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
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 { homebrewStep, masStep, ohmyzshStep, npmStep, pipStep, pyenvStep, nvmStep, dotnetStep, macosStep, cleanupStep, } from "./steps/index.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 { readConfig, isStepEnabled, PROTECTED_STEP_IDS } from "./config.js";
11
+ const ALL_STEPS = [
12
+ homebrewStep,
13
+ masStep,
14
+ ohmyzshStep,
15
+ npmStep,
16
+ pipStep,
17
+ pyenvStep,
18
+ nvmStep,
19
+ dotnetStep,
20
+ macosStep,
21
+ cleanupStep,
22
+ ];
23
+ const STEP_IDS_BY_NAME = new Map(ALL_STEPS.map((step) => [step.id, step]));
24
+ const requireFromThis = createRequire(import.meta.url);
25
+ const packageJson = requireFromThis(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"));
26
+ const BRAEBURN_VERSION = packageJson.version;
27
+ const program = new Command();
28
+ program
29
+ .name("braeburn")
30
+ .description("macOS system updater")
31
+ .version(BRAEBURN_VERSION)
32
+ .helpCommand(false);
33
+ program
34
+ .command("update", { isDefault: true })
35
+ .description("Run system update steps (default command)")
36
+ .argument("[steps...]", `Steps to run — omit to run all.\nAvailable: ${ALL_STEPS.map((s) => s.id).join(", ")}`)
37
+ .option("-y, --yes", "Auto-accept all prompts (default yes to everything)")
38
+ .option("-f, --force", "Alias for --yes")
39
+ .addHelpText("after", `
40
+ Step descriptions:
41
+ homebrew Update Homebrew itself and all installed formulae
42
+ mas Upgrade Mac App Store apps (requires: mas)
43
+ ohmyzsh Update Oh My Zsh (requires: ~/.oh-my-zsh)
44
+ npm Update global npm packages (requires: npm)
45
+ pip Update global pip3 packages (requires: pip3) ⚠ may be fragile
46
+ pyenv Upgrade pyenv, install latest Python 3.x (requires: pyenv or brew)
47
+ nvm Update Node.js via nvm (requires: ~/.nvm)
48
+ dotnet Update .NET global tools (requires: dotnet)
49
+ macos Check for macOS updates, prompt to install
50
+ cleanup Clean up Homebrew cache and old downloads
51
+
52
+ Examples:
53
+ braeburn Run all steps interactively
54
+ braeburn -y Run all 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
+ `)
59
+ .action(async (stepArguments, options) => {
60
+ const autoYes = options.yes === true || options.force === true;
61
+ let stepsToRun = stepArguments.length === 0
62
+ ? ALL_STEPS
63
+ : resolveStepsByIds(stepArguments);
64
+ // When no explicit steps are requested, filter out steps disabled in config.
65
+ // Explicit step arguments always bypass config (user knows what they want).
66
+ if (stepArguments.length === 0) {
67
+ const config = await readConfig();
68
+ stepsToRun = stepsToRun.filter((step) => isStepEnabled(config, step.id));
69
+ }
70
+ await runUpdateCommand({
71
+ steps: stepsToRun,
72
+ autoYes,
73
+ version: BRAEBURN_VERSION,
74
+ });
75
+ });
76
+ program
77
+ .command("log")
78
+ .description("View the most recent output log for a given step")
79
+ .argument("[step]", "Step ID to view logs for (e.g. homebrew, npm, pip)")
80
+ .option("--homebrew", "Show latest Homebrew log")
81
+ .option("--mas", "Show latest Mac App Store log")
82
+ .option("--ohmyzsh", "Show latest Oh My Zsh log")
83
+ .option("--npm", "Show latest npm log")
84
+ .option("--pip", "Show latest pip3 log")
85
+ .option("--pyenv", "Show latest pyenv log")
86
+ .option("--nvm", "Show latest nvm log")
87
+ .option("--dotnet", "Show latest .NET log")
88
+ .option("--macos", "Show latest macOS update log")
89
+ .option("--cleanup", "Show latest cleanup log")
90
+ .addHelpText("after", `
91
+ Examples:
92
+ braeburn log List all available step logs
93
+ braeburn log homebrew Show the latest Homebrew run log
94
+ braeburn log --brew Same as above
95
+ braeburn log npm | less Pipe log output through less
96
+ `)
97
+ .action((stepArgument, options) => {
98
+ const stepIdFromFlag = ALL_STEPS.map((step) => step.id).find((stepId) => options[stepId] === true);
99
+ const resolvedStepId = stepArgument ?? stepIdFromFlag;
100
+ if (!resolvedStepId) {
101
+ runLogListCommand();
102
+ return;
103
+ }
104
+ runLogCommand({ stepId: resolvedStepId });
105
+ });
106
+ const configCommand = program
107
+ .command("config")
108
+ .description("View or edit braeburn configuration")
109
+ .action(async () => {
110
+ await runConfigCommand({ allSteps: ALL_STEPS });
111
+ });
112
+ const configurableSteps = ALL_STEPS.filter((s) => !PROTECTED_STEP_IDS.has(s.id));
113
+ const configUpdateCommand = configCommand
114
+ .command("update")
115
+ .description("Enable or disable individual update steps")
116
+ .addHelpText("after", `
117
+ Examples:
118
+ braeburn config update --no-ohmyzsh Disable Oh My Zsh updates
119
+ braeburn config update --no-pip --no-nvm Disable pip and nvm updates
120
+ braeburn config update --ohmyzsh Re-enable Oh My Zsh updates
121
+ `);
122
+ for (const step of configurableSteps) {
123
+ configUpdateCommand.option(`--no-${step.id}`, `Disable ${step.name} updates`);
124
+ configUpdateCommand.option(`--${step.id}`, `Enable ${step.name} updates`);
125
+ }
126
+ configUpdateCommand.action(function () {
127
+ // Use getOptionValueSource to detect only flags explicitly passed on the CLI.
128
+ // Commander defaults --no-* to true, so we can't rely on option values alone.
129
+ const stepUpdates = {};
130
+ for (const step of configurableSteps) {
131
+ const source = configUpdateCommand.getOptionValueSource(step.id);
132
+ if (source === "cli") {
133
+ stepUpdates[step.id] = configUpdateCommand.opts()[step.id];
134
+ }
135
+ }
136
+ runConfigUpdateCommand({ stepUpdates, allSteps: ALL_STEPS });
137
+ });
138
+ function resolveStepsByIds(stepIds) {
139
+ const resolvedSteps = [];
140
+ for (const stepId of stepIds) {
141
+ const step = STEP_IDS_BY_NAME.get(stepId);
142
+ if (!step) {
143
+ process.stderr.write(`Unknown step: "${stepId}". Run braeburn --help to see available steps.\n`);
144
+ process.exit(1);
145
+ }
146
+ resolvedSteps.push(step);
147
+ }
148
+ return resolvedSteps;
149
+ }
150
+ program.parse();
@@ -0,0 +1,4 @@
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[];
package/dist/logger.js ADDED
@@ -0,0 +1,44 @@
1
+ import { mkdir, appendFile } from "node:fs/promises";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
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)) {
8
+ return;
9
+ }
10
+ await mkdir(BRAEBURN_LOG_DIRECTORY, { recursive: true });
11
+ }
12
+ export async function createLogWriterForStep(stepId) {
13
+ await ensureLogDirectoryExists();
14
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
15
+ const logFilePath = join(BRAEBURN_LOG_DIRECTORY, `${stepId}-${timestamp}.log`);
16
+ const writeLineToLog = async (line) => {
17
+ await appendFile(logFilePath, line + "\n", "utf-8");
18
+ };
19
+ return writeLineToLog;
20
+ }
21
+ export function findLatestLogFileForStep(stepId) {
22
+ if (!existsSync(BRAEBURN_LOG_DIRECTORY)) {
23
+ return null;
24
+ }
25
+ const allFiles = readdirSync(BRAEBURN_LOG_DIRECTORY);
26
+ const filesForThisStep = allFiles
27
+ .filter((fileName) => fileName.startsWith(`${stepId}-`))
28
+ .sort()
29
+ .reverse(); // most recent first
30
+ if (filesForThisStep.length === 0) {
31
+ return null;
32
+ }
33
+ return join(BRAEBURN_LOG_DIRECTORY, filesForThisStep[0]);
34
+ }
35
+ export function listAllStepIdsWithLogs() {
36
+ if (!existsSync(BRAEBURN_LOG_DIRECTORY)) {
37
+ return [];
38
+ }
39
+ const allFiles = readdirSync(BRAEBURN_LOG_DIRECTORY);
40
+ const stepIds = new Set(allFiles
41
+ .map((fileName) => fileName.split("-")[0])
42
+ .filter((maybeStepId) => Boolean(maybeStepId)));
43
+ return [...stepIds].sort();
44
+ }
package/dist/logo.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const LOGO_ART = " ; \n :x : \n .x$+x \n x+x; XXX& \n :xx&&& &&& \n +X +X x+ .::: \n ; X$$; :X$: \n $+ ;+ \n .x: : . \n :: ;+X \n +$ \n :";
package/dist/logo.js ADDED
@@ -0,0 +1,14 @@
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
+ export const LOGO_ART = ` ;
4
+ :x :
5
+ .x\$+x
6
+ x+x; XXX&
7
+ :xx&&& &&&
8
+ +X +X x+ .:::
9
+ ; X\$\$; :X\$:
10
+ \$+ ;+
11
+ .x: : .
12
+ :: ;+X
13
+ +\$
14
+ :`;
@@ -0,0 +1,21 @@
1
+ import type { StepLogWriter } from "./logger.js";
2
+ export type CommandOutputLine = {
3
+ text: string;
4
+ isError: boolean;
5
+ };
6
+ export type OutputLineCallback = (line: CommandOutputLine) => void;
7
+ type RunCommandOptions = {
8
+ shellCommand: string;
9
+ onOutputLine: OutputLineCallback;
10
+ logWriter: StepLogWriter;
11
+ };
12
+ export declare function runShellCommand(options: RunCommandOptions): Promise<void>;
13
+ type CheckCommandOptions = {
14
+ shellCommand: string;
15
+ };
16
+ export declare function doesShellCommandSucceed(options: CheckCommandOptions): Promise<boolean>;
17
+ type CaptureCommandOptions = {
18
+ shellCommand: string;
19
+ };
20
+ export declare function captureShellCommandOutput(options: CaptureCommandOptions): Promise<string>;
21
+ export {};
package/dist/runner.js ADDED
@@ -0,0 +1,34 @@
1
+ import { execa } from "execa";
2
+ export async function runShellCommand(options) {
3
+ const subprocess = execa("bash", ["-c", options.shellCommand], {
4
+ all: true,
5
+ reject: true,
6
+ });
7
+ subprocess.stdout?.on("data", (chunk) => {
8
+ const lines = String(chunk).split("\n").filter(Boolean);
9
+ for (const line of lines) {
10
+ options.onOutputLine({ text: line, isError: false });
11
+ options.logWriter(line);
12
+ }
13
+ });
14
+ subprocess.stderr?.on("data", (chunk) => {
15
+ const lines = String(chunk).split("\n").filter(Boolean);
16
+ for (const line of lines) {
17
+ options.onOutputLine({ text: line, isError: true });
18
+ options.logWriter(line);
19
+ }
20
+ });
21
+ await subprocess;
22
+ }
23
+ export async function doesShellCommandSucceed(options) {
24
+ const result = await execa("bash", ["-c", options.shellCommand], {
25
+ reject: false,
26
+ });
27
+ return result.exitCode === 0;
28
+ }
29
+ export async function captureShellCommandOutput(options) {
30
+ const result = await execa("bash", ["-c", options.shellCommand], {
31
+ reject: false,
32
+ });
33
+ return result.stdout.trim();
34
+ }
@@ -0,0 +1,3 @@
1
+ import { type Step } from "./index.js";
2
+ declare const cleanupStep: Step;
3
+ export default cleanupStep;
@@ -0,0 +1,13 @@
1
+ import { checkCommandExists, runStep } from "./index.js";
2
+ const cleanupStep = {
3
+ id: "cleanup",
4
+ name: "Cleanup",
5
+ description: "Remove outdated Homebrew downloads and cached versions",
6
+ async checkIsAvailable() {
7
+ return checkCommandExists("brew");
8
+ },
9
+ async run(context) {
10
+ await runStep("brew cleanup", context);
11
+ },
12
+ };
13
+ export default cleanupStep;
@@ -0,0 +1,3 @@
1
+ import { type Step } from "./index.js";
2
+ declare const dotnetStep: Step;
3
+ export default dotnetStep;
@@ -0,0 +1,14 @@
1
+ import { checkCommandExists, runStep } from "./index.js";
2
+ const dotnetStep = {
3
+ id: "dotnet",
4
+ name: ".NET",
5
+ description: "Update all globally installed .NET tools",
6
+ // No brewPackageToInstall — .NET has its own installer
7
+ async checkIsAvailable() {
8
+ return checkCommandExists("dotnet");
9
+ },
10
+ async run(context) {
11
+ await runStep("dotnet tool update --global --all", context);
12
+ },
13
+ };
14
+ export default dotnetStep;
@@ -0,0 +1,3 @@
1
+ import { type Step } from "./index.js";
2
+ declare const homebrewStep: Step;
3
+ export default homebrewStep;