From f0daed180ffb75641a70504cffd0e9ac31c53fa2 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Tue, 27 Jan 2026 13:07:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлены служебные команды __list-templates/__list-vars и генерация completion для bash/ zsh/fish - Введён детект режимов запуска (npx/local/direct/global) и применён в проверке обновлений - Обновлены help и документация (README, FEATURES) --- README.md | 8 ++ docs/ru/FEATURES.md | 19 ++++ src/args.ts | 3 + src/cli.ts | 6 +- src/completion.ts | 245 ++++++++++++++++++++++++++++++++++++++++++++ src/runtime.ts | 50 +++++++++ src/update.ts | 15 +-- 7 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 src/completion.ts create mode 100644 src/runtime.ts diff --git a/README.md b/README.md index bbe9419..215b99a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ npm i -g @gromlab/create повторный запрос появится через 24 часа. Чтобы пропустить проверку, используйте флаг `--skip-update`. +## Автодополнение + +Сгенерируйте скрипт и подключите его в оболочке: + +```bash +gromlab-create completion --shell bash +``` + ## Использование diff --git a/docs/ru/FEATURES.md b/docs/ru/FEATURES.md index 3d39164..f4ac509 100644 --- a/docs/ru/FEATURES.md +++ b/docs/ru/FEATURES.md @@ -40,13 +40,32 @@ - `-h`/`--help` — вывод справки. - `--skip-update` — запуск без проверки обновлений. +## Автодополнение + +- Генерация скрипта автодополнения для bash/zsh/fish: `gromlab-create completion --shell `. +- Автодополнение доступно только для глобальной установки CLI. +- Автодополнение шаблонов из `.templates/` и переменных `--` выбранного шаблона (кроме `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`). diff --git a/src/args.ts b/src/args.ts index 9e38f48..99f1970 100644 --- a/src/args.ts +++ b/src/args.ts @@ -10,6 +10,9 @@ export function printHelp() { ' <имя> Значение переменной name (обязательный аргумент)', ' [путь] Папка вывода (по умолчанию: текущая директория)', '', + 'Команды:', + ' completion --shell Сгенерировать скрипт автодополнения', + '', 'Опции:', ' -- Переменная шаблона (поддерживается любой --key )', ' --overwrite Перезаписывать существующие файлы', diff --git a/src/cli.ts b/src/cli.ts index 7c828cb..ebd07ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; } diff --git a/src/completion.ts b/src/completion.ts new file mode 100644 index 0000000..e33714f --- /dev/null +++ b/src/completion.ts @@ -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 '); +} + +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; +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..692f609 --- /dev/null +++ b/src/runtime.ts @@ -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'; +} diff --git a/src/update.ts b/src/update.ts index bfe37ba..960e6ba 100644 --- a/src/update.ts +++ b/src/update.ts @@ -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 { - if (isNpxInvocation()) return true; + if (detectRunMode() === 'npx') return true; if (hasSkipUpdateFlag(args)) return true; if (!isInteractive()) return true;