@andrian.yablonskyy/thub-agent 1.0.26 → 1.0.28

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
@@ -52,10 +52,11 @@ Key options for `thub run`:
52
52
  | `--user <name>` | Free-text job owner — a label, not an identity. Falls back to `THUB_USER` / `thub config set user <name>`. |
53
53
  | `--command <string>` | **Required.** The task's entry point: a shell command the Client runs (`sh -c`) in the task's work directory — the `--git-repo` checkout, else an empty directory — after preparing its inputs. Its exit code is the verdict. On HW it flashes the board itself (the Client doesn't); it gets `THUB_DUT_STLINK`/`_UART`/`_USB`/`_HOST`/`_CONTAINER`, `THUB_DOWNLOAD_<n>`, `THUB_DOWNLOADS_DIR`, `THUB_GIT_COMMIT`, `THUB_SUITE`, `THUB_META_*`. |
54
54
  | `--download-file <url>` | A file the Client downloads before running the command (repeatable, `http(s)`), into the job's `downloads/` directory. |
55
- | `--docker-image <name>` | SW only: a Docker image the Client runs as the DUT instead of its own `sw.image` (the Client must allow it: `sw.allowJobImages`). |
55
+ | `--docker-image <name>` | SW only: a Docker image the Client runs as the **DUT** (an emulator the tests talk to, at `$THUB_DUT_HOST` / `$THUB_DUT_CONTAINER`) — any SW Client runs it; without one, an SW job has no DUT container. It's not where `--command` runs: to run the tests in an image, see [Docker](#docker). |
56
56
  | `--git-repo <url> [<branch>\|<tag>\|<commit>]` | A git repository the Client clones (default ref: the default branch); the command runs in the checkout. |
57
57
  | `--depth <n>` | With `--git-repo`: commits to fetch, default `1`; `0` = full history. |
58
58
  | `--git-options <string>` | With `--git-repo`: extra git options placed between `git` and its subcommand on the Client, e.g. `'-c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222"'`. Shell-quoted (no shell run). Stored with the job, so reference key files rather than inlining secrets. |
59
+ | `--env <vars>` | Environment variables for every command the Client runs for the job (git, docker login, `--command`): `NAME=value[,NAME=value]`, repeatable; `--env NAME` alone takes the value from your shell. `DOCKER_REGISTRY` + `DOCKER_USERNAME` + `DOCKER_PASSWORD`: the Client logs in to that registry first (`docker login … --password-stdin`). Values reach only the Client running the job; the Coordinator masks them and drops them when the job ends. |
59
60
  | `--suite <name>` | Passed to the command as `THUB_SUITE`. |
60
61
  | `--arg <value>` | Extra argument for the command, as `"$@"` (repeatable). |
61
62
  | `--timeout <dur>` | e.g. `30m`, default `30m`. |
@@ -67,7 +68,7 @@ Key options for `thub run`:
67
68
  | `--json` | Machine-readable output. |
68
69
 
69
70
  - `thub run` prints the **job ID**, then streams logs until Ctrl-C. Ctrl-C detaches; the job keeps running on the Client.
70
- - `thub status <jobId>` prints the current state; if active it keeps streaming, if done it prints the verdict and artifact download links.
71
+ - `thub status <jobId>` prints the current state; if active it keeps streaming, if done it prints the verdict and artifact download links. `--json` prints the job once and never follows it (see [Job status and PASS/FAIL](#job-status-and-passfail)).
71
72
 
72
73
  Exit codes make the Agent usable as a CI step:
73
74
 
@@ -78,6 +79,7 @@ Exit codes make the Agent usable as a CI step:
78
79
  | `2` | `ERROR`, `TIMEOUT`, or `LOST` |
79
80
  | `3` | `CANCELED` |
80
81
  | `4` | Usage, auth or connection error |
82
+ | `5` | `thub status <jobId> --json`: the job is still queued or running |
81
83
  | `130` | Detached with Ctrl-C (job still running) |
82
84
 
83
85
  ## Examples
@@ -92,12 +94,12 @@ thub run --type hw --board nucleo-f401re \
92
94
  --arg --junit --wait
93
95
  ```
94
96
 
95
- **SW task in a Docker image of your own:**
97
+ **SW task against a DUT emulator image of your own** (the command runs on the Client host and talks to the emulator):
96
98
 
97
99
  ```bash
98
- thub run --type sw --docker-image alpine:3.20 \
100
+ thub run --type sw --docker-image registry.lab.local:5000/dut-emulator:2026.08 \
99
101
  --git-repo git@github.com:yourorg/firmware-tests.git main --depth 20 \
100
- --command 'make test' --wait
102
+ --command 'make test DUT="$THUB_DUT_HOST"' --wait
101
103
  ```
102
104
 
103
105
  **Associate a CI/CD job id with the internal job id:**
@@ -131,6 +133,88 @@ thub run --type hw --board nucleo-f401re \
131
133
  --git-repo "$TESTS_REPO" --command ./ci/test.sh --user "Your Name" --wait
132
134
  ```
133
135
 
136
+ ## Environment variables and secrets
137
+
138
+ `--env NAME=value[,NAME=value]` (repeatable) sets variables for every command the Client runs for the job: git, the registry login, the DUT setup and `--command`. `--env NAME` alone takes the value from your own environment, so a secret stays off the command line and out of CI logs:
139
+
140
+ ```bash
141
+ export API_TOKEN=…
142
+ thub run --type sw --git-repo "$TESTS_REPO" \
143
+ --env TARGET=staging --env API_TOKEN \
144
+ --command './run-tests.sh --target "$TARGET"' --wait
145
+ ```
146
+
147
+ **How the values are kept secret:**
148
+
149
+ - They reach **only the Client that runs the job**.
150
+ - The Agent API shows every value as `***`: `thub status --json`, `thub jobs --json`, even for your own job. The dashboard lists the names only.
151
+ - The Coordinator replaces the values with `***` once the job ends.
152
+ - A `--dry-run` masks values whose names look secret (`*PASS*`, `*TOKEN*`, `*KEY*`, …).
153
+
154
+ Your command's output is the job's log, so don't print secrets. Everything else — `--command`, `--arg`, `--meta`, `--git-options` — is stored as is and visible, so never put secrets there.
155
+
156
+ ## Docker
157
+
158
+ **Log in to a custom registry:** pass `DOCKER_REGISTRY`, `DOCKER_USERNAME` and `DOCKER_PASSWORD` with `--env`. Before anything else, the Client runs `echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" --username "$DOCKER_USERNAME" --password-stdin` into a Docker config of the job's own. The job's commands get it as `DOCKER_CONFIG`, and it's deleted with the job.
159
+
160
+ **Pull an image from it as the DUT:**
161
+
162
+ ```bash
163
+ export DOCKER_PASSWORD=…
164
+ thub run --type sw \
165
+ --env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USERNAME=ci --env DOCKER_PASSWORD \
166
+ --docker-image registry.lab.local:5000/dut-emulator:2026.08 \
167
+ --git-repo "$TESTS_REPO" --command './ci/test.sh --dut "$THUB_DUT_HOST"' --wait
168
+ ```
169
+
170
+ **Run the command inside a container:** `--command` starts on the Client host in the work directory (`$THUB_WORK_DIR`, the `--git-repo` checkout). Start the container from it, mounting that directory:
171
+
172
+ ```bash
173
+ thub run --type sw \
174
+ --env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USERNAME=ci --env DOCKER_PASSWORD \
175
+ --git-repo git@bitbucket.org:yourorg/web-ui-tests.git main \
176
+ --git-options '-c core.sshCommand="ssh -i /home/thub/.ssh/id_ed25519 -o StrictHostKeyChecking=accept-new"' \
177
+ --command 'docker run --rm -v "$THUB_WORK_DIR:/work" -w /work "$DOCKER_REGISTRY/python:3.14" ./run-tests.sh' \
178
+ --wait
179
+ ```
180
+
181
+ **Clone the repository inside the image, with parameters from `--env`:**
182
+
183
+ ```bash
184
+ thub run --type sw \
185
+ --env DOCKER_REGISTRY=registry.lab.local:5000,DOCKER_USERNAME=ci --env DOCKER_PASSWORD \
186
+ --env TEST_IMAGE=registry.lab.local:5000/team/test-runner:1.4 \
187
+ --env REPO_URL=git@bitbucket.org:yourorg/web-ui-tests.git,REPO_REF=main \
188
+ --env 'GIT_SSH_COMMAND=ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=accept-new' \
189
+ --command 'docker run --rm -e REPO_URL -e REPO_REF -e GIT_SSH_COMMAND \
190
+ -v "$HOME/.ssh:/root/.ssh:ro" -v "$THUB_WORK_DIR/results:/results" \
191
+ "$TEST_IMAGE" sh -c "git clone --depth 1 --branch \"\$REPO_REF\" \"\$REPO_URL\" /src && cd /src && ./run-tests.sh --junit /results"' \
192
+ --wait
193
+ ```
194
+
195
+ The Client host needs Docker and the Client's user in the `docker` group; SW Clients have both. Add `--dry-run` to see every command a job would run on the Client (secret-looking values masked) without running any. More in the main README, §7.2.
196
+
197
+ ## Job status and PASS/FAIL
198
+
199
+ The verdict is `--command`'s exit code: `0` → **PASSED**, anything else → **FAILED**. ERROR, TIMEOUT, LOST and CANCELED mean the job didn't run to a verdict. JUnit XML written to `results/` or `artifacts/` in the work directory is uploaded and summed into the job's `summary`.
200
+
201
+ **In CI:** `thub run … --wait` exits with the verdict code (table above): `0` PASSED, `1` FAILED, `2` infrastructure, `3` canceled.
202
+
203
+ **Check a job:**
204
+
205
+ ```bash
206
+ thub status M-00125 # state; follows the log while active; then "Verdict: …" and artifact links; exit = verdict
207
+ thub jobs --mine --state FAILED
208
+ ```
209
+
210
+ **From a script:** `thub status <jobId> --json` prints the job once, with `state`, `exit_code`, `summary`, `message` and `resource.name`. It exits with the verdict code, or `5` while the job is still queued or running:
211
+
212
+ ```bash
213
+ JOB=$(thub run --type sw --git-repo "$TESTS_REPO" --command ./ci/test.sh --detach --json | jq -r .jobId)
214
+ while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
215
+ jq -r '"\(.state) exit=\(.exit_code) failed=\(.summary.failed // 0)"' job.json # PASSED exit=0 failed=0
216
+ ```
217
+
134
218
  ## GitHub Actions
135
219
 
136
220
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-agent",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
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.0.26",
15
+ "@andrian.yablonskyy/thub-common": "^1.0.28",
16
16
  "commander": "^13.1.0"
17
17
  },
18
18
  "devDependencies": {
package/src/cli.js CHANGED
@@ -18,7 +18,8 @@
18
18
  const { Command } = require('commander'),
19
19
  {
20
20
  ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
21
- splitArgs
21
+ splitArgs,
22
+ parseEnvList
22
23
  } = require('@andrian.yablonskyy/thub-common'),
23
24
  { resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile } = require('./config'),
24
25
  { parseDurationSec } = require('./duration'),
@@ -99,6 +100,16 @@ function taskFromOptions(opts){
99
100
  }
100
101
  task.git = { url, ...(ref ? { ref } : {}), depth, ...(opts.gitOptions ? { options: opts.gitOptions } : {}) };
101
102
  }
103
+ let env;
104
+ try {
105
+ env = parseEnvList(opts.env);
106
+ }
107
+ catch (err){
108
+ throw usageError(err.message);
109
+ }
110
+ if (Object.keys(env).length){
111
+ task.env = env;
112
+ }
102
113
  return task;
103
114
  }
104
115
 
@@ -157,8 +168,8 @@ program
157
168
  )
158
169
  .option(
159
170
  '--docker-image <name>',
160
- 'SW jobs only: a Docker image the Client runs as the DUT (e.g. alpine, alpine:3.20, registry.lab:5000/emu:1), ' +
161
- 'instead of its own sw.image; pulled from its registry or Docker Hub. The Client must allow it (sw.allowJobImages)'
171
+ 'SW jobs only: a Docker image the Client runs as the job\'s DUT container, next to --command (e.g. registry.lab:5000/emu:1); ' +
172
+ 'pulled from the registry it names, else Docker Hub. Without it, an SW job has no DUT container'
162
173
  )
163
174
  .option(
164
175
  '--git-repo <url...>',
@@ -171,6 +182,15 @@ program
171
182
  'With --git-repo: extra git options, placed between `git` and its subcommand on the Client (shell-quoted, no shell run), ' +
172
183
  'e.g. \'-c core.sshCommand="ssh -i ~/.ssh/lab_key -p 2222"\'. Stored with the job — reference key files, don\'t inline secrets'
173
184
  )
185
+ .option(
186
+ '--env <vars>',
187
+ 'Environment variables for every command the Client runs for the job (git, docker login, --command): ' +
188
+ 'NAME=value[,NAME=value] (repeatable; a value may contain commas); --env NAME alone takes its value from this shell. ' +
189
+ 'With DOCKER_REGISTRY, DOCKER_USERNAME and DOCKER_PASSWORD the Client first logs in to that registry. ' +
190
+ 'Values reach only the Client running the job; the Coordinator masks them and drops them when the job ends',
191
+ collectRepeatable,
192
+ []
193
+ )
174
194
  .option('--suite <name>', 'Test suite name, passed to --command as THUB_SUITE', 'default')
175
195
  .option('--arg <value>', 'Extra argument for --command, as "$@" (repeatable)', collectRepeatable, [])
176
196
  .option('--timeout <duration>', 'e.g. 30m, 1h', '30m')
@@ -234,14 +254,16 @@ program
234
254
  .command('status')
235
255
  .description('Show status; follow log if running, show artifacts if done')
236
256
  .argument('<jobId>')
237
- .option('--json', 'Machine-readable output', false)
257
+ .option('--json', 'Print the job as JSON once, without following it; exit code: its verdict, or 5 while it\'s still active', false)
238
258
  .action(async (jobId, opts) => {
239
259
  try {
240
260
  const c = client(),
241
261
  job = await c.get(`/jobs/${jobId}`);
242
- if (opts.json && !ACTIVE_JOB_STATES.has(job.state)){
262
+ // --json never follows: a script polls it, and tells "still running"
263
+ // (exit 5) from a verdict.
264
+ if (opts.json){
243
265
  console.log(JSON.stringify(job));
244
- return process.exit(exitCodeForJobState(job.state));
266
+ return process.exit(ACTIVE_JOB_STATES.has(job.state) ? EXIT_CODES.ACTIVE : exitCodeForJobState(job.state));
245
267
  }
246
268
 
247
269
  console.log(`Job ${job.id} — ${job.state}${job.resource ? ' on ' + job.resource.name : ''}`);
package/src/streaming.js CHANGED
@@ -77,6 +77,9 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
77
77
  else if (event === 'state'){
78
78
  print(`-- ${data.state}${data.resource ? ' on ' + data.resource : ''} --`);
79
79
  }
80
+ else if (event === 'waiting'){
81
+ print(`-- waiting: ${data.reason} --`);
82
+ }
80
83
  else if (event === 'end'){
81
84
  print(`\nJob finished: ${data.state}`);
82
85
  if (data.artifactsUrl){