@basein/runner 0.1.1 → 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/README.md CHANGED
@@ -51,11 +51,18 @@ byte-for-byte.
51
51
 
52
52
  ```bash
53
53
  export BIR_AUTH_URL=https://your-basein-service # required to record anything
54
- bir login # once
54
+ bir login # once; approve it in the browser
55
55
  bir-hooks # leave running; Ctrl-C to stop
56
56
  claude # your session, now recorded
57
57
  ```
58
58
 
59
+ `bir login` prints a link and a short code and waits for you to approve it in
60
+ the browser — the OAuth device grant, the same shape `gh auth login` uses. It
61
+ never handles a password, so it works for accounts that only sign in with
62
+ Google, and the link can be opened on any device, which is what makes it usable
63
+ on a headless runner. `--no-browser` prints the link without opening one;
64
+ `--password` is the deprecated email-and-password prompt, removed next release.
65
+
59
66
  Check it:
60
67
 
61
68
  ```bash
@@ -1,21 +1,35 @@
1
1
  /**
2
- * auth client — ported from RRepeat's `src/auth/client.ts` (Phase 4.1).
2
+ * auth client — the CLI's session with the BaseIn service.
3
3
  *
4
- * Authenticates the CLI user against the BaseIn auth-service and returns a JWT
5
- * session. Every recording call carries that access token; the recording's owner
6
- * is the JWT subject on the server side, which is what makes tenancy work
7
- * without BaseInstRunner knowing anything about it.
4
+ * Every recording call carries an access token; the recording's owner is the
5
+ * JWT subject on the server side, which is what makes tenancy work without
6
+ * BaseInstRunner knowing anything about it.
7
+ *
8
+ * TWO HALVES, AND ONLY ONE OF THEM MAY PROMPT.
9
+ *
10
+ * `authenticate()` is the silent half, and every process calls it — `bir-hooks`
11
+ * at startup, each `bir-proxy`, and the CLI:
8
12
  *
9
- * Flow:
10
13
  * 1. Load the cached session from ~/.baseinstrunner/credentials.json.
11
14
  * 2. If the access token is still valid, reuse it.
12
15
  * 3. Else if a refresh token is present, POST /auth/refresh (silent).
13
- * 4. Else prompt for email + password and POST /auth/login.
14
- * 5. Persist the result (mode 0600) and return it.
16
+ * 4. Else give up and return undefined.
17
+ *
18
+ * It used to have a fifth step — prompt for a password — and that had to go
19
+ * when sign-in moved to the browser. A device flow prints a code and waits up
20
+ * to ten minutes for someone to approve it in a browser; a control server doing
21
+ * that at startup, possibly under a supervisor where nobody can see the code,
22
+ * would hang instead of degrading. So interaction lives in `deviceLogin()`,
23
+ * which only `bir login` calls.
15
24
  *
16
- * WHY IT NEVER `process.exit`s: `bir-proxy` runs inside the host's process tree.
17
- * Failing to authenticate must degrade to not-recording (§10), not kill a server
18
- * the host is waiting on. Callers get `undefined` and decide.
25
+ * `deviceLogin()` is the interactive half: the OAuth 2.0 device authorization
26
+ * grant (RFC 8628). It mints a code, prints a link, and polls until the person
27
+ * approves in the console. It never handles a password, which is what makes it
28
+ * work for Google-only accounts — they have no password to type.
29
+ *
30
+ * WHY NOTHING HERE `process.exit`s: `bir-proxy` runs inside the host's process
31
+ * tree. Failing to authenticate must degrade to not-recording (§10), not kill a
32
+ * server the host is waiting on. Callers get `undefined` and decide.
19
33
  *
20
34
  * Config:
21
35
  * BIR_AUTH_URL base URL of the BaseIn auth-service (required).
@@ -36,8 +50,13 @@ export interface AuthSession {
36
50
  export interface AuthenticateOptions {
37
51
  /** Base URL of the auth service. Defaults to BIR_AUTH_URL. */
38
52
  authUrl?: string;
39
- /** Never prompt: fail (return undefined) if no valid/refreshable session exists. */
40
- nonInteractive?: boolean;
53
+ }
54
+ interface AuthResponse {
55
+ accessToken: string;
56
+ refreshToken: string;
57
+ /** Access token lifetime in seconds. */
58
+ expiresIn: number;
59
+ user: AuthUser;
41
60
  }
42
61
  /**
43
62
  * Where the cached session lives.
@@ -105,4 +124,101 @@ export declare function describeAuthService(baseUrl: string, fetchImpl?: typeof
105
124
  * obtained. Callers degrade to not-recording rather than failing the session.
106
125
  */
107
126
  export declare function authenticate(opts?: AuthenticateOptions): Promise<AuthSession | undefined>;
127
+ /**
128
+ * The service has no device endpoints — it predates browser sign-in.
129
+ *
130
+ * Thrown so `bir login` can fall back to the password flow rather than leaving
131
+ * someone stranded on a runner that is newer than the service it talks to.
132
+ */
133
+ export declare class DeviceFlowUnsupported extends Error {
134
+ constructor();
135
+ }
136
+ /** The CLI's view of a started device authorization. */
137
+ export interface DeviceCodeGrant {
138
+ deviceCode: string;
139
+ userCode: string;
140
+ verificationUri: string;
141
+ verificationUriComplete?: string;
142
+ expiresIn: number;
143
+ interval: number;
144
+ }
145
+ /**
146
+ * Start a sign-in. Returns the codes; the caller shows one and polls with the
147
+ * other.
148
+ *
149
+ * `clientName`/`clientVersion` are shown on the approval page so the person can
150
+ * tell their own `bir login` from somebody else's — the one defence a device
151
+ * flow has against being talked into approving a stranger's code.
152
+ */
153
+ export declare function startDeviceCode(authUrl: string, client: {
154
+ name?: string;
155
+ version?: string;
156
+ }, fetchImpl?: typeof fetch): Promise<DeviceCodeGrant>;
157
+ /** The user gave up, or the code died before anyone approved it. */
158
+ export declare class DeviceFlowAborted extends Error {
159
+ readonly reason: "access_denied" | "expired_token" | "timeout" | "cancelled";
160
+ constructor(message: string, reason: "access_denied" | "expired_token" | "timeout" | "cancelled");
161
+ }
162
+ export interface PollOptions {
163
+ interval: number;
164
+ expiresIn: number;
165
+ fetchImpl?: typeof fetch;
166
+ /** Injected so tests run in milliseconds rather than minutes. */
167
+ sleep?: (ms: number) => Promise<void>;
168
+ signal?: AbortSignal;
169
+ now?: () => number;
170
+ }
171
+ /**
172
+ * Poll until the sign-in is approved, refused, or dies.
173
+ *
174
+ * RFC 8628 §3.5's loop, including the part people skip: `slow_down` is not an
175
+ * error, it is the server asking for a longer gap, and the interval must
176
+ * *stay* longer afterwards. Ignoring it turns a slow client into a blocked one.
177
+ */
178
+ export declare function pollDeviceToken(authUrl: string, deviceCode: string, opts: PollOptions): Promise<AuthResponse>;
179
+ /**
180
+ * Open a URL in the user's browser. Best effort, and never fatal.
181
+ *
182
+ * No dependency and no shell: this package ships zero runtime dependencies, and
183
+ * passing a URL through a shell is how a URL becomes a command. The link is
184
+ * printed either way, so a failure here costs nothing.
185
+ */
186
+ export declare function openBrowser(url: string): void;
187
+ export interface DeviceLoginOptions {
188
+ /** `--no-browser`, or a machine where opening one is wrong. */
189
+ openBrowser?: boolean;
190
+ fetchImpl?: typeof fetch;
191
+ sleep?: (ms: number) => Promise<void>;
192
+ signal?: AbortSignal;
193
+ /** Where the human-readable lines go. stderr, so stdout stays parseable. */
194
+ write?: (text: string) => void;
195
+ }
196
+ /**
197
+ * Sign in through the browser and cache the session.
198
+ *
199
+ * Everything a person sees goes to stderr: `bir login`'s stdout is one line
200
+ * that a script may read, and the code block is not it.
201
+ */
202
+ export declare function deviceLogin(authUrl: string, opts?: DeviceLoginOptions): Promise<AuthSession | undefined>;
203
+ /**
204
+ * Sign in with an email and a password.
205
+ *
206
+ * DEPRECATED, and kept for exactly two cases: `bir login --password`, and a
207
+ * runner talking to a service that predates the device endpoints. Both go in
208
+ * the release after this one, and this function goes with them.
209
+ *
210
+ * It cannot work at all for an account created through Google — there is no
211
+ * password on it to send — which is the main reason sign-in moved.
212
+ */
213
+ export declare function legacyPasswordLogin(authUrl: string): Promise<AuthSession | undefined>;
214
+ /**
215
+ * Sign out: revoke the refresh token server-side, then forget it locally.
216
+ *
217
+ * Deleting the file alone left the token live for its full 30 days, which made
218
+ * `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
219
+ * and its failure is not worth blocking on — the local half must happen either
220
+ * way, or "logged out" would be a lie on a machine with no network.
221
+ */
222
+ export declare function logout(authUrl?: string): Promise<void>;
223
+ export {};
108
224
  //# sourceMappingURL=client.d.ts.map
@@ -1,31 +1,48 @@
1
1
  /**
2
- * auth client — ported from RRepeat's `src/auth/client.ts` (Phase 4.1).
2
+ * auth client — the CLI's session with the BaseIn service.
3
3
  *
4
- * Authenticates the CLI user against the BaseIn auth-service and returns a JWT
5
- * session. Every recording call carries that access token; the recording's owner
6
- * is the JWT subject on the server side, which is what makes tenancy work
7
- * without BaseInstRunner knowing anything about it.
4
+ * Every recording call carries an access token; the recording's owner is the
5
+ * JWT subject on the server side, which is what makes tenancy work without
6
+ * BaseInstRunner knowing anything about it.
7
+ *
8
+ * TWO HALVES, AND ONLY ONE OF THEM MAY PROMPT.
9
+ *
10
+ * `authenticate()` is the silent half, and every process calls it — `bir-hooks`
11
+ * at startup, each `bir-proxy`, and the CLI:
8
12
  *
9
- * Flow:
10
13
  * 1. Load the cached session from ~/.baseinstrunner/credentials.json.
11
14
  * 2. If the access token is still valid, reuse it.
12
15
  * 3. Else if a refresh token is present, POST /auth/refresh (silent).
13
- * 4. Else prompt for email + password and POST /auth/login.
14
- * 5. Persist the result (mode 0600) and return it.
16
+ * 4. Else give up and return undefined.
17
+ *
18
+ * It used to have a fifth step — prompt for a password — and that had to go
19
+ * when sign-in moved to the browser. A device flow prints a code and waits up
20
+ * to ten minutes for someone to approve it in a browser; a control server doing
21
+ * that at startup, possibly under a supervisor where nobody can see the code,
22
+ * would hang instead of degrading. So interaction lives in `deviceLogin()`,
23
+ * which only `bir login` calls.
15
24
  *
16
- * WHY IT NEVER `process.exit`s: `bir-proxy` runs inside the host's process tree.
17
- * Failing to authenticate must degrade to not-recording (§10), not kill a server
18
- * the host is waiting on. Callers get `undefined` and decide.
25
+ * `deviceLogin()` is the interactive half: the OAuth 2.0 device authorization
26
+ * grant (RFC 8628). It mints a code, prints a link, and polls until the person
27
+ * approves in the console. It never handles a password, which is what makes it
28
+ * work for Google-only accounts — they have no password to type.
29
+ *
30
+ * WHY NOTHING HERE `process.exit`s: `bir-proxy` runs inside the host's process
31
+ * tree. Failing to authenticate must degrade to not-recording (§10), not kill a
32
+ * server the host is waiting on. Callers get `undefined` and decide.
19
33
  *
20
34
  * Config:
21
35
  * BIR_AUTH_URL base URL of the BaseIn auth-service (required).
22
36
  * BIR_AUTH_DISABLE "1" to skip auth entirely (local dev / CI).
23
37
  */
38
+ import { spawn } from "node:child_process";
24
39
  import { createInterface } from "node:readline";
25
40
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
41
+ import { hostname } from "node:os";
26
42
  import { configDir } from "../control/paths.js";
27
43
  import { join } from "node:path";
28
44
  import { logLine, errText } from "../util/log.js";
45
+ import { packageVersion } from "../util/version.js";
29
46
  /**
30
47
  * Where the cached session lives.
31
48
  *
@@ -323,10 +340,212 @@ export async function authenticate(opts) {
323
340
  return refreshed;
324
341
  }
325
342
  }
326
- if (opts?.nonInteractive || !process.stdin.isTTY) {
327
- logLine("auth.no_session", {
328
- why: "no valid session and no terminal to log in on — run `bir login`",
343
+ // No session, and this function does not make one. Signing in is a browser
344
+ // round trip that can take minutes; a proxy or control server blocking on it
345
+ // would hang the host instead of degrading to not-recording.
346
+ logLine("auth.no_session", {
347
+ why: "no valid session — run `bir login`",
348
+ });
349
+ return undefined;
350
+ }
351
+ // ── Device authorization grant (RFC 8628) ──────────────────────────────────
352
+ /**
353
+ * The service has no device endpoints — it predates browser sign-in.
354
+ *
355
+ * Thrown so `bir login` can fall back to the password flow rather than leaving
356
+ * someone stranded on a runner that is newer than the service it talks to.
357
+ */
358
+ export class DeviceFlowUnsupported extends Error {
359
+ constructor() {
360
+ super("this BaseIn service does not offer browser sign-in");
361
+ this.name = "DeviceFlowUnsupported";
362
+ }
363
+ }
364
+ /**
365
+ * Start a sign-in. Returns the codes; the caller shows one and polls with the
366
+ * other.
367
+ *
368
+ * `clientName`/`clientVersion` are shown on the approval page so the person can
369
+ * tell their own `bir login` from somebody else's — the one defence a device
370
+ * flow has against being talked into approving a stranger's code.
371
+ */
372
+ export async function startDeviceCode(authUrl, client, fetchImpl = fetch) {
373
+ const res = await fetchImpl(`${authUrl}/auth/device/code`, {
374
+ method: "POST",
375
+ headers: { "content-type": "application/json" },
376
+ body: JSON.stringify({ clientName: client.name, clientVersion: client.version }),
377
+ });
378
+ // A service without the endpoint 404s. Anything else is a real failure.
379
+ if (res.status === 404)
380
+ throw new DeviceFlowUnsupported();
381
+ if (!res.ok) {
382
+ const text = await res.text().catch(() => "");
383
+ const isHtml = (res.headers.get("content-type") ?? "").includes("html") || /^\s*<(!doctype|html)/i.test(text);
384
+ throw new Error(`HTTP ${res.status}${isHtml
385
+ ? " — the answer was a web page, not JSON: this URL is a website, not the BaseIn API"
386
+ : text
387
+ ? ` — ${text.slice(0, 300)}`
388
+ : ""}`);
389
+ }
390
+ return (await res.json());
391
+ }
392
+ /** The user gave up, or the code died before anyone approved it. */
393
+ export class DeviceFlowAborted extends Error {
394
+ reason;
395
+ constructor(message, reason) {
396
+ super(message);
397
+ this.reason = reason;
398
+ this.name = "DeviceFlowAborted";
399
+ }
400
+ }
401
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
402
+ /**
403
+ * Poll until the sign-in is approved, refused, or dies.
404
+ *
405
+ * RFC 8628 §3.5's loop, including the part people skip: `slow_down` is not an
406
+ * error, it is the server asking for a longer gap, and the interval must
407
+ * *stay* longer afterwards. Ignoring it turns a slow client into a blocked one.
408
+ */
409
+ export async function pollDeviceToken(authUrl, deviceCode, opts) {
410
+ const fetchImpl = opts.fetchImpl ?? fetch;
411
+ const sleep = opts.sleep ?? defaultSleep;
412
+ const now = opts.now ?? Date.now;
413
+ const deadline = now() + opts.expiresIn * 1000;
414
+ let interval = opts.interval;
415
+ for (;;) {
416
+ if (opts.signal?.aborted) {
417
+ throw new DeviceFlowAborted("sign-in cancelled", "cancelled");
418
+ }
419
+ if (now() >= deadline) {
420
+ throw new DeviceFlowAborted("the code expired before it was approved — run `bir login` again", "timeout");
421
+ }
422
+ await sleep(interval * 1000);
423
+ const res = await fetchImpl(`${authUrl}/auth/device/token`, {
424
+ method: "POST",
425
+ headers: { "content-type": "application/json" },
426
+ body: JSON.stringify({ deviceCode }),
427
+ });
428
+ if (res.ok)
429
+ return (await res.json());
430
+ const body = (await res.json().catch(() => ({})));
431
+ switch (body.error) {
432
+ case "authorization_pending":
433
+ continue;
434
+ case "slow_down":
435
+ // Adopt the server's number when it sends one; otherwise back off the
436
+ // way the RFC suggests. Either way the wider gap persists.
437
+ interval = typeof body.interval === "number" ? body.interval : interval + 5;
438
+ continue;
439
+ case "access_denied":
440
+ throw new DeviceFlowAborted("the sign-in was refused in the browser", "access_denied");
441
+ case "expired_token":
442
+ throw new DeviceFlowAborted("the code expired before it was approved — run `bir login` again", "expired_token");
443
+ default:
444
+ throw new Error(`sign-in failed: ${body.error ?? `HTTP ${res.status}`}`);
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * Open a URL in the user's browser. Best effort, and never fatal.
450
+ *
451
+ * No dependency and no shell: this package ships zero runtime dependencies, and
452
+ * passing a URL through a shell is how a URL becomes a command. The link is
453
+ * printed either way, so a failure here costs nothing.
454
+ */
455
+ export function openBrowser(url) {
456
+ try {
457
+ const [command, args] = process.platform === "win32"
458
+ ? // The empty string is `start`'s title argument. Without it, a quoted
459
+ // URL is taken *as* the title and nothing opens.
460
+ ["cmd", ["/c", "start", "", url]]
461
+ : process.platform === "darwin"
462
+ ? ["open", [url]]
463
+ : ["xdg-open", [url]];
464
+ const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
465
+ child.on("error", () => {
466
+ /* no browser here; the printed link is the fallback */
329
467
  });
468
+ child.unref();
469
+ }
470
+ catch {
471
+ /* see above */
472
+ }
473
+ }
474
+ /**
475
+ * Should we try to open a browser at all?
476
+ *
477
+ * Over SSH the browser would open on the wrong machine, and with no terminal
478
+ * there is nobody watching. In both cases the link still gets printed — it can
479
+ * be opened on a phone, which is the point of this grant.
480
+ */
481
+ function shouldOpenBrowser() {
482
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
483
+ return false;
484
+ return Boolean(process.stderr.isTTY);
485
+ }
486
+ /**
487
+ * Sign in through the browser and cache the session.
488
+ *
489
+ * Everything a person sees goes to stderr: `bir login`'s stdout is one line
490
+ * that a script may read, and the code block is not it.
491
+ */
492
+ export async function deviceLogin(authUrl, opts = {}) {
493
+ const write = opts.write ?? ((text) => process.stderr.write(text));
494
+ const grant = await startDeviceCode(authUrl, { name: clientName(), version: packageVersion() }, opts.fetchImpl);
495
+ const link = grant.verificationUriComplete ?? grant.verificationUri;
496
+ write(`\n[bir] Sign in to ${authUrl}\n\n`);
497
+ write(` Open ${link}\n`);
498
+ write(` Code ${grant.userCode}\n\n`);
499
+ const wantsBrowser = opts.openBrowser ?? shouldOpenBrowser();
500
+ if (wantsBrowser) {
501
+ write(" Opening your browser…\n");
502
+ openBrowser(link);
503
+ }
504
+ else {
505
+ write(" Open that link on any device — a phone is fine.\n");
506
+ }
507
+ write(" Waiting for approval… (Ctrl-C to cancel)\n\n");
508
+ logLine("auth.device_started", { url: authUrl, expiresIn: grant.expiresIn });
509
+ const res = await pollDeviceToken(authUrl, grant.deviceCode, {
510
+ interval: grant.interval,
511
+ expiresIn: grant.expiresIn,
512
+ fetchImpl: opts.fetchImpl,
513
+ sleep: opts.sleep,
514
+ signal: opts.signal,
515
+ });
516
+ const session = toSession(res);
517
+ saveCredentials(session);
518
+ return session;
519
+ }
520
+ /**
521
+ * A name for this machine, shown on the approval page.
522
+ *
523
+ * This is the string someone reads to decide whether the request is theirs, so
524
+ * it names the tool and the host. Never fatal: an unnamed client still works,
525
+ * it just tells the approver less.
526
+ */
527
+ function clientName() {
528
+ try {
529
+ return `bir on ${hostname()}`;
530
+ }
531
+ catch {
532
+ return "bir";
533
+ }
534
+ }
535
+ // ── The password flow, on its way out ──────────────────────────────────────
536
+ /**
537
+ * Sign in with an email and a password.
538
+ *
539
+ * DEPRECATED, and kept for exactly two cases: `bir login --password`, and a
540
+ * runner talking to a service that predates the device endpoints. Both go in
541
+ * the release after this one, and this function goes with them.
542
+ *
543
+ * It cannot work at all for an account created through Google — there is no
544
+ * password on it to send — which is the main reason sign-in moved.
545
+ */
546
+ export async function legacyPasswordLogin(authUrl) {
547
+ if (!process.stdin.isTTY) {
548
+ logLine("auth.no_session", { why: "no terminal to read a password on" });
330
549
  return undefined;
331
550
  }
332
551
  // Never send a password to something that is not the service. The website
@@ -353,4 +572,31 @@ export async function authenticate(opts) {
353
572
  return undefined;
354
573
  }
355
574
  }
575
+ /**
576
+ * Sign out: revoke the refresh token server-side, then forget it locally.
577
+ *
578
+ * Deleting the file alone left the token live for its full 30 days, which made
579
+ * `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
580
+ * and its failure is not worth blocking on — the local half must happen either
581
+ * way, or "logged out" would be a lie on a machine with no network.
582
+ */
583
+ export async function logout(authUrl) {
584
+ const cached = loadCredentials();
585
+ if (authUrl && cached?.refreshToken) {
586
+ try {
587
+ await fetch(`${authUrl}/auth/logout`, {
588
+ method: "POST",
589
+ headers: { "content-type": "application/json" },
590
+ body: JSON.stringify({ refreshToken: cached.refreshToken }),
591
+ });
592
+ }
593
+ catch (err) {
594
+ logLine("auth.logout_remote_failed", {
595
+ error: errText(err),
596
+ why: "the local credentials are cleared regardless",
597
+ });
598
+ }
599
+ }
600
+ clearCredentials();
601
+ }
356
602
  //# sourceMappingURL=client.js.map
package/dist/bin/bir.js CHANGED
@@ -28,7 +28,7 @@ import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry,
28
28
  import { isRemote, resolveServers } from "../config/resolve.js";
29
29
  import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
30
30
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
31
- import { AUTH_URL_HINT, authenticate, clearCredentials, describeAuthService, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
31
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
32
32
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
33
33
  import { errText } from "../util/log.js";
34
34
  import { packageVersion } from "../util/version.js";
@@ -45,8 +45,8 @@ Commands:
45
45
  status what is installed for this directory
46
46
  doctor is it actually working right now?
47
47
  wrap print a proxied entry for one server (any MCP client)
48
- login sign in to the BaseIn service
49
- logout forget the cached credentials
48
+ login sign in to the BaseIn service (opens your browser)
49
+ logout revoke this machine's session and forget it
50
50
 
51
51
  scenario list recorded runs and their calculated scenarios
52
52
  scenario show <runId> a run's scenario: intent, params, steps
@@ -65,7 +65,9 @@ Options:
65
65
  --replay install/remove the scenario server, enabling calculated replay
66
66
  --port <n> control-server port to write into the hook URLs (default ${DEFAULT_CONTROL_PORT})
67
67
  --json machine-readable output for status / doctor
68
- --dry replay against recorded outputs only; run no real tools`);
68
+ --dry replay against recorded outputs only; run no real tools
69
+ --no-browser login: print the link and code, open nothing (SSH, headless)
70
+ --password login: use the old email/password prompt (deprecated)`);
69
71
  process.exit(code);
70
72
  }
71
73
  function parseArgs(argv) {
@@ -83,6 +85,7 @@ function parseArgs(argv) {
83
85
  positionals: [],
84
86
  dry: false,
85
87
  force: false,
88
+ password: false,
86
89
  };
87
90
  for (let i = 1; i < argv.length; i += 1) {
88
91
  const arg = argv[i];
@@ -106,6 +109,12 @@ function parseArgs(argv) {
106
109
  case "--force":
107
110
  args.force = true;
108
111
  break;
112
+ case "--password":
113
+ args.password = true;
114
+ break;
115
+ case "--no-browser":
116
+ args.browser = false;
117
+ break;
109
118
  case "--config":
110
119
  args.configPath = argv[++i];
111
120
  break;
@@ -875,30 +884,63 @@ async function main() {
875
884
  case "replay":
876
885
  return replayCommand(args);
877
886
  case "login": {
878
- // Settle the URL the way every other command does, then refuse to ask for
879
- // a password when what is there is not the service. "Signed in" must mean
880
- // the service at BIR_AUTH_URL accepted these credentials — not that a
881
- // cached token exists for some other address.
887
+ // Settle the URL the way every other command does, then refuse to go on
888
+ // when what is there is not the service. "Signed in" must mean the service
889
+ // at BIR_AUTH_URL issued this session — not that a cached token exists for
890
+ // some other address.
882
891
  const configured = resolveAuthUrl();
883
- const baseUrl = configured ? await normalizeAuthUrl(configured) : "";
884
- if (baseUrl) {
885
- const problem = await describeAuthService(baseUrl);
886
- if (problem) {
887
- process.stderr.write(`[bir] ${baseUrl} is not a BaseIn service: ${problem}\n`);
888
- process.stderr.write(`[bir] ${AUTH_URL_HINT}\n`);
889
- return 1;
892
+ if (!configured) {
893
+ process.stderr.write("[bir] BIR_AUTH_URL is not set — there is nothing to sign in to.\n");
894
+ process.stderr.write(`[bir] ${AUTH_URL_HINT}\n`);
895
+ return 1;
896
+ }
897
+ const baseUrl = await normalizeAuthUrl(configured);
898
+ const problem = await describeAuthService(baseUrl);
899
+ if (problem) {
900
+ process.stderr.write(`[bir] ${baseUrl} is not a BaseIn service: ${problem}\n`);
901
+ process.stderr.write(`[bir] ${AUTH_URL_HINT}\n`);
902
+ return 1;
903
+ }
904
+ let session;
905
+ if (args.password) {
906
+ process.stderr.write("[bir] --password is deprecated and will be removed in the next release. " +
907
+ "Browser sign-in works for every account, including Google-only ones.\n");
908
+ session = await legacyPasswordLogin(baseUrl);
909
+ }
910
+ else {
911
+ try {
912
+ session = await deviceLogin(baseUrl, { openBrowser: args.browser });
913
+ }
914
+ catch (err) {
915
+ if (err instanceof DeviceFlowUnsupported) {
916
+ // A runner newer than its service. Falling back beats stranding
917
+ // someone on an install that used to work.
918
+ process.stderr.write("[bir] this service does not offer browser sign-in yet; " +
919
+ "falling back to email and password.\n");
920
+ session = await legacyPasswordLogin(baseUrl);
921
+ }
922
+ else if (err instanceof DeviceFlowAborted) {
923
+ process.stderr.write(`[bir] ${err.message}\n`);
924
+ return 1;
925
+ }
926
+ else {
927
+ throw err;
928
+ }
890
929
  }
891
930
  }
892
- const session = await authenticate(baseUrl ? { authUrl: baseUrl } : undefined);
893
931
  if (!session)
894
932
  return 1;
895
933
  out(`Signed in to ${baseUrl} as ${session.user.email}.`);
896
934
  return 0;
897
935
  }
898
- case "logout":
899
- clearCredentials();
936
+ case "logout": {
937
+ // Revoke server-side before forgetting locally, so a stolen credentials
938
+ // file is dead rather than merely absent from this machine.
939
+ const configured = resolveAuthUrl();
940
+ await logout(configured ? await normalizeAuthUrl(configured) : undefined);
900
941
  out("Logged out — cached credentials cleared.");
901
942
  return 0;
943
+ }
902
944
  default:
903
945
  usage(2);
904
946
  }
@@ -214,10 +214,11 @@ export class ProxySession {
214
214
  logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set" });
215
215
  return new NullRecorder();
216
216
  }
217
- // Non-interactive: a proxy runs inside the host's process tree with its stdio
218
- // bound to the JSON-RPC stream. There is nowhere to prompt, and blocking on
219
- // one would hang the host's server startup.
220
- const session = await authenticate({ authUrl: baseUrl, nonInteractive: true });
217
+ // A proxy runs inside the host's process tree with its stdio bound to the
218
+ // JSON-RPC stream: there is nowhere to prompt, and blocking on one would
219
+ // hang the host's server startup. `authenticate` never interacts — signing
220
+ // in lives in `bir login` alone — so that is guaranteed here, not requested.
221
+ const session = await authenticate({ authUrl: baseUrl });
221
222
  if (!session) {
222
223
  logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
223
224
  return new NullRecorder();
@@ -44,7 +44,7 @@ honest ceiling rather than a bug.
44
44
  export BIR_AUTH_URL=https://your-basein-service
45
45
  npm install && npm run build
46
46
  node dist/bin/bir.js install # or `bir install` once linked/published
47
- bir login
47
+ bir login # prints a link and a code; approve in the browser
48
48
  bir-hooks # leave running in its own terminal
49
49
  ```
50
50