@andrian.yablonskyy/thub-agent 1.0.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.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # @andrian.yablonskyy/thub-agent
2
+
3
+ The Agent CLI (`thub`) for [TestHub](https://github.com/andrianyablonskyy/thub) — a self-hosted job network that lets CI/CD pipelines and individual developers run firmware tests on real hardware or emulators in a private lab. `thub` is the single entry point for both: it's stateless, everything it knows comes from the [Coordinator](https://github.com/andrianyablonskyy/thub-coordinator) API, and it runs identically on a GitHub-hosted runner and a developer laptop — a developer reproducing a CI failure runs exactly the same command the pipeline runs.
4
+
5
+ See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the full system architecture and how this fits with the Coordinator and the [Client](https://github.com/andrianyablonskyy/thub-client).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @andrian.yablonskyy/thub-agent
11
+ # or, one-off in CI:
12
+ npx -y @andrian.yablonskyy/thub-agent run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" --wait
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Read from flags, then environment (`THUB_URL`, `THUB_TOKEN`, `THUB_GROUP`, `THUB_USER`), then `~/.config/thub/config.json`, then a bundled default. `url`/`token` are required by the time a command actually talks to the Coordinator; `group`/`user` are optional everywhere.
18
+
19
+ ```bash
20
+ thub config set url https://thub.example.com
21
+ thub config set token agt_...
22
+ thub config set group 548ae4ae-... # optional default --group
23
+ thub config set user "Your Name" # optional default --user
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ```
29
+ thub run [options] Submit a test job and follow its log
30
+ thub status <jobId> Show status; follow log if running, show artifacts if done
31
+ thub cancel <jobId> Cancel a job
32
+ thub resources List resources and their status
33
+ thub jobs [--mine] [--state <s>] List recent jobs
34
+ thub config set <key> <value> Save coordinator URL / token / default group / default user locally
35
+ ```
36
+
37
+ Key options for `thub run`:
38
+
39
+ | Option | Description |
40
+ |---|---|
41
+ | `--type hw\|sw` | Required resource type. |
42
+ | `--board <name>` / `--label <l>` | Required labels (repeatable). |
43
+ | `--group <groupId>` | Restrict scheduling to resources that are members of this group. Falls back to `THUB_GROUP` / `thub config set group <id>`. |
44
+ | `--user <name>` | Free-text job owner — a label, not an identity. Falls back to `THUB_USER` / `thub config set user <name>`. |
45
+ | `--image <url>` | Firmware/build image URL, fetched by the Client. |
46
+ | `--sha256 <hex>` | Expected sha256 of `--image`; the Client verifies it before flashing/running. |
47
+ | `--tests <url>` / `--suite <name>` | Test package and suite. |
48
+ | `--arg <value>` | Extra argument passed through to `run-tests.sh` on the Client (repeatable). |
49
+ | `--timeout <dur>` | e.g. `30m`, default `30m`. |
50
+ | `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. |
51
+ | `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. |
52
+ | `--source ci\|cli` | Override auto-detected job source (defaults to `ci` when `$GITHUB_ACTIONS=true`, else `cli`). |
53
+ | `--dry-run` | Exercise the full pipeline without the Client executing anything for real. |
54
+ | `--wait` | Do not detach on job end; exit with the job's verdict code (used in CI). |
55
+ | `--detach` | Print the job id and exit immediately. |
56
+ | `--json` | Machine-readable output. |
57
+
58
+ - `thub run` prints the **job ID**, then streams logs until Ctrl-C. Ctrl-C detaches; the job keeps running on the Client.
59
+ - `thub status <jobId>` prints the current state; if active it keeps streaming, if done it prints the verdict and artifact download links.
60
+
61
+ Exit codes make the Agent usable as a CI step:
62
+
63
+ | Code | Meaning |
64
+ |---|---|
65
+ | `0` | `PASSED` |
66
+ | `1` | `FAILED` |
67
+ | `2` | `ERROR`, `TIMEOUT`, or `LOST` |
68
+ | `3` | `CANCELED` |
69
+ | `4` | Usage, auth or connection error |
70
+ | `130` | Detached with Ctrl-C (job still running) |
71
+
72
+ ## Examples
73
+
74
+ **Associate a CI/CD job id with the internal job id:**
75
+
76
+ ```bash
77
+ thub run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
78
+ --meta ciJobId="$GITHUB_RUN_ID" --wait
79
+ thub status A-00123 --json | jq '.spec.meta.ciJobId'
80
+ ```
81
+
82
+ **Pass Git repo/branch/hash/tag to the Client** — these travel as `--meta key=value` and the Client exposes each one to `run-tests.sh` as `THUB_META_<KEY>` (`ciJobId` → `THUB_META_CI_JOB_ID`):
83
+
84
+ ```bash
85
+ thub run --type hw --board nucleo-f401re \
86
+ --image "$IMAGE_URL" --tests "$TESTS_URL" --suite smoke \
87
+ --meta repo=yourorg/firmware --meta branch=main --meta sha=a1b2c3d --wait
88
+ ```
89
+
90
+ **Dry-run the pipeline** — proves the Coordinator↔Client plumbing works without real hardware, a real emulator image, or a reachable Artifactory:
91
+
92
+ ```bash
93
+ thub run --type sw --image https://does-not-exist.invalid/app.bin \
94
+ --tests https://does-not-exist.invalid/tests.tar.gz --suite smoke \
95
+ --dry-run --wait
96
+ ```
97
+
98
+ **Run on a specific resource group only:**
99
+
100
+ ```bash
101
+ thub run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
102
+ --group 548ae4ae-ac5b-401f-acaa-24bbe790e62d --wait
103
+ ```
104
+
105
+ **Label a job with its owner** — purely informational, shows up on the dashboard, in `thub jobs`, and on the Client's own console:
106
+
107
+ ```bash
108
+ thub run --type hw --board nucleo-f401re \
109
+ --image "$IMAGE_URL" --tests "$TESTS_URL" --user "Your Name" --wait
110
+ ```
111
+
112
+ ## GitHub Actions
113
+
114
+ ```yaml
115
+ test-sw:
116
+ needs: build
117
+ runs-on: ubuntu-latest
118
+ env:
119
+ THUB_URL: https://thub.example.com
120
+ THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
121
+ steps:
122
+ - run: |
123
+ npx -y @andrian.yablonskyy/thub-agent run --type sw \
124
+ --image "${{ needs.build.outputs.image_url }}" \
125
+ --tests "${{ needs.build.outputs.tests_url }}" --suite full --wait
126
+ ```
127
+
128
+ 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.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ npm install
134
+ npm run lint
135
+ ```
136
+
137
+ ## License
138
+
139
+ Proprietary — see the header comment in each source file.
package/config.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "url": "http://localhost:8000",
3
+ "token": "agt_0CckgVh8wCMJpNkNpS7EBBeYJ8RCQ9LFIKUCYmZJ8oY",
4
+ "group": "",
5
+ "user": ""
6
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file eslint.config.js
3
+ * @description ESLint flat config for @andrian.yablonskyy/thub-agent: stylistic/promise rules plus Node globals
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const stylistic = require('@stylistic/eslint-plugin'),
17
+ promise = require('eslint-plugin-promise'),
18
+ globals = require('globals');
19
+
20
+ module.exports = [
21
+ { ignores: ['**/node_modules/**'] },
22
+ {
23
+ files: ['**/*.{js,cjs,mjs}'],
24
+ plugins: { '@stylistic': stylistic, promise },
25
+ languageOptions: {
26
+ ecmaVersion: 'latest',
27
+ sourceType: 'commonjs',
28
+ globals: { ...globals.node }
29
+ },
30
+ rules: {
31
+ // Core JS
32
+ 'prefer-const': 'error',
33
+ strict: ['error', 'global'],
34
+ camelcase: [
35
+ 'error',
36
+ {
37
+ properties: 'never',
38
+ ignoreGlobals: true,
39
+ ignoreDestructuring: true,
40
+ allow: ['[a-z]+([a-z0-9_])?']
41
+ }
42
+ ],
43
+ 'one-var': ['error', { var: 'never', let: 'consecutive', const: 'consecutive' }],
44
+ 'no-var': 'error',
45
+ // console.log/error *is* this CLI's actual output mechanism (job
46
+ // status, tables, error messages), not leftover debugging.
47
+ 'no-console': 'off',
48
+ 'no-restricted-syntax': [
49
+ 'error',
50
+ {
51
+ selector: 'CallExpression[callee.object.name="console"][callee.property.name!=/^(log|warn|error|info|trace)$/]',
52
+ message: 'Unexpected property on console object was called'
53
+ }
54
+ ],
55
+ curly: 'error',
56
+
57
+ // Stylistic
58
+ '@stylistic/indent': ['error', 2],
59
+ '@stylistic/key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }],
60
+ '@stylistic/keyword-spacing': ['error', { before: false, after: true }],
61
+ '@stylistic/linebreak-style': ['error', 'unix'],
62
+ '@stylistic/space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }],
63
+ '@stylistic/space-infix-ops': 'error',
64
+ '@stylistic/space-before-blocks': ['error', 'never'],
65
+ '@stylistic/comma-dangle': ['error', 'never'],
66
+ '@stylistic/max-len': ['error', 160],
67
+ '@stylistic/no-trailing-spaces': ['error', { skipBlankLines: false }],
68
+ '@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
69
+ '@stylistic/semi': ['error', 'always'],
70
+ '@stylistic/quotes': ['error', 'single'],
71
+ '@stylistic/one-var-declaration-per-line': ['error', 'always'],
72
+ '@stylistic/no-extra-semi': 'error',
73
+ '@stylistic/no-multi-spaces': 'error',
74
+ '@stylistic/no-mixed-spaces-and-tabs': 'error',
75
+ '@stylistic/arrow-parens': ['error', 'always'],
76
+ '@stylistic/arrow-spacing': ['error', { before: true, after: true }],
77
+ '@stylistic/block-spacing': 'error',
78
+ '@stylistic/brace-style': ['error', 'stroustrup'],
79
+ '@stylistic/comma-spacing': ['error', { before: false, after: true }],
80
+ '@stylistic/comma-style': ['error', 'last'],
81
+ '@stylistic/dot-location': ['error', 'property'],
82
+ '@stylistic/function-call-spacing': ['error', 'never'],
83
+
84
+ // Promises
85
+ 'promise/always-return': ['error', { ignoreLastCallback: true }],
86
+ 'promise/param-names': 'off',
87
+ 'promise/catch-or-return': ['error', { allowFinally: true }],
88
+ 'promise/no-native': 'off',
89
+ 'promise/no-nesting': 'off',
90
+ 'promise/no-callback-in-promise': 'off',
91
+ 'promise/avoid-new': 'off',
92
+ 'promise/no-new-statics': 'error',
93
+ 'promise/no-return-in-finally': 'warn',
94
+ 'promise/valid-params': 'warn'
95
+ }
96
+ }
97
+ ];
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@andrian.yablonskyy/thub-agent",
3
+ "version": "1.0.0",
4
+ "description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
5
+ "bin": {
6
+ "thub": "./src/cli.js"
7
+ },
8
+ "main": "src/cli.js",
9
+ "scripts": {
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix"
12
+ },
13
+ "dependencies": {
14
+ "@andrian.yablonskyy/thub-common": "*",
15
+ "commander": "^13.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "@stylistic/eslint-plugin": "^5.10.0",
19
+ "eslint": "^10.11.0",
20
+ "eslint-plugin-promise": "^7.3.0",
21
+ "globals": "^17.12.0"
22
+ }
23
+ }
package/src/cli.js ADDED
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file packages/agent/src/cli.js
5
+ * @description thub CLI entry point: run/status/cancel/resources/jobs/config commands (README §7)
6
+ *
7
+ * @author Andrian Yablonskyy
8
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
9
+ *
10
+ * This file is part of TestHub and is proprietary and confidential.
11
+ * Unauthorized copying, modification, distribution, or use of this file,
12
+ * via any medium, is strictly prohibited without prior written permission
13
+ * from AdSystem.PRO.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { Command } = require('commander'),
19
+ { ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState } = require('@andrian.yablonskyy/thub-common'),
20
+ { resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile } = require('./config'),
21
+ { parseDurationSec } = require('./duration'),
22
+ { followJob } = require('./streaming');
23
+
24
+ const program = new Command();
25
+ program
26
+ .name('thub')
27
+ .description('TestHub Agent — submit test jobs and follow them, from CI or your laptop')
28
+ .option('--url <url>', 'Coordinator URL (overrides THUB_URL / config file)')
29
+ .option('--token <token>', 'Agent token (overrides THUB_TOKEN / config file)');
30
+
31
+ function client(){
32
+ const { url, token } = resolveConnection(program.opts());
33
+ return new ApiClient({ baseUrl: url, token });
34
+ }
35
+
36
+ function fail(err){
37
+ console.error(`Error: ${err.message}`);
38
+ process.exit(err.status && Number.isInteger(err.status) && err.status < 100 ? err.status : EXIT_CODES.USAGE);
39
+ }
40
+
41
+ program
42
+ .command('run')
43
+ .description('Submit a test job and follow its log')
44
+ .requiredOption('--type <hw|sw>', 'Required resource type')
45
+ .option('--board <name>', 'Shorthand for --label board:<name>')
46
+ .option('--label <label>', 'Required label the resource must have (repeatable)', collectRepeatable, [])
47
+ .option(
48
+ '--group <groupId>',
49
+ 'Restrict scheduling to resources that are members of this group (§13.1). ' +
50
+ 'Overrides THUB_GROUP / config file; leave unset for an unconstrained run.'
51
+ )
52
+ .option(
53
+ '--user <name>',
54
+ 'Free-text job owner, shown on the Client and the dashboard to tell whose job is whose ' +
55
+ '— purely a label, not an identity. Overrides THUB_USER / config file.'
56
+ )
57
+ .requiredOption('--image <url>', 'Firmware/build image URL (Artifactory or a Docker registry blob) fetched by the Client')
58
+ .option('--sha256 <hex>', 'Expected sha256 of --image; the Client verifies it before flashing/running')
59
+ .requiredOption('--tests <url>', 'Test package URL in Artifactory')
60
+ .option('--suite <name>', 'Test suite name', 'default')
61
+ .option('--arg <value>', 'Extra argument passed through to run-tests.sh on the Client (repeatable)', collectRepeatable, [])
62
+ .option('--timeout <duration>', 'e.g. 30m, 1h', '30m')
63
+ .option('--priority <n>', 'Priority 0-100', (v) => Number(v))
64
+ .option('--wait', 'Do not detach on job end; exit with the verdict code (used in CI)', false)
65
+ .option('--detach', 'Print the job id and exit immediately', false)
66
+ .option('--json', 'Machine-readable output', false)
67
+ .option(
68
+ '--meta <keyValue>',
69
+ 'Extra metadata key=value, stored on the job and returned by `thub status --json` (repeatable). ' +
70
+ 'Use this to carry CI job ids, git repo/branch/sha/tag, or anything else you want attached to the run.',
71
+ collectRepeatable,
72
+ []
73
+ )
74
+ .option('--source <ci|cli>', 'Override auto-detected job source')
75
+ .option(
76
+ '--dry-run',
77
+ 'Exercise the full pipeline (schedule, accept, state transitions, logs, artifact, result) ' +
78
+ 'without the Client flashing/running anything for real',
79
+ false
80
+ )
81
+ .action(async (opts) => {
82
+ try {
83
+ const c = client(),
84
+ source = opts.source || (process.env.GITHUB_ACTIONS === 'true' ? 'ci' : 'cli'),
85
+ labels = [...(opts.board ? [`board:${opts.board}`] : []), ...opts.label],
86
+ meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
87
+ group = resolveGroup({ group: opts.group }),
88
+ user = resolveUser({ user: opts.user }),
89
+
90
+ spec = {
91
+ target: { type: opts.type, labels, ...(group ? { group } : {}) },
92
+ firmware: { url: opts.image, ...(opts.sha256 ? { sha256: opts.sha256 } : {}) },
93
+ tests: { url: opts.tests, suite: opts.suite, args: opts.arg },
94
+ timeoutSec: parseDurationSec(opts.timeout),
95
+ priority: opts.priority ?? (source === 'ci' ? 50 : 60),
96
+ source,
97
+ ...(user ? { user } : {}),
98
+ ...(Object.keys(meta).length ? { meta } : {}),
99
+ ...(opts.dryRun ? { dryRun: true } : {})
100
+ },
101
+
102
+ result = await c.post('/jobs', spec);
103
+ if (opts.json){
104
+ console.log(JSON.stringify(result));
105
+ }
106
+ else {
107
+ console.log(`Job ${result.jobId} queued`);
108
+ }
109
+
110
+ if (opts.detach){
111
+ return process.exit(0);
112
+ }
113
+
114
+ const code = await followJob(c, result.jobId, { waitMode: opts.wait });
115
+ process.exit(code);
116
+ }
117
+ catch (err){
118
+ fail(err);
119
+ }
120
+ });
121
+
122
+ program
123
+ .command('status')
124
+ .description('Show status; follow log if running, show artifacts if done')
125
+ .argument('<jobId>')
126
+ .option('--json', 'Machine-readable output', false)
127
+ .action(async (jobId, opts) => {
128
+ try {
129
+ const c = client(),
130
+ job = await c.get(`/jobs/${jobId}`);
131
+ if (opts.json && !ACTIVE_JOB_STATES.has(job.state)){
132
+ console.log(JSON.stringify(job));
133
+ return process.exit(exitCodeForJobState(job.state));
134
+ }
135
+
136
+ console.log(`Job ${job.id} — ${job.state}${job.resource ? ' on ' + job.resource.name : ''}`);
137
+ console.log(`Created: ${job.created_at}`);
138
+
139
+ if (ACTIVE_JOB_STATES.has(job.state)){
140
+ const code = await followJob(c, jobId, { fromSeq: 0, waitMode: false });
141
+ return process.exit(code);
142
+ }
143
+
144
+ const { artifacts } = await c.get(`/jobs/${jobId}/artifacts`);
145
+ console.log(`Verdict: ${job.state}`);
146
+ for (const a of artifacts){
147
+ console.log(` ${a.name} ${a.url}`);
148
+ }
149
+ process.exit(exitCodeForJobState(job.state));
150
+ }
151
+ catch (err){
152
+ fail(err);
153
+ }
154
+ });
155
+
156
+ program
157
+ .command('cancel')
158
+ .description('Cancel a job')
159
+ .argument('<jobId>')
160
+ .action(async (jobId) => {
161
+ try {
162
+ const job = await client().post(`/jobs/${jobId}/cancel`);
163
+ console.log(`Job ${job.id} -> ${job.state}`);
164
+ }
165
+ catch (err){
166
+ fail(err);
167
+ }
168
+ });
169
+
170
+ program
171
+ .command('resources')
172
+ .description('List resources and their status')
173
+ .option('--json', 'Machine-readable output', false)
174
+ .action(async (opts) => {
175
+ try {
176
+ const { resources } = await client().get('/resources');
177
+ if (opts.json){
178
+ return console.log(JSON.stringify(resources));
179
+ }
180
+ printTable(resources, [
181
+ ['NAME', (r) => r.name],
182
+ ['TYPE', (r) => r.type],
183
+ ['STATUS', (r) => r.status],
184
+ ['BUSY', (r) => r.busySource || '-'],
185
+ ['LABELS', (r) => r.labels.join(',')],
186
+ ['LAST HEARTBEAT', (r) => r.lastHeartbeatAt || 'never']
187
+ ]);
188
+ }
189
+ catch (err){
190
+ fail(err);
191
+ }
192
+ });
193
+
194
+ program
195
+ .command('jobs')
196
+ .description('List recent jobs')
197
+ .option('--mine', 'Only jobs submitted by this agent token', false)
198
+ .option('--state <state>', 'Filter by state')
199
+ .option('--json', 'Machine-readable output', false)
200
+ .action(async (opts) => {
201
+ try {
202
+ const { jobs } = await client().get('/jobs', { query: { mine: opts.mine, state: opts.state } });
203
+ if (opts.json){
204
+ return console.log(JSON.stringify(jobs));
205
+ }
206
+ printTable(jobs, [
207
+ ['ID', (j) => j.id],
208
+ ['SOURCE', (j) => j.source],
209
+ ['USER', (j) => j.spec.user || '-'],
210
+ ['STATE', (j) => j.state],
211
+ ['CREATED', (j) => j.created_at]
212
+ ]);
213
+ }
214
+ catch (err){
215
+ fail(err);
216
+ }
217
+ });
218
+
219
+ const config = program.command('config').description('Manage local Agent configuration');
220
+ config
221
+ .command('set')
222
+ .argument('<key>', 'url | token | group | user')
223
+ .argument('<value>')
224
+ .action((key, value) => {
225
+ if (!['url', 'token', 'group', 'user'].includes(key)){
226
+ console.error('Error: key must be "url", "token", "group", or "user"');
227
+ process.exit(EXIT_CODES.USAGE);
228
+ }
229
+ const current = readConfigFile();
230
+ writeConfigFile({ ...current, [key]: value });
231
+ console.log(`Saved ${key} to config`);
232
+ });
233
+
234
+ function collectRepeatable(value, previous){
235
+ return [...previous, value];
236
+ }
237
+
238
+ function printTable(rows, columns){
239
+ if (rows.length === 0){
240
+ console.log('(none)');
241
+ return;
242
+ }
243
+ const widths = columns.map(([header], i) => Math.max(header.length, ...rows.map((r) => String(columns[i][1](r)).length))),
244
+ line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
245
+ console.log(line(columns.map(([h]) => h)));
246
+ for (const row of rows){
247
+ console.log(line(columns.map(([, f]) => f(row))));
248
+ }
249
+ }
250
+
251
+ program.parseAsync(process.argv);
package/src/config.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file packages/agent/src/config.js
3
+ * @description Agent config resolution: flags > env > config file > bundled default (url/token/group/user)
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('node:fs'),
17
+ os = require('node:os'),
18
+ path = require('node:path');
19
+
20
+ // §7: "Configuration is read from flags, then environment (THUB_URL,
21
+ // THUB_TOKEN), then ~/.config/thub/config.json."
22
+ const CONFIG_PATH = path.join(os.homedir(), '.config', 'thub', 'config.json'),
23
+
24
+ // Bundled with the package as a last-resort default, below the user's own
25
+ // config file, so `thub` has something to fall back on before `config set`
26
+ // has ever been run.
27
+ PACKAGE_DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
28
+
29
+ function readJsonFile(filePath){
30
+ try {
31
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ function readConfigFile(){
39
+ return readJsonFile(CONFIG_PATH);
40
+ }
41
+
42
+ function writeConfigFile(config){
43
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
44
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
45
+ }
46
+
47
+ function resolveConnection(flags = {}){
48
+ const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() },
49
+ url = flags.url || process.env.THUB_URL || file.url,
50
+ token = flags.token || process.env.THUB_TOKEN || file.token;
51
+ if (!url || !token){
52
+ const err = new Error(
53
+ 'Missing coordinator URL or token. Set with `thub config set url <url>` / `thub config set token <token>`, ' +
54
+ 'or THUB_URL / THUB_TOKEN, or --url / --token.'
55
+ );
56
+ err.status = 4;
57
+ throw err;
58
+ }
59
+ return { url, token };
60
+ }
61
+
62
+ // Optional default for `--group` (§4.3/§7.1) — same flags > env > file
63
+ // precedence as url/token, but unset is fine: a job just runs unconstrained
64
+ // by group, same as if `groups` had never been used at all.
65
+ function resolveGroup(flags = {}){
66
+ const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() };
67
+ return flags.group || process.env.THUB_GROUP || file.group || undefined;
68
+ }
69
+
70
+ // Optional default for `--user` (§4.3/§7.1) — same flags > env > file
71
+ // precedence as group. Purely a label (who submitted this job), not an
72
+ // identity: unset is fine, and nothing on the Coordinator side enforces
73
+ // or authenticates it.
74
+ function resolveUser(flags = {}){
75
+ const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() };
76
+ return flags.user || process.env.THUB_USER || file.user || undefined;
77
+ }
78
+
79
+ module.exports = { CONFIG_PATH, readConfigFile, writeConfigFile, resolveConnection, resolveGroup, resolveUser };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file packages/agent/src/duration.js
3
+ * @description Parses human-readable durations (e.g. 30m, 1h) into seconds
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const UNITS = { s: 1, m: 60, h: 3600, d: 86400 };
17
+
18
+ function parseDurationSec(input){
19
+ if (input === undefined){
20
+ return undefined;
21
+ }
22
+ if (typeof input === 'number'){
23
+ return input;
24
+ }
25
+ const match = /^(\d+)([smhd])?$/.exec(String(input).trim());
26
+ if (!match){
27
+ throw new Error(`Invalid duration "${input}" — use e.g. 30m, 1h, 900s`);
28
+ }
29
+ const [, n, unit] = match;
30
+ return Number(n) * (UNITS[unit] || 1);
31
+ }
32
+
33
+ module.exports = { parseDurationSec };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @file packages/agent/src/streaming.js
3
+ * @description Follows a job's live log stream and resolves the CLI's exit code
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { exitCodeForJobState, EXIT_CODES } = require('@andrian.yablonskyy/thub-common');
17
+
18
+ /**
19
+ * Streams a job's logs until it reaches a terminal state, or the user
20
+ * hits Ctrl-C. Reconnects with exponential backoff and resumes from the
21
+ * last seen `seq` on network drops (§7).
22
+ *
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.
25
+ */
26
+ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print = console.log } = {}){
27
+ let lastEventId = fromSeq,
28
+ finished = false,
29
+ backoffMs = 500;
30
+ const controller = new AbortController(),
31
+
32
+ onSigint = async () => {
33
+ if (finished){
34
+ return;
35
+ }
36
+ finished = true;
37
+ controller.abort();
38
+ if (waitMode){
39
+ print('\nReceived cancel signal — canceling job...');
40
+ try {
41
+ await client.post(`/jobs/${jobId}/cancel`);
42
+ }
43
+ catch {
44
+ // best effort — the process is exiting either way
45
+ }
46
+ resolveOnce(EXIT_CODES.CANCELED);
47
+ }
48
+ else {
49
+ print(`\nDetached. Job keeps running.\nthub status ${jobId}`);
50
+ resolveOnce(EXIT_CODES.DETACHED);
51
+ }
52
+ };
53
+
54
+ let resolveOnce;
55
+ const done = new Promise((resolve) => {
56
+ resolveOnce = (code) => {
57
+ finished = true;
58
+ resolve(code);
59
+ };
60
+ });
61
+
62
+ process.on('SIGINT', onSigint);
63
+
64
+ (async () => {
65
+ while (!finished){
66
+ try {
67
+ await client.streamEvents(`/jobs/${jobId}/logs/stream`, {
68
+ lastEventId,
69
+ signal: controller.signal,
70
+ onEvent: ({ event, id, data }) => {
71
+ if (id){
72
+ lastEventId = id;
73
+ }
74
+ if (event === 'log'){
75
+ print(`[${data.stream}] ${data.line}`);
76
+ }
77
+ else if (event === 'state'){
78
+ print(`-- ${data.state}${data.resource ? ' on ' + data.resource : ''} --`);
79
+ }
80
+ else if (event === 'end'){
81
+ print(`\nJob finished: ${data.state}`);
82
+ if (data.artifactsUrl){
83
+ print(`Artifacts: ${data.artifactsUrl}`);
84
+ }
85
+ resolveOnce(exitCodeForJobState(data.state));
86
+ }
87
+ }
88
+ });
89
+ if (!finished){
90
+ // Server closed the stream without an `end` event (rare) — retry.
91
+ await sleep(backoffMs);
92
+ backoffMs = Math.min(backoffMs * 2, 10_000);
93
+ }
94
+ }
95
+ catch {
96
+ if (controller.signal.aborted){
97
+ break;
98
+ }
99
+ await sleep(backoffMs);
100
+ backoffMs = Math.min(backoffMs * 2, 10_000);
101
+ }
102
+ }
103
+ })();
104
+
105
+ try {
106
+ return await done;
107
+ }
108
+ finally {
109
+ process.off('SIGINT', onSigint);
110
+ controller.abort();
111
+ }
112
+ }
113
+
114
+ function sleep(ms){
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+
118
+ module.exports = { followJob };