diff --git a/src/commands/status.ts b/src/commands/status.ts index 760fde9..685e09e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -12,7 +12,7 @@ import { ORBITER_WASM_NAME, SATELLITE_WASM_NAME } from '../constants/constants'; -import {checkVersion, getSatelliteVersion} from '../services/version.services'; +import {checkVersion, getSatelliteVersion} from '../services/version/version.services'; import type {AssetKey} from '../types/asset-key'; import {toAssetKeys} from '../utils/asset-key.utils'; import {orbiterKey, satelliteKey} from '../utils/cli.config.utils'; diff --git a/src/commands/version.ts b/src/commands/version.ts index c93725c..f73e0c1 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,15 +1,14 @@ -import {isEmptyString, isNullish} from '@dfinity/utils'; -import {green, red} from 'kleur'; -import {clean} from 'semver'; +import {isEmptyString} from '@dfinity/utils'; +import {green} from 'kleur'; import {version as cliCurrentVersion} from '../../package.json'; -import { - githubCliLastRelease, - githubJunoDockerLastRelease, - type GithubLastReleaseResult -} from '../rest/github.rest'; +import {githubCliLastRelease, githubJunoDockerLastRelease} from '../rest/github.rest'; import {findEmulatorVersion} from '../services/emulator/version.services'; -import {checkVersion, type CheckVersionResult} from '../services/version.services'; -import {detectPackageManager} from '../utils/pm.utils'; +import { + buildVersionFromGitHub, + checkVersion, + type CheckVersionResult +} from '../services/version/version.services'; +import {pmInstallHint} from '../utils/pm.utils'; export const version = async () => { const check = await cliVersion(); @@ -23,7 +22,7 @@ export const version = async () => { const cliVersion = async (): Promise => { const result = await buildVersionFromGitHub({ - release: 'CLI', + logReleaseOnError: () => 'CLI', releaseFn: githubCliLastRelease }); @@ -37,23 +36,10 @@ const cliVersion = async (): Promise => { currentVersion: cliCurrentVersion, latestVersion, displayHint: 'CLI', - commandLineHint: installHint() + commandLineHint: pmInstallHint() }); }; -const installHint = (): string => { - const pm = detectPackageManager(); - - switch (pm) { - case 'yarn': - return 'yarn global add @junobuild/cli'; - case 'pnpm': - return 'pnpm add -g @junobuild/cli'; - default: - return 'npm i -g @junobuild/cli'; - } -}; - const emulatorVersion = async () => { const emulatorResult = await findEmulatorVersion(); @@ -64,7 +50,7 @@ const emulatorVersion = async () => { const {version: emulatorCurrentVersion} = emulatorResult; const result = await buildVersionFromGitHub({ - release: 'Juno Docker', + logReleaseOnError: () => 'Juno Docker', releaseFn: githubJunoDockerLastRelease }); @@ -88,31 +74,3 @@ const emulatorVersion = async () => { displayHint: 'Emulator' }); }; - -const buildVersionFromGitHub = async ({ - releaseFn, - release -}: { - releaseFn: () => Promise; - release: 'CLI' | 'Juno Docker'; -}): Promise<{result: 'success'; latestVersion: string} | {result: 'error'}> => { - const githubRelease = await releaseFn(); - - if (githubRelease.status === 'error') { - console.log(red(`Cannot fetch the last version of ${release} on GitHub 😢.`)); - return {result: 'error'}; - } - - const { - release: {tag_name} - } = githubRelease; - - const latestVersion = clean(tag_name); - - if (isNullish(latestVersion)) { - console.log(red(`Cannot extract version from the ${release} release. Reach out Juno❗️`)); - return {result: 'error'}; - } - - return {result: 'success', latestVersion}; -}; diff --git a/src/configs/cli.state.config.ts b/src/configs/cli.state.config.ts index bec9ec6..711525a 100644 --- a/src/configs/cli.state.config.ts +++ b/src/configs/cli.state.config.ts @@ -7,7 +7,7 @@ import { type CliStateSatelliteAppliedConfigHashes } from '../types/cli/cli.state'; -export const getStateConfig = (): Conf => +const getStateConfig = (): Conf => new Conf({projectName: ENV.config.projectStateName}); export const getLatestAppliedConfig = ({ diff --git a/src/configs/cli.versions.config.ts b/src/configs/cli.versions.config.ts new file mode 100644 index 0000000..655050a --- /dev/null +++ b/src/configs/cli.versions.config.ts @@ -0,0 +1,32 @@ +import Conf from 'conf'; +import {type CachedVersion, type CachedVersions} from '../types/cli/cli.versions'; + +const getVersionConfig = (): Conf => + new Conf({projectName: 'juno-cli-versions'}); + +export const getCachedVersions = (): Conf => getVersionConfig(); + +export const updateLastCheckToNow = ({key}: {key: keyof CachedVersions}) => { + const config = getVersionConfig(); + + const currentVersions = config.get(key); + + config.set(key, { + lastCheck: new Date().toISOString(), + ...(currentVersions ?? {}) + }); +}; + +export const saveCachedVersions = ({ + key, + versions +}: { + key: keyof CachedVersions; + versions: Omit; +}) => { + const config = getVersionConfig(); + config.set(key, { + lastCheck: new Date().toISOString(), + ...versions + }); +}; diff --git a/src/index.ts b/src/index.ts index aacc688..c3d9de7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import {logHelpUpgrade} from './help/upgrade.help'; import {logHelpVersion} from './help/version.help'; import {logHelpWhoAmI} from './help/whoami.help'; import {checkNodeVersion} from './utils/env.utils'; +import {checkWeeklyVersions} from './version'; export const run = async () => { const {valid} = checkNodeVersion(); @@ -192,11 +193,15 @@ export const run = async () => { break; case 'help': console.log(help); + process.exit(0); break; default: console.log(red('Unknown command.')); console.log(help); + process.exit(-1); } + + await checkWeeklyVersions({cmd}); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/src/services/assets/deploy.services.ts b/src/services/assets/deploy.services.ts index ce1be5b..908e9d1 100644 --- a/src/services/assets/deploy.services.ts +++ b/src/services/assets/deploy.services.ts @@ -7,7 +7,7 @@ import {clearProposalStagedAssets} from '../changes/changes.clear.services'; import {applyConfig} from '../config/apply.services'; import {init} from '../config/init.services'; import {links} from '../links.services'; -import {getSatelliteVersion} from '../version.services'; +import {getSatelliteVersion} from '../version/version.services'; import {parseBatchSize} from './_args.services'; import {deployImmediate} from './_deploy/deploy.individual.services'; import {deployWithProposal as executeDeployWithProposal} from './_deploy/deploy.with-proposal.services'; diff --git a/src/services/version/version.check.services.ts b/src/services/version/version.check.services.ts new file mode 100644 index 0000000..35830f5 --- /dev/null +++ b/src/services/version/version.check.services.ts @@ -0,0 +1,167 @@ +import {isNullish, nonNullish} from '@dfinity/utils'; +import ora from 'ora'; +import {compare} from 'semver'; +import {version as cliCurrentVersion} from '../../../package.json'; +import { + getCachedVersions, + saveCachedVersions, + updateLastCheckToNow +} from '../../configs/cli.versions.config'; +import { + githubCliLastRelease, + githubJunoDockerLastRelease, + type GithubLastReleaseResult +} from '../../rest/github.rest'; +import {type CachedVersions} from '../../types/cli/cli.versions'; +import {pmInstallHint} from '../../utils/pm.utils'; +import {findEmulatorVersion} from '../emulator/version.services'; +import { + buildVersionFromGitHub, + type BuildVersionFromGitHubResult, + checkVersion +} from './version.services'; + +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +export const checkCliVersion = async () => { + const checkVersionFn = ({latestVersion}: {latestVersion: string}) => { + checkVersion({ + currentVersion: cliCurrentVersion, + latestVersion, + displayHint: 'CLI', + commandLineHint: pmInstallHint(), + logUpToDate: false, + logSpacer: true + }); + }; + + await check({ + key: 'cli', + currentVersion: cliCurrentVersion, + releaseFn: githubCliLastRelease, + checkVersionFn + }); +}; + +export const checkEmulatorVersion = async () => { + const emulatorResult = await findEmulatorVersion(); + + if (emulatorResult.status !== 'success') { + return; + } + + const {version: emulatorCurrentVersion} = emulatorResult; + + // We fetched the emulator but the version is null which could happen has providing the metadata + // was patched in Juno Docker v0.6.3 + if (isNullish(emulatorCurrentVersion)) { + return; + } + + const checkVersionFn = ({latestVersion}: {latestVersion: string}) => { + checkVersion({ + currentVersion: emulatorCurrentVersion, + latestVersion, + displayHint: 'Emulator', + logUpToDate: false, + logSpacer: true + }); + }; + + await check({ + key: 'emulator', + currentVersion: emulatorCurrentVersion, + releaseFn: githubJunoDockerLastRelease, + checkVersionFn + }); +}; + +const check = async ({ + key, + currentVersion, + releaseFn, + checkVersionFn +}: { + key: keyof CachedVersions; + currentVersion: string; + releaseFn: () => Promise; + checkVersionFn: (params: {latestVersion: string}) => void; +}) => { + const cachedVersions = getCachedVersions(); + + const cachedInfo = cachedVersions.get(key); + + const lastCheck = cachedInfo?.lastCheck; + + if (isNullish(lastCheck)) { + saveCachedVersions({ + key, + versions: { + local: currentVersion + } + }); + return; + } + + const cachedLocalVersion = cachedInfo?.local; + + // The version was never cached or the developer upgraded since the last check. + // We assume they are on the latest version. If not, the next weekly check will catch it. + if (isNullish(cachedLocalVersion) || compare(currentVersion, cachedLocalVersion) > 0) { + saveCachedVersions({ + key, + versions: { + local: currentVersion + } + }); + return; + } + + const checkIsDue = + new Date(new Date(lastCheck).getTime() + ONE_WEEK_MS).getTime() <= new Date().getTime(); + + const cachedRemoteVersion = cachedInfo?.remote; + + // The weekly check is not due and we got a remote version in cache + if (!checkIsDue && nonNullish(cachedRemoteVersion)) { + // The current version is newer or equals, we assume the dev use the latest + if (compare(currentVersion, cachedRemoteVersion) >= 0) { + return; + } + + // Behind but check not due, compare cached remote version + checkVersionFn({latestVersion: cachedRemoteVersion}); + return; + } + + const loadVersionWithGitHub = async (): Promise => { + const spinner = ora(`Two secs, fetching ${key} latest version...`).start(); + + try { + return await buildVersionFromGitHub({ + releaseFn + }); + } finally { + spinner.stop(); + } + }; + + const result = await loadVersionWithGitHub(); + + if (result.result === 'error') { + updateLastCheckToNow({key}); + return; + } + + const {latestVersion} = result; + + saveCachedVersions({ + key, + versions: { + local: currentVersion, + remote: latestVersion + } + }); + + checkVersionFn({latestVersion}); +}; diff --git a/src/services/version.services.ts b/src/services/version/version.services.ts similarity index 63% rename from src/services/version.services.ts rename to src/services/version/version.services.ts index 3fb877c..18f41dd 100644 --- a/src/services/version.services.ts +++ b/src/services/version/version.services.ts @@ -7,9 +7,10 @@ import { import {JUNO_PACKAGE_SATELLITE_ID} from '@junobuild/config'; import {cyan, green, red, yellow} from 'kleur'; import ora from 'ora'; -import {compare} from 'semver'; -import type {SatelliteParametersWithId} from '../types/satellite'; -import {assertConfigAndLoadSatelliteContext} from '../utils/juno.config.utils'; +import {clean, compare} from 'semver'; +import {type GithubLastReleaseResult} from '../../rest/github.rest'; +import type {SatelliteParametersWithId} from '../../types/satellite'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/juno.config.utils'; export const getSatelliteVersion = async (): Promise< {result: 'success'; version: string} | {result: 'error'} @@ -85,25 +86,44 @@ export const checkVersion = ({ currentVersion, latestVersion, displayHint, - commandLineHint + commandLineHint, + logUpToDate = true, + logSpacer = false }: { currentVersion: string; latestVersion: string; displayHint: string; commandLineHint?: string; + logUpToDate?: boolean; + logSpacer?: boolean; }): CheckVersionResult => { const diff = compare(currentVersion, latestVersion); if (diff === 0) { - console.log(`Your ${displayHint} (${green(`v${currentVersion}`)}) is up-to-date.`); + if (logSpacer) { + console.log(''); + } + + if (logUpToDate) { + console.log(`Your ${displayHint} (${green(`v${currentVersion}`)}) is up-to-date.`); + } + return {diff: 'up-to-date'}; } if (diff === 1) { + if (logSpacer) { + console.log(''); + } + console.log(yellow(`Your ${displayHint} version is more recent than the latest available 🤔.`)); return {diff: 'error'}; } + if (logSpacer) { + console.log(''); + } + console.log( `Your ${displayHint} (${yellow(`v${currentVersion}`)}) is behind the latest version (${green( `v${latestVersion}` @@ -112,3 +132,43 @@ export const checkVersion = ({ return {diff: 'outdated'}; }; + +export type BuildVersionFromGitHubResult = + | {result: 'success'; latestVersion: string} + | {result: 'error'}; + +export const buildVersionFromGitHub = async ({ + releaseFn, + logReleaseOnError +}: { + releaseFn: () => Promise; + logReleaseOnError?: () => 'CLI' | 'Juno Docker'; +}): Promise => { + const githubRelease = await releaseFn(); + + if (githubRelease.status === 'error') { + if (nonNullish(logReleaseOnError)) { + console.log(red(`Cannot fetch the last version of ${logReleaseOnError()} on GitHub 😢.`)); + } + + return {result: 'error'}; + } + + const { + release: {tag_name} + } = githubRelease; + + const latestVersion = clean(tag_name); + + if (isNullish(latestVersion)) { + if (nonNullish(logReleaseOnError)) { + console.log( + red(`Cannot extract version from the ${logReleaseOnError()} release. Reach out Juno❗️`) + ); + } + + return {result: 'error'}; + } + + return {result: 'success', latestVersion}; +}; diff --git a/src/types/cli/cli.versions.ts b/src/types/cli/cli.versions.ts new file mode 100644 index 0000000..365665a --- /dev/null +++ b/src/types/cli/cli.versions.ts @@ -0,0 +1,15 @@ +import {j} from '@junobuild/schema'; + +export const CachedVersionSchema = j.strictObject({ + lastCheck: j.iso.datetime(), + local: j.string().optional(), + remote: j.string().optional() +}); + +export const CachedVersionsSchema = j.strictObject({ + cli: CachedVersionSchema.optional(), + emulator: CachedVersionSchema.optional() +}); + +export type CachedVersion = j.infer; +export type CachedVersions = j.infer; diff --git a/src/utils/pm.utils.ts b/src/utils/pm.utils.ts index 46f7e87..657288f 100644 --- a/src/utils/pm.utils.ts +++ b/src/utils/pm.utils.ts @@ -23,3 +23,16 @@ export const detectPackageManager = (): PackageManager | undefined => { return undefined; }; + +export const pmInstallHint = (): string => { + const pm = detectPackageManager(); + + switch (pm) { + case 'yarn': + return 'yarn global add @junobuild/cli'; + case 'pnpm': + return 'pnpm add -g @junobuild/cli'; + default: + return 'npm i -g @junobuild/cli'; + } +}; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..1f5924d --- /dev/null +++ b/src/version.ts @@ -0,0 +1,21 @@ +import {checkCliVersion, checkEmulatorVersion} from './services/version/version.check.services'; +import {isHeadless} from './utils/process.utils'; + +export const checkWeeklyVersions = async ({cmd, args}: {cmd: string; args?: string[]}) => { + // No check if used in headless mode + if (isHeadless()) { + return; + } + + const [subCommand] = args ?? []; + + if (cmd === 'emulator' && ['start', 'wait'].includes(subCommand)) { + return; + } + + await checkCliVersion(); + + if (['functions', 'fn'].includes(cmd)) { + await checkEmulatorVersion(); + } +};