diff --git a/README.md b/README.md index ce7151fe..5fc3fadc 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Upload IPFS files directly to Filecoin via the command line. Perfect for develop - Run `filecoin-pin --help` to see all available commands and options. - [CLI Walkthrough](https://docs.filecoin.io/builder-cookbook/filecoin-pin/filecoin-pin-cli) - **Installation**: `npm install -g filecoin-pin` +- **Update notice**: Every command quickly checks npm for a newer version and prints a reminder when one is available. Disable with `--no-update-check`. ### GitHub Action Automatically publish websites or build artifacts to IPFS and Filecoin as part of your CI/CD pipeline. Ideal for static websites, documentation sites, and automated deployment workflows. @@ -158,7 +159,8 @@ The Pinning Server requires the use of environment variables, as detailed below. ### Common CLI Arguments * `-h`, `--help`: Display help information for each command -* `-v`, `--version`: Output the version number +* `-V`, `--version`: Output the version number +* `-v`, `--verbose`: Verbose output * `--private-key`: Ethereum-style (`0x`) private key, funded with USDFC (required) * `--rpc-url`: Filecoin RPC endpoint (default: Calibration testnet) diff --git a/package.json b/package.json index 5799b211..e3125b61 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,10 @@ "./core/utils": { "types": "./dist/core/utils/index.d.ts", "default": "./dist/core/utils/index.js" + }, + "./version-check": { + "types": "./dist/common/version-check.d.ts", + "default": "./dist/common/version-check.js" } }, "bin": { @@ -68,7 +72,7 @@ "LICENSE.md" ], "scripts": { - "build": "tsc", + "build": "tsc && node scripts/write-version.mjs", "dev": "tsx watch src/cli.ts server", "start": "node dist/cli.js server", "test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration", @@ -109,12 +113,14 @@ "it-to-buffer": "^4.0.10", "multiformats": "^13.4.1", "picocolors": "^1.1.1", - "pino": "^10.0.0" + "pino": "^10.0.0", + "semver": "^7.6.3" }, "devDependencies": { "@biomejs/biome": "2.3.4", "@ipld/dag-cbor": "^9.2.5", "@types/node": "^24.5.1", + "@types/semver": "^7.5.8", "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.20.5", "typescript": "^5.9.2", diff --git a/scripts/write-version.mjs b/scripts/write-version.mjs new file mode 100644 index 00000000..0d7a454e --- /dev/null +++ b/scripts/write-version.mjs @@ -0,0 +1,10 @@ +import { readFile, writeFile } from 'fs/promises' + +const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url))) + +await writeFile( + new URL('../dist/core/utils/version.js', import.meta.url), + `export const version = '${pkg.version}' +export const name = '${pkg.name}' +` +) diff --git a/src/cli.ts b/src/cli.ts index 33f0dcc9..2313dd78 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,25 +1,22 @@ #!/usr/bin/env node -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - import { Command } from 'commander' +import pc from 'picocolors' + import { addCommand } from './commands/add.js' import { dataSetCommand } from './commands/data-set.js' import { importCommand } from './commands/import.js' import { paymentsCommand } from './commands/payments.js' import { serverCommand } from './commands/server.js' - -// Get package.json for version -const __dirname = dirname(fileURLToPath(import.meta.url)) -const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')) +import { checkForUpdate, type UpdateCheckStatus } from './common/version-check.js' +import { version as packageVersion } from './core/utils/version.js' // Create the main program const program = new Command() .name('filecoin-pin') .description('IPFS Pinning Service with Filecoin storage via Synapse SDK') - .version(packageJson.version) + .version(packageVersion) .option('-v, --verbose', 'verbose output') + .option('--no-update-check', 'skip check for updates') // Add subcommands program.addCommand(serverCommand) @@ -33,6 +30,44 @@ program.action(() => { program.help() }) +let updateCheckResult: UpdateCheckStatus | null = null + +program.hook('preAction', () => { + if (updateCheckResult) { + return + } + + const options = program.optsWithGlobals<{ updateCheck?: boolean }>() + if (options.updateCheck === false) { + updateCheckResult = null + return + } + + setImmediate(() => { + checkForUpdate({ currentVersion: packageVersion }) + .then((result) => { + updateCheckResult = result + }) + .catch(() => { + // could not check for update, swallow error + // checkForUpdate should not throw. If it does, it's an unexpected error. + }) + }).unref() +}) + +program.hook('postAction', async () => { + if (updateCheckResult?.status === 'update-available') { + const result = updateCheckResult + updateCheckResult = null + + const header = `${pc.yellow(`Update available: filecoin-pin ${result.currentVersion} → ${result.latestVersion}`)}. Upgrade with ${pc.cyan('npm i -g filecoin-pin@latest')}` + const releasesLink = 'https://github.com/filecoin-project/filecoin-pin/releases' + const instruction = `Visit ${releasesLink} to view release notes or download the latest version.` + console.log(header) + console.log(instruction) + } +}) + // Parse arguments and run program.parseAsync(process.argv).catch((error) => { console.error('Error:', error.message) diff --git a/src/common/version-check.ts b/src/common/version-check.ts new file mode 100644 index 00000000..773e44fd --- /dev/null +++ b/src/common/version-check.ts @@ -0,0 +1,111 @@ +import { compare } from 'semver' +import { name as packageName, version as packageVersion } from '../core/utils/version.js' + +type UpdateCheckStatus = + | { + status: 'disabled' + reason: string + } + | { + status: 'up-to-date' + currentVersion: string + latestVersion: string + } + | { + status: 'update-available' + currentVersion: string + latestVersion: string + } + | { + status: 'error' + currentVersion: string + message: string + } + +type CheckForUpdateOptions = { + packageName?: string + currentVersion?: string + timeoutMs?: number + disableCheck?: boolean +} + +const DEFAULT_PACKAGE_NAME = packageName +const DEFAULT_TIMEOUT_MS = 1500 +export async function checkForUpdate(options: CheckForUpdateOptions = {}): Promise { + const { packageName = DEFAULT_PACKAGE_NAME, timeoutMs = DEFAULT_TIMEOUT_MS } = options + const disableCheck = options.disableCheck === true + + if (disableCheck) { + return { + status: 'disabled', + reason: 'Update check disabled by configuration', + } + } + + const currentVersion = options.currentVersion ?? getLocalPackageVersion() + + const signal = AbortSignal.timeout(timeoutMs) + + try { + const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { + signal, + headers: { + accept: 'application/json', + }, + }) + + if (!response.ok) { + return { + status: 'error', + currentVersion, + message: `Received ${response.status} from npm registry`, + } + } + + const data = (await response.json()) as { version?: string } + + if (typeof data.version !== 'string') { + return { + status: 'error', + currentVersion, + message: 'Response missing version field', + } + } + + const latestVersion = data.version + + if (compare(latestVersion, currentVersion) > 0) { + return { + status: 'update-available', + currentVersion, + latestVersion, + } + } + + return { + status: 'up-to-date', + currentVersion, + latestVersion, + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { + status: 'error', + currentVersion, + message: 'Update check timed out', + } + } + + return { + status: 'error', + currentVersion, + message: error instanceof Error ? error.message : 'Unknown error during update check', + } + } +} + +function getLocalPackageVersion(): string { + return packageVersion +} + +export type { UpdateCheckStatus } diff --git a/src/core/synapse/telemetry-config.ts b/src/core/synapse/telemetry-config.ts index 0873f927..e36e12f9 100644 --- a/src/core/synapse/telemetry-config.ts +++ b/src/core/synapse/telemetry-config.ts @@ -1,6 +1,6 @@ import type { TelemetryConfig } from '@filoz/synapse-sdk' -// biome-ignore lint/correctness/useImportExtensions: package.json is bundled for browser and node -import packageJson from '../../../package.json' with { type: 'json' } + +import { name as packageName, version as packageVersion } from '../utils/version.js' export const getTelemetryConfig = (config?: TelemetryConfig | undefined): TelemetryConfig => { return { @@ -12,7 +12,7 @@ export const getTelemetryConfig = (config?: TelemetryConfig | undefined): Teleme }, sentrySetTags: { ...config?.sentrySetTags, - filecoinPinVersion: `${packageJson.name}@v${packageJson.version}`, + filecoinPinVersion: `${packageName}@v${packageVersion}`, }, } } diff --git a/src/core/utils/version.ts b/src/core/utils/version.ts new file mode 100644 index 00000000..a4f8feac --- /dev/null +++ b/src/core/utils/version.ts @@ -0,0 +1,2 @@ +export const version = '0.0.0-dev' +export const name = 'filecoin-pin' diff --git a/src/server.ts b/src/server.ts index 0ae04825..7ccda97d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,5 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - import { createConfig } from './config.js' +import { name as packageName, version as packageVersion } from './core/utils/version.js' import { createFilecoinPinningServer } from './filecoin-pinning-server.js' import { createLogger } from './logger.js' @@ -12,11 +9,9 @@ export interface ServiceInfo { } function getServiceInfo(): ServiceInfo { - const __dirname = dirname(fileURLToPath(import.meta.url)) - const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')) return { - service: packageJson.name, - version: packageJson.version, + service: packageName, + version: packageVersion, } } diff --git a/src/test/unit/version-check.test.ts b/src/test/unit/version-check.test.ts new file mode 100644 index 00000000..a8d24f4a --- /dev/null +++ b/src/test/unit/version-check.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { checkForUpdate } from '../../common/version-check.js' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) +describe('version check', () => { + it('detects when a newer version is available', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '0.12.0' }), + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await checkForUpdate({ currentVersion: '0.11.0' }) + + expect(result).toMatchObject({ + status: 'update-available', + currentVersion: '0.11.0', + latestVersion: '0.12.0', + }) + expect(fetchMock).toHaveBeenCalled() + }) + + it('returns up-to-date when versions match', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '0.11.0' }), + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await checkForUpdate({ currentVersion: '0.11.0' }) + + expect(result).toEqual({ + status: 'up-to-date', + currentVersion: '0.11.0', + latestVersion: '0.11.0', + }) + }) + + it('returns error when fetch fails', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')) + vi.stubGlobal('fetch', fetchMock) + + const result = await checkForUpdate({ currentVersion: '0.11.0' }) + + expect(result).toMatchObject({ + status: 'error', + currentVersion: '0.11.0', + message: 'network down', + }) + }) + + it('supports opting out via options', async () => { + const result = await checkForUpdate({ currentVersion: '0.11.0', disableCheck: true }) + expect(result).toEqual({ + status: 'disabled', + reason: 'Update check disabled by configuration', + }) + }) +}) diff --git a/upload-action/README.md b/upload-action/README.md index 4255b3ac..7b2f4736 100644 --- a/upload-action/README.md +++ b/upload-action/README.md @@ -62,7 +62,7 @@ For most users, automatic provider selection is recommended. However, for advanc - Only same-repo PRs and direct pushes are supported - This prevents non-maintainer PR actors from draining funds -## Versioning +## Versioning and Updates Use semantic version tags from [filecoin-pin releases](https://github.com/filecoin-project/filecoin-pin/releases): @@ -70,6 +70,8 @@ Use semantic version tags from [filecoin-pin releases](https://github.com/fileco - **`@v0.9.1`** - Specific version (production) - **`@`** - Maximum supply-chain security +The action checks npm for a newer `filecoin-pin` release at the start of each run and posts a GitHub Actions notice when one is available. + ## Caching & Artifacts - **Cache key**: `filecoin-pin-v1-${ipfsRootCid}` enables reuse for identical content diff --git a/upload-action/package-lock.json b/upload-action/package-lock.json index 53a3fee2..6d09395f 100644 --- a/upload-action/package-lock.json +++ b/upload-action/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.16", "dependencies": { "@actions/artifact": "^2.3.2", + "@actions/core": "^1.11.1", "@filoz/synapse-sdk": "^0.35.3", "@octokit/rest": "^22.0.0", "ethers": "^6.15.0", @@ -32,11 +33,11 @@ } }, "..": { - "version": "0.10.1", + "version": "0.11.1", "license": "Apache-2.0 OR MIT", "dependencies": { "@clack/prompts": "^0.11.0", - "@filoz/synapse-sdk": "^0.35.2", + "@filoz/synapse-sdk": "^0.35.3", "@helia/unixfs": "^6.0.1", "@ipld/car": "^5.4.2", "commander": "^14.0.1", @@ -46,13 +47,14 @@ "it-to-buffer": "^4.0.10", "multiformats": "^13.4.1", "picocolors": "^1.1.1", - "pino": "^10.0.0" + "pino": "^10.0.0", + "semver": "^7.6.3" }, "bin": { "filecoin-pin": "dist/cli.js" }, "devDependencies": { - "@biomejs/biome": "2.2.7", + "@biomejs/biome": "2.3.4", "@ipld/dag-cbor": "^9.2.5", "@types/node": "^24.5.1", "@vitest/coverage-v8": "^3.2.4", diff --git a/upload-action/package.json b/upload-action/package.json index 5f3ad860..f99d3cc4 100644 --- a/upload-action/package.json +++ b/upload-action/package.json @@ -5,6 +5,7 @@ "version": "1.0.16", "description": "Helper runner for Filecoin Pin Upload GitHub Action", "dependencies": { + "@actions/core": "^1.11.1", "@actions/artifact": "^2.3.2", "@filoz/synapse-sdk": "^0.35.3", "@octokit/rest": "^22.0.0", diff --git a/upload-action/src/run.mjs b/upload-action/src/run.mjs index e3177fdd..1f6a11ca 100644 --- a/upload-action/src/run.mjs +++ b/upload-action/src/run.mjs @@ -1,3 +1,6 @@ +import * as core from '@actions/core' +import { checkForUpdate } from 'filecoin-pin/version-check' + import { runBuild } from './build.js' import { getErrorMessage, handleError } from './errors.js' import { cleanupSynapse } from './filecoin.js' @@ -5,7 +8,24 @@ import { completeCheck, createCheck } from './github.js' import { getOutputSummary } from './outputs.js' import { runUpload } from './upload.js' +async function maybeNotifyAboutUpdates() { + try { + const result = await checkForUpdate() + if (result.status === 'update-available') { + core.notice( + `New filecoin-pin version available (${result.currentVersion} → ${result.latestVersion}). ` + + 'Update your workflow to use the latest release: https://github.com/filecoin-project/filecoin-pin/releases' + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + core.debug(`Update check failed: ${message}`) + } +} + async function main() { + // Check for updates in the background. + void maybeNotifyAboutUpdates() // Create/reuse check run (may already exist from early action step for fast UI feedback) await createCheck('Filecoin Upload')