@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.
@@ -8,12 +8,21 @@
8
8
  * fails the test, so nothing reaches the site — or escapes it — unclassified.
9
9
  *
10
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.
11
+ * for.** A customer's credential is an API token carrying the permissions its
12
+ * person chose (`resource:read|write`, write implying read), within one
13
+ * Account, their role there and the Account's token policy (control-plane
14
+ * ADR-0071). A token reaches the control plane's repository routes its grants
15
+ * cover, plus its own identity and tokens; every other route — the fleet, the
16
+ * operator's organisation, tenants, billing — answers it 403. So a command is
17
+ * `customer` exactly when every route it calls is a repository or self route in
18
+ * the control plane's `route-access.ts`, and `operator` otherwise, however
19
+ * customer-shaped it looks; a customer command with one operator subcommand or
20
+ * flag hides just that. Local commands need no control plane.
21
+ *
22
+ * **`permission` is the grant a token needs** — on the command, or per
23
+ * subcommand in `subcommandPermissions` (keyed by the path under the command,
24
+ * `subject set`) — read off the same table, so the site can say which
25
+ * `--permission` to ask for. A command that needs none has none.
17
26
  */
18
27
  /** The reference's sections, in the order a person meets them. */
19
28
  export const GROUPS = [
@@ -25,27 +34,35 @@ export const GROUPS = [
25
34
  { id: 'runners', title: 'Runners' },
26
35
  { id: 'billing', title: 'Usage and cost' },
27
36
  ];
28
- /** Calls a control-plane route a customer token is refused on. */
29
- const OPERATOR_TODAY = 'operator';
37
+ /** Calls a control-plane route a customer token is refused on, whatever it holds. */
38
+ const OPERATOR = 'operator';
30
39
  export const REFERENCE = {
31
40
  login: {
32
41
  group: 'setup',
33
42
  audience: 'customer',
34
43
  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.' },
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
+ { command: 'apex login --account acc_4f2b9e', description: 'In more than one Account: name the one to sign in to.' },
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.' },
37
51
  ],
52
+ // The operator's sign-in, approved on the Admin Console (control-plane ADR-0079).
53
+ operatorOptions: ['--console'],
38
54
  },
39
55
  logout: {
40
56
  group: 'setup',
41
57
  audience: 'customer',
42
58
  examples: [{ command: 'apex logout', description: 'Revoke the saved token on the server, then delete it.' }],
59
+ operatorOptions: ['--console'],
43
60
  },
44
61
  whoami: {
45
62
  group: 'setup',
46
63
  audience: 'customer',
47
64
  examples: [
48
- { command: 'apex whoami', description: 'Who the CLI acts as, with which credential, and when it expires.' },
65
+ { command: 'apex whoami', description: "Who the CLI acts as, with which credential, and — for a token — what it may do and which Account it belongs to." },
49
66
  { command: 'apex whoami --json', description: 'The same, for a script.' },
50
67
  ],
51
68
  },
@@ -54,10 +71,22 @@ export const REFERENCE = {
54
71
  audience: 'customer',
55
72
  examples: [
56
73
  {
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.',
74
+ command: 'apex token create deploy-bot --permission actions:write --permission secrets:write --repo acme/web --expires-in-days 30',
75
+ description: 'Create a token that may run workflows and set secrets on one repository, approved in the browser, and print it once.',
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.',
59
80
  },
60
- { command: 'apex token list --all', description: 'Every token you hold, expired and revoked ones included.' },
81
+ {
82
+ command: 'apex token edit 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 --remove-permission secrets:write',
83
+ description: 'Narrow a token: applies at once, from the terminal.',
84
+ },
85
+ {
86
+ command: 'apex token edit 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 --add-permission deployments:write',
87
+ description: 'Widen a token: the browser opens so you can approve it, within your Account\'s token policy.',
88
+ },
89
+ { command: 'apex token list --all', description: 'Every token you hold by Account, with its permissions — expired and revoked ones included.' },
61
90
  { 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
91
  ],
63
92
  },
@@ -86,11 +115,12 @@ export const REFERENCE = {
86
115
  migrate: {
87
116
  group: 'local',
88
117
  audience: 'customer',
89
- // `coexist` opts a repository's workflows in on the control plane, which a customer token cannot reach yet.
90
- operatorSubcommands: ['coexist'],
118
+ // `scan` reads GitHub or a checkout; `coexist` changes the repository's settings on the control plane.
119
+ subcommandPermissions: { coexist: 'administration:write' },
91
120
  examples: [
92
121
  { command: 'apex migrate scan acme', description: "Report what an organization's workflows would do on Apex Actions, read through the GitHub API." },
93
122
  { command: 'apex migrate scan --path .', description: 'Scan a checkout on this machine instead.' },
123
+ { command: 'apex migrate coexist acme/web --on --add ci.yml', description: 'Run Apex beside GitHub Actions on one repository, starting with one workflow.' },
94
124
  ],
95
125
  },
96
126
  secret: {
@@ -98,6 +128,7 @@ export const REFERENCE = {
98
128
  audience: 'customer',
99
129
  // The organisation scope answers only for the operator's own organisation.
100
130
  operatorOptions: ['--org'],
131
+ subcommandPermissions: { list: 'secrets:read', set: 'secrets:write', delete: 'secrets:write' },
101
132
  examples: [
102
133
  { 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
134
  { 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.' },
@@ -108,32 +139,173 @@ export const REFERENCE = {
108
139
  group: 'config',
109
140
  audience: 'customer',
110
141
  operatorOptions: ['--org'],
142
+ subcommandPermissions: { list: 'variables:read', set: 'variables:write', delete: 'variables:write' },
111
143
  examples: [
112
144
  { 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
145
  { command: 'apex variable list --repo acme/web --json', description: "A repository's variables and their values, as JSON." },
114
146
  ],
115
147
  },
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' },
148
+ // Runs and logs: a run's repository decides, by the token's `actions` grant.
149
+ logs: {
150
+ group: 'runs',
151
+ audience: 'customer',
152
+ permission: 'actions:read',
153
+ subcommandPermissions: { delete: 'actions:write' },
154
+ examples: [
155
+ { command: 'apex logs latest', description: 'The newest run you can see, every job, as it finished.' },
156
+ { command: 'apex logs 4812 --job test --follow', description: 'Follow one job of a run until it completes.' },
157
+ ],
158
+ },
159
+ search: {
160
+ group: 'runs',
161
+ audience: 'customer',
162
+ permission: 'actions:read',
163
+ examples: [{ command: 'apex search "ECONNRESET" --repo acme/web --conclusion failure', description: "Find a string in the last 30 days of a repository's failed jobs." }],
164
+ },
165
+ why: {
166
+ group: 'runs',
167
+ audience: 'customer',
168
+ permission: 'actions:read',
169
+ examples: [
170
+ { command: 'apex why 4812', description: 'Why a run happened: the event, the workflow and the rule that matched.' },
171
+ { command: 'apex why --delivery 7c1e0a52-3b9f-11ef-8e6b-2f4d1c9a0b77', description: 'Why a webhook produced no run at all.' },
172
+ ],
173
+ },
174
+ reproduce: {
175
+ group: 'runs',
176
+ audience: 'customer',
177
+ permission: 'actions:read',
178
+ examples: [{ command: 'apex reproduce 4812', description: 'Print the apex run command that replays a past run on this machine; secrets are named, never copied.' }],
179
+ },
180
+ timing: {
181
+ group: 'runs',
182
+ audience: 'customer',
183
+ permission: 'actions:read',
184
+ examples: [{ command: 'apex timing 4812 --steps 3', description: 'Where a run spent its time, with the three slowest steps of each job.' }],
185
+ },
186
+ tests: {
187
+ group: 'runs',
188
+ audience: 'customer',
189
+ permission: 'actions:read',
190
+ examples: [
191
+ { command: 'apex tests 4812', description: "A run's failing tests." },
192
+ { command: 'apex tests --flaky --repo acme/web', description: 'Tests that passed and failed at the same commit.' },
193
+ ],
194
+ },
195
+ rerun: {
196
+ group: 'runs',
197
+ audience: 'customer',
198
+ permission: 'actions:write',
199
+ examples: [
200
+ { command: 'apex rerun 4812 --failed', description: 'Re-run only what failed, and everything downstream of it.' },
201
+ { command: 'apex rerun 4812 --failed --resume', description: 'Resume each failed job at the step it failed at, where its snapshot survives.' },
202
+ ],
203
+ },
204
+ dispatch: {
205
+ group: 'runs',
206
+ audience: 'customer',
207
+ permission: 'actions:write',
208
+ examples: [{ command: 'apex dispatch deploy.yml --repo acme/web --ref main --input environment=staging', description: 'Trigger a workflow_dispatch run with a typed input.' }],
209
+ },
210
+ // Repository configuration: each resource its own grant.
211
+ environment: {
212
+ group: 'config',
213
+ audience: 'customer',
214
+ subcommandPermissions: { list: 'environments:read', set: 'environments:write', delete: 'environments:write' },
215
+ examples: [
216
+ { command: 'apex environment list acme/web', description: 'The environments a repository protects, and how.' },
217
+ { command: 'apex environment set acme/web production --reviewer user:octocat --branch-pattern main', description: 'Require a named reviewer, and deploy only from main.' },
218
+ ],
219
+ },
220
+ deployment: {
221
+ group: 'config',
222
+ audience: 'customer',
223
+ // Deciding also needs the person to be one of the environment's named reviewers.
224
+ subcommandPermissions: { list: 'deployments:read', approve: 'deployments:write', reject: 'deployments:write' },
225
+ examples: [
226
+ { command: 'apex deployment list', description: 'Deployments waiting for an approval, across the repositories you reach.' },
227
+ { command: 'apex deployment approve 91 --comment "staging looks good"', description: 'Approve one, with a comment recorded beside the decision.' },
228
+ ],
229
+ },
230
+ approval: {
231
+ group: 'config',
232
+ audience: 'customer',
233
+ subcommandPermissions: { list: 'approvals:read', approve: 'approvals:write', reject: 'approvals:write' },
234
+ examples: [
235
+ { command: 'apex approval list', description: 'Runs from forks waiting for somebody to approve them.' },
236
+ { command: 'apex approval approve 4812', description: 'Approve one: it runs without secrets, on a runner of its own.' },
237
+ ],
238
+ },
239
+ schedule: {
240
+ group: 'config',
241
+ audience: 'customer',
242
+ subcommandPermissions: { list: 'actions:read' },
243
+ examples: [{ command: 'apex schedule list acme/web', description: 'What a repository has scheduled, and when each fires next.' }],
244
+ },
245
+ notify: {
246
+ group: 'config',
247
+ audience: 'customer',
248
+ subcommandPermissions: {
249
+ list: 'notifications:read',
250
+ log: 'notifications:read',
251
+ add: 'notifications:write',
252
+ rm: 'notifications:write',
253
+ test: 'notifications:write',
254
+ },
255
+ examples: [
256
+ { command: 'apex notify add acme/web ops --kind slack --on broke,recovered', description: 'Tell a Slack channel when the default branch breaks and recovers; the URL is prompted for.' },
257
+ { command: 'apex notify log acme/web', description: "The last attempts to reach a repository's destinations." },
258
+ ],
259
+ },
260
+ actions: {
261
+ group: 'config',
262
+ audience: 'customer',
263
+ // An organisation's action policy is still the operator's.
264
+ operatorSubcommands: ['policy'],
265
+ subcommandPermissions: { list: 'actions:read', pin: 'administration:write' },
266
+ examples: [
267
+ { command: 'apex actions list acme/web', description: 'Every action a repository pulls in, least fixed first.' },
268
+ { command: 'apex actions pin acme/web --apply', description: 'Open a pull request pinning each action to the commit its version points at.' },
269
+ ],
270
+ },
271
+ oidc: {
272
+ group: 'config',
273
+ audience: 'customer',
274
+ subcommandPermissions: { subject: 'actions:read', 'subject set': 'actions:write' },
275
+ examples: [
276
+ { command: 'apex oidc discovery', description: 'The discovery document a cloud provider fetches to trust these tokens.' },
277
+ { command: 'apex oidc subject set acme/web --claim repo context job_workflow_ref', description: "Fold the calling workflow into a repository's subject claim." },
278
+ ],
279
+ },
280
+ cache: {
281
+ group: 'storage',
282
+ audience: 'customer',
283
+ subcommandPermissions: { list: 'actions:read', delete: 'actions:write' },
284
+ examples: [
285
+ { command: 'apex cache list acme/web', description: 'The caches a repository holds, most recently used first.' },
286
+ { command: 'apex cache delete acme/web 318', description: 'Drop one entry by id.' },
287
+ ],
288
+ },
289
+ artifact: {
290
+ group: 'storage',
291
+ audience: 'customer',
292
+ subcommandPermissions: { list: 'actions:read', download: 'actions:read', delete: 'actions:write' },
293
+ examples: [
294
+ { command: 'apex artifact list 4812', description: 'What a run produced.' },
295
+ { command: 'apex artifact download 4812 coverage -o coverage.zip', description: "Download one of a run's artifacts as a zip." },
296
+ ],
297
+ },
298
+ spend: {
299
+ group: 'billing',
300
+ audience: 'customer',
301
+ // With a token, one repository at a time; the fleet-wide report is the operator's.
302
+ permission: 'actions:read',
303
+ examples: [{ command: 'apex spend --repo acme/web --days 7', description: "A repository's last week of cost, next to what GitHub would have charged." }],
304
+ },
305
+ // The operator's: the fleet, the customer agreement's escalation path, and departed tenants' data.
306
+ runner: { group: 'runners', audience: OPERATOR },
307
+ terms: { group: 'billing', audience: OPERATOR },
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 },
139
311
  };
@@ -74,7 +74,7 @@ const handler = async (options) => {
74
74
  };
75
75
  export default new Command('spend')
76
76
  .description('Show what the fleet cost, next to what the same work would have cost on GitHub')
77
- .option('--repo <owner/repo>', 'one repository (default: every repository)')
77
+ .option('--repo <owner/repo>', 'one repository — required with an API token (default for the operator: every repository)')
78
78
  .option('--days <n>', 'window in days, clamped to 1–365 (default: 30)')
79
79
  .option('--workflows <n>', `workflows to list (default: ${DEFAULT_ROWS})`)
80
80
  .option('--json', 'print the report as JSON')
@@ -5,6 +5,8 @@ import { backfillAccounts, connectInstallation, listUnconnected } from '../../sr
5
5
  import { backfillLines, connectLine, unconnectedLine } from '../../src/domain/accounts.js';
6
6
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
7
  import { countsLine, departureLine } from '../../src/domain/departures.js';
8
+ import { listInstallations } from '../../src/application/manage-grants.js';
9
+ import { installationLine } from '../../src/domain/grants.js';
8
10
  /** Build the API client, or explain what is missing and stop. */
9
11
  const api = () => {
10
12
  try {
@@ -157,10 +159,44 @@ const connectCommand = new Command('connect')
157
159
  }
158
160
  console.log(`${chalk.green('✔')} ${connectLine(outcome.value)}`);
159
161
  });
162
+ const installationsCommand = new Command('installations')
163
+ .description("Every installation of the App as GitHub lists it, with what Apex knows of each — --fresh for those the tenants list never shows, which a grant needs the id of")
164
+ .option('--fresh', 'only installations that never ran, paid or were granted')
165
+ .option('--json', 'machine-readable output')
166
+ .action(async (opts) => {
167
+ // The operator token `apex login --console` saved reaches this route too (control-plane ADR-0079).
168
+ let client;
169
+ try {
170
+ client = new HttpControlPlane({ operator: true });
171
+ }
172
+ catch (err) {
173
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+ const outcome = await listInstallations(client, opts.fresh === true);
178
+ if (outcome.kind === 'refused') {
179
+ console.error(chalk.red(outcome.message));
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+ if (opts.json === true) {
184
+ console.log(JSON.stringify(outcome.value, null, 2));
185
+ return;
186
+ }
187
+ if (outcome.value.length === 0) {
188
+ console.log(chalk.green(opts.fresh === true ? 'no fresh installation — every one has run, paid or been granted' : 'GitHub lists no installation of the App'));
189
+ return;
190
+ }
191
+ for (const i of outcome.value)
192
+ console.log(` ${installationLine(i)}`);
193
+ console.log(chalk.dim('\nGrant one with: apex grant create <installationId> --minutes <n> --concurrency <n> --reason "<why>"'));
194
+ });
160
195
  export default new Command('tenant')
161
- .description('Tenants: connecting new installations to Accounts, who left and deleting their data on request, and the one-time move into Accounts')
196
+ .description('Tenants: every installation of the App, connecting new ones to Accounts, who left and deleting their data on request, and the one-time move into Accounts')
162
197
  .addCommand(departuresCommand)
163
198
  .addCommand(purgeCommand)
164
199
  .addCommand(backfillCommand)
165
200
  .addCommand(unconnectedCommand)
166
- .addCommand(connectCommand);
201
+ .addCommand(connectCommand)
202
+ .addCommand(installationsCommand);
@@ -1,10 +1,11 @@
1
1
  import { Command } from 'commander';
2
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';
3
+ import { createToken, editToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
4
+ import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, loopbackTimeoutHint, webBaseFor } from '../../src/domain/credentials.js';
5
5
  import { TOKEN_ENV, WEB_URL_ENV, apiUrlFrom } from '../../src/infrastructure/credentials.js';
6
6
  import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
7
7
  import { api, collect, usage } from '../secret/shared.js';
8
+ import { approvalFlow, withFlowOptions } from '../login/device.js';
8
9
  /**
9
10
  * `apex token`: the person's API tokens from the terminal (control-plane
10
11
  * ADR-0064, amended 2026-09-27).
@@ -13,46 +14,84 @@ import { api, collect, usage } from '../secret/shared.js';
13
14
  * login` does, and prints the token once — the token alone on stdout, so
14
15
  * `$(apex token create …)` captures it, and everything a person reads on
15
16
  * stderr. It is never saved, and the CLI's own login is left as it was.
17
+ * A token carries the permissions asked for with `--permission` and belongs
18
+ * to one Account (control-plane ADR-0071).
19
+ * `edit` narrows a token at once with whichever credential the CLI has, and
20
+ * widens one only through the same browser approval.
16
21
  * `list` and `revoke` use whichever credential the CLI already has.
17
22
  *
18
23
  * Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
19
24
  */
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;
25
+ const PERMISSION_HELP = `resource:read or resource:write (write implies read); resource is one of ${TOKEN_RESOURCES.join(', ')}`;
26
+ /**
27
+ * The approval half `create` and a widening `edit` share — the loopback on this machine, or a device code
28
+ * (control-plane ADR-0075). Everything a person reads goes to stderr: stdout is the token's alone.
29
+ */
30
+ const approvalDeps = (flags, what) => {
33
31
  const apiUrl = apiUrlFrom();
34
- const outcome = await createToken({
32
+ const chosen = approvalFlow(flags, apiUrl, (line) => console.error(line));
33
+ if ('problem' in chosen)
34
+ return chosen;
35
+ return {
35
36
  pkce: newPkcePair,
36
37
  listen: (state) => listenForCallback(state),
37
- openBrowser: opts.browser ? (url) => openInBrowser(url) : undefined,
38
+ openBrowser: (url) => openInBrowser(url),
38
39
  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(`Opening the browser to approve ${what}. If it does not open, visit:`);
40
41
  console.error(` ${url}`);
41
42
  console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
42
43
  },
43
44
  exchange: new HttpLoginExchange(apiUrl),
44
45
  webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
45
- }, { name, days, repos: opts.repo });
46
+ flow: chosen.flow,
47
+ device: chosen.device,
48
+ };
49
+ };
50
+ const refused = (message) => {
51
+ console.error(chalk.red(message));
52
+ process.exitCode = 1;
53
+ };
54
+ const createCommand = withFlowOptions(new Command('create')
55
+ .description('Create an API token for a script or CI system, approved in the browser — here, or on any device with a code; the token is printed once and not saved')
56
+ .argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
57
+ .option('--permission <permission>', `what the token may do, repeatable and at least one: ${PERMISSION_HELP}`, collect, [])
58
+ .option('--account <id>', 'the Account the token belongs to — needed when you belong to more than one')
59
+ .option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
60
+ .option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you reach in the Account', collect, [])
61
+ .option('--json', 'print the token and its details as JSON on stdout')).action(async (name, opts) => {
62
+ const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
63
+ const deps = approvalDeps(opts, 'this token');
64
+ if ('problem' in deps) {
65
+ usage(deps.problem);
66
+ return;
67
+ }
68
+ const outcome = await createToken(deps, {
69
+ name,
70
+ days,
71
+ repos: opts.repo,
72
+ permissions: opts.permission,
73
+ accountId: opts.account,
74
+ });
46
75
  switch (outcome.kind) {
47
76
  case 'created': {
48
77
  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));
78
+ console.log(JSON.stringify({
79
+ token: outcome.token,
80
+ id: outcome.id,
81
+ name: outcome.name,
82
+ login: outcome.login,
83
+ expiresAt: outcome.expiresAt,
84
+ repos: outcome.repos ?? null,
85
+ permissions: outcome.permissions ?? [],
86
+ accountId: outcome.accountId ?? null,
87
+ }, null, 2));
50
88
  }
51
89
  else {
52
90
  console.log(outcome.token);
53
91
  }
54
- const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you administer' : outcome.repos.join(', ');
92
+ const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you reach' : outcome.repos.join(', ');
55
93
  console.error(`${chalk.green('✔')} created "${outcome.name}" for ${outcome.login} — ${reach}; expires ${outcome.expiresAt}`);
94
+ console.error(` permissions: ${describeGrants(outcome.permissions)}${outcome.accountId === undefined ? '' : ` · Account ${outcome.accountId}`}`);
56
95
  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
96
  return;
58
97
  }
@@ -69,17 +108,92 @@ const createCommand = new Command('create')
69
108
  refused('the browser came back without a code; run apex token create again');
70
109
  return;
71
110
  case 'timeout':
72
- refused('no approval arrived within 5 minutes; run apex token create again');
111
+ refused(loopbackTimeoutHint('apex token create'));
112
+ return;
113
+ case 'expired':
114
+ refused('the code expired before anyone approved it; run apex token create again for a new one');
73
115
  return;
74
116
  case 'refused':
75
117
  refused(outcome.detail);
76
118
  return;
77
119
  }
78
120
  });
79
- const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all administered' : t.repos.join(', '));
121
+ const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all' : t.repos.join(', '));
80
122
  const day = (iso) => (iso === undefined ? '—' : iso.slice(0, 10));
123
+ const editCommand = withFlowOptions(new Command('edit')
124
+ .description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser — here, or on any device with a code')
125
+ .argument('<id>', 'the token id, from apex token list')
126
+ .option('--permission <permission>', `the token's whole new set of permissions, repeatable: ${PERMISSION_HELP}`, collect, [])
127
+ .option('--add-permission <permission>', 'add one permission to what the token holds (repeatable)', collect, [])
128
+ .option('--remove-permission <permission>', 'take one permission away (repeatable)', collect, [])
129
+ .option('--repo <owner/repo>', "the token's new list of repositories (repeatable, up to 100)", collect, [])
130
+ .option('--all-repos', 'let the token reach every repository you reach in its Account')
131
+ .option('--json', 'print the edited token as JSON')).action(async (id, opts) => {
132
+ if (opts.allRepos === true && opts.repo.length > 0) {
133
+ usage('pass --repo or --all-repos, not both');
134
+ return;
135
+ }
136
+ const deps = approvalDeps(opts, 'widening this token');
137
+ if ('problem' in deps) {
138
+ usage(deps.problem);
139
+ return;
140
+ }
141
+ const client = api();
142
+ if (!client)
143
+ return;
144
+ const outcome = await editToken(client, deps, id, {
145
+ ...(opts.permission.length > 0 ? { permissions: opts.permission } : {}),
146
+ add: opts.addPermission,
147
+ remove: opts.removePermission,
148
+ ...(opts.allRepos === true ? { repos: 'all' } : opts.repo.length > 0 ? { repos: opts.repo } : {}),
149
+ });
150
+ switch (outcome.kind) {
151
+ case 'edited':
152
+ case 'unchanged': {
153
+ const t = outcome.token;
154
+ if (opts.json === true) {
155
+ console.log(JSON.stringify({ token: t, changed: outcome.kind === 'edited', ...(outcome.kind === 'edited' ? { via: outcome.via } : {}) }, null, 2));
156
+ return;
157
+ }
158
+ if (outcome.kind === 'unchanged')
159
+ console.log(chalk.yellow(`"${t.name}" already holds that — nothing changed`));
160
+ else
161
+ console.log(`${chalk.green('✔')} edited "${t.name}"${outcome.via === 'browser' ? ' — the widening was approved in the browser' : ''}`);
162
+ console.log(` permissions ${describeGrants(t.permissions)}`);
163
+ console.log(` repositories ${reachOf(t)}`);
164
+ if (t.accountId !== undefined)
165
+ console.log(` account ${t.accountId}`);
166
+ return;
167
+ }
168
+ case 'invalid':
169
+ usage(outcome.detail);
170
+ return;
171
+ case 'not-found':
172
+ console.error(chalk.red(`no active token with id ${outcome.id} — apex token list shows yours`));
173
+ process.exitCode = 3;
174
+ return;
175
+ case 'denied':
176
+ refused(`declined in the browser: ${outcome.detail}`);
177
+ return;
178
+ case 'state-mismatch':
179
+ refused('a callback arrived that did not belong to this request, so it was refused; run apex token edit again');
180
+ return;
181
+ case 'malformed':
182
+ refused('the browser came back without a code; run apex token edit again');
183
+ return;
184
+ case 'timeout':
185
+ refused(loopbackTimeoutHint('apex token edit'));
186
+ return;
187
+ case 'expired':
188
+ refused('the code expired before anyone approved the widening; run apex token edit again for a new one');
189
+ return;
190
+ case 'refused':
191
+ refused(outcome.detail);
192
+ return;
193
+ }
194
+ });
81
195
  const listCommand = new Command('list')
82
- .description('Your API tokens — names and last four characters, never the tokens')
196
+ .description('Your API tokens by Account, with what each may do — names and last four characters, never the tokens')
83
197
  .option('--all', 'include expired and revoked tokens')
84
198
  .option('--json', 'machine-readable output')
85
199
  .action(async (opts) => {
@@ -99,23 +213,32 @@ const listCommand = new Command('list')
99
213
  console.log(chalk.yellow(outcome.hidden > 0 ? `No active tokens (${outcome.hidden} expired or revoked; --all shows them).` : 'You hold no tokens.'));
100
214
  return;
101
215
  }
102
- const rows = outcome.tokens.map((t) => [
216
+ const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'PERMISSIONS', 'REPOSITORIES', 'EXPIRES', 'LAST USED', 'STATE'];
217
+ const rowOf = (t) => [
103
218
  `${t.current === true ? '* ' : ' '}${t.name}`,
104
219
  t.id,
105
220
  `…${t.last4}`,
106
221
  t.source,
222
+ describeGrants(t.permissions),
107
223
  reachOf(t),
108
- day(t.createdAt),
109
224
  day(t.expiresAt),
110
225
  day(t.lastUsedAt),
111
226
  t.state,
112
- ]);
113
- const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'REPOSITORIES', 'CREATED', 'EXPIRES', 'LAST USED', 'STATE'];
227
+ ];
228
+ const rows = outcome.tokens.map(rowOf);
114
229
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
115
230
  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));
231
+ // One section per Account: a token belongs to one, and reaches only that Account's repositories.
232
+ const accounts = [...new Set(outcome.tokens.map((t) => t.accountId ?? ''))].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
233
+ accounts.forEach((account, i) => {
234
+ if (i > 0)
235
+ console.log('');
236
+ console.log(chalk.bold(account === '' ? 'No Account — these tokens reach nothing; create a new one' : `Account ${account}`));
237
+ console.log(chalk.dim(line(header)));
238
+ for (const t of outcome.tokens)
239
+ if ((t.accountId ?? '') === account)
240
+ console.log(line(rowOf(t)));
241
+ });
119
242
  if (outcome.tokens.some((t) => t.current === true))
120
243
  console.log(chalk.dim('* the token this CLI is using'));
121
244
  if (outcome.hidden > 0)
@@ -151,7 +274,8 @@ const revokeCommand = new Command('revoke')
151
274
  }
152
275
  });
153
276
  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}`)
277
+ .description(`API tokens for scripts and CI: create one with the permissions it needs (approved in the browser), edit, list or revoke yours. Also on the dashboard at ${TOKENS_PAGE}`)
155
278
  .addCommand(createCommand)
279
+ .addCommand(editCommand)
156
280
  .addCommand(listCommand)
157
281
  .addCommand(revokeCommand);