cleargate 0.3.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.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { Command } from "commander";
14
14
  // package.json
15
15
  var package_default = {
16
16
  name: "cleargate",
17
- version: "0.3.0",
17
+ version: "0.4.0",
18
18
  private: false,
19
19
  type: "module",
20
20
  description: "Planning framework for Claude Code agents \u2014 sprint/epic/story protocol, four-agent loop (architect/developer/qa/reporter), Karpathy-style awareness wiki.",
@@ -318,13 +318,294 @@ function matchesGlob(filePath, glob) {
318
318
 
319
319
  // src/commands/join.ts
320
320
  import * as os from "os";
321
+
322
+ // src/auth/identity-flow.ts
323
+ import * as readline from "readline";
324
+ var IdentityFlowError = class extends Error {
325
+ constructor(code, message) {
326
+ super(message ?? code);
327
+ this.code = code;
328
+ this.name = "IdentityFlowError";
329
+ }
330
+ code;
331
+ };
332
+ var DeviceFlowError = class extends Error {
333
+ constructor(code, message) {
334
+ super(message ?? code);
335
+ this.code = code;
336
+ this.name = "DeviceFlowError";
337
+ }
338
+ code;
339
+ };
340
+ async function pickProvider(opts) {
341
+ const available = opts.available ?? ["github", "email"];
342
+ if (opts.flag !== void 0) {
343
+ const flagLower = opts.flag.toLowerCase();
344
+ if (!available.includes(flagLower)) {
345
+ throw new IdentityFlowError(
346
+ "provider_unknown",
347
+ `cleargate: unknown provider '${opts.flag}'. Available: ${available.join(", ")}`
348
+ );
349
+ }
350
+ return flagLower;
351
+ }
352
+ if (!opts.isTTY) {
353
+ throw new IdentityFlowError(
354
+ "provider_required",
355
+ "cleargate: --auth required in non-interactive mode"
356
+ );
357
+ }
358
+ if (available.length === 1) {
359
+ return available[0];
360
+ }
361
+ return promptPicker(available, opts);
362
+ }
363
+ var PROVIDER_LABELS = {
364
+ github: "GitHub OAuth",
365
+ email: "Email magic-link"
366
+ };
367
+ async function promptPicker(options, { stdin, stdout } = {}) {
368
+ const write = stdout ?? ((s) => process.stdout.write(s));
369
+ write("How would you like to verify your email?\n");
370
+ options.forEach((p, i) => {
371
+ write(` ${i + 1}. ${PROVIDER_LABELS[p]}
372
+ `);
373
+ });
374
+ write(`Choice [1-${options.length}]: `);
375
+ const inputStream = stdin ?? process.stdin;
376
+ return new Promise((resolve14, reject) => {
377
+ let settled = false;
378
+ const rl = readline.createInterface({
379
+ input: inputStream,
380
+ output: void 0,
381
+ terminal: false
382
+ });
383
+ rl.once("line", (line) => {
384
+ settled = true;
385
+ rl.close();
386
+ const idx = parseInt(line.trim(), 10) - 1;
387
+ if (isNaN(idx) || idx < 0 || idx >= options.length) {
388
+ reject(
389
+ new IdentityFlowError(
390
+ "invalid_choice",
391
+ `cleargate: invalid choice '${line.trim()}'. Enter a number between 1 and ${options.length}.`
392
+ )
393
+ );
394
+ return;
395
+ }
396
+ resolve14(options[idx]);
397
+ });
398
+ rl.once("error", (err) => {
399
+ if (!settled) {
400
+ settled = true;
401
+ reject(err);
402
+ }
403
+ });
404
+ rl.once("close", () => {
405
+ if (!settled) {
406
+ settled = true;
407
+ reject(new IdentityFlowError("provider_required", "cleargate: no provider selected"));
408
+ }
409
+ });
410
+ });
411
+ }
412
+ function defaultSleep(ms) {
413
+ return new Promise((resolve14) => setTimeout(resolve14, ms));
414
+ }
415
+ async function startDeviceFlow(opts) {
416
+ const sleepFn = opts.sleepFn ?? defaultSleep;
417
+ let currentIntervalMs = opts.intervalOverrideMs !== void 0 ? opts.intervalOverrideMs : Math.max(opts.interval, 5) * 1e3;
418
+ const expiresAtMs = Date.now() + opts.expiresIn * 1e3;
419
+ const deadline = expiresAtMs + (opts.deadlineGraceMs ?? 1e4);
420
+ while (Date.now() < deadline) {
421
+ await sleepFn(currentIntervalMs);
422
+ let pollRes;
423
+ try {
424
+ pollRes = await opts.fetchPoll(opts.deviceCode);
425
+ } catch {
426
+ throw new DeviceFlowError("unreachable");
427
+ }
428
+ if (pollRes.status === 403) {
429
+ let body2 = {};
430
+ try {
431
+ body2 = await pollRes.json();
432
+ } catch {
433
+ }
434
+ if (body2["error"] === "access_denied") {
435
+ throw new DeviceFlowError("access_denied");
436
+ }
437
+ throw new DeviceFlowError("not_admin");
438
+ }
439
+ if (pollRes.status === 410) {
440
+ throw new DeviceFlowError("expired_token");
441
+ }
442
+ if (!pollRes.status || pollRes.status < 200 || pollRes.status >= 300) {
443
+ if (pollRes.status >= 500 || pollRes.status < 100) {
444
+ throw new DeviceFlowError("server_error");
445
+ }
446
+ }
447
+ let body;
448
+ try {
449
+ body = await pollRes.json();
450
+ } catch {
451
+ throw new DeviceFlowError("server_error");
452
+ }
453
+ const errorField = body["error"];
454
+ if (typeof errorField === "string") {
455
+ if (errorField === "authorization_pending") {
456
+ continue;
457
+ }
458
+ if (errorField === "slow_down") {
459
+ const retryAfter = body["interval"];
460
+ if (typeof retryAfter === "number") {
461
+ const bumped = retryAfter * 1e3;
462
+ if (bumped > currentIntervalMs) {
463
+ currentIntervalMs = bumped;
464
+ }
465
+ } else {
466
+ currentIntervalMs += 5e3;
467
+ }
468
+ continue;
469
+ }
470
+ if (errorField === "access_denied") {
471
+ throw new DeviceFlowError("access_denied");
472
+ }
473
+ if (errorField === "expired_token") {
474
+ throw new DeviceFlowError("expired_token");
475
+ }
476
+ throw new DeviceFlowError("server_error");
477
+ }
478
+ if (body["pending"] === true) {
479
+ const shouldApplyBump = opts.sleepFn !== void 0 || opts.intervalOverrideMs === void 0;
480
+ if (shouldApplyBump && typeof body["retry_after"] === "number") {
481
+ const bumped = body["retry_after"] * 1e3;
482
+ if (bumped > currentIntervalMs) {
483
+ currentIntervalMs = bumped;
484
+ }
485
+ }
486
+ continue;
487
+ }
488
+ if (typeof body["access_token"] === "string") {
489
+ return { accessToken: body["access_token"] };
490
+ }
491
+ if (typeof body["admin_token"] === "string") {
492
+ return { accessToken: body["admin_token"] };
493
+ }
494
+ throw new DeviceFlowError("server_error");
495
+ }
496
+ throw new DeviceFlowError("timeout");
497
+ }
498
+ function mapProviderError(httpStatus, errorCode, retryAfterSeconds) {
499
+ if (httpStatus === 400) {
500
+ switch (errorCode) {
501
+ case "provider_not_allowed":
502
+ return {
503
+ message: "cleargate: this invite requires a different provider \u2014 re-run with `--auth <pinned>`",
504
+ exitCode: 9,
505
+ retryable: true
506
+ };
507
+ case "provider_unknown":
508
+ return {
509
+ message: "cleargate: server does not have that provider registered \u2014 contact the project admin",
510
+ exitCode: 9,
511
+ retryable: false
512
+ };
513
+ case "identity_proof_required":
514
+ return {
515
+ message: "cleargate: this CLI is out of date \u2014 please upgrade and retry (`npm i -g cleargate@latest`)",
516
+ exitCode: 11,
517
+ retryable: false
518
+ };
519
+ default:
520
+ return {
521
+ message: "cleargate: invalid request to server (please file a bug)",
522
+ exitCode: 7,
523
+ retryable: false
524
+ };
525
+ }
526
+ }
527
+ if (httpStatus === 403 && errorCode === "email_mismatch") {
528
+ return {
529
+ message: "cleargate: verified email does not match the invitee \u2014 ask your admin to re-issue the invite",
530
+ exitCode: 10,
531
+ retryable: false
532
+ };
533
+ }
534
+ if (httpStatus === 404) {
535
+ return {
536
+ message: "cleargate: invite not found",
537
+ exitCode: 4,
538
+ retryable: false
539
+ };
540
+ }
541
+ if (httpStatus === 410) {
542
+ switch (errorCode) {
543
+ case "invite_expired":
544
+ return {
545
+ message: "cleargate: invite expired. Request a new invite",
546
+ exitCode: 3,
547
+ retryable: false
548
+ };
549
+ case "invite_already_consumed":
550
+ return {
551
+ message: "cleargate: invite already consumed. Request a new invite",
552
+ exitCode: 3,
553
+ retryable: false
554
+ };
555
+ case "challenge_expired":
556
+ return {
557
+ message: "cleargate: code expired. Re-run `cleargate join <url>` to start over",
558
+ exitCode: 3,
559
+ retryable: false
560
+ };
561
+ default:
562
+ return {
563
+ message: "cleargate: invite no longer valid. Request a new invite",
564
+ exitCode: 3,
565
+ retryable: false
566
+ };
567
+ }
568
+ }
569
+ if (httpStatus === 429) {
570
+ const retryHint = retryAfterSeconds !== void 0 ? `${retryAfterSeconds}` : "900";
571
+ return {
572
+ message: `cleargate: too many requests. Retry after ${retryHint}s`,
573
+ exitCode: 8,
574
+ retryable: true
575
+ };
576
+ }
577
+ if (httpStatus === 502 && errorCode === "provider_error") {
578
+ return {
579
+ message: "cleargate: code didn't match. Try again, or restart with `cleargate join <url>`",
580
+ exitCode: 12,
581
+ retryable: true
582
+ };
583
+ }
584
+ if (httpStatus >= 500) {
585
+ return {
586
+ message: `cleargate: server error ${httpStatus}`,
587
+ exitCode: 6,
588
+ retryable: false
589
+ };
590
+ }
591
+ return {
592
+ message: `cleargate: unexpected error ${httpStatus} ${errorCode}`,
593
+ exitCode: 7,
594
+ retryable: false
595
+ };
596
+ }
597
+
598
+ // src/commands/join.ts
599
+ import * as readline2 from "readline";
321
600
  var UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
601
+ var GITHUB_DEVICE_FLOW_URL = "https://github.com/login/oauth/access_token";
322
602
  async function joinHandler(opts) {
323
603
  const fetchFn = opts.fetch ?? globalThis.fetch;
324
604
  const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
325
605
  const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
326
606
  const exit = opts.exit ?? ((c) => process.exit(c));
327
607
  const hostname3 = opts.hostname ?? (() => os.hostname());
608
+ const isTTY = opts.isTTY ?? process.stdin.isTTY === true;
328
609
  let token;
329
610
  let baseUrl;
330
611
  try {
@@ -355,60 +636,309 @@ async function joinHandler(opts) {
355
636
  exit(5);
356
637
  return;
357
638
  }
358
- let response;
359
- try {
360
- response = await fetchFn(`${baseUrl}/join/${token}`, { method: "POST" });
361
- } catch (err) {
362
- stderr(
363
- `cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
364
- `
365
- );
366
- exit(2);
639
+ if (opts.nonInteractive && !opts.auth) {
640
+ stderr("cleargate: --auth required in non-interactive mode\n");
641
+ exit(1);
367
642
  return;
368
643
  }
369
- if (response.status === 410) {
370
- const body = await response.json().catch(() => ({}));
371
- if (body.error === "invite_expired") {
372
- stderr("cleargate: invite expired. Request a new invite.\n");
373
- } else {
374
- stderr("cleargate: invite already consumed. Request a new invite.\n");
375
- }
376
- exit(3);
644
+ if (opts.nonInteractive && opts.auth === "email" && !opts.code) {
645
+ stderr("cleargate: --code required for email provider in non-interactive mode\n");
646
+ exit(1);
377
647
  return;
378
648
  }
379
- if (response.status === 404) {
380
- stderr("cleargate: invite not found.\n");
381
- exit(4);
649
+ if (opts.nonInteractive && opts.auth === "github") {
650
+ stderr("cleargate: GitHub auth requires browser interaction; use `--auth email` for non-interactive flows\n");
651
+ exit(1);
382
652
  return;
383
653
  }
384
- if (response.status === 429) {
385
- const retry = response.headers.get("retry-after") ?? "900";
386
- stderr(`cleargate: too many requests. Retry after ${retry}s.
654
+ let provider;
655
+ try {
656
+ provider = await pickProvider({
657
+ flag: opts.auth,
658
+ isTTY: !opts.nonInteractive && isTTY,
659
+ available: ["github", "email"],
660
+ stdin: opts.stdin,
661
+ stdout
662
+ });
663
+ } catch (err) {
664
+ if (err instanceof IdentityFlowError) {
665
+ stderr(`${err.message}
387
666
  `);
388
- exit(8);
389
- return;
667
+ exit(1);
668
+ return;
669
+ }
670
+ throw err;
390
671
  }
391
- if (response.status >= 500) {
392
- stderr(`cleargate: server error ${response.status}.
393
- `);
394
- exit(6);
672
+ let challengeRes;
673
+ try {
674
+ challengeRes = await fetchFn(`${baseUrl}/join/${token}/challenge`, {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
677
+ body: JSON.stringify({ provider })
678
+ });
679
+ } catch (err) {
680
+ stderr(
681
+ `cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
682
+ `
683
+ );
684
+ exit(2);
395
685
  return;
396
686
  }
397
- if (!response.ok) {
398
- stderr(`cleargate: unexpected status ${response.status}.
687
+ if (!challengeRes.ok) {
688
+ const body = await challengeRes.json().catch(() => ({}));
689
+ const { message, exitCode } = mapProviderError(
690
+ challengeRes.status,
691
+ body.error ?? "",
692
+ parseRetryAfter(challengeRes)
693
+ );
694
+ stderr(`${message}
399
695
  `);
400
- exit(7);
696
+ exit(exitCode);
401
697
  return;
402
698
  }
403
- let rawBody;
699
+ let challengeBody;
404
700
  try {
405
- rawBody = await response.json();
701
+ challengeBody = await challengeRes.json();
406
702
  } catch {
407
703
  stderr("cleargate: server returned non-JSON response.\n");
408
704
  exit(7);
409
705
  return;
410
706
  }
411
- const b = rawBody;
707
+ const challengeId = challengeBody.challenge_id;
708
+ const clientHints = challengeBody.client_hints;
709
+ let completeRawBody;
710
+ if (provider === "github") {
711
+ const deviceCode = clientHints["device_code"];
712
+ const userCode = clientHints["user_code"];
713
+ const verificationUri = clientHints["verification_uri"];
714
+ const expiresIn = typeof clientHints["expires_in"] === "number" ? clientHints["expires_in"] : 900;
715
+ const interval = typeof clientHints["interval"] === "number" ? clientHints["interval"] : 5;
716
+ stdout(`Open the following URL in your browser and enter the code:
717
+ `);
718
+ stdout(` URL: ${verificationUri}
719
+ `);
720
+ stdout(` Code: ${userCode}
721
+ `);
722
+ stdout(` (Code expires in ${Math.floor(expiresIn / 60)} minutes)
723
+ `);
724
+ stdout("Waiting for authorization...\n");
725
+ let accessToken;
726
+ try {
727
+ const result = await startDeviceFlow({
728
+ deviceCode,
729
+ interval,
730
+ expiresIn,
731
+ fetchPoll: async (dc) => {
732
+ const res = await fetchFn(GITHUB_DEVICE_FLOW_URL, {
733
+ method: "POST",
734
+ headers: {
735
+ Accept: "application/json",
736
+ "Content-Type": "application/json"
737
+ },
738
+ body: JSON.stringify({
739
+ device_code: dc,
740
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
741
+ })
742
+ });
743
+ return {
744
+ status: res.status,
745
+ json: () => res.json()
746
+ };
747
+ },
748
+ ...opts.sleepFn !== void 0 ? { sleepFn: opts.sleepFn } : {},
749
+ ...opts.intervalOverrideMs !== void 0 ? { intervalOverrideMs: opts.intervalOverrideMs } : {}
750
+ });
751
+ accessToken = result.accessToken;
752
+ } catch (err) {
753
+ if (err instanceof DeviceFlowError) {
754
+ switch (err.code) {
755
+ case "access_denied":
756
+ stderr("cleargate: access denied \u2014 you declined authorization in the browser.\n");
757
+ exit(5);
758
+ return;
759
+ case "expired_token":
760
+ stderr("cleargate: device code expired \u2014 please re-run `cleargate join <url>`.\n");
761
+ exit(5);
762
+ return;
763
+ case "unreachable":
764
+ stderr("cleargate: cannot reach GitHub. Check your connection and retry.\n");
765
+ exit(2);
766
+ return;
767
+ default:
768
+ stderr(`cleargate: GitHub device flow error: ${err.code}
769
+ `);
770
+ exit(6);
771
+ return;
772
+ }
773
+ }
774
+ stderr("cleargate: unexpected error during GitHub device flow\n");
775
+ exit(6);
776
+ return;
777
+ }
778
+ let completeRes;
779
+ try {
780
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
781
+ method: "POST",
782
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
783
+ body: JSON.stringify({ challenge_id: challengeId, proof: { access_token: accessToken } })
784
+ });
785
+ } catch (err) {
786
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
787
+ `);
788
+ exit(2);
789
+ return;
790
+ }
791
+ if (!completeRes.ok) {
792
+ const body = await completeRes.json().catch(() => ({}));
793
+ const { message, exitCode } = mapProviderError(
794
+ completeRes.status,
795
+ body.error ?? "",
796
+ parseRetryAfter(completeRes)
797
+ );
798
+ stderr(`${message}
799
+ `);
800
+ exit(exitCode);
801
+ return;
802
+ }
803
+ try {
804
+ completeRawBody = await completeRes.json();
805
+ } catch {
806
+ stderr("cleargate: server returned non-JSON response.\n");
807
+ exit(7);
808
+ return;
809
+ }
810
+ } else {
811
+ const sentTo = typeof clientHints["sent_to"] === "string" ? clientHints["sent_to"] : "(unknown)";
812
+ const maxRetries = 3;
813
+ stdout(`We sent a 6-digit code to ${sentTo}.
814
+ `);
815
+ if (opts.code !== void 0) {
816
+ let completeRes;
817
+ try {
818
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
819
+ method: "POST",
820
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
821
+ body: JSON.stringify({ challenge_id: challengeId, proof: { code: opts.code } })
822
+ });
823
+ } catch (err) {
824
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
825
+ `);
826
+ exit(2);
827
+ return;
828
+ }
829
+ if (!completeRes.ok) {
830
+ const body = await completeRes.json().catch(() => ({}));
831
+ const { message, exitCode } = mapProviderError(
832
+ completeRes.status,
833
+ body.error ?? "",
834
+ parseRetryAfter(completeRes)
835
+ );
836
+ stderr(`${message}
837
+ `);
838
+ exit(exitCode);
839
+ return;
840
+ }
841
+ try {
842
+ completeRawBody = await completeRes.json();
843
+ } catch {
844
+ stderr("cleargate: server returned non-JSON response.\n");
845
+ exit(7);
846
+ return;
847
+ }
848
+ } else {
849
+ let readNextLine2 = function() {
850
+ if (lineQueue.length > 0) return Promise.resolve(lineQueue.shift());
851
+ if (rlClosed) return Promise.resolve("");
852
+ return new Promise((resolve14) => {
853
+ lineWaiters.push(resolve14);
854
+ });
855
+ };
856
+ var readNextLine = readNextLine2;
857
+ const inputStream = opts.stdin ?? process.stdin;
858
+ const rl = readline2.createInterface({
859
+ input: inputStream,
860
+ output: void 0,
861
+ terminal: false
862
+ });
863
+ const lineQueue = [];
864
+ const lineWaiters = [];
865
+ let rlClosed = false;
866
+ rl.on("line", (line) => {
867
+ const waiter = lineWaiters.shift();
868
+ if (waiter) {
869
+ waiter(line);
870
+ } else {
871
+ lineQueue.push(line);
872
+ }
873
+ });
874
+ rl.once("close", () => {
875
+ rlClosed = true;
876
+ for (const waiter of lineWaiters.splice(0)) {
877
+ waiter("");
878
+ }
879
+ });
880
+ let succeeded = false;
881
+ try {
882
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
883
+ stdout("Enter code: ");
884
+ const otpCode = (await readNextLine2()).trim();
885
+ let completeRes;
886
+ try {
887
+ completeRes = await fetchFn(`${baseUrl}/join/${token}/complete`, {
888
+ method: "POST",
889
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
890
+ body: JSON.stringify({ challenge_id: challengeId, proof: { code: otpCode } })
891
+ });
892
+ } catch (err) {
893
+ stderr(`cleargate: cannot reach ${baseUrl} (${err instanceof Error ? err.message : String(err)}).
894
+ `);
895
+ exit(2);
896
+ return;
897
+ }
898
+ if (completeRes.ok) {
899
+ try {
900
+ completeRawBody = await completeRes.json();
901
+ } catch {
902
+ stderr("cleargate: server returned non-JSON response.\n");
903
+ exit(7);
904
+ return;
905
+ }
906
+ succeeded = true;
907
+ break;
908
+ }
909
+ const body = await completeRes.json().catch(() => ({}));
910
+ const errorCode = body.error ?? "";
911
+ if (completeRes.status === 410 || errorCode === "challenge_expired") {
912
+ const { message, exitCode } = mapProviderError(completeRes.status, errorCode, parseRetryAfter(completeRes));
913
+ stderr(`${message}
914
+ `);
915
+ exit(exitCode);
916
+ return;
917
+ }
918
+ if (completeRes.status === 403 || completeRes.status >= 400 && completeRes.status < 500 && errorCode !== "provider_error") {
919
+ const { message, exitCode } = mapProviderError(completeRes.status, errorCode, parseRetryAfter(completeRes));
920
+ stderr(`${message}
921
+ `);
922
+ exit(exitCode);
923
+ return;
924
+ }
925
+ if (attempt < maxRetries) {
926
+ stderr(`cleargate: code didn't match. ${maxRetries - attempt} attempt${maxRetries - attempt === 1 ? "" : "s"} remaining.
927
+ `);
928
+ }
929
+ }
930
+ } finally {
931
+ rl.close();
932
+ }
933
+ if (!succeeded) {
934
+ stderr(`cleargate: code didn't match after ${maxRetries} tries. Run \`cleargate join <url>\` again to get a new code.
935
+ `);
936
+ exit(12);
937
+ return;
938
+ }
939
+ }
940
+ }
941
+ const b = completeRawBody;
412
942
  if (typeof b.refresh_token !== "string" || typeof b.project_name !== "string") {
413
943
  stderr("cleargate: server returned unexpected response shape.\n");
414
944
  exit(7);
@@ -431,6 +961,12 @@ async function joinHandler(opts) {
431
961
  exit(99);
432
962
  }
433
963
  }
964
+ function parseRetryAfter(res) {
965
+ const hdr = res.headers?.get?.("retry-after");
966
+ if (!hdr) return void 0;
967
+ const n = parseInt(hdr, 10);
968
+ return isNaN(n) ? void 0 : n;
969
+ }
434
970
 
435
971
  // src/commands/stamp.ts
436
972
  import * as fs4 from "fs";
@@ -1617,13 +2153,13 @@ async function readDriftState(projectRoot) {
1617
2153
  }
1618
2154
 
1619
2155
  // src/lib/prompts.ts
1620
- import * as readline from "readline";
2156
+ import * as readline3 from "readline";
1621
2157
  async function promptYesNo(question, defaultYes, opts) {
1622
2158
  const stdoutFn = opts?.stdout ?? ((s) => process.stdout.write(s));
1623
2159
  stdoutFn(question + "\n");
1624
2160
  const inputStream = opts?.stdin ?? process.stdin;
1625
2161
  return new Promise((resolve14) => {
1626
- const rl = readline.createInterface({
2162
+ const rl = readline3.createInterface({
1627
2163
  input: inputStream,
1628
2164
  output: void 0,
1629
2165
  // we handle output ourselves
@@ -1654,7 +2190,7 @@ async function promptEmail(question, defaultValue, opts) {
1654
2190
  stdoutFn(question + "\n");
1655
2191
  const inputStream = opts?.stdin ?? process.stdin;
1656
2192
  return new Promise((resolve14) => {
1657
- const rl = readline.createInterface({
2193
+ const rl = readline3.createInterface({
1658
2194
  input: inputStream,
1659
2195
  output: void 0,
1660
2196
  // we handle output ourselves
@@ -3004,7 +3540,7 @@ ${row}
3004
3540
  // src/commands/wiki-audit-status.ts
3005
3541
  import * as fs19 from "fs";
3006
3542
  import * as path20 from "path";
3007
- import * as readline2 from "readline";
3543
+ import * as readline4 from "readline";
3008
3544
  var TERMINAL = /* @__PURE__ */ new Set(["Completed", "Done", "Abandoned", "Closed", "Resolved"]);
3009
3545
  async function wikiAuditStatusHandler(opts = {}) {
3010
3546
  const cwd = opts.cwd ?? process.cwd();
@@ -3143,7 +3679,7 @@ async function wikiAuditStatusHandler(opts = {}) {
3143
3679
  answer = await opts.promptReader();
3144
3680
  } else {
3145
3681
  answer = await new Promise((resolve14) => {
3146
- const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
3682
+ const rl = readline4.createInterface({ input: process.stdin, output: process.stdout });
3147
3683
  rl.question(`apply ${fixable.length} changes? [y/N] `, (ans) => {
3148
3684
  rl.close();
3149
3685
  resolve14(ans);
@@ -7814,9 +8350,6 @@ import * as fs33 from "fs";
7814
8350
  import * as path43 from "path";
7815
8351
  import * as os5 from "os";
7816
8352
  var DEFAULT_MCP_URL = "http://localhost:3000";
7817
- function sleep(ms) {
7818
- return new Promise((resolve14) => setTimeout(resolve14, ms));
7819
- }
7820
8353
  function resolveMcpUrl(mcpUrlFlag, env) {
7821
8354
  return (mcpUrlFlag ?? (env ?? process.env)["CLEARGATE_MCP_URL"] ?? DEFAULT_MCP_URL).replace(/\/$/, "");
7822
8355
  }
@@ -7837,7 +8370,6 @@ async function adminLoginHandler(opts = {}) {
7837
8370
  const stdout = opts.stdout ?? ((msg) => process.stdout.write(msg + "\n"));
7838
8371
  const stderr = opts.stderr ?? ((msg) => process.stderr.write(msg + "\n"));
7839
8372
  const exitFn = opts.exit ?? ((code) => process.exit(code));
7840
- const sleepFn = opts.sleepFn ?? sleep;
7841
8373
  const mcpBase = resolveMcpUrl(opts.mcpUrl, opts.env);
7842
8374
  let startData;
7843
8375
  try {
@@ -7864,79 +8396,95 @@ async function adminLoginHandler(opts = {}) {
7864
8396
  stdout(` Code: ${startData.user_code}`);
7865
8397
  stdout(` (Code expires in ${Math.floor(startData.expires_in / 60)} minutes)`);
7866
8398
  stdout("Waiting for authorization...");
7867
- let currentInterval = opts.intervalOverrideMs ?? Math.max(startData.interval, 5) * 1e3;
7868
- const expiresAtMs = Date.now() + startData.expires_in * 1e3;
7869
- const deadline = expiresAtMs + 1e4;
7870
- let successData = null;
7871
- while (Date.now() < deadline) {
7872
- await sleepFn(currentInterval);
7873
- let pollRes;
7874
- try {
7875
- pollRes = await fetchFn(`${mcpBase}/admin-api/v1/auth/device/poll`, {
7876
- method: "POST",
7877
- headers: { "Content-Type": "application/json", Accept: "application/json" },
7878
- body: JSON.stringify({ device_code: startData.device_code })
7879
- });
7880
- } catch (err) {
7881
- stderr(`cleargate: error: network error while polling (${err instanceof Error ? err.message : String(err)})`);
7882
- return exitFn(3);
7883
- }
7884
- if (pollRes.status === 403) {
7885
- const body = await pollRes.json().catch(() => ({}));
7886
- if (body.error === "access_denied") {
7887
- stderr("cleargate: error: access denied \u2014 you declined authorization in the browser.");
7888
- return exitFn(5);
7889
- }
7890
- stderr("cleargate: error: your GitHub account is not authorized as an admin user.");
7891
- return exitFn(4);
7892
- }
7893
- if (pollRes.status === 410) {
7894
- stderr("cleargate: error: device code expired \u2014 please run `cleargate admin login` again.");
7895
- return exitFn(5);
7896
- }
7897
- if (!pollRes.ok) {
7898
- const body = await pollRes.json().catch(() => ({}));
7899
- stderr(`cleargate: error: unexpected server response ${pollRes.status}: ${body.error ?? "unknown"}`);
7900
- return exitFn(6);
7901
- }
7902
- const pollBody = await pollRes.json();
7903
- if (pollBody.pending) {
7904
- const shouldApplyBump = opts.sleepFn !== void 0 || opts.intervalOverrideMs === void 0;
7905
- if (shouldApplyBump && pollBody.retry_after !== void 0) {
7906
- const bumped = pollBody.retry_after * 1e3;
7907
- if (bumped > currentInterval) {
7908
- currentInterval = bumped;
8399
+ let capturedSuccessBody = null;
8400
+ const fetchPollCapture = async (deviceCode) => {
8401
+ const res = await fetchFn(`${mcpBase}/admin-api/v1/auth/device/poll`, {
8402
+ method: "POST",
8403
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
8404
+ body: JSON.stringify({ device_code: deviceCode })
8405
+ });
8406
+ const originalJson = res.json.bind(res);
8407
+ return {
8408
+ status: res.status,
8409
+ json: async () => {
8410
+ const body = await originalJson();
8411
+ if (body["pending"] === false) {
8412
+ capturedSuccessBody = body;
7909
8413
  }
8414
+ return body;
8415
+ }
8416
+ };
8417
+ };
8418
+ try {
8419
+ await startDeviceFlow({
8420
+ deviceCode: startData.device_code,
8421
+ interval: startData.interval,
8422
+ expiresIn: startData.expires_in,
8423
+ fetchPoll: fetchPollCapture,
8424
+ // Only pass sleepFn if the caller explicitly injected one (test seam).
8425
+ // When sleepFn is omitted, startDeviceFlow uses its own defaultSleep.
8426
+ // This preserves the original bump-suppression logic:
8427
+ // shouldApplyBump = (sleepFn provided) || (intervalOverrideMs not set).
8428
+ ...opts.sleepFn !== void 0 ? { sleepFn: opts.sleepFn } : {},
8429
+ ...opts.intervalOverrideMs !== void 0 ? { intervalOverrideMs: opts.intervalOverrideMs } : {},
8430
+ deadlineGraceMs: 1e4
8431
+ });
8432
+ } catch (err) {
8433
+ if (err instanceof DeviceFlowError) {
8434
+ switch (err.code) {
8435
+ case "access_denied":
8436
+ stderr("cleargate: error: access denied \u2014 you declined authorization in the browser.");
8437
+ return exitFn(5);
8438
+ case "not_admin":
8439
+ stderr("cleargate: error: your GitHub account is not authorized as an admin user.");
8440
+ return exitFn(4);
8441
+ case "expired_token":
8442
+ stderr("cleargate: error: device code expired \u2014 please run `cleargate admin login` again.");
8443
+ return exitFn(5);
8444
+ case "timeout":
8445
+ stderr("cleargate: error: timed out waiting for authorization. Please try again.");
8446
+ return exitFn(5);
8447
+ case "unreachable":
8448
+ stderr(`cleargate: error: network error while polling`);
8449
+ return exitFn(3);
8450
+ default:
8451
+ stderr(`cleargate: error: unexpected server response`);
8452
+ return exitFn(6);
7910
8453
  }
7911
- continue;
7912
8454
  }
7913
- successData = pollBody;
7914
- break;
8455
+ stderr(`cleargate: error: unexpected error during device flow`);
8456
+ return exitFn(6);
7915
8457
  }
7916
- if (!successData) {
8458
+ if (!capturedSuccessBody) {
7917
8459
  stderr("cleargate: error: timed out waiting for authorization. Please try again.");
7918
8460
  return exitFn(5);
7919
8461
  }
8462
+ const successBody = capturedSuccessBody;
7920
8463
  const authFilePath = resolveAuthFilePath(opts);
7921
8464
  try {
7922
- writeAdminAuth(authFilePath, successData.admin_token);
8465
+ writeAdminAuth(authFilePath, successBody.admin_token);
7923
8466
  } catch (err) {
7924
8467
  stderr(`cleargate: error: failed to write ${authFilePath}: ${err instanceof Error ? err.message : String(err)}`);
7925
8468
  return exitFn(99);
7926
8469
  }
7927
- stdout(`Logged in successfully. Token expires ${successData.expires_at}.`);
8470
+ stdout(`Logged in successfully. Token expires ${successBody.expires_at}.`);
7928
8471
  stdout(`Credentials saved to ${authFilePath} (chmod 600).`);
7929
8472
  }
7930
8473
 
7931
8474
  // src/cli.ts
7932
8475
  var program = new Command();
7933
8476
  program.name("cleargate").description("ClearGate CLI \u2014 connects AI agent teams to the ClearGate MCP server").version(package_default.version, "-V, --version").option("--profile <name>", "configuration profile to use", "default").option("--mcp-url <url>", "MCP server URL (overrides config file and env)").showHelpAfterError("(use `cleargate --help`)");
7934
- program.command("join <invite-url>").description("join a ClearGate workspace using an invite URL").action(async (inviteUrl, _opts, command) => {
8477
+ program.command("join <invite-url>").description("join a ClearGate workspace using an invite URL").option("--auth <provider>", "identity provider: github | email").option("--non-interactive", "fail instead of prompting (CI mode)").option("--code <code>", "OTP code for non-interactive email auth").action(async (inviteUrl, _opts, command) => {
7935
8478
  const globals = command.parent.opts();
8479
+ const cmdOpts = command.opts();
7936
8480
  await joinHandler({
7937
8481
  inviteUrl,
7938
8482
  profile: globals.profile,
7939
- mcpUrlFlag: globals.mcpUrl
8483
+ mcpUrlFlag: globals.mcpUrl,
8484
+ // FLASHCARD #cli #commander #optional-key: only set keys when defined
8485
+ ...cmdOpts.auth !== void 0 ? { auth: cmdOpts.auth } : {},
8486
+ ...cmdOpts.nonInteractive === true ? { nonInteractive: true } : {},
8487
+ ...cmdOpts.code !== void 0 ? { code: cmdOpts.code } : {}
7940
8488
  });
7941
8489
  });
7942
8490
  program.command("init").description("initialise a repo with ClearGate scaffold (CLAUDE.md block, hook config, agents, templates)").option("--force", "overwrite existing files that differ from the bundled payload").option("--yes", "non-interactive: accept all defaults without prompting").action(async (opts) => {