letthemquibble 2.0.0 → 2.1.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 (3) hide show
  1. package/README.md +16 -7
  2. package/dist/quibble.js +1283 -999
  3. package/package.json +3 -7
package/dist/quibble.js CHANGED
@@ -23498,6 +23498,43 @@ function freeText(max, typeMessage) {
23498
23498
  const message = lengthMessage(1, max);
23499
23499
  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 });
23500
23500
  }
23501
+ var SECRET_PATTERNS = [
23502
+ {
23503
+ kind: "an AWS access key",
23504
+ pattern: /(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])/
23505
+ },
23506
+ {
23507
+ kind: "a GitHub token",
23508
+ pattern: /(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{36,}(?![A-Za-z0-9])|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{22,}/
23509
+ },
23510
+ {
23511
+ kind: "an API key",
23512
+ pattern: /(?<![A-Za-z0-9-])sk-(?:ant-|proj-)?(?=[A-Za-z_-]*[0-9])[A-Za-z0-9_-]{32,}/
23513
+ },
23514
+ { kind: "a Stripe live key", pattern: /(?<![A-Za-z0-9])[rs]k_live_[A-Za-z0-9]{20,}/ },
23515
+ { kind: "a Slack token", pattern: /(?<![A-Za-z0-9])xox[abposr]-[A-Za-z0-9-]{10,}/ },
23516
+ {
23517
+ kind: "a Quibble project token",
23518
+ pattern: /(?<![A-Za-z0-9])qbp_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/
23519
+ },
23520
+ { kind: "a private key", pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/ }
23521
+ ];
23522
+ function findSecret(text) {
23523
+ return SECRET_PATTERNS.find(({ pattern }) => pattern.test(text))?.kind;
23524
+ }
23525
+ function secretFree(schema, max) {
23526
+ return schema.superRefine((value, ctx) => {
23527
+ if (value.length > max || CONTROL_RE.test(value))
23528
+ return;
23529
+ const kind = findSecret(value);
23530
+ if (kind !== void 0) {
23531
+ ctx.addIssue({
23532
+ code: "custom",
23533
+ message: `holds what looks like ${kind}: remove the secret and file again`
23534
+ });
23535
+ }
23536
+ });
23537
+ }
23501
23538
  var SEVERITY_MESSAGE = `must be one of ${SEVERITIES.join(", ")}`;
23502
23539
  var STATUS_MESSAGE = `must be one of ${STATUSES.join(", ")}`;
23503
23540
  var LIMIT_MESSAGE = `must be an integer between 1 and ${MAX_LIMIT}`;
@@ -23650,7 +23687,7 @@ var agentSchema = freeText(AGENT_MAX);
23650
23687
  var newComplaintShape = {
23651
23688
  org: orgSlugSchema,
23652
23689
  project: projectNameSchema,
23653
- message: freeText(MESSAGE_MAX),
23690
+ message: secretFree(freeText(MESSAGE_MAX), MESSAGE_MAX),
23654
23691
  severity: severitySchema.optional(),
23655
23692
  tags: tagsSchema.optional(),
23656
23693
  agent: agentSchema.optional(),
@@ -23658,7 +23695,7 @@ var newComplaintShape = {
23658
23695
  reporterName: freeText(REPORTER_MAX).optional(),
23659
23696
  cwd: cwdSchema.optional(),
23660
23697
  // Free text: it is trimmed and may hold newlines, and nothing parses it.
23661
- cmd: freeText(CMD_MAX).optional()
23698
+ cmd: secretFree(freeText(CMD_MAX), CMD_MAX).optional()
23662
23699
  };
23663
23700
  var newComplaintObject = external_exports.strictObject(newComplaintShape);
23664
23701
  function withDefaults(input2) {
@@ -25435,7 +25472,7 @@ function resolveEndpointSource(options, project, user) {
25435
25472
  };
25436
25473
  }
25437
25474
  function resolveContext(options) {
25438
- const project = findProjectConfig(options.cwd);
25475
+ const project = options.readProjectConfig === false ? void 0 : findProjectConfig(options.cwd);
25439
25476
  const user = loadUserConfig(options.env);
25440
25477
  const { mode, endpoint, endpointSource, localFix } = resolveEndpointSource(
25441
25478
  options,
@@ -25676,7 +25713,7 @@ function readComplaint(body) {
25676
25713
  throw new RemoteError(NOT_A_COMPLAINT, { cause: error62 });
25677
25714
  }
25678
25715
  }
25679
- var ApiClient = class {
25716
+ var ApiClient = class _ApiClient {
25680
25717
  endpoint;
25681
25718
  /** The organization of every org-scoped path, when the command resolved one. */
25682
25719
  org;
@@ -25724,6 +25761,19 @@ var ApiClient = class {
25724
25761
  if (this.org === void 0) throw new ConfigError(NO_ORG_MESSAGE);
25725
25762
  return `/v1/orgs/${encodeURIComponent(this.org)}`;
25726
25763
  }
25764
+ /**
25765
+ * The same client for one organization, with the same authorizer, so a
25766
+ * command that learns the organization from `GET /v1/me` reads the auth
25767
+ * config and refreshes a session once. It runs no `beforeFirst`.
25768
+ */
25769
+ forOrg(org) {
25770
+ return new _ApiClient({
25771
+ endpoint: this.endpoint,
25772
+ authorizer: this.#auth,
25773
+ org,
25774
+ ...this.#http
25775
+ });
25776
+ }
25727
25777
  /** Reads the caller's own identity. */
25728
25778
  async me() {
25729
25779
  return parseMe(await this.request("GET", "/v1/me"));
@@ -25751,6 +25801,18 @@ var ApiClient = class {
25751
25801
  async project(name) {
25752
25802
  return readProject(await this.request("GET", this.projectPath(name)), name);
25753
25803
  }
25804
+ /**
25805
+ * Creates a project, or restores a deleted one of that name (section 7.5).
25806
+ * The route needs `admin`, and answers `201` with the record.
25807
+ */
25808
+ async createProject(name, tags) {
25809
+ const body = await this.request(
25810
+ "POST",
25811
+ `${this.orgPath()}/projects`,
25812
+ JSON.stringify({ project: name, ...tags === void 0 ? {} : { tags } })
25813
+ );
25814
+ return readProject(body, name);
25815
+ }
25754
25816
  /**
25755
25817
  * Replaces the allowed tag list of a project, or clears it with `null`. The
25756
25818
  * route retags the stored complaints and answers how many it rewrote.
@@ -26314,7 +26376,9 @@ function projectOf(entry) {
26314
26376
  }
26315
26377
  function headlineOfEntry(entry) {
26316
26378
  const message = entry.body["message"];
26317
- return typeof message === "string" ? printable(summarize(message)) : "?";
26379
+ if (typeof message !== "string") return '"?"';
26380
+ const headline = summarize(message);
26381
+ return findSecret(headline) === void 0 ? `"${printable(headline)}"` : `key ${printable(entry.key)}`;
26318
26382
  }
26319
26383
  function queuedCount(n) {
26320
26384
  return `${n} queued ${n === 1 ? "complaint" : "complaints"}`;
@@ -26385,7 +26449,7 @@ async function flushQueue(options) {
26385
26449
  result.dropped += 1;
26386
26450
  writeLine(
26387
26451
  options.stderr,
26388
- `quibble: dropped a queued complaint for ${entry.org}/${projectOf(entry)}, "${headlineOfEntry(entry)}": ${printable(message)}`
26452
+ `quibble: dropped a queued complaint for ${entry.org}/${projectOf(entry)}, ${headlineOfEntry(entry)}: ${printable(message)}`
26389
26453
  );
26390
26454
  }
26391
26455
  return result;
@@ -26637,6 +26701,75 @@ async function runConfig(options, context, io) {
26637
26701
  import { existsSync as existsSync2, writeFileSync as writeFileSync5 } from "node:fs";
26638
26702
  import { basename as basename2, join as join7, resolve as resolve3 } from "node:path";
26639
26703
 
26704
+ // src/git.ts
26705
+ import { spawn as nodeSpawn } from "node:child_process";
26706
+ var GIT_TIMEOUT_MS = 5e3;
26707
+ var ORIGIN_ARGS = ["remote", "get-url", "origin"];
26708
+ function nodeGit(options = {}) {
26709
+ const spawn = options.spawn ?? nodeSpawn;
26710
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUT_MS;
26711
+ return {
26712
+ originUrl(cwd) {
26713
+ return new Promise((resolve4) => {
26714
+ let settled = false;
26715
+ let child;
26716
+ let output2 = "";
26717
+ const settle2 = (url2) => {
26718
+ if (settled) return;
26719
+ settled = true;
26720
+ clearTimeout(timer);
26721
+ resolve4(url2);
26722
+ };
26723
+ const timer = setTimeout(() => {
26724
+ settle2(void 0);
26725
+ child?.kill();
26726
+ }, timeoutMs);
26727
+ timer.unref();
26728
+ try {
26729
+ child = spawn("git", [...ORIGIN_ARGS], {
26730
+ cwd,
26731
+ shell: false,
26732
+ stdio: ["ignore", "pipe", "ignore"],
26733
+ windowsHide: true
26734
+ });
26735
+ } catch {
26736
+ settle2(void 0);
26737
+ return;
26738
+ }
26739
+ child.stdout?.setEncoding("utf8");
26740
+ child.stdout?.on("data", (chunk) => {
26741
+ output2 += chunk;
26742
+ });
26743
+ child.on("error", () => {
26744
+ settle2(void 0);
26745
+ });
26746
+ child.on("close", (code2) => {
26747
+ const url2 = output2.trim();
26748
+ settle2(code2 === 0 && url2 !== "" ? url2 : void 0);
26749
+ });
26750
+ });
26751
+ }
26752
+ };
26753
+ }
26754
+ function remoteSegment(url2) {
26755
+ let rest = url2.trim();
26756
+ if (rest.endsWith("/")) rest = rest.slice(0, -1);
26757
+ if (rest.endsWith(".git")) rest = rest.slice(0, -".git".length);
26758
+ const slash = rest.lastIndexOf("/");
26759
+ const colon = rest.lastIndexOf(":");
26760
+ const scpLike = !rest.includes("://") && colon > slash;
26761
+ return rest.slice((scpLike ? colon : slash) + 1);
26762
+ }
26763
+ function projectOfRemote(url2) {
26764
+ const segment = remoteSegment(url2);
26765
+ if (segment === "") return void 0;
26766
+ try {
26767
+ return parseProjectName(segment);
26768
+ } catch {
26769
+ return void 0;
26770
+ }
26771
+ }
26772
+
26640
26773
  // src/hook.ts
26641
26774
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
26642
26775
  import { dirname as dirname5, join as join5 } from "node:path";
@@ -26709,11 +26842,13 @@ var DEFAULT_SNIPPET_AGENT = "claude";
26709
26842
  var AGENTS_FILE_AGENT = "codex";
26710
26843
  var SNIPPET_INTRO = `This project uses Quibble. When the build, tests, docs or tooling waste your
26711
26844
  time, file a complaint instead of quietly working around the problem.`;
26845
+ var SNIPPET_SECRETS = "- Never paste secrets, tokens or .env lines into a complaint; Quibble refuses them.";
26712
26846
  var SNIPPET_USAGE = `- Set severity with \`-s low|medium|high\`. Add tags with \`-t <tag>\` (repeatable).
26713
26847
  - Put a one-line summary first; it is the headline everywhere. Add details below.
26714
26848
  - Name the command that failed with \`--cmd "<command>"\`.
26715
26849
  - Pipe a long message on stdin with \`quibble complain -\`.
26716
- - File one complaint per problem, as soon as you hit it, not at the end of the task.`;
26850
+ - File one complaint per problem, as soon as you hit it, not at the end of the task.
26851
+ ${SNIPPET_SECRETS}`;
26717
26852
  var SKILL_FRONT_MATTER = `---
26718
26853
  name: quibble
26719
26854
  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.
@@ -26831,1127 +26966,1273 @@ function refreshedLine(project, tags) {
26831
26966
  return `${lead} tag each complaint with ${orList(tags)}, or ${OTHER_TAG_PREFIX}<name> when none fits.`;
26832
26967
  }
26833
26968
 
26834
- // src/commands/snippet.ts
26835
- import { existsSync, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
26836
- import { dirname as dirname6, join as join6, resolve as resolve2 } from "node:path";
26837
- function existsError(path) {
26838
- return new UsageError(`${path} already exists: pass --force to overwrite it`);
26969
+ // src/commands/login.ts
26970
+ import { hostname as hostname4 } from "node:os";
26971
+
26972
+ // src/opener.ts
26973
+ import { spawn as nodeSpawn2 } from "node:child_process";
26974
+ var OPEN_TIMEOUT_MS = 5e3;
26975
+ var OPENABLE_URL_RE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9/?=._~-]*)?$/;
26976
+ function isWsl(platform, env) {
26977
+ if (platform !== "linux") return false;
26978
+ const set2 = (name) => (env[name] ?? "") !== "";
26979
+ return set2("WSL_DISTRO_NAME") || set2("WSL_INTEROP");
26839
26980
  }
26840
- function readOptional2(path) {
26841
- try {
26842
- return readFileSync5(path, "utf8");
26843
- } catch (error62) {
26844
- if (isNotFound(error62)) return "";
26845
- throw error62;
26981
+ function openCommands(url2, platform, env) {
26982
+ if (platform === "darwin") return [{ command: "open", args: [url2] }];
26983
+ if (platform === "win32") {
26984
+ return [{ command: "cmd", args: ["/c", "start", "", url2] }];
26985
+ }
26986
+ if (isWsl(platform, env)) {
26987
+ return [
26988
+ { command: "wslview", args: [url2] },
26989
+ { command: "cmd.exe", args: ["/c", "start", "", url2] }
26990
+ ];
26846
26991
  }
26992
+ return [{ command: "xdg-open", args: [url2] }];
26847
26993
  }
26848
- function writeSkill(path, snippet) {
26849
- mkdirSync5(dirname6(path), { recursive: true });
26850
- writeFileSync4(path, skillFile(snippet), "utf8");
26994
+ function attempt(spawn, entry, timeoutMs) {
26995
+ return new Promise((resolve4) => {
26996
+ let settled = false;
26997
+ const settle2 = (opened) => {
26998
+ if (settled) return;
26999
+ settled = true;
27000
+ clearTimeout(timer);
27001
+ resolve4(opened);
27002
+ };
27003
+ const timer = setTimeout(() => {
27004
+ settle2(false);
27005
+ }, timeoutMs);
27006
+ timer.unref();
27007
+ let child;
27008
+ try {
27009
+ child = spawn(entry.command, entry.args, {
27010
+ shell: false,
27011
+ detached: true,
27012
+ stdio: "ignore",
27013
+ windowsHide: true
27014
+ });
27015
+ } catch {
27016
+ settle2(false);
27017
+ return;
27018
+ }
27019
+ child.on("error", () => {
27020
+ settle2(false);
27021
+ });
27022
+ child.on("exit", (code2) => {
27023
+ settle2(code2 === 0);
27024
+ });
27025
+ child.unref();
27026
+ });
26851
27027
  }
26852
- async function snippetSource(project, context, io, options = {}) {
26853
- let tags;
26854
- if (context.mode === "remote") {
26855
- tags = (await (await openOrgClient(context, io, options)).project(project)).tags;
26856
- } else {
26857
- checkActAs(context);
26858
- tags = context.allowedTags;
26859
- }
26860
- const scope = {
26861
- ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
26862
- ...context.slug === void 0 ? {} : { org: context.slug }
27028
+ function platformOpener(options = {}) {
27029
+ const platform = options.platform ?? process.platform;
27030
+ const env = options.env ?? process.env;
27031
+ const spawn = options.spawn ?? nodeSpawn2;
27032
+ const timeoutMs = options.timeoutMs ?? OPEN_TIMEOUT_MS;
27033
+ return {
27034
+ async open(url2) {
27035
+ if (!OPENABLE_URL_RE.test(url2)) return false;
27036
+ for (const entry of openCommands(url2, platform, env)) {
27037
+ if (await attempt(spawn, entry, timeoutMs)) return true;
27038
+ }
27039
+ return false;
27040
+ }
26863
27041
  };
26864
- return { tags, scope };
26865
27042
  }
26866
- async function runSnippet(options, context, io) {
26867
- const project = requireProject(context);
26868
- const override = options.agent === void 0 ? void 0 : parseAgentName(options.agent);
26869
- const appendPath = options.append === void 0 ? void 0 : resolve2(io.cwd, options.append);
26870
- const skillPath = options.skill === true ? join6(context.projectRoot ?? io.cwd, SKILL_PATH) : void 0;
26871
- const existing = appendPath === void 0 ? "" : readOptional2(appendPath);
26872
- if (options.append !== void 0 && hasSnippet(existing)) {
26873
- throw new UsageError(
26874
- `${options.append} already has a "${SNIPPET_HEADING}" section: run quibble sync to refresh it`
26875
- );
26876
- }
26877
- if (skillPath !== void 0 && existsSync(skillPath) && options.force !== true) {
26878
- throw existsError(skillPath);
26879
- }
26880
- const hook = options.hook === true ? planHook(context.projectRoot ?? io.cwd) : void 0;
26881
- const { tags, scope } = await snippetSource(project, context, io);
26882
- let shown = { agent: override ?? DEFAULT_SNIPPET_AGENT, snippet: "" };
26883
- shown.snippet = agentSnippet(tags, { ...scope, agent: shown.agent });
26884
- if (appendPath !== void 0) {
26885
- const agent = override ?? agentForFile(appendPath);
26886
- const snippet = agentSnippet(tags, { ...scope, agent });
26887
- const kept = existing.replace(/\s+$/, "");
26888
- writeFileSync4(appendPath, kept === "" ? `${snippet}
26889
- ` : `${kept}
26890
27043
 
26891
- ${snippet}
26892
- `);
26893
- shown = { agent, snippet };
27044
+ // src/terminal.ts
27045
+ import { createInterface } from "node:readline";
27046
+ import { Writable } from "node:stream";
27047
+ var NodeTerminal = class {
27048
+ isTTY;
27049
+ #input;
27050
+ #output;
27051
+ #rl;
27052
+ /**
27053
+ * True unless a visible prompt is waiting, so a line that arrives early,
27054
+ * a pasted password after the email for one, echoes nowhere.
27055
+ */
27056
+ #muted = true;
27057
+ /** Lines that arrived before a prompt asked for them. */
27058
+ #lines = [];
27059
+ #waiting;
27060
+ #closed = false;
27061
+ constructor(input2, output2) {
27062
+ this.#input = input2;
27063
+ this.#output = output2;
27064
+ this.isTTY = input2.isTTY === true;
26894
27065
  }
26895
- if (skillPath !== void 0) {
26896
- const agent = override ?? DEFAULT_SNIPPET_AGENT;
26897
- const snippet = agentSnippet(tags, { ...scope, agent });
26898
- writeSkill(skillPath, snippet);
26899
- if (appendPath === void 0) shown = { agent, snippet };
27066
+ prompt(label) {
27067
+ return this.#read(label, false);
26900
27068
  }
26901
- const hookLine = hook === void 0 ? void 0 : applyHook(hook);
26902
- if (options.json === true) {
26903
- writeJson(io.stdout, {
26904
- snippet: shown.snippet,
26905
- agent: shown.agent,
26906
- appended: appendPath ?? null,
26907
- skill: skillPath ?? null,
26908
- hook: hook?.path ?? null
26909
- });
26910
- return 0;
27069
+ promptHidden(label) {
27070
+ return this.#read(label, true);
26911
27071
  }
26912
- if (appendPath === void 0 && skillPath === void 0) {
26913
- writeLine(io.stdout, shown.snippet);
27072
+ close() {
27073
+ this.#closed = true;
27074
+ this.#rl?.close();
27075
+ this.#rl = void 0;
26914
27076
  }
26915
- if (appendPath !== void 0) {
26916
- writeLine(io.stdout, `Appended the Quibble snippet to ${appendPath}`);
27077
+ /** The one interface, created on the first prompt. */
27078
+ #interface() {
27079
+ if (this.#rl !== void 0) return this.#rl;
27080
+ const output2 = this.#output;
27081
+ const sink2 = new Writable({
27082
+ write: (chunk, _encoding, done) => {
27083
+ if (!this.#muted) output2.write(chunk);
27084
+ done();
27085
+ }
27086
+ });
27087
+ Object.defineProperties(sink2, {
27088
+ columns: { get: () => output2.columns },
27089
+ rows: { get: () => output2.rows }
27090
+ });
27091
+ const rl = createInterface({
27092
+ input: this.#input,
27093
+ output: sink2,
27094
+ terminal: true,
27095
+ // No answer enters the history, so an arrow key recalls no password.
27096
+ historySize: 0
27097
+ });
27098
+ rl.on("line", (line) => {
27099
+ this.#muted = true;
27100
+ if (this.#waiting !== void 0) {
27101
+ const settle2 = this.#waiting;
27102
+ this.#waiting = void 0;
27103
+ settle2(line);
27104
+ return;
27105
+ }
27106
+ this.#lines.push(line);
27107
+ });
27108
+ rl.on("close", () => {
27109
+ this.#closed = true;
27110
+ this.#waiting?.("");
27111
+ this.#waiting = void 0;
27112
+ });
27113
+ rl.on("SIGINT", () => {
27114
+ this.close();
27115
+ this.#output.write("\n");
27116
+ process.kill(process.pid, "SIGINT");
27117
+ });
27118
+ this.#rl = rl;
27119
+ return rl;
26917
27120
  }
26918
- if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
26919
- if (hookLine !== void 0) writeLine(io.stdout, hookLine);
26920
- return 0;
26921
- }
26922
-
26923
- // src/commands/init.ts
26924
- var CODEX_LINE = "For Codex, run `quibble snippet --append AGENTS.md`, which names the agent codex.";
26925
- function agentLead(agent) {
26926
- return agent === DEFAULT_SNIPPET_AGENT ? "Add this to your CLAUDE.md:" : "Add this to your agent's instructions file:";
26927
- }
26928
- async function runInit(options, io) {
26929
- const directory = resolve3(io.cwd);
26930
- const path = join7(directory, PROJECT_CONFIG_FILENAME);
26931
- if (existsSync2(path) && options.force !== true) {
26932
- throw new ConfigError(`${path} already exists: pass --force to overwrite it`, path);
26933
- }
26934
- const skillPath = options.skill === true ? join7(directory, SKILL_PATH) : void 0;
26935
- if (skillPath !== void 0 && existsSync2(skillPath) && options.force !== true) {
26936
- throw existsError(skillPath);
26937
- }
26938
- const project = resolveProjectName(options.project, directory);
26939
- const org = options.org === void 0 || options.org.trim() === "" ? void 0 : parseOrgSlug(options.org);
26940
- const tags = options.tag === void 0 ? void 0 : parseAllowedTags(options.tag);
26941
- const agent = options.agent === void 0 ? DEFAULT_SNIPPET_AGENT : parseAgentName(options.agent);
26942
- const hook = options.hook === true ? planHook(directory) : void 0;
26943
- const endpoint = await endpointToWrite(options.endpoint, io);
26944
- const config2 = {
26945
- project,
26946
- ...org === void 0 ? {} : { org },
26947
- ...endpoint === void 0 ? {} : { endpoint },
26948
- ...tags === void 0 ? {} : { tags }
26949
- };
26950
- writeFileSync5(path, `${JSON.stringify(config2, null, 2)}
26951
- `, "utf8");
26952
- const context = {
26953
- ...resolveContext({
26954
- cwd: io.cwd,
26955
- env: io.env,
26956
- endpoint: options.endpoint,
26957
- org: options.org,
26958
- local: options.local
26959
- }),
26960
- // `init` acts as nobody, so it ignores `QUIBBLE_ACT_AS`.
26961
- actAs: void 0
26962
- };
26963
- const snippet = agentSnippet(tags, {
26964
- agent,
26965
- ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
26966
- ...org === void 0 ? {} : { org }
26967
- });
26968
- if (skillPath !== void 0) writeSkill(skillPath, snippet);
26969
- const hookLine = hook === void 0 ? void 0 : applyHook(hook);
26970
- const check2 = await checkServer(context, project, io);
26971
- if (options.json === true) {
26972
- writeJson(io.stdout, {
26973
- path,
26974
- project,
26975
- org: org ?? null,
26976
- tags: tags ?? null,
26977
- skill: skillPath ?? null,
26978
- hook: hook?.path ?? null,
26979
- server: check2.server
27121
+ /** Waits for the next line, which a paste may have delivered already. */
27122
+ #next() {
27123
+ const queued = this.#lines.shift();
27124
+ if (queued !== void 0) return Promise.resolve(queued);
27125
+ if (this.#closed) return Promise.resolve("");
27126
+ return new Promise((resolve4) => {
27127
+ this.#waiting = resolve4;
26980
27128
  });
26981
- return 0;
26982
27129
  }
26983
- writeLine(io.stdout, `Wrote ${path}`);
26984
- if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
26985
- if (hookLine !== void 0) writeLine(io.stdout, hookLine);
26986
- writeLine(io.stdout, `Project: ${project}`);
26987
- if (org !== void 0) writeLine(io.stdout, `Organization: ${org}`);
26988
- if (tags !== void 0) writeLine(io.stdout, `Tags: ${tags.join(", ")}`);
26989
- writeLine(io.stdout);
26990
- writeLine(io.stdout, agentLead(agent));
26991
- writeLine(io.stdout);
26992
- writeLine(io.stdout, snippet);
26993
- if (agent === DEFAULT_SNIPPET_AGENT) {
26994
- writeLine(io.stdout);
26995
- writeLine(io.stdout, CODEX_LINE);
26996
- }
26997
- if (check2.line !== void 0) {
26998
- writeLine(io.stdout);
26999
- writeLine(io.stdout, check2.line);
27000
- }
27001
- return 0;
27002
- }
27003
- async function endpointToWrite(given, io) {
27004
- if (given === void 0 || given.trim() === "") return void 0;
27005
- const value = parseEndpoint(given, "--endpoint");
27006
- if (value.kind === "local") return LOCAL_ENDPOINT;
27007
- const api = await discoverApiUrl(value.url, {
27008
- fetch: io.fetch,
27009
- debug: debugEnabled(io.env)
27010
- });
27011
- return isDefaultEndpoint(api) ? void 0 : api;
27012
- }
27013
- async function checkServer(context, project, io) {
27014
- const org = context.org;
27015
- if (context.mode !== "remote" || org === void 0) return { server: "unchecked" };
27016
- const endpoint = await resolveEndpoint(context, io);
27017
- if (context.token === void 0 && context.credential === void 0 && !isLoopbackUrl(endpoint)) {
27018
- return { server: "unchecked" };
27019
- }
27020
- const warn = (text) => {
27021
- writeLine(io.stderr, `quibble: warning: ${text}`);
27022
- };
27023
- try {
27024
- await (await openClient(context, io, { queue: false })).project(project);
27025
- return { server: "found", line: `Project ${org}/${project} exists on ${endpoint}.` };
27026
- } catch (error62) {
27027
- if (error62 instanceof RemoteError && error62.status === 404) {
27028
- if (error62.serverCode === ORG_NOT_FOUND) {
27029
- warn(`no organization ${org} on ${endpoint} for this account`);
27030
- return { server: "no_org" };
27130
+ async #read(label, hidden) {
27131
+ if (this.#closed) return "";
27132
+ const rl = this.#interface();
27133
+ if (hidden) {
27134
+ this.#output.write(`${label} `);
27135
+ rl.setPrompt("");
27136
+ rl.prompt();
27137
+ try {
27138
+ return await this.#next();
27139
+ } finally {
27140
+ this.#output.write("\n");
27031
27141
  }
27032
- warn(
27033
- `${org} has no project ${project} on ${endpoint}: an admin creates it in the organization settings`
27034
- );
27035
- return { server: "no_project" };
27036
27142
  }
27037
- warn(`could not check the project on ${endpoint}: ${describeError(error62)}`);
27038
- return { server: "unreachable" };
27039
- }
27040
- }
27041
- function resolveProjectName(given, directory) {
27042
- const candidate = given?.trim();
27043
- if (candidate !== void 0 && candidate !== "") return parseProjectName(candidate);
27044
- const name = basename2(directory);
27045
- try {
27046
- return parseProjectName(name);
27047
- } catch (error62) {
27048
- throw new ConfigError(
27049
- `cannot use the directory name "${name}" as a project name: pass --project <name>`,
27050
- void 0,
27051
- { cause: error62 }
27052
- );
27143
+ const queued = this.#lines.shift();
27144
+ if (queued !== void 0) {
27145
+ this.#output.write(`${label} ${queued}
27146
+ `);
27147
+ return queued;
27148
+ }
27149
+ rl.setPrompt(`${label} `);
27150
+ this.#muted = false;
27151
+ rl.prompt();
27152
+ try {
27153
+ return await this.#next();
27154
+ } finally {
27155
+ this.#muted = true;
27156
+ }
27053
27157
  }
27158
+ };
27159
+ function nodeTerminal(input2, output2) {
27160
+ return new NodeTerminal(input2, output2);
27054
27161
  }
27055
27162
 
27056
- // src/mine.ts
27057
- function mineMessage(env) {
27058
- return `--mine needs a reporter: set "reporter" in ${defaultConfigPath(env)}`;
27163
+ // src/commands/login.ts
27164
+ var LOCAL_IDENTITY_MESSAGE = "local mode: no identity.";
27165
+ var NEEDS_TERMINAL_MESSAGE = "quibble login --password needs a terminal: in CI, set QUIBBLE_TOKEN instead";
27166
+ var CI_MESSAGE = "quibble login needs a person to approve it in a browser: in CI, set QUIBBLE_TOKEN instead";
27167
+ var NO_BROWSER_MESSAGE = "--no-browser applies to the browser login only";
27168
+ var DENIED_MESSAGE = "login was denied in the browser";
27169
+ var EXPIRED_MESSAGE = "the login request expired: run quibble login again";
27170
+ var MALFORMED_MESSAGE = "the API answered a malformed login request";
27171
+ var UNKNOWN_STATUS_MESSAGE = "the API answered an unknown login status";
27172
+ var MFA_NEEDS_TERMINAL_MESSAGE = "this account needs a two-factor code: run quibble login in a terminal";
27173
+ var MFA_CHALLENGE = "SOFTWARE_TOKEN_MFA";
27174
+ var CUSTOM_CHALLENGE = "CUSTOM_CHALLENGE";
27175
+ var NEW_PASSWORD_CHALLENGE = "NEW_PASSWORD_REQUIRED";
27176
+ var CLI_LOGINS_PATH = "/v1/cli-logins";
27177
+ var CLI_LOGIN_POLL_PATH = "/v1/cli-logins/poll";
27178
+ var MIN_INTERVAL_SECONDS = 1;
27179
+ var MAX_INTERVAL_SECONDS = 30;
27180
+ var PLAIN_HOST_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::[0-9]+)?$/;
27181
+ var LOGIN_PAGE = "/login";
27182
+ var ORGS_PAGE = "/orgs";
27183
+ var NO_PROFILE_STATUSES = /* @__PURE__ */ new Set([404, 409]);
27184
+ function localModeLine(endpoint) {
27185
+ return `${endpoint} is in local mode: no login needed.`;
27059
27186
  }
27060
- async function resolveMine(context, client, env) {
27061
- if (client === void 0) {
27062
- if (context.reporter === DEFAULT_REPORTER) throw new UsageError(mineMessage(env));
27063
- return context.reporter;
27064
- }
27065
- return (await client.me()).reporter;
27187
+ function terminalOf(io) {
27188
+ return io.terminal ?? nodeTerminal(io.stdin, io.stderr);
27066
27189
  }
27067
-
27068
- // src/since.ts
27069
- var SINCE_MESSAGE = "--since must be a duration like 7d, a date like 2026-09-01, or an ISO 8601 UTC timestamp";
27070
- var DURATION_RE = /^(\d+)(m|h|d|w)$/;
27071
- var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
27072
- var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
27073
- var MINUTE_MS = 6e4;
27074
- var HOUR_MS = 60 * MINUTE_MS;
27075
- var DAY_MS3 = 24 * HOUR_MS;
27076
- var UNIT_MS = {
27077
- m: MINUTE_MS,
27078
- h: HOUR_MS,
27079
- d: DAY_MS3,
27080
- w: 7 * DAY_MS3
27081
- };
27082
- var EXPIRES_MESSAGE = "--expires must be a duration like 30d, a date like 2026-12-01, or an ISO 8601 UTC timestamp";
27083
- function reject(message = SINCE_MESSAGE) {
27084
- throw new UsageError(message);
27190
+ function clockOf2(io) {
27191
+ return io.now ?? (() => /* @__PURE__ */ new Date());
27085
27192
  }
27086
- function iso(at) {
27087
- if (Number.isNaN(at.getTime())) reject();
27088
- return at.toISOString();
27193
+ function sleepOf(io) {
27194
+ return io.sleep ?? ((ms) => new Promise((resolve4) => {
27195
+ setTimeout(resolve4, ms);
27196
+ }));
27089
27197
  }
27090
- function durationMs(digits, unit) {
27091
- if (digits.startsWith("0")) return void 0;
27092
- const step = UNIT_MS[unit];
27093
- return step === void 0 ? void 0 : Number(digits) * step;
27198
+ function ciIsSet(env) {
27199
+ const value = (env["CI"] ?? "").trim().toLowerCase();
27200
+ return value !== "" && value !== "0" && value !== "false";
27094
27201
  }
27095
- function fromDuration(digits, unit, now) {
27096
- const span = durationMs(digits, unit);
27097
- if (span === void 0) reject();
27098
- return iso(new Date(now.getTime() - span));
27202
+ async function answer(terminal, label, hidden = false) {
27203
+ const text = hidden ? await terminal.promptHidden(label) : await terminal.prompt(label);
27204
+ const value = hidden ? text : text.trim();
27205
+ if (value === "")
27206
+ throw new UsageError(`${label.replace(/:$/, "").toLowerCase()} must not be empty`);
27207
+ return value;
27099
27208
  }
27100
- function fromDate(value) {
27101
- const at = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
27102
- const stamp2 = iso(at);
27103
- if (!stamp2.startsWith(value)) reject();
27104
- return stamp2;
27209
+ function unsupportedStep(name) {
27210
+ return new RemoteError(
27211
+ `this account needs a sign-in step that quibble doesn't support (${name})`
27212
+ );
27105
27213
  }
27106
- function parseSince(text, now) {
27107
- const value = text.trim();
27108
- const duration3 = DURATION_RE.exec(value);
27109
- if (duration3 !== null) {
27110
- return fromDuration(duration3[1] ?? "", duration3[2] ?? "", now);
27214
+ function pageError(error62, webUrl) {
27215
+ if (error62 instanceof CognitoError) {
27216
+ const page = `${trimSlash2(webUrl)}${LOGIN_PAGE}`;
27217
+ if (error62.type === "PasswordResetRequiredException") {
27218
+ return new ConfigError(`reset your password at ${page}`);
27219
+ }
27220
+ if (error62.type === "UserNotConfirmedException") {
27221
+ return new ConfigError(`finish your sign-up at ${page}`);
27222
+ }
27111
27223
  }
27112
- if (DATE_RE.test(value)) return fromDate(value);
27113
- if (TIMESTAMP_RE.test(value)) return iso(new Date(value));
27114
- return reject();
27224
+ return error62;
27115
27225
  }
27116
- function parseExpires(text, now) {
27117
- const value = text.trim();
27118
- const duration3 = DURATION_RE.exec(value);
27119
- let stamp2;
27226
+ async function signIn(terminal, identity, config2) {
27227
+ const email3 = await answer(terminal, "Email:");
27228
+ const password = await answer(terminal, "Password:", true);
27229
+ const clientId = config2.cliClientId;
27120
27230
  try {
27121
- if (duration3 !== null) {
27122
- const span = durationMs(duration3[1] ?? "", duration3[2] ?? "");
27123
- if (span === void 0) reject(EXPIRES_MESSAGE);
27124
- stamp2 = iso(new Date(now.getTime() + span));
27125
- } else if (DATE_RE.test(value)) {
27126
- stamp2 = fromDate(value);
27127
- } else if (TIMESTAMP_RE.test(value)) {
27128
- stamp2 = iso(new Date(value));
27129
- } else {
27130
- return reject(EXPIRES_MESSAGE);
27231
+ let result = await initiateAuth(identity, {
27232
+ clientId,
27233
+ flow: "USER_PASSWORD_AUTH",
27234
+ username: email3,
27235
+ password
27236
+ });
27237
+ if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
27238
+ const code2 = await answer(terminal, "Code:");
27239
+ result = await respondToAuthChallenge(identity, {
27240
+ clientId,
27241
+ challenge: result.challenge,
27242
+ email: email3,
27243
+ code: code2
27244
+ });
27131
27245
  }
27132
- } catch {
27133
- return reject(EXPIRES_MESSAGE);
27134
- }
27135
- return stamp2;
27136
- }
27137
-
27138
- // src/scope.ts
27139
- function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
27140
- if (options.project !== void 0 && options.all === true) {
27141
- throw new UsageError("--project and --all cannot be used together");
27142
- }
27143
- if (options.open === true && options.status !== void 0) {
27144
- throw new UsageError("--open and --status cannot be used together");
27246
+ if (result.kind === "challenge") {
27247
+ const name = result.challenge.challengeName;
27248
+ if (name === NEW_PASSWORD_CHALLENGE) {
27249
+ throw new ConfigError(
27250
+ `finish your first sign-in at ${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`
27251
+ );
27252
+ }
27253
+ throw unsupportedStep(name);
27254
+ }
27255
+ return result.tokens;
27256
+ } catch (error62) {
27257
+ throw pageError(error62, config2.webUrl);
27145
27258
  }
27146
- const project = options.project ?? (options.all === true ? void 0 : context.project);
27147
- const status = options.open === true ? "open" : options.status;
27148
- return {
27149
- // No list reads two organizations, because the path names one.
27150
- org: requireOrg(context),
27151
- ...project === void 0 ? {} : { project },
27152
- ...status === void 0 ? {} : { status },
27153
- ...options.since === void 0 ? {} : { since: parseSince(options.since, now) }
27154
- };
27155
- }
27156
- function tagFilter(tags) {
27157
- return tags === void 0 || tags.length === 0 ? {} : { tags: [...tags] };
27158
- }
27159
-
27160
- // src/commands/list.ts
27161
- var COLUMNS = [
27162
- "id",
27163
- "created",
27164
- "severity",
27165
- "status",
27166
- "project",
27167
- "reporter",
27168
- "tags",
27169
- "group",
27170
- "summary"
27171
- ];
27172
- var REPORTER_WIDTH = 20;
27173
- var TAGS_WIDTH = 24;
27174
- var SUMMARY_MAX = 60;
27175
- var RECURRED_MARK = "\u21BB";
27176
- function cut3(text, width) {
27177
- if (text.length <= width) return text;
27178
- return `${text.slice(0, width - 3)}...`;
27179
27259
  }
27180
- function summaryOf(complaint) {
27181
- return cut3(printable(summarize(complaint.message)), SUMMARY_MAX);
27182
- }
27183
- function limitNote(count, limit, more) {
27184
- if (limit >= MAX_LIMIT) {
27185
- return count >= MAX_LIMIT ? `Showing the newest ${MAX_LIMIT} complaints: older ones may exist, narrow the filters to see them.` : void 0;
27260
+ async function runLogin(options, context, io) {
27261
+ requireServer("login", context);
27262
+ if (options.password === true) {
27263
+ if (options.browser === false) throw new UsageError(NO_BROWSER_MESSAGE);
27264
+ return passwordLogin(options, context, io);
27186
27265
  }
27187
- return more ? `Showing the newest ${limit} complaints: pass --limit to see more (up to ${MAX_LIMIT}).` : void 0;
27266
+ return browserLogin(options, context, io);
27188
27267
  }
27189
- var BUDGET_NOTE = "The server stopped searching early, so older matches may exist: narrow the filters or pass --since.";
27190
- async function runList(options, context, io) {
27191
- if (options.ids === true && options.json === true) {
27192
- throw new UsageError("--ids and --json cannot be used together");
27193
- }
27194
- const { store, client } = await openBackend(context, io);
27195
- const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
27196
- const filter = parseListFilter({
27197
- ...resolveScope(options, context),
27198
- ...options.severity === void 0 ? {} : { severity: options.severity },
27199
- ...tagFilter(options.tag),
27200
- ...options.search === void 0 ? {} : { q: options.search },
27201
- ...reporter === void 0 ? {} : { reporter },
27202
- ...options.limit === void 0 ? {} : { limit: options.limit }
27203
- });
27204
- const limit = filter.limit ?? DEFAULT_LIMIT;
27205
- const page = await listComplaintPage(store, {
27206
- ...filter,
27207
- limit: Math.min(limit + 1, MAX_LIMIT)
27208
- });
27209
- const complaints = page.complaints.slice(0, limit);
27210
- if (options.json === true) {
27211
- writeJson(io.stdout, complaints);
27268
+ async function passwordLogin(options, context, io) {
27269
+ const terminal = terminalOf(io);
27270
+ if (!terminal.isTTY) throw new UsageError(NEEDS_TERMINAL_MESSAGE);
27271
+ const endpoint = await resolveEndpoint(context, io);
27272
+ const http = { debug: context.debug, fetch: io.fetch };
27273
+ const config2 = await fetchAuthConfig(endpoint, http);
27274
+ if (config2.mode === "local") {
27275
+ writeLine(io.stdout, localModeLine(endpoint));
27212
27276
  return 0;
27213
27277
  }
27214
- const note = limitNote(complaints.length, limit, page.complaints.length > limit);
27215
- if (note !== void 0) writeLine(io.stderr, note);
27216
- if (page.truncated) writeLine(io.stderr, BUDGET_NOTE);
27217
- if (options.ids === true) {
27218
- for (const complaint of complaints) writeLine(io.stdout, complaint.id);
27219
- return 0;
27278
+ let tokens;
27279
+ try {
27280
+ tokens = await signIn(terminal, { region: config2.region, ...http }, config2);
27281
+ } finally {
27282
+ terminal.close();
27220
27283
  }
27221
- if (complaints.length === 0) {
27222
- writeLine(io.stdout, "No complaints found.");
27284
+ const me = await storeSession(tokens, endpoint, config2, http, io);
27285
+ return reportLogin(me, endpoint, options, context, io);
27286
+ }
27287
+ async function browserLogin(options, context, io) {
27288
+ if (ciIsSet(io.env)) throw new UsageError(CI_MESSAGE);
27289
+ const endpoint = await resolveEndpoint(context, io);
27290
+ const session = await browserSession(endpoint, options, context.debug, io);
27291
+ if (session === void 0) {
27292
+ writeLine(io.stdout, localModeLine(endpoint));
27223
27293
  return 0;
27224
27294
  }
27225
- const lines = options.ungroup === true ? formatTable(complaints.map((complaint) => ({ complaint }))) : formatTable(
27226
- groupComplaints(complaints).map((group) => ({
27227
- complaint: group.head,
27228
- group
27229
- }))
27230
- );
27231
- for (const line of lines) writeLine(io.stdout, line);
27232
- return 0;
27295
+ return reportLogin(session.me, endpoint, options, context, io);
27233
27296
  }
27234
- function reporterOf(complaint) {
27235
- return cut3(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
27236
- }
27237
- function groupCell(group) {
27238
- const count = group.count > 1 ? `\xD7${group.count}` : "";
27239
- return `${count}${group.recurred ? RECURRED_MARK : ""}`;
27240
- }
27241
- function formatTable(rows2) {
27242
- const grouped = rows2.some((row) => row.group !== void 0);
27243
- const columns = COLUMNS.filter((name) => grouped || name !== "group");
27244
- const cells = rows2.map(({ complaint, group }) => {
27245
- const values = {
27246
- id: complaint.id.slice(0, 8),
27247
- created: complaint.createdAt.slice(0, 16),
27248
- severity: complaint.severity,
27249
- status: complaint.status,
27250
- project: complaint.project,
27251
- reporter: reporterOf(complaint),
27252
- tags: cut3(printable(complaint.tags.join(",")), TAGS_WIDTH),
27253
- group: group === void 0 ? "" : groupCell(group),
27254
- summary: summaryOf(complaint)
27255
- };
27256
- return columns.map((name) => values[name]);
27297
+ async function browserSession(endpoint, options, debug, io) {
27298
+ const http = { debug, fetch: io.fetch };
27299
+ const config2 = await fetchAuthConfig(endpoint, http);
27300
+ if (config2.mode === "local") return void 0;
27301
+ const clock = clockOf2(io);
27302
+ const started = await startLogin(endpoint, config2.webUrl, http, {
27303
+ host: cliLoginHost((io.hostname ?? hostname4)()),
27304
+ os: process.platform,
27305
+ version: options.version
27257
27306
  });
27258
- const widths = columns.map(
27259
- (name, column) => cells.reduce(
27260
- (width, row) => Math.max(width, (row[column] ?? "").length),
27261
- name.length
27262
- )
27307
+ const deadline = clock().getTime() + (CLI_LOGIN_TTL_SECONDS + started.interval) * 1e3;
27308
+ writeLine(
27309
+ io.stderr,
27310
+ `Approve this login in your browser: ${started.url} (code ${started.code}). Waiting...`
27263
27311
  );
27264
- const format = (line) => line.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
27265
- return [format(columns), ...cells.map(format)];
27266
- }
27267
-
27268
- // src/commands/orgs.ts
27269
- var ORGS_PAGE = "/orgs";
27270
- var COLUMNS2 = ["SLUG", "NAME", "ROLE", "OPEN"];
27271
- async function runOrgs(options, context, io) {
27272
- requireServer("orgs", context);
27273
- const client = await openClient(context, io);
27274
- const endpoint = requireEndpoint(context);
27275
- const answer2 = await client.orgs();
27276
- if (options.json === true) {
27277
- writeJson(io.stdout, answer2.body);
27278
- return 0;
27279
- }
27280
- if (answer2.orgs.length === 0) {
27281
- const config2 = await fetchAuthConfig(endpoint, {
27282
- debug: context.debug,
27283
- fetch: io.fetch,
27284
- timeoutMs: context.timeoutMs
27285
- });
27286
- const webUrl = config2.mode === "cognito" ? trimSlash2(config2.webUrl) : endpoint;
27287
- writeLine(io.stdout, `No organizations yet: create one at ${webUrl}${ORGS_PAGE}.`);
27288
- return 0;
27312
+ if (options.browser !== false) {
27313
+ const opener = io.opener ?? platformOpener({ env: io.env });
27314
+ void opener.open(started.url).catch(() => false);
27289
27315
  }
27290
- for (const line of formatOrgs(answer2.orgs)) writeLine(io.stdout, line);
27291
- return 0;
27292
- }
27293
- function formatOrgs(orgs) {
27294
- const rows2 = [...orgs].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((entry) => [
27295
- entry.org,
27296
- entry.name,
27297
- entry.role,
27298
- entry.openCount === void 0 ? "" : String(entry.openCount)
27299
- ]);
27300
- const widths = COLUMNS2.map(
27301
- (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
27316
+ const approval = await waitForApproval(endpoint, started, deadline, http, io);
27317
+ const tokens = await browserSignIn(
27318
+ { region: config2.region, ...http },
27319
+ config2,
27320
+ approval,
27321
+ io
27302
27322
  );
27303
- const format = (cells) => cells.map(
27304
- (cell2, column) => column === COLUMNS2.length - 1 ? cell2.padStart(widths[column] ?? 0) : cell2.padEnd(widths[column] ?? 0)
27305
- ).join(" ").trimEnd();
27306
- return [format(COLUMNS2), ...rows2.map(format)];
27323
+ const me = await storeSession(tokens, endpoint, config2, http, io);
27324
+ return { me, webUrl: config2.webUrl };
27307
27325
  }
27308
-
27309
- // src/commands/login.ts
27310
- import { hostname as hostname4 } from "node:os";
27311
-
27312
- // src/opener.ts
27313
- import { spawn as nodeSpawn } from "node:child_process";
27314
- var OPEN_TIMEOUT_MS = 5e3;
27315
- var OPENABLE_URL_RE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9/?=._~-]*)?$/;
27316
- function isWsl(platform, env) {
27317
- if (platform !== "linux") return false;
27318
- const set2 = (name) => (env[name] ?? "") !== "";
27319
- return set2("WSL_DISTRO_NAME") || set2("WSL_INTEROP");
27326
+ function postJson(url2, body, http) {
27327
+ return send(url2, {
27328
+ method: "POST",
27329
+ headers: { "content-type": "application/json" },
27330
+ body: JSON.stringify(body),
27331
+ ...http
27332
+ });
27320
27333
  }
27321
- function openCommands(url2, platform, env) {
27322
- if (platform === "darwin") return [{ command: "open", args: [url2] }];
27323
- if (platform === "win32") {
27324
- return [{ command: "cmd", args: ["/c", "start", "", url2] }];
27325
- }
27326
- if (isWsl(platform, env)) {
27327
- return [
27328
- { command: "wslview", args: [url2] },
27329
- { command: "cmd.exe", args: ["/c", "start", "", url2] }
27330
- ];
27334
+ async function startLogin(endpoint, webUrl, http, body) {
27335
+ const url2 = `${trimSlash2(endpoint)}${CLI_LOGINS_PATH}`;
27336
+ const site = { method: "POST", url: url2, debug: http.debug };
27337
+ const response = await postJson(url2, body, http);
27338
+ const text = await readText(response, site);
27339
+ if (response.status === 404) {
27340
+ throw new RemoteError(
27341
+ `${endpoint} does not support the browser login: run quibble login --password${detail(site, 404)}`,
27342
+ { status: 404 }
27343
+ );
27331
27344
  }
27332
- return [{ command: "xdg-open", args: [url2] }];
27345
+ if (!response.ok) throw remoteFailure(site, response, text);
27346
+ const started = checkStart(asRecord(parseQuietly(text)), webUrl);
27347
+ if (started === void 0) {
27348
+ throw new RemoteError(`${MALFORMED_MESSAGE}${detail(site, response.status)}`);
27349
+ }
27350
+ return started;
27333
27351
  }
27334
- function attempt(spawn, entry, timeoutMs) {
27335
- return new Promise((resolve4) => {
27336
- let settled = false;
27337
- const settle2 = (opened) => {
27338
- if (settled) return;
27339
- settled = true;
27340
- clearTimeout(timer);
27341
- resolve4(opened);
27342
- };
27343
- const timer = setTimeout(() => {
27344
- settle2(false);
27345
- }, timeoutMs);
27346
- timer.unref();
27347
- let child;
27348
- try {
27349
- child = spawn(entry.command, entry.args, {
27350
- shell: false,
27351
- detached: true,
27352
- stdio: "ignore",
27353
- windowsHide: true
27354
- });
27355
- } catch {
27356
- settle2(false);
27357
- return;
27358
- }
27359
- child.on("error", () => {
27360
- settle2(false);
27361
- });
27362
- child.on("exit", (code2) => {
27363
- settle2(code2 === 0);
27364
- });
27365
- child.unref();
27366
- });
27352
+ function checkStart(body, webUrl) {
27353
+ const loginKey = body?.["loginKey"];
27354
+ const code2 = body?.["code"];
27355
+ const url2 = body?.["url"];
27356
+ if (typeof loginKey !== "string" || !CLI_LOGIN_KEY_RE.test(loginKey)) return void 0;
27357
+ if (typeof code2 !== "string" || typeof url2 !== "string") return void 0;
27358
+ try {
27359
+ if (parseCliLoginCode(code2) !== code2) return void 0;
27360
+ } catch {
27361
+ return void 0;
27362
+ }
27363
+ let origin;
27364
+ try {
27365
+ const web = new URL(webUrl);
27366
+ const secure = web.protocol === "https:" || web.protocol === "http:" && isLoopbackUrl(web.origin);
27367
+ if (!secure || !PLAIN_HOST_RE.test(web.host)) return void 0;
27368
+ origin = web.origin;
27369
+ } catch {
27370
+ return void 0;
27371
+ }
27372
+ if (url2 !== cliLoginUrl(origin, code2)) return void 0;
27373
+ const raw = body?.["interval"];
27374
+ const interval = typeof raw === "number" && Number.isInteger(raw) ? Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, raw)) : CLI_LOGIN_POLL_SECONDS;
27375
+ return { loginKey, code: code2, url: url2, interval };
27367
27376
  }
27368
- function platformOpener(options = {}) {
27369
- const platform = options.platform ?? process.platform;
27370
- const env = options.env ?? process.env;
27371
- const spawn = options.spawn ?? nodeSpawn;
27372
- const timeoutMs = options.timeoutMs ?? OPEN_TIMEOUT_MS;
27373
- return {
27374
- async open(url2) {
27375
- if (!OPENABLE_URL_RE.test(url2)) return false;
27376
- for (const entry of openCommands(url2, platform, env)) {
27377
- if (await attempt(spawn, entry, timeoutMs)) return true;
27377
+ async function waitForApproval(endpoint, started, deadline, http, io) {
27378
+ const clock = clockOf2(io);
27379
+ const sleep = sleepOf(io);
27380
+ for (; ; ) {
27381
+ await sleep(started.interval * 1e3);
27382
+ if (clock().getTime() > deadline) throw new ConfigError(EXPIRED_MESSAGE);
27383
+ const answer2 = await poll(endpoint, started.loginKey, http);
27384
+ if (answer2 === void 0) continue;
27385
+ const status = answer2["status"];
27386
+ switch (status) {
27387
+ case "pending":
27388
+ continue;
27389
+ case "denied":
27390
+ throw new ConfigError(DENIED_MESSAGE);
27391
+ case "expired":
27392
+ throw new ConfigError(EXPIRED_MESSAGE);
27393
+ case "approved": {
27394
+ const username = answer2["username"];
27395
+ const secret = answer2["secret"];
27396
+ if (typeof username !== "string" || username === "") {
27397
+ throw new RemoteError(MALFORMED_MESSAGE);
27398
+ }
27399
+ if (typeof secret !== "string" || secret === "") {
27400
+ throw new RemoteError(MALFORMED_MESSAGE);
27401
+ }
27402
+ return { username, secret };
27378
27403
  }
27379
- return false;
27404
+ default:
27405
+ throw new RemoteError(UNKNOWN_STATUS_MESSAGE);
27380
27406
  }
27381
- };
27382
- }
27383
-
27384
- // src/terminal.ts
27385
- import { createInterface } from "node:readline";
27386
- import { Writable } from "node:stream";
27387
- var NodeTerminal = class {
27388
- isTTY;
27389
- #input;
27390
- #output;
27391
- #rl;
27392
- /**
27393
- * True unless a visible prompt is waiting, so a line that arrives early,
27394
- * a pasted password after the email for one, echoes nowhere.
27395
- */
27396
- #muted = true;
27397
- /** Lines that arrived before a prompt asked for them. */
27398
- #lines = [];
27399
- #waiting;
27400
- #closed = false;
27401
- constructor(input2, output2) {
27402
- this.#input = input2;
27403
- this.#output = output2;
27404
- this.isTTY = input2.isTTY === true;
27405
27407
  }
27406
- prompt(label) {
27407
- return this.#read(label, false);
27408
- }
27409
- promptHidden(label) {
27410
- return this.#read(label, true);
27408
+ }
27409
+ async function poll(endpoint, loginKey, http) {
27410
+ const url2 = `${trimSlash2(endpoint)}${CLI_LOGIN_POLL_PATH}`;
27411
+ const site = { method: "POST", url: url2, debug: http.debug };
27412
+ let response;
27413
+ let text;
27414
+ try {
27415
+ response = await postJson(url2, { loginKey }, http);
27416
+ text = await readText(response, site);
27417
+ } catch (error62) {
27418
+ if (error62 instanceof RemoteError && error62.unanswered) return void 0;
27419
+ throw error62;
27411
27420
  }
27412
- close() {
27413
- this.#closed = true;
27414
- this.#rl?.close();
27415
- this.#rl = void 0;
27421
+ if (!response.ok) {
27422
+ if (response.status >= 500 || response.status === 429) return void 0;
27423
+ throw remoteFailure(site, response, text);
27416
27424
  }
27417
- /** The one interface, created on the first prompt. */
27418
- #interface() {
27419
- if (this.#rl !== void 0) return this.#rl;
27420
- const output2 = this.#output;
27421
- const sink2 = new Writable({
27422
- write: (chunk, _encoding, done) => {
27423
- if (!this.#muted) output2.write(chunk);
27424
- done();
27425
- }
27426
- });
27427
- Object.defineProperties(sink2, {
27428
- columns: { get: () => output2.columns },
27429
- rows: { get: () => output2.rows }
27430
- });
27431
- const rl = createInterface({
27432
- input: this.#input,
27433
- output: sink2,
27434
- terminal: true,
27435
- // No answer enters the history, so an arrow key recalls no password.
27436
- historySize: 0
27437
- });
27438
- rl.on("line", (line) => {
27439
- this.#muted = true;
27440
- if (this.#waiting !== void 0) {
27441
- const settle2 = this.#waiting;
27442
- this.#waiting = void 0;
27443
- settle2(line);
27444
- return;
27445
- }
27446
- this.#lines.push(line);
27447
- });
27448
- rl.on("close", () => {
27449
- this.#closed = true;
27450
- this.#waiting?.("");
27451
- this.#waiting = void 0;
27452
- });
27453
- rl.on("SIGINT", () => {
27454
- this.close();
27455
- this.#output.write("\n");
27456
- process.kill(process.pid, "SIGINT");
27425
+ return asRecord(parseJson(text, site, response.status)) ?? {};
27426
+ }
27427
+ async function browserSignIn(identity, config2, approval, io) {
27428
+ const clientId = config2.cliClientId;
27429
+ try {
27430
+ let result = await initiateAuth(identity, {
27431
+ clientId,
27432
+ flow: "CUSTOM_AUTH",
27433
+ username: approval.username
27457
27434
  });
27458
- this.#rl = rl;
27459
- return rl;
27435
+ if (result.kind === "challenge") {
27436
+ const name = result.challenge.challengeName;
27437
+ if (name !== CUSTOM_CHALLENGE) throw unsupportedStep(name);
27438
+ result = await respondToCustomChallenge(identity, {
27439
+ clientId,
27440
+ challenge: result.challenge,
27441
+ username: approval.username,
27442
+ answer: approval.secret
27443
+ });
27444
+ }
27445
+ if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
27446
+ result = await answerCode(
27447
+ identity,
27448
+ clientId,
27449
+ result.challenge,
27450
+ approval.username,
27451
+ io
27452
+ );
27453
+ }
27454
+ if (result.kind === "challenge") {
27455
+ throw unsupportedStep(result.challenge.challengeName);
27456
+ }
27457
+ return result.tokens;
27458
+ } catch (error62) {
27459
+ throw pageError(error62, config2.webUrl);
27460
27460
  }
27461
- /** Waits for the next line, which a paste may have delivered already. */
27462
- #next() {
27463
- const queued = this.#lines.shift();
27464
- if (queued !== void 0) return Promise.resolve(queued);
27465
- if (this.#closed) return Promise.resolve("");
27466
- return new Promise((resolve4) => {
27467
- this.#waiting = resolve4;
27461
+ }
27462
+ async function answerCode(identity, clientId, challenge, username, io) {
27463
+ const terminal = terminalOf(io);
27464
+ try {
27465
+ if (!terminal.isTTY) throw new ConfigError(MFA_NEEDS_TERMINAL_MESSAGE);
27466
+ const code2 = await answer(terminal, "Code:");
27467
+ return await respondToAuthChallenge(identity, {
27468
+ clientId,
27469
+ challenge,
27470
+ email: username,
27471
+ code: code2
27468
27472
  });
27473
+ } finally {
27474
+ terminal.close();
27469
27475
  }
27470
- async #read(label, hidden) {
27471
- if (this.#closed) return "";
27472
- const rl = this.#interface();
27473
- if (hidden) {
27474
- this.#output.write(`${label} `);
27475
- rl.setPrompt("");
27476
- rl.prompt();
27477
- try {
27478
- return await this.#next();
27479
- } finally {
27480
- this.#output.write("\n");
27481
- }
27482
- }
27483
- const queued = this.#lines.shift();
27484
- if (queued !== void 0) {
27485
- this.#output.write(`${label} ${queued}
27486
- `);
27487
- return queued;
27488
- }
27489
- rl.setPrompt(`${label} `);
27490
- this.#muted = false;
27491
- rl.prompt();
27492
- try {
27493
- return await this.#next();
27494
- } finally {
27495
- this.#muted = true;
27476
+ }
27477
+ async function storeSession(tokens, endpoint, config2, http, io) {
27478
+ if (tokens.refreshToken === void 0) {
27479
+ throw new RemoteError("sign-in didn't return a refresh token");
27480
+ }
27481
+ const now = clockOf2(io)();
27482
+ const me = await readProfile(
27483
+ new ApiClient({
27484
+ endpoint,
27485
+ authorizer: bearerAuthorizer(tokens.accessToken),
27486
+ ...http
27487
+ }),
27488
+ config2.webUrl
27489
+ );
27490
+ if (me.sub === void 0 || me.email === void 0) {
27491
+ throw new RemoteError(
27492
+ "unexpected response from the API: identity has no sub or email"
27493
+ );
27494
+ }
27495
+ saveCredential(io.env, {
27496
+ endpoint,
27497
+ refreshToken: tokens.refreshToken,
27498
+ accessToken: tokens.accessToken,
27499
+ expiresAt: new Date(now.getTime() + tokens.expiresIn * 1e3).toISOString(),
27500
+ sub: me.sub,
27501
+ email: me.email
27502
+ });
27503
+ return me;
27504
+ }
27505
+ function reportLogin(me, endpoint, options, context, io) {
27506
+ const saved = saveEndpoint(context, endpoint, io.env);
27507
+ if (options.json === true) {
27508
+ writeJson(io.stdout, { ...me.body, endpoint, saved: saved ?? null });
27509
+ return 0;
27510
+ }
27511
+ writeLine(io.stdout, loggedInLine(endpoint, me));
27512
+ if (saved !== void 0) {
27513
+ writeLine(io.stdout, `Saved ${endpoint} as your endpoint in ${saved}.`);
27514
+ }
27515
+ return 0;
27516
+ }
27517
+ function loggedInLine(endpoint, me) {
27518
+ return `Logged in to ${endpoint} as ${me.email ?? me.reporter}.`;
27519
+ }
27520
+ function saveEndpoint(context, endpoint, env) {
27521
+ if (context.endpointSource !== "--endpoint" || isDefaultEndpoint(endpoint)) {
27522
+ return void 0;
27523
+ }
27524
+ return rememberEndpoint(env, endpoint);
27525
+ }
27526
+ async function readProfile(client, webUrl) {
27527
+ try {
27528
+ return await client.me();
27529
+ } catch (error62) {
27530
+ if (error62 instanceof RemoteError && NO_PROFILE_STATUSES.has(error62.status ?? 0)) {
27531
+ throw new ConfigError(`finish your account at ${trimSlash2(webUrl)}${LOGIN_PAGE}`);
27496
27532
  }
27533
+ throw error62;
27497
27534
  }
27498
- };
27499
- function nodeTerminal(input2, output2) {
27500
- return new NodeTerminal(input2, output2);
27501
27535
  }
27502
-
27503
- // src/commands/login.ts
27504
- var LOCAL_IDENTITY_MESSAGE = "local mode: no identity.";
27505
- var NEEDS_TERMINAL_MESSAGE = "quibble login --password needs a terminal: in CI, set QUIBBLE_TOKEN instead";
27506
- var CI_MESSAGE = "quibble login needs a person to approve it in a browser: in CI, set QUIBBLE_TOKEN instead";
27507
- var NO_BROWSER_MESSAGE = "--no-browser applies to the browser login only";
27508
- var DENIED_MESSAGE = "login was denied in the browser";
27509
- var EXPIRED_MESSAGE = "the login request expired: run quibble login again";
27510
- var MALFORMED_MESSAGE = "the API answered a malformed login request";
27511
- var UNKNOWN_STATUS_MESSAGE = "the API answered an unknown login status";
27512
- var MFA_NEEDS_TERMINAL_MESSAGE = "this account needs a two-factor code: run quibble login in a terminal";
27513
- var MFA_CHALLENGE = "SOFTWARE_TOKEN_MFA";
27514
- var CUSTOM_CHALLENGE = "CUSTOM_CHALLENGE";
27515
- var NEW_PASSWORD_CHALLENGE = "NEW_PASSWORD_REQUIRED";
27516
- var CLI_LOGINS_PATH = "/v1/cli-logins";
27517
- var CLI_LOGIN_POLL_PATH = "/v1/cli-logins/poll";
27518
- var MIN_INTERVAL_SECONDS = 1;
27519
- var MAX_INTERVAL_SECONDS = 30;
27520
- var PLAIN_HOST_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::[0-9]+)?$/;
27521
- var LOGIN_PAGE = "/login";
27522
- var ORGS_PAGE2 = "/orgs";
27523
- var NO_PROFILE_STATUSES = /* @__PURE__ */ new Set([404, 409]);
27524
- function terminalOf(io) {
27525
- return io.terminal ?? nodeTerminal(io.stdin, io.stderr);
27536
+ async function runLogout(context, io) {
27537
+ requireServer("logout", context);
27538
+ const endpoint = await resolveEndpoint(context, io);
27539
+ const credential = findCredential(io.env, endpoint);
27540
+ if (credential === void 0) {
27541
+ writeLine(io.stdout, `Not logged in to ${endpoint}.`);
27542
+ return 0;
27543
+ }
27544
+ const http = { debug: context.debug, fetch: io.fetch };
27545
+ if (!await revoke(endpoint, credential.refreshToken, http)) {
27546
+ writeLine(
27547
+ io.stderr,
27548
+ `quibble: warning: could not revoke the refresh token at ${endpoint}`
27549
+ );
27550
+ }
27551
+ removeCredential(io.env, endpoint);
27552
+ writeLine(io.stdout, `Logged out of ${endpoint}.`);
27553
+ return 0;
27526
27554
  }
27527
- function clockOf2(io) {
27528
- return io.now ?? (() => /* @__PURE__ */ new Date());
27555
+ async function revoke(endpoint, refreshToken, http) {
27556
+ try {
27557
+ const config2 = await fetchAuthConfig(endpoint, http);
27558
+ if (config2.mode === "local") return true;
27559
+ await revokeToken(
27560
+ { region: config2.region, ...http },
27561
+ { clientId: config2.cliClientId, token: refreshToken }
27562
+ );
27563
+ return true;
27564
+ } catch {
27565
+ return false;
27566
+ }
27529
27567
  }
27530
- function sleepOf(io) {
27531
- return io.sleep ?? ((ms) => new Promise((resolve4) => {
27532
- setTimeout(resolve4, ms);
27533
- }));
27568
+ async function runWhoami(options, context, io) {
27569
+ if (context.mode === "local") {
27570
+ checkActAs(context);
27571
+ writeLine(io.stdout, LOCAL_IDENTITY_MESSAGE);
27572
+ return 0;
27573
+ }
27574
+ const client = await openClient(context, io);
27575
+ const endpoint = requireEndpoint(context);
27576
+ const me = await client.me();
27577
+ const org = me.kind === "token" || client.org === void 0 ? void 0 : await client.organization();
27578
+ if (options.json === true) {
27579
+ writeJson(io.stdout, {
27580
+ ...me.body,
27581
+ endpoint,
27582
+ ...org === void 0 ? {} : { org: org.body }
27583
+ });
27584
+ return 0;
27585
+ }
27586
+ const lines = whoamiLines(endpoint, me, org);
27587
+ if (me.kind !== "token" && me.orgs.length === 0) {
27588
+ const config2 = await fetchAuthConfig(endpoint, {
27589
+ debug: context.debug,
27590
+ fetch: io.fetch
27591
+ });
27592
+ const webUrl = config2.mode === "cognito" ? trimSlash2(config2.webUrl) : endpoint;
27593
+ lines.push("", `Create one at ${webUrl}${ORGS_PAGE}.`);
27594
+ }
27595
+ for (const line of lines) writeLine(io.stdout, line);
27596
+ return 0;
27534
27597
  }
27535
- function ciIsSet(env) {
27536
- const value = (env["CI"] ?? "").trim().toLowerCase();
27537
- return value !== "" && value !== "0" && value !== "false";
27598
+ function rows(pairs) {
27599
+ const width = Math.max(...pairs.map(([key]) => key.length));
27600
+ return pairs.map(([key, value]) => `${key.padEnd(width)} ${value}`);
27538
27601
  }
27539
- async function answer(terminal, label, hidden = false) {
27540
- const text = hidden ? await terminal.promptHidden(label) : await terminal.prompt(label);
27541
- const value = hidden ? text : text.trim();
27542
- if (value === "")
27543
- throw new UsageError(`${label.replace(/:$/, "").toLowerCase()} must not be empty`);
27544
- return value;
27602
+ function block(entries) {
27603
+ if (entries.length === 0) return [" none"];
27604
+ const width = Math.max(...entries.map(([key]) => key.length));
27605
+ return entries.map(([key, value]) => ` ${key.padEnd(width)} ${value}`);
27545
27606
  }
27546
- function unsupportedStep(name) {
27547
- return new RemoteError(
27548
- `this account needs a sign-in step that quibble doesn't support (${name})`
27549
- );
27607
+ function whoamiLines(endpoint, me, org) {
27608
+ if (me.kind === "token") {
27609
+ return rows([
27610
+ ["endpoint", endpoint],
27611
+ ["token", me.name ?? me.reporter],
27612
+ ["org", me.org ?? ""],
27613
+ ["project", me.project ?? ""],
27614
+ ["role", me.role ?? "filer"]
27615
+ ]);
27616
+ }
27617
+ const lines = rows([
27618
+ ["endpoint", endpoint],
27619
+ ["email", me.email ?? ""],
27620
+ ["name", me.name ?? ""],
27621
+ ["sub", me.sub ?? ""],
27622
+ ["platform", me.platform ? "yes" : "no"]
27623
+ ]);
27624
+ lines.push("", "Organizations");
27625
+ lines.push(...block(me.orgs.map((entry) => [entry.org, entry.role])));
27626
+ if (org === void 0) return lines;
27627
+ lines.push("", `Roles in ${org.org}`);
27628
+ lines.push(...block(org.roles.map((entry) => [entry.project, entry.role])));
27629
+ return lines;
27550
27630
  }
27551
- function pageError(error62, webUrl) {
27552
- if (error62 instanceof CognitoError) {
27553
- const page = `${trimSlash2(webUrl)}${LOGIN_PAGE}`;
27554
- if (error62.type === "PasswordResetRequiredException") {
27555
- return new ConfigError(`reset your password at ${page}`);
27556
- }
27557
- if (error62.type === "UserNotConfirmedException") {
27558
- return new ConfigError(`finish your sign-up at ${page}`);
27559
- }
27560
- }
27561
- return error62;
27631
+
27632
+ // src/commands/snippet.ts
27633
+ import { existsSync, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
27634
+ import { dirname as dirname6, join as join6, resolve as resolve2 } from "node:path";
27635
+ function existsError(path) {
27636
+ return new UsageError(`${path} already exists: pass --force to overwrite it`);
27562
27637
  }
27563
- async function signIn(terminal, identity, config2) {
27564
- const email3 = await answer(terminal, "Email:");
27565
- const password = await answer(terminal, "Password:", true);
27566
- const clientId = config2.cliClientId;
27638
+ function readOptional2(path) {
27567
27639
  try {
27568
- let result = await initiateAuth(identity, {
27569
- clientId,
27570
- flow: "USER_PASSWORD_AUTH",
27571
- username: email3,
27572
- password
27573
- });
27574
- if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
27575
- const code2 = await answer(terminal, "Code:");
27576
- result = await respondToAuthChallenge(identity, {
27577
- clientId,
27578
- challenge: result.challenge,
27579
- email: email3,
27580
- code: code2
27581
- });
27582
- }
27583
- if (result.kind === "challenge") {
27584
- const name = result.challenge.challengeName;
27585
- if (name === NEW_PASSWORD_CHALLENGE) {
27586
- throw new ConfigError(
27587
- `finish your first sign-in at ${trimSlash2(config2.webUrl)}${LOGIN_PAGE}`
27588
- );
27589
- }
27590
- throw unsupportedStep(name);
27591
- }
27592
- return result.tokens;
27640
+ return readFileSync5(path, "utf8");
27593
27641
  } catch (error62) {
27594
- throw pageError(error62, config2.webUrl);
27642
+ if (isNotFound(error62)) return "";
27643
+ throw error62;
27595
27644
  }
27596
27645
  }
27597
- async function runLogin(options, context, io) {
27598
- requireServer("login", context);
27599
- if (options.password === true) {
27600
- if (options.browser === false) throw new UsageError(NO_BROWSER_MESSAGE);
27601
- return passwordLogin(options, context, io);
27646
+ function writeSkill(path, snippet) {
27647
+ mkdirSync5(dirname6(path), { recursive: true });
27648
+ writeFileSync4(path, skillFile(snippet), "utf8");
27649
+ }
27650
+ async function snippetSource(project, context, io, options = {}) {
27651
+ let tags;
27652
+ if (context.mode === "remote") {
27653
+ tags = (await (await openOrgClient(context, io, options)).project(project)).tags;
27654
+ } else {
27655
+ checkActAs(context);
27656
+ tags = context.allowedTags;
27602
27657
  }
27603
- return browserLogin(options, context, io);
27658
+ const scope = {
27659
+ ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
27660
+ ...context.slug === void 0 ? {} : { org: context.slug }
27661
+ };
27662
+ return { tags, scope };
27604
27663
  }
27605
- async function passwordLogin(options, context, io) {
27606
- const terminal = terminalOf(io);
27607
- if (!terminal.isTTY) throw new UsageError(NEEDS_TERMINAL_MESSAGE);
27608
- const endpoint = await resolveEndpoint(context, io);
27609
- const http = { debug: context.debug, fetch: io.fetch };
27610
- const config2 = await fetchAuthConfig(endpoint, http);
27611
- if (config2.mode === "local") {
27612
- writeLine(io.stdout, `${endpoint} is in local mode: no login needed.`);
27664
+ async function runSnippet(options, context, io) {
27665
+ const project = requireProject(context);
27666
+ const override = options.agent === void 0 ? void 0 : parseAgentName(options.agent);
27667
+ const appendPath = options.append === void 0 ? void 0 : resolve2(io.cwd, options.append);
27668
+ const skillPath = options.skill === true ? join6(context.projectRoot ?? io.cwd, SKILL_PATH) : void 0;
27669
+ const existing = appendPath === void 0 ? "" : readOptional2(appendPath);
27670
+ if (options.append !== void 0 && hasSnippet(existing)) {
27671
+ throw new UsageError(
27672
+ `${options.append} already has a "${SNIPPET_HEADING}" section: run quibble sync to refresh it`
27673
+ );
27674
+ }
27675
+ if (skillPath !== void 0 && existsSync(skillPath) && options.force !== true) {
27676
+ throw existsError(skillPath);
27677
+ }
27678
+ const hook = options.hook === true ? planHook(context.projectRoot ?? io.cwd) : void 0;
27679
+ const { tags, scope } = await snippetSource(project, context, io);
27680
+ let shown = { agent: override ?? DEFAULT_SNIPPET_AGENT, snippet: "" };
27681
+ shown.snippet = agentSnippet(tags, { ...scope, agent: shown.agent });
27682
+ if (appendPath !== void 0) {
27683
+ const agent = override ?? agentForFile(appendPath);
27684
+ const snippet = agentSnippet(tags, { ...scope, agent });
27685
+ const kept = existing.replace(/\s+$/, "");
27686
+ writeFileSync4(appendPath, kept === "" ? `${snippet}
27687
+ ` : `${kept}
27688
+
27689
+ ${snippet}
27690
+ `);
27691
+ shown = { agent, snippet };
27692
+ }
27693
+ if (skillPath !== void 0) {
27694
+ const agent = override ?? DEFAULT_SNIPPET_AGENT;
27695
+ const snippet = agentSnippet(tags, { ...scope, agent });
27696
+ writeSkill(skillPath, snippet);
27697
+ if (appendPath === void 0) shown = { agent, snippet };
27698
+ }
27699
+ const hookLine = hook === void 0 ? void 0 : applyHook(hook);
27700
+ if (options.json === true) {
27701
+ writeJson(io.stdout, {
27702
+ snippet: shown.snippet,
27703
+ agent: shown.agent,
27704
+ appended: appendPath ?? null,
27705
+ skill: skillPath ?? null,
27706
+ hook: hook?.path ?? null
27707
+ });
27613
27708
  return 0;
27614
27709
  }
27615
- let tokens;
27616
- try {
27617
- tokens = await signIn(terminal, { region: config2.region, ...http }, config2);
27618
- } finally {
27619
- terminal.close();
27710
+ if (appendPath === void 0 && skillPath === void 0) {
27711
+ writeLine(io.stdout, shown.snippet);
27712
+ }
27713
+ if (appendPath !== void 0) {
27714
+ writeLine(io.stdout, `Appended the Quibble snippet to ${appendPath}`);
27620
27715
  }
27621
- return finishLogin(tokens, endpoint, config2, options, context, io);
27716
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
27717
+ if (hookLine !== void 0) writeLine(io.stdout, hookLine);
27718
+ return 0;
27622
27719
  }
27623
- async function browserLogin(options, context, io) {
27624
- if (ciIsSet(io.env)) throw new UsageError(CI_MESSAGE);
27625
- const endpoint = await resolveEndpoint(context, io);
27626
- const http = { debug: context.debug, fetch: io.fetch };
27627
- const config2 = await fetchAuthConfig(endpoint, http);
27628
- if (config2.mode === "local") {
27629
- writeLine(io.stdout, `${endpoint} is in local mode: no login needed.`);
27630
- return 0;
27720
+
27721
+ // src/commands/init.ts
27722
+ var CODEX_LINE = "For Codex, run `quibble snippet --append AGENTS.md`, which names the agent codex.";
27723
+ var LOGIN_PAGE2 = "/login";
27724
+ var ORGS_PAGE2 = "/orgs";
27725
+ var CONFLICT = "conflict";
27726
+ function agentLead(agent) {
27727
+ return agent === DEFAULT_SNIPPET_AGENT ? "Add this to your CLAUDE.md:" : "Add this to your agent's instructions file:";
27728
+ }
27729
+ var SOURCE_NOTES = {
27730
+ flag: "",
27731
+ git: " (from the git remote origin)",
27732
+ directory: " (from the directory name)",
27733
+ token: " (from the project token)"
27734
+ };
27735
+ async function runInit(options, io) {
27736
+ const directory = resolve3(io.cwd);
27737
+ const path = join7(directory, PROJECT_CONFIG_FILENAME);
27738
+ if (existsSync2(path) && options.force !== true) {
27739
+ throw new ConfigError(`${path} already exists: pass --force to overwrite it`, path);
27631
27740
  }
27632
- const clock = clockOf2(io);
27633
- const started = await startLogin(endpoint, config2.webUrl, http, {
27634
- host: cliLoginHost((io.hostname ?? hostname4)()),
27635
- os: process.platform,
27636
- version: options.version
27637
- });
27638
- const deadline = clock().getTime() + (CLI_LOGIN_TTL_SECONDS + started.interval) * 1e3;
27639
- writeLine(
27640
- io.stderr,
27641
- `Approve this login in your browser: ${started.url} (code ${started.code}). Waiting...`
27642
- );
27643
- if (options.browser !== false) {
27644
- const opener = io.opener ?? platformOpener({ env: io.env });
27645
- void opener.open(started.url).catch(() => false);
27741
+ const skillPath = options.skill === true ? join7(directory, SKILL_PATH) : void 0;
27742
+ if (skillPath !== void 0 && existsSync2(skillPath) && options.force !== true) {
27743
+ throw existsError(skillPath);
27646
27744
  }
27647
- const approval = await waitForApproval(endpoint, started, deadline, http, io);
27648
- const tokens = await browserSignIn(
27649
- { region: config2.region, ...http },
27650
- config2,
27651
- approval,
27745
+ const given = options.project?.trim();
27746
+ const flagProject = given === void 0 || given === "" ? void 0 : parseProjectName(given);
27747
+ let org = options.org === void 0 || options.org.trim() === "" ? void 0 : parseOrgSlug(options.org);
27748
+ const tags = options.tag === void 0 ? void 0 : parseAllowedTags(options.tag);
27749
+ const agent = options.agent === void 0 ? DEFAULT_SNIPPET_AGENT : parseAgentName(options.agent);
27750
+ const hook = options.hook === true ? planHook(directory) : void 0;
27751
+ const endpoint = await endpointToWrite(options.endpoint, io);
27752
+ const context = {
27753
+ ...resolveContext({
27754
+ cwd: io.cwd,
27755
+ env: io.env,
27756
+ endpoint: options.endpoint,
27757
+ local: options.local,
27758
+ readProjectConfig: false
27759
+ }),
27760
+ // `init` acts as nobody, so it ignores `QUIBBLE_ACT_AS`.
27761
+ actAs: void 0
27762
+ };
27763
+ let named;
27764
+ let nameRefusal;
27765
+ if (flagProject !== void 0) {
27766
+ named = { project: flagProject, source: "flag" };
27767
+ } else {
27768
+ try {
27769
+ named = await nameFromRepository(directory, io);
27770
+ } catch (error62) {
27771
+ if (!(error62 instanceof ConfigError) || context.token === void 0) throw error62;
27772
+ nameRefusal = error62;
27773
+ }
27774
+ }
27775
+ let remote;
27776
+ if (context.mode === "remote") {
27777
+ const login = await logIn(context, options, io);
27778
+ if (login === "local") return 0;
27779
+ remote = login;
27780
+ }
27781
+ if (org === void 0 && remote?.client !== void 0) {
27782
+ const account = await findOrganization(remote.client, remote, context, io);
27783
+ org = account.org;
27784
+ if (account.project !== void 0 && flagProject === void 0) {
27785
+ named = { project: account.project, source: "token" };
27786
+ }
27787
+ }
27788
+ if (named === void 0) throw nameRefusal;
27789
+ const { project, source } = named;
27790
+ const config2 = {
27791
+ project,
27792
+ ...org === void 0 ? {} : { org },
27793
+ ...endpoint === void 0 ? {} : { endpoint },
27794
+ ...tags === void 0 ? {} : { tags }
27795
+ };
27796
+ writeFileSync5(path, `${JSON.stringify(config2, null, 2)}
27797
+ `, "utf8");
27798
+ const snippet = agentSnippet(tags, {
27799
+ agent,
27800
+ ...context.mode === "remote" ? { endpoint: context.endpoint } : {},
27801
+ ...org === void 0 ? {} : { org }
27802
+ });
27803
+ if (skillPath !== void 0) writeSkill(skillPath, snippet);
27804
+ const hookLine = hook === void 0 ? void 0 : applyHook(hook);
27805
+ const check2 = remote?.client === void 0 || org === void 0 ? { server: "unchecked" } : await checkServer(remote.client.forOrg(org), remote.endpoint, context, {
27806
+ org,
27807
+ project,
27808
+ tags,
27652
27809
  io
27653
- );
27654
- return finishLogin(tokens, endpoint, config2, options, context, io);
27655
- }
27656
- function postJson(url2, body, http) {
27657
- return send(url2, {
27658
- method: "POST",
27659
- headers: { "content-type": "application/json" },
27660
- body: JSON.stringify(body),
27661
- ...http
27662
27810
  });
27663
- }
27664
- async function startLogin(endpoint, webUrl, http, body) {
27665
- const url2 = `${trimSlash2(endpoint)}${CLI_LOGINS_PATH}`;
27666
- const site = { method: "POST", url: url2, debug: http.debug };
27667
- const response = await postJson(url2, body, http);
27668
- const text = await readText(response, site);
27669
- if (response.status === 404) {
27670
- throw new RemoteError(
27671
- `${endpoint} does not support the browser login: run quibble login --password${detail(site, 404)}`,
27672
- { status: 404 }
27673
- );
27811
+ if (options.json === true) {
27812
+ writeJson(io.stdout, {
27813
+ path,
27814
+ project,
27815
+ projectSource: source,
27816
+ org: org ?? null,
27817
+ tags: tags ?? null,
27818
+ skill: skillPath ?? null,
27819
+ hook: hook?.path ?? null,
27820
+ server: check2.server
27821
+ });
27822
+ return 0;
27823
+ }
27824
+ writeLine(io.stdout, `Wrote ${path}`);
27825
+ if (skillPath !== void 0) writeLine(io.stdout, `Wrote ${skillPath}`);
27826
+ if (hookLine !== void 0) writeLine(io.stdout, hookLine);
27827
+ writeLine(io.stdout, `Project: ${project}${SOURCE_NOTES[source]}`);
27828
+ if (org !== void 0) writeLine(io.stdout, `Organization: ${org}`);
27829
+ if (tags !== void 0) writeLine(io.stdout, `Tags: ${tags.join(", ")}`);
27830
+ writeLine(io.stdout);
27831
+ writeLine(io.stdout, agentLead(agent));
27832
+ writeLine(io.stdout);
27833
+ writeLine(io.stdout, snippet);
27834
+ if (agent === DEFAULT_SNIPPET_AGENT) {
27835
+ writeLine(io.stdout);
27836
+ writeLine(io.stdout, CODEX_LINE);
27674
27837
  }
27675
- if (!response.ok) throw remoteFailure(site, response, text);
27676
- const started = checkStart(asRecord(parseQuietly(text)), webUrl);
27677
- if (started === void 0) {
27678
- throw new RemoteError(`${MALFORMED_MESSAGE}${detail(site, response.status)}`);
27838
+ if (check2.line !== void 0) {
27839
+ writeLine(io.stdout);
27840
+ writeLine(io.stdout, check2.line);
27679
27841
  }
27680
- return started;
27842
+ return 0;
27681
27843
  }
27682
- function checkStart(body, webUrl) {
27683
- const loginKey = body?.["loginKey"];
27684
- const code2 = body?.["code"];
27685
- const url2 = body?.["url"];
27686
- if (typeof loginKey !== "string" || !CLI_LOGIN_KEY_RE.test(loginKey)) return void 0;
27687
- if (typeof code2 !== "string" || typeof url2 !== "string") return void 0;
27688
- try {
27689
- if (parseCliLoginCode(code2) !== code2) return void 0;
27690
- } catch {
27691
- return void 0;
27692
- }
27693
- let origin;
27844
+ async function endpointToWrite(given, io) {
27845
+ if (given === void 0 || given.trim() === "") return void 0;
27846
+ const value = parseEndpoint(given, "--endpoint");
27847
+ if (value.kind === "local") return LOCAL_ENDPOINT;
27848
+ const api = await discoverApiUrl(value.url, {
27849
+ fetch: io.fetch,
27850
+ debug: debugEnabled(io.env)
27851
+ });
27852
+ return isDefaultEndpoint(api) ? void 0 : api;
27853
+ }
27854
+ async function nameFromRepository(directory, io) {
27855
+ const url2 = await (io.git ?? nodeGit()).originUrl(directory);
27856
+ const fromGit = url2 === void 0 ? void 0 : projectOfRemote(url2);
27857
+ if (fromGit !== void 0) return { project: fromGit, source: "git" };
27858
+ const name = basename2(directory);
27694
27859
  try {
27695
- const web = new URL(webUrl);
27696
- const secure = web.protocol === "https:" || web.protocol === "http:" && isLoopbackUrl(web.origin);
27697
- if (!secure || !PLAIN_HOST_RE.test(web.host)) return void 0;
27698
- origin = web.origin;
27699
- } catch {
27700
- return void 0;
27860
+ return { project: parseProjectName(name), source: "directory" };
27861
+ } catch (error62) {
27862
+ throw new ConfigError(
27863
+ `cannot use the directory name "${name}" as a project name: pass --project <name>`,
27864
+ void 0,
27865
+ { cause: error62 }
27866
+ );
27701
27867
  }
27702
- if (url2 !== cliLoginUrl(origin, code2)) return void 0;
27703
- const raw = body?.["interval"];
27704
- const interval = typeof raw === "number" && Number.isInteger(raw) ? Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, raw)) : CLI_LOGIN_POLL_SECONDS;
27705
- return { loginKey, code: code2, url: url2, interval };
27706
27868
  }
27707
- async function waitForApproval(endpoint, started, deadline, http, io) {
27708
- const clock = clockOf2(io);
27709
- const sleep = sleepOf(io);
27710
- for (; ; ) {
27711
- await sleep(started.interval * 1e3);
27712
- if (clock().getTime() > deadline) throw new ConfigError(EXPIRED_MESSAGE);
27713
- const answer2 = await poll(endpoint, started.loginKey, http);
27714
- if (answer2 === void 0) continue;
27715
- const status = answer2["status"];
27716
- switch (status) {
27717
- case "pending":
27718
- continue;
27719
- case "denied":
27720
- throw new ConfigError(DENIED_MESSAGE);
27721
- case "expired":
27722
- throw new ConfigError(EXPIRED_MESSAGE);
27723
- case "approved": {
27724
- const username = answer2["username"];
27725
- const secret = answer2["secret"];
27726
- if (typeof username !== "string" || username === "") {
27727
- throw new RemoteError(MALFORMED_MESSAGE);
27728
- }
27729
- if (typeof secret !== "string" || secret === "") {
27730
- throw new RemoteError(MALFORMED_MESSAGE);
27731
- }
27732
- return { username, secret };
27733
- }
27734
- default:
27735
- throw new RemoteError(UNKNOWN_STATUS_MESSAGE);
27869
+ async function logIn(context, options, io) {
27870
+ const endpoint = await resolveEndpoint(context, io);
27871
+ let webUrl;
27872
+ const loopback = isLoopbackUrl(endpoint);
27873
+ if (context.token === void 0 && context.credential === void 0 && !loopback) {
27874
+ if (options.login === false || ciIsSet(io.env)) {
27875
+ return { endpoint, client: void 0, webUrl };
27876
+ }
27877
+ const session = await browserSession(
27878
+ endpoint,
27879
+ { version: options.version },
27880
+ context.debug,
27881
+ io
27882
+ );
27883
+ if (session === void 0) {
27884
+ writeLine(io.stderr, localModeLine(endpoint));
27885
+ return "local";
27736
27886
  }
27887
+ writeLine(io.stderr, loggedInLine(endpoint, session.me));
27888
+ webUrl = session.webUrl;
27889
+ context.credential = findCredential(io.env, endpoint);
27737
27890
  }
27891
+ const client = await openClient(context, io, { queue: false });
27892
+ return { endpoint, client, webUrl };
27738
27893
  }
27739
- async function poll(endpoint, loginKey, http) {
27740
- const url2 = `${trimSlash2(endpoint)}${CLI_LOGIN_POLL_PATH}`;
27741
- const site = { method: "POST", url: url2, debug: http.debug };
27742
- let response;
27743
- let text;
27894
+ async function findOrganization(client, remote, context, io) {
27895
+ const endpoint = remote.endpoint;
27896
+ let me;
27744
27897
  try {
27745
- response = await postJson(url2, { loginKey }, http);
27746
- text = await readText(response, site);
27898
+ me = await client.me();
27747
27899
  } catch (error62) {
27748
- if (error62 instanceof RemoteError && error62.unanswered) return void 0;
27749
- throw error62;
27900
+ if (error62 instanceof RemoteError && error62.status === 404 && error62.serverCode !== ORG_NOT_FOUND) {
27901
+ const webUrl = await webUrlOf(remote, context, io);
27902
+ throw new ConfigError(`finish your account at ${webUrl}${LOGIN_PAGE2}`);
27903
+ }
27904
+ writeLine(
27905
+ io.stderr,
27906
+ `quibble: warning: could not read the organizations of ${endpoint}: ${describeError(error62)}`
27907
+ );
27908
+ return { org: void 0, project: void 0 };
27750
27909
  }
27751
- if (!response.ok) {
27752
- if (response.status >= 500 || response.status === 429) return void 0;
27753
- throw remoteFailure(site, response, text);
27910
+ if (me.kind === "token") {
27911
+ return {
27912
+ org: me.org,
27913
+ project: me.project === void 0 ? void 0 : parseProjectName(me.project)
27914
+ };
27754
27915
  }
27755
- return asRecord(parseJson(text, site, response.status)) ?? {};
27916
+ const who = me.email ?? me.reporter;
27917
+ const slugs = me.orgs.map((entry) => entry.org).sort();
27918
+ const [only] = slugs;
27919
+ if (slugs.length === 1 && only !== void 0) return { org: only, project: void 0 };
27920
+ if (slugs.length === 0) {
27921
+ const webUrl = await webUrlOf(remote, context, io);
27922
+ throw new ConfigError(
27923
+ `${who} belongs to no organization on ${endpoint}: create one at ${webUrl}${ORGS_PAGE2}, then run quibble init again`
27924
+ );
27925
+ }
27926
+ throw new UsageError(
27927
+ `${who} belongs to several organizations on ${endpoint}: ${slugs.join(", ")}. Pass --org <slug>`
27928
+ );
27756
27929
  }
27757
- async function browserSignIn(identity, config2, approval, io) {
27758
- const clientId = config2.cliClientId;
27930
+ async function webUrlOf(remote, context, io) {
27931
+ if (remote.webUrl !== void 0) return trimSlash2(remote.webUrl);
27932
+ const config2 = await fetchAuthConfig(remote.endpoint, {
27933
+ debug: context.debug,
27934
+ fetch: io.fetch
27935
+ });
27936
+ return config2.mode === "cognito" ? trimSlash2(config2.webUrl) : remote.endpoint;
27937
+ }
27938
+ async function checkServer(client, endpoint, context, input2) {
27939
+ const { org, project, io } = input2;
27940
+ const warn = (text) => {
27941
+ writeLine(io.stderr, `quibble: warning: ${text}`);
27942
+ };
27943
+ const found = {
27944
+ server: "found",
27945
+ line: `Project ${org}/${project} exists on ${endpoint}.`
27946
+ };
27759
27947
  try {
27760
- let result = await initiateAuth(identity, {
27761
- clientId,
27762
- flow: "CUSTOM_AUTH",
27763
- username: approval.username
27764
- });
27765
- if (result.kind === "challenge") {
27766
- const name = result.challenge.challengeName;
27767
- if (name !== CUSTOM_CHALLENGE) throw unsupportedStep(name);
27768
- result = await respondToCustomChallenge(identity, {
27769
- clientId,
27770
- challenge: result.challenge,
27771
- username: approval.username,
27772
- answer: approval.secret
27773
- });
27774
- }
27775
- if (result.kind === "challenge" && result.challenge.challengeName === MFA_CHALLENGE) {
27776
- result = await answerCode(
27777
- identity,
27778
- clientId,
27779
- result.challenge,
27780
- approval.username,
27781
- io
27782
- );
27948
+ await client.project(project);
27949
+ return found;
27950
+ } catch (error62) {
27951
+ if (!(error62 instanceof RemoteError && error62.status === 404)) {
27952
+ warn(`could not check the project on ${endpoint}: ${describeError(error62)}`);
27953
+ return { server: "unreachable" };
27783
27954
  }
27784
- if (result.kind === "challenge") {
27785
- throw unsupportedStep(result.challenge.challengeName);
27955
+ if (error62.serverCode === ORG_NOT_FOUND) {
27956
+ warn(`no organization ${org} on ${endpoint} for this account`);
27957
+ return { server: "no_org" };
27786
27958
  }
27787
- return result.tokens;
27788
- } catch (error62) {
27789
- throw pageError(error62, config2.webUrl);
27790
27959
  }
27791
- }
27792
- async function answerCode(identity, clientId, challenge, username, io) {
27793
- const terminal = terminalOf(io);
27960
+ const noAdmin = () => {
27961
+ warn(
27962
+ `${org} has no project ${project} on ${endpoint}: ask an admin of ${org} to create it`
27963
+ );
27964
+ return { server: "no_project" };
27965
+ };
27966
+ if (context.token !== void 0) return noAdmin();
27794
27967
  try {
27795
- if (!terminal.isTTY) throw new ConfigError(MFA_NEEDS_TERMINAL_MESSAGE);
27796
- const code2 = await answer(terminal, "Code:");
27797
- return await respondToAuthChallenge(identity, {
27798
- clientId,
27799
- challenge,
27800
- email: username,
27801
- code: code2
27802
- });
27803
- } finally {
27804
- terminal.close();
27805
- }
27806
- }
27807
- async function finishLogin(tokens, endpoint, config2, options, context, io) {
27808
- if (tokens.refreshToken === void 0) {
27809
- throw new RemoteError("sign-in didn't return a refresh token");
27810
- }
27811
- const now = clockOf2(io)();
27812
- const me = await readProfile(
27813
- new ApiClient({
27814
- endpoint,
27815
- authorizer: bearerAuthorizer(tokens.accessToken),
27816
- debug: context.debug,
27817
- fetch: io.fetch
27818
- }),
27819
- config2.webUrl
27820
- );
27821
- if (me.sub === void 0 || me.email === void 0) {
27822
- throw new RemoteError(
27823
- "unexpected response from the API: identity has no sub or email"
27968
+ await client.createProject(project, input2.tags);
27969
+ return {
27970
+ server: "created",
27971
+ line: `Created the project ${org}/${project} on ${endpoint}.`
27972
+ };
27973
+ } catch (error62) {
27974
+ if (error62 instanceof RemoteError) {
27975
+ if (error62.status === 409 && error62.serverCode === CONFLICT) return found;
27976
+ if (error62.status === 403) return noAdmin();
27977
+ }
27978
+ warn(
27979
+ `could not create the project ${org}/${project} on ${endpoint}: ${describeError(error62)}`
27824
27980
  );
27981
+ return { server: "no_project" };
27825
27982
  }
27826
- saveCredential(io.env, {
27827
- endpoint,
27828
- refreshToken: tokens.refreshToken,
27829
- accessToken: tokens.accessToken,
27830
- expiresAt: new Date(now.getTime() + tokens.expiresIn * 1e3).toISOString(),
27831
- sub: me.sub,
27832
- email: me.email
27833
- });
27834
- const saved = saveEndpoint(context, endpoint, io.env);
27835
- if (options.json === true) {
27836
- writeJson(io.stdout, { ...me.body, endpoint, saved: saved ?? null });
27837
- return 0;
27838
- }
27839
- writeLine(io.stdout, `Logged in to ${endpoint} as ${me.email}.`);
27840
- if (saved !== void 0) {
27841
- writeLine(io.stdout, `Saved ${endpoint} as your endpoint in ${saved}.`);
27983
+ }
27984
+
27985
+ // src/mine.ts
27986
+ function mineMessage(env) {
27987
+ return `--mine needs a reporter: set "reporter" in ${defaultConfigPath(env)}`;
27988
+ }
27989
+ async function resolveMine(context, client, env) {
27990
+ if (client === void 0) {
27991
+ if (context.reporter === DEFAULT_REPORTER) throw new UsageError(mineMessage(env));
27992
+ return context.reporter;
27842
27993
  }
27843
- return 0;
27994
+ return (await client.me()).reporter;
27995
+ }
27996
+
27997
+ // src/since.ts
27998
+ var SINCE_MESSAGE = "--since must be a duration like 7d, a date like 2026-09-01, or an ISO 8601 UTC timestamp";
27999
+ var DURATION_RE = /^(\d+)(m|h|d|w)$/;
28000
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
28001
+ var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
28002
+ var MINUTE_MS = 6e4;
28003
+ var HOUR_MS = 60 * MINUTE_MS;
28004
+ var DAY_MS3 = 24 * HOUR_MS;
28005
+ var UNIT_MS = {
28006
+ m: MINUTE_MS,
28007
+ h: HOUR_MS,
28008
+ d: DAY_MS3,
28009
+ w: 7 * DAY_MS3
28010
+ };
28011
+ var EXPIRES_MESSAGE = "--expires must be a duration like 30d, a date like 2026-12-01, or an ISO 8601 UTC timestamp";
28012
+ function reject(message = SINCE_MESSAGE) {
28013
+ throw new UsageError(message);
28014
+ }
28015
+ function iso(at) {
28016
+ if (Number.isNaN(at.getTime())) reject();
28017
+ return at.toISOString();
28018
+ }
28019
+ function durationMs(digits, unit) {
28020
+ if (digits.startsWith("0")) return void 0;
28021
+ const step = UNIT_MS[unit];
28022
+ return step === void 0 ? void 0 : Number(digits) * step;
28023
+ }
28024
+ function fromDuration(digits, unit, now) {
28025
+ const span = durationMs(digits, unit);
28026
+ if (span === void 0) reject();
28027
+ return iso(new Date(now.getTime() - span));
28028
+ }
28029
+ function fromDate(value) {
28030
+ const at = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
28031
+ const stamp2 = iso(at);
28032
+ if (!stamp2.startsWith(value)) reject();
28033
+ return stamp2;
27844
28034
  }
27845
- function saveEndpoint(context, endpoint, env) {
27846
- if (context.endpointSource !== "--endpoint" || isDefaultEndpoint(endpoint)) {
27847
- return void 0;
28035
+ function parseSince(text, now) {
28036
+ const value = text.trim();
28037
+ const duration3 = DURATION_RE.exec(value);
28038
+ if (duration3 !== null) {
28039
+ return fromDuration(duration3[1] ?? "", duration3[2] ?? "", now);
27848
28040
  }
27849
- return rememberEndpoint(env, endpoint);
28041
+ if (DATE_RE.test(value)) return fromDate(value);
28042
+ if (TIMESTAMP_RE.test(value)) return iso(new Date(value));
28043
+ return reject();
27850
28044
  }
27851
- async function readProfile(client, webUrl) {
28045
+ function parseExpires(text, now) {
28046
+ const value = text.trim();
28047
+ const duration3 = DURATION_RE.exec(value);
28048
+ let stamp2;
27852
28049
  try {
27853
- return await client.me();
27854
- } catch (error62) {
27855
- if (error62 instanceof RemoteError && NO_PROFILE_STATUSES.has(error62.status ?? 0)) {
27856
- throw new ConfigError(`finish your account at ${trimSlash2(webUrl)}${LOGIN_PAGE}`);
28050
+ if (duration3 !== null) {
28051
+ const span = durationMs(duration3[1] ?? "", duration3[2] ?? "");
28052
+ if (span === void 0) reject(EXPIRES_MESSAGE);
28053
+ stamp2 = iso(new Date(now.getTime() + span));
28054
+ } else if (DATE_RE.test(value)) {
28055
+ stamp2 = fromDate(value);
28056
+ } else if (TIMESTAMP_RE.test(value)) {
28057
+ stamp2 = iso(new Date(value));
28058
+ } else {
28059
+ return reject(EXPIRES_MESSAGE);
27857
28060
  }
27858
- throw error62;
28061
+ } catch {
28062
+ return reject(EXPIRES_MESSAGE);
27859
28063
  }
28064
+ return stamp2;
27860
28065
  }
27861
- async function runLogout(context, io) {
27862
- requireServer("logout", context);
27863
- const endpoint = await resolveEndpoint(context, io);
27864
- const credential = findCredential(io.env, endpoint);
27865
- if (credential === void 0) {
27866
- writeLine(io.stdout, `Not logged in to ${endpoint}.`);
27867
- return 0;
28066
+
28067
+ // src/scope.ts
28068
+ function resolveScope(options, context, now = /* @__PURE__ */ new Date()) {
28069
+ if (options.project !== void 0 && options.all === true) {
28070
+ throw new UsageError("--project and --all cannot be used together");
27868
28071
  }
27869
- const http = { debug: context.debug, fetch: io.fetch };
27870
- if (!await revoke(endpoint, credential.refreshToken, http)) {
27871
- writeLine(
27872
- io.stderr,
27873
- `quibble: warning: could not revoke the refresh token at ${endpoint}`
27874
- );
28072
+ if (options.open === true && options.status !== void 0) {
28073
+ throw new UsageError("--open and --status cannot be used together");
27875
28074
  }
27876
- removeCredential(io.env, endpoint);
27877
- writeLine(io.stdout, `Logged out of ${endpoint}.`);
27878
- return 0;
28075
+ const project = options.project ?? (options.all === true ? void 0 : context.project);
28076
+ const status = options.open === true ? "open" : options.status;
28077
+ return {
28078
+ // No list reads two organizations, because the path names one.
28079
+ org: requireOrg(context),
28080
+ ...project === void 0 ? {} : { project },
28081
+ ...status === void 0 ? {} : { status },
28082
+ ...options.since === void 0 ? {} : { since: parseSince(options.since, now) }
28083
+ };
27879
28084
  }
27880
- async function revoke(endpoint, refreshToken, http) {
27881
- try {
27882
- const config2 = await fetchAuthConfig(endpoint, http);
27883
- if (config2.mode === "local") return true;
27884
- await revokeToken(
27885
- { region: config2.region, ...http },
27886
- { clientId: config2.cliClientId, token: refreshToken }
27887
- );
27888
- return true;
27889
- } catch {
27890
- return false;
28085
+ function tagFilter(tags) {
28086
+ return tags === void 0 || tags.length === 0 ? {} : { tags: [...tags] };
28087
+ }
28088
+
28089
+ // src/commands/list.ts
28090
+ var COLUMNS = [
28091
+ "id",
28092
+ "created",
28093
+ "severity",
28094
+ "status",
28095
+ "project",
28096
+ "reporter",
28097
+ "tags",
28098
+ "group",
28099
+ "summary"
28100
+ ];
28101
+ var REPORTER_WIDTH = 20;
28102
+ var TAGS_WIDTH = 24;
28103
+ var SUMMARY_MAX = 60;
28104
+ var RECURRED_MARK = "\u21BB";
28105
+ function cut3(text, width) {
28106
+ if (text.length <= width) return text;
28107
+ return `${text.slice(0, width - 3)}...`;
28108
+ }
28109
+ function summaryOf(complaint) {
28110
+ return cut3(printable(summarize(complaint.message)), SUMMARY_MAX);
28111
+ }
28112
+ function limitNote(count, limit, more) {
28113
+ if (limit >= MAX_LIMIT) {
28114
+ return count >= MAX_LIMIT ? `Showing the newest ${MAX_LIMIT} complaints: older ones may exist, narrow the filters to see them.` : void 0;
27891
28115
  }
28116
+ return more ? `Showing the newest ${limit} complaints: pass --limit to see more (up to ${MAX_LIMIT}).` : void 0;
27892
28117
  }
27893
- async function runWhoami(options, context, io) {
27894
- if (context.mode === "local") {
27895
- checkActAs(context);
27896
- writeLine(io.stdout, LOCAL_IDENTITY_MESSAGE);
28118
+ var BUDGET_NOTE = "The server stopped searching early, so older matches may exist: narrow the filters or pass --since.";
28119
+ async function runList(options, context, io) {
28120
+ if (options.ids === true && options.json === true) {
28121
+ throw new UsageError("--ids and --json cannot be used together");
28122
+ }
28123
+ const { store, client } = await openBackend(context, io);
28124
+ const reporter = options.mine === true ? await resolveMine(context, client, io.env) : void 0;
28125
+ const filter = parseListFilter({
28126
+ ...resolveScope(options, context),
28127
+ ...options.severity === void 0 ? {} : { severity: options.severity },
28128
+ ...tagFilter(options.tag),
28129
+ ...options.search === void 0 ? {} : { q: options.search },
28130
+ ...reporter === void 0 ? {} : { reporter },
28131
+ ...options.limit === void 0 ? {} : { limit: options.limit }
28132
+ });
28133
+ const limit = filter.limit ?? DEFAULT_LIMIT;
28134
+ const page = await listComplaintPage(store, {
28135
+ ...filter,
28136
+ limit: Math.min(limit + 1, MAX_LIMIT)
28137
+ });
28138
+ const complaints = page.complaints.slice(0, limit);
28139
+ if (options.json === true) {
28140
+ writeJson(io.stdout, complaints);
28141
+ return 0;
28142
+ }
28143
+ const note = limitNote(complaints.length, limit, page.complaints.length > limit);
28144
+ if (note !== void 0) writeLine(io.stderr, note);
28145
+ if (page.truncated) writeLine(io.stderr, BUDGET_NOTE);
28146
+ if (options.ids === true) {
28147
+ for (const complaint of complaints) writeLine(io.stdout, complaint.id);
28148
+ return 0;
28149
+ }
28150
+ if (complaints.length === 0) {
28151
+ writeLine(io.stdout, "No complaints found.");
27897
28152
  return 0;
27898
28153
  }
28154
+ const lines = options.ungroup === true ? formatTable(complaints.map((complaint) => ({ complaint }))) : formatTable(
28155
+ groupComplaints(complaints).map((group) => ({
28156
+ complaint: group.head,
28157
+ group
28158
+ }))
28159
+ );
28160
+ for (const line of lines) writeLine(io.stdout, line);
28161
+ return 0;
28162
+ }
28163
+ function reporterOf(complaint) {
28164
+ return cut3(printable(complaint.reporterName ?? complaint.reporter), REPORTER_WIDTH);
28165
+ }
28166
+ function groupCell(group) {
28167
+ const count = group.count > 1 ? `\xD7${group.count}` : "";
28168
+ return `${count}${group.recurred ? RECURRED_MARK : ""}`;
28169
+ }
28170
+ function formatTable(rows2) {
28171
+ const grouped = rows2.some((row) => row.group !== void 0);
28172
+ const columns = COLUMNS.filter((name) => grouped || name !== "group");
28173
+ const cells = rows2.map(({ complaint, group }) => {
28174
+ const values = {
28175
+ id: complaint.id.slice(0, 8),
28176
+ created: complaint.createdAt.slice(0, 16),
28177
+ severity: complaint.severity,
28178
+ status: complaint.status,
28179
+ project: complaint.project,
28180
+ reporter: reporterOf(complaint),
28181
+ tags: cut3(printable(complaint.tags.join(",")), TAGS_WIDTH),
28182
+ group: group === void 0 ? "" : groupCell(group),
28183
+ summary: summaryOf(complaint)
28184
+ };
28185
+ return columns.map((name) => values[name]);
28186
+ });
28187
+ const widths = columns.map(
28188
+ (name, column) => cells.reduce(
28189
+ (width, row) => Math.max(width, (row[column] ?? "").length),
28190
+ name.length
28191
+ )
28192
+ );
28193
+ const format = (line) => line.map((cell2, column) => cell2.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
28194
+ return [format(columns), ...cells.map(format)];
28195
+ }
28196
+
28197
+ // src/commands/orgs.ts
28198
+ var ORGS_PAGE3 = "/orgs";
28199
+ var COLUMNS2 = ["SLUG", "NAME", "ROLE", "OPEN"];
28200
+ async function runOrgs(options, context, io) {
28201
+ requireServer("orgs", context);
27899
28202
  const client = await openClient(context, io);
27900
28203
  const endpoint = requireEndpoint(context);
27901
- const me = await client.me();
27902
- const org = me.kind === "token" || client.org === void 0 ? void 0 : await client.organization();
28204
+ const answer2 = await client.orgs();
27903
28205
  if (options.json === true) {
27904
- writeJson(io.stdout, {
27905
- ...me.body,
27906
- endpoint,
27907
- ...org === void 0 ? {} : { org: org.body }
27908
- });
28206
+ writeJson(io.stdout, answer2.body);
27909
28207
  return 0;
27910
28208
  }
27911
- const lines = whoamiLines(endpoint, me, org);
27912
- if (me.kind !== "token" && me.orgs.length === 0) {
28209
+ if (answer2.orgs.length === 0) {
27913
28210
  const config2 = await fetchAuthConfig(endpoint, {
27914
28211
  debug: context.debug,
27915
- fetch: io.fetch
28212
+ fetch: io.fetch,
28213
+ timeoutMs: context.timeoutMs
27916
28214
  });
27917
28215
  const webUrl = config2.mode === "cognito" ? trimSlash2(config2.webUrl) : endpoint;
27918
- lines.push("", `Create one at ${webUrl}${ORGS_PAGE2}.`);
28216
+ writeLine(io.stdout, `No organizations yet: create one at ${webUrl}${ORGS_PAGE3}.`);
28217
+ return 0;
27919
28218
  }
27920
- for (const line of lines) writeLine(io.stdout, line);
28219
+ for (const line of formatOrgs(answer2.orgs)) writeLine(io.stdout, line);
27921
28220
  return 0;
27922
28221
  }
27923
- function rows(pairs) {
27924
- const width = Math.max(...pairs.map(([key]) => key.length));
27925
- return pairs.map(([key, value]) => `${key.padEnd(width)} ${value}`);
27926
- }
27927
- function block(entries) {
27928
- if (entries.length === 0) return [" none"];
27929
- const width = Math.max(...entries.map(([key]) => key.length));
27930
- return entries.map(([key, value]) => ` ${key.padEnd(width)} ${value}`);
27931
- }
27932
- function whoamiLines(endpoint, me, org) {
27933
- if (me.kind === "token") {
27934
- return rows([
27935
- ["endpoint", endpoint],
27936
- ["token", me.name ?? me.reporter],
27937
- ["org", me.org ?? ""],
27938
- ["project", me.project ?? ""],
27939
- ["role", me.role ?? "filer"]
27940
- ]);
27941
- }
27942
- const lines = rows([
27943
- ["endpoint", endpoint],
27944
- ["email", me.email ?? ""],
27945
- ["name", me.name ?? ""],
27946
- ["sub", me.sub ?? ""],
27947
- ["platform", me.platform ? "yes" : "no"]
28222
+ function formatOrgs(orgs) {
28223
+ const rows2 = [...orgs].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((entry) => [
28224
+ entry.org,
28225
+ entry.name,
28226
+ entry.role,
28227
+ entry.openCount === void 0 ? "" : String(entry.openCount)
27948
28228
  ]);
27949
- lines.push("", "Organizations");
27950
- lines.push(...block(me.orgs.map((entry) => [entry.org, entry.role])));
27951
- if (org === void 0) return lines;
27952
- lines.push("", `Roles in ${org.org}`);
27953
- lines.push(...block(org.roles.map((entry) => [entry.project, entry.role])));
27954
- return lines;
28229
+ const widths = COLUMNS2.map(
28230
+ (name, column) => rows2.reduce((width, row) => Math.max(width, (row[column] ?? "").length), name.length)
28231
+ );
28232
+ const format = (cells) => cells.map(
28233
+ (cell2, column) => column === COLUMNS2.length - 1 ? cell2.padStart(widths[column] ?? 0) : cell2.padEnd(widths[column] ?? 0)
28234
+ ).join(" ").trimEnd();
28235
+ return [format(COLUMNS2), ...rows2.map(format)];
27955
28236
  }
27956
28237
 
27957
28238
  // src/commands/project.ts
@@ -28720,11 +29001,14 @@ function buildProgram(io, setCode) {
28720
29001
  }).exitOverride();
28721
29002
  withGlobalOptions(program2);
28722
29003
  withGlobalOptions(
28723
- 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")
29004
+ program2.command("init").description("write a .quibblerc in the current directory").option(
29005
+ "--project <name>",
29006
+ "project name (default: the git remote origin, else the directory name)"
29007
+ ).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("--no-login", "do not log in through the browser without a credential").option("--force", "overwrite an existing .quibblerc or skill file")
28724
29008
  ).action(async function() {
28725
29009
  const options = { ...this.opts(), ...globals(program2, this) };
28726
29010
  rejectEmptyOptions(options);
28727
- setCode(await runInit(options, io));
29011
+ setCode(await runInit({ ...options, version: packageVersion() }, io));
28728
29012
  });
28729
29013
  withGlobalOptions(
28730
29014
  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")