* 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
230 lines
8.4 KiB
TypeScript
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 });
|
|
});
|
|
}
|