@apex-actions/cli 0.69.0 → 0.70.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 +17 -3
- package/dist/cli/approval/main.js +1 -1
- package/dist/cli/logs/main.js +5 -1
- package/dist/cli/main.js +2 -67
- package/dist/cli/program.js +74 -0
- package/dist/cli/reference.js +139 -0
- package/dist/cli/tenant/main.js +1 -1
- package/dist/cli/token/main.js +157 -0
- package/dist/cli/whoami/main.js +7 -1
- package/dist/src/application/authenticate.js +16 -6
- package/dist/src/application/manage-tokens.js +65 -0
- package/dist/src/domain/credentials.js +31 -1
- package/dist/src/infrastructure/control-plane-http.js +15 -0
- package/dist/src/infrastructure/loopback-login.js +10 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -14,9 +14,13 @@ Part of the [Apex Actions](../) superproject. Conventions, architecture, and age
|
|
|
14
14
|
| `apex logs` | the control-plane REST API | the operator token (`APEX_ADMIN_TOKEN`) |
|
|
15
15
|
| `apex dispatch` | the control-plane REST API | the operator token (`APEX_ADMIN_TOKEN`) |
|
|
16
16
|
| `apex login` · `logout` · `whoami` | the browser and the control-plane REST API | nothing — `login` mints the credential |
|
|
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 list` · `revoke` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`) |
|
|
17
19
|
| `apex secret` · `apex variable` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`), repository admin |
|
|
18
20
|
|
|
19
|
-
Generated reference: `docs/feature-docs/cli-commands.md` (`pnpm docs:cli`)
|
|
21
|
+
Generated reference: `docs/feature-docs/cli-commands.md` (`pnpm docs:cli` from `packages/release`), and
|
|
22
|
+
the structured one the public site renders, `docs/feature-docs/cli-reference.json` (`pnpm docs:reference`;
|
|
23
|
+
customer commands only, classified in `cli/reference.ts`).
|
|
20
24
|
|
|
21
25
|
```bash
|
|
22
26
|
export APEX_API_URL=https://apex.example
|
|
@@ -53,8 +57,9 @@ The CLI resolves one credential, in this order (control-plane ADR-0064):
|
|
|
53
57
|
only ever sent to the control plane that issued it. `apex login` opens the consent page on the web
|
|
54
58
|
app — `APEX_WEB_URL`, or the API's origin — and listens on `127.0.0.1` for the loopback callback
|
|
55
59
|
(RFC 8252, PKCE). A customer token's scope is repository secrets and variables plus `/me`; every
|
|
56
|
-
other command answers with the server's 403. `apex logout`
|
|
57
|
-
|
|
60
|
+
other command answers with the server's 403. `apex logout` revokes the saved token on the server,
|
|
61
|
+
then deletes the file. No command prints a token — except `apex token create`, whose whole job is to
|
|
62
|
+
print the new one, once.
|
|
58
63
|
|
|
59
64
|
```bash
|
|
60
65
|
apex login # once per machine
|
|
@@ -64,6 +69,15 @@ apex variable set AWS_REGION --value eu-west-1 --repo acme/api
|
|
|
64
69
|
apex secret list --repo acme/api --json # names only
|
|
65
70
|
```
|
|
66
71
|
|
|
72
|
+
A pipeline gets its own token. It is approved in the browser like a login, printed once to stdout,
|
|
73
|
+
and never saved (control-plane ADR-0064: a token never mints a token):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
apex token create ci-deploy --repo acme/api --expires-in-days 30 # token on stdout, details on stderr
|
|
77
|
+
apex token list # * marks the one this CLI uses
|
|
78
|
+
apex token revoke 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 # or --all
|
|
79
|
+
```
|
|
80
|
+
|
|
67
81
|
`apex secret set` never takes the value from the command line — argv is visible in `ps` and saved in
|
|
68
82
|
shell history (BUG-0157) — so `--value` is refused with exit 2. Exit codes: 0 done · 1 the API
|
|
69
83
|
refused · 2 usage or configuration · 3 not found on `delete`.
|
|
@@ -70,7 +70,7 @@ const decide = (verb) => new Command(verb)
|
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
72
|
export default new Command('approval')
|
|
73
|
-
.description('Runs from forks: what is waiting, and deciding it
|
|
73
|
+
.description('Runs from forks: what is waiting for approval, and deciding it')
|
|
74
74
|
.addCommand(listCommand)
|
|
75
75
|
.addCommand(decide('approve'))
|
|
76
76
|
.addCommand(decide('reject'));
|
package/dist/cli/logs/main.js
CHANGED
|
@@ -122,7 +122,11 @@ const deleteCommand = new Command('delete')
|
|
|
122
122
|
.description('Delete every job’s log for a finished run — for everyone, and not undoable')
|
|
123
123
|
.argument('<run>', 'run id, #number, or "latest" — required, never assumed')
|
|
124
124
|
.option('--repo <owner/repo>', 'limit the run search to one repository')
|
|
125
|
-
.action(async (run,
|
|
125
|
+
.action(async (run, _options, command) => {
|
|
126
|
+
// `logs` declares `--repo` too, and Commander gives a shared option to the
|
|
127
|
+
// parent — so this subcommand's own options never hold it, and a #number was
|
|
128
|
+
// searched for across every repository (found live on 0.69.0). Read both.
|
|
129
|
+
const options = command.optsWithGlobals();
|
|
126
130
|
const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
|
|
127
131
|
if (options.repo !== undefined && repository === undefined) {
|
|
128
132
|
console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
|
package/dist/cli/main.js
CHANGED
|
@@ -1,37 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import Run from './run/main.js';
|
|
6
|
-
import Validate from './validate/main.js';
|
|
7
|
-
import Migrate from './migrate/main.js';
|
|
8
|
-
import Logs from './logs/main.js';
|
|
9
|
-
import Search from './search/main.js';
|
|
10
|
-
import Why from './why/main.js';
|
|
11
|
-
import Reproduce from './reproduce/main.js';
|
|
12
|
-
import Timing from './timing/main.js';
|
|
13
|
-
import Tests from './tests/main.js';
|
|
14
|
-
import Rerun from './rerun/main.js';
|
|
15
|
-
import Spend from './spend/main.js';
|
|
16
|
-
import Dispatch from './dispatch/main.js';
|
|
17
|
-
import Runner from './runner/main.js';
|
|
18
|
-
import Cache from './cache/main.js';
|
|
19
|
-
import Artifact from './artifact/main.js';
|
|
20
|
-
import Environment from './environment/main.js';
|
|
21
|
-
import Deployment from './deployment/main.js';
|
|
22
|
-
import Approval from './approval/main.js';
|
|
23
|
-
import Schedule from './schedule/main.js';
|
|
24
|
-
import Oidc from './oidc/main.js';
|
|
25
|
-
import Actions from './actions/main.js';
|
|
26
|
-
import Terms from './terms/main.js';
|
|
27
|
-
import Tenant from './tenant/main.js';
|
|
28
|
-
import Notify from './notify/main.js';
|
|
29
|
-
import Lsp from './lsp/main.js';
|
|
30
|
-
import Login from './login/main.js';
|
|
31
|
-
import Logout from './logout/main.js';
|
|
32
|
-
import Whoami from './whoami/main.js';
|
|
33
|
-
import Secret from './secret/main.js';
|
|
34
|
-
import Variable from './variable/main.js';
|
|
4
|
+
import { buildProgram } from './program.js';
|
|
35
5
|
/**
|
|
36
6
|
* The version, from the nearest `package.json` rather than from `VERSION`.
|
|
37
7
|
*
|
|
@@ -52,39 +22,4 @@ const readVersion = () => {
|
|
|
52
22
|
}
|
|
53
23
|
}
|
|
54
24
|
};
|
|
55
|
-
|
|
56
|
-
const program = new Command()
|
|
57
|
-
.name('apex')
|
|
58
|
-
.description('Apex Actions — developer and operator CLI')
|
|
59
|
-
.version(version)
|
|
60
|
-
.addCommand(Run)
|
|
61
|
-
.addCommand(Validate)
|
|
62
|
-
.addCommand(Migrate)
|
|
63
|
-
.addCommand(Logs)
|
|
64
|
-
.addCommand(Search)
|
|
65
|
-
.addCommand(Why)
|
|
66
|
-
.addCommand(Reproduce)
|
|
67
|
-
.addCommand(Timing)
|
|
68
|
-
.addCommand(Tests)
|
|
69
|
-
.addCommand(Rerun)
|
|
70
|
-
.addCommand(Spend)
|
|
71
|
-
.addCommand(Dispatch)
|
|
72
|
-
.addCommand(Runner)
|
|
73
|
-
.addCommand(Cache)
|
|
74
|
-
.addCommand(Artifact)
|
|
75
|
-
.addCommand(Environment)
|
|
76
|
-
.addCommand(Deployment)
|
|
77
|
-
.addCommand(Approval)
|
|
78
|
-
.addCommand(Schedule)
|
|
79
|
-
.addCommand(Oidc)
|
|
80
|
-
.addCommand(Actions)
|
|
81
|
-
.addCommand(Terms)
|
|
82
|
-
.addCommand(Tenant)
|
|
83
|
-
.addCommand(Notify)
|
|
84
|
-
.addCommand(Login)
|
|
85
|
-
.addCommand(Logout)
|
|
86
|
-
.addCommand(Whoami)
|
|
87
|
-
.addCommand(Secret)
|
|
88
|
-
.addCommand(Variable)
|
|
89
|
-
.addCommand(Lsp);
|
|
90
|
-
program.parse(process.argv);
|
|
25
|
+
buildProgram(readVersion()).parse(process.argv);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import Run from './run/main.js';
|
|
3
|
+
import Validate from './validate/main.js';
|
|
4
|
+
import Migrate from './migrate/main.js';
|
|
5
|
+
import Logs from './logs/main.js';
|
|
6
|
+
import Search from './search/main.js';
|
|
7
|
+
import Why from './why/main.js';
|
|
8
|
+
import Reproduce from './reproduce/main.js';
|
|
9
|
+
import Timing from './timing/main.js';
|
|
10
|
+
import Tests from './tests/main.js';
|
|
11
|
+
import Rerun from './rerun/main.js';
|
|
12
|
+
import Spend from './spend/main.js';
|
|
13
|
+
import Dispatch from './dispatch/main.js';
|
|
14
|
+
import Runner from './runner/main.js';
|
|
15
|
+
import Cache from './cache/main.js';
|
|
16
|
+
import Artifact from './artifact/main.js';
|
|
17
|
+
import Environment from './environment/main.js';
|
|
18
|
+
import Deployment from './deployment/main.js';
|
|
19
|
+
import Approval from './approval/main.js';
|
|
20
|
+
import Schedule from './schedule/main.js';
|
|
21
|
+
import Oidc from './oidc/main.js';
|
|
22
|
+
import Actions from './actions/main.js';
|
|
23
|
+
import Terms from './terms/main.js';
|
|
24
|
+
import Tenant from './tenant/main.js';
|
|
25
|
+
import Notify from './notify/main.js';
|
|
26
|
+
import Lsp from './lsp/main.js';
|
|
27
|
+
import Login from './login/main.js';
|
|
28
|
+
import Logout from './logout/main.js';
|
|
29
|
+
import Whoami from './whoami/main.js';
|
|
30
|
+
import Secret from './secret/main.js';
|
|
31
|
+
import Variable from './variable/main.js';
|
|
32
|
+
import Token from './token/main.js';
|
|
33
|
+
/**
|
|
34
|
+
* The `apex` command tree, without parsing anything.
|
|
35
|
+
*
|
|
36
|
+
* Split from `main.ts` so the tree can be read as data: `test/cli-reference.test.ts`
|
|
37
|
+
* walks it to write the structured reference the public docs render, and a test
|
|
38
|
+
* can build it without a process to parse.
|
|
39
|
+
*/
|
|
40
|
+
export const buildProgram = (version) => new Command()
|
|
41
|
+
.name('apex')
|
|
42
|
+
.description('Apex Actions — developer and operator CLI')
|
|
43
|
+
.version(version)
|
|
44
|
+
.addCommand(Run)
|
|
45
|
+
.addCommand(Validate)
|
|
46
|
+
.addCommand(Migrate)
|
|
47
|
+
.addCommand(Logs)
|
|
48
|
+
.addCommand(Search)
|
|
49
|
+
.addCommand(Why)
|
|
50
|
+
.addCommand(Reproduce)
|
|
51
|
+
.addCommand(Timing)
|
|
52
|
+
.addCommand(Tests)
|
|
53
|
+
.addCommand(Rerun)
|
|
54
|
+
.addCommand(Spend)
|
|
55
|
+
.addCommand(Dispatch)
|
|
56
|
+
.addCommand(Runner)
|
|
57
|
+
.addCommand(Cache)
|
|
58
|
+
.addCommand(Artifact)
|
|
59
|
+
.addCommand(Environment)
|
|
60
|
+
.addCommand(Deployment)
|
|
61
|
+
.addCommand(Approval)
|
|
62
|
+
.addCommand(Schedule)
|
|
63
|
+
.addCommand(Oidc)
|
|
64
|
+
.addCommand(Actions)
|
|
65
|
+
.addCommand(Terms)
|
|
66
|
+
.addCommand(Tenant)
|
|
67
|
+
.addCommand(Notify)
|
|
68
|
+
.addCommand(Login)
|
|
69
|
+
.addCommand(Logout)
|
|
70
|
+
.addCommand(Whoami)
|
|
71
|
+
.addCommand(Token)
|
|
72
|
+
.addCommand(Secret)
|
|
73
|
+
.addCommand(Variable)
|
|
74
|
+
.addCommand(Lsp);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the public CLI reference presents each top-level command: which group it
|
|
3
|
+
* sits in, who it is for, and a few examples a person can copy.
|
|
4
|
+
*
|
|
5
|
+
* `test/cli-reference.test.ts` walks `buildProgram` and writes
|
|
6
|
+
* `docs/feature-docs/cli-reference.json` from the tree plus this table; the
|
|
7
|
+
* marketing site renders that file. A top-level command missing from here
|
|
8
|
+
* fails the test, so nothing reaches the site — or escapes it — unclassified.
|
|
9
|
+
*
|
|
10
|
+
* **`audience` is what a customer can run today, not what the command is
|
|
11
|
+
* for.** A customer's credential is an API token whose one scope is
|
|
12
|
+
* repository secrets and variables, plus its own identity and tokens
|
|
13
|
+
* (control-plane ADR-0064); every other control-plane route answers it 403. So
|
|
14
|
+
* a command that calls one of those routes is `operator` until its route opens
|
|
15
|
+
* to customers, however customer-shaped it looks — publishing it would sell
|
|
16
|
+
* something the product refuses. Local commands need no control plane.
|
|
17
|
+
*/
|
|
18
|
+
/** The reference's sections, in the order a person meets them. */
|
|
19
|
+
export const GROUPS = [
|
|
20
|
+
{ id: 'setup', title: 'Sign in and tokens' },
|
|
21
|
+
{ id: 'local', title: 'Run locally' },
|
|
22
|
+
{ id: 'runs', title: 'Runs and logs' },
|
|
23
|
+
{ id: 'config', title: 'Repository configuration' },
|
|
24
|
+
{ id: 'storage', title: 'Caches and artifacts' },
|
|
25
|
+
{ id: 'runners', title: 'Runners' },
|
|
26
|
+
{ id: 'billing', title: 'Usage and cost' },
|
|
27
|
+
];
|
|
28
|
+
/** Calls a control-plane route a customer token is refused on. */
|
|
29
|
+
const OPERATOR_TODAY = 'operator';
|
|
30
|
+
export const REFERENCE = {
|
|
31
|
+
login: {
|
|
32
|
+
group: 'setup',
|
|
33
|
+
audience: 'customer',
|
|
34
|
+
examples: [
|
|
35
|
+
{ command: 'apex login', description: 'Sign in through the browser; the token is saved for your user only.' },
|
|
36
|
+
{ command: 'apex login --no-browser', description: 'On a machine without a browser: print the approval URL to open elsewhere.' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
logout: {
|
|
40
|
+
group: 'setup',
|
|
41
|
+
audience: 'customer',
|
|
42
|
+
examples: [{ command: 'apex logout', description: 'Revoke the saved token on the server, then delete it.' }],
|
|
43
|
+
},
|
|
44
|
+
whoami: {
|
|
45
|
+
group: 'setup',
|
|
46
|
+
audience: 'customer',
|
|
47
|
+
examples: [
|
|
48
|
+
{ command: 'apex whoami', description: 'Who the CLI acts as, with which credential, and when it expires.' },
|
|
49
|
+
{ command: 'apex whoami --json', description: 'The same, for a script.' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
token: {
|
|
53
|
+
group: 'setup',
|
|
54
|
+
audience: 'customer',
|
|
55
|
+
examples: [
|
|
56
|
+
{
|
|
57
|
+
command: 'apex token create deploy-bot --repo acme/web --expires-in-days 30',
|
|
58
|
+
description: 'Create a token held to one repository, approved in the browser, and print it once.',
|
|
59
|
+
},
|
|
60
|
+
{ command: 'apex token list --all', description: 'Every token you hold, expired and revoked ones included.' },
|
|
61
|
+
{ command: 'apex token revoke 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40', description: 'Revoke one token by the id apex token list shows; it stops working at once.' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
run: {
|
|
65
|
+
group: 'local',
|
|
66
|
+
audience: 'customer',
|
|
67
|
+
examples: [
|
|
68
|
+
{ command: 'apex run .github/workflows/ci.yml', description: 'Run a workflow on this machine with the same engine and executor as the service.' },
|
|
69
|
+
{ command: 'apex run --job test --secret NPM_TOKEN', description: 'Run one job; NPM_TOKEN is read from your environment, never from the command line.' },
|
|
70
|
+
{ command: 'apex run --event pull_request --payload event.json', description: 'Plan against a pull_request event with a payload you supply.' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
validate: {
|
|
74
|
+
group: 'local',
|
|
75
|
+
audience: 'customer',
|
|
76
|
+
examples: [
|
|
77
|
+
{ command: 'apex validate', description: 'Validate every workflow in .github/workflows/.' },
|
|
78
|
+
{ command: 'apex validate .github/workflows/release.yml --json', description: 'Validate one file and print the diagnostics as JSON.' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
lsp: {
|
|
82
|
+
group: 'local',
|
|
83
|
+
audience: 'customer',
|
|
84
|
+
examples: [{ command: 'apex lsp', description: 'Start the workflow language server on stdio, for your editor to launch.' }],
|
|
85
|
+
},
|
|
86
|
+
migrate: {
|
|
87
|
+
group: 'local',
|
|
88
|
+
audience: 'customer',
|
|
89
|
+
// `coexist` opts a repository's workflows in on the control plane, which a customer token cannot reach yet.
|
|
90
|
+
operatorSubcommands: ['coexist'],
|
|
91
|
+
examples: [
|
|
92
|
+
{ command: 'apex migrate scan acme', description: "Report what an organization's workflows would do on Apex Actions, read through the GitHub API." },
|
|
93
|
+
{ command: 'apex migrate scan --path .', description: 'Scan a checkout on this machine instead.' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
secret: {
|
|
97
|
+
group: 'config',
|
|
98
|
+
audience: 'customer',
|
|
99
|
+
// The organisation scope answers only for the operator's own organisation.
|
|
100
|
+
operatorOptions: ['--org'],
|
|
101
|
+
examples: [
|
|
102
|
+
{ command: 'printf %s "$NPM_TOKEN" | apex secret set NPM_TOKEN --repo acme/web', description: 'Set a secret from a pipe; a value never goes on the command line.' },
|
|
103
|
+
{ command: 'apex secret set DEPLOY_KEY --repo acme/web --repo acme/api --from-file key.pem', description: 'Set one value on two repositories, from a file.' },
|
|
104
|
+
{ command: 'apex secret list --repo acme/web', description: "A repository's secret names — never values." },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
variable: {
|
|
108
|
+
group: 'config',
|
|
109
|
+
audience: 'customer',
|
|
110
|
+
operatorOptions: ['--org'],
|
|
111
|
+
examples: [
|
|
112
|
+
{ command: 'apex variable set NODE_VERSION --value 24 --repo acme/web', description: 'Set a repository variable; a variable is not secret, so --value is allowed.' },
|
|
113
|
+
{ command: 'apex variable list --repo acme/web --json', description: "A repository's variables and their values, as JSON." },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
// Commands whose routes refuse a customer token today: the operator's, until those routes open.
|
|
117
|
+
logs: { group: 'runs', audience: OPERATOR_TODAY },
|
|
118
|
+
search: { group: 'runs', audience: OPERATOR_TODAY },
|
|
119
|
+
why: { group: 'runs', audience: OPERATOR_TODAY },
|
|
120
|
+
reproduce: { group: 'runs', audience: OPERATOR_TODAY },
|
|
121
|
+
timing: { group: 'runs', audience: OPERATOR_TODAY },
|
|
122
|
+
tests: { group: 'runs', audience: OPERATOR_TODAY },
|
|
123
|
+
rerun: { group: 'runs', audience: OPERATOR_TODAY },
|
|
124
|
+
dispatch: { group: 'runs', audience: OPERATOR_TODAY },
|
|
125
|
+
environment: { group: 'config', audience: OPERATOR_TODAY },
|
|
126
|
+
deployment: { group: 'config', audience: OPERATOR_TODAY },
|
|
127
|
+
approval: { group: 'config', audience: OPERATOR_TODAY },
|
|
128
|
+
schedule: { group: 'config', audience: OPERATOR_TODAY },
|
|
129
|
+
notify: { group: 'config', audience: OPERATOR_TODAY },
|
|
130
|
+
actions: { group: 'config', audience: OPERATOR_TODAY },
|
|
131
|
+
oidc: { group: 'config', audience: OPERATOR_TODAY },
|
|
132
|
+
cache: { group: 'storage', audience: OPERATOR_TODAY },
|
|
133
|
+
artifact: { group: 'storage', audience: OPERATOR_TODAY },
|
|
134
|
+
runner: { group: 'runners', audience: OPERATOR_TODAY },
|
|
135
|
+
spend: { group: 'billing', audience: OPERATOR_TODAY },
|
|
136
|
+
// The operator's by design: the customer agreement's escalation path, and departed tenants' data.
|
|
137
|
+
terms: { group: 'billing', audience: 'operator' },
|
|
138
|
+
tenant: { group: 'billing', audience: 'operator' },
|
|
139
|
+
};
|
package/dist/cli/tenant/main.js
CHANGED
|
@@ -15,7 +15,7 @@ const api = () => {
|
|
|
15
15
|
}
|
|
16
16
|
};
|
|
17
17
|
const departuresCommand = new Command('departures')
|
|
18
|
-
.description('Every tenant that uninstalled, newest first, and when its data is deleted
|
|
18
|
+
.description('Every tenant that uninstalled, newest first, and when its data is deleted')
|
|
19
19
|
.option('--json', 'machine-readable output')
|
|
20
20
|
.action(async (opts) => {
|
|
21
21
|
const client = api();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
|
|
4
|
+
import { MAX_TOKEN_DAYS, TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
|
|
5
|
+
import { TOKEN_ENV, WEB_URL_ENV, apiUrlFrom } from '../../src/infrastructure/credentials.js';
|
|
6
|
+
import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
|
|
7
|
+
import { api, collect, usage } from '../secret/shared.js';
|
|
8
|
+
/**
|
|
9
|
+
* `apex token`: the person's API tokens from the terminal (control-plane
|
|
10
|
+
* ADR-0064, amended 2026-09-27).
|
|
11
|
+
*
|
|
12
|
+
* `create` asks for the person's approval in the browser, exactly as `apex
|
|
13
|
+
* login` does, and prints the token once — the token alone on stdout, so
|
|
14
|
+
* `$(apex token create …)` captures it, and everything a person reads on
|
|
15
|
+
* stderr. It is never saved, and the CLI's own login is left as it was.
|
|
16
|
+
* `list` and `revoke` use whichever credential the CLI already has.
|
|
17
|
+
*
|
|
18
|
+
* Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
|
|
19
|
+
*/
|
|
20
|
+
const refused = (message) => {
|
|
21
|
+
console.error(chalk.red(message));
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
};
|
|
24
|
+
const createCommand = new Command('create')
|
|
25
|
+
.description('Create an API token for a script or CI system, approved in the browser; the token is printed once and not saved')
|
|
26
|
+
.argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
|
|
27
|
+
.option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
|
|
28
|
+
.option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you administer', collect, [])
|
|
29
|
+
.option('--no-browser', 'print the approval URL instead of opening a browser')
|
|
30
|
+
.option('--json', 'print the token and its details as JSON on stdout')
|
|
31
|
+
.action(async (name, opts) => {
|
|
32
|
+
const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
|
|
33
|
+
const apiUrl = apiUrlFrom();
|
|
34
|
+
const outcome = await createToken({
|
|
35
|
+
pkce: newPkcePair,
|
|
36
|
+
listen: (state) => listenForCallback(state),
|
|
37
|
+
openBrowser: opts.browser ? (url) => openInBrowser(url) : undefined,
|
|
38
|
+
announce: (url) => {
|
|
39
|
+
console.error(opts.browser ? 'Opening the browser to approve this token. If it does not open, visit:' : 'Open this URL to approve this token:');
|
|
40
|
+
console.error(` ${url}`);
|
|
41
|
+
console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
|
|
42
|
+
},
|
|
43
|
+
exchange: new HttpLoginExchange(apiUrl),
|
|
44
|
+
webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
|
|
45
|
+
}, { name, days, repos: opts.repo });
|
|
46
|
+
switch (outcome.kind) {
|
|
47
|
+
case 'created': {
|
|
48
|
+
if (opts.json === true) {
|
|
49
|
+
console.log(JSON.stringify({ token: outcome.token, id: outcome.id, name: outcome.name, login: outcome.login, expiresAt: outcome.expiresAt, repos: outcome.repos ?? null }, null, 2));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(outcome.token);
|
|
53
|
+
}
|
|
54
|
+
const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you administer' : outcome.repos.join(', ');
|
|
55
|
+
console.error(`${chalk.green('✔')} created "${outcome.name}" for ${outcome.login} — ${reach}; expires ${outcome.expiresAt}`);
|
|
56
|
+
console.error(chalk.yellow('This is the only time the token is shown. Store it now — in your CI system as a secret named APEX_TOKEN.'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
case 'invalid':
|
|
60
|
+
usage(outcome.detail);
|
|
61
|
+
return;
|
|
62
|
+
case 'denied':
|
|
63
|
+
refused(`declined in the browser: ${outcome.detail}`);
|
|
64
|
+
return;
|
|
65
|
+
case 'state-mismatch':
|
|
66
|
+
refused('a callback arrived that did not belong to this request, so it was refused; run apex token create again');
|
|
67
|
+
return;
|
|
68
|
+
case 'malformed':
|
|
69
|
+
refused('the browser came back without a code; run apex token create again');
|
|
70
|
+
return;
|
|
71
|
+
case 'timeout':
|
|
72
|
+
refused('no approval arrived within 5 minutes; run apex token create again');
|
|
73
|
+
return;
|
|
74
|
+
case 'refused':
|
|
75
|
+
refused(outcome.detail);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all administered' : t.repos.join(', '));
|
|
80
|
+
const day = (iso) => (iso === undefined ? '—' : iso.slice(0, 10));
|
|
81
|
+
const listCommand = new Command('list')
|
|
82
|
+
.description('Your API tokens — names and last four characters, never the tokens')
|
|
83
|
+
.option('--all', 'include expired and revoked tokens')
|
|
84
|
+
.option('--json', 'machine-readable output')
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
const client = api();
|
|
87
|
+
if (!client)
|
|
88
|
+
return;
|
|
89
|
+
const outcome = await listTokens(client, opts.all === true);
|
|
90
|
+
if (outcome.kind === 'refused') {
|
|
91
|
+
refused(outcome.detail);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (opts.json === true) {
|
|
95
|
+
console.log(JSON.stringify({ tokens: outcome.tokens }, null, 2));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (outcome.tokens.length === 0) {
|
|
99
|
+
console.log(chalk.yellow(outcome.hidden > 0 ? `No active tokens (${outcome.hidden} expired or revoked; --all shows them).` : 'You hold no tokens.'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const rows = outcome.tokens.map((t) => [
|
|
103
|
+
`${t.current === true ? '* ' : ' '}${t.name}`,
|
|
104
|
+
t.id,
|
|
105
|
+
`…${t.last4}`,
|
|
106
|
+
t.source,
|
|
107
|
+
reachOf(t),
|
|
108
|
+
day(t.createdAt),
|
|
109
|
+
day(t.expiresAt),
|
|
110
|
+
day(t.lastUsedAt),
|
|
111
|
+
t.state,
|
|
112
|
+
]);
|
|
113
|
+
const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'REPOSITORIES', 'CREATED', 'EXPIRES', 'LAST USED', 'STATE'];
|
|
114
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
115
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd();
|
|
116
|
+
console.log(chalk.dim(line(header)));
|
|
117
|
+
for (const r of rows)
|
|
118
|
+
console.log(line(r));
|
|
119
|
+
if (outcome.tokens.some((t) => t.current === true))
|
|
120
|
+
console.log(chalk.dim('* the token this CLI is using'));
|
|
121
|
+
if (outcome.hidden > 0)
|
|
122
|
+
console.log(chalk.dim(`${outcome.hidden} expired or revoked not shown; --all shows them.`));
|
|
123
|
+
});
|
|
124
|
+
const revokeCommand = new Command('revoke')
|
|
125
|
+
.description('Revoke one of your API tokens by id, or all of them; it stops working immediately')
|
|
126
|
+
.argument('[id]', 'the token id, from apex token list')
|
|
127
|
+
.option('--all', 'revoke every token you hold, including the one this CLI is using')
|
|
128
|
+
.action(async (id, opts) => {
|
|
129
|
+
if ((id === undefined) === (opts.all !== true)) {
|
|
130
|
+
usage('name one token id, or pass --all');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const client = api();
|
|
134
|
+
if (!client)
|
|
135
|
+
return;
|
|
136
|
+
const outcome = await revokeTokens(client, id === undefined ? { all: true } : { id });
|
|
137
|
+
switch (outcome.kind) {
|
|
138
|
+
case 'revoked':
|
|
139
|
+
console.log(`${chalk.green('✔')} revoked ${outcome.count} ${outcome.count === 1 ? 'token' : 'tokens'}`);
|
|
140
|
+
if (outcome.includedCurrent) {
|
|
141
|
+
console.log(chalk.yellow(`That included the token this CLI was using, so its next command will be refused. Run apex login again${process.env[TOKEN_ENV] ? `, or set ${TOKEN_ENV} to another token` : ''}.`));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
case 'not-found':
|
|
145
|
+
console.error(chalk.red(`no active token with id ${outcome.id} — apex token list shows yours`));
|
|
146
|
+
process.exitCode = 3;
|
|
147
|
+
return;
|
|
148
|
+
case 'refused':
|
|
149
|
+
refused(outcome.detail);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
export default new Command('token')
|
|
154
|
+
.description(`API tokens for scripts and CI: create one (approved in the browser), list yours, revoke one. Also on the dashboard at ${TOKENS_PAGE}`)
|
|
155
|
+
.addCommand(createCommand)
|
|
156
|
+
.addCommand(listCommand)
|
|
157
|
+
.addCommand(revokeCommand);
|
package/dist/cli/whoami/main.js
CHANGED
|
@@ -9,7 +9,13 @@ const credentialLine = (credential) => {
|
|
|
9
9
|
return 'unknown';
|
|
10
10
|
if (credential.kind !== 'token')
|
|
11
11
|
return credential.kind === 'machine' ? 'the machine token (operator)' : 'a signed-in session';
|
|
12
|
-
const source = credential.source === 'login'
|
|
12
|
+
const source = credential.source === 'login'
|
|
13
|
+
? 'minted by apex login'
|
|
14
|
+
: credential.source === 'cli'
|
|
15
|
+
? 'minted by apex token create'
|
|
16
|
+
: credential.source === 'dashboard'
|
|
17
|
+
? 'minted on the dashboard'
|
|
18
|
+
: 'a token';
|
|
13
19
|
return `token, ${source}${credential.expiresAt === undefined ? '' : `, expires ${credential.expiresAt}`}`;
|
|
14
20
|
};
|
|
15
21
|
/**
|
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import { authorizeUrl } from '../domain/credentials.js';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The loopback flow itself: open the consent page, wait for the one callback,
|
|
4
|
+
* redeem its code with the verifier. Shared by `apex login`, which saves what
|
|
5
|
+
* it gets, and `apex token create`, which hands it back to be printed once.
|
|
6
|
+
*/
|
|
7
|
+
export const approveInBrowser = async (deps, token) => {
|
|
3
8
|
const pair = deps.pkce();
|
|
4
9
|
const listener = await deps.listen(pair.state);
|
|
5
10
|
try {
|
|
6
|
-
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name });
|
|
11
|
+
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name, token });
|
|
7
12
|
deps.announce(url);
|
|
8
13
|
deps.openBrowser?.(url);
|
|
9
14
|
const callback = await listener.wait();
|
|
10
15
|
if (callback.kind !== 'code')
|
|
11
16
|
return callback;
|
|
12
|
-
let minted;
|
|
13
17
|
try {
|
|
14
|
-
minted
|
|
18
|
+
return { kind: 'minted', minted: await deps.exchange.redeem(callback.code, pair.verifier) };
|
|
15
19
|
}
|
|
16
20
|
catch (err) {
|
|
17
21
|
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
18
22
|
}
|
|
19
|
-
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
20
|
-
return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
|
|
21
23
|
}
|
|
22
24
|
finally {
|
|
23
25
|
await listener.close();
|
|
24
26
|
}
|
|
25
27
|
};
|
|
28
|
+
export const login = async (deps) => {
|
|
29
|
+
const approval = await approveInBrowser(deps);
|
|
30
|
+
if (approval.kind !== 'minted')
|
|
31
|
+
return approval;
|
|
32
|
+
const { minted } = approval;
|
|
33
|
+
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
34
|
+
return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
|
|
35
|
+
};
|
|
26
36
|
/**
|
|
27
37
|
* Revoke the saved token at the control plane that minted it, then forget it
|
|
28
38
|
* (control-plane ADR-0064: a token may revoke itself and nothing else). The file
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { tokenRequestProblem } from '../domain/credentials.js';
|
|
3
|
+
import { approveInBrowser } from './authenticate.js';
|
|
4
|
+
const detail = (err) => (err instanceof Error ? err.message : String(err));
|
|
5
|
+
/**
|
|
6
|
+
* Mint a token through the browser. The flags are checked before a browser
|
|
7
|
+
* opens, so a typo costs nothing; the control plane checks them again.
|
|
8
|
+
*/
|
|
9
|
+
export const createToken = async (deps, input) => {
|
|
10
|
+
const name = input.name.trim();
|
|
11
|
+
const problem = tokenRequestProblem(name, input.days, input.repos);
|
|
12
|
+
if (problem !== undefined)
|
|
13
|
+
return { kind: 'invalid', detail: problem };
|
|
14
|
+
const approval = await approveInBrowser({ ...deps, name }, { ...(input.days === undefined ? {} : { days: input.days }), ...(input.repos.length === 0 ? {} : { repos: input.repos }) });
|
|
15
|
+
if (approval.kind !== 'minted')
|
|
16
|
+
return approval;
|
|
17
|
+
const { minted } = approval;
|
|
18
|
+
return {
|
|
19
|
+
kind: 'created',
|
|
20
|
+
token: minted.token,
|
|
21
|
+
login: minted.login,
|
|
22
|
+
expiresAt: minted.expiresAt,
|
|
23
|
+
id: minted.id,
|
|
24
|
+
name: minted.name ?? name,
|
|
25
|
+
repos: minted.repos,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** The person's tokens, newest first; only the active ones unless `all`. */
|
|
29
|
+
export const listTokens = async (api, all) => {
|
|
30
|
+
let tokens;
|
|
31
|
+
try {
|
|
32
|
+
tokens = await api.listTokens();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { kind: 'refused', detail: detail(err) };
|
|
36
|
+
}
|
|
37
|
+
const shown = all ? tokens : tokens.filter((t) => t.state === 'active');
|
|
38
|
+
return { kind: 'listed', tokens: shown, hidden: tokens.length - shown.length };
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Revoke one token by id, or every token with `all`. Asks for the list first
|
|
42
|
+
* only to say whether the CLI just revoked its own credential, which is the one
|
|
43
|
+
* outcome a person would otherwise discover on their next command.
|
|
44
|
+
*/
|
|
45
|
+
export const revokeTokens = async (api, target) => {
|
|
46
|
+
let current;
|
|
47
|
+
try {
|
|
48
|
+
current = (await api.listTokens()).find((t) => t.current === true && t.state === 'active')?.id;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Not knowing which one is ours does not stop the revocation.
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if ('all' in target) {
|
|
55
|
+
const count = await api.revokeAllTokens();
|
|
56
|
+
return { kind: 'revoked', count, includedCurrent: current !== undefined };
|
|
57
|
+
}
|
|
58
|
+
if (!(await api.revokeToken(target.id)))
|
|
59
|
+
return { kind: 'not-found', id: target.id };
|
|
60
|
+
return { kind: 'revoked', count: 1, includedCurrent: current === target.id };
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return { kind: 'refused', detail: detail(err) };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
@@ -50,7 +50,11 @@ export const parseSavedCredential = (raw) => {
|
|
|
50
50
|
return undefined;
|
|
51
51
|
return { token: r['token'], login: r['login'], apiUrl: r['apiUrl'], expiresAt: r['expiresAt'] };
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The consent page `apex login` and `apex token create` open (RFC 8252
|
|
55
|
+
* loopback, RFC 7636 S256). A token request adds `purpose=token` and what the
|
|
56
|
+
* person is approving, so the page can show it and the code carries it.
|
|
57
|
+
*/
|
|
54
58
|
export const authorizeUrl = (webBase, request) => {
|
|
55
59
|
const query = new URLSearchParams({
|
|
56
60
|
port: String(request.port),
|
|
@@ -58,8 +62,34 @@ export const authorizeUrl = (webBase, request) => {
|
|
|
58
62
|
code_challenge: request.codeChallenge,
|
|
59
63
|
name: request.name,
|
|
60
64
|
});
|
|
65
|
+
if (request.token !== undefined) {
|
|
66
|
+
query.set('purpose', 'token');
|
|
67
|
+
if (request.token.days !== undefined)
|
|
68
|
+
query.set('days', String(request.token.days));
|
|
69
|
+
if (request.token.repos !== undefined && request.token.repos.length > 0)
|
|
70
|
+
query.set('repos', request.token.repos.join(','));
|
|
71
|
+
}
|
|
61
72
|
return `${normaliseUrl(webBase)}/cli/authorize?${query.toString()}`;
|
|
62
73
|
};
|
|
74
|
+
/** A token's lifetime bounds (control-plane ADR-0064): every token expires, 90 days at most. */
|
|
75
|
+
export const MAX_TOKEN_DAYS = 90;
|
|
76
|
+
/** Repositories one token may be held to. */
|
|
77
|
+
export const MAX_TOKEN_REPOS = 100;
|
|
78
|
+
const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
|
|
79
|
+
/** `apex token create`'s flags, checked before a browser opens — the control plane checks them again. */
|
|
80
|
+
export const tokenRequestProblem = (name, days, repos) => {
|
|
81
|
+
if (name.trim() === '' || name.trim().length > 100)
|
|
82
|
+
return 'a token needs a name of 1 to 100 characters';
|
|
83
|
+
if (days !== undefined && !(Number.isInteger(days) && days >= 1 && days <= MAX_TOKEN_DAYS)) {
|
|
84
|
+
return `--expires-in-days must be a whole number from 1 to ${MAX_TOKEN_DAYS}`;
|
|
85
|
+
}
|
|
86
|
+
if (repos.length > MAX_TOKEN_REPOS)
|
|
87
|
+
return `a token may name at most ${MAX_TOKEN_REPOS} repositories`;
|
|
88
|
+
const bad = repos.filter((r) => !REPO.test(r));
|
|
89
|
+
if (bad.length > 0)
|
|
90
|
+
return `not owner/repo: ${bad.join(', ')}`;
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
63
93
|
/**
|
|
64
94
|
* Judge what arrived at the loopback callback.
|
|
65
95
|
*
|
|
@@ -55,6 +55,21 @@ export class HttpControlPlane {
|
|
|
55
55
|
async whoami() {
|
|
56
56
|
return await this.#json('GET', '/api/v1/me');
|
|
57
57
|
}
|
|
58
|
+
// ── the person's tokens: read and revoke, never mint (ADR-0064, amended 2026-09-27) ──
|
|
59
|
+
async listTokens() {
|
|
60
|
+
return (await this.#json('GET', '/api/v1/me/tokens')).tokens;
|
|
61
|
+
}
|
|
62
|
+
async revokeToken(id) {
|
|
63
|
+
const response = await this.#request('DELETE', `/api/v1/me/tokens/${encodeURIComponent(id)}`);
|
|
64
|
+
if (response.status === 404)
|
|
65
|
+
return false;
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new HttpError(await this.#errorMessage(response), response.status);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
async revokeAllTokens() {
|
|
71
|
+
return (await this.#json('DELETE', '/api/v1/me/tokens')).revoked;
|
|
72
|
+
}
|
|
58
73
|
// ── secrets and variables (values are write-only) ──
|
|
59
74
|
async listSecrets(target) {
|
|
60
75
|
const body = await this.#json('GET', `${this.#scopePath(target)}/secrets`);
|
|
@@ -56,10 +56,10 @@ export const listenForCallback = async (expectedState, timeoutMs = LOGIN_TIMEOUT
|
|
|
56
56
|
const verdict = judgeCallback(url.searchParams, expectedState);
|
|
57
57
|
switch (verdict.kind) {
|
|
58
58
|
case 'code':
|
|
59
|
-
answer(res, 200, page('
|
|
59
|
+
answer(res, 200, page('Approved', 'The <code>apex</code> CLI has what you approved. You can close this tab.'));
|
|
60
60
|
break;
|
|
61
61
|
case 'denied':
|
|
62
|
-
answer(res, 200, page('
|
|
62
|
+
answer(res, 200, page('Declined', 'The request was declined. You can close this tab.'));
|
|
63
63
|
break;
|
|
64
64
|
case 'state-mismatch':
|
|
65
65
|
answer(res, 400, page('Not signed in', 'This callback does not belong to the login waiting in your terminal, so it was refused.'));
|
|
@@ -131,6 +131,13 @@ export class HttpLoginExchange {
|
|
|
131
131
|
if (typeof body.token !== 'string' || typeof body.login !== 'string' || typeof body.expiresAt !== 'string') {
|
|
132
132
|
throw new Error('the control plane answered the code exchange without a token');
|
|
133
133
|
}
|
|
134
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
token: body.token,
|
|
136
|
+
login: body.login,
|
|
137
|
+
expiresAt: body.expiresAt,
|
|
138
|
+
...(typeof body.id === 'string' ? { id: body.id } : {}),
|
|
139
|
+
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
|
140
|
+
...(Array.isArray(body.repos) ? { repos: body.repos.filter((r) => typeof r === 'string') } : {}),
|
|
141
|
+
};
|
|
135
142
|
}
|
|
136
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apex-actions/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"//files": "Without this npm ships src, test and the CI config alongside the binary a user installs.",
|
|
6
6
|
"files": [
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"test": "vitest run",
|
|
50
50
|
"lint": "eslint . --max-warnings=0",
|
|
51
51
|
"lint:arch": "depcruise src cli --config .dependency-cruiser.cjs",
|
|
52
|
-
"apex": "tsx cli/main.ts"
|
|
52
|
+
"apex": "tsx cli/main.ts",
|
|
53
|
+
"docs:reference": "APEX_UPDATE_GOLDEN=1 vitest run test/cli-reference.test.ts"
|
|
53
54
|
}
|
|
54
55
|
}
|