feat: автодополнение и детект режима запуска
- Добавлены служебные команды __list-templates/__list-vars и генерация completion для bash/
zsh/fish
- Введён детект режимов запуска (npx/local/direct/global) и применён в проверке обновлений
- Обновлены help и документация (README, FEATURES)
This commit is contained in:
@@ -13,6 +13,14 @@ npm i -g @gromlab/create
|
|||||||
повторный запрос появится через 24 часа. Чтобы пропустить проверку, используйте
|
повторный запрос появится через 24 часа. Чтобы пропустить проверку, используйте
|
||||||
флаг `--skip-update`.
|
флаг `--skip-update`.
|
||||||
|
|
||||||
|
## Автодополнение
|
||||||
|
|
||||||
|
Сгенерируйте скрипт и подключите его в оболочке:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gromlab-create completion --shell bash
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,32 @@
|
|||||||
- `-h`/`--help` — вывод справки.
|
- `-h`/`--help` — вывод справки.
|
||||||
- `--skip-update` — запуск без проверки обновлений.
|
- `--skip-update` — запуск без проверки обновлений.
|
||||||
|
|
||||||
|
## Автодополнение
|
||||||
|
|
||||||
|
- Генерация скрипта автодополнения для bash/zsh/fish: `gromlab-create completion --shell <shell>`.
|
||||||
|
- Автодополнение доступно только для глобальной установки CLI.
|
||||||
|
- Автодополнение шаблонов из `.templates/` и переменных `--<var>` выбранного шаблона (кроме `name`).
|
||||||
|
|
||||||
## Обновления CLI
|
## Обновления CLI
|
||||||
|
|
||||||
- При запуске (не через `npx`) проверяется доступность новой версии в npm.
|
- При запуске (не через `npx`) проверяется доступность новой версии в npm.
|
||||||
- Если пользователь отвечает «нет», повторный запрос появится через 24 часа.
|
- Если пользователь отвечает «нет», повторный запрос появится через 24 часа.
|
||||||
- В неинтерактивном режиме (без TTY) запрос не показывается.
|
- В неинтерактивном режиме (без TTY) запрос не показывается.
|
||||||
|
|
||||||
|
## Режимы запуска
|
||||||
|
|
||||||
|
- CLI определяет режим запуска: `npx`, `local`, `direct`, `global`.
|
||||||
|
- `npx` — запуск через `npx`/`npm exec` (проверка обновлений не выполняется).
|
||||||
|
- `local` — запуск из `node_modules/.bin`.
|
||||||
|
- `direct` — запуск через `node ./dist/cli.js` в текущем проекте.
|
||||||
|
- `global` — глобально установленный CLI.
|
||||||
|
|
||||||
## Бины
|
## Бины
|
||||||
|
|
||||||
- Основной бин: `gromlab-create`.
|
- Основной бин: `gromlab-create`.
|
||||||
- Дополнительный алиас: `create`.
|
- Дополнительный алиас: `create`.
|
||||||
|
|
||||||
|
## Служебные команды
|
||||||
|
|
||||||
|
- `__list-templates` — выводит список доступных шаблонов (для автодополнения).
|
||||||
|
- `__list-vars <шаблон>` — выводит переменные выбранного шаблона (кроме `name`).
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export function printHelp() {
|
|||||||
' <имя> Значение переменной name (обязательный аргумент)',
|
' <имя> Значение переменной name (обязательный аргумент)',
|
||||||
' [путь] Папка вывода (по умолчанию: текущая директория)',
|
' [путь] Папка вывода (по умолчанию: текущая директория)',
|
||||||
'',
|
'',
|
||||||
|
'Команды:',
|
||||||
|
' completion --shell <bash|zsh|fish> Сгенерировать скрипт автодополнения',
|
||||||
|
'',
|
||||||
'Опции:',
|
'Опции:',
|
||||||
' --<var> <value> Переменная шаблона (поддерживается любой --key <value>)',
|
' --<var> <value> Переменная шаблона (поддерживается любой --key <value>)',
|
||||||
' --overwrite Перезаписывать существующие файлы',
|
' --overwrite Перезаписывать существующие файлы',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PlanItem } from './types';
|
|||||||
import { normalizeArgs, resolveTemplateContext } from './validation';
|
import { normalizeArgs, resolveTemplateContext } from './validation';
|
||||||
import { buildPlan, getCollisions, getExistingDirs, getRoots, getTopLevelDirs, writePlan } from './plan';
|
import { buildPlan, getCollisions, getExistingDirs, getRoots, getTopLevelDirs, writePlan } from './plan';
|
||||||
import { maybeHandleUpdate } from './update';
|
import { maybeHandleUpdate } from './update';
|
||||||
|
import { handleInternalCommand } from './completion';
|
||||||
|
|
||||||
function resolvePath(baseDir: string, inputPath: string): string {
|
function resolvePath(baseDir: string, inputPath: string): string {
|
||||||
if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
|
if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
|
||||||
@@ -13,7 +14,10 @@ function resolvePath(baseDir: string, inputPath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
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) {
|
if (!shouldContinue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
245
src/completion.ts
Normal file
245
src/completion.ts
Normal 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
50
src/runtime.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -5,23 +5,12 @@ import * as https from 'https';
|
|||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import ora = require('ora');
|
import ora = require('ora');
|
||||||
|
import { detectRunMode } from './runtime';
|
||||||
|
|
||||||
const PACKAGE_NAME = '@gromlab/create';
|
const PACKAGE_NAME = '@gromlab/create';
|
||||||
const UPDATE_TIMEOUT_MS = 2000;
|
const UPDATE_TIMEOUT_MS = 2000;
|
||||||
const UPDATE_PROMPT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
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 {
|
function getUpdateStatePath(): string {
|
||||||
const xdgHome = process.env.XDG_CONFIG_HOME;
|
const xdgHome = process.env.XDG_CONFIG_HOME;
|
||||||
if (xdgHome) {
|
if (xdgHome) {
|
||||||
@@ -180,7 +169,7 @@ function hasSkipUpdateFlag(args: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function maybeHandleUpdate(args: string[]): Promise<boolean> {
|
export async function maybeHandleUpdate(args: string[]): Promise<boolean> {
|
||||||
if (isNpxInvocation()) return true;
|
if (detectRunMode() === 'npx') return true;
|
||||||
if (hasSkipUpdateFlag(args)) return true;
|
if (hasSkipUpdateFlag(args)) return true;
|
||||||
if (!isInteractive()) return true;
|
if (!isInteractive()) return true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user