atlass 1.12.0 → 1.13.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.
@@ -1,33 +1,60 @@
1
1
  import { Command } from "commander";
2
- import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
3
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
4
  import { Entry } from "@napi-rs/keyring";
7
- import kleur from "kleur";
5
+ import { homedir } from "node:os";
6
+ import { spawn } from "node:child_process";
8
7
  import { stripVTControlCharacters } from "node:util";
8
+ import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
9
+ import kleur from "kleur";
9
10
  import { marked } from "marked";
10
11
  import { randomUUID } from "node:crypto";
11
12
  import { common, createLowlight } from "lowlight";
12
- import { spawn } from "node:child_process";
13
- //#region src/cli/run.ts
14
- const SIGINT_EXIT_CODE = 130;
15
- function run(fn) {
16
- return async (...args) => {
17
- try {
18
- await fn(...args);
19
- } catch (err) {
20
- fail(err);
21
- }
22
- };
13
+ //#region src/api/http-error.ts
14
+ var HttpError = class extends Error {
15
+ status;
16
+ jira;
17
+ constructor(status, message, jira = {
18
+ errorMessages: [],
19
+ errors: {}
20
+ }) {
21
+ super(message);
22
+ this.status = status;
23
+ this.jira = jira;
24
+ this.name = "HttpError";
25
+ }
26
+ };
27
+ function httpError(status, path, body, loginHint) {
28
+ if (status === 401 || status === 403) return new HttpError(status, `Authentication failed (401/403). Run \`${loginHint}\` to update your token.`);
29
+ if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
30
+ if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
31
+ if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
32
+ if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
33
+ return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
23
34
  }
24
- function fail(err) {
25
- if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
26
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
27
- process.exit(1);
35
+ function extractErrorMessage(body) {
36
+ if (!body) return "";
37
+ const json = tryParseJson(body);
38
+ const { errorMessages, errors } = jiraErrors(body);
39
+ const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
40
+ if (fromJira.length) return fromJira.join("; ");
41
+ if (json?.message) return json.message;
42
+ if (json?.error?.message) return json.error.message;
43
+ return body.slice(0, 300);
28
44
  }
29
- function isPromptInterrupt(err) {
30
- return err instanceof Error && err.name === "ExitPromptError";
45
+ function jiraErrors(body) {
46
+ const json = tryParseJson(body);
47
+ return {
48
+ errorMessages: json?.errorMessages ?? [],
49
+ errors: json?.errors ?? {}
50
+ };
51
+ }
52
+ function tryParseJson(text) {
53
+ try {
54
+ return JSON.parse(text);
55
+ } catch {
56
+ return null;
57
+ }
31
58
  }
32
59
  //#endregion
33
60
  //#region src/api/client.ts
@@ -35,10 +62,12 @@ const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
35
62
  var AtlassianClient = class {
36
63
  site;
37
64
  authHeader;
38
- constructor(auth) {
39
- this.site = auth.site;
40
- const basic = Buffer.from(`${auth.email}:${auth.token}`).toString("base64");
65
+ loginHint;
66
+ constructor(credentials, loginHint) {
67
+ this.site = credentials.site;
68
+ const basic = Buffer.from(`${credentials.email}:${credentials.token}`).toString("base64");
41
69
  this.authHeader = `Basic ${basic}`;
70
+ this.loginHint = loginHint;
42
71
  }
43
72
  async request(path, init) {
44
73
  const res = await fetch(`${this.site}${path}`, {
@@ -51,7 +80,7 @@ var AtlassianClient = class {
51
80
  });
52
81
  if (!res.ok) {
53
82
  const body = await res.text().catch(() => "");
54
- throw httpError(res.status, path, body);
83
+ throw httpError(res.status, path, body, this.loginHint);
55
84
  }
56
85
  return res;
57
86
  }
@@ -111,204 +140,512 @@ function pathAndQuery(absoluteUrl) {
111
140
  const u = new URL(absoluteUrl);
112
141
  return u.pathname + u.search;
113
142
  }
114
- var HttpError = class extends Error {
115
- status;
116
- jira;
117
- constructor(status, message, jira = {
118
- errorMessages: [],
119
- errors: {}
120
- }) {
121
- super(message);
122
- this.status = status;
123
- this.jira = jira;
124
- this.name = "HttpError";
143
+ //#endregion
144
+ //#region src/api/session.ts
145
+ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
146
+ const ATLASSIAN_LOGIN = "atlass auth login";
147
+ const BITBUCKET_LOGIN = "atlass bitbucket login";
148
+ var BitbucketClient = class extends AtlassianClient {
149
+ workspace;
150
+ defaultRepo;
151
+ uuid;
152
+ constructor(email, token, bitbucket) {
153
+ super({
154
+ site: BITBUCKET_ORIGIN,
155
+ email,
156
+ token
157
+ }, BITBUCKET_LOGIN);
158
+ this.workspace = bitbucket.workspace;
159
+ this.defaultRepo = bitbucket.defaultRepo;
160
+ this.uuid = bitbucket.uuid;
125
161
  }
126
162
  };
127
- function httpError(status, path, body = "") {
128
- if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
129
- if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
130
- if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
131
- if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
132
- if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`, jiraErrors(body));
133
- return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`, jiraErrors(body));
163
+ function sessionFor(credentials) {
164
+ return new AtlassianClient(credentials, ATLASSIAN_LOGIN);
134
165
  }
135
- function extractErrorMessage(body) {
136
- if (!body) return "";
137
- const json = tryParseJson(body);
138
- const { errorMessages, errors } = jiraErrors(body);
139
- const fromJira = [...errorMessages, ...Object.entries(errors).map(([f, m]) => `${f}: ${m}`)];
140
- if (fromJira.length) return fromJira.join("; ");
141
- if (json?.message) return json.message;
142
- if (json?.error?.message) return json.error.message;
143
- return body.slice(0, 300);
166
+ function bitbucketSessionFor(email, token, bitbucket) {
167
+ return new BitbucketClient(email, token, bitbucket);
144
168
  }
145
- function jiraErrors(body) {
146
- const json = tryParseJson(body);
169
+ async function openSession(profile) {
170
+ const config = await profile.read();
171
+ if (!config?.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
172
+ const token = await profile.token(config.email, "atlassian");
173
+ if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
174
+ return sessionFor({
175
+ site: config.site,
176
+ email: config.email,
177
+ token
178
+ });
179
+ }
180
+ async function openBitbucketSession(profile) {
181
+ const config = await profile.read();
182
+ if (!config?.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
183
+ const token = await profile.token(config.email, "bitbucket");
184
+ if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
185
+ return bitbucketSessionFor(config.email, token, config.bitbucket);
186
+ }
187
+ //#endregion
188
+ //#region src/files/disk.ts
189
+ async function makeRoom(path) {
190
+ await mkdir(dirname(path), { recursive: true });
191
+ }
192
+ function diskFiles() {
147
193
  return {
148
- errorMessages: json?.errorMessages ?? [],
149
- errors: json?.errors ?? {}
194
+ async readText(path) {
195
+ return readFile(path, "utf8");
196
+ },
197
+ async readBytes(path) {
198
+ const buffer = await readFile(path);
199
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
200
+ },
201
+ async size(path) {
202
+ return (await stat(path)).size;
203
+ },
204
+ async writeText(path, text) {
205
+ await makeRoom(path);
206
+ await writeFile(path, text, "utf8");
207
+ },
208
+ async writeBytes(path, bytes) {
209
+ await makeRoom(path);
210
+ await writeFile(path, bytes);
211
+ }
150
212
  };
151
213
  }
152
- function tryParseJson(text) {
153
- try {
154
- return JSON.parse(text);
155
- } catch {
156
- return null;
157
- }
158
- }
159
214
  //#endregion
160
- //#region src/config.ts
215
+ //#region src/profile/disk.ts
216
+ const SERVICE = "atlass";
161
217
  function configDir() {
162
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "atlass");
218
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
219
+ return join(base, "atlass");
163
220
  }
164
221
  function configPath() {
165
222
  return join(configDir(), "config.json");
166
223
  }
167
- async function readConfig() {
168
- try {
169
- const raw = await readFile(configPath(), "utf8");
170
- const parsed = JSON.parse(raw);
171
- return {
172
- site: parsed.site,
173
- email: parsed.email,
174
- bitbucket: parsed.bitbucket
175
- };
176
- } catch {
177
- return null;
178
- }
224
+ function entry(email, kind) {
225
+ return new Entry(SERVICE, kind === "bitbucket" ? `${email}:bitbucket` : email);
179
226
  }
180
- async function writeConfig(config) {
181
- await mkdir(configDir(), { recursive: true });
182
- await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
227
+ function diskProfile() {
228
+ return {
229
+ async read() {
230
+ try {
231
+ const parsed = JSON.parse(await readFile(configPath(), "utf8"));
232
+ return {
233
+ site: parsed.site,
234
+ email: parsed.email,
235
+ bitbucket: parsed.bitbucket
236
+ };
237
+ } catch {
238
+ return null;
239
+ }
240
+ },
241
+ async write(config) {
242
+ await mkdir(configDir(), { recursive: true });
243
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
244
+ },
245
+ async clear() {
246
+ await rm(configPath(), { force: true });
247
+ },
248
+ async token(email, kind) {
249
+ return entry(email, kind).getPassword();
250
+ },
251
+ async setToken(email, kind, token) {
252
+ entry(email, kind).setPassword(token);
253
+ },
254
+ async deleteToken(email, kind) {
255
+ try {
256
+ entry(email, kind).deleteCredential();
257
+ } catch {
258
+ return;
259
+ }
260
+ }
261
+ };
183
262
  }
184
- async function clearConfig() {
185
- await rm(configPath(), { force: true });
263
+ //#endregion
264
+ //#region src/terminal/pager.ts
265
+ const DEFAULT_ROWS = 24;
266
+ const DEFAULT_COLUMNS = 80;
267
+ const DEFAULT_PAGER = "less";
268
+ const DEFAULT_LESS = "FRX";
269
+ function renderedHeight(text, columns) {
270
+ const width = Math.max(1, columns);
271
+ return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
186
272
  }
187
- function siteOrigin(input) {
188
- let value = input.trim();
189
- if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
190
- return new URL(value).origin;
273
+ function shouldPage(text, screen) {
274
+ if (!screen.isTTY) return false;
275
+ const rows = screen.rows ?? DEFAULT_ROWS;
276
+ return renderedHeight(text, screen.columns ?? DEFAULT_COLUMNS) > rows;
191
277
  }
192
- //#endregion
193
- //#region src/credentials.ts
194
- const SERVICE = "atlass";
195
- const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
196
- function entry(key) {
197
- return new Entry(SERVICE, key);
278
+ function pagerCommand(env) {
279
+ const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
280
+ return {
281
+ command,
282
+ args,
283
+ env: {
284
+ ...env,
285
+ LESS: env["LESS"] ?? DEFAULT_LESS
286
+ }
287
+ };
198
288
  }
199
- function deleteEntryIfPresent(key) {
200
- try {
201
- entry(key).deleteCredential();
202
- } catch {
203
- return;
289
+ async function tryPager(text, options = {}) {
290
+ if (!shouldPage(text, options.screen ?? process.stdout)) return false;
291
+ return pipeToPager(text, pagerCommand(options.env ?? process.env));
292
+ }
293
+ function pipeToPager(text, pager) {
294
+ return new Promise((resolve) => {
295
+ const child = spawn(pager.command, pager.args, {
296
+ stdio: [
297
+ "pipe",
298
+ "inherit",
299
+ "inherit"
300
+ ],
301
+ env: pager.env
302
+ });
303
+ child.once("error", () => resolve(false));
304
+ child.once("close", () => resolve(true));
305
+ child.stdin.once("error", () => {});
306
+ child.stdin.end(`${text}\n`);
307
+ });
308
+ }
309
+ //#endregion
310
+ //#region src/terminal.ts
311
+ var NotInteractiveError = class extends Error {
312
+ constructor(spec) {
313
+ super(spec.flag ? `Cannot prompt without a terminal. Pass ${spec.flag}.` : `Cannot prompt without a terminal: ${spec.message}`);
314
+ this.name = "NotInteractiveError";
204
315
  }
316
+ };
317
+ //#endregion
318
+ //#region src/terminal/prompts.ts
319
+ const inquirerPrompts = {
320
+ text: (spec) => input({
321
+ message: spec.message,
322
+ required: spec.required,
323
+ default: spec.default,
324
+ validate: spec.validate
325
+ }),
326
+ secret: (spec) => password({
327
+ message: spec.message,
328
+ mask: spec.mask
329
+ }),
330
+ confirm: (spec) => confirm({
331
+ message: spec.message,
332
+ default: spec.default
333
+ }),
334
+ pick: (spec) => select({
335
+ message: spec.message,
336
+ choices: spec.choices,
337
+ default: spec.default,
338
+ pageSize: spec.pageSize
339
+ }),
340
+ pickMany: (spec) => checkbox({
341
+ message: spec.message,
342
+ choices: spec.choices,
343
+ required: spec.required,
344
+ pageSize: spec.pageSize
345
+ }),
346
+ edit: (spec) => editor({
347
+ message: spec.message,
348
+ postfix: spec.postfix,
349
+ default: spec.default,
350
+ validate: spec.validate
351
+ }),
352
+ searchPick: (spec) => search({
353
+ message: spec.message,
354
+ source: (term) => spec.source(term),
355
+ pageSize: spec.pageSize
356
+ })
357
+ };
358
+ async function refuse(spec) {
359
+ throw new NotInteractiveError(spec);
360
+ }
361
+ const refusingPrompts = {
362
+ text: refuse,
363
+ secret: refuse,
364
+ confirm: refuse,
365
+ pick: refuse,
366
+ pickMany: refuse,
367
+ edit: refuse,
368
+ searchPick: refuse
369
+ };
370
+ //#endregion
371
+ //#region src/terminal/tty.ts
372
+ const DEFAULT_WIDTH = 80;
373
+ function ttyTerminal(interactive = true) {
374
+ return {
375
+ interactive,
376
+ out(value) {
377
+ if (Array.isArray(value) && value.length === 0) return;
378
+ console.log(Array.isArray(value) ? value.join("\n") : value);
379
+ },
380
+ err(value) {
381
+ console.error(value);
382
+ },
383
+ json(value) {
384
+ console.log(JSON.stringify(value, null, 2));
385
+ },
386
+ async page(text, options = {}) {
387
+ if (options.pager !== false && await tryPager(text)) return;
388
+ console.log(text);
389
+ },
390
+ get width() {
391
+ return process.stdout.columns ?? DEFAULT_WIDTH;
392
+ },
393
+ ask: interactive ? inquirerPrompts : refusingPrompts
394
+ };
205
395
  }
206
- function saveToken(email, token) {
207
- entry(email).setPassword(token);
396
+ //#endregion
397
+ //#region src/terminal/open.ts
398
+ function openTerminal(args, stdin) {
399
+ return ttyTerminal(stdin.isTTY === true && !refusedInput(args.at(-1)));
208
400
  }
209
- function readToken(email) {
210
- return entry(email).getPassword();
401
+ function refusedInput(last) {
402
+ for (let command = asCommand(last); command; command = command.parent) if (command.opts()["input"] === false) return true;
403
+ return false;
211
404
  }
212
- function deleteToken(email) {
213
- deleteEntryIfPresent(email);
405
+ function asCommand(value) {
406
+ const command = value;
407
+ return typeof command?.opts === "function" ? command : null;
214
408
  }
215
- function bitbucketKey(email) {
216
- return `${email}:bitbucket`;
409
+ //#endregion
410
+ //#region src/cli/run.ts
411
+ const SIGINT_EXIT_CODE = 130;
412
+ function bareAction(fn) {
413
+ return guarded((env, args) => fn(env, ...args));
414
+ }
415
+ function jiraAction(fn) {
416
+ return guarded(async (env, args) => fn({
417
+ ...env,
418
+ session: await openSession(env.profile)
419
+ }, ...args));
420
+ }
421
+ function bitbucketAction(fn) {
422
+ return guarded(async (env, args) => fn({
423
+ ...env,
424
+ session: await openBitbucketSession(env.profile)
425
+ }, ...args));
426
+ }
427
+ function guarded(fn) {
428
+ return async (...args) => {
429
+ try {
430
+ await fn({
431
+ term: openTerminal(args, process.stdin),
432
+ files: diskFiles(),
433
+ profile: diskProfile()
434
+ }, args);
435
+ } catch (err) {
436
+ fail(err);
437
+ }
438
+ };
217
439
  }
218
- function saveBitbucketToken(email, token) {
219
- entry(bitbucketKey(email)).setPassword(token);
440
+ function fail(err) {
441
+ if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
442
+ ttyTerminal().err(`Error: ${err instanceof Error ? err.message : String(err)}`);
443
+ process.exit(1);
220
444
  }
221
- function readBitbucketToken(email) {
222
- return entry(bitbucketKey(email)).getPassword();
445
+ function isPromptInterrupt(err) {
446
+ return err instanceof Error && err.name === "ExitPromptError";
223
447
  }
224
- function deleteBitbucketToken(email) {
225
- deleteEntryIfPresent(bitbucketKey(email));
448
+ //#endregion
449
+ //#region src/util/parse.ts
450
+ function parseIssueKey(input) {
451
+ const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
452
+ return match ? match[0] : null;
226
453
  }
227
- async function requireAuth() {
228
- const config = await readConfig();
229
- if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
230
- const token = readToken(config.email);
231
- if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
232
- return {
233
- site: config.site,
234
- email: config.email,
235
- token
236
- };
454
+ function parsePageId(input) {
455
+ const trimmed = input.trim();
456
+ if (/^\d+$/.test(trimmed)) return trimmed;
457
+ const fromPath = trimmed.match(/\/pages\/(\d+)/);
458
+ if (fromPath) return fromPath[1];
459
+ const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
460
+ if (fromQuery) return fromQuery[1];
461
+ return null;
237
462
  }
238
- async function requireBitbucketAuth() {
239
- const config = await readConfig();
240
- if (!config || !config.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
241
- const token = readBitbucketToken(config.email);
242
- if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
243
- return {
244
- site: BITBUCKET_ORIGIN,
245
- email: config.email,
246
- token,
247
- workspace: config.bitbucket.workspace,
248
- defaultRepo: config.bitbucket.defaultRepo,
249
- uuid: config.bitbucket.uuid
463
+ function isExternalHref(href) {
464
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
465
+ }
466
+ function resolveRepo(flag, config) {
467
+ if (flag) {
468
+ if (flag.includes("/")) {
469
+ const parts = flag.split("/");
470
+ const [workspace, repo] = parts;
471
+ if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
472
+ return {
473
+ workspace,
474
+ repo
475
+ };
476
+ }
477
+ if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
478
+ return {
479
+ workspace: config.workspace,
480
+ repo: flag
481
+ };
482
+ }
483
+ if (config.workspace && config.defaultRepo) return {
484
+ workspace: config.workspace,
485
+ repo: config.defaultRepo
250
486
  };
487
+ throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
488
+ }
489
+ const DEFAULT_LIMIT = 25;
490
+ const MAX_LIMIT = 100;
491
+ function parseLimit(value) {
492
+ if (!value) return DEFAULT_LIMIT;
493
+ const n = Number.parseInt(value, 10);
494
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
495
+ return Math.min(n, MAX_LIMIT);
496
+ }
497
+ function siteOrigin(input) {
498
+ let value = input.trim();
499
+ if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
500
+ return new URL(value).origin;
251
501
  }
252
502
  //#endregion
253
503
  //#region src/commands/auth.ts
254
- async function login() {
255
- const site = siteOrigin(await input({
504
+ async function login({ term, profile }) {
505
+ const site = siteOrigin(await term.ask.text({
256
506
  message: "Atlassian site (e.g. acme.atlassian.net):",
257
507
  required: true
258
508
  }));
259
- const email = await input({
509
+ const email = await term.ask.text({
260
510
  message: "Account email:",
261
511
  required: true
262
512
  });
263
- const token = await password({
513
+ const token = await term.ask.secret({
264
514
  message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
265
515
  mask: true
266
516
  });
267
- const me = await new AtlassianClient({
517
+ const me = await sessionFor({
268
518
  site,
269
519
  email,
270
520
  token
271
521
  }).getJson("/rest/api/3/myself");
272
- await writeConfig({
273
- ...await readConfig() ?? {},
522
+ const existing = await profile.read() ?? {};
523
+ await profile.write({
524
+ ...existing,
274
525
  site,
275
526
  email
276
527
  });
277
- saveToken(email, token);
278
- console.log(`Logged in as ${me.displayName} on ${site}.`);
528
+ await profile.setToken(email, "atlassian", token);
529
+ term.out(`Logged in as ${me.displayName} on ${site}.`);
279
530
  }
280
- async function logout() {
281
- const config = await readConfig();
282
- if (config?.email) deleteToken(config.email);
531
+ async function logout({ term, profile }) {
532
+ const config = await profile.read();
533
+ if (config?.email) await profile.deleteToken(config.email, "atlassian");
283
534
  const bitbucketLogin = config?.bitbucket && config.email ? {
284
535
  email: config.email,
285
536
  bitbucket: config.bitbucket
286
537
  } : null;
287
- if (bitbucketLogin) await writeConfig(bitbucketLogin);
288
- else await clearConfig();
289
- console.log("Logged out. Credentials removed.");
538
+ if (bitbucketLogin) await profile.write(bitbucketLogin);
539
+ else await profile.clear();
540
+ term.out("Logged out. Credentials removed.");
290
541
  }
291
- async function status() {
292
- const config = await readConfig();
542
+ async function status({ term, profile }) {
543
+ const config = await profile.read();
293
544
  if (!config || !config.site || !config.email) {
294
- console.log("Not logged in. Run `atlass auth login`.");
545
+ term.out("Not logged in. Run `atlass auth login`.");
295
546
  return;
296
547
  }
297
- const hasToken = readToken(config.email) !== null;
298
- console.log(`Site: ${config.site}`);
299
- console.log(`Email: ${config.email}`);
300
- console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
548
+ const hasToken = await profile.token(config.email, "atlassian") !== null;
549
+ term.out([
550
+ `Site: ${config.site}`,
551
+ `Email: ${config.email}`,
552
+ `Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`
553
+ ]);
301
554
  }
302
555
  //#endregion
303
556
  //#region src/cli/auth.ts
304
557
  function registerAuth(auth) {
305
558
  auth.description("Manage Atlassian credentials");
306
- auth.command("login").description("Store site, email, and API token").action(run(login));
307
- auth.command("logout").description("Remove stored credentials").action(run(logout));
308
- auth.command("status").description("Show the current login").action(run(status));
559
+ auth.command("login").description("Store site, email, and API token").action(bareAction(login));
560
+ auth.command("logout").description("Remove stored credentials").action(bareAction(logout));
561
+ auth.command("status").description("Show the current login").action(bareAction(status));
309
562
  return auth;
310
563
  }
311
564
  //#endregion
565
+ //#region src/api/bitbucket-user.ts
566
+ async function fetchCurrentUserUuid(client) {
567
+ const user = await client.getJson("/2.0/user");
568
+ if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
569
+ return user.uuid;
570
+ }
571
+ //#endregion
572
+ //#region src/commands/bitbucket-auth.ts
573
+ async function bitbucketLogin({ term, profile }) {
574
+ const existing = await profile.read() ?? {};
575
+ const email = existing.email ?? await term.ask.text({
576
+ message: "Account email:",
577
+ required: true
578
+ });
579
+ const workspace = (await term.ask.text({
580
+ message: "Bitbucket workspace (e.g. acme):",
581
+ required: true
582
+ })).trim();
583
+ const defaultRepo = (await term.ask.text({ message: "Default repo slug (optional):" })).trim() || void 0;
584
+ const token = await term.ask.secret({
585
+ message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
586
+ mask: true
587
+ });
588
+ const session = bitbucketSessionFor(email, token, { workspace });
589
+ const ws = await verifyWorkspace(session, workspace);
590
+ const uuid = await fetchCurrentUserUuid(session).catch(() => void 0);
591
+ await profile.write({
592
+ ...existing,
593
+ email,
594
+ bitbucket: {
595
+ workspace,
596
+ ...defaultRepo ? { defaultRepo } : {},
597
+ ...uuid ? { uuid } : {}
598
+ }
599
+ });
600
+ await profile.setToken(email, "bitbucket", token);
601
+ term.out(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
602
+ }
603
+ async function bitbucketLogout({ term, profile }) {
604
+ const config = await profile.read();
605
+ if (config?.email) await profile.deleteToken(config.email, "bitbucket");
606
+ const jiraLogin = config?.site && config.email ? {
607
+ site: config.site,
608
+ email: config.email
609
+ } : null;
610
+ if (jiraLogin) await profile.write(jiraLogin);
611
+ else await profile.clear();
612
+ term.out("Logged out of Bitbucket. Credentials removed.");
613
+ }
614
+ async function bitbucketStatus({ term, profile }) {
615
+ const config = await profile.read();
616
+ if (!config?.email || !config.bitbucket?.workspace) {
617
+ term.out("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
618
+ return;
619
+ }
620
+ const hasToken = await profile.token(config.email, "bitbucket") !== null;
621
+ term.out([
622
+ `Workspace: ${config.bitbucket.workspace}`,
623
+ `Email: ${config.email}`,
624
+ `Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`,
625
+ `Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`
626
+ ]);
627
+ }
628
+ async function rememberBitbucketUuid(profile, uuid) {
629
+ const config = await profile.read();
630
+ if (!config?.bitbucket) return;
631
+ await profile.write({
632
+ ...config,
633
+ bitbucket: {
634
+ ...config.bitbucket,
635
+ uuid
636
+ }
637
+ });
638
+ }
639
+ async function verifyWorkspace(transport, workspace) {
640
+ try {
641
+ return await transport.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
642
+ } catch (err) {
643
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Could not verify Bitbucket workspace "${workspace}" (401/403). Check the token and that it has workspace read + read:pipeline:bitbucket scopes.`);
644
+ if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
645
+ throw err;
646
+ }
647
+ }
648
+ //#endregion
312
649
  //#region src/api/bitbucket-repo.ts
313
650
  const BITBUCKET_WEB_ORIGIN = "https://bitbucket.org";
314
651
  function repoPath(ref, resource) {
@@ -403,13 +740,6 @@ async function listPullRequests(client, ref, params) {
403
740
  return collectPages(client, `${repoPath(ref, "pullrequests")}?${pullRequestsQuery(params)}`, params.limit, (value) => toPullRequestSummary(ref, value));
404
741
  }
405
742
  //#endregion
406
- //#region src/api/bitbucket-user.ts
407
- async function fetchCurrentUserUuid(client) {
408
- const user = await client.getJson("/2.0/user");
409
- if (!user.uuid) throw new Error("Bitbucket did not return an account uuid for the current user.");
410
- return user.uuid;
411
- }
412
- //#endregion
413
743
  //#region src/api/bitbucket-pipelines.ts
414
744
  const BUILD_NUMBER_SCAN_LIMIT = 1e3;
415
745
  function pipelineStatus(state) {
@@ -513,7 +843,7 @@ function formatDateTime(iso) {
513
843
  return date.toISOString().replace("T", " ").slice(0, 16);
514
844
  }
515
845
  const MINUTE = 60;
516
- const HOUR = 60 * MINUTE;
846
+ const HOUR = 3600;
517
847
  const DAY = 24 * HOUR;
518
848
  function relativeTime(iso, nowMs) {
519
849
  if (!iso) return "-";
@@ -559,43 +889,43 @@ function alignedRows(items, nowMs, cells) {
559
889
  json: row.json
560
890
  }));
561
891
  }
562
- function printRows(rows, options) {
563
- if (options.json) {
564
- console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
565
- return;
566
- }
567
- if (rows.length === 0) {
568
- console.log(options.empty);
569
- return;
570
- }
571
- for (const row of rows) console.log(formatRow(row));
572
- if (options.footer) console.log(options.footer);
892
+ function formatRows(rows, options) {
893
+ if (rows.length === 0) return [options.empty];
894
+ const lines = rows.map((row) => formatRow(row, options.width));
895
+ return options.footer ? [...lines, options.footer] : lines;
896
+ }
897
+ function writeRows(term, rows, options) {
898
+ if (options.json) term.json(rows.map((r) => r.json));
899
+ else term.out(formatRows(rows, {
900
+ ...options,
901
+ width: term.width
902
+ }));
573
903
  }
574
904
  const COPY_CONCURRENCY = 5;
575
- async function runSearch(rows, options, noun, copyOne) {
905
+ async function runSearch(term, rows, options, noun, copyOne) {
576
906
  if (!(options.copy && !options.json && rows.length > 0)) {
577
- printRows(rows, options);
907
+ writeRows(term, rows, options);
578
908
  return;
579
909
  }
580
910
  if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
581
- await copySelected(rows, noun, copyOne);
911
+ await copySelected(term, rows, noun, copyOne);
582
912
  }
583
913
  function searchFooter(limit) {
584
914
  return `
585
915
  Showing first ${limit}; refine with flags or raise --limit.`;
586
916
  }
587
- async function copySelected(rows, noun, copyOne) {
588
- if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
589
- const selected = await checkbox({
917
+ async function copySelected(term, rows, noun, copyOne) {
918
+ if (!term.interactive) throw new Error("--copy requires an interactive terminal.");
919
+ const selected = await term.ask.pickMany({
590
920
  message: `Select ${noun.plural} to copy:`,
591
921
  choices: rows.map((r) => ({
592
- name: formatRow(r),
922
+ name: formatRow(r, term.width),
593
923
  value: r.id
594
924
  })),
595
925
  pageSize: 20
596
926
  });
597
927
  if (selected.length === 0) {
598
- console.log("Nothing selected.");
928
+ term.out("Nothing selected.");
599
929
  return;
600
930
  }
601
931
  let copied = 0;
@@ -611,10 +941,10 @@ async function copySelected(rows, noun, copyOne) {
611
941
  }
612
942
  await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
613
943
  const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
614
- if (failures.length === 0) console.log(summary);
615
- else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
944
+ if (failures.length === 0) term.out(summary);
945
+ else term.out(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
616
946
  }
617
- function formatRow(row, width = process.stdout.columns ?? 80) {
947
+ function formatRow(row, width) {
618
948
  const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
619
949
  const text = room > 0 ? truncate(row.freeText, room) : "";
620
950
  const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
@@ -625,141 +955,21 @@ function truncate(text, max) {
625
955
  return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
626
956
  }
627
957
  //#endregion
628
- //#region src/util/parse.ts
629
- function parseIssueKey(input) {
630
- const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
631
- return match ? match[0] : null;
632
- }
633
- function parsePageId(input) {
634
- const trimmed = input.trim();
635
- if (/^\d+$/.test(trimmed)) return trimmed;
636
- const fromPath = trimmed.match(/\/pages\/(\d+)/);
637
- if (fromPath) return fromPath[1];
638
- const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
639
- if (fromQuery) return fromQuery[1];
640
- return null;
641
- }
642
- function isExternalHref(href) {
643
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
644
- }
645
- function resolveRepo(flag, config) {
646
- if (flag) {
647
- if (flag.includes("/")) {
648
- const parts = flag.split("/");
649
- const [workspace, repo] = parts;
650
- if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
651
- return {
652
- workspace,
653
- repo
654
- };
655
- }
656
- if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
657
- return {
658
- workspace: config.workspace,
659
- repo: flag
660
- };
661
- }
662
- if (config.workspace && config.defaultRepo) return {
663
- workspace: config.workspace,
664
- repo: config.defaultRepo
665
- };
666
- throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
667
- }
668
- const DEFAULT_LIMIT = 25;
669
- const MAX_LIMIT = 100;
670
- function parseLimit(value) {
671
- if (!value) return DEFAULT_LIMIT;
672
- const n = Number.parseInt(value, 10);
673
- if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
674
- return Math.min(n, MAX_LIMIT);
675
- }
676
- //#endregion
677
958
  //#region src/commands/bitbucket.ts
678
- async function bitbucketLogin() {
679
- const existing = await readConfig() ?? {};
680
- const email = existing.email ?? await input({
681
- message: "Account email:",
682
- required: true
683
- });
684
- const workspace = (await input({
685
- message: "Bitbucket workspace (e.g. acme):",
686
- required: true
687
- })).trim();
688
- const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
689
- const token = await password({
690
- message: "Bitbucket API token (needs pipeline, pull request, account, and workspace read scopes):",
691
- mask: true
692
- });
693
- const client = new AtlassianClient({
694
- site: BITBUCKET_ORIGIN,
695
- email,
696
- token
697
- });
698
- const ws = await verifyWorkspace(client, workspace);
699
- const uuid = await fetchCurrentUserUuid(client).catch(() => void 0);
700
- await writeConfig({
701
- ...existing,
702
- email,
703
- bitbucket: {
704
- workspace,
705
- ...defaultRepo ? { defaultRepo } : {},
706
- ...uuid ? { uuid } : {}
707
- }
708
- });
709
- saveBitbucketToken(email, token);
710
- console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
711
- }
712
- async function rememberBitbucketUuid(uuid) {
713
- const config = await readConfig();
714
- if (!config?.bitbucket) return;
715
- await writeConfig({
716
- ...config,
717
- bitbucket: {
718
- ...config.bitbucket,
719
- uuid
720
- }
721
- });
722
- }
723
- async function bitbucketLogout() {
724
- const config = await readConfig();
725
- if (config?.email) deleteBitbucketToken(config.email);
726
- const jiraLogin = config?.site && config.email ? {
727
- site: config.site,
728
- email: config.email
729
- } : null;
730
- if (jiraLogin) await writeConfig(jiraLogin);
731
- else await clearConfig();
732
- console.log("Logged out of Bitbucket. Credentials removed.");
733
- }
734
- async function bitbucketStatus() {
735
- const config = await readConfig();
736
- if (!config?.email || !config.bitbucket?.workspace) {
737
- console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
738
- return;
739
- }
740
- const hasToken = readBitbucketToken(config.email) !== null;
741
- console.log(`Workspace: ${config.bitbucket.workspace}`);
742
- console.log(`Email: ${config.email}`);
743
- console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
744
- console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
745
- }
746
- async function bitbucketPipelines(options) {
747
- const auth = await requireBitbucketAuth();
748
- const ref = resolveRepo(options.repo, auth);
749
- const client = new AtlassianClient(auth);
959
+ async function bitbucketPipelines({ session, term }, options) {
960
+ const ref = resolveRepo(options.repo, session);
750
961
  const limit = parseLimit(options.limit);
751
- printRows(pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(client, ref, limit)), Date.now()), {
962
+ writeRows(term, pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(session, ref, limit)), Date.now()), {
752
963
  json: options.json,
753
964
  empty: "No pipelines found."
754
965
  });
755
966
  }
756
- async function bitbucketPipeline(arg, options) {
967
+ async function bitbucketPipeline({ session, term }, arg, options) {
757
968
  const buildNumber = parseBuildNumber(arg);
758
- const auth = await requireBitbucketAuth();
759
- const ref = resolveRepo(options.repo, auth);
760
- const client = new AtlassianClient(auth);
761
- const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(client, ref, buildNumber));
762
- printPipelineDetail(detail, await withScopeHint(PIPELINE_SCOPE, () => listSteps(client, ref, detail.uuid)), Date.now());
969
+ const ref = resolveRepo(options.repo, session);
970
+ const detail = await withScopeHint(PIPELINE_SCOPE, () => getPipeline(session, ref, buildNumber));
971
+ const steps = await withScopeHint(PIPELINE_SCOPE, () => listSteps(session, ref, detail.uuid));
972
+ term.out(formatPipelineDetail(detail, steps, Date.now()));
763
973
  }
764
974
  function pipelineRows(pipelines, nowMs) {
765
975
  return alignedRows(pipelines, nowMs, (p) => ({
@@ -798,33 +1008,26 @@ function formatStepRows(steps) {
798
1008
  const ws = Math.max(...rows.map((r) => r.status.length));
799
1009
  return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
800
1010
  }
801
- function printPipelineDetail(detail, steps, nowMs) {
802
- console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
803
- if (detail.repo) console.log(`Repo: ${detail.repo}`);
804
- console.log(`Ref: ${refWithCommit(detail)}`);
805
- if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
806
- console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
1011
+ function formatPipelineDetail(detail, steps, nowMs) {
807
1012
  const by = detail.creator ? ` by ${detail.creator}` : "";
808
- console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
809
- if (steps.length > 0) {
810
- console.log("");
811
- console.log("Steps:");
812
- for (const line of formatStepRows(steps)) console.log(line);
813
- }
1013
+ return [
1014
+ `Pipeline #${detail.buildNumber} ${detail.status || "-"}`,
1015
+ ...detail.repo ? [`Repo: ${detail.repo}`] : [],
1016
+ `Ref: ${refWithCommit(detail)}`,
1017
+ ...detail.trigger ? [`Trigger: ${detail.trigger}`] : [],
1018
+ `Duration: ${formatDuration(detail.durationSeconds)}`,
1019
+ `Created: ${relativeTime(detail.createdOn, nowMs)}${by}`,
1020
+ ...steps.length > 0 ? [
1021
+ "",
1022
+ "Steps:",
1023
+ ...formatStepRows(steps)
1024
+ ] : []
1025
+ ];
814
1026
  }
815
1027
  function refWithCommit(pipeline) {
816
1028
  if (!pipeline.ref) return pipeline.commit || "-";
817
1029
  return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
818
1030
  }
819
- async function verifyWorkspace(client, workspace) {
820
- try {
821
- return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
822
- } catch (err) {
823
- if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Could not verify Bitbucket workspace "${workspace}" (401/403). Check the token and that it has workspace read + read:pipeline:bitbucket scopes.`);
824
- if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
825
- throw err;
826
- }
827
- }
828
1031
  function parseBuildNumber(arg) {
829
1032
  const raw = (arg ?? "").replace(/^#/, "").trim();
830
1033
  if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
@@ -843,23 +1046,21 @@ async function withScopeHint(scope, fn) {
843
1046
  //#region src/commands/bitbucket-prs.ts
844
1047
  const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
845
1048
  const ACCOUNT_SCOPE = "read:account";
846
- async function bitbucketPrs(options) {
1049
+ async function bitbucketPrs({ session, term, profile }, options) {
847
1050
  if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
848
1051
  const states = pullRequestStates(options);
849
- const auth = await requireBitbucketAuth();
850
- const ref = resolveRepo(options.repo, auth);
851
- const client = new AtlassianClient(auth);
1052
+ const ref = resolveRepo(options.repo, session);
852
1053
  const limit = parseLimit(options.limit);
853
1054
  const query = pullRequestQuery({
854
- ...await resolvePrincipals(client, auth, options),
1055
+ ...await resolvePrincipals(session, profile, options),
855
1056
  query: options.query
856
1057
  });
857
- const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(client, ref, {
1058
+ const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(session, ref, {
858
1059
  limit,
859
1060
  states,
860
1061
  query
861
1062
  }));
862
- printRows(pullRequestRows(prs, Date.now()), {
1063
+ writeRows(term, pullRequestRows(prs, Date.now()), {
863
1064
  json: options.json,
864
1065
  empty: emptyMessage(options),
865
1066
  footer: prs.length === limit ? searchFooter(limit) : void 0
@@ -883,8 +1084,8 @@ function pullRequestRows(prs, nowMs) {
883
1084
  };
884
1085
  });
885
1086
  }
886
- async function resolvePrincipals(client, auth, options) {
887
- const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(client, auth) : void 0;
1087
+ async function resolvePrincipals(session, profile, options) {
1088
+ const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(session, profile) : void 0;
888
1089
  return {
889
1090
  author: isMe(options.author) ? mine : options.author,
890
1091
  reviewer: isMe(options.reviewer) ? mine : options.reviewer
@@ -893,10 +1094,10 @@ async function resolvePrincipals(client, auth, options) {
893
1094
  function isMe(value) {
894
1095
  return value?.toLowerCase() === "me";
895
1096
  }
896
- async function currentUuid(client, auth) {
897
- if (auth.uuid) return auth.uuid;
898
- const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(client));
899
- await rememberBitbucketUuid(uuid);
1097
+ async function currentUuid(session, profile) {
1098
+ if (session.uuid) return session.uuid;
1099
+ const uuid = await withScopeHint(ACCOUNT_SCOPE, () => fetchCurrentUserUuid(session));
1100
+ await rememberBitbucketUuid(profile, uuid);
900
1101
  return uuid;
901
1102
  }
902
1103
  function emptyMessage(options) {
@@ -906,12 +1107,12 @@ function emptyMessage(options) {
906
1107
  //#region src/cli/bitbucket.ts
907
1108
  function registerBitbucket(bitbucket) {
908
1109
  bitbucket.description("Bitbucket commands");
909
- bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
910
- bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
911
- bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
912
- bitbucket.command("pipelines").description("List recent pipeline runs for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(run(bitbucketPipelines));
913
- bitbucket.command("prs").description("List pull requests for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-s, --state <state...>", "open, merged, declined, or superseded").option("--all", "include every state").option("-a, --author <who>", "limit to an author ('me', an account id, or a uuid)").option("--reviewer <who>", "limit to a reviewer ('me', an account id, or a uuid)").option("-q, --query <bbql>", "raw Bitbucket query (not for use with --author or --reviewer)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(run(bitbucketPrs));
914
- bitbucket.command("pipeline <number>").description("Show one pipeline run and its steps").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").action(run(bitbucketPipeline));
1110
+ bitbucket.command("login").description("Store Bitbucket workspace and API token").action(bareAction(bitbucketLogin));
1111
+ bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(bareAction(bitbucketLogout));
1112
+ bitbucket.command("status").description("Show the current Bitbucket login").action(bareAction(bitbucketStatus));
1113
+ bitbucket.command("pipelines").description("List recent pipeline runs for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(bitbucketAction(bitbucketPipelines));
1114
+ bitbucket.command("prs").description("List pull requests for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-s, --state <state...>", "open, merged, declined, or superseded").option("--all", "include every state").option("-a, --author <who>", "limit to an author ('me', an account id, or a uuid)").option("--reviewer <who>", "limit to a reviewer ('me', an account id, or a uuid)").option("-q, --query <bbql>", "raw Bitbucket query (not for use with --author or --reviewer)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(bitbucketAction(bitbucketPrs));
1115
+ bitbucket.command("pipeline <number>").description("Show one pipeline run and its steps").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").action(bitbucketAction(bitbucketPipeline));
915
1116
  return bitbucket;
916
1117
  }
917
1118
  //#endregion
@@ -1303,9 +1504,10 @@ function cqlStringLiteral(value) {
1303
1504
  }
1304
1505
  //#endregion
1305
1506
  //#region src/commands/resolve-ref.ts
1306
- async function resolveRef(arg, kind) {
1307
- const raw = arg ?? await input({
1507
+ async function resolveRef(ask, arg, kind) {
1508
+ const raw = arg ?? await ask.text({
1308
1509
  message: kind.message,
1510
+ flag: kind.flag,
1309
1511
  required: true
1310
1512
  });
1311
1513
  const ref = kind.parse(raw);
@@ -1368,7 +1570,6 @@ function applyMarks(text, marks) {
1368
1570
  if (typeof value === "string") href = value;
1369
1571
  break;
1370
1572
  }
1371
- default: break;
1372
1573
  }
1373
1574
  if (href) out = `[${out}](${href})`;
1374
1575
  return out;
@@ -1446,7 +1647,8 @@ const PANEL_LABELS = {
1446
1647
  error: "Error"
1447
1648
  };
1448
1649
  function renderPanel(node, ctx, indent) {
1449
- return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
1650
+ const type = typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info";
1651
+ return prefixLines(`**${PANEL_LABELS[type] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
1450
1652
  }
1451
1653
  function renderExpand(node, ctx, indent) {
1452
1654
  return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
@@ -1732,9 +1934,10 @@ function scalarString(value) {
1732
1934
  }
1733
1935
  function frontmatter(fields) {
1734
1936
  const lines = ["---"];
1735
- for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) if (value.length === 0) lines.push(`${key}: []`);
1736
- else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
1737
- else if (typeof value === "number") lines.push(`${key}: ${value}`);
1937
+ for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
1938
+ if (value.length === 0) lines.push(`${key}: []`);
1939
+ else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
1940
+ } else if (typeof value === "number") lines.push(`${key}: ${value}`);
1738
1941
  else lines.push(`${key}: ${quote(value)}`);
1739
1942
  lines.push("---");
1740
1943
  return lines.join("\n");
@@ -1780,21 +1983,23 @@ function leadingH1(lines) {
1780
1983
  }
1781
1984
  function commentsSection(comments) {
1782
1985
  if (comments.length === 0) return "";
1986
+ const blocks = comments.map((c) => {
1987
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
1988
+ return c.body ? `${heading}\n\n${c.body}` : heading;
1989
+ });
1783
1990
  return [
1784
1991
  COMMENTS_HEADING,
1785
1992
  "",
1786
- comments.map((c) => {
1787
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
1788
- return c.body ? `${heading}\n\n${c.body}` : heading;
1789
- }).join("\n\n")
1993
+ blocks.join("\n\n")
1790
1994
  ].join("\n");
1791
1995
  }
1792
1996
  function attachmentsSection(attachments) {
1793
1997
  if (attachments.length === 0) return "";
1998
+ const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
1794
1999
  return [
1795
2000
  ATTACHMENTS_HEADING,
1796
2001
  "",
1797
- ...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
2002
+ ...items
1798
2003
  ].join("\n");
1799
2004
  }
1800
2005
  //#endregion
@@ -1859,7 +2064,6 @@ function planCopy(source, out) {
1859
2064
  return {
1860
2065
  ...content,
1861
2066
  filePath: target.filePath,
1862
- assetsDir: target.assetsDir,
1863
2067
  downloads: planDownloads(attachments, target)
1864
2068
  };
1865
2069
  }
@@ -1905,21 +2109,18 @@ function numberedName(name, suffix) {
1905
2109
  }
1906
2110
  //#endregion
1907
2111
  //#region src/copy/run.ts
1908
- async function runCopy(plan, fetchBytes) {
1909
- const landed = await download(plan, fetchBytes);
1910
- await mkdir(dirname(plan.filePath), { recursive: true });
1911
- await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
1912
- console.log(wroteLine(plan.filePath, landed.length));
1913
- }
1914
- async function download(plan, fetchBytes) {
1915
- if (plan.downloads.length === 0) return [];
1916
- await mkdir(plan.assetsDir, { recursive: true });
2112
+ async function runCopy(env, plan) {
2113
+ const landed = await download(env, plan);
2114
+ await env.files.writeText(plan.filePath, renderCopy(plan, landed));
2115
+ env.term.out(wroteLine(plan.filePath, landed.length));
2116
+ }
2117
+ async function download({ term, files, session }, plan) {
1917
2118
  const landed = [];
1918
2119
  for (const { path, ...attachment } of plan.downloads) try {
1919
- await writeFile(path, await fetchBytes(attachment.url));
2120
+ await files.writeBytes(path, await session.getBinary(attachment.url));
1920
2121
  landed.push(attachment);
1921
2122
  } catch (err) {
1922
- console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
2123
+ term.err(` ! could not download ${attachment.filename}: ${err.message}`);
1923
2124
  }
1924
2125
  return landed;
1925
2126
  }
@@ -2136,80 +2337,31 @@ function withUploadedId(node, ids) {
2136
2337
  }
2137
2338
  //#endregion
2138
2339
  //#region src/update/run.ts
2139
- async function runPlan(plan, options, push) {
2340
+ async function runPlan(term, plan, options, push) {
2140
2341
  if (options.dryRun) {
2141
- for (const line of formatPlan(plan)) console.log(line);
2342
+ term.out(formatPlan(plan));
2142
2343
  return;
2143
2344
  }
2144
2345
  const verdict = plan.verdict;
2145
2346
  if (verdict.kind === "refuse") throw new Error(verdict.message);
2146
2347
  if (verdict.kind === "confirm") {
2147
- if (!await confirm({
2348
+ if (!await term.ask.confirm({
2148
2349
  message: verdict.message,
2350
+ flag: "--force",
2149
2351
  default: false
2150
2352
  })) {
2151
- console.log("Aborted.");
2353
+ term.out("Aborted.");
2152
2354
  return;
2153
2355
  }
2154
2356
  }
2155
2357
  await push();
2156
2358
  }
2157
2359
  //#endregion
2158
- //#region src/util/pager.ts
2159
- const DEFAULT_ROWS = 24;
2160
- const DEFAULT_COLUMNS = 80;
2161
- const DEFAULT_PAGER = "less";
2162
- const DEFAULT_LESS = "FRX";
2163
- function renderedHeight(text, columns) {
2164
- const width = Math.max(1, columns);
2165
- return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
2166
- }
2167
- function shouldPage(text, term) {
2168
- if (!term.isTTY) return false;
2169
- const rows = term.rows ?? DEFAULT_ROWS;
2170
- return renderedHeight(text, term.columns ?? DEFAULT_COLUMNS) > rows;
2171
- }
2172
- function pagerCommand(env) {
2173
- const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
2174
- return {
2175
- command,
2176
- args,
2177
- env: {
2178
- ...env,
2179
- LESS: env["LESS"] ?? DEFAULT_LESS
2180
- }
2181
- };
2182
- }
2183
- async function printPaged(text, options = {}) {
2184
- const term = options.term ?? process.stdout;
2185
- if (options.pager === false || !shouldPage(text, term)) {
2186
- console.log(text);
2187
- return;
2188
- }
2189
- if (!await pipeToPager(text, pagerCommand(options.env ?? process.env))) console.log(text);
2190
- }
2191
- function pipeToPager(text, pager) {
2192
- return new Promise((resolve) => {
2193
- const child = spawn(pager.command, pager.args, {
2194
- stdio: [
2195
- "pipe",
2196
- "inherit",
2197
- "inherit"
2198
- ],
2199
- env: pager.env
2200
- });
2201
- child.once("error", () => resolve(false));
2202
- child.once("close", () => resolve(true));
2203
- child.stdin.once("error", () => {});
2204
- child.stdin.end(`${text}\n`);
2205
- });
2206
- }
2207
- //#endregion
2208
2360
  //#region src/commands/confluence.ts
2209
- async function confluenceView(arg, options) {
2210
- const auth = await requireAuth();
2211
- const id = await resolveRef(arg, PAGE_REF);
2212
- await printPaged(formatPageView(await fetchPage(new AtlassianClient(auth), auth.site, id), Date.now(), options.allComments ?? false).join("\n"), { pager: options.pager });
2361
+ async function confluenceView({ session, term }, arg, options) {
2362
+ const id = await resolveRef(term.ask, arg, PAGE_REF);
2363
+ const lines = formatPageView(await fetchPage(session, session.site, id), Date.now(), options.allComments ?? false);
2364
+ await term.page(lines.join("\n"), { pager: options.pager });
2213
2365
  }
2214
2366
  function formatPageView(page, nowMs, allComments) {
2215
2367
  return [
@@ -2228,37 +2380,35 @@ function formatPageView(page, nowMs, allComments) {
2228
2380
  ...attachmentSection(page.attachments)
2229
2381
  ];
2230
2382
  }
2231
- async function confluenceCopy(arg, options) {
2232
- const auth = await requireAuth();
2233
- const id = await resolveRef(arg, PAGE_REF);
2234
- await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
2383
+ async function confluenceCopy(env, arg, options) {
2384
+ await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
2235
2385
  }
2236
- async function confluenceUpdate(arg, options) {
2237
- const file = arg ?? await input({
2386
+ async function confluenceUpdate({ session, term, files }, arg, options) {
2387
+ const file = arg ?? await term.ask.text({
2238
2388
  message: "Path to the page Markdown file:",
2389
+ flag: "[file]",
2239
2390
  required: true
2240
2391
  });
2241
- const src = parsePageSource(await readFile(file, "utf8"));
2242
- const client = new AtlassianClient(await requireAuth());
2243
- const state = await fetchPageState(client, src.id);
2244
- const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
2245
- await runPlan(plan, options, async () => {
2392
+ const src = parsePageSource(await files.readText(file));
2393
+ const state = await fetchPageState(session, src.id);
2394
+ const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), options);
2395
+ await runPlan(term, plan, options, async () => {
2246
2396
  const ids = /* @__PURE__ */ new Map();
2247
2397
  for (const upload of plan.uploads) {
2248
- console.log(`Uploading ${upload.filename} ...`);
2249
- const bytes = await readFile(upload.path);
2250
- ids.set(upload.href, await uploadAttachment(client, src.id, upload.filename, bytes));
2398
+ term.err(`Uploading ${upload.filename} ...`);
2399
+ const bytes = await files.readBytes(upload.path);
2400
+ ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
2251
2401
  }
2252
- const version = await updatePage(client, src.id, {
2402
+ const version = await updatePage(session, src.id, {
2253
2403
  title: plan.headline.next,
2254
2404
  nextVersion: state.version + 1,
2255
2405
  body: withUploadedIds(plan.body, ids),
2256
2406
  message: options.message ?? "Updated via atlass"
2257
2407
  });
2258
- console.log(`Updated page ${src.id} to version ${version}.`);
2408
+ term.out(`Updated page ${src.id} to version ${version}.`);
2259
2409
  });
2260
2410
  }
2261
- async function statLocalImages(dir, md) {
2411
+ async function statLocalImages(files, dir, md) {
2262
2412
  const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2263
2413
  return Promise.all(hrefs.map(async (href) => {
2264
2414
  const path = isAbsolute(href) ? href : resolve(dir, href);
@@ -2266,47 +2416,46 @@ async function statLocalImages(dir, md) {
2266
2416
  href,
2267
2417
  path,
2268
2418
  filename: basename(path),
2269
- ...await fileSize(path)
2419
+ ...await fileSize(files, path)
2270
2420
  };
2271
2421
  }));
2272
2422
  }
2273
- async function fileSize(path) {
2423
+ async function fileSize(files, path) {
2274
2424
  try {
2275
- return { size: (await stat(path)).size };
2425
+ return { size: await files.size(path) };
2276
2426
  } catch {
2277
2427
  return {};
2278
2428
  }
2279
2429
  }
2280
- async function confluenceSearch(query, options) {
2430
+ async function confluenceSearch(env, query, options) {
2281
2431
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
2282
- await listPages({
2432
+ await listPages(env, {
2283
2433
  text: query,
2284
2434
  space: options.space,
2285
2435
  cql: options.cql
2286
2436
  }, "No matching pages.", options);
2287
2437
  }
2288
- async function confluenceList(options) {
2289
- await listPages({
2438
+ async function confluenceList(env, options) {
2439
+ await listPages(env, {
2290
2440
  starred: true,
2291
2441
  space: options.space
2292
2442
  }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2293
2443
  }
2294
- async function listPages(filter, empty, options) {
2444
+ async function listPages(env, filter, empty, options) {
2445
+ const { session, term } = env;
2295
2446
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2296
- const auth = await requireAuth();
2297
- const client = new AtlassianClient(auth);
2298
2447
  const limit = parseLimit(options.limit);
2299
- const { pages, hasMore } = await searchPages(client, auth.site, {
2448
+ const { pages, hasMore } = await searchPages(session, session.site, {
2300
2449
  ...filter,
2301
2450
  limit
2302
2451
  });
2303
- await runSearch(formatPageRows(pages, Date.now()), {
2452
+ await runSearch(term, formatPageRows(pages, Date.now()), {
2304
2453
  json: options.json,
2305
2454
  copy: options.copy,
2306
2455
  out: options.out,
2307
2456
  empty,
2308
2457
  footer: hasMore ? searchFooter(limit) : void 0
2309
- }, PAGE_NOUN, (id) => copyPage(client, auth.site, id, options.out));
2458
+ }, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2310
2459
  }
2311
2460
  function formatPageRows(pages, nowMs) {
2312
2461
  return alignedRows(pages, nowMs, (p) => ({
@@ -2334,12 +2483,13 @@ const PAGE_NOUN = {
2334
2483
  singular: "page",
2335
2484
  plural: "pages"
2336
2485
  };
2337
- async function copyPage(client, site, id, out) {
2338
- console.log(`Fetching page ${id} ...`);
2339
- await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
2486
+ async function copyPage(env, id, out) {
2487
+ env.term.err(`Fetching page ${id} ...`);
2488
+ await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2340
2489
  }
2341
2490
  const PAGE_REF = {
2342
2491
  message: "Confluence page id or URL:",
2492
+ flag: "[page]",
2343
2493
  parse: parsePageId,
2344
2494
  notFound: (raw) => `Could not find a page id in "${raw}".`
2345
2495
  };
@@ -2347,11 +2497,11 @@ const PAGE_REF = {
2347
2497
  //#region src/cli/confluence.ts
2348
2498
  function registerConfluence(confluence) {
2349
2499
  confluence.description("Confluence commands");
2350
- confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(run(confluenceView));
2351
- confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
2352
- confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(run(confluenceUpdate));
2353
- confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(confluenceList));
2354
- confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(confluenceSearch));
2500
+ confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(confluenceView));
2501
+ confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(jiraAction(confluenceCopy));
2502
+ confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(confluenceUpdate));
2503
+ confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(confluenceList));
2504
+ confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(confluenceSearch));
2355
2505
  return confluence;
2356
2506
  }
2357
2507
  //#endregion
@@ -2462,10 +2612,10 @@ function projectSearchQuery(query, startAt) {
2462
2612
  }
2463
2613
  //#endregion
2464
2614
  //#region src/commands/jira-create-resolve.ts
2465
- async function resolveProject(client, site, arg, strict) {
2615
+ async function resolveProject(ask, client, site, arg, strict) {
2466
2616
  if (arg) return arg.toUpperCase();
2467
2617
  if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
2468
- return search({
2618
+ return ask.searchPick({
2469
2619
  message: "Project:",
2470
2620
  source: async (term) => (await listProjects(client, site, term)).map((p) => ({
2471
2621
  name: `${p.key} ${p.name}`,
@@ -2474,7 +2624,7 @@ async function resolveProject(client, site, arg, strict) {
2474
2624
  pageSize: 15
2475
2625
  });
2476
2626
  }
2477
- async function resolveType(project, types, arg, strict) {
2627
+ async function resolveType(ask, project, types, arg, strict) {
2478
2628
  if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
2479
2629
  if (arg) {
2480
2630
  const found = matchType(types, arg);
@@ -2483,7 +2633,7 @@ async function resolveType(project, types, arg, strict) {
2483
2633
  throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
2484
2634
  }
2485
2635
  if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
2486
- return select({
2636
+ return ask.pick({
2487
2637
  message: "Issue type:",
2488
2638
  choices: types.map((t) => ({
2489
2639
  name: t.name,
@@ -2548,7 +2698,7 @@ function matchCascading(field, value) {
2548
2698
  }
2549
2699
  //#endregion
2550
2700
  //#region src/create/encode.ts
2551
- const SERVER_FILLED = new Set([
2701
+ const SERVER_FILLED = /* @__PURE__ */ new Set([
2552
2702
  "project",
2553
2703
  "issuetype",
2554
2704
  "reporter"
@@ -2628,7 +2778,7 @@ async function encodeField(field, values, resolveUser) {
2628
2778
  }
2629
2779
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
2630
2780
  const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
2631
- const NAMED_TYPES = new Set([
2781
+ const NAMED_TYPES = /* @__PURE__ */ new Set([
2632
2782
  "priority",
2633
2783
  "version",
2634
2784
  "component",
@@ -2809,16 +2959,16 @@ function formatIssueTypeRows(types) {
2809
2959
  //#endregion
2810
2960
  //#region src/create/prompt.ts
2811
2961
  const PROMPT_SOURCE = "prompt";
2812
- async function walkFields(meta, deps) {
2962
+ async function walkFields(ask, meta, deps) {
2813
2963
  const firstPass = meta.filter((f) => f.required && !SERVER_FILLED.has(f.fieldId));
2814
2964
  const inputs = [];
2815
2965
  for (const field of firstPass) {
2816
- const answer = await promptField(field, deps, true);
2966
+ const answer = await promptField(ask, field, deps, true);
2817
2967
  if (answer) inputs.push(answer);
2818
2968
  }
2819
2969
  const optional = meta.filter((f) => !firstPass.includes(f) && !SERVER_FILLED.has(f.fieldId));
2820
2970
  if (optional.length === 0) return inputs;
2821
- const chosen = await checkbox({
2971
+ const chosen = await ask.pickMany({
2822
2972
  message: "Set any optional fields?",
2823
2973
  choices: optional.map((f) => ({
2824
2974
  name: `${f.name} (${fieldTypeLabel(f.schema)})`,
@@ -2827,13 +2977,13 @@ async function walkFields(meta, deps) {
2827
2977
  pageSize: 15
2828
2978
  });
2829
2979
  for (const field of optional.filter((f) => chosen.includes(f.fieldId))) {
2830
- const answer = await promptField(field, deps, false);
2980
+ const answer = await promptField(ask, field, deps, false);
2831
2981
  if (answer) inputs.push(answer);
2832
2982
  }
2833
2983
  return inputs;
2834
2984
  }
2835
- async function promptField(field, deps, required) {
2836
- const answer = await askValues(field, deps, required);
2985
+ async function promptField(ask, field, deps, required) {
2986
+ const answer = await askValues(ask, field, deps, required);
2837
2987
  if (answer.values.length === 0) return void 0;
2838
2988
  return {
2839
2989
  name: field.fieldId,
@@ -2846,14 +2996,14 @@ const SKIP = {
2846
2996
  name: "(skip)",
2847
2997
  value: ""
2848
2998
  };
2849
- async function askValues(field, deps, required) {
2999
+ async function askValues(ask, field, deps, required) {
2850
3000
  const { schema } = field;
2851
3001
  const message = `${field.name}:`;
2852
3002
  const options = field.allowedValues ?? [];
2853
- if (schema.type === "option-with-child") return askCascading(field, required);
3003
+ if (schema.type === "option-with-child") return askCascading(ask, field, required);
2854
3004
  if (options.length > 0 && schema.type === "array") {
2855
3005
  const defaults = new Set(defaultIds(field));
2856
- return { values: await checkbox({
3006
+ return { values: await ask.pickMany({
2857
3007
  message,
2858
3008
  choices: options.map((v) => ({
2859
3009
  ...choice(v),
@@ -2865,7 +3015,7 @@ async function askValues(field, deps, required) {
2865
3015
  }
2866
3016
  if (options.length > 0) {
2867
3017
  const choices = options.map(choice);
2868
- const picked = await select({
3018
+ const picked = await ask.pick({
2869
3019
  message,
2870
3020
  choices: required ? choices : [SKIP, ...choices],
2871
3021
  default: defaultIds(field)[0],
@@ -2873,9 +3023,9 @@ async function askValues(field, deps, required) {
2873
3023
  });
2874
3024
  return { values: picked ? [picked] : [] };
2875
3025
  }
2876
- if (schema.type === "user") return askUser(field, deps, required);
3026
+ if (schema.type === "user") return askUser(ask, field, deps, required);
2877
3027
  if (isMultiLine(schema)) {
2878
- const text = await editor({
3028
+ const text = await ask.edit({
2879
3029
  message: `${field.name} (opens your editor, Markdown):`,
2880
3030
  postfix: ".md",
2881
3031
  default: primitiveDefault(field),
@@ -2884,7 +3034,7 @@ async function askValues(field, deps, required) {
2884
3034
  return { values: text.trim() ? [text] : [] };
2885
3035
  }
2886
3036
  const hint = inputHint(field);
2887
- const text = await input({
3037
+ const text = await ask.text({
2888
3038
  message: hint ? `${field.name} (${hint}):` : message,
2889
3039
  default: primitiveDefault(field),
2890
3040
  validate: async (v) => {
@@ -2922,10 +3072,10 @@ function primitiveDefault(field) {
2922
3072
  if (!field.hasDefaultValue || d == null) return void 0;
2923
3073
  return typeof d === "string" || typeof d === "number" ? String(d) : void 0;
2924
3074
  }
2925
- async function askCascading(field, required) {
3075
+ async function askCascading(ask, field, required) {
2926
3076
  const options = field.allowedValues ?? [];
2927
3077
  const parentChoices = options.map(choice);
2928
- const parentId = await select({
3078
+ const parentId = await ask.pick({
2929
3079
  message: `${field.name}:`,
2930
3080
  choices: required ? parentChoices : [SKIP, ...parentChoices],
2931
3081
  pageSize: 15
@@ -2934,7 +3084,7 @@ async function askCascading(field, required) {
2934
3084
  const parent = options.find((v) => v.id === parentId);
2935
3085
  const children = parent?.children ?? [];
2936
3086
  if (children.length === 0) return { values: [parentId] };
2937
- const childId = await select({
3087
+ const childId = await ask.pick({
2938
3088
  message: `${field.name} > ${allowedLabel(parent)}:`,
2939
3089
  choices: [{
2940
3090
  name: "(none)",
@@ -2952,10 +3102,10 @@ const UNASSIGNED = {
2952
3102
  name: "(unassigned)",
2953
3103
  value: ""
2954
3104
  };
2955
- async function askUser(field, deps, required) {
3105
+ async function askUser(ask, field, deps, required) {
2956
3106
  const fixed = required ? [ME] : [ME, UNASSIGNED];
2957
3107
  const seen = /* @__PURE__ */ new Map();
2958
- const picked = await search({
3108
+ const picked = await ask.searchPick({
2959
3109
  message: `${field.name} (type to search):`,
2960
3110
  source: async (term) => {
2961
3111
  if (!term) return fixed;
@@ -2997,7 +3147,7 @@ function summarizeText(text) {
2997
3147
  }
2998
3148
  //#endregion
2999
3149
  //#region src/commands/jira-create.ts
3000
- async function flagInputs(options) {
3150
+ async function flagInputs(files, options) {
3001
3151
  const inputs = [];
3002
3152
  const one = (name, value, source) => {
3003
3153
  if (value !== void 0) inputs.push({
@@ -3010,7 +3160,7 @@ async function flagInputs(options) {
3010
3160
  one("description", options.description, "--description");
3011
3161
  if (options.descriptionFile !== void 0) {
3012
3162
  if (options.description !== void 0) throw new Error("--description and --description-file cannot be used together.");
3013
- one("description", await readFile(options.descriptionFile, "utf8"), "--description-file");
3163
+ one("description", await files.readText(options.descriptionFile), "--description-file");
3014
3164
  }
3015
3165
  one("assignee", options.assignee, "--assignee");
3016
3166
  one("priority", options.priority, "--priority");
@@ -3037,17 +3187,16 @@ function parseFieldFlag(raw) {
3037
3187
  source: "--field"
3038
3188
  };
3039
3189
  }
3040
- async function jiraCreate(projectArg, typeArg, options) {
3041
- const inputs = await flagInputs(options);
3042
- const strict = inputs.length > 0 || options.input === false || !process.stdin.isTTY;
3043
- const auth = await requireAuth();
3044
- const client = new AtlassianClient(auth);
3045
- const project = await resolveProject(client, auth.site, projectArg, strict);
3046
- const type = await resolveType(project, await fetchCreateIssueTypes(client, project), typeArg, strict);
3047
- const meta = await fetchCreateFields(client, project, type.id);
3048
- const resolveUser = userResolver(client, project);
3049
- if (!strict) inputs.push(...await walkFields(meta, {
3050
- searchUsers: (query) => searchAssignableUsers(client, project, query),
3190
+ async function jiraCreate({ session, term, files }, projectArg, typeArg, options) {
3191
+ const inputs = await flagInputs(files, options);
3192
+ const strict = inputs.length > 0 || !term.interactive;
3193
+ const project = await resolveProject(term.ask, session, session.site, projectArg, strict);
3194
+ const types = await fetchCreateIssueTypes(session, project);
3195
+ const type = await resolveType(term.ask, project, types, typeArg, strict);
3196
+ const meta = await fetchCreateFields(session, project, type.id);
3197
+ const resolveUser = userResolver(session, project);
3198
+ if (!strict) inputs.push(...await walkFields(term.ask, meta, {
3199
+ searchUsers: (query) => searchAssignableUsers(session, project, query),
3051
3200
  validate: async (field, value) => {
3052
3201
  return (await encodeCreate([field], [{
3053
3202
  name: field.fieldId,
@@ -3065,23 +3214,23 @@ async function jiraCreate(projectArg, typeArg, options) {
3065
3214
  ...result.fields
3066
3215
  };
3067
3216
  if (options.dryRun) {
3068
- console.log(JSON.stringify({ fields }, null, 2));
3217
+ term.json({ fields });
3069
3218
  return;
3070
3219
  }
3071
3220
  if (!strict) {
3072
- for (const line of formatReview(meta, inputs)) console.log(line);
3073
- if (!await confirm({
3221
+ term.out(formatReview(meta, inputs));
3222
+ if (!await term.ask.confirm({
3074
3223
  message: `Create ${project} ${type.name}?`,
3075
3224
  default: true
3076
3225
  })) {
3077
- console.log("Aborted.");
3226
+ term.out("Aborted.");
3078
3227
  return;
3079
3228
  }
3080
3229
  }
3081
3230
  try {
3082
- const created = await createIssue(client, auth.site, fields);
3083
- if (options.json) console.log(JSON.stringify(created, null, 2));
3084
- else console.log(`Created ${created.key} ${created.url}`);
3231
+ const created = await createIssue(session, session.site, fields);
3232
+ if (options.json) term.json(created);
3233
+ else term.out(`Created ${created.key} ${created.url}`);
3085
3234
  } catch (err) {
3086
3235
  throw describeRejection(err, meta);
3087
3236
  }
@@ -3107,19 +3256,18 @@ function describeRejection(err, meta) {
3107
3256
  ...perField
3108
3257
  ].join("\n"));
3109
3258
  }
3110
- async function jiraFields(projectArg, typeArg, options) {
3111
- const client = new AtlassianClient(await requireAuth());
3259
+ async function jiraFields({ session, term }, projectArg, typeArg, options) {
3112
3260
  const project = projectArg.toUpperCase();
3113
- const types = await fetchCreateIssueTypes(client, project);
3261
+ const types = await fetchCreateIssueTypes(session, project);
3114
3262
  if (!typeArg) {
3115
- if (options.json) console.log(JSON.stringify(types, null, 2));
3116
- else if (types.length === 0) console.log(`You cannot create issues in ${project}.`);
3117
- else for (const line of formatIssueTypeRows(types)) console.log(line);
3263
+ if (options.json) term.json(types);
3264
+ else if (types.length === 0) term.out(`You cannot create issues in ${project}.`);
3265
+ else term.out(formatIssueTypeRows(types));
3118
3266
  return;
3119
3267
  }
3120
- const fields = await fetchCreateFields(client, project, (await resolveType(project, types, typeArg, true)).id);
3121
- if (options.json) console.log(JSON.stringify(fields, null, 2));
3122
- else for (const line of formatFieldRows(fields)) console.log(line);
3268
+ const fields = await fetchCreateFields(session, project, (await resolveType(term.ask, project, types, typeArg, true)).id);
3269
+ if (options.json) term.json(fields);
3270
+ else term.out(formatFieldRows(fields));
3123
3271
  }
3124
3272
  //#endregion
3125
3273
  //#region src/api/jira-search.ts
@@ -3295,47 +3443,34 @@ function dedupeAndSortStatuses(statuses) {
3295
3443
  }
3296
3444
  //#endregion
3297
3445
  //#region src/commands/jira.ts
3298
- async function jiraProjects(query, options) {
3299
- const auth = await requireAuth();
3300
- const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
3301
- if (options.json) {
3302
- console.log(JSON.stringify(projects, null, 2));
3303
- return;
3304
- }
3305
- if (projects.length === 0) {
3306
- console.log("No matching projects.");
3307
- return;
3308
- }
3309
- for (const line of formatProjectRows(projects)) console.log(line);
3446
+ async function jiraProjects({ session, term }, query, options) {
3447
+ const projects = await listProjects(session, session.site, query);
3448
+ if (options.json) term.json(projects);
3449
+ else if (projects.length === 0) term.out("No matching projects.");
3450
+ else term.out(formatProjectRows(projects));
3310
3451
  }
3311
3452
  function formatProjectRows(projects) {
3312
3453
  const width = Math.max(...projects.map((p) => p.key.length));
3313
3454
  return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
3314
3455
  }
3315
- async function jiraStatuses(query, options) {
3316
- let statuses = await listStatuses(new AtlassianClient(await requireAuth()), options.project);
3456
+ async function jiraStatuses({ session, term }, query, options) {
3457
+ let statuses = await listStatuses(session, options.project);
3317
3458
  if (query) {
3318
3459
  const needle = query.toLowerCase();
3319
3460
  statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
3320
3461
  }
3321
- if (options.json) {
3322
- console.log(JSON.stringify(statuses, null, 2));
3323
- return;
3324
- }
3325
- if (statuses.length === 0) {
3326
- console.log("No matching statuses.");
3327
- return;
3328
- }
3329
- for (const line of formatStatusRows(statuses)) console.log(line);
3462
+ if (options.json) term.json(statuses);
3463
+ else if (statuses.length === 0) term.out("No matching statuses.");
3464
+ else term.out(formatStatusRows(statuses));
3330
3465
  }
3331
3466
  function formatStatusRows(statuses) {
3332
3467
  const width = Math.max(...statuses.map((s) => s.name.length));
3333
3468
  return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
3334
3469
  }
3335
- async function jiraView(arg, options) {
3336
- const auth = await requireAuth();
3337
- const key = await resolveRef(arg, ISSUE_REF);
3338
- await printPaged(formatIssueView(await fetchIssue(new AtlassianClient(auth), auth.site, key), Date.now(), options.allComments ?? false).join("\n"), { pager: options.pager });
3470
+ async function jiraView({ session, term }, arg, options) {
3471
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3472
+ const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
3473
+ await term.page(lines.join("\n"), { pager: options.pager });
3339
3474
  }
3340
3475
  const CATEGORY_COLORS = {
3341
3476
  new: kleur.cyan,
@@ -3365,46 +3500,44 @@ function formatIssueView(issue, nowMs, allComments) {
3365
3500
  ...attachmentSection(issue.attachments)
3366
3501
  ];
3367
3502
  }
3368
- async function jiraCopy(arg, options) {
3369
- const auth = await requireAuth();
3370
- const key = await resolveRef(arg, ISSUE_REF);
3371
- await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
3503
+ async function jiraCopy(env, arg, options) {
3504
+ await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
3372
3505
  }
3373
- async function jiraUpdate(arg, options) {
3374
- const src = parseIssueSource(await readFile(arg ?? await input({
3506
+ async function jiraUpdate({ session, term, files }, arg, options) {
3507
+ const file = arg ?? await term.ask.text({
3375
3508
  message: "Path to the issue Markdown file:",
3509
+ flag: "[file]",
3376
3510
  required: true
3377
- }), "utf8"));
3378
- const auth = await requireAuth();
3379
- const client = new AtlassianClient(auth);
3380
- const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
3381
- await runPlan(plan, options, async () => {
3511
+ });
3512
+ const src = parseIssueSource(await files.readText(file));
3513
+ const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
3514
+ await runPlan(term, plan, options, async () => {
3382
3515
  const { current, next } = plan.headline;
3383
- await updateIssue(client, src.key, {
3516
+ await updateIssue(session, src.key, {
3384
3517
  description: plan.body,
3385
3518
  summary: next !== current ? next : void 0
3386
3519
  });
3387
- console.log(`Updated ${src.key}.`);
3520
+ term.out(`Updated ${src.key}.`);
3388
3521
  });
3389
3522
  }
3390
- async function copyIssue(client, site, key, out) {
3391
- console.log(`Fetching ${key} ...`);
3392
- await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
3523
+ async function copyIssue(env, key, out) {
3524
+ env.term.err(`Fetching ${key} ...`);
3525
+ await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
3393
3526
  }
3394
3527
  const ISSUE_REF = {
3395
3528
  message: "Jira issue key or URL:",
3529
+ flag: "[issue]",
3396
3530
  parse: parseIssueKey,
3397
3531
  notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3398
3532
  };
3399
3533
  //#endregion
3400
3534
  //#region src/commands/jira-search.ts
3401
- async function jiraSearch(query, options) {
3535
+ async function jiraSearch(env, query, options) {
3536
+ const { session, term } = env;
3402
3537
  if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
3403
3538
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3404
- const auth = await requireAuth();
3405
- const client = new AtlassianClient(auth);
3406
3539
  const limit = parseLimit(options.limit);
3407
- const issues = await searchIssues(client, auth.site, {
3540
+ const issues = await searchIssues(session, session.site, {
3408
3541
  text: query,
3409
3542
  project: options.project,
3410
3543
  assignee: options.assignee,
@@ -3412,29 +3545,28 @@ async function jiraSearch(query, options) {
3412
3545
  jql: options.jql,
3413
3546
  limit
3414
3547
  });
3415
- await runSearch(formatIssueRows(issues, Date.now()), {
3548
+ await runSearch(term, formatIssueRows(issues, Date.now()), {
3416
3549
  json: options.json,
3417
3550
  copy: options.copy,
3418
3551
  out: options.out,
3419
3552
  empty: "No matching issues.",
3420
3553
  footer: issues.length === limit ? searchFooter(limit) : void 0
3421
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3554
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3422
3555
  }
3423
- async function jiraList(options) {
3556
+ async function jiraList(env, options) {
3557
+ const { session, term } = env;
3424
3558
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3425
- const auth = await requireAuth();
3426
- const client = new AtlassianClient(auth);
3427
- const { issues, truncated } = await listAssignedIssues(client, auth.site, {
3559
+ const { issues, truncated } = await listAssignedIssues(session, session.site, {
3428
3560
  all: options.all,
3429
3561
  project: options.project
3430
3562
  });
3431
- await runSearch(formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3563
+ await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3432
3564
  json: options.json,
3433
3565
  copy: options.copy,
3434
3566
  out: options.out,
3435
3567
  empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
3436
3568
  footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
3437
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3569
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3438
3570
  }
3439
3571
  function formatIssueRows(issues, nowMs) {
3440
3572
  return alignedRows(issues, nowMs, (i) => ({
@@ -3457,20 +3589,20 @@ function collect(value, previous = []) {
3457
3589
  }
3458
3590
  function registerJira(jira) {
3459
3591
  jira.description("Jira commands");
3460
- jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
3461
- jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(run(jiraStatuses));
3462
- jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--no-input", "never prompt; fail if a required field is missing").option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(run(jiraCreate));
3463
- jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(run(jiraFields));
3464
- jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(run(jiraView));
3465
- jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(jiraCopy));
3466
- jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(run(jiraUpdate));
3467
- jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraList));
3468
- jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraSearch));
3592
+ jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
3593
+ jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(jiraAction(jiraStatuses));
3594
+ jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
3595
+ jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
3596
+ jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(jiraView));
3597
+ jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(jiraAction(jiraCopy));
3598
+ jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(jiraUpdate));
3599
+ jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(jiraList));
3600
+ jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(jiraSearch));
3469
3601
  return jira;
3470
3602
  }
3471
3603
  //#endregion
3472
3604
  //#region package.json
3473
- var version = "1.12.0";
3605
+ var version = "1.13.0";
3474
3606
  //#endregion
3475
3607
  //#region src/cli/build.ts
3476
3608
  const PRODUCTS = {
@@ -3478,16 +3610,22 @@ const PRODUCTS = {
3478
3610
  confluence: registerConfluence,
3479
3611
  bitbucket: registerBitbucket
3480
3612
  };
3613
+ const NO_INPUT = "never prompt; fail if a required value is missing";
3614
+ function withNoInput(command) {
3615
+ command.option("--no-input", NO_INPUT);
3616
+ for (const sub of command.commands) withNoInput(sub);
3617
+ return command;
3618
+ }
3481
3619
  function buildAtlass() {
3482
3620
  const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
3483
3621
  registerAuth(program.command("auth"));
3484
3622
  registerJira(program.command("jira"));
3485
3623
  registerConfluence(program.command("confluence").alias("conf"));
3486
3624
  registerBitbucket(program.command("bitbucket").alias("bb"));
3487
- return program;
3625
+ return withNoInput(program);
3488
3626
  }
3489
3627
  function buildStandalone(product) {
3490
- return PRODUCTS[product](new Command().name(product).version(version));
3628
+ return withNoInput(PRODUCTS[product](new Command().name(product).version(version)));
3491
3629
  }
3492
3630
  //#endregion
3493
3631
  export { buildStandalone as n, fail as r, buildAtlass as t };