Skip to content

Commit abbe4b0

Browse files
authored
Merge pull request #729 from particle-iot/feature/sc-126217/implement-update-cli-command-in-js
feature/sc-126217/implement-update-cli-command-in-js
2 parents 87f794a + 7089080 commit abbe4b0

File tree

5 files changed

+198
-64
lines changed

5 files changed

+198
-64
lines changed

.circleci/config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,12 @@ jobs:
234234
name: Upload to S3
235235
command: |
236236
aws s3 --profile ARTIFACT-UPLOAD cp build/release/ s3://${S3_BUCKET}/particle-cli/ --recursive \
237-
--cache-control max-age=0
237+
--cache-control "public, max-age=0"
238238
- run:
239239
name: Upload cli installer to S3
240240
command: |
241241
aws s3 --profile ARTIFACT-UPLOAD cp installer/unix/install-cli s3://${S3_BUCKET}/particle-cli/installer/install-cli \
242-
--cache-control max-age=0
242+
--cache-control "public, max-age=0"
243243
244244
# Copied from following repos
245245
# https://github.com/particle-iot-inc/cache-aside/blob/2ee9e2d77138f1a9d22a7d604e7f8cc0d45f016e/.circleci/config.yml

settings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ let settings = {
1212
flashWarningShownOn: null,
1313
// TODO set to false once we give flags to control this
1414
disableUpdateCheck: envValueBoolean('PARTICLE_DISABLE_UPDATE', false),
15-
updateCheckInterval: 24 * 60 * 60 * 1000, // 24 hours
15+
updateCheckInterval: 4 * 60 * 60 * 1000, // 4 hours
1616
updateCheckTimeout: 3000,
1717

1818
//10 megs -- this constant here is arbitrary
@@ -21,6 +21,7 @@ let settings = {
2121
wirelessSetupFilter: /^Photon-.*$/,
2222

2323
serial_follow_delay: 250,
24+
manifestHost: envValue('PARTICLE_MANIFEST_HOST','binaries.particle.io'),
2425

2526
notSourceExtensions: [
2627
'.ds_store',

src/app/update-check.js

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,28 @@
1-
const chalk = require('chalk');
2-
const semver = require('semver');
31
const latestVersion = require('latest-version');
42
const settings = require('../../settings');
5-
const pkg = require('../../package');
6-
const ui = require('./ui');
7-
3+
const execa = require('execa');
84

95
module.exports = async (skip, force) => {
10-
const { displayVersionBanner } = module.exports.__internal__;
11-
126
if (skip) {
137
return;
148
}
159

1610
const now = Date.now();
1711
const lastCheck = settings.profile_json.last_version_check || 0;
12+
const skipUpdates = !settings.profile_json.enableUpdates || settings.disableUpdateCheck;
1813

1914
if ((now - lastCheck >= settings.updateCheckInterval) || force){
2015
settings.profile_json.last_version_check = now;
21-
22-
try {
23-
const version = await getPublishedVersion(pkg, settings);
24-
25-
if (semver.gt(version, pkg.version)){
26-
settings.profile_json.newer_version = version;
27-
} else {
28-
delete settings.profile_json.newer_version;
29-
}
30-
31-
settings.saveProfileData();
32-
33-
if (settings.profile_json.newer_version){
34-
displayVersionBanner(settings.profile_json.newer_version);
35-
}
36-
} catch (error){
16+
settings.saveProfileData();
17+
if (skipUpdates) {
3718
return;
3819
}
39-
return;
20+
execa('particle', ['update-cli'], { cleanup: false, detached: true });
4021
}
4122
};
4223

43-
async function getPublishedVersion(pkgJSON, settings){
44-
const { latestVersion } = module.exports.__internal__;
45-
46-
try {
47-
const promise = withTimeout(latestVersion(pkgJSON.name), settings.updateCheckTimeout);
48-
return await ui.spin(promise, 'Checking for updates...');
49-
} catch (error){
50-
return pkgJSON.version;
51-
}
52-
}
53-
54-
function displayVersionBanner(version){
55-
console.error('particle-cli v' + pkg.version);
56-
console.error();
57-
console.error(chalk.yellow('!'), 'A newer version (' + chalk.cyan(version) + ') of', chalk.bold.white('particle-cli'), 'is available.');
58-
console.error(chalk.yellow('!'), 'Upgrade now by running:', chalk.bold.white('particle update-cli'));
59-
console.error();
60-
}
61-
62-
function withTimeout(promise, ms){
63-
const timer = delay(ms).then(() => {
64-
throw new Error('The operation timed out');
65-
});
66-
return Promise.race([promise, timer]);
67-
}
68-
69-
function delay(ms){
70-
return new Promise((resolve) => setTimeout(resolve, ms));
71-
}
72-
7324

7425
module.exports.__internal__ = {
75-
latestVersion,
76-
displayVersionBanner
26+
latestVersion
7727
};
7828

src/cli/update-cli.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ module.exports = ({ commandProcessor, root }) => {
99
boolean: true,
1010
description: 'Disable automatic update checks'
1111
},
12+
'version': {
13+
description: 'Update to a specific version'
14+
}
1215
},
1316
handler: (args) => {
1417
const UpdateCliCommand = require('../cmd/update-cli');

src/cmd/update-cli.js

Lines changed: 185 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
const os = require('os');
2+
const path = require('path');
3+
const fs = require('fs-extra');
4+
const pkg = require('../../package');
5+
const semver = require('semver');
16
const log = require('../lib/log');
27
const chalk = require('chalk');
8+
const settings = require('../../settings');
9+
const request = require('request');
10+
const zlib = require('zlib');
11+
const Spinner = require('cli-spinner').Spinner;
12+
const crypto = require('crypto');
313

414
/*
515
* The update-cli command tells the CLI installer to reinstall the latest version of the CLI
@@ -8,16 +18,186 @@ const chalk = require('chalk');
818
* If the CLI was installed using npm, tell the user to update using npm
919
*/
1020
class UpdateCliCommand {
11-
update({ 'enable-updates': enableUpdates, 'disable-updates': disableUpdates }) {
21+
update({ 'enable-updates': enableUpdates, 'disable-updates': disableUpdates, version }) {
1222
if (enableUpdates) {
13-
log.info('Automatic update checks are now enabled');
14-
return;
23+
return this.enableUpdates();
1524
}
1625
if (disableUpdates) {
17-
log.info('Automatic update checks are now disabled');
26+
return this.disableUpdates();
27+
}
28+
if (!process.pkg) {
29+
log.info(`Update the CLI by running ${chalk.bold('npm install -g particle-cli')}`);
30+
log.info('To stay up to date with the latest features and improvements, please install the latest Particle Installer executable from our website: https://www.particle.io/cli');
31+
return;
32+
}
33+
return this.updateCli(version);
34+
}
35+
36+
async enableUpdates() {
37+
// set the update flag to true
38+
settings.profile_json.enableUpdates = true;
39+
settings.saveProfileData();
40+
log.info('Automatic update checks are now enabled');
41+
}
42+
async disableUpdates() {
43+
// set the update flag to false
44+
settings.profile_json.enableUpdates = false;
45+
settings.saveProfileData();
46+
log.info('Automatic update checks are now disabled');
47+
}
48+
49+
async updateCli(version) {
50+
log.info(`Updating the CLI to ${version ? version : 'latest'}`);
51+
const spinner = new Spinner('Updating CLI...');
52+
spinner.start();
53+
// download manifest
54+
const manifest = await this.downloadManifest(version);
55+
const upToDate = semver.gte(pkg.version, manifest.version) && !version;
56+
if (upToDate) {
57+
spinner.stop(true);
58+
log.info('CLI is already up to date');
1859
return;
1960
}
20-
log.info(`Update the CLI by running ${chalk.bold('npm install -g particle-cli')}`);
61+
const cliPath = await this.downloadCLI(manifest);
62+
await this.replaceCLI(cliPath);
63+
spinner.stop(true);
64+
await this.configureProfileSettings(version);
65+
log.info('CLI updated successfully');
66+
}
67+
68+
async downloadManifest(version) {
69+
const fileName = version ? `manifest-${version}.json` : 'manifest.json';
70+
const url = `https://${settings.manifestHost}/particle-cli/${fileName}`;
71+
return new Promise((resolve, reject ) => {
72+
return request(url, (error, response, body) => {
73+
if (error) {
74+
return this.logAndReject(error, reject, version);
75+
}
76+
if (response.statusCode !== 200) {
77+
return this.logAndReject(`Failed to download manifest: Status Code ${response.statusCode}`, reject, version);
78+
}
79+
try {
80+
resolve(JSON.parse(body));
81+
} catch (error) {
82+
this.logAndReject(error, reject, version);
83+
}
84+
});
85+
});
86+
}
87+
88+
logAndReject(error, reject, version) {
89+
const baseMessage = 'We were unable to check for updates';
90+
const message = version ? `${baseMessage}: Version ${version} not found` : `${baseMessage} Please try again later`;
91+
log.error(error);
92+
reject(message);
93+
}
94+
95+
async downloadCLI(manifest) {
96+
try {
97+
const { url, sha256: expectedHash } = this.getBuildDetailsFromManifest(manifest);
98+
const fileName = url.split('/').pop();
99+
const fileNameWithoutLastExtension = path.basename(fileName, path.extname(fileName));
100+
const filePath = path.join(os.tmpdir(), fileNameWithoutLastExtension);
101+
const tempFilePath = `${filePath}.gz`;
102+
103+
const output = fs.createWriteStream(tempFilePath);
104+
105+
return await new Promise((resolve, reject) => {
106+
request(url)
107+
.on('response', (response) => {
108+
if (response.statusCode !== 200) {
109+
log.debug(`Failed to download CLI: Status Code ${response.statusCode}`);
110+
return reject(new Error('No file found to download'));
111+
}
112+
})
113+
.pipe(output)
114+
.on('finish', async () => {
115+
const fileHash = await this.getFileHash(tempFilePath);
116+
if (fileHash === expectedHash) {
117+
const unzipPath = await this.unzipFile(tempFilePath, filePath);
118+
resolve(unzipPath);
119+
} else {
120+
reject(new Error('Hash mismatch'));
121+
}
122+
})
123+
.on('error', (error) => {
124+
reject(error);
125+
});
126+
});
127+
} catch (error) {
128+
log.debug(`Failed during download or verification: ${error}`);
129+
throw new Error('Failed to download or verify the CLI, please try again later');
130+
}
131+
}
132+
133+
async getFileHash(filePath) {
134+
return new Promise((resolve, reject) => {
135+
const hash = crypto.createHash('sha256');
136+
const stream = fs.createReadStream(filePath);
137+
stream.on('data', (data) => hash.update(data));
138+
stream.on('end', () => resolve(hash.digest('hex')));
139+
stream.on('error', (error) => reject(error));
140+
});
141+
}
142+
143+
async unzipFile(sourcePath, targetPath) {
144+
return new Promise((resolve, reject) => {
145+
const gunzip = zlib.createGunzip();
146+
const source = fs.createReadStream(sourcePath);
147+
const destination = fs.createWriteStream(targetPath);
148+
source
149+
.pipe(gunzip)
150+
.pipe(destination)
151+
.on('finish', () => resolve(targetPath))
152+
.on('error', (error) => reject(error));
153+
});
154+
}
155+
156+
getBuildDetailsFromManifest(manifest) {
157+
const platformMapping = {
158+
darwin: 'darwin',
159+
linux: 'linux',
160+
win32: 'win'
161+
};
162+
const archMapping = {
163+
x64: 'amd64',
164+
arm64: 'arm64'
165+
};
166+
const platform = os.platform();
167+
const arch = os.arch();
168+
const platformKey = platformMapping[platform] || platform;
169+
const archKey = archMapping[arch] || arch;
170+
const platformManifest = manifest.builds && manifest.builds[platformKey];
171+
const archManifest = platformManifest && platformManifest[archKey];
172+
if (!archManifest) {
173+
throw new Error(`No CLI build found for ${platform} ${arch}`);
174+
}
175+
return archManifest;
176+
}
177+
178+
async replaceCLI(newCliPath) {
179+
// rename the original CLI
180+
const binPath = this.getBinaryPath();
181+
const fileName = os.platform() === 'win32' ? 'particle.exe' : 'particle';
182+
const cliPath = path.join(binPath, fileName);
183+
const oldCliPath = path.join(binPath, `${fileName}.old`);
184+
await fs.move(cliPath, oldCliPath, { overwrite: true });
185+
await fs.move(newCliPath, cliPath);
186+
await fs.chmod(cliPath, 0o755); // add execute permissions
187+
}
188+
189+
getBinaryPath() {
190+
if (os.platform() === 'win32') {
191+
return path.join(process.env.LOCALAPPDATA, 'particle', 'bin');
192+
}
193+
return path.join(os.homedir(), 'bin');
194+
}
195+
async configureProfileSettings(version) {
196+
settings.profile_json.last_version_check = new Date().getTime();
197+
settings.saveProfileData();
198+
if (version) {
199+
await this.disableUpdates(); // disable updates since we are installing a specific version
200+
}
21201
}
22202
}
23203

0 commit comments

Comments
 (0)