diff --git a/assets/qdl/darwin/arm64/libusb-1.0.0.dylib b/assets/qdl/darwin/arm64/libusb-1.0.0.dylib index ceedd6e10..e12f4d7ce 100644 Binary files a/assets/qdl/darwin/arm64/libusb-1.0.0.dylib and b/assets/qdl/darwin/arm64/libusb-1.0.0.dylib differ diff --git a/assets/qdl/darwin/arm64/qdl b/assets/qdl/darwin/arm64/qdl index 28a64224f..25ca013b3 100755 Binary files a/assets/qdl/darwin/arm64/qdl and b/assets/qdl/darwin/arm64/qdl differ diff --git a/assets/qdl/darwin/x64/libusb-1.0.0.dylib b/assets/qdl/darwin/x64/libusb-1.0.0.dylib index 156d79ebd..12e382a67 100644 Binary files a/assets/qdl/darwin/x64/libusb-1.0.0.dylib and b/assets/qdl/darwin/x64/libusb-1.0.0.dylib differ diff --git a/assets/qdl/darwin/x64/qdl b/assets/qdl/darwin/x64/qdl index 080fe0e42..6924c4d43 100755 Binary files a/assets/qdl/darwin/x64/qdl and b/assets/qdl/darwin/x64/qdl differ diff --git a/assets/qdl/linux/arm64/qdl b/assets/qdl/linux/arm64/qdl index 836f3dd22..9f140d47a 100755 Binary files a/assets/qdl/linux/arm64/qdl and b/assets/qdl/linux/arm64/qdl differ diff --git a/assets/qdl/linux/x64/qdl b/assets/qdl/linux/x64/qdl index 924a090cc..9e1b20df8 100755 Binary files a/assets/qdl/linux/x64/qdl and b/assets/qdl/linux/x64/qdl differ diff --git a/assets/qdl/win32/x64/liblzma-5.dll b/assets/qdl/win32/x64/liblzma-5.dll index f82777989..16a0ddf6c 100644 Binary files a/assets/qdl/win32/x64/liblzma-5.dll and b/assets/qdl/win32/x64/liblzma-5.dll differ diff --git a/assets/qdl/win32/x64/libusb-1.0.dll b/assets/qdl/win32/x64/libusb-1.0.dll index d482c2cfd..e0f60c4ff 100644 Binary files a/assets/qdl/win32/x64/libusb-1.0.dll and b/assets/qdl/win32/x64/libusb-1.0.dll differ diff --git a/assets/qdl/win32/x64/libxml2-16.dll b/assets/qdl/win32/x64/libxml2-16.dll new file mode 100644 index 000000000..048951f44 Binary files /dev/null and b/assets/qdl/win32/x64/libxml2-16.dll differ diff --git a/assets/qdl/win32/x64/qdl.exe b/assets/qdl/win32/x64/qdl.exe index b1aa9a4db..b4132d359 100644 Binary files a/assets/qdl/win32/x64/qdl.exe and b/assets/qdl/win32/x64/qdl.exe differ diff --git a/src/cmd/setup-tachyon.js b/src/cmd/setup-tachyon.js index 1ce241a2f..1127660cb 100644 --- a/src/cmd/setup-tachyon.js +++ b/src/cmd/setup-tachyon.js @@ -5,18 +5,14 @@ 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, handleFlashError } = require('../lib/tachyon-utils'); -const { supportedCountries } = require('../lib/supported-countries'); +const { getTachyonInfo, getEDLDevice, handleFlashError, promptOSSelection, isFile, readManifestFromLocalFile +} = require('../lib/tachyon-utils'); +const { workflows, workflowRun } = require('../lib/tachyon/workflow'); const showWelcomeMessage = (ui) => ` =================================================================================== @@ -26,7 +22,7 @@ const showWelcomeMessage = (ui) => ` Welcome to the Particle Tachyon setup! This interactive command: - Flashes your Tachyon device -- Configures it (password, WiFi credentials etc...) +- Configures it - Connects it to the internet and the Particle Cloud! ${ui.chalk.bold('What you\'ll need:')} @@ -49,34 +45,35 @@ module.exports = class SetupTachyonCommands extends CLICommandBase { this.device = null; this._baseDir = settings.ensureFolder(); this._logsDir = path.join(this._baseDir, 'logs'); - + this.downloadManager = new DownloadManager(this.ui); this.outputLog = null; this.defaultOptions = { region: 'NA', version: settings.tachyonVersion || 'stable', board: 'formfactor_dvt', distroVersion: '20.04', - country: 'USA', + country: settings.profile_json.country || 'USA', variant: null, skipFlashingOs: false, skipCli: false, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // eslint-disable-line new-cap - alwaysCleanCache: false + alwaysCleanCache: false, + workflow: workflows.ubuntu20, + flashSuccessful: true }; this.options = {}; } async setup({ skip_flashing_os: skipFlashingOs, timezone, load_config: loadConfig, save_config: saveConfig, region, version, variant, board, distro_version: distroVersion, skip_cli: skipCli } = {}) { - const requiredFields = ['region', 'version', 'systemPassword', 'productId', 'timezone']; const options = { skipFlashingOs, timezone, loadConfig, saveConfig, region, version, variant, board, distroVersion, skipCli }; await this.ui.write(showWelcomeMessage(this.ui)); // step 1 login - this._formatAndDisplaySteps("Okay—first up! Checking if you're logged in...", 1); + this._formatAndDisplaySteps("Okay—first up! Checking if you're logged in..."); 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._formatAndDisplaySteps("Now let's get the device info"); this.ui.write(''); const device = await getEDLDevice({ ui: this.ui, showSetupMessage: true }); this.device = device; @@ -86,33 +83,30 @@ module.exports = class SetupTachyonCommands extends CLICommandBase { await fs.ensureFile(this.outputLog); this.ui.write(`${os.EOL}Starting Process. See logs at: ${this.outputLog}${os.EOL}`); const deviceInfo = await this._getDeviceInfo(); + deviceInfo.usbVersion = this.device.usbVersion.major; this._printDeviceInfo(deviceInfo); // check if there is a config file - const config = await this._loadConfig({ options, requiredFields, deviceInfo }); - config.isLocalVersion = this._validateVersion(config); + // validate version if local then workflow will be inferred from the manifest + const isLocalVersion = version ? await isFile(version) : false; + const config = await this._loadConfig({ options, deviceInfo, isLocalVersion }); - 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 - } + const context = { + ...config, + ui: this.ui, + api: this.api, + deviceInfo: deviceInfo, + device: this.device, + log: { + file: this.outputLog, + info: (msg) => fs.appendFileSync(this.outputLog, `info: ${msg} ${os.EOL}`), + error: (msg) => fs.appendFileSync(this.outputLog, `error: ${msg} ${os.EOL}`), + } + }; - if (settings.isStaging) { - config.apiServer = settings.apiUrl; - config.server = 'https://edge.staging.particle.io'; - config.verbose = true; + const workflowContext = await workflowRun(config.workflow, context); + if (workflowContext.saveConfig) { + await this._saveConfig(workflowContext); } - - 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() { @@ -165,9 +159,42 @@ module.exports = class SetupTachyonCommands extends CLICommandBase { } } - async _loadConfig({ options, requiredFields, deviceInfo }) { + _formatAndDisplaySteps(text, step) { + // Display the formatted step + this.ui.write(`${os.EOL}===================================================================================${os.EOL}`); + if (step) { + this.ui.write(`Step ${step}:${os.EOL}`); + } + this.ui.write(`${text}`); + } + + async _pickWorkflowToExecute() { + this._formatAndDisplaySteps(`Choose an operating system to flash onto this device ${os.EOL}`); + const workflow = await promptOSSelection({ ui: this.ui, workflows }); + if (workflow.selectionWarning) { + this.ui.write(this.ui.chalk.yellow(workflow.selectionWarning)); + } + return workflow; + } + + /** + * + * @param {Workflow} selectedWorkflow + * @return {Promise} + * @private + */ + async _loadConfig({ options, deviceInfo, isLocalVersion }) { + let selectedWorkflow; const configFromFile = await this._loadConfigFromFile(options.loadConfig); const optionsFromDevice = {}; + + selectedWorkflow = await this._selectWorkflow({ + isLocalVersion, + version: options.version, + configFromFile, + defaultWorkflow: this.defaultOptions.workflow + }); + const cleanedOptions = Object.fromEntries( // eslint-disable-next-line no-unused-vars Object.entries(options).filter(([_, v]) => v !== undefined) @@ -176,531 +203,78 @@ module.exports = class SetupTachyonCommands extends CLICommandBase { optionsFromDevice.region = deviceInfo.region.toLowerCase() !== 'unknown' ? deviceInfo.region : 'NA'; optionsFromDevice.board = deviceInfo.board; } + const config = { ...this.defaultOptions, + ...selectedWorkflow?.overrideDefaults, ...optionsFromDevice, ...configFromFile, - ...cleanedOptions + ...cleanedOptions, + workflow: selectedWorkflow, + isLocalVersion: !!isLocalVersion }; - // 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'); - } + if (settings.isStaging) { + config.apiServer = settings.apiUrl; + config.server = 'https://edge.staging.particle.io'; + config.verbose = true; } - return isLocalVersion; - } - async _getUserConfigurationStep() { - return this._runStepWithTiming( - `Now let's capture some information about how you'd like your device to be configured when it first boots.${os.EOL}${os.EOL}` + - `First, pick a password for the root account on your Tachyon device.${os.EOL}` + - `This same password is also used for the "particle" user account.${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}` + - `Next, provide a Wi-Fi network for your device to connect to the internet.${os.EOL}` + - `An internet connection is necessary to activate 5G 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 (!isLocalVersion) { + config.manifest = await this._getManifestBuilds({ + version: config.version, + osInfo: config.workflow.osInfo, + region: config.region, + board: config.board, }); - 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' - }) - ); + return config; } - 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}` + async _selectWorkflow({ isLocalVersion, version, configFromFile, defaultWorkflow }) { + if (isLocalVersion) { + const manifest = await readManifestFromLocalFile(version); + return Object.values(workflows).find(wf => + wf.osInfo.distribution === manifest.distribution && + wf.osInfo.distributionVersion === manifest.distribution_version ); } - } - - _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}`); + if (configFromFile?.workflow) { + return workflows[configFromFile.workflow]; } - } - - _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 + if (!configFromFile?.silent) { + return this._pickWorkflowToExecute(); } - - 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; + return defaultWorkflow; } - 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, distroVersion, 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 && (!distroVersion || build.distribution_version === distroVersion)); - 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.toUpperCase()} ${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'; + 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}`); } - 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) + async _getManifestBuilds({ version, osInfo, region, board }) { + const manifestVersion = await this.downloadManager.fetchManifest({ version }); + return manifestVersion.builds.filter(os => + os.distribution === osInfo.distribution && + os.distribution_version === osInfo.distributionVersion && + os.region === region && + os.board === board ); - - 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) { + async _saveConfig(config) { const configFields = [ 'region', 'version', @@ -712,39 +286,18 @@ module.exports = class SetupTachyonCommands extends CLICommandBase { 'wifi', 'country', ]; - const configData = { ...config, ...configBlob }; + const configData = { ...config }; const savedConfig = Object.fromEntries( configFields .filter(key => key in configData && configData[key] !== null && configData[key] !== undefined) .map(key => [key, configData[key]]) ); + savedConfig.workflow = config.workflow.value; 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 } ); diff --git a/src/lib/download-manager.js b/src/lib/download-manager.js index 62f2942fb..6fe78caa2 100644 --- a/src/lib/download-manager.js +++ b/src/lib/download-manager.js @@ -33,8 +33,7 @@ class DownloadManager { } } - async fetchManifest({ version = 'stable', isRb3Board = false }) { - const type = isRb3Board ? 'rb3g2' : 'tachyon'; + async fetchManifest({ version = 'stable', type = 'tachyon' }) { const metadataUrl = `${settings.tachyonMeta}/${type}-${encodeURIComponent(version)}.json`; try { @@ -49,7 +48,6 @@ class DownloadManager { return response.json(); } catch (err) { - console.log(err); throw new Error('Could not download the version file. Please check your internet connection.'); } } diff --git a/src/lib/tachyon-utils.js b/src/lib/tachyon-utils.js index 2c71efd45..d8190bf60 100644 --- a/src/lib/tachyon-utils.js +++ b/src/lib/tachyon-utils.js @@ -1,7 +1,9 @@ const fs = require('fs-extra'); const os = require('os'); +const semver = require('semver'); const { getEdlDevices } = require('particle-usb'); const { delay } = require('./utilities'); +const unzip = require('unzipper'); const DEVICE_READY_WAIT_TIME = 500; // ms const UI = require('./ui'); const QdlFlasher = require('./qdl'); @@ -485,6 +487,61 @@ async function handleFlashError({ error, ui }) { return false; } +/** + * + * @param ui + * @return {Promise} + */ +async function promptOSSelection({ ui, workflows }) { + const choices = Object.values(workflows); + const question = [{ + type: 'list', + name: 'osType', + message: ui.chalk.bold.white('Select the OS Type to setup in your device'), + choices + }]; + const { osType } = await ui.prompt(question); + return workflows[osType]; +} + +async function 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 { + await fs.access(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; +} + +async function readManifestFromLocalFile(path, targetFile = 'manifest.json') { + const directory = await unzip.Open.file(path); + const entry = directory.files.find(f => f.path.endsWith(targetFile)); + + if (!entry) { + throw new Error(`File "${targetFile}" not found in ${path}`); + } + // Stream and parse + const content = await entry.buffer(); + try { + return JSON.parse(content.toString('utf8')); + } catch (err) { + throw new Error(`Invalid JSON in ${targetFile}: ${err.message}`); + } +} + module.exports = { addLogHeaders, addManifestInfoLog, @@ -493,5 +550,8 @@ module.exports = { prepareFlashFiles, getTachyonInfo, promptWifiNetworks, - handleFlashError + handleFlashError, + promptOSSelection, + isFile, + readManifestFromLocalFile }; diff --git a/src/lib/tachyon/steps.js b/src/lib/tachyon/steps.js new file mode 100644 index 000000000..5f0c1d3c0 --- /dev/null +++ b/src/lib/tachyon/steps.js @@ -0,0 +1,534 @@ +const os = require('os'); +const crypto = require('crypto'); +const fs = require('fs-extra'); +const temp = require('temp').track(); +const path = require('path'); +const settings = require('../../../settings'); +const { sha512crypt } = require('sha512crypt-node'); +const { promptWifiNetworks, prepareFlashFiles } = require('../tachyon-utils'); +const { platformForId, PLATFORMS } = require('../platform'); +const { supportedCountries } = require('../supported-countries'); +const DownloadManager = require('../download-manager'); +const FlashCommand = require('../../cmd/flash'); + +/** + * + * @param {Workflow} workflow + * @return {Promise} + */ +async function pickVariant({ ui, workflow, manifest, version, log, isLocalVersion, variant, board }, stepIndex){ + let selectedVariant; + if (variant) { + selectedVariant = variant; + ui.write(os.EOL); + ui.write(`Skipping step ${stepIndex}: Using current variant: ${selectedVariant}`); + } else if (workflow.variants.length > 1){ + const text = `Select the variant of the Tachyon operating system to set up.${os.EOL}` + + `The 'desktop' includes a GUI and is best for interacting with the device with a keyboard, mouse, and display.${os.EOL}` + + `The 'headless' variant is accessed only by a terminal out of the box. ${os.EOL}`; + + formatAndDisplaySteps({ + ui, + text, + step: stepIndex + }); + const choices = workflow.variants; + const question = [{ + type: 'list', + name: 'selectedVariant', + message: 'Select the OS variant:', + choices + }]; + const answer = await ui.prompt(question); + selectedVariant = answer.selectedVariant; + } else { + selectedVariant = workflow.variants[0].value; + ui.write(`Skipping step ${stepIndex}: Using default OS variant ${selectedVariant} ${os.EOL}`); + } + log.info(`picking OS variant: ${selectedVariant}`); + // return url to be stored in context + if (!isLocalVersion) { + const build = manifest.find(build => build.variant === selectedVariant); + if (!build) { + throw new Error(`No builds found for this variant ${selectedVariant}, board ${board} and version ${version}`); + } + const artifact = build.artifacts[0]; + return { + url: artifact.artifact_url, + expectedChecksum: artifact.sha256_checksum, + variant: selectedVariant, + buildVersion: build.version + }; + } + return { variant: selectedVariant }; +} + +/** + * Step Download OS + * @param {Pick} context + * @param {Number} stepIndex - Index of the step + * @return {Promise<{osFilePath: *}|*>} + */ +async function downloadOS({ ui, alwaysCleanCache, isLocalVersion, version, url, buildVersion, expectedChecksum }, stepIndex) { + if (isLocalVersion) { + ui.write(`Skipping step ${stepIndex}: Using local version ${version}`); + return { version, osFilePath: version }; + } + formatAndDisplaySteps({ + ui, + text: `Downloading OS version: ${buildVersion}${os.EOL}`, + step: stepIndex + }); + + const downloadManager = new DownloadManager(ui); + + const outputFileName = url.replace(/.*\//, ''); + const osFilePath = await downloadManager.download({ + url, + outputFileName, + expectedChecksum, + options: { alwaysCleanCache } + }); + return { osFilePath }; +} + +/** + * + * @param {Pick } context + * @return {Promise} + */ +async function printOSInfo({ workflow, variant, buildVersion, version, region, ui }) { + const { distributionDisplay } = workflow.osInfo; + ui.write(os.EOL); + ui.write(ui.chalk.bold('Operating system information:')); + ui.write(ui.chalk.bold(`Tachyon ${distributionDisplay} (${variant}, ${region} region)`)); + ui.write(`${ui.chalk.bold('Version:')} ${buildVersion || version }`); +} + +/** + * + * @param {Pick } context + * @return {Promise<{systemPaswsword, wifi}>} + */ +async function getUserConfigurationStep({ ui, systemPassword, wifi }, stepIndex) { + if (systemPassword && wifi) { + ui.write(os.EOL); + ui.write(`Skipping step ${stepIndex}: Using stored user configuration`); + return { systemPassword, wifi }; + } + return runStepWithTiming( + ui, + `Now let's capture some information about how you'd like your device to be configured when it first boots.${os.EOL}${os.EOL}` + + `First, pick a password for the root account on your Tachyon device.${os.EOL}` + + `This same password is also used for the "particle" user account.${os.EOL}`, + stepIndex, + () => getUserConfiguration({ ui }), + 0 + ); +} + +async function getUserConfiguration({ ui }) { + const password = await getSystemPassword({ ui }); + const systemPassword = _generateShadowCompatibleHash(password); + const wifi = await getWifiConfiguration({ ui }); + + return { systemPassword, wifi }; +} + +async function getSystemPassword({ ui }) { + let password = ''; + while (password === '') { + password = await 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 === '') { + ui.write('System password cannot be blank.'); + } + } + return password; +} + +function _generateShadowCompatibleHash(password) { + // crypt uses . instead of + for base64 + const salt = crypto.randomBytes(12).toString('base64').replaceAll('+', '.'); + return sha512crypt(password, `$6$${salt}`); +} + +async function getWifiConfiguration({ ui }) { + ui.write( + ui.chalk.bold( + `${os.EOL}` + + `Next, provide a Wi-Fi network for your device to connect to the internet.${os.EOL}` + + `An internet connection is necessary to activate 5G cellular connectivity on your device.${os.EOL}` + ) + ); + return promptWifiNetworks(ui); +} + +async function configureProductStep({ ui, api, productId, deviceInfo }, stepIndex) { + formatAndDisplaySteps({ + ui, + text: `Next, let's select a Particle product for your Tachyon.${os.EOL}` + + 'A product will help manage the Tachyon device and keep things organized.', + step: stepIndex, + }); + let selectedProductId = productId; + if (!selectedProductId) { + selectedProductId = await selectProduct({ ui, api }); + } + const { product } = await api.getProduct({ product: selectedProductId }); + + await assignDeviceToProduct({ + productId: selectedProductId, + deviceId: deviceInfo.deviceId, + productSlug: product.slug, + ui, + api + }); + + return { productSlug: product.slug, productId: selectedProductId }; +} + +async function selectProduct({ ui, api }) { + const { orgSlug } = await getOrg({ ui, api }); + + let productId = await getProduct({ orgSlug, ui, api }); + + if (!productId) { + productId = await createProduct({ orgSlug, ui, api }); + } + + return productId; +} + +async function createProduct({ orgSlug, ui, api }) { + 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 ui.prompt(question); + const { product } = await api.createProduct({ + name: productName, + platformId, + orgSlug, + locationOptIn: locationOptIn.toLowerCase() === 'y' + }); + ui.write(`Product ${product.name} created successfully!`); + return product?.id; +} + + +async function getOrg({ api, ui }) { + const orgsResp = await api.getOrgs(); + const orgs = orgsResp.organizations; + + const orgName = orgs.length + ? await ui.promptForList( + 'Select an organization:', + [...orgs.map(org => org.name), 'Sandbox']) + : 'Sandbox'; + + const orgSlug = orgName !== 'Sandbox' ? orgs.find(org => org.name === orgName).slug : null; + return { orgName, orgSlug }; +} + +async function getProduct({ orgSlug, ui, api }) { + const productsResp = await ui.showBusySpinnerUntilResolved( + `Fetching products for ${orgSlug || 'sandbox'}`, + 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 ui.promptForList( + 'Select a product', + [...products.map(product => product.name), newProductName]); + + const selectedProduct = selectedProductName !== newProductName ? + (products.find(p => p.name === selectedProductName)) : + null; + return selectedProduct?.id || null; +} + +async function assignDeviceToProduct({ deviceId, productId, ui, api, productSlug }) { + const data = await 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}`); + } + ui.write(`Device ${deviceId} Assigned to the product ${productSlug}`); +} + +async function getCountryStep({ ui, country, silent }, stepIndex ) { + if (silent) { + ui.write(`${os.EOL}`); + ui.write(`Skipping step: ${stepIndex}: Using country ${country}`); + return { country }; + } + + return runStepWithTiming( + ui, + `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', + stepIndex, + () => promptForCountry({ ui, country }), + 0 + ); +} + +async function promptForCountry({ ui, country }) { + const question = [ + { + type: 'list', + name: 'countryCode', + message: 'Select your country:', + choices: [...supportedCountries, new ui.Separator()], + default: country + }, + ]; + const { countryCode } = await ui.prompt(question); + settings.profile_json.country = countryCode; + settings.saveProfileData(); + if (countryCode === 'OTHER') { + ui.write('No cellular profile will be enabled for your device'); + } + return { country: countryCode }; +} + +async function registerDeviceStep({ ui, api, productId, deviceInfo }, stepIndex) { + formatAndDisplaySteps({ + ui, + text: `Great! The download is complete.${os.EOL}` + + "Now, let's register your product on the Particle platform.", + step: stepIndex + }); + const { registration_code: registrationCode } = await api.getRegistrationCode({ + productId, + deviceId: deviceInfo.deviceId, + }); + return { registrationCode }; +} + +async function getESIMProfilesStep({ api, ui, deviceInfo, productId, country }, stepIndex){ + let esim = null; + formatAndDisplaySteps({ + ui, + text: `Now let's get the eSIM profiles for your device ${os.EOL}`, + step: stepIndex, + }); + try { + esim = await api.getESIMProfiles(deviceInfo.deviceId, productId, country); + } catch (error) { + const message = `Error getting eSIM profiles: ${error.message}${os.EOL}`; + ui.write(this.ui.chalk.yellow(message)); + } + return { esim }; +} + +async function createConfigBlobStep(context, stepIndex) { + formatAndDisplaySteps({ + ui: context.ui, + text: 'Creating the configuration file to write to the Tachyon device...', + step: stepIndex, + }); + const { configBlobPath } = await createBlobFile(context); + const { xmlFile: xmlPath } = await prepareFlashFiles({ + logFile: context.log.file, + ui: context.ui, + partitionsList: ['misc'], + dir: path.dirname(configBlobPath), + deviceId: context.deviceInfo.deviceId, + operation: 'program', + checkFiles: true, + device: context.device, + }); + return { xmlPath }; +} + +async function createBlobFile(context) { + const noConfigInfo = ['workflow', 'manifest', 'ui', 'api', 'log', 'device', 'deviceInfo']; + const config = Object.fromEntries( + Object.entries(context).filter(([key, value]) => + !noConfigInfo.includes(key) && value != null + ) + ); + + if (!config.skipCli) { + const profileFile = settings.findOverridesFile(); + if (await fs.exists(profileFile)) { + config.cliConfig = await fs.readFile(profileFile, 'utf8'); + } + } + 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, `${context.deviceInfo.deviceId}_misc.backup`); + await fs.writeFile(filePath, buffer); + + return { configBlobPath: filePath, configBlob: config }; +} + +async function flashOSAndConfigStep({ ui, log, productSlug, device, xmlPath, variant, osFilePath, skipFlashingOs, workflow }, stepIndex) { + const message = getFlashMessage({ device, productSlug, workflow }); + return runStepWithTiming( + ui, + message, + stepIndex, + () => flash({ + device, + log, + osPath: osFilePath, + xmlPath: xmlPath, + skipFlashingOs: skipFlashingOs, + skipReset: variant !== 'headless' + }) + ); +} + +async function flash({ device, osPath, xmlPath, skipFlashingOs, skipReset, log }) { + const flashCommand = new FlashCommand(); + const shouldResetOS = skipReset || xmlPath; + if (!skipFlashingOs) { + // flash OS + await flashCommand.flashTachyon({ + device, + files: [osPath], + skipReset: shouldResetOS, + output: log.file, + verbose: false + }); + } else { + log.info(`Skip flashing OS ${os.EOL}`); + } + if (xmlPath) { + // flash xml + await flashCommand.flashTachyonXml({ + device, + files: [osPath, xmlPath], + skipReset: skipReset, + output: log.file, + }); + } + return { flashSuccessful: true }; + +} + +function getFlashMessage({ device, productSlug, workflow }){ + let message = `Heads up: this is a large image and flashing will take about 2 minutes to complete.${os.EOL}`; + const slowUsb = 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}`); + } + const messageTitle = workflow.customFlashMessage || + `Okay—last step! We're now flashing the device with the configuration, including the password, Wi-Fi settings, and operating system.${os.EOL}`; + return messageTitle + + 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 ${consoleLink({ productSlug, deviceId: device.id })}${os.EOL}`; +} + +async function setupCompletedStep({ ui, variant, flashSuccessful, productSlug, deviceInfo, workflow }, stepIndex) { + if (flashSuccessful) { + const messageContent = workflow.variants.find(v => v.value === variant)?.setupCompletedMessage; + const footer = `Learn more about Tachyon at our developer site: https://developer.particle.io/tachyon${os.EOL}` + + `${os.EOL}` + + `View your device on the Particle Console at: ${consoleLink({ + productSlug, + deviceId: deviceInfo.deviceId + })}`; + formatAndDisplaySteps({ + ui, + text: messageContent + footer, + step: stepIndex + }); + } else { + 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}` + ); + } +} + + +function consoleLink({ productSlug, deviceId }) { + const baseUrl = `https://console${settings.isStaging ? '.staging' : ''}.particle.io`; + return `${baseUrl}/${productSlug}/devices/${deviceId}`; +} + +async function runStepWithTiming(ui, stepDesc, stepNumber, asyncTask, minDuration = 2000) { + formatAndDisplaySteps({ ui, text: stepDesc, step: 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}`); + } +} + +function formatAndDisplaySteps({ ui, text, step }) { + // Display the formatted step + ui.write(`${os.EOL}===================================================================================${os.EOL}`); + if (step) { + ui.write(`Step ${step}:${os.EOL}`); + } + ui.write(`${text}`); +} + +module.exports = { + pickVariant, + downloadOS, + printOSInfo, + getUserConfigurationStep, + configureProductStep, + getCountryStep, + registerDeviceStep, + getESIMProfilesStep, + createConfigBlobStep, + flashOSAndConfigStep, + setupCompletedStep +}; diff --git a/src/lib/tachyon/workflow.js b/src/lib/tachyon/workflow.js new file mode 100644 index 000000000..c3fbe65bf --- /dev/null +++ b/src/lib/tachyon/workflow.js @@ -0,0 +1,235 @@ +const os = require('os'); +const steps = require('./steps'); +/** + * @typedef {Object} Workflow + * @property {string} name + * @property {string} value + * @property {Object} [overrideDefaults] - In case some defaults needs to be overridden + * @property {Object} osInfo - Data required to filter out the OS from manifest + * @property {string} [selectionWarning] - Warning to show after the user selects this workflow. + * @property {{ name: string, value: string }[]} variants - Accepted variants to choose + * @property {ReadonlyArray} steps + */ + +/** + * Minimal logger interface that writes JSON lines. + * @typedef {Object} Logger + * @property {(msg: string, extra?: LogExtra) => void} info Write an info entry. + * @property {(msg: string, extra?: LogExtra) => void} error Write an error entry. + * @property {() => Promise} close Flush and close the file. + */ + +/** + * Setup options used by the workflow runner. + * @typedef {Object} SetupOptions + * @property {('NA'|'RoW'|string)} region - Target region. Default: `'NA'`. + * @property {string} version - Tachyon version or channel. Default: `settings.tachyonVersion || 'stable'`. + * @property {string} board - Hardware/board identifier (e.g., `'formfactor_dvt'`). Default: `'formfactor_dvt'`. + * @property {string} distroVersion - Distro version (e.g., `'20.04'`). Default: `'20.04'`. + * @property {string} country - Country/locale code (e.g., `'USA'`). Default: `'USA'`. + * @property {string|null} variant - Optional SKU/variant; `null` if not applicable. Default: `null`. + * @property {boolean} skipFlashingOs - If `true`, do not flash the OS. Default: `false`. + * @property {boolean} skipCli - If `true`, skip CLI install/config steps. Default: `false`. + * @property {string} timezone - IANA timezone (e.g., `'America/Mexico_City'`). Default: from `Intl.DateTimeFormat().resolvedOptions().timeZone`. + * @property {boolean} alwaysCleanCache - If `true`, wipe local caches before running. Default: `false`. + */ +/** + * @typedef Config + * @property {Object} deviceInfo - device info from identify + * @property {Object} selectedOS - information of OS to be installed + * @property {SetupOptions} options + * @property {boolean} silent - indicates if the workflow will run on silent mode + * @property {Object} state - indicates logs from every step + */ + +/** + * @typedef {Object} Context + * @property {{ write:(msg:string)=>void }} ui + * @property {Workflow} workflow + * @property {Logger} log + * @property {Config} config + * @property {Object} state + */ + +/** + * A single step in the workflow. + * @callback Step + * @param {Context} ctx + * @returns {Promise|void} + */ + +/** @type {Workflow} */ +const ubuntu20 = Object.freeze({ + name: 'Ubuntu 20.04 (stable), recommended', + value: 'ubuntu20', + osInfo: { + distributionDisplay: 'Ubuntu 20.04', + distribution: 'ubuntu', + distributionVersion: '20.04', + distributionVariant: 'ubuntu' + }, + variants: [ + { + name: 'Desktop (GUI)', + value: 'desktop', + setupCompletedMessage: '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}` + }, + { + name: 'Headless (command-line only)', + value: 'headless', + setupCompletedMessage: '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}` + }, + ], + steps: Object.freeze([ + steps.pickVariant, + steps.getUserConfigurationStep, + steps.configureProductStep, + steps.getCountryStep, + steps.downloadOS, + steps.printOSInfo, + steps.registerDeviceStep, + steps.getESIMProfilesStep, + steps.createConfigBlobStep, + steps.flashOSAndConfigStep, + steps.setupCompletedStep + ]) +}); + +/** @type {Workflow} */ +const ubuntu24 = Object.freeze({ + name: 'Ubuntu 24.04 (beta)', + value: 'ubuntu24', + selectionWarning: 'Heads-up: Development of Ubuntu 24.04 (beta) is still in progress. Some features may be ' + + `unstable or missing.${os.EOL}` + + `See https://developer.particle.io/tachyon/software/ubuntu_24_04/overview for more information.${os.EOL}`, + osInfo: { + distributionDisplay: 'Ubuntu 24.04', + distribution: 'ubuntu', + distributionVersion: '24.04', + distributionVariant: 'ubuntu' + }, + overrideDefaults:{ + version: 'latest', + }, + variants: [ + { + name: 'Desktop (GUI)', + value: 'desktop', + setupCompletedMessage: '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}` + + ` - 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 what's currently supported on Ubuntu 24.04, visit https://developer.particle.io/tachyon/software/ubuntu_24_04/overview${os.EOL}${os.EOL}` + }, + ], + steps: Object.freeze([ + steps.pickVariant, + steps.getUserConfigurationStep, + steps.configureProductStep, + steps.downloadOS, + steps.printOSInfo, + steps.registerDeviceStep, + steps.createConfigBlobStep, + steps.flashOSAndConfigStep, + steps.setupCompletedStep + ]) +}); + + +/** @type {Workflow} */ +const android14 = Object.freeze({ + name: 'Android 14 (beta)', + value: 'android14', + osInfo: { + distributionDisplay: 'Android 14', + distribution: 'android', + distributionVersion: '14', + }, + overrideDefaults:{ + version: 'latest', + variant: 'android' + }, + variants: [ + { + name: 'Android UI', + value: 'android', + setupCompletedMessage: `All done! Your Tachyon device is ready to boot to Android.${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}` + + `After the device boots Android, you can:${os.EOL}` + + ` - Connect to Wi-Fi and cellular through the Settings app.${os.EOL}` + + ` - Install additional apps through adb.${os.EOL}` + + `For more information about what's currently supported on Android 14, visit https://developer.particle.io/tachyon/software/android_14/android-14-overview${os.EOL}${os.EOL}` + }, + ], + customFlashMessage: `Okay—last step! We're now flashing the device with the operating system${os.EOL}`, + selectionWarning: `Heads-up: this setup won’t provision the eSIM or connect to the Particle Cloud.${os.EOL}` + + `If you need to provision the SIM, set up Ubuntu 20.04 first.${os.EOL}` + + `See https://developer.particle.io/tachyon/software/android_14/android-14-overview for more information.${os.EOL}`, + steps: Object.freeze([ + steps.pickVariant, + steps.configureProductStep, + steps.downloadOS, + steps.printOSInfo, + steps.flashOSAndConfigStep, + steps.setupCompletedStep + ]), +}); + +/** + * + * @param {Workflow} workflow - workflow to run + * @param {Context} context - information required to every step to run + * @return {Promise} + */ +async function run(workflow, context) { + let currentContext = context; + currentContext.log.info(`[${new Date().toISOString()}] Starting workflow ${workflow.name}`); + for (const [index, step] of workflow.steps.entries()) { + try { + currentContext.log.info(`[${new Date().toISOString()}] Step ${step.name} started`); + const result = await step(currentContext, index + 1); + currentContext = { ...currentContext, ...(result ?? {}) }; + } catch (error) { + currentContext.log.error(`[${new Date().toISOString()}] Error occurred during step: ${step?.name}: ${error.message} `); + throw error; + } finally { + currentContext.log.info(`[${new Date().toISOString()}] Step ${step?.name} completed`); + } + } + currentContext.log.info(`[${new Date().toISOString()}] Finished workflow ${workflow.name}`); + return currentContext; +} + +module.exports = { + workflows: { + ubuntu20, + ubuntu24, + android14, + }, + workflowRun: run +}; diff --git a/src/lib/ui/index.js b/src/lib/ui/index.js index 0f069a5ae..aa650b93f 100644 --- a/src/lib/ui/index.js +++ b/src/lib/ui/index.js @@ -161,6 +161,19 @@ module.exports = class UI { ].join(' ')); } + async promptForList(message, choices) { + const question = [ + { + type: 'list', + name: 'result', + message, + choices, + }, + ]; + const { result } = await this.prompt(question); + return result; + } + logDeviceDetail(devices, { varsOnly = false, fnsOnly = false } = {}){ const { EOL, chalk } = this; const deviceList = Array.isArray(devices) ? devices : [devices];