OpenWriterControle/packages/cli/src/commands/status.ts
Darren Hinde d2a633d7dc
feat(cli): Bun-first CLI migration with 7 commands and comprehensive test suite (#259)
* feat(cli): migrate packages/cli to Bun-first with full test suite

- Replace fs-extra with Bun.file/Bun.write/node:fs/promises across all 9 source files
- Fix --help fast-path so all 8 subcommands are visible
- Replace tsup/tsx/vitest with bun build/bun run/bun:test
- Replace __dirname with import.meta.dir; use JSON import assertion for version
- Fix hardcoded OAC_VERSION in add.ts; add hashesMatch import to status.ts
- Replace computeFileHash with Bun.file().bytes() in sha256.ts
- Rename checkNodeVersion → checkBunVersion; parallelise doctor checks
- Fix TypeScript void-inference errors in update.ts, list.ts, status.ts
  by replacing .catch() with let/try-catch where result is used downstream
- Add ManifestError named error class; remove unsafe type casts
- Add 43 bun:test unit tests (sha256, manifest, installer, version) — 0 failures

* fix(cli): resolve critical bugs and standards violations found in review

Critical bug fixes:
- ide-detect.ts: replace Bun.file(dir).exists() with stat().isDirectory()
  for all directory checks — Bun.file().exists() always returns false for
  directories, breaking Cursor/Windsurf/OpenCode/Claude detection entirely
- bundled.ts: add registry.json exclusion anchor to findPackageRoot() to
  prevent monorepo root from matching before the CLI package root

Standards cleanup (§4.1, §5.1, §6.1, §15.3, §21.1):
- status.ts: parallelise findModifiedFiles + detectIdes with Promise.all
- apply.ts: fix duplicate warn/limit messages; remove else after return in
  reportWarnings; remove redundant mkdir before Bun.write
- version.ts: remove unnecessary (pkgJson as {version?:string}) cast
- manifest.ts: remove redundant mkdir before Bun.write; remove as unknown cast
- installer.ts: remove redundant mkdir calls before Bun.write throughout
- add.ts: add comment explaining why node:fs/promises rm is used

Tests (43 → 142, +99 new tests across 4 new files):
- ide-detect.test.ts: 26 tests covering all 4 IDEs, both claude indicators,
  detectIdes parallel, isIdePresent — directly validates the directory fix
- bundled.test.ts: 27 tests for classifyBundledFile, findPackageRoot,
  listBundledFiles, getBundledFilePath, bundledFileExists
- config.test.ts: 25 tests for readConfig/writeConfig round-trips,
  createDefaultConfig, mergeConfig, isYoloMode, isAutoBackup
- installer-update.test.ts: 14 tests covering all 5 updateFiles decision
  branches (install/update/skip/yolo/dry-run) plus isProjectRoot
- sha256.test.ts: +4 tests for empty file, large file, binary content

* fix(cli): fix P0 bugs — global flags, .git detection, package root resolution

- Remove duplicate --dry-run/--yolo/--verbose from parent program; Commander.js
  global option stealing caused all safety flags to be silently dropped in every
  subcommand action callback
- Fix isProjectRoot() to use stat() for .git detection; Bun.file().exists()
  returns false for directories, breaking oac init in standard git repos
- Add OAC_PACKAGE_ROOT env var override to getPackageRoot() for dev/monorepo mode;
  registry.json heuristic excluded the repo root causing oac init/add/update to
  throw when run from source
- Fix build script: remove --banner flag that caused double shebang in dist/index.js
- Rewrite bin/oac.js to invoke bun instead of node (dist is bun-only target)
- Refactor installer.ts let accumulators to const using Promise.all + reduce
- Add MVP planning docs (00-MVP-PLAN.md, master synthesis, project breakdown)

* fix(install.sh): handle both singular and plural component type formats

The get_registry_key() function was always adding 's' to the type,
causing 'contexts' to become 'contextss' which doesn't exist in registry.json.

Now handles:
- Singular forms: context → contexts, agent → agents, skill → skills
- Plural forms: contexts → contexts (unchanged), agents → agents
- Config stays singular
- Fallback for any type ending in 's'

Fixes #257
2026-03-24 21:54:03 +00:00

230 lines
8.4 KiB
TypeScript

import { type Command } from 'commander';
import { join } from 'node:path';
import { readCliVersion } from '../lib/version.js';
import { readManifest } from '../lib/manifest.js';
import { computeFileHash, hashesMatch } from '../lib/sha256.js';
import { detectIdes } from '../lib/ide-detect.js';
import { log, info, warn, bold, dim, success } from '../ui/logger.js';
import type { ManifestFile, ManifestFileType } from '../lib/manifest.js';
import type { DetectedIde } from '../lib/ide-detect.js';
// ── Types ─────────────────────────────────────────────────────────────────────
export type StatusOptions = {
verbose: boolean;
};
type ComponentCounts = {
agents: number;
context: number;
skills: number;
other: number;
total: number;
};
type ModifiedResult = {
count: number;
paths: string[];
};
// ── Pure counters ─────────────────────────────────────────────────────────────
/** Counts manifest entries by their file type. Pure function. */
const countComponents = (manifest: ManifestFile): ComponentCounts => {
const entries = Object.values(manifest.files);
const byType = (t: ManifestFileType): number =>
entries.filter((e) => e.type === t).length;
const agents = byType('agent');
const context = byType('context');
const skills = byType('skill');
const other = entries.length - agents - context - skills;
return { agents, context, skills, other, total: entries.length };
};
// ── SHA256 diff check ─────────────────────────────────────────────────────────
/**
* Compares each manifest file's stored hash against the file on disk.
* Returns the count and paths of files that have been locally modified.
* Wraps computeFileHash in try/catch — deleted files are treated as modified.
*/
const findModifiedFiles = async (
projectRoot: string,
manifest: ManifestFile,
): Promise<ModifiedResult> => {
const entries = Object.entries(manifest.files);
const checks = await Promise.all(
entries.map(async ([relPath, entry]) => {
const absPath = join(projectRoot, relPath);
try {
const diskHash = await computeFileHash(absPath);
return !hashesMatch(diskHash, entry.sha256) ? relPath : null;
} catch {
// File deleted or unreadable — counts as modified
return relPath;
}
}),
);
const paths = checks.filter((p): p is string => p !== null);
return { count: paths.length, paths };
};
// ── IDE formatter ─────────────────────────────────────────────────────────────
/** Formats the list of detected IDEs into a display string. Pure function. */
const formatIdeList = (ides: DetectedIde[]): string => {
const detected = ides.filter((i) => i.detected);
const notDetected = ides.filter((i) => !i.detected);
if (detected.length === 0) {
return 'None detected — run `oac apply <ide>` to set up';
}
const detectedNames = detected.map((i) => i.type).join(', ');
if (notDetected.length === 0) return detectedNames;
const notDetectedNames = notDetected.map((i) => i.type).join(', ');
return `${detectedNames} (not detected: ${notDetectedNames})`;
};
// ── Update check ──────────────────────────────────────────────────────────────
/** Returns a human-readable update status line. Pure function. */
const formatUpdateStatus = (manifestVersion: string, cliVersion: string): string =>
manifestVersion === cliVersion
? `Up to date (v${cliVersion})`
: `Available — manifest has v${manifestVersion}, CLI is v${cliVersion} (run 'oac update')`;
// ── Timestamp formatter ───────────────────────────────────────────────────────
/** Formats an ISO timestamp into a readable local date string. Pure function. */
const formatTimestamp = (iso: string): string => {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
};
// ── Display ───────────────────────────────────────────────────────────────────
/** Prints the one-screen status summary. Side-effect only. */
const printStatus = (
cliVersion: string,
projectRoot: string,
manifest: ManifestFile,
counts: ComponentCounts,
modified: ModifiedResult,
ides: DetectedIde[],
): void => {
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
const displayPath = projectRoot.startsWith(homeDir)
? `~${projectRoot.slice(homeDir.length)}`
: projectRoot;
log('');
bold(`OAC v${cliVersion} — ${displayPath}`);
log('');
info(`Agents: ${counts.agents} installed`);
info(`Context: ${counts.context} files`);
info(`Skills: ${counts.skills} installed`);
if (modified.count > 0) {
warn(`Modified: ${modified.count} file${modified.count !== 1 ? 's' : ''} have local changes`);
} else {
success(`Modified: No local changes`);
}
info(`Updates: ${formatUpdateStatus(manifest.oacVersion, cliVersion)}`);
info(`IDEs: ${formatIdeList(ides)}`);
info(`Last updated: ${formatTimestamp(manifest.updatedAt)}`);
log('');
dim(` Run 'oac doctor' for full health check`);
log('');
};
/** Prints verbose details about modified files. Side-effect only. */
const printVerboseModified = (modified: ModifiedResult): void => {
if (modified.count === 0) return;
dim(' Modified files:');
for (const p of modified.paths) {
dim(` • ${p}`);
}
log('');
};
// ── Command handler ───────────────────────────────────────────────────────────
/**
* Implements `oac status`:
* 1. Reads manifest — exits early with helpful message if not initialized
* 2. Counts components by type
* 3. Checks for user-modified files via SHA256 comparison
* 4. Detects IDEs
* 5. Prints one-screen summary
* Always exits 0 (read-only command).
*/
export async function statusCommand(options: StatusOptions): Promise<void> {
const projectRoot = process.cwd();
const cliVersion = readCliVersion();
// Step 1: read manifest — not initialized is a valid state, not an error
let manifest: ManifestFile | null;
try {
manifest = await readManifest(projectRoot);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log(` OAC manifest is invalid: ${msg}`);
log(` Run 'oac init' to reset, or fix .oac/manifest.json manually.`);
process.exit(0);
return; // unreachable — satisfies TypeScript
}
if (manifest === null) {
log('');
log(' OAC not initialized. Run \'oac init\' to get started.');
log('');
process.exit(0);
}
// Step 2: count components
const counts = countComponents(manifest);
// Steps 3 & 4: check modified files and detect IDEs in parallel
const [modified, ides] = await Promise.all([
findModifiedFiles(projectRoot, manifest),
detectIdes(projectRoot),
]);
// Step 5: print summary
printStatus(cliVersion, projectRoot, manifest, counts, modified, ides);
if (options.verbose) {
printVerboseModified(modified);
}
process.exit(0);
}
// ── Commander registration ────────────────────────────────────────────────────
/**
* Registers the `oac status` command on the given Commander program.
* Called by the CLI entry point (index.ts).
*/
export function registerStatusCommand(program: Command): void {
program
.command('status')
.description('Show a one-screen summary of your OAC installation')
.option('--verbose', 'Show details about modified files', false)
.action(async (opts: { verbose?: boolean }) => {
await statusCommand({ verbose: opts.verbose ?? false });
});
}