Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
939 lines
28 KiB
TypeScript
939 lines
28 KiB
TypeScript
/**
|
|
* Command-template execution standard.
|
|
* Zones: shell-free command parsing, placeholder expansion, local process execution, composition semantics
|
|
* Owns portable command-template parsing, expansion, risk checks, retries, timeouts, and direct execution.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { homedir } from "node:os";
|
|
import { isAbsolute, resolve } from "node:path";
|
|
|
|
export type CommandTemplateFailureScope = "continue" | "branch" | "root";
|
|
|
|
export interface CommandTemplateActorRecipeContext {
|
|
alias?: string;
|
|
file?: string;
|
|
name?: string;
|
|
path?: string;
|
|
role?: string;
|
|
}
|
|
|
|
export interface CommandTemplateObjectConfig {
|
|
actorRecipeContext?: CommandTemplateActorRecipeContext;
|
|
label?: string;
|
|
parallel?: boolean;
|
|
when?: boolean | string;
|
|
template?: CommandTemplateValue;
|
|
args?: string[];
|
|
defaults?: Record<string, unknown>;
|
|
timeout?: number | string;
|
|
delay?: number | string;
|
|
output?: string;
|
|
retry?: number | string;
|
|
failure?: CommandTemplateFailureScope;
|
|
recover?: CommandTemplateValue;
|
|
repeat?: number | string;
|
|
}
|
|
|
|
export type CommandTemplateValue =
|
|
| string
|
|
| CommandTemplateConfig[]
|
|
| CommandTemplateObjectConfig;
|
|
|
|
export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
|
|
|
|
export interface CommandTemplateLeafConfig extends CommandTemplateObjectConfig {
|
|
template: string;
|
|
}
|
|
|
|
export interface CommandTemplateInvocation {
|
|
command: string;
|
|
args: string[];
|
|
}
|
|
|
|
export interface CommandTemplateExecOptions {
|
|
cwd?: string;
|
|
timeout?: number;
|
|
signal?: AbortSignal;
|
|
stdin?: string;
|
|
killGrace?: number;
|
|
retry?: number;
|
|
}
|
|
|
|
export interface CommandTemplateExecResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
killed: boolean;
|
|
}
|
|
|
|
export type CommandTemplateRiskLabel =
|
|
| "risk.shell"
|
|
| "risk.eval"
|
|
| "risk.broad_fs_write"
|
|
| "risk.destructive_fs"
|
|
| "risk.network"
|
|
| "risk.external_side_effect"
|
|
| "risk.long_running"
|
|
| "risk.platform_specific"
|
|
| "risk.secret_touching";
|
|
|
|
const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
|
|
"risk.shell",
|
|
"risk.eval",
|
|
"risk.destructive_fs",
|
|
"risk.broad_fs_write",
|
|
"risk.external_side_effect",
|
|
"risk.secret_touching",
|
|
"risk.network",
|
|
"risk.long_running",
|
|
"risk.platform_specific",
|
|
];
|
|
|
|
export type CommandTemplateExecCommand = (
|
|
command: string,
|
|
args: string[],
|
|
options?: CommandTemplateExecOptions,
|
|
) => Promise<CommandTemplateExecResult>;
|
|
|
|
function normalizeCommandTemplateArgs(value: string[] | undefined): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map(String).map((item) => item.trim());
|
|
}
|
|
|
|
export function normalizeCommandTemplateConfig(
|
|
config: CommandTemplateConfig,
|
|
): CommandTemplateObjectConfig {
|
|
return typeof config === "string" ? { template: config } : config;
|
|
}
|
|
|
|
function normalizeRecoverConfig(
|
|
config: CommandTemplateValue | undefined,
|
|
): CommandTemplateConfig | undefined {
|
|
if (config === undefined) return undefined;
|
|
return Array.isArray(config) ? { template: config } : config;
|
|
}
|
|
|
|
function normalizeCommandTemplateDefaults(
|
|
defaults: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> | undefined {
|
|
if (!defaults) return undefined;
|
|
const normalized: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
normalized[key] = Array.isArray(value)
|
|
? value
|
|
: value === undefined || value === null
|
|
? ""
|
|
: String(value);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function resolveInheritedDefaultReferences(
|
|
ownDefaults: Record<string, unknown> | undefined,
|
|
inheritedDefaults: Record<string, unknown> | undefined,
|
|
runtimeValues: Record<string, unknown> = {},
|
|
): Record<string, unknown> | undefined {
|
|
if (!ownDefaults || !inheritedDefaults) return ownDefaults;
|
|
const resolved = { ...ownDefaults };
|
|
for (const [key, value] of Object.entries(ownDefaults)) {
|
|
if (typeof value !== "string") continue;
|
|
const exact = /^\{([A-Za-z_][A-Za-z0-9_-]*)\}$/.exec(value);
|
|
if (
|
|
!exact ||
|
|
Object.hasOwn(runtimeValues, exact[1]) ||
|
|
!Object.hasOwn(inheritedDefaults, exact[1])
|
|
)
|
|
continue;
|
|
resolved[key] = inheritedDefaults[exact[1]];
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export function resolveCommandTemplateRepeat(
|
|
value: number | string | undefined,
|
|
values: Record<string, unknown> = {},
|
|
): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value === "number") {
|
|
if (!Number.isInteger(value) || value < 1)
|
|
throw new Error("Command template repeat must be a positive integer.");
|
|
return value;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
const lengthMatch = trimmed.match(
|
|
/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/,
|
|
);
|
|
if (lengthMatch) {
|
|
const source = values[lengthMatch[1]];
|
|
if (Array.isArray(source)) return source.length;
|
|
if (source === undefined) return undefined;
|
|
}
|
|
throw new Error(
|
|
"Command template repeat must be a positive integer or {array.length}.",
|
|
);
|
|
}
|
|
|
|
function getExecutableName(command: string | undefined): string {
|
|
if (!command) return "";
|
|
return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
|
}
|
|
|
|
function matchesFlag(arg: string, flag: string): boolean {
|
|
if (arg === flag) return true;
|
|
if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
|
|
return arg.slice(1).includes(flag.slice(1));
|
|
return false;
|
|
}
|
|
|
|
function hasAnyFlag(args: string[], flags: string[]): boolean {
|
|
return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
|
|
}
|
|
|
|
function hasRiskyPathArg(args: string[]): boolean {
|
|
return args.some(
|
|
(arg) =>
|
|
arg === "/" ||
|
|
arg === "~" ||
|
|
arg === "./" ||
|
|
arg === "../" ||
|
|
arg.includes("{") ||
|
|
arg.startsWith("~/") ||
|
|
arg.startsWith("/"),
|
|
);
|
|
}
|
|
|
|
function sortRiskLabels(
|
|
labels: Iterable<CommandTemplateRiskLabel>,
|
|
): CommandTemplateRiskLabel[] {
|
|
const unique = new Set(labels);
|
|
return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
|
|
}
|
|
|
|
function hasAnyArg(args: string[], values: string[]): boolean {
|
|
return args.some((arg) => values.includes(arg.toLowerCase()));
|
|
}
|
|
|
|
function hasSecretTouchingText(parts: string[]): boolean {
|
|
return parts.some((part) =>
|
|
/(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
|
|
part,
|
|
),
|
|
);
|
|
}
|
|
|
|
function getLeafCommandTemplateRiskLabels(
|
|
config: CommandTemplateLeafConfig,
|
|
): CommandTemplateRiskLabel[] {
|
|
const parts = splitCommandTemplate(config.template);
|
|
const command = getExecutableName(parts[0]);
|
|
const args = parts.slice(1);
|
|
const labels = new Set<CommandTemplateRiskLabel>();
|
|
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
|
labels.add("risk.shell");
|
|
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
|
|
}
|
|
if (
|
|
["node", "deno", "bun"].includes(command) &&
|
|
hasAnyFlag(args, ["-e", "--eval"])
|
|
) {
|
|
labels.add("risk.eval");
|
|
}
|
|
if (
|
|
["python", "python3", "perl", "ruby"].includes(command) &&
|
|
hasAnyFlag(args, ["-c", "-e"])
|
|
) {
|
|
labels.add("risk.eval");
|
|
}
|
|
if (
|
|
command === "rm" &&
|
|
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
|
|
hasRiskyPathArg(args))
|
|
) {
|
|
labels.add("risk.destructive_fs");
|
|
}
|
|
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
labels.add("risk.broad_fs_write");
|
|
}
|
|
if (
|
|
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
|
|
command,
|
|
) ||
|
|
(command === "git" &&
|
|
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
|
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
|
|
) {
|
|
labels.add("risk.network");
|
|
}
|
|
if (
|
|
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
|
(command === "git" && hasAnyArg(args, ["push"])) ||
|
|
(["npm", "pnpm", "yarn"].includes(command) &&
|
|
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
|
|
) {
|
|
labels.add("risk.external_side_effect");
|
|
}
|
|
if (
|
|
command === "sleep" ||
|
|
command === "watch" ||
|
|
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
|
hasAnyArg(args, ["--watch", "--serve", "serve"])
|
|
) {
|
|
labels.add("risk.long_running");
|
|
}
|
|
if (
|
|
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
|
|
command,
|
|
)
|
|
) {
|
|
labels.add("risk.platform_specific");
|
|
}
|
|
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
|
|
labels.add("risk.secret_touching");
|
|
}
|
|
return sortRiskLabels(labels);
|
|
}
|
|
|
|
function getLeafCommandTemplateWarnings(
|
|
config: CommandTemplateLeafConfig,
|
|
): string[] {
|
|
const parts = splitCommandTemplate(config.template);
|
|
const command = getExecutableName(parts[0]);
|
|
const args = parts.slice(1);
|
|
const warnings: string[] = [];
|
|
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
|
const shellContent = hasAnyFlag(args, ["-c"])
|
|
? "shell command strings"
|
|
: "shell scripts";
|
|
warnings.push(
|
|
`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`,
|
|
);
|
|
}
|
|
if (
|
|
["node", "deno", "bun"].includes(command) &&
|
|
hasAnyFlag(args, ["-e", "--eval"])
|
|
) {
|
|
warnings.push(
|
|
`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
|
|
);
|
|
}
|
|
if (
|
|
["python", "python3", "perl", "ruby"].includes(command) &&
|
|
hasAnyFlag(args, ["-c", "-e"])
|
|
) {
|
|
warnings.push(
|
|
`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
|
|
);
|
|
}
|
|
if (
|
|
command === "rm" &&
|
|
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
|
|
hasRiskyPathArg(args))
|
|
) {
|
|
warnings.push(
|
|
`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`,
|
|
);
|
|
}
|
|
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
warnings.push(
|
|
`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`,
|
|
);
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
function pad(value: number, width: number): string {
|
|
return String(value).padStart(width, "0");
|
|
}
|
|
|
|
export function isCommandTemplateRepeatPlaceholder(name: string): boolean {
|
|
return /^_{0,6}(?:index|prev|next|repeat)$/.test(name);
|
|
}
|
|
|
|
export function getCommandTemplateRepeatDefaults(
|
|
index: number,
|
|
repeat: number,
|
|
): Record<string, string> {
|
|
const prev = (index - 1 + repeat) % repeat;
|
|
const next = (index + 1) % repeat;
|
|
const values: Record<string, string> = {
|
|
index: String(index),
|
|
next: String(next),
|
|
prev: String(prev),
|
|
repeat: String(repeat),
|
|
};
|
|
for (const name of ["index", "prev", "next", "repeat"]) {
|
|
const numeric = Number(values[name]);
|
|
for (let underscores = 1; underscores <= 6; underscores += 1) {
|
|
values[`${"_".repeat(underscores)}${name}`] = pad(
|
|
numeric,
|
|
underscores + 1,
|
|
);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function expandRepeatConfig(
|
|
config: CommandTemplateObjectConfig,
|
|
context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
|
|
): CommandTemplateObjectConfig[] | undefined {
|
|
const repeat = resolveCommandTemplateRepeat(
|
|
config.repeat,
|
|
context.defaults ?? {},
|
|
);
|
|
if (repeat === undefined) return undefined;
|
|
return Array.from({ length: repeat }, (_unused, index0) => {
|
|
const { repeat: _repeat, ...rest } = config;
|
|
return {
|
|
...rest,
|
|
defaults: {
|
|
...(context.defaults ?? {}),
|
|
...(rest.defaults ?? {}),
|
|
...getCommandTemplateRepeatDefaults(index0, repeat),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
export function expandCommandTemplateConfigs(
|
|
config: CommandTemplateConfig,
|
|
inherited: Pick<CommandTemplateObjectConfig, "args" | "defaults"> = {},
|
|
): CommandTemplateLeafConfig[] {
|
|
const normalizedConfig = normalizeCommandTemplateConfig(config);
|
|
const inheritedDefaults = normalizeCommandTemplateDefaults(
|
|
inherited.defaults,
|
|
);
|
|
const ownDefaults = resolveInheritedDefaultReferences(
|
|
normalizeCommandTemplateDefaults(normalizedConfig.defaults),
|
|
inheritedDefaults,
|
|
);
|
|
const context = {
|
|
...(inherited.args !== undefined ? { args: inherited.args } : {}),
|
|
...(inheritedDefaults ? { defaults: inheritedDefaults } : {}),
|
|
...(normalizedConfig.args !== undefined
|
|
? { args: normalizedConfig.args }
|
|
: {}),
|
|
...(ownDefaults
|
|
? { defaults: { ...(inheritedDefaults ?? {}), ...ownDefaults } }
|
|
: {}),
|
|
};
|
|
const repeated = expandRepeatConfig(normalizedConfig, context);
|
|
if (repeated) {
|
|
return repeated.flatMap((step) =>
|
|
expandCommandTemplateConfigs(step, context),
|
|
);
|
|
}
|
|
const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
|
|
const recoverSteps = recoverConfig
|
|
? expandCommandTemplateConfigs(recoverConfig, context)
|
|
: [];
|
|
if (Array.isArray(normalizedConfig.template)) {
|
|
return [
|
|
...normalizedConfig.template.flatMap((step) =>
|
|
expandCommandTemplateConfigs(step, context),
|
|
),
|
|
...recoverSteps,
|
|
];
|
|
}
|
|
if (typeof normalizedConfig.template !== "string") return recoverSteps;
|
|
return [
|
|
{
|
|
...normalizedConfig,
|
|
...context,
|
|
template: normalizedConfig.template,
|
|
retry: normalizedConfig.retry,
|
|
},
|
|
...recoverSteps,
|
|
];
|
|
}
|
|
|
|
export function getCommandTemplateWarnings(
|
|
config: CommandTemplateConfig,
|
|
): string[] {
|
|
return [
|
|
...new Set(
|
|
expandCommandTemplateConfigs(config).flatMap((leaf) =>
|
|
getLeafCommandTemplateWarnings(leaf),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
export function getCommandTemplateRiskLabels(
|
|
config: CommandTemplateConfig,
|
|
): CommandTemplateRiskLabel[] {
|
|
return sortRiskLabels(
|
|
expandCommandTemplateConfigs(config).flatMap((leaf) =>
|
|
getLeafCommandTemplateRiskLabels(leaf),
|
|
),
|
|
);
|
|
}
|
|
|
|
function parseCommandTemplateArgToken(value: string): {
|
|
name: string;
|
|
defaultValue?: string;
|
|
} {
|
|
const separatorIndex = value.indexOf("=");
|
|
const rawName =
|
|
separatorIndex === -1 ? value : value.slice(0, separatorIndex);
|
|
const colonIndex = rawName.indexOf(":");
|
|
return {
|
|
name: (colonIndex === -1 ? rawName : rawName.slice(0, colonIndex)).trim(),
|
|
...(separatorIndex === -1
|
|
? {}
|
|
: { defaultValue: value.slice(separatorIndex + 1).trim() }),
|
|
};
|
|
}
|
|
|
|
function parseCommandTemplatePlaceholderContent(
|
|
content: string,
|
|
): { name: string; inlineDefault?: string } | undefined {
|
|
const match = content.match(
|
|
/^([A-Za-z_][A-Za-z0-9_-]*)(?::(?:string|path|int|number|bool|array|enum\([^)]*\)))?(?:=([^}]*))?$/,
|
|
);
|
|
if (!match) return undefined;
|
|
return {
|
|
name: match[1],
|
|
...(match[2] !== undefined ? { inlineDefault: match[2] } : {}),
|
|
};
|
|
}
|
|
|
|
export function getCommandTemplateDefaults(
|
|
config: CommandTemplateConfig | undefined,
|
|
): Record<string, string> {
|
|
const normalizedConfig = config
|
|
? normalizeCommandTemplateConfig(config)
|
|
: undefined;
|
|
const defaults: Record<string, string> = {};
|
|
for (const item of normalizeCommandTemplateArgs(normalizedConfig?.args)) {
|
|
if (!item) continue;
|
|
const parsed = parseCommandTemplateArgToken(item);
|
|
if (!parsed.name || parsed.defaultValue === undefined) continue;
|
|
defaults[parsed.name] = parsed.defaultValue;
|
|
}
|
|
for (const [key, value] of Object.entries(normalizedConfig?.defaults ?? {})) {
|
|
defaults[key] = value === undefined || value === null ? "" : String(value);
|
|
}
|
|
return defaults;
|
|
}
|
|
|
|
export function splitCommandTemplate(input: string): string[] {
|
|
const words: string[] = [];
|
|
let current = "";
|
|
let quote: "'" | '"' | undefined;
|
|
let escaped = false;
|
|
let active = false;
|
|
for (const char of input) {
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
active = true;
|
|
continue;
|
|
}
|
|
if (char === "\\" && quote !== "'") {
|
|
escaped = true;
|
|
active = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (char === quote) quote = undefined;
|
|
else current += char;
|
|
active = true;
|
|
continue;
|
|
}
|
|
if (char === "'" || char === '"') {
|
|
quote = char;
|
|
active = true;
|
|
continue;
|
|
}
|
|
if (/\s/.test(char)) {
|
|
if (active) words.push(current);
|
|
if (active) current = "";
|
|
active = false;
|
|
continue;
|
|
}
|
|
current += char;
|
|
active = true;
|
|
}
|
|
if (escaped) current += "\\";
|
|
if (active || current) words.push(current);
|
|
return words;
|
|
}
|
|
|
|
export function expandCommandTemplateExecutable(
|
|
command: string,
|
|
cwd: string,
|
|
): string {
|
|
if (command === "~") return homedir();
|
|
if (command.startsWith("~/")) return resolve(homedir(), command.slice(2));
|
|
if (command.includes("/") && !isAbsolute(command))
|
|
return resolve(cwd, command);
|
|
return command;
|
|
}
|
|
|
|
function evaluateCommandTemplateExpression(
|
|
expression: string,
|
|
values: Record<string, unknown>,
|
|
): number {
|
|
let index = 0;
|
|
const source = expression.replace(/\s+/g, "");
|
|
const peek = (): string | undefined => source[index];
|
|
const consume = (char: string): boolean => {
|
|
if (peek() !== char) return false;
|
|
index += 1;
|
|
return true;
|
|
};
|
|
const parsePrimary = (): number => {
|
|
if (consume("(")) {
|
|
const value = parseExpression();
|
|
if (!consume(")"))
|
|
throw new Error(`Invalid command template expression: ${expression}`);
|
|
return value;
|
|
}
|
|
const numberMatch = source.slice(index).match(/^\d+/);
|
|
if (numberMatch) {
|
|
index += numberMatch[0].length;
|
|
return Number(numberMatch[0]);
|
|
}
|
|
const nameMatch = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/);
|
|
if (nameMatch) {
|
|
index += nameMatch[0].length;
|
|
const value = values[nameMatch[0]];
|
|
if (value === undefined || !/^-?\d+$/.test(String(value)))
|
|
throw new Error(
|
|
`Invalid command template expression variable: ${nameMatch[0]}`,
|
|
);
|
|
return Number(value);
|
|
}
|
|
throw new Error(`Invalid command template expression: ${expression}`);
|
|
};
|
|
const parseTerm = (): number => {
|
|
let value = parsePrimary();
|
|
while (true) {
|
|
if (consume("*")) value *= parsePrimary();
|
|
else if (consume("/")) value = Math.trunc(value / parsePrimary());
|
|
else if (consume("%")) value %= parsePrimary();
|
|
else return value;
|
|
}
|
|
};
|
|
const parseExpression = (): number => {
|
|
let value = parseTerm();
|
|
while (true) {
|
|
if (consume("+")) value += parseTerm();
|
|
else if (consume("-")) value -= parseTerm();
|
|
else return value;
|
|
}
|
|
};
|
|
const value = parseExpression();
|
|
if (index !== source.length)
|
|
throw new Error(`Invalid command template expression: ${expression}`);
|
|
return value;
|
|
}
|
|
|
|
function substituteCommandTemplateExpression(
|
|
content: string,
|
|
values: Record<string, unknown>,
|
|
): string | undefined {
|
|
const padded = content.match(/^(_{1,6})\((.+)\)$/);
|
|
if (padded) {
|
|
return pad(
|
|
evaluateCommandTemplateExpression(padded[2], values),
|
|
padded[1].length + 1,
|
|
);
|
|
}
|
|
if (!/[()+\-*\/%]/.test(content)) return undefined;
|
|
return String(evaluateCommandTemplateExpression(content, values));
|
|
}
|
|
|
|
function shouldResolveEmbeddedCommandTemplateToken(
|
|
token: string,
|
|
values: Record<string, unknown>,
|
|
): boolean {
|
|
const matches = [...token.matchAll(/\{([^{}]+)\}/g)];
|
|
if (matches.length === 0) return false;
|
|
return matches.every((match) => {
|
|
const content = match[1];
|
|
if (resolveCommandTemplateNullish(content, values) !== undefined)
|
|
return true;
|
|
if (resolveCommandTemplateTernary(content, values) !== undefined)
|
|
return true;
|
|
const indexed = content.match(
|
|
/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/,
|
|
);
|
|
if (indexed) return Object.hasOwn(values, indexed[1]);
|
|
const simple = parseCommandTemplatePlaceholderContent(content);
|
|
if (simple)
|
|
return (
|
|
Object.hasOwn(values, simple.name) || simple.inlineDefault !== undefined
|
|
);
|
|
try {
|
|
return substituteCommandTemplateExpression(content, values) !== undefined;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function isFalsyCommandTemplateValue(value: unknown): boolean {
|
|
if (value === undefined || value === null || value === false) return true;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
return (
|
|
normalized === "" ||
|
|
normalized === "0" ||
|
|
normalized === "false" ||
|
|
normalized === "no"
|
|
);
|
|
}
|
|
|
|
function resolveCommandTemplateCondition(
|
|
condition: string,
|
|
values: Record<string, unknown>,
|
|
): unknown {
|
|
const trimmed = condition.trim();
|
|
const negated = trimmed.startsWith("!");
|
|
const name = negated ? trimmed.slice(1).trim() : trimmed;
|
|
const value = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)
|
|
? values[name]
|
|
: undefined;
|
|
return negated ? isFalsyCommandTemplateValue(value) : value;
|
|
}
|
|
|
|
export function shouldRunCommandTemplateNode(
|
|
value: boolean | string | undefined,
|
|
values: Record<string, unknown>,
|
|
): boolean {
|
|
if (value === undefined) return true;
|
|
if (typeof value === "boolean") return value;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return false;
|
|
const exact = /^\{([^{}]+)\}$/.exec(trimmed);
|
|
const resolved = exact
|
|
? resolveCommandTemplateValue(exact[1], values, "command template when")
|
|
: resolveCommandTemplateCondition(trimmed, values);
|
|
return !isFalsyCommandTemplateValue(resolved);
|
|
}
|
|
|
|
function resolveCommandTemplateNullish(
|
|
content: string,
|
|
values: Record<string, unknown>,
|
|
): string | undefined {
|
|
const coalescing = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\?\?(.*)$/);
|
|
if (!coalescing) return undefined;
|
|
const value = values[coalescing[1]];
|
|
return isFalsyCommandTemplateValue(value) ? coalescing[2] : String(value);
|
|
}
|
|
|
|
function resolveCommandTemplateTernary(
|
|
content: string,
|
|
values: Record<string, unknown>,
|
|
): string | undefined {
|
|
const ternary = content.match(/^([^?:]+)\?([^:]*):(.*)$/);
|
|
if (!ternary) return undefined;
|
|
const condition = resolveCommandTemplateCondition(ternary[1], values);
|
|
return isFalsyCommandTemplateValue(condition) ? ternary[3] : ternary[2];
|
|
}
|
|
|
|
function resolveCommandTemplateValue(
|
|
content: string,
|
|
values: Record<string, unknown>,
|
|
missingLabel: string,
|
|
depth = 0,
|
|
): string | undefined {
|
|
if (depth > 5)
|
|
throw new Error(`Command template value recursion exceeded: ${content}`);
|
|
const nullish = resolveCommandTemplateNullish(content, values);
|
|
if (nullish !== undefined) return nullish;
|
|
const ternary = resolveCommandTemplateTernary(content, values);
|
|
if (ternary !== undefined) return ternary;
|
|
const indexed = content.match(
|
|
/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/,
|
|
);
|
|
if (indexed) {
|
|
const source = values[indexed[1]];
|
|
const indexValue = /^\d+$/.test(indexed[2])
|
|
? indexed[2]
|
|
: values[indexed[2]];
|
|
const index = Number(indexValue);
|
|
if (
|
|
!Array.isArray(source) ||
|
|
!Number.isInteger(index) ||
|
|
index < 0 ||
|
|
index >= source.length
|
|
) {
|
|
throw new Error(`Missing ${missingLabel} value: ${content}`);
|
|
}
|
|
return String(source[index] ?? "");
|
|
}
|
|
const simple = parseCommandTemplatePlaceholderContent(content);
|
|
if (simple) {
|
|
if (Object.hasOwn(values, simple.name)) {
|
|
const raw = values[simple.name] ?? "";
|
|
if (
|
|
typeof raw === "string" &&
|
|
shouldResolveEmbeddedCommandTemplateToken(raw, values)
|
|
) {
|
|
return substituteCommandTemplateToken(
|
|
raw,
|
|
values,
|
|
missingLabel,
|
|
depth + 1,
|
|
);
|
|
}
|
|
return Array.isArray(raw) ? JSON.stringify(raw) : String(raw);
|
|
}
|
|
if (simple.inlineDefault !== undefined) return simple.inlineDefault;
|
|
}
|
|
const expression = substituteCommandTemplateExpression(content, values);
|
|
if (expression !== undefined) return expression;
|
|
return undefined;
|
|
}
|
|
|
|
export function substituteCommandTemplateToken(
|
|
token: string,
|
|
values: Record<string, unknown>,
|
|
missingLabel = "command template",
|
|
depth = 0,
|
|
): string {
|
|
return token.replace(/\{([^{}]+)\}/g, (_match, content: string) => {
|
|
const resolved = resolveCommandTemplateValue(
|
|
content,
|
|
values,
|
|
missingLabel,
|
|
depth,
|
|
);
|
|
if (resolved !== undefined) return resolved;
|
|
throw new Error(`Missing ${missingLabel} value: ${content}`);
|
|
});
|
|
}
|
|
|
|
export async function execCommandTemplate(
|
|
command: string,
|
|
args: string[],
|
|
options: CommandTemplateExecOptions = {},
|
|
): Promise<CommandTemplateExecResult> {
|
|
const maxAttempts = options.retry ?? 1;
|
|
let lastResult: CommandTemplateExecResult = {
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 1,
|
|
killed: false,
|
|
};
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
const result = await execCommandTemplateOnce(command, args, options);
|
|
if (result.code === 0) return result;
|
|
lastResult = result;
|
|
}
|
|
return lastResult;
|
|
}
|
|
|
|
function execCommandTemplateOnce(
|
|
command: string,
|
|
args: string[],
|
|
options: CommandTemplateExecOptions = {},
|
|
): Promise<CommandTemplateExecResult> {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn(command, args, {
|
|
cwd: options.cwd,
|
|
shell: false,
|
|
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let killed = false;
|
|
let settled = false;
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
let killTimeoutId: NodeJS.Timeout | undefined;
|
|
const killProcess = (): void => {
|
|
if (killed) return;
|
|
killed = true;
|
|
proc.kill("SIGTERM");
|
|
killTimeoutId = setTimeout(() => {
|
|
if (!settled) proc.kill("SIGKILL");
|
|
}, options.killGrace ?? 5000);
|
|
};
|
|
const settle = (code: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (killTimeoutId) clearTimeout(killTimeoutId);
|
|
if (options.signal)
|
|
options.signal.removeEventListener("abort", killProcess);
|
|
resolve({ stdout, stderr, code, killed });
|
|
};
|
|
if (options.signal) {
|
|
if (options.signal.aborted) killProcess();
|
|
else
|
|
options.signal.addEventListener("abort", killProcess, { once: true });
|
|
}
|
|
if (options.timeout !== undefined && options.timeout > 0)
|
|
timeoutId = setTimeout(killProcess, options.timeout);
|
|
proc.stdout?.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
proc.stderr?.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
proc.stdin?.on("error", () => {});
|
|
if (options.stdin !== undefined) proc.stdin?.end(options.stdin);
|
|
proc.on("error", (error) => {
|
|
stderr += error instanceof Error ? error.message : String(error);
|
|
settle(1);
|
|
});
|
|
proc.on("close", (code) => {
|
|
settle(code ?? (killed ? 1 : 0));
|
|
});
|
|
});
|
|
}
|
|
|
|
export function buildCommandTemplateInvocation(
|
|
config: CommandTemplateConfig,
|
|
values: Record<string, unknown>,
|
|
cwd: string,
|
|
options: { emptyMessage?: string; missingLabel?: string } = {},
|
|
): CommandTemplateInvocation {
|
|
const normalizedConfig = normalizeCommandTemplateConfig(config);
|
|
if (Array.isArray(normalizedConfig.template)) {
|
|
throw new Error(
|
|
options.emptyMessage ??
|
|
"Command template sequence cannot be executed as one command",
|
|
);
|
|
}
|
|
if (!normalizedConfig.template)
|
|
throw new Error(options.emptyMessage ?? "Command template is required");
|
|
if (typeof normalizedConfig.template !== "string") {
|
|
throw new Error(
|
|
options.emptyMessage ??
|
|
"Command template object cannot be executed as one command",
|
|
);
|
|
}
|
|
const parts = splitCommandTemplate(normalizedConfig.template);
|
|
const commandPart = parts[0];
|
|
if (!commandPart)
|
|
throw new Error(options.emptyMessage ?? "Command template is empty");
|
|
const resolvedValues = {
|
|
...getCommandTemplateDefaults(normalizedConfig),
|
|
...values,
|
|
};
|
|
const command = expandCommandTemplateExecutable(
|
|
substituteCommandTemplateToken(
|
|
commandPart,
|
|
resolvedValues,
|
|
options.missingLabel,
|
|
),
|
|
cwd,
|
|
);
|
|
const args = parts
|
|
.slice(1)
|
|
.map((part) =>
|
|
substituteCommandTemplateToken(
|
|
part,
|
|
resolvedValues,
|
|
options.missingLabel,
|
|
),
|
|
)
|
|
.filter((part) => part !== "");
|
|
return { command, args };
|
|
}
|