diff --git a/README.md b/README.md index 09ed741..5c8480b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,27 @@ storm continue 42 --dry-run # Update storm-agent to the latest version storm update + +# Register a project for global operations +storm global add . + +# List all registered projects with issue counts +storm global list + +# Run storm across all registered projects +storm global run + +# Preview issues across all projects without executing +storm global run --dry-run + +# Run all projects concurrently +storm global run --parallel + +# Show branches and PRs across all projects +storm global status + +# Unregister a project +storm global remove . ``` ## Generating issues @@ -156,6 +177,36 @@ The continue template supports all standard placeholders plus PR-specific ones: | `{{ pr.diff }}` | Diff stat summary | | `{{ pr.reviews }}` | Formatted review comments with file paths and diff hunks | +## Global mode + +The `storm global` command lets you manage and run storm across multiple projects from a single place. Project paths are stored in `~/.storm/global.json`. + +```bash +# Register projects +storm global add /path/to/project-a +storm global add . + +# See all registered projects and their issue counts +storm global list + +# Run storm across all projects sequentially +storm global run + +# Run in parallel +storm global run --parallel + +# Preview without executing +storm global run --dry-run + +# Check status across all projects +storm global status + +# Unregister a project +storm global remove /path/to/project-a +``` + +Projects must have a valid `.storm/storm.json` to be registered. Invalid or missing projects are skipped with a warning during `global run` and `global status`. + ## Updating storm-agent If you installed via the quick install script, update to the latest version with: diff --git a/index.ts b/index.ts index a6b4503..b6dbe5c 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,13 @@ import { statusCommand } from "./src/commands/status.js"; import { generateCommand } from "./src/commands/generate.js"; import { updateCommand } from "./src/commands/update.js"; import { continueCommand } from "./src/commands/continue.js"; +import { + globalAddCommand, + globalRemoveCommand, + globalListCommand, + globalRunCommand, + globalStatusCommand, +} from "./src/commands/global.js"; const program = new Command(); @@ -75,4 +82,45 @@ program await updateCommand(); }); +const globalCmd = program + .command("global") + .description("Manage and run storm across multiple projects"); + +globalCmd + .command("add ") + .description("Register a project path for global operations") + .action(async (path: string) => { + await globalAddCommand(path); + }); + +globalCmd + .command("remove ") + .description("Unregister a project path") + .action(async (path: string) => { + await globalRemoveCommand(path); + }); + +globalCmd + .command("list") + .description("Show registered projects with issue counts") + .action(async () => { + await globalListCommand(); + }); + +globalCmd + .command("run") + .description("Run storm across all registered projects") + .option("--dry-run", "Preview issues without executing") + .option("--parallel", "Run projects concurrently instead of sequentially") + .action(async (options) => { + await globalRunCommand({ dryRun: options.dryRun, parallel: options.parallel }); + }); + +globalCmd + .command("status") + .description("Show storm branches and PRs across all projects") + .action(async () => { + await globalStatusCommand(); + }); + program.parse(); diff --git a/src/__tests__/global-commands.test.ts b/src/__tests__/global-commands.test.ts new file mode 100644 index 0000000..84e8942 --- /dev/null +++ b/src/__tests__/global-commands.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { join, resolve } from "path"; +import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { + loadGlobalConfig, + saveGlobalConfig, + addProject, + removeProject, + GLOBAL_CONFIG_DIR, + GLOBAL_CONFIG_FILE, +} from "../core/global-config.js"; + +function createTempDir(): string { + const dir = join(tmpdir(), `storm-global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createStormProject(projectPath: string): void { + const stormDir = join(projectPath, ".storm"); + mkdirSync(stormDir, { recursive: true }); + writeFileSync( + join(stormDir, "storm.json"), + JSON.stringify({ + github: { repo: "owner/repo", label: "storm", baseBranch: "main" }, + }) + ); +} + +describe("global-config module exports", () => { + it("exports correct global config path constants", () => { + expect(GLOBAL_CONFIG_DIR).toContain(".storm"); + expect(GLOBAL_CONFIG_FILE).toContain("global.json"); + }); +}); + +describe("addProject", () => { + // We need to back up and restore the real global config + let originalContent: string | null = null; + + beforeEach(() => { + try { + originalContent = readFileSync(GLOBAL_CONFIG_FILE, "utf-8"); + } catch { + originalContent = null; + } + // Start with clean config + if (existsSync(GLOBAL_CONFIG_FILE)) { + writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify({ projects: [] })); + } + }); + + afterEach(() => { + if (originalContent !== null) { + writeFileSync(GLOBAL_CONFIG_FILE, originalContent); + } else if (existsSync(GLOBAL_CONFIG_FILE)) { + rmSync(GLOBAL_CONFIG_FILE); + } + }); + + it("adds a valid project", async () => { + const projectPath = createTempDir(); + createStormProject(projectPath); + + const result = await addProject(projectPath); + expect(result.added).toBe(true); + expect(result.resolved).toBe(resolve(projectPath)); + + const config = await loadGlobalConfig(); + expect(config.projects.some((p) => p.path === resolve(projectPath))).toBe(true); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("rejects project without storm config", async () => { + const projectPath = createTempDir(); + // No .storm/storm.json created + + const result = await addProject(projectPath); + expect(result.added).toBe(false); + expect(result.error).toContain("No .storm/storm.json found"); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("rejects duplicate project", async () => { + const projectPath = createTempDir(); + createStormProject(projectPath); + + const first = await addProject(projectPath); + expect(first.added).toBe(true); + + const second = await addProject(projectPath); + expect(second.added).toBe(false); + expect(second.error).toContain("already registered"); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("resolves relative path to absolute", async () => { + // Create a project in a known temp location + const projectPath = createTempDir(); + createStormProject(projectPath); + + const result = await addProject(projectPath); + expect(result.resolved).toBe(resolve(projectPath)); + // The resolved path should be absolute + expect(result.resolved.startsWith("/")).toBe(true); + + rmSync(projectPath, { recursive: true, force: true }); + }); +}); + +describe("removeProject", () => { + let originalContent: string | null = null; + + beforeEach(() => { + try { + originalContent = readFileSync(GLOBAL_CONFIG_FILE, "utf-8"); + } catch { + originalContent = null; + } + }); + + afterEach(() => { + if (originalContent !== null) { + writeFileSync(GLOBAL_CONFIG_FILE, originalContent); + } else if (existsSync(GLOBAL_CONFIG_FILE)) { + rmSync(GLOBAL_CONFIG_FILE); + } + }); + + it("removes an existing project", async () => { + const projectPath = createTempDir(); + createStormProject(projectPath); + + // Add first + await addProject(projectPath); + const resolved = resolve(projectPath); + + // Verify it's there + let config = await loadGlobalConfig(); + expect(config.projects.some((p) => p.path === resolved)).toBe(true); + + // Remove + const result = await removeProject(projectPath); + expect(result.removed).toBe(true); + + // Verify it's gone + config = await loadGlobalConfig(); + expect(config.projects.some((p) => p.path === resolved)).toBe(false); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("returns false for non-registered project", async () => { + await saveGlobalConfig({ projects: [] }); + + const result = await removeProject("/nonexistent/path"); + expect(result.removed).toBe(false); + }); + + it("only removes the specified project", async () => { + const projectA = createTempDir(); + const projectB = createTempDir(); + createStormProject(projectA); + createStormProject(projectB); + + await addProject(projectA); + await addProject(projectB); + + await removeProject(projectA); + + const config = await loadGlobalConfig(); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].path).toBe(resolve(projectB)); + + rmSync(projectA, { recursive: true, force: true }); + rmSync(projectB, { recursive: true, force: true }); + }); +}); diff --git a/src/__tests__/global-config.test.ts b/src/__tests__/global-config.test.ts new file mode 100644 index 0000000..d4a65d7 --- /dev/null +++ b/src/__tests__/global-config.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "path"; +import { mkdirSync, rmSync, writeFileSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import type { GlobalConfig } from "../core/types.js"; + +// We test the core logic by creating a temp directory structure +// and calling the functions with controlled paths. + +function createTempDir(): string { + const dir = join(tmpdir(), `storm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createStormProject(projectPath: string): void { + const stormDir = join(projectPath, ".storm"); + mkdirSync(stormDir, { recursive: true }); + writeFileSync( + join(stormDir, "storm.json"), + JSON.stringify({ + github: { repo: "owner/repo", label: "storm", baseBranch: "main" }, + agent: { command: "claude", args: [], model: "sonnet" }, + defaults: { maxIterations: 10, delay: 2, stopOnError: false, parallel: false }, + }) + ); +} + +describe("global-config", () => { + let tempDir: string; + let configDir: string; + let configFile: string; + + // Instead of importing the module directly (which uses hardcoded homedir), + // we test the logic by reimplementing the core functions with a configurable path. + // This validates the serialization/deserialization logic. + + async function loadGlobalConfig(): Promise { + try { + const raw = await Bun.file(configFile).json(); + return { projects: Array.isArray(raw.projects) ? raw.projects : [] }; + } catch { + return { projects: [] }; + } + } + + async function saveGlobalConfig(config: GlobalConfig): Promise { + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + await Bun.write(configFile, JSON.stringify(config, null, 2) + "\n"); + } + + beforeEach(() => { + tempDir = createTempDir(); + configDir = join(tempDir, ".storm"); + configFile = join(configDir, "global.json"); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("loadGlobalConfig", () => { + it("returns empty projects array when config file does not exist", async () => { + const config = await loadGlobalConfig(); + expect(config.projects).toEqual([]); + }); + + it("loads config from existing file", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + configFile, + JSON.stringify({ projects: [{ path: "/foo/bar" }] }) + ); + + const config = await loadGlobalConfig(); + expect(config.projects).toEqual([{ path: "/foo/bar" }]); + }); + + it("handles malformed JSON gracefully", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync(configFile, "not valid json{{{"); + + const config = await loadGlobalConfig(); + expect(config.projects).toEqual([]); + }); + + it("handles missing projects field", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync(configFile, JSON.stringify({ other: "data" })); + + const config = await loadGlobalConfig(); + expect(config.projects).toEqual([]); + }); + + it("handles non-array projects field", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync(configFile, JSON.stringify({ projects: "not-array" })); + + const config = await loadGlobalConfig(); + expect(config.projects).toEqual([]); + }); + }); + + describe("saveGlobalConfig", () => { + it("creates config directory if it does not exist", async () => { + expect(existsSync(configDir)).toBe(false); + + await saveGlobalConfig({ projects: [{ path: "/a" }] }); + + expect(existsSync(configDir)).toBe(true); + const saved = await Bun.file(configFile).json(); + expect(saved.projects).toEqual([{ path: "/a" }]); + }); + + it("overwrites existing config", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync(configFile, JSON.stringify({ projects: [{ path: "/old" }] })); + + await saveGlobalConfig({ projects: [{ path: "/new" }] }); + + const saved = await Bun.file(configFile).json(); + expect(saved.projects).toEqual([{ path: "/new" }]); + }); + + it("writes formatted JSON with trailing newline", async () => { + await saveGlobalConfig({ projects: [] }); + + const content = await Bun.file(configFile).text(); + expect(content).toBe(JSON.stringify({ projects: [] }, null, 2) + "\n"); + }); + + it("preserves multiple projects", async () => { + const projects = [ + { path: "/a" }, + { path: "/b" }, + { path: "/c" }, + ]; + await saveGlobalConfig({ projects }); + + const saved = await Bun.file(configFile).json(); + expect(saved.projects).toEqual(projects); + }); + }); + + describe("addProject logic", () => { + it("adds a new project to empty config", async () => { + const projectPath = createTempDir(); + createStormProject(projectPath); + + // Simulate addProject logic + const stormConfig = join(projectPath, ".storm", "storm.json"); + expect(existsSync(stormConfig)).toBe(true); + + const config = await loadGlobalConfig(); + config.projects.push({ path: projectPath }); + await saveGlobalConfig(config); + + const loaded = await loadGlobalConfig(); + expect(loaded.projects).toHaveLength(1); + expect(loaded.projects[0].path).toBe(projectPath); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("rejects project without .storm/storm.json", () => { + const projectPath = createTempDir(); + const stormConfig = join(projectPath, ".storm", "storm.json"); + expect(existsSync(stormConfig)).toBe(false); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("detects duplicate projects", async () => { + const projectPath = createTempDir(); + createStormProject(projectPath); + + const config: GlobalConfig = { projects: [{ path: projectPath }] }; + await saveGlobalConfig(config); + + const loaded = await loadGlobalConfig(); + const isDuplicate = loaded.projects.some((p) => p.path === projectPath); + expect(isDuplicate).toBe(true); + + rmSync(projectPath, { recursive: true, force: true }); + }); + + it("adds multiple distinct projects", async () => { + const projectA = createTempDir(); + const projectB = createTempDir(); + createStormProject(projectA); + createStormProject(projectB); + + await saveGlobalConfig({ projects: [{ path: projectA }] }); + + const config = await loadGlobalConfig(); + const isDuplicate = config.projects.some((p) => p.path === projectB); + expect(isDuplicate).toBe(false); + + config.projects.push({ path: projectB }); + await saveGlobalConfig(config); + + const loaded = await loadGlobalConfig(); + expect(loaded.projects).toHaveLength(2); + + rmSync(projectA, { recursive: true, force: true }); + rmSync(projectB, { recursive: true, force: true }); + }); + }); + + describe("removeProject logic", () => { + it("removes an existing project", async () => { + await saveGlobalConfig({ + projects: [{ path: "/a" }, { path: "/b" }, { path: "/c" }], + }); + + const config = await loadGlobalConfig(); + config.projects = config.projects.filter((p) => p.path !== "/b"); + await saveGlobalConfig(config); + + const loaded = await loadGlobalConfig(); + expect(loaded.projects).toHaveLength(2); + expect(loaded.projects.map((p) => p.path)).toEqual(["/a", "/c"]); + }); + + it("does nothing when project is not registered", async () => { + await saveGlobalConfig({ projects: [{ path: "/a" }] }); + + const config = await loadGlobalConfig(); + const before = config.projects.length; + config.projects = config.projects.filter((p) => p.path !== "/nonexistent"); + + expect(config.projects.length).toBe(before); + }); + + it("handles removing from empty config", async () => { + await saveGlobalConfig({ projects: [] }); + + const config = await loadGlobalConfig(); + config.projects = config.projects.filter((p) => p.path !== "/a"); + + expect(config.projects).toHaveLength(0); + }); + + it("removes the last project leaving empty array", async () => { + await saveGlobalConfig({ projects: [{ path: "/only" }] }); + + const config = await loadGlobalConfig(); + config.projects = config.projects.filter((p) => p.path !== "/only"); + await saveGlobalConfig(config); + + const loaded = await loadGlobalConfig(); + expect(loaded.projects).toEqual([]); + }); + }); + + describe("round-trip serialization", () => { + it("preserves project data through save and load cycle", async () => { + const original: GlobalConfig = { + projects: [ + { path: "/Users/me/code/project-a" }, + { path: "/Users/me/code/project-b" }, + { path: "/opt/work/project-c" }, + ], + }; + + await saveGlobalConfig(original); + const loaded = await loadGlobalConfig(); + + expect(loaded).toEqual(original); + }); + + it("handles paths with special characters", async () => { + const original: GlobalConfig = { + projects: [ + { path: "/Users/me/my project" }, + { path: "/Users/me/project (copy)" }, + ], + }; + + await saveGlobalConfig(original); + const loaded = await loadGlobalConfig(); + + expect(loaded).toEqual(original); + }); + }); +}); diff --git a/src/commands/global.ts b/src/commands/global.ts new file mode 100644 index 0000000..21738b9 --- /dev/null +++ b/src/commands/global.ts @@ -0,0 +1,157 @@ +import { existsSync } from "fs"; +import { join, basename } from "path"; +import pc from "picocolors"; +import { + loadGlobalConfig, + addProject, + removeProject, +} from "../core/global-config.js"; +import { loadConfig, validateConfig } from "../core/config.js"; +import { fetchLabeledIssues } from "../core/github.js"; +import { log } from "../core/output.js"; +import { CONFIG_DIR } from "../core/constants.js"; +import { runCommand } from "./run.js"; +import { statusCommand } from "./status.js"; + +export async function globalAddCommand(path: string) { + const result = await addProject(path); + if (result.added) { + log.success(`Registered project: ${result.resolved}`); + } else { + log.error(result.error ?? `Failed to add: ${result.resolved}`); + process.exit(1); + } +} + +export async function globalRemoveCommand(path: string) { + const result = await removeProject(path); + if (result.removed) { + log.success(`Unregistered project: ${result.resolved}`); + } else { + log.warn(`Project not found in global config: ${result.resolved}`); + } +} + +export async function globalListCommand() { + const globalConfig = await loadGlobalConfig(); + + if (globalConfig.projects.length === 0) { + log.info("No projects registered. Use `storm global add ` to add one."); + return; + } + + console.log(""); + for (const project of globalConfig.projects) { + const name = basename(project.path); + const configExists = existsSync(join(project.path, CONFIG_DIR)); + + if (!configExists) { + console.log(` ${pc.bold(name)} ${pc.dim(project.path)} ${pc.red("(missing .storm/)")}`); + continue; + } + + try { + const config = await loadConfig(project.path); + const errors = validateConfig(config); + if (errors.length > 0) { + console.log(` ${pc.bold(name)} ${pc.dim(project.path)} ${pc.yellow("(invalid config)")}`); + continue; + } + + const issues = await fetchLabeledIssues(config.github.repo, config.github.label); + console.log( + ` ${pc.bold(name)} ${pc.dim(project.path)} ${pc.cyan(`${issues.length} issue(s)`)}` + ); + } catch (err) { + console.log(` ${pc.bold(name)} ${pc.dim(project.path)} ${pc.red("(error loading)")}`); + } + } + console.log(""); +} + +export async function globalRunCommand(options: { dryRun?: boolean; parallel?: boolean }) { + const globalConfig = await loadGlobalConfig(); + + if (globalConfig.projects.length === 0) { + log.info("No projects registered. Use `storm global add ` to add one."); + return; + } + + const projects = globalConfig.projects.filter((p) => { + if (!existsSync(join(p.path, CONFIG_DIR))) { + log.warn(`Skipping ${p.path} — missing ${CONFIG_DIR}/`); + return false; + } + return true; + }); + + if (projects.length === 0) { + log.info("No valid projects to run."); + return; + } + + if (options.parallel) { + log.info(`Running across ${projects.length} project(s) in parallel...`); + const results = await Promise.allSettled( + projects.map(async (project) => { + const name = basename(project.path); + try { + console.log(""); + log.step(`[${name}] Starting...`); + await runCommand(project.path, { dryRun: options.dryRun }); + log.success(`[${name}] Completed`); + } catch (err) { + log.error(`[${name}] Failed: ${err}`); + } + }) + ); + } else { + log.info(`Running across ${projects.length} project(s) sequentially...`); + for (const project of projects) { + const name = basename(project.path); + console.log(""); + log.step(`${"=".repeat(40)}`); + log.step(`Project: ${pc.bold(name)} (${project.path})`); + log.step(`${"=".repeat(40)}`); + + try { + await runCommand(project.path, { dryRun: options.dryRun }); + } catch (err) { + log.error(`[${name}] Failed: ${err}`); + log.warn("Continuing to next project..."); + } + } + } + + console.log(""); + log.success("Global run complete."); +} + +export async function globalStatusCommand() { + const globalConfig = await loadGlobalConfig(); + + if (globalConfig.projects.length === 0) { + log.info("No projects registered. Use `storm global add ` to add one."); + return; + } + + for (const project of globalConfig.projects) { + const name = basename(project.path); + + if (!existsSync(join(project.path, CONFIG_DIR))) { + log.warn(`Skipping ${name} — missing ${CONFIG_DIR}/`); + continue; + } + + console.log(""); + log.step(`${"=".repeat(40)}`); + log.step(`Project: ${pc.bold(name)} (${project.path})`); + log.step(`${"=".repeat(40)}`); + + try { + await statusCommand(project.path); + } catch (err) { + log.error(`[${name}] Failed: ${err}`); + } + } +} diff --git a/src/core/global-config.ts b/src/core/global-config.ts new file mode 100644 index 0000000..1e3b0fb --- /dev/null +++ b/src/core/global-config.ts @@ -0,0 +1,59 @@ +import { join, resolve } from "path"; +import { homedir } from "os"; +import { existsSync, mkdirSync } from "fs"; +import type { GlobalConfig } from "./types.js"; +import { CONFIG_DIR, CONFIG_FILE } from "./constants.js"; + +export const GLOBAL_CONFIG_DIR = join(homedir(), ".storm"); +export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, "global.json"); + +const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { projects: [] }; + +export async function loadGlobalConfig(): Promise { + try { + const raw = await Bun.file(GLOBAL_CONFIG_FILE).json(); + return { projects: Array.isArray(raw.projects) ? raw.projects : [] }; + } catch { + return { ...DEFAULT_GLOBAL_CONFIG, projects: [] }; + } +} + +export async function saveGlobalConfig(config: GlobalConfig): Promise { + if (!existsSync(GLOBAL_CONFIG_DIR)) { + mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); + } + await Bun.write(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n"); +} + +export async function addProject(inputPath: string): Promise<{ added: boolean; resolved: string; error?: string }> { + const resolved = resolve(inputPath); + const stormConfig = join(resolved, CONFIG_DIR, CONFIG_FILE); + + if (!existsSync(stormConfig)) { + return { added: false, resolved, error: `No ${CONFIG_DIR}/${CONFIG_FILE} found at ${resolved}` }; + } + + const config = await loadGlobalConfig(); + const exists = config.projects.some((p) => p.path === resolved); + if (exists) { + return { added: false, resolved, error: `Project already registered: ${resolved}` }; + } + + config.projects.push({ path: resolved }); + await saveGlobalConfig(config); + return { added: true, resolved }; +} + +export async function removeProject(inputPath: string): Promise<{ removed: boolean; resolved: string }> { + const resolved = resolve(inputPath); + const config = await loadGlobalConfig(); + const before = config.projects.length; + config.projects = config.projects.filter((p) => p.path !== resolved); + + if (config.projects.length === before) { + return { removed: false, resolved }; + } + + await saveGlobalConfig(config); + return { removed: true, resolved }; +} diff --git a/src/core/types.ts b/src/core/types.ts index fd2552a..9b86761 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -123,3 +123,11 @@ export interface PRReviewComment { line: number | null; diffHunk: string; } + +export interface GlobalProject { + path: string; +} + +export interface GlobalConfig { + projects: GlobalProject[]; +}