mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
* test(skills): define terminal opener contract * feat(skills): add secure terminal opener * fix(skills): report detached terminal errors * docs: sync terminal opener skill count * fix(security): require explicit terminal launch * test(skills): cover terminal opener review findings * fix(skills): bound terminal launch waits * test(skills): cover terminal fallback output * fix(skills): report terminal mux fallback --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
397 lines
12 KiB
JavaScript
Executable File
397 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const childProcess = require('child_process');
|
|
|
|
const DEFAULT_TERMINAL = 'wezterm';
|
|
const SPAWN_KILL_SIGNAL = 'SIGTERM';
|
|
const SYNC_TIMEOUT_MS = 10_000;
|
|
const SUPPORTED_TERMINALS = new Set([DEFAULT_TERMINAL]);
|
|
|
|
function usage() {
|
|
return `Open an executable and its argument array in a visible terminal.
|
|
|
|
Usage:
|
|
node skills/terminal-opener/scripts/open-terminal.js [options] -- <executable> [args...]
|
|
node skills/terminal-opener/scripts/open-terminal.js --detect [--terminal <name>] [--json]
|
|
|
|
Options:
|
|
--terminal <name> Terminal adapter (default: ECC_TERMINAL or wezterm).
|
|
--cwd <path> Initial host directory (default: current directory).
|
|
--recover Start a standalone terminal with stock configuration.
|
|
--standalone Alias for --recover.
|
|
--detect Check whether the selected terminal can be launched.
|
|
--launch Explicitly open the terminal (the default only prints a plan).
|
|
--dry-run Explicitly print the launch plan without opening a terminal.
|
|
--json Emit the plan, capability, or launch result as JSON.
|
|
--help, -h Show this help.
|
|
|
|
Always pass the executable and arguments as separate entries after --.
|
|
Shell command strings are not accepted.
|
|
`;
|
|
}
|
|
|
|
function isAbsolutePath(value) {
|
|
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
}
|
|
|
|
function validateTerminalName(value) {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) {
|
|
throw new Error('Invalid terminal name; use a simple adapter name such as wezterm.');
|
|
}
|
|
}
|
|
|
|
function validateCwd(value) {
|
|
if (value.includes('\0')) throw new Error('--cwd must not contain a NUL byte.');
|
|
if (!isAbsolutePath(value)) throw new Error('--cwd must be an absolute path.');
|
|
}
|
|
|
|
function validateExecutable(value) {
|
|
if (!value || /[\0\r\n]/.test(value)) {
|
|
throw new Error('Executable must be a non-empty argv entry without control bytes.');
|
|
}
|
|
|
|
const whitespaceIndex = value.search(/\s/);
|
|
const separatorIndexes = [value.indexOf('/'), value.indexOf('\\')].filter(index => index >= 0);
|
|
const firstSeparatorIndex = separatorIndexes.length > 0 ? Math.min(...separatorIndexes) : -1;
|
|
const resemblesExecutablePath = isAbsolutePath(value)
|
|
|| (firstSeparatorIndex >= 0 && (whitespaceIndex < 0 || firstSeparatorIndex < whitespaceIndex));
|
|
|
|
if (whitespaceIndex >= 0 && !resemblesExecutablePath) {
|
|
throw new Error(
|
|
'Executable must be one argv entry, not an interpolated shell command string.'
|
|
);
|
|
}
|
|
if (!resemblesExecutablePath && /[;&|<>`$]/.test(value)) {
|
|
throw new Error(
|
|
'Executable must be one argv entry, not an interpolated shell command string.'
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateArgv(argv) {
|
|
for (const argument of argv) {
|
|
if (argument.includes('\0')) throw new Error('Arguments must not contain NUL bytes.');
|
|
}
|
|
}
|
|
|
|
function readValue(argv, index, option) {
|
|
const value = argv[index + 1];
|
|
if (value === undefined || value.startsWith('--')) {
|
|
throw new Error(`Missing value for ${option}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseArgs(argv, context = {}) {
|
|
const env = context.env || process.env;
|
|
const initialTerminal = env.ECC_TERMINAL || DEFAULT_TERMINAL;
|
|
const initialCwd = context.cwd || process.cwd();
|
|
const options = {
|
|
argv: [],
|
|
cwd: initialCwd,
|
|
detect: false,
|
|
dryRun: true,
|
|
executable: undefined,
|
|
help: false,
|
|
json: false,
|
|
mode: 'normal',
|
|
terminal: initialTerminal,
|
|
};
|
|
let dryRunRequested = false;
|
|
let launchRequested = false;
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--') {
|
|
options.executable = argv[index + 1];
|
|
options.argv = argv.slice(index + 2);
|
|
break;
|
|
}
|
|
if (argument === '--terminal' || argument === '--cwd') {
|
|
const value = readValue(argv, index, argument);
|
|
options[argument.slice(2)] = value;
|
|
index += 1;
|
|
} else if (argument === '--recover' || argument === '--standalone') {
|
|
options.mode = 'recover';
|
|
} else if (argument === '--detect') {
|
|
options.detect = true;
|
|
} else if (argument === '--launch') {
|
|
launchRequested = true;
|
|
options.dryRun = false;
|
|
} else if (argument === '--dry-run') {
|
|
dryRunRequested = true;
|
|
options.dryRun = true;
|
|
} else if (argument === '--json') {
|
|
options.json = true;
|
|
} else if (argument === '--help' || argument === '-h') {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`Unknown option "${argument}"; put the executable after --.`);
|
|
}
|
|
}
|
|
|
|
if (launchRequested && dryRunRequested) {
|
|
throw new Error('--launch and --dry-run are mutually exclusive.');
|
|
}
|
|
|
|
validateTerminalName(options.terminal);
|
|
validateCwd(options.cwd);
|
|
if (!options.help && !options.detect && !options.executable) {
|
|
throw new Error('An executable is required after --.');
|
|
}
|
|
if (options.executable) validateExecutable(options.executable);
|
|
validateArgv(options.argv);
|
|
return options;
|
|
}
|
|
|
|
function unsupportedPlan(options) {
|
|
return {
|
|
ok: false,
|
|
reason: 'unsupported-terminal',
|
|
action: `Terminal "${options.terminal}" is not supported. Install WezTerm, then rerun with --terminal wezterm.`,
|
|
terminal: options.terminal,
|
|
executable: options.executable,
|
|
argv: [...options.argv],
|
|
cwd: options.cwd,
|
|
dryRun: options.dryRun,
|
|
launchMode: options.mode === 'recover' ? 'recover' : 'mux',
|
|
command: null,
|
|
args: null,
|
|
fallback: null,
|
|
probe: null,
|
|
};
|
|
}
|
|
|
|
function buildLaunchPlan(options) {
|
|
if (!SUPPORTED_TERMINALS.has(options.terminal)) return unsupportedPlan(options);
|
|
|
|
const commandArgs = options.executable ? [options.executable, ...options.argv] : [];
|
|
const recover = options.mode === 'recover';
|
|
return {
|
|
ok: true,
|
|
reason: null,
|
|
action: null,
|
|
terminal: options.terminal,
|
|
executable: options.executable,
|
|
argv: [...options.argv],
|
|
cwd: options.cwd,
|
|
dryRun: options.dryRun,
|
|
launchMode: recover ? 'recover' : 'mux',
|
|
command: DEFAULT_TERMINAL,
|
|
args: recover
|
|
? ['--skip-config', 'start', '--always-new-process', '--cwd', options.cwd, '--', ...commandArgs]
|
|
: ['cli', 'spawn', '--new-window', '--cwd', options.cwd, '--', ...commandArgs],
|
|
fallback: recover
|
|
? null
|
|
: {
|
|
command: DEFAULT_TERMINAL,
|
|
args: ['start', '--cwd', options.cwd, '--', ...commandArgs],
|
|
},
|
|
probe: { command: DEFAULT_TERMINAL, args: ['--version'] },
|
|
};
|
|
}
|
|
|
|
function unavailableCapability(plan, reason, detail) {
|
|
return {
|
|
terminal: plan.terminal,
|
|
supported: true,
|
|
available: false,
|
|
reason,
|
|
detail,
|
|
action: 'Install WezTerm and ensure wezterm is on PATH, then rerun with --detect.',
|
|
};
|
|
}
|
|
|
|
function detectTerminalCapability(plan, spawnSyncImpl = childProcess.spawnSync) {
|
|
if (!plan.ok) {
|
|
return {
|
|
terminal: plan.terminal,
|
|
supported: false,
|
|
available: false,
|
|
reason: plan.reason,
|
|
detail: null,
|
|
action: plan.action,
|
|
};
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = spawnSyncImpl(plan.probe.command, plan.probe.args, {
|
|
encoding: 'utf8',
|
|
killSignal: SPAWN_KILL_SIGNAL,
|
|
shell: false,
|
|
timeout: SYNC_TIMEOUT_MS,
|
|
});
|
|
} catch (error) {
|
|
return unavailableCapability(plan, 'probe-failed', error.message);
|
|
}
|
|
if (result.error) {
|
|
const reason = result.error.code === 'ETIMEDOUT' ? 'probe-failed' : 'not-installed';
|
|
return unavailableCapability(plan, reason, result.error.message);
|
|
}
|
|
if (result.status !== 0) {
|
|
return unavailableCapability(
|
|
plan,
|
|
'probe-failed',
|
|
`Terminal version probe exited with status ${result.status}.`
|
|
);
|
|
}
|
|
return {
|
|
terminal: plan.terminal,
|
|
supported: true,
|
|
available: true,
|
|
reason: null,
|
|
detail: null,
|
|
action: null,
|
|
version: String(result.stdout || '').trim(),
|
|
};
|
|
}
|
|
|
|
function reportDetachedError(error) {
|
|
process.stderr.write(`Error: ${error.message}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function launchDetached(command, args, cwd, spawnImpl, onDetachedError) {
|
|
let child;
|
|
try {
|
|
child = spawnImpl(command, args, {
|
|
cwd,
|
|
detached: true,
|
|
shell: false,
|
|
stdio: 'ignore',
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Unable to start ${command}: ${error.message}`, { cause: error });
|
|
}
|
|
if (!child || typeof child.unref !== 'function') {
|
|
throw new Error('Terminal process did not start correctly.');
|
|
}
|
|
if (typeof child.once === 'function') {
|
|
child.once('error', error => {
|
|
onDetachedError(
|
|
new Error(`Unable to start ${command}: ${error.message}`, { cause: error })
|
|
);
|
|
});
|
|
}
|
|
child.unref();
|
|
}
|
|
|
|
function launch(plan, dependencies = {}) {
|
|
const spawnSyncImpl = dependencies.spawnSync || childProcess.spawnSync;
|
|
const spawnImpl = dependencies.spawn || childProcess.spawn;
|
|
const onDetachedError = dependencies.onDetachedError || reportDetachedError;
|
|
const capability = detectTerminalCapability(plan, spawnSyncImpl);
|
|
if (!capability.available) {
|
|
throw new Error(`${capability.reason}: ${capability.action}`);
|
|
}
|
|
|
|
if (plan.launchMode === 'recover') {
|
|
launchDetached(plan.command, plan.args, plan.cwd, spawnImpl, onDetachedError);
|
|
return { strategy: 'detached-recover', capability };
|
|
}
|
|
|
|
const muxResult = spawnSyncImpl(plan.command, plan.args, {
|
|
cwd: plan.cwd,
|
|
encoding: 'utf8',
|
|
killSignal: SPAWN_KILL_SIGNAL,
|
|
shell: false,
|
|
timeout: SYNC_TIMEOUT_MS,
|
|
});
|
|
if (!muxResult.error && muxResult.status === 0) {
|
|
return { strategy: 'mux', capability };
|
|
}
|
|
|
|
const muxFailure = muxResult.error
|
|
? muxResult.error.message
|
|
: `${plan.command} cli spawn exited with status ${muxResult.status}: ${String(
|
|
muxResult.stderr || ''
|
|
).trim()}`;
|
|
|
|
launchDetached(
|
|
plan.fallback.command,
|
|
plan.fallback.args,
|
|
plan.cwd,
|
|
spawnImpl,
|
|
onDetachedError
|
|
);
|
|
return { strategy: 'detached-fallback', capability, muxFailure };
|
|
}
|
|
|
|
function printJson(value) {
|
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function formatLaunchResult(plan, result, json) {
|
|
if (json) {
|
|
return `${JSON.stringify({ ...plan, ...result }, null, 2)}\n`;
|
|
}
|
|
|
|
const summary =
|
|
`Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n`;
|
|
if (result.strategy !== 'detached-fallback') return summary;
|
|
return `${summary}Mux launch failed: ${result.muxFailure}\n`;
|
|
}
|
|
|
|
function printPlan(plan, json) {
|
|
if (json) return printJson(plan);
|
|
if (!plan.ok) {
|
|
process.stdout.write(`${plan.action}\n`);
|
|
return;
|
|
}
|
|
process.stdout.write(
|
|
`Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n`
|
|
);
|
|
}
|
|
|
|
function printCapability(capability, json) {
|
|
if (json) return printJson(capability);
|
|
if (capability.available) {
|
|
process.stdout.write(`${capability.terminal} is available (${capability.version}).\n`);
|
|
} else {
|
|
process.stdout.write(`${capability.terminal} is unavailable. ${capability.action}\n`);
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(usage());
|
|
return;
|
|
}
|
|
|
|
const plan = buildLaunchPlan(options);
|
|
if (options.detect) {
|
|
const capability = detectTerminalCapability(plan);
|
|
printCapability(capability, options.json);
|
|
if (!capability.available) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
printPlan(plan, options.json);
|
|
return;
|
|
}
|
|
const result = launch(plan);
|
|
process.stdout.write(formatLaunchResult(plan, result, options.json));
|
|
} catch (error) {
|
|
process.stderr.write(`Error: ${error.message}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) main();
|
|
|
|
module.exports = {
|
|
buildLaunchPlan,
|
|
detectTerminalCapability,
|
|
formatLaunchResult,
|
|
launch,
|
|
parseArgs,
|
|
usage,
|
|
};
|