@cardstack/boxel-cli 0.12.0-unstable.3 → 0.12.0-unstable.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.12.0-unstable.3",
3
+ "version": "0.12.0-unstable.4",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -8,13 +8,15 @@ import { operatorRequest } from '../../lib/seed-auth.ts';
8
8
  export { readKeyValue, type KeyValueSource } from '../../lib/key-value.ts';
9
9
 
10
10
  // `boxel realm-server keys`: the realm server's system keys, the platform's
11
- // own vendor keys that `_request-forward` attaches. Authorized with the realm
11
+ // own vendor keys that `_request-forward` attaches, and with
12
+ // `--kind oauth-client` its platform clients, the OAuth apps Boxel registered
13
+ // with a vendor that connect mode offers everyone. Authorized with the realm
12
14
  // secret seed. A value is typed at a masked prompt or read from stdin or a
13
15
  // file, never taken as an argument, and no command prints one: the server
14
- // never answers with a value. Every change carries an operator note, which
15
- // the server records in the key's history next to what it observed itself: the
16
- // caller's address and when the operator token was signed. A name in a note is
17
- // as the operator typed it; nothing verifies it.
16
+ // never answers with a value. Every change to a system key carries an
17
+ // operator note, which the server records in the key's history next to what
18
+ // it observed itself: the caller's address and when the operator token was
19
+ // signed. A name in a note is as the operator typed it; nothing verifies it.
18
20
 
19
21
  export type SystemKeyAuthMethod = 'header' | 'url-parameter';
20
22
  export type SystemKeyCreditStrategy = 'openrouter' | 'none';
@@ -58,6 +60,38 @@ export interface NewSystemKey {
58
60
  note: string;
59
61
  }
60
62
 
63
+ export type SystemKeyKind = 'system' | 'oauth-client';
64
+
65
+ export interface PlatformClient {
66
+ id: string;
67
+ name: string;
68
+ label: string;
69
+ clientId: string;
70
+ grantType: string;
71
+ authorizeURL: string | null;
72
+ tokenURL: string;
73
+ scopes: string;
74
+ tokenAuth: string;
75
+ apiHosts: string[];
76
+ state: string;
77
+ createdAt: string;
78
+ updatedAt: string;
79
+ }
80
+
81
+ export interface NewPlatformClient {
82
+ name: string;
83
+ label: string;
84
+ secret: string;
85
+ oauth: {
86
+ clientId: string;
87
+ authorizeUrl: string;
88
+ tokenUrl: string;
89
+ scopes?: string;
90
+ tokenAuth?: 'client_secret_post' | 'client_secret_basic';
91
+ apiHosts: string[];
92
+ };
93
+ }
94
+
61
95
  async function operatorFetch(
62
96
  options: SystemKeysOptions,
63
97
  path: string,
@@ -143,6 +177,94 @@ export async function deleteSystemKey(
143
177
  ).deleted;
144
178
  }
145
179
 
180
+ export async function listPlatformClients(
181
+ options: SystemKeysOptions,
182
+ ): Promise<PlatformClient[]> {
183
+ return (await operatorFetch(options, '_platform-oauth-clients', 'GET'))
184
+ .clients;
185
+ }
186
+
187
+ export async function addPlatformClient(
188
+ options: SystemKeysOptions,
189
+ client: NewPlatformClient,
190
+ ): Promise<PlatformClient> {
191
+ return (
192
+ await operatorFetch(options, '_platform-oauth-clients', 'POST', client)
193
+ ).client;
194
+ }
195
+
196
+ // Replacing the secret also clears a client its token endpoint refused.
197
+ export async function replacePlatformClientSecret(
198
+ options: SystemKeysOptions,
199
+ name: string,
200
+ secret: string,
201
+ ): Promise<PlatformClient> {
202
+ return (
203
+ await operatorFetch(
204
+ options,
205
+ `_platform-oauth-clients/${encodeURIComponent(name)}`,
206
+ 'PATCH',
207
+ { secret },
208
+ )
209
+ ).client;
210
+ }
211
+
212
+ export async function deletePlatformClient(
213
+ options: SystemKeysOptions,
214
+ name: string,
215
+ ): Promise<void> {
216
+ await operatorFetch(
217
+ options,
218
+ `_platform-oauth-clients/${encodeURIComponent(name)}`,
219
+ 'DELETE',
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Deleting a platform client leaves every connection through it needing an
225
+ * administrator, and adding it again under the same name doesn't take them
226
+ * back. So the command shows the client and asks first, unless `--yes` says
227
+ * not to; without a terminal to ask in, `--yes` is required.
228
+ */
229
+ export async function confirmPlatformClientDelete(
230
+ options: SystemKeysOptions,
231
+ name: string,
232
+ {
233
+ yes,
234
+ json,
235
+ isTTY = Boolean(process.stdin.isTTY),
236
+ confirm = async (question: string) =>
237
+ /^y(es)?$/i.test(await prompt(question)),
238
+ }: {
239
+ yes?: boolean;
240
+ json?: boolean;
241
+ isTTY?: boolean;
242
+ confirm?: (question: string) => Promise<boolean>;
243
+ },
244
+ ): Promise<void> {
245
+ if (yes) {
246
+ return;
247
+ }
248
+ if (json || !isTTY) {
249
+ throw new Error(
250
+ 'Pass --yes to delete a platform client without a terminal to confirm in',
251
+ );
252
+ }
253
+ let client = (await listPlatformClients(options)).find(
254
+ (candidate) => candidate.name === name,
255
+ );
256
+ if (!client) {
257
+ throw new Error(`No platform client is named ${name}`);
258
+ }
259
+ printClient(client);
260
+ console.log(
261
+ 'Every connection made through it will need an administrator, and adding the client again does not restore them.',
262
+ );
263
+ if (!(await confirm(`Delete the platform client ${name}? [y/N] `))) {
264
+ throw new Error('Not deleted');
265
+ }
266
+ }
267
+
146
268
  // The id of the key named by `--id`, or of the key for `--destination`,
147
269
  // compared as the server stores destinations (as the URL parser spells them).
148
270
  export async function resolveSystemKeyId(
@@ -215,6 +337,22 @@ function printKey(key: SystemKey): void {
215
337
  console.log(` updated: ${key.updatedAt}`);
216
338
  }
217
339
 
340
+ function printClient(client: PlatformClient): void {
341
+ console.log(`${client.name}: ${client.label}`);
342
+ console.log(` id: ${client.id}`);
343
+ console.log(` client id: ${client.clientId}`);
344
+ console.log(` signs in at: ${client.authorizeURL}`);
345
+ console.log(` token endpoint: ${client.tokenURL} (${client.tokenAuth})`);
346
+ console.log(` scopes: ${client.scopes || 'none'}`);
347
+ console.log(
348
+ ` offered for: ${client.apiHosts.length > 0 ? client.apiHosts.join(', ') : 'no API host'}`,
349
+ );
350
+ console.log(
351
+ ` state: ${client.state === 'broken' ? 'broken: its token endpoint refused the secret; replace it' : client.state}`,
352
+ );
353
+ console.log(` updated: ${client.updatedAt}`);
354
+ }
355
+
218
356
  function printHistory(history: SystemKeyChange[]): void {
219
357
  if (history.length === 0) {
220
358
  console.log(' history: none recorded');
@@ -229,8 +367,17 @@ function printHistory(history: SystemKeyChange[]): void {
229
367
  }
230
368
  }
231
369
 
232
- interface KeysCliOptions {
370
+ export interface KeysCliOptions {
233
371
  realmServer: string;
372
+ kind?: string;
373
+ name?: string;
374
+ label?: string;
375
+ clientId?: string;
376
+ authorizeUrl?: string;
377
+ tokenUrl?: string;
378
+ scopes?: string;
379
+ tokenAuth?: string;
380
+ apiHost?: string[];
234
381
  id?: string;
235
382
  destination?: string;
236
383
  auth?: SystemKeyAuthMethod;
@@ -240,6 +387,7 @@ interface KeysCliOptions {
240
387
  stdin?: boolean;
241
388
  keyFile?: string;
242
389
  note?: string;
390
+ yes?: boolean;
243
391
  json?: boolean;
244
392
  }
245
393
 
@@ -270,9 +418,71 @@ function output(json: boolean | undefined, value: unknown, print: () => void) {
270
418
  }
271
419
  }
272
420
 
421
+ function kindOf(options: KeysCliOptions): SystemKeyKind {
422
+ let kind = options.kind ?? 'system';
423
+ if (kind !== 'system' && kind !== 'oauth-client') {
424
+ throw new Error('--kind must be system or oauth-client');
425
+ }
426
+ return kind;
427
+ }
428
+
429
+ // A platform client is named by `--name`; a system key by `--id` or
430
+ // `--destination`. Each refuses the other's selectors.
431
+ function clientName(options: KeysCliOptions): string {
432
+ refuseNoteForClient(options);
433
+ if (options.id || options.destination) {
434
+ throw new Error(
435
+ 'Name a platform client with --name, not --id or --destination',
436
+ );
437
+ }
438
+ if (!options.name) {
439
+ throw new Error('Name the platform client with --name');
440
+ }
441
+ return options.name;
442
+ }
443
+
444
+ // The server keeps no history of platform clients, so a note would be
445
+ // recorded nowhere.
446
+ export function refuseNoteForClient(options: KeysCliOptions): void {
447
+ if (options.note !== undefined) {
448
+ throw new Error(
449
+ "--note is recorded in a system key's history; the server keeps none for a platform client",
450
+ );
451
+ }
452
+ }
453
+
454
+ export function refuseClientOptions(options: KeysCliOptions): void {
455
+ let clientOnly = (
456
+ [
457
+ ['name', '--name'],
458
+ ['label', '--label'],
459
+ ['clientId', '--client-id'],
460
+ ['authorizeUrl', '--authorize-url'],
461
+ ['tokenUrl', '--token-url'],
462
+ ['scopes', '--scopes'],
463
+ ['tokenAuth', '--token-auth'],
464
+ ['apiHost', '--api-host'],
465
+ ['yes', '--yes'],
466
+ ] as const
467
+ ).filter(([option]) => options[option] !== undefined);
468
+ if (clientOnly.length > 0) {
469
+ throw new Error(
470
+ `${clientOnly.map(([, flag]) => flag).join(', ')} ${clientOnly.length === 1 ? 'is' : 'are'} for a platform client: pass --kind oauth-client`,
471
+ );
472
+ }
473
+ }
474
+
475
+ const KIND_HELP =
476
+ 'system (the default): a system key; oauth-client: a platform client, an OAuth app Boxel registered with a vendor';
477
+
478
+ function collect(value: string, previous: string[] = []): string[] {
479
+ return [...previous, value];
480
+ }
481
+
273
482
  const SELECT_HELP = {
274
483
  id: 'The key, by its id',
275
484
  destination: 'The key, by its destination URL',
485
+ name: 'The platform client, by its name (with --kind oauth-client)',
276
486
  };
277
487
 
278
488
  function valueOptions(command: Command): Command {
@@ -289,16 +499,27 @@ export function registerKeysCommand(realmServer: Command): void {
289
499
  let keys = realmServer
290
500
  .command('keys')
291
501
  .description(
292
- "Manage the realm server's system keys: the platform's own vendor keys that the proxy attaches. No command shows a value",
502
+ "Manage the realm server's system keys (the platform's own vendor keys that the proxy attaches) and, with --kind oauth-client, its platform clients. No command shows a value",
293
503
  );
294
504
 
295
505
  keys
296
506
  .command('list')
297
- .description('List the system keys, without their values')
507
+ .description('List the system keys or platform clients, without secrets')
298
508
  .requiredOption('--realm-server <url>', 'The realm server to ask')
509
+ .option('--kind <kind>', KIND_HELP)
299
510
  .option('--json', 'Output JSON')
300
511
  .action(async (options: KeysCliOptions) =>
301
512
  run(async () => {
513
+ if (kindOf(options) === 'oauth-client') {
514
+ let clients = await listPlatformClients(await seedOptions(options));
515
+ output(options.json, clients, () => {
516
+ if (clients.length === 0) {
517
+ console.log('No platform clients.');
518
+ }
519
+ clients.forEach(printClient);
520
+ });
521
+ return;
522
+ }
302
523
  let list = await listSystemKeys(await seedOptions(options));
303
524
  output(options.json, list, () => {
304
525
  if (list.length === 0) {
@@ -312,14 +533,28 @@ export function registerKeysCommand(realmServer: Command): void {
312
533
  keys
313
534
  .command('show')
314
535
  .description(
315
- 'Show one system key and its recent history, without its value',
536
+ 'Show one system key and its recent history, or one platform client, without secrets',
316
537
  )
317
538
  .requiredOption('--realm-server <url>', 'The realm server to ask')
539
+ .option('--kind <kind>', KIND_HELP)
318
540
  .option('--id <id>', SELECT_HELP.id)
319
541
  .option('--destination <url>', SELECT_HELP.destination)
542
+ .option('--name <name>', SELECT_HELP.name)
320
543
  .option('--json', 'Output JSON')
321
544
  .action(async (options: KeysCliOptions) =>
322
545
  run(async () => {
546
+ if (kindOf(options) === 'oauth-client') {
547
+ let name = clientName(options);
548
+ let client = (
549
+ await listPlatformClients(await seedOptions(options))
550
+ ).find((c) => c.name === name);
551
+ if (!client) {
552
+ throw new Error(`No platform client is named ${name}`);
553
+ }
554
+ output(options.json, client, () => printClient(client));
555
+ return;
556
+ }
557
+ refuseClientOptions(options);
323
558
  let seed = await seedOptions(options);
324
559
  let shown = await showSystemKey(
325
560
  seed,
@@ -336,35 +571,74 @@ export function registerKeysCommand(realmServer: Command): void {
336
571
  keys
337
572
  .command('add')
338
573
  .description(
339
- 'Add a system key for a destination that has none. The value is typed at a masked prompt, or read with --stdin or --key-file',
574
+ "Add a system key for a destination that has none, or with --kind oauth-client a platform client. The value (a platform client's client secret) is typed at a masked prompt, or read with --stdin or --key-file",
340
575
  )
341
576
  .requiredOption('--realm-server <url>', 'The realm server to change')
342
- .requiredOption(
577
+ .option('--kind <kind>', KIND_HELP)
578
+ .option(
343
579
  '--destination <url>',
344
580
  'The URL the key is sent to; requests under it get the key',
345
581
  )
346
- .requiredOption(
582
+ .option(
347
583
  '--credit-strategy <strategy>',
348
584
  'How calls are charged: openrouter or none',
349
585
  )
350
586
  .option(
351
587
  '--auth <method>',
352
- 'Where the key goes: header or url-parameter',
353
- 'header',
588
+ 'Where the key goes: header (the default) or url-parameter',
354
589
  )
355
590
  .option(
356
591
  '--param-name <name>',
357
592
  'The header (default Authorization) or URL parameter (default key) that carries the key. A header always carries it as a Bearer token',
358
593
  )
359
- .option('--streaming', 'The destination supports streamed responses'),
594
+ .option('--streaming', 'The destination supports streamed responses')
595
+ .option(
596
+ '--name <name>',
597
+ 'A platform client: its name, lower-case letters, digits and dashes (such as github)',
598
+ )
599
+ .option(
600
+ '--label <label>',
601
+ 'A platform client: the name connect mode shows (such as "Boxel (read-only)")',
602
+ )
603
+ .option('--client-id <id>', "A platform client: the vendor's client id")
604
+ .option(
605
+ '--authorize-url <url>',
606
+ "A platform client: the vendor's authorization page",
607
+ )
608
+ .option(
609
+ '--token-url <url>',
610
+ "A platform client: the vendor's token endpoint",
611
+ )
612
+ .option(
613
+ '--scopes <scopes>',
614
+ 'A platform client: the scopes it asks for, space-separated (none for a GitHub App, whose permissions are its own)',
615
+ )
616
+ .option(
617
+ '--token-auth <method>',
618
+ 'A platform client: client_secret_post (the default) or client_secret_basic',
619
+ )
620
+ .option(
621
+ '--api-host <url>',
622
+ 'A platform client: an API host it serves, where connect mode offers it; repeat for more',
623
+ collect,
624
+ ),
360
625
  )
361
626
  .option('--json', 'Output JSON')
362
627
  .action(async (options: KeysCliOptions) =>
363
628
  run(async () => {
364
- if (!['openrouter', 'none'].includes(options.creditStrategy!)) {
629
+ if (kindOf(options) === 'oauth-client') {
630
+ await addClientCommand(options);
631
+ return;
632
+ }
633
+ refuseClientOptions(options);
634
+ if (!options.destination) {
635
+ throw new Error('A system key needs --destination');
636
+ }
637
+ if (!['openrouter', 'none'].includes(options.creditStrategy ?? '')) {
365
638
  throw new Error('--credit-strategy must be openrouter or none');
366
639
  }
367
- if (!['header', 'url-parameter'].includes(options.auth!)) {
640
+ let auth = options.auth ?? 'header';
641
+ if (!['header', 'url-parameter'].includes(auth)) {
368
642
  throw new Error('--auth must be header or url-parameter');
369
643
  }
370
644
  let seed = await seedOptions(options);
@@ -373,8 +647,8 @@ export function registerKeysCommand(realmServer: Command): void {
373
647
  stdinUsed: Boolean(options.stdin),
374
648
  });
375
649
  let key = await addSystemKey(seed, {
376
- destination: options.destination!,
377
- authMethod: options.auth,
650
+ destination: options.destination,
651
+ authMethod: auth as SystemKeyAuthMethod,
378
652
  paramName: options.paramName,
379
653
  creditStrategy: options.creditStrategy!,
380
654
  supportsStreaming: Boolean(options.streaming),
@@ -392,15 +666,31 @@ export function registerKeysCommand(realmServer: Command): void {
392
666
  keys
393
667
  .command('replace')
394
668
  .description(
395
- "Replace a system key's value, keeping its destination and settings",
669
+ "Replace a system key's value, keeping its destination and settings, or with --kind oauth-client a platform client's secret, which also clears a secret its token endpoint refused",
396
670
  )
397
671
  .requiredOption('--realm-server <url>', 'The realm server to change')
672
+ .option('--kind <kind>', KIND_HELP)
398
673
  .option('--id <id>', SELECT_HELP.id)
399
- .option('--destination <url>', SELECT_HELP.destination),
674
+ .option('--destination <url>', SELECT_HELP.destination)
675
+ .option('--name <name>', SELECT_HELP.name),
400
676
  )
401
677
  .option('--json', 'Output JSON')
402
678
  .action(async (options: KeysCliOptions) =>
403
679
  run(async () => {
680
+ if (kindOf(options) === 'oauth-client') {
681
+ let name = clientName(options);
682
+ let seed = await seedOptions(options);
683
+ let secret = await readKeyValue(options);
684
+ let client = await replacePlatformClientSecret(seed, name, secret);
685
+ output(options.json, client, () => {
686
+ console.log(
687
+ `${FG_GREEN}Replaced the platform client's secret.${RESET}`,
688
+ );
689
+ printClient(client);
690
+ });
691
+ return;
692
+ }
693
+ refuseClientOptions(options);
404
694
  let seed = await seedOptions(options);
405
695
  let id = await resolveSystemKeyId(seed, options);
406
696
  let value = await readKeyValue(options);
@@ -417,17 +707,38 @@ export function registerKeysCommand(realmServer: Command): void {
417
707
 
418
708
  keys
419
709
  .command('delete')
420
- .description('Delete a system key; the proxy stops serving its destination')
710
+ .description(
711
+ 'Delete a system key, so the proxy stops serving its destination, or with --kind oauth-client a platform client, whose connections then need an administrator',
712
+ )
421
713
  .requiredOption('--realm-server <url>', 'The realm server to change')
714
+ .option('--kind <kind>', KIND_HELP)
422
715
  .option('--id <id>', SELECT_HELP.id)
423
716
  .option('--destination <url>', SELECT_HELP.destination)
717
+ .option('--name <name>', SELECT_HELP.name)
424
718
  .option(
425
719
  '--note <text>',
426
720
  'The operator note recorded with the change: who made it and why (prompted for when omitted). A name in it is recorded as given, not verified',
427
721
  )
722
+ .option(
723
+ '--yes',
724
+ "With --kind oauth-client, don't show the client and ask to confirm",
725
+ )
428
726
  .option('--json', 'Output JSON')
429
727
  .action(async (options: KeysCliOptions) =>
430
728
  run(async () => {
729
+ if (kindOf(options) === 'oauth-client') {
730
+ let name = clientName(options);
731
+ let seed = await seedOptions(options);
732
+ await confirmPlatformClientDelete(seed, name, options);
733
+ await deletePlatformClient(seed, name);
734
+ output(options.json, { deleted: name }, () => {
735
+ console.log(
736
+ `${FG_GREEN}Deleted the platform client ${name}.${RESET}`,
737
+ );
738
+ });
739
+ return;
740
+ }
741
+ refuseClientOptions(options);
431
742
  let seed = await seedOptions(options);
432
743
  let id = await resolveSystemKeyId(seed, options);
433
744
  let note = await readNote(options.note, { stdinUsed: false });
@@ -440,3 +751,74 @@ export function registerKeysCommand(realmServer: Command): void {
440
751
  }),
441
752
  );
442
753
  }
754
+
755
+ // A platform client takes none of a system key's settings.
756
+ export function refuseSystemOptions(options: KeysCliOptions): void {
757
+ let systemOnly = (
758
+ [
759
+ ['destination', '--destination'],
760
+ ['creditStrategy', '--credit-strategy'],
761
+ ['auth', '--auth'],
762
+ ['paramName', '--param-name'],
763
+ ['streaming', '--streaming'],
764
+ ] as const
765
+ ).filter(([option]) => options[option] !== undefined);
766
+ if (systemOnly.length > 0) {
767
+ throw new Error(
768
+ `${systemOnly.map(([, flag]) => flag).join(', ')} ${systemOnly.length === 1 ? 'is' : 'are'} for a system key, not a platform client`,
769
+ );
770
+ }
771
+ }
772
+
773
+ async function addClientCommand(options: KeysCliOptions): Promise<void> {
774
+ refuseNoteForClient(options);
775
+ refuseSystemOptions(options);
776
+ let missing = (
777
+ [
778
+ ['name', '--name'],
779
+ ['label', '--label'],
780
+ ['clientId', '--client-id'],
781
+ ['authorizeUrl', '--authorize-url'],
782
+ ['tokenUrl', '--token-url'],
783
+ ['apiHost', '--api-host'],
784
+ ] as const
785
+ ).filter(([option]) => !options[option]);
786
+ if (missing.length > 0) {
787
+ throw new Error(
788
+ `A platform client needs ${missing.map(([, flag]) => flag).join(', ')}`,
789
+ );
790
+ }
791
+ if (
792
+ options.tokenAuth !== undefined &&
793
+ !['client_secret_post', 'client_secret_basic'].includes(options.tokenAuth)
794
+ ) {
795
+ throw new Error(
796
+ '--token-auth must be client_secret_post or client_secret_basic',
797
+ );
798
+ }
799
+ let seed = await seedOptions(options);
800
+ let secret = await readKeyValue(options);
801
+ let client = await addPlatformClient(seed, {
802
+ name: options.name!,
803
+ label: options.label!,
804
+ secret,
805
+ oauth: {
806
+ clientId: options.clientId!,
807
+ authorizeUrl: options.authorizeUrl!,
808
+ tokenUrl: options.tokenUrl!,
809
+ ...(options.scopes !== undefined ? { scopes: options.scopes } : {}),
810
+ ...(options.tokenAuth !== undefined
811
+ ? {
812
+ tokenAuth: options.tokenAuth as
813
+ | 'client_secret_post'
814
+ | 'client_secret_basic',
815
+ }
816
+ : {}),
817
+ apiHosts: options.apiHost!,
818
+ },
819
+ });
820
+ output(options.json, client, () => {
821
+ console.log(`${FG_GREEN}Added the platform client.${RESET}`);
822
+ printClient(client);
823
+ });
824
+ }