@apex-actions/cli 0.73.1 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,15 @@ 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 | the operator token (`APEX_ADMIN_TOKEN`) |
15
- | `apex dispatch` | the control-plane REST API | the operator token (`APEX_ADMIN_TOKEN`) |
14
+ | `apex logs` | the control-plane REST API | a customer token with `actions:read` (`logs delete`: `actions:write`), or the operator token |
15
+ | `apex dispatch` | the control-plane REST API | a customer token with `actions:write`, or the operator token |
16
16
  | `apex login` · `logout` · `whoami` | the browser and the control-plane REST API | nothing — `login` mints the credential |
17
17
  | `apex token create` | the browser and the control-plane REST API | a person signed in to the dashboard, who approves it |
18
+ | `apex token edit` | the control-plane REST API; the browser to widen | a customer token to narrow; a person signed in to the dashboard to widen |
18
19
  | `apex token list` · `revoke` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`) |
19
- | `apex secret` · `apex variable` | the control-plane REST API | a customer token (`APEX_TOKEN` or `apex login`), repository admin |
20
+ | `apex secret` · `apex variable` | the control-plane REST API | a customer token with `secrets:*` / `variables:*` (`APEX_TOKEN` or `apex login`) |
21
+
22
+ Every other customer command in `cli/reference.ts` names the permission it needs; the public reference shows it.
20
23
 
21
24
  Generated reference: `docs/feature-docs/cli-commands.md` (`pnpm docs:cli` from `packages/release`), and
22
25
  the structured one the public site renders, `docs/feature-docs/cli-reference.json` (`pnpm docs:reference`;
@@ -43,21 +46,33 @@ apex migrate scan acme --repo acme/api --json # one repository of an organizat
43
46
 
44
47
  ### Credentials
45
48
 
46
- The CLI resolves one credential, in this order (control-plane ADR-0064):
49
+ The CLI resolves one credential, in this order (control-plane ADR-0064, amended by
50
+ [ADR-0005](docs/adr/0005-an-exported-token-outranks-a-saved-login.md)) — **the environment outranks
51
+ the file**:
47
52
 
48
53
  1. `APEX_TOKEN` — a token minted on the dashboard (`/settings/tokens`), for CI and scripts;
49
- 2. the token `apex login` saved in `$XDG_CONFIG_HOME/apex/credentials` (default `~/.config`),
54
+ 2. `APEX_ADMIN_TOKEN` — the operator's machine token, unchanged: unscoped, so treat it as a root
55
+ credential. An exported variable is the deliberate choice, as `GH_TOKEN` outranks `gh`'s stored
56
+ login; a saved login used to win, so an operator shell acted as whichever customer had last run
57
+ `apex login` on the machine;
58
+ 3. the token `apex login` saved in `$XDG_CONFIG_HOME/apex/credentials` (default `~/.config`),
50
59
  mode `0600` in a `0700` directory — a file readable by group or others is **refused**, as `ssh`
51
60
  refuses a private key;
52
- 3. `APEX_ADMIN_TOKEN` — the operator's machine token, unchanged: unscoped, so treat it as a root
53
- credential;
54
61
  4. otherwise the command stops with exit 2, naming `APEX_TOKEN` and `apex login`.
55
62
 
56
63
  `APEX_API_URL` selects the control plane (default `https://app.apexactions.com`); a saved login is
57
64
  only ever sent to the control plane that issued it. `apex login` opens the consent page on the web
58
65
  app — `APEX_WEB_URL`, or the API's origin — and listens on `127.0.0.1` for the loopback callback
59
- (RFC 8252, PKCE). A customer token's scope is repository secrets and variables plus `/me`; every
60
- other command answers with the server's 403. `apex logout` revokes the saved token on the server,
66
+ (RFC 8252, PKCE); once the code is redeemed it hands the tab back to the web app's bare `/cli/done`
67
+ page (control-plane ADR-0075). A customer token carries **permissions** (`resource:read|write`, write implying
68
+ read; resources `actions`, `approvals`, `deployments`, `secrets`, `variables`, `environments`, `notifications`,
69
+ `administration`) and belongs to **one Account** (control-plane ADR-0071). It reaches the repository routes its
70
+ grants cover, within its person's role and the Account owner's token policy; `apex login` takes the policy's
71
+ defaults for the person's role (the Owner's: everything), in the Account `--account` names when there is more than
72
+ one. A route it lacks the grant for answers 403 `this token needs <permission>`, which the CLI completes with the
73
+ `apex token edit <id> --add-permission <permission>` that would add it. `apex whoami`
74
+ names the credential in use, where it was found, and any other credential present that it
75
+ outranked. `apex logout` revokes the saved token on the server,
61
76
  then deletes the file. No command prints a token — except `apex token create`, whose whole job is to
62
77
  print the new one, once.
63
78
 
@@ -73,8 +88,11 @@ A pipeline gets its own token. It is approved in the browser like a login, print
73
88
  and never saved (control-plane ADR-0064: a token never mints a token):
74
89
 
75
90
  ```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
91
+ apex token create ci-deploy --permission actions:write --permission secrets:write \
92
+ --repo acme/api --expires-in-days 30 # token on stdout, details on stderr
93
+ apex token edit <id> --remove-permission secrets:write # narrowing: applies at once
94
+ apex token edit <id> --add-permission deployments:write # widening: approved in the browser
95
+ apex token list # by Account, with grants; * marks the one in use
78
96
  apex token revoke 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 # or --all
79
97
  ```
80
98
 
@@ -59,7 +59,7 @@ const setCommand = new Command('set')
59
59
  .argument('<repository>', 'owner/repo')
60
60
  .argument('<name>', 'environment name, as the workflow spells it')
61
61
  .option('--wait-timer <minutes>', 'delay before the job may start; 0 removes it', (v) => Number(v))
62
- .option('--reviewer <who...>', 'user:<login>, team:<org/slug>, or a bare login (a user)')
62
+ .option('--reviewer <who...>', 'user:<login>, role:<owner|account_admin|billing|member|viewer>, or a bare login (a user)')
63
63
  .option('--branch-pattern <glob...>', 'refs permitted to deploy here (main, releases/*, v?.0)')
64
64
  .option('--prevent-self-review', 'refuse an approval from whoever triggered the run')
65
65
  .option('--allow-self-review', 'permit it (the default)')
@@ -78,7 +78,7 @@ const setCommand = new Command('set')
78
78
  for (const raw of opts.reviewer) {
79
79
  const parsed = parseReviewer(raw);
80
80
  if (!parsed) {
81
- console.error(chalk.red(`expected user:<login>, team:<org/slug> or a bare login, got ${JSON.stringify(raw)}`));
81
+ console.error(chalk.red(`expected user:<login>, role:<owner|account_admin|billing|member|viewer> or a bare login, got ${JSON.stringify(raw)}`));
82
82
  process.exitCode = 2;
83
83
  return;
84
84
  }
@@ -2,8 +2,8 @@ import { hostname } from 'node:os';
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { login } from '../../src/application/authenticate.js';
5
- import { TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
6
- import { FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPath } from '../../src/infrastructure/credentials.js';
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';
7
7
  import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
8
8
  /**
9
9
  * `apex login`: sign the CLI in with the browser a person already uses
@@ -12,9 +12,14 @@ import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from
12
12
  *
13
13
  * It opens a browser only because it was asked to — this command is the
14
14
  * asking — and prints the URL either way, for a machine where none opens.
15
+ *
16
+ * The token carries the Account token policy's defaults for the person's role
17
+ * there (the Owner's: everything), and belongs to that one Account — named with
18
+ * `--account` when the person is in more than one (control-plane ADR-0071).
15
19
  */
16
20
  export default new Command('login')
17
- .description('Sign the CLI in through the browser; the token is saved for this user only')
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")
22
+ .option('--account <id>', 'the Account to sign in to — needed when you belong to more than one')
18
23
  .option('--no-browser', 'print the URL to open instead of opening a browser')
19
24
  .action(async (opts) => {
20
25
  const apiUrl = apiUrlFrom();
@@ -36,6 +41,7 @@ export default new Command('login')
36
41
  apiUrl,
37
42
  webBase,
38
43
  name: hostname().slice(0, 100) || 'apex CLI',
44
+ accountId: opts.account,
39
45
  });
40
46
  }
41
47
  catch (err) {
@@ -46,9 +52,15 @@ export default new Command('login')
46
52
  switch (outcome.kind) {
47
53
  case 'logged-in':
48
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
+ }
49
58
  console.log(chalk.dim(`Saved to ${outcome.location}. Revoke it any time at ${webBase}${TOKENS_PAGE}.`));
50
- if (process.env[TOKEN_ENV])
51
- console.log(chalk.yellow(`${TOKEN_ENV} is set in this shell and is used before the saved login.`));
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
+ }
52
64
  return;
53
65
  case 'denied':
54
66
  console.error(chalk.red(`the login was not approved: ${outcome.detail}`));
@@ -63,7 +75,7 @@ export default new Command('login')
63
75
  process.exitCode = 1;
64
76
  return;
65
77
  case 'timeout':
66
- console.error(chalk.red('no approval arrived within 5 minutes; run `apex login` again'));
78
+ console.error(chalk.red('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run `apex login` again'));
67
79
  process.exitCode = 1;
68
80
  return;
69
81
  case 'refused':
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import { logout } from '../../src/application/authenticate.js';
4
4
  import { TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
5
5
  import { HttpControlPlane } from '../../src/infrastructure/control-plane-http.js';
6
- import { FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPath } from '../../src/infrastructure/credentials.js';
6
+ import { ADMIN_TOKEN_ENV, FileCredentialStore, TOKEN_ENV, WEB_URL_ENV, apiUrlFrom, credentialsPath } from '../../src/infrastructure/credentials.js';
7
7
  /**
8
8
  * `apex logout`: revoke the token `apex login` saved, then forget it.
9
9
  *
@@ -38,6 +38,8 @@ export default new Command('logout')
38
38
  console.log(chalk.yellow(`The token could not be revoked (${outcome.revoked.failed}) and may still be valid — revoke it at ${tokensPage}.`));
39
39
  process.exitCode = 1;
40
40
  }
41
- if (process.env[TOKEN_ENV])
42
- console.log(chalk.yellow(`${TOKEN_ENV} is still set in this shell, and the CLI still uses it.`));
41
+ for (const name of [TOKEN_ENV, ADMIN_TOKEN_ENV]) {
42
+ if (process.env[name])
43
+ console.log(chalk.yellow(`${name} is still set in this shell, and the CLI still uses it.`));
44
+ }
43
45
  });
@@ -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,14 +34,15 @@ 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.' },
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.' },
36
46
  { command: 'apex login --no-browser', description: 'On a machine without a browser: print the approval URL to open elsewhere.' },
37
47
  ],
38
48
  },
@@ -45,7 +55,7 @@ export const REFERENCE = {
45
55
  group: 'setup',
46
56
  audience: 'customer',
47
57
  examples: [
48
- { command: 'apex whoami', description: 'Who the CLI acts as, with which credential, and when it expires.' },
58
+ { 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
59
  { command: 'apex whoami --json', description: 'The same, for a script.' },
50
60
  ],
51
61
  },
@@ -54,10 +64,18 @@ export const REFERENCE = {
54
64
  audience: 'customer',
55
65
  examples: [
56
66
  {
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.',
67
+ command: 'apex token create deploy-bot --permission actions:write --permission secrets:write --repo acme/web --expires-in-days 30',
68
+ description: 'Create a token that may run workflows and set secrets on one repository, approved in the browser, and print it once.',
69
+ },
70
+ {
71
+ command: 'apex token edit 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 --remove-permission secrets:write',
72
+ description: 'Narrow a token: applies at once, from the terminal.',
73
+ },
74
+ {
75
+ command: 'apex token edit 0b6f1c2e-9d4a-4f7e-8a31-5c2d9e7b1a40 --add-permission deployments:write',
76
+ description: 'Widen a token: the browser opens so you can approve it, within your Account\'s token policy.',
59
77
  },
60
- { command: 'apex token list --all', description: 'Every token you hold, expired and revoked ones included.' },
78
+ { command: 'apex token list --all', description: 'Every token you hold by Account, with its permissions — expired and revoked ones included.' },
61
79
  { 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
80
  ],
63
81
  },
@@ -86,11 +104,12 @@ export const REFERENCE = {
86
104
  migrate: {
87
105
  group: 'local',
88
106
  audience: 'customer',
89
- // `coexist` opts a repository's workflows in on the control plane, which a customer token cannot reach yet.
90
- operatorSubcommands: ['coexist'],
107
+ // `scan` reads GitHub or a checkout; `coexist` changes the repository's settings on the control plane.
108
+ subcommandPermissions: { coexist: 'administration:write' },
91
109
  examples: [
92
110
  { command: 'apex migrate scan acme', description: "Report what an organization's workflows would do on Apex Actions, read through the GitHub API." },
93
111
  { command: 'apex migrate scan --path .', description: 'Scan a checkout on this machine instead.' },
112
+ { command: 'apex migrate coexist acme/web --on --add ci.yml', description: 'Run Apex beside GitHub Actions on one repository, starting with one workflow.' },
94
113
  ],
95
114
  },
96
115
  secret: {
@@ -98,6 +117,7 @@ export const REFERENCE = {
98
117
  audience: 'customer',
99
118
  // The organisation scope answers only for the operator's own organisation.
100
119
  operatorOptions: ['--org'],
120
+ subcommandPermissions: { list: 'secrets:read', set: 'secrets:write', delete: 'secrets:write' },
101
121
  examples: [
102
122
  { 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
123
  { 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 +128,171 @@ export const REFERENCE = {
108
128
  group: 'config',
109
129
  audience: 'customer',
110
130
  operatorOptions: ['--org'],
131
+ subcommandPermissions: { list: 'variables:read', set: 'variables:write', delete: 'variables:write' },
111
132
  examples: [
112
133
  { 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
134
  { command: 'apex variable list --repo acme/web --json', description: "A repository's variables and their values, as JSON." },
114
135
  ],
115
136
  },
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' },
137
+ // Runs and logs: a run's repository decides, by the token's `actions` grant.
138
+ logs: {
139
+ group: 'runs',
140
+ audience: 'customer',
141
+ permission: 'actions:read',
142
+ subcommandPermissions: { delete: 'actions:write' },
143
+ examples: [
144
+ { command: 'apex logs latest', description: 'The newest run you can see, every job, as it finished.' },
145
+ { command: 'apex logs 4812 --job test --follow', description: 'Follow one job of a run until it completes.' },
146
+ ],
147
+ },
148
+ search: {
149
+ group: 'runs',
150
+ audience: 'customer',
151
+ permission: 'actions:read',
152
+ 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." }],
153
+ },
154
+ why: {
155
+ group: 'runs',
156
+ audience: 'customer',
157
+ permission: 'actions:read',
158
+ examples: [
159
+ { command: 'apex why 4812', description: 'Why a run happened: the event, the workflow and the rule that matched.' },
160
+ { command: 'apex why --delivery 7c1e0a52-3b9f-11ef-8e6b-2f4d1c9a0b77', description: 'Why a webhook produced no run at all.' },
161
+ ],
162
+ },
163
+ reproduce: {
164
+ group: 'runs',
165
+ audience: 'customer',
166
+ permission: 'actions:read',
167
+ examples: [{ command: 'apex reproduce 4812', description: 'Print the apex run command that replays a past run on this machine; secrets are named, never copied.' }],
168
+ },
169
+ timing: {
170
+ group: 'runs',
171
+ audience: 'customer',
172
+ permission: 'actions:read',
173
+ examples: [{ command: 'apex timing 4812 --steps 3', description: 'Where a run spent its time, with the three slowest steps of each job.' }],
174
+ },
175
+ tests: {
176
+ group: 'runs',
177
+ audience: 'customer',
178
+ permission: 'actions:read',
179
+ examples: [
180
+ { command: 'apex tests 4812', description: "A run's failing tests." },
181
+ { command: 'apex tests --flaky --repo acme/web', description: 'Tests that passed and failed at the same commit.' },
182
+ ],
183
+ },
184
+ rerun: {
185
+ group: 'runs',
186
+ audience: 'customer',
187
+ permission: 'actions:write',
188
+ examples: [
189
+ { command: 'apex rerun 4812 --failed', description: 'Re-run only what failed, and everything downstream of it.' },
190
+ { command: 'apex rerun 4812 --failed --resume', description: 'Resume each failed job at the step it failed at, where its snapshot survives.' },
191
+ ],
192
+ },
193
+ dispatch: {
194
+ group: 'runs',
195
+ audience: 'customer',
196
+ permission: 'actions:write',
197
+ examples: [{ command: 'apex dispatch deploy.yml --repo acme/web --ref main --input environment=staging', description: 'Trigger a workflow_dispatch run with a typed input.' }],
198
+ },
199
+ // Repository configuration: each resource its own grant.
200
+ environment: {
201
+ group: 'config',
202
+ audience: 'customer',
203
+ subcommandPermissions: { list: 'environments:read', set: 'environments:write', delete: 'environments:write' },
204
+ examples: [
205
+ { command: 'apex environment list acme/web', description: 'The environments a repository protects, and how.' },
206
+ { command: 'apex environment set acme/web production --reviewer user:octocat --branch-pattern main', description: 'Require a named reviewer, and deploy only from main.' },
207
+ ],
208
+ },
209
+ deployment: {
210
+ group: 'config',
211
+ audience: 'customer',
212
+ // Deciding also needs the person to be one of the environment's named reviewers.
213
+ subcommandPermissions: { list: 'deployments:read', approve: 'deployments:write', reject: 'deployments:write' },
214
+ examples: [
215
+ { command: 'apex deployment list', description: 'Deployments waiting for an approval, across the repositories you reach.' },
216
+ { command: 'apex deployment approve 91 --comment "staging looks good"', description: 'Approve one, with a comment recorded beside the decision.' },
217
+ ],
218
+ },
219
+ approval: {
220
+ group: 'config',
221
+ audience: 'customer',
222
+ subcommandPermissions: { list: 'approvals:read', approve: 'approvals:write', reject: 'approvals:write' },
223
+ examples: [
224
+ { command: 'apex approval list', description: 'Runs from forks waiting for somebody to approve them.' },
225
+ { command: 'apex approval approve 4812', description: 'Approve one: it runs without secrets, on a runner of its own.' },
226
+ ],
227
+ },
228
+ schedule: {
229
+ group: 'config',
230
+ audience: 'customer',
231
+ subcommandPermissions: { list: 'actions:read' },
232
+ examples: [{ command: 'apex schedule list acme/web', description: 'What a repository has scheduled, and when each fires next.' }],
233
+ },
234
+ notify: {
235
+ group: 'config',
236
+ audience: 'customer',
237
+ subcommandPermissions: {
238
+ list: 'notifications:read',
239
+ log: 'notifications:read',
240
+ add: 'notifications:write',
241
+ rm: 'notifications:write',
242
+ test: 'notifications:write',
243
+ },
244
+ examples: [
245
+ { 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.' },
246
+ { command: 'apex notify log acme/web', description: "The last attempts to reach a repository's destinations." },
247
+ ],
248
+ },
249
+ actions: {
250
+ group: 'config',
251
+ audience: 'customer',
252
+ // An organisation's action policy is still the operator's.
253
+ operatorSubcommands: ['policy'],
254
+ subcommandPermissions: { list: 'actions:read', pin: 'administration:write' },
255
+ examples: [
256
+ { command: 'apex actions list acme/web', description: 'Every action a repository pulls in, least fixed first.' },
257
+ { command: 'apex actions pin acme/web --apply', description: 'Open a pull request pinning each action to the commit its version points at.' },
258
+ ],
259
+ },
260
+ oidc: {
261
+ group: 'config',
262
+ audience: 'customer',
263
+ subcommandPermissions: { subject: 'actions:read', 'subject set': 'actions:write' },
264
+ examples: [
265
+ { command: 'apex oidc discovery', description: 'The discovery document a cloud provider fetches to trust these tokens.' },
266
+ { command: 'apex oidc subject set acme/web --claim repo context job_workflow_ref', description: "Fold the calling workflow into a repository's subject claim." },
267
+ ],
268
+ },
269
+ cache: {
270
+ group: 'storage',
271
+ audience: 'customer',
272
+ subcommandPermissions: { list: 'actions:read', delete: 'actions:write' },
273
+ examples: [
274
+ { command: 'apex cache list acme/web', description: 'The caches a repository holds, most recently used first.' },
275
+ { command: 'apex cache delete acme/web 318', description: 'Drop one entry by id.' },
276
+ ],
277
+ },
278
+ artifact: {
279
+ group: 'storage',
280
+ audience: 'customer',
281
+ subcommandPermissions: { list: 'actions:read', download: 'actions:read', delete: 'actions:write' },
282
+ examples: [
283
+ { command: 'apex artifact list 4812', description: 'What a run produced.' },
284
+ { command: 'apex artifact download 4812 coverage -o coverage.zip', description: "Download one of a run's artifacts as a zip." },
285
+ ],
286
+ },
287
+ spend: {
288
+ group: 'billing',
289
+ audience: 'customer',
290
+ // With a token, one repository at a time; the fleet-wide report is the operator's.
291
+ permission: 'actions:read',
292
+ examples: [{ command: 'apex spend --repo acme/web --days 7', description: "A repository's last week of cost, next to what GitHub would have charged." }],
293
+ },
294
+ // The operator's: the fleet, the customer agreement's escalation path, and departed tenants' data.
295
+ runner: { group: 'runners', audience: OPERATOR },
296
+ terms: { group: 'billing', audience: OPERATOR },
297
+ tenant: { group: 'billing', audience: OPERATOR },
139
298
  };
@@ -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')
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { listDepartures, purgeDeparture } from '../../src/application/manage-departures.js';
4
- import { backfillAccounts } from '../../src/application/manage-accounts.js';
5
- import { backfillLines } from '../../src/domain/accounts.js';
4
+ import { backfillAccounts, connectInstallation, listUnconnected } from '../../src/application/manage-accounts.js';
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
8
  /** Build the API client, or explain what is missing and stop. */
@@ -103,8 +103,64 @@ const backfillCommand = new Command('backfill')
103
103
  if (!outcome.value.applied)
104
104
  console.log(chalk.yellow('\nNothing was changed. Review the above, then re-run with --apply.'));
105
105
  });
106
+ const unconnectedCommand = new Command('unconnected')
107
+ .description('Installations no Apex Account owns, the longest-waiting first — their people are refused until connected (decisions-log O20)')
108
+ .option('--json', 'machine-readable output')
109
+ .action(async (opts) => {
110
+ const client = api();
111
+ if (!client)
112
+ return;
113
+ const outcome = await listUnconnected(client);
114
+ if (outcome.kind === 'refused') {
115
+ console.error(chalk.red(outcome.message));
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ if (opts.json === true) {
120
+ console.log(JSON.stringify(outcome.value, null, 2));
121
+ return;
122
+ }
123
+ if (outcome.value.length === 0) {
124
+ console.log(chalk.green('every installation is connected to an Account'));
125
+ return;
126
+ }
127
+ const now = new Date();
128
+ for (const i of outcome.value)
129
+ console.log(` ${unconnectedLine(i, now)}`);
130
+ console.log(chalk.dim('\nConnect one with: apex tenant connect <installationId> --owner <github-login> [--name <Account name>]'));
131
+ });
132
+ const connectCommand = new Command('connect')
133
+ .description('Connect one installation to the Account --owner owns, creating it with them its Owner when they own none (decisions-log O20)')
134
+ .argument('<installationId>', 'the installation to connect (see: apex tenant unconnected)')
135
+ .requiredOption('--owner <login>', 'the GitHub login of the Account’s Owner; they must have signed in once')
136
+ .option('--name <name>', 'the Account’s name — required when the Owner owns no Account yet')
137
+ .option('--json', 'machine-readable output')
138
+ .action(async (raw, opts) => {
139
+ const installationId = Number(raw);
140
+ if (!Number.isSafeInteger(installationId) || installationId <= 0) {
141
+ console.error(chalk.red(`expected a positive installation id, got ${JSON.stringify(raw)}`));
142
+ process.exitCode = 2;
143
+ return;
144
+ }
145
+ const client = api();
146
+ if (!client)
147
+ return;
148
+ const outcome = await connectInstallation(client, { installationId, ownerLogin: opts.owner, name: opts.name });
149
+ if (outcome.kind === 'refused') {
150
+ console.error(chalk.red(outcome.message));
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+ if (opts.json === true) {
155
+ console.log(JSON.stringify(outcome.value, null, 2));
156
+ return;
157
+ }
158
+ console.log(`${chalk.green('✔')} ${connectLine(outcome.value)}`);
159
+ });
106
160
  export default new Command('tenant')
107
- .description('Tenants: who left and deleting their data on request, and the one-time move into Accounts')
161
+ .description('Tenants: connecting new installations to Accounts, who left and deleting their data on request, and the one-time move into Accounts')
108
162
  .addCommand(departuresCommand)
109
163
  .addCommand(purgeCommand)
110
- .addCommand(backfillCommand);
164
+ .addCommand(backfillCommand)
165
+ .addCommand(unconnectedCommand)
166
+ .addCommand(connectCommand);