Skip to content

Commit da65081

Browse files
authored
Merge pull request #637 from Quenty/users/quenty/nevermore-cli-versioning
feat: Nevermore CLI now checks to make sure it is up to date
2 parents fbe4f7a + 9512418 commit da65081

23 files changed

+1054
-177
lines changed

pnpm-lock.yaml

Lines changed: 632 additions & 101 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/cli-output-helpers/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "@quenty/cli-output-helpers",
33
"version": "1.2.5",
44
"description": "Helpers to generate Nevermore package and game templates",
5+
"type": "module",
56
"keywords": [
67
"Roblox",
78
"Raven",
@@ -26,12 +27,12 @@
2627
"devDependencies": {
2728
"@types/node": "^18.11.4",
2829
"prettier": "2.7.1",
29-
"typescript": "^4.8.4"
30+
"typescript": "^5.9.3"
3031
},
3132
"scripts": {
3233
"build": "tsc --build",
33-
"watch": "tsc --build --watch",
34-
"clean": "tsc --build --clean",
34+
"build:watch": "tsc --build --watch",
35+
"build:clean": "tsc --build --clean",
3536
"preinstall": "npx only-allow pnpm"
3637
},
3738
"publishConfig": {

tools/cli-output-helpers/src/outputHelper.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import chalk from 'chalk';
22

3+
export type BoxOptions = {
4+
centered?: boolean;
5+
};
6+
37
/**
48
* Helps with output
59
*/
@@ -27,7 +31,7 @@ export class OutputHelper {
2731
* @param message Message to format
2832
* @returns Formatted string
2933
*/
30-
public static formatWarn(message: string): string {
34+
public static formatWarning(message: string): string {
3135
return chalk.yellowBright(message);
3236
}
3337

@@ -49,27 +53,80 @@ export class OutputHelper {
4953
return chalk.magentaBright(message);
5054
}
5155

56+
private static _stripAnsi = (text: string): string =>
57+
text.replace(/\x1b\[[0-9;]*m/g, '');
58+
59+
/**
60+
* Helper method to put a box around the output
61+
*/
62+
public static formatBox(message: string, options?: BoxOptions): string {
63+
const lines = message.trim().split('\n');
64+
const width = lines.reduce(
65+
(a, b) => Math.max(a, OutputHelper._stripAnsi(b).length),
66+
0
67+
);
68+
69+
const centered = options?.centered ?? false;
70+
71+
const surround = (text: string) => {
72+
const first = centered
73+
? Math.floor((width - OutputHelper._stripAnsi(text).length) / 2)
74+
: 0;
75+
const last = width - OutputHelper._stripAnsi(text).length - first;
76+
return (
77+
'║ \x1b[0m' +
78+
' '.repeat(first) +
79+
text +
80+
' '.repeat(last) +
81+
'\x1b[31m ║'
82+
);
83+
};
84+
85+
const bar = '═'.repeat(width);
86+
const top = '\x1b[31m╔═══' + bar + '═══╗';
87+
const pad = surround('');
88+
const bottom = '╚═══' + bar + '═══╝\x1b[0m';
89+
90+
return [top, pad, ...lines.map(surround), pad, bottom].join('\n');
91+
}
92+
5293
/**
5394
* Logs information to the console
5495
* @param message Message to write
5596
*/
56-
public static error(message: string) {
97+
public static error(message: string): void {
5798
console.error(this.formatError(message));
5899
}
59100

60101
/**
61102
* Logs information to the console
62103
* @param message Message to write
63104
*/
64-
public static info(message: string) {
105+
public static info(message: string): void {
65106
console.log(this.formatInfo(message));
66107
}
67108

68109
/**
69110
* Logs warning to the console
70111
* @param message Message to write
71112
*/
72-
public static warn(message: string) {
73-
console.log(this.formatWarn(message));
113+
public static warn(message: string): void {
114+
console.log(this.formatWarning(message));
115+
}
116+
117+
/**
118+
* Logs hint to the console
119+
* @param message Message to write
120+
*/
121+
public static hint(message: string): void {
122+
console.log(this.formatHint(message));
123+
}
124+
125+
/**
126+
* Renders a box around the message
127+
* @param message
128+
*/
129+
public static box(message: string, options?: BoxOptions): void {
130+
console.log(this.formatBox(message, options));
74131
}
75132
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## Nevermore CLI Helpers
2+
3+
This library helps with generic CLI functions that need to be shared with other packages.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "cli-output-helpers",
3+
"globIgnorePaths": [
4+
"**/.package-lock.json",
5+
"**/.pnpm",
6+
"**/.pnpm-workspace-state-v1.json",
7+
"**/.modules.yaml",
8+
"**/.ignored",
9+
"**/.ignored_*"
10+
],
11+
"tree": {
12+
"$path": { "optional": "does_not_exist_to_stop_rojo_errors" }
13+
}
14+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@quenty/nevermore-cli-helpers",
3+
"version": "1.0.0",
4+
"description": "Helpers to generate Nevermore package and game templates",
5+
"type": "module",
6+
"keywords": [
7+
"Roblox",
8+
"Raven",
9+
"CLI"
10+
],
11+
"main": "dist/utils.js",
12+
"bugs": {
13+
"url": "https://github.com/Quenty/Nevermore/issues"
14+
},
15+
"repository": {
16+
"type": "git",
17+
"url": "https://github.com/Quenty/Nevermore.git",
18+
"directory": "tools/nevermore-cli-helpers/"
19+
},
20+
"license": "UNLICENSED",
21+
"contributors": [
22+
"Quenty"
23+
],
24+
"dependencies": {
25+
"@quenty/cli-output-helpers": "workspace:*",
26+
"latest-version": "^9.0.0",
27+
"semver": "^7.6.0"
28+
},
29+
"devDependencies": {
30+
"@types/node": "^18.11.4",
31+
"@types/semver": "^7.5.0",
32+
"prettier": "2.7.1",
33+
"typescript": "^5.9.3",
34+
"typescript-memoize": "^1.1.1"
35+
},
36+
"scripts": {
37+
"build": "tsc --build",
38+
"build:watch": "tsc --build --watch",
39+
"build:clean": "tsc --build --clean",
40+
"preinstall": "npx only-allow pnpm"
41+
},
42+
"publishConfig": {
43+
"access": "public"
44+
},
45+
"engines": {
46+
"node": ">=16"
47+
}
48+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { VersionChecker } from './version-checker.js';
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Checks for updates to a given npm package and notifies the user if an update is available.
3+
*/
4+
5+
import * as os from 'os';
6+
import * as path from 'path';
7+
import * as semver from 'semver';
8+
import { OutputHelper } from '@quenty/cli-output-helpers';
9+
import { readFile, writeFile } from 'fs/promises';
10+
import { Memoize } from 'typescript-memoize';
11+
12+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
13+
14+
interface VersionCache {
15+
lastCheck: number;
16+
latestVersion: string;
17+
currentVersion: string;
18+
}
19+
20+
interface UpdateCheckResult {
21+
updateAvailable: boolean;
22+
currentVersion: string;
23+
latestVersion: string;
24+
}
25+
26+
interface VersionCheckerOptions {
27+
packageName: string;
28+
humanReadableName?: string;
29+
registryUrl: string;
30+
currentVersion?: string;
31+
packageJsonPath?: string;
32+
updateCommand?: string;
33+
verbose?: boolean;
34+
}
35+
36+
export class VersionChecker {
37+
public static async checkForUpdatesAsync(
38+
options: VersionCheckerOptions
39+
): Promise<UpdateCheckResult | undefined> {
40+
try {
41+
return await VersionChecker._checkForUpdatesInternalAsync(options);
42+
} catch (error) {
43+
const name = options.humanReadableName || options.packageName;
44+
OutputHelper.box(
45+
`Failed to check for updates for ${name} due to ${error}`
46+
);
47+
}
48+
49+
return undefined;
50+
}
51+
52+
private static async _checkForUpdatesInternalAsync(
53+
options: VersionCheckerOptions
54+
): Promise<UpdateCheckResult | undefined> {
55+
const {
56+
packageName,
57+
registryUrl,
58+
currentVersion,
59+
packageJsonPath,
60+
updateCommand = `npm install -g ${packageName}@latest`,
61+
} = options;
62+
63+
const version = await VersionChecker._queryOurVersionAsync(
64+
currentVersion,
65+
packageJsonPath
66+
);
67+
if (!version) {
68+
if (options.verbose) {
69+
OutputHelper.error(
70+
`Could not determine current version for ${packageName}, skipping update check.`
71+
);
72+
}
73+
return undefined;
74+
}
75+
76+
const result = await VersionChecker._queryUpdateStateAsync(
77+
packageName,
78+
version,
79+
registryUrl
80+
);
81+
82+
if (options.verbose) {
83+
OutputHelper.info(
84+
`Checked for updates for ${packageName}. Current version: ${result.currentVersion}, Latest version: ${result.latestVersion}, and update available: ${result.updateAvailable}`
85+
);
86+
}
87+
88+
if (result.updateAvailable) {
89+
const name = options.humanReadableName || packageName;
90+
const text = [
91+
`${name} update available: ${result.currentVersion}${result.latestVersion}`,
92+
'',
93+
OutputHelper.formatHint(`Run '${updateCommand}' to update`),
94+
].join('\n');
95+
96+
OutputHelper.box(text, { centered: true });
97+
}
98+
99+
return result;
100+
}
101+
102+
@Memoize()
103+
private static async _queryOurVersionAsync(
104+
currentVersion: string | undefined,
105+
packageJsonPath: string | undefined
106+
): Promise<string | null> {
107+
if (currentVersion) {
108+
return currentVersion;
109+
}
110+
111+
if (!packageJsonPath) {
112+
throw new Error(
113+
'Either currentVersion or packageJsonPath must be provided to determine the current version.'
114+
);
115+
}
116+
117+
const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
118+
return pkg.version || null;
119+
}
120+
121+
private static async _queryUpdateStateAsync(
122+
packageName: string,
123+
currentVersion: string,
124+
registryUrl: string
125+
): Promise<UpdateCheckResult> {
126+
// Use a simple cache file in the user's home directory
127+
const cacheKey = `${packageName
128+
.replace('/', '-')
129+
.replace('@', '')}-version`;
130+
const cacheFile = path.join(os.homedir(), '.nevermore-version-cache');
131+
132+
// Try to read cached data
133+
let cachedData: VersionCache | undefined;
134+
let loadedCacheData;
135+
try {
136+
const cacheContent = await readFile(cacheFile, 'utf-8');
137+
loadedCacheData = JSON.parse(cacheContent);
138+
cachedData = loadedCacheData[cacheKey] as VersionCache | undefined;
139+
} catch (error) {
140+
// Cache file doesn't exist or is invalid, will check for updates
141+
}
142+
143+
// If we checked recently, skip
144+
const now = Date.now();
145+
if (
146+
cachedData &&
147+
(now - cachedData.lastCheck < CHECK_INTERVAL_MS ||
148+
cachedData.currentVersion !== currentVersion)
149+
) {
150+
return {
151+
updateAvailable: semver.gt(cachedData.latestVersion, currentVersion),
152+
currentVersion: currentVersion,
153+
latestVersion: cachedData.latestVersion,
154+
};
155+
}
156+
157+
const { default: latestVersion } = await import('latest-version');
158+
159+
// Check for new version
160+
const latestVersionString = await latestVersion(packageName, {
161+
registryUrl: registryUrl,
162+
});
163+
164+
// Save to cache
165+
const newCache: VersionCache = {
166+
lastCheck: now,
167+
latestVersion: latestVersionString,
168+
currentVersion: currentVersion,
169+
};
170+
const newResults = loadedCacheData || {};
171+
newResults[cacheKey] = newCache;
172+
173+
try {
174+
await writeFile(cacheFile, JSON.stringify(newResults, null, 2), 'utf-8');
175+
} catch (error) {
176+
// Ignore cache write errors, update check still worked
177+
OutputHelper.warn(`Failed to write cache file: ${error}`);
178+
}
179+
180+
// Return whether update is available
181+
return {
182+
updateAvailable: semver.gt(latestVersionString, currentVersion),
183+
currentVersion: currentVersion,
184+
latestVersion: latestVersionString,
185+
};
186+
}
187+
}

0 commit comments

Comments
 (0)