1- import { readFileSync } from 'node:fs' ;
1+ import { spawn } from 'node:child_process' ;
2+ import { existsSync , readFileSync } from 'node:fs' ;
3+ import { mkdir , readFile , rename , writeFile } from 'node:fs/promises' ;
24import { dirname , join } from 'node:path' ;
5+ import process from 'node:process' ;
36import { fileURLToPath } from 'node:url' ;
7+ import { CACHE_DIR } from '../config.js' ;
8+
9+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 ;
10+ const UPDATE_CHECK_STATE_FILE = join ( CACHE_DIR , 'version-check.json' ) ;
411
512function getPackageJsonPath ( ) : string {
613 return join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' , '..' , 'package.json' ) ;
@@ -18,7 +25,7 @@ export function getPackageMeta(): { name: string; version: string } {
1825}
1926
2027/** 比较 semver x.y.z(忽略预发布标签,仅比较主版本段) */
21- function compareSemver ( a : string , b : string ) : number {
28+ export function compareSemver ( a : string , b : string ) : number {
2229 const core = ( s : string ) => s . split ( '-' ) [ 0 ] ?? '' ;
2330 const pa = core ( a ) . split ( '.' ) . map ( ( x ) => parseInt ( x , 10 ) ) ;
2431 const pb = core ( b ) . split ( '.' ) . map ( ( x ) => parseInt ( x , 10 ) ) ;
@@ -36,6 +43,30 @@ function compareSemver(a: string, b: string): number {
3643 return 0 ;
3744}
3845
46+ export type PackageUpdateCheckResult = {
47+ checked : boolean ;
48+ current : string ;
49+ latest : string | null ;
50+ updateAvailable : boolean ;
51+ } ;
52+
53+ type PackageUpdateCheckState = {
54+ checkedAt : string ;
55+ packageName : string ;
56+ currentVersion : string ;
57+ latestVersion : string ;
58+ } ;
59+
60+ type CheckPackageUpdateOptions = {
61+ currentVersion ?: string ;
62+ fetchLatestVersion ?: ( packageName : string ) => Promise < string > ;
63+ force ?: boolean ;
64+ intervalMs ?: number ;
65+ now ?: Date ;
66+ packageName ?: string ;
67+ statePath ?: string ;
68+ } ;
69+
3970export async function fetchNpmLatestVersion ( packageName : string ) : Promise < string > {
4071 const path = `${ encodeURIComponent ( packageName ) } /latest` ;
4172 const url = `https://registry.npmjs.org/${ path } ` ;
@@ -52,12 +83,148 @@ export async function fetchNpmLatestVersion(packageName: string): Promise<string
5283 return data . version ;
5384}
5485
86+ function getUpdateCheckStatePath ( ) : string {
87+ return process . env . BOSS_CLI_UPDATE_CHECK_STATE_PATH ?. trim ( ) || UPDATE_CHECK_STATE_FILE ;
88+ }
89+
90+ function shouldCheckFromState (
91+ state : PackageUpdateCheckState | null ,
92+ now : Date ,
93+ intervalMs : number ,
94+ ) : boolean {
95+ if ( ! state ) {
96+ return true ;
97+ }
98+ const checkedAtMs = Date . parse ( state . checkedAt ) ;
99+ if ( Number . isNaN ( checkedAtMs ) ) {
100+ throw new Error ( `版本检查缓存 checkedAt 无法解析:${ state . checkedAt } ` ) ;
101+ }
102+ return now . getTime ( ) - checkedAtMs > intervalMs ;
103+ }
104+
105+ async function readUpdateCheckState ( statePath : string ) : Promise < PackageUpdateCheckState | null > {
106+ if ( ! existsSync ( statePath ) ) {
107+ return null ;
108+ }
109+ const raw = await readFile ( statePath , 'utf8' ) ;
110+ const parsed = JSON . parse ( raw ) as Partial < PackageUpdateCheckState > ;
111+ if (
112+ typeof parsed . checkedAt !== 'string' ||
113+ typeof parsed . packageName !== 'string' ||
114+ typeof parsed . currentVersion !== 'string' ||
115+ typeof parsed . latestVersion !== 'string'
116+ ) {
117+ throw new Error ( `版本检查缓存格式无效:${ statePath } ` ) ;
118+ }
119+ return {
120+ checkedAt : parsed . checkedAt ,
121+ packageName : parsed . packageName ,
122+ currentVersion : parsed . currentVersion ,
123+ latestVersion : parsed . latestVersion ,
124+ } ;
125+ }
126+
127+ async function writeUpdateCheckState (
128+ statePath : string ,
129+ state : PackageUpdateCheckState ,
130+ ) : Promise < void > {
131+ await mkdir ( dirname ( statePath ) , { recursive : true } ) ;
132+ const tmpPath = `${ statePath } .tmp` ;
133+ await writeFile ( tmpPath , `${ JSON . stringify ( state , null , 2 ) } \n` , 'utf8' ) ;
134+ await rename ( tmpPath , statePath ) ;
135+ }
136+
137+ export function formatPackageUpdateNotice ( result : PackageUpdateCheckResult ) : string {
138+ if ( ! result . updateAvailable || ! result . latest ) {
139+ throw new Error ( '没有可用更新,无法生成升级提示' ) ;
140+ }
141+ return `[boss-cli] boss-cli 需要升级:当前 ${ result . current } ,最新 ${ result . latest } 。请运行 boss update` ;
142+ }
143+
144+ export async function checkPackageUpdate (
145+ options : CheckPackageUpdateOptions = { } ,
146+ ) : Promise < PackageUpdateCheckResult > {
147+ const meta = getPackageMeta ( ) ;
148+ const packageName = options . packageName ?? meta . name ;
149+ const current = options . currentVersion ?? meta . version ;
150+ const now = options . now ?? new Date ( ) ;
151+ const intervalMs = options . intervalMs ?? UPDATE_CHECK_INTERVAL_MS ;
152+ const statePath = options . statePath ?? getUpdateCheckStatePath ( ) ;
153+ const fetchLatestVersion = options . fetchLatestVersion ?? fetchNpmLatestVersion ;
154+ const state = await readUpdateCheckState ( statePath ) ;
155+
156+ if ( ! options . force && ! shouldCheckFromState ( state , now , intervalMs ) ) {
157+ const latest = state ?. latestVersion ?? null ;
158+ return {
159+ checked : false ,
160+ current,
161+ latest,
162+ updateAvailable : latest ? compareSemver ( latest , current ) > 0 : false ,
163+ } ;
164+ }
165+
166+ const latest = await fetchLatestVersion ( packageName ) ;
167+ const result = {
168+ checked : true ,
169+ current,
170+ latest,
171+ updateAvailable : compareSemver ( latest , current ) > 0 ,
172+ } ;
173+ await writeUpdateCheckState ( statePath , {
174+ checkedAt : now . toISOString ( ) ,
175+ packageName,
176+ currentVersion : current ,
177+ latestVersion : latest ,
178+ } ) ;
179+ return result ;
180+ }
181+
182+ export async function printPackageUpdateNoticeIfDue ( ) : Promise < void > {
183+ try {
184+ const result = await checkPackageUpdate ( ) ;
185+ if ( result . checked && result . updateAvailable ) {
186+ console . error ( formatPackageUpdateNotice ( result ) ) ;
187+ }
188+ } catch ( e ) {
189+ const message = e instanceof Error ? e . message : String ( e ) ;
190+ console . error ( `[boss-cli] 版本更新检查失败:${ message } ` ) ;
191+ }
192+ }
193+
55194export async function printVersionInfo ( ) : Promise < void > {
56195 const { name, version : current } = getPackageMeta ( ) ;
57196 console . log ( `${ name } ${ current } ` ) ;
58- const latest = await fetchNpmLatestVersion ( name ) ;
59- const cmp = compareSemver ( latest , current ) ;
60- if ( cmp > 0 ) {
61- console . log ( `新版本可用: ${ latest } (当前 ${ current } )。更新: npm i -g ${ name } ` ) ;
197+ const result = await checkPackageUpdate ( { force : true } ) ;
198+ if ( result . updateAvailable ) {
199+ console . log ( formatPackageUpdateNotice ( result ) ) ;
200+ } else if ( result . latest ) {
201+ console . log ( `未发现更新:当前 ${ result . current } ,npm latest ${ result . latest } ` ) ;
202+ }
203+ }
204+
205+ export async function runPackageUpdate ( ) : Promise < string > {
206+ const { name } = getPackageMeta ( ) ;
207+ const npmCommand = process . platform === 'win32' ? 'npm.cmd' : 'npm' ;
208+ const args = [ 'install' , '-g' , `${ name } @latest` ] ;
209+ console . error ( `[boss-cli] 正在执行:npm install -g ${ name } @latest` ) ;
210+
211+ const code = await new Promise < number > ( ( resolve , reject ) => {
212+ const child = spawn ( npmCommand , args , {
213+ shell : process . platform === 'win32' ,
214+ stdio : 'inherit' ,
215+ } ) ;
216+ child . on ( 'error' , reject ) ;
217+ child . on ( 'close' , ( exitCode , signal ) => {
218+ if ( signal ) {
219+ reject ( new Error ( `npm 更新进程被信号中断:${ signal } ` ) ) ;
220+ return ;
221+ }
222+ resolve ( exitCode ?? 0 ) ;
223+ } ) ;
224+ } ) ;
225+
226+ if ( code !== 0 ) {
227+ throw new Error ( `npm 更新失败,退出码 ${ code } ` ) ;
62228 }
229+ return 'boss-cli 更新完成。请重新运行 boss version 确认当前版本。' ;
63230}
0 commit comments