letthemquibble 1.2.0 → 2.0.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
@@ -50,6 +50,13 @@ quibble list
50
50
  quibble summary
51
51
  ```
52
52
 
53
+ `quibble login` opens the browser, where you check the code and approve the
54
+ login. It needs no terminal, so a coding agent can start it too. Over SSH or
55
+ in WSL, open the printed URL on any device where you are signed in, or pass
56
+ `--no-browser` to print it alone. `quibble login --password` asks for the
57
+ email, the password and, when two-factor sign-in is on, a code in the
58
+ terminal instead.
59
+
53
60
  `quibble init` writes a `.quibblerc` and prints a short section for your
54
61
  `CLAUDE.md` or `AGENTS.md`. It tells the agent when to complain and how, for
55
62
  example:
@@ -98,6 +105,7 @@ once.
98
105
 
99
106
  For CI, a maintainer runs `quibble token create ci`, and the job sets the two
100
107
  lines it prints, `QUIBBLE_ENDPOINT` and `QUIBBLE_TOKEN`, instead of logging in.
108
+ A plain `quibble login` refuses to run when `CI` is set.
101
109
  A complaint that cannot reach the API waits in `~/.quibble/unsent.jsonl` and
102
110
  goes out with the next remote command, or with `quibble retry`.
103
111
 
@@ -105,5 +113,6 @@ Run `quibble --help` for every command.
105
113
 
106
114
  ## More
107
115
 
116
+ - <https://letthemquibble.com/help>: install, update, setup and login
108
117
  - <https://github.com/repineap/quibble>
109
118
  - <https://letthemquibble.com>
package/dist/quibble.js CHANGED
@@ -6303,9 +6303,9 @@ var asciiTabOrNewline = /[\t\n\r]/g;
6303
6303
  function stripTabAndNewline(value) {
6304
6304
  return value.replace(asciiTabOrNewline, "");
6305
6305
  }
6306
- function urlHostnameOk(url2, hostname4) {
6307
- hostname4.lastIndex = 0;
6308
- return hostname4.test(url2.hostname);
6306
+ function urlHostnameOk(url2, hostname5) {
6307
+ hostname5.lastIndex = 0;
6308
+ return hostname5.test(url2.hostname);
6309
6309
  }
6310
6310
  function urlProtocolOk(url2, protocol) {
6311
6311
  protocol.lastIndex = 0;
@@ -23233,6 +23233,14 @@ var ACT_AS_HEADER = "x-quibble-act-as";
23233
23233
  var TRUNCATED_HEADER = "x-quibble-truncated";
23234
23234
  var IDEMPOTENCY_HEADER = "idempotency-key";
23235
23235
  var IDEMPOTENCY_KEY_MAX = 128;
23236
+ var CLI_LOGIN_TTL_SECONDS = 600;
23237
+ var CLI_LOGIN_POLL_SECONDS = 3;
23238
+ var CLI_LOGIN_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
23239
+ var CLI_LOGIN_CODE_LENGTH = 8;
23240
+ var CLI_LOGIN_HOST_MAX = 64;
23241
+ var CLI_LOGIN_OS_MAX = 32;
23242
+ var CLI_LOGIN_VERSION_MAX = 32;
23243
+ var CLI_LOGIN_PAGE_PATH = "/cli-login";
23236
23244
 
23237
23245
  // ../core/dist/errors.js
23238
23246
  var QuibbleError = class extends Error {
@@ -23287,6 +23295,35 @@ function describeError(error62) {
23287
23295
  return error62 instanceof Error ? error62.message : String(error62);
23288
23296
  }
23289
23297
 
23298
+ // ../core/dist/cli-login.js
23299
+ var CLI_LOGIN_CONTROL_RE = /[\u0000-\u001F\u007F-\u009F\p{Cf}]/u;
23300
+ var CONTROL_GLOBAL_RE = /[\u0000-\u001F\u007F-\u009F\p{Cf}]/gu;
23301
+ var CLI_LOGIN_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
23302
+ var CLI_LOGIN_CODE_MESSAGE = "code: not a login code";
23303
+ var HALF = CLI_LOGIN_CODE_LENGTH / 2;
23304
+ var CODE_RE = new RegExp(`^([${CLI_LOGIN_CODE_ALPHABET}]{${HALF}})-?([${CLI_LOGIN_CODE_ALPHABET}]{${HALF}})$`);
23305
+ var UNKNOWN_HOST = "unknown";
23306
+ function parseCliLoginCode(value) {
23307
+ const match = CODE_RE.exec(value.trim().toUpperCase());
23308
+ if (match === null)
23309
+ throw new ValidationError([CLI_LOGIN_CODE_MESSAGE]);
23310
+ return `${match[1] ?? ""}-${match[2] ?? ""}`;
23311
+ }
23312
+ function cliLoginUrl(webUrl, code2) {
23313
+ return `${webUrl.replace(/\/+$/, "")}${CLI_LOGIN_PAGE_PATH}?code=${code2}`;
23314
+ }
23315
+ function cut(value, max) {
23316
+ if (value.length <= max)
23317
+ return value;
23318
+ const head = value.slice(0, max);
23319
+ const last = head.charCodeAt(head.length - 1);
23320
+ return last >= 55296 && last <= 56319 ? head.slice(0, -1) : head;
23321
+ }
23322
+ function cliLoginHost(raw) {
23323
+ const host = cut(raw.replace(CONTROL_GLOBAL_RE, "").trim(), CLI_LOGIN_HOST_MAX).trim();
23324
+ return host === "" ? UNKNOWN_HOST : host;
23325
+ }
23326
+
23290
23327
  // ../core/dist/fingerprint.js
23291
23328
  function headlineOf(message) {
23292
23329
  for (const line of message.split("\n")) {
@@ -23307,7 +23344,7 @@ var PUNCTUATION_RE = /[^\p{L}\p{N}\s<>]/gu;
23307
23344
  function isHexId(run2) {
23308
23345
  return /[a-f]/.test(run2) && /[0-9]/.test(run2);
23309
23346
  }
23310
- function cut(text, max) {
23347
+ function cut2(text, max) {
23311
23348
  if (text.length <= max)
23312
23349
  return text;
23313
23350
  const head = text.slice(0, max);
@@ -23325,7 +23362,7 @@ function fingerprintOf(message) {
23325
23362
  text = text.replace(NUMBER_RE, "<n>");
23326
23363
  text = text.replace(PUNCTUATION_RE, " ");
23327
23364
  text = text.replace(/\s+/gu, " ").trim();
23328
- text = cut(text, FINGERPRINT_MAX).trim();
23365
+ text = cut2(text, FINGERPRINT_MAX).trim();
23329
23366
  return text === "" ? EMPTY_FINGERPRINT : text;
23330
23367
  }
23331
23368
  function groupComplaints(complaints) {
@@ -23906,6 +23943,23 @@ function newTokenSchema(now) {
23906
23943
  ...input2.expiresAt === void 0 ? {} : { expiresAt: input2.expiresAt }
23907
23944
  }));
23908
23945
  }
23946
+ function reportedField(max) {
23947
+ const message = lengthMessage(1, max);
23948
+ return external_exports.string({ message }).trim().min(1, { message }).max(max, { message }).refine((value) => !CLI_LOGIN_CONTROL_RE.test(value), { message: CONTROL_MESSAGE });
23949
+ }
23950
+ var newCliLoginSchema = external_exports.strictObject({
23951
+ host: reportedField(CLI_LOGIN_HOST_MAX),
23952
+ os: reportedField(CLI_LOGIN_OS_MAX),
23953
+ version: reportedField(CLI_LOGIN_VERSION_MAX)
23954
+ }).transform((input2) => ({
23955
+ host: input2.host,
23956
+ os: input2.os,
23957
+ version: input2.version
23958
+ }));
23959
+ var LOGIN_KEY_MESSAGE = "must be 43 base64url characters";
23960
+ var cliLoginPollSchema = external_exports.strictObject({
23961
+ loginKey: external_exports.string({ message: LOGIN_KEY_MESSAGE }).regex(CLI_LOGIN_KEY_RE, { message: LOGIN_KEY_MESSAGE })
23962
+ }).transform((input2) => ({ loginKey: input2.loginKey }));
23909
23963
  var UNKNOWN_KEY_MESSAGE = "unknown key";
23910
23964
  var emptyBodySchema = external_exports.strictObject({});
23911
23965
  function formatIssues(error62) {
@@ -24756,6 +24810,7 @@ var TOO_MANY_LINE = "Too many attempts. Try again later.";
24756
24810
  var WRONG_CODE_LINE = "Wrong code.";
24757
24811
  var EXPIRED_CODE_LINE = "That code has expired. Request a new one.";
24758
24812
  var PASSWORD_POLICY_LINE = "Use at least 12 characters, with an uppercase letter, a lowercase letter, a number and a symbol.";
24813
+ var BROWSER_REFUSED_LINE = "the browser approval could not be used: run quibble login again";
24759
24814
  var UNREACHABLE_TYPE = "Unreachable";
24760
24815
  var INCORRECT_CREDENTIALS_MESSAGE = "Incorrect username or password.";
24761
24816
  var CognitoError = class extends RemoteError {
@@ -24772,14 +24827,15 @@ var CognitoError = class extends RemoteError {
24772
24827
  function endpointUrl(region) {
24773
24828
  return `https://cognito-idp.${region}.amazonaws.com/`;
24774
24829
  }
24775
- function errorLineOf(type, detail2, signIn2) {
24830
+ function errorLineOf(type, detail2, step) {
24776
24831
  switch (type) {
24777
24832
  case UNREACHABLE_TYPE:
24778
24833
  return UNREACHABLE_LINE;
24779
24834
  case "UserNotFoundException":
24780
- return signIn2 ? WRONG_CREDENTIALS_LINE : detail2 ?? type;
24835
+ return step === "sign-in" ? WRONG_CREDENTIALS_LINE : detail2 ?? type;
24781
24836
  case "NotAuthorizedException":
24782
- return signIn2 && detail2 === INCORRECT_CREDENTIALS_MESSAGE ? WRONG_CREDENTIALS_LINE : detail2 ?? type;
24837
+ if (step === "browser") return BROWSER_REFUSED_LINE;
24838
+ return step === "sign-in" && detail2 === INCORRECT_CREDENTIALS_MESSAGE ? WRONG_CREDENTIALS_LINE : detail2 ?? type;
24783
24839
  case "LimitExceededException":
24784
24840
  case "TooManyRequestsException":
24785
24841
  case "TooManyFailedAttemptsException":
@@ -24800,7 +24856,7 @@ function typeOf(value) {
24800
24856
  const hash2 = value.indexOf("#");
24801
24857
  return hash2 === -1 ? value : value.slice(hash2 + 1);
24802
24858
  }
24803
- async function call(endpoint, operation, body, signIn2 = false) {
24859
+ async function call(endpoint, operation, body, step = "other") {
24804
24860
  const url2 = endpointUrl(endpoint.region);
24805
24861
  const send2 = endpoint.fetch ?? globalThis.fetch;
24806
24862
  const site = {
@@ -24812,7 +24868,7 @@ async function call(endpoint, operation, body, signIn2 = false) {
24812
24868
  throw new CognitoError(
24813
24869
  type,
24814
24870
  detail2,
24815
- errorLineOf(type, detail2, signIn2),
24871
+ errorLineOf(type, detail2, step),
24816
24872
  detail(site, status)
24817
24873
  );
24818
24874
  };
@@ -24895,14 +24951,27 @@ function authAnswerOf(body) {
24895
24951
  }
24896
24952
  };
24897
24953
  }
24954
+ function flowOf(input2) {
24955
+ switch (input2.flow) {
24956
+ case "USER_PASSWORD_AUTH":
24957
+ return {
24958
+ parameters: { USERNAME: input2.username, PASSWORD: input2.password },
24959
+ step: "sign-in"
24960
+ };
24961
+ case "REFRESH_TOKEN_AUTH":
24962
+ return { parameters: { REFRESH_TOKEN: input2.refreshToken }, step: "other" };
24963
+ case "CUSTOM_AUTH":
24964
+ return { parameters: { USERNAME: input2.username }, step: "browser" };
24965
+ }
24966
+ }
24898
24967
  async function initiateAuth(endpoint, input2) {
24899
- const parameters = input2.flow === "USER_PASSWORD_AUTH" ? { USERNAME: input2.username, PASSWORD: input2.password } : { REFRESH_TOKEN: input2.refreshToken };
24968
+ const { parameters, step } = flowOf(input2);
24900
24969
  return authAnswerOf(
24901
24970
  await call(
24902
24971
  endpoint,
24903
24972
  "InitiateAuth",
24904
24973
  { ClientId: input2.clientId, AuthFlow: input2.flow, AuthParameters: parameters },
24905
- input2.flow === "USER_PASSWORD_AUTH"
24974
+ step
24906
24975
  )
24907
24976
  );
24908
24977
  }
@@ -24917,6 +24986,22 @@ async function respondToAuthChallenge(endpoint, input2) {
24917
24986
  })
24918
24987
  );
24919
24988
  }
24989
+ async function respondToCustomChallenge(endpoint, input2) {
24990
+ const username = input2.challenge.challengeParameters["USERNAME"] ?? input2.username;
24991
+ return authAnswerOf(
24992
+ await call(
24993
+ endpoint,
24994
+ "RespondToAuthChallenge",
24995
+ {
24996
+ ClientId: input2.clientId,
24997
+ ChallengeName: input2.challenge.challengeName,
24998
+ Session: input2.challenge.session,
24999
+ ChallengeResponses: { USERNAME: username, ANSWER: input2.answer }
25000
+ },
25001
+ "browser"
25002
+ )
25003
+ );
25004
+ }
24920
25005
  async function revokeToken(endpoint, input2) {
24921
25006
  await call(endpoint, "RevokeToken", { ClientId: input2.clientId, Token: input2.token });
24922
25007
  }
@@ -27088,12 +27173,12 @@ var REPORTER_WIDTH = 20;
27088
27173
  var TAGS_WIDTH = 24;
27089
27174
  var SUMMARY_MAX = 60;
27090
27175
  var RECURRED_MARK = "\u21BB";
27091
- function cut2(text, width) {
27176
+ function cut3(text, width) {
27092
27177
  if (text.length <= width) return text;
27093
27178
  return `${text.slice(0, width - 3)}...`;
27094
27179
  }
27095
27180
  function summaryOf(complaint) {
27096
- return cut2(printable(summarize(complaint.message)), SUMMARY_MAX);
27181
+ return cut3(printable(summarize(complaint.message)), SUMMARY_MAX);
27097
27182
  }
27098
27183
  function limitNote(count, limit, more) {
27099
27184
  if (limit >= MAX_LIMIT) {
@@ -27147,7 +27232,7 @@ async function runList(options, context, io) {
27147
27232
  return 0;
27148
27233
  }
27149
27234
  function reporterOf(complaint) {
27150
- return cut2(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
27235
+ return cut3(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
27151
27236
  }
27152
27237
  function groupCell(group) {
27153
27238
  const count = group.count > 1 ? `\xD7${group.count}` : "";
@@ -27164,7 +27249,7 @@ function formatTable(rows2) {
27164
27249
  status: complaint.status,
27165
27250
  project: complaint.project,
27166
27251
  reporter: reporterOf(complaint),
27167
- tags: cut2(printable(complaint.tags.join(",")), TAGS_WIDTH),
27252
+ tags: cut3(printable(complaint.tags.join(",")), TAGS_WIDTH),
27168
27253
  group: group === void 0 ? "" : groupCell(group),
27169
27254
  summary: summaryOf(complaint)
27170
27255
  };
@@ -27221,6 +27306,81 @@ function formatOrgs(orgs) {
27221
27306
  return [format(COLUMNS2), ...rows2.map(format)];
27222
27307
  }
27223
27308
 
27309
+ // src/commands/login.ts
27310
+ import { hostname as hostname4 } from "node:os";
27311
+
27312
+ // src/opener.ts
27313
+ import { spawn as nodeSpawn } from "node:child_process";
27314
+ var OPEN_TIMEOUT_MS = 5e3;
27315
+ var OPENABLE_URL_RE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9/?=._~-]*)?$/;
27316
+ function isWsl(platform, env) {
27317
+ if (platform !== "linux") return false;
27318
+ const set2 = (name) => (env[name] ?? "") !== "";
27319
+ return set2("WSL_DISTRO_NAME") || set2("WSL_INTEROP");
27320
+ }
27321
+ function openCommands(url2, platform, env) {
27322
+ if (platform === "darwin") return [{ command: "open", args: [url2] }];
27323
+ if (platform === "win32") {
27324
+ return [{ command: "cmd", args: ["/c", "start", "", url2] }];
27325
+ }
27326
+ if (isWsl(platform, env)) {
27327
+ return [
27328
+ { command: "wslview", args: [url2] },
27329
+ { command: "cmd.exe", args: ["/c", "start", "", url2] }
27330
+ ];
27331
+ }
27332
+ return [{ command: "xdg-open", args: [url2] }];
27333
+ }
27334
+ function attempt(spawn, entry, timeoutMs) {
27335
+ return new Promise((resolve4) => {
27336
+ let settled = false;
27337
+ const settle2 = (opened) => {
27338
+ if (settled) return;
27339
+ settled = true;
27340
+ clearTimeout(timer);
27341
+ resolve4(opened);
27342
+ };
27343
+ const timer = setTimeout(() => {
27344
+ settle2(false);
27345
+ }, timeoutMs);
27346
+ timer.unref();
27347
+ let child;
27348
+ try {
27349
+ child = spawn(entry.command, entry.args, {
27350
+ shell: false,
27351
+ detached: true,
27352
+ stdio: "ignore",
27353
+ windowsHide: true
27354
+ });
27355
+ } catch {
27356
+ settle2(false);
27357
+ return;
27358
+ }
27359
+ child.on("error", () => {
27360
+ settle2(false);
27361
+ });
27362
+ child.on("exit", (code2) => {
27363
+ settle2(code2 === 0);
27364
+ });
27365
+ child.unref();
27366
+ });
27367
+ }
27368
+ function platformOpener(options = {}) {
27369
+ const platform = options.platform ?? process.platform;
27370
+ const env = options.env ?? process.env;
27371
+ const spawn = options.spawn ?? nodeSpawn;
27372
+ const timeoutMs = options.timeoutMs ?? OPEN_TIMEOUT_MS;
27373
+ return {
27374
+ async open(url2) {
27375
+ if (!OPENABLE_URL_RE.test(url2)) return false;
27376
+ for (const entry of openCommands(url2, platform, env)) {
27377
+ if (await attempt(spawn, entry, timeoutMs)) return true;
27378
+ }
27379
+ return false;
27380
+ }
27381
+ };
27382
+ }
27383
+
27224
27384
  // src/terminal.ts
27225
27385
  import { createInterface } from "node:readline";
27226
27386
  import { Writable } from "node:stream";
@@ -27342,15 +27502,40 @@ function nodeTerminal(input2, output2) {
27342
27502
 
27343
27503
  // src/commands/login.ts
27344
27504
  var LOCAL_IDENTITY_MESSAGE = "local mode: no identity.";
27345
- var NEEDS_TERMINAL_MESSAGE = "quibble login needs a terminal: in CI, set QUIBBLE_TOKEN instead";
27505
+ var NEEDS_TERMINAL_MESSAGE = "quibble login --password needs a terminal: in CI, set QUIBBLE_TOKEN instead";
27506
+ var CI_MESSAGE = "quibble login needs a person to approve it in a browser: in CI, set QUIBBLE_TOKEN instead";
27507
+ var NO_BROWSER_MESSAGE = "--no-browser applies to the browser login only";
27508
+ var DENIED_MESSAGE = "login was denied in the browser";
27509
+ var EXPIRED_MESSAGE = "the login request expired: run quibble login again";
27510
+ var MALFORMED_MESSAGE = "the API answered a malformed login request";
27511
+ var UNKNOWN_STATUS_MESSAGE = "the API answered an unknown login status";
27512
+ var MFA_NEEDS_TERMINAL_MESSAGE = "this account needs a two-factor code: run quibble login in a terminal";
27346
27513
  var MFA_CHALLENGE = "SOFTWARE_TOKEN_MFA";
27514
+ var CUSTOM_CHALLENGE = "CUSTOM_CHALLENGE";
27347
27515
  var NEW_PASSWORD_CHALLENGE = "NEW_PASSWORD_REQUIRED";
27516
+ var CLI_LOGINS_PATH = "/v1/cli-logins";
27517
+ var CLI_LOGIN_POLL_PATH = "/v1/cli-logins/poll";
27518
+ var MIN_INTERVAL_SECONDS = 1;
27519
+ var MAX_INTERVAL_SECONDS = 30;
27520
+ var PLAIN_HOST_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::[0-9]+)?$/;
27348
27521
  var LOGIN_PAGE = "/login";
27349
27522
  var ORGS_PAGE2 = "/orgs";
27350
27523
  var NO_PROFILE_STATUSES = /* @__PURE__ */ new Set([404, 409]);
27351
27524
  function terminalOf(io) {
27352
27525
  return io.terminal ?? nodeTerminal(io.stdin, io.stderr);
27353
27526
  }
27527
+ function clockOf2(io) {
27528
+ return io.now ?? (() => /* @__PURE__ */ new Date());
27529
+ }
27530
+ function sleepOf(io) {
27531
+ return io.sleep ?? ((ms) => new Promise((resolve4) => {
27532
+ setTimeout(resolve4, ms);
27533
+ }));
27534
+ }
27535
+ function ciIsSet(env) {
27536
+ const value = (env["CI"] ?? "").trim().toLowerCase();
27537
+ return value !== "" && value !== "0" && value !== "false";
27538
+ }
27354
27539
  async function answer(terminal, label, hidden = false) {
27355
27540
  const text = hidden ? await terminal.promptHidden(label) : await terminal.prompt(label);
27356
27541
  const value = hidden ? text : text.trim();
@@ -27358,6 +27543,23 @@ async function answer(terminal, label, hidden = false) {
27358
27543
  throw new UsageError(`${label.replace(/:$/, "").toLowerCase()} must not be empty`);
27359
27544
  return value;
27360
27545
  }
27546
+ function unsupportedStep(name) {
27547
+ return new RemoteError(
27548
+ `this account needs a sign-in step that quibble doesn't support (${name})`
27549
+ );
27550
+ }
27551
+ function pageError(error62, webUrl) {
27552
+ if (error62 instanceof CognitoError) {
27553
+ const page = `${trimSlash2(webUrl)}${LOGIN_PAGE}`;
27554
+ if (error62.type === "PasswordResetRequiredException") {
27555
+ return new ConfigError(`reset your password at ${page}`);
27556
+ }
27557
+ if (error62.type === "UserNotConfirmedException") {
27558
+ return new ConfigError(`finish your sign-up at ${page}`);
27559
+ }
27560
+ }
27561
+ return error62;
27562
+ }
27361
27563
  async function signIn(terminal, identity, config2) {
27362
27564
  const email3 = await answer(terminal, "Email:");
27363
27565
  const password = await answer(terminal, "Password:", true);
@@ -27385,26 +27587,22 @@ async function signIn(terminal, identity, config2) {
27385
27587
  `finish your first sign-in at ${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`
27386
27588
  );
27387
27589
  }
27388
- throw new RemoteError(
27389
- `this account needs a sign-in step that quibble doesn't support (${name})`
27390
- );
27590
+ throw unsupportedStep(name);
27391
27591
  }
27392
27592
  return result.tokens;
27393
27593
  } catch (error62) {
27394
- if (error62 instanceof CognitoError) {
27395
- const page = `${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`;
27396
- if (error62.type === "PasswordResetRequiredException") {
27397
- throw new ConfigError(`reset your password at ${page}`);
27398
- }
27399
- if (error62.type === "UserNotConfirmedException") {
27400
- throw new ConfigError(`finish your sign-up at ${page}`);
27401
- }
27402
- }
27403
- throw error62;
27594
+ throw pageError(error62, config2.webUrl);
27404
27595
  }
27405
27596
  }
27406
27597
  async function runLogin(options, context, io) {
27407
27598
  requireServer("login", context);
27599
+ if (options.password === true) {
27600
+ if (options.browser === false) throw new UsageError(NO_BROWSER_MESSAGE);
27601
+ return passwordLogin(options, context, io);
27602
+ }
27603
+ return browserLogin(options, context, io);
27604
+ }
27605
+ async function passwordLogin(options, context, io) {
27408
27606
  const terminal = terminalOf(io);
27409
27607
  if (!terminal.isTTY) throw new UsageError(NEEDS_TERMINAL_MESSAGE);
27410
27608
  const endpoint = await resolveEndpoint(context, io);
@@ -27420,15 +27618,203 @@ async function runLogin(options, context, io) {
27420
27618
  } finally {
27421
27619
  terminal.close();
27422
27620
  }
27621
+ return finishLogin(tokens, endpoint, config2, options, context, io);
27622
+ }
27623
+ async function browserLogin(options, context, io) {
27624
+ if (ciIsSet(io.env)) throw new UsageError(CI_MESSAGE);
27625
+ const endpoint = await resolveEndpoint(context, io);
27626
+ const http = { debug: context.debug, fetch: io.fetch };
27627
+ const config2 = await fetchAuthConfig(endpoint, http);
27628
+ if (config2.mode === "local") {
27629
+ writeLine(io.stdout, `${endpoint} is in local mode: no login needed.`);
27630
+ return 0;
27631
+ }
27632
+ const clock = clockOf2(io);
27633
+ const started = await startLogin(endpoint, config2.webUrl, http, {
27634
+ host: cliLoginHost((io.hostname ?? hostname4)()),
27635
+ os: process.platform,
27636
+ version: options.version
27637
+ });
27638
+ const deadline = clock().getTime() + (CLI_LOGIN_TTL_SECONDS + started.interval) * 1e3;
27639
+ writeLine(
27640
+ io.stderr,
27641
+ `Approve this login in your browser: ${started.url} (code ${started.code}). Waiting...`
27642
+ );
27643
+ if (options.browser !== false) {
27644
+ const opener = io.opener ?? platformOpener({ env: io.env });
27645
+ void opener.open(started.url).catch(() => false);
27646
+ }
27647
+ const approval = await waitForApproval(endpoint, started, deadline, http, io);
27648
+ const tokens = await browserSignIn(
27649
+ { region: config2.region, ...http },
27650
+ config2,
27651
+ approval,
27652
+ io
27653
+ );
27654
+ return finishLogin(tokens, endpoint, config2, options, context, io);
27655
+ }
27656
+ function postJson(url2, body, http) {
27657
+ return send(url2, {
27658
+ method: "POST",
27659
+ headers: { "content-type": "application/json" },
27660
+ body: JSON.stringify(body),
27661
+ ...http
27662
+ });
27663
+ }
27664
+ async function startLogin(endpoint, webUrl, http, body) {
27665
+ const url2 = `${trimSlash2(endpoint)}${CLI_LOGINS_PATH}`;
27666
+ const site = { method: "POST", url: url2, debug: http.debug };
27667
+ const response = await postJson(url2, body, http);
27668
+ const text = await readText(response, site);
27669
+ if (response.status === 404) {
27670
+ throw new RemoteError(
27671
+ `${endpoint} does not support the browser login: run quibble login --password${detail(site, 404)}`,
27672
+ { status: 404 }
27673
+ );
27674
+ }
27675
+ if (!response.ok) throw remoteFailure(site, response, text);
27676
+ const started = checkStart(asRecord(parseQuietly(text)), webUrl);
27677
+ if (started === void 0) {
27678
+ throw new RemoteError(`${MALFORMED_MESSAGE}${detail(site, response.status)}`);
27679
+ }
27680
+ return started;
27681
+ }
27682
+ function checkStart(body, webUrl) {
27683
+ const loginKey = body?.["loginKey"];
27684
+ const code2 = body?.["code"];
27685
+ const url2 = body?.["url"];
27686
+ if (typeof loginKey !== "string" || !CLI_LOGIN_KEY_RE.test(loginKey)) return void 0;
27687
+ if (typeof code2 !== "string" || typeof url2 !== "string") return void 0;
27688
+ try {
27689
+ if (parseCliLoginCode(code2) !== code2) return void 0;
27690
+ } catch {
27691
+ return void 0;
27692
+ }
27693
+ let origin;
27694
+ try {
27695
+ const web = new URL(webUrl);
27696
+ const secure = web.protocol === "https:" || web.protocol === "http:" && isLoopbackUrl(web.origin);
27697
+ if (!secure || !PLAIN_HOST_RE.test(web.host)) return void 0;
27698
+ origin = web.origin;
27699
+ } catch {
27700
+ return void 0;
27701
+ }
27702
+ if (url2 !== cliLoginUrl(origin, code2)) return void 0;
27703
+ const raw = body?.["interval"];
27704
+ const interval = typeof raw === "number" && Number.isInteger(raw) ? Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, raw)) : CLI_LOGIN_POLL_SECONDS;
27705
+ return { loginKey, code: code2, url: url2, interval };
27706
+ }
27707
+ async function waitForApproval(endpoint, started, deadline, http, io) {
27708
+ const clock = clockOf2(io);
27709
+ const sleep = sleepOf(io);
27710
+ for (; ; ) {
27711
+ await sleep(started.interval * 1e3);
27712
+ if (clock().getTime() > deadline) throw new ConfigError(EXPIRED_MESSAGE);
27713
+ const answer2 = await poll(endpoint, started.loginKey, http);
27714
+ if (answer2 === void 0) continue;
27715
+ const status = answer2["status"];
27716
+ switch (status) {
27717
+ case "pending":
27718
+ continue;
27719
+ case "denied":
27720
+ throw new ConfigError(DENIED_MESSAGE);
27721
+ case "expired":
27722
+ throw new ConfigError(EXPIRED_MESSAGE);
27723
+ case "approved": {
27724
+ const username = answer2["username"];
27725
+ const secret = answer2["secret"];
27726
+ if (typeof username !== "string" || username === "") {
27727
+ throw new RemoteError(MALFORMED_MESSAGE);
27728
+ }
27729
+ if (typeof secret !== "string" || secret === "") {
27730
+ throw new RemoteError(MALFORMED_MESSAGE);
27731
+ }
27732
+ return { username, secret };
27733
+ }
27734
+ default:
27735
+ throw new RemoteError(UNKNOWN_STATUS_MESSAGE);
27736
+ }
27737
+ }
27738
+ }
27739
+ async function poll(endpoint, loginKey, http) {
27740
+ const url2 = `${trimSlash2(endpoint)}${CLI_LOGIN_POLL_PATH}`;
27741
+ const site = { method: "POST", url: url2, debug: http.debug };
27742
+ let response;
27743
+ let text;
27744
+ try {
27745
+ response = await postJson(url2, { loginKey }, http);
27746
+ text = await readText(response, site);
27747
+ } catch (error62) {
27748
+ if (error62 instanceof RemoteError && error62.unanswered) return void 0;
27749
+ throw error62;
27750
+ }
27751
+ if (!response.ok) {
27752
+ if (response.status >= 500 || response.status === 429) return void 0;
27753
+ throw remoteFailure(site, response, text);
27754
+ }
27755
+ return asRecord(parseJson(text, site, response.status)) ?? {};
27756
+ }
27757
+ async function browserSignIn(identity, config2, approval, io) {
27758
+ const clientId = config2.cliClientId;
27759
+ try {
27760
+ let result = await initiateAuth(identity, {
27761
+ clientId,
27762
+ flow: "CUSTOM_AUTH",
27763
+ username: approval.username
27764
+ });
27765
+ if (result.kind === "challenge") {
27766
+ const name = result.challenge.challengeName;
27767
+ if (name !== CUSTOM_CHALLENGE) throw unsupportedStep(name);
27768
+ result = await respondToCustomChallenge(identity, {
27769
+ clientId,
27770
+ challenge: result.challenge,
27771
+ username: approval.username,
27772
+ answer: approval.secret
27773
+ });
27774
+ }
27775
+ if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
27776
+ result = await answerCode(
27777
+ identity,
27778
+ clientId,
27779
+ result.challenge,
27780
+ approval.username,
27781
+ io
27782
+ );
27783
+ }
27784
+ if (result.kind === "challenge") {
27785
+ throw unsupportedStep(result.challenge.challengeName);
27786
+ }
27787
+ return result.tokens;
27788
+ } catch (error62) {
27789
+ throw pageError(error62, config2.webUrl);
27790
+ }
27791
+ }
27792
+ async function answerCode(identity, clientId, challenge, username, io) {
27793
+ const terminal = terminalOf(io);
27794
+ try {
27795
+ if (!terminal.isTTY) throw new ConfigError(MFA_NEEDS_TERMINAL_MESSAGE);
27796
+ const code2 = await answer(terminal, "Code:");
27797
+ return await respondToAuthChallenge(identity, {
27798
+ clientId,
27799
+ challenge,
27800
+ email: username,
27801
+ code: code2
27802
+ });
27803
+ } finally {
27804
+ terminal.close();
27805
+ }
27806
+ }
27807
+ async function finishLogin(tokens, endpoint, config2, options, context, io) {
27423
27808
  if (tokens.refreshToken === void 0) {
27424
27809
  throw new RemoteError("sign-in didn't return a refresh token");
27425
27810
  }
27426
- const now = /* @__PURE__ */ new Date();
27811
+ const now = clockOf2(io)();
27427
27812
  const me = await readProfile(
27428
27813
  new ApiClient({
27429
27814
  endpoint,
27430
27815
  authorizer: bearerAuthorizer(tokens.accessToken),
27431
- ...http
27816
+ debug: context.debug,
27817
+ fetch: io.fetch
27432
27818
  }),
27433
27819
  config2.webUrl
27434
27820
  );
@@ -28487,11 +28873,17 @@ function buildProgram(io, setCode) {
28487
28873
  setCode(await runOrgs(options, contextFor(options, io), io));
28488
28874
  });
28489
28875
  withGlobalOptions(
28490
- program2.command("login").description("log in to the endpoint")
28876
+ program2.command("login").description("log in to the endpoint, approved in a browser").option("--no-browser", "print the approval URL, and open no browser").option("--password", "log in with an email and a password on the terminal")
28491
28877
  ).action(async function() {
28492
28878
  const options = { ...this.opts(), ...globals(program2, this) };
28493
28879
  rejectEmptyOptions(options);
28494
- setCode(await runLogin(options, contextFor(options, io), io));
28880
+ setCode(
28881
+ await runLogin(
28882
+ { ...options, version: packageVersion() },
28883
+ contextFor(options, io),
28884
+ io
28885
+ )
28886
+ );
28495
28887
  });
28496
28888
  withGlobalOptions(
28497
28889
  program2.command("logout").description("log out of the endpoint")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letthemquibble",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "File complaints about the project you work in, from a coding agent or a shell.",
5
5
  "license": "MIT",
6
6
  "type": "module",