Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to **basou** are recorded here. The project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0.

## Unreleased

### Added

- `basou portfolio` — a read-only, headless listing of the workspaces
registered in `~/.basou/portfolio.yaml` (each entry's label, path, and
whether it exists / is an initialized planning master), with `--json`. It is
the text/JSON counterpart to the `basou view --portfolio` GUI: an agent
oriented in one project can now discover where a sibling project lives
without opening a browser or reading the config file by hand. It reads only
the config and probes existence + `.basou` presence — it never runs the
`basou view --check` redundancy/footprint safety preflight, which stays its
own surface.

## 0.34.0 — 2026-07-14

### Added
Expand Down
155 changes: 155 additions & 0 deletions packages/cli/src/commands/portfolio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
doRunPortfolioList,
type PortfolioListContext,
type PortfolioListResult,
renderPortfolioList,
runPortfolioList,
} from "./portfolio.js";

let dir: string | undefined;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "basou-portfolio-cmd-"));
});

afterEach(async () => {
vi.restoreAllMocks();
if (dir !== undefined) await rm(dir, { recursive: true, force: true });
dir = undefined;
});

function getDir(): string {
if (dir === undefined) throw new Error("dir not initialized");
return dir;
}

async function writeConfig(body: string): Promise<string> {
const path = join(getDir(), "portfolio.yaml");
await writeFile(path, body);
return path;
}

describe("doRunPortfolioList", () => {
it("resolves each entry's label / path / exists / initialized", async () => {
const a = join(getDir(), "alpha-planning");
const b = join(getDir(), "beta-planning");
const configPath = await writeConfig(
`version: 1\nworkspaces:\n - path: ${a}\n label: alpha\n - path: ${b}\n`,
);
// Injected probes: alpha exists + initialized, beta exists but no .basou.
const ctx: PortfolioListContext = {
configPath,
pathExists: (p) => p === a || p === b,
isInitialized: (p) => p === a,
};
const result = await doRunPortfolioList({}, ctx);
expect(result).toEqual<PortfolioListResult>({
configPath,
workspaces: [
{ label: "alpha", path: a, exists: true, initialized: true },
{ label: null, path: b, exists: true, initialized: false },
],
});
});

it("reports a stale entry (path gone) as exists:false, initialized:false — never probing .basou", async () => {
const gone = join(getDir(), "moved-away");
const configPath = await writeConfig(`workspaces:\n - path: ${gone}\n label: stale\n`);
const isInitialized = vi.fn(() => true); // would lie; must not be consulted
const result = await doRunPortfolioList(
{},
{ configPath, pathExists: () => false, isInitialized },
);
expect(result.workspaces).toEqual([
{ label: "stale", path: gone, exists: false, initialized: false },
]);
expect(isInitialized).not.toHaveBeenCalled();
});

it("prints JSON when --json is set", async () => {
const a = join(getDir(), "alpha-planning");
const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
await doRunPortfolioList(
{ json: true },
{ configPath, pathExists: () => true, isInitialized: () => true },
);
expect(log).toHaveBeenCalledTimes(1);
const raw = log.mock.calls[0]?.[0] as string;
const printed = JSON.parse(raw) as PortfolioListResult;
expect(printed).toEqual({
configPath,
workspaces: [{ label: "alpha", path: a, exists: true, initialized: true }],
});
// Contract: single-line minified JSON (no pretty-print), so line-oriented
// consumers (e.g. `basou portfolio --json | jq -c`) stay stable.
expect(raw).not.toContain("\n");
expect(raw).toBe(JSON.stringify(printed));
});

it("prints the human listing when --json is not set", async () => {
const a = join(getDir(), "alpha-planning");
const configPath = await writeConfig(`workspaces:\n - path: ${a}\n label: alpha\n`);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
await doRunPortfolioList({}, { configPath, pathExists: () => true, isInitialized: () => true });
const out = log.mock.calls[0]?.[0] as string;
expect(out).toContain("# Portfolio (workspaces you orient across)");
expect(out).toContain("1 workspace registered");
expect(out).toContain(a);
expect(out).toContain("basou view --portfolio");
});

it("propagates a helpful error when the config is missing", async () => {
await expect(
doRunPortfolioList({}, { configPath: join(getDir(), "nope.yaml") }),
).rejects.toThrow(/No portfolio config/);
});

it("runPortfolioList (wired entry) renders the error and sets exitCode 1 on a missing config", async () => {
const prevExit = process.exitCode;
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
await runPortfolioList({}, { configPath: join(getDir(), "nope.yaml") });
expect(process.exitCode).toBe(1);
expect(err).toHaveBeenCalledWith(expect.stringMatching(/No portfolio config/));
} finally {
process.exitCode = prevExit;
}
});
});

describe("renderPortfolioList", () => {
it("marks a healthy master as initialized", () => {
const out = renderPortfolioList({
configPath: "/x",
workspaces: [{ label: "acme", path: "/p/acme-planning", exists: true, initialized: true }],
});
expect(out).toContain("acme");
expect(out).toContain("/p/acme-planning");
expect(out).toContain("✓ initialized");
});

it("marks a missing path and an uninitialized path distinctly", () => {
const out = renderPortfolioList({
configPath: "/x",
workspaces: [
{ label: "gone", path: "/p/gone", exists: false, initialized: false },
{ label: "bare", path: "/p/bare", exists: true, initialized: false },
],
});
expect(out).toContain("⚠ path not found");
expect(out).toContain("⚠ no .basou (not a planning master?)");
});

it("falls back to (no label) when an entry has none", () => {
const out = renderPortfolioList({
configPath: "/x",
workspaces: [{ label: null, path: "/p/x", exists: true, initialized: true }],
});
expect(out).toContain("(no label)");
});
});
170 changes: 170 additions & 0 deletions packages/cli/src/commands/portfolio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import type { Command } from "commander";
import { isVerbose, renderCliError } from "../lib/error-render.js";
import { DEFAULT_PORTFOLIO_CONFIG_PATH, loadPortfolioConfig } from "../lib/portfolio-config.js";

export type PortfolioListOptions = {
json?: boolean;
verbose?: boolean;
};

/**
* Injectable seams for {@link doRunPortfolioList} so tests stay hermetic: the
* config path to read, and the two on-disk probes (existence and `.basou`
* ownership). Defaults hit the real filesystem.
*/
export type PortfolioListContext = {
/** Config file to read (default: ~/.basou/portfolio.yaml). */
configPath?: string;
/** Whether the workspace path resolves to something on disk (default: fs.existsSync). */
pathExists?: (path: string) => boolean;
/** Whether the workspace path owns a `.basou/` store (default: fs.existsSync of `<path>/.basou`). */
isInitialized?: (path: string) => boolean;
};

/**
* One resolved portfolio entry as reported by `basou portfolio`. The `--json`
* shape is part of the CLI contract, so this is kept deliberately minimal and
* stable: label + path + two cheap on-disk facts, and nothing from the
* `view --check` safety preflight (that stays its own surface).
*/
export type PortfolioEntry = {
/** The display label from the config, or null when the entry declared none. */
label: string | null;
/** The workspace path, absolute and `~`-expanded (as `loadPortfolioConfig` returns it). */
path: string;
/** Whether `path` resolves to something on disk (a stale entry points nowhere). */
exists: boolean;
/** Whether `path` owns a `.basou/` store — i.e. it is an initialized planning master. */
initialized: boolean;
};

/** Result of {@link doRunPortfolioList}: the config that was read plus its resolved entries. */
export type PortfolioListResult = {
/** The config file that was read. */
configPath: string;
/** Every registered workspace, in config order (deduped by resolved path upstream). */
workspaces: PortfolioEntry[];
};

/**
* Wire `basou portfolio` onto `program`: a read-only, headless listing of the
* workspaces registered in `~/.basou/portfolio.yaml`. It is the text/JSON
* counterpart to the `basou view --portfolio` GUI — an agent oriented in one
* project can discover where a sibling project lives without opening a browser
* (the localhost GUI is unreachable in a non-interactive session). It reads
* nothing but the config, probes only existence + `.basou` presence, and never
* runs the redundancy/footprint safety preflight (that is `basou view --check`).
*/
export function registerPortfolioCommand(program: Command): void {
program
.command("portfolio")
.description(
"List the workspaces you orient across (read-only): every planning master registered in ~/.basou/portfolio.yaml, with its path and whether it exists / is initialized. The headless text/JSON counterpart to the `basou view --portfolio` GUI — for discovering where a sibling project lives without opening a browser",
)
.option("--json", "Output the result as JSON")
.option("-v, --verbose", "Show error causes")
.action(async (opts: PortfolioListOptions) => {
await runPortfolioList(opts);
});
}

/** Whether `path` resolves to a directory on disk (a missing/other-type path → false). */
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}

/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunPortfolioList}. */
export async function runPortfolioList(
options: PortfolioListOptions,
ctx: PortfolioListContext = {},
): Promise<void> {
try {
await doRunPortfolioList(options, ctx);
} catch (error: unknown) {
renderCliError(error, { verbose: isVerbose(options) });
process.exitCode = 1;
}
}

/**
* Load the portfolio config and resolve each entry against disk. A path that
* does not resolve reports `exists:false` (and therefore `initialized:false` —
* a missing path cannot be a master), so a stale entry surfaces instead of
* throwing. A missing/empty/malformed config propagates `loadPortfolioConfig`'s
* user-facing error to {@link runPortfolioList}.
*/
export async function doRunPortfolioList(
options: PortfolioListOptions,
ctx: PortfolioListContext,
): Promise<PortfolioListResult> {
const configPath = ctx.configPath ?? DEFAULT_PORTFOLIO_CONFIG_PATH;
const pathExists = ctx.pathExists ?? ((p: string) => existsSync(p));
// A `.basou` store is a DIRECTORY. Match basou's canonical `hasBasouStore`
// (repo-root.ts) `isDirectory()` check rather than a bare `existsSync`, so a
// stray regular file named `.basou` is not mis-reported as an initialized
// master (which the real resolver would reject).
const isInitialized = ctx.isInitialized ?? ((p: string) => isDirectory(join(p, ".basou")));

const workspaces = await loadPortfolioConfig(configPath);
const result: PortfolioListResult = {
configPath,
workspaces: workspaces.map((w) => {
const exists = pathExists(w.path);
return {
label: w.label ?? null,
path: w.path,
exists,
initialized: exists && isInitialized(w.path),
};
}),
};

if (options.json === true) {
console.log(JSON.stringify(result));
} else {
console.log(renderPortfolioList(result));
}
return result;
}

const LABEL_COLUMN_CAP = 24;

/**
* Render the listing. Leads with the count, then one line per workspace
* (label, path, and a status marker for a stale or uninitialized entry), and
* closes by pointing at the GUI / safety-preflight surfaces so the read-only
* framing is not over-read as the whole portfolio story.
*/
export function renderPortfolioList(result: PortfolioListResult): string {
const lines: string[] = [];
const n = result.workspaces.length;
lines.push("# Portfolio (workspaces you orient across)");
lines.push("");
lines.push(`${n} workspace${n === 1 ? "" : "s"} registered in ~/.basou/portfolio.yaml:`);
lines.push("");

const labelWidth = Math.min(
LABEL_COLUMN_CAP,
Math.max(0, ...result.workspaces.map((w) => (w.label ?? "(no label)").length)),
);
for (const w of result.workspaces) {
const label = (w.label ?? "(no label)").padEnd(labelWidth);
const status = !w.exists
? "⚠ path not found"
: w.initialized
? "✓ initialized"
: "⚠ no .basou (not a planning master?)";
lines.push(`- ${label} ${w.path} ${status}`);
}
lines.push("");
lines.push(
"Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint safety preflight.",
);
return lines.join("\n");
}
1 change: 1 addition & 0 deletions packages/cli/src/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("buildProgram", () => {
"init",
"note",
"orient",
"portfolio",
"project",
"protocol",
"refresh",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerImportCommand } from "./commands/import.js";
import { registerInitCommand } from "./commands/init.js";
import { registerNoteCommand } from "./commands/note.js";
import { registerOrientCommand } from "./commands/orient.js";
import { registerPortfolioCommand } from "./commands/portfolio.js";
import { registerProjectCommand } from "./commands/project.js";
import { registerProtocolCommand } from "./commands/protocol.js";
import { registerRefreshCommand } from "./commands/refresh.js";
Expand Down Expand Up @@ -71,6 +72,7 @@ export function buildProgram(): Command {
registerDecisionsCommand(program);
registerReportCommand(program);
registerOrientCommand(program);
registerPortfolioCommand(program);
registerReviewCommand(program);
registerReviewGapsCommand(program);
registerProjectCommand(program);
Expand Down