@thinremote/thinr-cli 1.2.0-alpha.1 → 1.2.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
package/CHANGELOG.md CHANGED
@@ -1,9 +1,35 @@
1
1
  # Changelog
2
2
 
3
- ## [1.2.0] - 2026-04-16
3
+ ## [1.2.0] - 2026-06-10
4
4
 
5
5
  ### Added
6
6
 
7
+ - `thinr` with no arguments now launches the interactive dashboard
8
+ directly when stdout is a TTY (and a configuration exists), so the
9
+ fleet view is one keystroke away. Non-TTY contexts (pipes, scripts)
10
+ fall back to `--help`. First-time users still get the auth flow,
11
+ then drop straight into the dashboard.
12
+ - TUI dashboard: alarms panel on the overview tab, with rule- and
13
+ instance-level views, severity colouring and live updates over the
14
+ same event stream the rest of the dashboard already uses.
15
+ - `thinr device delete <deviceId>` — remove a device record from the
16
+ platform. Interactive `confirm` by default, `--yes` to skip in
17
+ scripts, idempotent on a missing device. Useful after re-provisioning
18
+ an agent under a new hostname leaves the old `ip-…` record offline.
19
+ - MCP: `thinr_device_delete` — same operation for AI clients.
20
+ - MCP: `thinr_push` and `thinr_pull` — upload a local file to a
21
+ device and download a remote file to local disk, respectively.
22
+ Rounds out filesystem parity with the CLI so an agent can move
23
+ binary or large payloads that don't fit inline in `thinr_write`.
24
+ - MCP: alarms tools (`thinr_alarm_instances`,
25
+ `thinr_alarm_instance_get`, `thinr_alarm_instance_stats`,
26
+ `thinr_alarm_instance_update`, `thinr_alarm_instance_delete`,
27
+ `thinr_alarm_rules`, `thinr_alarm_rule_read`, `thinr_alarm_rule_write`,
28
+ `thinr_alarm_rule_delete`) — list, inspect and manage alarm
29
+ instances and rules from AI clients, with state/severity parsing
30
+ shared with the dashboard.
31
+ - MCP: product profile config tools (buckets, properties, API
32
+ resources) and access-token tools (user-level and device-level).
7
33
  - `thinr product exec <productId> <command...>` — run a shell command
8
34
  in parallel on every active device of a product, with bounded
9
35
  concurrency (`-c, --concurrency`, default 10), per-device timeout
@@ -23,9 +49,27 @@
23
49
 
24
50
  ### Changed
25
51
 
52
+ - Unified filesystem verbs across CLI, MCP and playbooks so the same
53
+ eight nouns mean the same thing everywhere: `read` / `write`
54
+ (inline content) · `push` / `pull` (local ↔ remote file transfer)
55
+ · `ls` · `mkdir` · `rm` · `mv`.
56
+ - CLI: `thinr device cat` is now `thinr device read` (`cat` kept as
57
+ a hidden alias). New `thinr device write <deviceId> <path>
58
+ [content]` accepts the payload as an argument or on stdin.
59
+ - MCP: `thinr_delete` renamed to `thinr_rm`, `thinr_move` renamed to
60
+ `thinr_mv`. No backwards-compatibility aliases.
61
+ - Playbook actions: `delete` → `rm`, `move` → `mv`. The `write`
62
+ action now only accepts inline `content`; use the new `push`
63
+ action to upload a local file (`source`, `destination`). New
64
+ `pull` action downloads a remote file to the local disk.
26
65
  - `cli-table3` added as a direct dependency to render the
27
66
  product-exec summary.
28
67
 
68
+ ### Removed
69
+
70
+ - `thinr dashboard` subcommand. The dashboard is now the default for
71
+ bare `thinr`; the explicit subcommand had not shipped to anyone yet.
72
+
29
73
  ## [1.1.0] - 2026-04-16
30
74
 
31
75
  Major release: MCP server for AI clients, subcommand-first CLI, JSON
package/README.md CHANGED
@@ -30,11 +30,13 @@ task at hand.
30
30
  - [JSON output](#json-output)
31
31
  - [`thinr device <action> [<deviceId>]`](#thinr-device-action-deviceid)
32
32
  - [`thinr product <action> <productId>`](#thinr-product-action-productid)
33
+ - [`thinr playbook <action>`](#thinr-playbook-action)
33
34
  - [`thinr profile`](#thinr-profile)
34
35
  - [`thinr logout`](#thinr-logout)
35
36
  - [Part 2 — MCP server](#part-2--mcp-server)
36
37
  - [Starting the server](#starting-the-server)
37
38
  - [Tool catalog](#tool-catalog)
39
+ - [Authoring playbooks from natural language](#authoring-playbooks-from-natural-language)
38
40
  - [Per-call controls](#per-call-controls)
39
41
  - [Integrating with Claude Code](#integrating-with-claude-code)
40
42
  - [Profiles and multi-account use](#profiles-and-multi-account-use)
@@ -337,6 +339,69 @@ Also exposed as an MCP tool (`thinr_product_exec`) with the same
337
339
  parameters, so agents can drive a fleet operation with a single call
338
340
  instead of looping `thinr_exec` per device.
339
341
 
342
+ #### `playbook`
343
+
344
+ Manage playbooks stored on a product and run them against one device
345
+ or the whole fleet:
346
+
347
+ ```bash
348
+ thinr product playbook list <productId> [--json]
349
+ thinr product playbook upload <productId> <name> [file] [--description <text>] [--skip-validation] [--json]
350
+ thinr product playbook download <productId> <name> [-o <file>] [--json]
351
+ thinr product playbook delete <productId> <name> [--json]
352
+ thinr product playbook run <productId> <name> (--device <id> | --fleet) [options]
353
+ ```
354
+
355
+ `upload` reads the YAML from a file, or from stdin when the path is
356
+ omitted or `-`. The document is validated against the playbook schema
357
+ before being written to the product storage; an invalid document is
358
+ rejected with a descriptive error and nothing is uploaded. Pass
359
+ `--skip-validation` only to work around a schema-runtime gap (do not
360
+ use it to bypass real errors).
361
+
362
+ `run` targets either a single device (`--device <id>`) or the whole
363
+ fleet (`--fleet`, progressive batch rollout):
364
+
365
+ - `-v, --var <key=value>` — override an overridable playbook variable
366
+ (repeatable; values are coerced to the declared type).
367
+ - `--vars-file <path>` — load variable overrides from a YAML/JSON file.
368
+ - `--dry-run` — print the resolved plan without contacting any device.
369
+ - `--check` — contact devices read-only and report what each step would
370
+ change.
371
+ - `--yes` / `-y` — skip the interactive confirmation prompt.
372
+
373
+ Fleet-only options:
374
+
375
+ - `--batch-size <n>` — devices attempted in parallel per batch
376
+ (default: 5).
377
+ - `--failure-threshold <p>` — abort rollout when cumulative failure
378
+ rate reaches P% (default: 25).
379
+ - `-g, --group <id>` — restrict to an asset group.
380
+ - `--filter <key=value>` — extra server-side filter (repeatable).
381
+ - `--include-offline` — attempt offline devices too (default: active
382
+ only).
383
+
384
+ Each fleet rollout writes a persistent JSON report to
385
+ `playbooks/runs/<timestamp>-<name>-<user>.json` in the product's file
386
+ storage, capturing resolved variables, per-device outcomes, and any
387
+ abort reason.
388
+
389
+ ### `thinr playbook <action>`
390
+
391
+ Run or validate a local playbook YAML file (not stored on a product).
392
+ Useful for one-shot or ad-hoc runs:
393
+
394
+ ```bash
395
+ thinr playbook validate <file>
396
+ thinr playbook run <file> [--dry-run] [--check] [options]
397
+ ```
398
+
399
+ `validate` parses the YAML and reports any schema errors. `run`
400
+ resolves the target block declared inside the playbook and executes
401
+ it; the same variable-override flags as `thinr product playbook run`
402
+ apply. For playbooks that should be reusable across users, upload
403
+ them to their product with `thinr product playbook upload` instead.
404
+
340
405
  ### `thinr profile`
341
406
 
342
407
  Manage the profile store (see [Profiles and multi-account
@@ -384,25 +449,86 @@ passed through (via the global flag) to impersonate another account.
384
449
 
385
450
  ### Tool catalog
386
451
 
387
- Roughly 28 tools, grouped by capability. Every tool accepts optional
388
- `device`, `user`, and `profile` arguments, so a single session can
389
- target any device/account/environment without restart.
390
-
391
- | Area | Tools |
392
- | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
393
- | Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
394
- | Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
395
- | Filesystem | `thinr_read`, `thinr_write`, `thinr_ls`, `thinr_mkdir`, `thinr_delete`, `thinr_move` |
396
- | Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
397
- | Properties | `thinr_property_get`, `thinr_property_set` |
398
- | Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
399
- | Monitoring and update | `thinr_monitoring`, `thinr_update` |
400
- | Products | `thinr_products`, `thinr_product_delete`, `thinr_device_set_product` |
401
- | Product scripts | `thinr_product_script_list`, `thinr_product_script_read`, `thinr_product_script_write`, `thinr_product_script_delete` |
452
+ Grouped by capability. Every tool accepts optional `device`, `user`,
453
+ and `profile` arguments, so a single session can target any
454
+ device/account/environment without restart.
455
+
456
+ | Area | Tools |
457
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
458
+ | Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
459
+ | Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
460
+ | Filesystem | `thinr_read`, `thinr_write`, `thinr_push`, `thinr_pull`, `thinr_ls`, `thinr_mkdir`, `thinr_rm`, `thinr_mv` |
461
+ | Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
462
+ | Properties | `thinr_property_get`, `thinr_property_set` |
463
+ | Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
464
+ | Monitoring and update | `thinr_monitoring`, `thinr_bucket_read`, `thinr_update` |
465
+ | Products | `thinr_products`, `thinr_product_delete`, `thinr_device_set_product`, `thinr_product_exec`, `thinr_product_write` |
466
+ | Product scripts | `thinr_product_script_list`, `thinr_product_script_read`, `thinr_product_script_write`, `thinr_product_script_delete` |
467
+ | Playbooks (authoring) | `thinr_playbook_schema`, `thinr_playbook_validate`, `thinr_playbook_run` (ad-hoc) |
468
+ | Product playbooks | `thinr_product_playbook_list`, `thinr_product_playbook_read`, `thinr_product_playbook_write`, `thinr_product_playbook_run` (single device), `thinr_product_playbook_rollout` (fleet), `thinr_product_playbook_delete` |
402
469
 
403
470
  Full input/output schemas are published via standard MCP
404
471
  `list_tools`; the client will show them when you connect.
405
472
 
473
+ ### Authoring playbooks from natural language
474
+
475
+ Playbooks are declarative YAML documents — steps like `exec`, `write`,
476
+ `install_package`, `service`, plus a `target` block and a `vars`
477
+ block — that the runner applies to a device or a fleet. With the MCP
478
+ server connected, an AI assistant can turn a natural-language request
479
+ into a validated playbook and store it on the relevant product
480
+ without the user ever writing YAML by hand.
481
+
482
+ The intended authoring loop is:
483
+
484
+ 1. **Fetch the schema.** Call `thinr_playbook_schema` once at the
485
+ start of the session to get the authoritative list of top-level
486
+ fields, target block, step actions with their parameters, and the
487
+ two accepted forms of the `vars` block (plain and extended).
488
+ 2. **Draft the YAML.** Translate the user's request into a playbook
489
+ draft. Use the extended `vars` form
490
+ (`default` / `description` / `type` / `overridable` / `required`)
491
+ whenever the user hints at knobs the caller may need to tune at
492
+ run time.
493
+ 3. **Validate and iterate.** Call `thinr_playbook_validate` with the
494
+ draft. On failure, read the consolidated error message, fix the
495
+ YAML, and call validate again. Never skip this step.
496
+ 4. **Upload.** Once validation is clean, call
497
+ `thinr_product_playbook_write` with the product id, a short name
498
+ (letters / digits / underscore / dash), and the validated YAML.
499
+ The tool re-validates server-side; never pass `skip_validation` to
500
+ mask a real error.
501
+ 5. **Pre-flight on one device.** Call
502
+ `thinr_product_playbook_run` with a single `device` (plus
503
+ `dry_run: true` or `check: true` for a read-only preview) to
504
+ confirm the steps behave as expected.
505
+ 6. **Roll out.** Finally, `thinr_product_playbook_rollout` rolls the
506
+ playbook across the fleet in batches with a failure-rate kill
507
+ switch, and writes a persistent report to `playbooks/runs/` in the
508
+ product file storage.
509
+
510
+ Example prompt the user can give the assistant:
511
+
512
+ > "On product `edge-gateways`, create a playbook called
513
+ > `nginx-upgrade` that updates the nginx package, renders
514
+ > `/etc/nginx/nginx.conf` from a template, and restarts nginx only
515
+ > if the config check passes. Make `worker_connections` an
516
+ > overridable integer variable, default 1024. Then try it on
517
+ > `edge-gw-17` in check mode."
518
+
519
+ A well-behaved assistant translates this into:
520
+
521
+ - `thinr_playbook_schema` → read the authoring contract.
522
+ - Draft YAML → `thinr_playbook_validate` until clean.
523
+ - `thinr_product_playbook_write` with `product="edge-gateways"`,
524
+ `name="nginx-upgrade"`, and the validated content.
525
+ - `thinr_product_playbook_run` with `device="edge-gw-17"` and
526
+ `check: true` to preview what would change.
527
+
528
+ After that a second message like "looks good, roll it out to staging"
529
+ maps to `thinr_product_playbook_rollout` with the matching
530
+ `group` / `filter` and a conservative `batch_size`.
531
+
406
532
  ### Per-call controls
407
533
 
408
534
  The server starts with no per-session pin: every tool call must say
@@ -485,7 +611,57 @@ npm link # makes `thinr` available on $PATH
485
611
  node bin/thinr.js # or run directly without linking
486
612
  ```
487
613
 
488
- There are no automated tests yet — contributions welcome.
614
+ Unit tests live under `tests/` and run offline:
615
+
616
+ ```bash
617
+ npm test
618
+ ```
619
+
620
+ ### Integration tests
621
+
622
+ The product-playbook end-to-end script at
623
+ [`tests/integration/playbook-e2e.js`](tests/integration/playbook-e2e.js)
624
+ drives a real ThinRemote cloud through the full playbook lifecycle
625
+ (create product → upload flat/extended/internal playbooks → list →
626
+ download → replace → reject invalid inputs → optionally run in
627
+ `check` mode against a device → delete). Cleanup of the temporary
628
+ product and its file storage is unconditional (`try / finally`),
629
+ even if the run fails halfway.
630
+
631
+ Prerequisites:
632
+
633
+ - An authenticated CLI profile (`thinr` once, or `THINR_PROFILE=<name>`
634
+ pointing at an existing entry in `~/.config/thinr-cli/config.json`).
635
+ - The account must be allowed to create products and file storages.
636
+
637
+ Invocations:
638
+
639
+ ```bash
640
+ # Offline checks only — parses fixtures, exercises resolveVarScope,
641
+ # asserts the dry-run plan. No network access. Safe in CI without
642
+ # credentials.
643
+ node tests/integration/playbook-e2e.js --offline
644
+
645
+ # Full flow against the active profile. Creates a throw-away product
646
+ # named "playbook-e2e-<timestamp>-<rand>" and deletes it on exit.
647
+ node tests/integration/playbook-e2e.js
648
+
649
+ # Add a test device to exercise `check`-mode execution with and
650
+ # without variable overrides. The device must belong to the active
651
+ # user; it does NOT need to be bound to the temporary product.
652
+ THINR_E2E_DEVICE=<device-id> node tests/integration/playbook-e2e.js
653
+ ```
654
+
655
+ The script only touches resources it creates itself (identified by
656
+ the `playbook-e2e-` product-id prefix). If a run is interrupted
657
+ before the `finally` cleanup executes — e.g. the process is killed
658
+ — list leftovers with `thinr product list --json` and drop each one
659
+ through the MCP `thinr_product_delete` tool (from an AI client) or
660
+ via a Node one-liner reusing `deleteProductWithStorage` from
661
+ `lib/product.js`.
662
+
663
+ No credentials or tokens are ever written to the repository; the
664
+ script reads them from the standard CLI profile store.
489
665
 
490
666
  ## License
491
667
 
package/bin/thinr.js CHANGED
@@ -4,9 +4,8 @@ import { readFileSync } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { dirname, resolve } from 'path';
6
6
  import { Command } from 'commander';
7
- import chalk from 'chalk';
8
7
  import figlet from 'figlet';
9
- import ora from 'ora';
8
+ import { accent, info } from '../lib/format.js';
10
9
  import { configExists, readConfig, setActiveProfile } from '../lib/config.js';
11
10
  import { detectJsonModeFromArgv } from '../lib/output.js';
12
11
 
@@ -34,22 +33,23 @@ for (let i = 2; i < process.argv.length; i++) {
34
33
  detectJsonModeFromArgv();
35
34
  import { authenticate } from '../lib/auth.js';
36
35
  import { deviceCommand } from '../commands/device/index.js';
36
+ import { fleetCommand } from '../commands/fleet/index.js';
37
37
  import { logoutCommand } from '../commands/logout.js';
38
- import { productCommand } from '../commands/product.js';
38
+ import { playbookCommand } from '../commands/playbook/index.js';
39
+ import { productCommand } from '../commands/product/index.js';
39
40
  import { profileCommand } from '../commands/profile.js';
41
+ import { runDashboard } from '../commands/dashboard.js';
40
42
  import { setBaseURL } from '../lib/api.js';
41
43
  import { startMCPServer } from '../lib/mcp/server.js';
42
44
 
43
- // Function to display the banner
44
45
  function displayBanner() {
45
- console.log(chalk.cyan(figlet.textSync('thinr-cli', { horizontalLayout: 'full' })));
46
- console.log(chalk.blue('Thin Remote CLI - Remote management for IoT devices'));
46
+ console.log(accent(figlet.textSync('thinr-cli', { horizontalLayout: 'full' })));
47
+ console.log(info('Thin Remote CLI - Remote management for IoT devices'));
47
48
  console.log();
48
49
  }
49
50
 
50
51
  const program = new Command();
51
52
 
52
- // Set up CLI information
53
53
  program
54
54
  .name('thinr')
55
55
  .description('CLI for ThinRemote - Remote management for IoT devices')
@@ -57,14 +57,16 @@ program
57
57
  .option('-u, --user <username>', 'API user override (admin impersonation)')
58
58
  .option('--profile <name>', 'Configuration profile to use (defaults to the saved default)');
59
59
 
60
- // Register commands
61
60
  deviceCommand(program);
61
+ fleetCommand(program);
62
62
  productCommand(program);
63
+ playbookCommand(program);
63
64
  profileCommand(program);
64
65
  logoutCommand(program);
65
66
 
66
67
  program
67
68
  .command('mcp')
69
+ .helpGroup('Integration:')
68
70
  .description('Start the MCP server (stdio) for AI tool integration')
69
71
  .action(async () => {
70
72
  try {
@@ -91,27 +93,24 @@ if (configExists()) {
91
93
  }
92
94
  }
93
95
 
94
- // Handle no command (just "thinr")
96
+ // Handle no command (just "thinr"): launch the dashboard when running
97
+ // interactively. Non-TTY contexts (pipes, scripts, `thinr | cat`) fall
98
+ // back to `--help` so they get something useful instead of an Ink crash.
95
99
  if (process.argv.length <= 2) {
96
- const spinner = ora('Checking configuration...').start();
97
-
98
- try {
100
+ if (!process.stdout.isTTY) {
101
+ program.help();
102
+ } else {
99
103
  if (!configExists()) {
100
- // Display banner only if configuration doesn't exist
101
104
  displayBanner();
102
- spinner.succeed('No configuration found. Starting setup...');
103
- await authenticate();
104
- program.help();
105
- } else {
106
- spinner.succeed('Configuration found');
107
- // Show help if already configured, but don't display banner twice
108
- program.help();
105
+ try {
106
+ await authenticate();
107
+ } catch (error) {
108
+ console.error(`Configuration error: ${error.message}`);
109
+ process.exit(1);
110
+ }
109
111
  }
110
- } catch (error) {
111
- spinner.fail(`Configuration error: ${error.message}`);
112
- process.exit(1);
112
+ await runDashboard();
113
113
  }
114
- spinner.succeed('Configuration found');
115
114
  } else {
116
115
  // Parse arguments - don't show banner for regular commands
117
116
  program.parse(process.argv);
@@ -0,0 +1,154 @@
1
+ // @ts-check
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { configExists } from '../lib/config.js';
4
+ import {
5
+ setJsonMode,
6
+ isJsonMode,
7
+ printOk,
8
+ printErr,
9
+ createSpinner,
10
+ classifyError,
11
+ } from '../lib/output.js';
12
+
13
+ export function ensureConfigured() {
14
+ if (!configExists()) {
15
+ printErr('Not configured. Run thinr without parameters to set up.', {
16
+ code: 'not_configured',
17
+ });
18
+ }
19
+ }
20
+
21
+ export function applyJsonFlag(opts) {
22
+ if (opts.json) setJsonMode(true);
23
+ }
24
+
25
+ // Walks up to the root program so subcommands can read `--user` regardless
26
+ // of nesting depth.
27
+ export function getGlobalUser(cmd) {
28
+ let root = cmd;
29
+ while (root.parent) root = root.parent;
30
+ return root.opts().user || null;
31
+ }
32
+
33
+ export function parsePositiveInt(label) {
34
+ return (value) => {
35
+ const n = Number(value);
36
+ if (!Number.isInteger(n) || n <= 0) {
37
+ throw new InvalidArgumentError(`${label} must be a positive integer`);
38
+ }
39
+ return n;
40
+ };
41
+ }
42
+
43
+ // Collect repeatable `-i key=value` / `-v key=value` pairs into a single
44
+ // object. Uses Commander's InvalidArgumentError so a bad value prints a
45
+ // clean "error: option … argument is invalid" line instead of a stack trace.
46
+ export function collectKeyValue(value, previous = {}) {
47
+ const idx = value.indexOf('=');
48
+ if (idx === -1) throw new InvalidArgumentError('must be key=value');
49
+ return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
50
+ }
51
+
52
+ // Historical aliases kept so existing subcommands don't need renaming at
53
+ // the call sites. Same function, different names where it reads better.
54
+ export const collectInput = collectKeyValue;
55
+ export const collectVar = collectKeyValue;
56
+
57
+ // Resolve a dot-path against a nested object. Returns `undefined` as soon
58
+ // as any intermediate hop is missing — matching the previous inline
59
+ // `reduce((o, k) => o && o[k], obj)` behaviour across the CLI.
60
+ export function extractField(obj, path) {
61
+ if (!path) return obj;
62
+ return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
63
+ }
64
+
65
+ /**
66
+ * Live counter for fan-out spinners. Wraps an ora spinner and rewrites
67
+ * its text as work enters/leaves the pool and completes. Use it to avoid
68
+ * reimplementing the `done/in flight` bookkeeping in every fan-out
69
+ * command (product exec, product fs, product push, playbook run…).
70
+ *
71
+ * const progress = new ProgressSpinner(spinner, devices.length,
72
+ * ({ done, total, inFlight }) =>
73
+ * `Running on ${total} devices — ${done}/${total} done · ${inFlight} in flight`);
74
+ * progress.render();
75
+ * // inside worker:
76
+ * progress.startItem();
77
+ * // …
78
+ * progress.finishItem();
79
+ */
80
+ export class ProgressSpinner {
81
+ constructor(spinner, total, formatText) {
82
+ this.spinner = spinner;
83
+ this.total = total;
84
+ this.formatText = formatText;
85
+ this.done = 0;
86
+ this.inFlight = 0;
87
+ }
88
+
89
+ render() {
90
+ this.spinner.text = this.formatText({
91
+ done: this.done,
92
+ total: this.total,
93
+ inFlight: this.inFlight,
94
+ });
95
+ }
96
+
97
+ startItem() {
98
+ this.inFlight++;
99
+ this.render();
100
+ }
101
+
102
+ finishItem() {
103
+ this.inFlight--;
104
+ this.done++;
105
+ this.render();
106
+ }
107
+
108
+ stop() {
109
+ this.spinner.stop();
110
+ }
111
+ }
112
+
113
+ /**
114
+ * One-shot command skeleton shared by read-only device subcommands
115
+ * (status, list, property, resource…). Handles the spinner start,
116
+ * try/catch, `classifyError`+`printErr` on failure, and the
117
+ * `isJsonMode() ? printOk : console.log` default for the success path.
118
+ *
119
+ * Shape:
120
+ * await runDeviceCommand({
121
+ * start: 'Fetching devices…', // spinner label
122
+ * fn: () => getDevices(filter), // async unit of work
123
+ * success: n => `Found ${n} device(s)`,// string | (result) => string | null
124
+ * failure: 'Failed to list devices', // string on failure
125
+ * onSuccess: (result) => { … }, // optional full render override
126
+ * });
127
+ *
128
+ * When `success` is `null` the spinner just stops (no checkmark), which
129
+ * matches the idiom used by commands whose output block already speaks
130
+ * for the "found" state. When `onSuccess` is omitted the result is
131
+ * printed verbatim (`printOk` in JSON mode, `console.log` otherwise).
132
+ */
133
+ export async function runDeviceCommand({ start, fn, success, failure, onSuccess }) {
134
+ const spinner = createSpinner(start).start();
135
+ try {
136
+ const result = await fn();
137
+ if (success === null) {
138
+ spinner.stop();
139
+ } else {
140
+ spinner.succeed(typeof success === 'function' ? success(result) : success);
141
+ }
142
+ if (onSuccess) {
143
+ await onSuccess(result);
144
+ } else if (isJsonMode()) {
145
+ printOk(result);
146
+ } else {
147
+ console.log(result);
148
+ }
149
+ } catch (error) {
150
+ spinner.fail(typeof failure === 'function' ? failure(error) : failure);
151
+ const { message, code } = classifyError(error);
152
+ printErr(message, { code });
153
+ }
154
+ }
@@ -0,0 +1,17 @@
1
+ // @ts-check
2
+ import { ensureConfigured } from './device/_shared.js';
3
+
4
+ export async function runDashboard() {
5
+ ensureConfigured();
6
+ // React's package entry switches between react.development.js and
7
+ // react.production.js based on NODE_ENV at first load. The dev
8
+ // build has a known leak under frequent setState (facebook/react
9
+ // #34770) — heap grows from ~100MB to >1GB in minutes when WS
10
+ // events drive the UI. Force production unless the user has
11
+ // explicitly asked for the dev build (e.g. while debugging).
12
+ // Set BEFORE the dynamic import so the bundle resolves React with
13
+ // the correct env baked in.
14
+ if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production';
15
+ const { run } = await import('../dist/dashboard.js');
16
+ await run();
17
+ }
@@ -1,48 +1,31 @@
1
1
  // @ts-check
2
- import chalk from 'chalk';
3
- import { InvalidArgumentError } from 'commander';
4
- import { configExists } from '../../lib/config.js';
5
- import { setJsonMode, classifyError, printErr } from '../../lib/output.js';
2
+ import { classifyError } from '../../lib/output.js';
3
+ import { error as errorStyle } from '../../lib/format.js';
6
4
 
7
- export function ensureConfigured() {
8
- if (!configExists()) {
9
- printErr('Not configured. Run thinr without parameters to set up.', {
10
- code: 'not_configured',
11
- });
12
- }
13
- }
14
-
15
- export function applyJsonFlag(opts) {
16
- if (opts.json) setJsonMode(true);
17
- }
18
-
19
- // Walks up to the root program so subcommands can read `--user` regardless
20
- // of nesting depth.
21
- export function getGlobalUser(cmd) {
22
- let root = cmd;
23
- while (root.parent) root = root.parent;
24
- return root.opts().user || null;
25
- }
26
-
27
- // Collect repeatable `-i key=value` pairs into a single object. Uses
28
- // Commander's InvalidArgumentError so a bad value prints a clean
29
- // "error: option … argument is invalid" line instead of a stack trace.
30
- export function collectInput(value, previous = {}) {
31
- const idx = value.indexOf('=');
32
- if (idx === -1) throw new InvalidArgumentError('must be key=value');
33
- return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
34
- }
5
+ export {
6
+ ensureConfigured,
7
+ applyJsonFlag,
8
+ getGlobalUser,
9
+ parsePositiveInt,
10
+ collectKeyValue,
11
+ collectInput,
12
+ collectVar,
13
+ extractField,
14
+ ProgressSpinner,
15
+ runDeviceCommand,
16
+ } from '../_shared.js';
35
17
 
36
18
  // Wrapper for the proxy / console handlers, which return a Promise that
37
19
  // resolves with an exit code or rejects with a tagged Error (instead of
38
- // calling process.exit themselves).
20
+ // calling process.exit themselves). Kept local to `device/` because only
21
+ // the interactive device subcommands use it.
39
22
  export const runInteractive = async (fn) => {
40
23
  try {
41
24
  const exitCode = await fn();
42
25
  process.exit(exitCode ?? 0);
43
26
  } catch (error) {
44
27
  const { message, code } = classifyError(error);
45
- console.error(chalk.red(`Error [${code}]: ${message}`));
28
+ console.error(errorStyle(`Error [${code}]: ${message}`));
46
29
  process.exit(1);
47
30
  }
48
31
  };
@@ -5,6 +5,7 @@ import { ensureConfigured, runInteractive } from './_shared.js';
5
5
  export function registerConsoleCommand(device) {
6
6
  device
7
7
  .command('console <deviceId>')
8
+ .helpGroup('Remote access:')
8
9
  .description('Open an interactive terminal on the device')
9
10
  .action((deviceId) =>
10
11
  runInteractive(() => {