particle-cli
Version:
Simple Node commandline application for working with your Particle devices and using the Particle Cloud
758 lines (673 loc) • 27.2 kB
JavaScript
const CLICommandBase = require('./base');
const spinnerMixin = require('../lib/spinner-mixin');
const fs = require('fs-extra');
const ParticleApi = require('./api');
const settings = require('../../settings');
const createApiCache = require('../lib/api-cache');
const ApiClient = require('../lib/api-client');
const crypto = require('crypto');
const temp = require('temp').track();
const os = require('os');
const FlashCommand = require('./flash');
const CloudCommand = require('./cloud');
const { sha512crypt } = require('sha512crypt-node');
const DownloadManager = require('../lib/download-manager');
const { platformForId, PLATFORMS } = require('../lib/platform');
const path = require('path');
const semver = require('semver');
const { prepareFlashFiles, getTachyonInfo, promptWifiNetworks, getEDLDevice } = require('../lib/tachyon-utils');
const { supportedCountries } = require('../lib/supported-countries');
const showWelcomeMessage = (ui) => `
===================================================================================
Particle Tachyon Setup Command
===================================================================================
Welcome to the Particle Tachyon setup! This interactive command:
- Flashes your Tachyon device
- Configures it (password, WiFi credentials etc...)
- Connects it to the internet and the Particle Cloud!
${ui.chalk.bold('What you\'ll need:')}
1. Your Tachyon device
2. The Tachyon battery
3. A USB-C cable
${ui.chalk.bold('Important:')}
${ui.chalk.bold(`${os.EOL}`)}
- This tool requires you to be logged into your Particle account.
- For more details, check out the documentation at: https://part.cl/setup-tachyon ${os.EOL}`;
module.exports = class SetupTachyonCommands extends CLICommandBase {
constructor({ ui } = {}) {
super();
spinnerMixin(this);
this._setupApi();
this.ui = ui || this.ui;
this.device = null;
this._baseDir = settings.ensureFolder();
this._logsDir = path.join(this._baseDir, 'logs');
this.outputLog = null;
this.defaultOptions = {
region: 'NA',
version: 'stable',
board: 'formfactor',
country: 'USA',
variant: null,
skipFlashingOs: false,
skipCli: false,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // eslint-disable-line new-cap
alwaysCleanCache: false
};
this.options = {};
}
async setup({ skip_flashing_os: skipFlashingOs, timezone, load_config: loadConfig, save_config: saveConfig, region, version, variant, board, skip_cli: skipCli } = {}) {
const requiredFields = ['region', 'version', 'systemPassword', 'productId', 'timezone'];
const options = { skipFlashingOs, timezone, loadConfig, saveConfig, region, version, variant, board, skipCli };
await this.ui.write(showWelcomeMessage(this.ui));
// step 1 login
this._formatAndDisplaySteps("Okay—first up! Checking if you're logged in...", 1);
await this._verifyLogin();
this.ui.write('');
this.ui.write(`...All set! You're logged in as ${this.ui.chalk.bold(settings.username)} and ready to go!`);
// step 2 get device info
this._formatAndDisplaySteps("Now let's get the device info", 2);
this.ui.write('');
const device = await getEDLDevice({ ui: this.ui, showSetupMessage: true });
this.device = device;
// ensure logs dir
await fs.ensureDir(this._logsDir);
this.outputLog = path.join(this._logsDir, `tachyon_flash_${this.device.id}_${Date.now()}.log`);
await fs.ensureFile(this.outputLog);
this.ui.write(`${os.EOL}Starting Process. See logs at: ${this.outputLog}${os.EOL}`);
const deviceInfo = await this._getDeviceInfo();
this._printDeviceInfo(deviceInfo);
// check if there is a config file
const config = await this._loadConfig({ options, requiredFields, deviceInfo });
config.isLocalVersion = this._validateVersion(config);
if (config.silent) {
this.ui.write(this.ui.chalk.bold(`Skipping to Step 5 - Using configuration file: ${loadConfig} ${os.EOL}`));
} else {
Object.assign(config, await this._getUserConfigurationStep()); // step 3
config.productId = await this._getProductStep(); // step 4
config.variant = await this._pickVariantStep(config); // step 5
config.country = await this._getCountryStep(); // step 6
}
if (settings.isStaging) {
config.apiServer = settings.apiUrl;
config.server = 'https://edge.staging.particle.io';
config.verbose = true;
}
config.packagePath = await this._downloadStep(config); // step 6
this.product = await this._getProductDetails(config.productId);
config.registrationCode = await this._registerDeviceStep(config); // step 7
config.esim = await this._getESIMProfiles({ deviceId: this.device.id, country: config.country, productId: config.productId }); // after add device to product
const { xmlPath } = await this._configureConfigAndSaveStep(config); // step 8
const flashSuccess = await this._flashStep(config.packagePath, xmlPath, config); // step 9
await this._finalStep(flashSuccess, config); // step 10
}
async _getDeviceInfo() {
try {
return await this.ui.showBusySpinnerUntilResolved('Getting device info', getTachyonInfo({
outputLog: this.outputLog,
ui: this.ui,
device: this.device
}));
} catch (error) {
// If this fails, the flash won't work so abort early.
throw new Error('Unable to get device info. Please restart the device and try again.');
}
}
async _printDeviceInfo(deviceInfo) {
this.ui.write(this.ui.chalk.bold('Device info:'));
this.ui.write(os.EOL);
this.ui.write(` - Device ID: ${deviceInfo.deviceId}`);
if (deviceInfo.osVersion.includes('EVT')) {
this.ui.write(' - Board: EVT');
}
this.ui.write(` - Region: ${deviceInfo.region}`);
this.ui.write(` - OS Version: ${deviceInfo.osVersion}`);
let usbWarning = '';
if (this.device.usbVersion.major <= 2) {
usbWarning = this.ui.chalk.yellow(' (use a USB 3.0 port and USB-C cable for faster flashing)');
}
this.ui.write(` - USB Version: ${this.device.usbVersion.major}.${this.device.usbVersion.minor}${usbWarning}`);
}
async _verifyLogin() {
const api = new ApiClient();
try {
api.ensureToken();
const currentToken = await api.getCurrentToken();
const minRemainingTime = 60 * 60 * 1000; // 1 hour
const expiresAt = currentToken.expires_at ? new Date(currentToken.expires_at) : null;
if (expiresAt !== null && (expiresAt - Date.now()) < minRemainingTime) {
throw new Error('Token expired or near to expire');
}
} catch {
const cloudCommand = new CloudCommand();
await cloudCommand.login();
this._setupApi();
}
}
async _loadConfig({ options, requiredFields, deviceInfo }) {
const configFromFile = await this._loadConfigFromFile(options.loadConfig);
const optionsFromDevice = {};
const cleanedOptions = Object.fromEntries(
// eslint-disable-next-line no-unused-vars
Object.entries(options).filter(([_, v]) => v !== undefined)
);
if (deviceInfo) {
optionsFromDevice.region = deviceInfo.region.toLowerCase() !== 'unknown' ? deviceInfo.region : 'NA';
optionsFromDevice.board = deviceInfo.osVersion === 'Ubuntu 20.04' ? 'formfactor_dvt' : 'formfactor';
}
const config = {
...this.defaultOptions,
...optionsFromDevice,
...configFromFile,
...cleanedOptions
};
// validate the config file if is silent
if (configFromFile?.silent) {
await this._validateConfig(config, requiredFields);
}
return config;
}
async _loadConfigFromFile(loadConfig) {
if (loadConfig) {
try {
const data = fs.readFileSync(loadConfig, 'utf8');
const config = JSON.parse(data);
// remove board to prevent overwriting.
delete config.board;
return { ...config, silent: true, loadedFromFile: true };
} catch (error) {
throw new Error(`The configuration file is not a valid JSON file: ${error.message}`);
}
}
}
async _validateConfig(config, requiredFields) {
const missingFields = requiredFields.filter(field => !config[field]);
if (missingFields.length) {
const message = `The configuration file is missing required fields: ${missingFields.join(', ')}${os.EOL}`;
this.ui.stdout.write(this.ui.chalk.red(message));
this.ui.write(this.ui.chalk.red('Re-run the command with the correct configuration file.'));
throw new Error('Not a valid configuration file');
}
}
_validateVersion(config) {
const isLocalVersion = this._isFile(config.version);
if (!isLocalVersion && config.silent) {
// validate we have board and variant
if (!config.board || !config.variant) {
throw new Error('Board and variant are required for silent mode');
}
}
return isLocalVersion;
}
async _getUserConfigurationStep() {
return this._runStepWithTiming(
`Now lets capture some information about how you'd like your device to be configured when it first boots.${os.EOL}${os.EOL}` +
`First, you'll be asked to set a password for the root account on your Tachyon device.${os.EOL}` +
`This same password is used for the user “particle”.${os.EOL}` +
`Don't worry if you forget this—you can always reset your device later.${os.EOL}${os.EOL}` +
`Finally you'll be prompted to provide a Wi-Fi network.${os.EOL}` +
`This is needed to install the eSIM profile over the air so the device can connect to the 5G cellular network.${os.EOL}`,
3,
() => this._userConfiguration(),
0
);
}
async _userConfiguration() {
const passwordAnswer = await this._getSystemPassword();
const systemPassword = this._generateShadowCompatibleHash(passwordAnswer);
const wifi = await this._getWifiConfiguration();
return { systemPassword, wifi };
}
async _getWifiConfiguration() {
this.ui.write(
this.ui.chalk.bold(
`${os.EOL}` +
`Wi-Fi setup is required to continue when using Particle setup!${os.EOL}` +
`This active internet connection is necessary to activate cellular connectivity on your device.${os.EOL}`
)
);
return promptWifiNetworks(this.ui);
}
async _getSystemPassword() {
let password = '';
while (password === '') {
password = await this.ui.promptPasswordWithConfirmation({
customMessage: 'Enter a password for the root and particle accounts:',
customConfirmationMessage: 'Re-enter the password for the root and particle accounts:'
});
if (password === '') {
this.ui.write('System password cannot be blank.');
}
}
return password;
}
async _getProductStep() {
return this._runStepWithTiming(
`Next, let's select a Particle product for your Tachyon.${os.EOL}` +
'A product will help manage the Tachyon device and keep things organized.',
4,
() => this._selectProduct()
);
}
async _getCountryStep() {
return this._runStepWithTiming(
`Next, let's configure the cellular connection for your Tachyon!.${os.EOL}` +
'Select from the list of countries supported for the built in Particle cellular ' +
`connection or select 'Other' if your country is not listed.${os.EOL}` +
'For more information, visit: https://developer.particle.io/redirect/tachyon-cellular-setup',
6,
() => this._promptForCountry()
);
}
async _pickVariantStep(config) {
if (config.isLocalVersion || config.variant) {
this.ui.write(`Skipping to Step 5 - Using ${config.variant || config.version} operating system.${os.EOL}`);
return;
}
const isRb3Board = config.board === 'rb3g2'; // RGB board
let variantDescription = `Select the variant of the Tachyon operating system to set up.${os.EOL}`;
if (isRb3Board) {
variantDescription += 'The "preinstalled server" variant is for the RGB board.';
} else {
variantDescription += `The 'desktop' includes a GUI and is best for interacting with the device with a keyboard, mouse, and display.${os.EOL}`;
variantDescription += "The 'headless' variant is accessed only by a terminal out of the box.";
}
return this._runStepWithTiming(
variantDescription,
5,
() => this._selectVariant(isRb3Board)
);
}
async _getESIMProfiles({ deviceId, country, productId }) {
try {
return await this.api.getESIMProfiles(deviceId, productId, country);
} catch (error) {
const message = `Error getting eSIM profiles: ${error.message}${os.EOL}`;
this.ui.write(this.ui.chalk.yellow(message));
return null;
}
}
async _downloadStep(config) {
return this._runStepWithTiming(
`Next, we'll download the Tachyon Operating System image.${os.EOL}` +
`Heads up: it's a large file — 3GB! Don't worry, though—the download will resume${os.EOL}` +
`if it's interrupted. If you have to kill the CLI, it will pick up where it left. You can also${os.EOL}` +
"just let it run in the background. We'll wait for you to be ready when its time to flash the device.",
7,
() => this._download(config)
);
}
async _getProductDetails(productId) {
const { product } = await this.api.getProduct({ product: productId });
return product;
}
async _registerDeviceStep(config) {
return this._runStepWithTiming(
`Great! The download is complete.${os.EOL}` +
"Now, let's register your product on the Particle platform.",
8,
() => this._getRegistrationCode(config.productId)
);
}
async _configureConfigAndSaveStep(config) {
const { path: configBlobPath, configBlob } = await this._runStepWithTiming(
'Creating the configuration file to write to the Tachyon device...',
9,
() => this._createConfigBlob(config, this.device.id)
);
const { xmlFile: xmlPath } = await prepareFlashFiles({
logFile: this.outputLog,
ui: this.ui,
partitionsList: ['misc'],
dir: path.dirname(configBlobPath),
deviceId: this.device.id,
operation: 'program',
checkFiles: true,
device: this.device
});
// Save the config file if requested
if (config.saveConfig) {
await this._saveConfig(config, configBlob);
}
return { xmlPath };
}
async _flashStep(packagePath, xmlPath, config) {
let message = `Heads up: this is a large image and flashing will take about 2 minutes to complete.${os.EOL}`;
const slowUsb = this.device.usbVersion.major <= 2;
if (slowUsb) {
message = `Heads up: this is a large image and flashing will take about 8 minutes to complete.${os.EOL}` +
this.ui.chalk.yellow(`${os.EOL}The device is connected to a slow USB port. Connect a USB Type-C cable directly to a USB 3.0 port to shorten this step to 2 minutes.${os.EOL}`);
}
return this._runStepWithTiming(
`Okay—last step! We're now flashing the device with the configuration, including the password, Wi-Fi settings, and operating system.${os.EOL}` +
message +
`${os.EOL}` +
`Meanwhile, you can explore the developer documentation at https://developer.particle.io${os.EOL}` +
`${os.EOL}` +
`You can also view your device on the Console at ${this._consoleLink()}${os.EOL}`,
10,
() => this._flash({
files: [packagePath, xmlPath],
skipFlashingOs: config.skipFlashingOs,
skipReset: config.variant === 'desktop'
})
);
}
async _finalStep(flashSuccessful, config) { // TODO (hmontero): once we have the device in the cloud, we should show the device id
if (flashSuccessful) {
if (config.variant === 'desktop') {
this._formatAndDisplaySteps(
`All done! Your Tachyon device is ready to boot to the desktop and will automatically connect to Wi-Fi.${os.EOL}${os.EOL}` +
`To continue:${os.EOL}` +
` - Disconnect the USB-C cable${os.EOL}` +
` - Connect a USB-C Hub with an HDMI monitor, keyboard, and mouse.${os.EOL}` +
` - Power off the device by holding the power button for 3 seconds and releasing.${os.EOL}` +
` - Power on the device by pressing the power button.${os.EOL}${os.EOL}` +
`When the device boots it will:${os.EOL}` +
` - Activate the built-in 5G modem.${os.EOL}` +
` - Connect to the Particle Cloud.${os.EOL}` +
` - Run all system services, including the desktop if an HDMI monitor is connected.${os.EOL}${os.EOL}` +
`For more information about Tachyon, visit our developer site at: https://developer.particle.io!${os.EOL}` +
`${os.EOL}` +
`View your device on the Particle Console at: ${this._consoleLink()}`,
11
);
} else {
this._formatAndDisplaySteps(
`All done! Your Tachyon device is now booting into the operating system and will automatically connect to Wi-Fi.${os.EOL}${os.EOL}` +
`It will also:${os.EOL}` +
` - Activate the built-in 5G modem${os.EOL}` +
` - Connect to the Particle Cloud${os.EOL}` +
` - Run all system services, including battery charging${os.EOL}${os.EOL}` +
`For more information about Tachyon, visit our developer site at: https://developer.particle.io!${os.EOL}` +
`${os.EOL}` +
`View your device on the Particle Console at: ${this._consoleLink()}`,
11
);
}
} else {
this.ui.write(
`${os.EOL}Flashing failed. Please unplug your device and rerun this. We're going to have to try it again.${os.EOL}` +
`If it continues to fail, please select a different USB port or visit https://part.cl/setup-tachyon and the setup link for more information.${os.EOL}`
);
}
}
_consoleLink() {
const baseUrl = `https://console${settings.isStaging ? '.staging' : ''}.particle.io`;
return `${baseUrl}/${this.product.slug}/devices/${this.device.id}`;
}
async _runStepWithTiming(stepDesc, stepNumber, asyncTask, minDuration = 2000) {
this._formatAndDisplaySteps(stepDesc, stepNumber);
const startTime = Date.now();
try {
const result = await asyncTask();
const elapsed = Date.now() - startTime;
if (elapsed < minDuration) {
await new Promise((resolve) => setTimeout(resolve, minDuration - elapsed));
}
return result;
} catch (err) {
throw new Error(`Step ${stepNumber} failed with the following error: ${err.message}`);
}
}
_formatAndDisplaySteps(text, step) {
// Display the formatted step
this.ui.write(`${os.EOL}===================================================================================${os.EOL}`);
this.ui.write(`Step ${step}:${os.EOL}`);
this.ui.write(`${text}`);
}
async _selectVariant(isRb3Board) {
const rgbVariantMapping = {
'preinstalled server': 'preinstalled-server'
};
const tachyonVariantMapping = {
'desktop (GUI)': 'desktop',
'headless (command-line only)': 'headless'
};
const variantMapping = isRb3Board ? rgbVariantMapping : tachyonVariantMapping;
const question = [
{
type: 'list',
name: 'variant',
message: 'Select the OS variant:',
choices: Object.keys(variantMapping),
},
];
const { variant } = await this.ui.prompt(question);
return variantMapping[variant];
}
async _selectProduct() {
const { orgSlug } = await this._getOrg();
let productId = await this._getProduct(orgSlug);
if (!productId) {
productId = await this._createProduct({ orgSlug });
}
return productId;
}
async _getOrg() {
const orgsResp = await this.api.getOrgs();
const orgs = orgsResp.organizations;
const orgName = orgs.length
? await this._promptForOrg([...orgs.map(org => org.name), 'Sandbox'])
: 'Sandbox';
const orgSlug = orgName !== 'Sandbox' ? orgs.find(org => org.name === orgName).slug : null;
return { orgName, orgSlug };
}
async _promptForOrg(choices) {
const question = [
{
type: 'list',
name: 'org',
message: 'Select an organization:',
choices,
},
];
const { org } = await this.ui.prompt(question);
return org;
}
async _getProduct(orgSlug) {
const productsResp = await this.ui.showBusySpinnerUntilResolved(`Fetching products for ${orgSlug || 'sandbox'}`, this.api.getProducts(orgSlug));
let newProductName = 'Create a new product';
let products = productsResp?.products || [];
products = products.filter((product) => platformForId(product.platform_id)?.name === 'tachyon');
if (!products.length) {
return null; // No products available
}
const selectedProductName = await this._promptForProduct([...products.map(product => product.name), newProductName]);
const selectedProduct = selectedProductName !== newProductName ? (products.find(p => p.name === selectedProductName)) : null;
return selectedProduct?.id || null;
}
async _promptForProduct(choices) {
const question = [
{
type: 'list',
name: 'product',
message: 'Select a product:',
choices,
},
];
const { product } = await this.ui.prompt(question);
return product;
}
async _createProduct({ orgSlug }) {
const platformId = PLATFORMS.find(p => p.name === 'tachyon').id;
const question = [{
type: 'input',
name: 'productName',
message: 'Enter the product name:',
validate: (value) => {
if (value.length === 0) {
return 'You need to provide a product name';
}
return true;
}
}, {
type: 'input',
name: 'locationOptIn',
message: 'Would you like to opt in to location services? (y/n):',
default: 'y'
}];
const { productName, locationOptIn } = await this.ui.prompt(question);
const { product } = await this.api.createProduct({
name: productName,
platformId,
orgSlug,
locationOptIn: locationOptIn.toLowerCase() === 'y'
});
this.ui.write(`Product ${product.name} created successfully!`);
return product?.id;
}
async _promptForCountry() {
// check if the country is already set
const defaultCountry = settings.profile_json.country || this.defaultOptions.country;
const question = [
{
type: 'list',
name: 'countryCode',
message: 'Select your country:',
choices: [...supportedCountries, new this.ui.Separator()],
default: defaultCountry
},
];
const { countryCode } = await this.ui.prompt(question);
settings.profile_json.country = countryCode;
settings.saveProfileData();
if (countryCode === 'OTHER') {
this.ui.write('No cellular profile will be enabled for your device');
}
return countryCode;
}
async _download({ region, version, alwaysCleanCache, variant, board, isRb3Board, isLocalVersion }) {
//before downloading a file, we need to check if 'version' is a local file or directory
//if it is a local file or directory, we need to return the path to the file
if (isLocalVersion) {
return version;
}
const manager = new DownloadManager(this.ui);
const manifest = await manager.fetchManifest({ version, isRb3Board });
const build = manifest?.builds.find(build => build.region === region && build.variant === variant && build.board === board);
if (!build) {
throw new Error('No build available for the provided parameters');
}
const artifact = build.artifacts[0];
this._printOSInfo(build);
const url = artifact.artifact_url;
const outputFileName = url.replace(/.*\//, '');
const expectedChecksum = artifact.sha256_checksum;
return manager.download({ url, outputFileName, expectedChecksum, options: { alwaysCleanCache } });
}
_printOSInfo(build) {
const { distribution, variant, distribution_version: distributionVersion, version, region } = build;
this.ui.write(os.EOL);
this.ui.write(this.ui.chalk.bold('Operating system information:'));
this.ui.write(this.ui.chalk.bold(`Tachyon ${distribution} ${distributionVersion} (${variant}, ${region} region)`));
this.ui.write(`${this.ui.chalk.bold('Version:')} ${version}`);
}
async _getRegistrationCode(productId) {
await this._assignDeviceToProduct({ productId: productId, deviceId: this.device.id });
const data = await this.api.getRegistrationCode({ productId, deviceId: this.device.id });
return data.registration_code;
}
async _assignDeviceToProduct({ deviceId, productId }) {
const data = await this.api.addDeviceToProduct(deviceId, productId);
if (data.updatedDeviceIds.length === 0 && data.existingDeviceIds.length === 0) {
let errorDescription = '';
if (data.invalidDeviceIds.length > 0) {
errorDescription = ': Invalid device ID';
}
if (data.nonmemberDeviceIds.length > 0) {
errorDescription = ': Device is owned by another user';
}
throw new Error(`Failed to assign device ${deviceId} ${errorDescription}`);
}
}
async _createConfigBlob(_config, deviceId) {
// Format the config and registration code into a config blob (JSON file, prefixed by the file size)
const config = Object.fromEntries(
Object.entries(_config).filter(([, value]) => value != null)
);
if (!config.skipCli) {
const profileFile = settings.findOverridesFile();
if (await fs.exists(profileFile)) {
config.cliConfig = await fs.readFile(profileFile, 'utf8');
}
}
// inject initial time
config['initialTime'] = new Date().toISOString();
// Write config JSON to a temporary file (generate a filename with the temp npm module)
// prefixed by the JSON string length as a 32 bit integer
let jsonString = JSON.stringify(config, null, 2);
const buffer = Buffer.alloc(4 + Buffer.byteLength(jsonString));
buffer.writeUInt32BE(Buffer.byteLength(jsonString), 0);
buffer.write(jsonString, 4);
const tempDir = await temp.mkdir('tachyon-config');
const filePath = path.join(tempDir, `${deviceId}_misc.backup`);
await fs.writeFile(filePath, buffer);
return { path: filePath, configBlob: config };
}
_generateShadowCompatibleHash(password) {
// crypt uses . instead of + for base64
const salt = crypto.randomBytes(12).toString('base64').replaceAll('+', '.');
return sha512crypt(password, `$6$${salt}`);
}
async _flash({ files, skipFlashingOs, skipReset }) {
const packagePath = files[0];
const flashCommand = new FlashCommand();
if (!skipFlashingOs) {
await flashCommand.flashTachyon({ device: this.device, files: [packagePath], skipReset: true, output: this.outputLog, verbose: false });
}
await flashCommand.flashTachyonXml({ device: this.device, files, skipReset, output: this.outputLog });
return true;
}
async _saveConfig(config, configBlob) {
const configFields = [
'region',
'version',
'variant',
'skipCli',
'systemPassword',
'productId',
'timezone',
'wifi',
'country',
];
const configData = { ...config, ...configBlob };
const savedConfig = Object.fromEntries(
configFields
.filter(key => key in configData && configData[key] !== null && configData[key] !== undefined)
.map(key => [key, configData[key]])
);
await fs.writeFile(config.saveConfig, JSON.stringify(savedConfig, null, 2), 'utf-8');
this.ui.write(`${os.EOL}Configuration file written here: ${config.saveConfig}${os.EOL}`);
}
_isFile(version) {
const validChannels = ['latest', 'stable', 'beta', 'rc'];
const isValidChannel = validChannels.includes(version);
const isValidSemver = semver.valid(version);
const isFile = !isValidChannel && !isValidSemver;
// access(OK
if (isFile) {
try {
fs.accessSync(version, fs.constants.F_OK | fs.constants.R_OK);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`The file "${version}" does not exist.`);
} else if (error.code === 'EACCES') {
throw new Error(`The file "${version}" is not accessible (permission denied).`);
}
throw error;
}
}
return isFile;
}
_particleApi() {
const auth = settings.access_token;
const api = new ParticleApi(settings.apiUrl, { accessToken: auth } );
const apiCache = createApiCache(api);
return { api: apiCache, auth };
}
_setupApi() {
const { api } = this._particleApi();
this.api = api;
}
};