diff --git a/installer/package.json b/installer/package.json index 04cd9997..c764709a 100644 --- a/installer/package.json +++ b/installer/package.json @@ -1,6 +1,6 @@ { "name": "syncable-cli-skills", - "version": "0.1.3", + "version": "0.1.5", "type": "module", "description": "Install Syncable CLI skills for AI coding agents (Claude Code, Cursor, Windsurf, Codex, Gemini CLI)", "license": "GPL-3.0", diff --git a/installer/src/index.ts b/installer/src/index.ts index f01e740c..ab78d98d 100644 --- a/installer/src/index.ts +++ b/installer/src/index.ts @@ -210,7 +210,8 @@ program if (syncCtlStatus.status === 'ok') { console.log(chalk.green(` ✓ sync-ctl v${syncCtlStatus.version}`)); } else if (syncCtlStatus.status === 'outdated') { - console.log(chalk.yellow(` ⚠ sync-ctl v${syncCtlStatus.version} (outdated)`)); + const latestInfo = syncCtlStatus.latestVersion ? ` → ${syncCtlStatus.latestVersion} available` : ''; + console.log(chalk.yellow(` ⚠ sync-ctl v${syncCtlStatus.version} (outdated${latestInfo})`)); } else { console.log(chalk.red(' ✗ sync-ctl not found')); } @@ -236,21 +237,30 @@ program if (syncCtlStatus.status === 'missing' || syncCtlStatus.status === 'outdated') { const cargoNow = await checkCargo(); if (cargoNow.status === 'ok') { - const message = syncCtlStatus.status === 'outdated' - ? 'Update syncable-cli via cargo?' - : 'Install syncable-cli via cargo?'; - const { installCli } = opts.yes - ? { installCli: true } - : await inquirer.prompt([{ type: 'confirm', name: 'installCli', message, default: true }]); - - if (installCli) { - const spinner = ora(' Running: cargo install syncable-cli').start(); - const force = syncCtlStatus.status === 'outdated'; - const success = await installSyncCtl(force); + if (syncCtlStatus.status === 'outdated') { + // Always auto-upgrade to latest — no prompt needed + const latestLabel = syncCtlStatus.latestVersion ? ` to v${syncCtlStatus.latestVersion}` : ''; + const spinner = ora(` Upgrading sync-ctl${latestLabel}...`).start(); + const success = await installSyncCtl(true); // force = true for upgrade if (success) { - spinner.succeed(' sync-ctl installed'); + spinner.succeed(` sync-ctl upgraded${latestLabel}`); } else { - spinner.fail(' Failed to install sync-ctl. Try: cargo install syncable-cli'); + spinner.fail(' Failed to upgrade sync-ctl. Try: cargo install syncable-cli --force'); + } + } else { + // Missing — ask to install + const { installCli } = opts.yes + ? { installCli: true } + : await inquirer.prompt([{ type: 'confirm', name: 'installCli', message: 'Install syncable-cli via cargo?', default: true }]); + + if (installCli) { + const spinner = ora(' Running: cargo install syncable-cli').start(); + const success = await installSyncCtl(false); + if (success) { + spinner.succeed(' sync-ctl installed'); + } else { + spinner.fail(' Failed to install sync-ctl. Try: cargo install syncable-cli'); + } } } } @@ -441,7 +451,8 @@ program const projectOnlyFlag = opts.projectOnly ? ['--project-only'] : []; const verboseFlag = opts.verbose ? ['--verbose'] : []; await program.commands.find((c) => c.name() === 'uninstall')!.parseAsync(['node', 'x', ...agentsFlag, ...yesFlag]); - await program.commands.find((c) => c.name() === 'install')!.parseAsync(['node', 'x', '--skip-cli', ...agentsFlag, ...yesFlag, ...dryRunFlag, ...globalOnlyFlag, ...projectOnlyFlag, ...verboseFlag]); + // NOTE: Do NOT pass --skip-cli here — update must always check for and install the latest sync-ctl + await program.commands.find((c) => c.name() === 'install')!.parseAsync(['node', 'x', ...agentsFlag, ...yesFlag, ...dryRunFlag, ...globalOnlyFlag, ...projectOnlyFlag, ...verboseFlag]); }); program diff --git a/installer/src/prerequisites/check.ts b/installer/src/prerequisites/check.ts index ad3761ab..93f6cfcd 100644 --- a/installer/src/prerequisites/check.ts +++ b/installer/src/prerequisites/check.ts @@ -2,10 +2,12 @@ import { execCommand, commandExists, parseVersion, compareVersions, cargoBinDir import { MIN_SYNC_CTL_VERSION } from '../constants.js'; import fs from 'fs'; import path from 'path'; +import https from 'https'; export interface PrereqStatus { status: 'ok' | 'missing' | 'outdated'; version?: string; + latestVersion?: string; } export function checkNodeVersion(): PrereqStatus { @@ -31,6 +33,34 @@ export async function checkCargo(): Promise { } } +/** + * Fetch the latest syncable-cli version from crates.io. + * Returns null if the lookup fails (network error, timeout, etc.) + */ +export async function getLatestCratesVersion(): Promise { + return new Promise((resolve) => { + const req = https.get( + 'https://crates.io/api/v1/crates/syncable-cli', + { headers: { 'User-Agent': 'syncable-cli-skills-installer' }, timeout: 5_000 }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { data += chunk; }); + res.on('end', () => { + try { + const json = JSON.parse(data); + const version = json?.crate?.max_version || json?.versions?.[0]?.num; + resolve(version || null); + } catch { + resolve(null); + } + }); + }, + ); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + export async function checkSyncCtl(): Promise { try { const { stdout } = await execCommand('sync-ctl --version'); @@ -39,12 +69,24 @@ export async function checkSyncCtl(): Promise { return { status: 'ok', version: stdout.trim() }; } + const currentStr = `${version.major}.${version.minor}.${version.patch}`; + + // First check: is it below the hard minimum? const minVersion = parseVersion(MIN_SYNC_CTL_VERSION); if (minVersion && compareVersions(version, minVersion) < 0) { - return { status: 'outdated', version: `${version.major}.${version.minor}.${version.patch}` }; + return { status: 'outdated', version: currentStr }; + } + + // Second check: is there a newer version on crates.io? + const latestStr = await getLatestCratesVersion(); + if (latestStr) { + const latest = parseVersion(latestStr); + if (latest && compareVersions(version, latest) < 0) { + return { status: 'outdated', version: currentStr, latestVersion: latestStr }; + } } - return { status: 'ok', version: `${version.major}.${version.minor}.${version.patch}` }; + return { status: 'ok', version: currentStr }; } catch { return { status: 'missing' }; }