@apex-actions/cli 0.74.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 +48 -12
- package/dist/cli/grant/main.js +141 -0
- package/dist/cli/login/device.js +43 -0
- package/dist/cli/login/main.js +110 -25
- package/dist/cli/logout/main.js +16 -8
- package/dist/cli/program.js +2 -0
- package/dist/cli/reference.js +211 -39
- package/dist/cli/spend/main.js +1 -1
- package/dist/cli/tenant/main.js +38 -2
- package/dist/cli/token/main.js +156 -32
- package/dist/cli/whoami/main.js +44 -2
- package/dist/src/application/authenticate.js +104 -7
- package/dist/src/application/manage-grants.js +21 -0
- package/dist/src/application/manage-tokens.js +84 -4
- package/dist/src/domain/credentials.js +185 -17
- package/dist/src/domain/grants.js +111 -0
- package/dist/src/infrastructure/control-plane-http.js +91 -18
- package/dist/src/infrastructure/credentials.js +53 -8
- package/dist/src/infrastructure/device-login.js +82 -0
- package/dist/src/infrastructure/loopback-login.js +54 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,18 @@ 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 |
|
|
15
|
-
| `apex dispatch` | the control-plane REST API |
|
|
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
|
+
| `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 |
|
|
18
19
|
| `apex token list` · `revoke` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`) |
|
|
19
|
-
| `apex secret` · `apex variable` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`)
|
|
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) |
|
|
24
|
+
|
|
25
|
+
Every other customer command in `cli/reference.ts` names the permission it needs; the public reference shows it.
|
|
20
26
|
|
|
21
27
|
Generated reference: `docs/feature-docs/cli-commands.md` (`pnpm docs:cli` from `packages/release`), and
|
|
22
28
|
the structured one the public site renders, `docs/feature-docs/cli-reference.json` (`pnpm docs:reference`;
|
|
@@ -24,7 +30,7 @@ customer commands only, classified in `cli/reference.ts`).
|
|
|
24
30
|
|
|
25
31
|
```bash
|
|
26
32
|
export APEX_API_URL=https://apex.example
|
|
27
|
-
export APEX_ADMIN_TOKEN=… # the
|
|
33
|
+
export APEX_ADMIN_TOKEN=… # the machine token, not a person's session
|
|
28
34
|
|
|
29
35
|
apex dispatch .github/workflows/deploy.yml --repo octo/app --ref main --input environment=production
|
|
30
36
|
apex logs latest --repo octo/app --follow # exits non-zero if the run failed
|
|
@@ -43,26 +49,53 @@ apex migrate scan acme --repo acme/api --json # one repository of an organizat
|
|
|
43
49
|
|
|
44
50
|
### Credentials
|
|
45
51
|
|
|
46
|
-
The CLI resolves one credential, in this order (control-plane ADR-0064
|
|
52
|
+
The CLI resolves one credential, in this order (control-plane ADR-0064, amended by
|
|
53
|
+
[ADR-0005](docs/adr/0005-an-exported-token-outranks-a-saved-login.md)) — **the environment outranks
|
|
54
|
+
the file**:
|
|
47
55
|
|
|
48
56
|
1. `APEX_TOKEN` — a token minted on the dashboard (`/settings/tokens`), for CI and scripts;
|
|
49
|
-
2. the token
|
|
57
|
+
2. `APEX_ADMIN_TOKEN` — the operator's machine token, unchanged: unscoped, so treat it as a root
|
|
58
|
+
credential. An exported variable is the deliberate choice, as `GH_TOKEN` outranks `gh`'s stored
|
|
59
|
+
login; a saved login used to win, so an operator shell acted as whichever customer had last run
|
|
60
|
+
`apex login` on the machine;
|
|
61
|
+
3. the token `apex login` saved in `$XDG_CONFIG_HOME/apex/credentials` (default `~/.config`),
|
|
50
62
|
mode `0600` in a `0700` directory — a file readable by group or others is **refused**, as `ssh`
|
|
51
63
|
refuses a private key;
|
|
52
|
-
3. `APEX_ADMIN_TOKEN` — the operator's machine token, unchanged: unscoped, so treat it as a root
|
|
53
|
-
credential;
|
|
54
64
|
4. otherwise the command stops with exit 2, naming `APEX_TOKEN` and `apex login`.
|
|
55
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
|
+
|
|
56
74
|
`APEX_API_URL` selects the control plane (default `https://app.apexactions.com`); a saved login is
|
|
57
75
|
only ever sent to the control plane that issued it. `apex login` opens the consent page on the web
|
|
58
76
|
app — `APEX_WEB_URL`, or the API's origin — and listens on `127.0.0.1` for the loopback callback
|
|
59
|
-
(RFC 8252, PKCE)
|
|
60
|
-
|
|
77
|
+
(RFC 8252, PKCE); once the code is redeemed it hands the tab back to the web app's bare `/cli/done`
|
|
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
|
|
85
|
+
read; resources `actions`, `approvals`, `deployments`, `secrets`, `variables`, `environments`, `notifications`,
|
|
86
|
+
`administration`) and belongs to **one Account** (control-plane ADR-0071). It reaches the repository routes its
|
|
87
|
+
grants cover, within its person's role and the Account owner's token policy; `apex login` takes the policy's
|
|
88
|
+
defaults for the person's role (the Owner's: everything), in the Account `--account` names when there is more than
|
|
89
|
+
one. A route it lacks the grant for answers 403 `this token needs <permission>`, which the CLI completes with the
|
|
90
|
+
`apex token edit <id> --add-permission <permission>` that would add it. `apex whoami`
|
|
91
|
+
names the credential in use, where it was found, and any other credential present that it
|
|
92
|
+
outranked. `apex logout` revokes the saved token on the server,
|
|
61
93
|
then deletes the file. No command prints a token — except `apex token create`, whose whole job is to
|
|
62
94
|
print the new one, once.
|
|
63
95
|
|
|
64
96
|
```bash
|
|
65
97
|
apex login # once per machine
|
|
98
|
+
apex login --device # over SSH: approve from a phone with the printed code
|
|
66
99
|
printf %s "$TOKEN" | apex secret set ETS_PACKAGES_TOKEN --repo acme/api --repo acme/web
|
|
67
100
|
apex secret set DEPLOY_KEY --repo acme/api --from-file ./deploy.key
|
|
68
101
|
apex variable set AWS_REGION --value eu-west-1 --repo acme/api
|
|
@@ -73,8 +106,11 @@ A pipeline gets its own token. It is approved in the browser like a login, print
|
|
|
73
106
|
and never saved (control-plane ADR-0064: a token never mints a token):
|
|
74
107
|
|
|
75
108
|
```bash
|
|
76
|
-
apex token create ci-deploy --
|
|
77
|
-
|
|
109
|
+
apex token create ci-deploy --permission actions:write --permission secrets:write \
|
|
110
|
+
--repo acme/api --expires-in-days 30 # token on stdout, details on stderr
|
|
111
|
+
apex token edit <id> --remove-permission secrets:write # narrowing: applies at once
|
|
112
|
+
apex token edit <id> --add-permission deployments:write # widening: approved in the browser
|
|
113
|
+
apex token list # by Account, with grants; * marks the one in use
|
|
78
114
|
apex token revoke 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 # or --all
|
|
79
115
|
```
|
|
80
116
|
|
|
@@ -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
|
+
};
|
package/dist/cli/login/main.js
CHANGED
|
@@ -1,41 +1,66 @@
|
|
|
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, webBaseFor } from '../../src/domain/credentials.js';
|
|
6
|
-
import { 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
|
|
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.
|
|
19
|
+
*
|
|
20
|
+
* The token carries the Account token policy's defaults for the person's role
|
|
21
|
+
* there (the Owner's: everything), and belongs to that one Account — named with
|
|
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`).
|
|
15
27
|
*/
|
|
16
|
-
export default new Command('login')
|
|
17
|
-
.description(
|
|
18
|
-
.option('--
|
|
19
|
-
.action(async (opts) => {
|
|
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")
|
|
30
|
+
.option('--account <id>', 'the Account to sign in to — needed when you belong to more than one')
|
|
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
|
+
}
|
|
20
36
|
const apiUrl = apiUrlFrom();
|
|
21
37
|
const webBase = webBaseFor(apiUrl, process.env[WEB_URL_ENV]);
|
|
22
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
|
+
}
|
|
23
45
|
let outcome;
|
|
24
46
|
try {
|
|
25
47
|
outcome = await login({
|
|
26
48
|
pkce: newPkcePair,
|
|
27
49
|
listen: (state) => listenForCallback(state),
|
|
28
|
-
openBrowser:
|
|
50
|
+
openBrowser: (url) => openInBrowser(url),
|
|
29
51
|
announce: (url) => {
|
|
30
|
-
console.log(
|
|
52
|
+
console.log('Opening the browser to approve this login. If it does not open, visit:');
|
|
31
53
|
console.log(` ${url}`);
|
|
32
54
|
console.log(chalk.dim('Waiting for the approval (5 minutes)…'));
|
|
33
55
|
},
|
|
56
|
+
flow: chosen.flow,
|
|
57
|
+
device: chosen.device,
|
|
34
58
|
exchange: new HttpLoginExchange(apiUrl),
|
|
35
59
|
store,
|
|
36
60
|
apiUrl,
|
|
37
61
|
webBase,
|
|
38
62
|
name: hostname().slice(0, 100) || 'apex CLI',
|
|
63
|
+
accountId: opts.account,
|
|
39
64
|
});
|
|
40
65
|
}
|
|
41
66
|
catch (err) {
|
|
@@ -43,31 +68,91 @@ export default new Command('login')
|
|
|
43
68
|
process.exitCode = 1;
|
|
44
69
|
return;
|
|
45
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;
|
|
46
89
|
switch (outcome.kind) {
|
|
47
|
-
case 'logged-in':
|
|
48
|
-
console.log(`${chalk.green('✔')} signed in as ${outcome.login} — the token expires ${outcome.expiresAt}`);
|
|
49
|
-
console.log(chalk.dim(`Saved to ${outcome.location}. Revoke it any time at ${webBase}${TOKENS_PAGE}.`));
|
|
50
|
-
if (process.env[TOKEN_ENV])
|
|
51
|
-
console.log(chalk.yellow(`${TOKEN_ENV} is set in this shell and is used before the saved login.`));
|
|
52
|
-
return;
|
|
53
90
|
case 'denied':
|
|
54
91
|
console.error(chalk.red(`the login was not approved: ${outcome.detail}`));
|
|
55
|
-
process.exitCode = 1;
|
|
56
92
|
return;
|
|
57
93
|
case 'state-mismatch':
|
|
58
|
-
console.error(chalk.red(
|
|
59
|
-
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`));
|
|
60
95
|
return;
|
|
61
96
|
case 'malformed':
|
|
62
|
-
console.error(chalk.red(
|
|
63
|
-
process.exitCode = 1;
|
|
97
|
+
console.error(chalk.red(`the callback carried no code, so nothing was saved; run \`${command}\` again`));
|
|
64
98
|
return;
|
|
65
99
|
case 'timeout':
|
|
66
|
-
console.error(chalk.red('
|
|
67
|
-
|
|
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`));
|
|
68
104
|
return;
|
|
69
105
|
case 'refused':
|
|
70
106
|
console.error(chalk.red(`the control plane refused the login code: ${outcome.detail}`));
|
|
71
|
-
process.exitCode = 1;
|
|
72
107
|
}
|
|
73
|
-
}
|
|
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
|
+
};
|
package/dist/cli/logout/main.js
CHANGED
|
@@ -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 { 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 { FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPat
|
|
|
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
|
-
.
|
|
19
|
-
|
|
20
|
-
const
|
|
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,6 +44,8 @@ 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
|
-
|
|
42
|
-
|
|
47
|
+
for (const name of operator ? [] : [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
|
|
48
|
+
if (process.env[name])
|
|
49
|
+
console.log(chalk.yellow(`${name} is still set in this shell, and the CLI still uses it.`));
|
|
50
|
+
}
|
|
43
51
|
});
|
package/dist/cli/program.js
CHANGED
|
@@ -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)
|