@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/AGENTS.md +35 -32
- package/CHANGELOG.md +21 -0
- package/README.md +23 -20
- package/dist/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +28 -8
- package/dist/api.js.map +1 -1
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +92 -6
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +153 -58
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +49 -19
- package/dist/commands.js.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +10 -8
- package/dist/login.js.map +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +34 -5
- package/dist/project.js.map +1 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +91 -20
- package/dist/session.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/api.ts +38 -8
- package/src/args.ts +102 -6
- package/src/cli.ts +186 -57
- package/src/commands.ts +66 -19
- package/src/login.ts +14 -8
- package/src/project.ts +39 -5
- package/src/session.ts +106 -21
- package/src/version.ts +1 -1
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.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
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
|
|
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
|
|
220
|
-
// a non-JSON body, or an error code this pinned CLI does
|
|
221
|
-
//
|
|
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.
|
|
6
|
+
export const VERSION = '0.2.0';
|
|
7
7
|
|
|
8
8
|
export const CLI_CLIENT_ID = `cli/${VERSION}`;
|