@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.
- package/README.md +22 -4
- package/dist/cli/grant/main.js +141 -0
- package/dist/cli/login/device.js +43 -0
- package/dist/cli/login/main.js +104 -31
- package/dist/cli/logout/main.js +13 -7
- package/dist/cli/program.js +2 -0
- package/dist/cli/reference.js +14 -1
- package/dist/cli/tenant/main.js +38 -2
- package/dist/cli/token/main.js +40 -19
- package/dist/src/application/authenticate.js +82 -1
- package/dist/src/application/manage-grants.js +21 -0
- package/dist/src/application/manage-tokens.js +3 -3
- package/dist/src/domain/credentials.js +70 -1
- package/dist/src/domain/grants.js +111 -0
- package/dist/src/infrastructure/control-plane-http.js +18 -2
- package/dist/src/infrastructure/credentials.js +23 -0
- package/dist/src/infrastructure/device-login.js +82 -0
- package/dist/src/infrastructure/loopback-login.js +33 -26
- package/package.json +1 -1
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
|
|
15
|
-
| `apex dispatch` | the control-plane REST API | a customer token with `actions:write`, or the
|
|
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
|
|
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).
|
|
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
|
+
};
|
package/dist/cli/login/main.js
CHANGED
|
@@ -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
|
|
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('--
|
|
24
|
-
|
|
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:
|
|
50
|
+
openBrowser: (url) => openInBrowser(url),
|
|
34
51
|
announce: (url) => {
|
|
35
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
79
|
-
|
|
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
|
+
};
|
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 { 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
|
-
.
|
|
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,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
|
}
|
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)
|
package/dist/cli/reference.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
};
|
package/dist/cli/tenant/main.js
CHANGED
|
@@ -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
|
|
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);
|