@andrian.yablonskyy/thub-agent 1.0.33 → 1.1.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 CHANGED
@@ -14,13 +14,16 @@ npx -y @andrian.yablonskyy/thub-agent run --type sw --download-file "$IMAGE_URL"
14
14
 
15
15
  ## Configuration
16
16
 
17
- Read from flags, then environment (`THUB_URL`, `THUB_TOKEN`, `THUB_GROUP`, `THUB_USER`), then `~/.config/thub/agent.json`, then a bundled default. `url`/`token` are required by the time a command actually talks to the Coordinator; `group`/`user` are optional everywhere.
17
+ Read from flags (`--url`, `--key`), then environment (`THUB_URL`, `THUB_KEY`, `THUB_GROUP`, `THUB_USER`; the old names `--token`/`THUB_TOKEN` still work), then `~/.config/thub/agent.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
18
 
19
19
  `npm install -g` creates `~/.config/thub/agent.json` for you (blank `url`/`token`, so nothing works until you set them) if it doesn't already exist — a re-install never overwrites it. Fill it in with `thub config set`:
20
20
 
21
21
  ```bash
22
22
  thub config set url https://thub.example.com
23
- thub config set token agt_...
23
+ thub config set key thk_... # your access key: from an admin (Users), or your dashboard profile
24
+ thub whoami # jane (user) <jane@example.com>
25
+ thub key show # its last characters, created, last used
26
+ thub key rotate # a new key; the old one stops at once (saved here if it came from here)
24
27
  thub config set group 548ae4ae-... # optional default --group
25
28
  thub config set user "Your Name" # optional default --user
26
29
  ```
@@ -29,10 +32,10 @@ thub config set user "Your Name" # optional default --user
29
32
 
30
33
  ```
31
34
  thub run [options] Submit a test job and follow its log
32
- thub status <jobId> Show status; follow log if running, verdict and test counts if done
35
+ thub status <jobId> Show status; follow log if running, verdict, test counts and artifacts if done
33
36
  thub cancel <jobId> Cancel a job
34
37
  thub resources List resources and their status
35
- thub jobs [--mine] [--state <s>] List recent jobs
38
+ thub jobs [--mine] [--state <s>] List recent jobs (a cli token: only its own; a ci token: all, or its own with --mine)
36
39
  thub config set <key> <value> Save coordinator URL / token / default group / default user locally
37
40
  thub check-update Compare this Agent with the latest published version
38
41
  thub self-update [--to <x.y.z>] Update this Agent with npm i -g
@@ -249,7 +252,7 @@ test-sw:
249
252
  runs-on: ubuntu-latest
250
253
  env:
251
254
  THUB_URL: https://thub.example.com
252
- THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
255
+ THUB_KEY: ${{ secrets.THUB_CI_TOKEN }} # a CI token (dashboard → CI tokens)
253
256
  steps:
254
257
  - run: |
255
258
  npx -y @andrian.yablonskyy/thub-agent run --type sw \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-agent",
3
- "version": "1.0.33",
3
+ "version": "1.1.0",
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.32",
15
+ "@andrian.yablonskyy/thub-common": "^1.1.0",
16
16
  "commander": "^13.1.0"
17
17
  },
18
18
  "devDependencies": {
package/src/cli.js CHANGED
@@ -15,13 +15,13 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const { Command } = require('commander'),
18
+ const { Command, Option } = require('commander'),
19
19
  {
20
20
  ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
21
21
  splitArgs,
22
22
  parseEnvList
23
23
  } = require('@andrian.yablonskyy/thub-common'),
24
- { resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile } = require('./config'),
24
+ { resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile, CONFIG_PATH } = require('./config'),
25
25
  { parseDurationSec } = require('./duration'),
26
26
  { followJob } = require('./streaming'),
27
27
  { applyRequestedUpdate, installAgent, version } = require('./self-update');
@@ -31,7 +31,8 @@ program
31
31
  .name('thub')
32
32
  .description('TestHub Agent — submit test jobs and follow them, from CI or your laptop')
33
33
  .option('--url <url>', 'Coordinator URL (overrides THUB_URL / config file)')
34
- .option('--token <token>', 'Agent token (overrides THUB_TOKEN / config file)')
34
+ .option('--key <key>', 'Your access key (overrides THUB_KEY / config file)')
35
+ .addOption(new Option('--token <token>', 'Old name of --key').hideHelp())
35
36
  .version(version);
36
37
 
37
38
  // An admin-requested self-update (README §10.2) is applied at the start of
@@ -252,7 +253,7 @@ program
252
253
 
253
254
  program
254
255
  .command('status')
255
- .description('Show status; follow log if running, verdict and test counts if done')
256
+ .description('Show status; follow log if running, verdict, test counts and artifacts if done')
256
257
  .argument('<jobId>')
257
258
  .option('--json', 'Print the job as JSON once, without following it; exit code: its verdict, or 5 while it\'s still active', false)
258
259
  .action(async (jobId, opts) => {
@@ -282,6 +283,13 @@ program
282
283
  if (job.message){
283
284
  console.log(`Message: ${job.message}`);
284
285
  }
286
+ // What the job published elsewhere and reported (links, not files).
287
+ if (job.artifacts?.length){
288
+ console.log('Artifacts:');
289
+ for (const a of job.artifacts){
290
+ console.log(` ${a.name} ${a.size != null ? formatBytes(a.size) : '—'} ${a.link}`);
291
+ }
292
+ }
285
293
  process.exit(exitCodeForJobState(job.state));
286
294
  }
287
295
  catch (err){
@@ -330,7 +338,7 @@ program
330
338
  program
331
339
  .command('jobs')
332
340
  .description('List recent jobs')
333
- .option('--mine', 'Only jobs submitted by this agent token', false)
341
+ .option('--mine', 'Only jobs submitted by this agent token (always so for a cli token, which sees only its own)', false)
334
342
  .option('--state <state>', 'Filter by state')
335
343
  .option('--json', 'Machine-readable output', false)
336
344
  .action(async (opts) => {
@@ -352,19 +360,94 @@ program
352
360
  }
353
361
  });
354
362
 
363
+ // 1233 -> "1.2 KB" (1024-based), for artifact sizes.
364
+ function formatBytes(bytes){
365
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
366
+ let value = bytes,
367
+ unit = 0;
368
+ while (value >= 1024 && unit < units.length - 1){
369
+ value /= 1024;
370
+ unit += 1;
371
+ }
372
+ return unit === 0 ? `${value} B` : `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
373
+ }
374
+
355
375
  const config = program.command('config').description('Manage local Agent configuration');
356
376
  config
357
377
  .command('set')
358
- .argument('<key>', 'url | token | group | user')
378
+ .argument('<name>', 'url | key | group | user')
359
379
  .argument('<value>')
360
- .action((key, value) => {
361
- if (!['url', 'token', 'group', 'user'].includes(key)){
362
- console.error('Error: key must be "url", "token", "group", or "user"');
380
+ .action((name, value) => {
381
+ // `token` is the old name of `key`.
382
+ const setting = name === 'token' ? 'key' : name;
383
+ if (!['url', 'key', 'group', 'user'].includes(setting)){
384
+ console.error('Error: the setting must be "url", "key", "group", or "user"');
363
385
  process.exit(EXIT_CODES.USAGE);
364
386
  }
365
- const current = readConfigFile();
366
- writeConfigFile({ ...current, [key]: value });
367
- console.log(`Saved ${key} to config`);
387
+ const { token: _old, ...current } = readConfigFile();
388
+ writeConfigFile({ ...(setting === 'key' ? current : { ...current, ...(_old ? { token: _old } : {}) }), [setting]: value });
389
+ console.log(`Saved ${setting} to config`);
390
+ });
391
+
392
+ // Who this access key belongs to (§10.3).
393
+ function describeMe(me){
394
+ const role = me.user?.role ? me.user.role.charAt(0).toUpperCase() + me.user.role.slice(1) : '';
395
+ return me.user ? `${me.user.username} (${role})${me.user.email ? ` <${me.user.email}>` : ''}` : `CI token "${me.name}"`;
396
+ }
397
+
398
+ program
399
+ .command('whoami')
400
+ .description('Show whose access key this Agent uses')
401
+ .action(async () => {
402
+ try {
403
+ console.log(describeMe(await client().get('/me')));
404
+ }
405
+ catch (err){
406
+ fail(err);
407
+ }
408
+ });
409
+
410
+ const keyCommand = program.command('key').description('Your access key');
411
+ keyCommand
412
+ .command('show')
413
+ .description('Show your access key\'s details (never the key itself — only its last characters)')
414
+ .action(async () => {
415
+ try {
416
+ const me = await client().get('/me'),
417
+ { keySource } = resolveConnection(program.opts());
418
+ console.log(`User: ${describeMe(me)}`);
419
+ if (me.key){
420
+ console.log(`Key: …${me.key.hint || '????'} (from ${keySource === 'file' ? CONFIG_PATH : keySource === 'flag' ? '--key' : keySource})`);
421
+ console.log(`Created: ${formatDateTime(me.key.createdAt)}`);
422
+ console.log(`Last used: ${me.key.lastUsedAt ? formatDateTime(me.key.lastUsedAt) : 'never'}`);
423
+ }
424
+ }
425
+ catch (err){
426
+ fail(err);
427
+ }
428
+ });
429
+ keyCommand
430
+ .command('rotate')
431
+ .description('Replace your access key: the current one stops working at once')
432
+ .action(async () => {
433
+ try {
434
+ const { keySource } = resolveConnection(program.opts()),
435
+ { key, username } = await client().post('/me/key/rotate');
436
+ if (keySource === 'file'){
437
+ const { token: _old, ...current } = readConfigFile();
438
+ writeConfigFile({ ...current, key });
439
+ console.log(`New access key for ${username} saved to ${CONFIG_PATH} — the old one has stopped working.`);
440
+ }
441
+ else {
442
+ console.log(`New access key for ${username} — the old one has stopped working. Shown only now:\n\n ${key}\n`);
443
+ console.log(keySource === 'flag'
444
+ ? 'Use it with --key from now on (or save it: thub config set key <key>).'
445
+ : `Update ${keySource} (e.g. your CI secret) with it, or save it: thub config set key <key>.`);
446
+ }
447
+ }
448
+ catch (err){
449
+ fail(err);
450
+ }
368
451
  });
369
452
 
370
453
  program
package/src/config.js CHANGED
@@ -18,7 +18,8 @@ const fs = require('node:fs'),
18
18
  path = require('node:path');
19
19
 
20
20
  // §7: "Configuration is read from flags, then environment (THUB_URL,
21
- // THUB_TOKEN), then ~/.config/thub/agent.json."
21
+ // THUB_KEY), then ~/.config/thub/agent.json." The access key (§10.3) used to
22
+ // be called a token: --token, THUB_TOKEN and a saved `token` still work.
22
23
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'thub', 'agent.json'),
23
24
 
24
25
  // Bundled with the package as a last-resort default, below the user's own
@@ -44,19 +45,24 @@ function writeConfigFile(config){
44
45
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
45
46
  }
46
47
 
48
+ // `keySource` says where the key came from — `thub key rotate` saves the
49
+ // new one only where it can (the config file), and says what to update
50
+ // otherwise.
47
51
  function resolveConnection(flags = {}){
48
52
  const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() },
49
53
  url = flags.url || process.env.THUB_URL || file.url,
50
- token = flags.token || process.env.THUB_TOKEN || file.token;
54
+ [token, keySource] = flags.key || flags.token ? [flags.key || flags.token, 'flag']
55
+ : process.env.THUB_KEY || process.env.THUB_TOKEN ? [process.env.THUB_KEY || process.env.THUB_TOKEN, process.env.THUB_KEY ? 'THUB_KEY' : 'THUB_TOKEN']
56
+ : file.key || file.token ? [file.key || file.token, 'file'] : [null, null];
51
57
  if (!url || !token){
52
58
  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.'
59
+ 'Missing Coordinator URL or access key. Set them with `thub config set url <url>` and `thub config set key <key>`, ' +
60
+ 'or THUB_URL / THUB_KEY, or --url / --key. An admin gives you your key (Users), or create it on your dashboard profile.'
55
61
  );
56
62
  err.status = 4;
57
63
  throw err;
58
64
  }
59
- return { url, token };
65
+ return { url, token, keySource };
60
66
  }
61
67
 
62
68
  // Optional default for `--group` (§4.3/§7.1) — same flags > env > file