Adds docker_compose_restart/build/up, git_pull (guarded by git_status_clean precondition), systemctl_restart (via sudo), caddy_validate, caddy_reload, and caddy_write_config (atomic stdin→Caddyfile.new→Caddyfile write). - CommandDef gains preconditions[] and stdin_from_body fields - exec route checks git_status_clean before git_pull; returns 409 on dirty tree with a clear message - stdin field in ExecBody is piped to child stdin for caddy_write_config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
80 lines
2.3 KiB
TypeScript
80 lines
2.3 KiB
TypeScript
import fs from 'fs';
|
|
import yaml from 'js-yaml';
|
|
|
|
export interface ArgsConfig {
|
|
allowed?: string[];
|
|
}
|
|
|
|
export interface CommandDef {
|
|
cmd: string[];
|
|
cwd?: string;
|
|
/** Required path prefix for dynamic cwd: caller passes repo path as first arg */
|
|
cwd_pattern?: string;
|
|
args?: ArgsConfig;
|
|
description?: string;
|
|
/** Named preconditions that must pass before the command runs (e.g. 'git_status_clean') */
|
|
preconditions?: string[];
|
|
/** When true, the caller's 'stdin' body field is piped to the child process */
|
|
stdin_from_body?: boolean;
|
|
}
|
|
|
|
export type Whitelist = Record<string, CommandDef>;
|
|
|
|
let whitelist: Whitelist = {};
|
|
|
|
export function loadWhitelist(path: string): void {
|
|
const raw = fs.readFileSync(path, 'utf8');
|
|
const parsed = yaml.load(raw) as { commands: Whitelist };
|
|
whitelist = parsed.commands ?? {};
|
|
|
|
for (const [key, def] of Object.entries(whitelist)) {
|
|
if (!Array.isArray(def.cmd) || def.cmd.length === 0) {
|
|
throw new Error(`commands.yml: '${key}' must have a non-empty 'cmd' array`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getCommand(key: string): CommandDef | undefined {
|
|
return whitelist[key];
|
|
}
|
|
|
|
export function listCommands(): string[] {
|
|
return Object.keys(whitelist);
|
|
}
|
|
|
|
/**
|
|
* Validates the dynamic cwd arg when cwd_pattern is set.
|
|
* Returns an error string if validation fails, null on success.
|
|
*/
|
|
export function validateCwd(def: CommandDef, requestArgs: string[]): string | null {
|
|
if (!def.cwd_pattern) return null;
|
|
if (requestArgs.length === 0) return `command requires a repo path as first argument`;
|
|
if (!requestArgs[0].startsWith(def.cwd_pattern)) {
|
|
return `repo path must start with '${def.cwd_pattern}'`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validates request args against a command's allowed list.
|
|
* When cwd_pattern is set, skips the first arg (used as cwd).
|
|
* Returns an error string if validation fails, null on success.
|
|
*/
|
|
export function validateArgs(def: CommandDef, requestArgs: string[]): string | null {
|
|
const args = def.cwd_pattern ? requestArgs.slice(1) : requestArgs;
|
|
const allowed = def.args?.allowed;
|
|
|
|
if (args.length === 0) return null;
|
|
|
|
if (!allowed || allowed.length === 0) {
|
|
return `command does not accept arguments`;
|
|
}
|
|
|
|
for (const arg of args) {
|
|
if (!allowed.includes(arg)) {
|
|
return `argument '${arg}' is not in the allowed list`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|