diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cfb7dd5..f8e4a1b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,8 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build - - run: npm run test:all + - run: npm run test + - run: npm run test:integration env: TIGRIS_STORAGE_ACCESS_KEY_ID: ${{ secrets.TIGRIS_STORAGE_ACCESS_KEY_ID }} TIGRIS_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.TIGRIS_STORAGE_SECRET_ACCESS_KEY }} diff --git a/src/constants.ts b/src/constants.ts index f44c4a1..5e8a0ce 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,5 +3,5 @@ export const DEFAULT_IAM_ENDPOINT = 'https://iam.storageapi.dev'; export const DEFAULT_MGMT_ENDPOINT = 'https://mgmt.storageapi.dev'; export const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@tigrisdata/cli/latest'; -export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // Check for updates every 24 hours -export const UPDATE_NOTIFY_INTERVAL_MS = 6 * 60 * 60 * 1000; // Show update notification every 6 hours +export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // Check for updates every 6 hours +export const UPDATE_NOTIFY_INTERVAL_MS = 1 * 60 * 60 * 1000; // Show update notification every 1 hour diff --git a/src/lib/update.ts b/src/lib/update.ts new file mode 100644 index 0000000..a811278 --- /dev/null +++ b/src/lib/update.ts @@ -0,0 +1,62 @@ +import { failWithError } from '@utils/exit.js'; +import { + msg, + printAlreadyDone, + printStart, + printSuccess, +} from '@utils/messages.js'; +import { getFormat } from '@utils/options.js'; +import { + fetchLatestVersion, + getUpdateCommand, + isNewerVersion, +} from '@utils/update-check.js'; +import { execSync } from 'child_process'; + +import { version as currentVersion } from '../../package.json'; + +const context = msg('update'); + +export default async function update( + options: Record = {} +): Promise { + const format = getFormat(options); + + printStart(context); + + try { + // Always fetch fresh when the user explicitly runs `tigris update` + const latestVersion = await fetchLatestVersion(); + + const updateAvailable = isNewerVersion(currentVersion, latestVersion); + const updateCommand = getUpdateCommand(); + + if (format === 'json') { + console.log( + JSON.stringify({ + currentVersion, + latestVersion, + updateAvailable, + updateCommand, + }) + ); + return; + } + + console.log(`Current version: ${currentVersion}`); + + if (updateAvailable) { + console.log(`Latest version: ${latestVersion}`); + console.log('Updating...'); + execSync(updateCommand, { + stdio: 'inherit', + ...(process.platform === 'win32' ? { shell: 'powershell.exe' } : {}), + }); + printSuccess(context, { latestVersion }); + } else { + printAlreadyDone(context, { currentVersion }); + } + } catch (error) { + failWithError(context, error); + } +} diff --git a/src/specs.yaml b/src/specs.yaml index 8c08053..6c0e8d5 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -215,6 +215,17 @@ commands: onSuccess: '' onFailure: 'Failed to get user information' onAlreadyDone: "Not authenticated\nRun \"tigris login\" to authenticate" + # update + - name: update + description: Update the CLI to the latest version + examples: + - "tigris update" + messages: + onStart: 'Checking for updates...' + onSuccess: 'Updated to {{latestVersion}}' + onFailure: 'Failed to update' + onAlreadyDone: 'Already on the latest version ({{currentVersion}})' + # logout - name: logout description: End the current session and clear login state. Credentials saved via 'configure' are kept diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index d11d6e0..ec05746 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -18,7 +18,7 @@ interface UpdateCheckCache { const CACHE_PATH = join(homedir(), '.tigris', 'update-check.json'); -function readUpdateCache(): UpdateCheckCache | null { +export function readUpdateCache(): UpdateCheckCache | null { try { const data = readFileSync(CACHE_PATH, 'utf-8'); const parsed = JSON.parse(data); @@ -92,9 +92,33 @@ export function isNewerVersion(current: string, latest: string): boolean { return false; } -function fetchLatestVersionInBackground(): void { - try { - const req = https.get(NPM_REGISTRY_URL, { timeout: 5000 }, (res) => { +/** + * Returns the platform-appropriate shell command for updating the CLI. + */ +export function getUpdateCommand(): string { + const isBinary = + (globalThis as { __TIGRIS_BINARY?: boolean }).__TIGRIS_BINARY === true; + const isWindows = process.platform === 'win32'; + + if (!isBinary) { + return 'npm install -g @tigrisdata/cli'; + } else if (isWindows) { + return 'irm https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.ps1 | iex'; + } else { + return 'curl -fsSL https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.sh | sh'; + } +} + +/** + * Fetch the latest published version string from the npm registry. + * When `unref` is true the underlying socket is unref'd so it won't + * keep the process alive (used by the background check). + */ +export function fetchLatestVersion( + options: { unref?: boolean } = {} +): Promise { + return new Promise((resolve, reject) => { + const req = https.get(NPM_REGISTRY_URL, { timeout: 10000 }, (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk; @@ -103,32 +127,40 @@ function fetchLatestVersionInBackground(): void { try { const json = JSON.parse(data); if (typeof json.version === 'string') { - const existing = readUpdateCache(); writeUpdateCache({ - ...existing, + ...readUpdateCache(), latestVersion: json.version, lastChecked: Date.now(), }); + resolve(json.version); + } else { + reject(new Error('Unexpected registry response')); } } catch { - // Silent on parse failure + reject(new Error('Failed to parse registry response')); } }); }); - req.on('error', () => { - // Silent on network failure + req.on('error', (err) => { + reject(err); }); req.on('timeout', () => { req.destroy(); + reject(new Error('Request timed out')); }); + if (options.unref) { + req.on('socket', (socket) => { + socket.unref(); + }); + } req.end(); - // Unref so the request doesn't keep the process alive - req.on('socket', (socket) => { - socket.unref(); - }); - } catch { + }); +} + +function fetchLatestVersionInBackground(): void { + fetchLatestVersion({ unref: true }).catch(() => { // Silent on failure - } + }); } export function checkForUpdates(): void { @@ -146,20 +178,8 @@ export function checkForUpdates(): void { !cache.lastNotified || Date.now() - cache.lastNotified > notifyIntervalMs ) { - const isBinary = - (globalThis as { __TIGRIS_BINARY?: boolean }).__TIGRIS_BINARY === true; - const isWindows = process.platform === 'win32'; const line1 = `Update available: ${currentVersion} → ${cache.latestVersion}`; - let line2: string; - if (!isBinary) { - line2 = 'Run `npm install -g @tigrisdata/cli` to upgrade.'; - } else if (isWindows) { - line2 = - 'Run `irm https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.ps1 | iex`'; - } else { - line2 = - 'Run `curl -fsSL https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.sh | sh`'; - } + const line2 = 'Run "tigris update" to upgrade.'; const width = Math.max(line1.length, line2.length) + 4; const top = '┌' + '─'.repeat(width - 2) + '┐'; const bot = '└' + '─'.repeat(width - 2) + '┘';