* 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
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
import { z } from "zod";
|
|
import { join } from "node:path";
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
|
|
const REGISTRY_FILENAME = "registry.json";
|
|
|
|
/** Install destinations for each component type (relative to project root). */
|
|
const INSTALL_DIRS = {
|
|
agent: ".opencode/agent/",
|
|
context: ".opencode/context/",
|
|
skill: ".opencode/skills/",
|
|
} as const;
|
|
|
|
/** Source directories inside the npm bundle for each component type. */
|
|
const BUNDLE_DIRS = {
|
|
agent: ".opencode/agent/",
|
|
context: ".opencode/context/",
|
|
skill: ".opencode/skills/",
|
|
} as const;
|
|
|
|
// ── Schemas ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The component types that the CLI can install via `oac add`.
|
|
* Matches the user-facing ref prefix: `agent:X`, `context:X`, `skill:X`.
|
|
*/
|
|
export const ComponentTypeSchema = z.enum(["agent", "context", "skill"]);
|
|
|
|
/**
|
|
* A single installable component entry from registry.json.
|
|
* The registry also contains subagents, commands, tools, plugins — those are
|
|
* not user-installable via `oac add` and are excluded from RegistryComponent.
|
|
*/
|
|
export const RegistryComponentSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
type: ComponentTypeSchema,
|
|
path: z.string(),
|
|
description: z.string(),
|
|
tags: z.array(z.string()).default([]),
|
|
dependencies: z.array(z.string()).default([]),
|
|
category: z.string().default("standard"),
|
|
/** Skills may list multiple files to install. */
|
|
files: z.array(z.string()).optional(),
|
|
});
|
|
|
|
/**
|
|
* Loose schema for non-installable component categories (subagents, commands,
|
|
* tools, plugins). We only need to parse them without strict validation.
|
|
*/
|
|
const AnyComponentSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
type: z.string(),
|
|
path: z.string(),
|
|
description: z.string(),
|
|
}).passthrough();
|
|
|
|
export const RegistrySchema = z.object({
|
|
version: z.string(),
|
|
schema_version: z.string().optional(),
|
|
repository: z.string().optional(),
|
|
categories: z.record(z.string(), z.string()).optional(),
|
|
components: z.object({
|
|
agents: z.array(RegistryComponentSchema).default([]),
|
|
skills: z.array(RegistryComponentSchema).default([]),
|
|
contexts: z.array(RegistryComponentSchema).default([]),
|
|
// Non-installable sections — parsed loosely so schema changes don't break us
|
|
subagents: z.array(AnyComponentSchema).default([]),
|
|
commands: z.array(AnyComponentSchema).default([]),
|
|
tools: z.array(AnyComponentSchema).default([]),
|
|
plugins: z.array(AnyComponentSchema).default([]),
|
|
}),
|
|
});
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
|
|
export type ComponentType = z.infer<typeof ComponentTypeSchema>;
|
|
export type RegistryComponent = z.infer<typeof RegistryComponentSchema>;
|
|
export type Registry = z.infer<typeof RegistrySchema>;
|
|
|
|
// ── Path helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/** Returns the absolute path to registry.json given the package root. */
|
|
export const getRegistryPath = (packageRoot: string): string =>
|
|
join(packageRoot, REGISTRY_FILENAME);
|
|
|
|
// ── Pure query helpers ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns all installable components (agents + skills + contexts) from the
|
|
* registry, optionally filtered to a single type.
|
|
* Pure — no side effects.
|
|
*/
|
|
export const listComponents = (
|
|
registry: Registry,
|
|
type?: ComponentType,
|
|
): RegistryComponent[] => {
|
|
const all: RegistryComponent[] = [
|
|
...registry.components.agents,
|
|
...registry.components.skills,
|
|
...registry.components.contexts,
|
|
];
|
|
return type === undefined ? all : all.filter((c) => c.type === type);
|
|
};
|
|
|
|
/**
|
|
* Alias matching the acceptance criteria name.
|
|
* Filters installable components by type string.
|
|
* Pure — no side effects.
|
|
*/
|
|
export const listComponentsByType = (
|
|
registry: Registry,
|
|
type: string,
|
|
): RegistryComponent[] => {
|
|
const parsed = ComponentTypeSchema.safeParse(type);
|
|
if (!parsed.success) return [];
|
|
return listComponents(registry, parsed.data);
|
|
};
|
|
|
|
/**
|
|
* Resolves a `type:name` ref (e.g. `"context:react-patterns"`) to a component.
|
|
* Returns `null` — never throws — when the component is not found or the ref
|
|
* format is invalid.
|
|
* Pure — no side effects.
|
|
*/
|
|
export const resolveComponent = (
|
|
registry: Registry,
|
|
ref: string,
|
|
): RegistryComponent | null => {
|
|
const colonIndex = ref.indexOf(":");
|
|
if (colonIndex === -1) return null;
|
|
|
|
const rawType = ref.slice(0, colonIndex);
|
|
const id = ref.slice(colonIndex + 1);
|
|
if (!rawType || !id) return null;
|
|
|
|
const typeResult = ComponentTypeSchema.safeParse(rawType);
|
|
if (!typeResult.success) return null;
|
|
|
|
const candidates = listComponents(registry, typeResult.data);
|
|
return candidates.find((c) => c.id === id) ?? null;
|
|
};
|
|
|
|
/**
|
|
* Returns the directory (relative to project root) where a component should
|
|
* be installed.
|
|
* Pure — no side effects.
|
|
*/
|
|
export const getInstallPath = (component: RegistryComponent): string =>
|
|
INSTALL_DIRS[component.type];
|
|
|
|
/**
|
|
* Returns the directory (relative to the npm bundle / package root) where the
|
|
* component's source files live.
|
|
* Pure — no side effects.
|
|
*/
|
|
export const getBundledSourcePath = (component: RegistryComponent): string =>
|
|
BUNDLE_DIRS[component.type];
|
|
|
|
// ── I/O ────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Reads and validates registry.json from `packageRoot`.
|
|
* Throws with a clear, actionable message when:
|
|
* - the file does not exist
|
|
* - the JSON is malformed
|
|
* - the schema validation fails
|
|
*/
|
|
export const readRegistry = async (packageRoot: string): Promise<Registry> => {
|
|
const registryPath = getRegistryPath(packageRoot);
|
|
|
|
const exists = await Bun.file(registryPath).exists();
|
|
if (!exists) {
|
|
throw new Error(
|
|
`registry.json not found at "${registryPath}".\n` +
|
|
`This file should be bundled with the @nextsystems/oac package.\n` +
|
|
`Try reinstalling: npm install -g @nextsystems/oac`,
|
|
);
|
|
}
|
|
|
|
const raw = await Bun.file(registryPath).json().catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error(
|
|
`Failed to parse registry.json at "${registryPath}": ${msg}\n` +
|
|
`The file may be corrupted. Try reinstalling: npm install -g @nextsystems/oac`,
|
|
);
|
|
}) as unknown;
|
|
|
|
const result = RegistrySchema.safeParse(raw);
|
|
if (!result.success) {
|
|
const issues = result.error.issues
|
|
.map((i) => ` • ${i.path.join(".")}: ${i.message}`)
|
|
.join("\n");
|
|
throw new Error(
|
|
`Invalid registry.json at "${registryPath}":\n${issues}\n` +
|
|
`The registry schema may have changed. Try reinstalling: npm install -g @nextsystems/oac`,
|
|
);
|
|
}
|
|
|
|
return result.data;
|
|
};
|
|
|
|
/**
|
|
* Convenience alias: reads registry.json from `packageRoot`.
|
|
* Identical to `readRegistry` — provided for callers that prefer this name.
|
|
*/
|
|
export const loadRegistry = readRegistry;
|