@bli-cockpit/cli 0.2.124 → 0.2.126

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.
@@ -0,0 +1,111 @@
1
+ import { stat, writeFile } from 'node:fs/promises';
2
+ import { writeLine } from './cli-io.js';
3
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from './agent-door.js';
4
+ /**
5
+ * `cockpit careers resume <id>` — the drawer's "Open resume" button, typed
6
+ * (BLI-4462).
7
+ *
8
+ * It lives beside `careers.ts` rather than inside it because it is the only
9
+ * careers verb that does not end at `emitAgentDoor`: the Tower door answers
10
+ * with a SIGNED URL that lives 60 seconds, and `--out <path>` spends it here,
11
+ * fetching the private object straight from storage and writing the bytes to
12
+ * disk. Two requests, one act.
13
+ *
14
+ * The URL is a bearer credential for that object, so it goes to stdout only
15
+ * when a person asked for it and NEVER into a log line: every `[careers
16
+ * resume]` line carries the application id, a byte count, the seconds left and
17
+ * a reason label. The overwrite refusal is checked twice, once before the
18
+ * link is fetched so the sentence is the useful one, and again by opening the
19
+ * file with `wx`, which is what actually closes the race.
20
+ */
21
+ const TAG = '[careers resume]';
22
+ const RESUME_TIMEOUT_MS = 60_000;
23
+ /** What the door promises the link is good for. Read from the answer when it says. */
24
+ const DEFAULT_EXPIRES_IN_SECONDS = 60;
25
+ export async function runCareersResume(command, door) {
26
+ const id = command.id ?? '';
27
+ const outPath = command.outPath;
28
+ if (outPath !== undefined && command.force !== true && await fileExists(outPath)) {
29
+ return failAgentDoor(door, TAG, 'file_exists', `${outPath} is already there, so nothing was downloaded. Pass --force to overwrite it, or name another path.`);
30
+ }
31
+ const answer = await askAgentDoor(door, {
32
+ path: `/api/careers/applications/${encodeURIComponent(id)}/resume-url`,
33
+ method: 'GET',
34
+ label: 'careers resume',
35
+ timeoutMs: RESUME_TIMEOUT_MS,
36
+ });
37
+ if (!answer.ok)
38
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
39
+ const url = readUrl(answer.body);
40
+ if (!url) {
41
+ return failAgentDoor(door, TAG, 'no_signed_url', 'Tower answered that door without a link, so there is nothing to open or download. That application may have no resume on file.');
42
+ }
43
+ const expiresInSeconds = readExpiry(answer.body);
44
+ if (outPath === undefined)
45
+ return emitLink(door, id, url, expiresInSeconds);
46
+ return downloadResume(door, { id, url, outPath, force: command.force === true, expiresInSeconds });
47
+ }
48
+ /** No `--out`: the link itself, on stdout, and never in the receipt line. */
49
+ function emitLink(door, id, url, expiresInSeconds) {
50
+ writeLine(door.io.stderr, `${TAG} link ${JSON.stringify({ application_id: id, expires_in_seconds: expiresInSeconds, reason: 'signed_url_issued' })}`);
51
+ if (door.json)
52
+ return emitAgentDoor(door, { url, expires_in_seconds: expiresInSeconds });
53
+ writeLine(door.io.stdout, url);
54
+ return 0;
55
+ }
56
+ async function downloadResume(door, file) {
57
+ // Straight to storage, not through Tower: this URL IS the authorisation, and
58
+ // the device token has no business on a request to another host.
59
+ let response;
60
+ try {
61
+ response = await door.io.fetch(file.url, { method: 'GET' });
62
+ }
63
+ catch (error) {
64
+ return failAgentDoor(door, TAG, 'download_failed', `Could not reach the storage that holds that resume: ${error instanceof Error ? error.message : String(error)}. Nothing was written to ${file.outPath}.`);
65
+ }
66
+ if (!response.ok) {
67
+ return failAgentDoor(door, TAG, `http_${response.status}`, `Storage answered ${response.status} for that resume; the link is only good for ${file.expiresInSeconds} seconds. Nothing was written to ${file.outPath}.`);
68
+ }
69
+ const bytes = Buffer.from(await response.arrayBuffer());
70
+ try {
71
+ // `wx` fails rather than clobbering: the check above is the good sentence,
72
+ // this is the one that holds when the file appears in between.
73
+ await writeFile(file.outPath, bytes, file.force ? {} : { flag: 'wx' });
74
+ }
75
+ catch (error) {
76
+ const reason = error?.code === 'EEXIST' ? 'file_exists' : 'write_failed';
77
+ writeLine(door.io.stderr, `${TAG} download ${JSON.stringify({ application_id: file.id, bytes: bytes.length, reason })}`);
78
+ return failAgentDoor(door, TAG, reason, `Downloaded ${bytes.length} bytes and could not write them to ${file.outPath}: ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ writeLine(door.io.stderr, `${TAG} download ${JSON.stringify({ application_id: file.id, bytes: bytes.length, reason: 'written' })}`);
81
+ if (door.json)
82
+ return emitAgentDoor(door, { path: file.outPath, bytes: bytes.length });
83
+ writeLine(door.io.stdout, `Wrote ${bytes.length} bytes to ${file.outPath}.`);
84
+ return 0;
85
+ }
86
+ function readUrl(body) {
87
+ if (!body || typeof body !== 'object')
88
+ return null;
89
+ const value = body['url'];
90
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
91
+ }
92
+ /**
93
+ * The door answers `expires_in`; every surface a person reads says
94
+ * `expires_in_seconds`, because "60" with no unit is the kind of number that
95
+ * gets read as minutes.
96
+ */
97
+ function readExpiry(body) {
98
+ if (!body || typeof body !== 'object')
99
+ return DEFAULT_EXPIRES_IN_SECONDS;
100
+ const value = body['expires_in'];
101
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_EXPIRES_IN_SECONDS;
102
+ }
103
+ async function fileExists(path) {
104
+ try {
105
+ await stat(path);
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './agent-door.js';
3
+ import { runCareersResume } from './careers-resume.js';
3
4
  /**
4
5
  * `cockpit careers` — application review (BLI-3706) and, since BLI-4460, the
5
6
  * pipeline: invite, decide, the role's take-home and the settings. BLI-4461
@@ -9,9 +10,15 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './age
9
10
  * decides policy. The take-home body arrives from a FILE rather than a flag,
10
11
  * because an email body does not belong in an argument vector, a shell history
11
12
  * or a process list.
13
+ *
14
+ * `resume` (BLI-4462) is the one verb that does not end at `emitAgentDoor`,
15
+ * because its answer is a 60-second signed URL and `--out` spends it on a
16
+ * download. That work lives in `careers-resume.ts`.
12
17
  */
13
18
  export async function runCareers(command, io) {
14
19
  const door = await openAgentDoor('careers', command, io);
20
+ if (command.action === 'resume')
21
+ return runCareersResume(command, door);
15
22
  let plan;
16
23
  try {
17
24
  plan = await planCareersRequest(command);
@@ -47,6 +54,10 @@ async function planCareersRequest(command) {
47
54
  // names where the run actually happens rather than implying it ran here.
48
55
  case 'grade': return { path: `${application}/grade`, method: 'POST', body: { now: command.now === true } };
49
56
  case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
57
+ // `resume` never arrives here: `runCareers` hands it to `careers-resume.ts`
58
+ // above, which owns both of its requests. The case keeps the switch
59
+ // exhaustive, so a verb added later cannot fall through this table unseen.
60
+ case 'resume': throw new Error('careers resume is answered by runCareersResume, not by this table.');
50
61
  case 'takehome-show': return { path: takehomePath(command), method: 'GET' };
51
62
  case 'takehome-set': return { path: takehomePath(command), method: 'PUT', body: await takehomeBody(command) };
52
63
  case 'settings-show': return { path: '/api/careers/settings', method: 'GET' };
@@ -1,12 +1,14 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
- const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
2
+ const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'resume', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
3
+ /** Typed word → the one action it means. The left side is what a person reads on the button. */
4
+ const ALSO_SPELLED = { 'send-takehome': 'invite' };
3
5
  const DECISIONS = ['passed_on', 'archived', 'reopen'];
4
6
  /** Verbs that name an application id, a role slug, or nothing at all. */
5
- const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade'];
7
+ const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade', 'resume'];
6
8
  const NEEDS_ROLE = ['takehome-show', 'takehome-set'];
7
9
  export function parseCareersArgs(args) {
8
- const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled', '--booking-link', '--grader-model'];
9
- const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now'], valueFlags });
10
+ const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled', '--booking-link', '--grader-model', '--out'];
11
+ const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now', '--force'], valueFlags });
10
12
  const positionals = [...values.positionals];
11
13
  // `takehome show` and `settings set` are two words a person types and one
12
14
  // action everything downstream reads.
@@ -14,15 +16,18 @@ export function parseCareersArgs(args) {
14
16
  // `team device` reads as `team device list`: the noun alone is a question.
15
17
  const first = positionals[0] ?? 'list';
16
18
  const paired = first === 'takehome' || first === 'settings';
17
- const action = paired
19
+ const typed = paired
18
20
  ? `${first}-${positionals.splice(0, positionals[1] === undefined ? 1 : 2)[1] ?? 'show'}`
19
21
  : (positionals.shift() ?? 'list');
22
+ // `send-takehome` is the button's own words; `invite` is the older spelling
23
+ // of the same act. One action from here on, the typed word only for errors.
24
+ const action = ALSO_SPELLED[typed] ?? typed;
20
25
  if (!ACTIONS.includes(action))
21
- throw new Error(`careers takes ${ACTIONS.join(', ')}.`);
26
+ throw new Error(`careers takes ${[...ACTIONS, ...Object.keys(ALSO_SPELLED)].join(', ')}.`);
22
27
  const verb = action;
23
28
  const target = positionals.shift();
24
29
  if (NEEDS_ID.includes(verb) && !target)
25
- throw new Error(`careers ${verb} needs an application id.`);
30
+ throw new Error(`careers ${typed} needs an application id.`);
26
31
  if (NEEDS_ROLE.includes(verb) && !target)
27
32
  throw new Error(`careers ${verb.replace('-', ' ')} needs a role slug.`);
28
33
  if (positionals.length > 0)
@@ -43,6 +48,16 @@ export function parseCareersArgs(args) {
43
48
  const enabledRaw = optionalNonEmpty(values.flags.get('--enabled'));
44
49
  if (enabledRaw !== undefined && enabledRaw !== 'true' && enabledRaw !== 'false')
45
50
  throw new Error('--enabled must be true or false.');
51
+ // The resume is the only thing this noun writes to disk, so both file flags
52
+ // belong to it alone and say so rather than being accepted and ignored.
53
+ const outPath = optionalNonEmpty(values.flags.get('--out'));
54
+ const force = values.booleans.has('--force');
55
+ if (outPath !== undefined && verb !== 'resume')
56
+ throw new Error('--out belongs to `cockpit careers resume <id>`.');
57
+ if (force && verb !== 'resume')
58
+ throw new Error('--force belongs to `cockpit careers resume <id> --out <path>`.');
59
+ if (force && outPath === undefined)
60
+ throw new Error('--force only means something beside --out <path>: with no file to write there is nothing to overwrite.');
46
61
  return {
47
62
  kind: 'careers', action: verb,
48
63
  id: NEEDS_ID.includes(verb) ? target : undefined,
@@ -55,6 +70,7 @@ export function parseCareersArgs(args) {
55
70
  bookingLink: httpsFlag(values.flags.get('--booking-link')),
56
71
  graderModel: optionalNonEmpty(values.flags.get('--grader-model')),
57
72
  now: values.booleans.has('--now'),
73
+ outPath, force,
58
74
  autoInviteMinScore, attentionMinScore,
59
75
  autoInviteEnabled: enabledRaw === undefined ? undefined : enabledRaw === 'true',
60
76
  homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json'),
@@ -25,13 +25,14 @@ export const TOWER_COMMAND_HELP = [
25
25
  [
26
26
  "careers",
27
27
  [
28
- "Usage: cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <d>|grade <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
28
+ "Usage: cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <d>|grade <id>|resume <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
29
29
  "",
30
30
  "Super-admin application review and pipeline. Lists at most 100 matches with total and has_more; use filters to narrow.",
31
31
  "list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
32
- "invite <id> sends that role's take-home from the configured mailbox, whatever the screening scored. Only a row still at `screened`.",
32
+ "send-takehome <id> (also `invite <id>`): sends that role's take-home from the configured mailbox, whatever the screening scored. The same act as Tower's Send take-home button, and only a row still at `screened`.",
33
33
  "decide <id> --decision <passed_on|archived|reopen> — your own call on a row; `reopen` puts it back to `screened`.",
34
34
  "grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 15 minutes; --now says so rather than pretending the run happened here.",
35
+ "resume <id> [--out <path>] [--force]: the candidate's resume, the same file the drawer's Open resume button shows. Bare, it prints a signed link that dies in 60 seconds; --out downloads the file to that path and refuses to overwrite one already there unless you pass --force.",
35
36
  "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
36
37
  "takehome set <role> [--subject <s>] [--body-file <path>] [--link <url>] [--pass-score <0-10>] — the body comes from a FILE and must keep {first_name} and {link}.",
37
38
  "settings show — the bar, the attention threshold, whether automatic sending is on, and which mailbox it goes out from.",
@@ -104,7 +104,7 @@ export function localCommandHelp(command) {
104
104
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
105
105
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
106
106
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
107
- " cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--json]",
107
+ " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
108
108
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
109
109
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
110
110
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.124");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.126");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.124",
3
+ "version": "0.2.126",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.32",
31
- "@bli-cockpit/mcp": "0.1.51",
31
+ "@bli-cockpit/mcp": "0.1.53",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }