Files
lab/bastion/scripts/generate-completions.ts
Michal 897844fae0 refactor: rename CLI binary from lab to labctl
Updated everywhere: constants, package.json bin, completions,
nfpm packaging, build scripts, CI, banner text. Binary is now
/usr/bin/labctl. Internal package names (@lab/*) unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:07:17 +00:00

386 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
/**
* generate-completions.ts -- auto-generates shell completions from the commander.js command tree.
*
* Usage:
* tsx scripts/generate-completions.ts # print generated files to stdout
* tsx scripts/generate-completions.ts --write # write completions/ files
* tsx scripts/generate-completions.ts --check # exit 0 if files match, 1 if stale
*
* Requires `pnpm build` to have run first (workspace packages must be compiled).
*/
import { Command, type Option, type Argument } from 'commander';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ============================================================
// Command tree extraction
// ============================================================
interface CmdInfo {
name: string;
description: string;
hidden: boolean;
options: OptInfo[];
args: ArgInfo[];
subcommands: CmdInfo[];
}
interface OptInfo {
short?: string;
long: string;
description: string;
takesValue: boolean;
choices?: string[];
negate: boolean;
}
interface ArgInfo {
name: string;
description: string;
required: boolean;
variadic: boolean;
choices?: string[];
}
function extractOption(opt: Option): OptInfo {
return {
short: (opt as unknown as Record<string, string>).short || undefined,
long: (opt as unknown as Record<string, string>).long,
description: opt.description,
takesValue: (opt as unknown as Record<string, boolean>).required || (opt as unknown as Record<string, boolean>).optional || false,
choices: (opt as unknown as Record<string, string[] | undefined>).argChoices || undefined,
negate: (opt as unknown as Record<string, boolean>).negate || false,
};
}
function extractArgument(arg: Argument): ArgInfo {
return {
name: (arg as unknown as Record<string, string>)._name ?? arg.name(),
description: arg.description,
required: (arg as unknown as Record<string, boolean>).required,
variadic: (arg as unknown as Record<string, boolean>).variadic,
choices: (arg as unknown as Record<string, string[] | undefined>)._choices || undefined,
};
}
function extractCommand(cmd: Command): CmdInfo {
const options = (cmd.options as Option[])
.filter((o) => {
const long = (o as unknown as Record<string, string>).long;
return long !== '--help' && long !== '--version';
})
.map(extractOption);
const args = ((cmd as unknown as Record<string, Argument[]>).registeredArguments ?? [])
.map(extractArgument);
const subcommands = (cmd.commands as Command[])
.filter((sub) => sub.name() !== 'help')
.map(extractCommand);
if ((cmd.commands as Command[]).some((sub) => sub.name() === 'help')) {
subcommands.push({
name: 'help',
description: 'display help for command',
hidden: false,
options: [],
args: [],
subcommands: [],
});
}
return {
name: cmd.name(),
description: cmd.description(),
hidden: (cmd as unknown as Record<string, boolean>)._hidden ?? false,
options,
args,
subcommands,
};
}
async function extractTree(): Promise<CmdInfo> {
const { createProgram } = await import('../src/cli/src/index.js') as { createProgram: () => Command };
const program = createProgram();
return extractCommand(program);
}
// ============================================================
// Utilities
// ============================================================
function esc(s: string): string {
return s.replace(/'/g, "\\'");
}
/** Collect all commands recursively with their full path. */
function collectCommands(cmd: CmdInfo, prefix: string[] = []): { path: string[]; cmd: CmdInfo }[] {
const result: { path: string[]; cmd: CmdInfo }[] = [];
for (const sub of cmd.subcommands) {
const fullPath = [...prefix, sub.name];
result.push({ path: fullPath, cmd: sub });
result.push(...collectCommands(sub, fullPath));
}
return result;
}
// ============================================================
// Fish completion generator
// ============================================================
function generateFish(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string): void => { lines.push(s); };
const BIN = root.name;
emit(`# ${BIN} fish completions -- auto-generated by scripts/generate-completions.ts`);
emit('# DO NOT EDIT MANUALLY -- run: pnpm completions:generate');
emit('');
emit(`complete -c ${BIN} -e`);
emit(`complete -c ${BIN} -f`);
emit('');
// Global options
emit('# Global options');
emit(`complete -c ${BIN} -s v -l version -d 'Show version'`);
emit(`complete -c ${BIN} -s h -l help -d 'Show help'`);
emit('');
const allCmds = collectCommands(root);
// Helper function for fish: test if exactly the given subcommand chain is present
emit('# Helper: test if a subcommand chain is active');
emit(`function __${BIN}_using_cmd`);
emit(' set -l tokens (commandline -opc)');
emit(' set -l expected $argv');
emit(' set -l depth (count $expected)');
emit(' set -l found 0');
emit(' set -l i 1');
emit(' for tok in $tokens[2..]');
emit(' if string match -q -- "-*" $tok');
emit(' continue');
emit(' end');
emit(' set i (math $i + 1)');
emit(' set -l idx (math $i - 1)');
emit(' if test $idx -le $depth');
emit(' if test "$tok" != "$expected[$idx]"');
emit(' return 1');
emit(' end');
emit(' set found (math $found + 1)');
emit(' else');
emit(' return 1');
emit(' end');
emit(' end');
emit(' test $found -eq $depth');
emit('end');
emit('');
// Top-level commands
const topCmds = root.subcommands.filter((c) => !c.hidden);
emit('# Top-level commands');
for (const cmd of topCmds) {
emit(`complete -c ${BIN} -n "not __fish_seen_subcommand_from ${topCmds.map((c) => c.name).join(' ')}" -a ${cmd.name} -d '${esc(cmd.description)}'`);
}
emit('');
// Subcommands and options at each level
for (const { path, cmd } of allCmds) {
if (cmd.hidden) continue;
// If this command has subcommands, offer them
const visibleSubs = cmd.subcommands.filter((s) => !s.hidden);
if (visibleSubs.length > 0) {
const parentCondition = `__${BIN}_using_cmd ${path.join(' ')}`;
emit(`# ${path.join(' ')} subcommands`);
for (const sub of visibleSubs) {
emit(`complete -c ${BIN} -n "${parentCondition}" -a ${sub.name} -d '${esc(sub.description)}'`);
}
emit('');
}
// Options for this command
if (cmd.options.length > 0) {
const condition = `__${BIN}_using_cmd ${path.join(' ')}`;
emit(`# ${path.join(' ')} options`);
for (const opt of cmd.options) {
const parts = [`complete -c ${BIN} -n "${condition}"`];
if (opt.short) parts.push(`-s ${opt.short.replace('-', '')}`);
parts.push(`-l ${opt.long.replace(/^--/, '')}`);
parts.push(`-d '${esc(opt.description)}'`);
if (opt.takesValue) {
if (opt.choices) {
parts.push(`-xa '${opt.choices.join(' ')}'`);
} else {
parts.push('-x');
}
}
emit(parts.join(' '));
}
emit('');
}
}
return lines.join('\n') + '\n';
}
// ============================================================
// Bash completion generator
// ============================================================
function generateBash(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string): void => { lines.push(s); };
const BIN = root.name;
emit(`# ${BIN} bash completions -- auto-generated by scripts/generate-completions.ts`);
emit('# DO NOT EDIT MANUALLY -- run: pnpm completions:generate');
emit('');
const allCmds = collectCommands(root);
const topCmds = root.subcommands.filter((c) => !c.hidden).map((c) => c.name);
emit(`_${BIN}() {`);
emit(' local cur prev words cword');
emit(' _init_completion || return');
emit('');
emit(` local top_commands="${topCmds.join(' ')}"`);
emit('');
// Build chain of subcommands from command line
emit(' # Extract the subcommand chain (skip options and their values)');
emit(' local -a subcmd_chain=()');
emit(' local i skip_next=false');
emit(' for ((i=1; i < cword; i++)); do');
emit(' if $skip_next; then skip_next=false; continue; fi');
emit(' case "${words[i]}" in');
emit(' -*) ;; # skip options');
emit(' *) subcmd_chain+=("${words[i]}") ;;');
emit(' esac');
emit(' done');
emit('');
emit(' local chain_len=${#subcmd_chain[@]}');
emit(' local chain_str="${subcmd_chain[*]}"');
emit('');
// Build case statement for each command path
emit(' case "$chain_str" in');
// Start with the deepest paths first to match longest
const sortedCmds = [...allCmds].sort((a, b) => b.path.length - a.path.length);
for (const { path, cmd } of sortedCmds) {
if (cmd.hidden) continue;
const pathStr = path.join(' ');
const visibleSubs = cmd.subcommands.filter((s) => !s.hidden).map((s) => s.name);
const optFlags: string[] = [];
for (const opt of cmd.options) {
if (opt.short) optFlags.push(opt.short);
optFlags.push(opt.long);
}
optFlags.push('-h', '--help');
const completions = [...visibleSubs, ...optFlags].join(' ');
emit(` "${pathStr}")`);
emit(` COMPREPLY=($(compgen -W "${completions}" -- "$cur"))`);
emit(' return ;;');
}
// Top-level (no subcommand yet)
emit(' "")');
emit(` COMPREPLY=($(compgen -W "$top_commands -h --help -v --version" -- "$cur"))`);
emit(' return ;;');
// Default
emit(' *)');
emit(' COMPREPLY=($(compgen -W "-h --help" -- "$cur"))');
emit(' return ;;');
emit(' esac');
emit('}');
emit('');
emit(`complete -F _${BIN} ${BIN}`);
return lines.join('\n') + '\n';
}
// ============================================================
// Main
// ============================================================
async function main(): Promise<void> {
const mode = process.argv[2] ?? '';
let tree: CmdInfo;
try {
tree = await extractTree();
} catch (err) {
console.error('Failed to extract command tree from createProgram().');
console.error('Make sure workspace packages are built: pnpm build');
console.error(err);
process.exit(1);
}
const fishContent = generateFish(tree);
const bashContent = generateBash(tree);
const completionsDir = join(ROOT, 'completions');
const fishPath = join(completionsDir, 'labctl.fish');
const bashPath = join(completionsDir, 'labctl.bash');
if (mode === '--check') {
let stale = false;
try {
const currentFish = readFileSync(fishPath, 'utf-8');
if (currentFish !== fishContent) {
console.error('completions/labctl.fish is stale');
stale = true;
}
} catch {
console.error('completions/labctl.fish does not exist');
stale = true;
}
try {
const currentBash = readFileSync(bashPath, 'utf-8');
if (currentBash !== bashContent) {
console.error('completions/labctl.bash is stale');
stale = true;
}
} catch {
console.error('completions/labctl.bash does not exist');
stale = true;
}
if (stale) {
console.error('Run: pnpm completions:generate');
process.exit(1);
}
console.log('Completions are up to date.');
process.exit(0);
}
if (mode === '--write') {
mkdirSync(completionsDir, { recursive: true });
writeFileSync(fishPath, fishContent);
writeFileSync(bashPath, bashContent);
console.log(`Wrote ${fishPath}`);
console.log(`Wrote ${bashPath}`);
process.exit(0);
}
// Default: print to stdout
console.log('=== completions/labctl.fish ===');
console.log(fishContent);
console.log('=== completions/labctl.bash ===');
console.log(bashContent);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});