@andrian.yablonskyy/thub-agent 1.1.5 → 1.1.6

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.
package/README.md CHANGED
@@ -32,6 +32,7 @@ thub key rotate # a new key; the old one stops at once (saved
32
32
  thub run [options] Submit a test job and follow its log
33
33
  thub status <jobId> Show status; follow log if running, verdict, test counts and artifacts if done
34
34
  thub cancel <jobId> Cancel a job
35
+ thub power on|off|reset <jobId> [--delay <sec>] [--port <n>] Switch the USB power of the Client running your job (owner only)
35
36
  thub resources List resources and their status
36
37
  thub jobs [--mine] [--state <s>] List recent jobs (a cli token: only its own; a ci token: all, or its own with --mine)
37
38
  thub config set <key> <value> Save coordinator URL / key / default user locally
@@ -58,6 +59,9 @@ Key options for `thub run`. **On the Client** names the environment variable the
58
59
  | `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. | `JOB_PRIORITY` |
59
60
  | `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. | `JOB_META_<KEY>` (also `THUB_META_<KEY>`) |
60
61
  | `--dry-run` | Exercise the full pipeline without the Client executing anything for real. | — (the command doesn't run) |
62
+ | `--power-on-start on\|off\|reset` | HW only: switch the Client's USB power ports (uhubctl) before the DUT is prepared; a failure ends the job in `ERROR`. | `JOB_POWER_ON_START` |
63
+ | `--power-on-end on\|off\|reset` | HW only: switch them when the job ends, whatever its verdict. | `JOB_POWER_ON_END` |
64
+ | `--power-reset-delay <sec>` | How long a `reset` keeps the power off, 0–60 s; default `1`. | `JOB_POWER_RESET_DELAY` |
61
65
  | `--wait` | Do not detach on job end; exit with the job's verdict code (used in CI). | — (Agent only) |
62
66
  | `--detach` | Print the job id and exit immediately. | — (Agent only) |
63
67
  | `--json` | Machine-readable output. | — (Agent only) |
@@ -77,6 +81,45 @@ Exit codes make the Agent usable as a CI step:
77
81
  | `5` | `thub status <jobId> --json`: the job is still queued or running |
78
82
  | `130` | Detached with Ctrl-C (job still running) |
79
83
 
84
+ ### USB port power (HW)
85
+
86
+ On a Client with USB power ports (uhubctl, see the main README §8.7), a job can cold-boot its board and switch it off at the end. Its owner can also power-cycle it while it runs, without canceling the job:
87
+
88
+ ```bash
89
+ thub run --type hw --label board:nucleo-f401re \
90
+ --download-file "$IMAGE_URL" --command './ci/flash-and-test.sh' \
91
+ --power-on-start reset --power-reset-delay 2 --power-on-end off --wait
92
+
93
+ thub power reset M-00131 # every port, 1 s off (the default delay)
94
+ thub power reset M-00131 --port 2 --delay 3 # the Client's second port, 3 s off
95
+ thub power off M-00131
96
+ ```
97
+
98
+ `thub power` works only for your own job, while it's `PREPARING` or `RUNNING`; otherwise the Coordinator says why it refused. The job's log shows each action:
99
+
100
+ ```
101
+ [runner] USB power reset (port 2) requested by alice (thub power)
102
+ [runner] USB power reset: 1-1.4:3 (off 3 s)
103
+ ```
104
+
105
+ ### Smart sockets, PDUs and other lab devices
106
+
107
+ Boards on a smart socket (Shelly, Tasmota, Kasa, Home Assistant) or a PDU outlet are switched by the job itself. Its `--command`, or a script from the repository it clones, calls the device with `curl`, `snmpset` or the device's own CLI, with credentials passed as `--env`. Each Client instance names its bench's device in its environment (e.g. `BENCH_POWER=shelly:10.0.20.11`, set with a systemd drop-in):
108
+
109
+ ```bash
110
+ # inline: Shelly Gen2 off, 2 s, on, then the tests
111
+ thub run --type hw --label board:nucleo-f401re --env SHELLY_PASSWORD \
112
+ --command 'S="http://$BENCH_IP/rpc/Switch.Set?id=0"
113
+ curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=false" && sleep 2 &&
114
+ curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=true" && ./ci/test.sh' --wait
115
+
116
+ # from the test repository: ci/run.sh resets the bench's socket or PDU outlet, tests, and always powers it off
117
+ thub run --type hw --env GH_TOKEN --env POWER_PASSWORD \
118
+ --command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@github.com/yourorg/firmware-tests.git" src && cd src && exec ci/run.sh' --wait
119
+ ```
120
+
121
+ Use `exec` so a canceled job's `SIGTERM` reaches the script's trap. The full `ci/power.sh` and `ci/run.sh` examples (Shelly, Tasmota, Home Assistant, Kasa, APC PDU over SNMP) are in the main README, §8.8, and in the dashboard's Help.
122
+
80
123
  ## Examples
81
124
 
82
125
  **Run a task** — download the firmware, clone the tests at a tag, flash and test (HW):
@@ -237,7 +280,11 @@ while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
237
280
  jq -r '"\(.state) exit=\(.exit_code) failed=\(.summary.failed // 0)"' job.json # PASSED exit=0 failed=0
238
281
  ```
239
282
 
240
- ## GitHub Actions
283
+ ## CI/CD: GitHub Actions, GitLab, Bitbucket, Jenkins
284
+
285
+ Every CI system works the same way: store a CI token (dashboard → CI tokens) as the secret `THUB_KEY`, set `THUB_URL`, and run `thub run … --wait` on a runner with Node.js 24. The step's exit code is the verdict. Set `THUB_NO_SELF_UPDATE=1` on short-lived runners.
286
+
287
+ ### GitHub Actions
241
288
 
242
289
  ```yaml
243
290
  test-sw:
@@ -255,7 +302,79 @@ test-sw:
255
302
  git checkout -q FETCH_HEAD && ./ci/sw-tests.sh' --suite full --wait
256
303
  ```
257
304
 
258
- If the GitHub job is canceled, the runner sends `SIGINT` to the Agent. In `--wait` mode (CI), that's treated as a cancel request (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
305
+ In `--wait` mode, `SIGINT` and `SIGTERM` (how GitHub, GitLab and Jenkins stop a canceled step) make the Agent cancel the job (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
306
+
307
+ ### GitLab CI/CD
308
+
309
+ ```yaml
310
+ test-hw:
311
+ image: node:24
312
+ variables: { THUB_URL: https://thub.example.com, THUB_NO_SELF_UPDATE: "1" } # THUB_KEY: a masked CI/CD variable
313
+ script:
314
+ - |
315
+ npx -y @andrian.yablonskyy/thub-agent run --type hw --label board:nucleo-f401re --download-file "$IMAGE_URL" \
316
+ --env CI_JOB_TOKEN --env CI_SERVER_HOST --env CI_PROJECT_PATH --env CI_COMMIT_SHA \
317
+ --command 'git init -q src && cd src &&
318
+ git fetch -q --depth 1 "https://gitlab-ci-token:$CI_JOB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" "$CI_COMMIT_SHA" &&
319
+ git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' \
320
+ --wait --meta pipelineUrl="$CI_PIPELINE_URL"
321
+ ```
322
+
323
+ ### Bitbucket Pipelines
324
+
325
+ ```yaml
326
+ - step:
327
+ name: HW tests
328
+ image: node:24
329
+ script: # repository variables: THUB_URL, THUB_KEY (secured), TESTS_TOKEN (a repository access token)
330
+ - >-
331
+ npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL"
332
+ --env TESTS_TOKEN --env BITBUCKET_REPO_FULL_NAME --env BITBUCKET_COMMIT
333
+ --command 'git init -q src && cd src &&
334
+ git fetch -q --depth 1 "https://x-token-auth:$TESTS_TOKEN@bitbucket.org/$BITBUCKET_REPO_FULL_NAME.git" "$BITBUCKET_COMMIT" &&
335
+ git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' --wait
336
+ ```
337
+
338
+ ### Jenkins
339
+
340
+ ```groovy
341
+ stage('HW tests') {
342
+ agent { docker { image 'node:24' } }
343
+ environment {
344
+ THUB_URL = 'https://thub.example.com'
345
+ THUB_KEY = credentials('thub-ci-token') // Secret text
346
+ NPM_CONFIG_CACHE = "${env.WORKSPACE}/.npm"
347
+ }
348
+ steps {
349
+ sh '''
350
+ npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL" \
351
+ --command './ci/hw-tests.sh' --wait --meta buildUrl="$BUILD_URL"
352
+ '''
353
+ }
354
+ }
355
+ ```
356
+
357
+ Full pipelines — build, test, and bringing the JUnit report back into GitLab, Bitbucket or Jenkins — are in the main README, §11, and in the dashboard's Help.
358
+
359
+ ## Artifact storage
360
+
361
+ `--download-file` is a plain, anonymous GET. For private storage, either pass a short-lived signed URL (`aws s3 presign …`), or fetch the file in `--command` with credentials passed as `--env`. Outputs are uploaded by the command and listed in `$THUB_ARTIFACTS_FILE`:
362
+
363
+ ```bash
364
+ thub run --type sw --env ART_TOKEN \
365
+ --command './ci/test.sh; rc=$?
366
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/junit.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/junit.xml"
367
+ exit $rc' --wait
368
+ ```
369
+
370
+ The main README, §7.5, and the dashboard's Help have examples for Artifactory, AWS S3, Google Drive (rclone), FTP/FTPS/SFTP, and custom HTTP authentication (bearer, API key, basic, `.netrc`, mutual TLS, OAuth2).
371
+
372
+ More authentication how-tos, in the main README and the dashboard's Help:
373
+
374
+ - **Downloads over HTTPS:** a private CA (`NODE_EXTRA_CA_CERTS` on the Client), and client certificates (`curl --cert` in `--command`; `--download-file` can't present one). See §7.5.
375
+ - **Artifactory:** scoped, short-lived access tokens minted per CI run, the JFrog CLI, and signed URLs. See §7.5.
376
+ - **git over SSH:** a deploy key plus a pinned `known_hosts`, both passed with `--env`. See §7.2.
377
+ - **`docker login`:** your own registry, Artifactory, ghcr, GitLab, Docker Hub, ECR, Google Artifact Registry and ACR. See §7.2.
259
378
 
260
379
  ## Development
261
380
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-agent",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
5
5
  "bin": {
6
6
  "thub": "./src/cli.js"
@@ -12,7 +12,7 @@
12
12
  "lint:fix": "eslint . --fix"
13
13
  },
14
14
  "dependencies": {
15
- "@andrian.yablonskyy/thub-common": "^1.1.4",
15
+ "@andrian.yablonskyy/thub-common": "^1.1.5",
16
16
  "commander": "^13.1.0"
17
17
  },
18
18
  "devDependencies": {
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @file packages/agent/src/cli.js
5
- * @description thub CLI entry point: run/status/cancel/resources/jobs/config commands (README §7)
5
+ * @description thub CLI entry point: run/status/cancel/power/resources/jobs/config commands (README §7)
6
6
  *
7
7
  * @author Andrian Yablonskyy
8
8
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -18,7 +18,7 @@
18
18
  const { Command, Option } = require('commander'),
19
19
  {
20
20
  ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
21
- parseEnvList
21
+ parseEnvList, POWER_ACTIONS, DEFAULT_RESET_DELAY_SEC, MAX_RESET_DELAY_SEC, powerRequestErrors
22
22
  } = require('@andrian.yablonskyy/thub-common'),
23
23
  { resolveConnection, writeConfigFile, readConfigFile, CONFIG_PATH } = require('./config'),
24
24
  { parseDurationSec } = require('./duration'),
@@ -81,6 +81,34 @@ function taskFromOptions(opts){
81
81
  return task;
82
82
  }
83
83
 
84
+ // --power-on-start / --power-on-end / --power-reset-delay -> the job spec's
85
+ // `power` (README §8.7), checked here so a typo fails before it's sent.
86
+ function powerFromOptions(opts){
87
+ const power = {};
88
+ for (const [option, key]of [['powerOnStart', 'onStart'], ['powerOnEnd', 'onEnd']]){
89
+ if (opts[option] !== undefined){
90
+ if (!POWER_ACTIONS.includes(opts[option])){
91
+ throw usageError(`--${option.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${opts[option]}: must be one of ${POWER_ACTIONS.join(', ')}`);
92
+ }
93
+ power[key] = opts[option];
94
+ }
95
+ }
96
+ if (opts.powerResetDelay !== undefined){
97
+ const errors = powerRequestErrors({ action: 'reset', delaySec: opts.powerResetDelay });
98
+ if (errors.length){
99
+ throw usageError(`--power-reset-delay: ${errors.join('; ')}`);
100
+ }
101
+ if (power.onStart !== 'reset' && power.onEnd !== 'reset'){
102
+ throw usageError('--power-reset-delay needs --power-on-start reset or --power-on-end reset');
103
+ }
104
+ power.resetDelaySec = opts.powerResetDelay;
105
+ }
106
+ if (Object.keys(power).length && opts.type !== 'hw'){
107
+ throw usageError('--power-on-start / --power-on-end are for HW jobs only (--type hw)');
108
+ }
109
+ return Object.keys(power).length ? power : null;
110
+ }
111
+
84
112
  function usageError(message){
85
113
  return Object.assign(new Error(message), { status: EXIT_CODES.USAGE });
86
114
  }
@@ -170,9 +198,13 @@ program
170
198
  'without the Client flashing/running anything for real',
171
199
  false
172
200
  )
201
+ .option('--power-on-start <action>', `USB power on the Client (uhubctl) before the DUT is prepared: ${POWER_ACTIONS.join(' | ')} (HW only)`)
202
+ .option('--power-on-end <action>', `USB power on the Client when the job ends, whatever its verdict: ${POWER_ACTIONS.join(' | ')} (HW only)`)
203
+ .option('--power-reset-delay <sec>', `Seconds a reset keeps the power off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
173
204
  .action(async (opts) => {
174
205
  try {
175
206
  const task = taskFromOptions(opts),
207
+ power = powerFromOptions(opts),
176
208
  c = client(),
177
209
  labels = requiredLabels(opts),
178
210
  meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
@@ -184,7 +216,8 @@ program
184
216
  timeoutSec: parseDurationSec(opts.timeout),
185
217
  ...(opts.priority !== undefined ? { priority: opts.priority } : {}),
186
218
  ...(Object.keys(meta).length ? { meta } : {}),
187
- ...(opts.dryRun ? { dryRun: true } : {})
219
+ ...(opts.dryRun ? { dryRun: true } : {}),
220
+ ...(power ? { power } : {})
188
221
  },
189
222
 
190
223
  result = await c.post('/jobs', spec);
@@ -267,6 +300,36 @@ program
267
300
  }
268
301
  });
269
302
 
303
+ // README §8.7: the owner of a running job switches its Client's USB power
304
+ // right away — no need to wait for the job to end or to cancel it.
305
+ program
306
+ .command('power')
307
+ .description('Switch the USB power of the Client running your job (uhubctl): on, off or reset — while it runs')
308
+ .argument('<action>', POWER_ACTIONS.join(' | '))
309
+ .argument('<jobId>')
310
+ .option('--delay <sec>', `reset: seconds the power stays off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
311
+ .option('--port <n>', 'Only this port of the Client: its number in the Client\'s hw-devices.usbPower.ports (default: all of them)', (v) => Number(v))
312
+ .option('--json', 'Machine-readable output', false)
313
+ .action(async (action, jobId, opts) => {
314
+ try {
315
+ const errors = powerRequestErrors({ action, delaySec: opts.delay, port: opts.port });
316
+ if (errors.length){
317
+ throw usageError(errors.join('; ').replace('the reset delay (delaySec)', '--delay').replace(/^port/, '--port'));
318
+ }
319
+ const res = await client().post(`/jobs/${jobId}/power`, {
320
+ action, ...(opts.delay !== undefined ? { delaySec: opts.delay } : {}), ...(opts.port !== undefined ? { port: opts.port } : {})
321
+ });
322
+ if (opts.json){
323
+ return console.log(JSON.stringify(res));
324
+ }
325
+ const what = `USB power ${res.action}${res.port ? ` (port ${res.port})` : ''}${res.action === 'reset' ? `, ${res.delaySec} s off` : ''}`;
326
+ console.log(`${what} sent to ${res.resource.name} for job ${res.jobId} — the job's log shows when it's done.`);
327
+ }
328
+ catch (err){
329
+ fail(err);
330
+ }
331
+ });
332
+
270
333
  program
271
334
  .command('resources')
272
335
  .description('List resources and their status')
package/src/streaming.js CHANGED
@@ -21,7 +21,9 @@ const { exitCodeForJobState, EXIT_CODES } = require('@andrian.yablonskyy/thub-co
21
21
  * last seen `seq` on network drops (§7).
22
22
  *
23
23
  * In --wait mode (used by CI, §11) Ctrl-C/SIGINT is treated as a cancel
24
- * request; otherwise it only detaches and the job keeps running.
24
+ * request — and so is SIGTERM, which is how GitLab CI, Jenkins and most CI
25
+ * runners stop a canceled step; otherwise it only detaches and the job
26
+ * keeps running (SIGTERM then just ends the Agent, as before).
25
27
  */
26
28
  async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print = console.log } = {}){
27
29
  let lastEventId = fromSeq,
@@ -60,6 +62,9 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
60
62
  });
61
63
 
62
64
  process.on('SIGINT', onSigint);
65
+ if (waitMode){
66
+ process.on('SIGTERM', onSigint);
67
+ }
63
68
 
64
69
  (async () => {
65
70
  while (!finished){
@@ -107,6 +112,7 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
107
112
  }
108
113
  finally {
109
114
  process.off('SIGINT', onSigint);
115
+ process.off('SIGTERM', onSigint);
110
116
  controller.abort();
111
117
  }
112
118
  }