@cardstack/boxel-cli 0.9.0-unstable.5 → 0.9.0-unstable.6

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.
Files changed (86) hide show
  1. package/bundled-test-harness/assets/{ai-module-creation-test-BNiZ9Als.js → ai-module-creation-test-CYdqfeyE.js} +1 -1
  2. package/bundled-test-harness/assets/{app-CP2kZsut.js → app-DEPzUD4Y.js} +1 -1
  3. package/bundled-test-harness/assets/{binary-upload-dedupe-test-c4W0iix2.js → binary-upload-dedupe-test-BIf-AWak.js} +1 -1
  4. package/bundled-test-harness/assets/{can-invoke-test-DyozfCFg.js → can-invoke-test-BjgjfYqo.js} +1 -1
  5. package/bundled-test-harness/assets/{card-chooser-paging-test-BKdBwPQ3.js → card-chooser-paging-test-C2v55yZ3.js} +1 -1
  6. package/bundled-test-harness/assets/{card-chooser-test-DAPWVfJL.js → card-chooser-test-OqnnAsAY.js} +1 -1
  7. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-Dz1qg9ZW.js → card-def-field-def-relationships-test-TNzXYsVI.js} +1 -1
  8. package/bundled-test-harness/assets/{card-delete-test-BmI-DLtn.js → card-delete-test-BVnE3BU6.js} +1 -1
  9. package/bundled-test-harness/assets/{choreo-feature-reel-test-S0XwN4xA.js → choreo-feature-reel-test-DrvZlnG0.js} +1 -1
  10. package/bundled-test-harness/assets/{choreo-gallery-B0nnktwP.js → choreo-gallery-D8__QZCe.js} +11 -11
  11. package/bundled-test-harness/assets/{choreo-gallery-build-order-test-C6q9CDNY.js → choreo-gallery-build-order-test-7TpxKQd2.js} +1 -1
  12. package/bundled-test-harness/assets/{choreo-gallery-compositor-test-C-GwjEUw.js → choreo-gallery-compositor-test-BgMWB-xz.js} +1 -1
  13. package/bundled-test-harness/assets/choreo-gallery-cover-test-uR-CNV8g.js +205 -0
  14. package/bundled-test-harness/assets/{choreo-gallery-crossing-stage-test-B7jRQTAh.js → choreo-gallery-crossing-stage-test-CmGH0E5q.js} +1 -1
  15. package/bundled-test-harness/assets/{choreo-gallery-demos-alive-test-BLqE-zk1.js → choreo-gallery-demos-alive-test-DZ1sPqe8.js} +8 -2
  16. package/bundled-test-harness/assets/{choreo-gallery-drift-test-DOXLQNB9.js → choreo-gallery-drift-test-q7KosXIw.js} +1 -1
  17. package/bundled-test-harness/assets/{choreo-gallery-escort-test-lfBfmp-4.js → choreo-gallery-escort-test-CH_qyw6w.js} +1 -1
  18. package/bundled-test-harness/assets/{choreo-gallery-films-test-M761cc8Q.js → choreo-gallery-films-test-CYmek5qU.js} +1 -1
  19. package/bundled-test-harness/assets/{choreo-gallery-filter-test-BrUWMDaJ.js → choreo-gallery-filter-test-Curg5Fg7.js} +1 -1
  20. package/bundled-test-harness/assets/{choreo-gallery-fold-test-CTtdGSAI.js → choreo-gallery-fold-test-BDTkaH6H.js} +1 -1
  21. package/bundled-test-harness/assets/{choreo-gallery-guides-test-PHmvqxfk.js → choreo-gallery-guides-test-B1vY7Fkx.js} +2 -2
  22. package/bundled-test-harness/assets/{choreo-gallery-hang-test-DP5lQG0y.js → choreo-gallery-hang-test-BJJghTnK.js} +1 -1
  23. package/bundled-test-harness/assets/{choreo-gallery-highlight-test-CLhvRo6X.js → choreo-gallery-highlight-test-DGT0jmDf.js} +2 -2
  24. package/bundled-test-harness/assets/{choreo-gallery-inline-edit-test-jOddqOxH.js → choreo-gallery-inline-edit-test-DwF8ZeCB.js} +1 -1
  25. package/bundled-test-harness/assets/{choreo-gallery-interruption-test-hdQSH8Mi.js → choreo-gallery-interruption-test-DvfkLdWd.js} +1 -1
  26. package/bundled-test-harness/assets/{choreo-gallery-lone-stack-test-AHGiG8yJ.js → choreo-gallery-lone-stack-test-CDwE2xKJ.js} +1 -1
  27. package/bundled-test-harness/assets/{choreo-gallery-navigation-test-BlGmMyFM.js → choreo-gallery-navigation-test-DG6IdX6k.js} +1 -1
  28. package/bundled-test-harness/assets/{choreo-gallery-playhead-transport-test-8aNOk09i.js → choreo-gallery-playhead-transport-test-CITO-DAn.js} +1 -1
  29. package/bundled-test-harness/assets/{choreo-gallery-poplayout-subtree-test-BCJLJdyC.js → choreo-gallery-poplayout-subtree-test-Cs4S9Ky9.js} +1 -1
  30. package/bundled-test-harness/assets/{choreo-gallery-presence-modes-test-CbhdndwS.js → choreo-gallery-presence-modes-test-p8mppXAD.js} +1 -1
  31. package/bundled-test-harness/assets/{choreo-gallery-presentation-test-DDUagg6a.js → choreo-gallery-presentation-test-CT0lZepE.js} +36 -1
  32. package/bundled-test-harness/assets/{choreo-gallery-sheet-standalone-test-BNyhCtW2.js → choreo-gallery-sheet-standalone-test-D-MLCQ6u.js} +1 -1
  33. package/bundled-test-harness/assets/{choreo-gallery-sheet-test-NEe_oYnA.js → choreo-gallery-sheet-test-unHvAt2e.js} +1 -1
  34. package/bundled-test-harness/assets/{choreo-gallery-site-test-hT5mISBG.js → choreo-gallery-site-test-BRHuPInI.js} +1 -1
  35. package/bundled-test-harness/assets/{choreo-gallery-stage-Bmz9m8sP.js → choreo-gallery-stage-DVGS6XI-.js} +1 -1
  36. package/bundled-test-harness/assets/choreo-gallery-stage-keyboard-test-BYpAwZPD.js +102 -0
  37. package/bundled-test-harness/assets/{choreo-gallery-stages-test-DZjVu0Zq.js → choreo-gallery-stages-test-DPFv-GKb.js} +2 -2
  38. package/bundled-test-harness/assets/{choreo-gallery-subdivision-test-DBjnIZ8l.js → choreo-gallery-subdivision-test-C4rwq-DA.js} +1 -1
  39. package/bundled-test-harness/assets/{choreo-gallery-table-plan-test-DTSu2DjF.js → choreo-gallery-table-plan-test-CAQdsCBB.js} +1 -1
  40. package/bundled-test-harness/assets/{choreo-gallery-theater-test-BsLF8HcI.js → choreo-gallery-theater-test-BxvAn-i8.js} +2 -2
  41. package/bundled-test-harness/assets/{choreo-gallery-tuning-test-B1JW701Z.js → choreo-gallery-tuning-test-CvAiiRMn.js} +1 -1
  42. package/bundled-test-harness/assets/{choreo-gallery-widget-room-test-DcEIPub1.js → choreo-gallery-widget-room-test-DLkgh8Ws.js} +2 -2
  43. package/bundled-test-harness/assets/{choreo-gallery-wires-test-6VOc5SF8.js → choreo-gallery-wires-test-DMdHX2zj.js} +1 -1
  44. package/bundled-test-harness/assets/{codeblocks-test-DGtwtURg.js → codeblocks-test-DDMirwuJ.js} +1 -1
  45. package/bundled-test-harness/assets/{compaction-test-CTNozvI6.js → compaction-test-BKQ_668T.js} +1 -1
  46. package/bundled-test-harness/assets/{container-Az9Nq0LL.js → container-BrZklF85.js} +5 -2
  47. package/bundled-test-harness/assets/{create-listing-modal-test-cdE1T_YD.js → create-listing-modal-test-Bsgixn9Z.js} +1 -1
  48. package/bundled-test-harness/assets/{debug-message-test-BfS52C_5.js → debug-message-test-8e9w8xNX.js} +1 -1
  49. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-C6Rl_3oe.js → fallback-banner-live-delete-test-CNsd-0FB.js} +1 -1
  50. package/bundled-test-harness/assets/{fallback-banner-test-CsSGrseR.js → fallback-banner-test-DlT-8Lpv.js} +1 -1
  51. package/bundled-test-harness/assets/{fallback-models-test-D7FBFpyT.js → fallback-models-test-CwG7Ydui.js} +1 -1
  52. package/bundled-test-harness/assets/{file-attachment-test-DtPZTxy4.js → file-attachment-test-Cvs-ulUS.js} +1 -1
  53. package/bundled-test-harness/assets/{general-test-CMH4p9LD.js → general-test-BDjfjhSB.js} +1 -1
  54. package/bundled-test-harness/assets/{main-DQBeCoaR.js → main-BeQF5cZd.js} +1 -1
  55. package/bundled-test-harness/assets/{model-cost-tier-test-DR2G3zVF.js → model-cost-tier-test-FFf8YNd5.js} +1 -1
  56. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-Dtd5macc.js → operator-mode-card-chooser-test-DKo-UoRQ.js} +1 -1
  57. package/bundled-test-harness/assets/{operator-mode-links-test-BW7fJlL7.js → operator-mode-links-test-Dl5g5COc.js} +1 -1
  58. package/bundled-test-harness/assets/{operator-mode-test-OwSg0jux.js → operator-mode-test-Bo8diNLE.js} +1 -1
  59. package/bundled-test-harness/assets/{operator-mode-ui-test-gV3LQASK.js → operator-mode-ui-test-CSc-CYTU.js} +1 -1
  60. package/bundled-test-harness/assets/{overlay-menu-items-test-DhsN6QTO.js → overlay-menu-items-test-Bs1zoaym.js} +1 -1
  61. package/bundled-test-harness/assets/{past-sessions-test-DD6A7KuB.js → past-sessions-test-BGg7ZyON.js} +1 -1
  62. package/bundled-test-harness/assets/{permission-checking-realm-test-bQz4K37U.js → permission-checking-realm-test-Be6jGmyM.js} +1 -1
  63. package/bundled-test-harness/assets/{realm-config-policy-standing-test-Cs9XpWCX.js → realm-config-policy-standing-test-nM6CGX-Q.js} +1 -1
  64. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-_D_xuw3p.js → realm-config-routing-rule-edit-test-TIuE2nug.js} +1 -1
  65. package/bundled-test-harness/assets/{realm-config-settings-test-DNqVtlJT.js → realm-config-settings-test-CWqwPMkw.js} +1 -1
  66. package/bundled-test-harness/assets/{reasoning-test-B74P8G_y.js → reasoning-test-CfRBo1ut.js} +1 -1
  67. package/bundled-test-harness/assets/{scrolling-test-EMUFGWKu.js → scrolling-test-DTJ7HVdE.js} +1 -1
  68. package/bundled-test-harness/assets/{sending-test-BIADsBdp.js → sending-test-D6iQh346.js} +1 -1
  69. package/bundled-test-harness/assets/{skills-test-AcW3L6RD.js → skills-test-Ca1EpjMI.js} +1 -1
  70. package/bundled-test-harness/assets/{store-test-CgPJ9v5Z.js → store-test-BS1Ue4Rt.js} +1 -1
  71. package/bundled-test-harness/assets/{tests-QZJXdl8x.js → tests-CPowoOp0.js} +471 -469
  72. package/bundled-test-harness/assets/{text-input-validator-test-DybvZTP3.js → text-input-validator-test-XpZxMbh0.js} +1 -1
  73. package/bundled-test-harness/assets/{to-device-streaming-test-BbgQFsQk.js → to-device-streaming-test-CKu1Ud8R.js} +1 -1
  74. package/bundled-test-harness/assets/{token-usage-test-BkQp1TTc.js → token-usage-test-Cszf7EU8.js} +1 -1
  75. package/bundled-test-harness/assets/{tools-test-DEfT44PZ.js → tools-test-DdlkGAFH.js} +1 -1
  76. package/bundled-test-harness/index.html +4 -4
  77. package/bundled-test-harness/tests/index.html +4 -4
  78. package/bundled-types/host-app/components/host-mode/content.gts +9 -0
  79. package/dist/api.js +166 -165
  80. package/dist/index.js +271 -270
  81. package/package.json +1 -1
  82. package/src/commands/realm-server/data-keys.ts +7 -14
  83. package/src/commands/realm-server/index.ts +2 -0
  84. package/src/commands/realm-server/keys.ts +496 -0
  85. package/src/lib/prompt.ts +45 -12
  86. package/src/lib/seed-auth.ts +63 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.9.0-unstable.5",
3
+ "version": "0.9.0-unstable.6",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -1,9 +1,8 @@
1
1
  import type { Command } from 'commander';
2
- import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3
2
  import { cliLog } from '../../lib/cli-log.ts';
4
3
  import { FG_GREEN, FG_YELLOW, RESET } from '../../lib/colors.ts';
5
4
  import { resolveRealmSecretSeed } from '../../lib/prompt.ts';
6
- import { mintOperatorToken } from '../../lib/seed-auth.ts';
5
+ import { operatorRequest } from '../../lib/seed-auth.ts';
7
6
 
8
7
  // `boxel realm-server data-keys`: where a rotation of the data key that seals
9
8
  // the realm server's stored secrets stands, and the pass that re-seals stored
@@ -45,22 +44,16 @@ export interface DataKeysOptions {
45
44
  }
46
45
 
47
46
  async function operatorFetch(
48
- {
49
- realmServerUrl,
50
- realmSecretSeed,
51
- fetch = globalThis.fetch,
52
- }: DataKeysOptions,
47
+ options: DataKeysOptions,
53
48
  path: string,
54
49
  method: 'GET' | 'POST',
55
50
  ): Promise<any> {
56
- let url = new URL(path, ensureTrailingSlash(realmServerUrl)).href;
57
- let response = await fetch(url, {
51
+ let { url, response } = await operatorRequest(
52
+ options,
53
+ 'data-keys',
54
+ path,
58
55
  method,
59
- headers: {
60
- Accept: 'application/json',
61
- Authorization: `Bearer ${mintOperatorToken(realmSecretSeed, 'data-keys')}`,
62
- },
63
- });
56
+ );
64
57
  if (!response.ok) {
65
58
  let body = await response.text().catch(() => '');
66
59
  throw new Error(
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import { registerDataKeysCommand } from './data-keys.ts';
3
+ import { registerKeysCommand } from './keys.ts';
3
4
 
4
5
  // Operator commands for a realm server, authorized with its realm secret
5
6
  // seed. The namespace stays out of the general help.
@@ -11,4 +12,5 @@ export function registerRealmServerCommand(program: Command): void {
11
12
  );
12
13
 
13
14
  registerDataKeysCommand(realmServer);
15
+ registerKeysCommand(realmServer);
14
16
  }
@@ -0,0 +1,496 @@
1
+ import type { Command } from 'commander';
2
+ import { readFile } from 'fs/promises';
3
+ import { cliLog } from '../../lib/cli-log.ts';
4
+ import { FG_GREEN, RESET } from '../../lib/colors.ts';
5
+ import {
6
+ prompt,
7
+ promptPassword,
8
+ resolveRealmSecretSeed,
9
+ } from '../../lib/prompt.ts';
10
+ import { operatorRequest } from '../../lib/seed-auth.ts';
11
+
12
+ // `boxel realm-server keys`: the realm server's system keys, the platform's
13
+ // own vendor keys that `_request-forward` attaches. Authorized with the realm
14
+ // secret seed. A value is typed at a masked prompt or read from stdin or a
15
+ // file, never taken as an argument, and no command prints one: the server
16
+ // never answers with a value. Every change carries an operator note, which
17
+ // the server records in the key's history next to what it observed itself: the
18
+ // caller's address and when the operator token was signed. A name in a note is
19
+ // as the operator typed it; nothing verifies it.
20
+
21
+ export type SystemKeyAuthMethod = 'header' | 'url-parameter';
22
+ export type SystemKeyCreditStrategy = 'openrouter' | 'none';
23
+
24
+ export interface SystemKey {
25
+ id: string;
26
+ destination: string;
27
+ authMethod: string;
28
+ paramName: string | null;
29
+ creditStrategy: string;
30
+ supportsStreaming: boolean;
31
+ hasValue: boolean;
32
+ keyId: string | null;
33
+ createdAt: string;
34
+ updatedAt: string;
35
+ }
36
+
37
+ export interface SystemKeyChange {
38
+ action: 'add' | 'replace' | 'delete';
39
+ destination: string;
40
+ note: string;
41
+ keyId: string | null;
42
+ changedAt: string;
43
+ clientAddress: string | null;
44
+ tokenIssuedAt: string | null;
45
+ }
46
+
47
+ export interface SystemKeysOptions {
48
+ realmServerUrl: string;
49
+ realmSecretSeed: string;
50
+ fetch?: typeof globalThis.fetch;
51
+ }
52
+
53
+ export interface NewSystemKey {
54
+ destination: string;
55
+ authMethod?: SystemKeyAuthMethod;
56
+ paramName?: string;
57
+ creditStrategy: SystemKeyCreditStrategy;
58
+ supportsStreaming?: boolean;
59
+ value: string;
60
+ note: string;
61
+ }
62
+
63
+ async function operatorFetch(
64
+ options: SystemKeysOptions,
65
+ path: string,
66
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
67
+ body?: unknown,
68
+ ): Promise<any> {
69
+ let { url, response } = await operatorRequest(
70
+ options,
71
+ 'system-keys',
72
+ path,
73
+ method,
74
+ body,
75
+ );
76
+ if (!response.ok) {
77
+ let text = await response.text().catch(() => '');
78
+ let message = text.slice(0, 300);
79
+ try {
80
+ let errors = JSON.parse(text)?.errors;
81
+ if (Array.isArray(errors) && errors.length > 0) {
82
+ message = errors.join('; ');
83
+ }
84
+ } catch {
85
+ // Not JSON; report the text as it is.
86
+ }
87
+ throw new Error(
88
+ `${method} ${url} answered HTTP ${response.status}: ${message}`,
89
+ );
90
+ }
91
+ return response.json();
92
+ }
93
+
94
+ export async function listSystemKeys(
95
+ options: SystemKeysOptions,
96
+ ): Promise<SystemKey[]> {
97
+ return (await operatorFetch(options, '_system-keys', 'GET')).keys;
98
+ }
99
+
100
+ export async function showSystemKey(
101
+ options: SystemKeysOptions,
102
+ id: string,
103
+ ): Promise<{ key: SystemKey; history: SystemKeyChange[] }> {
104
+ return operatorFetch(
105
+ options,
106
+ `_system-keys/${encodeURIComponent(id)}`,
107
+ 'GET',
108
+ );
109
+ }
110
+
111
+ export async function addSystemKey(
112
+ options: SystemKeysOptions,
113
+ key: NewSystemKey,
114
+ ): Promise<SystemKey> {
115
+ return (await operatorFetch(options, '_system-keys', 'POST', key)).key;
116
+ }
117
+
118
+ export async function replaceSystemKey(
119
+ options: SystemKeysOptions,
120
+ id: string,
121
+ change: { value: string; note: string },
122
+ ): Promise<SystemKey> {
123
+ return (
124
+ await operatorFetch(
125
+ options,
126
+ `_system-keys/${encodeURIComponent(id)}`,
127
+ 'PATCH',
128
+ change,
129
+ )
130
+ ).key;
131
+ }
132
+
133
+ export async function deleteSystemKey(
134
+ options: SystemKeysOptions,
135
+ id: string,
136
+ change: { note: string },
137
+ ): Promise<SystemKey> {
138
+ return (
139
+ await operatorFetch(
140
+ options,
141
+ `_system-keys/${encodeURIComponent(id)}`,
142
+ 'DELETE',
143
+ change,
144
+ )
145
+ ).deleted;
146
+ }
147
+
148
+ // The id of the key named by `--id`, or of the key for `--destination`,
149
+ // compared as the server stores destinations (as the URL parser spells them).
150
+ export async function resolveSystemKeyId(
151
+ options: SystemKeysOptions,
152
+ selector: { id?: string; destination?: string },
153
+ ): Promise<string> {
154
+ if (selector.id && selector.destination) {
155
+ throw new Error('Name the key with --id or --destination, not both');
156
+ }
157
+ if (selector.id) {
158
+ return selector.id;
159
+ }
160
+ if (!selector.destination) {
161
+ throw new Error('Name the key with --id or --destination');
162
+ }
163
+ let destination: string;
164
+ try {
165
+ destination = new URL(selector.destination).href;
166
+ } catch {
167
+ throw new Error(`${selector.destination} is not a URL`);
168
+ }
169
+ let match = (await listSystemKeys(options)).find(
170
+ (key) => key.destination === destination,
171
+ );
172
+ if (!match) {
173
+ throw new Error(`No system key has the destination ${destination}`);
174
+ }
175
+ return match.id;
176
+ }
177
+
178
+ function stripOneTrailingNewline(text: string): string {
179
+ return text.replace(/\r?\n$/, '');
180
+ }
181
+
182
+ export interface KeyValueSource {
183
+ stdin?: boolean;
184
+ keyFile?: string;
185
+ }
186
+
187
+ /**
188
+ * The key value, from exactly one source: `--key-file`, `--stdin`, or, when
189
+ * stdin is a terminal, a masked prompt. One trailing newline is dropped from
190
+ * a file or stdin. Without a terminal and without either flag it refuses,
191
+ * rather than hang waiting for keystrokes.
192
+ */
193
+ export async function readKeyValue(
194
+ source: KeyValueSource,
195
+ io: {
196
+ input?: NodeJS.ReadStream;
197
+ output?: NodeJS.WritableStream;
198
+ } = {},
199
+ ): Promise<string> {
200
+ let input = io.input ?? process.stdin;
201
+ if (source.stdin && source.keyFile) {
202
+ throw new Error('Pass --stdin or --key-file, not both');
203
+ }
204
+ let value: string;
205
+ if (source.keyFile) {
206
+ value = stripOneTrailingNewline(await readFile(source.keyFile, 'utf8'));
207
+ } else if (source.stdin) {
208
+ let chunks: Buffer[] = [];
209
+ for await (let chunk of input) {
210
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
211
+ }
212
+ value = stripOneTrailingNewline(Buffer.concat(chunks).toString('utf8'));
213
+ } else if (input.isTTY) {
214
+ value = await promptPassword('Key value: ', {
215
+ revealLast: 4,
216
+ input,
217
+ output: io.output,
218
+ });
219
+ } else {
220
+ throw new Error(
221
+ 'Cannot prompt for the key value: stdin is not a terminal. Pass --stdin or --key-file',
222
+ );
223
+ }
224
+ if (!value) {
225
+ throw new Error('The key value is empty');
226
+ }
227
+ return value;
228
+ }
229
+
230
+ /**
231
+ * The operator note for a change: `--note`, or a prompt when stdin is a
232
+ * terminal that the value didn't come through.
233
+ */
234
+ export async function readNote(
235
+ note: string | undefined,
236
+ { stdinUsed }: { stdinUsed: boolean },
237
+ ): Promise<string> {
238
+ if (note !== undefined) {
239
+ if (!note.trim()) {
240
+ throw new Error('The operator note is empty');
241
+ }
242
+ return note;
243
+ }
244
+ if (stdinUsed || !process.stdin.isTTY) {
245
+ throw new Error(
246
+ 'Pass --note: the operator note, recorded in the key history, says who made the change and why. A name in it is recorded as given, not verified',
247
+ );
248
+ }
249
+ let answer = await prompt(
250
+ 'Operator note (who and why; a name here is recorded as given, not verified): ',
251
+ );
252
+ if (!answer) {
253
+ throw new Error('The operator note is empty');
254
+ }
255
+ return answer;
256
+ }
257
+
258
+ function printKey(key: SystemKey): void {
259
+ console.log(`${key.destination}`);
260
+ console.log(` id: ${key.id}`);
261
+ console.log(
262
+ ` auth: ${key.authMethod}${key.paramName ? ` (${key.paramName})` : ''}`,
263
+ );
264
+ console.log(` credit strategy: ${key.creditStrategy}`);
265
+ console.log(` streaming: ${key.supportsStreaming ? 'yes' : 'no'}`);
266
+ console.log(
267
+ ` value: ${key.hasValue ? (key.keyId ? `sealed under data key ${key.keyId}` : 'in plain text only, until the backfill seals it') : 'none'}`,
268
+ );
269
+ console.log(` updated: ${key.updatedAt}`);
270
+ }
271
+
272
+ function printHistory(history: SystemKeyChange[]): void {
273
+ if (history.length === 0) {
274
+ console.log(' history: none recorded');
275
+ return;
276
+ }
277
+ console.log(' history:');
278
+ for (let change of history) {
279
+ console.log(` ${change.changedAt} ${change.action}: ${change.note}`);
280
+ console.log(
281
+ ` from ${change.clientAddress ?? 'an unknown address'}, token signed ${change.tokenIssuedAt ?? 'at an unknown time'}`,
282
+ );
283
+ }
284
+ }
285
+
286
+ interface KeysCliOptions {
287
+ realmServer: string;
288
+ id?: string;
289
+ destination?: string;
290
+ auth?: SystemKeyAuthMethod;
291
+ paramName?: string;
292
+ creditStrategy?: SystemKeyCreditStrategy;
293
+ streaming?: boolean;
294
+ stdin?: boolean;
295
+ keyFile?: string;
296
+ note?: string;
297
+ json?: boolean;
298
+ }
299
+
300
+ async function seedOptions(
301
+ options: KeysCliOptions,
302
+ ): Promise<SystemKeysOptions> {
303
+ let realmSecretSeed = await resolveRealmSecretSeed(true);
304
+ if (!realmSecretSeed) {
305
+ throw new Error('A realm secret seed is required');
306
+ }
307
+ return { realmServerUrl: options.realmServer, realmSecretSeed };
308
+ }
309
+
310
+ async function run(fn: () => Promise<void>): Promise<void> {
311
+ try {
312
+ await fn();
313
+ } catch (e) {
314
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
315
+ process.exit(1);
316
+ }
317
+ }
318
+
319
+ function output(json: boolean | undefined, value: unknown, print: () => void) {
320
+ if (json) {
321
+ cliLog.output(JSON.stringify(value, null, 2));
322
+ } else {
323
+ print();
324
+ }
325
+ }
326
+
327
+ const SELECT_HELP = {
328
+ id: 'The key, by its id',
329
+ destination: 'The key, by its destination URL',
330
+ };
331
+
332
+ function valueOptions(command: Command): Command {
333
+ return command
334
+ .option('--stdin', 'Read the key value from stdin')
335
+ .option('--key-file <path>', 'Read the key value from a file')
336
+ .option(
337
+ '--note <text>',
338
+ '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',
339
+ );
340
+ }
341
+
342
+ export function registerKeysCommand(realmServer: Command): void {
343
+ let keys = realmServer
344
+ .command('keys')
345
+ .description(
346
+ "Manage the realm server's system keys: the platform's own vendor keys that the proxy attaches. No command shows a value",
347
+ );
348
+
349
+ keys
350
+ .command('list')
351
+ .description('List the system keys, without their values')
352
+ .requiredOption('--realm-server <url>', 'The realm server to ask')
353
+ .option('--json', 'Output JSON')
354
+ .action(async (options: KeysCliOptions) =>
355
+ run(async () => {
356
+ let list = await listSystemKeys(await seedOptions(options));
357
+ output(options.json, list, () => {
358
+ if (list.length === 0) {
359
+ console.log('No system keys.');
360
+ }
361
+ list.forEach(printKey);
362
+ });
363
+ }),
364
+ );
365
+
366
+ keys
367
+ .command('show')
368
+ .description(
369
+ 'Show one system key and its recent history, without its value',
370
+ )
371
+ .requiredOption('--realm-server <url>', 'The realm server to ask')
372
+ .option('--id <id>', SELECT_HELP.id)
373
+ .option('--destination <url>', SELECT_HELP.destination)
374
+ .option('--json', 'Output JSON')
375
+ .action(async (options: KeysCliOptions) =>
376
+ run(async () => {
377
+ let seed = await seedOptions(options);
378
+ let shown = await showSystemKey(
379
+ seed,
380
+ await resolveSystemKeyId(seed, options),
381
+ );
382
+ output(options.json, shown, () => {
383
+ printKey(shown.key);
384
+ printHistory(shown.history);
385
+ });
386
+ }),
387
+ );
388
+
389
+ valueOptions(
390
+ keys
391
+ .command('add')
392
+ .description(
393
+ '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',
394
+ )
395
+ .requiredOption('--realm-server <url>', 'The realm server to change')
396
+ .requiredOption(
397
+ '--destination <url>',
398
+ 'The URL the key is sent to; requests under it get the key',
399
+ )
400
+ .requiredOption(
401
+ '--credit-strategy <strategy>',
402
+ 'How calls are charged: openrouter or none',
403
+ )
404
+ .option(
405
+ '--auth <method>',
406
+ 'Where the key goes: header or url-parameter',
407
+ 'header',
408
+ )
409
+ .option(
410
+ '--param-name <name>',
411
+ 'The header (default Authorization) or URL parameter (default key) that carries the key. A header always carries it as a Bearer token',
412
+ )
413
+ .option('--streaming', 'The destination supports streamed responses'),
414
+ )
415
+ .option('--json', 'Output JSON')
416
+ .action(async (options: KeysCliOptions) =>
417
+ run(async () => {
418
+ if (!['openrouter', 'none'].includes(options.creditStrategy!)) {
419
+ throw new Error('--credit-strategy must be openrouter or none');
420
+ }
421
+ if (!['header', 'url-parameter'].includes(options.auth!)) {
422
+ throw new Error('--auth must be header or url-parameter');
423
+ }
424
+ let seed = await seedOptions(options);
425
+ let value = await readKeyValue(options);
426
+ let note = await readNote(options.note, {
427
+ stdinUsed: Boolean(options.stdin),
428
+ });
429
+ let key = await addSystemKey(seed, {
430
+ destination: options.destination!,
431
+ authMethod: options.auth,
432
+ paramName: options.paramName,
433
+ creditStrategy: options.creditStrategy!,
434
+ supportsStreaming: Boolean(options.streaming),
435
+ value,
436
+ note,
437
+ });
438
+ output(options.json, key, () => {
439
+ console.log(`${FG_GREEN}Added the system key.${RESET}`);
440
+ printKey(key);
441
+ });
442
+ }),
443
+ );
444
+
445
+ valueOptions(
446
+ keys
447
+ .command('replace')
448
+ .description(
449
+ "Replace a system key's value, keeping its destination and settings",
450
+ )
451
+ .requiredOption('--realm-server <url>', 'The realm server to change')
452
+ .option('--id <id>', SELECT_HELP.id)
453
+ .option('--destination <url>', SELECT_HELP.destination),
454
+ )
455
+ .option('--json', 'Output JSON')
456
+ .action(async (options: KeysCliOptions) =>
457
+ run(async () => {
458
+ let seed = await seedOptions(options);
459
+ let id = await resolveSystemKeyId(seed, options);
460
+ let value = await readKeyValue(options);
461
+ let note = await readNote(options.note, {
462
+ stdinUsed: Boolean(options.stdin),
463
+ });
464
+ let key = await replaceSystemKey(seed, id, { value, note });
465
+ output(options.json, key, () => {
466
+ console.log(`${FG_GREEN}Replaced the system key.${RESET}`);
467
+ printKey(key);
468
+ });
469
+ }),
470
+ );
471
+
472
+ keys
473
+ .command('delete')
474
+ .description('Delete a system key; the proxy stops serving its destination')
475
+ .requiredOption('--realm-server <url>', 'The realm server to change')
476
+ .option('--id <id>', SELECT_HELP.id)
477
+ .option('--destination <url>', SELECT_HELP.destination)
478
+ .option(
479
+ '--note <text>',
480
+ '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',
481
+ )
482
+ .option('--json', 'Output JSON')
483
+ .action(async (options: KeysCliOptions) =>
484
+ run(async () => {
485
+ let seed = await seedOptions(options);
486
+ let id = await resolveSystemKeyId(seed, options);
487
+ let note = await readNote(options.note, { stdinUsed: false });
488
+ let key = await deleteSystemKey(seed, id, { note });
489
+ output(options.json, key, () => {
490
+ console.log(
491
+ `${FG_GREEN}Deleted the system key for ${key.destination}.${RESET}`,
492
+ );
493
+ });
494
+ }),
495
+ );
496
+ }
package/src/lib/prompt.ts CHANGED
@@ -23,23 +23,48 @@ export function prompt(question: string): Promise<string> {
23
23
  });
24
24
  }
25
25
 
26
+ export interface PromptPasswordOptions {
27
+ /**
28
+ * After Enter, show this many of the value's last characters, so the
29
+ * operator can tell the value they pasted is the one they meant. Never more
30
+ * than a quarter of the value is shown.
31
+ */
32
+ revealLast?: number;
33
+ /** @internal The streams to read from and write to; tests pass their own. */
34
+ input?: NodeJS.ReadStream;
35
+ output?: NodeJS.WritableStream;
36
+ }
37
+
38
+ /**
39
+ * The trailing characters of `value` that `revealLast` may show: at most
40
+ * `revealLast`, and at most a quarter of the value.
41
+ */
42
+ export function revealedSuffix(value: string, revealLast: number): string {
43
+ let count = Math.min(revealLast, Math.floor(value.length / 4));
44
+ return count > 0 ? value.slice(-count) : '';
45
+ }
46
+
26
47
  /**
27
- * Read a secret from stdin without echoing it to the TTY. Keystrokes are
28
- * masked with `*` and Ctrl+C exits. Intended for seeds, passwords, and other
48
+ * Read a secret from stdin without echoing it to the TTY. Every character
49
+ * echoes as `*` and Ctrl+C exits. Intended for seeds, passwords, keys and other
29
50
  * sensitive CLI input that must not appear in shell history or `ps aux`.
30
51
  */
31
- export function promptPassword(question: string): Promise<string> {
52
+ export function promptPassword(
53
+ question: string,
54
+ options: PromptPasswordOptions = {},
55
+ ): Promise<string> {
56
+ const stdin = options.input ?? process.stdin;
57
+ const stdout = options.output ?? process.stdout;
32
58
  const mutableOutput = new Writable({
33
59
  write: (_chunk, _encoding, callback) => callback(),
34
60
  });
35
61
  const rl = readline.createInterface({
36
- input: process.stdin,
62
+ input: stdin,
37
63
  output: mutableOutput,
38
64
  terminal: true,
39
65
  });
40
66
 
41
67
  return new Promise((resolve, reject) => {
42
- const stdin = process.stdin;
43
68
  const wasFlowing = stdin.readableFlowing;
44
69
 
45
70
  if (stdin.isTTY) {
@@ -65,14 +90,22 @@ export function promptPassword(question: string): Promise<string> {
65
90
  // Ctrl+C inside a paste still work.
66
91
  const raw = chunk
67
92
  .toString()
68
- .split('[200~')
93
+ .split('\u001b[200~')
69
94
  .join('')
70
- .split('[201~')
95
+ .split('\u001b[201~')
71
96
  .join('');
72
97
  for (const c of raw) {
73
98
  if (c === '\n' || c === '\r') {
74
99
  cleanup();
75
- process.stdout.write('\n');
100
+ stdout.write('\n');
101
+ const suffix = options.revealLast
102
+ ? revealedSuffix(password, options.revealLast)
103
+ : '';
104
+ if (suffix) {
105
+ stdout.write(
106
+ ` (${[...password].length} characters, ending in ${suffix})\n`,
107
+ );
108
+ }
76
109
  resolve(password);
77
110
  return;
78
111
  } else if (c === '\u0003') {
@@ -82,13 +115,13 @@ export function promptPassword(question: string): Promise<string> {
82
115
  } else if (c === '\u007F' || c === '\b') {
83
116
  // Backspace
84
117
  if (password.length > 0) {
85
- password = password.slice(0, -1);
86
- process.stdout.write('\b \b');
118
+ password = [...password].slice(0, -1).join('');
119
+ stdout.write('\b \b');
87
120
  }
88
121
  } else if (c >= ' ') {
89
122
  // Printable character; suppress other control bytes entirely.
90
123
  password += c;
91
- process.stdout.write('*');
124
+ stdout.write('*');
92
125
  }
93
126
  }
94
127
  } catch (e) {
@@ -99,7 +132,7 @@ export function promptPassword(question: string): Promise<string> {
99
132
 
100
133
  let password = '';
101
134
  try {
102
- process.stdout.write(question);
135
+ stdout.write(question);
103
136
  stdin.on('data', onData);
104
137
  stdin.resume();
105
138
  } catch (e) {