feat: add gateway lock, shell completion, and tailscale serve (Tier 4 features 1-3)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateBashCompletion, generateZshCompletion, generateFishCompletion } from './completion.js';
|
||||
|
||||
const EXPECTED_SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
|
||||
|
||||
describe('generateBashCompletion', () => {
|
||||
it('generates valid bash completion', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('_flynn_completions');
|
||||
expect(script).toContain('complete -F _flynn_completions flynn');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateBashCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains global options', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('--help');
|
||||
expect(script).toContain('--version');
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateBashCompletion();
|
||||
expect(script).toContain('--config');
|
||||
expect(script).toContain('--fullscreen');
|
||||
expect(script).toContain('--no-tools');
|
||||
expect(script).toContain('--raw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateZshCompletion', () => {
|
||||
it('generates valid zsh completion', () => {
|
||||
const script = generateZshCompletion();
|
||||
expect(script).toContain('#compdef flynn');
|
||||
expect(script).toContain('compdef _flynn flynn');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateZshCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateZshCompletion();
|
||||
expect(script).toContain('--config');
|
||||
expect(script).toContain('--fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFishCompletion', () => {
|
||||
it('generates valid fish completion', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain('complete -c flynn');
|
||||
expect(script).toContain('__fish_use_subcommand');
|
||||
});
|
||||
|
||||
it('contains all subcommands', () => {
|
||||
const script = generateFishCompletion();
|
||||
for (const cmd of EXPECTED_SUBCOMMANDS) {
|
||||
expect(script).toContain(cmd);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains subcommand-specific options', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain('__fish_seen_subcommand_from start');
|
||||
expect(script).toContain('__fish_seen_subcommand_from tui');
|
||||
});
|
||||
|
||||
it('contains shell type completions for completion subcommand', () => {
|
||||
const script = generateFishCompletion();
|
||||
expect(script).toContain("'bash zsh fish'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Command } from 'commander';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
|
||||
|
||||
const SUBCOMMAND_OPTIONS: Record<string, string[]> = {
|
||||
start: ['-c', '--config'],
|
||||
tui: ['-c', '--config', '-f', '--fullscreen'],
|
||||
send: ['-c', '--config', '--no-tools'],
|
||||
config: ['-c', '--config', '--raw'],
|
||||
completion: ['--install'],
|
||||
};
|
||||
|
||||
export function generateBashCompletion(): string {
|
||||
const subcmds = SUBCOMMANDS.join(' ');
|
||||
const cases = Object.entries(SUBCOMMAND_OPTIONS)
|
||||
.map(([cmd, opts]) => ` ${cmd}) COMPREPLY=( $(compgen -W "${opts.join(' ')}" -- "$cur") ) ;;`)
|
||||
.join('\n');
|
||||
|
||||
return `# Flynn bash completion — generated by 'flynn completion bash'
|
||||
_flynn_completions() {
|
||||
local cur prev subcmd
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "${subcmds} --help --version" -- "$cur") )
|
||||
return
|
||||
fi
|
||||
|
||||
subcmd="\${COMP_WORDS[1]}"
|
||||
case "$subcmd" in
|
||||
${cases}
|
||||
completion) COMPREPLY=( $(compgen -W "bash zsh fish --install" -- "$cur") ) ;;
|
||||
*) COMPREPLY=() ;;
|
||||
esac
|
||||
}
|
||||
complete -F _flynn_completions flynn
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateZshCompletion(): string {
|
||||
const subcmds = SUBCOMMANDS.map(c => `'${c}:${c} subcommand'`).join(' ');
|
||||
const cases = Object.entries(SUBCOMMAND_OPTIONS)
|
||||
.map(([cmd, opts]) => {
|
||||
const flags = opts.filter(o => o.startsWith('--')).map(o => `'${o}'`).join(' ');
|
||||
return ` ${cmd}) compadd ${flags} ;;`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `#compdef flynn
|
||||
# Flynn zsh completion — generated by 'flynn completion zsh'
|
||||
_flynn() {
|
||||
local -a subcmds
|
||||
subcmds=(${subcmds})
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' subcmds
|
||||
return
|
||||
fi
|
||||
|
||||
case "\${words[2]}" in
|
||||
${cases}
|
||||
completion) compadd bash zsh fish '--install' ;;
|
||||
*) ;;
|
||||
esac
|
||||
}
|
||||
compdef _flynn flynn
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateFishCompletion(): string {
|
||||
const lines = [
|
||||
`# Flynn fish completion — generated by 'flynn completion fish'`,
|
||||
`# Disable file completions by default`,
|
||||
`complete -c flynn -f`,
|
||||
``,
|
||||
`# Subcommands`,
|
||||
];
|
||||
|
||||
for (const cmd of SUBCOMMANDS) {
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -a '${cmd}' -d '${cmd} subcommand'`);
|
||||
}
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l help -d 'Show help'`);
|
||||
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l version -d 'Show version'`);
|
||||
lines.push('');
|
||||
lines.push('# Subcommand options');
|
||||
|
||||
for (const [cmd, opts] of Object.entries(SUBCOMMAND_OPTIONS)) {
|
||||
for (const opt of opts) {
|
||||
const flag = opt.replace(/^-+/, '');
|
||||
const short = opt.startsWith('--') ? '' : ` -s ${flag}`;
|
||||
const long = opt.startsWith('--') ? ` -l ${flag}` : '';
|
||||
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from ${cmd}'${short}${long}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'Shell type'`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getInstallPath(shell: string): string {
|
||||
const home = homedir();
|
||||
switch (shell) {
|
||||
case 'bash': return resolve(home, '.local/share/bash-completion/completions/flynn');
|
||||
case 'zsh': return resolve(home, '.zfunc/_flynn');
|
||||
case 'fish': return resolve(home, '.config/fish/completions/flynn.fish');
|
||||
default: throw new Error(`Unknown shell: ${shell}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCompletionCommand(program: Command): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion script')
|
||||
.argument('<shell>', 'Shell type: bash, zsh, or fish')
|
||||
.option('--install', 'Install to standard location')
|
||||
.action((shell: string, opts: { install?: boolean }) => {
|
||||
const generators: Record<string, () => string> = {
|
||||
bash: generateBashCompletion,
|
||||
zsh: generateZshCompletion,
|
||||
fish: generateFishCompletion,
|
||||
};
|
||||
|
||||
const generator = generators[shell];
|
||||
if (!generator) {
|
||||
console.error(`Unknown shell: ${shell}. Supported: bash, zsh, fish`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const script = generator();
|
||||
|
||||
if (opts.install) {
|
||||
const installPath = getInstallPath(shell);
|
||||
mkdirSync(resolve(installPath, '..'), { recursive: true });
|
||||
writeFileSync(installPath, script, 'utf-8');
|
||||
console.log(`Completion script installed to: ${installPath}`);
|
||||
if (shell === 'zsh') {
|
||||
console.log(`Ensure ~/.zfunc is in your fpath: fpath=(~/.zfunc $fpath)`);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(script);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,6 +185,22 @@ const checkSkills: Check = async (ctx) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkTailscale: Check = async (ctx) => {
|
||||
if (!ctx.config?.server?.tailscale?.serve) {
|
||||
return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' };
|
||||
}
|
||||
try {
|
||||
const { isTailscaleAvailable } = await import('../gateway/tailscale.js');
|
||||
const result = await isTailscaleAvailable();
|
||||
if (result.available) {
|
||||
return { status: 'pass', label: 'Tailscale Serve', detail: `(v${result.version})` };
|
||||
}
|
||||
return { status: 'fail', label: 'Tailscale Serve', detail: result.error ?? 'Tailscale not available' };
|
||||
} catch (err) {
|
||||
return { status: 'fail', label: 'Tailscale Serve', detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const allChecks: Check[] = [
|
||||
checkConfigExists,
|
||||
checkConfigParses,
|
||||
@@ -196,6 +212,7 @@ const allChecks: Check[] = [
|
||||
checkTelegram,
|
||||
checkMcpServers,
|
||||
checkSkills,
|
||||
checkTailscale,
|
||||
];
|
||||
|
||||
/** Run all doctor checks in order. Exported for testing. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerSessionsCommand } from './sessions.js';
|
||||
import { registerDoctorCommand } from './doctor.js';
|
||||
import { registerConfigCommand } from './config-cmd.js';
|
||||
import { registerTuiCommand } from './tui.js';
|
||||
import { registerCompletionCommand } from './completion.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command();
|
||||
@@ -21,6 +22,7 @@ export function createProgram(): Command {
|
||||
registerSessionsCommand(program);
|
||||
registerDoctorCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerCompletionCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user