@apex-actions/cli 0.100.0 → 0.101.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.
@@ -5,7 +5,8 @@ import { createGrantBody, createdLine, grantLine, revokedLine, updateGrantBody }
5
5
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
6
6
  /**
7
7
  * `apex grant`: complimentary access from the terminal (api ADR-0056 as amended by api ADR-0079,
8
- * TASK-0199). The admin API's own shapes, unchanged. Every write needs a person: the operator token
8
+ * TASK-0199). The admin API's own shapes, unchanged. A grant belongs to an Account and covers every Organization in it
9
+ * but the operator's (decisions-log O66, O73), so `create` names an Account, never an installation. Every write needs a person: the operator token
9
10
  * `apex login --console` saved, whose operator the grant row and the audit log name. The machine token
10
11
  * (`APEX_ADMIN_TOKEN`) can list and is refused every write, so a leaked one cannot make a free tenant.
11
12
  */
@@ -57,15 +58,15 @@ const listCommand = new Command('list')
57
58
  console.log(` ${grantLine(g)}`);
58
59
  });
59
60
  const createCommand = new Command('create')
60
- .description('Grant an installation complimentary access, replacing any open grant it holds — recorded against you')
61
- .argument('<installationId>', 'the GitHub App installation (see: apex tenant installations --fresh)')
61
+ .description("Grant an Account complimentary access, replacing any open grant it holds — its Organizations share the minutes; recorded against you")
62
+ .requiredOption('--account <id>', 'the Account the grant belongs to (see: apex tenant installations — connect a fresh installation to an Account first)')
62
63
  .requiredOption('--minutes <n>', 'standard minutes per calendar month, 1 to 80,000')
63
64
  .requiredOption('--concurrency <n>', 'jobs at once, 1 to 50')
64
65
  .requiredOption('--reason <text>', 'who this is for and why they run free, up to 500 characters — stored on the grant')
65
66
  .option('--until <YYYY-MM-DD>', 'the grant ends at the end of that UTC day (default: open-ended)')
66
67
  .option('--json', 'machine-readable output')
67
- .action(async (installation, opts) => {
68
- const body = createGrantBody(installation, opts, new Date());
68
+ .action(async (opts) => {
69
+ const body = createGrantBody(opts.account, opts, new Date());
69
70
  if (!body.ok) {
70
71
  usage(body.problem);
71
72
  return;
@@ -108,13 +109,13 @@ const updateCommand = new Command('update')
108
109
  console.log(JSON.stringify(grant, null, 2));
109
110
  return;
110
111
  }
111
- console.log(`${chalk.green('✔')} Changed the grant for ${grant.owner}.`);
112
+ console.log(`${chalk.green('✔')} Changed the grant for ${grant.accountName}.`);
112
113
  console.log(` ${grantLine(grant)}`);
113
114
  });
114
115
  const revokeCommand = new Command('revoke')
115
116
  .description('End a grant now: new runs are refused, queued free work is cancelled with a reason, and running work finishes')
116
117
  .argument('<grantId>', 'the grant (see: apex grant list)')
117
- .option('--yes', 'confirm — revoking cannot be undone; the organisation does not fall back to the pre-trial minutes')
118
+ .option('--yes', 'confirm — revoking cannot be undone; the Account’s Organizations do not fall back to the pre-trial minutes')
118
119
  .option('--json', 'machine-readable output')
119
120
  .action(async (id, opts) => {
120
121
  if (opts.yes !== true) {
@@ -223,7 +223,7 @@ const connectCommand = new Command('connect')
223
223
  console.log(`${chalk.green('✔')} ${connectLine(outcome.value)}`);
224
224
  });
225
225
  const installationsCommand = new Command('installations')
226
- .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")
226
+ .description("Every installation of the App as GitHub lists it, with what Apex knows of each and the Account it is in — --fresh for those the tenants list never shows")
227
227
  .option('--fresh', 'only installations that never ran, paid or were granted')
228
228
  .option('--json', 'machine-readable output')
229
229
  .action(async (opts) => {
@@ -253,7 +253,7 @@ const installationsCommand = new Command('installations')
253
253
  }
254
254
  for (const i of outcome.value)
255
255
  console.log(` ${installationLine(i)}`);
256
- console.log(chalk.dim('\nGrant one with: apex grant create <installationId> --minutes <n> --concurrency <n> --reason "<why>"'));
256
+ console.log(chalk.dim('\nA grant belongs to an Account: connect an installation with apex tenant connect, then grant with: apex grant create --account <accountId> --minutes <n> --concurrency <n> --reason "<why>"'));
257
257
  });
258
258
  export default new Command('tenant')
259
259
  .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')
@@ -10,7 +10,7 @@ const attempt = async (fn) => {
10
10
  /** Every grant ever made, newest first. The control plane decides who may read them. */
11
11
  export const listGrants = async (api) => await attempt(() => api.listGrants());
12
12
  /**
13
- * Grant an installation complimentary access, replacing any open grant it holds. Only a person may: the
13
+ * Grant an Account complimentary access, replacing any open grant it holds (O73). Only a person may: the
14
14
  * control plane records the operator the token belongs to, and refuses the machine token.
15
15
  */
16
16
  export const createGrant = async (api, body) => await attempt(() => api.createGrant(body));
@@ -25,7 +25,7 @@ export const backfillLines = (r) => {
25
25
  if (r.subscriptions.length > 0)
26
26
  lines.push('Subscriptions:', ...r.subscriptions.map((s) => ` ${s.owner} (${s.status}): ${SUB_VERB[s.action][t]}`));
27
27
  if (r.grants.length > 0) {
28
- lines.push('Open grants — unchanged here; choose the one to keep when the claim pools per Account:', ...r.grants.map((g) => ` ${g.owner}: ${g.minutesPerMonth} minutes a month, ${g.concurrency} concurrent${g.expiresAt === undefined ? '' : `, until ${g.expiresAt.slice(0, 10)}`}`));
28
+ lines.push('The Account’s open grant — unchanged here; it covers every Organization in the Account:', ...r.grants.map((g) => ` ${g.id}: ${g.minutesPerMonth} minutes a month, ${g.concurrency} concurrent${g.expiresAt === undefined ? '' : `, until ${g.expiresAt.slice(0, 10)}`}`));
29
29
  }
30
30
  if (r.notes.length > 0)
31
31
  lines.push('Notes:', ...r.notes.map((n) => ` ${n}`));
@@ -3,7 +3,8 @@
3
3
  * Complimentary access from the terminal (api ADR-0056 as amended by
4
4
  * api ADR-0079, TASK-0199): the grant and installation shapes the admin API
5
5
  * answers with, the checks `apex grant` makes before a round trip — the
6
- * control plane makes them again — and how each reads as a line.
6
+ * control plane makes them again — and how each reads as a line. A grant belongs to an Account and covers
7
+ * its Organizations, the operator's left out (decisions-log O66, O73).
7
8
  */
8
9
  /** The admin API's limits on a grant (control-plane `domain/grant.ts`): the largest plan's — Business's 80,000 since the re-pricing (O48). */
9
10
  export const MAX_GRANT_MINUTES = 80_000;
@@ -31,11 +32,13 @@ export const endOfDay = (day, now) => {
31
32
  };
32
33
  const minutesProblem = `--minutes must be a whole number from 1 to ${MAX_GRANT_MINUTES.toLocaleString('en-US')}`;
33
34
  const concurrencyProblem = `--concurrency must be a whole number from 1 to ${MAX_GRANT_CONCURRENCY}`;
34
- /** `apex grant create`'s arguments, checked. */
35
- export const createGrantBody = (installation, flags, now) => {
36
- const installationId = whole(installation, 1, Number.MAX_SAFE_INTEGER);
37
- if (installationId === undefined)
38
- return { ok: false, problem: `expected a positive installation id, got ${JSON.stringify(installation)}` };
35
+ /** An Account id is the control plane's own text; this only refuses one that cannot be. */
36
+ const MAX_ACCOUNT_ID = 200;
37
+ /** `apex grant create`'s arguments, checked: `account` is `--account`, the Account the grant belongs to (O73). */
38
+ export const createGrantBody = (account, flags, now) => {
39
+ const accountId = account?.trim() ?? '';
40
+ if (accountId === '' || accountId.length > MAX_ACCOUNT_ID || /\s/.test(accountId))
41
+ return { ok: false, problem: `--account must be an Account id (see: apex tenant installations), got ${JSON.stringify(account ?? '')}` };
39
42
  const minutesPerMonth = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
40
43
  if (minutesPerMonth === undefined)
41
44
  return { ok: false, problem: minutesProblem };
@@ -46,9 +49,9 @@ export const createGrantBody = (installation, flags, now) => {
46
49
  if (reason === '' || reason.length > MAX_GRANT_REASON)
47
50
  return { ok: false, problem: `--reason is required, up to ${MAX_GRANT_REASON} characters — who this is for, and why they run free` };
48
51
  if (flags.until === undefined)
49
- return { ok: true, value: { installationId, minutesPerMonth, concurrency, reason } };
52
+ return { ok: true, value: { accountId, minutesPerMonth, concurrency, reason } };
50
53
  const until = endOfDay(flags.until, now);
51
- return until.ok ? { ok: true, value: { installationId, minutesPerMonth, concurrency, reason, expiresAt: until.value } } : until;
54
+ return until.ok ? { ok: true, value: { accountId, minutesPerMonth, concurrency, reason, expiresAt: until.value } } : until;
52
55
  };
53
56
  /** `apex grant update`'s flags, checked: at least one change, each well-formed, and not both an end date and none. */
54
57
  export const updateGrantBody = (flags, now) => {
@@ -80,18 +83,22 @@ export const updateGrantBody = (flags, now) => {
80
83
  return { ok: true, value: body };
81
84
  };
82
85
  const day = (iso) => iso?.slice(0, 10);
86
+ /** The Organizations a grant covers, by login — or that it covers none yet. */
87
+ export const coveredLine = (g) => g.organizations.length === 0 ? 'no Organization yet' : g.organizations.map((o) => o.login).join(', ');
88
+ /** Whose a grant is: the Account, and the Organizations that share it. */
89
+ const whose = (g) => `${g.accountName} (Account ${g.accountId}; covers ${coveredLine(g)})`;
83
90
  /** One grant as a line: whose, how it stands, the month so far, and who granted it and why. */
84
91
  export const grantLine = (g) => {
85
92
  const used = g.usedMinutes === undefined ? '' : `${Math.floor(g.usedMinutes).toLocaleString('en-US')} of `;
86
93
  const state = g.state === 'ended' ? `ended ${day(g.endedAt ?? g.revokedAt) ?? ''}${g.revokedBy === undefined ? '' : ` by ${g.revokedBy}`}`.trim() : g.dormant ? `${g.state}, dormant — they pay for a plan` : g.state;
87
94
  const until = g.expiresAt === undefined ? '' : `, until ${day(g.expiresAt) ?? ''}`;
88
- return `${g.id} ${g.owner} (installation ${g.installationId}) — ${state}; ${used}${g.minutesPerMonth.toLocaleString('en-US')} minutes a month, ${g.concurrency} at once${until}; granted by ${g.grantedBy}: ${g.reason}`;
95
+ return `${g.id} ${whose(g)}${g.madeOn === undefined ? '' : `, made on ${g.madeOn}`} — ${state}; ${used}${g.minutesPerMonth.toLocaleString('en-US')} minutes a month, ${g.concurrency} at once${until}; granted by ${g.grantedBy}: ${g.reason}`;
89
96
  };
90
97
  /** What a create means, in the Console's words: open, a replacement, or dormant behind a subscription. */
91
98
  export const createdLine = (c) => c.dormant
92
- ? `Granted to ${c.grant.owner}. They pay for a plan, so the grant is dormant until they stop.`
93
- : `Granted to ${c.grant.owner}.${c.superseded === undefined ? '' : ' It replaces the grant they held.'}`;
94
- export const revokedLine = (r) => `Revoked the grant for ${r.grant.owner}.${r.cancelledJobs > 0 ? ` ${r.cancelledJobs} queued job${r.cancelledJobs === 1 ? ' was' : 's were'} cancelled.` : ''}`;
99
+ ? `Granted to ${c.grant.accountName}. They pay for a plan, so the grant is dormant until they stop.`
100
+ : `Granted to ${c.grant.accountName}.${c.superseded === undefined ? '' : ' It replaces the grant the Account held.'}`;
101
+ export const revokedLine = (r) => `Revoked the grant for ${r.grant.accountName}.${r.cancelledJobs > 0 ? ` ${r.cancelledJobs} queued job${r.cancelledJobs === 1 ? ' was' : 's were'} cancelled.` : ''}`;
95
102
  /** One installation as a line: whose, and what Apex knows of it — or that it is new. */
96
103
  export const installationLine = (i) => {
97
104
  const known = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-actions/cli",
3
- "version": "0.100.0",
3
+ "version": "0.101.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": [