@altronix/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function build(this: Command): Promise<void>;
package/dist/build.js ADDED
@@ -0,0 +1,443 @@
1
+ import dotenv from 'dotenv';
2
+ import { Ajv } from 'ajv';
3
+ import { parse as parseJsonc } from 'jsonc-parser';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import cp from 'node:child_process';
7
+ import inquirer from '@inquirer/confirm';
8
+ import { concat, concatMap, EMPTY, EmptyError, from, last, lastValueFrom, map, merge, mergeMap, Observable, of, share, tap, toArray } from 'rxjs';
9
+ import { PassThrough } from 'node:stream';
10
+ import { render } from 'ink';
11
+ import React from 'react';
12
+ import Ui from './build.ui.js';
13
+ import { keys } from './keys.js';
14
+ const schemaBoard = {
15
+ type: 'object',
16
+ required: ['images'],
17
+ properties: {
18
+ version: { type: 'string', nullable: true },
19
+ soc: { type: 'string', nullable: true },
20
+ cpu: { type: 'string', nullable: true },
21
+ variant: { type: 'string', nullable: true },
22
+ images: {
23
+ type: 'object',
24
+ required: [],
25
+ additionalProperties: {
26
+ type: 'object',
27
+ properties: {
28
+ configs: {
29
+ type: 'array',
30
+ items: { type: 'string' },
31
+ nullable: true
32
+ },
33
+ overlays: {
34
+ type: 'array',
35
+ items: { type: 'string' },
36
+ nullable: true
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ };
43
+ const schemaBuild = {
44
+ type: 'object',
45
+ required: [],
46
+ additionalProperties: {
47
+ type: 'object',
48
+ required: ['sourceDir', 'binaryDir', 'installDir', 'boards'],
49
+ properties: {
50
+ sourceDir: { type: 'string' },
51
+ binaryDir: { type: 'string' },
52
+ installDir: { type: 'string' },
53
+ boards: {
54
+ type: 'object',
55
+ required: [],
56
+ additionalProperties: {
57
+ anyOf: [{ ...schemaBoard }, { type: 'array', items: schemaBoard }]
58
+ }
59
+ }
60
+ }
61
+ }
62
+ };
63
+ const schema = {
64
+ type: 'object',
65
+ required: ['applications', 'bootloaders'],
66
+ additionalProperties: false,
67
+ properties: {
68
+ applications: schemaBuild,
69
+ bootloaders: schemaBuild
70
+ }
71
+ };
72
+ const ajv = new Ajv({ allErrors: true, verbose: true });
73
+ const validate = ajv.compile(schema);
74
+ async function stat(path) {
75
+ return new Promise((resolve) => fs.stat(path, (err, stat) => {
76
+ if (err) {
77
+ resolve(false);
78
+ }
79
+ else {
80
+ resolve(stat);
81
+ }
82
+ }));
83
+ }
84
+ async function resolver(cwd) {
85
+ // NOTE this assumes atx-zdk is named in the west.yml file as either atx or
86
+ // atx-zdk. The default case is atx-zdk. Most people never rename the
87
+ // yaml. However, if somebody wants to rename atx-zdk repo in their
88
+ // workspace. We can have atx.json() pass this name in and resolve it
89
+ // that way
90
+ const project = path.resolve(cwd);
91
+ const workspace = path.resolve(cwd, '..');
92
+ const atx0 = path.resolve(cwd, '..', 'atx');
93
+ const atx1 = path.resolve(cwd, '..', 'atx-zdk');
94
+ const atx = (await stat(atx0)) ? atx0 : (await stat(atx1)) ? atx1 : project;
95
+ return (dir, from) => {
96
+ if (dir.startsWith('<workspace>')) {
97
+ return path.join(workspace, dir.substring(12));
98
+ }
99
+ else if (dir.startsWith('<project>')) {
100
+ return path.join(project, dir.substring(10));
101
+ }
102
+ else if (dir.startsWith('<atx>')) {
103
+ return path.join(atx, dir.substring(6));
104
+ }
105
+ else if (path.isAbsolute(dir)) {
106
+ return dir;
107
+ }
108
+ else {
109
+ return path.resolve(from || cwd, dir);
110
+ }
111
+ };
112
+ }
113
+ async function parseAppVersion(v) {
114
+ const data = await fs.promises.readFile(v, 'ascii');
115
+ const major = data.matchAll(/^VERSION_MAJOR = ([0-9]+)/gm).next();
116
+ const minor = data.matchAll(/^VERSION_MINOR = ([0-9]+)/gm).next();
117
+ const patch = data.matchAll(/^PATCHLEVEL = ([0-9]+)/gm).next();
118
+ const tweak = data.matchAll(/^VERSION_TWEAK = ([0-9]+)/gm).next();
119
+ const extra = data.matchAll(/^EXTRAVERSION = ([\.a-zA-Z0-9-]+)/gm).next();
120
+ return {
121
+ major: (major.value && major.value[1] && parseInt(major.value[1])) || 0,
122
+ minor: (minor.value && minor.value[1] && parseInt(minor.value[1])) || 0,
123
+ patch: (patch.value && patch.value[1] && parseInt(patch.value[1])) || 0,
124
+ tweak: (tweak.value && tweak.value[1] && parseInt(tweak.value[1])) || 0,
125
+ extra: (extra.value && extra.value[1]) || ''
126
+ };
127
+ }
128
+ function formatVersion(ver) {
129
+ const { major, minor, patch, tweak, extra } = ver;
130
+ return extra.length
131
+ ? `${major}-${minor}-${patch}-${tweak}-${extra}`
132
+ : `${major}-${minor}-${patch}-${tweak}`;
133
+ }
134
+ async function westOptionsNormalize(board, config, build, cwd, verbose) {
135
+ const resolve = await resolver(cwd);
136
+ const name = build.__key;
137
+ const boardTarget = [board.__key, board.soc, board.cpu, board.variant]
138
+ .filter((item) => !!item)
139
+ .join('/');
140
+ const installDir = resolve(build.installDir);
141
+ const sourceDir = resolve(build.sourceDir);
142
+ const binaryDir = path.join(resolve(build.binaryDir), boardTarget.replaceAll('/', '-'), config.__key);
143
+ const versionFile = path.join(sourceDir, 'VERSION');
144
+ const version = formatVersion(await parseAppVersion(versionFile));
145
+ const confs = config.configs
146
+ ? config.configs.map((f) => resolve(f, sourceDir))
147
+ : [];
148
+ const overlays = config.overlays
149
+ ? config.overlays.map((f) => resolve(f, sourceDir))
150
+ : [];
151
+ return {
152
+ name,
153
+ board: boardTarget.replaceAll('/', '-'),
154
+ boardTarget,
155
+ config: config.__key,
156
+ cwd,
157
+ version,
158
+ sourceDir,
159
+ binaryDir,
160
+ installDir,
161
+ outputFile: path.join(binaryDir, 'build.log'),
162
+ errorFile: path.join(binaryDir, 'build.err'),
163
+ confs,
164
+ overlays,
165
+ verbose
166
+ };
167
+ }
168
+ function westItem(opts) {
169
+ const { board, name, config, version } = opts;
170
+ return {
171
+ kind: 'west',
172
+ item: `${board}-${name}-${config}-${version}`
173
+ };
174
+ }
175
+ function west(args) {
176
+ const { cwd, boardTarget, sourceDir, binaryDir, confs, overlays } = args;
177
+ const { item } = westItem(args);
178
+ const expect = path.join(binaryDir, 'zephyr', 'zephyr.bin');
179
+ return of([
180
+ `build`,
181
+ `-b ${boardTarget}`,
182
+ `-s ${sourceDir}`,
183
+ `-d ${binaryDir}`,
184
+ `--`,
185
+ `-DEXTRA_CONF_FILE="${[...confs].join(';')}"`,
186
+ `-DEXTRA_DTC_OVERLAY_FILE="${[...overlays].join(';')}"`
187
+ ]).pipe(mergeMap((westArgs) => new Observable((subscriber) => {
188
+ const west = cp.spawn('west', westArgs, { cwd, shell: true });
189
+ const fout = fs.createWriteStream(args.outputFile);
190
+ const ferr = fs.createWriteStream(args.errorFile);
191
+ const out = new PassThrough();
192
+ const err = new PassThrough();
193
+ let error = '';
194
+ west.stdout.pipe(fout);
195
+ west.stdout.pipe(out);
196
+ west.stderr.pipe(ferr);
197
+ west.stderr.pipe(err);
198
+ west.on('error', (e) => {
199
+ fout.close();
200
+ ferr.close();
201
+ out.destroy();
202
+ err.destroy();
203
+ subscriber.next({ item, error: e.name });
204
+ });
205
+ west.on('exit', (_code) => {
206
+ fout.close();
207
+ ferr.close();
208
+ out.destroy();
209
+ err.destroy();
210
+ fs.stat(expect, (e) => {
211
+ e
212
+ ? subscriber.next({ item, error })
213
+ : subscriber.next({ item, complete: true });
214
+ subscriber.complete();
215
+ });
216
+ });
217
+ out.on('data', (d) => subscriber.next({ item, output: d.toString() }));
218
+ err.on('data', (d) => (error += d));
219
+ })));
220
+ }
221
+ function emulateBytePages(board) {
222
+ return (board.startsWith('same54_xpro') ||
223
+ board.startsWith('netway') ||
224
+ board.startsWith('oa2b'));
225
+ }
226
+ function extraAppConfs(extraConfs, config) {
227
+ const key = process.env['ALTRONIX_RELEASE_KEY'];
228
+ if (!key)
229
+ throw new Error('missing ALTRONIX_RELEASE_KEY from environment');
230
+ // Custom TLVs for partition identification embedded in the MCUboot image header.
231
+ // - 0x8001: partition index (1 = slot1_partition, where firmware updates are written)
232
+ // - 0x8002: partition name string
233
+ // Note: INT32 values use little-endian hex (1 -> 0x01000000) per imgtool convention.
234
+ const imgtoolArgs = [
235
+ '--custom-tlv 0x8001 0x01000000',
236
+ '--custom-tlv 0x8002 slot1_partition'
237
+ ].join(' ');
238
+ const extraConfsData = [
239
+ `CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="${key}"`,
240
+ `CONFIG_BOOTLOADER_MCUBOOT=y`,
241
+ `CONFIG_ATX_UPDATE_ENABLE=y`,
242
+ `CONFIG_ATX_CONFIG="${config}"`,
243
+ `CONFIG_MCUBOOT_EXTRA_IMGTOOL_ARGS="${imgtoolArgs}"`
244
+ ].join('\r\n');
245
+ return from(fs.promises.writeFile(extraConfs, extraConfsData));
246
+ }
247
+ function extraBootConfs(board, extraConfs) {
248
+ const key = process.env['ALTRONIX_RELEASE_KEY'];
249
+ if (!key)
250
+ throw new Error('missing ALTRONIX_RELEASE_KEY from environment');
251
+ const extraConfsData = emulateBytePages(board)
252
+ ? [`CONFIG_BOOT_SIGNATURE_KEY_FILE="${key}"`]
253
+ : [`CONFIG_BOOT_SIGNATURE_KEY_FILE="${key}"`];
254
+ return from(fs.promises.writeFile(extraConfs, extraConfsData.join('\r\n')));
255
+ }
256
+ function copy() {
257
+ return (obs$) => obs$.pipe(mergeMap((opts) => {
258
+ const { src, dst } = opts;
259
+ const promise = fs.promises
260
+ .copyFile(src, dst)
261
+ .then(() => opts)
262
+ .catch(() => ({ ...opts, err: dst }));
263
+ return from(promise);
264
+ }));
265
+ }
266
+ function mkdir() {
267
+ return (obs$) => obs$.pipe(mergeMap((dir) => new Observable((subscriber) => {
268
+ fs.promises
269
+ .mkdir(dir, { recursive: true })
270
+ .then(() => subscriber.next())
271
+ .catch((e) => subscriber.error(e))
272
+ .finally(() => subscriber.complete());
273
+ })));
274
+ }
275
+ function rmdir(dir) {
276
+ return new Observable((subscriber) => {
277
+ fs.promises
278
+ .rm(dir, { recursive: true })
279
+ .then(() => subscriber.next(dir))
280
+ .catch((e) => subscriber.error(e))
281
+ .finally(() => subscriber.complete());
282
+ });
283
+ }
284
+ function exists(dir) {
285
+ return new Observable((subscriber) => {
286
+ fs.promises
287
+ .stat(dir)
288
+ .then(() => subscriber.next(true))
289
+ .catch(() => subscriber.next(false))
290
+ .finally(() => subscriber.complete());
291
+ });
292
+ }
293
+ function confirm(force) {
294
+ return (obs$) => force
295
+ ? of(true)
296
+ : obs$.pipe(concatMap((dir) => from(inquirer({ message: `Delete ${dir}?` }))));
297
+ }
298
+ function throwIf(predicate, message) {
299
+ return tap((v) => {
300
+ if (predicate(v))
301
+ throw new Error(message);
302
+ });
303
+ }
304
+ function clean(force) {
305
+ return (obs$) => obs$.pipe(concatMap((dir) => exists(dir).pipe(concatMap((exists) => {
306
+ return exists
307
+ ? of(dir).pipe(confirm(force), throwIf((confirm) => !confirm, 'User rejected delete'), mergeMap((_) => rmdir(dir)), map(() => dir))
308
+ : of(dir);
309
+ }))));
310
+ }
311
+ function tovoid() {
312
+ return (obs$) => obs$.pipe(map(() => void 0));
313
+ }
314
+ function filterExists(getPath) {
315
+ return (obs$) => obs$.pipe(mergeMap((item) => from(stat(getPath(item))).pipe(mergeMap((s) => (s ? of(item) : EMPTY)))));
316
+ }
317
+ function concatFiles(opts) {
318
+ const { inputs, output } = opts;
319
+ return from(Promise.all(inputs.map((f) => fs.promises.readFile(f)))
320
+ .then((buffers) => Buffer.concat(buffers))
321
+ .then((data) => fs.promises.writeFile(output, data)));
322
+ }
323
+ export async function build() {
324
+ const config = this.opts()['config'] || path.resolve('./', 'atx.json');
325
+ const verbose = this.optsWithGlobals()['verbose'];
326
+ const mBoard = new RegExp(this.opts()['matchesBoard'] || '(.*)');
327
+ const mApplication = new RegExp(this.opts()['matchesApplication'] || '(.*)');
328
+ const mConfig = new RegExp(this.opts()['matchesConfig'] || '(.*)');
329
+ const cwd = path.resolve(path.dirname(config));
330
+ const data = await fs.promises.readFile(config, 'ascii');
331
+ const atx = parseJsonc(data);
332
+ const extraAppConfFile = 'application.conf';
333
+ const extraBootConfFile = 'bootloader.conf';
334
+ if (!validate(atx))
335
+ throw validate.errors;
336
+ const env = path.resolve(cwd, '.env');
337
+ dotenv.config({ path: env });
338
+ const apps = keys(atx.applications)
339
+ .filter(({ __key }) => __key.match(mApplication))
340
+ .flatMap((app) => {
341
+ return keys(app.boards)
342
+ .filter(({ __key }) => __key.match(mBoard))
343
+ .flatMap((board) => {
344
+ return keys(board.images)
345
+ .filter(({ __key }) => __key.match(mConfig))
346
+ .map((config) => {
347
+ return westOptionsNormalize(board, config, app, cwd, verbose);
348
+ });
349
+ });
350
+ });
351
+ const bootloaders = keys(atx.bootloaders).flatMap((app) => {
352
+ return keys(app.boards)
353
+ .filter(({ __key }) => __key.match(mBoard))
354
+ .flatMap((board) => {
355
+ return keys(board.images).map((config) => westOptionsNormalize(board, config, app, cwd, verbose));
356
+ });
357
+ });
358
+ let concurrent = this.opts()['concurrent']
359
+ ? parseInt(this.opts()['concurrent'])
360
+ : Infinity;
361
+ let buildApps = this.opts()['application'];
362
+ let buildBoot = this.opts()['bootloader'];
363
+ if (!(buildApps || buildBoot)) {
364
+ buildApps = buildBoot = true;
365
+ }
366
+ const apps$ = buildApps ? from(await Promise.all(apps)) : EMPTY;
367
+ const boot$ = buildBoot ? from(await Promise.all(bootloaders)) : EMPTY;
368
+ // Handle all the preliminary stuff before building
369
+ const ready$ = concat(merge(apps$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b]))), boot$.pipe(mergeMap(({ installDir: s, binaryDir: b }) => from([s, b])))).pipe(toArray(), mergeMap((arr) => from([...new Set(arr)])), // dedupe
370
+ clean(this.opts()['yes']), mkdir()), apps$.pipe(mergeMap(({ binaryDir, config }) => extraAppConfs(path.join(binaryDir, extraAppConfFile), config))), boot$.pipe(mergeMap(({ board, binaryDir }) => extraBootConfs(board, path.join(binaryDir, extraBootConfFile))))).pipe(last(), share());
371
+ // Get an array of every build item.
372
+ const items$ = merge(apps$.pipe(map(westItem)), boot$.pipe(map(westItem))).pipe(toArray());
373
+ // Run build commands for all bootloaders and applications concurrently
374
+ const build$ = merge(apps$.pipe(map(west)), boot$.pipe(map(west))).pipe(mergeMap((obs) => obs, concurrent), share());
375
+ // Create update.bin by concatenating signed binaries in the build dir
376
+ const update$ = apps$.pipe(mergeMap(({ binaryDir }) => {
377
+ const zephyrDir = path.join(binaryDir, 'zephyr');
378
+ const required = path.join(zephyrDir, 'zephyr.signed.bin');
379
+ const optional = [
380
+ path.join(zephyrDir, 'www.signed.bin'),
381
+ path.join(zephyrDir, 'packedfs.signed.bin')
382
+ ];
383
+ return from(optional).pipe(filterExists((f) => f), toArray(), mergeMap((extras) => concatFiles({
384
+ inputs: [required, ...extras],
385
+ output: path.join(zephyrDir, 'update.bin')
386
+ })));
387
+ }), tovoid());
388
+ // Install everything into installDir
389
+ const install$ = merge(apps$.pipe(mergeMap(({ name, config, version: ver, board, binaryDir, installDir: d }) => {
390
+ const prefix = `${board}-${name}-${config}-${ver}`;
391
+ const zephyrDir = path.join(binaryDir, 'zephyr');
392
+ // Required files (error if missing)
393
+ const required = [
394
+ {
395
+ src: path.join(zephyrDir, 'zephyr.signed.bin'),
396
+ dst: path.join(d, `${prefix}.signed.bin`)
397
+ },
398
+ {
399
+ src: path.join(zephyrDir, 'update.bin'),
400
+ dst: path.join(d, `${prefix}.update.bin`)
401
+ }
402
+ ];
403
+ // Optional files (skip if missing)
404
+ const optional = [
405
+ {
406
+ src: path.join(zephyrDir, 'www.bin'),
407
+ dst: path.join(d, `${prefix}.www.bin`)
408
+ },
409
+ {
410
+ src: path.join(zephyrDir, 'www.signed.bin'),
411
+ dst: path.join(d, `${prefix}.www.signed.bin`)
412
+ },
413
+ {
414
+ src: path.join(zephyrDir, 'packedfs.bin'),
415
+ dst: path.join(d, `${prefix}.packedfs.bin`)
416
+ },
417
+ {
418
+ src: path.join(zephyrDir, 'packedfs.signed.bin'),
419
+ dst: path.join(d, `${prefix}.packedfs.signed.bin`)
420
+ }
421
+ ];
422
+ const optional$ = from(optional).pipe(filterExists((opt) => opt.src));
423
+ return merge(from(required), optional$);
424
+ })), boot$.pipe(map(({ name, config, board, binaryDir, installDir: d }) => {
425
+ return {
426
+ src: path.join(binaryDir, 'zephyr', 'zephyr.bin'),
427
+ dst: path.join(d, `${board}-${name}-${config}.bin`)
428
+ };
429
+ }))).pipe(copy(), tovoid());
430
+ try {
431
+ await lastValueFrom(ready$);
432
+ const items = await lastValueFrom(items$);
433
+ const renderer = render(React.createElement(Ui, { items: items, "progress$": build$, onComplete: () => renderer.unmount() }));
434
+ await renderer.waitUntilExit();
435
+ renderer.cleanup();
436
+ return lastValueFrom(concat(update$, install$));
437
+ }
438
+ catch (e) {
439
+ if (!(e instanceof EmptyError))
440
+ throw e;
441
+ console.log('nothing to build');
442
+ }
443
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { Observable } from 'rxjs';
3
+ export declare class BuildError extends Error implements BuildItem {
4
+ item: string;
5
+ kind: 'west';
6
+ constructor(item: string, kind: 'west', message: string);
7
+ }
8
+ export interface BuildItem {
9
+ item: string;
10
+ kind: 'west';
11
+ }
12
+ export interface BuildProgress<K extends string = string> {
13
+ item: K;
14
+ output?: string;
15
+ error?: string;
16
+ complete?: boolean;
17
+ }
18
+ export interface Options {
19
+ items: BuildItem[];
20
+ progress$: Observable<BuildProgress>;
21
+ onComplete: BuildEffectCallback;
22
+ }
23
+ export default function ({ items, progress$, onComplete }: Options): React.JSX.Element;
24
+ interface BuildEffectCallback<E extends Error = Error> {
25
+ (e?: E): void;
26
+ }
27
+ export {};
@@ -0,0 +1,95 @@
1
+ import React, { useLayoutEffect, useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { scan } from 'rxjs';
4
+ import useStdoutDimensions from './useStdoutDimensions.js';
5
+ export class BuildError extends Error {
6
+ constructor(item, kind, message) {
7
+ super(message);
8
+ Object.defineProperty(this, "item", {
9
+ enumerable: true,
10
+ configurable: true,
11
+ writable: true,
12
+ value: item
13
+ });
14
+ Object.defineProperty(this, "kind", {
15
+ enumerable: true,
16
+ configurable: true,
17
+ writable: true,
18
+ value: kind
19
+ });
20
+ }
21
+ }
22
+ export default function ({ items, progress$, onComplete }) {
23
+ const [col, _rows] = useStdoutDimensions();
24
+ const [width, setWidth] = useState(0);
25
+ const progress = useBuildEffect(progress$, items.map(({ item: i }) => i), onComplete);
26
+ useLayoutEffect(() => {
27
+ const width = items.map(({ item }) => item).reduce(calculateItemWidth, 0);
28
+ setWidth(width);
29
+ }, [items]);
30
+ return (React.createElement(Box, { flexDirection: "column" }, items.map(({ item, kind }) => (React.createElement(Box, { key: item },
31
+ React.createElement(Box, { width: width, marginRight: 1 },
32
+ React.createElement(Text, { wrap: "truncate", bold: true }, item.padStart(width))),
33
+ React.createElement(Box, { width: 4, marginRight: 1 },
34
+ React.createElement(Text, { wrap: "truncate", bold: true, color: buildColor(kind) }, kind.toUpperCase().padStart(4))),
35
+ React.createElement(Box, { width: 2, marginRight: 1 }, progress[item] && React.createElement(Text, { color: "cyan" }, "=>")),
36
+ React.createElement(Box, { width: col - width - 4 - 2 - 1 - 1 - 1 },
37
+ React.createElement(Text, { wrap: "truncate", dimColor: !progressComplete(progress[item]), color: progressColor(progress[item]) }, progress[item])))))));
38
+ }
39
+ function buildColor(_kind) {
40
+ return 'blue';
41
+ }
42
+ function progressComplete(progress) {
43
+ return progress === 'OK!';
44
+ }
45
+ function progressColor(progress) {
46
+ return !progress ||
47
+ progress.charAt(0) == '|' ||
48
+ progress.charAt(0) == '/' ||
49
+ progress.charAt(0) == '-' ||
50
+ progress.charAt(0) == '\\'
51
+ ? 'white'
52
+ : progressComplete(progress)
53
+ ? 'green'
54
+ : 'red';
55
+ }
56
+ function initProgress(items) {
57
+ return items.reduce((acc, curr) => ({ ...acc, [curr]: '' }), {});
58
+ }
59
+ function calculateItemWidth(acc, next) {
60
+ return next.length > acc ? next.length : acc;
61
+ }
62
+ function progressInc(progress) {
63
+ if (progress.charAt(0) == '|') {
64
+ return '/';
65
+ }
66
+ else if (progress.charAt(0) == '/') {
67
+ return '-';
68
+ }
69
+ else if (progress.charAt(0) == '-') {
70
+ return '\\';
71
+ }
72
+ else {
73
+ return '|';
74
+ }
75
+ }
76
+ function progressReducer(acc, next) {
77
+ acc[next.item] = next.complete
78
+ ? 'OK!'
79
+ : next.error
80
+ ? next.error.replace(/\r?\n/g, '')
81
+ : progressInc(acc[next.item]);
82
+ return acc;
83
+ }
84
+ function useBuildEffect(obs$, items, cb) {
85
+ const [progress, setProgress] = useState(initProgress(items));
86
+ useLayoutEffect(() => {
87
+ const s = obs$.pipe(scan(progressReducer, progress)).subscribe({
88
+ next: (progress) => setProgress({ ...progress }),
89
+ complete: cb,
90
+ error: cb
91
+ });
92
+ return () => s.unsubscribe();
93
+ }, [obs$]);
94
+ return progress;
95
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { program } from 'commander';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
- import { build } from '@altronix/build';
5
+ import { build } from './build.js';
6
+ import { update } from './update.js';
6
7
  import { fileURLToPath } from 'url';
7
8
  // https://stackoverflow.com/questions/8817423/why-is-dirname-not-defined-in-node-repl
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -16,17 +17,6 @@ program
16
17
  .version(ver)
17
18
  .option('-Q, --quiet', 'no logs')
18
19
  .option('-VV, --verbose', 'extra logging');
19
- // Scan command
20
- /*
21
- const ignore = 'node_modules;target;build;.git';
22
- program
23
- .command('scan')
24
- .description('scan for *.cddl files')
25
- .option('-p, --path <PATH>', 'root directory to start scan', cwd)
26
- .option('-m, --matches <REGEX>', 'match expression', '.*cddl$')
27
- .option('-i, --ignores <REGEX>', 'ignore directories', ignore)
28
- .action(seedle.scan);
29
- */
30
20
  // Build command
31
21
  // TODO - detect if west is available and fail early
32
22
  program
@@ -36,123 +26,16 @@ program
36
26
  .option('-C, --concurrent <NUMBER>', 'how many builds to run concurrently')
37
27
  .option('-A, --application', 'build applications')
38
28
  .option('-B, --bootloader', 'build bootloaders')
39
- .option('-W, --wasm', 'build wasm')
40
29
  .option('--matches-board [regex]', 'board regex match expr')
41
30
  .option('--matches-application [regex]', 'application regex match expr')
42
31
  .option('--matches-config [regex]', 'config regex match expr')
43
32
  .option('-y, --yes', 'answer yes automatically')
44
33
  .action(build);
45
- // Device commands are optional - only available when @altronix/device can be loaded
46
- async function registerDeviceCommands() {
47
- try {
48
- // Test if device package is available
49
- await import('@altronix/device');
50
- // Dynamically import device-dependent modules
51
- const { getAbout, getSite, setSite } = await import('./about.js');
52
- const { reboot, save, saveAndReboot } = await import('./exe.js');
53
- const { getNet, setNet, setDhcp } = await import('./net.js');
54
- const { getCloud, setCloud } = await import('./cloud.js');
55
- const { stress } = await import('./stress.js');
56
- const { update } = await import('./update.js');
57
- const { listen } = await import('./listen.js');
58
- const { getDemo, getHello } = await import('./sample.js');
59
- const { concurrent } = await import('./concurrent.js');
60
- const device = program
61
- .command('device')
62
- .description('manage zephyr device settings')
63
- .option('-p, --port <PORT>', 'listen port for incoming device connections')
64
- .option('-v, --verbose', 'print extra debug informating during request')
65
- .option('-f, --first', 'perform request on first device we see (any device)');
66
- device
67
- .command('get-about')
68
- .description('get about data on the device')
69
- .action(getAbout);
70
- device
71
- .command('get-site')
72
- .description('get the site ID of the device')
73
- .action(getSite);
74
- device
75
- .command('set-site')
76
- .description('set the site ID of the device')
77
- .argument('<site>', 'new site id')
78
- .action(setSite);
79
- device
80
- .command('save')
81
- .description('save data to persistant storage. (does not reboot)')
82
- .action(save);
83
- device
84
- .command('reboot')
85
- .description('reboot the device. (does not save)')
86
- .action(reboot);
87
- device
88
- .command('save-reboot')
89
- .description('save data to persistant storage and reboot the device')
90
- .action(saveAndReboot);
91
- device
92
- .command('get-net')
93
- .description('get network configuration from the device')
94
- .action(getNet);
95
- device
96
- .command('set-net')
97
- .description('set network interface into static ip mode')
98
- .argument('<ip>', 'the new IP address')
99
- .argument('<sn>', 'the new SUBNET mask')
100
- .argument('<gw>', 'the new GATEWAY address')
101
- .action(setNet);
102
- device
103
- .command('set-dhcp')
104
- .description('set network interface into DHCP mode')
105
- .action(setDhcp);
106
- device
107
- .command('get-cloud')
108
- .description('get cloud endpoint on the device')
109
- .action(getCloud);
110
- device
111
- .command('set-cloud')
112
- .description('set cloud endpoint on the device')
113
- .argument('<endpoint>', 'cloud service location')
114
- .action(setCloud);
115
- device
116
- .command('stress')
117
- .description('run a stress test on a device')
118
- .argument('<count>', 'how many requests to make')
119
- .action(stress);
120
- device
121
- .command('update')
122
- .description('run firmware update from file')
123
- .argument('<file>', 'file to update device with')
124
- .action(update);
125
- device
126
- .command('listen')
127
- .description('listen for alerts and heartbeats')
128
- .argument('<duration>', 'how long to listen')
129
- .action(listen);
130
- device
131
- .command('concurrent')
132
- .description('make a number of requests concurrently')
133
- .argument('<count>', 'how many requests to make concurrently')
134
- .action(concurrent);
135
- const sample = program
136
- .command('sample')
137
- .description('manage zephyr device settings')
138
- .option('-p, --port <PORT>', 'listen port for incoming device connections')
139
- .option('-v, --verbose', 'print extra debug informating during request')
140
- .option('-f, --first', 'perform request on first device we see (any device)');
141
- sample
142
- .command('get-demo')
143
- .description('read the demo data from the sample')
144
- .action(getDemo);
145
- sample
146
- .command('get-hello')
147
- .description('read the hello data from the sample')
148
- .action(getHello);
149
- }
150
- catch {
151
- // Device package not available - device commands will not be registered
152
- }
153
- }
154
- // Load plugins
155
- // (await plugins()).forEach(({ plugin: _, path: __, description: ___ }) => {});
156
- registerDeviceCommands()
157
- .then(() => program.parseAsync())
158
- .catch((e) => console.error(e));
34
+ // Update command
35
+ program
36
+ .command('update')
37
+ .description('inspect or flash firmware update')
38
+ .argument('<url>', 'target device URL (e.g., https://192.168.1.1:8008)')
39
+ .argument('<file>', 'path to update binary')
40
+ .action(update);
41
+ program.parseAsync().catch((e) => console.error(e));
package/dist/keys.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type WithKey<T> = T & {
2
+ __key: string;
3
+ };
4
+ export type FlattenWithKey<Type> = Type extends Array<infer Item> ? Array<WithKey<Item>> : Array<WithKey<Type>>;
5
+ export declare function keys<T>(map: {
6
+ [key: string]: T;
7
+ }): FlattenWithKey<T>;
package/dist/keys.js ADDED
@@ -0,0 +1,14 @@
1
+ export function keys(map) {
2
+ let ret = [];
3
+ Object.keys(map)
4
+ .filter((key) => key !== '__key')
5
+ .forEach((key) => {
6
+ if (Array.isArray(map[key])) {
7
+ map[key].forEach((item) => ret.push({ ...item, __key: key }));
8
+ }
9
+ else {
10
+ ret.push({ ...map[key], __key: key });
11
+ }
12
+ });
13
+ return ret;
14
+ }
package/dist/plugin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import cp from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { filter, from, lastValueFrom, map, merge, mergeMap, Observable, of, toArray } from 'rxjs';
4
- import { keys } from '@altronix/build';
4
+ import { keys } from './keys.js';
5
5
  function npmls(g) {
6
6
  return of(['ls', `${g ? '-g' : ''}`, '-l', '--json', '--depth 0']).pipe(mergeMap((args) => new Observable((subscriber) => {
7
7
  const ls = cp.spawn('npm', args, { shell: true });
@@ -1,7 +1,7 @@
1
1
  import React, { useLayoutEffect, useState } from 'react';
2
2
  import { scan } from 'rxjs';
3
3
  import { Box, Text } from 'ink';
4
- import { useStdoutDimensions as useDimensions } from '@altronix/build';
4
+ import useDimensions from './useStdoutDimensions.js';
5
5
  export default function Progress({ total, response$, onComplete }) {
6
6
  const [cols] = useDimensions();
7
7
  const [width, setWidth] = useState(Math.min(cols - 4, 76));
package/dist/update.d.ts CHANGED
@@ -1,2 +1,42 @@
1
1
  import { Command } from 'commander';
2
- export declare function update(this: Command, file: string): Promise<void>;
2
+ import { UpdateProgress } from './update.ui.js';
3
+ /**
4
+ * Start flash operation - erases the partition
5
+ * GET /api/flash/start?partition=<name>
6
+ */
7
+ export declare function flashStart(baseUrl: string, partition: string): Promise<void>;
8
+ /**
9
+ * Transfer a single chunk
10
+ * PUT /api/flash/transfer?partition=<name>
11
+ * Body: CBOR-encoded [offset, data]
12
+ */
13
+ export declare function flashTransfer(baseUrl: string, partition: string, offset: number, data: Uint8Array): Promise<void>;
14
+ /**
15
+ * Complete the flash operation - triggers reboot
16
+ * GET /api/flash/transfer_complete?partition=<name>
17
+ */
18
+ export declare function flashTransferComplete(baseUrl: string, partition: string): Promise<void>;
19
+ /**
20
+ * Split image into 512-byte chunks, padding last chunk with 0xFF
21
+ */
22
+ export declare function chunkImage(image: Uint8Array): Array<{
23
+ offset: number;
24
+ data: Uint8Array;
25
+ }>;
26
+ export type OnProgress = (progress: UpdateProgress) => void;
27
+ export interface UploadImage {
28
+ partition: string;
29
+ image: Uint8Array;
30
+ }
31
+ /**
32
+ * Upload firmware to device
33
+ *
34
+ * For each partition:
35
+ * 1. Erase partition (GET /api/flash/start)
36
+ * 2. Transfer chunks (PUT /api/flash/transfer with CBOR [offset, data])
37
+ *
38
+ * After all partitions:
39
+ * 3. Finalize update (GET /api/flash/transfer_complete)
40
+ */
41
+ export declare function uploadFirmware(baseUrl: string, images: UploadImage[], onProgress: OnProgress): Promise<void>;
42
+ export declare function update(this: Command, url: string, file: string): Promise<void>;
package/dist/update.js CHANGED
@@ -1,59 +1,161 @@
1
- import { Linq } from '@altronix/device';
2
- import { Update } from '@altronix/zdk/common';
3
- import { concat, defer, EmptyError, finalize, from, lastValueFrom, map, mergeScan, switchMap } from 'rxjs';
4
- import fs from 'fs';
5
- import logger from './logger.js';
6
- import { select, first } from './select.js';
7
- import progress from './progress.js';
8
- function chunks(data, size) {
9
- let arr = [];
10
- let remainder = data.length % size;
11
- function slicer(n) {
12
- for (let i = 0; i < n; i++) {
13
- let idx = i * size;
14
- arr.push(data.slice(idx, idx + size));
15
- }
1
+ import fs from 'node:fs';
2
+ import https from 'node:https';
3
+ import http from 'node:http';
4
+ import { parseMcubootImages, AltronixImage } from '@altronix/webtobin';
5
+ import * as CBOR from 'cbor2';
6
+ import { render } from 'ink';
7
+ import React from 'react';
8
+ import UpdateUi from './update.ui.js';
9
+ const CHUNK_SIZE = 512;
10
+ // =============================================================================
11
+ // OTA API
12
+ // =============================================================================
13
+ /**
14
+ * Make an HTTP/HTTPS request to the OTA API
15
+ */
16
+ function otaRequest(method, baseUrl, path, partition, body) {
17
+ return new Promise((resolve, reject) => {
18
+ const url = new URL(path, baseUrl);
19
+ url.searchParams.set('partition', partition);
20
+ const isHttps = url.protocol === 'https:';
21
+ const transport = isHttps ? https : http;
22
+ const req = transport.request(url, {
23
+ method,
24
+ headers: body
25
+ ? {
26
+ 'Content-Type': 'application/cbor',
27
+ 'Content-Length': body.length,
28
+ }
29
+ : undefined,
30
+ rejectUnauthorized: false,
31
+ }, (res) => {
32
+ res.resume();
33
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
34
+ resolve();
35
+ }
36
+ else {
37
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
38
+ }
39
+ });
40
+ req.on('error', reject);
41
+ if (body)
42
+ req.write(body);
43
+ req.end();
44
+ });
45
+ }
46
+ /**
47
+ * Start flash operation - erases the partition
48
+ * GET /api/flash/start?partition=<name>
49
+ */
50
+ export function flashStart(baseUrl, partition) {
51
+ return otaRequest('GET', baseUrl, '/api/flash/start', partition);
52
+ }
53
+ /**
54
+ * Transfer a single chunk
55
+ * PUT /api/flash/transfer?partition=<name>
56
+ * Body: CBOR-encoded [offset, data]
57
+ */
58
+ export function flashTransfer(baseUrl, partition, offset, data) {
59
+ const payload = new Uint8Array(CBOR.encode([offset, data]));
60
+ return otaRequest('PUT', baseUrl, '/api/flash/transfer', partition, payload);
61
+ }
62
+ /**
63
+ * Complete the flash operation - triggers reboot
64
+ * GET /api/flash/transfer_complete?partition=<name>
65
+ */
66
+ export function flashTransferComplete(baseUrl, partition) {
67
+ return otaRequest('GET', baseUrl, '/api/flash/transfer_complete', partition);
68
+ }
69
+ // =============================================================================
70
+ // Chunking
71
+ // =============================================================================
72
+ /**
73
+ * Split image into 512-byte chunks, padding last chunk with 0xFF
74
+ */
75
+ export function chunkImage(image) {
76
+ const chunks = [];
77
+ for (let offset = 0; offset < image.length; offset += CHUNK_SIZE) {
78
+ const slice = image.subarray(offset, offset + CHUNK_SIZE);
79
+ const data = new Uint8Array(CHUNK_SIZE);
80
+ data.fill(0xff);
81
+ data.set(slice);
82
+ chunks.push({ offset, data });
16
83
  }
17
- if (remainder == 0) {
18
- slicer(data.length / size);
84
+ return chunks;
85
+ }
86
+ /**
87
+ * Upload firmware to device
88
+ *
89
+ * For each partition:
90
+ * 1. Erase partition (GET /api/flash/start)
91
+ * 2. Transfer chunks (PUT /api/flash/transfer with CBOR [offset, data])
92
+ *
93
+ * After all partitions:
94
+ * 3. Finalize update (GET /api/flash/transfer_complete)
95
+ */
96
+ export async function uploadFirmware(baseUrl, images, onProgress) {
97
+ for (const { partition, image } of images) {
98
+ // Erase
99
+ onProgress({ partition, phase: 'erase' });
100
+ await flashStart(baseUrl, partition);
101
+ // Transfer chunks
102
+ const chunks = chunkImage(image);
103
+ for (let i = 0; i < chunks.length; i++) {
104
+ const { offset, data } = chunks[i];
105
+ await flashTransfer(baseUrl, partition, offset, data);
106
+ onProgress({
107
+ partition,
108
+ phase: 'transfer',
109
+ current: offset + CHUNK_SIZE,
110
+ total: image.length
111
+ });
112
+ }
113
+ // Mark complete
114
+ onProgress({ partition, phase: 'complete' });
19
115
  }
20
- else {
21
- let n = Math.ceil(data.length / size);
22
- let end = size * (n - 1);
23
- slicer(n - 1);
24
- let last = new Uint8Array(size);
25
- last.set(data.slice(end));
26
- arr.push(last);
116
+ // Finalize (once, after all partitions)
117
+ if (images.length > 0) {
118
+ await flashTransferComplete(baseUrl, images[0].partition);
27
119
  }
28
- return arr;
29
- }
30
- function updateMap(data, idx) {
31
- let update = new Update({ data, offset: idx * 512 });
32
- // NOTE: Have to initialize the Uint8Array across binding this way
33
- // https://gitlab.altronix.com/software-engineering/sdk/atx-zdk/-/issues/1
34
- update.data = data;
35
- return update;
36
120
  }
37
- function updater(linq, serial) {
38
- return (acc, update) => linq
39
- .put(serial, '/api/v1/update/transfer', update.cbor())
40
- .pipe(map(({ meta }) => ({ ...meta, remaining: acc.remaining - 1 })));
41
- }
42
- export async function update(file) {
43
- logger(this.optsWithGlobals()['verbose'] ? 'silly' : 'info');
44
- const linq = new Linq();
45
- const dev$ = linq
46
- .connections()
47
- .pipe(this.optsWithGlobals()['first'] ? first() : select());
48
- const obs = from(fs.promises.readFile(file)).pipe(switchMap((bin) => dev$.pipe(map((serial) => ({ serial, bin })))), switchMap(({ serial, bin }) => {
49
- const update = chunks(bin, 512).map(updateMap);
50
- const start$ = defer(() => linq.get(serial, '/api/v1/update/start'));
51
- const finish$ = defer(() => linq.get(serial, '/api/v1/update/finish'));
52
- const transfer$ = from(update).pipe(mergeScan(updater(linq, serial), { remaining: update.length }, 1));
53
- return concat(start$, transfer$, finish$).pipe(progress(update.length));
54
- }), finalize(() => linq.shutdown()));
55
- return lastValueFrom(obs).catch((e) => {
56
- if (!(e instanceof EmptyError))
57
- throw e;
121
+ // =============================================================================
122
+ // CLI Command
123
+ // =============================================================================
124
+ export async function update(url, file) {
125
+ // Parse firmware file
126
+ const buffer = await fs.promises.readFile(file);
127
+ const bin = new Uint8Array(buffer);
128
+ const mcubootImages = parseMcubootImages(bin);
129
+ if (mcubootImages.length === 0) {
130
+ console.error('No valid MCUboot images found in file');
131
+ process.exit(1);
132
+ }
133
+ // Extract partition data
134
+ const images = mcubootImages.map((img) => {
135
+ const altronix = AltronixImage.fromMcubootImage(img);
136
+ return {
137
+ partition: altronix.partitionName,
138
+ image: altronix.signedImage
139
+ };
140
+ });
141
+ // Build UI items
142
+ const items = mcubootImages.map((img) => {
143
+ const altronix = AltronixImage.fromMcubootImage(img);
144
+ const { major, minor, revision, build } = altronix.version;
145
+ return {
146
+ partition: altronix.partitionName,
147
+ version: `${major}.${minor}.${revision}+${build}`,
148
+ size: altronix.signedImage.length
149
+ };
58
150
  });
151
+ // Render UI - it handles the upload internally
152
+ const renderer = render(React.createElement(UpdateUi, { baseUrl: url, items: items, images: images, onComplete: (error) => {
153
+ renderer.unmount();
154
+ if (error) {
155
+ console.error(`\nUpdate failed: ${error.message}`);
156
+ process.exit(1);
157
+ }
158
+ console.log('\nUpdate complete. Device is rebooting...');
159
+ } }));
160
+ await renderer.waitUntilExit();
59
161
  }
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { UploadImage } from './update.js';
3
+ /**
4
+ * Partition metadata for display
5
+ */
6
+ export interface UpdateItem {
7
+ partition: string;
8
+ version: string;
9
+ size: number;
10
+ }
11
+ /**
12
+ * Progress event from upload
13
+ */
14
+ export interface UpdateProgress {
15
+ partition: string;
16
+ phase: 'erase' | 'transfer' | 'complete';
17
+ current?: number;
18
+ total?: number;
19
+ }
20
+ interface UpdateUiProps {
21
+ baseUrl: string;
22
+ items: UpdateItem[];
23
+ images: UploadImage[];
24
+ onComplete: (error?: Error) => void;
25
+ }
26
+ export default function UpdateUi({ baseUrl, items, images, onComplete }: UpdateUiProps): React.JSX.Element;
27
+ export {};
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import useStdoutDimensions from './useStdoutDimensions.js';
4
+ import { uploadFirmware } from './update.js';
5
+ export default function UpdateUi({ baseUrl, items, images, onComplete }) {
6
+ const [col] = useStdoutDimensions();
7
+ const [state, setState] = useState(() => initState(items));
8
+ useEffect(() => {
9
+ uploadFirmware(baseUrl, images, (progress) => {
10
+ setState((prev) => updateState(prev, progress));
11
+ })
12
+ .then(() => onComplete())
13
+ .catch((e) => onComplete(e));
14
+ }, []);
15
+ const maxNameLen = Math.max(...items.map((i) => i.partition.length));
16
+ const barWidth = Math.min(col - maxNameLen - 25, 40);
17
+ return (React.createElement(Box, { flexDirection: "column" },
18
+ React.createElement(Box, { marginBottom: 1 },
19
+ React.createElement(Text, { bold: true }, "OTA Update")),
20
+ items.map((item) => {
21
+ const s = state[item.partition];
22
+ const percent = s && s.total > 0 ? Math.floor((100 * s.current) / s.total) : 0;
23
+ const filled = s && s.total > 0 ? Math.floor((barWidth * s.current) / s.total) : 0;
24
+ const empty = barWidth - filled;
25
+ return (React.createElement(Box, { key: item.partition },
26
+ React.createElement(Box, { width: maxNameLen + 1 },
27
+ React.createElement(Text, { bold: true }, item.partition.padEnd(maxNameLen))),
28
+ React.createElement(Box, { marginLeft: 1 },
29
+ React.createElement(Text, null, "["),
30
+ React.createElement(Text, { color: "green" }, '#'.repeat(filled)),
31
+ React.createElement(Text, { color: "gray" }, '-'.repeat(empty)),
32
+ React.createElement(Text, null, "]")),
33
+ React.createElement(Box, { marginLeft: 1, width: 5 },
34
+ React.createElement(Text, null,
35
+ percent.toString().padStart(3),
36
+ "%")),
37
+ React.createElement(Box, { marginLeft: 1 },
38
+ React.createElement(Text, { color: phaseColor(s?.phase) }, phaseText(s?.phase)))));
39
+ }),
40
+ React.createElement(Box, { marginTop: 1 },
41
+ React.createElement(Text, { dimColor: true },
42
+ formatSize(items.reduce((acc, i) => acc + i.size, 0)),
43
+ " total"))));
44
+ }
45
+ function initState(items) {
46
+ return items.reduce((acc, item) => ({
47
+ ...acc,
48
+ [item.partition]: {
49
+ phase: 'pending',
50
+ current: 0,
51
+ total: item.size
52
+ }
53
+ }), {});
54
+ }
55
+ function updateState(prev, progress) {
56
+ const current = prev[progress.partition];
57
+ if (!current)
58
+ return prev;
59
+ switch (progress.phase) {
60
+ case 'erase':
61
+ return {
62
+ ...prev,
63
+ [progress.partition]: { ...current, phase: 'erase' }
64
+ };
65
+ case 'transfer':
66
+ return {
67
+ ...prev,
68
+ [progress.partition]: {
69
+ ...current,
70
+ phase: 'transfer',
71
+ current: progress.current ?? current.current
72
+ }
73
+ };
74
+ case 'complete':
75
+ return {
76
+ ...prev,
77
+ [progress.partition]: {
78
+ ...current,
79
+ phase: 'done',
80
+ current: current.total
81
+ }
82
+ };
83
+ default:
84
+ return prev;
85
+ }
86
+ }
87
+ function phaseColor(phase) {
88
+ switch (phase) {
89
+ case 'erase':
90
+ return 'yellow';
91
+ case 'transfer':
92
+ return 'cyan';
93
+ case 'done':
94
+ return 'green';
95
+ default:
96
+ return 'gray';
97
+ }
98
+ }
99
+ function phaseText(phase) {
100
+ switch (phase) {
101
+ case 'pending':
102
+ return 'waiting';
103
+ case 'erase':
104
+ return 'erasing';
105
+ case 'transfer':
106
+ return 'uploading';
107
+ case 'done':
108
+ return 'OK';
109
+ default:
110
+ return '';
111
+ }
112
+ }
113
+ function formatSize(bytes) {
114
+ if (bytes < 1024)
115
+ return `${bytes} B`;
116
+ if (bytes < 1024 * 1024)
117
+ return `${(bytes / 1024).toFixed(1)} KB`;
118
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
119
+ }
@@ -0,0 +1 @@
1
+ export default function useStdoutDimensions(): [number, number];
@@ -0,0 +1,17 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useStdout } from 'ink';
3
+ export default function useStdoutDimensions() {
4
+ const { stdout } = useStdout();
5
+ const [dimensions, setDimensions] = useState([
6
+ stdout.columns,
7
+ stdout.rows
8
+ ]);
9
+ useEffect(() => {
10
+ const handler = () => setDimensions([stdout.columns, stdout.rows]);
11
+ stdout.on('resize', handler);
12
+ return () => {
13
+ stdout.off('resize', handler);
14
+ };
15
+ }, [stdout]);
16
+ return dimensions;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altronix/cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,35 +13,35 @@
13
13
  "dist"
14
14
  ],
15
15
  "dependencies": {
16
- "@altronix/zdk": "^0.9.0",
16
+ "@altronix/webtobin": "^0.8.0",
17
+ "@inquirer/confirm": "^3.2.0",
18
+ "ajv": "^8.17.1",
19
+ "cbor2": "^2.2.1",
17
20
  "commander": "^10.0.1",
18
- "ink": "^4.1.0",
21
+ "dotenv": "^16.6.1",
22
+ "ink": "^4.4.1",
19
23
  "jsonc-parser": "^3.3.1",
20
- "react": "^18.2.0",
21
- "rxjs": "^7.8.1",
22
- "winston": "^3.13.0",
23
- "@altronix/build": "0.13.0"
24
- },
25
- "optionalDependencies": {
26
- "@altronix/device": "0.9.0"
24
+ "react": "^18.3.1",
25
+ "rxjs": "^7.8.2",
26
+ "winston": "^3.19.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@sindresorhus/tsconfig": "^3.0.1",
30
- "@types/react": "^18.0.32",
30
+ "@types/react": "^18.3.27",
31
31
  "@typescript-eslint/eslint-plugin": "~5.44.0",
32
32
  "@typescript-eslint/parser": "~5.44.0",
33
- "ava": "^5.2.0",
34
- "chalk": "^5.2.0",
33
+ "ava": "^5.3.1",
34
+ "chalk": "^5.6.2",
35
35
  "eslint": "~8.28.0",
36
36
  "eslint-config-prettier": "~8.5.0",
37
37
  "eslint-config-xo-react": "^0.27.0",
38
38
  "eslint-plugin-jest": "~27.1.7",
39
- "eslint-plugin-react": "^7.32.2",
40
- "eslint-plugin-react-hooks": "^4.6.0",
39
+ "eslint-plugin-react": "^7.37.5",
40
+ "eslint-plugin-react-hooks": "^4.6.2",
41
41
  "ink-testing-library": "^3.0.0",
42
- "prettier": "^2.8.7",
43
- "ts-node": "^10.9.1",
44
- "typescript": "^5.0.3",
42
+ "prettier": "^2.8.8",
43
+ "ts-node": "^10.9.2",
44
+ "typescript": "^5.9.3",
45
45
  "xo": "^0.53.1"
46
46
  },
47
47
  "ava": {