@apex-actions/cli 0.75.0 → 0.76.1

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.
@@ -1,10 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { createToken, editToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
4
- import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, webBaseFor } from '../../src/domain/credentials.js';
4
+ import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, loopbackTimeoutHint, webBaseFor } from '../../src/domain/credentials.js';
5
5
  import { TOKEN_ENV, WEB_URL_ENV, apiUrlFrom } from '../../src/infrastructure/credentials.js';
6
6
  import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
7
7
  import { api, collect, usage } from '../secret/shared.js';
8
+ import { approvalFlow, withFlowOptions } from '../login/device.js';
8
9
  /**
9
10
  * `apex token`: the person's API tokens from the terminal (control-plane
10
11
  * ADR-0064, amended 2026-09-27).
@@ -22,38 +23,49 @@ import { api, collect, usage } from '../secret/shared.js';
22
23
  * Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
23
24
  */
24
25
  const PERMISSION_HELP = `resource:read or resource:write (write implies read); resource is one of ${TOKEN_RESOURCES.join(', ')}`;
25
- /** The browser half `create` and a widening `edit` share. */
26
- const approvalDeps = (browser, what) => {
26
+ /**
27
+ * The approval half `create` and a widening `edit` share — the loopback on this machine, or a device code
28
+ * (control-plane ADR-0075). Everything a person reads goes to stderr: stdout is the token's alone.
29
+ */
30
+ const approvalDeps = (flags, what) => {
27
31
  const apiUrl = apiUrlFrom();
32
+ const chosen = approvalFlow(flags, apiUrl, (line) => console.error(line));
33
+ if ('problem' in chosen)
34
+ return chosen;
28
35
  return {
29
36
  pkce: newPkcePair,
30
37
  listen: (state) => listenForCallback(state),
31
- openBrowser: browser ? (url) => openInBrowser(url) : undefined,
38
+ openBrowser: (url) => openInBrowser(url),
32
39
  announce: (url) => {
33
- console.error(browser ? `Opening the browser to approve ${what}. If it does not open, visit:` : `Open this URL to approve ${what}:`);
40
+ console.error(`Opening the browser to approve ${what}. If it does not open, visit:`);
34
41
  console.error(` ${url}`);
35
42
  console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
36
43
  },
37
44
  exchange: new HttpLoginExchange(apiUrl),
38
45
  webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
46
+ flow: chosen.flow,
47
+ device: chosen.device,
39
48
  };
40
49
  };
41
50
  const refused = (message) => {
42
51
  console.error(chalk.red(message));
43
52
  process.exitCode = 1;
44
53
  };
45
- const createCommand = new Command('create')
46
- .description('Create an API token for a script or CI system, approved in the browser; the token is printed once and not saved')
54
+ const createCommand = withFlowOptions(new Command('create')
55
+ .description('Create an API token for a script or CI system, approved in the browser — here, or on any device with a code; the token is printed once and not saved')
47
56
  .argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
48
57
  .option('--permission <permission>', `what the token may do, repeatable and at least one: ${PERMISSION_HELP}`, collect, [])
49
58
  .option('--account <id>', 'the Account the token belongs to — needed when you belong to more than one')
50
59
  .option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
51
60
  .option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you reach in the Account', collect, [])
52
- .option('--no-browser', 'print the approval URL instead of opening a browser')
53
- .option('--json', 'print the token and its details as JSON on stdout')
54
- .action(async (name, opts) => {
61
+ .option('--json', 'print the token and its details as JSON on stdout')).action(async (name, opts) => {
55
62
  const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
56
- const outcome = await createToken(approvalDeps(opts.browser, 'this token'), {
63
+ const deps = approvalDeps(opts, 'this token');
64
+ if ('problem' in deps) {
65
+ usage(deps.problem);
66
+ return;
67
+ }
68
+ const outcome = await createToken(deps, {
57
69
  name,
58
70
  days,
59
71
  repos: opts.repo,
@@ -96,7 +108,10 @@ const createCommand = new Command('create')
96
108
  refused('the browser came back without a code; run apex token create again');
97
109
  return;
98
110
  case 'timeout':
99
- refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token create again');
111
+ refused(loopbackTimeoutHint('apex token create'));
112
+ return;
113
+ case 'expired':
114
+ refused('the code expired before anyone approved it; run apex token create again for a new one');
100
115
  return;
101
116
  case 'refused':
102
117
  refused(outcome.detail);
@@ -105,25 +120,28 @@ const createCommand = new Command('create')
105
120
  });
106
121
  const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all' : t.repos.join(', '));
107
122
  const day = (iso) => (iso === undefined ? '—' : iso.slice(0, 10));
108
- const editCommand = new Command('edit')
109
- .description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser')
123
+ const editCommand = withFlowOptions(new Command('edit')
124
+ .description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser — here, or on any device with a code')
110
125
  .argument('<id>', 'the token id, from apex token list')
111
126
  .option('--permission <permission>', `the token's whole new set of permissions, repeatable: ${PERMISSION_HELP}`, collect, [])
112
127
  .option('--add-permission <permission>', 'add one permission to what the token holds (repeatable)', collect, [])
113
128
  .option('--remove-permission <permission>', 'take one permission away (repeatable)', collect, [])
114
129
  .option('--repo <owner/repo>', "the token's new list of repositories (repeatable, up to 100)", collect, [])
115
130
  .option('--all-repos', 'let the token reach every repository you reach in its Account')
116
- .option('--no-browser', 'if the edit widens the token, print the approval URL instead of opening a browser')
117
- .option('--json', 'print the edited token as JSON')
118
- .action(async (id, opts) => {
131
+ .option('--json', 'print the edited token as JSON')).action(async (id, opts) => {
119
132
  if (opts.allRepos === true && opts.repo.length > 0) {
120
133
  usage('pass --repo or --all-repos, not both');
121
134
  return;
122
135
  }
136
+ const deps = approvalDeps(opts, 'widening this token');
137
+ if ('problem' in deps) {
138
+ usage(deps.problem);
139
+ return;
140
+ }
123
141
  const client = api();
124
142
  if (!client)
125
143
  return;
126
- const outcome = await editToken(client, approvalDeps(opts.browser, 'widening this token'), id, {
144
+ const outcome = await editToken(client, deps, id, {
127
145
  ...(opts.permission.length > 0 ? { permissions: opts.permission } : {}),
128
146
  add: opts.addPermission,
129
147
  remove: opts.removePermission,
@@ -164,7 +182,10 @@ const editCommand = new Command('edit')
164
182
  refused('the browser came back without a code; run apex token edit again');
165
183
  return;
166
184
  case 'timeout':
167
- refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token edit again');
185
+ refused(loopbackTimeoutHint('apex token edit'));
186
+ return;
187
+ case 'expired':
188
+ refused('the code expired before anyone approved the widening; run apex token edit again for a new one');
168
189
  return;
169
190
  case 'refused':
170
191
  refused(outcome.detail);
@@ -32,8 +32,71 @@ export const approveInBrowser = async (deps, token, accountId) => {
32
32
  await listener.close();
33
33
  }
34
34
  };
35
+ /** RFC 8628 §3.2's default when a server names no interval. */
36
+ const DEFAULT_INTERVAL_SECONDS = 5;
37
+ /**
38
+ * The device flow (control-plane ADR-0075, RFC 8628 run by Apex): ask for a request, show the code, and
39
+ * poll with the device code — which never leaves this process — every `interval` seconds, slowing down
40
+ * when told, until the person approves or denies on any device, or the code's ten minutes pass.
41
+ */
42
+ export const approveOnDevice = async (deps, token, accountId) => {
43
+ const { device } = deps;
44
+ const purpose = token === undefined ? 'login' : (token.purpose ?? 'token');
45
+ let start;
46
+ try {
47
+ start = await device.exchange.start({
48
+ name: deps.name,
49
+ purpose,
50
+ ...(token?.days === undefined ? {} : { days: token.days }),
51
+ ...(token?.allRepos === true ? { allRepositories: true } : token?.repos !== undefined && token.repos.length > 0 ? { repos: token.repos } : {}),
52
+ ...(token?.permissions === undefined ? {} : { permissions: token.permissions }),
53
+ ...((token?.accountId ?? accountId) === undefined ? {} : { accountId: token?.accountId ?? accountId }),
54
+ ...(token?.tokenId === undefined ? {} : { tokenId: token.tokenId }),
55
+ ...(device.os === undefined ? {} : { os: device.os }),
56
+ ...(device.hostname === undefined ? {} : { hostname: device.hostname }),
57
+ });
58
+ }
59
+ catch (err) {
60
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
61
+ }
62
+ device.announceCode(start, device.why);
63
+ let interval = Number.isFinite(start.interval) && start.interval >= 0 ? start.interval : DEFAULT_INTERVAL_SECONDS;
64
+ const deadline = device.now() + start.expiresIn * 1000;
65
+ for (;;) {
66
+ await device.sleep(interval * 1000);
67
+ let answer;
68
+ try {
69
+ answer = await device.exchange.poll(start.deviceCode);
70
+ }
71
+ catch (err) {
72
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
73
+ }
74
+ switch (answer.kind) {
75
+ case 'minted':
76
+ return { kind: 'minted', minted: answer.minted };
77
+ case 'slow_down':
78
+ // RFC 8628 §3.5: add five seconds — or keep to what the server now says.
79
+ interval = answer.interval ?? interval + 5;
80
+ break;
81
+ case 'authorization_pending':
82
+ if (answer.interval !== undefined)
83
+ interval = answer.interval;
84
+ break;
85
+ case 'access_denied':
86
+ return { kind: 'denied', detail: 'denied on the approval page' };
87
+ case 'expired_token':
88
+ return { kind: 'expired' };
89
+ case 'invalid_grant':
90
+ return { kind: 'refused', detail: 'the control plane does not know this device request — run the command again' };
91
+ }
92
+ if (device.now() >= deadline)
93
+ return { kind: 'expired' };
94
+ }
95
+ };
96
+ /** The approval, by whichever flow was chosen: the loopback unless `flow` is `device`. */
97
+ export const approve = async (deps, token, accountId) => deps.flow === 'device' && deps.device !== undefined ? await approveOnDevice({ ...deps, device: deps.device }, token, accountId) : await approveInBrowser(deps, token, accountId);
35
98
  export const login = async (deps) => {
36
- const approval = await approveInBrowser(deps, undefined, deps.accountId);
99
+ const approval = await approve(deps, undefined, deps.accountId);
37
100
  if (approval.kind !== 'minted')
38
101
  return approval;
39
102
  const { minted } = approval;
@@ -49,6 +112,24 @@ export const login = async (deps) => {
49
112
  accountId: minted.accountId,
50
113
  };
51
114
  };
115
+ /**
116
+ * `apex login --console` (control-plane ADR-0079): the same loopback + PKCE approval, opened on the Admin
117
+ * Console's bare page rather than the app's — `deps.webBase` is the Console's — and redeemed at the same
118
+ * control plane, for an **operator token**. Only the loopback: an operator approves in the Console's own
119
+ * browser session. What comes back must be an operator token, or nothing is saved.
120
+ */
121
+ export const consoleLogin = async (deps) => {
122
+ const approval = await approveInBrowser({ ...deps, flow: 'loopback' });
123
+ if (approval.kind !== 'minted')
124
+ return approval;
125
+ const { minted } = approval;
126
+ if (minted.token === undefined)
127
+ return { kind: 'refused', detail: 'the control plane answered the login without a token' };
128
+ if (!minted.token.startsWith(deps.operatorPrefix))
129
+ return { kind: 'refused', detail: 'the control plane answered with an Account token, not an operator token — nothing was saved' };
130
+ await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
131
+ return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
132
+ };
52
133
  /**
53
134
  * Revoke the saved token at the control plane that minted it, then forget it
54
135
  * (control-plane ADR-0064: a token may revoke itself and nothing else). The file
@@ -0,0 +1,21 @@
1
+ import { withOperatorHint } from '../domain/grants.js';
2
+ const attempt = async (fn) => {
3
+ try {
4
+ return { kind: 'ok', value: await fn() };
5
+ }
6
+ catch (err) {
7
+ return { kind: 'refused', message: withOperatorHint(err instanceof Error ? err.message : String(err)) };
8
+ }
9
+ };
10
+ /** Every grant ever made, newest first. The control plane decides who may read them. */
11
+ export const listGrants = async (api) => await attempt(() => api.listGrants());
12
+ /**
13
+ * Grant an installation complimentary access, replacing any open grant it holds. Only a person may: the
14
+ * control plane records the operator the token belongs to, and refuses the machine token.
15
+ */
16
+ export const createGrant = async (api, body) => await attempt(() => api.createGrant(body));
17
+ export const updateGrant = async (api, id, body) => await attempt(() => api.updateGrant(id, body));
18
+ /** End a grant now; its queued free work is cancelled with a reason on each check run. */
19
+ export const revokeGrant = async (api, id) => await attempt(() => api.revokeGrant(id));
20
+ /** Every installation GitHub lists for the App — `fresh` keeps only those the tenants list never shows. */
21
+ export const listInstallations = async (api, fresh) => await attempt(async () => (await api.listInstallations()).filter((i) => !fresh || !i.listedAsTenant));
@@ -1,6 +1,6 @@
1
1
  // application layer — see ../../../../docs/conventions.md §1
2
2
  import { editedPermissions, tokenEditProblem, tokenRequestProblem } from '../domain/credentials.js';
3
- import { approveInBrowser } from './authenticate.js';
3
+ import { approve } from './authenticate.js';
4
4
  const detail = (err) => (err instanceof Error ? err.message : String(err));
5
5
  /**
6
6
  * Mint a token through the browser. The flags are checked before a browser
@@ -11,7 +11,7 @@ export const createToken = async (deps, input) => {
11
11
  const problem = tokenRequestProblem(name, input.days, input.repos, input.permissions);
12
12
  if (problem !== undefined)
13
13
  return { kind: 'invalid', detail: problem };
14
- const approval = await approveInBrowser({ ...deps, name }, {
14
+ const approval = await approve({ ...deps, name }, {
15
15
  purpose: 'token',
16
16
  permissions: [...new Set(input.permissions)],
17
17
  ...(input.days === undefined ? {} : { days: input.days }),
@@ -81,7 +81,7 @@ export const editToken = async (api, approval, id, edit) => {
81
81
  if (answer.status !== 403)
82
82
  return { kind: 'refused', detail: answer.detail };
83
83
  // A widening: a person approves it in the browser, within the Account's policy.
84
- const approved = await approveInBrowser({ ...approval, name: target.name }, {
84
+ const approved = await approve({ ...approval, name: target.name }, {
85
85
  purpose: 'edit',
86
86
  tokenId: id,
87
87
  permissions,
@@ -19,7 +19,30 @@ export const describeSource = (source) => {
19
19
  case 'login':
20
20
  return 'the token `apex login` saved';
21
21
  case 'operator':
22
- return 'APEX_ADMIN_TOKEN, the operator token';
22
+ return 'APEX_ADMIN_TOKEN, the machine token';
23
+ case 'console':
24
+ return 'the operator token `apex login --console` saved';
25
+ }
26
+ };
27
+ /** An operator token's prefix (control-plane ADR-0079); an Account token's is `apex_pat_`. */
28
+ export const OPERATOR_TOKEN_PREFIX = 'apex_opt_';
29
+ /**
30
+ * The Admin Console's base URL, where `apex login --console` is approved (control-plane ADR-0079):
31
+ * `APEX_CONSOLE_URL` when set, else the API's host with `app.` read as `admin.` — the hosted service's
32
+ * pair — else `undefined`, and the command asks for `APEX_CONSOLE_URL`.
33
+ */
34
+ export const consoleBaseFor = (apiUrl, consoleUrl) => {
35
+ if (consoleUrl !== undefined && consoleUrl !== '')
36
+ return normaliseUrl(consoleUrl);
37
+ try {
38
+ const url = new URL(apiUrl);
39
+ if (!url.hostname.startsWith('app.'))
40
+ return undefined;
41
+ url.hostname = `admin.${url.hostname.slice('app.'.length)}`;
42
+ return normaliseUrl(url.origin);
43
+ }
44
+ catch {
45
+ return undefined;
23
46
  }
24
47
  };
25
48
  /** A URL without its trailing slash, so two spellings of one origin compare equal. */
@@ -209,3 +232,49 @@ export const judgeCallback = (params, expectedState) => {
209
232
  return { kind: 'malformed' };
210
233
  return { kind: 'code', code };
211
234
  };
235
+ const set = (v) => v !== undefined && v !== '';
236
+ /**
237
+ * Why no browser can open on this machine, in words for the terminal — or `undefined` when one can.
238
+ * ADR-0075's list: an SSH session, a Codespace, a dev container, a container, or Linux with no display.
239
+ */
240
+ export const headlessReason = ({ env, platform, dockerenv }) => {
241
+ if (set(env['SSH_CONNECTION']) || set(env['SSH_TTY']))
242
+ return 'this is an SSH session';
243
+ if (set(env['CODESPACES']))
244
+ return 'this is a Codespace';
245
+ if (set(env['REMOTE_CONTAINERS']))
246
+ return 'this is a dev container';
247
+ if (dockerenv)
248
+ return 'this is a container';
249
+ if (platform === 'linux' && !set(env['DISPLAY']) && !set(env['WAYLAND_DISPLAY']))
250
+ return 'this Linux machine has no display';
251
+ return undefined;
252
+ };
253
+ /**
254
+ * Which flow to use. `--device` and `--no-browser` ask for the device flow (`--no-browser` once printed a
255
+ * loopback URL, which only ever worked on this same machine); `--browser` insists on the loopback;
256
+ * otherwise a headless environment chooses the device flow and says why.
257
+ */
258
+ export const chooseFlow = (flags, headless) => {
259
+ if (flags.device === true && flags.browser === true)
260
+ return { problem: 'pass --device or --browser, not both' };
261
+ if (flags.device === true || flags.browser === false)
262
+ return { flow: 'device' };
263
+ if (flags.browser === true)
264
+ return { flow: 'loopback' };
265
+ return headless === undefined ? { flow: 'loopback' } : { flow: 'device', why: headless };
266
+ };
267
+ /** The lines the terminal shows for a device approval — the code, where to enter it, and for how long. */
268
+ export const deviceInstructions = (start, why) => {
269
+ const minutes = Math.max(1, Math.round(start.expiresIn / 60));
270
+ return [
271
+ ...(why === undefined ? [] : [`No browser can open here (${why}), so approve with a code instead.`]),
272
+ `Open ${start.verificationUri} on any device and enter ${start.userCode}`,
273
+ `The code expires in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}. Enter it only on that page — Apex never asks for it anywhere else.`,
274
+ ];
275
+ };
276
+ /** RFC 8628 §3.5's answers to a poll that minted nothing, plus `invalid_grant` for a code never issued. */
277
+ export const DEVICE_POLL_ERRORS = ['authorization_pending', 'slow_down', 'access_denied', 'expired_token', 'invalid_grant'];
278
+ export const isDevicePollError = (v) => typeof v === 'string' && DEVICE_POLL_ERRORS.includes(v);
279
+ /** What a loopback timeout says: most likely the browser is on another machine, and there is a flow for that. */
280
+ export const loopbackTimeoutHint = (command) => `No approval arrived. If your browser is on another machine, run \`${command} --device\`.`;
@@ -0,0 +1,111 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ /**
3
+ * Complimentary access from the terminal (control-plane ADR-0056 as amended by
4
+ * ADR-0079, TASK-0199): the grant and installation shapes the admin API
5
+ * answers with, the checks `apex grant` makes before a round trip — the
6
+ * control plane makes them again — and how each reads as a line.
7
+ */
8
+ /** The admin API's limits on a grant (control-plane `domain/grant.ts`). */
9
+ export const MAX_GRANT_MINUTES = 120_000;
10
+ export const MAX_GRANT_CONCURRENCY = 50;
11
+ export const MAX_GRANT_REASON = 500;
12
+ const whole = (raw, min, max) => {
13
+ if (raw === undefined || !/^[0-9]+$/.test(raw.trim()))
14
+ return undefined;
15
+ const n = Number(raw.trim());
16
+ return Number.isSafeInteger(n) && n >= min && n <= max ? n : undefined;
17
+ };
18
+ /**
19
+ * `--until YYYY-MM-DD`: the grant ends at the end of that UTC day, as the Console's form has it — so the
20
+ * instant sent is the next day's midnight. A day that is not a real date, or not after `now`, is refused.
21
+ */
22
+ export const endOfDay = (day, now) => {
23
+ const value = day.trim();
24
+ const start = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00.000Z`) : new Date(Number.NaN);
25
+ if (Number.isNaN(start.getTime()) || start.toISOString().slice(0, 10) !== value)
26
+ return { ok: false, problem: `--until must be a date, YYYY-MM-DD, got ${JSON.stringify(day)}` };
27
+ const end = new Date(start.getTime() + 86_400_000);
28
+ if (end.getTime() <= now.getTime())
29
+ return { ok: false, problem: '--until must be today or later — the grant ends at the end of that UTC day' };
30
+ return { ok: true, value: end.toISOString() };
31
+ };
32
+ const minutesProblem = `--minutes must be a whole number from 1 to ${MAX_GRANT_MINUTES.toLocaleString('en-US')}`;
33
+ const concurrencyProblem = `--concurrency must be a whole number from 1 to ${MAX_GRANT_CONCURRENCY}`;
34
+ /** `apex grant create`'s arguments, checked. */
35
+ export const createGrantBody = (installation, flags, now) => {
36
+ const installationId = whole(installation, 1, Number.MAX_SAFE_INTEGER);
37
+ if (installationId === undefined)
38
+ return { ok: false, problem: `expected a positive installation id, got ${JSON.stringify(installation)}` };
39
+ const minutesPerMonth = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
40
+ if (minutesPerMonth === undefined)
41
+ return { ok: false, problem: minutesProblem };
42
+ const concurrency = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
43
+ if (concurrency === undefined)
44
+ return { ok: false, problem: concurrencyProblem };
45
+ const reason = flags.reason?.trim() ?? '';
46
+ if (reason === '' || reason.length > MAX_GRANT_REASON)
47
+ return { ok: false, problem: `--reason is required, up to ${MAX_GRANT_REASON} characters — who this is for, and why they run free` };
48
+ if (flags.until === undefined)
49
+ return { ok: true, value: { installationId, minutesPerMonth, concurrency, reason } };
50
+ const until = endOfDay(flags.until, now);
51
+ return until.ok ? { ok: true, value: { installationId, minutesPerMonth, concurrency, reason, expiresAt: until.value } } : until;
52
+ };
53
+ /** `apex grant update`'s flags, checked: at least one change, each well-formed, and not both an end date and none. */
54
+ export const updateGrantBody = (flags, now) => {
55
+ const body = {};
56
+ if (flags.minutes !== undefined) {
57
+ const n = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
58
+ if (n === undefined)
59
+ return { ok: false, problem: minutesProblem };
60
+ body.minutesPerMonth = n;
61
+ }
62
+ if (flags.concurrency !== undefined) {
63
+ const n = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
64
+ if (n === undefined)
65
+ return { ok: false, problem: concurrencyProblem };
66
+ body.concurrency = n;
67
+ }
68
+ if (flags.until !== undefined && flags.noEnd === true)
69
+ return { ok: false, problem: 'pass --until or --no-end, not both' };
70
+ if (flags.noEnd === true)
71
+ body.expiresAt = null;
72
+ if (flags.until !== undefined) {
73
+ const until = endOfDay(flags.until, now);
74
+ if (!until.ok)
75
+ return until;
76
+ body.expiresAt = until.value;
77
+ }
78
+ if (Object.keys(body).length === 0)
79
+ return { ok: false, problem: 'nothing to change: pass --minutes, --concurrency, --until or --no-end' };
80
+ return { ok: true, value: body };
81
+ };
82
+ const day = (iso) => iso?.slice(0, 10);
83
+ /** One grant as a line: whose, how it stands, the month so far, and who granted it and why. */
84
+ export const grantLine = (g) => {
85
+ const used = g.usedMinutes === undefined ? '' : `${Math.floor(g.usedMinutes).toLocaleString('en-US')} of `;
86
+ const state = g.state === 'ended' ? `ended ${day(g.endedAt ?? g.revokedAt) ?? ''}${g.revokedBy === undefined ? '' : ` by ${g.revokedBy}`}`.trim() : g.dormant ? `${g.state}, dormant — they pay for a plan` : g.state;
87
+ const until = g.expiresAt === undefined ? '' : `, until ${day(g.expiresAt) ?? ''}`;
88
+ return `${g.id} ${g.owner} (installation ${g.installationId}) — ${state}; ${used}${g.minutesPerMonth.toLocaleString('en-US')} minutes a month, ${g.concurrency} at once${until}; granted by ${g.grantedBy}: ${g.reason}`;
89
+ };
90
+ /** What a create means, in the Console's words: open, a replacement, or dormant behind a subscription. */
91
+ export const createdLine = (c) => c.dormant
92
+ ? `Granted to ${c.grant.owner}. They pay for a plan, so the grant is dormant until they stop.`
93
+ : `Granted to ${c.grant.owner}.${c.superseded === undefined ? '' : ' It replaces the grant they held.'}`;
94
+ export const revokedLine = (r) => `Revoked the grant for ${r.grant.owner}.${r.cancelledJobs > 0 ? ` ${r.cancelledJobs} queued job${r.cancelledJobs === 1 ? ' was' : 's were'} cancelled.` : ''}`;
95
+ /** One installation as a line: whose, and what Apex knows of it — or that it is new. */
96
+ export const installationLine = (i) => {
97
+ const known = [
98
+ i.accountId === undefined ? 'no Account' : `Account ${i.accountId}`,
99
+ ...(i.subscription === undefined ? [] : [`subscription ${i.subscription}`]),
100
+ ...(i.grant === undefined ? [] : [`grant ${i.grant}`]),
101
+ ...(i.recentUsage ? ['ran in the last 30 days'] : []),
102
+ ];
103
+ return `${i.installationId} ${i.account.login} (${i.account.type === 'User' ? 'personal account' : 'organization'})${i.listedAsTenant ? '' : ' — new'} · ${known.join(', ')}`;
104
+ };
105
+ /**
106
+ * The control plane refuses a grant write made with the machine token, and every operator route to an Account
107
+ * token. Either way the remedy is the operator token: say so.
108
+ */
109
+ export const withOperatorHint = (message) => /machine token|no token reaches|operator’s|only the grant routes|not signed in/.test(message)
110
+ ? `${message} — sign in as an operator with: apex login --console`
111
+ : message;
@@ -3,7 +3,7 @@ import { writeFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { withPermissionHint } from '../domain/credentials.js';
6
- import { NotConfiguredError, apiUrlFrom, resolveCredential } from './credentials.js';
6
+ import { NotConfiguredError, apiUrlFrom, resolveCredential, resolveOperatorCredential } from './credentials.js';
7
7
  export { NotConfiguredError } from './credentials.js';
8
8
  /** The API refused, with its status, so a use case can tell a 403 from a 500. */
9
9
  export class HttpError extends Error {
@@ -48,7 +48,7 @@ export class HttpControlPlane {
48
48
  this.credentialsAlsoPresent = [];
49
49
  }
50
50
  else {
51
- const resolved = resolveCredential();
51
+ const resolved = config.operator === true ? resolveOperatorCredential() : resolveCredential();
52
52
  this.#token = resolved.token;
53
53
  this.#baseUrl = (config.baseUrl ?? resolved.apiUrl).replace(/\/$/, '');
54
54
  this.credentialSource = resolved.source;
@@ -247,6 +247,22 @@ export class HttpControlPlane {
247
247
  async accountsBackfill(input, apply) {
248
248
  return await this.#json('POST', `/api/v1/admin/accounts/backfill${apply ? '?apply=true' : ''}`, input);
249
249
  }
250
+ // ── complimentary access (control-plane ADR-0056, ADR-0079): the admin API's shapes, unchanged ──
251
+ async listGrants() {
252
+ return (await this.#json('GET', '/api/v1/admin/grants')).grants;
253
+ }
254
+ async createGrant(body) {
255
+ return await this.#json('POST', '/api/v1/admin/grants', body);
256
+ }
257
+ async updateGrant(id, body) {
258
+ return (await this.#json('PATCH', `/api/v1/admin/grants/${encodeURIComponent(id)}`, body)).grant;
259
+ }
260
+ async revokeGrant(id) {
261
+ return await this.#json('DELETE', `/api/v1/admin/grants/${encodeURIComponent(id)}`);
262
+ }
263
+ async listInstallations() {
264
+ return (await this.#json('GET', '/api/v1/admin/installations')).installations;
265
+ }
250
266
  async accountsUnconnected() {
251
267
  return (await this.#json('GET', '/api/v1/admin/accounts/unconnected')).installations;
252
268
  }
@@ -9,6 +9,7 @@ export const URL_ENV = 'APEX_API_URL';
9
9
  export const WEB_URL_ENV = 'APEX_WEB_URL';
10
10
  export const TOKEN_ENV = 'APEX_TOKEN';
11
11
  export const ADMIN_TOKEN_ENV = 'APEX_ADMIN_TOKEN';
12
+ export const CONSOLE_URL_ENV = 'APEX_CONSOLE_URL';
12
13
  const set = (value) => value !== undefined && value !== '';
13
14
  /** The control plane: `APEX_API_URL`, or the hosted service. */
14
15
  export const apiUrlFrom = (env = process.env) => normaliseUrl(set(env[URL_ENV]) ? env[URL_ENV] : DEFAULT_API_URL);
@@ -35,6 +36,11 @@ export class InsecureCredentialsError extends NotConfiguredError {
35
36
  }
36
37
  /** `$XDG_CONFIG_HOME/apex/credentials`, defaulting to `~/.config`. */
37
38
  export const credentialsPath = (env = process.env) => join(set(env['XDG_CONFIG_HOME']) ? env['XDG_CONFIG_HOME'] : join(homedir(), '.config'), 'apex', 'credentials');
39
+ /**
40
+ * `$XDG_CONFIG_HOME/apex/console-credentials`: where `apex login --console` keeps the operator token
41
+ * (control-plane ADR-0079) — beside the Account login, never in place of it.
42
+ */
43
+ export const consoleCredentialsPath = (env = process.env) => join(dirname(credentialsPath(env)), 'console-credentials');
38
44
  /**
39
45
  * Adapter: the credentials file `apex login` writes.
40
46
  *
@@ -154,3 +160,20 @@ export const resolveCredential = (env = process.env, store = new FileCredentialS
154
160
  }
155
161
  throw new NotConfiguredError();
156
162
  };
163
+ /**
164
+ * Which credential an operator command — `apex grant`, `apex tenant installations` — uses (control-plane
165
+ * ADR-0079): the operator token `apex login --console` saved, when it is private, unexpired and was minted by
166
+ * the control plane `APEX_API_URL` names; otherwise the ordinary order ({@link resolveCredential}), in which
167
+ * the machine token can read grants and is refused every write. An expired operator token is skipped, and
168
+ * the refusal that follows says to sign in again.
169
+ */
170
+ export const resolveOperatorCredential = (env = process.env, consoleStore = new FileCredentialStore(consoleCredentialsPath(env)), fallback = () => resolveCredential(env), now = new Date()) => {
171
+ const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
172
+ const saved = consoleStore.read();
173
+ if (saved !== undefined && !hasExpired(saved, now)) {
174
+ const mintedBy = normaliseUrl(saved.apiUrl);
175
+ if (explicitUrl === undefined || explicitUrl === mintedBy)
176
+ return { token: saved.token, apiUrl: mintedBy, source: 'console' };
177
+ }
178
+ return fallback();
179
+ };
@@ -0,0 +1,82 @@
1
+ // infrastructure layer — see ../../../../docs/conventions.md §1
2
+ import { existsSync } from 'node:fs';
3
+ import { hostname, platform, release, type } from 'node:os';
4
+ import { headlessReason, isDevicePollError, normaliseUrl } from '../domain/credentials.js';
5
+ import { redeemedFrom } from './loopback-login.js';
6
+ /**
7
+ * The device flow's adapters (control-plane ADR-0075): the two calls, what this machine says about
8
+ * itself, and whether a browser can open here at all.
9
+ */
10
+ /** Why no browser can open here, read from this process's environment; `undefined` when one can. */
11
+ export const detectHeadless = () => headlessReason({ env: process.env, platform: process.platform, dockerenv: existsSync('/.dockerenv') });
12
+ /** What the approval page shows as "reported by the terminal": this machine's name and system. */
13
+ export const machineReport = () => ({
14
+ hostname: hostname().slice(0, 100),
15
+ os: `${type()} ${release()} (${platform()})`.slice(0, 100),
16
+ });
17
+ export const sleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms));
18
+ /** Adapter: `POST /api/v1/cli/device` and the `{ deviceCode }` poll of `POST /api/v1/cli/token`. No credential either way. */
19
+ export class HttpDeviceExchange {
20
+ #baseUrl;
21
+ #fetch;
22
+ constructor(baseUrl, fetchImpl = fetch) {
23
+ this.#baseUrl = normaliseUrl(baseUrl);
24
+ this.#fetch = fetchImpl;
25
+ }
26
+ async start(request) {
27
+ const body = await this.#post('/api/v1/cli/device', {
28
+ name: request.name,
29
+ purpose: request.purpose,
30
+ ...(request.days === undefined ? {} : { expiresInDays: request.days }),
31
+ ...(request.repos === undefined ? {} : { repos: request.repos }),
32
+ ...(request.permissions === undefined ? {} : { permissions: request.permissions }),
33
+ ...(request.accountId === undefined ? {} : { accountId: request.accountId }),
34
+ ...(request.tokenId === undefined ? {} : { tokenId: request.tokenId }),
35
+ ...(request.allRepositories === true ? { allRepositories: true } : {}),
36
+ ...(request.os === undefined ? {} : { os: request.os }),
37
+ ...(request.hostname === undefined ? {} : { hostname: request.hostname }),
38
+ });
39
+ if (!body.ok) {
40
+ if (body.status === 429)
41
+ throw new Error('too many device logins from this address this hour — wait, or approve in a browser on this machine with --browser');
42
+ throw new Error(typeof body.json['error'] === 'string' ? body.json['error'] : `starting the device login failed with ${body.status}`);
43
+ }
44
+ const j = body.json;
45
+ if (typeof j['deviceCode'] !== 'string' || typeof j['userCode'] !== 'string' || typeof j['verificationUri'] !== 'string') {
46
+ throw new Error('the control plane answered the device login without a code — is it older than this CLI?');
47
+ }
48
+ return {
49
+ deviceCode: j['deviceCode'],
50
+ userCode: j['userCode'],
51
+ verificationUri: j['verificationUri'],
52
+ expiresIn: typeof j['expiresIn'] === 'number' ? j['expiresIn'] : 600,
53
+ interval: typeof j['interval'] === 'number' ? j['interval'] : 5,
54
+ };
55
+ }
56
+ async poll(deviceCode) {
57
+ const body = await this.#post('/api/v1/cli/token', { deviceCode });
58
+ if (body.ok)
59
+ return { kind: 'minted', minted: redeemedFrom(body.json) };
60
+ const error = body.json['error'];
61
+ const interval = body.json['interval'];
62
+ if (body.status === 400 && isDevicePollError(error))
63
+ return { kind: error, ...(typeof interval === 'number' ? { interval } : {}) };
64
+ throw new Error(typeof error === 'string' ? error : `polling the device login failed with ${body.status}`);
65
+ }
66
+ async #post(path, payload) {
67
+ let response;
68
+ try {
69
+ response = await this.#fetch(`${this.#baseUrl}${path}`, {
70
+ method: 'POST',
71
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
72
+ body: JSON.stringify(payload),
73
+ signal: AbortSignal.timeout(30_000),
74
+ });
75
+ }
76
+ catch (err) {
77
+ throw new Error(`${this.#baseUrl} is unreachable: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
78
+ }
79
+ const json = (await response.json().catch(() => ({})));
80
+ return { ok: response.ok, status: response.status, json };
81
+ }
82
+ }