letthemquibble 1.0.1 → 1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -15
  3. package/dist/quibble.js +2976 -653
  4. package/package.json +3 -1
package/dist/quibble.js CHANGED
@@ -51,11 +51,11 @@ var require_error = __commonJS({
51
51
  * @param {string} code an id string representing the error
52
52
  * @param {string} message human-readable description of the error
53
53
  */
54
- constructor(exitCode, code, message) {
54
+ constructor(exitCode, code2, message) {
55
55
  super(message);
56
56
  Error.captureStackTrace(this, this.constructor);
57
57
  this.name = this.constructor.name;
58
- this.code = code;
58
+ this.code = code2;
59
59
  this.exitCode = exitCode;
60
60
  this.nestedError = void 0;
61
61
  }
@@ -1156,18 +1156,18 @@ var require_suggestSimilar = __commonJS({
1156
1156
  }
1157
1157
  return d[a.length][b.length];
1158
1158
  }
1159
- function suggestSimilar(word, candidates) {
1160
- if (!candidates || candidates.length === 0) return "";
1161
- candidates = Array.from(new Set(candidates));
1159
+ function suggestSimilar(word, candidates2) {
1160
+ if (!candidates2 || candidates2.length === 0) return "";
1161
+ candidates2 = Array.from(new Set(candidates2));
1162
1162
  const searchingOptions = word.startsWith("--");
1163
1163
  if (searchingOptions) {
1164
1164
  word = word.slice(2);
1165
- candidates = candidates.map((candidate) => candidate.slice(2));
1165
+ candidates2 = candidates2.map((candidate) => candidate.slice(2));
1166
1166
  }
1167
1167
  let similar = [];
1168
1168
  let bestDistance = maxDistance;
1169
1169
  const minSimilarity = 0.4;
1170
- candidates.forEach((candidate) => {
1170
+ candidates2.forEach((candidate) => {
1171
1171
  if (candidate.length <= 1) return;
1172
1172
  const distance = editDistance(word, candidate);
1173
1173
  const length = Math.max(word.length, candidate.length);
@@ -1649,9 +1649,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
1649
1649
  * @return never
1650
1650
  * @private
1651
1651
  */
1652
- _exit(exitCode, code, message) {
1652
+ _exit(exitCode, code2, message) {
1653
1653
  if (this._exitCallback) {
1654
- this._exitCallback(new CommanderError2(exitCode, code, message));
1654
+ this._exitCallback(new CommanderError2(exitCode, code2, message));
1655
1655
  }
1656
1656
  process3.exit(exitCode);
1657
1657
  }
@@ -2289,14 +2289,14 @@ Expecting one of '${allowedValues.join("', '")}'`);
2289
2289
  });
2290
2290
  }
2291
2291
  const exitCallback = this._exitCallback;
2292
- proc.on("close", (code) => {
2293
- code = code ?? 1;
2292
+ proc.on("close", (code2) => {
2293
+ code2 = code2 ?? 1;
2294
2294
  if (!exitCallback) {
2295
- process3.exit(code);
2295
+ process3.exit(code2);
2296
2296
  } else {
2297
2297
  exitCallback(
2298
2298
  new CommanderError2(
2299
- code,
2299
+ code2,
2300
2300
  "commander.executeSubCommandAsync",
2301
2301
  "(close)"
2302
2302
  )
@@ -2803,8 +2803,8 @@ Expecting one of '${allowedValues.join("', '")}'`);
2803
2803
  }
2804
2804
  const config2 = errorOptions || {};
2805
2805
  const exitCode = config2.exitCode || 1;
2806
- const code = config2.code || "commander.error";
2807
- this._exit(exitCode, code, message);
2806
+ const code2 = config2.code || "commander.error";
2807
+ this._exit(exitCode, code2, message);
2808
2808
  }
2809
2809
  /**
2810
2810
  * Apply any option related environment variables, if option does
@@ -3490,110 +3490,6 @@ var {
3490
3490
  Help
3491
3491
  } = import_index.default;
3492
3492
 
3493
- // src/config.ts
3494
- import { readFileSync as readFileSync2 } from "node:fs";
3495
- import { dirname as dirname3, isAbsolute, join as join3, relative, resolve, sep } from "node:path";
3496
-
3497
- // ../core/dist/types.js
3498
- var SEVERITIES = ["low", "medium", "high"];
3499
- var STATUSES = ["open", "resolved", "dismissed", "deleted"];
3500
- var DELETED_STATUS = "deleted";
3501
- var MESSAGE_MAX = 4e3;
3502
- var RESOLUTION_MAX = 500;
3503
- var TAGS_MAX = 10;
3504
- var TAG_MAX = 32;
3505
- var ALLOWED_TAGS_MAX = 50;
3506
- var OTHER_TAG = "other";
3507
- var OTHER_TAG_PREFIX = `${OTHER_TAG}:`;
3508
- var OTHER_TAG_HINT = `${OTHER_TAG_PREFIX}<suggestion>`;
3509
- var ALLOWED_TAG_RE = /^[a-z0-9][a-z0-9._-]*$/;
3510
- var AGENT_MAX = 64;
3511
- var REPORTER_MAX = 128;
3512
- var EMAIL_MAX = REPORTER_MAX;
3513
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3514
- var TOKEN_NAME_MAX = 64;
3515
- var CWD_MAX = 1024;
3516
- var UNTAGGED_TAG = "-";
3517
- var STATS_MAX_WINDOW_DAYS = 90;
3518
- var ROLES = ["filer", "viewer", "maintainer"];
3519
- var ORG_ROLES = ["member", "admin", "owner"];
3520
- var ORG_COLOURS = [
3521
- "slate",
3522
- "moss",
3523
- "ember",
3524
- "rust",
3525
- "teal",
3526
- "plum",
3527
- "snowcat",
3528
- "bone"
3529
- ];
3530
- var DEFAULT_ORG_COLOUR = "slate";
3531
- var ORG_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/;
3532
- var RESERVED_SLUGS = [
3533
- "api",
3534
- "admin",
3535
- "login",
3536
- "logout",
3537
- "o",
3538
- "orgs",
3539
- "new",
3540
- "platform",
3541
- "local",
3542
- "www",
3543
- "app",
3544
- "settings",
3545
- "signup"
3546
- ];
3547
- var LOCAL_ORG = "local";
3548
- var DISPLAY_NAME_MAX = 64;
3549
- var PROJECT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
3550
- var DEFAULT_LIMIT = 50;
3551
- var MAX_LIMIT = 200;
3552
- var DEFAULT_SEVERITY = "medium";
3553
- var DEFAULT_STATUS = "open";
3554
- var DEFAULT_AGENT = "unknown";
3555
- var DEFAULT_REPORTER = "unknown";
3556
- var ACT_AS_HEADER = "x-quibble-act-as";
3557
-
3558
- // ../core/dist/errors.js
3559
- var QuibbleError = class extends Error {
3560
- /** Stable machine-readable code, also used by the API error body. */
3561
- code;
3562
- constructor(code, message, options) {
3563
- super(message, options);
3564
- this.code = code;
3565
- this.name = new.target.name;
3566
- }
3567
- };
3568
- var ValidationError = class extends QuibbleError {
3569
- /** One readable line per problem, such as `message: must be 1-4000 characters`. */
3570
- issues;
3571
- constructor(issues, options) {
3572
- const list = issues.length > 0 ? issues : ["invalid input"];
3573
- super("validation_error", list.join("; "), options);
3574
- this.issues = [...list];
3575
- }
3576
- };
3577
- var StorageError = class extends QuibbleError {
3578
- constructor(message, options) {
3579
- super("storage_error", message, options);
3580
- }
3581
- };
3582
- var ConfigError = class extends QuibbleError {
3583
- /** The file that holds the bad configuration, when one exists. */
3584
- path;
3585
- constructor(message, path, options) {
3586
- super("config_error", message, options);
3587
- this.path = path;
3588
- }
3589
- };
3590
- function isNotFound(error62) {
3591
- return typeof error62 === "object" && error62 !== null && error62.code === "ENOENT";
3592
- }
3593
- function describeError(error62) {
3594
- return error62 instanceof Error ? error62.message : String(error62);
3595
- }
3596
-
3597
3493
  // ../../node_modules/.pnpm/zod@4.6.4/node_modules/zod/v4/classic/external.js
3598
3494
  var external_exports = {};
3599
3495
  __export(external_exports, {
@@ -6407,9 +6303,9 @@ var asciiTabOrNewline = /[\t\n\r]/g;
6407
6303
  function stripTabAndNewline(value) {
6408
6304
  return value.replace(asciiTabOrNewline, "");
6409
6305
  }
6410
- function urlHostnameOk(url2, hostname3) {
6411
- hostname3.lastIndex = 0;
6412
- return hostname3.test(url2.hostname);
6306
+ function urlHostnameOk(url2, hostname4) {
6307
+ hostname4.lastIndex = 0;
6308
+ return hostname4.test(url2.hostname);
6413
6309
  }
6414
6310
  function urlProtocolOk(url2, protocol) {
6415
6311
  protocol.lastIndex = 0;
@@ -6682,12 +6578,12 @@ function isIso7064Mod97(iban3) {
6682
6578
  let remainder = 0;
6683
6579
  const len = iban3.length;
6684
6580
  for (let i = 4; i < len; i++) {
6685
- const code = iban3.charCodeAt(i);
6686
- remainder = (code >= 65 ? remainder * 100 + (code - 55) : remainder * 10 + (code - 48)) % 97;
6581
+ const code2 = iban3.charCodeAt(i);
6582
+ remainder = (code2 >= 65 ? remainder * 100 + (code2 - 55) : remainder * 10 + (code2 - 48)) % 97;
6687
6583
  }
6688
6584
  for (let i = 0; i < 4; i++) {
6689
- const code = iban3.charCodeAt(i);
6690
- remainder = (code >= 65 ? remainder * 100 + (code - 55) : remainder * 10 + (code - 48)) % 97;
6585
+ const code2 = iban3.charCodeAt(i);
6586
+ remainder = (code2 >= 65 ? remainder * 100 + (code2 - 55) : remainder * 10 + (code2 - 48)) % 97;
6691
6587
  }
6692
6588
  return remainder === 1;
6693
6589
  }
@@ -7117,7 +7013,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
7117
7013
  }
7118
7014
  return propValues;
7119
7015
  });
7120
- const isObject2 = isObject;
7016
+ const isObject3 = isObject;
7121
7017
  const catchall = def.catchall;
7122
7018
  let value;
7123
7019
  const memo2 = globalConfig.memoizer;
@@ -7125,7 +7021,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
7125
7021
  inst._zod.parse = (payload, ctx) => {
7126
7022
  value ?? (value = _normalized.value);
7127
7023
  const input2 = payload.value;
7128
- if (!isObject2(input2)) {
7024
+ if (!isObject3(input2)) {
7129
7025
  payload.issues.push({
7130
7026
  expected: "object",
7131
7027
  code: "invalid_type",
@@ -7261,7 +7157,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
7261
7157
  return doc.compile();
7262
7158
  };
7263
7159
  let fastpass;
7264
- const isObject2 = isObject;
7160
+ const isObject3 = isObject;
7265
7161
  const jit = !globalConfig.jitless;
7266
7162
  const allowsEval2 = allowsEval;
7267
7163
  const fastEnabled = jit && allowsEval2.value;
@@ -7270,7 +7166,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
7270
7166
  inst._zod.parse = (payload, ctx) => {
7271
7167
  value ?? (value = _normalized.value);
7272
7168
  const input2 = payload.value;
7273
- if (!isObject2(input2)) {
7169
+ if (!isObject3(input2)) {
7274
7170
  payload.issues.push({
7275
7171
  expected: "object",
7276
7172
  code: "invalid_type",
@@ -8620,7 +8516,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
8620
8516
  var NONE = 0;
8621
8517
  var ASSUMED = 1;
8622
8518
  var PROVEN = 2;
8623
- function isRecursive(inst, stack, resolve3) {
8519
+ function isRecursive(inst, stack, resolve4) {
8624
8520
  const cached2 = recursive.get(inst);
8625
8521
  if (cached2 !== void 0)
8626
8522
  return cached2 ? PROVEN : NONE;
@@ -8630,7 +8526,7 @@ function isRecursive(inst, stack, resolve3) {
8630
8526
  let result = NONE;
8631
8527
  const check2 = (child) => {
8632
8528
  if (result !== PROVEN && child?._zod) {
8633
- const answer2 = isRecursive(child, stack, resolve3);
8529
+ const answer2 = isRecursive(child, stack, resolve4);
8634
8530
  if (answer2 > result)
8635
8531
  result = answer2;
8636
8532
  }
@@ -8641,7 +8537,7 @@ function isRecursive(inst, stack, resolve3) {
8641
8537
  const desc = Object.getOwnPropertyDescriptor(sh, key);
8642
8538
  if (spread && !desc.enumerable)
8643
8539
  continue;
8644
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve3) : NONE;
8540
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve4) : NONE;
8645
8541
  if (child > answer2)
8646
8542
  answer2 = child;
8647
8543
  }
@@ -8705,7 +8601,7 @@ function isRecursive(inst, stack, resolve3) {
8705
8601
  break;
8706
8602
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
8707
8603
  case "lazy": {
8708
- const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
8604
+ const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
8709
8605
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
8710
8606
  break;
8711
8607
  }
@@ -16605,12 +16501,12 @@ function compileFn(schema, options) {
16605
16501
  doc.write(outputAccessor === null ? `return true;` : `return ${outputAccessor};`);
16606
16502
  const constantNames = ["INVALID", ...ctx.constants.keys()];
16607
16503
  const constantValues = [INVALID, ...ctx.constants.values()];
16608
- const code = doc.content.join("\n");
16504
+ const code2 = doc.content.join("\n");
16609
16505
  const fullCode = options?.debug ? constantNames.length > 0 ? `// Constants: ${constantNames.join(", ")}
16610
- ${code}` : code : "";
16506
+ ${code2}` : code2 : "";
16611
16507
  const F = Function;
16612
16508
  const factoryCode = `return (input) => {
16613
- ${code}
16509
+ ${code2}
16614
16510
  }`;
16615
16511
  let fn;
16616
16512
  try {
@@ -19536,8 +19432,8 @@ function finalize(ctx, schema) {
19536
19432
  compactTypeUnion(entry[1].def ?? entry[1].schema);
19537
19433
  }
19538
19434
  }
19539
- for (const rewrite of ctx.deferred)
19540
- rewrite();
19435
+ for (const rewrite2 of ctx.deferred)
19436
+ rewrite2();
19541
19437
  if (ctx.intersections.length) {
19542
19438
  const carriers = /* @__PURE__ */ new Map();
19543
19439
  for (const seen of ctx.seen.values()) {
@@ -23262,66 +23158,292 @@ function date4(params) {
23262
23158
  return _coercedDate(ZodDate, params);
23263
23159
  }
23264
23160
 
23161
+ // ../core/dist/jitless.js
23162
+ external_exports.config({ jitless: true });
23163
+
23164
+ // ../core/dist/types.js
23165
+ var SEVERITIES = ["low", "medium", "high"];
23166
+ var STATUSES = ["open", "resolved", "dismissed", "deleted"];
23167
+ var DELETED_STATUS = "deleted";
23168
+ var REASONS = ["fixed", "duplicate", "wontfix", "noise"];
23169
+ var DUPLICATE_REASON = "duplicate";
23170
+ var MESSAGE_MAX = 4e3;
23171
+ var RESOLUTION_MAX = 500;
23172
+ var TAGS_MAX = 10;
23173
+ var TAG_MAX = 32;
23174
+ var ALLOWED_TAGS_MAX = 50;
23175
+ var OTHER_TAG = "other";
23176
+ var OTHER_TAG_PREFIX = `${OTHER_TAG}:`;
23177
+ var OTHER_TAG_HINT = `${OTHER_TAG_PREFIX}<suggestion>`;
23178
+ var ALLOWED_TAG_RE = /^[a-z0-9][a-z0-9._-]*$/;
23179
+ var AGENT_MAX = 64;
23180
+ var REPORTER_MAX = 128;
23181
+ var EMAIL_MAX = REPORTER_MAX;
23182
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23183
+ var TOKEN_NAME_MAX = 64;
23184
+ var CWD_MAX = 1024;
23185
+ var CMD_MAX = 1e3;
23186
+ var FINGERPRINT_MAX = 200;
23187
+ var EMPTY_FINGERPRINT = "<empty>";
23188
+ var RECURRENCE_WINDOW_DAYS = 90;
23189
+ var SEARCH_MAX = 200;
23190
+ var BULK_MAX = 100;
23191
+ var ID_MAX = 64;
23192
+ var UNTAGGED_TAG = "-";
23193
+ var STATS_MAX_WINDOW_DAYS = 90;
23194
+ var ROLES = ["filer", "viewer", "maintainer"];
23195
+ var ORG_ROLES = ["member", "admin", "owner"];
23196
+ var ORG_COLOURS = [
23197
+ "slate",
23198
+ "moss",
23199
+ "ember",
23200
+ "rust",
23201
+ "teal",
23202
+ "plum",
23203
+ "snowcat",
23204
+ "bone"
23205
+ ];
23206
+ var DEFAULT_ORG_COLOUR = "slate";
23207
+ var ORG_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/;
23208
+ var RESERVED_SLUGS = [
23209
+ "api",
23210
+ "admin",
23211
+ "login",
23212
+ "logout",
23213
+ "o",
23214
+ "orgs",
23215
+ "new",
23216
+ "platform",
23217
+ "local",
23218
+ "www",
23219
+ "app",
23220
+ "settings",
23221
+ "signup"
23222
+ ];
23223
+ var LOCAL_ORG = "local";
23224
+ var DISPLAY_NAME_MAX = 64;
23225
+ var PROJECT_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
23226
+ var DEFAULT_LIMIT = 50;
23227
+ var MAX_LIMIT = 200;
23228
+ var DEFAULT_SEVERITY = "medium";
23229
+ var DEFAULT_STATUS = "open";
23230
+ var DEFAULT_AGENT = "unknown";
23231
+ var DEFAULT_REPORTER = "unknown";
23232
+ var ACT_AS_HEADER = "x-quibble-act-as";
23233
+ var TRUNCATED_HEADER = "x-quibble-truncated";
23234
+ var IDEMPOTENCY_HEADER = "idempotency-key";
23235
+ var IDEMPOTENCY_KEY_MAX = 128;
23236
+
23237
+ // ../core/dist/errors.js
23238
+ var QuibbleError = class extends Error {
23239
+ /** Stable machine-readable code, also used by the API error body. */
23240
+ code;
23241
+ constructor(code2, message, options) {
23242
+ super(message, options);
23243
+ this.code = code2;
23244
+ this.name = new.target.name;
23245
+ }
23246
+ };
23247
+ var ValidationError = class extends QuibbleError {
23248
+ /** One readable line per problem, such as `message: must be 1-4000 characters`. */
23249
+ issues;
23250
+ constructor(issues, options) {
23251
+ const list = issues.length > 0 ? issues : ["invalid input"];
23252
+ super("validation_error", list.join("; "), options);
23253
+ this.issues = [...list];
23254
+ }
23255
+ };
23256
+ var StorageError = class extends QuibbleError {
23257
+ constructor(message, options) {
23258
+ super("storage_error", message, options);
23259
+ }
23260
+ };
23261
+ var DuplicateIdError = class extends StorageError {
23262
+ /** The id that a record already holds. */
23263
+ id;
23264
+ /**
23265
+ * The record that won, when the store read it back, so the caller answers
23266
+ * it without a read of its own that an index may not see yet.
23267
+ */
23268
+ existing;
23269
+ constructor(id, existing, options) {
23270
+ super(`complaint ${id} already exists`, options);
23271
+ this.id = id;
23272
+ this.existing = existing;
23273
+ }
23274
+ };
23275
+ var ConfigError = class extends QuibbleError {
23276
+ /** The file that holds the bad configuration, when one exists. */
23277
+ path;
23278
+ constructor(message, path, options) {
23279
+ super("config_error", message, options);
23280
+ this.path = path;
23281
+ }
23282
+ };
23283
+ function isNotFound(error62) {
23284
+ return typeof error62 === "object" && error62 !== null && error62.code === "ENOENT";
23285
+ }
23286
+ function describeError(error62) {
23287
+ return error62 instanceof Error ? error62.message : String(error62);
23288
+ }
23289
+
23290
+ // ../core/dist/fingerprint.js
23291
+ function headlineOf(message) {
23292
+ for (const line of message.split("\n")) {
23293
+ const trimmed = line.trim();
23294
+ if (trimmed !== "")
23295
+ return trimmed;
23296
+ }
23297
+ return "";
23298
+ }
23299
+ var WORD_CHAR = "[\\p{L}\\p{N}]";
23300
+ var QUOTED_RE = new RegExp(`"[^"]*"|\`[^\`]*\`|(?<!${WORD_CHAR})'.*?'(?!${WORD_CHAR})`, "gu");
23301
+ var PATH_RE = /[^\s]*[/\\][^\s]*/gu;
23302
+ var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gu;
23303
+ var HEX_LITERAL_RE = /0x[0-9a-f]+/gu;
23304
+ var HEX_RUN_RE = new RegExp(`(?<!${WORD_CHAR})[0-9a-f]{7,}(?!${WORD_CHAR})`, "gu");
23305
+ var NUMBER_RE = new RegExp("\\p{N}+(?:\\.\\p{N}+)*", "gu");
23306
+ var PUNCTUATION_RE = /[^\p{L}\p{N}\s<>]/gu;
23307
+ function isHexId(run2) {
23308
+ return /[a-f]/.test(run2) && /[0-9]/.test(run2);
23309
+ }
23310
+ function cut(text, max) {
23311
+ if (text.length <= max)
23312
+ return text;
23313
+ const head = text.slice(0, max);
23314
+ const last = head.charCodeAt(head.length - 1);
23315
+ return last >= 55296 && last <= 56319 ? head.slice(0, -1) : head;
23316
+ }
23317
+ function fingerprintOf(message) {
23318
+ let text = headlineOf(message).toLowerCase();
23319
+ text = text.replace(/[<>]/gu, " ");
23320
+ text = text.replace(QUOTED_RE, " <str> ");
23321
+ text = text.replace(PATH_RE, "<path>");
23322
+ text = text.replace(UUID_RE, "<hex>");
23323
+ text = text.replace(HEX_LITERAL_RE, "<hex>");
23324
+ text = text.replace(HEX_RUN_RE, (run2) => isHexId(run2) ? "<hex>" : run2);
23325
+ text = text.replace(NUMBER_RE, "<n>");
23326
+ text = text.replace(PUNCTUATION_RE, " ");
23327
+ text = text.replace(/\s+/gu, " ").trim();
23328
+ text = cut(text, FINGERPRINT_MAX).trim();
23329
+ return text === "" ? EMPTY_FINGERPRINT : text;
23330
+ }
23331
+ function groupComplaints(complaints) {
23332
+ const groups = /* @__PURE__ */ new Map();
23333
+ for (const complaint of complaints) {
23334
+ const key = `${complaint.org}/${complaint.project}
23335
+ ${complaint.fingerprint}`;
23336
+ const group = groups.get(key);
23337
+ const recurred = complaint.status === "open" && complaint.recurred === true;
23338
+ if (group === void 0) {
23339
+ groups.set(key, {
23340
+ project: complaint.project,
23341
+ fingerprint: complaint.fingerprint,
23342
+ head: complaint,
23343
+ members: [complaint],
23344
+ count: 1,
23345
+ recurred
23346
+ });
23347
+ continue;
23348
+ }
23349
+ group.members.push(complaint);
23350
+ group.count = group.members.length;
23351
+ group.recurred ||= recurred;
23352
+ }
23353
+ return [...groups.values()];
23354
+ }
23355
+
23265
23356
  // ../core/dist/validate.js
23266
- var NULLABLE_KEYS = [
23357
+ var NEW_NULLABLE_KEYS = [
23267
23358
  "severity",
23268
23359
  "tags",
23269
23360
  "agent",
23270
23361
  "reporter",
23271
23362
  "reporterName",
23272
23363
  "cwd",
23273
- "status",
23274
- "resolution",
23275
- "updatedAt"
23364
+ "cmd"
23276
23365
  ];
23277
- var BLANKABLE_KEYS = [
23366
+ var NEW_BLANKABLE_KEYS = [
23278
23367
  "severity",
23279
23368
  "agent",
23280
23369
  "reporter",
23281
23370
  "reporterName",
23371
+ "cmd"
23372
+ ];
23373
+ var STORED_STATUS_KEYS = [
23282
23374
  "status",
23283
23375
  "resolution",
23376
+ "reason",
23377
+ "duplicateOf",
23378
+ "statusChangedAt",
23379
+ "statusChangedBy",
23380
+ "statusChangedByName",
23284
23381
  "updatedAt"
23285
23382
  ];
23383
+ var STORED_NULLABLE_KEYS = [
23384
+ ...NEW_NULLABLE_KEYS,
23385
+ ...STORED_STATUS_KEYS,
23386
+ "fingerprint",
23387
+ "recurred"
23388
+ ];
23389
+ var STORED_BLANKABLE_KEYS = [
23390
+ ...NEW_BLANKABLE_KEYS,
23391
+ ...STORED_STATUS_KEYS,
23392
+ "fingerprint"
23393
+ ];
23286
23394
  var FILTER_KEYS = [
23287
23395
  "project",
23288
23396
  "severity",
23289
23397
  "status",
23290
- "tag",
23398
+ "tags",
23291
23399
  "reporter",
23400
+ "q",
23292
23401
  "since",
23293
23402
  "until",
23294
23403
  "limit"
23295
23404
  ];
23296
- var STATUS_CHANGE_KEYS = ["status", "resolution"];
23405
+ var FILTER_NULLABLE_KEYS = ["fingerprint"];
23406
+ var STATUS_CHANGE_KEYS = ["status", "resolution", "reason", "duplicateOf"];
23297
23407
  function isPlainObject2(value) {
23298
23408
  return typeof value === "object" && value !== null && !Array.isArray(value);
23299
23409
  }
23300
23410
  function isBlank(value) {
23301
23411
  return typeof value === "string" && value.trim() === "";
23302
23412
  }
23303
- function normalizeComplaintInput(value) {
23413
+ function complaintInput(nullable2, blankable) {
23414
+ return (value) => {
23415
+ if (!isPlainObject2(value))
23416
+ return value;
23417
+ const out = { ...value };
23418
+ for (const key of nullable2) {
23419
+ if (out[key] === null)
23420
+ delete out[key];
23421
+ }
23422
+ for (const key of blankable) {
23423
+ if (isBlank(out[key]))
23424
+ delete out[key];
23425
+ }
23426
+ if (isBlank(out["cwd"]))
23427
+ out["cwd"] = ".";
23428
+ return out;
23429
+ };
23430
+ }
23431
+ function normalizeKeys(value, keys) {
23304
23432
  if (!isPlainObject2(value))
23305
23433
  return value;
23306
23434
  const out = { ...value };
23307
- for (const key of NULLABLE_KEYS) {
23308
- if (out[key] === null)
23309
- delete out[key];
23310
- }
23311
- for (const key of BLANKABLE_KEYS) {
23312
- if (isBlank(out[key]))
23435
+ for (const key of keys) {
23436
+ if (out[key] === null || isBlank(out[key]))
23313
23437
  delete out[key];
23314
23438
  }
23315
- if (isBlank(out["cwd"]))
23316
- out["cwd"] = ".";
23317
23439
  return out;
23318
23440
  }
23319
- function normalizeKeys(value, keys) {
23441
+ function dropNulls(value, keys) {
23320
23442
  if (!isPlainObject2(value))
23321
23443
  return value;
23322
23444
  const out = { ...value };
23323
23445
  for (const key of keys) {
23324
- if (out[key] === null || isBlank(out[key]))
23446
+ if (out[key] === null)
23325
23447
  delete out[key];
23326
23448
  }
23327
23449
  return out;
@@ -23333,6 +23455,12 @@ function boundedString(max, typeMessage) {
23333
23455
  const message = lengthMessage(1, max);
23334
23456
  return external_exports.string({ message: typeMessage ?? message }).trim().min(1, { message }).max(max, { message });
23335
23457
  }
23458
+ var CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/;
23459
+ var CONTROL_MESSAGE = "must not contain control characters";
23460
+ function freeText(max, typeMessage) {
23461
+ const message = lengthMessage(1, max);
23462
+ return external_exports.string({ message: typeMessage ?? message }).overwrite((value) => value.replace(/\r\n?/g, "\n")).trim().min(1, { message }).max(max, { message }).refine((value) => !CONTROL_RE.test(value), { message: CONTROL_MESSAGE });
23463
+ }
23336
23464
  var SEVERITY_MESSAGE = `must be one of ${SEVERITIES.join(", ")}`;
23337
23465
  var STATUS_MESSAGE = `must be one of ${STATUSES.join(", ")}`;
23338
23466
  var LIMIT_MESSAGE = `must be an integer between 1 and ${MAX_LIMIT}`;
@@ -23348,13 +23476,20 @@ var timestampSchema = external_exports.string({ message: TIMESTAMP_MESSAGE }).re
23348
23476
  function toMilliseconds(timestamp) {
23349
23477
  return new Date(timestamp).toISOString();
23350
23478
  }
23351
- var projectNameSchema = external_exports.string({ message: PROJECT_MESSAGE }).regex(PROJECT_NAME_RE, { message: PROJECT_MESSAGE });
23479
+ function foldProjectName(name) {
23480
+ return name.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
23481
+ }
23482
+ var projectNameSchema = external_exports.string({ message: PROJECT_MESSAGE }).overwrite(foldProjectName).regex(PROJECT_NAME_RE, { message: PROJECT_MESSAGE });
23352
23483
  var orgSlugSchema = external_exports.string({ message: SLUG_MESSAGE }).refine((value) => ORG_SLUG_RE.test(value) && !value.includes("--"), {
23353
23484
  message: SLUG_MESSAGE
23354
23485
  });
23355
23486
  var severitySchema = external_exports.enum(SEVERITIES, { message: SEVERITY_MESSAGE });
23356
23487
  var statusSchema = external_exports.enum(STATUSES, { message: STATUS_MESSAGE });
23357
- var resolutionSchema = boundedString(RESOLUTION_MAX);
23488
+ var REASON_MESSAGE = `must be one of ${REASONS.join(", ")}`;
23489
+ var reasonSchema = external_exports.enum(REASONS, { message: REASON_MESSAGE });
23490
+ var idSchema = boundedString(ID_MAX, ID_MESSAGE);
23491
+ var resolutionSchema = freeText(RESOLUTION_MAX);
23492
+ var storedResolutionSchema = boundedString(RESOLUTION_MAX);
23358
23493
  var TAG_LENGTH_MESSAGE = lengthMessage(1, TAG_MAX);
23359
23494
  var SUGGESTION_LENGTH_MESSAGE = `must be at most ${TAG_MAX} characters after "${OTHER_TAG_PREFIX}"`;
23360
23495
  var tagSchema = external_exports.string({ message: "must be a string" }).trim().min(1, { message: TAG_LENGTH_MESSAGE }).superRefine((tag, ctx) => {
@@ -23370,8 +23505,8 @@ var tagSchema = external_exports.string({ message: "must be a string" }).trim().
23370
23505
  }
23371
23506
  });
23372
23507
  var TAGS_TYPE_MESSAGE = "must be an array of strings";
23373
- var UNTAGGED_TAG_MESSAGE = `must not be "${UNTAGGED_TAG}", which selects the complaints with no tag`;
23374
- var RESERVED_TAG_MESSAGE = `must not be "${OTHER_TAG}" or start with "${OTHER_TAG_PREFIX}", which are reserved`;
23508
+ var UNTAGGED_TAG_MESSAGE = `must not be "${UNTAGGED_TAG}" (reserved for untagged)`;
23509
+ var RESERVED_TAG_MESSAGE = `must not be "${OTHER_TAG}" or start with "${OTHER_TAG_PREFIX}" (reserved)`;
23375
23510
  var ALLOWED_TAG_MESSAGE = 'must use only letters, digits, ".", "_" and "-", and start with a letter or digit';
23376
23511
  function normalizeTag(tag) {
23377
23512
  const lower = tag.toLowerCase();
@@ -23386,7 +23521,7 @@ var complaintTagSchema = tagSchema.refine((tag) => tag !== UNTAGGED_TAG, {
23386
23521
  var tagsSchema = external_exports.array(complaintTagSchema, { message: TAGS_TYPE_MESSAGE }).transform(normalizeTags).refine((tags) => tags.length <= TAGS_MAX, {
23387
23522
  message: `must have at most ${TAGS_MAX} tags`
23388
23523
  });
23389
- var tagsChangeObject = external_exports.object({
23524
+ var tagsChangeObject = external_exports.strictObject({
23390
23525
  tags: tagsSchema.refine((tags) => tags.length > 0, {
23391
23526
  message: "must list at least 1 tag"
23392
23527
  })
@@ -23474,17 +23609,21 @@ var limitSchema = external_exports.preprocess((value) => {
23474
23609
  const parsed = Number(trimmed);
23475
23610
  return Number.isNaN(parsed) ? value : parsed;
23476
23611
  }, external_exports.number({ message: LIMIT_MESSAGE }).int({ message: LIMIT_MESSAGE }).min(1, { message: LIMIT_MESSAGE }).max(MAX_LIMIT, { message: LIMIT_MESSAGE }));
23477
- var newComplaintObject = external_exports.object({
23612
+ var agentSchema = freeText(AGENT_MAX);
23613
+ var newComplaintShape = {
23478
23614
  org: orgSlugSchema,
23479
23615
  project: projectNameSchema,
23480
- message: boundedString(MESSAGE_MAX),
23616
+ message: freeText(MESSAGE_MAX),
23481
23617
  severity: severitySchema.optional(),
23482
23618
  tags: tagsSchema.optional(),
23483
- agent: boundedString(AGENT_MAX).optional(),
23484
- reporter: boundedString(REPORTER_MAX).optional(),
23485
- reporterName: boundedString(REPORTER_MAX).optional(),
23486
- cwd: cwdSchema.optional()
23487
- });
23619
+ agent: agentSchema.optional(),
23620
+ reporter: freeText(REPORTER_MAX).optional(),
23621
+ reporterName: freeText(REPORTER_MAX).optional(),
23622
+ cwd: cwdSchema.optional(),
23623
+ // Free text: it is trimmed and may hold newlines, and nothing parses it.
23624
+ cmd: freeText(CMD_MAX).optional()
23625
+ };
23626
+ var newComplaintObject = external_exports.strictObject(newComplaintShape);
23488
23627
  function withDefaults(input2) {
23489
23628
  return {
23490
23629
  org: input2.org,
@@ -23496,74 +23635,207 @@ function withDefaults(input2) {
23496
23635
  reporter: input2.reporter ?? DEFAULT_REPORTER,
23497
23636
  // The server sets the display name, so it stays absent when nobody did.
23498
23637
  ...input2.reporterName === void 0 ? {} : { reporterName: input2.reporterName },
23499
- ...input2.cwd === void 0 ? {} : { cwd: input2.cwd }
23638
+ ...input2.cwd === void 0 ? {} : { cwd: input2.cwd },
23639
+ ...input2.cmd === void 0 ? {} : { cmd: input2.cmd }
23500
23640
  };
23501
23641
  }
23502
- var newComplaintSchema = external_exports.preprocess(normalizeComplaintInput, newComplaintObject).transform(withDefaults);
23503
- var storedComplaintObject = newComplaintObject.extend({
23642
+ var newComplaintSchema = external_exports.preprocess(complaintInput(NEW_NULLABLE_KEYS, NEW_BLANKABLE_KEYS), newComplaintObject).transform(withDefaults);
23643
+ var actorSchema = boundedString(REPORTER_MAX);
23644
+ var storedComplaintObject = external_exports.object({
23645
+ ...newComplaintShape,
23646
+ // The free text of a stored record is read without the control rule, so a
23647
+ // record written before it stays readable.
23648
+ message: boundedString(MESSAGE_MAX),
23649
+ agent: boundedString(AGENT_MAX).optional(),
23650
+ reporter: boundedString(REPORTER_MAX).optional(),
23651
+ reporterName: boundedString(REPORTER_MAX).optional(),
23652
+ cmd: boundedString(CMD_MAX).optional(),
23504
23653
  id: external_exports.string({ message: ID_MESSAGE }).trim().min(1, { message: ID_MESSAGE }),
23654
+ fingerprint: external_exports.string({ message: ID_MESSAGE }).min(1, { message: ID_MESSAGE }).optional(),
23655
+ recurred: external_exports.boolean({ message: "must be a boolean" }).optional(),
23505
23656
  createdAt: timestampSchema,
23506
23657
  status: statusSchema.optional(),
23507
- resolution: resolutionSchema.optional(),
23658
+ resolution: storedResolutionSchema.optional(),
23659
+ reason: reasonSchema.optional(),
23660
+ duplicateOf: idSchema.optional(),
23661
+ statusChangedAt: timestampSchema.optional(),
23662
+ statusChangedBy: actorSchema.optional(),
23663
+ statusChangedByName: actorSchema.optional(),
23508
23664
  updatedAt: timestampSchema.optional()
23509
23665
  });
23510
- var complaintSchema = external_exports.preprocess(normalizeComplaintInput, storedComplaintObject).transform((input2) => ({
23666
+ var complaintSchema = external_exports.preprocess(complaintInput(STORED_NULLABLE_KEYS, STORED_BLANKABLE_KEYS), storedComplaintObject).transform((input2) => ({
23511
23667
  id: input2.id,
23512
23668
  ...withDefaults(input2),
23669
+ fingerprint: input2.fingerprint ?? fingerprintOf(input2.message),
23670
+ // The store writes the flag only when it is true.
23671
+ ...input2.recurred === true ? { recurred: true } : {},
23513
23672
  // Millisecond precision, so a record written without a fraction still
23514
23673
  // compares by time against a `since` and an `until`.
23515
23674
  createdAt: toMilliseconds(input2.createdAt),
23516
23675
  status: input2.status ?? DEFAULT_STATUS,
23517
23676
  ...input2.resolution === void 0 ? {} : { resolution: input2.resolution },
23677
+ ...input2.reason === void 0 ? {} : { reason: input2.reason },
23678
+ ...input2.duplicateOf === void 0 ? {} : { duplicateOf: input2.duplicateOf },
23679
+ ...input2.statusChangedAt === void 0 ? {} : { statusChangedAt: toMilliseconds(input2.statusChangedAt) },
23680
+ ...input2.statusChangedBy === void 0 ? {} : { statusChangedBy: input2.statusChangedBy },
23681
+ ...input2.statusChangedByName === void 0 ? {} : { statusChangedByName: input2.statusChangedByName },
23518
23682
  ...input2.updatedAt === void 0 ? {} : { updatedAt: toMilliseconds(input2.updatedAt) }
23519
23683
  }));
23520
- var statusChangeObject = external_exports.object({
23684
+ var REASON_STATUS_MESSAGE = "only a resolve or a dismiss takes a reason";
23685
+ var DUPLICATE_OF_MESSAGE = `needs reason ${DUPLICATE_REASON}`;
23686
+ var statusChangeShape = {
23521
23687
  status: statusSchema,
23522
- resolution: resolutionSchema.optional()
23523
- });
23524
- var statusChangeSchema = external_exports.preprocess((value) => normalizeKeys(value, STATUS_CHANGE_KEYS), statusChangeObject).transform((input2) => ({
23525
- status: input2.status,
23526
- ...input2.resolution === void 0 ? {} : { resolution: input2.resolution }
23527
- }));
23528
- var BEFORE_UNTIL_MESSAGE = "must be before until";
23529
- function checkWindow(window, ctx) {
23530
- const { since, until } = window;
23531
- if (since === void 0 || until === void 0)
23532
- return;
23533
- if (Date.parse(since) < Date.parse(until))
23534
- return;
23535
- ctx.addIssue({ code: "custom", path: ["since"], message: BEFORE_UNTIL_MESSAGE });
23536
- }
23537
- var listFilterObject = external_exports.object({
23688
+ resolution: resolutionSchema.optional(),
23689
+ reason: reasonSchema.optional(),
23690
+ duplicateOf: idSchema.optional()
23691
+ };
23692
+ function checkReason(change, ctx) {
23693
+ if (change.reason !== void 0 && change.status !== "resolved" && change.status !== "dismissed") {
23694
+ ctx.addIssue({ code: "custom", path: ["reason"], message: REASON_STATUS_MESSAGE });
23695
+ }
23696
+ if (change.duplicateOf !== void 0 && change.reason !== DUPLICATE_REASON) {
23697
+ ctx.addIssue({
23698
+ code: "custom",
23699
+ path: ["duplicateOf"],
23700
+ message: DUPLICATE_OF_MESSAGE
23701
+ });
23702
+ }
23703
+ }
23704
+ function toStatusChange(input2) {
23705
+ return {
23706
+ status: input2.status,
23707
+ ...input2.resolution === void 0 ? {} : { resolution: input2.resolution },
23708
+ ...input2.reason === void 0 ? {} : { reason: input2.reason },
23709
+ ...input2.duplicateOf === void 0 ? {} : { duplicateOf: input2.duplicateOf }
23710
+ };
23711
+ }
23712
+ var statusChangeSchema = external_exports.preprocess((value) => normalizeKeys(value, STATUS_CHANGE_KEYS), external_exports.strictObject(statusChangeShape).superRefine(checkReason)).transform(toStatusChange);
23713
+ var BULK_IDS_MESSAGE = `must list 1 to ${BULK_MAX} ids`;
23714
+ var BULK_BOTH_MESSAGE = "must change the status or the tags, not both";
23715
+ var BULK_NEITHER_MESSAGE = "must carry a status, addTags or removeTags";
23716
+ var BULK_NO_TAG_MESSAGE = "must carry at least 1 tag in addTags or removeTags";
23717
+ var BULK_TAG_KEYS = ["addTags", "removeTags"];
23718
+ var bulkIdsSchema = external_exports.array(idSchema, { message: BULK_IDS_MESSAGE }).min(1, { message: BULK_IDS_MESSAGE }).max(BULK_MAX, { message: BULK_IDS_MESSAGE }).transform((ids) => [...new Set(ids)]);
23719
+ var bulkChangeSchema = external_exports.preprocess((value) => dropNulls(normalizeKeys(value, STATUS_CHANGE_KEYS), BULK_TAG_KEYS), external_exports.strictObject({
23720
+ ids: bulkIdsSchema,
23721
+ status: statusChangeShape.status.optional(),
23722
+ resolution: statusChangeShape.resolution,
23723
+ reason: statusChangeShape.reason,
23724
+ duplicateOf: statusChangeShape.duplicateOf,
23725
+ addTags: tagsSchema.optional(),
23726
+ removeTags: tagsSchema.optional()
23727
+ }).superRefine((input2, ctx) => {
23728
+ const status = STATUS_CHANGE_KEYS.some((key) => input2[key] !== void 0);
23729
+ const tags = BULK_TAG_KEYS.some((key) => input2[key] !== void 0);
23730
+ if (status && tags) {
23731
+ ctx.addIssue({ code: "custom", message: BULK_BOTH_MESSAGE });
23732
+ return;
23733
+ }
23734
+ if (!status && !tags) {
23735
+ ctx.addIssue({ code: "custom", message: BULK_NEITHER_MESSAGE });
23736
+ return;
23737
+ }
23738
+ if (status) {
23739
+ if (input2.status === void 0) {
23740
+ ctx.addIssue({ code: "custom", path: ["status"], message: STATUS_MESSAGE });
23741
+ return;
23742
+ }
23743
+ checkReason({ ...input2, status: input2.status }, ctx);
23744
+ return;
23745
+ }
23746
+ const addTags = input2.addTags ?? [];
23747
+ const removeTags = input2.removeTags ?? [];
23748
+ if (addTags.length + removeTags.length === 0) {
23749
+ ctx.addIssue({ code: "custom", message: BULK_NO_TAG_MESSAGE });
23750
+ return;
23751
+ }
23752
+ for (const tag of removeTags) {
23753
+ if (!addTags.includes(tag))
23754
+ continue;
23755
+ ctx.addIssue({
23756
+ code: "custom",
23757
+ path: ["removeTags"],
23758
+ message: `${tag} is also in addTags`
23759
+ });
23760
+ }
23761
+ })).transform((input2) => {
23762
+ if (input2.status !== void 0) {
23763
+ return {
23764
+ ids: input2.ids,
23765
+ kind: "status",
23766
+ change: toStatusChange({ ...input2, status: input2.status })
23767
+ };
23768
+ }
23769
+ return {
23770
+ ids: input2.ids,
23771
+ kind: "tags",
23772
+ addTags: input2.addTags ?? [],
23773
+ removeTags: input2.removeTags ?? []
23774
+ };
23775
+ });
23776
+ var BEFORE_UNTIL_MESSAGE = "must be before until";
23777
+ function checkWindow(window, ctx) {
23778
+ const { since, until } = window;
23779
+ if (since === void 0 || until === void 0)
23780
+ return;
23781
+ if (Date.parse(since) < Date.parse(until))
23782
+ return;
23783
+ ctx.addIssue({ code: "custom", path: ["since"], message: BEFORE_UNTIL_MESSAGE });
23784
+ }
23785
+ var filterTagsSchema = external_exports.array(tagSchema, { message: TAGS_TYPE_MESSAGE }).transform(normalizeTags).refine((tags) => tags.length > 0, { message: "must list at least 1 tag" }).refine((tags) => tags.length <= TAGS_MAX, {
23786
+ message: `must have at most ${TAGS_MAX} tags`
23787
+ });
23788
+ var FINGERPRINT_MESSAGE = lengthMessage(1, FINGERPRINT_MAX);
23789
+ var listFilterShape = {
23538
23790
  org: orgSlugSchema,
23539
23791
  project: projectNameSchema.optional(),
23540
23792
  severity: severitySchema.optional(),
23541
23793
  status: statusSchema.optional(),
23542
- // The filter spends `UNTAGGED_TAG` on the complaints with no tag.
23543
- tag: tagSchema.optional(),
23794
+ tags: filterTagsSchema.optional(),
23544
23795
  reporter: boundedString(REPORTER_MAX).optional(),
23796
+ // Exact, so it is never trimmed, and never empty, because no fingerprint is.
23797
+ fingerprint: external_exports.string({ message: FINGERPRINT_MESSAGE }).min(1, { message: FINGERPRINT_MESSAGE }).max(FINGERPRINT_MAX, { message: FINGERPRINT_MESSAGE }).optional(),
23798
+ // The search is case-insensitive in every store.
23799
+ q: boundedString(SEARCH_MAX).transform((value) => value.toLowerCase()).optional(),
23545
23800
  since: timestampSchema.optional(),
23546
23801
  until: timestampSchema.optional(),
23547
23802
  limit: limitSchema.optional()
23548
- }).superRefine(checkWindow);
23549
- var listFilterSchema = external_exports.preprocess((value) => normalizeKeys(value ?? {}, FILTER_KEYS), listFilterObject).transform((input2) => ({
23803
+ };
23804
+ var LIST_FILTER_KEYS = Object.keys(listFilterShape);
23805
+ var listFilterObject = external_exports.strictObject(listFilterShape).superRefine(checkWindow);
23806
+ var listFilterSchema = external_exports.preprocess((value) => dropNulls(normalizeKeys(value ?? {}, FILTER_KEYS), FILTER_NULLABLE_KEYS), listFilterObject).transform((input2) => ({
23550
23807
  org: input2.org,
23551
23808
  ...input2.project === void 0 ? {} : { project: input2.project },
23552
23809
  ...input2.severity === void 0 ? {} : { severity: input2.severity },
23553
23810
  ...input2.status === void 0 ? {} : { status: input2.status },
23554
- // The tag is normalized like a stored one, so `other: network` matches.
23555
- ...input2.tag === void 0 ? {} : { tag: normalizeTag(input2.tag) },
23811
+ ...input2.tags === void 0 ? {} : { tags: input2.tags },
23556
23812
  ...input2.reporter === void 0 ? {} : { reporter: input2.reporter },
23813
+ ...input2.fingerprint === void 0 ? {} : { fingerprint: input2.fingerprint },
23814
+ ...input2.q === void 0 ? {} : { q: input2.q },
23557
23815
  ...input2.since === void 0 ? {} : { since: toMilliseconds(input2.since) },
23558
23816
  ...input2.until === void 0 ? {} : { until: toMilliseconds(input2.until) },
23559
23817
  limit: input2.limit ?? DEFAULT_LIMIT
23560
23818
  }));
23561
23819
  var DAY_MS = 24 * 60 * 60 * 1e3;
23562
23820
  var WINDOW_MESSAGE = `the window must not exceed ${STATS_MAX_WINDOW_DAYS} days`;
23563
- var statsQueryObject = external_exports.object({
23821
+ var ALL_WINDOW = "all";
23822
+ var ALL_WINDOW_MESSAGE = `must be ${ALL_WINDOW}`;
23823
+ var ALL_WINDOW_BOUNDS_MESSAGE = `${ALL_WINDOW} takes no since or until`;
23824
+ var statsQueryObject = external_exports.strictObject({
23564
23825
  since: timestampSchema.optional(),
23565
23826
  until: timestampSchema.optional(),
23566
- status: statusSchema.optional()
23827
+ status: statusSchema.optional(),
23828
+ window: external_exports.literal(ALL_WINDOW, { message: ALL_WINDOW_MESSAGE }).optional()
23829
+ }).superRefine((input2, ctx) => {
23830
+ if (input2.window === void 0)
23831
+ return;
23832
+ if (input2.since === void 0 && input2.until === void 0)
23833
+ return;
23834
+ ctx.addIssue({
23835
+ code: "custom",
23836
+ path: ["window"],
23837
+ message: ALL_WINDOW_BOUNDS_MESSAGE
23838
+ });
23567
23839
  });
23568
23840
  var ROLE_MESSAGE = `must be one of ${ROLES.join(", ")}`;
23569
23841
  var ORG_ROLE_MESSAGE = `must be one of ${ORG_ROLES.join(", ")}`;
@@ -23576,8 +23848,8 @@ var orgRoleSchema = external_exports.enum(ORG_ROLES, { message: ORG_ROLE_MESSAGE
23576
23848
  var colourSchema = external_exports.enum(ORG_COLOURS, { message: COLOUR_MESSAGE });
23577
23849
  var emailSchema = boundedString(EMAIL_MAX, EMAIL_MESSAGE).refine((value) => EMAIL_RE.test(value), { message: EMAIL_MESSAGE });
23578
23850
  var inviteEmailSchema = emailSchema.transform((value) => value.toLowerCase());
23579
- var displayNameSchema = boundedString(DISPLAY_NAME_MAX);
23580
- var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, ["colour"]), external_exports.object({
23851
+ var displayNameSchema = freeText(DISPLAY_NAME_MAX);
23852
+ var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, ["colour"]), external_exports.strictObject({
23581
23853
  slug: orgSlugSchema.refine((value) => !RESERVED_SLUGS.some((reserved) => reserved === value), { message: RESERVED_SLUG_MESSAGE }),
23582
23854
  name: displayNameSchema,
23583
23855
  colour: colourSchema.optional()
@@ -23586,18 +23858,21 @@ var newOrgSchema = external_exports.preprocess((value) => normalizeKeys(value, [
23586
23858
  name: input2.name,
23587
23859
  colour: input2.colour ?? DEFAULT_ORG_COLOUR
23588
23860
  }));
23589
- var orgPatchSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "colour"]), external_exports.object({ name: displayNameSchema.optional(), colour: colourSchema.optional() })).transform((input2) => ({
23861
+ var orgPatchSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "colour"]), external_exports.strictObject({
23862
+ name: displayNameSchema.optional(),
23863
+ colour: colourSchema.optional()
23864
+ })).transform((input2) => ({
23590
23865
  ...input2.name === void 0 ? {} : { name: input2.name },
23591
23866
  ...input2.colour === void 0 ? {} : { colour: input2.colour }
23592
23867
  })).refine((patch) => Object.keys(patch).length > 0, { message: ORG_PATCH_MESSAGE });
23593
23868
  var PLATFORM_PATCH_MESSAGE = "must set disabled, caps, or both";
23594
23869
  var CAP_MESSAGE = "must be a positive whole number";
23595
- var orgCapsSchema = external_exports.object({
23870
+ var orgCapsSchema = external_exports.strictObject({
23596
23871
  projectsPerOrg: external_exports.number({ message: CAP_MESSAGE }).int({ message: CAP_MESSAGE }).min(1, { message: CAP_MESSAGE }).optional()
23597
23872
  }).transform((input2) => ({
23598
23873
  ...input2.projectsPerOrg === void 0 ? {} : { projectsPerOrg: input2.projectsPerOrg }
23599
23874
  }));
23600
- var platformOrgPatchSchema = external_exports.object({
23875
+ var platformOrgPatchSchema = external_exports.strictObject({
23601
23876
  disabled: external_exports.boolean({ message: "must be a boolean" }).optional(),
23602
23877
  caps: orgCapsSchema.nullable().optional()
23603
23878
  }).transform((input2) => ({
@@ -23606,30 +23881,41 @@ var platformOrgPatchSchema = external_exports.object({
23606
23881
  })).refine((patch) => Object.keys(patch).length > 0, {
23607
23882
  message: PLATFORM_PATCH_MESSAGE
23608
23883
  });
23609
- var orgInviteSchema = external_exports.object({ email: inviteEmailSchema, role: orgRoleSchema }).transform((input2) => ({ email: input2.email, role: input2.role }));
23610
- var orgRoleChangeSchema = external_exports.object({ role: orgRoleSchema }).transform((input2) => ({ role: input2.role }));
23611
- var profileSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name"]), external_exports.object({ name: displayNameSchema })).transform((input2) => ({ name: input2.name }));
23612
- var newProjectSchema = external_exports.preprocess((value) => normalizeKeys(value, ["tags"]), external_exports.object({
23884
+ var orgInviteSchema = external_exports.strictObject({ email: inviteEmailSchema, role: orgRoleSchema }).transform((input2) => ({ email: input2.email, role: input2.role }));
23885
+ var orgRoleChangeSchema = external_exports.strictObject({ role: orgRoleSchema }).transform((input2) => ({ role: input2.role }));
23886
+ var profileSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name"]), external_exports.strictObject({ name: displayNameSchema })).transform((input2) => ({ name: input2.name }));
23887
+ var newProjectSchema = external_exports.preprocess((value) => normalizeKeys(value, ["tags"]), external_exports.strictObject({
23613
23888
  project: projectNameSchema,
23614
23889
  tags: allowedTagsSchema.optional()
23615
23890
  })).transform((input2) => ({
23616
23891
  project: input2.project,
23617
23892
  ...input2.tags === void 0 ? {} : { tags: input2.tags }
23618
23893
  }));
23619
- var projectUpdateSchema = external_exports.object({ tags: allowedTagsSchema.nullable() }).transform((input2) => ({ tags: input2.tags }));
23620
- var tagPromotionSchema = external_exports.object({ tag: allowedTagSchema }).transform((input2) => ({ tag: normalizeTag(input2.tag) }));
23621
- var roleChangeSchema = external_exports.object({ role: roleSchema }).transform((input2) => ({ role: input2.role }));
23622
- var newTokenSchema = external_exports.preprocess((value) => normalizeKeys(value, ["name", "expiresAt"]), external_exports.object({
23623
- name: boundedString(TOKEN_NAME_MAX),
23624
- expiresAt: timestampSchema.optional()
23625
- })).transform((input2) => ({
23626
- name: input2.name,
23627
- ...input2.expiresAt === void 0 ? {} : { expiresAt: input2.expiresAt }
23628
- }));
23894
+ var projectUpdateSchema = external_exports.strictObject({ tags: allowedTagsSchema.nullable() }).transform((input2) => ({ tags: input2.tags }));
23895
+ var tagPromotionSchema = external_exports.strictObject({ tag: allowedTagSchema }).transform((input2) => ({ tag: normalizeTag(input2.tag) }));
23896
+ var roleChangeSchema = external_exports.strictObject({ role: roleSchema }).transform((input2) => ({ role: input2.role }));
23897
+ var FUTURE_MESSAGE = "must be in the future";
23898
+ function newTokenSchema(now) {
23899
+ return external_exports.preprocess((value) => normalizeKeys(value, ["name", "expiresAt"]), external_exports.strictObject({
23900
+ name: freeText(TOKEN_NAME_MAX),
23901
+ expiresAt: timestampSchema.refine((value) => Date.parse(value) > now.getTime(), {
23902
+ message: FUTURE_MESSAGE
23903
+ }).optional()
23904
+ })).transform((input2) => ({
23905
+ name: input2.name,
23906
+ ...input2.expiresAt === void 0 ? {} : { expiresAt: input2.expiresAt }
23907
+ }));
23908
+ }
23909
+ var UNKNOWN_KEY_MESSAGE = "unknown key";
23910
+ var emptyBodySchema = external_exports.strictObject({});
23629
23911
  function formatIssues(error62) {
23630
- return error62.issues.map((issue2) => {
23631
- const path = issue2.path.map((part) => String(part)).join(".");
23632
- return path === "" ? issue2.message : `${path}: ${issue2.message}`;
23912
+ return error62.issues.flatMap((issue2) => {
23913
+ const path = issue2.path.map((part) => String(part));
23914
+ if (issue2.code === "unrecognized_keys") {
23915
+ return issue2.keys.map((key) => `${[...path, key].join(".")}: ${UNKNOWN_KEY_MESSAGE}`);
23916
+ }
23917
+ const name = path.join(".");
23918
+ return [name === "" ? issue2.message : `${name}: ${issue2.message}`];
23633
23919
  });
23634
23920
  }
23635
23921
  function run(schema, input2) {
@@ -23648,6 +23934,13 @@ function parseComplaint(input2) {
23648
23934
  function parseStatusChange(input2) {
23649
23935
  return run(statusChangeSchema, input2);
23650
23936
  }
23937
+ var statusActorSchema = external_exports.strictObject({ by: actorSchema, byName: actorSchema.optional() }).transform((input2) => ({
23938
+ by: input2.by,
23939
+ ...input2.byName === void 0 ? {} : { byName: input2.byName }
23940
+ }));
23941
+ function parseStatusActor(input2) {
23942
+ return run(statusActorSchema, input2);
23943
+ }
23651
23944
  function parseTagsChange(input2) {
23652
23945
  return run(tagsChangeSchema, input2);
23653
23946
  }
@@ -23663,6 +23956,14 @@ function parseProjectName(input2) {
23663
23956
  function parseTagPromotion(input2) {
23664
23957
  return run(tagPromotionSchema, input2);
23665
23958
  }
23959
+ function parseNewToken(input2, now = /* @__PURE__ */ new Date()) {
23960
+ return run(newTokenSchema(now), input2);
23961
+ }
23962
+ function parseAgentName(input2) {
23963
+ return run(external_exports.object({ agent: agentSchema }), { agent: input2 }).agent;
23964
+ }
23965
+ var IDEMPOTENCY_KEY_RE = new RegExp(`^[A-Za-z0-9_-]{1,${IDEMPOTENCY_KEY_MAX}}$`);
23966
+ var IDEMPOTENCY_KEY_MESSAGE = `Idempotency-Key: must be 1-${IDEMPOTENCY_KEY_MAX} letters, digits, "-" or "_"`;
23666
23967
  function parseOrgSlug(input2) {
23667
23968
  return run(orgSlugSchema, input2);
23668
23969
  }
@@ -23718,8 +24019,8 @@ function summarizeComplaints(complaints) {
23718
24019
  var SUMMARY_WORDS = 20;
23719
24020
  var ELLIPSIS = "\u2026";
23720
24021
  function summarize(message) {
23721
- const line = message.split("\n").map((each) => each.trim()).find((each) => each !== "");
23722
- if (line === void 0)
24022
+ const line = headlineOf(message);
24023
+ if (line === "")
23723
24024
  return "";
23724
24025
  const words = line.split(/\s+/);
23725
24026
  if (words.length <= SUMMARY_WORDS)
@@ -23727,8 +24028,57 @@ function summarize(message) {
23727
24028
  return `${words.slice(0, SUMMARY_WORDS).join(" ")}${ELLIPSIS}`;
23728
24029
  }
23729
24030
 
24031
+ // ../core/dist/query.js
24032
+ var TAG_KEY = "tag";
24033
+ var SINGLE_KEYS = new Set(LIST_FILTER_KEYS.filter((key) => key !== "org" && key !== "tags"));
24034
+ function listQueryOf(filter) {
24035
+ const pairs = [];
24036
+ for (const key of LIST_FILTER_KEYS) {
24037
+ if (key === "org")
24038
+ continue;
24039
+ const value = filter[key];
24040
+ if (value === void 0)
24041
+ continue;
24042
+ if (key === "tags") {
24043
+ for (const tag of filter.tags ?? [])
24044
+ pairs.push([TAG_KEY, tag]);
24045
+ continue;
24046
+ }
24047
+ pairs.push([key, String(value)]);
24048
+ }
24049
+ return pairs;
24050
+ }
24051
+
24052
+ // ../core/dist/endpoint.js
24053
+ var DEFAULT_ENDPOINT = "https://api.letthemquibble.com";
24054
+ var DISCOVERY_PATH = "/.well-known/quibble.json";
24055
+ var LOCAL_ENDPOINT = "local";
24056
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1"];
24057
+ function trimSlash(url2) {
24058
+ return url2.replace(/\/+$/, "");
24059
+ }
24060
+ function normalizeEndpoint(url2) {
24061
+ const text = trimSlash(url2.trim());
24062
+ try {
24063
+ const parsed = new URL(text);
24064
+ return `${parsed.protocol}//${parsed.host}${trimSlash(parsed.pathname)}`;
24065
+ } catch {
24066
+ return text;
24067
+ }
24068
+ }
24069
+ function isDefaultEndpoint(url2) {
24070
+ return normalizeEndpoint(url2) === DEFAULT_ENDPOINT;
24071
+ }
24072
+ function isLoopbackUrl(url2) {
24073
+ try {
24074
+ return LOOPBACK_HOSTS.includes(new URL(url2).hostname);
24075
+ } catch {
24076
+ return false;
24077
+ }
24078
+ }
24079
+
23730
24080
  // ../core/dist/service.js
23731
- import { randomUUID } from "node:crypto";
24081
+ import { createHash, randomUUID } from "node:crypto";
23732
24082
  function applyTagRules(tags, allowedTags) {
23733
24083
  if (allowedTags === void 0)
23734
24084
  return [...tags];
@@ -23744,23 +24094,57 @@ async function createComplaint(store, input2, options = {}) {
23744
24094
  id: options.id === void 0 ? randomUUID() : options.id(),
23745
24095
  ...normalized,
23746
24096
  tags,
24097
+ fingerprint: fingerprintOf(normalized.message),
23747
24098
  createdAt: stamp(options),
23748
24099
  status: DEFAULT_STATUS
23749
24100
  };
23750
- await store.create(complaint);
23751
- return complaint;
24101
+ if (options.reserve !== void 0)
24102
+ await options.reserve(complaint);
24103
+ const match = options.keyed === true ? await store.create(complaint, { keyed: true }) : await store.create(complaint);
24104
+ return {
24105
+ ...complaint,
24106
+ openMatches: match.open,
24107
+ recurred: match.resolved
24108
+ };
23752
24109
  }
23753
24110
  async function listComplaints(store, filter) {
23754
24111
  const parsed = parseListFilter(filter);
23755
24112
  return store.list(parsed);
23756
24113
  }
24114
+ async function listComplaintPage(store, filter) {
24115
+ const parsed = parseListFilter(filter);
24116
+ if (store.listPage !== void 0)
24117
+ return store.listPage(parsed);
24118
+ return { complaints: await store.list(parsed), truncated: false };
24119
+ }
24120
+ function scanComplaints(store, filter) {
24121
+ return store.scan(parseListFilter(filter));
24122
+ }
23757
24123
  async function getComplaint(store, id) {
23758
24124
  return store.get(id);
23759
24125
  }
23760
- async function updateComplaintStatus(store, id, change, options = {}) {
24126
+ async function updateComplaintStatus(store, id, change, options) {
23761
24127
  const parsed = parseStatusChange(change);
23762
- const update = { ...parsed, updatedAt: stamp(options) };
23763
- return store.update(id, update);
24128
+ const actor = parseStatusActor(options.actor);
24129
+ if (parsed.duplicateOf !== void 0) {
24130
+ const target = options.current ?? await store.get(id);
24131
+ if (target === void 0)
24132
+ return void 0;
24133
+ if (parsed.duplicateOf === id) {
24134
+ throw new ValidationError(["duplicateOf: must name another complaint"]);
24135
+ }
24136
+ const original = await store.get(parsed.duplicateOf);
24137
+ if (original === void 0 || original.org !== target.org) {
24138
+ throw new ValidationError([`duplicateOf: no complaint ${parsed.duplicateOf}`]);
24139
+ }
24140
+ }
24141
+ const update = {
24142
+ ...parsed,
24143
+ updatedAt: stamp(options),
24144
+ changedBy: actor.by,
24145
+ ...actor.byName === void 0 ? {} : { changedByName: actor.byName }
24146
+ };
24147
+ return store.update(id, update, options.current);
23764
24148
  }
23765
24149
  async function updateComplaintTags(store, id, change, options = {}) {
23766
24150
  const parsed = parseTagsChange(change);
@@ -23768,7 +24152,7 @@ async function updateComplaintTags(store, id, change, options = {}) {
23768
24152
  tags: applyTagRules(parsed.tags, options.allowedTags),
23769
24153
  updatedAt: stamp(options)
23770
24154
  };
23771
- return store.updateTags(id, update);
24155
+ return store.updateTags(id, update, options.current);
23772
24156
  }
23773
24157
 
23774
24158
  // ../core/dist/file-store.js
@@ -23776,15 +24160,36 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
23776
24160
  import { dirname } from "node:path";
23777
24161
  var STATUS_OP = "status";
23778
24162
  var TAGS_OP = "tags";
24163
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
23779
24164
  var FileStore = class {
23780
24165
  /** Path of the JSONL file. */
23781
24166
  path;
24167
+ /** The creates of this process, one after the other. */
24168
+ #creating = Promise.resolve();
23782
24169
  constructor(path) {
23783
24170
  this.path = path;
23784
24171
  }
23785
- /** Appends one line. Creates the parent directory on the first write. */
23786
- async create(complaint) {
23787
- await this.#append(complaint);
24172
+ /**
24173
+ * Counts what the complaint's project holds under its fingerprint, from the
24174
+ * folded records, then appends the one complaint line, with `recurred: true`
24175
+ * when a resolved match created in the last `RECURRENCE_WINDOW_DAYS` exists.
24176
+ * Creates the parent directory on the first write. A keyed complaint whose
24177
+ * id a record already holds is a `DuplicateIdError` carrying that record,
24178
+ * and nothing is written. The creates of one process run one at a time.
24179
+ */
24180
+ create(complaint, options = {}) {
24181
+ const write = this.#creating.then(async () => {
24182
+ const records = await this.#readAll();
24183
+ const existing = records.find((record2) => record2.id === complaint.id);
24184
+ if (options.keyed === true && existing !== void 0) {
24185
+ throw new DuplicateIdError(complaint.id, existing);
24186
+ }
24187
+ const match = matchOf(records, complaint);
24188
+ await this.#append(match.resolved ? { ...complaint, recurred: true } : complaint);
24189
+ return match;
24190
+ });
24191
+ this.#creating = write.catch(() => void 0);
24192
+ return write;
23788
24193
  }
23789
24194
  /** Returns the matching complaints, newest first. */
23790
24195
  async list(filter) {
@@ -23796,6 +24201,11 @@ var FileStore = class {
23796
24201
  for (const complaint of await this.#matches(filter))
23797
24202
  yield complaint;
23798
24203
  }
24204
+ /** Returns how many complaints match, whatever the limit says. */
24205
+ async count(filter) {
24206
+ const complaints = await this.#readAll();
24207
+ return complaints.filter((complaint) => matchesFilter(complaint, filter)).length;
24208
+ }
23799
24209
  /** Returns the complaint with that id, or `undefined`. */
23800
24210
  async get(id) {
23801
24211
  const complaints = await this.#readAll();
@@ -23804,26 +24214,32 @@ var FileStore = class {
23804
24214
  /**
23805
24215
  * Appends one status line and returns the updated complaint. Returns
23806
24216
  * `undefined`, and writes nothing, when no complaint has that id. A deleted
23807
- * complaint is reached like any other, so a reopen restores it.
24217
+ * complaint is reached like any other, so a reopen restores it. `current` is
24218
+ * ignored, because a fold reads every record anyway.
23808
24219
  */
23809
- async update(id, change) {
24220
+ async update(id, change, _current) {
23810
24221
  const complaint = await this.get(id);
23811
24222
  if (complaint === void 0)
23812
24223
  return void 0;
23813
- await this.#append({
24224
+ const line = {
23814
24225
  op: STATUS_OP,
23815
24226
  id,
23816
24227
  status: change.status,
23817
24228
  ...change.resolution === void 0 ? {} : { resolution: change.resolution },
24229
+ ...change.reason === void 0 ? {} : { reason: change.reason },
24230
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf },
24231
+ changedBy: change.changedBy,
24232
+ ...change.changedByName === void 0 ? {} : { changedByName: change.changedByName },
23818
24233
  updatedAt: change.updatedAt
23819
- });
23820
- return applyStatus(complaint, change);
24234
+ };
24235
+ await this.#append(line);
24236
+ return applyStatus(complaint, line);
23821
24237
  }
23822
24238
  /**
23823
24239
  * Appends one tags line and returns the updated complaint. Returns
23824
24240
  * `undefined`, and writes nothing, when no complaint has that id.
23825
24241
  */
23826
- async updateTags(id, change) {
24242
+ async updateTags(id, change, _current) {
23827
24243
  const complaint = await this.get(id);
23828
24244
  if (complaint === void 0)
23829
24245
  return void 0;
@@ -23946,31 +24362,70 @@ var FileStore = class {
23946
24362
  }
23947
24363
  const change = parseStatusChange({
23948
24364
  status: parsed["status"],
23949
- resolution: parsed["resolution"]
24365
+ resolution: parsed["resolution"],
24366
+ reason: parsed["reason"],
24367
+ duplicateOf: parsed["duplicateOf"]
23950
24368
  });
23951
- return { op, id, ...change, updatedAt };
24369
+ const changedBy = this.#actor(parsed, "changedBy", bad);
24370
+ const changedByName = this.#actor(parsed, "changedByName", bad);
24371
+ return {
24372
+ op,
24373
+ id,
24374
+ ...change,
24375
+ ...changedBy === void 0 ? {} : { changedBy },
24376
+ ...changedByName === void 0 ? {} : { changedByName },
24377
+ updatedAt
24378
+ };
23952
24379
  } catch (error62) {
23953
24380
  if (!(error62 instanceof ValidationError))
23954
24381
  throw error62;
23955
24382
  throw bad(error62.message, error62);
23956
24383
  }
23957
24384
  }
24385
+ /** Reads the optional actor key of a status line. */
24386
+ #actor(parsed, key, bad) {
24387
+ const value = parsed[key];
24388
+ if (value === void 0)
24389
+ return void 0;
24390
+ if (typeof value !== "string" || value.trim() === "" || value.length > REPORTER_MAX) {
24391
+ throw bad(`${key}: must be 1-${REPORTER_MAX} characters`);
24392
+ }
24393
+ return value;
24394
+ }
23958
24395
  /** The prefix that every malformed complaint line shares. */
23959
24396
  #malformed(lineNumber) {
23960
24397
  return `malformed complaint on line ${lineNumber} of ${this.path}`;
23961
24398
  }
23962
24399
  };
23963
24400
  function applyStatus(complaint, change) {
23964
- const updated = {
23965
- ...complaint,
24401
+ const { resolution: _resolution, reason: _reason, duplicateOf: _duplicateOf, statusChangedBy: _statusChangedBy, statusChangedByName: _statusChangedByName, ...rest } = complaint;
24402
+ return {
24403
+ ...rest,
23966
24404
  status: change.status,
24405
+ ...change.resolution === void 0 ? {} : { resolution: change.resolution },
24406
+ ...change.reason === void 0 ? {} : { reason: change.reason },
24407
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf },
24408
+ statusChangedAt: change.updatedAt,
24409
+ ...change.changedBy === void 0 ? {} : { statusChangedBy: change.changedBy },
24410
+ ...change.changedByName === void 0 ? {} : { statusChangedByName: change.changedByName },
23967
24411
  updatedAt: change.updatedAt
23968
24412
  };
23969
- if (change.resolution === void 0)
23970
- delete updated.resolution;
23971
- else
23972
- updated.resolution = change.resolution;
23973
- return updated;
24413
+ }
24414
+ function matchOf(complaints, complaint) {
24415
+ const since = new Date(Date.parse(complaint.createdAt) - RECURRENCE_WINDOW_DAYS * DAY_MS2).toISOString();
24416
+ let open2 = 0;
24417
+ let resolved = false;
24418
+ for (const each of complaints) {
24419
+ if (each.project !== complaint.project)
24420
+ continue;
24421
+ if (each.fingerprint !== complaint.fingerprint)
24422
+ continue;
24423
+ if (each.status === "open")
24424
+ open2 += 1;
24425
+ if (each.status === "resolved" && each.createdAt >= since)
24426
+ resolved = true;
24427
+ }
24428
+ return { open: open2, resolved };
23974
24429
  }
23975
24430
  function applyTags(complaint, change) {
23976
24431
  return { ...complaint, tags: [...change.tags], updatedAt: change.updatedAt };
@@ -23990,11 +24445,17 @@ function matchesFilter(complaint, filter) {
23990
24445
  if (filter.reporter !== void 0 && complaint.reporter !== filter.reporter) {
23991
24446
  return false;
23992
24447
  }
23993
- if (filter.tag !== void 0) {
23994
- const kept = filter.tag === UNTAGGED_TAG ? complaint.tags.length === 0 : complaint.tags.includes(filter.tag);
24448
+ if (filter.tags !== void 0) {
24449
+ const kept = filter.tags.some((tag) => tag === UNTAGGED_TAG ? complaint.tags.length === 0 : complaint.tags.includes(tag));
23995
24450
  if (!kept)
23996
24451
  return false;
23997
24452
  }
24453
+ if (filter.fingerprint !== void 0 && complaint.fingerprint !== filter.fingerprint) {
24454
+ return false;
24455
+ }
24456
+ if (filter.q !== void 0 && !complaint.message.toLowerCase().includes(filter.q)) {
24457
+ return false;
24458
+ }
23998
24459
  if (filter.since !== void 0 && complaint.createdAt < filter.since)
23999
24460
  return false;
24000
24461
  if (filter.until !== void 0 && complaint.createdAt >= filter.until)
@@ -24028,6 +24489,10 @@ function defaultConfigPath(env = process.env) {
24028
24489
  return join(defaultQuibbleHome(env), CONFIG_FILENAME);
24029
24490
  }
24030
24491
 
24492
+ // src/config.ts
24493
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
24494
+ import { dirname as dirname3, isAbsolute, join as join3, relative, resolve, sep } from "node:path";
24495
+
24031
24496
  // src/credentials.ts
24032
24497
  import { randomBytes } from "node:crypto";
24033
24498
  import {
@@ -24039,11 +24504,98 @@ import {
24039
24504
  writeFileSync
24040
24505
  } from "node:fs";
24041
24506
  import { dirname as dirname2, join as join2 } from "node:path";
24507
+ var CREDENTIALS_FILENAME = "credentials.json";
24508
+ var CREDENTIALS_MODE = 384;
24509
+ var HOME_MODE = 448;
24510
+ function defaultCredentialsPath(env) {
24511
+ return join2(defaultQuibbleHome(env), CREDENTIALS_FILENAME);
24512
+ }
24513
+ function readEntry(value) {
24514
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
24515
+ return void 0;
24516
+ }
24517
+ const raw = value;
24518
+ const keys = ["endpoint", "refreshToken", "accessToken", "expiresAt", "sub", "email"];
24519
+ for (const key of keys) {
24520
+ if (typeof raw[key] !== "string" || raw[key] === "") return void 0;
24521
+ }
24522
+ return {
24523
+ endpoint: raw["endpoint"],
24524
+ refreshToken: raw["refreshToken"],
24525
+ accessToken: raw["accessToken"],
24526
+ expiresAt: raw["expiresAt"],
24527
+ sub: raw["sub"],
24528
+ email: raw["email"]
24529
+ };
24530
+ }
24531
+ function readCredentials(env) {
24532
+ const path = defaultCredentialsPath(env);
24533
+ let text;
24534
+ try {
24535
+ text = readFileSync(path, "utf8");
24536
+ } catch (error62) {
24537
+ if (isNotFound(error62)) return {};
24538
+ throw new ConfigError(`cannot read ${path}`, path, { cause: error62 });
24539
+ }
24540
+ let parsed;
24541
+ try {
24542
+ parsed = JSON.parse(text);
24543
+ } catch (error62) {
24544
+ throw new ConfigError(`invalid JSON in ${path}`, path, { cause: error62 });
24545
+ }
24546
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
24547
+ throw new ConfigError(`invalid credentials in ${path}: must be a JSON object`, path);
24548
+ }
24549
+ const entries = {};
24550
+ for (const [key, value] of Object.entries(parsed)) {
24551
+ const entry = readEntry(value);
24552
+ if (entry !== void 0) entries[key] = entry;
24553
+ }
24554
+ return entries;
24555
+ }
24556
+ function findCredential(env, endpoint) {
24557
+ return readCredentials(env)[normalizeEndpoint(endpoint)];
24558
+ }
24559
+ function writeCredentials(env, entries) {
24560
+ const path = defaultCredentialsPath(env);
24561
+ mkdirSync(dirname2(path), { recursive: true, mode: HOME_MODE });
24562
+ const temp = join2(
24563
+ dirname2(path),
24564
+ `.${CREDENTIALS_FILENAME}.${randomBytes(6).toString("hex")}`
24565
+ );
24566
+ try {
24567
+ writeFileSync(temp, `${JSON.stringify(entries, null, 2)}
24568
+ `, {
24569
+ encoding: "utf8",
24570
+ mode: CREDENTIALS_MODE
24571
+ });
24572
+ chmodSync(temp, CREDENTIALS_MODE);
24573
+ renameSync(temp, path);
24574
+ } catch (error62) {
24575
+ rmSync(temp, { force: true });
24576
+ throw error62;
24577
+ }
24578
+ }
24579
+ function saveCredential(env, credential) {
24580
+ const key = normalizeEndpoint(credential.endpoint);
24581
+ const stored = { ...credential, endpoint: key };
24582
+ writeCredentials(env, { ...readCredentials(env), [key]: stored });
24583
+ return stored;
24584
+ }
24585
+ function removeCredential(env, endpoint) {
24586
+ const entries = readCredentials(env);
24587
+ const key = normalizeEndpoint(endpoint);
24588
+ if (entries[key] === void 0) return false;
24589
+ delete entries[key];
24590
+ writeCredentials(env, entries);
24591
+ return true;
24592
+ }
24042
24593
 
24043
24594
  // src/errors.ts
24595
+ var AMBIGUOUS_ID = "ambiguous_id";
24044
24596
  var UsageError = class extends QuibbleError {
24045
24597
  constructor(message, options) {
24046
- super("usage_error", message, options);
24598
+ super(options?.code ?? "usage_error", message, options);
24047
24599
  }
24048
24600
  };
24049
24601
  var NotFoundError = class extends QuibbleError {
@@ -24058,9 +24610,18 @@ var RemoteError = class extends QuibbleError {
24058
24610
  * other one stays exit 2.
24059
24611
  */
24060
24612
  status;
24613
+ /** The `error.code` of the answer, when it carried one. */
24614
+ serverCode;
24615
+ /** The server's own message, when the answer carried one. */
24616
+ serverMessage;
24617
+ /** True when no server answered, which the offline queue of 5.13 reads. */
24618
+ unanswered;
24061
24619
  constructor(message, options) {
24062
24620
  super("network_error", message, options);
24063
24621
  this.status = options?.status;
24622
+ this.serverCode = options?.serverCode;
24623
+ this.serverMessage = options?.serverMessage;
24624
+ this.unanswered = options?.unanswered === true;
24064
24625
  }
24065
24626
  };
24066
24627
  function exitCodeFor(error62) {
@@ -24081,7 +24642,7 @@ var DEBUG_ENV = "QUIBBLE_DEBUG";
24081
24642
  function debugEnabled(env) {
24082
24643
  return env[DEBUG_ENV] === "1";
24083
24644
  }
24084
- function trimSlash(url2) {
24645
+ function trimSlash2(url2) {
24085
24646
  return url2.replace(/\/+$/, "");
24086
24647
  }
24087
24648
  function originOf(url2) {
@@ -24117,12 +24678,12 @@ async function send(url2, request) {
24117
24678
  if (isTimeout(error62)) {
24118
24679
  throw new RemoteError(
24119
24680
  `request to ${originOf(url2)} timed out after ${timeoutMs / 1e3}s${detail(site)}`,
24120
- { cause: error62 }
24681
+ { cause: error62, unanswered: true }
24121
24682
  );
24122
24683
  }
24123
24684
  throw new RemoteError(
24124
24685
  `cannot reach ${originOf(url2)}: ${describeError(error62)}${detail(site)}`,
24125
- { cause: error62 }
24686
+ { cause: error62, unanswered: true }
24126
24687
  );
24127
24688
  }
24128
24689
  }
@@ -24132,7 +24693,7 @@ async function readText(response, site) {
24132
24693
  } catch (error62) {
24133
24694
  throw new RemoteError(
24134
24695
  `cannot read the response from ${originOf(site.url)}: ${describeError(error62)}${detail(site, response.status)}`,
24135
- { cause: error62 }
24696
+ { cause: error62, unanswered: true }
24136
24697
  );
24137
24698
  }
24138
24699
  }
@@ -24140,7 +24701,7 @@ function parseJson(text, site, status) {
24140
24701
  try {
24141
24702
  return JSON.parse(text);
24142
24703
  } catch (error62) {
24143
- throw new RemoteError(`the API returned a malformed body${detail(site, status)}`, {
24704
+ throw new RemoteError(`the API returned invalid JSON${detail(site, status)}`, {
24144
24705
  cause: error62
24145
24706
  });
24146
24707
  }
@@ -24176,134 +24737,40 @@ function serverCode(text) {
24176
24737
  return stringOf(asRecord(record2?.["error"]), "code");
24177
24738
  }
24178
24739
  function remoteFailure(site, response, text) {
24179
- const message = serverMessage(text, response) ?? `the API answered ${response.status}`;
24740
+ const own2 = serverMessage(text, response);
24741
+ const message = own2 ?? `the API returned HTTP ${response.status}`;
24180
24742
  return new RemoteError(`${message}${detail(site, response.status)}`, {
24181
- status: response.status
24743
+ status: response.status,
24744
+ serverCode: serverCode(text),
24745
+ serverMessage: own2
24182
24746
  });
24183
24747
  }
24184
24748
 
24185
- // src/credentials.ts
24186
- var CREDENTIALS_FILENAME = "credentials.json";
24187
- var CREDENTIALS_MODE = 384;
24188
- var HOME_MODE = 448;
24189
- function defaultCredentialsPath(env) {
24190
- return join2(defaultQuibbleHome(env), CREDENTIALS_FILENAME);
24191
- }
24192
- function normalizeEndpoint(endpoint) {
24193
- const text = trimSlash(endpoint.trim());
24194
- try {
24195
- const url2 = new URL(text);
24196
- return `${url2.protocol}//${url2.host}${trimSlash(url2.pathname)}`;
24197
- } catch {
24198
- return text;
24199
- }
24200
- }
24201
- function readEntry(value) {
24202
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
24203
- return void 0;
24204
- }
24205
- const raw = value;
24206
- const keys = ["endpoint", "refreshToken", "accessToken", "expiresAt", "sub", "email"];
24207
- for (const key of keys) {
24208
- if (typeof raw[key] !== "string" || raw[key] === "") return void 0;
24749
+ // src/cognito.ts
24750
+ var TARGET_PREFIX = "AWSCognitoIdentityProviderService.";
24751
+ var CONTENT_TYPE = "application/x-amz-json-1.1";
24752
+ var DEFAULT_EXPIRES_IN = 3600;
24753
+ var UNREACHABLE_LINE = "Couldn't reach the sign-in service.";
24754
+ var WRONG_CREDENTIALS_LINE = "Wrong email or password.";
24755
+ var TOO_MANY_LINE = "Too many attempts. Try again later.";
24756
+ var WRONG_CODE_LINE = "Wrong code.";
24757
+ var EXPIRED_CODE_LINE = "That code has expired. Request a new one.";
24758
+ var PASSWORD_POLICY_LINE = "Use at least 12 characters, with an uppercase letter, a lowercase letter, a number and a symbol.";
24759
+ var UNREACHABLE_TYPE = "Unreachable";
24760
+ var INCORRECT_CREDENTIALS_MESSAGE = "Incorrect username or password.";
24761
+ var CognitoError = class extends RemoteError {
24762
+ /** The `__type`, with any prefix up to a `#` dropped, or `Unreachable`. */
24763
+ type;
24764
+ /** Cognito's own `message`, or `undefined` when the answer carried none. */
24765
+ detail;
24766
+ constructor(type, detail2, line, suffix = "") {
24767
+ super(`${line}${suffix}`, { unanswered: type === UNREACHABLE_TYPE });
24768
+ this.type = type;
24769
+ this.detail = detail2;
24209
24770
  }
24210
- return {
24211
- endpoint: raw["endpoint"],
24212
- refreshToken: raw["refreshToken"],
24213
- accessToken: raw["accessToken"],
24214
- expiresAt: raw["expiresAt"],
24215
- sub: raw["sub"],
24216
- email: raw["email"]
24217
- };
24218
- }
24219
- function readCredentials(env) {
24220
- const path = defaultCredentialsPath(env);
24221
- let text;
24222
- try {
24223
- text = readFileSync(path, "utf8");
24224
- } catch (error62) {
24225
- if (isNotFound(error62)) return {};
24226
- throw new ConfigError(`cannot read ${path}`, path, { cause: error62 });
24227
- }
24228
- let parsed;
24229
- try {
24230
- parsed = JSON.parse(text);
24231
- } catch (error62) {
24232
- throw new ConfigError(`invalid JSON in ${path}`, path, { cause: error62 });
24233
- }
24234
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
24235
- throw new ConfigError(`invalid credentials in ${path}: must be a JSON object`, path);
24236
- }
24237
- const entries = {};
24238
- for (const [key, value] of Object.entries(parsed)) {
24239
- const entry = readEntry(value);
24240
- if (entry !== void 0) entries[key] = entry;
24241
- }
24242
- return entries;
24243
- }
24244
- function findCredential(env, endpoint) {
24245
- return readCredentials(env)[normalizeEndpoint(endpoint)];
24246
- }
24247
- function writeCredentials(env, entries) {
24248
- const path = defaultCredentialsPath(env);
24249
- mkdirSync(dirname2(path), { recursive: true, mode: HOME_MODE });
24250
- const temp = join2(
24251
- dirname2(path),
24252
- `.${CREDENTIALS_FILENAME}.${randomBytes(6).toString("hex")}`
24253
- );
24254
- try {
24255
- writeFileSync(temp, `${JSON.stringify(entries, null, 2)}
24256
- `, {
24257
- encoding: "utf8",
24258
- mode: CREDENTIALS_MODE
24259
- });
24260
- chmodSync(temp, CREDENTIALS_MODE);
24261
- renameSync(temp, path);
24262
- } catch (error62) {
24263
- rmSync(temp, { force: true });
24264
- throw error62;
24265
- }
24266
- }
24267
- function saveCredential(env, credential) {
24268
- const key = normalizeEndpoint(credential.endpoint);
24269
- const stored = { ...credential, endpoint: key };
24270
- writeCredentials(env, { ...readCredentials(env), [key]: stored });
24271
- return stored;
24272
- }
24273
- function removeCredential(env, endpoint) {
24274
- const entries = readCredentials(env);
24275
- const key = normalizeEndpoint(endpoint);
24276
- if (entries[key] === void 0) return false;
24277
- delete entries[key];
24278
- writeCredentials(env, entries);
24279
- return true;
24280
- }
24281
-
24282
- // src/cognito.ts
24283
- var TARGET_PREFIX = "AWSCognitoIdentityProviderService.";
24284
- var CONTENT_TYPE = "application/x-amz-json-1.1";
24285
- var DEFAULT_EXPIRES_IN = 3600;
24286
- var UNREACHABLE_LINE = "The sign-in service could not be reached.";
24287
- var WRONG_CREDENTIALS_LINE = "Wrong email or password.";
24288
- var TOO_MANY_LINE = "Too many attempts, try again later.";
24289
- var WRONG_CODE_LINE = "Wrong code.";
24290
- var EXPIRED_CODE_LINE = "That code has expired, request a new one.";
24291
- var PASSWORD_POLICY_LINE = "A password needs 12 characters with a lowercase letter, an uppercase letter, a digit and a symbol.";
24292
- var UNREACHABLE_TYPE = "Unreachable";
24293
- var INCORRECT_CREDENTIALS_MESSAGE = "Incorrect username or password.";
24294
- var CognitoError = class extends RemoteError {
24295
- /** The `__type`, with any prefix up to a `#` dropped, or `Unreachable`. */
24296
- type;
24297
- /** Cognito's own `message`, or `undefined` when the answer carried none. */
24298
- detail;
24299
- constructor(type, detail2, line, suffix = "") {
24300
- super(`${line}${suffix}`);
24301
- this.type = type;
24302
- this.detail = detail2;
24303
- }
24304
- };
24305
- function endpointUrl(region) {
24306
- return `https://cognito-idp.${region}.amazonaws.com/`;
24771
+ };
24772
+ function endpointUrl(region) {
24773
+ return `https://cognito-idp.${region}.amazonaws.com/`;
24307
24774
  }
24308
24775
  function errorLineOf(type, detail2, signIn2) {
24309
24776
  switch (type) {
@@ -24394,7 +24861,7 @@ function authAnswerOf(body) {
24394
24861
  const shape = result;
24395
24862
  const accessToken = shape["AccessToken"];
24396
24863
  if (typeof accessToken !== "string" || accessToken === "") {
24397
- throw new RemoteError("the sign-in returned no access token");
24864
+ throw new RemoteError("sign-in didn't return an access token");
24398
24865
  }
24399
24866
  const refreshToken = shape["RefreshToken"];
24400
24867
  const expiresIn = shape["ExpiresIn"];
@@ -24409,7 +24876,7 @@ function authAnswerOf(body) {
24409
24876
  }
24410
24877
  const challengeName = body["ChallengeName"];
24411
24878
  if (typeof challengeName !== "string" || challengeName === "") {
24412
- throw new RemoteError("the sign-in returned neither tokens nor a challenge");
24879
+ throw new RemoteError("sign-in returned an unexpected response");
24413
24880
  }
24414
24881
  const session = body["Session"];
24415
24882
  const parameters = body["ChallengeParameters"];
@@ -24478,9 +24945,8 @@ function actAsRefused(sub, endpoint) {
24478
24945
  function clockOf(options) {
24479
24946
  return options.now ?? (() => /* @__PURE__ */ new Date());
24480
24947
  }
24481
- var LOCAL_HOSTS = ["localhost", "127.0.0.1"];
24482
24948
  async function fetchAuthConfig(endpoint, options = {}) {
24483
- const url2 = `${trimSlash(endpoint)}${AUTH_CONFIG_PATH}`;
24949
+ const url2 = `${trimSlash2(endpoint)}${AUTH_CONFIG_PATH}`;
24484
24950
  const site = { method: "GET", url: url2, debug: options.debug };
24485
24951
  const response = await send(url2, { method: "GET", ...options });
24486
24952
  const text = await readText(response, site);
@@ -24496,7 +24962,7 @@ async function fetchAuthConfig(endpoint, options = {}) {
24496
24962
  const webUrl = stringOf(body, "webUrl");
24497
24963
  if (region === void 0 || cliClientId === void 0 || webUrl === void 0) {
24498
24964
  throw new RemoteError(
24499
- `the API returned an auth config without a region${detail(site, response.status)}`
24965
+ `unexpected response from the API: auth config has no region${detail(site, response.status)}`
24500
24966
  );
24501
24967
  }
24502
24968
  return { mode: "cognito", region, cliClientId, webUrl };
@@ -24509,13 +24975,6 @@ function bearerAuthorizer(token) {
24509
24975
  };
24510
24976
  }
24511
24977
  var DEV_TOKEN = "dev";
24512
- function isLoopback(endpoint) {
24513
- try {
24514
- return LOCAL_HOSTS.includes(new URL(endpoint).hostname);
24515
- } catch {
24516
- return false;
24517
- }
24518
- }
24519
24978
  var DevAuthorizer = class {
24520
24979
  actAs;
24521
24980
  #endpoint;
@@ -24601,7 +25060,9 @@ var SessionAuthorizer = class {
24601
25060
  throw error62;
24602
25061
  }
24603
25062
  if (answer2.kind === "challenge") {
24604
- throw new RemoteError(`the refresh asked for ${answer2.challenge.challengeName}`);
25063
+ throw new RemoteError(
25064
+ `unexpected sign-in step during refresh (${answer2.challenge.challengeName})`
25065
+ );
24605
25066
  }
24606
25067
  const now = clockOf(this.#options)();
24607
25068
  return saveCredential(this.#options.env, {
@@ -24618,12 +25079,97 @@ function createAuthorizer(endpoint, options) {
24618
25079
  return new SessionAuthorizer(endpoint, options);
24619
25080
  }
24620
25081
 
25082
+ // src/endpoint.ts
25083
+ var DISCOVERY_TIMEOUT_MS = 5e3;
25084
+ var SCHEMES = ["http:", "https:"];
25085
+ function parseEndpoint(value, from) {
25086
+ const text = value.trim();
25087
+ if (text.toLowerCase() === LOCAL_ENDPOINT) return { kind: "local" };
25088
+ const invalid = new ConfigError(
25089
+ `invalid endpoint "${text}" from ${from}: pass an https URL, a host, or local`
25090
+ );
25091
+ let url2 = text;
25092
+ if (!text.includes("://")) {
25093
+ url2 = isLoopbackUrl(`http://${text}`) ? `http://${text}` : `https://${text}`;
25094
+ }
25095
+ let parsed;
25096
+ try {
25097
+ parsed = new URL(url2);
25098
+ } catch {
25099
+ throw invalid;
25100
+ }
25101
+ if (!SCHEMES.includes(parsed.protocol) || parsed.hostname === "") throw invalid;
25102
+ return { kind: "url", url: normalizeEndpoint(url2) };
25103
+ }
25104
+ var discoveries = /* @__PURE__ */ new Map();
25105
+ function resetDiscovery() {
25106
+ discoveries.clear();
25107
+ }
25108
+ function discoverApiUrl(url2, options = {}) {
25109
+ const key = normalizeEndpoint(url2);
25110
+ if (isDefaultEndpoint(key)) return Promise.resolve(DEFAULT_ENDPOINT);
25111
+ const known = discoveries.get(key);
25112
+ if (known !== void 0) return known;
25113
+ const found = lookup(key, options).then((api) => {
25114
+ if (!discoveries.has(api)) discoveries.set(api, Promise.resolve(api));
25115
+ return api;
25116
+ });
25117
+ discoveries.set(key, found);
25118
+ return found;
25119
+ }
25120
+ async function lookup(url2, options) {
25121
+ const target = `${url2}${DISCOVERY_PATH}`;
25122
+ try {
25123
+ const response = await send(target, {
25124
+ method: "GET",
25125
+ headers: { accept: "application/json" },
25126
+ fetch: options.fetch,
25127
+ debug: options.debug,
25128
+ timeoutMs: options.timeoutMs ?? DISCOVERY_TIMEOUT_MS
25129
+ });
25130
+ const text = await readText(response, { method: "GET", url: target });
25131
+ if (!response.ok) return url2;
25132
+ const api = stringOf(asRecord(parseQuietly(text)), "api");
25133
+ if (api === void 0) return url2;
25134
+ const parsed = new URL(api.trim());
25135
+ if (!SCHEMES.includes(parsed.protocol)) return url2;
25136
+ return normalizeEndpoint(api);
25137
+ } catch {
25138
+ return url2;
25139
+ }
25140
+ }
25141
+
24621
25142
  // src/config.ts
24622
- var NO_ENDPOINT_MESSAGE = "no endpoint: pass --endpoint <url>, set QUIBBLE_ENDPOINT, or add one to .quibblerc";
24623
25143
  function requireEndpoint(context) {
24624
- if (context.endpoint === void 0) throw new ConfigError(NO_ENDPOINT_MESSAGE);
25144
+ if (context.endpoint === void 0) {
25145
+ throw new ConfigError(`local mode has no endpoint: ${context.localFix ?? ""}`);
25146
+ }
24625
25147
  return context.endpoint;
24626
25148
  }
25149
+ async function resolveEndpoint(context, io) {
25150
+ const endpoint = requireEndpoint(context);
25151
+ if (context.discovered) return endpoint;
25152
+ const api = await discoverApiUrl(endpoint, {
25153
+ fetch: io.fetch,
25154
+ debug: context.debug,
25155
+ timeoutMs: context.timeoutMs
25156
+ });
25157
+ context.endpoint = api;
25158
+ context.credential = findCredential(io.env, api);
25159
+ context.discovered = true;
25160
+ return api;
25161
+ }
25162
+ function foreignOrgMessage(context) {
25163
+ const slug = context.slug;
25164
+ if (context.mode !== "local" || slug === void 0 || slug === LOCAL_ORG) {
25165
+ return void 0;
25166
+ }
25167
+ return `local mode holds the organization ${LOCAL_ORG} only, not ${slug}: ${context.localFix ?? ""} to file to ${slug} on a server, or pass --org ${LOCAL_ORG}`;
25168
+ }
25169
+ function refuseForeignOrg(context) {
25170
+ const message = foreignOrgMessage(context);
25171
+ if (message !== void 0) throw new ConfigError(message);
25172
+ }
24627
25173
  var NO_ORG_MESSAGE = "no organization: pass --org <slug> or run `quibble init --org <slug>`";
24628
25174
  function requireOrg(context) {
24629
25175
  if (context.org === void 0) throw new ConfigError(NO_ORG_MESSAGE);
@@ -24747,32 +25293,91 @@ function firstValue(...values) {
24747
25293
  }
24748
25294
  return void 0;
24749
25295
  }
25296
+ function localFixOf(candidate) {
25297
+ switch (candidate.source) {
25298
+ case "--endpoint":
25299
+ return "drop --endpoint local";
25300
+ case "QUIBBLE_ENDPOINT":
25301
+ return "unset QUIBBLE_ENDPOINT";
25302
+ default:
25303
+ return `remove "endpoint": "local" from ${candidate.path ?? ""}`;
25304
+ }
25305
+ }
25306
+ function resolveEndpointSource(options, project, user) {
25307
+ if (options.local === true) {
25308
+ return {
25309
+ mode: "local",
25310
+ endpoint: void 0,
25311
+ endpointSource: "--local",
25312
+ localFix: "drop --local"
25313
+ };
25314
+ }
25315
+ const candidates2 = [
25316
+ { source: "--endpoint", value: options.endpoint },
25317
+ { source: "QUIBBLE_ENDPOINT", value: options.env["QUIBBLE_ENDPOINT"] },
25318
+ { source: ".quibblerc", value: project?.endpoint, path: project?.path },
25319
+ {
25320
+ source: "config.json",
25321
+ value: user.endpoint,
25322
+ path: defaultConfigPath(options.env)
25323
+ }
25324
+ ];
25325
+ const chosen = candidates2.find(
25326
+ (candidate) => candidate.value !== void 0 && candidate.value.trim() !== ""
25327
+ );
25328
+ if (chosen === void 0) {
25329
+ return {
25330
+ mode: "remote",
25331
+ endpoint: DEFAULT_ENDPOINT,
25332
+ endpointSource: "default",
25333
+ localFix: void 0
25334
+ };
25335
+ }
25336
+ const value = parseEndpoint(chosen.value ?? "", chosen.path ?? chosen.source);
25337
+ if (value.kind === "local") {
25338
+ return {
25339
+ mode: "local",
25340
+ endpoint: void 0,
25341
+ endpointSource: chosen.source,
25342
+ localFix: localFixOf(chosen)
25343
+ };
25344
+ }
25345
+ return {
25346
+ mode: "remote",
25347
+ endpoint: value.url,
25348
+ endpointSource: chosen.source,
25349
+ localFix: void 0
25350
+ };
25351
+ }
24750
25352
  function resolveContext(options) {
24751
25353
  const project = findProjectConfig(options.cwd);
24752
25354
  const user = loadUserConfig(options.env);
24753
- const endpoint = firstValue(
24754
- options.endpoint,
24755
- options.env["QUIBBLE_ENDPOINT"],
24756
- project?.endpoint,
24757
- user.endpoint
25355
+ const { mode, endpoint, endpointSource, localFix } = resolveEndpointSource(
25356
+ options,
25357
+ project,
25358
+ user
24758
25359
  );
24759
- const name = firstValue(options.project, project?.project);
24760
- const given = firstValue(options.org, project?.org);
24761
- const slug = given === void 0 ? void 0 : parseOrgSlug(given);
25360
+ const given = firstValue(options.project, project?.project);
25361
+ const name = given === void 0 ? void 0 : foldProjectName(given);
25362
+ const org = firstValue(options.org, project?.org);
25363
+ const slug = org === void 0 ? void 0 : parseOrgSlug(org);
24762
25364
  const token = firstValue(options.env[TOKEN_ENV]);
24763
- const mode = options.local === true || endpoint === void 0 ? "local" : "remote";
25365
+ const discovered = endpoint !== void 0 && isDefaultEndpoint(endpoint);
24764
25366
  return {
24765
25367
  project: name,
24766
25368
  org: mode === "local" ? LOCAL_ORG : slug,
25369
+ slug,
24767
25370
  projectRoot: project?.root,
24768
25371
  configPath: project?.path,
24769
25372
  cwd: project === void 0 ? void 0 : relativeCwd(project.root, options.cwd),
24770
25373
  mode,
24771
25374
  endpoint,
25375
+ endpointSource,
25376
+ localFix,
25377
+ discovered,
25378
+ timeoutMs: void 0,
24772
25379
  token,
24773
- // `quibble config` diagnoses a missing session, so the entry is read here
24774
- // and no command has to read the file to describe the session.
24775
- credential: endpoint === void 0 ? void 0 : findCredential(options.env, endpoint),
25380
+ credential: discovered && endpoint !== void 0 ? findCredential(options.env, endpoint) : void 0,
24776
25381
  actAs: firstValue(options.env[ACT_AS_ENV]),
24777
25382
  debug: debugEnabled(options.env),
24778
25383
  agent: firstValue(options.agent, user.agent) ?? DEFAULT_AGENT,
@@ -24785,6 +25390,88 @@ function resolveContext(options) {
24785
25390
  projectConfig: project
24786
25391
  };
24787
25392
  }
25393
+ function rememberEndpoint(env, endpoint) {
25394
+ const path = defaultConfigPath(env);
25395
+ const raw = readJsonFile(path) ?? {};
25396
+ if (stringField(raw, "endpoint", path) !== void 0) return void 0;
25397
+ mkdirSync2(dirname3(path), { recursive: true });
25398
+ writeFileSync2(path, `${JSON.stringify({ ...raw, endpoint }, null, 2)}
25399
+ `, "utf8");
25400
+ return path;
25401
+ }
25402
+
25403
+ // src/ids.ts
25404
+ var FULL_ID_LENGTH = 36;
25405
+ var SHORT_ID_MIN = 6;
25406
+ var CANDIDATES_SHOWN = 5;
25407
+ var SHORT_ID_RE = /^[0-9a-f-]+$/;
25408
+ function readIdValue(value) {
25409
+ const trimmed = value.trim();
25410
+ if (trimmed.length === FULL_ID_LENGTH) return { id: trimmed, prefix: false };
25411
+ const lower = trimmed.toLowerCase();
25412
+ if (!SHORT_ID_RE.test(lower)) throw new UsageError(`${value} is not an id`);
25413
+ if (lower.length < SHORT_ID_MIN) {
25414
+ throw new UsageError(
25415
+ `${value} is too short: pass at least ${SHORT_ID_MIN} characters of the id`
25416
+ );
25417
+ }
25418
+ return { id: lower, prefix: true };
25419
+ }
25420
+ function ambiguousError(prefix, ids, noun) {
25421
+ const shown = ids.slice(0, CANDIDATES_SHOWN).join(", ");
25422
+ const more = ids.length > CANDIDATES_SHOWN ? ` and ${ids.length - CANDIDATES_SHOWN} more` : "";
25423
+ return new UsageError(`${prefix} matches ${ids.length} ${noun}: ${shown}${more}`, {
25424
+ code: AMBIGUOUS_ID
25425
+ });
25426
+ }
25427
+ async function candidates(source) {
25428
+ const org = requireOrg(source.context);
25429
+ const ids = [];
25430
+ if (source.local !== void 0) {
25431
+ for (const status of [void 0, "deleted"]) {
25432
+ const filter = { org, ...status === void 0 ? {} : { status } };
25433
+ for await (const complaint of scanComplaints(source.local, filter)) {
25434
+ ids.push(complaint.id);
25435
+ }
25436
+ }
25437
+ return ids;
25438
+ }
25439
+ const project = source.context.projectConfig?.project;
25440
+ for (const status of [void 0, "deleted"]) {
25441
+ const filter = {
25442
+ org,
25443
+ limit: MAX_LIMIT,
25444
+ ...project === void 0 ? {} : { project },
25445
+ ...status === void 0 ? {} : { status }
25446
+ };
25447
+ for (const complaint of await listComplaints(source.store, filter)) {
25448
+ ids.push(complaint.id);
25449
+ }
25450
+ }
25451
+ return ids;
25452
+ }
25453
+ async function resolveIds(values, source) {
25454
+ const read = values.map((value) => readIdValue(value));
25455
+ let pool;
25456
+ const resolved = [];
25457
+ for (const value of read) {
25458
+ if (!value.prefix) {
25459
+ resolved.push(value.id);
25460
+ continue;
25461
+ }
25462
+ pool ??= candidates(source);
25463
+ const matches = [...new Set((await pool).filter((id) => id.startsWith(value.id)))];
25464
+ if (matches.length === 1) {
25465
+ resolved.push(matches[0]);
25466
+ continue;
25467
+ }
25468
+ if (matches.length > 1) throw ambiguousError(value.id, matches, "complaints");
25469
+ throw new NotFoundError(
25470
+ source.local === void 0 ? `no complaint among the newest ${MAX_LIMIT} starts with ${value.id}: pass the full id` : `no complaint starts with ${value.id}`
25471
+ );
25472
+ }
25473
+ return resolved;
25474
+ }
24788
25475
 
24789
25476
  // src/io.ts
24790
25477
  async function readStdin(stream) {
@@ -24803,9 +25490,10 @@ function writeJson(stream, value) {
24803
25490
  }
24804
25491
 
24805
25492
  // src/client.ts
24806
- var NOT_A_COMPLAINT = "the API returned a body that is not a complaint";
25493
+ import { randomUUID as randomUUID2 } from "node:crypto";
25494
+ var NOT_A_COMPLAINT = "unexpected response from the API: expected a complaint";
24807
25495
  var ORG_NOT_FOUND = "org_not_found";
24808
- var NOT_A_PROMOTION = "the API returned a body that is not a tag promotion";
25496
+ var NOT_A_PROMOTION = "unexpected response from the API: expected a tag promotion";
24809
25497
  function readRoles(value) {
24810
25498
  if (!Array.isArray(value)) return [];
24811
25499
  const roles = [];
@@ -24838,7 +25526,7 @@ function parseMe(body) {
24838
25526
  const record2 = asRecord(body);
24839
25527
  const reporter = stringOf(record2, "reporter");
24840
25528
  if (record2 === void 0 || reporter === void 0) {
24841
- throw new RemoteError("the API returned an identity without a reporter");
25529
+ throw new RemoteError("unexpected response from the API: identity has no reporter");
24842
25530
  }
24843
25531
  return {
24844
25532
  kind: stringOf(record2, "kind") ?? "user",
@@ -24857,7 +25545,7 @@ function parseMe(body) {
24857
25545
  function parseOrgAnswer(body, slug) {
24858
25546
  const record2 = asRecord(body);
24859
25547
  if (record2 === void 0) {
24860
- throw new RemoteError("the API returned a body that is not an organization");
25548
+ throw new RemoteError("unexpected response from the API: expected an organization");
24861
25549
  }
24862
25550
  return {
24863
25551
  org: stringOf(record2, "org") ?? slug,
@@ -24867,6 +25555,35 @@ function parseOrgAnswer(body, slug) {
24867
25555
  body: record2
24868
25556
  };
24869
25557
  }
25558
+ function readToken(value) {
25559
+ const record2 = asRecord(value);
25560
+ const id = stringOf(record2, "id");
25561
+ const name = stringOf(record2, "name");
25562
+ const createdAt = stringOf(record2, "createdAt");
25563
+ if (id === void 0 || name === void 0 || createdAt === void 0) {
25564
+ throw new RemoteError("unexpected response from the API: expected a token");
25565
+ }
25566
+ return {
25567
+ id,
25568
+ name,
25569
+ createdAt,
25570
+ expiresAt: stringOf(record2, "expiresAt"),
25571
+ lastUsedAt: stringOf(record2, "lastUsedAt"),
25572
+ revokedAt: stringOf(record2, "revokedAt")
25573
+ };
25574
+ }
25575
+ function readProject(body, name) {
25576
+ const record2 = asRecord(body);
25577
+ if (record2 === void 0) {
25578
+ throw new RemoteError("unexpected response from the API: expected a project");
25579
+ }
25580
+ const tags = record2["tags"];
25581
+ return {
25582
+ project: stringOf(record2, "project") ?? name,
25583
+ tags: Array.isArray(tags) ? tags.map((tag) => String(tag)) : void 0,
25584
+ body: record2
25585
+ };
25586
+ }
24870
25587
  function readComplaint(body) {
24871
25588
  try {
24872
25589
  return parseComplaint(body);
@@ -24881,24 +25598,37 @@ var ApiClient = class {
24881
25598
  #base;
24882
25599
  #auth;
24883
25600
  #http;
25601
+ #beforeFirst;
24884
25602
  constructor(options) {
24885
25603
  this.endpoint = options.endpoint;
24886
25604
  this.org = options.org;
24887
- this.#base = trimSlash(options.endpoint);
25605
+ this.#base = trimSlash2(options.endpoint);
24888
25606
  this.#auth = options.authorizer;
25607
+ this.#beforeFirst = options.beforeFirst;
24889
25608
  this.#http = {
24890
25609
  fetch: options.fetch,
24891
25610
  timeoutMs: options.timeoutMs,
24892
25611
  debug: options.debug
24893
25612
  };
24894
25613
  }
24895
- /** Sends one request and returns its body. A non-2xx answer is an error. */
24896
- async request(method, path, body) {
24897
- return this.#call(method, path, body, false);
25614
+ /**
25615
+ * Sends one request and returns its body. A non-2xx answer is an error.
25616
+ * `headers` are sent beside the auth headers, such as the idempotency key
25617
+ * of a filing.
25618
+ */
25619
+ async request(method, path, body, headers) {
25620
+ return (await this.#call(method, path, body, false, headers)).body;
25621
+ }
25622
+ /**
25623
+ * The same, and the headers of the answer, which carry what the body does
25624
+ * not, such as the truncation mark of a list.
25625
+ */
25626
+ async requestWithHeaders(method, path) {
25627
+ return this.#call(method, path, void 0, false);
24898
25628
  }
24899
25629
  /** The same, except that a `404` answers `undefined` instead of failing. */
24900
25630
  async requestOptional(method, path, body) {
24901
- return this.#call(method, path, body, true);
25631
+ return (await this.#call(method, path, body, true)).body;
24902
25632
  }
24903
25633
  /**
24904
25634
  * The prefix of every org-scoped path. Every command that reaches one
@@ -24917,7 +25647,9 @@ var ApiClient = class {
24917
25647
  async orgs() {
24918
25648
  const body = asRecord(await this.request("GET", "/v1/orgs"));
24919
25649
  if (body === void 0) {
24920
- throw new RemoteError("the API returned a body that is not an organization list");
25650
+ throw new RemoteError(
25651
+ "unexpected response from the API: expected an organization list"
25652
+ );
24921
25653
  }
24922
25654
  return { orgs: readOrgs(body["orgs"]), body };
24923
25655
  }
@@ -24926,16 +25658,62 @@ var ApiClient = class {
24926
25658
  const slug = this.org ?? "";
24927
25659
  return parseOrgAnswer(await this.request("GET", this.orgPath()), slug);
24928
25660
  }
25661
+ /** The path of one project of the organization. */
25662
+ projectPath(name) {
25663
+ return `${this.orgPath()}/projects/${encodeURIComponent(name)}`;
25664
+ }
24929
25665
  /** Reads the project record, which owns the allowed tag list in remote mode. */
24930
25666
  async project(name) {
24931
- const body = asRecord(
24932
- await this.request("GET", `${this.orgPath()}/projects/${encodeURIComponent(name)}`)
25667
+ return readProject(await this.request("GET", this.projectPath(name)), name);
25668
+ }
25669
+ /**
25670
+ * Replaces the allowed tag list of a project, or clears it with `null`. The
25671
+ * route retags the stored complaints and answers how many it rewrote.
25672
+ */
25673
+ async setProjectTags(name, tags) {
25674
+ const body = await this.request(
25675
+ "PATCH",
25676
+ this.projectPath(name),
25677
+ JSON.stringify({ tags })
25678
+ );
25679
+ const answer2 = readProject(body, name);
25680
+ const retagged = answer2.body["retagged"];
25681
+ return { ...answer2, retagged: typeof retagged === "number" ? retagged : 0 };
25682
+ }
25683
+ /**
25684
+ * Soft-deletes a project (section 7.5). The route hides it and its
25685
+ * complaints everywhere and revokes its tokens, and answers `204`.
25686
+ */
25687
+ async deleteProject(name) {
25688
+ await this.request("DELETE", this.projectPath(name));
25689
+ }
25690
+ /** Lists the tokens of a project, newest first, as the route sorts them. */
25691
+ async tokens(project) {
25692
+ const body = await this.request("GET", `${this.projectPath(project)}/tokens`);
25693
+ if (!Array.isArray(body)) {
25694
+ throw new RemoteError("unexpected response from the API: expected a token list");
25695
+ }
25696
+ return { tokens: body.map((entry) => readToken(entry)), body };
25697
+ }
25698
+ /** Mints a token. The answer carries the plaintext, once. */
25699
+ async createToken(project, input2) {
25700
+ const body = await this.request(
25701
+ "POST",
25702
+ `${this.projectPath(project)}/tokens`,
25703
+ JSON.stringify(input2)
25704
+ );
25705
+ const plaintext = stringOf(asRecord(body), "plaintext");
25706
+ if (plaintext === void 0) {
25707
+ throw new RemoteError("unexpected response from the API: token has no plaintext");
25708
+ }
25709
+ return { token: readToken(body), plaintext, body };
25710
+ }
25711
+ /** Revokes one token. A repeat answers `204` again. */
25712
+ async revokeToken(project, id) {
25713
+ await this.request(
25714
+ "DELETE",
25715
+ `${this.projectPath(project)}/tokens/${encodeURIComponent(id)}`
24933
25716
  );
24934
- const tags = body?.["tags"];
24935
- return {
24936
- project: stringOf(body, "project") ?? name,
24937
- tags: Array.isArray(tags) ? tags.map((tag) => String(tag)) : void 0
24938
- };
24939
25717
  }
24940
25718
  /**
24941
25719
  * Promotes one suggestion to a real tag. The route needs `maintainer`, so a
@@ -24956,15 +25734,36 @@ var ApiClient = class {
24956
25734
  }
24957
25735
  return { tags: tags.map((entry) => String(entry)), retagged };
24958
25736
  }
24959
- async #call(method, path, body, notFoundIsUndefined) {
25737
+ /**
25738
+ * Sends one request with the auth rules of section 5.7 and returns the
25739
+ * answer whatever its status. It never runs `beforeFirst`, so the offline
25740
+ * queue sends through it. A request that gets no answer still throws.
25741
+ */
25742
+ async exchange(method, path, body, headers) {
25743
+ const { response, text } = await this.#exchange(method, path, body, headers);
25744
+ return { status: response.status, text };
25745
+ }
25746
+ async #call(method, path, body, notFoundIsUndefined, headers) {
25747
+ const first = this.#beforeFirst;
25748
+ this.#beforeFirst = void 0;
25749
+ if (first !== void 0) await first(this);
25750
+ const { response, text, site } = await this.#exchange(method, path, body, headers);
25751
+ if (response.status === 404 && notFoundIsUndefined && serverCode(text) !== ORG_NOT_FOUND) {
25752
+ return { body: void 0, headers: response.headers };
25753
+ }
25754
+ if (!response.ok) throw remoteFailure(site, response, text);
25755
+ if (response.status === 204) return { body: void 0, headers: response.headers };
25756
+ return { body: parseJson(text, site, response.status), headers: response.headers };
25757
+ }
25758
+ async #exchange(method, path, body, extra) {
24960
25759
  const url2 = `${this.#base}${path}`;
24961
25760
  const site = { method, url: url2, debug: this.#http.debug };
24962
- let response = await this.#send(url2, method, await this.#auth.token(), body);
25761
+ let response = await this.#send(url2, method, await this.#auth.token(), body, extra);
24963
25762
  if (response.status === 401) {
24964
25763
  const fresh = await this.#auth.retry();
24965
25764
  if (fresh !== void 0) {
24966
25765
  await readText(response, site);
24967
- response = await this.#send(url2, method, fresh, body);
25766
+ response = await this.#send(url2, method, fresh, body, extra);
24968
25767
  if (response.status === 401) throw sessionExpired(this.endpoint);
24969
25768
  } else if (this.#auth.actAs !== void 0) {
24970
25769
  await readText(response, site);
@@ -24972,14 +25771,13 @@ var ApiClient = class {
24972
25771
  }
24973
25772
  }
24974
25773
  const text = await readText(response, site);
24975
- if (response.status === 404 && notFoundIsUndefined && serverCode(text) !== ORG_NOT_FOUND) {
24976
- return void 0;
24977
- }
24978
- if (!response.ok) throw remoteFailure(site, response, text);
24979
- return parseJson(text, site, response.status);
25774
+ return { response, text, site };
24980
25775
  }
24981
- #send(url2, method, bearer, body) {
24982
- const headers = { authorization: `Bearer ${bearer}` };
25776
+ #send(url2, method, bearer, body, extra = {}) {
25777
+ const headers = {
25778
+ ...extra,
25779
+ authorization: `Bearer ${bearer}`
25780
+ };
24983
25781
  const actAs = this.#auth.actAs;
24984
25782
  if (actAs !== void 0) headers[ACT_AS_HEADER] = actAs;
24985
25783
  if (body !== void 0) headers["content-type"] = "application/json";
@@ -24991,6 +25789,67 @@ var ApiClient = class {
24991
25789
  });
24992
25790
  }
24993
25791
  };
25792
+ var UndeliveredError = class extends RemoteError {
25793
+ body;
25794
+ reason;
25795
+ /**
25796
+ * The idempotency key the request carried, which the queued entry keeps,
25797
+ * so a request that did reach the server files nothing new when it is sent
25798
+ * again.
25799
+ */
25800
+ key;
25801
+ constructor(body, reason, key, cause) {
25802
+ super(cause.message, { cause, status: cause.status, unanswered: cause.unanswered });
25803
+ this.body = body;
25804
+ this.reason = reason;
25805
+ this.key = key;
25806
+ }
25807
+ };
25808
+ function retryableReason(error62) {
25809
+ if (!(error62 instanceof RemoteError)) return void 0;
25810
+ if (error62.unanswered) return "the API is unreachable";
25811
+ const status = error62.status;
25812
+ if (status === void 0) return void 0;
25813
+ if (status === 429) return error62.serverMessage ?? `the API answered ${status}`;
25814
+ if (status >= 500) return `the API answered ${status}`;
25815
+ return void 0;
25816
+ }
25817
+ function complaintBody(complaint) {
25818
+ return {
25819
+ project: complaint.project,
25820
+ message: complaint.message,
25821
+ severity: complaint.severity,
25822
+ tags: complaint.tags,
25823
+ agent: complaint.agent,
25824
+ ...complaint.cwd === void 0 ? {} : { cwd: complaint.cwd },
25825
+ ...complaint.cmd === void 0 ? {} : { cmd: complaint.cmd }
25826
+ };
25827
+ }
25828
+ function readBulkResults(body) {
25829
+ const results = asRecord(body)?.["results"];
25830
+ if (!Array.isArray(results)) {
25831
+ throw new RemoteError("unexpected response from the API: expected bulk results");
25832
+ }
25833
+ return results.map((entry) => {
25834
+ const record2 = asRecord(entry);
25835
+ const id = stringOf(record2, "id");
25836
+ if (record2 === void 0 || id === void 0) {
25837
+ throw new RemoteError("unexpected response from the API: expected a bulk result");
25838
+ }
25839
+ if (record2["ok"] === true) {
25840
+ return { id, ok: true, complaint: readComplaint(record2["complaint"]) };
25841
+ }
25842
+ const error62 = asRecord(record2["error"]);
25843
+ return {
25844
+ id,
25845
+ ok: false,
25846
+ error: {
25847
+ code: stringOf(error62, "code") ?? "error",
25848
+ message: stringOf(error62, "message") ?? "the change failed"
25849
+ }
25850
+ };
25851
+ });
25852
+ }
24994
25853
  function adopt(target, source) {
24995
25854
  for (const key of Object.keys(target)) {
24996
25855
  delete target[key];
@@ -25007,39 +25866,55 @@ var RemoteStore = class {
25007
25866
  return `${this.#client.orgPath()}/complaints${suffix}`;
25008
25867
  }
25009
25868
  /**
25010
- * Posts the complaint. The API assigns the stored `id` and `createdAt`, so
25011
- * the validated record of the response replaces the record passed in.
25869
+ * Posts the complaint. The API assigns the stored `id`, `createdAt`,
25870
+ * `fingerprint` and `recurred`, so the validated record of the response
25871
+ * replaces the record passed in, and the answer's match is returned.
25012
25872
  */
25013
25873
  async create(complaint) {
25014
- const {
25015
- id: _id,
25016
- createdAt: _createdAt,
25017
- org: _org,
25018
- reporter: _reporter,
25019
- reporterName: _reporterName,
25020
- ...input2
25021
- } = complaint;
25022
- const body = await this.#client.request("POST", this.#path(), JSON.stringify(input2));
25874
+ const input2 = complaintBody(complaint);
25875
+ const key = randomUUID2();
25876
+ let body;
25877
+ try {
25878
+ body = await this.#client.request("POST", this.#path(), JSON.stringify(input2), {
25879
+ [IDEMPOTENCY_HEADER]: key
25880
+ });
25881
+ } catch (error62) {
25882
+ const reason = retryableReason(error62);
25883
+ if (reason !== void 0 && error62 instanceof RemoteError) {
25884
+ throw new UndeliveredError(input2, reason, key, error62);
25885
+ }
25886
+ throw error62;
25887
+ }
25023
25888
  adopt(complaint, readComplaint(body));
25889
+ const answer2 = asRecord(body);
25890
+ const openMatches = answer2?.["openMatches"];
25891
+ return {
25892
+ open: typeof openMatches === "number" ? openMatches : 0,
25893
+ resolved: answer2?.["recurred"] === true
25894
+ };
25024
25895
  }
25025
25896
  /** Returns the matching complaints, newest first. */
25026
25897
  async list(filter) {
25027
- const query = new URLSearchParams();
25028
- if (filter.project !== void 0) query.set("project", filter.project);
25029
- if (filter.severity !== void 0) query.set("severity", filter.severity);
25030
- if (filter.status !== void 0) query.set("status", filter.status);
25031
- if (filter.tag !== void 0) query.set("tag", filter.tag);
25032
- if (filter.reporter !== void 0) query.set("reporter", filter.reporter);
25033
- if (filter.since !== void 0) query.set("since", filter.since);
25034
- if (filter.until !== void 0) query.set("until", filter.until);
25035
- if (filter.limit !== void 0) query.set("limit", String(filter.limit));
25036
- const search = query.toString();
25898
+ return (await this.listPage(filter)).complaints;
25899
+ }
25900
+ /**
25901
+ * Returns the matching complaints, newest first, and whether the server
25902
+ * stopped at its read budget, which the answer marks with a header.
25903
+ */
25904
+ async listPage(filter) {
25905
+ const search = new URLSearchParams(listQueryOf(filter)).toString();
25037
25906
  const suffix = search === "" ? "" : `?${search}`;
25038
- const body = await this.#client.request("GET", this.#path(suffix));
25907
+ const { body, headers } = await this.#client.requestWithHeaders(
25908
+ "GET",
25909
+ this.#path(suffix)
25910
+ );
25039
25911
  if (!Array.isArray(body)) {
25040
- throw new RemoteError("the API returned a body that is not an array");
25912
+ throw new RemoteError("unexpected response from the API: expected a list");
25041
25913
  }
25042
- return body.map((item) => readComplaint(item));
25914
+ return {
25915
+ complaints: body.map((item) => readComplaint(item)),
25916
+ truncated: headers.get(TRUNCATED_HEADER) === "1"
25917
+ };
25043
25918
  }
25044
25919
  /** Returns the complaint with that id, or `undefined` when the API says 404. */
25045
25920
  async get(id) {
@@ -25059,11 +25934,26 @@ var RemoteStore = class {
25059
25934
  this.#path(`/${encodeURIComponent(id)}`),
25060
25935
  JSON.stringify({
25061
25936
  status: change.status,
25062
- ...change.resolution === void 0 ? {} : { resolution: change.resolution }
25937
+ ...change.resolution === void 0 ? {} : { resolution: change.resolution },
25938
+ ...change.reason === void 0 ? {} : { reason: change.reason },
25939
+ ...change.duplicateOf === void 0 ? {} : { duplicateOf: change.duplicateOf }
25063
25940
  })
25064
25941
  );
25065
25942
  return body === void 0 ? void 0 : readComplaint(body);
25066
25943
  }
25944
+ /**
25945
+ * Applies one status change to many complaints with the bulk route, in one
25946
+ * request. A per-id `not_found` reads as an absent record, exactly like the
25947
+ * `404` of one id, so its result carries no complaint and the code.
25948
+ */
25949
+ async bulkStatus(ids, change) {
25950
+ const body = await this.#client.request(
25951
+ "POST",
25952
+ this.#path("/bulk"),
25953
+ JSON.stringify({ ids, ...change })
25954
+ );
25955
+ return readBulkResults(body);
25956
+ }
25067
25957
  /**
25068
25958
  * Replaces the tags. The API stamps the change with its own time, so the
25069
25959
  * request carries the list only.
@@ -25078,7 +25968,375 @@ var RemoteStore = class {
25078
25968
  }
25079
25969
  };
25080
25970
 
25971
+ // src/queue.ts
25972
+ import { createHash as createHash2, randomBytes as randomBytes2, randomUUID as randomUUID3 } from "node:crypto";
25973
+ import {
25974
+ appendFileSync,
25975
+ closeSync,
25976
+ fstatSync,
25977
+ linkSync,
25978
+ mkdirSync as mkdirSync3,
25979
+ openSync,
25980
+ readFileSync as readFileSync3,
25981
+ readSync,
25982
+ renameSync as renameSync2,
25983
+ rmSync as rmSync2,
25984
+ statSync,
25985
+ writeSync
25986
+ } from "node:fs";
25987
+ import { hostname as hostname3 } from "node:os";
25988
+ import { dirname as dirname4, join as join4 } from "node:path";
25989
+
25990
+ // src/text.ts
25991
+ var CONTROL_RE2 = /[\u0000-\u001F\u007F-\u009F]/g;
25992
+ function escape(char) {
25993
+ return `\\x${(char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")}`;
25994
+ }
25995
+ function printable(text) {
25996
+ return text.replace(/\s+/g, " ").replace(CONTROL_RE2, escape);
25997
+ }
25998
+ function printableLines(text) {
25999
+ return text.replace(/\r\n?/g, "\n").replace(
26000
+ CONTROL_RE2,
26001
+ (char) => char === "\n" || char === " " ? char : escape(char)
26002
+ );
26003
+ }
26004
+
26005
+ // src/queue.ts
26006
+ var QUEUE_FILENAME = "unsent.jsonl";
26007
+ var LOCK_FILENAME = "unsent.lock";
26008
+ var LOCK_STALE_MS = 5 * 6e4;
26009
+ var QUEUE_MODE = CREDENTIALS_MODE;
26010
+ function queuePath(env) {
26011
+ return join4(defaultQuibbleHome(env), QUEUE_FILENAME);
26012
+ }
26013
+ function lockPathOf(queue) {
26014
+ return join4(dirname4(queue), LOCK_FILENAME);
26015
+ }
26016
+ function authLabel(context) {
26017
+ if (context.token !== void 0) {
26018
+ const hash2 = createHash2("sha256").update(context.token).digest("hex");
26019
+ return `token:${hash2.slice(0, 16)}`;
26020
+ }
26021
+ if (context.credential !== void 0) return `session:${context.credential.sub}`;
26022
+ return `dev:${context.actAs ?? ""}`;
26023
+ }
26024
+ function readOptional(path) {
26025
+ try {
26026
+ return readFileSync3(path, "utf8");
26027
+ } catch (error62) {
26028
+ if (isNotFound(error62)) return "";
26029
+ throw new StorageError(`cannot read ${path}`, { cause: error62 });
26030
+ }
26031
+ }
26032
+ function endsInsideLine(path) {
26033
+ let fd;
26034
+ try {
26035
+ fd = openSync(path, "r");
26036
+ } catch (error62) {
26037
+ if (isNotFound(error62)) return false;
26038
+ throw error62;
26039
+ }
26040
+ try {
26041
+ const size = fstatSync(fd).size;
26042
+ if (size === 0) return false;
26043
+ const last = Buffer.alloc(1);
26044
+ readSync(fd, last, 0, 1, size - 1);
26045
+ return last[0] !== 10;
26046
+ } finally {
26047
+ closeSync(fd);
26048
+ }
26049
+ }
26050
+ function appendLine(path, value) {
26051
+ try {
26052
+ mkdirSync3(dirname4(path), { recursive: true, mode: HOME_MODE });
26053
+ const lead = endsInsideLine(path) ? "\n" : "";
26054
+ appendFileSync(path, `${lead}${JSON.stringify(value)}
26055
+ `, {
26056
+ encoding: "utf8",
26057
+ mode: QUEUE_MODE
26058
+ });
26059
+ } catch (error62) {
26060
+ throw new StorageError(`cannot write ${path}`, { cause: error62 });
26061
+ }
26062
+ }
26063
+ function badLine(path, line, why) {
26064
+ return new StorageError(`${path}: line ${line}: ${why}`);
26065
+ }
26066
+ function readQueuedLine(record2, path, line) {
26067
+ const body = asRecord(record2["body"]);
26068
+ const fields = ["key", "endpoint", "org", "auth", "queuedAt"];
26069
+ for (const field of fields) {
26070
+ if (typeof record2[field] !== "string") {
26071
+ throw badLine(path, line, `${field} must be a string`);
26072
+ }
26073
+ }
26074
+ if (body === void 0) throw badLine(path, line, "body must be an object");
26075
+ return {
26076
+ key: record2["key"],
26077
+ endpoint: record2["endpoint"],
26078
+ org: record2["org"],
26079
+ auth: record2["auth"],
26080
+ body,
26081
+ queuedAt: record2["queuedAt"]
26082
+ };
26083
+ }
26084
+ function readQueue(path) {
26085
+ const pending = /* @__PURE__ */ new Map();
26086
+ const known = /* @__PURE__ */ new Set();
26087
+ const lines = readOptional(path).split("\n");
26088
+ for (const [index, text] of lines.entries()) {
26089
+ const line = index + 1;
26090
+ if (text.trim() === "") continue;
26091
+ let record2;
26092
+ try {
26093
+ record2 = asRecord(JSON.parse(text));
26094
+ } catch {
26095
+ throw badLine(path, line, "not JSON");
26096
+ }
26097
+ if (record2 === void 0) throw badLine(path, line, "not a JSON object");
26098
+ const op = record2["op"];
26099
+ if (op === "queued") {
26100
+ const entry = readQueuedLine(record2, path, line);
26101
+ known.add(entry.key);
26102
+ pending.set(entry.key, entry);
26103
+ continue;
26104
+ }
26105
+ if (op === "sent" || op === "dropped") {
26106
+ const key = record2["key"];
26107
+ if (typeof key !== "string" || !known.has(key)) {
26108
+ throw badLine(path, line, `${op} names no queued complaint`);
26109
+ }
26110
+ pending.delete(key);
26111
+ continue;
26112
+ }
26113
+ throw badLine(path, line, `unknown op ${JSON.stringify(op)}`);
26114
+ }
26115
+ return [...pending.values()];
26116
+ }
26117
+ function pendingFor(path, endpoint) {
26118
+ const key = normalizeEndpoint(endpoint);
26119
+ return readQueue(path).filter((entry) => entry.endpoint === key);
26120
+ }
26121
+ function enqueue(path, input2, now = /* @__PURE__ */ new Date()) {
26122
+ const entry = {
26123
+ key: input2.key ?? randomUUID3(),
26124
+ endpoint: normalizeEndpoint(input2.endpoint),
26125
+ org: input2.org,
26126
+ auth: input2.auth,
26127
+ body: input2.body,
26128
+ queuedAt: now.toISOString()
26129
+ };
26130
+ appendLine(path, { op: "queued", ...entry });
26131
+ return entry;
26132
+ }
26133
+ function processRuns(pid) {
26134
+ try {
26135
+ process.kill(pid, 0);
26136
+ return true;
26137
+ } catch (error62) {
26138
+ return error62.code === "EPERM";
26139
+ }
26140
+ }
26141
+ function judgeLock(lock, now = /* @__PURE__ */ new Date()) {
26142
+ let text;
26143
+ try {
26144
+ text = readFileSync3(lock, "utf8");
26145
+ } catch (error62) {
26146
+ if (isNotFound(error62)) return void 0;
26147
+ throw new StorageError(`cannot read ${lock}`, { cause: error62 });
26148
+ }
26149
+ const record2 = asRecord(parseQuietly(text));
26150
+ const pid = record2?.["pid"];
26151
+ const host = stringOf(record2, "host");
26152
+ const at = Date.parse(stringOf(record2, "at") ?? "");
26153
+ if (typeof pid !== "number" || host === void 0 || Number.isNaN(at)) {
26154
+ let written;
26155
+ try {
26156
+ written = statSync(lock).mtimeMs;
26157
+ } catch (error62) {
26158
+ if (isNotFound(error62)) return void 0;
26159
+ throw new StorageError(`cannot read ${lock}`, { cause: error62 });
26160
+ }
26161
+ return { text, stale: now.getTime() - written > LOCK_STALE_MS };
26162
+ }
26163
+ const dead = host === hostname3() && !processRuns(pid);
26164
+ return { text, stale: dead || now.getTime() - at > LOCK_STALE_MS };
26165
+ }
26166
+ function createLock(lock, owner = { pid: process.pid, host: hostname3(), at: (/* @__PURE__ */ new Date()).toISOString() }) {
26167
+ const text = JSON.stringify(owner);
26168
+ let fd;
26169
+ try {
26170
+ mkdirSync3(dirname4(lock), { recursive: true, mode: HOME_MODE });
26171
+ fd = openSync(lock, "wx", QUEUE_MODE);
26172
+ } catch (error62) {
26173
+ if (error62.code === "EEXIST") return void 0;
26174
+ throw new StorageError(`cannot create ${lock}`, { cause: error62 });
26175
+ }
26176
+ try {
26177
+ writeSync(fd, text);
26178
+ } finally {
26179
+ closeSync(fd);
26180
+ }
26181
+ return text;
26182
+ }
26183
+ function takeOverLock(lock, judged, owner) {
26184
+ const moved = `${lock}.${process.pid}.${randomBytes2(6).toString("hex")}`;
26185
+ try {
26186
+ renameSync2(lock, moved);
26187
+ } catch (error62) {
26188
+ if (isNotFound(error62)) return void 0;
26189
+ throw new StorageError(`cannot move ${lock}`, { cause: error62 });
26190
+ }
26191
+ const text = readOptional(moved);
26192
+ if (text === judged.text) {
26193
+ rmSync2(moved, { force: true });
26194
+ return createLock(lock, owner);
26195
+ }
26196
+ try {
26197
+ linkSync(moved, lock);
26198
+ } catch (error62) {
26199
+ if (error62.code !== "EEXIST") {
26200
+ throw new StorageError(`cannot restore ${lock}`, { cause: error62 });
26201
+ }
26202
+ } finally {
26203
+ rmSync2(moved, { force: true });
26204
+ }
26205
+ return void 0;
26206
+ }
26207
+ function acquireLock(lock) {
26208
+ const created = createLock(lock);
26209
+ if (created !== void 0) return created;
26210
+ const judged = judgeLock(lock);
26211
+ if (judged === void 0) return createLock(lock);
26212
+ if (!judged.stale) return void 0;
26213
+ return takeOverLock(lock, judged);
26214
+ }
26215
+ function releaseLock(lock, text) {
26216
+ if (readOptional(lock) === text) rmSync2(lock, { force: true });
26217
+ }
26218
+ function stopReason(status, text) {
26219
+ if (status >= 500) return `the API answered ${status}`;
26220
+ const code2 = serverCode(text);
26221
+ const stops = status === 429 || status === 401 || status === 403 || status === 404 && code2 === ORG_NOT_FOUND;
26222
+ if (!stops) return void 0;
26223
+ const message = stringOf(asRecord(asRecord(parseQuietly(text))?.["error"]), "message");
26224
+ return message ?? `the API answered ${status}`;
26225
+ }
26226
+ function projectOf(entry) {
26227
+ const project = entry.body["project"];
26228
+ return typeof project === "string" ? project : "?";
26229
+ }
26230
+ function headlineOfEntry(entry) {
26231
+ const message = entry.body["message"];
26232
+ return typeof message === "string" ? printable(summarize(message)) : "?";
26233
+ }
26234
+ function queuedCount(n) {
26235
+ return `${n} queued ${n === 1 ? "complaint" : "complaints"}`;
26236
+ }
26237
+ async function flushQueue(options) {
26238
+ const endpoint = normalizeEndpoint(options.endpoint);
26239
+ const mine = () => readQueue(options.path).filter(
26240
+ (entry) => entry.endpoint === endpoint && entry.auth === options.auth
26241
+ );
26242
+ const result = {
26243
+ sent: [],
26244
+ dropped: 0,
26245
+ left: 0,
26246
+ locked: false,
26247
+ stopped: void 0
26248
+ };
26249
+ if (mine().length === 0) return result;
26250
+ const lock = lockPathOf(options.path);
26251
+ const held = acquireLock(lock);
26252
+ if (held === void 0) {
26253
+ return { ...result, locked: true, left: mine().length };
26254
+ }
26255
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
26256
+ try {
26257
+ const entries = mine();
26258
+ for (const [index, entry] of entries.entries()) {
26259
+ let status;
26260
+ let text;
26261
+ try {
26262
+ ({ status, text } = await options.client.exchange(
26263
+ "POST",
26264
+ `/v1/orgs/${encodeURIComponent(entry.org)}/complaints`,
26265
+ JSON.stringify(entry.body),
26266
+ { [IDEMPOTENCY_HEADER]: entry.key }
26267
+ ));
26268
+ } catch (error62) {
26269
+ result.stopped = errorMessage(error62);
26270
+ result.left = entries.length - index;
26271
+ return result;
26272
+ }
26273
+ if (status >= 200 && status < 300) {
26274
+ const id = stringOf(asRecord(parseQuietly(text)), "id") ?? "";
26275
+ appendLine(options.path, {
26276
+ op: "sent",
26277
+ key: entry.key,
26278
+ id,
26279
+ sentAt: now().toISOString()
26280
+ });
26281
+ result.sent.push(id);
26282
+ continue;
26283
+ }
26284
+ const reason = stopReason(status, text);
26285
+ if (reason !== void 0) {
26286
+ result.stopped = reason;
26287
+ result.left = entries.length - index;
26288
+ return result;
26289
+ }
26290
+ const record2 = asRecord(asRecord(parseQuietly(text))?.["error"]);
26291
+ const code2 = stringOf(record2, "code") ?? `http_${status}`;
26292
+ const message = stringOf(record2, "message") ?? `the API answered ${status}`;
26293
+ appendLine(options.path, {
26294
+ op: "dropped",
26295
+ key: entry.key,
26296
+ code: code2,
26297
+ message,
26298
+ droppedAt: now().toISOString()
26299
+ });
26300
+ result.dropped += 1;
26301
+ writeLine(
26302
+ options.stderr,
26303
+ `quibble: dropped a queued complaint for ${entry.org}/${projectOf(entry)}, "${headlineOfEntry(entry)}": ${printable(message)}`
26304
+ );
26305
+ }
26306
+ return result;
26307
+ } finally {
26308
+ releaseLock(lock, held);
26309
+ }
26310
+ }
26311
+
25081
26312
  // src/store.ts
26313
+ function queueSender(context, endpoint, io) {
26314
+ return async (client) => {
26315
+ let result;
26316
+ try {
26317
+ result = await flushQueue({
26318
+ path: queuePath(io.env),
26319
+ endpoint,
26320
+ auth: authLabel(context),
26321
+ client,
26322
+ stderr: io.stderr
26323
+ });
26324
+ } catch (error62) {
26325
+ if (!(error62 instanceof StorageError)) throw error62;
26326
+ writeLine(
26327
+ io.stderr,
26328
+ `quibble: warning: cannot send the queue: ${errorMessage(error62)}`
26329
+ );
26330
+ return;
26331
+ }
26332
+ if (result.stopped !== void 0) {
26333
+ writeLine(
26334
+ io.stderr,
26335
+ `quibble: ${queuedCount(result.left)} still unsent: ${result.stopped}`
26336
+ );
26337
+ }
26338
+ };
26339
+ }
25082
26340
  function actAsProblem(context) {
25083
26341
  if (context.actAs === void 0) return void 0;
25084
26342
  const endpoint = context.endpoint;
@@ -25087,22 +26345,25 @@ function actAsProblem(context) {
25087
26345
  }
25088
26346
  if (context.token !== void 0) return "QUIBBLE_TOKEN is set";
25089
26347
  if (context.credential !== void 0) {
25090
- return `a stored session for ${endpoint} is used`;
26348
+ return `you are logged in to ${endpoint}`;
25091
26349
  }
25092
- if (!isLoopback(endpoint)) return `${endpoint} is not a loopback endpoint`;
26350
+ if (!isLoopbackUrl(endpoint)) return `${endpoint} is not a loopback endpoint`;
25093
26351
  return void 0;
25094
26352
  }
25095
26353
  function checkActAs(context) {
25096
26354
  const problem = actAsProblem(context);
25097
26355
  if (problem !== void 0) throw actAsNotApplicable(problem);
25098
26356
  }
25099
- function openClient(context, io) {
26357
+ async function openClient(context, io, options = {}) {
26358
+ const endpoint = await resolveEndpoint(context, io);
25100
26359
  checkActAs(context);
25101
- const endpoint = requireEndpoint(context);
25102
- const http = { debug: context.debug, fetch: io.fetch };
25103
- const org = context.org === void 0 ? {} : { org: context.org };
26360
+ const http = { debug: context.debug, fetch: io.fetch, timeoutMs: context.timeoutMs };
26361
+ const org = {
26362
+ ...context.org === void 0 ? {} : { org: context.org },
26363
+ ...options.queue === false ? {} : { beforeFirst: queueSender(context, endpoint, io) }
26364
+ };
25104
26365
  if (context.token === void 0 && context.credential === void 0) {
25105
- if (!isLoopback(endpoint)) throw notLoggedIn(endpoint);
26366
+ if (!isLoopbackUrl(endpoint)) throw notLoggedIn(endpoint);
25106
26367
  return new ApiClient({
25107
26368
  endpoint,
25108
26369
  authorizer: devAuthorizer(endpoint, context.actAs, http),
@@ -25117,25 +26378,35 @@ function openClient(context, io) {
25117
26378
  ...http
25118
26379
  });
25119
26380
  }
25120
- function openOrgClient(context, io) {
25121
- checkActAs(context);
26381
+ async function openOrgClient(context, io, options = {}) {
26382
+ if (context.mode === "local") checkActAs(context);
25122
26383
  requireOrg(context);
25123
- return openClient(context, io);
26384
+ return openClient(context, io, options);
25124
26385
  }
25125
- function openBackend(context, io) {
26386
+ async function openBackend(context, io) {
25126
26387
  if (context.mode === "local") {
25127
26388
  checkActAs(context);
25128
- return { store: new FileStore(context.storePath), client: void 0 };
26389
+ refuseForeignOrg(context);
26390
+ const local = new FileStore(context.storePath);
26391
+ return { store: local, client: void 0, local };
25129
26392
  }
25130
- const client = openOrgClient(context, io);
25131
- return { store: new RemoteStore(client), client };
26393
+ const client = await openOrgClient(context, io);
26394
+ return { store: new RemoteStore(client), client, local: void 0 };
25132
26395
  }
25133
- function openStore(context, io) {
25134
- return openBackend(context, io).store;
26396
+ async function openStore(context, io) {
26397
+ return (await openBackend(context, io)).store;
26398
+ }
26399
+ function needsServer(command, context) {
26400
+ return new ConfigError(
26401
+ `${command} needs a server: ${context.localFix ?? "drop --local"}`
26402
+ );
26403
+ }
26404
+ function requireServer(command, context) {
26405
+ if (context.mode !== "remote") throw needsServer(command, context);
25135
26406
  }
25136
26407
 
25137
26408
  // src/commands/complain.ts
25138
- var REMOTE_REPORTER_MESSAGE = "--reporter is not allowed in remote mode: the server records the signed-in user";
26409
+ var REMOTE_REPORTER_MESSAGE = "--reporter only works in local mode: the server sets the reporter";
25139
26410
  async function runComplain(message, options, context, io) {
25140
26411
  const project = requireProject(context);
25141
26412
  const org = requireOrg(context);
@@ -25143,7 +26414,7 @@ async function runComplain(message, options, context, io) {
25143
26414
  throw new UsageError(REMOTE_REPORTER_MESSAGE);
25144
26415
  }
25145
26416
  const body = await readMessage(message, io);
25146
- const store = openStore(context, io);
26417
+ const store = await openStore(context, io);
25147
26418
  const input2 = {
25148
26419
  org,
25149
26420
  project,
@@ -25153,13 +26424,21 @@ async function runComplain(message, options, context, io) {
25153
26424
  ...options.tag === void 0 || options.tag.length === 0 ? {} : { tags: options.tag },
25154
26425
  agent: context.agent,
25155
26426
  reporter: context.reporter,
25156
- ...context.cwd === void 0 ? {} : { cwd: context.cwd }
26427
+ ...context.cwd === void 0 ? {} : { cwd: context.cwd },
26428
+ ...options.cmd === void 0 ? {} : { cmd: options.cmd }
25157
26429
  };
25158
- const complaint = await createComplaint(store, input2, {
25159
- allowedTags: context.allowedTags
25160
- });
26430
+ let complaint;
26431
+ try {
26432
+ complaint = await createComplaint(store, input2, {
26433
+ allowedTags: context.allowedTags
26434
+ });
26435
+ } catch (error62) {
26436
+ if (!(error62 instanceof UndeliveredError)) throw error62;
26437
+ return queueComplaint(error62, org, project, context, io, options.json === true);
26438
+ }
25161
26439
  if (options.json === true) {
25162
26440
  writeJson(io.stdout, complaint);
26441
+ localNotice(context, io);
25163
26442
  return 0;
25164
26443
  }
25165
26444
  const scope = context.mode === "remote" ? `${complaint.org}/${complaint.project}` : complaint.project;
@@ -25167,6 +26446,43 @@ async function runComplain(message, options, context, io) {
25167
26446
  io.stdout,
25168
26447
  `Filed complaint ${complaint.id} for ${scope} (${complaint.severity})`
25169
26448
  );
26449
+ for (const line of matchLines(complaint)) writeLine(io.stdout, line);
26450
+ localNotice(context, io);
26451
+ return 0;
26452
+ }
26453
+ function localNotice(context, io) {
26454
+ if (context.mode !== "local") return;
26455
+ writeLine(
26456
+ io.stderr,
26457
+ `quibble: local mode: nothing was sent; the complaint is in ${context.storePath}`
26458
+ );
26459
+ }
26460
+ function matchLines(complaint) {
26461
+ const lines = [];
26462
+ if (complaint.openMatches === 1) {
26463
+ lines.push("1 open complaint already shares this headline.");
26464
+ } else if (complaint.openMatches > 1) {
26465
+ lines.push(`${complaint.openMatches} open complaints already share this headline.`);
26466
+ }
26467
+ if (complaint.recurred) {
26468
+ lines.push("A resolved complaint shares this headline: this one is marked recurred.");
26469
+ }
26470
+ return lines;
26471
+ }
26472
+ function queueComplaint(error62, org, project, context, io, json2) {
26473
+ const path = queuePath(io.env);
26474
+ enqueue(path, {
26475
+ endpoint: requireEndpoint(context),
26476
+ org,
26477
+ auth: authLabel(context),
26478
+ body: error62.body,
26479
+ key: error62.key
26480
+ });
26481
+ writeLine(
26482
+ io.stderr,
26483
+ `quibble: queued the complaint for ${org}/${project} in ${path}: ${error62.reason}. It is sent with the next remote command under the same credential, or run \`quibble retry\`.`
26484
+ );
26485
+ if (json2) writeJson(io.stdout, { queued: true, org, project, reason: error62.reason });
25170
26486
  return 0;
25171
26487
  }
25172
26488
  async function readMessage(message, io) {
@@ -25184,7 +26500,7 @@ function describeCredential(context) {
25184
26500
  if (context.credential !== void 0) {
25185
26501
  return `logged in as ${context.credential.email}`;
25186
26502
  }
25187
- if (context.endpoint !== void 0 && isLoopback(context.endpoint)) {
26503
+ if (context.endpoint !== void 0 && isLoopbackUrl(context.endpoint)) {
25188
26504
  return "loopback dev token";
25189
26505
  }
25190
26506
  return "not logged in";
@@ -25196,57 +26512,150 @@ function describeAuth(context) {
25196
26512
  if (problem === void 0) return `${credential}, acting as ${context.actAs}`;
25197
26513
  return `${credential}; ${ACT_AS_ENV} does not apply: ${problem}`;
25198
26514
  }
25199
- function runConfig(options, context, io) {
26515
+ async function runConfig(options, context, io) {
26516
+ if (context.mode === "remote") await resolveEndpoint(context, io);
26517
+ const ignored = foreignOrgMessage(context) === void 0 ? void 0 : context.slug;
25200
26518
  const report = {
25201
26519
  project: context.project ?? "none",
25202
26520
  org: context.org ?? null,
26521
+ orgIgnored: ignored ?? null,
25203
26522
  projectRoot: context.projectRoot ?? null,
25204
26523
  configPath: context.configPath ?? null,
25205
26524
  mode: context.mode,
25206
- endpoint: context.endpoint ?? null,
26525
+ endpoint: context.endpoint ?? "local",
26526
+ endpointSource: context.endpointSource,
25207
26527
  storePath: context.storePath,
25208
26528
  agent: context.agent,
25209
26529
  reporter: context.reporter,
25210
26530
  actAs: context.actAs ?? null,
25211
- auth: describeAuth(context)
26531
+ auth: describeAuth(context),
26532
+ // Every credential counts, and nothing is sent.
26533
+ unsent: context.mode === "remote" && context.endpoint !== void 0 ? pendingFor(queuePath(io.env), context.endpoint).length : 0
25212
26534
  };
25213
26535
  if (options.json === true) {
25214
26536
  writeJson(io.stdout, report);
25215
26537
  return 0;
25216
26538
  }
25217
- const width = Math.max(...Object.keys(report).map((key) => key.length));
25218
- for (const [key, value] of Object.entries(report)) {
25219
- writeLine(io.stdout, `${key.padEnd(width)} ${value ?? "none"}`);
26539
+ const { orgIgnored, ...lines } = report;
26540
+ const text = { ...lines };
26541
+ if (orgIgnored !== null) {
26542
+ text["org"] = `${report.org ?? ""}; ${orgIgnored} does not apply: ${context.localFix ?? ""}`;
26543
+ }
26544
+ const width = Math.max(...Object.keys(text).map((key) => key.length));
26545
+ for (const [key, value] of Object.entries(text)) {
26546
+ writeLine(io.stdout, `${key.padEnd(width)} ${String(value ?? "none")}`);
25220
26547
  }
25221
26548
  return 0;
25222
26549
  }
25223
26550
 
25224
26551
  // src/commands/init.ts
25225
- import { existsSync, writeFileSync as writeFileSync2 } from "node:fs";
25226
- import { basename, join as join4, resolve as resolve2 } from "node:path";
25227
- var SNIPPET_INTRO = `## Filing complaints
26552
+ import { existsSync as existsSync2, writeFileSync as writeFileSync5 } from "node:fs";
26553
+ import { basename as basename2, join as join7, resolve as resolve3 } from "node:path";
25228
26554
 
25229
- This project uses Quibble. When the build, the tests, the documentation or the
25230
- tooling waste your time, file a complaint instead of working around the problem
25231
- in silence.`;
25232
- var SNIPPET_USAGE = `Use \`-s low|medium|high\` for severity and repeat \`-t <tag>\` for tags. The first
25233
- line of the message is the headline every surface shows, so put the summary on
25234
- the first line and the detail below it. Pipe a long message on stdin with
25235
- \`quibble complain -\`. File one complaint per problem, and file it when you hit
25236
- the problem rather than at the end of the task.`;
25237
- function exampleCommand(tag) {
25238
- return ` quibble complain "what went wrong, and what you expected" -s medium -t ${tag}`;
25239
- }
25240
- var AGENT_SNIPPET = [SNIPPET_INTRO, exampleCommand("build"), SNIPPET_USAGE].join(
25241
- "\n\n"
25242
- );
26555
+ // src/hook.ts
26556
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
26557
+ import { dirname as dirname5, join as join5 } from "node:path";
26558
+ var SETTINGS_PATH = ".claude/settings.json";
26559
+ var HOOK_COMMAND = "command -v quibble >/dev/null 2>&1 && quibble sync --quiet || true";
26560
+ var HOOK_MARK = "quibble sync --quiet";
26561
+ function hookEntry() {
26562
+ return {
26563
+ matcher: "startup",
26564
+ hooks: [{ type: "command", command: HOOK_COMMAND }]
26565
+ };
26566
+ }
26567
+ function isObject2(value) {
26568
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26569
+ }
26570
+ function runsSync(entry) {
26571
+ if (!isObject2(entry) || !Array.isArray(entry["hooks"])) return false;
26572
+ return entry["hooks"].some(
26573
+ (hook) => isObject2(hook) && typeof hook["command"] === "string" && hook["command"].includes(HOOK_MARK)
26574
+ );
26575
+ }
26576
+ function planHook(root) {
26577
+ const path = join5(root, SETTINGS_PATH);
26578
+ let text;
26579
+ try {
26580
+ text = readFileSync4(path, "utf8");
26581
+ } catch (error62) {
26582
+ if (!isNotFound(error62)) throw error62;
26583
+ const created = { hooks: { SessionStart: [hookEntry()] } };
26584
+ return { path, text: `${JSON.stringify(created, null, 2)}
26585
+ ` };
26586
+ }
26587
+ const invalid = new UsageError(
26588
+ `${path} is not valid JSON: fix it, then run the command again`
26589
+ );
26590
+ let settings;
26591
+ try {
26592
+ settings = JSON.parse(text);
26593
+ } catch {
26594
+ throw invalid;
26595
+ }
26596
+ if (!isObject2(settings)) throw invalid;
26597
+ const hooks = settings["hooks"] === void 0 ? {} : settings["hooks"];
26598
+ if (!isObject2(hooks)) throw new UsageError(`${path}: hooks must be an object`);
26599
+ const start = hooks["SessionStart"] === void 0 ? [] : hooks["SessionStart"];
26600
+ if (!Array.isArray(start)) {
26601
+ throw new UsageError(`${path}: hooks.SessionStart must be an array`);
26602
+ }
26603
+ if (start.some(runsSync)) return { path, text: void 0 };
26604
+ const merged = {
26605
+ ...settings,
26606
+ hooks: { ...hooks, SessionStart: [...start, hookEntry()] }
26607
+ };
26608
+ return { path, text: `${JSON.stringify(merged, null, 2)}
26609
+ ` };
26610
+ }
26611
+ function applyHook(plan) {
26612
+ if (plan.text === void 0) return `${plan.path} already runs quibble sync`;
26613
+ mkdirSync4(dirname5(plan.path), { recursive: true });
26614
+ writeFileSync3(plan.path, plan.text, "utf8");
26615
+ return `Added the quibble sync hook to ${plan.path}`;
26616
+ }
26617
+
26618
+ // src/snippet.ts
26619
+ import { basename } from "node:path";
26620
+ var SNIPPET_HEADING = "## Filing complaints";
26621
+ var SKILL_HEADING = "# Filing complaints";
26622
+ var SKILL_PATH = ".claude/skills/quibble/SKILL.md";
26623
+ var DEFAULT_SNIPPET_AGENT = "claude";
26624
+ var AGENTS_FILE_AGENT = "codex";
26625
+ var SNIPPET_INTRO = `This project uses Quibble. When the build, tests, docs or tooling waste your
26626
+ time, file a complaint instead of quietly working around the problem.`;
26627
+ var SNIPPET_USAGE = `- Set severity with \`-s low|medium|high\`. Add tags with \`-t <tag>\` (repeatable).
26628
+ - Put a one-line summary first; it is the headline everywhere. Add details below.
26629
+ - Name the command that failed with \`--cmd "<command>"\`.
26630
+ - Pipe a long message on stdin with \`quibble complain -\`.
26631
+ - File one complaint per problem, as soon as you hit it, not at the end of the task.`;
26632
+ var SKILL_FRONT_MATTER = `---
26633
+ name: quibble
26634
+ description: File a Quibble complaint when the build, tests, docs or tooling of this project waste your time. Use it as soon as you hit the problem, not at the end of the task.
26635
+ ---`;
25243
26636
  var SNIPPET_LOGIN = "Run `quibble login` once before the first complaint.";
25244
26637
  function orgSentence(org) {
25245
26638
  return `Complaints go to the \`${org}\` organization.`;
25246
26639
  }
26640
+ function exampleCommand(tag, agent) {
26641
+ return ` quibble complain "what went wrong, and what you expected" -s medium -t ${tag} --agent ${agent}`;
26642
+ }
26643
+ function tagRules(tags) {
26644
+ const list = tags.map((tag) => `\`${tag}\``).join(", ");
26645
+ return `Every complaint needs at least one of these tags: ${list}.
26646
+ If none fits, pass \`-t ${OTHER_TAG_PREFIX}<name>\` naming the tag you wanted, such as
26647
+ \`-t ${OTHER_TAG_PREFIX}network\`. Run \`quibble tags\` to see the list.`;
26648
+ }
25247
26649
  function agentSnippet(tags, scope = {}) {
25248
26650
  const first = tags?.[0];
25249
- const blocks = tags === void 0 || first === void 0 ? [SNIPPET_INTRO, exampleCommand("build"), SNIPPET_USAGE] : [SNIPPET_INTRO, exampleCommand(first), tagRules(tags), SNIPPET_USAGE];
26651
+ const agent = scope.agent ?? DEFAULT_SNIPPET_AGENT;
26652
+ const blocks = [
26653
+ SNIPPET_HEADING,
26654
+ SNIPPET_INTRO,
26655
+ exampleCommand(first ?? "build", agent)
26656
+ ];
26657
+ if (tags !== void 0 && first !== void 0) blocks.push(tagRules(tags));
26658
+ blocks.push(SNIPPET_USAGE);
25250
26659
  const sentences = [];
25251
26660
  const { endpoint, org } = scope;
25252
26661
  if (endpoint !== void 0 && endpoint !== "") sentences.push(SNIPPET_LOGIN);
@@ -25254,54 +26663,300 @@ function agentSnippet(tags, scope = {}) {
25254
26663
  if (sentences.length > 0) blocks.push(sentences.join(" "));
25255
26664
  return blocks.join("\n\n");
25256
26665
  }
25257
- function tagRules(tags) {
25258
- const list = tags.map((tag) => `\`${tag}\``).join(", ");
25259
- return `Every complaint needs at least one tag. The allowed tags are ${list}.
25260
- When no tag fits, pass \`-t ${OTHER_TAG_HINT}\` and name the tag you wanted, such
25261
- as \`-t ${OTHER_TAG_PREFIX}network\`. Run \`quibble tags\` to print the list.`;
25262
- }
25263
- function runInit(options, io) {
25264
- const directory = resolve2(io.cwd);
25265
- const path = join4(directory, PROJECT_CONFIG_FILENAME);
25266
- if (existsSync(path) && options.force !== true) {
26666
+ function skillFile(snippet) {
26667
+ const body = snippet.startsWith(SNIPPET_HEADING) ? `${SKILL_HEADING}${snippet.slice(SNIPPET_HEADING.length)}` : snippet;
26668
+ return `${SKILL_FRONT_MATTER}
26669
+
26670
+ ${body}
26671
+ `;
26672
+ }
26673
+ function agentForFile(path) {
26674
+ return basename(path).toLowerCase() === "agents.md" ? AGENTS_FILE_AGENT : DEFAULT_SNIPPET_AGENT;
26675
+ }
26676
+ function hasSnippet(text) {
26677
+ return text.split(/\r?\n/).some((line) => line.trimEnd() === SNIPPET_HEADING);
26678
+ }
26679
+ function splitLines(text) {
26680
+ const lines = [];
26681
+ const pattern = /([^\r\n]*)(\r\n|\n|\r)?/g;
26682
+ let match;
26683
+ while ((match = pattern.exec(text)) !== null) {
26684
+ const [whole, content = "", eol = ""] = match;
26685
+ if (whole === "") break;
26686
+ lines.push({ text: content, eol });
26687
+ }
26688
+ return lines;
26689
+ }
26690
+ function fenceOf(text) {
26691
+ if (text.startsWith("```")) return "```";
26692
+ if (text.startsWith("~~~")) return "~~~";
26693
+ return void 0;
26694
+ }
26695
+ function endsSection(text) {
26696
+ return text.startsWith("# ") || text.startsWith("## ");
26697
+ }
26698
+ function agentOfText(lines) {
26699
+ const line = lines.find(
26700
+ (text) => text.includes("quibble complain") && text.includes("--agent")
26701
+ );
26702
+ if (line === void 0) return void 0;
26703
+ return /--agent\s+(\S+)/.exec(line)?.[1];
26704
+ }
26705
+ function findSection(text) {
26706
+ const lines = splitLines(text);
26707
+ const start = lines.findIndex((line) => line.text.trimEnd() === SNIPPET_HEADING);
26708
+ if (start < 0) return void 0;
26709
+ let next = lines.length;
26710
+ let fence;
26711
+ for (let index = start + 1; index < lines.length; index += 1) {
26712
+ const content = lines[index]?.text ?? "";
26713
+ if (fence !== void 0) {
26714
+ if (content.startsWith(fence)) fence = void 0;
26715
+ continue;
26716
+ }
26717
+ fence = fenceOf(content);
26718
+ if (fence === void 0 && endsSection(content)) {
26719
+ next = index;
26720
+ break;
26721
+ }
26722
+ }
26723
+ let end = next;
26724
+ while (end > start + 1 && (lines[end - 1]?.text.trim() ?? "") === "") end -= 1;
26725
+ const eol = lines[start]?.eol || "\n";
26726
+ return { lines, start, end, eol: eol === "\r\n" ? "\r\n" : "\n" };
26727
+ }
26728
+ function sectionText(section) {
26729
+ return section.lines.slice(section.start, section.end).map((line) => line.text);
26730
+ }
26731
+ function replaceSection(section, snippet) {
26732
+ const join9 = (lines) => lines.map((line) => `${line.text}${line.eol}`).join("");
26733
+ const body = snippet.split("\n").map((line) => `${line}${section.eol}`).join("");
26734
+ return `${join9(section.lines.slice(0, section.start))}${body}${join9(section.lines.slice(section.end))}`;
26735
+ }
26736
+ function withLineBreak(text, old) {
26737
+ return old.includes("\r\n") ? text.replace(/\r?\n/g, "\r\n") : text;
26738
+ }
26739
+ function orList(items) {
26740
+ if (items.length <= 1) return items.join("");
26741
+ return `${items.slice(0, -1).join(", ")} or ${items.at(-1) ?? ""}`;
26742
+ }
26743
+ function refreshedLine(project, tags) {
26744
+ const lead = `Quibble refreshed its instructions for ${project}:`;
26745
+ if (tags === void 0 || tags.length === 0) return `${lead} any tag is allowed.`;
26746
+ return `${lead} tag each complaint with ${orList(tags)}, or ${OTHER_TAG_PREFIX}<name> when none fits.`;
26747
+ }
26748
+
26749
+ // src/commands/snippet.ts
26750
+ import { existsSync, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
26751
+ import { dirname as dirname6, join as join6, resolve as resolve2 } from "node:path";
26752
+ function existsError(path) {
26753
+ return new UsageError(`${path} already exists: pass --force to overwrite it`);
26754
+ }
26755
+ function readOptional2(path) {
26756
+ try {
26757
+ return readFileSync5(path, "utf8");
26758
+ } catch (error62) {
26759
+ if (isNotFound(error62)) return "";
26760
+ throw error62;
26761
+ }
26762
+ }
26763
+ function writeSkill(path, snippet) {
26764
+ mkdirSync5(dirname6(path), { recursive: true });
26765
+ writeFileSync4(path, skillFile(snippet), "utf8");
26766
+ }
26767
+ async function snippetSource(project, context, io, options = {}) {
26768
+ let tags;
26769
+ if (context.mode === "remote") {
26770
+ tags = (await (await openOrgClient(context, io, options)).project(project)).tags;
26771
+ } else {
26772
+ checkActAs(context);
26773
+ tags = context.allowedTags;
26774
+ }
26775
+ const scope = {
26776
+ ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
26777
+ ...context.slug === void 0 ? {} : { org: context.slug }
26778
+ };
26779
+ return { tags, scope };
26780
+ }
26781
+ async function runSnippet(options, context, io) {
26782
+ const project = requireProject(context);
26783
+ const override = options.agent === void 0 ? void 0 : parseAgentName(options.agent);
26784
+ const appendPath = options.append === void 0 ? void 0 : resolve2(io.cwd, options.append);
26785
+ const skillPath = options.skill === true ? join6(context.projectRoot ?? io.cwd, SKILL_PATH) : void 0;
26786
+ const existing = appendPath === void 0 ? "" : readOptional2(appendPath);
26787
+ if (options.append !== void 0 && hasSnippet(existing)) {
26788
+ throw new UsageError(
26789
+ `${options.append} already has a "${SNIPPET_HEADING}" section: run quibble sync to refresh it`
26790
+ );
26791
+ }
26792
+ if (skillPath !== void 0 && existsSync(skillPath) && options.force !== true) {
26793
+ throw existsError(skillPath);
26794
+ }
26795
+ const hook = options.hook === true ? planHook(context.projectRoot ?? io.cwd) : void 0;
26796
+ const { tags, scope } = await snippetSource(project, context, io);
26797
+ let shown = { agent: override ?? DEFAULT_SNIPPET_AGENT, snippet: "" };
26798
+ shown.snippet = agentSnippet(tags, { ...scope, agent: shown.agent });
26799
+ if (appendPath !== void 0) {
26800
+ const agent = override ?? agentForFile(appendPath);
26801
+ const snippet = agentSnippet(tags, { ...scope, agent });
26802
+ const kept = existing.replace(/\s+$/, "");
26803
+ writeFileSync4(appendPath, kept === "" ? `${snippet}
26804
+ ` : `${kept}
26805
+
26806
+ ${snippet}
26807
+ `);
26808
+ shown = { agent, snippet };
26809
+ }
26810
+ if (skillPath !== void 0) {
26811
+ const agent = override ?? DEFAULT_SNIPPET_AGENT;
26812
+ const snippet = agentSnippet(tags, { ...scope, agent });
26813
+ writeSkill(skillPath, snippet);
26814
+ if (appendPath === void 0) shown = { agent, snippet };
26815
+ }
26816
+ const hookLine = hook === void 0 ? void 0 : applyHook(hook);
26817
+ if (options.json === true) {
26818
+ writeJson(io.stdout, {
26819
+ snippet: shown.snippet,
26820
+ agent: shown.agent,
26821
+ appended: appendPath ?? null,
26822
+ skill: skillPath ?? null,
26823
+ hook: hook?.path ?? null
26824
+ });
26825
+ return 0;
26826
+ }
26827
+ if (appendPath === void 0 && skillPath === void 0) {
26828
+ writeLine(io.stdout, shown.snippet);
26829
+ }
26830
+ if (appendPath !== void 0) {
26831
+ writeLine(io.stdout, `Appended the Quibble snippet to ${appendPath}`);
26832
+ }
26833
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
26834
+ if (hookLine !== void 0) writeLine(io.stdout, hookLine);
26835
+ return 0;
26836
+ }
26837
+
26838
+ // src/commands/init.ts
26839
+ var CODEX_LINE = "For Codex, run `quibble snippet --append AGENTS.md`, which names the agent codex.";
26840
+ function agentLead(agent) {
26841
+ return agent === DEFAULT_SNIPPET_AGENT ? "Add this to your CLAUDE.md:" : "Add this to your agent's instructions file:";
26842
+ }
26843
+ async function runInit(options, io) {
26844
+ const directory = resolve3(io.cwd);
26845
+ const path = join7(directory, PROJECT_CONFIG_FILENAME);
26846
+ if (existsSync2(path) && options.force !== true) {
25267
26847
  throw new ConfigError(`${path} already exists: pass --force to overwrite it`, path);
25268
26848
  }
26849
+ const skillPath = options.skill === true ? join7(directory, SKILL_PATH) : void 0;
26850
+ if (skillPath !== void 0 && existsSync2(skillPath) && options.force !== true) {
26851
+ throw existsError(skillPath);
26852
+ }
25269
26853
  const project = resolveProjectName(options.project, directory);
25270
26854
  const org = options.org === void 0 || options.org.trim() === "" ? void 0 : parseOrgSlug(options.org);
25271
- const endpoint = options.endpoint?.trim();
25272
26855
  const tags = options.tag === void 0 ? void 0 : parseAllowedTags(options.tag);
26856
+ const agent = options.agent === void 0 ? DEFAULT_SNIPPET_AGENT : parseAgentName(options.agent);
26857
+ const hook = options.hook === true ? planHook(directory) : void 0;
26858
+ const endpoint = await endpointToWrite(options.endpoint, io);
25273
26859
  const config2 = {
25274
26860
  project,
25275
26861
  ...org === void 0 ? {} : { org },
25276
- ...endpoint === void 0 || endpoint === "" ? {} : { endpoint },
26862
+ ...endpoint === void 0 ? {} : { endpoint },
25277
26863
  ...tags === void 0 ? {} : { tags }
25278
26864
  };
25279
- writeFileSync2(path, `${JSON.stringify(config2, null, 2)}
26865
+ writeFileSync5(path, `${JSON.stringify(config2, null, 2)}
25280
26866
  `, "utf8");
26867
+ const context = {
26868
+ ...resolveContext({
26869
+ cwd: io.cwd,
26870
+ env: io.env,
26871
+ endpoint: options.endpoint,
26872
+ org: options.org,
26873
+ local: options.local
26874
+ }),
26875
+ // `init` acts as nobody, so it ignores `QUIBBLE_ACT_AS`.
26876
+ actAs: void 0
26877
+ };
26878
+ const snippet = agentSnippet(tags, {
26879
+ agent,
26880
+ ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
26881
+ ...org === void 0 ? {} : { org }
26882
+ });
26883
+ if (skillPath !== void 0) writeSkill(skillPath, snippet);
26884
+ const hookLine = hook === void 0 ? void 0 : applyHook(hook);
26885
+ const check2 = await checkServer(context, project, io);
25281
26886
  if (options.json === true) {
25282
- writeJson(io.stdout, { path, project, org: org ?? null, tags: tags ?? null });
26887
+ writeJson(io.stdout, {
26888
+ path,
26889
+ project,
26890
+ org: org ?? null,
26891
+ tags: tags ?? null,
26892
+ skill: skillPath ?? null,
26893
+ hook: hook?.path ?? null,
26894
+ server: check2.server
26895
+ });
25283
26896
  return 0;
25284
26897
  }
25285
26898
  writeLine(io.stdout, `Wrote ${path}`);
26899
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
26900
+ if (hookLine !== void 0) writeLine(io.stdout, hookLine);
25286
26901
  writeLine(io.stdout, `Project: ${project}`);
25287
26902
  if (org !== void 0) writeLine(io.stdout, `Organization: ${org}`);
25288
26903
  if (tags !== void 0) writeLine(io.stdout, `Tags: ${tags.join(", ")}`);
25289
26904
  writeLine(io.stdout);
25290
- writeLine(io.stdout, "Add this to your CLAUDE.md or AGENTS.md:");
26905
+ writeLine(io.stdout, agentLead(agent));
25291
26906
  writeLine(io.stdout);
25292
- writeLine(
25293
- io.stdout,
25294
- agentSnippet(tags, {
25295
- ...endpoint === void 0 ? {} : { endpoint },
25296
- ...org === void 0 ? {} : { org }
25297
- })
25298
- );
26907
+ writeLine(io.stdout, snippet);
26908
+ if (agent === DEFAULT_SNIPPET_AGENT) {
26909
+ writeLine(io.stdout);
26910
+ writeLine(io.stdout, CODEX_LINE);
26911
+ }
26912
+ if (check2.line !== void 0) {
26913
+ writeLine(io.stdout);
26914
+ writeLine(io.stdout, check2.line);
26915
+ }
25299
26916
  return 0;
25300
26917
  }
26918
+ async function endpointToWrite(given, io) {
26919
+ if (given === void 0 || given.trim() === "") return void 0;
26920
+ const value = parseEndpoint(given, "--endpoint");
26921
+ if (value.kind === "local") return LOCAL_ENDPOINT;
26922
+ const api = await discoverApiUrl(value.url, {
26923
+ fetch: io.fetch,
26924
+ debug: debugEnabled(io.env)
26925
+ });
26926
+ return isDefaultEndpoint(api) ? void 0 : api;
26927
+ }
26928
+ async function checkServer(context, project, io) {
26929
+ const org = context.org;
26930
+ if (context.mode !== "remote" || org === void 0) return { server: "unchecked" };
26931
+ const endpoint = await resolveEndpoint(context, io);
26932
+ if (context.token === void 0 && context.credential === void 0 && !isLoopbackUrl(endpoint)) {
26933
+ return { server: "unchecked" };
26934
+ }
26935
+ const warn = (text) => {
26936
+ writeLine(io.stderr, `quibble: warning: ${text}`);
26937
+ };
26938
+ try {
26939
+ await (await openClient(context, io, { queue: false })).project(project);
26940
+ return { server: "found", line: `Project ${org}/${project} exists on ${endpoint}.` };
26941
+ } catch (error62) {
26942
+ if (error62 instanceof RemoteError && error62.status === 404) {
26943
+ if (error62.serverCode === ORG_NOT_FOUND) {
26944
+ warn(`no organization ${org} on ${endpoint} for this account`);
26945
+ return { server: "no_org" };
26946
+ }
26947
+ warn(
26948
+ `${org} has no project ${project} on ${endpoint}: an admin creates it in the organization settings`
26949
+ );
26950
+ return { server: "no_project" };
26951
+ }
26952
+ warn(`could not check the project on ${endpoint}: ${describeError(error62)}`);
26953
+ return { server: "unreachable" };
26954
+ }
26955
+ }
25301
26956
  function resolveProjectName(given, directory) {
25302
26957
  const candidate = given?.trim();
25303
26958
  if (candidate !== void 0 && candidate !== "") return parseProjectName(candidate);
25304
- const name = basename(directory);
26959
+ const name = basename2(directory);
25305
26960
  try {
25306
26961
  return parseProjectName(name);
25307
26962
  } catch (error62) {
@@ -25332,25 +26987,30 @@ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
25332
26987
  var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
25333
26988
  var MINUTE_MS = 6e4;
25334
26989
  var HOUR_MS = 60 * MINUTE_MS;
25335
- var DAY_MS2 = 24 * HOUR_MS;
26990
+ var DAY_MS3 = 24 * HOUR_MS;
25336
26991
  var UNIT_MS = {
25337
26992
  m: MINUTE_MS,
25338
26993
  h: HOUR_MS,
25339
- d: DAY_MS2,
25340
- w: 7 * DAY_MS2
26994
+ d: DAY_MS3,
26995
+ w: 7 * DAY_MS3
25341
26996
  };
25342
- function reject() {
25343
- throw new UsageError(SINCE_MESSAGE);
26997
+ var EXPIRES_MESSAGE = "--expires must be a duration like 30d, a date like 2026-12-01, or an ISO 8601 UTC timestamp";
26998
+ function reject(message = SINCE_MESSAGE) {
26999
+ throw new UsageError(message);
25344
27000
  }
25345
27001
  function iso(at) {
25346
27002
  if (Number.isNaN(at.getTime())) reject();
25347
27003
  return at.toISOString();
25348
27004
  }
25349
- function fromDuration(digits, unit, now) {
25350
- if (digits.startsWith("0")) reject();
27005
+ function durationMs(digits, unit) {
27006
+ if (digits.startsWith("0")) return void 0;
25351
27007
  const step = UNIT_MS[unit];
25352
- if (step === void 0) reject();
25353
- return iso(new Date(now.getTime() - Number(digits) * step));
27008
+ return step === void 0 ? void 0 : Number(digits) * step;
27009
+ }
27010
+ function fromDuration(digits, unit, now) {
27011
+ const span = durationMs(digits, unit);
27012
+ if (span === void 0) reject();
27013
+ return iso(new Date(now.getTime() - span));
25354
27014
  }
25355
27015
  function fromDate(value) {
25356
27016
  const at = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -25368,6 +27028,27 @@ function parseSince(text, now) {
25368
27028
  if (TIMESTAMP_RE.test(value)) return iso(new Date(value));
25369
27029
  return reject();
25370
27030
  }
27031
+ function parseExpires(text, now) {
27032
+ const value = text.trim();
27033
+ const duration3 = DURATION_RE.exec(value);
27034
+ let stamp2;
27035
+ try {
27036
+ if (duration3 !== null) {
27037
+ const span = durationMs(duration3[1] ?? "", duration3[2] ?? "");
27038
+ if (span === void 0) reject(EXPIRES_MESSAGE);
27039
+ stamp2 = iso(new Date(now.getTime() + span));
27040
+ } else if (DATE_RE.test(value)) {
27041
+ stamp2 = fromDate(value);
27042
+ } else if (TIMESTAMP_RE.test(value)) {
27043
+ stamp2 = iso(new Date(value));
27044
+ } else {
27045
+ return reject(EXPIRES_MESSAGE);
27046
+ }
27047
+ } catch {
27048
+ return reject(EXPIRES_MESSAGE);
27049
+ }
27050
+ return stamp2;
27051
+ }
25371
27052
 
25372
27053
  // src/scope.ts
25373
27054
  function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
@@ -25387,6 +27068,9 @@ function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
25387
27068
  ...options.since === void 0 ? {} : { since: parseSince(options.since, now) }
25388
27069
  };
25389
27070
  }
27071
+ function tagFilter(tags) {
27072
+ return tags === void 0 || tags.length === 0 ? {} : { tags: [...tags] };
27073
+ }
25390
27074
 
25391
27075
  // src/commands/list.ts
25392
27076
  var COLUMNS = [
@@ -25396,67 +27080,114 @@ var COLUMNS = [
25396
27080
  "status",
25397
27081
  "project",
25398
27082
  "reporter",
27083
+ "tags",
27084
+ "group",
25399
27085
  "summary"
25400
27086
  ];
25401
27087
  var REPORTER_WIDTH = 20;
27088
+ var TAGS_WIDTH = 24;
25402
27089
  var SUMMARY_MAX = 60;
27090
+ var RECURRED_MARK = "\u21BB";
27091
+ function cut2(text, width) {
27092
+ if (text.length <= width) return text;
27093
+ return `${text.slice(0, width - 3)}...`;
27094
+ }
25403
27095
  function summaryOf(complaint) {
25404
- const summary = summarize(complaint.message);
25405
- if (summary.length <= SUMMARY_MAX) return summary;
25406
- return `${summary.slice(0, SUMMARY_MAX - 3)}...`;
27096
+ return cut2(printable(summarize(complaint.message)), SUMMARY_MAX);
25407
27097
  }
27098
+ function limitNote(count, limit, more) {
27099
+ if (limit >= MAX_LIMIT) {
27100
+ return count >= MAX_LIMIT ? `Showing the newest ${MAX_LIMIT} complaints: older ones may exist, narrow the filters to see them.` : void 0;
27101
+ }
27102
+ return more ? `Showing the newest ${limit} complaints: pass --limit to see more (up to ${MAX_LIMIT}).` : void 0;
27103
+ }
27104
+ var BUDGET_NOTE = "The server stopped searching early, so older matches may exist: narrow the filters or pass --since.";
25408
27105
  async function runList(options, context, io) {
25409
- const { store, client } = openBackend(context, io);
27106
+ if (options.ids === true && options.json === true) {
27107
+ throw new UsageError("--ids and --json cannot be used together");
27108
+ }
27109
+ const { store, client } = await openBackend(context, io);
25410
27110
  const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
25411
27111
  const filter = parseListFilter({
25412
27112
  ...resolveScope(options, context),
25413
27113
  ...options.severity === void 0 ? {} : { severity: options.severity },
25414
- ...options.tag === void 0 ? {} : { tag: options.tag },
27114
+ ...tagFilter(options.tag),
27115
+ ...options.search === void 0 ? {} : { q: options.search },
25415
27116
  ...reporter === void 0 ? {} : { reporter },
25416
27117
  ...options.limit === void 0 ? {} : { limit: options.limit }
25417
27118
  });
25418
- const complaints = await listComplaints(store, filter);
27119
+ const limit = filter.limit ?? DEFAULT_LIMIT;
27120
+ const page = await listComplaintPage(store, {
27121
+ ...filter,
27122
+ limit: Math.min(limit + 1, MAX_LIMIT)
27123
+ });
27124
+ const complaints = page.complaints.slice(0, limit);
25419
27125
  if (options.json === true) {
25420
27126
  writeJson(io.stdout, complaints);
25421
27127
  return 0;
25422
27128
  }
27129
+ const note = limitNote(complaints.length, limit, page.complaints.length > limit);
27130
+ if (note !== void 0) writeLine(io.stderr, note);
27131
+ if (page.truncated) writeLine(io.stderr, BUDGET_NOTE);
27132
+ if (options.ids === true) {
27133
+ for (const complaint of complaints) writeLine(io.stdout, complaint.id);
27134
+ return 0;
27135
+ }
25423
27136
  if (complaints.length === 0) {
25424
27137
  writeLine(io.stdout, "No complaints found.");
25425
27138
  return 0;
25426
27139
  }
25427
- for (const line of formatTable(complaints)) writeLine(io.stdout, line);
27140
+ const lines = options.ungroup === true ? formatTable(complaints.map((complaint) => ({ complaint }))) : formatTable(
27141
+ groupComplaints(complaints).map((group) => ({
27142
+ complaint: group.head,
27143
+ group
27144
+ }))
27145
+ );
27146
+ for (const line of lines) writeLine(io.stdout, line);
25428
27147
  return 0;
25429
27148
  }
25430
27149
  function reporterOf(complaint) {
25431
- const name = complaint.reporterName ?? complaint.reporter;
25432
- if (name.length <= REPORTER_WIDTH) return name;
25433
- return `${name.slice(0, REPORTER_WIDTH - 3)}...`;
25434
- }
25435
- function formatTable(complaints) {
25436
- const rows2 = complaints.map((complaint) => [
25437
- complaint.id.slice(0, 8),
25438
- complaint.createdAt.slice(0, 16),
25439
- complaint.severity,
25440
- complaint.status,
25441
- complaint.project,
25442
- reporterOf(complaint),
25443
- summaryOf(complaint)
25444
- ]);
25445
- const widths = COLUMNS.map(
25446
- (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
27150
+ return cut2(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
27151
+ }
27152
+ function groupCell(group) {
27153
+ const count = group.count > 1 ? `\xD7${group.count}` : "";
27154
+ return `${count}${group.recurred ? RECURRED_MARK : ""}`;
27155
+ }
27156
+ function formatTable(rows2) {
27157
+ const grouped = rows2.some((row) => row.group !== void 0);
27158
+ const columns = COLUMNS.filter((name) => grouped || name !== "group");
27159
+ const cells = rows2.map(({ complaint, group }) => {
27160
+ const values = {
27161
+ id: complaint.id.slice(0, 8),
27162
+ created: complaint.createdAt.slice(0, 16),
27163
+ severity: complaint.severity,
27164
+ status: complaint.status,
27165
+ project: complaint.project,
27166
+ reporter: reporterOf(complaint),
27167
+ tags: cut2(printable(complaint.tags.join(",")), TAGS_WIDTH),
27168
+ group: group === void 0 ? "" : groupCell(group),
27169
+ summary: summaryOf(complaint)
27170
+ };
27171
+ return columns.map((name) => values[name]);
27172
+ });
27173
+ const widths = columns.map(
27174
+ (name, column) => cells.reduce(
27175
+ (width, row) => Math.max(width, (row[column] ?? "").length),
27176
+ name.length
27177
+ )
25447
27178
  );
25448
- const format = (cells) => cells.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
25449
- return [format(COLUMNS), ...rows2.map(format)];
27179
+ const format = (line) => line.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
27180
+ return [format(columns), ...cells.map(format)];
25450
27181
  }
25451
27182
 
25452
27183
  // src/commands/orgs.ts
25453
- var ORGS_NEEDS_SERVER = "orgs needs a server: pass --endpoint <url>, set QUIBBLE_ENDPOINT, or add one to .quibblerc";
25454
27184
  var ORGS_PAGE = "/orgs";
25455
27185
  var COLUMNS2 = ["SLUG", "NAME", "ROLE", "OPEN"];
25456
27186
  async function runOrgs(options, context, io) {
25457
- if (context.mode !== "remote") throw new ConfigError(ORGS_NEEDS_SERVER);
27187
+ requireServer("orgs", context);
27188
+ const client = await openClient(context, io);
25458
27189
  const endpoint = requireEndpoint(context);
25459
- const answer2 = await openClient(context, io).orgs();
27190
+ const answer2 = await client.orgs();
25460
27191
  if (options.json === true) {
25461
27192
  writeJson(io.stdout, answer2.body);
25462
27193
  return 0;
@@ -25464,9 +27195,10 @@ async function runOrgs(options, context, io) {
25464
27195
  if (answer2.orgs.length === 0) {
25465
27196
  const config2 = await fetchAuthConfig(endpoint, {
25466
27197
  debug: context.debug,
25467
- fetch: io.fetch
27198
+ fetch: io.fetch,
27199
+ timeoutMs: context.timeoutMs
25468
27200
  });
25469
- const webUrl = config2.mode === "cognito" ? trimSlash(config2.webUrl) : endpoint;
27201
+ const webUrl = config2.mode === "cognito" ? trimSlash2(config2.webUrl) : endpoint;
25470
27202
  writeLine(io.stdout, `No organizations yet: create one at ${webUrl}${ORGS_PAGE}.`);
25471
27203
  return 0;
25472
27204
  }
@@ -25526,19 +27258,19 @@ var NodeTerminal = class {
25526
27258
  #interface() {
25527
27259
  if (this.#rl !== void 0) return this.#rl;
25528
27260
  const output2 = this.#output;
25529
- const sink = new Writable({
27261
+ const sink2 = new Writable({
25530
27262
  write: (chunk, _encoding, done) => {
25531
27263
  if (!this.#muted) output2.write(chunk);
25532
27264
  done();
25533
27265
  }
25534
27266
  });
25535
- Object.defineProperties(sink, {
27267
+ Object.defineProperties(sink2, {
25536
27268
  columns: { get: () => output2.columns },
25537
27269
  rows: { get: () => output2.rows }
25538
27270
  });
25539
27271
  const rl = createInterface({
25540
27272
  input: this.#input,
25541
- output: sink,
27273
+ output: sink2,
25542
27274
  terminal: true,
25543
27275
  // No answer enters the history, so an arrow key recalls no password.
25544
27276
  historySize: 0
@@ -25571,8 +27303,8 @@ var NodeTerminal = class {
25571
27303
  const queued = this.#lines.shift();
25572
27304
  if (queued !== void 0) return Promise.resolve(queued);
25573
27305
  if (this.#closed) return Promise.resolve("");
25574
- return new Promise((resolve3) => {
25575
- this.#waiting = resolve3;
27306
+ return new Promise((resolve4) => {
27307
+ this.#waiting = resolve4;
25576
27308
  });
25577
27309
  }
25578
27310
  async #read(label, hidden) {
@@ -25610,7 +27342,7 @@ function nodeTerminal(input2, output2) {
25610
27342
 
25611
27343
  // src/commands/login.ts
25612
27344
  var LOCAL_IDENTITY_MESSAGE = "local mode: no identity.";
25613
- var NEEDS_TERMINAL_MESSAGE = "quibble login needs a terminal: a CI job sets QUIBBLE_TOKEN instead";
27345
+ var NEEDS_TERMINAL_MESSAGE = "quibble login needs a terminal: in CI, set QUIBBLE_TOKEN instead";
25614
27346
  var MFA_CHALLENGE = "SOFTWARE_TOKEN_MFA";
25615
27347
  var NEW_PASSWORD_CHALLENGE = "NEW_PASSWORD_REQUIRED";
25616
27348
  var LOGIN_PAGE = "/login";
@@ -25638,29 +27370,29 @@ async function signIn(terminal, identity, config2) {
25638
27370
  password
25639
27371
  });
25640
27372
  if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
25641
- const code = await answer(terminal, "Code:");
27373
+ const code2 = await answer(terminal, "Code:");
25642
27374
  result = await respondToAuthChallenge(identity, {
25643
27375
  clientId,
25644
27376
  challenge: result.challenge,
25645
27377
  email: email3,
25646
- code
27378
+ code: code2
25647
27379
  });
25648
27380
  }
25649
27381
  if (result.kind === "challenge") {
25650
27382
  const name = result.challenge.challengeName;
25651
27383
  if (name === NEW_PASSWORD_CHALLENGE) {
25652
27384
  throw new ConfigError(
25653
- `finish your first sign-in at ${trimSlash(config2.webUrl)}${LOGIN_PAGE}`
27385
+ `finish your first sign-in at ${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`
25654
27386
  );
25655
27387
  }
25656
27388
  throw new RemoteError(
25657
- `the sign-in asked for ${name}, which quibble does not support`
27389
+ `this account needs a sign-in step that quibble doesn't support (${name})`
25658
27390
  );
25659
27391
  }
25660
27392
  return result.tokens;
25661
27393
  } catch (error62) {
25662
27394
  if (error62 instanceof CognitoError) {
25663
- const page = `${trimSlash(config2.webUrl)}${LOGIN_PAGE}`;
27395
+ const page = `${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`;
25664
27396
  if (error62.type === "PasswordResetRequiredException") {
25665
27397
  throw new ConfigError(`reset your password at ${page}`);
25666
27398
  }
@@ -25672,13 +27404,14 @@ async function signIn(terminal, identity, config2) {
25672
27404
  }
25673
27405
  }
25674
27406
  async function runLogin(options, context, io) {
25675
- const endpoint = requireEndpoint(context);
27407
+ requireServer("login", context);
25676
27408
  const terminal = terminalOf(io);
25677
27409
  if (!terminal.isTTY) throw new UsageError(NEEDS_TERMINAL_MESSAGE);
27410
+ const endpoint = await resolveEndpoint(context, io);
25678
27411
  const http = { debug: context.debug, fetch: io.fetch };
25679
27412
  const config2 = await fetchAuthConfig(endpoint, http);
25680
27413
  if (config2.mode === "local") {
25681
- writeLine(io.stdout, `${endpoint} needs no login.`);
27414
+ writeLine(io.stdout, `${endpoint} is in local mode: no login needed.`);
25682
27415
  return 0;
25683
27416
  }
25684
27417
  let tokens;
@@ -25688,7 +27421,7 @@ async function runLogin(options, context, io) {
25688
27421
  terminal.close();
25689
27422
  }
25690
27423
  if (tokens.refreshToken === void 0) {
25691
- throw new RemoteError("the sign-in returned no refresh token");
27424
+ throw new RemoteError("sign-in didn't return a refresh token");
25692
27425
  }
25693
27426
  const now = /* @__PURE__ */ new Date();
25694
27427
  const me = await readProfile(
@@ -25700,7 +27433,9 @@ async function runLogin(options, context, io) {
25700
27433
  config2.webUrl
25701
27434
  );
25702
27435
  if (me.sub === void 0 || me.email === void 0) {
25703
- throw new RemoteError("the API returned an identity without a sub or an email");
27436
+ throw new RemoteError(
27437
+ "unexpected response from the API: identity has no sub or email"
27438
+ );
25704
27439
  }
25705
27440
  saveCredential(io.env, {
25706
27441
  endpoint,
@@ -25710,25 +27445,36 @@ async function runLogin(options, context, io) {
25710
27445
  sub: me.sub,
25711
27446
  email: me.email
25712
27447
  });
27448
+ const saved = saveEndpoint(context, endpoint, io.env);
25713
27449
  if (options.json === true) {
25714
- writeJson(io.stdout, { ...me.body, endpoint });
27450
+ writeJson(io.stdout, { ...me.body, endpoint, saved: saved ?? null });
25715
27451
  return 0;
25716
27452
  }
25717
27453
  writeLine(io.stdout, `Logged in to ${endpoint} as ${me.email}.`);
27454
+ if (saved !== void 0) {
27455
+ writeLine(io.stdout, `Saved ${endpoint} as your endpoint in ${saved}.`);
27456
+ }
25718
27457
  return 0;
25719
27458
  }
27459
+ function saveEndpoint(context, endpoint, env) {
27460
+ if (context.endpointSource !== "--endpoint" || isDefaultEndpoint(endpoint)) {
27461
+ return void 0;
27462
+ }
27463
+ return rememberEndpoint(env, endpoint);
27464
+ }
25720
27465
  async function readProfile(client, webUrl) {
25721
27466
  try {
25722
27467
  return await client.me();
25723
27468
  } catch (error62) {
25724
27469
  if (error62 instanceof RemoteError && NO_PROFILE_STATUSES.has(error62.status ?? 0)) {
25725
- throw new ConfigError(`finish your account at ${trimSlash(webUrl)}${LOGIN_PAGE}`);
27470
+ throw new ConfigError(`finish your account at ${trimSlash2(webUrl)}${LOGIN_PAGE}`);
25726
27471
  }
25727
27472
  throw error62;
25728
27473
  }
25729
27474
  }
25730
27475
  async function runLogout(context, io) {
25731
- const endpoint = requireEndpoint(context);
27476
+ requireServer("logout", context);
27477
+ const endpoint = await resolveEndpoint(context, io);
25732
27478
  const credential = findCredential(io.env, endpoint);
25733
27479
  if (credential === void 0) {
25734
27480
  writeLine(io.stdout, `Not logged in to ${endpoint}.`);
@@ -25764,8 +27510,8 @@ async function runWhoami(options, context, io) {
25764
27510
  writeLine(io.stdout, LOCAL_IDENTITY_MESSAGE);
25765
27511
  return 0;
25766
27512
  }
27513
+ const client = await openClient(context, io);
25767
27514
  const endpoint = requireEndpoint(context);
25768
- const client = openClient(context, io);
25769
27515
  const me = await client.me();
25770
27516
  const org = me.kind === "token" || client.org === void 0 ? void 0 : await client.organization();
25771
27517
  if (options.json === true) {
@@ -25782,7 +27528,7 @@ async function runWhoami(options, context, io) {
25782
27528
  debug: context.debug,
25783
27529
  fetch: io.fetch
25784
27530
  });
25785
- const webUrl = config2.mode === "cognito" ? trimSlash(config2.webUrl) : endpoint;
27531
+ const webUrl = config2.mode === "cognito" ? trimSlash2(config2.webUrl) : endpoint;
25786
27532
  lines.push("", `Create one at ${webUrl}${ORGS_PAGE2}.`);
25787
27533
  }
25788
27534
  for (const line of lines) writeLine(io.stdout, line);
@@ -25822,18 +27568,40 @@ function whoamiLines(endpoint, me, org) {
25822
27568
  return lines;
25823
27569
  }
25824
27570
 
27571
+ // src/commands/project.ts
27572
+ var DELETE_NEEDS_YES = "project delete needs --yes when stdin is not a terminal";
27573
+ var DELETE_MISMATCH = "the name did not match: nothing was deleted";
27574
+ async function confirm(org, project, io) {
27575
+ const terminal = io.terminal ?? nodeTerminal(io.stdin, io.stderr);
27576
+ if (!terminal.isTTY) throw new UsageError(DELETE_NEEDS_YES);
27577
+ let answer2;
27578
+ try {
27579
+ answer2 = await terminal.prompt(`Type the project name to delete ${org}/${project}:`);
27580
+ } finally {
27581
+ terminal.close();
27582
+ }
27583
+ if (foldProjectName(answer2.trim()) !== project) throw new UsageError(DELETE_MISMATCH);
27584
+ }
27585
+ async function runProjectDelete(name, options, context, io) {
27586
+ requireServer("project delete", context);
27587
+ const project = parseProjectName(name);
27588
+ const org = requireOrg(context);
27589
+ if (options.yes !== true) await confirm(org, project, io);
27590
+ await (await openOrgClient(context, io)).deleteProject(project);
27591
+ if (options.json === true) {
27592
+ writeJson(io.stdout, { org, project, deleted: true });
27593
+ return 0;
27594
+ }
27595
+ writeLine(io.stdout, `Deleted project ${org}/${project}.`);
27596
+ return 0;
27597
+ }
27598
+
25825
27599
  // src/commands/promote.ts
25826
- var PROMOTE_NEEDS_SERVER = "promote needs a server: pass --endpoint <url>, set QUIBBLE_ENDPOINT, or add one to .quibblerc";
25827
- var PROMOTE_NEEDS_SERVER_LOCAL = "promote needs a server: drop --local, because the project record of the server owns the allowed tag list";
25828
27600
  async function runPromote(tag, options, context, io) {
25829
- if (context.mode !== "remote") {
25830
- throw new ConfigError(
25831
- context.endpoint === void 0 ? PROMOTE_NEEDS_SERVER : PROMOTE_NEEDS_SERVER_LOCAL
25832
- );
25833
- }
27601
+ requireServer("promote", context);
25834
27602
  const project = requireProject(context);
25835
27603
  const promotion = parseTagPromotion({ tag });
25836
- const result = await openOrgClient(context, io).promoteTag(project, promotion.tag);
27604
+ const result = await (await openOrgClient(context, io)).promoteTag(project, promotion.tag);
25837
27605
  if (options.json === true) {
25838
27606
  writeJson(io.stdout, {
25839
27607
  project,
@@ -25855,10 +27623,12 @@ async function runPromote(tag, options, context, io) {
25855
27623
 
25856
27624
  // src/commands/retag.ts
25857
27625
  var NO_TAG_MESSAGE = "retag needs at least one --tag";
25858
- async function runRetag(id, options, context, io) {
27626
+ async function runRetag(value, options, context, io) {
25859
27627
  const tags = options.tag ?? [];
25860
27628
  if (tags.length === 0) throw new UsageError(NO_TAG_MESSAGE);
25861
- const { store, client } = openBackend(context, io);
27629
+ const { store, client, local } = await openBackend(context, io);
27630
+ const [id] = await resolveIds([value], { store, local, context });
27631
+ if (id === void 0) throw new NotFoundError(`no complaint with id ${value}`);
25862
27632
  const allowedTags = client === void 0 ? await localAllowedTags(store, id, context) : void 0;
25863
27633
  const complaint = await updateComplaintTags(
25864
27634
  store,
@@ -25882,56 +27652,255 @@ async function localAllowedTags(store, id, context) {
25882
27652
  return complaint.project === config2.project ? config2.tags : void 0;
25883
27653
  }
25884
27654
 
27655
+ // src/commands/retry.ts
27656
+ async function runRetry(options, context, io) {
27657
+ requireServer("retry", context);
27658
+ const client = await openClient(context, io, { queue: false });
27659
+ const endpoint = requireEndpoint(context);
27660
+ const path = queuePath(io.env);
27661
+ const result = await flushQueue({
27662
+ path,
27663
+ endpoint,
27664
+ auth: authLabel(context),
27665
+ client,
27666
+ stderr: io.stderr
27667
+ });
27668
+ if (result.stopped !== void 0) {
27669
+ throw new RemoteError(`${queuedCount(result.left)} still unsent: ${result.stopped}`);
27670
+ }
27671
+ const auth = authLabel(context);
27672
+ const left = pendingFor(path, endpoint).filter((entry) => entry.auth === auth).length;
27673
+ if (options.json === true) {
27674
+ writeJson(io.stdout, { sent: result.sent, dropped: result.dropped, left });
27675
+ return 0;
27676
+ }
27677
+ if (result.locked) {
27678
+ writeLine(io.stdout, "Another quibble process is sending the queue.");
27679
+ return 0;
27680
+ }
27681
+ if (result.sent.length === 0 && result.dropped === 0) {
27682
+ writeLine(io.stdout, "No queued complaints.");
27683
+ return 0;
27684
+ }
27685
+ const sent = result.sent.length;
27686
+ writeLine(
27687
+ io.stdout,
27688
+ `Sent ${sent} queued ${sent === 1 ? "complaint" : "complaints"}; ${left} left.`
27689
+ );
27690
+ return 0;
27691
+ }
27692
+
27693
+ // src/commands/show.ts
27694
+ var LABEL_WIDTH = "duplicate".length;
27695
+ function groupLine(count) {
27696
+ if (count >= MAX_LIMIT) return `${MAX_LIMIT}+ complaints share this headline`;
27697
+ return count === 1 ? "1 complaint shares this headline" : `${count} complaints share this headline`;
27698
+ }
27699
+ function groupCountOf(complaint, members2) {
27700
+ const others = members2.filter((member) => member.id !== complaint.id).length;
27701
+ return Math.min(others + 1, MAX_LIMIT);
27702
+ }
27703
+ function statusLine(complaint) {
27704
+ const reason = complaint.reason === void 0 ? "" : ` (${complaint.reason})`;
27705
+ const by = complaint.statusChangedByName ?? complaint.statusChangedBy;
27706
+ const when = by === void 0 || complaint.statusChangedAt === void 0 ? "" : ` by ${by} at ${complaint.statusChangedAt}`;
27707
+ return `${complaint.status}${reason}${when}`;
27708
+ }
27709
+ function showLines(complaint, groupCount, remote) {
27710
+ const reporter = complaint.reporterName === void 0 ? complaint.reporter : `${complaint.reporterName} (${complaint.reporter})`;
27711
+ const rows2 = [
27712
+ ["id", complaint.id],
27713
+ ["project", remote ? `${complaint.org}/${complaint.project}` : complaint.project],
27714
+ ["status", statusLine(complaint)],
27715
+ ["note", complaint.resolution],
27716
+ ["duplicate", complaint.duplicateOf],
27717
+ ["severity", complaint.severity],
27718
+ ["tags", complaint.tags.length === 0 ? void 0 : complaint.tags.join(", ")],
27719
+ ["agent", complaint.agent],
27720
+ ["reporter", reporter],
27721
+ ["created", complaint.createdAt],
27722
+ ["updated", complaint.updatedAt],
27723
+ ["cwd", complaint.cwd],
27724
+ ["cmd", complaint.cmd],
27725
+ ["group", groupLine(groupCount)],
27726
+ ["recurred", complaint.recurred === true ? "yes" : void 0]
27727
+ ];
27728
+ const lines = rows2.filter((row) => row[1] !== void 0).map(([label, value]) => `${label.padEnd(LABEL_WIDTH)} ${printable(value)}`);
27729
+ return [...lines, "", printableLines(complaint.message)];
27730
+ }
27731
+ async function runShow(value, options, context, io) {
27732
+ const { store, local } = await openBackend(context, io);
27733
+ const [id] = await resolveIds([value], { store, local, context });
27734
+ const complaint = id === void 0 ? void 0 : await getComplaint(store, id);
27735
+ if (id === void 0 || complaint === void 0) {
27736
+ throw new NotFoundError(`no complaint with id ${id ?? value}`);
27737
+ }
27738
+ const group = await listComplaints(store, {
27739
+ org: complaint.org,
27740
+ project: complaint.project,
27741
+ fingerprint: complaint.fingerprint,
27742
+ limit: MAX_LIMIT
27743
+ });
27744
+ const groupCount = groupCountOf(complaint, group);
27745
+ if (options.json === true) {
27746
+ writeJson(io.stdout, { ...complaint, groupCount });
27747
+ return 0;
27748
+ }
27749
+ for (const line of showLines(complaint, groupCount, context.mode === "remote")) {
27750
+ writeLine(io.stdout, line);
27751
+ }
27752
+ return 0;
27753
+ }
27754
+
25885
27755
  // src/commands/status.ts
27756
+ var REMOTE_ACTOR = "server";
25886
27757
  var VERBS = {
25887
27758
  open: "Reopened",
25888
27759
  resolved: "Resolved",
25889
27760
  dismissed: "Dismissed",
25890
27761
  deleted: "Deleted"
25891
27762
  };
25892
- async function runStatusChange(status, id, options, context, io) {
25893
- const store = openStore(context, io);
25894
- const complaint = await updateComplaintStatus(store, id, {
27763
+ var NOT_FOUND = "not_found";
27764
+ function noComplaint(id) {
27765
+ return `no complaint with id ${id}`;
27766
+ }
27767
+ async function readIdArguments(values, io) {
27768
+ if (!values.includes("-")) return [...values];
27769
+ if (values.length > 1) {
27770
+ throw new UsageError("- reads the ids from stdin, so it takes no other id");
27771
+ }
27772
+ if (io.stdin.isTTY === true) throw new UsageError("no ids: pipe them on stdin");
27773
+ const ids = (await readStdin(io.stdin)).split(/\s+/).filter((id) => id !== "");
27774
+ if (ids.length === 0) throw new UsageError("no ids on stdin");
27775
+ return ids;
27776
+ }
27777
+ async function runStatusChange(status, values, options, context, io) {
27778
+ const reason = options.reason ?? (options.duplicateOf === void 0 ? void 0 : DUPLICATE_REASON);
27779
+ const draft = {
25895
27780
  status,
25896
- ...options.note === void 0 ? {} : { resolution: options.note }
27781
+ ...options.note === void 0 ? {} : { resolution: options.note },
27782
+ ...reason === void 0 ? {} : { reason },
27783
+ ...options.duplicateOf === void 0 ? {} : { duplicateOf: options.duplicateOf }
27784
+ };
27785
+ parseStatusChange(draft);
27786
+ const given = await readIdArguments(values, io);
27787
+ const backend = await openBackend(context, io);
27788
+ const extra = options.duplicateOf === void 0 ? [] : [options.duplicateOf];
27789
+ const resolved = await resolveIds([...given, ...extra], {
27790
+ store: backend.store,
27791
+ local: backend.local,
27792
+ context
25897
27793
  });
25898
- if (complaint === void 0) throw new NotFoundError(`no complaint with id ${id}`);
25899
- if (options.json === true) {
25900
- writeJson(io.stdout, complaint);
27794
+ const ids = [...new Set(resolved.slice(0, given.length))];
27795
+ const duplicateOf = options.duplicateOf === void 0 ? void 0 : resolved.at(-1);
27796
+ const change = parseStatusChange({
27797
+ ...draft,
27798
+ ...duplicateOf === void 0 ? {} : { duplicateOf }
27799
+ });
27800
+ const actor = { by: context.mode === "remote" ? REMOTE_ACTOR : context.reporter };
27801
+ if (ids.length === 1) {
27802
+ const id = ids[0];
27803
+ const complaint = await updateComplaintStatus(backend.store, id, change, { actor });
27804
+ if (complaint === void 0) throw new NotFoundError(noComplaint(id));
27805
+ if (options.json === true) {
27806
+ writeJson(io.stdout, complaint);
27807
+ return 0;
27808
+ }
27809
+ writeLine(io.stdout, successLine(status, complaint));
25901
27810
  return 0;
25902
27811
  }
25903
- writeLine(
25904
- io.stdout,
25905
- `${VERBS[status]} complaint ${complaint.id} for ${complaint.project}`
25906
- );
25907
- return 0;
27812
+ const { results, error: error62 } = backend.store instanceof RemoteStore ? await remoteResults(backend.store, ids, change) : {
27813
+ results: await localResults(
27814
+ ids,
27815
+ async (id) => updateComplaintStatus(backend.store, id, change, { actor })
27816
+ ),
27817
+ error: void 0
27818
+ };
27819
+ const failures = [];
27820
+ for (const result of results) {
27821
+ if (result.ok && result.complaint !== void 0) {
27822
+ if (options.json !== true)
27823
+ writeLine(io.stdout, successLine(status, result.complaint));
27824
+ continue;
27825
+ }
27826
+ const code2 = result.error?.code ?? NOT_FOUND;
27827
+ const message = code2 === NOT_FOUND ? noComplaint(result.id) : result.error?.message;
27828
+ writeLine(io.stderr, `quibble: ${result.id}: ${message ?? "the change failed"}`);
27829
+ failures.push({ remote: backend.store instanceof RemoteStore && code2 !== NOT_FOUND });
27830
+ }
27831
+ if (options.json === true) writeJson(io.stdout, results);
27832
+ if (error62 !== void 0) throw error62;
27833
+ if (failures.length === 0) return 0;
27834
+ return failures.some((failure2) => failure2.remote) ? 2 : 1;
27835
+ }
27836
+ function successLine(status, complaint) {
27837
+ return `${VERBS[status]} complaint ${complaint.id} for ${complaint.project}`;
27838
+ }
27839
+ async function localResults(ids, apply) {
27840
+ const results = [];
27841
+ for (const id of ids) {
27842
+ try {
27843
+ const complaint = await apply(id);
27844
+ results.push(
27845
+ complaint === void 0 ? { id, ok: false, error: { code: NOT_FOUND, message: noComplaint(id) } } : { id, ok: true, complaint }
27846
+ );
27847
+ } catch (error62) {
27848
+ if (!(error62 instanceof ValidationError)) throw error62;
27849
+ results.push({
27850
+ id,
27851
+ ok: false,
27852
+ error: { code: error62.code, message: errorMessage(error62) }
27853
+ });
27854
+ }
27855
+ }
27856
+ return results;
27857
+ }
27858
+ async function remoteResults(store, ids, change) {
27859
+ const results = [];
27860
+ for (let start = 0; start < ids.length; start += BULK_MAX) {
27861
+ try {
27862
+ results.push(
27863
+ ...await store.bulkStatus(ids.slice(start, start + BULK_MAX), change)
27864
+ );
27865
+ } catch (error62) {
27866
+ return { results, error: error62 };
27867
+ }
27868
+ }
27869
+ return { results, error: void 0 };
25908
27870
  }
25909
27871
 
25910
27872
  // src/commands/summary.ts
25911
27873
  var FORMATS = ["text", "markdown"];
25912
27874
  var SEVERITY_ORDER = [...SEVERITIES].reverse();
25913
27875
  var TRUNCATED_MESSAGE = `Counting the newest ${MAX_LIMIT} complaints only.`;
27876
+ var BUDGET_MESSAGE = "The server stopped searching early, so the counts are a floor: narrow the filters or pass --since.";
25914
27877
  var EMPTY_MESSAGE = "No complaints found.";
25915
27878
  async function runSummary(options, context, io) {
25916
27879
  if (options.format !== void 0 && options.json === true) {
25917
27880
  throw new UsageError("--json and --format cannot be used together");
25918
27881
  }
25919
27882
  const format = readFormat(options.format);
25920
- const { store, client } = openBackend(context, io);
27883
+ const { store, client } = await openBackend(context, io);
25921
27884
  const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
25922
27885
  const filter = parseListFilter({
25923
27886
  ...resolveScope(options, context),
27887
+ ...options.severity === void 0 ? {} : { severity: options.severity },
27888
+ ...tagFilter(options.tag),
25924
27889
  ...reporter === void 0 ? {} : { reporter },
25925
27890
  limit: MAX_LIMIT
25926
27891
  });
25927
- const complaints = await listComplaints(store, filter);
25928
- const truncated = complaints.length === MAX_LIMIT;
27892
+ const page = await listComplaintPage(store, filter);
27893
+ const complaints = page.complaints;
27894
+ const capped = complaints.length === MAX_LIMIT;
27895
+ const truncated = capped || page.truncated;
25929
27896
  const summary = summarizeComplaints(complaints);
25930
27897
  if (options.json === true) {
25931
27898
  writeJson(io.stdout, {
25932
27899
  scope: {
25933
27900
  project: filter.project ?? null,
25934
27901
  status: filter.status ?? null,
27902
+ severity: filter.severity ?? null,
27903
+ tags: filter.tags ?? null,
25935
27904
  since: filter.since ?? null
25936
27905
  },
25937
27906
  truncated,
@@ -25943,7 +27912,8 @@ async function runSummary(options, context, io) {
25943
27912
  writeLine(io.stdout, EMPTY_MESSAGE);
25944
27913
  return 0;
25945
27914
  }
25946
- const lines = format === "markdown" ? markdownLines(filter, summary, truncated) : textLines(filter, summary, truncated);
27915
+ const note = capped ? TRUNCATED_MESSAGE : page.truncated ? BUDGET_MESSAGE : void 0;
27916
+ const lines = format === "markdown" ? markdownLines(filter, summary, note) : textLines(filter, summary, note);
25947
27917
  for (const line of lines) writeLine(io.stdout, line);
25948
27918
  return 0;
25949
27919
  }
@@ -25958,7 +27928,9 @@ function scopeLabel(filter) {
25958
27928
  const name = filter.project ?? "all projects";
25959
27929
  const since = filter.since === void 0 ? "" : ` since ${filter.since}`;
25960
27930
  const status = filter.status === void 0 ? "" : ` with status ${filter.status}`;
25961
- return `Complaints for ${name}${since}${status}`;
27931
+ const severity = filter.severity === void 0 ? "" : ` with severity ${filter.severity}`;
27932
+ const tags = filter.tags === void 0 ? "" : ` tagged ${filter.tags.map((tag) => tag === UNTAGGED_TAG ? "untagged" : oneLine(tag)).join(" or ")}`;
27933
+ return `Complaints for ${name}${since}${status}${severity}${tags}`;
25962
27934
  }
25963
27935
  function blocksOf(filter, summary) {
25964
27936
  const severity = SEVERITY_ORDER.filter((name) => summary.bySeverity[name] > 0).map(
@@ -25982,13 +27954,11 @@ function statusCounts(summary) {
25982
27954
  const { open: open2, resolved, dismissed, deleted } = summary.byStatus;
25983
27955
  return `open ${open2}, resolved ${resolved}, dismissed ${dismissed}, deleted ${deleted}`;
25984
27956
  }
25985
- function oneLine(name) {
25986
- return name.replace(/\s+/g, " ");
25987
- }
27957
+ var oneLine = printable;
25988
27958
  function cell(name) {
25989
27959
  return oneLine(name).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
25990
27960
  }
25991
- function textLines(filter, summary, truncated) {
27961
+ function textLines(filter, summary, note) {
25992
27962
  const lines = [`${scopeLabel(filter)}: ${summary.total} (${statusCounts(summary)})`];
25993
27963
  for (const block2 of blocksOf(filter, summary)) {
25994
27964
  const names = block2.rows.map((row) => oneLine(row.name));
@@ -25998,10 +27968,10 @@ function textLines(filter, summary, truncated) {
25998
27968
  lines.push(` ${(names[index] ?? "").padEnd(width)} ${row.count}`);
25999
27969
  }
26000
27970
  }
26001
- if (truncated) lines.push("", TRUNCATED_MESSAGE);
27971
+ if (note !== void 0) lines.push("", note);
26002
27972
  return lines;
26003
27973
  }
26004
- function markdownLines(filter, summary, truncated) {
27974
+ function markdownLines(filter, summary, note) {
26005
27975
  const { open: open2, resolved, dismissed, deleted } = summary.byStatus;
26006
27976
  const noun = summary.total === 1 ? "complaint" : "complaints";
26007
27977
  const counts = `${open2} open, ${resolved} resolved, ${dismissed} dismissed, ${deleted} deleted`;
@@ -26016,25 +27986,275 @@ function markdownLines(filter, summary, truncated) {
26016
27986
  );
26017
27987
  for (const row of block2.rows) lines.push(`| ${cell(row.name)} | ${row.count} |`);
26018
27988
  }
26019
- if (truncated) lines.push("", TRUNCATED_MESSAGE);
27989
+ if (note !== void 0) lines.push("", note);
26020
27990
  return lines;
26021
27991
  }
26022
27992
 
27993
+ // src/commands/sync.ts
27994
+ import { existsSync as existsSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
27995
+ import { join as join8 } from "node:path";
27996
+ import { Writable as Writable2 } from "node:stream";
27997
+ var SYNC_TIMEOUT_MS = 3e3;
27998
+ var SECTION_FILES = ["CLAUDE.md", "AGENTS.md"];
27999
+ function readOptional3(path) {
28000
+ try {
28001
+ return readFileSync6(path, "utf8");
28002
+ } catch (error62) {
28003
+ if (isNotFound(error62)) return void 0;
28004
+ throw error62;
28005
+ }
28006
+ }
28007
+ function holders(root) {
28008
+ const found = [];
28009
+ for (const name of SECTION_FILES) {
28010
+ const path = join8(root, name);
28011
+ const text = readOptional3(path);
28012
+ if (text !== void 0 && findSection(text) !== void 0) {
28013
+ found.push({ path, text, skill: false });
28014
+ }
28015
+ }
28016
+ const skill = join8(root, SKILL_PATH);
28017
+ if (existsSync3(skill)) {
28018
+ found.push({ path: skill, text: readOptional3(skill) ?? "", skill: true });
28019
+ }
28020
+ return found;
28021
+ }
28022
+ function agentOf(lines, path) {
28023
+ const named = agentOfText(lines);
28024
+ if (named !== void 0) {
28025
+ try {
28026
+ return parseAgentName(named);
28027
+ } catch {
28028
+ }
28029
+ }
28030
+ return agentForFile(path);
28031
+ }
28032
+ function rewrite(holder, tags, scope) {
28033
+ if (holder.skill) {
28034
+ const agent2 = agentOf(holder.text.split(/\r?\n/), holder.path);
28035
+ return withLineBreak(skillFile(agentSnippet(tags, { ...scope, agent: agent2 })), holder.text);
28036
+ }
28037
+ const section = findSection(holder.text);
28038
+ if (section === void 0) return holder.text;
28039
+ const agent = agentOf(sectionText(section), holder.path);
28040
+ return replaceSection(section, agentSnippet(tags, { ...scope, agent }));
28041
+ }
28042
+ function sink() {
28043
+ return new Writable2({
28044
+ write(_chunk, _encoding, done) {
28045
+ done();
28046
+ }
28047
+ });
28048
+ }
28049
+ async function runSync(options, contextOf, io) {
28050
+ if (options.quiet !== true) return sync(options, contextOf(), io);
28051
+ const silent = { ...io, stdout: sink(), stderr: sink() };
28052
+ try {
28053
+ const context = contextOf();
28054
+ context.timeoutMs = SYNC_TIMEOUT_MS;
28055
+ const result = await syncFiles({ hook: options.hook }, context, silent);
28056
+ if (result.files.some((file2) => file2.updated)) {
28057
+ writeLine(io.stdout, refreshedLine(result.project, result.tags));
28058
+ }
28059
+ } catch {
28060
+ }
28061
+ return 0;
28062
+ }
28063
+ async function sync(options, context, io) {
28064
+ const result = await syncFiles(options, context, io);
28065
+ if (options.json === true) {
28066
+ writeJson(io.stdout, {
28067
+ project: result.project,
28068
+ tags: result.tags ?? null,
28069
+ files: result.files,
28070
+ hook: result.hook ?? null
28071
+ });
28072
+ return 0;
28073
+ }
28074
+ for (const file2 of result.files) {
28075
+ writeLine(
28076
+ io.stdout,
28077
+ file2.updated ? `Updated ${file2.path}` : `${file2.path} is up to date`
28078
+ );
28079
+ }
28080
+ return 0;
28081
+ }
28082
+ async function syncFiles(options, context, io) {
28083
+ const project = requireProject(context);
28084
+ const root = context.projectRoot ?? io.cwd;
28085
+ let hook;
28086
+ if (options.hook === true) {
28087
+ const plan = planHook(root);
28088
+ const line = applyHook(plan);
28089
+ hook = plan.path;
28090
+ if (options.json !== true) writeLine(io.stdout, line);
28091
+ }
28092
+ const found = holders(root);
28093
+ if (found.length === 0) {
28094
+ throw new UsageError(
28095
+ `no Quibble snippet under ${root}: run quibble snippet --append CLAUDE.md`
28096
+ );
28097
+ }
28098
+ const { tags, scope } = await snippetSource(project, context, io, { queue: false });
28099
+ const files = [];
28100
+ for (const holder of found) {
28101
+ const next = rewrite(holder, tags, scope);
28102
+ const updated = next !== holder.text;
28103
+ if (updated) writeFileSync6(holder.path, next, "utf8");
28104
+ files.push({ path: holder.path, updated });
28105
+ }
28106
+ return { project, tags, files, hook };
28107
+ }
28108
+
26023
28109
  // src/commands/tags.ts
26024
28110
  async function runTags(options, context, io) {
26025
28111
  const project = requireProject(context);
26026
28112
  if (context.mode !== "remote") checkActAs(context);
26027
- const tags = context.mode === "remote" ? (await openOrgClient(context, io).project(project)).tags : context.allowedTags;
28113
+ const tags = context.mode === "remote" ? (await (await openOrgClient(context, io)).project(project)).tags : context.allowedTags;
26028
28114
  if (options.json === true) {
26029
28115
  writeJson(io.stdout, { project, tags: tags ?? null });
26030
28116
  return 0;
26031
28117
  }
26032
- if (tags === void 0) {
28118
+ printList(project, tags, io);
28119
+ return 0;
28120
+ }
28121
+ function printList(project, tags, io) {
28122
+ if (tags === void 0 || tags.length === 0) {
26033
28123
  writeLine(io.stdout, `No tags configured for ${project}: any tag is allowed.`);
26034
- return 0;
28124
+ return;
26035
28125
  }
26036
28126
  for (const tag of tags) writeLine(io.stdout, tag);
26037
28127
  writeLine(io.stdout, OTHER_TAG_HINT);
28128
+ }
28129
+ async function runTagsEdit(edit, given, options, context, io) {
28130
+ requireServer("tags", context);
28131
+ const project = requireProject(context);
28132
+ if (given.length === 0) throw new UsageError(`tags ${edit} needs at least one tag`);
28133
+ const named = parseAllowedTags(given);
28134
+ const client = await openOrgClient(context, io);
28135
+ let next;
28136
+ if (edit === "set") {
28137
+ next = named;
28138
+ } else {
28139
+ const current = (await client.project(project)).tags ?? [];
28140
+ const combined = edit === "add" ? [...current, ...named.filter((tag) => !current.includes(tag))] : current.filter((tag) => !named.includes(tag));
28141
+ next = combined.length === 0 ? null : parseAllowedTags(combined);
28142
+ }
28143
+ const answer2 = await client.setProjectTags(project, next);
28144
+ if (options.json === true) {
28145
+ writeJson(io.stdout, answer2.body);
28146
+ return 0;
28147
+ }
28148
+ const noun = answer2.retagged === 1 ? "complaint" : "complaints";
28149
+ writeLine(
28150
+ io.stdout,
28151
+ `Updated the tags of ${project}: ${answer2.retagged} ${noun} retagged`
28152
+ );
28153
+ printList(project, answer2.tags, io);
28154
+ return 0;
28155
+ }
28156
+
28157
+ // src/commands/token.ts
28158
+ var COLUMNS3 = ["ID", "NAME", "CREATED", "LAST USED", "EXPIRES", "STATE"];
28159
+ function minute(stamp2) {
28160
+ return stamp2.slice(0, 16).replace("T", " ");
28161
+ }
28162
+ function stateOf(token, now) {
28163
+ if (token.revokedAt !== void 0) return "revoked";
28164
+ if (token.expiresAt !== void 0 && Date.parse(token.expiresAt) <= now.getTime()) {
28165
+ return "expired";
28166
+ }
28167
+ return "active";
28168
+ }
28169
+ function formatTokens(tokens, now) {
28170
+ const rows2 = tokens.map((token) => [
28171
+ token.id.slice(0, 8),
28172
+ token.name,
28173
+ minute(token.createdAt),
28174
+ token.lastUsedAt === void 0 ? "\u2014" : minute(token.lastUsedAt),
28175
+ token.expiresAt === void 0 ? "never" : minute(token.expiresAt),
28176
+ stateOf(token, now)
28177
+ ]);
28178
+ const widths = COLUMNS3.map(
28179
+ (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
28180
+ );
28181
+ const format = (cells) => cells.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
28182
+ return [format(COLUMNS3), ...rows2.map(format)];
28183
+ }
28184
+ function scopeOf(context) {
28185
+ requireServer("token", context);
28186
+ return { project: requireProject(context), org: requireOrg(context) };
28187
+ }
28188
+ async function runTokenCreate(name, options, context, io, now = /* @__PURE__ */ new Date()) {
28189
+ const { project, org } = scopeOf(context);
28190
+ const expiresAt = options.expires === void 0 ? void 0 : parseExpires(options.expires, now);
28191
+ const body = parseNewToken(
28192
+ { name, ...expiresAt === void 0 ? {} : { expiresAt } },
28193
+ now
28194
+ );
28195
+ const client = await openOrgClient(context, io);
28196
+ const minted = await client.createToken(project, body);
28197
+ if (options.json === true) {
28198
+ writeJson(io.stdout, minted.body);
28199
+ return 0;
28200
+ }
28201
+ writeLine(
28202
+ io.stdout,
28203
+ `Created token ${minted.token.name} for ${org}/${project}. It is shown once:`
28204
+ );
28205
+ writeLine(io.stdout, `QUIBBLE_ENDPOINT=${trimSlash2(client.endpoint)}`);
28206
+ writeLine(io.stdout, `QUIBBLE_TOKEN=${minted.plaintext}`);
28207
+ return 0;
28208
+ }
28209
+ async function runTokenList(options, context, io, now = /* @__PURE__ */ new Date()) {
28210
+ const { project } = scopeOf(context);
28211
+ const answer2 = await (await openOrgClient(context, io)).tokens(project);
28212
+ if (options.json === true) {
28213
+ writeJson(io.stdout, answer2.body);
28214
+ return 0;
28215
+ }
28216
+ if (answer2.tokens.length === 0) {
28217
+ writeLine(io.stdout, `No tokens for ${project}.`);
28218
+ return 0;
28219
+ }
28220
+ for (const line of formatTokens(answer2.tokens, now)) writeLine(io.stdout, line);
28221
+ return 0;
28222
+ }
28223
+ function findToken(tokens, value) {
28224
+ const named = tokens.find((token) => token.name === value.trim());
28225
+ if (named !== void 0) return named;
28226
+ let read;
28227
+ try {
28228
+ read = readIdValue(value);
28229
+ } catch (error62) {
28230
+ if (error62 instanceof UsageError)
28231
+ throw new NotFoundError(`no token is named ${value}`);
28232
+ throw error62;
28233
+ }
28234
+ const { id, prefix } = read;
28235
+ const matches = tokens.filter(
28236
+ (token) => prefix ? token.id.startsWith(id) : token.id === id
28237
+ );
28238
+ if (matches.length === 1) return matches[0];
28239
+ if (matches.length > 1) {
28240
+ throw ambiguousError(
28241
+ id,
28242
+ matches.map((token) => token.id),
28243
+ "tokens"
28244
+ );
28245
+ }
28246
+ throw new NotFoundError(`no token is named or starts with ${id}`);
28247
+ }
28248
+ async function runTokenRevoke(value, options, context, io) {
28249
+ const { project, org } = scopeOf(context);
28250
+ const client = await openOrgClient(context, io);
28251
+ const token = findToken((await client.tokens(project)).tokens, value);
28252
+ await client.revokeToken(project, token.id);
28253
+ if (options.json === true) {
28254
+ writeJson(io.stdout, { id: token.id, name: token.name, revoked: true });
28255
+ return 0;
28256
+ }
28257
+ writeLine(io.stdout, `Revoked token ${token.name} of ${org}/${project}.`);
26038
28258
  return 0;
26039
28259
  }
26040
28260
 
@@ -26049,12 +28269,20 @@ var NON_EMPTY_OPTIONS = [
26049
28269
  "note"
26050
28270
  ];
26051
28271
  var STATUS_COMMANDS = [
26052
- ["resolve", "resolved", "mark a complaint resolved"],
26053
- ["dismiss", "dismissed", "dismiss a complaint as a duplicate or as noise"],
26054
- ["delete", "deleted", "hide a complaint from every default view"],
26055
- ["reopen", "open", "reopen a complaint, restore a deleted one and remove its note"]
28272
+ ["resolve", "resolved", "mark complaints resolved"],
28273
+ ["dismiss", "dismissed", "dismiss duplicates, won't-fixes or noise"],
28274
+ ["delete", "deleted", "hide complaints from the default views"],
28275
+ ["reopen", "open", "reopen complaints and clear their notes"]
28276
+ ];
28277
+ var TAGS_EDITS = [
28278
+ ["set", "replace the project's allowed tag list"],
28279
+ ["add", "add tags to the allowed tag list"],
28280
+ ["remove", "remove tags from the allowed tag list"]
26056
28281
  ];
28282
+ var ID_HELP = `complaint id, or a unique prefix of at least ${SHORT_ID_MIN} characters`;
28283
+ var REASON_HELP = `${REASONS.slice(0, -1).join(", ")} or ${REASONS.at(-1) ?? ""}`;
26057
28284
  function packageVersion() {
28285
+ if (typeof QUIBBLE_BUILD_VERSION === "string") return QUIBBLE_BUILD_VERSION;
26058
28286
  const require2 = createRequire(import.meta.url);
26059
28287
  const manifest = require2("../package.json");
26060
28288
  return typeof manifest.version === "string" ? manifest.version : "0.0.0";
@@ -26068,7 +28296,7 @@ function rejectEmptyOptions(options) {
26068
28296
  }
26069
28297
  }
26070
28298
  function withGlobalOptions(command) {
26071
- return command.option("--local", "use the local store, even when an endpoint is configured").option("--endpoint <url>", "base URL of the Quibble API").option("--org <slug>", "organization of every remote request").option("--json", "print JSON instead of text");
28299
+ return command.option("--local", "use the local store, whatever the endpoint says").option("--endpoint <url>", "API URL, web URL, host, or local").option("--org <slug>", "organization to use in remote mode").option("--json", "print JSON instead of text");
26072
28300
  }
26073
28301
  function globals(program2, command) {
26074
28302
  const outer = program2.opts();
@@ -26106,88 +28334,174 @@ function buildProgram(io, setCode) {
26106
28334
  }).exitOverride();
26107
28335
  withGlobalOptions(program2);
26108
28336
  withGlobalOptions(
26109
- program2.command("init").description("write a .quibblerc in the current directory").option("--project <name>", "project name, defaults to the directory name").option("-t, --tag <tag>", "allowed tag of the project, repeatable", collect).option("--force", "overwrite an existing .quibblerc")
26110
- ).action(function() {
28337
+ program2.command("init").description("write a .quibblerc in the current directory").option("--project <name>", "project name (default: the directory name)").option("-t, --tag <tag>", "allowed tag (repeatable)", collect).option("--agent <name>", "agent named in the snippet (default: claude)").option("--skill", "also write .claude/skills/quibble/SKILL.md").option("--hook", "also add the quibble sync hook to .claude/settings.json").option("--force", "overwrite an existing .quibblerc or skill file")
28338
+ ).action(async function() {
28339
+ const options = { ...this.opts(), ...globals(program2, this) };
28340
+ rejectEmptyOptions(options);
28341
+ setCode(await runInit(options, io));
28342
+ });
28343
+ withGlobalOptions(
28344
+ program2.command("snippet").description("print or write the agent snippet of this project").option("--append <file>", "append the snippet to a file such as CLAUDE.md").option("--skill", "write .claude/skills/quibble/SKILL.md").option("--hook", "add the quibble sync hook to .claude/settings.json").option("--agent <name>", "agent named in the snippet").option("--project <name>", "project name (overrides .quibblerc)").option("--force", "overwrite an existing skill file")
28345
+ ).action(async function() {
26111
28346
  const options = { ...this.opts(), ...globals(program2, this) };
26112
28347
  rejectEmptyOptions(options);
26113
- setCode(runInit(options, io));
28348
+ setCode(await runSnippet(options, contextFor(options, io), io));
28349
+ });
28350
+ withGlobalOptions(
28351
+ program2.command("sync").description("rewrite the agent snippet in place with the current tags").option("--hook", "also add the quibble sync hook to .claude/settings.json").option("--quiet", "print nothing but a changed tag list, and always exit 0").option("--project <name>", "project name (overrides .quibblerc)")
28352
+ ).action(async function() {
28353
+ const options = { ...this.opts(), ...globals(program2, this) };
28354
+ setCode(
28355
+ await runSync(
28356
+ options,
28357
+ () => {
28358
+ rejectEmptyOptions(options);
28359
+ return contextFor(options, io);
28360
+ },
28361
+ io
28362
+ )
28363
+ );
26114
28364
  });
26115
28365
  withGlobalOptions(
26116
- program2.command("complain").description("file a complaint, reading the message from stdin when omitted").argument("[message]", "the complaint, or `-` to read stdin").option("-s, --severity <level>", "low, medium or high").option("-t, --tag <tag>", "tag, repeatable", collect).option("--agent <name>", "agent that files the complaint").option("--reporter <name>", "person on whose behalf it is filed").option("--project <name>", "project name, overrides the .quibblerc")
28366
+ program2.command("complain").description("file a complaint").argument("[message]", "what went wrong; omit it or pass `-` to read stdin").option("-s, --severity <level>", "low, medium or high").option("-t, --tag <tag>", "tag (repeatable)", collect).option("--agent <name>", "name of the agent filing it").option("--reporter <name>", "person you are filing for (local mode only)").option("--project <name>", "project name (overrides .quibblerc)").option("--cmd <command>", "the command that failed")
26117
28367
  ).action(async function(message) {
26118
28368
  const options = { ...this.opts(), ...globals(program2, this) };
26119
28369
  rejectEmptyOptions(options);
26120
28370
  setCode(await runComplain(message, options, contextFor(options, io), io));
26121
28371
  });
26122
28372
  withGlobalOptions(
26123
- program2.command("list").description("list complaints, newest first").option("--project <name>", "list one project").option("--all", "list every project").option("-s, --severity <level>", "low, medium or high").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "only open complaints, short for --status open").option("-t, --tag <tag>", "only complaints with this tag, or `-` for none").option("--mine", "only the complaints of the calling reporter").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--limit <n>", "how many complaints to return")
28373
+ program2.command("list").description("list complaints, newest first").option("--project <name>", "list one project").option("--all", "list all projects").option("-s, --severity <level>", "low, medium or high").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "same as --status open").option(
28374
+ "-t, --tag <tag>",
28375
+ "complaints with any of these tags (repeatable, `-` for untagged)",
28376
+ collect
28377
+ ).option("--mine", "only your own complaints").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--search <text>", "complaints whose message contains the text").option("--limit <n>", "maximum number of complaints").option("--ungroup", "one row per complaint instead of one per group").option("--ids", "print the full id of every complaint, one per line")
26124
28378
  ).action(async function() {
26125
28379
  const options = { ...this.opts(), ...globals(program2, this) };
26126
28380
  rejectEmptyOptions(options);
26127
28381
  setCode(await runList(options, contextFor(options, io), io));
26128
28382
  });
28383
+ withGlobalOptions(
28384
+ program2.command("show").description("print one whole complaint").argument("<id>", ID_HELP)
28385
+ ).action(async function(id) {
28386
+ const options = { ...this.opts(), ...globals(program2, this) };
28387
+ rejectEmptyOptions(options);
28388
+ setCode(await runShow(id, options, contextFor(options, io), io));
28389
+ });
26129
28390
  for (const [name, status, description] of STATUS_COMMANDS) {
26130
- const command = program2.command(name).description(description).argument("<id>", "full id of the complaint");
28391
+ const command = program2.command(name).description(description).argument("<id...>", `${ID_HELP}; \`-\` reads the ids from stdin`);
26131
28392
  if (status !== "open") {
26132
- command.option("--note <text>", "note that explains the change");
28393
+ command.option("--note <text>", "note explaining the change");
26133
28394
  }
26134
- withGlobalOptions(command).action(async function(id) {
28395
+ if (status === "resolved" || status === "dismissed") {
28396
+ command.option("--reason <reason>", REASON_HELP).option("--duplicate-of <id>", "the complaint these repeat (implies duplicate)");
28397
+ }
28398
+ withGlobalOptions(command).action(async function(ids) {
26135
28399
  const options = { ...this.opts(), ...globals(program2, this) };
26136
28400
  rejectEmptyOptions(options);
26137
- setCode(await runStatusChange(status, id, options, contextFor(options, io), io));
28401
+ setCode(await runStatusChange(status, ids, options, contextFor(options, io), io));
26138
28402
  });
26139
28403
  }
26140
28404
  withGlobalOptions(
26141
- program2.command("retag").description("replace the tags of a complaint").argument("<id>", "full id of the complaint").option("-t, --tag <tag>", "tag, repeatable and needed at least once", collect)
28405
+ program2.command("retag").description("replace a complaint's tags").argument("<id>", ID_HELP).option("-t, --tag <tag>", "tag (repeatable, at least one)", collect)
26142
28406
  ).action(async function(id) {
26143
28407
  const options = { ...this.opts(), ...globals(program2, this) };
26144
28408
  rejectEmptyOptions(options);
26145
28409
  setCode(await runRetag(id, options, contextFor(options, io), io));
26146
28410
  });
26147
28411
  withGlobalOptions(
26148
- program2.command("summary").description("count the matching complaints by severity, project and tag").option("--project <name>", "summarize one project").option("--all", "summarize every project").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "only open complaints, short for --status open").option("--mine", "only the complaints of the calling reporter").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--format <format>", "text or markdown")
28412
+ program2.command("summary").description("count complaints by status, severity, project and tag").option("--project <name>", "summarize one project").option("--all", "summarize all projects").option("--status <status>", "open, resolved, dismissed or deleted").option("--open", "same as --status open").option("-s, --severity <level>", "low, medium or high").option(
28413
+ "-t, --tag <tag>",
28414
+ "complaints with any of these tags (repeatable, `-` for untagged)",
28415
+ collect
28416
+ ).option("--mine", "only your own complaints").option("--since <when>", "a duration like 7d, a date or a UTC timestamp").option("--format <format>", "text or markdown")
26149
28417
  ).action(async function() {
26150
28418
  const options = { ...this.opts(), ...globals(program2, this) };
26151
28419
  rejectEmptyOptions(options);
26152
28420
  setCode(await runSummary(options, contextFor(options, io), io));
26153
28421
  });
26154
- withGlobalOptions(
26155
- program2.command("tags").description("print the allowed tags of the project").option("--project <name>", "project name, overrides the .quibblerc")
28422
+ const tags = withGlobalOptions(
28423
+ program2.command("tags").description("print or edit the project's allowed tags").option("--project <name>", "project name (overrides .quibblerc)")
26156
28424
  ).action(async function() {
26157
28425
  const options = { ...this.opts(), ...globals(program2, this) };
26158
28426
  rejectEmptyOptions(options);
26159
28427
  setCode(await runTags(options, contextFor(options, io), io));
26160
28428
  });
28429
+ for (const [edit, description] of TAGS_EDITS) {
28430
+ withGlobalOptions(
28431
+ tags.command(edit).description(`${description} (remote only)`).argument("[tag...]", "tags").option("--project <name>", "project name (overrides .quibblerc)")
28432
+ ).action(async function(given) {
28433
+ const options = { ...tags.opts(), ...this.opts(), ...globals(program2, this) };
28434
+ rejectEmptyOptions(options);
28435
+ setCode(await runTagsEdit(edit, given, options, contextFor(options, io), io));
28436
+ });
28437
+ }
28438
+ const token = program2.command("token").description("manage the project's tokens (remote only)");
28439
+ withGlobalOptions(
28440
+ token.command("create").description("mint a project token and print it once").argument("<name>", "token name, unique in the project").option("--expires <when>", "a duration like 30d, a date or a UTC timestamp").option("--project <name>", "project name (overrides .quibblerc)")
28441
+ ).action(async function(name) {
28442
+ const options = { ...this.opts(), ...globals(program2, this) };
28443
+ rejectEmptyOptions(options);
28444
+ setCode(await runTokenCreate(name, options, contextFor(options, io), io));
28445
+ });
28446
+ withGlobalOptions(
28447
+ token.command("list").description("list the project's tokens").option("--project <name>", "project name (overrides .quibblerc)")
28448
+ ).action(async function() {
28449
+ const options = { ...this.opts(), ...globals(program2, this) };
28450
+ rejectEmptyOptions(options);
28451
+ setCode(await runTokenList(options, contextFor(options, io), io));
28452
+ });
26161
28453
  withGlobalOptions(
26162
- program2.command("promote").description("add a suggestion to the allowed tags and retag the complaints").argument("<tag>", "the tag to promote, without the `other:` prefix").option("--project <name>", "project name, overrides the .quibblerc")
28454
+ token.command("revoke").description("revoke a token").argument("<token>", "token name, or an id prefix of at least 6 characters").option("--project <name>", "project name (overrides .quibblerc)")
28455
+ ).action(async function(value) {
28456
+ const options = { ...this.opts(), ...globals(program2, this) };
28457
+ rejectEmptyOptions(options);
28458
+ setCode(await runTokenRevoke(value, options, contextFor(options, io), io));
28459
+ });
28460
+ const projectCommand = program2.command("project").description("manage the projects of the organization (remote only)");
28461
+ withGlobalOptions(
28462
+ projectCommand.command("delete").description("delete a project and hide its complaints").argument("<name>", "project name").option("--yes", "skip the question")
28463
+ ).action(async function(name) {
28464
+ const options = { ...this.opts(), ...globals(program2, this) };
28465
+ rejectEmptyOptions(options);
28466
+ setCode(await runProjectDelete(name, options, contextFor(options, io), io));
28467
+ });
28468
+ withGlobalOptions(
28469
+ program2.command("retry").description("send the complaints queued while offline")
28470
+ ).action(async function() {
28471
+ const options = { ...this.opts(), ...globals(program2, this) };
28472
+ rejectEmptyOptions(options);
28473
+ setCode(await runRetry(options, contextFor(options, io), io));
28474
+ });
28475
+ withGlobalOptions(
28476
+ program2.command("promote").description("turn an `other:` suggestion into an allowed tag and retag complaints").argument("<tag>", "tag to promote, without `other:`").option("--project <name>", "project name (overrides .quibblerc)")
26163
28477
  ).action(async function(tag) {
26164
28478
  const options = { ...this.opts(), ...globals(program2, this) };
26165
28479
  rejectEmptyOptions(options);
26166
28480
  setCode(await runPromote(tag, options, contextFor(options, io), io));
26167
28481
  });
26168
28482
  withGlobalOptions(
26169
- program2.command("orgs").description("list the organizations the caller belongs to")
28483
+ program2.command("orgs").description("list your organizations")
26170
28484
  ).action(async function() {
26171
28485
  const options = { ...this.opts(), ...globals(program2, this) };
26172
28486
  rejectEmptyOptions(options);
26173
28487
  setCode(await runOrgs(options, contextFor(options, io), io));
26174
28488
  });
26175
28489
  withGlobalOptions(
26176
- program2.command("login").description("sign in to the endpoint on the terminal and store the session")
28490
+ program2.command("login").description("log in to the endpoint")
26177
28491
  ).action(async function() {
26178
28492
  const options = { ...this.opts(), ...globals(program2, this) };
26179
28493
  rejectEmptyOptions(options);
26180
28494
  setCode(await runLogin(options, contextFor(options, io), io));
26181
28495
  });
26182
28496
  withGlobalOptions(
26183
- program2.command("logout").description("revoke the refresh token and remove the stored session")
28497
+ program2.command("logout").description("log out of the endpoint")
26184
28498
  ).action(async function() {
26185
28499
  const options = { ...this.opts(), ...globals(program2, this) };
26186
28500
  rejectEmptyOptions(options);
26187
28501
  setCode(await runLogout(contextFor(options, io), io));
26188
28502
  });
26189
28503
  withGlobalOptions(
26190
- program2.command("whoami").description("print the identity that the API resolves")
28504
+ program2.command("whoami").description("show the current identity")
26191
28505
  ).action(async function() {
26192
28506
  const options = { ...this.opts(), ...globals(program2, this) };
26193
28507
  rejectEmptyOptions(options);
@@ -26195,10 +28509,10 @@ function buildProgram(io, setCode) {
26195
28509
  });
26196
28510
  withGlobalOptions(
26197
28511
  program2.command("config").description("print the resolved configuration")
26198
- ).action(function() {
28512
+ ).action(async function() {
26199
28513
  const options = { ...this.opts(), ...globals(program2, this) };
26200
28514
  rejectEmptyOptions(options);
26201
- setCode(runConfig(options, contextFor(options, io), io));
28515
+ setCode(await runConfig(options, contextFor(options, io), io));
26202
28516
  });
26203
28517
  return program2;
26204
28518
  }
@@ -26206,9 +28520,10 @@ function stripErrorPrefix(message) {
26206
28520
  return message.replace(/^error:\s*/i, "");
26207
28521
  }
26208
28522
  async function main(argv, io) {
26209
- let code = 0;
28523
+ resetDiscovery();
28524
+ let code2 = 0;
26210
28525
  const program2 = buildProgram(io, (value) => {
26211
- code = value;
28526
+ code2 = value;
26212
28527
  });
26213
28528
  if (argv.length === 0) {
26214
28529
  io.stdout.write(program2.helpInformation());
@@ -26216,7 +28531,7 @@ async function main(argv, io) {
26216
28531
  }
26217
28532
  try {
26218
28533
  await program2.parseAsync(argv, { from: "user" });
26219
- return code;
28534
+ return code2;
26220
28535
  } catch (error62) {
26221
28536
  if (error62 instanceof CommanderError) {
26222
28537
  if (error62.code === "commander.help" || error62.code === "commander.helpDisplayed") {
@@ -26238,10 +28553,18 @@ for (const stream of [process2.stdout, process2.stderr]) {
26238
28553
  throw error62;
26239
28554
  });
26240
28555
  }
26241
- process2.exitCode = await main(process2.argv.slice(2), {
28556
+ function flushed(stream) {
28557
+ return new Promise((resolve4) => {
28558
+ stream.write("", () => resolve4());
28559
+ });
28560
+ }
28561
+ var code = await main(process2.argv.slice(2), {
26242
28562
  stdin: process2.stdin,
26243
28563
  stdout: process2.stdout,
26244
28564
  stderr: process2.stderr,
26245
28565
  env: process2.env,
26246
28566
  cwd: process2.cwd()
26247
28567
  });
28568
+ process2.exitCode = code;
28569
+ await Promise.all([flushed(process2.stdout), flushed(process2.stderr)]);
28570
+ process2.exit(code);