@brass-build/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/login.ts CHANGED
@@ -37,6 +37,10 @@ export interface LoginStartOptions {
37
37
  // Mint a fresh grant even when a usable one is pending (`--new`), for a
38
38
  // human who lost the relayed code.
39
39
  force?: boolean;
40
+ // Set when a wait follows in the same command (`--start --wait`), whose own
41
+ // terminal result is the one the caller reads. `--json` is one document per
42
+ // run, so the started state prints to the human stream only.
43
+ resultFollows?: boolean;
40
44
  env?: NodeJS.ProcessEnv;
41
45
  now?: () => number;
42
46
  }
@@ -59,14 +63,16 @@ export async function loginStart(options: LoginStartOptions): Promise<number> {
59
63
  promptFor(options.log, pending, {
60
64
  lead: resumed === null ? null : 'A sign-in is already waiting for approval.',
61
65
  });
62
- options.log.result({
63
- state: 'started',
64
- resumed: resumed !== null,
65
- verification_url: targetUrl(pending),
66
- user_code: pending.userCode,
67
- expires_at: pending.expiresAt,
68
- expires_in_seconds: remainingSeconds(pending, now()),
69
- });
66
+ if (options.resultFollows !== true) {
67
+ options.log.result({
68
+ state: 'started',
69
+ resumed: resumed !== null,
70
+ verification_url: targetUrl(pending),
71
+ user_code: pending.userCode,
72
+ expires_at: pending.expiresAt,
73
+ expires_in_seconds: remainingSeconds(pending, now()),
74
+ });
75
+ }
70
76
  return 0;
71
77
  }
72
78
 
package/src/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
1
+ import { readFile, writeFile, readdir, stat, mkdir, realpath } from 'node:fs/promises';
2
2
  import { join, relative, sep, dirname } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { zipSync } from 'fflate';
@@ -123,22 +123,56 @@ export function mergeSchemaIntoManifest(
123
123
  // by forward-slash relative path (zip entries never use the OS separator).
124
124
  // Symlinks are followed via `stat`; empty directories are omitted (a static
125
125
  // bundle has none that matter).
126
+ // The one entry name a zip built from an object map cannot carry. Assigning it
127
+ // on an ordinary object literal runs the prototype setter instead of adding an
128
+ // entry, and both this walk and fflate's own index are such maps, so the file
129
+ // drops out of the archive and the map inherits from the bytes it was handed.
130
+ // fflate then walks that index with `for...in`, reaches the inherited keys, and
131
+ // writes one bogus directory per byte of the file. Only the exact name at the
132
+ // root of the bundle does this: a `__proto__` inside a subdirectory is keyed by
133
+ // its whole relative path, which is an ordinary string.
134
+ const UNPUBLISHABLE_ENTRY_NAME = '__proto__';
135
+
126
136
  export async function collectZipEntries(root: string): Promise<Record<string, Uint8Array>> {
127
- const entries: Record<string, Uint8Array> = {};
128
- async function walk(dir: string): Promise<void> {
137
+ // Null-prototype, so the name above lands as an ordinary key and the refusal
138
+ // below can see it. On a plain literal it is already gone by then.
139
+ const entries: Record<string, Uint8Array> = Object.create(null) as Record<
140
+ string,
141
+ Uint8Array
142
+ >;
143
+ // The real paths of the directories on the way down to `dir`, so a symlink
144
+ // pointing back at one of its own ancestors ends the descent instead of
145
+ // walking the subtree again at every level. `stat` resolves symlinks, so
146
+ // without this the walk re-reads the whole subtree until the kernel refuses
147
+ // the path at its symlink depth and the publish dies on a raw ELOOP naming a
148
+ // path thousands of characters long. Only an ANCESTOR ends it: two separate
149
+ // links to one directory are two real places to publish from, and each is
150
+ // still walked.
151
+ async function walk(dir: string, ancestors: readonly string[]): Promise<void> {
152
+ const real = await realpath(dir);
153
+ if (ancestors.includes(real)) return;
154
+ const chain = [...ancestors, real];
129
155
  const names = await readdir(dir);
130
156
  for (const name of names) {
131
157
  const abs = join(dir, name);
132
158
  const info = await stat(abs);
133
159
  if (info.isDirectory()) {
134
- await walk(abs);
160
+ await walk(abs, chain);
135
161
  } else if (info.isFile()) {
136
162
  const rel = relative(root, abs).split(sep).join('/');
137
163
  entries[rel] = new Uint8Array(await readFile(abs));
138
164
  }
139
165
  }
140
166
  }
141
- await walk(root);
167
+ await walk(root, []);
168
+ if (UNPUBLISHABLE_ENTRY_NAME in entries) {
169
+ throw new Error(
170
+ `Cannot publish a file named "${UNPUBLISHABLE_ENTRY_NAME}" at the top ` +
171
+ `level of ${root}: the archive format keys entries by name, and that ` +
172
+ `name is an object's prototype rather than an entry. Rename or remove ` +
173
+ `it.`,
174
+ );
175
+ }
142
176
  return entries;
143
177
  }
144
178
 
package/src/session.ts CHANGED
@@ -4,17 +4,25 @@
4
4
  // code prefilled (and prints the URL + code as a fallback for a headless
5
5
  // box), then polls until the human approves. Approval mints the same tokens
6
6
  // `/refresh` returns, including the opaque `session_token` the CLI stores to
7
- // keep refreshing. The CLI's OAuth client is the seeded
8
- // `brass_app_internal_cli`; its redirect allowlist is the loopback wildcards,
9
- // which the `/refresh` Origin gate checks the CLI's portless 127.0.0.1 origin
10
- // against on every later token refresh.
7
+ // keep refreshing, which is bound to the CLI's own app id. That app's
8
+ // registered redirect URLs are the loopback wildcards, which is what lets the
9
+ // portless 127.0.0.1 origin below pass the `/refresh` Origin check on every
10
+ // later token refresh.
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
- import { BrassApiError } from './api.js';
13
+ import { BrassApiError, networkError } from './api.js';
14
14
  import type { AuthProvider } from './auth.js';
15
15
 
16
- // The seeded CLI OAuth client (see infra/lib/api-stack.ts). Stable across
17
- // environments, so one CLI build signs in against prod or dev.
16
+ // Every auth call carries a few hundred bytes, so a host still silent after
17
+ // this is one that is not going to answer.
18
+ const AUTH_TIMEOUT_MS = 15_000;
19
+ // Signing out on the server is best effort: `runLogout` clears the local
20
+ // credential whatever this returns, so a host that stalls must not be what
21
+ // stops the credential being cleared.
22
+ const SIGN_OUT_TIMEOUT_MS = 5_000;
23
+
24
+ // The app id the CLI signs in as. Stable across environments, so one CLI
25
+ // build authenticates against whichever stack the origin flags name.
18
26
  export const CLI_APP_ID = 'brass_app_internal_cli';
19
27
 
20
28
  // The Origin the CLI presents on every `/refresh` call (a command refreshing
@@ -30,7 +38,9 @@ export interface RefreshedTokens {
30
38
  idToken: string;
31
39
  expiresAt: number;
32
40
  // Present only on the initial device-grant token exchange: the opaque
33
- // session pointer the CLI persists and echoes on later refreshes.
41
+ // session pointer the CLI persists and echoes on later refreshes. Bound to
42
+ // the CLI's app id, so a copy of it authenticates as the CLI and nothing
43
+ // else.
34
44
  sessionToken?: string;
35
45
  }
36
46
 
@@ -41,6 +51,20 @@ interface RefreshWire {
41
51
  session_token?: string;
42
52
  }
43
53
 
54
+ // The lifetime to stamp a token set with, given whatever `expires_in` the
55
+ // response carried. Every value that is not a positive finite number stamps an
56
+ // `expiresAt` the refresh guard `Date.now() >= expiresAt` reads wrong: a
57
+ // missing or non-numeric one makes it NaN, so every comparison is false and
58
+ // the token is never re-refreshed, and a zero or negative one makes it already
59
+ // past, so every call refreshes again. The SDK's `tokenTtlSeconds` is the same
60
+ // guard on the same field.
61
+ const DEFAULT_TOKEN_TTL_SECONDS = 3600;
62
+ function tokenTtlSeconds(expiresIn: unknown): number {
63
+ return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0
64
+ ? expiresIn
65
+ : DEFAULT_TOKEN_TTL_SECONDS;
66
+ }
67
+
44
68
  // Refresh an access token at `/refresh` from the stored session pointer
45
69
  // (`sid`). A form-encoded body keeps this a CORS simple request (the auth API
46
70
  // serves no preflight); the Origin header is required by the endpoint's
@@ -59,9 +83,10 @@ export async function postRefresh(
59
83
  method: 'POST',
60
84
  headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
61
85
  body,
86
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
62
87
  });
63
88
  } catch (cause) {
64
- throw new BrassApiError(0, `Network error reaching ${authBaseUrl}: ${String(cause)}`);
89
+ throw networkError(`reaching ${authBaseUrl}`, cause);
65
90
  }
66
91
  if (!response.ok) {
67
92
  // Carry the status, and name what a 401 means. Every command reaches the
@@ -76,21 +101,79 @@ export async function postRefresh(
76
101
  : `Sign-in exchange failed (${response.status}).`,
77
102
  );
78
103
  }
79
- const json = (await response.json()) as RefreshWire;
104
+ const json = (await response.json().catch(() => ({}))) as Partial<RefreshWire>;
105
+ // A 200 carrying no tokens is a failed exchange whatever the status said.
106
+ // Taken here because the fields are read unchecked otherwise: an absent
107
+ // `access_token` is stored as `undefined` and presented as the literal
108
+ // header `Bearer undefined`, which comes back 401 and is reported as an
109
+ // expired sign-in, sending the caller to `brass login` for a session that
110
+ // was never the problem. `pollDeviceToken` requires both fields already.
111
+ if (!json.access_token || !json.id_token) {
112
+ throw new BrassApiError(
113
+ response.status,
114
+ `Sign-in exchange returned no tokens (${response.status}).`,
115
+ );
116
+ }
80
117
  return {
81
118
  accessToken: json.access_token,
82
119
  idToken: json.id_token,
83
- // Default the lifetime when the server omits it: `undefined * 1000` is
84
- // `NaN`, which makes `expiresAt` NaN and the `Date.now() >= expiresAt`
85
- // refresh guard permanently false, so the in-process token would never
86
- // re-refresh. `pollDeviceToken` already guards the same field.
87
- expiresAt: now + (json.expires_in ?? 3600) * 1000,
120
+ expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
88
121
  ...(json.session_token ? { sessionToken: json.session_token } : {}),
89
122
  };
90
123
  }
91
124
 
92
- // Decode the email claim from a Cognito id token, best-effort (used only for
93
- // the "Signed in as ..." confirmation; never for authorization).
125
+ // End this machine's sign-in on the server, not only in the local
126
+ // credentials file. The stored pointer keeps minting access tokens for the
127
+ // rest of the session's life, so forgetting it locally leaves a working
128
+ // credential behind wherever a copy of the file went.
129
+ //
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.
133
+ export async function postSignOut(
134
+ authBaseUrl: string,
135
+ sid: string,
136
+ origin: string = REFRESH_ORIGIN,
137
+ ): Promise<boolean> {
138
+ const url = `${authBaseUrl}/sign-out-of-app?${new URLSearchParams({ app_id: CLI_APP_ID }).toString()}`;
139
+ try {
140
+ const response = await fetch(url, {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
143
+ body: new URLSearchParams({ sid }),
144
+ signal: AbortSignal.timeout(SIGN_OUT_TIMEOUT_MS),
145
+ });
146
+ // A 401 means the pointer no longer resolves, which is the state the
147
+ // caller wanted: there is nothing left to revoke.
148
+ return response.ok || response.status === 401;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ // 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.
158
+ export async function postDeviceCancel(
159
+ authBaseUrl: string,
160
+ deviceCode: string,
161
+ ): Promise<boolean> {
162
+ try {
163
+ const response = await fetch(`${authBaseUrl}/device/cancel`, {
164
+ method: 'POST',
165
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
166
+ body: new URLSearchParams({ device_code: deviceCode, app_id: CLI_APP_ID }),
167
+ signal: AbortSignal.timeout(SIGN_OUT_TIMEOUT_MS),
168
+ });
169
+ return response.ok;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ // Decode the email claim from an id token, best-effort (used only for the
176
+ // "Signed in as ..." confirmation; never for authorization).
94
177
  export function decodeEmail(idToken: string): string | undefined {
95
178
  const part = idToken.split('.')[1];
96
179
  if (part === undefined) return undefined;
@@ -144,6 +227,7 @@ export async function deviceAuthorize(
144
227
  method: 'POST',
145
228
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
146
229
  body,
230
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
147
231
  });
148
232
  } catch (cause) {
149
233
  throw new Error(`Network error reaching ${authBaseUrl}: ${String(cause)}`);
@@ -190,6 +274,7 @@ export async function pollDeviceTokenOnce(
190
274
  method: 'POST',
191
275
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
192
276
  body,
277
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
193
278
  });
194
279
  } catch {
195
280
  // A transient network error mid-poll: the device grant is still valid
@@ -207,7 +292,7 @@ export async function pollDeviceTokenOnce(
207
292
  tokens: {
208
293
  accessToken: json.access_token,
209
294
  idToken: json.id_token,
210
- expiresAt: now + (json.expires_in ?? 3600) * 1000,
295
+ expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
211
296
  ...(json.session_token ? { sessionToken: json.session_token } : {}),
212
297
  },
213
298
  };
@@ -216,9 +301,9 @@ export async function pollDeviceTokenOnce(
216
301
  if (json.error === 'access_denied') return { state: 'denied' };
217
302
  // RFC 8628 §3.5: polling too fast. The caller backs off.
218
303
  if (json.error === 'slow_down') return { state: 'slow_down' };
219
- // Everything else `authorization_pending`, a transient non-2xx (5xx/429),
220
- // a non-JSON body, or an error code this pinned CLI does not recognize — is
221
- // "keep waiting". The caller's deadline check is the single terminal bound,
304
+ // Everything else is "keep waiting": `authorization_pending`, a transient
305
+ // non-2xx (5xx/429), a non-JSON body, or an error code this pinned CLI does
306
+ // not recognize. The caller's deadline check is the single terminal bound,
222
307
  // so an unknown error or a server blip does not abort a sign-in the user is
223
308
  // one poll away from completing; a genuinely expired grant simply ends
224
309
  // there as a clean timeout.
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.1.0';
6
+ export const VERSION = '0.2.0';
7
7
 
8
8
  export const CLI_CLIENT_ID = `cli/${VERSION}`;