Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/sc-126217/implement-update-cli-command-in-js #729

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,12 @@ jobs:
name: Upload to S3
command: |
aws s3 --profile ARTIFACT-UPLOAD cp build/release/ s3://${S3_BUCKET}/particle-cli/ --recursive \
--cache-control max-age=0
--cache-control "public, max-age=0"
- run:
name: Upload cli installer to S3
command: |
aws s3 --profile ARTIFACT-UPLOAD cp installer/unix/install-cli s3://${S3_BUCKET}/particle-cli/installer/install-cli \
--cache-control max-age=0
--cache-control "public, max-age=0"

# Copied from following repos
# https://github.com/particle-iot-inc/cache-aside/blob/2ee9e2d77138f1a9d22a7d604e7f8cc0d45f016e/.circleci/config.yml
Expand Down
3 changes: 2 additions & 1 deletion settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ let settings = {
flashWarningShownOn: null,
// TODO set to false once we give flags to control this
disableUpdateCheck: envValueBoolean('PARTICLE_DISABLE_UPDATE', false),
updateCheckInterval: 24 * 60 * 60 * 1000, // 24 hours
updateCheckInterval: 4 * 60 * 60 * 1000, // 4 hours
updateCheckTimeout: 3000,

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

serial_follow_delay: 250,
manifestHost: envValue('PARTICLE_MANIFEST_HOST','binaries.particle.io'),

notSourceExtensions: [
'.ds_store',
Expand Down
62 changes: 6 additions & 56 deletions src/app/update-check.js
Original file line number Diff line number Diff line change
@@ -1,78 +1,28 @@
const chalk = require('chalk');
const semver = require('semver');
const latestVersion = require('latest-version');
const settings = require('../../settings');
const pkg = require('../../package');
const ui = require('./ui');

const execa = require('execa');

module.exports = async (skip, force) => {
const { displayVersionBanner } = module.exports.__internal__;

if (skip) {
return;
}

const now = Date.now();
const lastCheck = settings.profile_json.last_version_check || 0;
const skipUpdates = !settings.profile_json.enableUpdates || settings.disableUpdateCheck;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone doesn't set enableUpdates it will skip updates. I think that's ok if we assume most people will install through the installer. npm install wouldn't set enableUpdates to true so that's fine. You may need to add a call to particle update-cli --enable-updates when installing from Workbench.

How about going back to the PRD and putting a little table of which installation methods enable updates, which ones don't and which ones have an option to enable/disable updates.


if ((now - lastCheck >= settings.updateCheckInterval) || force){
settings.profile_json.last_version_check = now;

try {
const version = await getPublishedVersion(pkg, settings);

if (semver.gt(version, pkg.version)){
settings.profile_json.newer_version = version;
} else {
delete settings.profile_json.newer_version;
}

settings.saveProfileData();

if (settings.profile_json.newer_version){
displayVersionBanner(settings.profile_json.newer_version);
}
} catch (error){
settings.saveProfileData();
if (skipUpdates) {
return;
}
return;
execa('particle', ['update-cli'], { cleanup: false, detached: true });
}
};

async function getPublishedVersion(pkgJSON, settings){
const { latestVersion } = module.exports.__internal__;

try {
const promise = withTimeout(latestVersion(pkgJSON.name), settings.updateCheckTimeout);
return await ui.spin(promise, 'Checking for updates...');
} catch (error){
return pkgJSON.version;
}
}

function displayVersionBanner(version){
console.error('particle-cli v' + pkg.version);
console.error();
console.error(chalk.yellow('!'), 'A newer version (' + chalk.cyan(version) + ') of', chalk.bold.white('particle-cli'), 'is available.');
console.error(chalk.yellow('!'), 'Upgrade now by running:', chalk.bold.white('particle update-cli'));
console.error();
}

function withTimeout(promise, ms){
const timer = delay(ms).then(() => {
throw new Error('The operation timed out');
});
return Promise.race([promise, timer]);
}

function delay(ms){
return new Promise((resolve) => setTimeout(resolve, ms));
}


module.exports.__internal__ = {
latestVersion,
displayVersionBanner
latestVersion
};

3 changes: 3 additions & 0 deletions src/cli/update-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ module.exports = ({ commandProcessor, root }) => {
boolean: true,
description: 'Disable automatic update checks'
},
'version': {
description: 'Update to a specific version'
}
},
handler: (args) => {
const UpdateCliCommand = require('../cmd/update-cli');
Expand Down
190 changes: 185 additions & 5 deletions src/cmd/update-cli.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
const os = require('os');
const path = require('path');
const fs = require('fs-extra');
const pkg = require('../../package');
const semver = require('semver');
const log = require('../lib/log');
const chalk = require('chalk');
const settings = require('../../settings');
const request = require('request');
const zlib = require('zlib');
const Spinner = require('cli-spinner').Spinner;
const crypto = require('crypto');

/*
* The update-cli command tells the CLI installer to reinstall the latest version of the CLI
Expand All @@ -8,16 +18,186 @@ const chalk = require('chalk');
* If the CLI was installed using npm, tell the user to update using npm
*/
class UpdateCliCommand {
update({ 'enable-updates': enableUpdates, 'disable-updates': disableUpdates }) {
update({ 'enable-updates': enableUpdates, 'disable-updates': disableUpdates, version }) {
if (enableUpdates) {
log.info('Automatic update checks are now enabled');
return;
return this.enableUpdates();
}
if (disableUpdates) {
log.info('Automatic update checks are now disabled');
return this.disableUpdates();
}
if (!process.pkg) {
log.info(`Update the CLI by running ${chalk.bold('npm install -g particle-cli')}`);
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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good message!

return;
}
return this.updateCli(version);
}

async enableUpdates() {
// set the update flag to true
settings.profile_json.enableUpdates = true;
settings.saveProfileData();
log.info('Automatic update checks are now enabled');
}
async disableUpdates() {
// set the update flag to false
settings.profile_json.enableUpdates = false;
settings.saveProfileData();
log.info('Automatic update checks are now disabled');
}

async updateCli(version) {
log.info(`Updating the CLI to ${version ? version : 'latest'}`);
const spinner = new Spinner('Updating CLI...');
spinner.start();
// download manifest
const manifest = await this.downloadManifest(version);
const upToDate = semver.gte(pkg.version, manifest.version) && !version;
if (upToDate) {
spinner.stop(true);
log.info('CLI is already up to date');
return;
}
log.info(`Update the CLI by running ${chalk.bold('npm install -g particle-cli')}`);
const cliPath = await this.downloadCLI(manifest);
await this.replaceCLI(cliPath);
spinner.stop(true);
await this.configureProfileSettings(version);
log.info('CLI updated successfully');
}

async downloadManifest(version) {
const fileName = version ? `manifest-${version}.json` : 'manifest.json';
const url = `https://${settings.manifestHost}/particle-cli/${fileName}`;
return new Promise((resolve, reject ) => {
return request(url, (error, response, body) => {
if (error) {
return this.logAndReject(error, reject, version);
}
if (response.statusCode !== 200) {
return this.logAndReject(`Failed to download manifest: Status Code ${response.statusCode}`, reject, version);
}
try {
resolve(JSON.parse(body));
} catch (error) {
this.logAndReject(error, reject, version);
}
});
});
}

logAndReject(error, reject, version) {
const baseMessage = 'We were unable to check for updates';
const message = version ? `${baseMessage}: Version ${version} not found` : `${baseMessage} Please try again later`;
log.error(error);
reject(message);
}

async downloadCLI(manifest) {
try {
const { url, sha256: expectedHash } = this.getBuildDetailsFromManifest(manifest);
const fileName = url.split('/').pop();
const fileNameWithoutLastExtension = path.basename(fileName, path.extname(fileName));
const filePath = path.join(os.tmpdir(), fileNameWithoutLastExtension);
const tempFilePath = `${filePath}.gz`;

const output = fs.createWriteStream(tempFilePath);

return await new Promise((resolve, reject) => {
request(url)
.on('response', (response) => {
if (response.statusCode !== 200) {
log.debug(`Failed to download CLI: Status Code ${response.statusCode}`);
return reject(new Error('No file found to download'));
}
})
.pipe(output)
.on('finish', async () => {
const fileHash = await this.getFileHash(tempFilePath);
if (fileHash === expectedHash) {
const unzipPath = await this.unzipFile(tempFilePath, filePath);
resolve(unzipPath);
} else {
reject(new Error('Hash mismatch'));
}
})
.on('error', (error) => {
reject(error);
});
});
} catch (error) {
log.debug(`Failed during download or verification: ${error}`);
throw new Error('Failed to download or verify the CLI, please try again later');
}
}

async getFileHash(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', (error) => reject(error));
});
}

async unzipFile(sourcePath, targetPath) {
return new Promise((resolve, reject) => {
const gunzip = zlib.createGunzip();
const source = fs.createReadStream(sourcePath);
const destination = fs.createWriteStream(targetPath);
source
.pipe(gunzip)
.pipe(destination)
.on('finish', () => resolve(targetPath))
.on('error', (error) => reject(error));
});
}

getBuildDetailsFromManifest(manifest) {
const platformMapping = {
darwin: 'darwin',
linux: 'linux',
win32: 'win'
};
const archMapping = {
x64: 'amd64',
arm64: 'arm64'
};
const platform = os.platform();
const arch = os.arch();
const platformKey = platformMapping[platform] || platform;
const archKey = archMapping[arch] || arch;
const platformManifest = manifest.builds && manifest.builds[platformKey];
const archManifest = platformManifest && platformManifest[archKey];
if (!archManifest) {
throw new Error(`No CLI build found for ${platform} ${arch}`);
}
return archManifest;
}

async replaceCLI(newCliPath) {
// rename the original CLI
const binPath = this.getBinaryPath();
const fileName = os.platform() === 'win32' ? 'particle.exe' : 'particle';
const cliPath = path.join(binPath, fileName);
const oldCliPath = path.join(binPath, `${fileName}.old`);
await fs.move(cliPath, oldCliPath, { overwrite: true });
await fs.move(newCliPath, cliPath);
await fs.chmod(cliPath, 0o755); // add execute permissions
}

getBinaryPath() {
if (os.platform() === 'win32') {
return path.join(process.env.LOCALAPPDATA, 'particle', 'bin');
}
return path.join(os.homedir(), 'bin');
}
async configureProfileSettings(version) {
settings.profile_json.last_version_check = new Date().getTime();
settings.saveProfileData();
if (version) {
await this.disableUpdates(); // disable updates since we are installing a specific version
}
}
}

Expand Down