Skip to content

Commit a21436c

Browse files
committed
feat: Nevermore CLI now checks to make sure it is up to date
1 parent fbe4f7a commit a21436c

23 files changed

+1121
-176
lines changed

pnpm-lock.yaml

Lines changed: 720 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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
},
35+
"scripts": {
36+
"build": "tsc --build",
37+
"build:watch": "tsc --build --watch",
38+
"build:clean": "tsc --build --clean",
39+
"preinstall": "npx only-allow pnpm"
40+
},
41+
"publishConfig": {
42+
"access": "public"
43+
},
44+
"engines": {
45+
"node": ">=16"
46+
}
47+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { checkForUpdatesAsync } from './version-checker.js';
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
11+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
12+
13+
interface VersionCache {
14+
lastCheck: number;
15+
latestVersion: string;
16+
currentVersion: string;
17+
}
18+
19+
interface UpdateCheckResult {
20+
updateAvailable: boolean;
21+
currentVersion: string;
22+
latestVersion: string;
23+
}
24+
25+
interface VersionCheckerOptions {
26+
packageName: string;
27+
humanReadableName?: string;
28+
registryUrl: string;
29+
currentVersion?: string;
30+
packageJsonPath?: string;
31+
updateCommand?: string;
32+
verbose?: boolean;
33+
}
34+
35+
export async function checkForUpdatesAsync(
36+
options: VersionCheckerOptions
37+
): Promise<void> {
38+
try {
39+
await checkForUpdatesInternalAsync(options);
40+
} catch (error) {
41+
const name = options.humanReadableName || options.packageName;
42+
OutputHelper.box(`Failed to check for updates for ${name} due to ${error}`);
43+
}
44+
}
45+
46+
async function checkForUpdatesInternalAsync(
47+
options: VersionCheckerOptions
48+
): Promise<void> {
49+
const {
50+
packageName,
51+
registryUrl,
52+
currentVersion,
53+
packageJsonPath,
54+
updateCommand = `npm install -g ${packageName}@latest`,
55+
} = options;
56+
57+
const version = await queryOurVersionAsync(currentVersion, packageJsonPath);
58+
if (!version) {
59+
if (options.verbose) {
60+
OutputHelper.error(
61+
`Could not determine current version for ${packageName}, skipping update check.`
62+
);
63+
}
64+
return;
65+
}
66+
67+
const result = await queryUpdateStateAsync(packageName, version, registryUrl);
68+
69+
if (options.verbose) {
70+
OutputHelper.info(
71+
`Checked for updates for ${packageName}. Current version: ${result.currentVersion}, Latest version: ${result.latestVersion}, and update available: ${result.updateAvailable}`
72+
);
73+
}
74+
75+
if (result.updateAvailable) {
76+
const name = options.humanReadableName || packageName;
77+
const text = [
78+
`${name} update available: ${result.currentVersion}${result.latestVersion}`,
79+
'',
80+
OutputHelper.formatHint(`Run '${updateCommand}' to update`),
81+
].join('\n');
82+
83+
OutputHelper.box(text, { centered: true });
84+
}
85+
}
86+
87+
async function queryOurVersionAsync(
88+
currentVersion: string | undefined,
89+
packageJsonPath: string | undefined
90+
): Promise<string | null> {
91+
if (currentVersion) {
92+
return currentVersion;
93+
}
94+
95+
if (!packageJsonPath) {
96+
throw new Error(
97+
'Either currentVersion or packageJsonPath must be provided to determine the current version.'
98+
);
99+
}
100+
101+
const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
102+
return pkg.version || null;
103+
}
104+
105+
async function queryUpdateStateAsync(
106+
packageName: string,
107+
currentVersion: string,
108+
registryUrl: string
109+
): Promise<UpdateCheckResult> {
110+
// Use a simple cache file in the user's home directory
111+
const cacheKey = `${packageName.replace('/', '-').replace('@', '')}-version`;
112+
const cacheFile = path.join(os.homedir(), '.nevermore-version-cache');
113+
114+
// Try to read cached data
115+
let cachedData: VersionCache | undefined;
116+
let loadedCacheData;
117+
try {
118+
const cacheContent = await readFile(cacheFile, 'utf-8');
119+
loadedCacheData = JSON.parse(cacheContent);
120+
cachedData = loadedCacheData[cacheKey] as VersionCache | undefined;
121+
} catch (error) {
122+
// Cache file doesn't exist or is invalid, will check for updates
123+
}
124+
125+
// If we checked recently, skip
126+
const now = Date.now();
127+
if (
128+
cachedData &&
129+
(now - cachedData.lastCheck < CHECK_INTERVAL_MS ||
130+
cachedData.currentVersion !== currentVersion)
131+
) {
132+
return {
133+
updateAvailable: semver.gt(cachedData.latestVersion, currentVersion),
134+
currentVersion: currentVersion,
135+
latestVersion: cachedData.latestVersion,
136+
};
137+
}
138+
139+
const { default: latestVersion } = await import('latest-version');
140+
141+
// Check for new version
142+
const latestVersionString = await latestVersion(packageName, {
143+
registryUrl: registryUrl,
144+
});
145+
146+
// Save to cache
147+
const newCache: VersionCache = {
148+
lastCheck: now,
149+
latestVersion: latestVersionString,
150+
currentVersion: currentVersion,
151+
};
152+
const newResults = loadedCacheData || {};
153+
newResults[cacheKey] = newCache;
154+
155+
try {
156+
await writeFile(cacheFile, JSON.stringify(newResults, null, 2), 'utf-8');
157+
} catch (error) {
158+
// Ignore cache write errors, update check still worked
159+
OutputHelper.warn(`Failed to write cache file: ${error}`);
160+
}
161+
162+
// Return whether update is available
163+
return {
164+
updateAvailable: semver.gt(latestVersionString, currentVersion),
165+
currentVersion: currentVersion,
166+
latestVersion: latestVersionString,
167+
};
168+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"rootDir": "./src",
6+
"declarationDir": "./dist",
7+
"composite": true,
8+
"declaration": true
9+
},
10+
"references": [
11+
{ "path": "../cli-output-helpers" },
12+
],
13+
"include": ["src/**/*"]
14+
}

tools/nevermore-cli/.npmrc

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)