@brass-build/cli 0.2.0 → 0.4.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.
Files changed (59) hide show
  1. package/AGENTS.md +170 -11
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +79 -15
  4. package/dist/api.d.ts +45 -0
  5. package/dist/api.d.ts.map +1 -1
  6. package/dist/api.js +21 -2
  7. package/dist/api.js.map +1 -1
  8. package/dist/approval-prompt.d.ts +25 -0
  9. package/dist/approval-prompt.d.ts.map +1 -0
  10. package/dist/approval-prompt.js +44 -0
  11. package/dist/approval-prompt.js.map +1 -0
  12. package/dist/args.d.ts.map +1 -1
  13. package/dist/args.js +10 -1
  14. package/dist/args.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +213 -31
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands.d.ts +28 -1
  19. package/dist/commands.d.ts.map +1 -1
  20. package/dist/commands.js +358 -24
  21. package/dist/commands.js.map +1 -1
  22. package/dist/config.d.ts +1 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js.map +1 -1
  25. package/dist/login.d.ts +1 -0
  26. package/dist/login.d.ts.map +1 -1
  27. package/dist/login.js +30 -16
  28. package/dist/login.js.map +1 -1
  29. package/dist/project.d.ts +8 -0
  30. package/dist/project.d.ts.map +1 -1
  31. package/dist/project.js +75 -7
  32. package/dist/project.js.map +1 -1
  33. package/dist/sdk-pairing.d.ts +9 -0
  34. package/dist/sdk-pairing.d.ts.map +1 -0
  35. package/dist/sdk-pairing.js +42 -0
  36. package/dist/sdk-pairing.js.map +1 -0
  37. package/dist/session.d.ts +15 -1
  38. package/dist/session.d.ts.map +1 -1
  39. package/dist/session.js +78 -16
  40. package/dist/session.js.map +1 -1
  41. package/dist/store.d.ts +10 -0
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/store.js +41 -0
  44. package/dist/store.js.map +1 -1
  45. package/dist/version.d.ts +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +2 -1
  48. package/src/api.ts +80 -2
  49. package/src/approval-prompt.ts +75 -0
  50. package/src/args.ts +10 -1
  51. package/src/cli.ts +257 -32
  52. package/src/commands.ts +468 -30
  53. package/src/config.ts +5 -1
  54. package/src/login.ts +40 -15
  55. package/src/project.ts +82 -7
  56. package/src/sdk-pairing.ts +53 -0
  57. package/src/session.ts +106 -14
  58. package/src/store.ts +77 -0
  59. package/src/version.ts +1 -1
package/src/login.ts CHANGED
@@ -14,13 +14,14 @@
14
14
  // place with a fresh one whose code is printed. A caller that has to relay a
15
15
  // second code because the first expired is the cost being avoided.
16
16
 
17
- import { deviceAuthorize, pollDeviceTokenOnce, decodeEmail } from './session.js';
17
+ import { deviceAuthorize, pollDeviceTokenOnce, postDeviceCancel, decodeEmail } from './session.js';
18
18
  import {
19
19
  readPendingLogin,
20
20
  writePendingLogin,
21
21
  writeStoredCredential,
22
22
  type PendingLogin,
23
23
  } from './store.js';
24
+ import { renderApprovalPrompt } from './approval-prompt.js';
24
25
  import type { Logger } from './log.js';
25
26
  import type { Profile } from './config.js';
26
27
 
@@ -37,6 +38,9 @@ export interface LoginStartOptions {
37
38
  // Mint a fresh grant even when a usable one is pending (`--new`), for a
38
39
  // human who lost the relayed code.
39
40
  force?: boolean;
41
+ // The app to approve alongside the CLI (`--app`), so one human action
42
+ // covers the sign-in and the app the machine will hand a browser.
43
+ targetAppId?: string;
40
44
  // Set when a wait follows in the same command (`--start --wait`), whose own
41
45
  // terminal result is the one the caller reads. `--json` is one document per
42
46
  // run, so the started state prints to the human stream only.
@@ -56,10 +60,24 @@ export async function loginStart(options: LoginStartOptions): Promise<number> {
56
60
  // second code and forgets the first, so an approval the human is part-way
57
61
  // through completes a grant the CLI can no longer redeem and they are asked
58
62
  // to sign in again.
59
- const existing = options.force === true ? null : await readPendingLogin(options.profile, env);
63
+ const existing = await readPendingLogin(options.profile, env);
60
64
  const resumed =
61
- existing !== null && existing.expiresAt - now() > MIN_USABLE_REMAINING_MS ? existing : null;
62
- const pending = resumed ?? (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env));
65
+ options.force !== true &&
66
+ existing !== null &&
67
+ existing.expiresAt - now() > MIN_USABLE_REMAINING_MS
68
+ ? existing
69
+ : null;
70
+ // A superseded grant that is still live stays approvable on the server for
71
+ // the rest of its TTL while this machine forgets the only copy of its
72
+ // device code, so cancel it before the record is overwritten. Best-effort:
73
+ // an undelivered cancel leaves a grant nobody can redeem from here, and the
74
+ // new grant is the one this command is for.
75
+ if (resumed === null && existing !== null && existing.expiresAt > now()) {
76
+ await postDeviceCancel(existing.authBaseUrl, existing.deviceCode);
77
+ }
78
+ const pending =
79
+ resumed ??
80
+ (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env, options.targetAppId));
63
81
  promptFor(options.log, pending, {
64
82
  lead: resumed === null ? null : 'A sign-in is already waiting for approval.',
65
83
  });
@@ -119,7 +137,13 @@ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
119
137
 
120
138
  for (;;) {
121
139
  if (now() >= pending.expiresAt) {
122
- pending = await mintPendingLogin(pending.authBaseUrl, now(), options.profile, env);
140
+ pending = await mintPendingLogin(
141
+ pending.authBaseUrl,
142
+ now(),
143
+ options.profile,
144
+ env,
145
+ pending.targetAppId,
146
+ );
123
147
  intervalSeconds = pending.intervalSeconds;
124
148
  renewed = true;
125
149
  promptFor(options.log, pending, {
@@ -135,7 +159,7 @@ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
135
159
  }
136
160
  await writeStoredCredential(
137
161
  options.profile,
138
- { session: { sid: outcome.tokens.sessionToken } },
162
+ { session: { sid: outcome.tokens.sessionToken, authBaseUrl: pending.authBaseUrl } },
139
163
  env,
140
164
  );
141
165
  await writePendingLogin(options.profile, null, env);
@@ -178,11 +202,13 @@ async function mintPendingLogin(
178
202
  now: number,
179
203
  profile: Profile,
180
204
  env: NodeJS.ProcessEnv,
205
+ targetAppId?: string,
181
206
  ): Promise<PendingLogin> {
182
- const auth = await deviceAuthorize(authBaseUrl, now);
207
+ const auth = await deviceAuthorize(authBaseUrl, now, targetAppId);
183
208
  const pending: PendingLogin = {
184
209
  authBaseUrl,
185
210
  deviceCode: auth.deviceCode,
211
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
186
212
  userCode: auth.userCode,
187
213
  verificationUri: auth.verificationUri,
188
214
  ...(auth.verificationUriComplete !== undefined
@@ -203,15 +229,14 @@ function remainingSeconds(pending: PendingLogin, now: number): number {
203
229
  return Math.max(0, Math.round((pending.expiresAt - now) / 1000));
204
230
  }
205
231
 
206
- // The one rendering of "here is what to relay, and here is what to run next",
207
- // so a resumed, renewed, and freshly minted grant all read the same to whoever
208
- // is relaying it.
209
232
  function promptFor(log: Logger, pending: PendingLogin, opts: { lead: string | null }): void {
210
233
  log.info(
211
- (opts.lead !== null ? `${opts.lead}\n` : '') +
212
- 'To approve this sign-in, go to:\n' +
213
- ` ${targetUrl(pending)}\n` +
214
- `and confirm the code: ${pending.userCode}\n` +
215
- '\nThen run `brass login --check --wait` to finish signing in.',
234
+ renderApprovalPrompt({
235
+ url: targetUrl(pending),
236
+ code: pending.userCode,
237
+ lead: opts.lead,
238
+ nextCommand: 'brass login --check --wait',
239
+ relaying: true,
240
+ }),
216
241
  );
217
242
  }
package/src/project.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile, writeFile, readdir, stat, mkdir, realpath } from 'node:fs/promises';
2
- import { join, relative, sep, dirname } from 'node:path';
2
+ import { join, relative, resolve, sep, dirname } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { zipSync } from 'fflate';
5
5
  import type { BrassSchemaManifest } from './api.js';
@@ -84,15 +84,90 @@ export interface AppManifest {
84
84
  [key: string]: unknown;
85
85
  }
86
86
 
87
+ // The manifest filename, and the bundle-relative directory the capability
88
+ // docs put it in.
89
+ export const MANIFEST_FILENAME = 'brass-app.json';
90
+ const WELL_KNOWN_DIR = '.well-known';
91
+
92
+ // Where `publish` and `status` look for the served manifest, most
93
+ // authoritative first. The copy inside the bundle is the one the platform
94
+ // reads back after a deploy, so it outranks the repo root, which is the flat
95
+ // layout an app that keeps its manifest beside `package.json` uses.
96
+ export function publishManifestCandidates(dir: string): string[] {
97
+ return [join(dir, WELL_KNOWN_DIR, MANIFEST_FILENAME), MANIFEST_FILENAME];
98
+ }
99
+
100
+ // Where `schema pull` looks for a manifest to merge a pulled schema into.
101
+ // Source locations only: a bundler rewrites its output directory on the next
102
+ // build, so a schema written there is gone before the app is published. Each
103
+ // candidate is used only when it already holds a file, so a layout this list
104
+ // does not name falls back to the root default rather than being guessed at.
105
+ export function sourceManifestCandidates(): string[] {
106
+ return [
107
+ MANIFEST_FILENAME,
108
+ join(WELL_KNOWN_DIR, MANIFEST_FILENAME),
109
+ join('public', WELL_KNOWN_DIR, MANIFEST_FILENAME),
110
+ join('static', WELL_KNOWN_DIR, MANIFEST_FILENAME),
111
+ ];
112
+ }
113
+
114
+ // Absent means no file at this path, which is what lets a caller walk a
115
+ // candidate list. Every other failure throws: a manifest that exists and
116
+ // cannot be parsed is a mistake to report, and answering it with `null` drops
117
+ // the `name` and `client_token` it was written to carry, which reads as a
118
+ // missing manifest and creates a duplicate app on the next stateless run.
87
119
  export async function readManifest(path: string): Promise<AppManifest | null> {
120
+ let raw: string;
88
121
  try {
89
- const raw = await readFile(path, 'utf8');
90
- const parsed = JSON.parse(raw) as unknown;
91
- if (typeof parsed !== 'object' || parsed === null) return null;
92
- return parsed as AppManifest;
93
- } catch {
94
- return null;
122
+ raw = await readFile(path, 'utf8');
123
+ } catch (err) {
124
+ const code = (err as NodeJS.ErrnoException).code;
125
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
126
+ throw new Error(`Could not read the manifest at ${path}: ${(err as Error).message}`);
127
+ }
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(raw) as unknown;
131
+ } catch (err) {
132
+ throw new Error(`The manifest at ${path} is not valid JSON: ${(err as Error).message}`);
133
+ }
134
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
135
+ throw new Error(`The manifest at ${path} must hold a JSON object.`);
136
+ }
137
+ return parsed as AppManifest;
138
+ }
139
+
140
+ // The first candidate that holds a manifest, paired with the path it came
141
+ // from so a caller can name it. A candidate with no file is skipped; one that
142
+ // is present and unreadable throws out of `readManifest`, so a malformed
143
+ // manifest is reported rather than passed over for the next candidate down.
144
+ export async function loadFirstManifest(
145
+ cwd: string,
146
+ candidates: readonly string[],
147
+ ): Promise<{ path: string; manifest: AppManifest } | null> {
148
+ for (const candidate of candidates) {
149
+ const manifest = await readManifest(resolve(cwd, candidate));
150
+ if (manifest !== null) return { path: candidate, manifest };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ // The first candidate that holds a file, without parsing it. `schema pull`
156
+ // picks its destination this way so it merges into a malformed manifest's
157
+ // path and fails there, instead of stepping over it and writing a second
158
+ // manifest the bundle never serves.
159
+ export async function firstExistingManifestPath(
160
+ cwd: string,
161
+ candidates: readonly string[],
162
+ ): Promise<string | null> {
163
+ for (const candidate of candidates) {
164
+ try {
165
+ if ((await stat(resolve(cwd, candidate))).isFile()) return candidate;
166
+ } catch {
167
+ // Not there, or not reachable through this path. Try the next.
168
+ }
95
169
  }
170
+ return null;
96
171
  }
97
172
 
98
173
  export async function writeManifest(path: string, manifest: AppManifest): Promise<void> {
@@ -0,0 +1,53 @@
1
+ // Which `@brass-build/client` this CLI is allowed to use.
2
+ //
3
+ // The dependency is pinned to one exact version, and what the CLI imports is
4
+ // the `internal` subpath, the entry that promises nothing from one release to
5
+ // the next. So the only copy it can run against is the one it names, and a
6
+ // correct install always provides it.
7
+ //
8
+ // What does not is a CLI running somewhere its own dependency is not
9
+ // reachable, and every way in is quiet. `npx brass` from a directory with no
10
+ // `node_modules` fetches the CLI alone from the registry. `npm i -g` nests a
11
+ // registry copy under the CLI. Installing the CLI tarball by itself resolves
12
+ // the SDK from npm, which succeeds precisely because an unreleased build in a
13
+ // working tree still carries the last released version number.
14
+ //
15
+ // The check is on the VERSION, not on any one export, and that is the point:
16
+ // a check per export is re-earned every time the CLI reaches for a new one,
17
+ // and it reports whatever that call happened to need rather than the pairing
18
+ // that is actually wrong. One version comparison covers every surface the CLI
19
+ // imports today and every one it adds later.
20
+
21
+ export interface LoadedSdk {
22
+ version: string;
23
+ // Which file answered. The version cannot identify a copy on its own: a
24
+ // machine can hold several that all report the same one, which is exactly
25
+ // the case here, so the path is what ends the search.
26
+ path: string;
27
+ }
28
+
29
+ // The mismatch, as a sentence, or `null` when the pairing is sound.
30
+ //
31
+ // `loaded` is null when the SDK could not be resolved from the CLI's own
32
+ // location. That is not a mismatch: it is the shape an in-repo run takes,
33
+ // where the module specifier is mapped to the SDK's source and there is no
34
+ // installed package to read a version from. Being unable to check is not
35
+ // evidence of a fault, so it passes.
36
+ export function sdkMismatchMessage(args: {
37
+ pinned: string | undefined;
38
+ loaded: LoadedSdk | null;
39
+ }): string | null {
40
+ const { pinned, loaded } = args;
41
+ if (pinned === undefined || loaded === null) return null;
42
+ if (loaded.version === pinned) return null;
43
+ return (
44
+ `This CLI is built against @brass-build/client ${pinned}, and the copy it ` +
45
+ `loads is ${loaded.version} (${loaded.path}). It reads the SDK's internal ` +
46
+ `entry, which changes between releases, so the two have to match. Install ` +
47
+ `the CLI into the directory you run it from ("npm i @brass-build/cli") and ` +
48
+ `run it as ./node_modules/.bin/brass with absolute paths: a global install ` +
49
+ `keeps its own copy of the SDK, and npx resolves node_modules from the ` +
50
+ `current directory, so it fetches one from the registry when run anywhere ` +
51
+ `else.`
52
+ );
53
+ }
package/src/session.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
13
  import { BrassApiError, networkError } from './api.js';
14
+ import { renderApprovalPrompt } from './approval-prompt.js';
14
15
  import type { AuthProvider } from './auth.js';
15
16
 
16
17
  // Every auth call carries a few hundred bytes, so a host still silent after
@@ -127,9 +128,9 @@ export async function postRefresh(
127
128
  // rest of the session's life, so forgetting it locally leaves a working
128
129
  // credential behind wherever a copy of the file went.
129
130
  //
130
- // Reports whether the server confirmed it. Every outcome still clears the
131
- // local credential (the caller asked to be signed out on this machine), so
132
- // this is what tells the caller whether a credential elsewhere is still live.
131
+ // Reports whether the server confirmed it. The stored pointer is the caller's
132
+ // only handle on the session, so what the caller does with the record follows
133
+ // from this: an undelivered revoke keeps it for the retry.
133
134
  export async function postSignOut(
134
135
  authBaseUrl: string,
135
136
  sid: string,
@@ -151,10 +152,81 @@ export async function postSignOut(
151
152
  }
152
153
  }
153
154
 
155
+ export type BrowserSessionOutcome =
156
+ | { state: 'ready'; url: string; expiresIn: number }
157
+ // The app is not approved for the platform session behind this machine's
158
+ // sign-in. The URL is a page the human opens to grant it; the mint is a
159
+ // re-run of the same command afterwards.
160
+ | { state: 'needs_approval'; approvalUrl: string; expiresIn: number };
161
+
162
+ interface BrowserSessionWire {
163
+ url?: string;
164
+ expires_in?: number;
165
+ error?: string;
166
+ error_description?: string;
167
+ approval_url?: string;
168
+ }
169
+
170
+ // Ask the auth API for a URL that signs a browser in as the human this
171
+ // machine is signed in as. The stored device pointer is the credential, and
172
+ // the answer is `return_to` with a one-time code on it, so what reaches the
173
+ // browser expires in minutes and is spent once.
174
+ //
175
+ // The URL is a credential for as long as it lives. It belongs in the browser
176
+ // the caller is about to drive, not in a log, a PR body, or a commit.
177
+ export async function postDeviceAppSession(
178
+ authBaseUrl: string,
179
+ sid: string,
180
+ args: { appId: string; returnTo: string },
181
+ origin: string = REFRESH_ORIGIN,
182
+ ): Promise<BrowserSessionOutcome> {
183
+ const url = `${authBaseUrl}/device/app-session`;
184
+ let response: Response;
185
+ try {
186
+ response = await fetch(url, {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
189
+ body: new URLSearchParams({ sid, app_id: args.appId, return_to: args.returnTo }),
190
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
191
+ });
192
+ } catch (cause) {
193
+ throw networkError(`reaching ${authBaseUrl}`, cause);
194
+ }
195
+ const json = (await response.json().catch(() => ({}))) as BrowserSessionWire;
196
+ if (response.ok) {
197
+ if (!json.url) {
198
+ throw new BrassApiError(response.status, 'Brass returned no browser-session URL.');
199
+ }
200
+ return {
201
+ state: 'ready',
202
+ url: json.url,
203
+ expiresIn: tokenTtlSeconds(json.expires_in),
204
+ };
205
+ }
206
+ if (json.error === 'app_not_approved' && json.approval_url) {
207
+ return {
208
+ state: 'needs_approval',
209
+ approvalUrl: json.approval_url,
210
+ expiresIn: tokenTtlSeconds(json.expires_in),
211
+ };
212
+ }
213
+ // The server's own wording where it has one: every refusal here names a
214
+ // specific thing the caller can fix (an unknown app, a return_to off the
215
+ // allowlist, a sign-in that has ended), and rewording them locally would
216
+ // lose which.
217
+ throw new BrassApiError(
218
+ response.status,
219
+ json.error_description ??
220
+ (response.status === 401
221
+ ? "Your Brass sign-in is no longer valid (401). Run 'brass login' to sign in again."
222
+ : `Could not mint a browser session (${response.status}).`),
223
+ );
224
+ }
225
+
154
226
  // Abandon a sign-in this machine started and never redeemed, so the code it
155
- // relayed to a human stops being redeemable. Best effort, like `postSignOut`:
156
- // the caller clears the local record either way, and reports what it could not
157
- // reach.
227
+ // relayed to a human stops being redeemable. Reports whether the server took
228
+ // it, like `postSignOut`: the record naming the grant is the caller's only
229
+ // handle on it, so what the caller does with the record follows from this.
158
230
  export async function postDeviceCancel(
159
231
  authBaseUrl: string,
160
232
  deviceCode: string,
@@ -216,11 +288,17 @@ interface DeviceAuthorizeWire {
216
288
  expires_in: number;
217
289
  }
218
290
 
291
+ // `targetAppId` is the app the machine will hand a browser once it is signed
292
+ // in (`brass login --app`). The grant still mints for the CLI alone; naming a
293
+ // target only widens what the human's one approval covers, so a later
294
+ // `brass browser-session` needs no second trip to a browser.
219
295
  export async function deviceAuthorize(
220
296
  authBaseUrl: string,
221
297
  now: number = Date.now(),
298
+ targetAppId?: string,
222
299
  ): Promise<DeviceAuthorization> {
223
300
  const body = new URLSearchParams({ app_id: CLI_APP_ID });
301
+ if (targetAppId !== undefined) body.set('target_app_id', targetAppId);
224
302
  let response: Response;
225
303
  try {
226
304
  response = await fetch(`${authBaseUrl}/device/authorize`, {
@@ -232,8 +310,18 @@ export async function deviceAuthorize(
232
310
  } catch (cause) {
233
311
  throw new Error(`Network error reaching ${authBaseUrl}: ${String(cause)}`);
234
312
  }
235
- if (!response.ok) throw new Error(`Device sign-in could not start (${response.status})`);
236
- const json = (await response.json()) as DeviceAuthorizeWire;
313
+ // The server's own reason where it sent one. Every 400 here names a
314
+ // specific thing the caller got wrong (an app id that is not an app, an app
315
+ // that does not support device sign-in), and a status on its own sends them
316
+ // looking at the network instead.
317
+ const json = (await response.json().catch(() => ({}))) as DeviceAuthorizeWire & {
318
+ error_description?: string;
319
+ };
320
+ if (!response.ok) {
321
+ throw new Error(
322
+ json.error_description ?? `Device sign-in could not start (${response.status})`,
323
+ );
324
+ }
237
325
  return {
238
326
  deviceCode: json.device_code,
239
327
  userCode: json.user_code,
@@ -334,6 +422,8 @@ export async function pollDeviceToken(
334
422
 
335
423
  export interface DeviceLoginOptions {
336
424
  authBaseUrl: string;
425
+ // An app to approve alongside the CLI (`brass login --app`).
426
+ targetAppId?: string;
337
427
  // Shows the verification URL + user code to the human; defaults to stderr.
338
428
  onPrompt?: (auth: DeviceAuthorization) => void;
339
429
  // Opens the approval page (user code prefilled) in a browser; defaults to
@@ -344,7 +434,7 @@ export interface DeviceLoginOptions {
344
434
  }
345
435
 
346
436
  export async function loginDevice(options: DeviceLoginOptions): Promise<LoginResult> {
347
- const auth = await deviceAuthorize(options.authBaseUrl);
437
+ const auth = await deviceAuthorize(options.authBaseUrl, Date.now(), options.targetAppId);
348
438
  (options.onPrompt ?? defaultDevicePrompt)(auth);
349
439
  (options.openBrowser ?? openBrowser)(auth.verificationUriComplete ?? auth.verificationUri);
350
440
  const tokens = await pollDeviceToken(
@@ -357,12 +447,14 @@ export async function loginDevice(options: DeviceLoginOptions): Promise<LoginRes
357
447
  }
358
448
 
359
449
  function defaultDevicePrompt(auth: DeviceAuthorization): void {
360
- const target = auth.verificationUriComplete ?? auth.verificationUri;
361
450
  process.stderr.write(
362
- '\nOpening your browser to approve this sign-in.\n' +
363
- `If it doesn't open, go to:\n ${target}\n` +
364
- `and confirm the code: ${auth.userCode}\n` +
365
- '\nWaiting for approval...\n',
451
+ `\n${renderApprovalPrompt({
452
+ url: auth.verificationUriComplete ?? auth.verificationUri,
453
+ code: auth.userCode,
454
+ // This command polls to approval on its own, so there is nothing further
455
+ // to run; what the reader needs is that the wait has started.
456
+ closing: 'Waiting for approval...',
457
+ })}\n`,
366
458
  );
367
459
  }
368
460
 
package/src/store.ts CHANGED
@@ -47,6 +47,9 @@ export async function writeStoredCredential(
47
47
  export interface PendingLogin {
48
48
  authBaseUrl: string;
49
49
  deviceCode: string;
50
+ // The app named by `brass login --app`, carried so a grant this CLI renews
51
+ // in place asks for the same approval the relayed one did.
52
+ targetAppId?: string;
50
53
  userCode: string;
51
54
  verificationUri: string;
52
55
  verificationUriComplete?: string;
@@ -112,6 +115,80 @@ export async function writePendingLogin(
112
115
  } satisfies PendingLoginsFile);
113
116
  }
114
117
 
118
+ // An app approval `brass browser-session` asked for and no one has granted
119
+ // yet. Recorded for the same reason a pending sign-in is: the caller relayed a
120
+ // URL to a human and now has to wait, and `brass status` is where it asks what
121
+ // is outstanding. Without it, status reports the app as ready and names a next
122
+ // step that is not the blocked one.
123
+ //
124
+ // One per profile, carrying the app it is for, so status reports it only
125
+ // against the app it resolved. A second app asked about while a first is
126
+ // pending overwrites it; the link it replaces stays valid on the server, and
127
+ // re-running the command for that app reports it again.
128
+ export interface PendingApproval {
129
+ appId: string;
130
+ approvalUrl: string;
131
+ authBaseUrl: string;
132
+ expiresAt: number;
133
+ }
134
+
135
+ interface PendingApprovalsFile {
136
+ version: 1;
137
+ pending: Record<Profile, PendingApproval>;
138
+ }
139
+
140
+ export function pendingApprovalFilePath(env: NodeJS.ProcessEnv = process.env): string {
141
+ return join(dirname(credentialsFilePath(env)), 'pending-approval.json');
142
+ }
143
+
144
+ async function readPendingApprovalsFile(
145
+ env: NodeJS.ProcessEnv,
146
+ ): Promise<PendingApprovalsFile | null> {
147
+ try {
148
+ const raw = await readFile(pendingApprovalFilePath(env), 'utf8');
149
+ const parsed = JSON.parse(raw) as PendingApprovalsFile;
150
+ if (parsed.version !== 1 || typeof parsed.pending !== 'object') return null;
151
+ return parsed;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ export async function readPendingApproval(
158
+ profile: Profile,
159
+ env: NodeJS.ProcessEnv = process.env,
160
+ ): Promise<PendingApproval | null> {
161
+ const entry = (await readPendingApprovalsFile(env))?.pending[profile];
162
+ if (
163
+ entry === undefined ||
164
+ typeof entry.appId !== 'string' ||
165
+ typeof entry.approvalUrl !== 'string' ||
166
+ typeof entry.expiresAt !== 'number'
167
+ ) {
168
+ return null;
169
+ }
170
+ return entry;
171
+ }
172
+
173
+ // The approval URL names a question rather than carrying a credential (the
174
+ // page still gates on the reader's own cookie), but the file is written
175
+ // owner-only like its siblings: what it discloses is which machine is asking
176
+ // about which app.
177
+ export async function writePendingApproval(
178
+ profile: Profile,
179
+ pending: PendingApproval | null,
180
+ env: NodeJS.ProcessEnv = process.env,
181
+ ): Promise<void> {
182
+ const existing = (await readPendingApprovalsFile(env)) ?? { version: 1 as const, pending: {} };
183
+ const entries = { ...existing.pending };
184
+ if (pending === null) delete entries[profile];
185
+ else entries[profile] = pending;
186
+ await writeOwnerOnlyJson(pendingApprovalFilePath(env), {
187
+ version: 1,
188
+ pending: entries,
189
+ } satisfies PendingApprovalsFile);
190
+ }
191
+
115
192
  async function writeOwnerOnlyJson(path: string, payload: unknown): Promise<void> {
116
193
  await mkdir(dirname(path), { recursive: true });
117
194
  await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
package/src/version.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  // free here, unlike the SDK's query-param channel). Lets the platform
4
4
  // measure the CLI version distribution, in particular pinned CI copies.
5
5
  // A unit test pins this to package.json's version.
6
- export const VERSION = '0.2.0';
6
+ export const VERSION = '0.4.0';
7
7
 
8
8
  export const CLI_CLIENT_ID = `cli/${VERSION}`;