Skip to content

Commit b973d51

Browse files
authored
feat(cli): add update checks (#3464)
1 parent a84ce8d commit b973d51

File tree

7 files changed

+637
-20
lines changed

7 files changed

+637
-20
lines changed

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
runs-on: ubuntu-latest
3030
strategy:
3131
matrix:
32-
go-version: ['1.23.x', '1.24.x']
32+
go-version: ['1.24.x']
3333
steps:
3434
- name: Checkout Repo
3535
uses: actions/checkout@main

genkit-tools/cli/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"build:watch": "tsc --watch",
2323
"compile:bun": "bun build src/bin/genkit.ts --compile --outfile dist/bin/genkit --minify",
2424
"test": "jest --verbose",
25-
"genversion": "genversion -esf src/utils/version.ts"
25+
"genversion": "genversion -esf --property name,version src/utils/version.ts"
2626
},
2727
"repository": {
2828
"type": "git",
@@ -40,13 +40,14 @@
4040
"get-port": "5.1.1",
4141
"@inquirer/prompts": "^7.8.0",
4242
"open": "^6.3.0",
43-
"ora": "^5.4.1"
43+
"ora": "^5.4.1",
44+
"semver": "^7.7.2"
4445
},
4546
"devDependencies": {
4647
"@jest/globals": "^29.7.0",
47-
"@types/inquirer": "^8.1.3",
4848
"@types/jest": "^29.5.12",
4949
"@types/node": "^20.11.19",
50+
"@types/semver": "^7.7.0",
5051
"bun-types": "^1.2.16",
5152
"genversion": "^3.2.0",
5253
"jest": "^29.7.0",

genkit-tools/cli/src/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import { start } from './commands/start';
3939
import { uiStart } from './commands/ui-start';
4040
import { uiStop } from './commands/ui-stop';
41+
import { showUpdateNotification } from './utils/updates';
4142
import { version } from './utils/version';
4243

4344
/**
@@ -66,6 +67,7 @@ export async function startCLI(): Promise<void> {
6667
.name('genkit')
6768
.description('Genkit CLI')
6869
.version(version)
70+
.option('--no-update-notification', 'Do not show update notification')
6971
.hook('preAction', async (_, actionCommand) => {
7072
await notifyAnalyticsIfFirstRun();
7173

@@ -87,6 +89,21 @@ export async function startCLI(): Promise<void> {
8789
await record(new RunCommandEvent(commandName));
8890
});
8991

92+
// Check for updates and show notification if available,
93+
// unless --no-update-notification is set
94+
// Run this synchronously to ensure it shows before command execution
95+
const hasNoUpdateNotification = process.argv.includes(
96+
'--no-update-notification'
97+
);
98+
if (!hasNoUpdateNotification) {
99+
try {
100+
await showUpdateNotification();
101+
} catch (e) {
102+
logger.debug('Failed to show update notification', e);
103+
// Silently ignore errors - update notifications shouldn't break the CLI
104+
}
105+
}
106+
90107
// When running as a spawned UI server process, argv[1] will be '__server-harness'
91108
// instead of a normal command. This allows the same binary to serve both CLI and server roles.
92109
if (process.argv[2] === SERVER_HARNESS_COMMAND) {

genkit-tools/cli/src/commands/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import {
2525
import * as clc from 'colorette';
2626
import { Command } from 'commander';
2727

28+
export const UPDATE_NOTIFICATIONS_OPT_OUT_CONFIG_TAG =
29+
'updateNotificationsOptOut';
30+
2831
const CONFIG_TAGS: Record<
2932
string,
3033
(value: string) => string | boolean | number
@@ -38,6 +41,15 @@ const CONFIG_TAGS: Record<
3841
return o;
3942
}
4043
},
44+
[UPDATE_NOTIFICATIONS_OPT_OUT_CONFIG_TAG]: (value) => {
45+
let o: boolean | undefined;
46+
try {
47+
o = JSON.parse(value);
48+
} finally {
49+
if (typeof o !== 'boolean') throw new Error('Expected boolean');
50+
return o;
51+
}
52+
},
4153
};
4254

4355
export const config = new Command('config');

genkit-tools/cli/src/utils/updates.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { GenkitToolsError } from '@genkit-ai/tools-common/manager';
18+
import { getUserSettings, logger } from '@genkit-ai/tools-common/utils';
19+
import axios, { AxiosInstance } from 'axios';
20+
import * as clc from 'colorette';
21+
import { arch, platform } from 'os';
22+
import semver from 'semver';
23+
import { UPDATE_NOTIFICATIONS_OPT_OUT_CONFIG_TAG } from '../commands/config';
24+
import { detectCLIRuntime } from '../utils/runtime-detector';
25+
import {
26+
version as currentVersion,
27+
name as packageName,
28+
} from '../utils/version';
29+
30+
const GCS_BUCKET_URL = 'https://storage.googleapis.com/genkit-assets-cli';
31+
const CLI_DOCS_URL = 'https://genkit.dev/docs/devtools/';
32+
const AXIOS_INSTANCE: AxiosInstance = axios.create({
33+
timeout: 3000,
34+
});
35+
36+
/**
37+
* Interface for update check result
38+
*/
39+
export interface UpdateCheckResult {
40+
hasUpdate: boolean;
41+
currentVersion: string;
42+
latestVersion: string;
43+
}
44+
45+
/**
46+
* Returns the current CLI version, normalized.
47+
*/
48+
export function getCurrentVersion(): string {
49+
return normalizeVersion(currentVersion);
50+
}
51+
52+
/**
53+
* Normalizes a version string by removing a leading 'v' if present.
54+
* @param version - The version string to normalize
55+
* @returns The normalized version string
56+
*/
57+
function normalizeVersion(version: string): string {
58+
return version.replace(/^v/, '');
59+
}
60+
61+
/**
62+
* Interface for the Google Cloud Storage latest.json response
63+
*/
64+
interface GCSLatestResponse {
65+
channel: string;
66+
latestVersion: string;
67+
lastUpdated: string;
68+
platforms: Record<
69+
string,
70+
{
71+
url: string;
72+
version: string;
73+
versionedUrl: string;
74+
}
75+
>;
76+
}
77+
78+
/**
79+
* Interface for npm registry response
80+
*/
81+
interface NpmRegistryResponse {
82+
'dist-tags': {
83+
latest: string;
84+
[key: string]: string;
85+
};
86+
versions: Record<string, unknown>;
87+
}
88+
89+
/**
90+
* Fetches the latest release data from GCS.
91+
*/
92+
async function getGCSLatestData(): Promise<GCSLatestResponse> {
93+
const response = await AXIOS_INSTANCE.get(`${GCS_BUCKET_URL}/latest.json`);
94+
95+
if (response.status !== 200) {
96+
throw new GenkitToolsError(
97+
`Failed to fetch GCS latest.json: ${response.statusText}`
98+
);
99+
}
100+
101+
return response.data as GCSLatestResponse;
102+
}
103+
104+
/**
105+
* Gets the latest CLI version from npm registry for non-binary installations.
106+
* @param ignoreRC - If true, ignore prerelease versions (default: true)
107+
*/
108+
export async function getLatestVersionFromNpm(
109+
ignoreRC: boolean = true
110+
): Promise<string | null> {
111+
try {
112+
const response = await AXIOS_INSTANCE.get(
113+
`https://registry.npmjs.org/${packageName}`
114+
);
115+
116+
if (response.status !== 200) {
117+
throw new GenkitToolsError(
118+
`Failed to fetch npm versions: ${response.statusText}`
119+
);
120+
}
121+
122+
const data: NpmRegistryResponse = response.data;
123+
124+
// Prefer dist-tags.latest if valid and not a prerelease (if ignoreRC)
125+
const latest = data['dist-tags']?.latest;
126+
if (latest) {
127+
const clean = normalizeVersion(latest);
128+
if (semver.valid(clean) && (!ignoreRC || !semver.prerelease(clean))) {
129+
return clean;
130+
}
131+
}
132+
133+
// Fallback: find the highest valid version in versions
134+
const versions = Object.keys(data.versions)
135+
.map(normalizeVersion)
136+
.filter((v) => semver.valid(v) && (!ignoreRC || !semver.prerelease(v)));
137+
138+
if (versions.length === 0) {
139+
return null;
140+
}
141+
142+
// Sort by semver descending (newest first)
143+
versions.sort(semver.rcompare);
144+
return versions[0];
145+
} catch (error: unknown) {
146+
if (error instanceof GenkitToolsError) {
147+
throw error;
148+
}
149+
150+
throw new GenkitToolsError(
151+
`Failed to fetch npm versions: ${(error as Error)?.message ?? String(error)}`
152+
);
153+
}
154+
}
155+
156+
/**
157+
* Checks if update notifications are disabled via environment variable or user config.
158+
*/
159+
function isUpdateNotificationsDisabled(): boolean {
160+
if (process.env.GENKIT_CLI_DISABLE_UPDATE_NOTIFICATIONS === 'true') {
161+
return true;
162+
}
163+
const userSettings = getUserSettings();
164+
return Boolean(userSettings[UPDATE_NOTIFICATIONS_OPT_OUT_CONFIG_TAG]);
165+
}
166+
167+
/**
168+
* Gets the latest version and update message for compiled binary installations.
169+
*/
170+
async function getBinaryUpdateInfo(): Promise<string | null> {
171+
const gcsLatestData = await getGCSLatestData();
172+
const machine = `${platform}-${arch}`;
173+
const platformData = gcsLatestData.platforms[machine];
174+
175+
if (!platformData) {
176+
logger.debug(`No update information for platform: ${machine}`);
177+
return null;
178+
}
179+
180+
const latestVersion = normalizeVersion(gcsLatestData.latestVersion);
181+
return latestVersion;
182+
}
183+
184+
/**
185+
* Gets the latest version and update message for npm installations.
186+
*/
187+
async function getNpmUpdateInfo(): Promise<string | null> {
188+
const latestVersion = await getLatestVersionFromNpm();
189+
if (!latestVersion) {
190+
logger.debug('No available versions found from npm.');
191+
return null;
192+
}
193+
return latestVersion;
194+
}
195+
196+
/**
197+
* Shows an update notification if a new version is available.
198+
* This function is designed to be called from the CLI entry point.
199+
* It can be disabled by the user's configuration or environment variable.
200+
*/
201+
export async function showUpdateNotification(): Promise<void> {
202+
try {
203+
if (isUpdateNotificationsDisabled()) {
204+
return;
205+
}
206+
207+
const { isCompiledBinary } = detectCLIRuntime();
208+
const updateInfo = isCompiledBinary
209+
? await getBinaryUpdateInfo()
210+
: await getNpmUpdateInfo();
211+
212+
if (!updateInfo) {
213+
return;
214+
}
215+
216+
const latestVersion = updateInfo;
217+
const current = normalizeVersion(currentVersion);
218+
219+
if (!semver.valid(latestVersion) || !semver.valid(current)) {
220+
logger.debug(
221+
`Invalid semver: current=${current}, latest=${latestVersion}`
222+
);
223+
return;
224+
}
225+
226+
if (!semver.gt(latestVersion, current)) {
227+
return;
228+
}
229+
230+
// Determine install method and update command for message
231+
const installMethod = isCompiledBinary
232+
? 'installer script'
233+
: 'your package manager';
234+
const updateCommand = isCompiledBinary
235+
? 'curl -sL cli.genkit.dev | uninstall=true bash'
236+
: 'npm install -g genkit-cli';
237+
238+
const updateNotificationMessage =
239+
`Update available ${clc.gray(`v${current}`)}${clc.green(`v${latestVersion}`)}\n` +
240+
`To update to the latest version using ${installMethod}, run\n${clc.cyan(updateCommand)}\n` +
241+
`For other CLI management options, visit ${CLI_DOCS_URL}\n` +
242+
`${clc.dim('Run')} ${clc.bold('genkit config set updateNotificationsOptOut true')} ${clc.dim('to disable these notifications')}\n`;
243+
244+
logger.info(`\n${updateNotificationMessage}`);
245+
} catch (e) {
246+
// Silently fail - update notifications shouldn't break the CLI
247+
logger.debug('Failed to show update notification', e);
248+
}
249+
}

0 commit comments

Comments
 (0)