Skip to content

Commit d27e6e6

Browse files
SgtPookiBigLep
andauthored
feat: perform update checks (#219)
* feat: perform update checks * fix: immediate unref, AbortSignal.timeout(), misc * chore: remove unnecessary promise result check * Refine README for upload-action Moved notice about updated checking. --------- Co-authored-by: Steve Loeppky <[email protected]>
1 parent 391cd79 commit d27e6e6

File tree

13 files changed

+277
-28
lines changed

13 files changed

+277
-28
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Upload IPFS files directly to Filecoin via the command line. Perfect for develop
3838
- Run `filecoin-pin --help` to see all available commands and options.
3939
- [CLI Walkthrough](https://docs.filecoin.io/builder-cookbook/filecoin-pin/filecoin-pin-cli)
4040
- **Installation**: `npm install -g filecoin-pin`
41+
- **Update notice**: Every command quickly checks npm for a newer version and prints a reminder when one is available. Disable with `--no-update-check`.
4142

4243
### GitHub Action
4344
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.
158159
### Common CLI Arguments
159160

160161
* `-h`, `--help`: Display help information for each command
161-
* `-v`, `--version`: Output the version number
162+
* `-V`, `--version`: Output the version number
163+
* `-v`, `--verbose`: Verbose output
162164
* `--private-key`: Ethereum-style (`0x`) private key, funded with USDFC (required)
163165
* `--rpc-url`: Filecoin RPC endpoint (default: Calibration testnet)
164166

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
"./core/utils": {
5454
"types": "./dist/core/utils/index.d.ts",
5555
"default": "./dist/core/utils/index.js"
56+
},
57+
"./version-check": {
58+
"types": "./dist/common/version-check.d.ts",
59+
"default": "./dist/common/version-check.js"
5660
}
5761
},
5862
"bin": {
@@ -68,7 +72,7 @@
6872
"LICENSE.md"
6973
],
7074
"scripts": {
71-
"build": "tsc",
75+
"build": "tsc && node scripts/write-version.mjs",
7276
"dev": "tsx watch src/cli.ts server",
7377
"start": "node dist/cli.js server",
7478
"test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration",
@@ -109,12 +113,14 @@
109113
"it-to-buffer": "^4.0.10",
110114
"multiformats": "^13.4.1",
111115
"picocolors": "^1.1.1",
112-
"pino": "^10.0.0"
116+
"pino": "^10.0.0",
117+
"semver": "^7.6.3"
113118
},
114119
"devDependencies": {
115120
"@biomejs/biome": "2.3.4",
116121
"@ipld/dag-cbor": "^9.2.5",
117122
"@types/node": "^24.5.1",
123+
"@types/semver": "^7.5.8",
118124
"@vitest/coverage-v8": "^3.2.4",
119125
"tsx": "^4.20.5",
120126
"typescript": "^5.9.2",

scripts/write-version.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { readFile, writeFile } from 'fs/promises'
2+
3+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url)))
4+
5+
await writeFile(
6+
new URL('../dist/core/utils/version.js', import.meta.url),
7+
`export const version = '${pkg.version}'
8+
export const name = '${pkg.name}'
9+
`
10+
)

src/cli.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
#!/usr/bin/env node
2-
import { readFileSync } from 'node:fs'
3-
import { dirname, join } from 'node:path'
4-
import { fileURLToPath } from 'node:url'
5-
62
import { Command } from 'commander'
3+
import pc from 'picocolors'
4+
75
import { addCommand } from './commands/add.js'
86
import { dataSetCommand } from './commands/data-set.js'
97
import { importCommand } from './commands/import.js'
108
import { paymentsCommand } from './commands/payments.js'
119
import { serverCommand } from './commands/server.js'
12-
13-
// Get package.json for version
14-
const __dirname = dirname(fileURLToPath(import.meta.url))
15-
const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'))
10+
import { checkForUpdate, type UpdateCheckStatus } from './common/version-check.js'
11+
import { version as packageVersion } from './core/utils/version.js'
1612

1713
// Create the main program
1814
const program = new Command()
1915
.name('filecoin-pin')
2016
.description('IPFS Pinning Service with Filecoin storage via Synapse SDK')
21-
.version(packageJson.version)
17+
.version(packageVersion)
2218
.option('-v, --verbose', 'verbose output')
19+
.option('--no-update-check', 'skip check for updates')
2320

2421
// Add subcommands
2522
program.addCommand(serverCommand)
@@ -33,6 +30,44 @@ program.action(() => {
3330
program.help()
3431
})
3532

33+
let updateCheckResult: UpdateCheckStatus | null = null
34+
35+
program.hook('preAction', () => {
36+
if (updateCheckResult) {
37+
return
38+
}
39+
40+
const options = program.optsWithGlobals<{ updateCheck?: boolean }>()
41+
if (options.updateCheck === false) {
42+
updateCheckResult = null
43+
return
44+
}
45+
46+
setImmediate(() => {
47+
checkForUpdate({ currentVersion: packageVersion })
48+
.then((result) => {
49+
updateCheckResult = result
50+
})
51+
.catch(() => {
52+
// could not check for update, swallow error
53+
// checkForUpdate should not throw. If it does, it's an unexpected error.
54+
})
55+
}).unref()
56+
})
57+
58+
program.hook('postAction', async () => {
59+
if (updateCheckResult?.status === 'update-available') {
60+
const result = updateCheckResult
61+
updateCheckResult = null
62+
63+
const header = `${pc.yellow(`Update available: filecoin-pin ${result.currentVersion}${result.latestVersion}`)}. Upgrade with ${pc.cyan('npm i -g filecoin-pin@latest')}`
64+
const releasesLink = 'https://github.com/filecoin-project/filecoin-pin/releases'
65+
const instruction = `Visit ${releasesLink} to view release notes or download the latest version.`
66+
console.log(header)
67+
console.log(instruction)
68+
}
69+
})
70+
3671
// Parse arguments and run
3772
program.parseAsync(process.argv).catch((error) => {
3873
console.error('Error:', error.message)

src/common/version-check.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { compare } from 'semver'
2+
import { name as packageName, version as packageVersion } from '../core/utils/version.js'
3+
4+
type UpdateCheckStatus =
5+
| {
6+
status: 'disabled'
7+
reason: string
8+
}
9+
| {
10+
status: 'up-to-date'
11+
currentVersion: string
12+
latestVersion: string
13+
}
14+
| {
15+
status: 'update-available'
16+
currentVersion: string
17+
latestVersion: string
18+
}
19+
| {
20+
status: 'error'
21+
currentVersion: string
22+
message: string
23+
}
24+
25+
type CheckForUpdateOptions = {
26+
packageName?: string
27+
currentVersion?: string
28+
timeoutMs?: number
29+
disableCheck?: boolean
30+
}
31+
32+
const DEFAULT_PACKAGE_NAME = packageName
33+
const DEFAULT_TIMEOUT_MS = 1500
34+
export async function checkForUpdate(options: CheckForUpdateOptions = {}): Promise<UpdateCheckStatus> {
35+
const { packageName = DEFAULT_PACKAGE_NAME, timeoutMs = DEFAULT_TIMEOUT_MS } = options
36+
const disableCheck = options.disableCheck === true
37+
38+
if (disableCheck) {
39+
return {
40+
status: 'disabled',
41+
reason: 'Update check disabled by configuration',
42+
}
43+
}
44+
45+
const currentVersion = options.currentVersion ?? getLocalPackageVersion()
46+
47+
const signal = AbortSignal.timeout(timeoutMs)
48+
49+
try {
50+
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
51+
signal,
52+
headers: {
53+
accept: 'application/json',
54+
},
55+
})
56+
57+
if (!response.ok) {
58+
return {
59+
status: 'error',
60+
currentVersion,
61+
message: `Received ${response.status} from npm registry`,
62+
}
63+
}
64+
65+
const data = (await response.json()) as { version?: string }
66+
67+
if (typeof data.version !== 'string') {
68+
return {
69+
status: 'error',
70+
currentVersion,
71+
message: 'Response missing version field',
72+
}
73+
}
74+
75+
const latestVersion = data.version
76+
77+
if (compare(latestVersion, currentVersion) > 0) {
78+
return {
79+
status: 'update-available',
80+
currentVersion,
81+
latestVersion,
82+
}
83+
}
84+
85+
return {
86+
status: 'up-to-date',
87+
currentVersion,
88+
latestVersion,
89+
}
90+
} catch (error) {
91+
if (error instanceof Error && error.name === 'AbortError') {
92+
return {
93+
status: 'error',
94+
currentVersion,
95+
message: 'Update check timed out',
96+
}
97+
}
98+
99+
return {
100+
status: 'error',
101+
currentVersion,
102+
message: error instanceof Error ? error.message : 'Unknown error during update check',
103+
}
104+
}
105+
}
106+
107+
function getLocalPackageVersion(): string {
108+
return packageVersion
109+
}
110+
111+
export type { UpdateCheckStatus }

src/core/synapse/telemetry-config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { TelemetryConfig } from '@filoz/synapse-sdk'
2-
// biome-ignore lint/correctness/useImportExtensions: package.json is bundled for browser and node
3-
import packageJson from '../../../package.json' with { type: 'json' }
2+
3+
import { name as packageName, version as packageVersion } from '../utils/version.js'
44

55
export const getTelemetryConfig = (config?: TelemetryConfig | undefined): TelemetryConfig => {
66
return {
@@ -12,7 +12,7 @@ export const getTelemetryConfig = (config?: TelemetryConfig | undefined): Teleme
1212
},
1313
sentrySetTags: {
1414
...config?.sentrySetTags,
15-
filecoinPinVersion: `${packageJson.name}@v${packageJson.version}`,
15+
filecoinPinVersion: `${packageName}@v${packageVersion}`,
1616
},
1717
}
1818
}

src/core/utils/version.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const version = '0.0.0-dev'
2+
export const name = 'filecoin-pin'

src/server.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { readFileSync } from 'node:fs'
2-
import { dirname, join } from 'node:path'
3-
import { fileURLToPath } from 'node:url'
4-
51
import { createConfig } from './config.js'
2+
import { name as packageName, version as packageVersion } from './core/utils/version.js'
63
import { createFilecoinPinningServer } from './filecoin-pinning-server.js'
74
import { createLogger } from './logger.js'
85

@@ -12,11 +9,9 @@ export interface ServiceInfo {
129
}
1310

1411
function getServiceInfo(): ServiceInfo {
15-
const __dirname = dirname(fileURLToPath(import.meta.url))
16-
const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'))
1712
return {
18-
service: packageJson.name,
19-
version: packageJson.version,
13+
service: packageName,
14+
version: packageVersion,
2015
}
2116
}
2217

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { checkForUpdate } from '../../common/version-check.js'
4+
5+
afterEach(() => {
6+
vi.restoreAllMocks()
7+
vi.unstubAllGlobals()
8+
})
9+
describe('version check', () => {
10+
it('detects when a newer version is available', async () => {
11+
const fetchMock = vi.fn().mockResolvedValue({
12+
ok: true,
13+
json: async () => ({ version: '0.12.0' }),
14+
})
15+
vi.stubGlobal('fetch', fetchMock)
16+
17+
const result = await checkForUpdate({ currentVersion: '0.11.0' })
18+
19+
expect(result).toMatchObject({
20+
status: 'update-available',
21+
currentVersion: '0.11.0',
22+
latestVersion: '0.12.0',
23+
})
24+
expect(fetchMock).toHaveBeenCalled()
25+
})
26+
27+
it('returns up-to-date when versions match', async () => {
28+
const fetchMock = vi.fn().mockResolvedValue({
29+
ok: true,
30+
json: async () => ({ version: '0.11.0' }),
31+
})
32+
vi.stubGlobal('fetch', fetchMock)
33+
34+
const result = await checkForUpdate({ currentVersion: '0.11.0' })
35+
36+
expect(result).toEqual({
37+
status: 'up-to-date',
38+
currentVersion: '0.11.0',
39+
latestVersion: '0.11.0',
40+
})
41+
})
42+
43+
it('returns error when fetch fails', async () => {
44+
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
45+
vi.stubGlobal('fetch', fetchMock)
46+
47+
const result = await checkForUpdate({ currentVersion: '0.11.0' })
48+
49+
expect(result).toMatchObject({
50+
status: 'error',
51+
currentVersion: '0.11.0',
52+
message: 'network down',
53+
})
54+
})
55+
56+
it('supports opting out via options', async () => {
57+
const result = await checkForUpdate({ currentVersion: '0.11.0', disableCheck: true })
58+
expect(result).toEqual({
59+
status: 'disabled',
60+
reason: 'Update check disabled by configuration',
61+
})
62+
})
63+
})

upload-action/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,16 @@ For most users, automatic provider selection is recommended. However, for advanc
6262
- Only same-repo PRs and direct pushes are supported
6363
- This prevents non-maintainer PR actors from draining funds
6464

65-
## Versioning
65+
## Versioning and Updates
6666

6767
Use semantic version tags from [filecoin-pin releases](https://github.com/filecoin-project/filecoin-pin/releases):
6868

6969
- **`@v0`** - Latest v0.x.x (recommended)
7070
- **`@v0.9.1`** - Specific version (production)
7171
- **`@<commit-sha>`** - Maximum supply-chain security
7272

73+
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.
74+
7375
## Caching & Artifacts
7476

7577
- **Cache key**: `filecoin-pin-v1-${ipfsRootCid}` enables reuse for identical content

0 commit comments

Comments
 (0)