feat: автодополнение и детект режима запуска

- Добавлены служебные команды __list-templates/__list-vars и генерация completion для bash/
    zsh/fish
  - Введён детект режимов запуска (npx/local/direct/global) и применён в проверке обновлений
  - Обновлены help и документация (README, FEATURES)
This commit is contained in:
2026-01-27 13:07:44 +03:00
parent bdb7180d92
commit f0daed180f
7 changed files with 332 additions and 14 deletions

View File

@@ -13,6 +13,14 @@ npm i -g @gromlab/create
повторный запрос появится через 24 часа. Чтобы пропустить проверку, используйте
флаг `--skip-update`.
## Автодополнение
Сгенерируйте скрипт и подключите его в оболочке:
```bash
gromlab-create completion --shell bash
```
## Использование

View File

@@ -40,13 +40,32 @@
- `-h`/`--help` — вывод справки.
- `--skip-update` — запуск без проверки обновлений.
## Автодополнение
- Генерация скрипта автодополнения для bash/zsh/fish: `gromlab-create completion --shell <shell>`.
- Автодополнение доступно только для глобальной установки CLI.
- Автодополнение шаблонов из `.templates/` и переменных `--<var>` выбранного шаблона (кроме `name`).
## Обновления CLI
- При запуске (не через `npx`) проверяется доступность новой версии в npm.
- Если пользователь отвечает «нет», повторный запрос появится через 24 часа.
- В неинтерактивном режиме (без TTY) запрос не показывается.
## Режимы запуска
- CLI определяет режим запуска: `npx`, `local`, `direct`, `global`.
- `npx` — запуск через `npx`/`npm exec` (проверка обновлений не выполняется).
- `local` — запуск из `node_modules/.bin`.
- `direct` — запуск через `node ./dist/cli.js` в текущем проекте.
- `global` — глобально установленный CLI.
## Бины
- Основной бин: `gromlab-create`.
- Дополнительный алиас: `create`.
## Служебные команды
- `__list-templates` — выводит список доступных шаблонов (для автодополнения).
- `__list-vars <шаблон>` — выводит переменные выбранного шаблона (кроме `name`).

View File

@@ -10,6 +10,9 @@ export function printHelp() {
' <имя> Значение переменной name (обязательный аргумент)',
' [путь] Папка вывода (по умолчанию: текущая директория)',
'',
'Команды:',
' completion --shell <bash|zsh|fish> Сгенерировать скрипт автодополнения',
'',
'Опции:',
' --<var> <value> Переменная шаблона (поддерживается любой --key <value>)',
' --overwrite Перезаписывать существующие файлы',

View File

@@ -6,6 +6,7 @@ import { PlanItem } from './types';
import { normalizeArgs, resolveTemplateContext } from './validation';
import { buildPlan, getCollisions, getExistingDirs, getRoots, getTopLevelDirs, writePlan } from './plan';
import { maybeHandleUpdate } from './update';
import { handleInternalCommand } from './completion';
function resolvePath(baseDir: string, inputPath: string): string {
if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
@@ -13,7 +14,10 @@ function resolvePath(baseDir: string, inputPath: string): string {
}
async function run() {
const shouldContinue = await maybeHandleUpdate(process.argv.slice(2));
const args = process.argv.slice(2);
if (handleInternalCommand(args)) return;
const shouldContinue = await maybeHandleUpdate(args);
if (!shouldContinue) {
return;
}

245
src/completion.ts Normal file
View File

@@ -0,0 +1,245 @@
import * as fs from 'fs';
import * as path from 'path';
import { collectTemplateVariables, listTemplateNames } from './templateUtils';
import { detectRunMode } from './runtime';
const BIN_NAMES = ['gromlab-create', 'create'];
function templatesDir(cwd: string): string {
return path.resolve(cwd, '.templates');
}
function listTemplates(cwd: string): string[] {
const dir = templatesDir(cwd);
try {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
return listTemplateNames(dir);
} catch {
return [];
}
}
function listTemplateVars(cwd: string, templateName: string): string[] {
const dir = path.join(templatesDir(cwd), templateName);
try {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
const vars = Array.from(collectTemplateVariables(dir))
.filter((name) => name !== 'name')
.map((name) => `--${name}`)
.sort();
return vars;
} catch {
return [];
}
}
function printLines(items: string[]) {
if (items.length === 0) return;
console.log(items.join('\n'));
}
function buildBashCompletion(): string {
return [
'# bash completion for gromlab-create',
'',
'_gromlab_create_list_templates() {',
' gromlab-create __list-templates --skip-update 2>/dev/null',
'}',
'',
'_gromlab_create_list_vars() {',
' local template="$1"',
' if [[ -z "$template" ]]; then',
' return',
' fi',
' gromlab-create __list-vars "$template" --skip-update 2>/dev/null',
'}',
'',
'_gromlab_create_completions() {',
' local cur="${COMP_WORDS[COMP_CWORD]}"',
' local cword=$COMP_CWORD',
' local template=""',
' local w',
' for ((i=1; i<${#COMP_WORDS[@]}; i++)); do',
' w="${COMP_WORDS[i]}"',
' if [[ "$w" == -* ]]; then',
' continue',
' fi',
' template="$w"',
' break',
' done',
'',
' if [[ $cword -eq 1 ]]; then',
' COMPREPLY=( $(compgen -W "$(_gromlab_create_list_templates)" -- "$cur") )',
' return',
' fi',
'',
' if [[ "$cur" == --* ]]; then',
' local opts="--overwrite --skip-update --help -h"',
' local vars=""',
' if [[ -n "$template" ]]; then',
' vars="$(_gromlab_create_list_vars "$template")"',
' fi',
' COMPREPLY=( $(compgen -W "$opts $vars" -- "$cur") )',
' return',
' fi',
'',
' compopt -o default 2>/dev/null',
'}',
'',
`complete -F _gromlab_create_completions ${BIN_NAMES.join(' ')}`,
''
].join('\n');
}
function buildZshCompletion(): string {
return [
'#compdef gromlab-create create',
'',
'_gromlab_create_list_templates() {',
' gromlab-create __list-templates --skip-update 2>/dev/null',
'}',
'',
'_gromlab_create_list_vars() {',
' local template="$1"',
' if [[ -z "$template" ]]; then',
' return',
' fi',
' gromlab-create __list-vars "$template" --skip-update 2>/dev/null',
'}',
'',
'_gromlab_create() {',
' local -a opts vars',
' local template=""',
' local w',
' for w in "${words[@]:1}"; do',
' if [[ "$w" == -* ]]; then',
' continue',
' fi',
' template="$w"',
' break',
' done',
'',
' if (( CURRENT == 2 )); then',
' _values "templates" $(_gromlab_create_list_templates)',
' return',
' fi',
'',
' if [[ "${words[CURRENT]}" == --* ]]; then',
' opts=(--overwrite --skip-update --help -h)',
' if [[ -n "$template" ]]; then',
' vars=($(_gromlab_create_list_vars "$template"))',
' else',
' vars=()',
' fi',
' _describe -t options "options" opts',
' _describe -t vars "vars" vars',
' return',
' fi',
'',
' _files',
'}',
'',
'compdef _gromlab_create gromlab-create create',
''
].join('\n');
}
function buildFishCompletion(): string {
return [
'function __gromlab_create_template',
' set -l tokens (commandline -opc)',
' for i in (seq 2 (count $tokens))',
' set -l token $tokens[$i]',
' if not string match -qr "^-" -- $token',
' echo $token',
' return',
' end',
' end',
'end',
'',
'function __gromlab_create_list_templates',
' gromlab-create __list-templates --skip-update 2>/dev/null',
'end',
'',
'function __gromlab_create_list_vars',
' set -l template (__gromlab_create_template)',
' if test -n "$template"',
' gromlab-create __list-vars $template --skip-update 2>/dev/null',
' end',
'end',
'',
'for cmd in gromlab-create create',
' complete -c $cmd -n "__fish_use_subcommand" -a "(__gromlab_create_list_templates)"',
' complete -c $cmd -l overwrite -d "Перезаписывать существующие файлы"',
' complete -c $cmd -l skip-update -d "Не проверять обновления CLI"',
' complete -c $cmd -s h -l help -d "Справка"',
' complete -c $cmd -n "string match -qr \"^--\" (commandline -ct)" -a "(__gromlab_create_list_vars)"',
'end',
''
].join('\n');
}
function resolveShell(args: string[]): string | undefined {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--shell' && args[i + 1]) return args[i + 1];
if (arg.startsWith('--shell=')) return arg.slice('--shell='.length);
}
const candidate = args.find((arg) => !arg.startsWith('-'));
return candidate;
}
function printCompletionUsage() {
console.log('Использование:');
console.log(' gromlab-create completion --shell <bash|zsh|fish>');
}
export function handleInternalCommand(args: string[], cwd: string = process.cwd()): boolean {
if (args.length === 0) return false;
const [command, ...rest] = args;
if (command === '__list-templates') {
printLines(listTemplates(cwd));
return true;
}
if (command === '__list-vars') {
const templateName = rest.find((arg) => !arg.startsWith('-'));
if (templateName) {
printLines(listTemplateVars(cwd, templateName));
}
return true;
}
if (command === 'completion') {
if (detectRunMode() !== 'global') {
console.error('Автодополнение доступно только для глобальной установки CLI.');
process.exitCode = 1;
return true;
}
const shell = resolveShell(rest);
if (!shell) {
printCompletionUsage();
process.exitCode = 1;
return true;
}
const normalized = shell.trim().toLowerCase();
let script = '';
if (normalized === 'bash') script = buildBashCompletion();
if (normalized === 'zsh') script = buildZshCompletion();
if (normalized === 'fish') script = buildFishCompletion();
if (!script) {
console.error('Неизвестный shell. Доступно: bash, zsh, fish.');
process.exitCode = 1;
return true;
}
console.log(script);
return true;
}
return false;
}

50
src/runtime.ts Normal file
View File

@@ -0,0 +1,50 @@
import * as path from 'path';
export type RunMode = 'npx' | 'local' | 'direct' | 'global';
function hasPathSegment(input: string, segment: string): boolean {
const sep = path.sep;
const altSep = sep === '/' ? '\\' : '/';
const pattern = `${sep}${segment}${sep}`;
const altPattern = `${altSep}${segment}${altSep}`;
return input.includes(pattern) || input.includes(altPattern);
}
function isNpxInvocation(argv1: string, env: NodeJS.ProcessEnv): boolean {
const npmCommand = env.npm_command ?? '';
const npmExecPath = env.npm_execpath ?? '';
if (hasPathSegment(argv1, '_npx')) return true;
if (npmCommand === 'exec') return true;
if (npmExecPath.includes('npx-cli.js')) return true;
return false;
}
function isLocalInvocation(argv1: string): boolean {
return hasPathSegment(argv1, 'node_modules') && hasPathSegment(argv1, '.bin');
}
function isDirectInvocation(argv0: string, argv1: string): boolean {
if (!argv0 || !argv1) return false;
const base = path.basename(argv0).toLowerCase();
const isNode = base === 'node' || base === 'node.exe';
if (!isNode) return false;
const resolvedArgv1 = path.resolve(argv1);
const localDist = path.resolve(process.cwd(), 'dist', 'cli.js');
return resolvedArgv1 === localDist;
}
export function detectRunMode(argv: string[] = process.argv, env: NodeJS.ProcessEnv = process.env): RunMode {
const argv0 = argv[0] ?? '';
const argv1 = argv[1] ?? '';
if (isNpxInvocation(argv1, env)) return 'npx';
if (isLocalInvocation(argv1)) return 'local';
if (isDirectInvocation(argv0, argv1)) return 'direct';
return 'global';
}
export function isGlobalInvocation(argv: string[] = process.argv, env: NodeJS.ProcessEnv = process.env): boolean {
return detectRunMode(argv, env) === 'global';
}

View File

@@ -5,23 +5,12 @@ import * as https from 'https';
import * as readline from 'readline';
import { spawnSync } from 'child_process';
import ora = require('ora');
import { detectRunMode } from './runtime';
const PACKAGE_NAME = '@gromlab/create';
const UPDATE_TIMEOUT_MS = 2000;
const UPDATE_PROMPT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
function isNpxInvocation(): boolean {
const argv1 = process.argv[1] ?? '';
const npmCommand = process.env.npm_command ?? '';
const npmExecPath = process.env.npm_execpath ?? '';
if (argv1.includes(`${path.sep}_npx${path.sep}`)) return true;
if (npmCommand === 'exec') return true;
if (npmExecPath.includes('npx-cli.js')) return true;
return false;
}
function getUpdateStatePath(): string {
const xdgHome = process.env.XDG_CONFIG_HOME;
if (xdgHome) {
@@ -180,7 +169,7 @@ function hasSkipUpdateFlag(args: string[]): boolean {
}
export async function maybeHandleUpdate(args: string[]): Promise<boolean> {
if (isNpxInvocation()) return true;
if (detectRunMode() === 'npx') return true;
if (hasSkipUpdateFlag(args)) return true;
if (!isInteractive()) return true;