@apex-actions/cli 0.75.0 → 0.76.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
@@ -11,13 +11,16 @@ Part of the [Apex Actions](../) superproject. Conventions, architecture, and age
11
11
  | `apex run` | the runner binary, locally | `apex-runner` on `PATH` |
12
12
  | `apex validate` | the engine binary, locally | `apex-engine` on `PATH` |
13
13
  | `apex migrate scan` | the engine binary + the GitHub REST API | `apex-engine` on `PATH`; `GITHUB_TOKEN` unless `--path` |
14
- | `apex logs` | the control-plane REST API | a customer token with `actions:read` (`logs delete`: `actions:write`), or the operator token |
15
- | `apex dispatch` | the control-plane REST API | a customer token with `actions:write`, or the operator token |
14
+ | `apex logs` | the control-plane REST API | a customer token with `actions:read` (`logs delete`: `actions:write`), or the machine token |
15
+ | `apex dispatch` | the control-plane REST API | a customer token with `actions:write`, or the machine token |
16
16
  | `apex login` · `logout` · `whoami` | the browser and the control-plane REST API | nothing — `login` mints the credential |
17
17
  | `apex token create` | the browser and the control-plane REST API | a person signed in to the dashboard, who approves it |
18
18
  | `apex token edit` | the control-plane REST API; the browser to widen | a customer token to narrow; a person signed in to the dashboard to widen |
19
19
  | `apex token list` · `revoke` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`) |
20
20
  | `apex secret` · `apex variable` | the control-plane REST API | a customer token with `secrets:*` / `variables:*` (`APEX_TOKEN` or `apex login`) |
21
+ | `apex login --console` · `logout --console` | the Admin Console in the browser, and the control-plane REST API | an operator signed in to the Admin Console, who approves it |
22
+ | `apex grant list` · `apex tenant installations` | the control-plane admin API | the operator token (`apex login --console`), or the machine token |
23
+ | `apex grant create` · `update` · `revoke` | the control-plane admin API | the operator token (`apex login --console`) — never the machine token (control-plane ADR-0056, ADR-0079) |
21
24
 
22
25
  Every other customer command in `cli/reference.ts` names the permission it needs; the public reference shows it.
23
26
 
@@ -27,7 +30,7 @@ customer commands only, classified in `cli/reference.ts`).
27
30
 
28
31
  ```bash
29
32
  export APEX_API_URL=https://apex.example
30
- export APEX_ADMIN_TOKEN=… # the operator token, not a person's session
33
+ export APEX_ADMIN_TOKEN=… # the machine token, not a person's session
31
34
 
32
35
  apex dispatch .github/workflows/deploy.yml --repo octo/app --ref main --input environment=production
33
36
  apex logs latest --repo octo/app --follow # exits non-zero if the run failed
@@ -60,11 +63,25 @@ the file**:
60
63
  refuses a private key;
61
64
  4. otherwise the command stops with exit 2, naming `APEX_TOKEN` and `apex login`.
62
65
 
66
+ **Operator commands** — `apex grant` and `apex tenant installations` — look first for the **operator
67
+ token** `apex login --console` saved in `$XDG_CONFIG_HOME/apex/console-credentials` (same mode rules), when
68
+ it is unexpired and was minted by the control plane `APEX_API_URL` names, and otherwise use the order above
69
+ (control-plane ADR-0079). The operator token is approved on the Admin Console (`APEX_CONSOLE_URL`, or the
70
+ API's `app.` host read as `admin.`) by an operator, by loopback only; it lives a week at most and reaches
71
+ only the grant routes and the installation list, and every grant it writes names its operator. The machine
72
+ token can list grants and is refused every write. `apex logout --console` revokes and forgets it.
73
+
63
74
  `APEX_API_URL` selects the control plane (default `https://app.apexactions.com`); a saved login is
64
75
  only ever sent to the control plane that issued it. `apex login` opens the consent page on the web
65
76
  app — `APEX_WEB_URL`, or the API's origin — and listens on `127.0.0.1` for the loopback callback
66
77
  (RFC 8252, PKCE); once the code is redeemed it hands the tab back to the web app's bare `/cli/done`
67
- page (control-plane ADR-0075). A customer token carries **permissions** (`resource:read|write`, write implying
78
+ page (control-plane ADR-0075). Where no browser can open on this machine — an SSH session
79
+ (`SSH_CONNECTION`/`SSH_TTY`), a Codespace, a dev container, `/.dockerenv`, or Linux with neither `DISPLAY`
80
+ nor `WAYLAND_DISPLAY` — or with `--device` (or `--no-browser`), it uses the **device flow** instead (RFC 8628,
81
+ run by Apex): it prints a code and `…/cli/device`, the person enters the code on any device, a phone included,
82
+ and the CLI polls — honouring `interval` and `slow_down` — until the approval, a denial or the code's ten
83
+ minutes end. `--browser` insists on the loopback. `apex token create` and a widening `apex token edit` take
84
+ the same three flags. A customer token carries **permissions** (`resource:read|write`, write implying
68
85
  read; resources `actions`, `approvals`, `deployments`, `secrets`, `variables`, `environments`, `notifications`,
69
86
  `administration`) and belongs to **one Account** (control-plane ADR-0071). It reaches the repository routes its
70
87
  grants cover, within its person's role and the Account owner's token policy; `apex login` takes the policy's
@@ -78,6 +95,7 @@ print the new one, once.
78
95
 
79
96
  ```bash
80
97
  apex login # once per machine
98
+ apex login --device # over SSH: approve from a phone with the printed code
81
99
  printf %s "$TOKEN" | apex secret set ETS_PACKAGES_TOKEN --repo acme/api --repo acme/web
82
100
  apex secret set DEPLOY_KEY --repo acme/api --from-file ./deploy.key
83
101
  apex variable set AWS_REGION --value eu-west-1 --repo acme/api
@@ -0,0 +1,141 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { createGrant, listGrants, revokeGrant, updateGrant } from '../../src/application/manage-grants.js';
4
+ import { createGrantBody, createdLine, grantLine, revokedLine, updateGrantBody } from '../../src/domain/grants.js';
5
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
6
+ /**
7
+ * `apex grant`: complimentary access from the terminal (control-plane ADR-0056 as amended by ADR-0079,
8
+ * TASK-0199). The admin API's own shapes, unchanged. Every write needs a person: the operator token
9
+ * `apex login --console` saved, whose operator the grant row and the audit log name. The machine token
10
+ * (`APEX_ADMIN_TOKEN`) can list and is refused every write, so a leaked one cannot make a free tenant.
11
+ */
12
+ /** The operator's API client — the saved operator token first — or explain what is missing and stop. */
13
+ export const operatorApi = () => {
14
+ try {
15
+ return new HttpControlPlane({ operator: true });
16
+ }
17
+ catch (err) {
18
+ console.error(chalk.red(err instanceof NotConfiguredError ? `${err.message}; operators sign in with apex login --console` : String(err)));
19
+ process.exitCode = 2;
20
+ return undefined;
21
+ }
22
+ };
23
+ /** A refusal to stderr and exit 1, or the value. */
24
+ const settled = (outcome) => {
25
+ if (outcome.kind === 'ok')
26
+ return outcome.value;
27
+ console.error(chalk.red(outcome.message));
28
+ process.exitCode = 1;
29
+ return undefined;
30
+ };
31
+ /** A flag problem: exit 2 (§7), before any round trip. */
32
+ const usage = (problem) => {
33
+ console.error(chalk.red(problem));
34
+ process.exitCode = 2;
35
+ };
36
+ const listCommand = new Command('list')
37
+ .description('Every grant ever made, newest first: how each stands, the month so far, who granted it and why')
38
+ .option('--active', 'only grants that are open now')
39
+ .option('--json', 'machine-readable output')
40
+ .action(async (opts) => {
41
+ const client = operatorApi();
42
+ if (!client)
43
+ return;
44
+ const grants = settled(await listGrants(client));
45
+ if (grants === undefined)
46
+ return;
47
+ const shown = opts.active === true ? grants.filter((g) => g.state === 'active') : grants;
48
+ if (opts.json === true) {
49
+ console.log(JSON.stringify(shown, null, 2));
50
+ return;
51
+ }
52
+ if (shown.length === 0) {
53
+ console.log(opts.active === true ? 'no grant is open' : 'no complimentary access has been granted');
54
+ return;
55
+ }
56
+ for (const g of shown)
57
+ console.log(` ${grantLine(g)}`);
58
+ });
59
+ const createCommand = new Command('create')
60
+ .description('Grant an installation complimentary access, replacing any open grant it holds — recorded against you')
61
+ .argument('<installationId>', 'the GitHub App installation (see: apex tenant installations --fresh)')
62
+ .requiredOption('--minutes <n>', 'standard minutes per calendar month, 1 to 120,000')
63
+ .requiredOption('--concurrency <n>', 'jobs at once, 1 to 50')
64
+ .requiredOption('--reason <text>', 'who this is for and why they run free, up to 500 characters — stored on the grant')
65
+ .option('--until <YYYY-MM-DD>', 'the grant ends at the end of that UTC day (default: open-ended)')
66
+ .option('--json', 'machine-readable output')
67
+ .action(async (installation, opts) => {
68
+ const body = createGrantBody(installation, opts, new Date());
69
+ if (!body.ok) {
70
+ usage(body.problem);
71
+ return;
72
+ }
73
+ const client = operatorApi();
74
+ if (!client)
75
+ return;
76
+ const created = settled(await createGrant(client, body.value));
77
+ if (created === undefined)
78
+ return;
79
+ if (opts.json === true) {
80
+ console.log(JSON.stringify(created, null, 2));
81
+ return;
82
+ }
83
+ console.log(`${chalk.green('✔')} ${createdLine(created)}`);
84
+ console.log(` ${grantLine(created.grant)}`);
85
+ });
86
+ const updateCommand = new Command('update')
87
+ .description("Change an open grant's minutes, jobs at once or end date — it takes effect on the next admission and claim")
88
+ .argument('<grantId>', 'the grant (see: apex grant list)')
89
+ .option('--minutes <n>', 'standard minutes per calendar month, 1 to 120,000')
90
+ .option('--concurrency <n>', 'jobs at once, 1 to 50')
91
+ .option('--until <YYYY-MM-DD>', 'end at the end of that UTC day')
92
+ .option('--no-end', 'remove the end date, making the grant open-ended')
93
+ .option('--json', 'machine-readable output')
94
+ .action(async (id, opts) => {
95
+ // Commander reads `--no-end` as `end: false`; anything else leaves `end` at its default, true.
96
+ const body = updateGrantBody({ minutes: opts.minutes, concurrency: opts.concurrency, until: opts.until, noEnd: opts.end === false }, new Date());
97
+ if (!body.ok) {
98
+ usage(body.problem);
99
+ return;
100
+ }
101
+ const client = operatorApi();
102
+ if (!client)
103
+ return;
104
+ const grant = settled(await updateGrant(client, id, body.value));
105
+ if (grant === undefined)
106
+ return;
107
+ if (opts.json === true) {
108
+ console.log(JSON.stringify(grant, null, 2));
109
+ return;
110
+ }
111
+ console.log(`${chalk.green('✔')} Changed the grant for ${grant.owner}.`);
112
+ console.log(` ${grantLine(grant)}`);
113
+ });
114
+ const revokeCommand = new Command('revoke')
115
+ .description('End a grant now: new runs are refused, queued free work is cancelled with a reason, and running work finishes')
116
+ .argument('<grantId>', 'the grant (see: apex grant list)')
117
+ .option('--yes', 'confirm — revoking cannot be undone; the organisation does not fall back to the pre-trial minutes')
118
+ .option('--json', 'machine-readable output')
119
+ .action(async (id, opts) => {
120
+ if (opts.yes !== true) {
121
+ usage(`revoking ends the grant now and cannot be undone — re-run with --yes to revoke ${id}`);
122
+ return;
123
+ }
124
+ const client = operatorApi();
125
+ if (!client)
126
+ return;
127
+ const revoked = settled(await revokeGrant(client, id));
128
+ if (revoked === undefined)
129
+ return;
130
+ if (opts.json === true) {
131
+ console.log(JSON.stringify(revoked, null, 2));
132
+ return;
133
+ }
134
+ console.log(`${chalk.green('✔')} ${revokedLine(revoked)}`);
135
+ });
136
+ export default new Command('grant')
137
+ .description('Complimentary access: list, grant, change and revoke — every write is recorded against the operator signed in with apex login --console')
138
+ .addCommand(listCommand)
139
+ .addCommand(createCommand)
140
+ .addCommand(updateCommand)
141
+ .addCommand(revokeCommand);
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk';
2
+ import { chooseFlow, deviceInstructions } from '../../src/domain/credentials.js';
3
+ import { HttpDeviceExchange, detectHeadless, machineReport, sleep } from '../../src/infrastructure/device-login.js';
4
+ /**
5
+ * How `apex login`, `apex token create` and a widening `apex token edit` get their approval back
6
+ * (control-plane ADR-0075): the loopback redirect where a browser opens on this machine, a device code
7
+ * typed on any device where one cannot. One set of flags for the three commands.
8
+ */
9
+ export const withFlowOptions = (command) => command
10
+ .option('--device', 'approve on any device — a phone included — by entering the code this prints at the address it prints')
11
+ .option('--browser', 'open a browser on this machine to approve, even where the CLI would choose a device code (SSH, a container, a Codespace)')
12
+ .option('--no-browser', 'the same as --device: print a code to enter on any device instead of opening a browser here');
13
+ /** The chosen flow, and the device flow's dependencies when that is it; or the flags' problem. */
14
+ export const approvalFlow = (flags, apiUrl, print) => {
15
+ const choice = chooseFlow(flags, detectHeadless());
16
+ if ('problem' in choice)
17
+ return choice;
18
+ if (choice.flow === 'loopback')
19
+ return { flow: 'loopback' };
20
+ const machine = machineReport();
21
+ return {
22
+ flow: 'device',
23
+ why: choice.why,
24
+ device: {
25
+ exchange: new HttpDeviceExchange(apiUrl),
26
+ announceCode: (start, why) => {
27
+ const [reason, open, expires] = why === undefined ? [undefined, ...deviceInstructions(start)] : deviceInstructions(start, why);
28
+ if (reason !== undefined)
29
+ print(chalk.dim(reason));
30
+ if (open !== undefined)
31
+ print(open);
32
+ if (expires !== undefined)
33
+ print(chalk.dim(expires));
34
+ print(chalk.dim('Waiting for the approval…'));
35
+ },
36
+ sleep,
37
+ now: () => Date.now(),
38
+ why: choice.why,
39
+ os: machine.os,
40
+ hostname: machine.hostname,
41
+ },
42
+ };
43
+ };
@@ -1,41 +1,60 @@
1
1
  import { hostname } from 'node:os';
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
- import { login } from '../../src/application/authenticate.js';
5
- import { TOKENS_PAGE, describeGrants, webBaseFor } from '../../src/domain/credentials.js';
6
- import { ADMIN_TOKEN_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPath } from '../../src/infrastructure/credentials.js';
4
+ import { consoleLogin, login } from '../../src/application/authenticate.js';
5
+ import { OPERATOR_TOKEN_PREFIX, TOKENS_PAGE, consoleBaseFor, describeGrants, loopbackTimeoutHint, webBaseFor } from '../../src/domain/credentials.js';
6
+ import { ADMIN_TOKEN_ENV, CONSOLE_URL_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, consoleCredentialsPath, credentialsPath, } from '../../src/infrastructure/credentials.js';
7
7
  import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
8
+ import { approvalFlow, withFlowOptions } from './device.js';
8
9
  /**
9
10
  * `apex login`: sign the CLI in with the browser a person already uses
10
11
  * (control-plane ADR-0064, RFC 8252 + PKCE). The token it mints is the same
11
12
  * one the dashboard mints, saved `0600` for this user alone.
12
13
  *
13
14
  * It opens a browser only because it was asked to — this command is the
14
- * asking — and prints the URL either way, for a machine where none opens.
15
+ * asking — and prints the URL either way. Where no browser can open on this
16
+ * machine (SSH, a container, a Codespace, a Linux box with no display), or
17
+ * with `--device`, it prints a code to enter on any device instead
18
+ * (control-plane ADR-0075); `--browser` insists on the browser here.
15
19
  *
16
20
  * The token carries the Account token policy's defaults for the person's role
17
21
  * there (the Owner's: everything), and belongs to that one Account — named with
18
22
  * `--account` when the person is in more than one (control-plane ADR-0071).
23
+ *
24
+ * `--console` is the operator's sign-in (control-plane ADR-0079): approved on the
25
+ * Admin Console by an operator, it saves an **operator token** beside the Account
26
+ * login — a week at most, reaching only complimentary access (`apex grant`).
19
27
  */
20
- export default new Command('login')
21
- .description("Sign the CLI in through the browser; the token is saved for this user only and carries your Account's default permissions for your role")
28
+ export default withFlowOptions(new Command('login')
29
+ .description("Sign the CLI in through the browser — on this machine, or on any device with a code; the token is saved for this user only and carries your Account's default permissions for your role")
22
30
  .option('--account <id>', 'the Account to sign in to — needed when you belong to more than one')
23
- .option('--no-browser', 'print the URL to open instead of opening a browser')
24
- .action(async (opts) => {
31
+ .option('--console', 'sign in as an operator, approved on the Admin Console: an operator token for apex grant, a week at most, saved beside the Account login')).action(async (opts) => {
32
+ if (opts.console === true) {
33
+ await consoleSignIn(opts);
34
+ return;
35
+ }
25
36
  const apiUrl = apiUrlFrom();
26
37
  const webBase = webBaseFor(apiUrl, process.env[WEB_URL_ENV]);
27
38
  const store = new FileCredentialStore(credentialsPath());
39
+ const chosen = approvalFlow(opts, apiUrl, (line) => console.log(line));
40
+ if ('problem' in chosen) {
41
+ console.error(chalk.red(chosen.problem));
42
+ process.exitCode = 2;
43
+ return;
44
+ }
28
45
  let outcome;
29
46
  try {
30
47
  outcome = await login({
31
48
  pkce: newPkcePair,
32
49
  listen: (state) => listenForCallback(state),
33
- openBrowser: opts.browser ? (url) => openInBrowser(url) : undefined,
50
+ openBrowser: (url) => openInBrowser(url),
34
51
  announce: (url) => {
35
- console.log(opts.browser ? 'Opening the browser to approve this login. If it does not open, visit:' : 'Open this URL to approve this login:');
52
+ console.log('Opening the browser to approve this login. If it does not open, visit:');
36
53
  console.log(` ${url}`);
37
54
  console.log(chalk.dim('Waiting for the approval (5 minutes)…'));
38
55
  },
56
+ flow: chosen.flow,
57
+ device: chosen.device,
39
58
  exchange: new HttpLoginExchange(apiUrl),
40
59
  store,
41
60
  apiUrl,
@@ -49,37 +68,91 @@ export default new Command('login')
49
68
  process.exitCode = 1;
50
69
  return;
51
70
  }
71
+ if (outcome.kind !== 'logged-in') {
72
+ reportFailure(outcome, 'apex login');
73
+ return;
74
+ }
75
+ console.log(`${chalk.green('✔')} signed in as ${outcome.login} — the token expires ${outcome.expiresAt}`);
76
+ if (outcome.permissions !== undefined) {
77
+ console.log(` permissions: ${describeGrants(outcome.permissions)}${outcome.accountId === undefined ? '' : ` · Account ${outcome.accountId}`}`);
78
+ }
79
+ console.log(chalk.dim(`Saved to ${outcome.location}. Revoke it any time at ${webBase}${TOKENS_PAGE}.`));
80
+ // The environment outranks the saved login (ADR-0005): say so, or the login looks like it did nothing.
81
+ for (const name of [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
82
+ if (process.env[name])
83
+ console.log(chalk.yellow(`${name} is set in this shell and is used before the saved login; unset it to use the login.`));
84
+ }
85
+ });
86
+ /** Every way a login can end without a token, in words for the terminal. */
87
+ const reportFailure = (outcome, command) => {
88
+ process.exitCode = 1;
52
89
  switch (outcome.kind) {
53
- case 'logged-in':
54
- console.log(`${chalk.green('✔')} signed in as ${outcome.login} — the token expires ${outcome.expiresAt}`);
55
- if (outcome.permissions !== undefined) {
56
- console.log(` permissions: ${describeGrants(outcome.permissions)}${outcome.accountId === undefined ? '' : ` · Account ${outcome.accountId}`}`);
57
- }
58
- console.log(chalk.dim(`Saved to ${outcome.location}. Revoke it any time at ${webBase}${TOKENS_PAGE}.`));
59
- // The environment outranks the saved login (ADR-0005): say so, or the login looks like it did nothing.
60
- for (const name of [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
61
- if (process.env[name])
62
- console.log(chalk.yellow(`${name} is set in this shell and is used before the saved login; unset it to use the login.`));
63
- }
64
- return;
65
90
  case 'denied':
66
91
  console.error(chalk.red(`the login was not approved: ${outcome.detail}`));
67
- process.exitCode = 1;
68
92
  return;
69
93
  case 'state-mismatch':
70
- console.error(chalk.red('a callback arrived that this login did not ask for (its state did not match), so nothing was saved; run `apex login` again'));
71
- process.exitCode = 1;
94
+ console.error(chalk.red(`a callback arrived that this login did not ask for (its state did not match), so nothing was saved; run \`${command}\` again`));
72
95
  return;
73
96
  case 'malformed':
74
- console.error(chalk.red('the callback carried no code, so nothing was saved; run `apex login` again'));
75
- process.exitCode = 1;
97
+ console.error(chalk.red(`the callback carried no code, so nothing was saved; run \`${command}\` again`));
76
98
  return;
77
99
  case 'timeout':
78
- console.error(chalk.red('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run `apex login` again'));
79
- process.exitCode = 1;
100
+ console.error(chalk.red(command === 'apex login --console' ? 'No approval arrived. An operator token is approved only in a browser on this machine, signed in to the Admin Console.' : loopbackTimeoutHint(command)));
101
+ return;
102
+ case 'expired':
103
+ console.error(chalk.red(`the code expired before anyone approved it, so nothing was saved; run \`${command}\` again for a new one`));
80
104
  return;
81
105
  case 'refused':
82
106
  console.error(chalk.red(`the control plane refused the login code: ${outcome.detail}`));
83
- process.exitCode = 1;
84
107
  }
85
- });
108
+ };
109
+ /**
110
+ * `apex login --console` (control-plane ADR-0079): the loopback approval on the Admin Console, saving the
111
+ * operator token to its own file. No device flow — `--device` is refused rather than silently ignored.
112
+ */
113
+ const consoleSignIn = async (opts) => {
114
+ if (opts.device === true || opts.browser === false || opts.account !== undefined) {
115
+ console.error(chalk.red('apex login --console takes no --device, --no-browser or --account: an operator token is approved in a browser on this machine, on the Admin Console'));
116
+ process.exitCode = 2;
117
+ return;
118
+ }
119
+ const apiUrl = apiUrlFrom();
120
+ const consoleBase = consoleBaseFor(apiUrl, process.env[CONSOLE_URL_ENV]);
121
+ if (consoleBase === undefined) {
122
+ console.error(chalk.red(`set ${CONSOLE_URL_ENV} to the Admin Console's address — it cannot be worked out from ${apiUrl}`));
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+ const store = new FileCredentialStore(consoleCredentialsPath());
127
+ let outcome;
128
+ try {
129
+ outcome = await consoleLogin({
130
+ pkce: newPkcePair,
131
+ listen: (state) => listenForCallback(state),
132
+ openBrowser: (url) => openInBrowser(url),
133
+ announce: (url) => {
134
+ console.log('Opening the Admin Console to approve an operator token. If it does not open, visit:');
135
+ console.log(` ${url}`);
136
+ console.log(chalk.dim('Waiting for the approval (5 minutes)…'));
137
+ },
138
+ exchange: new HttpLoginExchange(apiUrl),
139
+ store,
140
+ apiUrl,
141
+ webBase: consoleBase,
142
+ name: hostname().slice(0, 100) || 'apex CLI',
143
+ operatorPrefix: OPERATOR_TOKEN_PREFIX,
144
+ });
145
+ }
146
+ catch (err) {
147
+ console.error(chalk.red(`login failed: ${err instanceof Error ? err.message : String(err)}`));
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ if (outcome.kind !== 'logged-in') {
152
+ reportFailure(outcome, 'apex login --console');
153
+ return;
154
+ }
155
+ console.log(`${chalk.green('✔')} signed in as the operator ${outcome.login} — the operator token expires ${outcome.expiresAt}`);
156
+ console.log(' it reaches complimentary access only: apex grant list | create | update | revoke, and apex tenant installations');
157
+ console.log(chalk.dim(`Saved to ${outcome.location}. Revoke it with apex logout --console, or at ${consoleBase}/tokens.`));
158
+ };
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { logout } from '../../src/application/authenticate.js';
4
- import { TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
4
+ import { TOKENS_PAGE, consoleBaseFor, webBaseFor } from '../../src/domain/credentials.js';
5
5
  import { HttpControlPlane } from '../../src/infrastructure/control-plane-http.js';
6
- import { ADMIN_TOKEN_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPath } from '../../src/infrastructure/credentials.js';
6
+ import { ADMIN_TOKEN_ENV, CONSOLE_URL_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, consoleCredentialsPath, credentialsPath, } from '../../src/infrastructure/credentials.js';
7
7
  /**
8
8
  * `apex logout`: revoke the token `apex login` saved, then forget it.
9
9
  *
@@ -12,12 +12,18 @@ import { ADMIN_TOKEN_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFro
12
12
  * token may do to itself (control-plane ADR-0064). The file goes whether or not
13
13
  * the server could be reached; when it could not, the command says the token
14
14
  * may still be valid and where to revoke it, rather than implying it is dead.
15
+ *
16
+ * `--console` does the same to the operator token `apex login --console` saved
17
+ * (control-plane ADR-0079), which may revoke itself and nothing else.
15
18
  */
16
19
  export default new Command('logout')
17
20
  .description('Revoke the token `apex login` saved, and forget it')
18
- .action(async () => {
19
- const store = new FileCredentialStore(credentialsPath());
20
- const tokensPage = `${webBaseFor(apiUrlFrom(), process.env[WEB_URL_ENV])}${TOKENS_PAGE}`;
21
+ .option('--console', 'revoke and forget the operator token `apex login --console` saved instead')
22
+ .action(async (opts) => {
23
+ const operator = opts.console === true;
24
+ const store = new FileCredentialStore(operator ? consoleCredentialsPath() : credentialsPath());
25
+ const consoleBase = consoleBaseFor(apiUrlFrom(), process.env[CONSOLE_URL_ENV]);
26
+ const tokensPage = operator ? `${consoleBase ?? 'the Admin Console'}/tokens` : `${webBaseFor(apiUrlFrom(), process.env[WEB_URL_ENV])}${TOKENS_PAGE}`;
21
27
  let outcome;
22
28
  try {
23
29
  outcome = await logout(store, (saved) => new HttpControlPlane({ baseUrl: saved.apiUrl, token: saved.token }).revokeCurrentToken());
@@ -28,7 +34,7 @@ export default new Command('logout')
28
34
  return;
29
35
  }
30
36
  if (outcome.kind === 'nothing') {
31
- console.log(`No saved login at ${outcome.location}; nothing to forget.`);
37
+ console.log(`No saved ${operator ? 'operator token' : 'login'} at ${outcome.location}; nothing to forget.`);
32
38
  }
33
39
  else if (outcome.revoked === true) {
34
40
  console.log(`${chalk.green('✔')} revoked the saved token and removed ${outcome.location}`);
@@ -38,7 +44,7 @@ export default new Command('logout')
38
44
  console.log(chalk.yellow(`The token could not be revoked (${outcome.revoked.failed}) and may still be valid — revoke it at ${tokensPage}.`));
39
45
  process.exitCode = 1;
40
46
  }
41
- for (const name of [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
47
+ for (const name of operator ? [] : [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
42
48
  if (process.env[name])
43
49
  console.log(chalk.yellow(`${name} is still set in this shell, and the CLI still uses it.`));
44
50
  }
@@ -22,6 +22,7 @@ import Oidc from './oidc/main.js';
22
22
  import Actions from './actions/main.js';
23
23
  import Terms from './terms/main.js';
24
24
  import Tenant from './tenant/main.js';
25
+ import Grant from './grant/main.js';
25
26
  import Notify from './notify/main.js';
26
27
  import Lsp from './lsp/main.js';
27
28
  import Login from './login/main.js';
@@ -64,6 +65,7 @@ export const buildProgram = (version) => new Command()
64
65
  .addCommand(Actions)
65
66
  .addCommand(Terms)
66
67
  .addCommand(Tenant)
68
+ .addCommand(Grant)
67
69
  .addCommand(Notify)
68
70
  .addCommand(Login)
69
71
  .addCommand(Logout)
@@ -43,13 +43,20 @@ export const REFERENCE = {
43
43
  examples: [
44
44
  { command: 'apex login', description: "Sign in through the browser; the token carries your Account's default permissions for your role, and is saved for your user only." },
45
45
  { command: 'apex login --account acc_4f2b9e', description: 'In more than one Account: name the one to sign in to.' },
46
- { command: 'apex login --no-browser', description: 'On a machine without a browser: print the approval URL to open elsewhere.' },
46
+ {
47
+ command: 'apex login --device',
48
+ description: 'Over SSH, in a container or a Codespace: print a code to enter at app.apexactions.com/cli/device on any device — a phone included. Chosen by itself where no browser can open; --no-browser means the same.',
49
+ },
50
+ { command: 'apex login --browser', description: 'Open a browser on this machine even where the CLI would choose a code.' },
47
51
  ],
52
+ // The operator's sign-in, approved on the Admin Console (control-plane ADR-0079).
53
+ operatorOptions: ['--console'],
48
54
  },
49
55
  logout: {
50
56
  group: 'setup',
51
57
  audience: 'customer',
52
58
  examples: [{ command: 'apex logout', description: 'Revoke the saved token on the server, then delete it.' }],
59
+ operatorOptions: ['--console'],
53
60
  },
54
61
  whoami: {
55
62
  group: 'setup',
@@ -67,6 +74,10 @@ export const REFERENCE = {
67
74
  command: 'apex token create deploy-bot --permission actions:write --permission secrets:write --repo acme/web --expires-in-days 30',
68
75
  description: 'Create a token that may run workflows and set secrets on one repository, approved in the browser, and print it once.',
69
76
  },
77
+ {
78
+ command: 'apex token create ci-deploy --permission actions:write --device',
79
+ description: 'The same from a machine with no browser: approve it on any device with the code it prints; the token still prints only here.',
80
+ },
70
81
  {
71
82
  command: 'apex token edit 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 --remove-permission secrets:write',
72
83
  description: 'Narrow a token: applies at once, from the terminal.',
@@ -295,4 +306,6 @@ export const REFERENCE = {
295
306
  runner: { group: 'runners', audience: OPERATOR },
296
307
  terms: { group: 'billing', audience: OPERATOR },
297
308
  tenant: { group: 'billing', audience: OPERATOR },
309
+ // Complimentary access: the operator token `apex login --console` saves writes; no Account token reaches it (control-plane ADR-0079).
310
+ grant: { group: 'billing', audience: OPERATOR },
298
311
  };
@@ -5,6 +5,8 @@ import { backfillAccounts, connectInstallation, listUnconnected } from '../../sr
5
5
  import { backfillLines, connectLine, unconnectedLine } from '../../src/domain/accounts.js';
6
6
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
7
  import { countsLine, departureLine } from '../../src/domain/departures.js';
8
+ import { listInstallations } from '../../src/application/manage-grants.js';
9
+ import { installationLine } from '../../src/domain/grants.js';
8
10
  /** Build the API client, or explain what is missing and stop. */
9
11
  const api = () => {
10
12
  try {
@@ -157,10 +159,44 @@ const connectCommand = new Command('connect')
157
159
  }
158
160
  console.log(`${chalk.green('✔')} ${connectLine(outcome.value)}`);
159
161
  });
162
+ const installationsCommand = new Command('installations')
163
+ .description("Every installation of the App as GitHub lists it, with what Apex knows of each — --fresh for those the tenants list never shows, which a grant needs the id of")
164
+ .option('--fresh', 'only installations that never ran, paid or were granted')
165
+ .option('--json', 'machine-readable output')
166
+ .action(async (opts) => {
167
+ // The operator token `apex login --console` saved reaches this route too (control-plane ADR-0079).
168
+ let client;
169
+ try {
170
+ client = new HttpControlPlane({ operator: true });
171
+ }
172
+ catch (err) {
173
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+ const outcome = await listInstallations(client, opts.fresh === true);
178
+ if (outcome.kind === 'refused') {
179
+ console.error(chalk.red(outcome.message));
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+ if (opts.json === true) {
184
+ console.log(JSON.stringify(outcome.value, null, 2));
185
+ return;
186
+ }
187
+ if (outcome.value.length === 0) {
188
+ console.log(chalk.green(opts.fresh === true ? 'no fresh installation — every one has run, paid or been granted' : 'GitHub lists no installation of the App'));
189
+ return;
190
+ }
191
+ for (const i of outcome.value)
192
+ console.log(` ${installationLine(i)}`);
193
+ console.log(chalk.dim('\nGrant one with: apex grant create <installationId> --minutes <n> --concurrency <n> --reason "<why>"'));
194
+ });
160
195
  export default new Command('tenant')
161
- .description('Tenants: connecting new installations to Accounts, who left and deleting their data on request, and the one-time move into Accounts')
196
+ .description('Tenants: every installation of the App, connecting new ones to Accounts, who left and deleting their data on request, and the one-time move into Accounts')
162
197
  .addCommand(departuresCommand)
163
198
  .addCommand(purgeCommand)
164
199
  .addCommand(backfillCommand)
165
200
  .addCommand(unconnectedCommand)
166
- .addCommand(connectCommand);
201
+ .addCommand(connectCommand)
202
+ .addCommand(installationsCommand);