Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const func = require('./function');
const keys = require('./keys');
const library = require('./library');
const logicFunction = require('./logic-function');
const org = require('./org');
const preprocess = require('./preprocess');
const product = require('./product');
const project = require('./project');
Expand Down Expand Up @@ -65,6 +66,7 @@ module.exports = function registerAllCommands(context) {
keys(context);
library(context);
logicFunction(context);
org(context);
preprocess(context);
product(context);
project(context);
Expand Down
43 changes: 43 additions & 0 deletions src/cli/org.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

module.exports = ({ commandProcessor, root }) => {
const org = commandProcessor.createCategory(root, 'org', 'Access Particle organization functionality');
const exportCmd = commandProcessor.createCategory(org, 'export', 'Export organization data');

commandProcessor.createCommand(exportCmd, 'devices', 'Export all devices from an organization', {
params: '<org>',
options: {
format: {
alias: 'f',
description: 'Output format: csv or json',
default: 'csv'
},
product: {
alias: 'p',
description: 'Filter by product IDs (comma-separated)'
},
group: {
alias: 'g',
description: 'Filter by group names (comma-separated)'
},
output: {
alias: 'o',
description: 'Output file path (stdout by default)'
}
},
examples: {
'$0 $command my-org': 'Export all devices from organization `my-org` as CSV to stdout',
'$0 $command my-org --format json': 'Export all devices as JSON',
'$0 $command my-org --product 12345': 'Export devices from product `12345` only',
'$0 $command my-org --group production': 'Export devices in the `production` group',
'$0 $command my-org -p 12345,67890 -g production,staging': 'Export devices matching multiple filters',
'$0 $command my-org -o devices.csv': 'Export devices to a file'
},
handler: (args) => {
const OrgCmd = require('../cmd/org');
return new OrgCmd(args).exportDevices(args);
}
});

return org;
};
31 changes: 31 additions & 0 deletions src/cmd/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,37 @@ module.exports = class ParticleApi {
}));
}

/**
* Export devices for an organization
* @param {Object} options - Export options
* @param {string} options.orgSlug - Organization ID or slug
* @param {string} [options.format='json'] - Output format ('json' or 'csv')
* @param {number} [options.page=1] - Page number
* @param {number} [options.perPage=1000] - Results per page
* @param {string} [options.productIds] - Comma-separated product IDs filter
* @param {string} [options.groupIds] - Comma-separated group names filter
* @returns {Promise} - API response
*/
exportOrgDevices({ orgSlug, format = 'json', page = 1, perPage = 1000, productIds, groupIds }) {
const queryParams = new URLSearchParams();
queryParams.append('format', format);
queryParams.append('page', page.toString());
queryParams.append('per_page', perPage.toString());

if (productIds) {
queryParams.append('productIds', productIds);
}
if (groupIds) {
queryParams.append('groupIds', groupIds);
}

return this._wrap(this.api.request({
uri: `/v1/orgs/${orgSlug}/devices/export?${queryParams.toString()}`,
method: 'get',
auth: this.accessToken
}));
}

_wrap(promise){
return Promise.resolve(promise)
.then(result => result.body || result)
Expand Down
179 changes: 179 additions & 0 deletions src/cmd/org.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
'use strict';

const os = require('os');
const fs = require('fs-extra');
const VError = require('verror');
const settings = require('../../settings');
const ParticleAPI = require('./api');
const { normalizedApiError } = require('../lib/api-client');
const CLICommandBase = require('./base');

const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY_MS = 1000;
const PER_PAGE = 1000;

module.exports = class OrgCommand extends CLICommandBase {
constructor(...args) {
super(...args);
}

async exportDevices({ format = 'csv', product, group, output, params: { org } }) {
// Validate format
const formatLower = format.toLowerCase();
if (formatLower !== 'csv' && formatLower !== 'json') {
throw new Error('Format must be either "csv" or "json"');
}

const api = createAPI();
const allDevices = [];
let page = 1;
let totalPages = 1;
let totalRecords = 0;

this.ui.stdout.write(`Exporting devices from organization ${org}...${os.EOL}`);

// Fetch all pages with progress display
while (page <= totalPages) {
const result = await this._fetchPageWithRetry(api, {
orgSlug: org,
format: 'json', // Always fetch as JSON for processing
page,
perPage: PER_PAGE,
productIds: product,
groupIds: group
});

if (page === 1) {
totalRecords = result.meta.total_records;
totalPages = result.meta.total_pages;

if (totalRecords === 0) {
this.ui.stdout.write(`No devices found.${os.EOL}`);
return;
}

this.ui.stdout.write(`Found ${totalRecords} devices across ${totalPages} page(s)${os.EOL}`);
}

allDevices.push(...result.devices);

// Show progress
const progress = Math.min(100, Math.round((allDevices.length / totalRecords) * 100));
this.ui.stdout.write(`\rFetching... ${progress}% (${allDevices.length}/${totalRecords} devices)`);

page++;
}

this.ui.stdout.write(`${os.EOL}`);

// Format output
let outputContent;
if (formatLower === 'json') {
outputContent = JSON.stringify({ devices: allDevices, meta: { total_records: totalRecords } }, null, 2);
} else {
outputContent = this._generateCsv(allDevices);
}

// Write output
if (output) {
await fs.writeFile(output, outputContent, 'utf8');
this.ui.stdout.write(`Exported ${allDevices.length} devices to ${output}${os.EOL}`);
} else {
this.ui.stdout.write(outputContent);
if (!outputContent.endsWith(os.EOL)) {
this.ui.stdout.write(os.EOL);
}
}
}

async _fetchPageWithRetry(api, options, attempt = 1) {
try {
return await api.exportOrgDevices(options);
} catch (error) {
// Check if it's an authorization/validation error (400/401) - don't retry these
// The API wrapper converts 400/401 to UnauthorizedError
if (error.name === 'UnauthorizedError' || error.statusCode === 400 || error.statusCode === 401) {
throw new Error(error.message || 'Bad request');
}

// Retry for transient failures
if (attempt < MAX_RETRIES && this._isRetryableError(error)) {
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
this.ui.stderr.write(`${os.EOL}Request failed, retrying in ${delay}ms (attempt ${attempt}/${MAX_RETRIES})...${os.EOL}`);
await this._sleep(delay);
return this._fetchPageWithRetry(api, options, attempt + 1);
}

const message = 'Error exporting organization devices';
throw createAPIErrorResult({ error, message });
}
}

_isRetryableError(error) {
// Retry on network errors, timeouts, and 5xx server errors
if (!error.statusCode) {
return true; // Network error
}
return error.statusCode >= 500 && error.statusCode < 600;
}

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

_generateCsv(devices) {
const headers = [
'Device ID',
'Device Name',
'Product ID',
'Platform ID',
'Online',
'Last Heard',
'Serial Number',
'ICCID',
'Groups',
'Firmware Version'
];

const rows = devices.map(device => {
return [
this._escapeCsvField(device.id || ''),
this._escapeCsvField(device.name || ''),
device.product_id || '',
device.platform_id || '',
device.online ? 'true' : 'false',
device.last_heard || '',
this._escapeCsvField(device.serial_number || ''),
this._escapeCsvField(device.iccid || ''),
this._escapeCsvField((device.groups || []).join(';')),
this._escapeCsvField(device.firmware_version || '')
].join(',');
});

return [headers.join(','), ...rows].join(os.EOL);
}

_escapeCsvField(value) {
if (value === null || value === undefined) {
return '';
}
const str = String(value);
// Escape if contains comma, newline, or double quote
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
};


// UTILS //////////////////////////////////////////////////////////////////////
function createAPI() {
return new ParticleAPI(settings.apiUrl, {
accessToken: settings.access_token
});
}

function createAPIErrorResult({ error: e, message }) {
return new VError(normalizedApiError(e), message);
}
Loading
Loading