feat: автообновление, шаблон zustand-store, документация

- Добавлено автоматическое обновление со спиннером (ora)
- Новый шаблон zustand-store
- Документация (FEATURES.md, LICENSE, README)
This commit is contained in:
2026-01-27 12:33:11 +03:00
parent d58eb04456
commit bdb7180d92
14 changed files with 725 additions and 124 deletions

225
src/update.ts Normal file
View File

@@ -0,0 +1,225 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as https from 'https';
import * as readline from 'readline';
import { spawnSync } from 'child_process';
import ora = require('ora');
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) {
return path.join(xdgHome, 'gromlab-create', 'update.json');
}
const appData = process.env.APPDATA;
if (appData) {
return path.join(appData, 'gromlab-create', 'update.json');
}
return path.join(os.homedir(), '.gromlab-create', 'update.json');
}
function readDeclinedAt(): number | undefined {
const statePath = getUpdateStatePath();
try {
const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw) as { declinedAt?: number };
if (typeof data.declinedAt === 'number') return data.declinedAt;
} catch {
return undefined;
}
return undefined;
}
function writeDeclinedAt(timestamp: number) {
const statePath = getUpdateStatePath();
try {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, JSON.stringify({ declinedAt: timestamp }), 'utf8');
} catch {
// ignore write errors
}
}
function clearDeclinedAt() {
const statePath = getUpdateStatePath();
try {
fs.unlinkSync(statePath);
} catch {
// ignore remove errors
}
}
function readCurrentVersion(): string | undefined {
try {
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
const raw = fs.readFileSync(packageJsonPath, 'utf8');
const data = JSON.parse(raw) as { version?: string };
return typeof data.version === 'string' ? data.version : undefined;
} catch {
return undefined;
}
}
function parseSemver(version: string): number[] | null {
const clean = version.split('+')[0].split('-')[0].trim();
if (!clean) return null;
const parts = clean.split('.').map((part) => Number(part));
if (parts.some((value) => Number.isNaN(value))) return null;
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function compareSemver(a: string, b: string): number {
const aParts = parseSemver(a);
const bParts = parseSemver(b);
if (!aParts || !bParts) return 0;
for (let i = 0; i < 3; i++) {
if (aParts[i] > bParts[i]) return 1;
if (aParts[i] < bParts[i]) return -1;
}
return 0;
}
function fetchLatestVersion(packageName: string, timeoutMs: number): Promise<string | undefined> {
const encodedName = encodeURIComponent(packageName);
const url = `https://registry.npmjs.org/${encodedName}/latest`;
return new Promise((resolve) => {
let settled = false;
const finish = (value?: string) => {
if (settled) return;
settled = true;
resolve(value);
};
const req = https.get(url, { headers: { Accept: 'application/json' } }, (res) => {
if (res.statusCode !== 200) {
res.resume();
finish();
return;
}
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(data) as { version?: string };
const version = typeof json.version === 'string' ? json.version : undefined;
finish(version);
} catch {
finish();
}
});
});
req.on('error', () => finish());
req.setTimeout(timeoutMs, () => {
req.destroy();
finish();
});
});
}
function isInteractive(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
function askYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
const normalized = answer.trim().toLowerCase();
resolve(['y', 'yes', 'д', 'да'].includes(normalized));
});
});
}
function runUpdate(packageName: string): boolean {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(npmCmd, ['i', '-g', `${packageName}@latest`], { stdio: 'inherit' });
if (result.error) {
console.error(String(result.error));
return false;
}
return result.status === 0;
}
function rerunCommand(args: string[]): never {
const binName = process.platform === 'win32' ? 'gromlab-create.cmd' : 'gromlab-create';
const result = spawnSync(binName, args, { stdio: 'inherit' });
if (result.error) {
const nodeResult = spawnSync(process.execPath, [process.argv[1], ...args], { stdio: 'inherit' });
process.exit(nodeResult.status ?? 1);
}
process.exit(result.status ?? 0);
}
function hasSkipUpdateFlag(args: string[]): boolean {
return args.some((arg) => arg === '--skip-update' || arg.startsWith('--skip-update='));
}
export async function maybeHandleUpdate(args: string[]): Promise<boolean> {
if (isNpxInvocation()) return true;
if (hasSkipUpdateFlag(args)) return true;
if (!isInteractive()) return true;
const currentVersion = readCurrentVersion();
if (!currentVersion) return true;
const declinedAt = readDeclinedAt();
if (declinedAt && Date.now() - declinedAt < UPDATE_PROMPT_COOLDOWN_MS) return true;
const spinner = ora('Проверка обновлений...').start();
const latestVersion = await fetchLatestVersion(PACKAGE_NAME, UPDATE_TIMEOUT_MS);
if (!latestVersion) {
spinner.stop();
return true;
}
if (compareSemver(latestVersion, currentVersion) <= 0) {
spinner.stop();
return true;
}
spinner.succeed(`Доступна новая версия: ${currentVersion}${latestVersion}`);
const shouldUpdate = await askYesNo('Обновить сейчас? [y/N] ');
if (!shouldUpdate) {
writeDeclinedAt(Date.now());
return true;
}
const updateSpinner = ora('Обновление...').start();
const updated = runUpdate(PACKAGE_NAME);
if (!updated) {
updateSpinner.fail('Не удалось обновить');
return true;
}
updateSpinner.succeed('Обновлено');
clearDeclinedAt();
rerunCommand(args);
return false;
}