Skip to content

Commit c8286ce

Browse files
authored
Merge pull request #2634 from adobe/copilot/check-updates-with-npm
Add update check to `hlx up` command
2 parents 7f2a60c + b816bf7 commit c8286ce

File tree

6 files changed

+475
-0
lines changed

6 files changed

+475
-0
lines changed

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"shelljs": "0.10.0",
7979
"unified": "11.0.5",
8080
"uuid": "13.0.0",
81+
"xdg-basedir": "5.1.0",
8182
"yargs": "18.0.0"
8283
},
8384
"devDependencies": {

src/up.cmd.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { HelixProject } from './server/HelixProject.js';
1717
import GitUtils from './git-utils.js';
1818
import pkgJson from './package.cjs';
1919
import { AbstractServerCommand } from './abstract-server.cmd.js';
20+
import { checkForUpdates } from './update-check.js';
2021

2122
export default class UpCommand extends AbstractServerCommand {
2223
withLiveReload(value) {
@@ -121,6 +122,11 @@ export default class UpCommand extends AbstractServerCommand {
121122
this.log.info(chalk`{yellow /_/ |_/_____/_/ /_/ /____/_/_/ /_/ /_/\\__,_/_/\\__,_/\\__/\\____/_/}`);
122123
this.log.info('');
123124

125+
// Check for updates asynchronously (non-blocking)
126+
checkForUpdates('@adobe/aem-cli', pkgJson.version, this.log).catch(() => {
127+
// Silently ignore errors
128+
});
129+
124130
const ref = await GitUtils.getBranch(this.directory);
125131
this._gitUrl = await GitUtils.getOriginURL(this.directory, { ref });
126132
if (!this._gitUrl) {

src/update-check.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import fs from 'fs/promises';
13+
import path from 'path';
14+
import os from 'os';
15+
import semver from 'semver';
16+
import chalk from 'chalk-template';
17+
import { xdgCache } from 'xdg-basedir';
18+
import { getFetch } from './fetch-utils.js';
19+
20+
/**
21+
* Gets the path to the update check cache file using XDG base directories.
22+
* @returns {string} Path to the cache file
23+
*/
24+
function getUpdateCheckCacheFile() {
25+
const cacheDir = xdgCache || path.join(os.homedir(), '.cache');
26+
return path.join(cacheDir, 'aem-cli', 'last-update-check');
27+
}
28+
29+
/**
30+
* Checks if we should skip the update check based on the last check time.
31+
* @returns {Promise<boolean>} True if we should skip the check
32+
*/
33+
async function shouldSkipUpdateCheck() {
34+
try {
35+
const cacheFile = getUpdateCheckCacheFile();
36+
const stats = await fs.stat(cacheFile);
37+
const lastCheck = stats.mtime.getTime();
38+
const now = Date.now();
39+
const oneDayInMs = 24 * 60 * 60 * 1000;
40+
41+
return (now - lastCheck) < oneDayInMs;
42+
} catch (error) {
43+
// If file doesn't exist or any other error, we should check
44+
return false;
45+
}
46+
}
47+
48+
/**
49+
* Updates the last update check timestamp.
50+
*/
51+
async function updateLastCheckTime() {
52+
try {
53+
const cacheFile = getUpdateCheckCacheFile();
54+
const cacheDir = path.dirname(cacheFile);
55+
56+
// Ensure cache directory exists
57+
await fs.mkdir(cacheDir, { recursive: true });
58+
59+
// Touch the file to update its mtime
60+
await fs.writeFile(cacheFile, Date.now().toString());
61+
} catch (error) {
62+
// Silently ignore errors - this is not critical
63+
}
64+
}
65+
66+
/**
67+
* Checks if a newer version of the package is available on npm.
68+
* @param {string} packageName - The npm package name to check
69+
* @param {string} currentVersion - The current version of the package
70+
* @param {object} logger - Logger instance for outputting messages
71+
* @returns {Promise<void>}
72+
*/
73+
export async function checkForUpdates(packageName, currentVersion, logger) {
74+
try {
75+
// Check if we should skip the update check (rate limiting)
76+
if (await shouldSkipUpdateCheck()) {
77+
return;
78+
}
79+
80+
const fetch = getFetch();
81+
const controller = new AbortController();
82+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
83+
84+
const response = await fetch(`https://registry.npmjs.org/${packageName}`, {
85+
signal: controller.signal,
86+
});
87+
clearTimeout(timeoutId);
88+
89+
if (!response.ok) {
90+
// Silently fail if we can't check for updates
91+
return;
92+
}
93+
94+
const data = await response.json();
95+
const latestVersion = data['dist-tags']?.latest;
96+
97+
if (latestVersion && semver.gt(latestVersion, currentVersion)) {
98+
const boxWidth = 61;
99+
const updateMsg = `Update available! ${currentVersion}${latestVersion}`;
100+
const installMsg = `Run npm install -g ${packageName} to update`;
101+
102+
// Use String.padEnd() instead of manual padding calculation
103+
const updatePadded = ` ${updateMsg}`.padEnd(boxWidth - 1);
104+
const installPadded = ` ${installMsg}`.padEnd(boxWidth - 1);
105+
106+
logger.warn('');
107+
logger.warn(chalk`{yellow ╭─────────────────────────────────────────────────────────────╮}`);
108+
logger.warn(chalk`{yellow │ │}`);
109+
logger.warn(chalk`{yellow │${updatePadded} │}`);
110+
logger.warn(chalk`{yellow │${installPadded} │}`);
111+
logger.warn(chalk`{yellow │ │}`);
112+
logger.warn(chalk`{yellow ╰─────────────────────────────────────────────────────────────╯}`);
113+
logger.warn('');
114+
}
115+
116+
// Update the last check time after a successful check
117+
await updateLastCheckTime();
118+
} catch (error) {
119+
// Silently fail - don't block the command if update check fails
120+
// Only log in debug mode if available
121+
if (logger.level === 'debug' || logger.level === 'silly') {
122+
logger.debug(`Update check failed: ${error.message}`);
123+
}
124+
}
125+
}

test/up-cmd.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import UpCommand from '../src/up.cmd.js';
2424
import GitUtils from '../src/git-utils.js';
2525
import { getFetch } from '../src/fetch-utils.js';
26+
import pkgJson from '../src/package.cjs';
2627

2728
const TEST_DIR = path.resolve(__rootdir, 'test', 'fixtures', 'project');
2829

@@ -37,6 +38,15 @@ describe('Integration test for up command with helix pages', function suite() {
3738
testDir = path.resolve(testRoot, 'project');
3839
nock = new Nock();
3940
nock.enableNetConnect(/127.0.0.1/);
41+
// Mock npm registry for update check (optional - not all tests trigger it)
42+
nock('https://registry.npmjs.org')
43+
.get('/@adobe/aem-cli')
44+
.optionally()
45+
.reply(200, {
46+
'dist-tags': {
47+
latest: pkgJson.version, // Same as current version to avoid update notification
48+
},
49+
});
4050
await fse.copy(TEST_DIR, testDir);
4151
});
4252

@@ -544,6 +554,15 @@ describe('Integration test for up command with git worktrees', function suite()
544554
await fse.copy(TEST_DIR, testDir);
545555
nock = new Nock();
546556
nock.enableNetConnect(/127.0.0.1/);
557+
// Mock npm registry for update check (optional - not all tests trigger it)
558+
nock('https://registry.npmjs.org')
559+
.get('/@adobe/aem-cli')
560+
.optionally()
561+
.reply(200, {
562+
'dist-tags': {
563+
latest: pkgJson.version, // Same as current version to avoid update notification
564+
},
565+
});
547566
});
548567

549568
afterEach(async () => {
@@ -706,6 +725,15 @@ describe('Integration test for up command with cache', function suite() {
706725
testDir = path.resolve(testRoot, 'project');
707726
await fse.copy(TEST_DIR, testDir);
708727
nock = new Nock();
728+
// Mock npm registry for update check (optional - not all tests trigger it)
729+
nock('https://registry.npmjs.org')
730+
.get('/@adobe/aem-cli')
731+
.optionally()
732+
.reply(200, {
733+
'dist-tags': {
734+
latest: pkgJson.version, // Same as current version to avoid update notification
735+
},
736+
});
709737
});
710738

711739
afterEach(async () => {

0 commit comments

Comments
 (0)