@thinremote/thinr-cli 1.1.0 → 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 (92) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +230 -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/concurrency.js +52 -0
  50. package/lib/config.js +5 -3
  51. package/lib/console.js +12 -4
  52. package/lib/constants.js +76 -0
  53. package/lib/dashboard/event-stream.js +239 -0
  54. package/lib/dashboard/metric-aggregate.js +64 -0
  55. package/lib/dashboard/metric-filter.js +39 -0
  56. package/lib/debug-log.js +93 -0
  57. package/lib/device-api.js +109 -147
  58. package/lib/devices.js +18 -21
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1343 -10
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +15 -3
  92. package/commands/product.js +0 -167
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-06-10
4
+
5
+ ### Added
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).
33
+ - `thinr product exec <productId> <command...>` — run a shell command
34
+ in parallel on every active device of a product, with bounded
35
+ concurrency (`-c, --concurrency`, default 10), per-device timeout
36
+ (`--timeout`, default 30 s), `--fail-fast`, asset-group filter
37
+ (`-g, --group`) and an `-a, --all` switch to include offline
38
+ devices. Live progress shows a single spinner with done/total
39
+ counters; the final render uses a `cli-table3` table per device
40
+ plus a compact `Output:` section and a one-line totals bar.
41
+ - `thinr_product_exec` MCP tool — the same fan-out as the CLI
42
+ subcommand, returning a consolidated text report so an AI agent
43
+ can roll out a change across a whole fleet in a single call
44
+ instead of looping `thinr_exec` per device.
45
+ - `lib/concurrency.js#runPool` — tiny helper used by both entry
46
+ points; keeps at most N workers in flight and returns a parallel
47
+ results array. `runPool` plus an internal try/catch in each worker
48
+ keeps fleet-wide errors localised per device.
49
+
50
+ ### Changed
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.
65
+ - `cli-table3` added as a direct dependency to render the
66
+ product-exec summary.
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
+
3
73
  ## [1.1.0] - 2026-04-16
4
74
 
5
75
  Major release: MCP server for AI clients, subcommand-first CLI, JSON
@@ -128,6 +198,7 @@ consistent error taxonomy across both interfaces.
128
198
  - Property listing and retrieval
129
199
  - Device resource listing and execution
130
200
 
201
+ [1.2.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.1.0...1.2.0
131
202
  [1.1.0]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.2...1.1.0
132
203
  [1.0.2]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.1...1.0.2
133
204
  [1.0.1]: https://github.com/Thin-Remote/thinr-cli/compare/1.0.0...1.0.1
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)
@@ -299,6 +301,107 @@ Options:
299
301
  Offline devices are skipped for `resource` — the server would reject
300
302
  the call anyway.
301
303
 
304
+ #### `exec`
305
+
306
+ Run a shell command in parallel across every active device of the
307
+ product and report a per-device status table plus each device's
308
+ output:
309
+
310
+ ```bash
311
+ thinr product exec <productId> <command...> [options]
312
+ ```
313
+
314
+ Options:
315
+
316
+ - `-j, --json`: Output as JSON. Emits a single envelope with a
317
+ `summary` block and a `results[]` array carrying stdout, stderr,
318
+ exit code, `timedOut` flag and per-device duration.
319
+ - `-c, --concurrency <n>`: Max parallel executions (default: 10)
320
+ - `--timeout <seconds>`: Per-device timeout (default: 30)
321
+ - `--fail-fast`: Stop dequeueing new devices as soon as one fails
322
+ - `-g, --group <group>`: Filter devices by asset group
323
+ - `-a, --all`: Include offline devices (default: only active)
324
+
325
+ Examples:
326
+
327
+ ```bash
328
+ # hostname on every active device
329
+ thinr product exec my-product hostname
330
+
331
+ # apt upgrade across the fleet with 5 parallel slots and a long timeout
332
+ thinr product exec my-product "apt-get -y upgrade" -c 5 --timeout 600
333
+
334
+ # target only a subset of the product
335
+ thinr product exec my-product "systemctl restart agent" -g staging
336
+ ```
337
+
338
+ Also exposed as an MCP tool (`thinr_product_exec`) with the same
339
+ parameters, so agents can drive a fleet operation with a single call
340
+ instead of looping `thinr_exec` per device.
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
+
302
405
  ### `thinr profile`
303
406
 
304
407
  Manage the profile store (see [Profiles and multi-account
@@ -346,25 +449,86 @@ passed through (via the global flag) to impersonate another account.
346
449
 
347
450
  ### Tool catalog
348
451
 
349
- Roughly 28 tools, grouped by capability. Every tool accepts optional
350
- `device`, `user`, and `profile` arguments, so a single session can
351
- target any device/account/environment without restart.
352
-
353
- | Area | Tools |
354
- | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
355
- | Discovery | `thinr_devices` (with optional `query` for regex filtering), `thinr_device_info`, `thinr_profiles` |
356
- | Shell | `thinr_exec` (buffered), with streaming stdout/stderr |
357
- | Filesystem | `thinr_read`, `thinr_write`, `thinr_ls`, `thinr_mkdir`, `thinr_delete`, `thinr_move` |
358
- | Resources | `thinr_resource_list` (with `in`/`out` schemas), `thinr_resource_call` |
359
- | Properties | `thinr_property_get`, `thinr_property_set` |
360
- | Scripts (device) | `thinr_script_list`, `thinr_script_write`, `thinr_script_delete` |
361
- | Monitoring and update | `thinr_monitoring`, `thinr_update` |
362
- | Products | `thinr_products`, `thinr_product_delete`, `thinr_device_set_product` |
363
- | 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` |
364
469
 
365
470
  Full input/output schemas are published via standard MCP
366
471
  `list_tools`; the client will show them when you connect.
367
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
+
368
532
  ### Per-call controls
369
533
 
370
534
  The server starts with no per-session pin: every tool call must say
@@ -447,7 +611,57 @@ npm link # makes `thinr` available on $PATH
447
611
  node bin/thinr.js # or run directly without linking
448
612
  ```
449
613
 
450
- 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.
451
665
 
452
666
  ## License
453
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
+ }