OpenWriterControle/packages/cli/src/lib/config.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

50 lines
1.8 KiB
TypeScript

import { z } from "zod";
import { mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
export const OacPreferencesSchema = z.object({
yoloMode: z.boolean(),
autoBackup: z.boolean(),
});
export const OacConfigSchema = z.object({
version: z.literal("1"),
preferences: OacPreferencesSchema,
});
export type OacPreferences = z.infer<typeof OacPreferencesSchema>;
export type OacConfig = z.infer<typeof OacConfigSchema>;
export const getConfigPath = (projectRoot: string): string =>
join(projectRoot, ".oac", "config.json");
export const createDefaultConfig = (): OacConfig => ({
version: "1",
preferences: { yoloMode: false, autoBackup: true },
});
// Pure — returns new object, no mutation
export const mergeConfig = (base: OacConfig, overrides: Partial<OacPreferences>): OacConfig =>
({ ...base, preferences: { ...base.preferences, ...overrides } });
export const isYoloMode = (config: OacConfig): boolean =>
config.preferences.yoloMode || process.env["CI"] === "true";
export const isAutoBackup = (config: OacConfig): boolean =>
config.preferences.autoBackup;
export async function readConfig(projectRoot: string): Promise<OacConfig | null> {
const configPath = getConfigPath(projectRoot);
if (!(await Bun.file(configPath).exists())) return null;
const raw = await Bun.file(configPath).json() as unknown;
const result = OacConfigSchema.safeParse(raw);
if (!result.success) {
throw new Error(`Invalid config at "${configPath}": ${result.error.message}`);
}
return result.data;
}
export async function writeConfig(projectRoot: string, config: OacConfig): Promise<void> {
const configPath = getConfigPath(projectRoot);
await mkdir(dirname(configPath), { recursive: true });
await Bun.write(configPath, JSON.stringify(config, null, 2));
}