Skip to content

Commit 2710f17

Browse files
committed
feat(cli): add upgrade command to update bundled assets in-place
Allows updating _bmad/, .ralph/ loop/lib/templates, and slash commands without resetting project state (config, logs, fix_plan, specs). Extracts copyBundledAssets() from installProject() for reuse.
1 parent ce08f9f commit 2710f17

6 files changed

Lines changed: 282 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bmalph",
3-
"version": "0.5.1",
3+
"version": "0.6.0",
44
"description": "Unified AI Development Framework - BMAD phases with Ralph execution loop for Claude Code",
55
"type": "module",
66
"bin": {

src/cli.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { Command } from "commander";
22
import { initCommand } from "./commands/init.js";
33
import { implementCommand } from "./commands/implement.js";
44
import { resetCommand } from "./commands/reset.js";
5+
import { upgradeCommand } from "./commands/upgrade.js";
56

67
const program = new Command();
78

89
program
910
.name("bmalph")
1011
.description("BMAD-METHOD + Ralph integration — structured planning to autonomous implementation")
11-
.version("0.4.2");
12+
.version("0.6.0");
1213

1314
program
1415
.command("init")
@@ -29,4 +30,9 @@ program
2930
.option("--hard", "Also remove _bmad/, .ralph/, and artifacts")
3031
.action(resetCommand);
3132

33+
program
34+
.command("upgrade")
35+
.description("Update installed assets to match current bmalph version")
36+
.action(upgradeCommand);
37+
3238
program.parse();

src/commands/upgrade.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import chalk from "chalk";
2+
import { isInitialized, copyBundledAssets, mergeClaudeMd } from "../installer.js";
3+
4+
export async function upgradeCommand(): Promise<void> {
5+
const projectDir = process.cwd();
6+
7+
if (!(await isInitialized(projectDir))) {
8+
console.log(chalk.red("bmalph is not initialized. Run 'bmalph init' first."));
9+
return;
10+
}
11+
12+
console.log(chalk.blue("Upgrading bundled assets..."));
13+
14+
const result = await copyBundledAssets(projectDir);
15+
await mergeClaudeMd(projectDir);
16+
17+
console.log(chalk.green("\nUpdated:"));
18+
for (const path of result.updatedPaths) {
19+
console.log(` ${path}`);
20+
}
21+
22+
console.log(chalk.dim("\nPreserved:"));
23+
console.log(" bmalph/config.json");
24+
console.log(" bmalph/state/");
25+
console.log(" .ralph/logs/");
26+
console.log(" .ralph/@fix_plan.md");
27+
console.log(" .ralph/docs/");
28+
console.log(" .ralph/specs/");
29+
30+
console.log(chalk.green("\nUpgrade complete."));
31+
}

src/installer.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,14 @@ export function getSlashCommandsDir(): string {
1717
return join(__dirname, "..", "slash-commands");
1818
}
1919

20-
export async function installProject(projectDir: string): Promise<void> {
20+
export interface UpgradeResult {
21+
updatedPaths: string[];
22+
}
23+
24+
export async function copyBundledAssets(projectDir: string): Promise<UpgradeResult> {
2125
const bmadDir = getBmadDir();
2226
const ralphDir = getRalphDir();
23-
24-
// Create directory structure
25-
await mkdir(join(projectDir, "bmalph/state"), { recursive: true });
26-
await mkdir(join(projectDir, ".ralph/specs"), { recursive: true });
27-
await mkdir(join(projectDir, ".ralph/logs"), { recursive: true });
28-
await mkdir(join(projectDir, ".ralph/lib"), { recursive: true });
27+
const slashCommandsDir = getSlashCommandsDir();
2928

3029
// Copy BMAD files → _bmad/
3130
await cp(bmadDir, join(projectDir, "_bmad"), { recursive: true });
@@ -42,6 +41,7 @@ modules:
4241
);
4342

4443
// Copy Ralph templates → .ralph/
44+
await mkdir(join(projectDir, ".ralph"), { recursive: true });
4545
await cp(
4646
join(ralphDir, "templates/PROMPT.md"),
4747
join(projectDir, ".ralph/PROMPT.md"),
@@ -56,7 +56,6 @@ modules:
5656
await cp(join(ralphDir, "lib"), join(projectDir, ".ralph/lib"), { recursive: true });
5757

5858
// Install slash command → .claude/commands/bmalph.md
59-
const slashCommandsDir = getSlashCommandsDir();
6059
await mkdir(join(projectDir, ".claude/commands"), { recursive: true });
6160
await cp(
6261
join(slashCommandsDir, "bmalph.md"),
@@ -65,6 +64,28 @@ modules:
6564

6665
// Update .gitignore
6766
await updateGitignore(projectDir);
67+
68+
return {
69+
updatedPaths: [
70+
"_bmad/",
71+
".ralph/ralph_loop.sh",
72+
".ralph/lib/",
73+
".ralph/PROMPT.md",
74+
".ralph/@AGENT.md",
75+
".claude/commands/bmalph.md",
76+
".gitignore",
77+
],
78+
};
79+
}
80+
81+
export async function installProject(projectDir: string): Promise<void> {
82+
// Create user directories (not overwritten by upgrade)
83+
await mkdir(join(projectDir, "bmalph/state"), { recursive: true });
84+
await mkdir(join(projectDir, ".ralph/specs"), { recursive: true });
85+
await mkdir(join(projectDir, ".ralph/logs"), { recursive: true });
86+
await mkdir(join(projectDir, ".ralph/docs/generated"), { recursive: true });
87+
88+
await copyBundledAssets(projectDir);
6889
}
6990

7091
async function updateGitignore(projectDir: string): Promise<void> {

tests/commands/upgrade.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
3+
vi.mock("chalk", () => ({
4+
default: {
5+
red: (s: string) => s,
6+
green: (s: string) => s,
7+
blue: (s: string) => s,
8+
yellow: (s: string) => s,
9+
bold: (s: string) => s,
10+
dim: (s: string) => s,
11+
cyan: (s: string) => s,
12+
},
13+
}));
14+
15+
vi.mock("../../src/installer.js", () => ({
16+
isInitialized: vi.fn(),
17+
copyBundledAssets: vi.fn(),
18+
mergeClaudeMd: vi.fn(),
19+
}));
20+
21+
describe("upgrade command", () => {
22+
let consoleSpy: ReturnType<typeof vi.spyOn>;
23+
24+
beforeEach(() => {
25+
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
26+
vi.clearAllMocks();
27+
});
28+
29+
afterEach(() => {
30+
consoleSpy.mockRestore();
31+
vi.restoreAllMocks();
32+
});
33+
34+
it("exits early when not initialized", async () => {
35+
const { isInitialized } = await import("../../src/installer.js");
36+
vi.mocked(isInitialized).mockResolvedValue(false);
37+
38+
const { upgradeCommand } = await import("../../src/commands/upgrade.js");
39+
await upgradeCommand();
40+
41+
expect(consoleSpy).toHaveBeenCalledWith(
42+
expect.stringContaining("not initialized"),
43+
);
44+
const { copyBundledAssets } = await import("../../src/installer.js");
45+
expect(copyBundledAssets).not.toHaveBeenCalled();
46+
});
47+
48+
it("calls copyBundledAssets with cwd", async () => {
49+
const { isInitialized, copyBundledAssets } = await import(
50+
"../../src/installer.js"
51+
);
52+
vi.mocked(isInitialized).mockResolvedValue(true);
53+
vi.mocked(copyBundledAssets).mockResolvedValue({
54+
updatedPaths: ["_bmad/", ".ralph/ralph_loop.sh"],
55+
});
56+
57+
const { upgradeCommand } = await import("../../src/commands/upgrade.js");
58+
await upgradeCommand();
59+
60+
expect(copyBundledAssets).toHaveBeenCalledWith(process.cwd());
61+
});
62+
63+
it("calls mergeClaudeMd after copying", async () => {
64+
const { isInitialized, copyBundledAssets, mergeClaudeMd } = await import(
65+
"../../src/installer.js"
66+
);
67+
vi.mocked(isInitialized).mockResolvedValue(true);
68+
vi.mocked(copyBundledAssets).mockResolvedValue({
69+
updatedPaths: ["_bmad/"],
70+
});
71+
vi.mocked(mergeClaudeMd).mockResolvedValue(undefined);
72+
73+
const { upgradeCommand } = await import("../../src/commands/upgrade.js");
74+
await upgradeCommand();
75+
76+
expect(mergeClaudeMd).toHaveBeenCalledWith(process.cwd());
77+
});
78+
79+
it("displays updated paths in output", async () => {
80+
const { isInitialized, copyBundledAssets, mergeClaudeMd } = await import(
81+
"../../src/installer.js"
82+
);
83+
vi.mocked(isInitialized).mockResolvedValue(true);
84+
vi.mocked(copyBundledAssets).mockResolvedValue({
85+
updatedPaths: [
86+
"_bmad/",
87+
".ralph/ralph_loop.sh",
88+
".ralph/lib/",
89+
".ralph/PROMPT.md",
90+
".ralph/@AGENT.md",
91+
".claude/commands/bmalph.md",
92+
],
93+
});
94+
vi.mocked(mergeClaudeMd).mockResolvedValue(undefined);
95+
96+
const { upgradeCommand } = await import("../../src/commands/upgrade.js");
97+
await upgradeCommand();
98+
99+
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
100+
expect(output).toContain("_bmad/");
101+
expect(output).toContain(".ralph/ralph_loop.sh");
102+
expect(output).toContain(".claude/commands/bmalph.md");
103+
});
104+
105+
it("displays preserved paths in output", async () => {
106+
const { isInitialized, copyBundledAssets, mergeClaudeMd } = await import(
107+
"../../src/installer.js"
108+
);
109+
vi.mocked(isInitialized).mockResolvedValue(true);
110+
vi.mocked(copyBundledAssets).mockResolvedValue({
111+
updatedPaths: ["_bmad/"],
112+
});
113+
vi.mocked(mergeClaudeMd).mockResolvedValue(undefined);
114+
115+
const { upgradeCommand } = await import("../../src/commands/upgrade.js");
116+
await upgradeCommand();
117+
118+
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
119+
expect(output).toContain("bmalph/config.json");
120+
expect(output).toContain(".ralph/logs/");
121+
expect(output).toContain(".ralph/@fix_plan.md");
122+
});
123+
});

tests/installer.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2-
import { installProject, mergeClaudeMd, isInitialized } from "../src/installer.js";
2+
import { installProject, copyBundledAssets, mergeClaudeMd, isInitialized } from "../src/installer.js";
33
import { mkdir, rm, access, readFile, writeFile } from "fs/promises";
44
import { join } from "path";
55
import { tmpdir } from "os";
@@ -119,6 +119,96 @@ describe("installer", () => {
119119
});
120120
});
121121

122+
describe("copyBundledAssets", { timeout: 30000 }, () => {
123+
it("copies all expected files", async () => {
124+
// Create minimal directory structure (simulating existing init)
125+
await mkdir(join(testDir, ".ralph"), { recursive: true });
126+
await mkdir(join(testDir, ".claude/commands"), { recursive: true });
127+
128+
const result = await copyBundledAssets(testDir);
129+
130+
await expect(access(join(testDir, "_bmad/core"))).resolves.toBeUndefined();
131+
await expect(access(join(testDir, "_bmad/config.yaml"))).resolves.toBeUndefined();
132+
await expect(access(join(testDir, ".ralph/ralph_loop.sh"))).resolves.toBeUndefined();
133+
await expect(access(join(testDir, ".ralph/lib"))).resolves.toBeUndefined();
134+
await expect(access(join(testDir, ".ralph/PROMPT.md"))).resolves.toBeUndefined();
135+
await expect(access(join(testDir, ".ralph/@AGENT.md"))).resolves.toBeUndefined();
136+
await expect(access(join(testDir, ".claude/commands/bmalph.md"))).resolves.toBeUndefined();
137+
expect(result.updatedPaths.length).toBeGreaterThan(0);
138+
});
139+
140+
it("does NOT create bmalph/state/ or .ralph/logs/", async () => {
141+
const result = await copyBundledAssets(testDir);
142+
143+
await expect(access(join(testDir, "bmalph/state"))).rejects.toThrow();
144+
await expect(access(join(testDir, ".ralph/logs"))).rejects.toThrow();
145+
expect(result.updatedPaths).not.toContain("bmalph/state/");
146+
});
147+
148+
it("preserves existing .ralph/@fix_plan.md", async () => {
149+
await mkdir(join(testDir, ".ralph"), { recursive: true });
150+
await writeFile(join(testDir, ".ralph/@fix_plan.md"), "# My Plan\n- task 1");
151+
152+
await copyBundledAssets(testDir);
153+
154+
const content = await readFile(join(testDir, ".ralph/@fix_plan.md"), "utf-8");
155+
expect(content).toBe("# My Plan\n- task 1");
156+
});
157+
158+
it("preserves existing .ralph/logs/ content", async () => {
159+
await mkdir(join(testDir, ".ralph/logs"), { recursive: true });
160+
await writeFile(join(testDir, ".ralph/logs/run-001.log"), "log content");
161+
162+
await copyBundledAssets(testDir);
163+
164+
const content = await readFile(join(testDir, ".ralph/logs/run-001.log"), "utf-8");
165+
expect(content).toBe("log content");
166+
});
167+
168+
it("preserves existing bmalph/config.json", async () => {
169+
await mkdir(join(testDir, "bmalph"), { recursive: true });
170+
await writeFile(
171+
join(testDir, "bmalph/config.json"),
172+
JSON.stringify({ name: "my-project", level: 3 }),
173+
);
174+
175+
await copyBundledAssets(testDir);
176+
177+
const config = JSON.parse(
178+
await readFile(join(testDir, "bmalph/config.json"), "utf-8"),
179+
);
180+
expect(config.name).toBe("my-project");
181+
expect(config.level).toBe(3);
182+
});
183+
184+
it("is idempotent (twice = same result)", async () => {
185+
await copyBundledAssets(testDir);
186+
const firstRun = await readFile(join(testDir, ".ralph/ralph_loop.sh"), "utf-8");
187+
188+
await copyBundledAssets(testDir);
189+
const secondRun = await readFile(join(testDir, ".ralph/ralph_loop.sh"), "utf-8");
190+
191+
expect(firstRun).toBe(secondRun);
192+
193+
// .gitignore should not duplicate entries
194+
const gitignore = await readFile(join(testDir, ".gitignore"), "utf-8");
195+
const matches = gitignore.match(/\.ralph\/logs\//g);
196+
expect(matches).toHaveLength(1);
197+
});
198+
199+
it("returns list of updated paths", async () => {
200+
const result = await copyBundledAssets(testDir);
201+
202+
expect(result.updatedPaths).toContain("_bmad/");
203+
expect(result.updatedPaths).toContain(".ralph/ralph_loop.sh");
204+
expect(result.updatedPaths).toContain(".ralph/lib/");
205+
expect(result.updatedPaths).toContain(".ralph/PROMPT.md");
206+
expect(result.updatedPaths).toContain(".ralph/@AGENT.md");
207+
expect(result.updatedPaths).toContain(".claude/commands/bmalph.md");
208+
expect(result.updatedPaths).toContain(".gitignore");
209+
});
210+
});
211+
122212
describe("mergeClaudeMd", () => {
123213
it("creates CLAUDE.md if it does not exist", async () => {
124214
await mergeClaudeMd(testDir);

0 commit comments

Comments
 (0)