mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. Code that used to ask "which CLI is this?" reads the entry instead. Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every spawn command as a literal string, captured from the hand-written builders before they were deleted, and `test/location-overlay-commands.test.ts` does the same for all 20 remote and in-container pane commands. Config can never contain shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only in this release. OMP is included as a registry entry rather than a tenth hand-written builder, so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of `buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen and doctor ladders all drop out. Guard rails: - `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id branching reappears outside `stock.ts`, in any of its four shapes (`===`, `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the negated forms, which is how 36 of them survived an earlier pass. Every allowlisted branch carries its reason. - `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities; deriving one from another shipped the `until=stop`-hangs-on-shell bug. - `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config` wire field is separate, bridged only by `legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. - Registry data resolves AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks). A module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. - Six fields are annotated DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/ `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured. A test pins the list so it cannot quietly grow. Three user-visible changes, all deliberate and named: - `probeDockerCliVersion()` derives the in-container binary from the registry rather than assuming it equals the mode name (`antigravity` runs `agy`). - The remote CLI version probe now covers grok and deepseek, which the hardcoded map it replaces omitted while its own comment said the rule was "every mode except shell". - `codeman doctor`'s CLI rows are generated from the entries, so Claude's install hint is the install command rather than a docs URL, five CLIs gain hints they never had, and the row order follows the catalog. Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars (matching the `cliId` pattern) before its failure message quotes the value back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading the hand-editable `clis.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview Loads, merges and re-validates the CLI registry.
|
||||
*
|
||||
* `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the
|
||||
* stock catalog — so a shipped fix to a stock definition actually reaches an existing
|
||||
* install, and the file stays small enough to hand-edit.
|
||||
*
|
||||
* Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge
|
||||
* key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that
|
||||
* fails validation after merge falls back to its pristine stock definition (a fat-fingered
|
||||
* override cannot brick a shipped CLI); a custom entry that fails is dropped with a warning
|
||||
* rather than failing the whole load. Stock entries are always emitted, so `shell` and
|
||||
* `claude` can be disabled but can never go missing — large parts of the app assume at
|
||||
* minimum that a shell fallback exists.
|
||||
*
|
||||
* ⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file. That is a
|
||||
* deliberate property, not a missing feature: there is no settings UI and no write API yet,
|
||||
* so there is nothing to persist, and it means importing the registry — which
|
||||
* `src/web/schemas.ts` does, transitively, just to validate a request — performs no
|
||||
* filesystem writes. A `seededStockIds` ratchet belongs with the write API that needs it.
|
||||
*
|
||||
* @module config/cli-registry/registry
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
|
||||
import { dataPath } from '../instance.js';
|
||||
import type { CliEntry, CliId, CliRegistryFile } from './types.js';
|
||||
import { CliEntrySchema } from './schema.js';
|
||||
import { STOCK_CLIS } from './stock.js';
|
||||
|
||||
/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */
|
||||
export function asCliId(raw: string): CliId {
|
||||
if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) {
|
||||
throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`);
|
||||
}
|
||||
return raw as CliId;
|
||||
}
|
||||
|
||||
function filePath(): string {
|
||||
return dataPath('clis.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must never be merged out of a hand-editable JSON file.
|
||||
*
|
||||
* `JSON.parse` produces `__proto__` as an ORDINARY own property, but `result[key] = …` on a
|
||||
* plain object walks the setter chain and would set the merged object's PROTOTYPE instead.
|
||||
* Not exploitable today — every merged entry is spread into `{ ...merged, id, stock }` and
|
||||
* then Zod-parsed before anything reads it, which drops the effect — but "not exploitable
|
||||
* because of what a caller happens to do afterwards" is a property that quietly stops
|
||||
* holding. A `continue` in the loop that reads the file is the cheap end of that trade.
|
||||
*/
|
||||
const UNMERGEABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */
|
||||
function deepMerge<T>(base: T, override: unknown): T {
|
||||
if (override === null || typeof override !== 'object' || Array.isArray(override)) {
|
||||
return (override === undefined ? base : (override as T)) ?? base;
|
||||
}
|
||||
if (base === null || typeof base !== 'object' || Array.isArray(base)) {
|
||||
return override as T;
|
||||
}
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
|
||||
if (UNMERGEABLE_KEYS.has(key)) continue;
|
||||
result[key] = deepMerge((base as Record<string, unknown>)[key], value);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export interface LoadResult {
|
||||
entries: CliEntry[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a group/world-writable registry file — same posture as the ssh-key discipline.
|
||||
* This file selects the binaries Codeman spawns, so a writable one is a way to redirect
|
||||
* every session.
|
||||
*
|
||||
* POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file
|
||||
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
|
||||
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
|
||||
* this check cannot see and does not attempt to.
|
||||
*/
|
||||
function isUnsafePermissions(path: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
try {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
return (mode & 0o077) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null {
|
||||
if (!existsSync(path)) return null;
|
||||
if (isUnsafePermissions(path)) {
|
||||
warnings.push(`${path} is group/world-writable; ignoring it and falling back to stock CLIs.`);
|
||||
return null;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf-8');
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CliRegistryFile;
|
||||
if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') {
|
||||
throw new Error('missing "clis" object');
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// QUARANTINE, never overwrite: the file is hand-editable, so a syntax error is far more
|
||||
// likely to be a half-finished edit than junk. Renaming keeps the user's work.
|
||||
const quarantined = `${path}.invalid-${Date.now()}`;
|
||||
try {
|
||||
renameSync(path, quarantined);
|
||||
warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`);
|
||||
} catch {
|
||||
warnings.push(
|
||||
`${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the stock catalog with a (possibly absent) registry file. PURE — no IO, which is
|
||||
* what lets the load tests drive every merge case directly.
|
||||
*/
|
||||
export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult {
|
||||
const stockById = new Map(stock.map((e) => [e.id as string, e]));
|
||||
const overrides = file?.clis ?? {};
|
||||
const entries: CliEntry[] = [];
|
||||
|
||||
for (const stockEntry of stock) {
|
||||
const id = stockEntry.id as string;
|
||||
const override = overrides[id];
|
||||
const merged = override ? deepMerge(stockEntry, override) : stockEntry;
|
||||
// `stock: true` is forced here rather than read from the merged object, so an override
|
||||
// can never flip a custom entry's provenance or vice versa.
|
||||
const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true });
|
||||
if (parsed.success) {
|
||||
entries.push(parsed.data as CliEntry);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}`
|
||||
);
|
||||
entries.push(stockEntry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, raw] of Object.entries(overrides)) {
|
||||
if (stockById.has(id)) continue; // already merged above
|
||||
// Same forcing in the other direction: a custom entry claiming `stock: true` cannot
|
||||
// shadow or impersonate a shipped one.
|
||||
const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false });
|
||||
if (parsed.success) {
|
||||
entries.push(parsed.data as CliEntry);
|
||||
} else {
|
||||
warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.order - b.order);
|
||||
return { entries, warnings };
|
||||
}
|
||||
|
||||
let cache: LoadResult | null = null;
|
||||
|
||||
/**
|
||||
* Load the effective registry (stock + user overrides). Memoized for the process lifetime;
|
||||
* `reloadCliRegistry()` invalidates.
|
||||
*/
|
||||
export function loadCliRegistry(): LoadResult {
|
||||
if (cache) return cache;
|
||||
const warnings: string[] = [];
|
||||
const existing = readRegistryFile(filePath(), warnings);
|
||||
cache = resolveRegistry(STOCK_CLIS, existing, warnings);
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */
|
||||
export function reloadCliRegistry(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export function listClis(): CliEntry[] {
|
||||
return loadCliRegistry().entries;
|
||||
}
|
||||
|
||||
export function enabledClis(): CliEntry[] {
|
||||
return listClis().filter((e) => e.enabled);
|
||||
}
|
||||
|
||||
export function getCli(id: string): CliEntry | undefined {
|
||||
return listClis().find((e) => (e.id as string) === id);
|
||||
}
|
||||
|
||||
export function cliIds(): string[] {
|
||||
return listClis().map((e) => e.id as string);
|
||||
}
|
||||
|
||||
/** Every enabled entry's id, in registry order. */
|
||||
export function enabledCliIds(): string[] {
|
||||
return enabledClis().map((e) => e.id as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the install command for the current platform, falling back to the linux one (the
|
||||
* common case for a `curl | bash` or `npm install -g` line) and then to whatever is
|
||||
* declared. Display text only — never executed. See CliDiscovery.install.command.
|
||||
*/
|
||||
export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined {
|
||||
const { command } = entry.discovery.install;
|
||||
const platform = process.platform as 'linux' | 'darwin' | 'win32';
|
||||
return command[platform] ?? command.linux ?? Object.values(command)[0];
|
||||
}
|
||||
Reference in New Issue
Block a user