atlass 1.11.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";
5
+ import { homedir } from "node:os";
6
+ import { spawn } from "node:child_process";
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import { checkbox, confirm, editor, input, password, search, select } from "@inquirer/prompts";
7
9
  import kleur from "kleur";
8
10
  import { marked } from "marked";
9
11
  import { randomUUID } from "node:crypto";
10
- import { stripVTControlCharacters } from "node:util";
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,331 +140,310 @@ 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
- }
179
- }
180
- async function writeConfig(config) {
181
- await mkdir(configDir(), { recursive: true });
182
- await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
224
+ function entry(email, kind) {
225
+ return new Entry(SERVICE, kind === "bitbucket" ? `${email}:bitbucket` : email);
183
226
  }
184
- async function clearConfig() {
185
- await rm(configPath(), { force: true });
186
- }
187
- function siteOrigin(input) {
188
- let value = input.trim();
189
- if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
190
- return new URL(value).origin;
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
+ };
191
262
  }
192
263
  //#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);
198
- }
199
- function deleteEntryIfPresent(key) {
200
- try {
201
- entry(key).deleteCredential();
202
- } catch {
203
- return;
204
- }
205
- }
206
- function saveToken(email, token) {
207
- entry(email).setPassword(token);
208
- }
209
- function readToken(email) {
210
- return entry(email).getPassword();
211
- }
212
- function deleteToken(email) {
213
- deleteEntryIfPresent(email);
214
- }
215
- function bitbucketKey(email) {
216
- return `${email}:bitbucket`;
217
- }
218
- function saveBitbucketToken(email, token) {
219
- entry(bitbucketKey(email)).setPassword(token);
220
- }
221
- function readBitbucketToken(email) {
222
- return entry(bitbucketKey(email)).getPassword();
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);
223
272
  }
224
- function deleteBitbucketToken(email) {
225
- deleteEntryIfPresent(bitbucketKey(email));
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;
226
277
  }
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.");
278
+ function pagerCommand(env) {
279
+ const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
232
280
  return {
233
- site: config.site,
234
- email: config.email,
235
- token
281
+ command,
282
+ args,
283
+ env: {
284
+ ...env,
285
+ LESS: env["LESS"] ?? DEFAULT_LESS
286
+ }
236
287
  };
237
288
  }
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
- };
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));
250
292
  }
251
- //#endregion
252
- //#region src/commands/auth.ts
253
- async function login() {
254
- const site = siteOrigin(await input({
255
- message: "Atlassian site (e.g. acme.atlassian.net):",
256
- required: true
257
- }));
258
- const email = await input({
259
- message: "Account email:",
260
- required: true
261
- });
262
- const token = await password({
263
- message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
264
- mask: true
265
- });
266
- const me = await new AtlassianClient({
267
- site,
268
- email,
269
- token
270
- }).getJson("/rest/api/3/myself");
271
- await writeConfig({
272
- ...await readConfig() ?? {},
273
- site,
274
- email
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`);
275
307
  });
276
- saveToken(email, token);
277
- console.log(`Logged in as ${me.displayName} on ${site}.`);
278
- }
279
- async function logout() {
280
- const config = await readConfig();
281
- if (config?.email) deleteToken(config.email);
282
- const bitbucketLogin = config?.bitbucket && config.email ? {
283
- email: config.email,
284
- bitbucket: config.bitbucket
285
- } : null;
286
- if (bitbucketLogin) await writeConfig(bitbucketLogin);
287
- else await clearConfig();
288
- console.log("Logged out. Credentials removed.");
289
308
  }
290
- async function status() {
291
- const config = await readConfig();
292
- if (!config || !config.site || !config.email) {
293
- console.log("Not logged in. Run `atlass auth login`.");
294
- return;
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";
295
315
  }
296
- const hasToken = readToken(config.email) !== null;
297
- console.log(`Site: ${config.site}`);
298
- console.log(`Email: ${config.email}`);
299
- console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
300
- }
316
+ };
301
317
  //#endregion
302
- //#region src/cli/auth.ts
303
- function registerAuth(auth) {
304
- auth.description("Manage Atlassian credentials");
305
- auth.command("login").description("Store site, email, and API token").action(run(login));
306
- auth.command("logout").description("Remove stored credentials").action(run(logout));
307
- auth.command("status").description("Show the current login").action(run(status));
308
- return auth;
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
+ };
309
395
  }
310
396
  //#endregion
311
- //#region src/api/bitbucket.ts
312
- const BITBUCKET_MAX_PAGELEN = 100;
313
- const BUILD_NUMBER_SCAN_LIMIT = 1e3;
314
- function pipelineStatus(state) {
315
- if (!state) return "";
316
- if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
317
- return state.name ?? "";
397
+ //#region src/terminal/open.ts
398
+ function openTerminal(args, stdin) {
399
+ return ttyTerminal(stdin.isTTY === true && !refusedInput(args.at(-1)));
318
400
  }
319
- function pipelinesQuery(limit) {
320
- return new URLSearchParams({
321
- sort: "-created_on",
322
- pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
323
- }).toString();
401
+ function refusedInput(last) {
402
+ for (let command = asCommand(last); command; command = command.parent) if (command.opts()["input"] === false) return true;
403
+ return false;
324
404
  }
325
- function wallClockSeconds(startOn, endOn) {
326
- if (!startOn || !endOn) return null;
327
- const start = Date.parse(startOn);
328
- const end = Date.parse(endOn);
329
- if (Number.isNaN(start) || Number.isNaN(end)) return null;
330
- return Math.floor((end - start) / 1e3);
405
+ function asCommand(value) {
406
+ const command = value;
407
+ return typeof command?.opts === "function" ? command : null;
331
408
  }
332
- function shortHash(hash) {
333
- return hash?.slice(0, 7) ?? "";
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
+ };
334
439
  }
335
- function toPipelineSummary(p) {
336
- return {
337
- buildNumber: p.build_number,
338
- status: pipelineStatus(p.state),
339
- ref: p.target?.ref_name ?? "",
340
- commit: shortHash(p.target?.commit?.hash),
341
- durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
342
- createdOn: p.created_on ?? "",
343
- creator: p.creator?.display_name ?? "",
344
- uuid: p.uuid
345
- };
346
- }
347
- function toPipelineDetail(p) {
348
- return {
349
- ...toPipelineSummary(p),
350
- repo: p.repository?.full_name ?? "",
351
- trigger: p.trigger?.name ?? ""
352
- };
353
- }
354
- function toStepSummary(step) {
355
- return {
356
- name: step.name ?? "",
357
- status: pipelineStatus(step.state),
358
- durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
359
- };
360
- }
361
- function repoPath(ref) {
362
- return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
363
- }
364
- async function* walkPages(client, firstPath) {
365
- let path = firstPath;
366
- while (path) {
367
- const page = await client.getJson(path);
368
- for (const value of page.values ?? []) yield value;
369
- path = page.next ? pathAndQuery(page.next) : null;
370
- }
371
- }
372
- async function listPipelines(client, ref, limit) {
373
- const out = [];
374
- const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
375
- for await (const value of walkPages(client, first)) {
376
- out.push(toPipelineSummary(value));
377
- if (out.length >= limit) break;
378
- }
379
- return out;
380
- }
381
- async function getPipeline(client, ref, buildNumber) {
382
- const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
383
- if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
384
- return toPipelineDetail(found);
385
- }
386
- async function fetchByBuildNumber(client, ref, buildNumber) {
387
- try {
388
- return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
389
- } catch (err) {
390
- if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
391
- throw err;
392
- }
393
- }
394
- async function findInRecentRuns(client, ref, buildNumber) {
395
- const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
396
- let scanned = 0;
397
- for await (const pipeline of walkPages(client, first)) {
398
- if (pipeline.build_number === buildNumber) return pipeline;
399
- if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
400
- }
401
- return null;
402
- }
403
- async function listSteps(client, ref, pipelineId) {
404
- const out = [];
405
- const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
406
- for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
407
- return out;
408
- }
409
- //#endregion
410
- //#region src/util/format.ts
411
- function formatDuration(seconds) {
412
- if (seconds == null) return "-";
413
- if (seconds < 60) return `${seconds}s`;
414
- const m = Math.floor(seconds / 60);
415
- const s = seconds % 60;
416
- if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
417
- return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
418
- }
419
- function formatDateTime(iso) {
420
- const date = new Date(iso);
421
- if (Number.isNaN(date.getTime())) return iso;
422
- return date.toISOString().replace("T", " ").slice(0, 16);
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);
423
444
  }
424
- const MINUTE = 60;
425
- const HOUR = 60 * MINUTE;
426
- const DAY = 24 * HOUR;
427
- function relativeTime(iso, nowMs) {
428
- if (!iso) return "-";
429
- const then = Date.parse(iso);
430
- if (Number.isNaN(then)) return "-";
431
- const secs = Math.floor((nowMs - then) / 1e3);
432
- if (secs < MINUTE) return "just now";
433
- if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
434
- if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
435
- const days = Math.floor(secs / DAY);
436
- if (days < 45) return `${days}d ago`;
437
- if (days < 365) return `${Math.floor(days / 30)}mo ago`;
438
- return `${Math.floor(days / 365)}y ago`;
445
+ function isPromptInterrupt(err) {
446
+ return err instanceof Error && err.name === "ExitPromptError";
439
447
  }
440
448
  //#endregion
441
449
  //#region src/util/parse.ts
@@ -486,201 +494,664 @@ function parseLimit(value) {
486
494
  if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
487
495
  return Math.min(n, MAX_LIMIT);
488
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;
501
+ }
489
502
  //#endregion
490
- //#region src/commands/bitbucket.ts
491
- async function bitbucketLogin() {
492
- const existing = await readConfig() ?? {};
493
- const email = existing.email ?? await input({
503
+ //#region src/commands/auth.ts
504
+ async function login({ term, profile }) {
505
+ const site = siteOrigin(await term.ask.text({
506
+ message: "Atlassian site (e.g. acme.atlassian.net):",
507
+ required: true
508
+ }));
509
+ const email = await term.ask.text({
510
+ message: "Account email:",
511
+ required: true
512
+ });
513
+ const token = await term.ask.secret({
514
+ message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
515
+ mask: true
516
+ });
517
+ const me = await sessionFor({
518
+ site,
519
+ email,
520
+ token
521
+ }).getJson("/rest/api/3/myself");
522
+ const existing = await profile.read() ?? {};
523
+ await profile.write({
524
+ ...existing,
525
+ site,
526
+ email
527
+ });
528
+ await profile.setToken(email, "atlassian", token);
529
+ term.out(`Logged in as ${me.displayName} on ${site}.`);
530
+ }
531
+ async function logout({ term, profile }) {
532
+ const config = await profile.read();
533
+ if (config?.email) await profile.deleteToken(config.email, "atlassian");
534
+ const bitbucketLogin = config?.bitbucket && config.email ? {
535
+ email: config.email,
536
+ bitbucket: config.bitbucket
537
+ } : null;
538
+ if (bitbucketLogin) await profile.write(bitbucketLogin);
539
+ else await profile.clear();
540
+ term.out("Logged out. Credentials removed.");
541
+ }
542
+ async function status({ term, profile }) {
543
+ const config = await profile.read();
544
+ if (!config || !config.site || !config.email) {
545
+ term.out("Not logged in. Run `atlass auth login`.");
546
+ return;
547
+ }
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
+ ]);
554
+ }
555
+ //#endregion
556
+ //#region src/cli/auth.ts
557
+ function registerAuth(auth) {
558
+ auth.description("Manage Atlassian credentials");
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));
562
+ return auth;
563
+ }
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({
494
576
  message: "Account email:",
495
577
  required: true
496
578
  });
497
- const workspace = (await input({
579
+ const workspace = (await term.ask.text({
498
580
  message: "Bitbucket workspace (e.g. acme):",
499
581
  required: true
500
582
  })).trim();
501
- const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
502
- const token = await password({
503
- message: "Bitbucket API token (needs read:pipeline + workspace read scopes):",
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):",
504
586
  mask: true
505
587
  });
506
- const ws = await verifyWorkspace(new AtlassianClient({
507
- site: BITBUCKET_ORIGIN,
508
- email,
509
- token
510
- }), workspace);
511
- await writeConfig({
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({
512
592
  ...existing,
513
593
  email,
514
594
  bitbucket: {
515
595
  workspace,
516
- ...defaultRepo ? { defaultRepo } : {}
596
+ ...defaultRepo ? { defaultRepo } : {},
597
+ ...uuid ? { uuid } : {}
517
598
  }
518
599
  });
519
- saveBitbucketToken(email, token);
520
- console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
600
+ await profile.setToken(email, "bitbucket", token);
601
+ term.out(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
521
602
  }
522
- async function bitbucketLogout() {
523
- const config = await readConfig();
524
- if (config?.email) deleteBitbucketToken(config.email);
603
+ async function bitbucketLogout({ term, profile }) {
604
+ const config = await profile.read();
605
+ if (config?.email) await profile.deleteToken(config.email, "bitbucket");
525
606
  const jiraLogin = config?.site && config.email ? {
526
607
  site: config.site,
527
608
  email: config.email
528
609
  } : null;
529
- if (jiraLogin) await writeConfig(jiraLogin);
530
- else await clearConfig();
531
- console.log("Logged out of Bitbucket. Credentials removed.");
610
+ if (jiraLogin) await profile.write(jiraLogin);
611
+ else await profile.clear();
612
+ term.out("Logged out of Bitbucket. Credentials removed.");
532
613
  }
533
- async function bitbucketStatus() {
534
- const config = await readConfig();
614
+ async function bitbucketStatus({ term, profile }) {
615
+ const config = await profile.read();
535
616
  if (!config?.email || !config.bitbucket?.workspace) {
536
- console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
537
- return;
538
- }
539
- const hasToken = readBitbucketToken(config.email) !== null;
540
- console.log(`Workspace: ${config.bitbucket.workspace}`);
541
- console.log(`Email: ${config.email}`);
542
- console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
543
- console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
544
- }
545
- async function bitbucketPipelines(options) {
546
- const auth = await requireBitbucketAuth();
547
- const ref = resolveRepo(options.repo, auth);
548
- const client = new AtlassianClient(auth);
549
- const limit = parseLimit(options.limit);
550
- const pipelines = await withScopeHint(() => listPipelines(client, ref, limit));
551
- if (options.json) {
552
- console.log(JSON.stringify(pipelines, null, 2));
553
- return;
554
- }
555
- if (pipelines.length === 0) {
556
- console.log("No pipelines found.");
617
+ term.out("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
557
618
  return;
558
619
  }
559
- for (const line of formatPipelineRows(pipelines, Date.now())) console.log(line);
560
- }
561
- async function bitbucketPipeline(arg, options) {
562
- const buildNumber = parseBuildNumber(arg);
563
- const auth = await requireBitbucketAuth();
564
- const ref = resolveRepo(options.repo, auth);
565
- const client = new AtlassianClient(auth);
566
- const detail = await withScopeHint(() => getPipeline(client, ref, buildNumber));
567
- printPipelineDetail(detail, await withScopeHint(() => listSteps(client, ref, detail.uuid)), Date.now());
568
- }
569
- function formatPipelineRows(pipelines, nowMs) {
570
- const rows = pipelines.map((p) => ({
571
- num: `#${p.buildNumber}`,
572
- status: p.status || "-",
573
- ref: p.ref || p.commit || "-",
574
- dur: formatDuration(p.durationSeconds),
575
- age: relativeTime(p.createdOn, nowMs),
576
- creator: p.creator || "-"
577
- }));
578
- const width = (sel) => Math.max(...rows.map((r) => sel(r).length));
579
- const wn = width((r) => r.num);
580
- const ws = width((r) => r.status);
581
- const wr = width((r) => r.ref);
582
- const wd = width((r) => r.dur);
583
- const wa = width((r) => r.age);
584
- return rows.map((r) => `${r.num.padEnd(wn)} ${r.status.padEnd(ws)} ${r.ref.padEnd(wr)} ${r.dur.padEnd(wd)} ${r.age.padEnd(wa)} ${r.creator}`);
585
- }
586
- function formatStepRows(steps) {
587
- const rows = steps.map((s) => ({
588
- name: s.name || "-",
589
- status: s.status || "-",
590
- dur: formatDuration(s.durationSeconds)
591
- }));
592
- const wn = Math.max(...rows.map((r) => r.name.length));
593
- const ws = Math.max(...rows.map((r) => r.status.length));
594
- return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
595
- }
596
- function printPipelineDetail(detail, steps, nowMs) {
597
- console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
598
- if (detail.repo) console.log(`Repo: ${detail.repo}`);
599
- console.log(`Ref: ${refWithCommit(detail)}`);
600
- if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
601
- console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
602
- const by = detail.creator ? ` by ${detail.creator}` : "";
603
- console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
604
- if (steps.length > 0) {
605
- console.log("");
606
- console.log("Steps:");
607
- for (const line of formatStepRows(steps)) console.log(line);
608
- }
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
+ ]);
609
627
  }
610
- function refWithCommit(detail) {
611
- if (!detail.ref) return detail.commit || "-";
612
- return detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref;
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
+ });
613
638
  }
614
- async function verifyWorkspace(client, workspace) {
639
+ async function verifyWorkspace(transport, workspace) {
615
640
  try {
616
- return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
641
+ return await transport.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
617
642
  } catch (err) {
618
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.`);
619
644
  if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
620
645
  throw err;
621
646
  }
622
647
  }
623
- function parseBuildNumber(arg) {
624
- const raw = (arg ?? "").replace(/^#/, "").trim();
625
- if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
626
- return Number.parseInt(raw, 10);
648
+ //#endregion
649
+ //#region src/api/bitbucket-repo.ts
650
+ const BITBUCKET_WEB_ORIGIN = "https://bitbucket.org";
651
+ function repoPath(ref, resource) {
652
+ return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/${resource}`;
627
653
  }
628
- async function withScopeHint(fn) {
629
- try {
630
- return await fn();
631
- } catch (err) {
632
- if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error("Bitbucket rejected the request (401/403). Check the token has the read:pipeline:bitbucket scope, or run `atlass bitbucket login` to update it.");
633
- throw err;
654
+ async function* walkPages(client, firstPath) {
655
+ let path = firstPath;
656
+ while (path) {
657
+ const page = await client.getJson(path);
658
+ for (const value of page.values ?? []) yield value;
659
+ path = page.next ? pathAndQuery(page.next) : null;
634
660
  }
635
661
  }
636
- //#endregion
637
- //#region src/cli/bitbucket.ts
638
- function registerBitbucket(bitbucket) {
639
- bitbucket.description("Bitbucket commands");
640
- bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
641
- bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
642
- bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
643
- 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));
644
- 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));
645
- return bitbucket;
662
+ async function collectPages(client, first, limit, map) {
663
+ const out = [];
664
+ for await (const value of walkPages(client, first)) {
665
+ out.push(map(value));
666
+ if (out.length >= limit) break;
667
+ }
668
+ return out;
646
669
  }
647
670
  //#endregion
648
- //#region src/adf/from-markdown.ts
649
- function mediaNode(source, alt) {
650
- const attrs = { ...source };
651
- if (alt) attrs["alt"] = alt;
671
+ //#region src/api/bitbucket-pull-requests.ts
672
+ const PULL_REQUEST_STATES = [
673
+ "OPEN",
674
+ "MERGED",
675
+ "DECLINED",
676
+ "SUPERSEDED"
677
+ ];
678
+ function parsePullRequestStates(values) {
679
+ const raw = (values ?? []).flatMap((value) => value.split(","));
680
+ const states = [];
681
+ for (const value of raw) {
682
+ const state = value.trim().toUpperCase();
683
+ if (!state) continue;
684
+ if (!PULL_REQUEST_STATES.includes(state)) throw new Error(`Invalid --state "${value.trim()}". Expected ${PULL_REQUEST_STATES.map((s) => s.toLowerCase()).join(", ").replace(/, (?=[^,]*$)/, ", or ")}.`);
685
+ if (!states.includes(state)) states.push(state);
686
+ }
687
+ return states;
688
+ }
689
+ function allPullRequestStates() {
690
+ return [...PULL_REQUEST_STATES];
691
+ }
692
+ const PRINCIPAL_UUID = /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i;
693
+ const PRINCIPAL_ACCOUNT_ID = /^(?:[0-9a-z_-]{2,}[:|][0-9a-z|_-]{8,}|[0-9a-f]{24})$/i;
694
+ function principalTerm(field, flag, value) {
695
+ if (PRINCIPAL_UUID.test(value)) return `${field}.uuid = "${value}"`;
696
+ if (PRINCIPAL_ACCOUNT_ID.test(value)) return `${field}.account_id = "${value}"`;
697
+ throw new Error(`Invalid ${flag} "${value}". Expected me, an account id, or a uuid in braces.`);
698
+ }
699
+ function pullRequestQuery(filters) {
700
+ if (filters.query) {
701
+ if (/\bstate\b/i.test(filters.query)) throw new Error("--query cannot mention state; use --state instead.");
702
+ return filters.query;
703
+ }
704
+ const terms = [];
705
+ if (filters.author) terms.push(principalTerm("author", "--author", filters.author));
706
+ if (filters.reviewer) terms.push(principalTerm("reviewers", "--reviewer", filters.reviewer));
707
+ if (terms.length === 0) return void 0;
708
+ return terms.length === 1 ? terms[0] : `(${terms.join(" OR ")})`;
709
+ }
710
+ function pullRequestsQuery(params) {
711
+ const pagelen = Math.min(Math.max(params.limit, 1), 100);
712
+ const search = new URLSearchParams({
713
+ sort: "-updated_on",
714
+ pagelen: String(pagelen)
715
+ });
716
+ for (const state of params.states) search.append("state", state);
717
+ if (params.query) search.append("q", params.query);
718
+ return search.toString();
719
+ }
720
+ function pullRequestUrl(ref, id) {
721
+ return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pull-requests/${id}`;
722
+ }
723
+ function toPullRequestSummary(ref, pr) {
652
724
  return {
653
- type: "mediaSingle",
654
- attrs: { layout: "center" },
655
- content: [{
656
- type: "media",
657
- attrs
658
- }]
725
+ id: pr.id,
726
+ title: pr.title ?? "",
727
+ state: pr.state ?? "",
728
+ draft: pr.draft ?? false,
729
+ author: pr.author?.display_name ?? "",
730
+ authorUuid: pr.author?.uuid ?? "",
731
+ sourceBranch: pr.source?.branch?.name ?? "",
732
+ destinationBranch: pr.destination?.branch?.name ?? "",
733
+ commentCount: pr.comment_count ?? 0,
734
+ createdOn: pr.created_on ?? "",
735
+ updatedOn: pr.updated_on ?? "",
736
+ url: pullRequestUrl(ref, pr.id)
659
737
  };
660
738
  }
661
- function imageHrefs(md) {
662
- const hrefs = /* @__PURE__ */ new Set();
663
- marked.walkTokens(marked.lexer(md), (token) => {
664
- if (token.type === "image") hrefs.add(token.href);
665
- });
666
- return [...hrefs];
739
+ async function listPullRequests(client, ref, params) {
740
+ return collectPages(client, `${repoPath(ref, "pullrequests")}?${pullRequestsQuery(params)}`, params.limit, (value) => toPullRequestSummary(ref, value));
667
741
  }
668
- function markdownToAdf(md, options = {}) {
669
- const ctx = { resolveImage: options.resolveImage ?? externalMedia };
742
+ //#endregion
743
+ //#region src/api/bitbucket-pipelines.ts
744
+ const BUILD_NUMBER_SCAN_LIMIT = 1e3;
745
+ function pipelineStatus(state) {
746
+ if (!state) return "";
747
+ if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
748
+ return state.name ?? "";
749
+ }
750
+ function pipelinesQuery(limit) {
751
+ return new URLSearchParams({
752
+ sort: "-created_on",
753
+ pagelen: String(Math.min(Math.max(limit, 1), 100))
754
+ }).toString();
755
+ }
756
+ function wallClockSeconds(startOn, endOn) {
757
+ if (!startOn || !endOn) return null;
758
+ const start = Date.parse(startOn);
759
+ const end = Date.parse(endOn);
760
+ if (Number.isNaN(start) || Number.isNaN(end)) return null;
761
+ return Math.floor((end - start) / 1e3);
762
+ }
763
+ function shortHash(hash) {
764
+ return hash?.slice(0, 7) ?? "";
765
+ }
766
+ function pipelineUrl(ref, buildNumber) {
767
+ return `${BITBUCKET_WEB_ORIGIN}/${ref.workspace}/${ref.repo}/pipelines/results/${buildNumber}`;
768
+ }
769
+ function toPipelineSummary(ref, p) {
670
770
  return {
671
- type: "doc",
672
- version: 1,
673
- content: blocks(marked.lexer(md), ctx)
771
+ buildNumber: p.build_number,
772
+ status: pipelineStatus(p.state),
773
+ ref: p.target?.ref_name ?? "",
774
+ commit: shortHash(p.target?.commit?.hash),
775
+ durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
776
+ createdOn: p.created_on ?? "",
777
+ creator: p.creator?.display_name ?? "",
778
+ uuid: p.uuid,
779
+ url: pipelineUrl(ref, p.build_number)
674
780
  };
675
781
  }
676
- function blocks(tokens, ctx) {
677
- const out = [];
678
- for (const token of tokens) out.push(...block(token, ctx));
679
- return out;
782
+ function toPipelineDetail(ref, p) {
783
+ return {
784
+ ...toPipelineSummary(ref, p),
785
+ repo: p.repository?.full_name ?? "",
786
+ trigger: p.trigger?.name ?? ""
787
+ };
680
788
  }
681
- function block(token, ctx) {
682
- switch (token.type) {
683
- case "space": return [];
789
+ function toStepSummary(step) {
790
+ return {
791
+ name: step.name ?? "",
792
+ status: pipelineStatus(step.state),
793
+ durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
794
+ };
795
+ }
796
+ function pipelinesPath(ref) {
797
+ return repoPath(ref, "pipelines");
798
+ }
799
+ async function listPipelines(client, ref, limit) {
800
+ return collectPages(client, `${pipelinesPath(ref)}?${pipelinesQuery(limit)}`, limit, (value) => toPipelineSummary(ref, value));
801
+ }
802
+ async function getPipeline(client, ref, buildNumber) {
803
+ const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
804
+ if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
805
+ return toPipelineDetail(ref, found);
806
+ }
807
+ async function fetchByBuildNumber(client, ref, buildNumber) {
808
+ try {
809
+ return await client.getJson(`${pipelinesPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
810
+ } catch (err) {
811
+ if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
812
+ throw err;
813
+ }
814
+ }
815
+ async function findInRecentRuns(client, ref, buildNumber) {
816
+ const first = `${pipelinesPath(ref)}?${pipelinesQuery(100)}`;
817
+ let scanned = 0;
818
+ for await (const pipeline of walkPages(client, first)) {
819
+ if (pipeline.build_number === buildNumber) return pipeline;
820
+ if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
821
+ }
822
+ return null;
823
+ }
824
+ async function listSteps(client, ref, pipelineId) {
825
+ const out = [];
826
+ const first = `${pipelinesPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
827
+ for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
828
+ return out;
829
+ }
830
+ //#endregion
831
+ //#region src/util/format.ts
832
+ function formatDuration(seconds) {
833
+ if (seconds == null) return "-";
834
+ if (seconds < 60) return `${seconds}s`;
835
+ const m = Math.floor(seconds / 60);
836
+ const s = seconds % 60;
837
+ if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
838
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
839
+ }
840
+ function formatDateTime(iso) {
841
+ const date = new Date(iso);
842
+ if (Number.isNaN(date.getTime())) return iso;
843
+ return date.toISOString().replace("T", " ").slice(0, 16);
844
+ }
845
+ const MINUTE = 60;
846
+ const HOUR = 3600;
847
+ const DAY = 24 * HOUR;
848
+ function relativeTime(iso, nowMs) {
849
+ if (!iso) return "-";
850
+ const then = Date.parse(iso);
851
+ if (Number.isNaN(then)) return "-";
852
+ const secs = Math.floor((nowMs - then) / 1e3);
853
+ if (secs < MINUTE) return "just now";
854
+ if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
855
+ if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
856
+ const days = Math.floor(secs / DAY);
857
+ if (days < 45) return `${days}d ago`;
858
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
859
+ return `${Math.floor(days / 365)}y ago`;
860
+ }
861
+ //#endregion
862
+ //#region src/util/link.ts
863
+ const OSC8 = "\x1B]8;;";
864
+ const BEL = "\x07";
865
+ function hyperlink(text, url) {
866
+ if (!kleur.enabled || !url) return text;
867
+ return `${OSC8}${url}${BEL}${text}${OSC8}${BEL}`;
868
+ }
869
+ //#endregion
870
+ //#region src/commands/search-run.ts
871
+ function alignedRows(items, nowMs, cells) {
872
+ const rows = items.map((item) => {
873
+ const cell = cells(item);
874
+ return {
875
+ ...cell,
876
+ age: relativeTime(cell.timestamp, nowMs),
877
+ json: { ...item }
878
+ };
879
+ });
880
+ const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
881
+ const idWidth = width((row) => row.id);
882
+ const labelWidth = width((row) => row.label);
883
+ const ageWidth = width((row) => row.age);
884
+ return rows.map((row) => ({
885
+ id: row.id,
886
+ url: row.url,
887
+ fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
888
+ freeText: row.text,
889
+ json: row.json
890
+ }));
891
+ }
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
+ }));
903
+ }
904
+ const COPY_CONCURRENCY = 5;
905
+ async function runSearch(term, rows, options, noun, copyOne) {
906
+ if (!(options.copy && !options.json && rows.length > 0)) {
907
+ writeRows(term, rows, options);
908
+ return;
909
+ }
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.");
911
+ await copySelected(term, rows, noun, copyOne);
912
+ }
913
+ function searchFooter(limit) {
914
+ return `
915
+ Showing first ${limit}; refine with flags or raise --limit.`;
916
+ }
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({
920
+ message: `Select ${noun.plural} to copy:`,
921
+ choices: rows.map((r) => ({
922
+ name: formatRow(r, term.width),
923
+ value: r.id
924
+ })),
925
+ pageSize: 20
926
+ });
927
+ if (selected.length === 0) {
928
+ term.out("Nothing selected.");
929
+ return;
930
+ }
931
+ let copied = 0;
932
+ const failures = [];
933
+ const queue = [...selected];
934
+ async function worker() {
935
+ for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
936
+ await copyOne(id);
937
+ copied++;
938
+ } catch (err) {
939
+ failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
940
+ }
941
+ }
942
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
943
+ const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
944
+ if (failures.length === 0) term.out(summary);
945
+ else term.out(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
946
+ }
947
+ function formatRow(row, width) {
948
+ const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
949
+ const text = room > 0 ? truncate(row.freeText, room) : "";
950
+ const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
951
+ return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
952
+ }
953
+ function truncate(text, max) {
954
+ const clean = text.replace(/\s+/g, " ").trim();
955
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
956
+ }
957
+ //#endregion
958
+ //#region src/commands/bitbucket.ts
959
+ async function bitbucketPipelines({ session, term }, options) {
960
+ const ref = resolveRepo(options.repo, session);
961
+ const limit = parseLimit(options.limit);
962
+ writeRows(term, pipelineRows(await withScopeHint(PIPELINE_SCOPE, () => listPipelines(session, ref, limit)), Date.now()), {
963
+ json: options.json,
964
+ empty: "No pipelines found."
965
+ });
966
+ }
967
+ async function bitbucketPipeline({ session, term }, arg, options) {
968
+ const buildNumber = parseBuildNumber(arg);
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()));
973
+ }
974
+ function pipelineRows(pipelines, nowMs) {
975
+ return alignedRows(pipelines, nowMs, (p) => ({
976
+ id: `#${p.buildNumber}`,
977
+ url: p.url,
978
+ label: p.status || "-",
979
+ color: colorForBitbucketState(p.status),
980
+ text: `${refWithCommit(p)} · ${formatDuration(p.durationSeconds)}`,
981
+ timestamp: p.createdOn
982
+ }));
983
+ }
984
+ const STATE_COLORS = {
985
+ SUCCESSFUL: kleur.green,
986
+ MERGED: kleur.green,
987
+ FAILED: kleur.red,
988
+ ERROR: kleur.red,
989
+ DECLINED: kleur.red,
990
+ IN_PROGRESS: kleur.yellow,
991
+ PENDING: kleur.yellow,
992
+ OPEN: kleur.yellow,
993
+ PAUSED: kleur.cyan,
994
+ STOPPED: kleur.gray,
995
+ SUPERSEDED: kleur.gray,
996
+ DRAFT: kleur.gray
997
+ };
998
+ function colorForBitbucketState(state) {
999
+ return STATE_COLORS[state.toUpperCase()] ?? kleur.white;
1000
+ }
1001
+ function formatStepRows(steps) {
1002
+ const rows = steps.map((s) => ({
1003
+ name: s.name || "-",
1004
+ status: s.status || "-",
1005
+ dur: formatDuration(s.durationSeconds)
1006
+ }));
1007
+ const wn = Math.max(...rows.map((r) => r.name.length));
1008
+ const ws = Math.max(...rows.map((r) => r.status.length));
1009
+ return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
1010
+ }
1011
+ function formatPipelineDetail(detail, steps, nowMs) {
1012
+ const by = detail.creator ? ` by ${detail.creator}` : "";
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
+ ];
1026
+ }
1027
+ function refWithCommit(pipeline) {
1028
+ if (!pipeline.ref) return pipeline.commit || "-";
1029
+ return pipeline.commit ? `${pipeline.ref} (${pipeline.commit})` : pipeline.ref;
1030
+ }
1031
+ function parseBuildNumber(arg) {
1032
+ const raw = (arg ?? "").replace(/^#/, "").trim();
1033
+ if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
1034
+ return Number.parseInt(raw, 10);
1035
+ }
1036
+ const PIPELINE_SCOPE = "read:pipeline:bitbucket";
1037
+ async function withScopeHint(scope, fn) {
1038
+ try {
1039
+ return await fn();
1040
+ } catch (err) {
1041
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Bitbucket rejected the request (401/403). Check the token has the ${scope} scope, or run \`atlass bitbucket login\` to update it.`);
1042
+ throw err;
1043
+ }
1044
+ }
1045
+ //#endregion
1046
+ //#region src/commands/bitbucket-prs.ts
1047
+ const PULL_REQUEST_SCOPE = "read:pullrequest:bitbucket";
1048
+ const ACCOUNT_SCOPE = "read:account";
1049
+ async function bitbucketPrs({ session, term, profile }, options) {
1050
+ if (options.query && (options.author || options.reviewer)) throw new Error("--query cannot be combined with --author or --reviewer.");
1051
+ const states = pullRequestStates(options);
1052
+ const ref = resolveRepo(options.repo, session);
1053
+ const limit = parseLimit(options.limit);
1054
+ const query = pullRequestQuery({
1055
+ ...await resolvePrincipals(session, profile, options),
1056
+ query: options.query
1057
+ });
1058
+ const prs = await withScopeHint(PULL_REQUEST_SCOPE, () => listPullRequests(session, ref, {
1059
+ limit,
1060
+ states,
1061
+ query
1062
+ }));
1063
+ writeRows(term, pullRequestRows(prs, Date.now()), {
1064
+ json: options.json,
1065
+ empty: emptyMessage(options),
1066
+ footer: prs.length === limit ? searchFooter(limit) : void 0
1067
+ });
1068
+ }
1069
+ function pullRequestStates(options) {
1070
+ if (!options.all) return parsePullRequestStates(options.state);
1071
+ if (options.state) throw new Error("--all cannot be combined with --state.");
1072
+ return allPullRequestStates();
1073
+ }
1074
+ function pullRequestRows(prs, nowMs) {
1075
+ return alignedRows(prs, nowMs, (pr) => {
1076
+ const label = pr.draft ? "DRAFT" : pr.state || "-";
1077
+ return {
1078
+ id: `#${pr.id}`,
1079
+ url: pr.url,
1080
+ label,
1081
+ color: colorForBitbucketState(label),
1082
+ text: pr.title,
1083
+ timestamp: pr.updatedOn
1084
+ };
1085
+ });
1086
+ }
1087
+ async function resolvePrincipals(session, profile, options) {
1088
+ const mine = isMe(options.author) || isMe(options.reviewer) ? await currentUuid(session, profile) : void 0;
1089
+ return {
1090
+ author: isMe(options.author) ? mine : options.author,
1091
+ reviewer: isMe(options.reviewer) ? mine : options.reviewer
1092
+ };
1093
+ }
1094
+ function isMe(value) {
1095
+ return value?.toLowerCase() === "me";
1096
+ }
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);
1101
+ return uuid;
1102
+ }
1103
+ function emptyMessage(options) {
1104
+ return options.author || options.reviewer || options.query || options.state || options.all ? "No matching pull requests." : "No open pull requests.";
1105
+ }
1106
+ //#endregion
1107
+ //#region src/cli/bitbucket.ts
1108
+ function registerBitbucket(bitbucket) {
1109
+ bitbucket.description("Bitbucket commands");
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));
1116
+ return bitbucket;
1117
+ }
1118
+ //#endregion
1119
+ //#region src/adf/from-markdown.ts
1120
+ function mediaNode(source, alt) {
1121
+ const attrs = { ...source };
1122
+ if (alt) attrs["alt"] = alt;
1123
+ return {
1124
+ type: "mediaSingle",
1125
+ attrs: { layout: "center" },
1126
+ content: [{
1127
+ type: "media",
1128
+ attrs
1129
+ }]
1130
+ };
1131
+ }
1132
+ function imageHrefs(md) {
1133
+ const hrefs = /* @__PURE__ */ new Set();
1134
+ marked.walkTokens(marked.lexer(md), (token) => {
1135
+ if (token.type === "image") hrefs.add(token.href);
1136
+ });
1137
+ return [...hrefs];
1138
+ }
1139
+ function markdownToAdf(md, options = {}) {
1140
+ const ctx = { resolveImage: options.resolveImage ?? externalMedia };
1141
+ return {
1142
+ type: "doc",
1143
+ version: 1,
1144
+ content: blocks(marked.lexer(md), ctx)
1145
+ };
1146
+ }
1147
+ function blocks(tokens, ctx) {
1148
+ const out = [];
1149
+ for (const token of tokens) out.push(...block(token, ctx));
1150
+ return out;
1151
+ }
1152
+ function block(token, ctx) {
1153
+ switch (token.type) {
1154
+ case "space": return [];
684
1155
  case "heading": return [{
685
1156
  type: "heading",
686
1157
  attrs: { level: clampLevel$1(token.depth) },
@@ -849,31 +1320,40 @@ function clampLevel$1(value) {
849
1320
  return Math.min(6, Math.max(1, Math.trunc(value) || 1));
850
1321
  }
851
1322
  //#endregion
852
- //#region src/util/html.ts
853
- const MAX_CODE_POINT = 1114111;
854
- const NAMED = {
855
- amp: "&",
856
- lt: "<",
857
- gt: ">",
858
- quot: "\"",
859
- apos: "'",
860
- nbsp: " "
861
- };
862
- function decodeEntities(text) {
863
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
864
- if (body[0] === "#") {
865
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
866
- return isCodePoint(code) ? String.fromCodePoint(code) : match;
867
- }
868
- return NAMED[body.toLowerCase()] ?? match;
869
- });
870
- }
871
- function isCodePoint(code) {
872
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
873
- }
874
- //#endregion
875
- //#region src/api/confluence.ts
876
- async function fetchPage(client, site, id) {
1323
+ //#region src/api/confluence-attachments.ts
1324
+ const UNKNOWN_SIZE = -1;
1325
+ async function listAttachments(client, id) {
1326
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1327
+ filename: a.title ?? a.id,
1328
+ fileId: mediaNodeId(a),
1329
+ size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1330
+ }));
1331
+ }
1332
+ async function uploadAttachment(client, pageId, filename, bytes) {
1333
+ return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1334
+ }
1335
+ async function fileIdByListing(client, pageId, filename) {
1336
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1337
+ if (match) return match.fileId;
1338
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
1339
+ }
1340
+ async function fetchAttachments(client, id) {
1341
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1342
+ mediaId: mediaNodeId(a),
1343
+ filename: a.title ?? a.id,
1344
+ url: withWikiContextPath(a.downloadLink ?? "")
1345
+ }));
1346
+ }
1347
+ function mediaNodeId(a) {
1348
+ return a.fileId ?? a.id;
1349
+ }
1350
+ function withWikiContextPath(link) {
1351
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1352
+ return `/wiki${link}`;
1353
+ }
1354
+ //#endregion
1355
+ //#region src/api/confluence-pages.ts
1356
+ async function fetchPage(client, site, id) {
877
1357
  const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
878
1358
  const names = new UserNames(client);
879
1359
  const [spaceKey, attachments, comments, author] = await Promise.all([
@@ -905,22 +1385,6 @@ async function fetchPageState(client, id) {
905
1385
  body: parseAdfJson(page.body?.atlas_doc_format?.value)
906
1386
  };
907
1387
  }
908
- const UNKNOWN_SIZE = -1;
909
- async function listAttachments(client, id) {
910
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
911
- filename: a.title ?? a.id,
912
- fileId: mediaNodeId(a),
913
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
914
- }));
915
- }
916
- async function uploadAttachment(client, pageId, filename, bytes) {
917
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
918
- }
919
- async function fileIdByListing(client, pageId, filename) {
920
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
921
- if (match) return match.fileId;
922
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
923
- }
924
1388
  async function updatePage(client, id, params) {
925
1389
  return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
926
1390
  id,
@@ -936,37 +1400,6 @@ async function updatePage(client, id, params) {
936
1400
  }
937
1401
  })).version?.number ?? params.nextVersion;
938
1402
  }
939
- async function searchPages(client, site, params) {
940
- const cql = buildCql(params);
941
- const query = new URLSearchParams({
942
- cql,
943
- limit: String(params.limit),
944
- expand: "space"
945
- });
946
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
947
- const serverPageWasFull = results.length === params.limit;
948
- return {
949
- pages: results.filter((r) => r.content?.id).map((r) => ({
950
- id: r.content?.id ?? "",
951
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
952
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
953
- updated: r.lastModified ?? "",
954
- url: r.url ? `${site}/wiki${r.url}` : ""
955
- })),
956
- hasMore: serverPageWasFull
957
- };
958
- }
959
- function buildCql(params) {
960
- if (params.cql) return params.cql;
961
- const clauses = ["type = page"];
962
- if (params.starred) clauses.push("favourite = currentUser()");
963
- if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
964
- if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
965
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
966
- }
967
- function cqlStringLiteral(value) {
968
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
969
- }
970
1403
  async function fetchSpaceKey(client, spaceId) {
971
1404
  if (!spaceId) return "";
972
1405
  try {
@@ -975,16 +1408,6 @@ async function fetchSpaceKey(client, spaceId) {
975
1408
  return "";
976
1409
  }
977
1410
  }
978
- async function fetchAttachments(client, id) {
979
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
980
- mediaId: mediaNodeId(a),
981
- filename: a.title ?? a.id,
982
- url: withWikiContextPath(a.downloadLink ?? "")
983
- }));
984
- }
985
- function mediaNodeId(a) {
986
- return a.fileId ?? a.id;
987
- }
988
1411
  async function fetchComments$1(client, id, names) {
989
1412
  const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
990
1413
  return Promise.all(res.results.map(async (c) => ({
@@ -993,10 +1416,6 @@ async function fetchComments$1(client, id, names) {
993
1416
  body: parseAdfJson(c.body?.atlas_doc_format?.value)
994
1417
  })));
995
1418
  }
996
- function withWikiContextPath(link) {
997
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
998
- return `/wiki${link}`;
999
- }
1000
1419
  function parseAdfJson(value) {
1001
1420
  if (!value) return null;
1002
1421
  try {
@@ -1028,10 +1447,67 @@ var UserNames = class {
1028
1447
  }
1029
1448
  };
1030
1449
  //#endregion
1450
+ //#region src/util/html.ts
1451
+ const MAX_CODE_POINT = 1114111;
1452
+ const NAMED = {
1453
+ amp: "&",
1454
+ lt: "<",
1455
+ gt: ">",
1456
+ quot: "\"",
1457
+ apos: "'",
1458
+ nbsp: " "
1459
+ };
1460
+ function decodeEntities(text) {
1461
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
1462
+ if (body[0] === "#") {
1463
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
1464
+ return isCodePoint(code) ? String.fromCodePoint(code) : match;
1465
+ }
1466
+ return NAMED[body.toLowerCase()] ?? match;
1467
+ });
1468
+ }
1469
+ function isCodePoint(code) {
1470
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
1471
+ }
1472
+ //#endregion
1473
+ //#region src/api/confluence-search.ts
1474
+ async function searchPages(client, site, params) {
1475
+ const cql = buildCql(params);
1476
+ const query = new URLSearchParams({
1477
+ cql,
1478
+ limit: String(params.limit),
1479
+ expand: "space"
1480
+ });
1481
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
1482
+ const serverPageWasFull = results.length === params.limit;
1483
+ return {
1484
+ pages: results.filter((r) => r.content?.id).map((r) => ({
1485
+ id: r.content?.id ?? "",
1486
+ space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
1487
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
1488
+ updated: r.lastModified ?? "",
1489
+ url: r.url ? `${site}/wiki${r.url}` : ""
1490
+ })),
1491
+ hasMore: serverPageWasFull
1492
+ };
1493
+ }
1494
+ function buildCql(params) {
1495
+ if (params.cql) return params.cql;
1496
+ const clauses = ["type = page"];
1497
+ if (params.starred) clauses.push("favourite = currentUser()");
1498
+ if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
1499
+ if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
1500
+ return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
1501
+ }
1502
+ function cqlStringLiteral(value) {
1503
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1504
+ }
1505
+ //#endregion
1031
1506
  //#region src/commands/resolve-ref.ts
1032
- async function resolveRef(arg, kind) {
1033
- const raw = arg ?? await input({
1507
+ async function resolveRef(ask, arg, kind) {
1508
+ const raw = arg ?? await ask.text({
1034
1509
  message: kind.message,
1510
+ flag: kind.flag,
1035
1511
  required: true
1036
1512
  });
1037
1513
  const ref = kind.parse(raw);
@@ -1039,94 +1515,69 @@ async function resolveRef(arg, kind) {
1039
1515
  return ref;
1040
1516
  }
1041
1517
  //#endregion
1042
- //#region src/util/link.ts
1043
- const OSC8 = "\x1B]8;;";
1044
- const BEL = "\x07";
1045
- function hyperlink(text, url) {
1046
- if (!kleur.enabled || !url) return text;
1047
- return `${OSC8}${url}${BEL}${text}${OSC8}${BEL}`;
1518
+ //#region src/adf/to-markdown-inline.ts
1519
+ function renderMedia(node, ctx) {
1520
+ const attrs = node.attrs ?? {};
1521
+ const alt = attrs.alt ?? "";
1522
+ const resolved = ctx.resolveMedia?.(attrs);
1523
+ if (resolved) return `![${alt}](${resolved})`;
1524
+ return `[embedded media: ${alt || attrs.id || "media"}]`;
1048
1525
  }
1049
- //#endregion
1050
- //#region src/commands/search-run.ts
1051
- function alignedRows(items, nowMs, cells) {
1052
- const rows = items.map((item) => ({
1053
- ...cells(item),
1054
- age: relativeTime(item.updated, nowMs),
1055
- json: { ...item }
1056
- }));
1057
- const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
1058
- const idWidth = width((row) => row.id);
1059
- const labelWidth = width((row) => row.label);
1060
- const ageWidth = width((row) => row.age);
1061
- return rows.map((row) => ({
1062
- id: row.id,
1063
- url: row.url,
1064
- fixedColumns: `${row.id.padEnd(idWidth)} ${row.color(row.label.padEnd(labelWidth))} ${row.age.padEnd(ageWidth)}`,
1065
- freeText: row.text,
1066
- json: row.json
1067
- }));
1526
+ function renderInline(nodes, ctx) {
1527
+ return nodes.map((n) => renderInlineNode(n, ctx)).join("");
1068
1528
  }
1069
- const COPY_CONCURRENCY = 5;
1070
- async function runSearch(rows, options, noun, copyOne) {
1071
- if (options.json) {
1072
- console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
1073
- return;
1074
- }
1075
- if (rows.length === 0) {
1076
- console.log(options.empty);
1077
- return;
1078
- }
1079
- if (options.copy) {
1080
- if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
1081
- await copySelected(rows, noun, copyOne);
1082
- return;
1529
+ function renderInlineNode(node, ctx) {
1530
+ switch (node.type) {
1531
+ case "text": return applyMarks(node.text ?? "", node.marks ?? []);
1532
+ case "hardBreak": return " \n";
1533
+ case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
1534
+ case "emoji": {
1535
+ const text = node.attrs?.["text"];
1536
+ if (typeof text === "string" && text.length > 0) return text;
1537
+ const short = node.attrs?.["shortName"];
1538
+ return typeof short === "string" ? short : "";
1539
+ }
1540
+ case "date": return formatDate(node.attrs?.["timestamp"]);
1541
+ case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
1542
+ case "inlineCard": {
1543
+ const url = node.attrs?.["url"];
1544
+ if (typeof url === "string") return `[${url}](${url})`;
1545
+ return "";
1546
+ }
1547
+ case "media": return renderMedia(node, ctx);
1548
+ default: return node.text ?? "";
1083
1549
  }
1084
- for (const row of rows) console.log(formatRow(row));
1085
- if (options.footer) console.log(options.footer);
1086
- }
1087
- function searchFooter(limit) {
1088
- return `
1089
- Showing first ${limit}; refine with flags or raise --limit.`;
1090
1550
  }
1091
- async function copySelected(rows, noun, copyOne) {
1092
- if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
1093
- const selected = await checkbox({
1094
- message: `Select ${noun.plural} to copy:`,
1095
- choices: rows.map((r) => ({
1096
- name: formatRow(r),
1097
- value: r.id
1098
- })),
1099
- pageSize: 20
1100
- });
1101
- if (selected.length === 0) {
1102
- console.log("Nothing selected.");
1103
- return;
1104
- }
1105
- let copied = 0;
1106
- const failures = [];
1107
- const queue = [...selected];
1108
- async function worker() {
1109
- for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
1110
- await copyOne(id);
1111
- copied++;
1112
- } catch (err) {
1113
- failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
1551
+ function applyMarks(text, marks) {
1552
+ if (text.length === 0) return text;
1553
+ let out = text;
1554
+ let href;
1555
+ for (const mark of marks) switch (mark.type) {
1556
+ case "code":
1557
+ out = `\`${out}\``;
1558
+ break;
1559
+ case "strong":
1560
+ out = `**${out}**`;
1561
+ break;
1562
+ case "em":
1563
+ out = `*${out}*`;
1564
+ break;
1565
+ case "strike":
1566
+ out = `~~${out}~~`;
1567
+ break;
1568
+ case "link": {
1569
+ const value = mark.attrs?.["href"];
1570
+ if (typeof value === "string") href = value;
1571
+ break;
1114
1572
  }
1115
1573
  }
1116
- await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
1117
- const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
1118
- if (failures.length === 0) console.log(summary);
1119
- else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1120
- }
1121
- function formatRow(row, width = process.stdout.columns ?? 80) {
1122
- const room = width - stripVTControlCharacters(row.fixedColumns).length - 2;
1123
- const text = room > 0 ? truncate(row.freeText, room) : "";
1124
- const columns = hyperlink(row.id, row.url) + row.fixedColumns.slice(row.id.length);
1125
- return text ? `${columns} ${hyperlink(text, row.url)}` : columns;
1574
+ if (href) out = `[${out}](${href})`;
1575
+ return out;
1126
1576
  }
1127
- function truncate(text, max) {
1128
- const clean = text.replace(/\s+/g, " ").trim();
1129
- return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
1577
+ function formatDate(timestamp) {
1578
+ const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
1579
+ if (Number.isNaN(ms)) return "";
1580
+ return new Date(ms).toISOString().slice(0, 10);
1130
1581
  }
1131
1582
  //#endregion
1132
1583
  //#region src/adf/to-markdown.ts
@@ -1196,7 +1647,8 @@ const PANEL_LABELS = {
1196
1647
  error: "Error"
1197
1648
  };
1198
1649
  function renderPanel(node, ctx, indent) {
1199
- 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}> `);
1200
1652
  }
1201
1653
  function renderExpand(node, ctx, indent) {
1202
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>`;
@@ -1219,65 +1671,6 @@ function renderTable(node, ctx) {
1219
1671
  function renderCellOnOneLine(cell, ctx) {
1220
1672
  return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
1221
1673
  }
1222
- function renderMedia(node, ctx) {
1223
- const attrs = node.attrs ?? {};
1224
- const alt = attrs.alt ?? "";
1225
- const resolved = ctx.resolveMedia?.(attrs);
1226
- if (resolved) return `![${alt}](${resolved})`;
1227
- return `[embedded media: ${alt || attrs.id || "media"}]`;
1228
- }
1229
- function renderInline(nodes, ctx) {
1230
- return nodes.map((n) => renderInlineNode(n, ctx)).join("");
1231
- }
1232
- function renderInlineNode(node, ctx) {
1233
- switch (node.type) {
1234
- case "text": return applyMarks(node.text ?? "", node.marks ?? []);
1235
- case "hardBreak": return " \n";
1236
- case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
1237
- case "emoji": {
1238
- const text = node.attrs?.["text"];
1239
- if (typeof text === "string" && text.length > 0) return text;
1240
- const short = node.attrs?.["shortName"];
1241
- return typeof short === "string" ? short : "";
1242
- }
1243
- case "date": return formatDate(node.attrs?.["timestamp"]);
1244
- case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
1245
- case "inlineCard": {
1246
- const url = node.attrs?.["url"];
1247
- if (typeof url === "string") return `[${url}](${url})`;
1248
- return "";
1249
- }
1250
- case "media": return renderMedia(node, ctx);
1251
- default: return node.text ?? "";
1252
- }
1253
- }
1254
- function applyMarks(text, marks) {
1255
- if (text.length === 0) return text;
1256
- let out = text;
1257
- let href;
1258
- for (const mark of marks) switch (mark.type) {
1259
- case "code":
1260
- out = `\`${out}\``;
1261
- break;
1262
- case "strong":
1263
- out = `**${out}**`;
1264
- break;
1265
- case "em":
1266
- out = `*${out}*`;
1267
- break;
1268
- case "strike":
1269
- out = `~~${out}~~`;
1270
- break;
1271
- case "link": {
1272
- const value = mark.attrs?.["href"];
1273
- if (typeof value === "string") href = value;
1274
- break;
1275
- }
1276
- default: break;
1277
- }
1278
- if (href) out = `[${out}](${href})`;
1279
- return out;
1280
- }
1281
1674
  function prefixLines(text, prefix) {
1282
1675
  return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
1283
1676
  }
@@ -1288,11 +1681,6 @@ function clampLevel(value) {
1288
1681
  function toNumber(value) {
1289
1682
  return typeof value === "number" ? value : void 0;
1290
1683
  }
1291
- function formatDate(timestamp) {
1292
- const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
1293
- if (Number.isNaN(ms)) return "";
1294
- return new Date(ms).toISOString().slice(0, 10);
1295
- }
1296
1684
  //#endregion
1297
1685
  //#region src/markdown/highlight.ts
1298
1686
  const HEADING = /^#{1,6} /;
@@ -1546,9 +1934,10 @@ function scalarString(value) {
1546
1934
  }
1547
1935
  function frontmatter(fields) {
1548
1936
  const lines = ["---"];
1549
- for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) if (value.length === 0) lines.push(`${key}: []`);
1550
- else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
1551
- 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}`);
1552
1941
  else lines.push(`${key}: ${quote(value)}`);
1553
1942
  lines.push("---");
1554
1943
  return lines.join("\n");
@@ -1594,21 +1983,23 @@ function leadingH1(lines) {
1594
1983
  }
1595
1984
  function commentsSection(comments) {
1596
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
+ });
1597
1990
  return [
1598
1991
  COMMENTS_HEADING,
1599
1992
  "",
1600
- comments.map((c) => {
1601
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
1602
- return c.body ? `${heading}\n\n${c.body}` : heading;
1603
- }).join("\n\n")
1993
+ blocks.join("\n\n")
1604
1994
  ].join("\n");
1605
1995
  }
1606
1996
  function attachmentsSection(attachments) {
1607
1997
  if (attachments.length === 0) return "";
1998
+ const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
1608
1999
  return [
1609
2000
  ATTACHMENTS_HEADING,
1610
2001
  "",
1611
- ...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
2002
+ ...items
1612
2003
  ].join("\n");
1613
2004
  }
1614
2005
  //#endregion
@@ -1673,7 +2064,6 @@ function planCopy(source, out) {
1673
2064
  return {
1674
2065
  ...content,
1675
2066
  filePath: target.filePath,
1676
- assetsDir: target.assetsDir,
1677
2067
  downloads: planDownloads(attachments, target)
1678
2068
  };
1679
2069
  }
@@ -1719,21 +2109,18 @@ function numberedName(name, suffix) {
1719
2109
  }
1720
2110
  //#endregion
1721
2111
  //#region src/copy/run.ts
1722
- async function runCopy(plan, fetchBytes) {
1723
- const landed = await download(plan, fetchBytes);
1724
- await mkdir(dirname(plan.filePath), { recursive: true });
1725
- await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
1726
- console.log(wroteLine(plan.filePath, landed.length));
1727
- }
1728
- async function download(plan, fetchBytes) {
1729
- if (plan.downloads.length === 0) return [];
1730
- 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) {
1731
2118
  const landed = [];
1732
2119
  for (const { path, ...attachment } of plan.downloads) try {
1733
- await writeFile(path, await fetchBytes(attachment.url));
2120
+ await files.writeBytes(path, await session.getBinary(attachment.url));
1734
2121
  landed.push(attachment);
1735
2122
  } catch (err) {
1736
- console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
2123
+ term.err(` ! could not download ${attachment.filename}: ${err.message}`);
1737
2124
  }
1738
2125
  return landed;
1739
2126
  }
@@ -1792,6 +2179,82 @@ function planIssueUpdate(source, issue, options) {
1792
2179
  refusals
1793
2180
  }, options.force ?? false);
1794
2181
  }
2182
+ function formatPlan(plan) {
2183
+ const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
2184
+ if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
2185
+ const counts = imageCounts(plan.images);
2186
+ if (counts) lines.push(row("images", counts));
2187
+ for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
2188
+ if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
2189
+ if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
2190
+ lines.push(" nothing was written (dry run)");
2191
+ return lines;
2192
+ }
2193
+ const IMAGE_COUNT_LABELS = [
2194
+ ["upload", "new"],
2195
+ ["changed", "changed"],
2196
+ ["reuse", "reused"],
2197
+ ["external", "external"],
2198
+ ["unsupported", "unsupported"],
2199
+ ["missing", "missing"]
2200
+ ];
2201
+ function imageCounts(images) {
2202
+ return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
2203
+ n: images.filter((i) => i.kind === kind).length,
2204
+ label
2205
+ })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
2206
+ }
2207
+ function row(label, value) {
2208
+ return ` ${`${label}:`.padEnd(9)}${value}`;
2209
+ }
2210
+ function headline(label, current, title, useTitle) {
2211
+ return {
2212
+ label,
2213
+ current,
2214
+ next: useTitle && title ? title : current
2215
+ };
2216
+ }
2217
+ function revision(local, server) {
2218
+ return {
2219
+ local: local || "unknown",
2220
+ server: server || "unknown",
2221
+ stale: local !== server
2222
+ };
2223
+ }
2224
+ function revisionText(revision) {
2225
+ return `copied at ${revision.local}, server now ${revision.server}`;
2226
+ }
2227
+ function emptyBodyRefusal(body) {
2228
+ if (body.content && body.content.length > 0) return [];
2229
+ return ["Refusing to update: the converted body is empty."];
2230
+ }
2231
+ function withVerdict(plan, force) {
2232
+ return {
2233
+ ...plan,
2234
+ verdict: verdict(plan, force)
2235
+ };
2236
+ }
2237
+ function verdict(plan, force) {
2238
+ if (plan.refusals.length > 0) return {
2239
+ kind: "refuse",
2240
+ message: plan.refusals.join("\n")
2241
+ };
2242
+ const noun = plan.noun;
2243
+ if (plan.revision.stale && !force) return {
2244
+ kind: "refuse",
2245
+ message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
2246
+ };
2247
+ if (plan.lossy.size > 0 && !force) return {
2248
+ kind: "confirm",
2249
+ message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
2250
+ };
2251
+ return { kind: "proceed" };
2252
+ }
2253
+ function capitalize(text) {
2254
+ return text.charAt(0).toUpperCase() + text.slice(1);
2255
+ }
2256
+ //#endregion
2257
+ //#region src/update/plan-page.ts
1795
2258
  function planPageUpdate(source, state, attachments, localImages, options) {
1796
2259
  const collection = `contentId-${source.id}`;
1797
2260
  const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
@@ -1872,156 +2335,33 @@ function withUploadedId(node, ids) {
1872
2335
  content: swapped.content.map((n) => withUploadedId(n, ids))
1873
2336
  };
1874
2337
  }
1875
- function formatPlan(plan) {
1876
- const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
1877
- if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
1878
- const counts = imageCounts(plan.images);
1879
- if (counts) lines.push(row("images", counts));
1880
- for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
1881
- if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
1882
- if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
1883
- lines.push(" nothing was written (dry run)");
1884
- return lines;
1885
- }
1886
- const IMAGE_COUNT_LABELS = [
1887
- ["upload", "new"],
1888
- ["changed", "changed"],
1889
- ["reuse", "reused"],
1890
- ["external", "external"],
1891
- ["unsupported", "unsupported"],
1892
- ["missing", "missing"]
1893
- ];
1894
- function imageCounts(images) {
1895
- return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
1896
- n: images.filter((i) => i.kind === kind).length,
1897
- label
1898
- })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
1899
- }
1900
- function row(label, value) {
1901
- return ` ${`${label}:`.padEnd(9)}${value}`;
1902
- }
1903
- function headline(label, current, title, useTitle) {
1904
- return {
1905
- label,
1906
- current,
1907
- next: useTitle && title ? title : current
1908
- };
1909
- }
1910
- function revision(local, server) {
1911
- return {
1912
- local: local || "unknown",
1913
- server: server || "unknown",
1914
- stale: local !== server
1915
- };
1916
- }
1917
- function revisionText(revision) {
1918
- return `copied at ${revision.local}, server now ${revision.server}`;
1919
- }
1920
- function emptyBodyRefusal(body) {
1921
- if (body.content && body.content.length > 0) return [];
1922
- return ["Refusing to update: the converted body is empty."];
1923
- }
1924
- function withVerdict(plan, force) {
1925
- return {
1926
- ...plan,
1927
- verdict: verdict(plan, force)
1928
- };
1929
- }
1930
- function verdict(plan, force) {
1931
- if (plan.refusals.length > 0) return {
1932
- kind: "refuse",
1933
- message: plan.refusals.join("\n")
1934
- };
1935
- const noun = plan.noun;
1936
- if (plan.revision.stale && !force) return {
1937
- kind: "refuse",
1938
- message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
1939
- };
1940
- if (plan.lossy.size > 0 && !force) return {
1941
- kind: "confirm",
1942
- message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
1943
- };
1944
- return { kind: "proceed" };
1945
- }
1946
- function capitalize(text) {
1947
- return text.charAt(0).toUpperCase() + text.slice(1);
1948
- }
1949
2338
  //#endregion
1950
2339
  //#region src/update/run.ts
1951
- async function runPlan(plan, options, push) {
2340
+ async function runPlan(term, plan, options, push) {
1952
2341
  if (options.dryRun) {
1953
- for (const line of formatPlan(plan)) console.log(line);
2342
+ term.out(formatPlan(plan));
1954
2343
  return;
1955
2344
  }
1956
2345
  const verdict = plan.verdict;
1957
2346
  if (verdict.kind === "refuse") throw new Error(verdict.message);
1958
2347
  if (verdict.kind === "confirm") {
1959
- if (!await confirm({
2348
+ if (!await term.ask.confirm({
1960
2349
  message: verdict.message,
2350
+ flag: "--force",
1961
2351
  default: false
1962
2352
  })) {
1963
- console.log("Aborted.");
2353
+ term.out("Aborted.");
1964
2354
  return;
1965
2355
  }
1966
2356
  }
1967
2357
  await push();
1968
2358
  }
1969
2359
  //#endregion
1970
- //#region src/util/pager.ts
1971
- const DEFAULT_ROWS = 24;
1972
- const DEFAULT_COLUMNS = 80;
1973
- const DEFAULT_PAGER = "less";
1974
- const DEFAULT_LESS = "FRX";
1975
- function renderedHeight(text, columns) {
1976
- const width = Math.max(1, columns);
1977
- return text.split("\n").map((line) => Math.max(1, Math.ceil(stripVTControlCharacters(line).length / width))).reduce((sum, rows) => sum + rows, 0);
1978
- }
1979
- function shouldPage(text, term) {
1980
- if (!term.isTTY) return false;
1981
- const rows = term.rows ?? DEFAULT_ROWS;
1982
- return renderedHeight(text, term.columns ?? DEFAULT_COLUMNS) > rows;
1983
- }
1984
- function pagerCommand(env) {
1985
- const [command = DEFAULT_PAGER, ...args] = (env["PAGER"] ?? "").trim().split(/\s+/).filter(Boolean);
1986
- return {
1987
- command,
1988
- args,
1989
- env: {
1990
- ...env,
1991
- LESS: env["LESS"] ?? DEFAULT_LESS
1992
- }
1993
- };
1994
- }
1995
- async function printPaged(text, options = {}) {
1996
- const term = options.term ?? process.stdout;
1997
- if (options.pager === false || !shouldPage(text, term)) {
1998
- console.log(text);
1999
- return;
2000
- }
2001
- if (!await pipeToPager(text, pagerCommand(options.env ?? process.env))) console.log(text);
2002
- }
2003
- function pipeToPager(text, pager) {
2004
- return new Promise((resolve) => {
2005
- const child = spawn(pager.command, pager.args, {
2006
- stdio: [
2007
- "pipe",
2008
- "inherit",
2009
- "inherit"
2010
- ],
2011
- env: pager.env
2012
- });
2013
- child.once("error", () => resolve(false));
2014
- child.once("close", () => resolve(true));
2015
- child.stdin.once("error", () => {});
2016
- child.stdin.end(`${text}\n`);
2017
- });
2018
- }
2019
- //#endregion
2020
2360
  //#region src/commands/confluence.ts
2021
- async function confluenceView(arg, options) {
2022
- const auth = await requireAuth();
2023
- const id = await resolveRef(arg, PAGE_REF);
2024
- 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 });
2025
2365
  }
2026
2366
  function formatPageView(page, nowMs, allComments) {
2027
2367
  return [
@@ -2040,37 +2380,35 @@ function formatPageView(page, nowMs, allComments) {
2040
2380
  ...attachmentSection(page.attachments)
2041
2381
  ];
2042
2382
  }
2043
- async function confluenceCopy(arg, options) {
2044
- const auth = await requireAuth();
2045
- const id = await resolveRef(arg, PAGE_REF);
2046
- 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);
2047
2385
  }
2048
- async function confluenceUpdate(arg, options) {
2049
- const file = arg ?? await input({
2386
+ async function confluenceUpdate({ session, term, files }, arg, options) {
2387
+ const file = arg ?? await term.ask.text({
2050
2388
  message: "Path to the page Markdown file:",
2389
+ flag: "[file]",
2051
2390
  required: true
2052
2391
  });
2053
- const src = parsePageSource(await readFile(file, "utf8"));
2054
- const client = new AtlassianClient(await requireAuth());
2055
- const state = await fetchPageState(client, src.id);
2056
- const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
2057
- 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 () => {
2058
2396
  const ids = /* @__PURE__ */ new Map();
2059
2397
  for (const upload of plan.uploads) {
2060
- console.log(`Uploading ${upload.filename} ...`);
2061
- const bytes = await readFile(upload.path);
2062
- 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));
2063
2401
  }
2064
- const version = await updatePage(client, src.id, {
2402
+ const version = await updatePage(session, src.id, {
2065
2403
  title: plan.headline.next,
2066
2404
  nextVersion: state.version + 1,
2067
2405
  body: withUploadedIds(plan.body, ids),
2068
2406
  message: options.message ?? "Updated via atlass"
2069
2407
  });
2070
- console.log(`Updated page ${src.id} to version ${version}.`);
2408
+ term.out(`Updated page ${src.id} to version ${version}.`);
2071
2409
  });
2072
2410
  }
2073
- async function statLocalImages(dir, md) {
2411
+ async function statLocalImages(files, dir, md) {
2074
2412
  const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2075
2413
  return Promise.all(hrefs.map(async (href) => {
2076
2414
  const path = isAbsolute(href) ? href : resolve(dir, href);
@@ -2078,291 +2416,101 @@ async function statLocalImages(dir, md) {
2078
2416
  href,
2079
2417
  path,
2080
2418
  filename: basename(path),
2081
- ...await fileSize(path)
2419
+ ...await fileSize(files, path)
2082
2420
  };
2083
2421
  }));
2084
2422
  }
2085
- async function fileSize(path) {
2423
+ async function fileSize(files, path) {
2086
2424
  try {
2087
- return { size: (await stat(path)).size };
2425
+ return { size: await files.size(path) };
2088
2426
  } catch {
2089
2427
  return {};
2090
2428
  }
2091
2429
  }
2092
- async function confluenceSearch(query, options) {
2430
+ async function confluenceSearch(env, query, options) {
2093
2431
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
2094
- await listPages({
2432
+ await listPages(env, {
2095
2433
  text: query,
2096
2434
  space: options.space,
2097
2435
  cql: options.cql
2098
2436
  }, "No matching pages.", options);
2099
2437
  }
2100
- async function confluenceList(options) {
2101
- await listPages({
2438
+ async function confluenceList(env, options) {
2439
+ await listPages(env, {
2102
2440
  starred: true,
2103
- space: options.space
2104
- }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2105
- }
2106
- async function listPages(filter, empty, options) {
2107
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2108
- const auth = await requireAuth();
2109
- const client = new AtlassianClient(auth);
2110
- const limit = parseLimit(options.limit);
2111
- const { pages, hasMore } = await searchPages(client, auth.site, {
2112
- ...filter,
2113
- limit
2114
- });
2115
- await runSearch(formatPageRows(pages, Date.now()), {
2116
- json: options.json,
2117
- copy: options.copy,
2118
- out: options.out,
2119
- empty,
2120
- footer: hasMore ? searchFooter(limit) : void 0
2121
- }, PAGE_NOUN, (id) => copyPage(client, auth.site, id, options.out));
2122
- }
2123
- function formatPageRows(pages, nowMs) {
2124
- return alignedRows(pages, nowMs, (p) => ({
2125
- id: p.id,
2126
- url: p.url,
2127
- label: p.space,
2128
- color: colorForSpace(p.space),
2129
- text: p.title
2130
- }));
2131
- }
2132
- const SPACE_COLORS = [
2133
- kleur.cyan,
2134
- kleur.yellow,
2135
- kleur.green,
2136
- kleur.magenta,
2137
- kleur.blue
2138
- ];
2139
- function colorForSpace(space) {
2140
- let hash = 0;
2141
- for (const char of space) hash = hash * 31 + (char.codePointAt(0) ?? 0) >>> 0;
2142
- return SPACE_COLORS[hash % SPACE_COLORS.length] ?? kleur.cyan;
2143
- }
2144
- const PAGE_NOUN = {
2145
- singular: "page",
2146
- plural: "pages"
2147
- };
2148
- async function copyPage(client, site, id, out) {
2149
- console.log(`Fetching page ${id} ...`);
2150
- await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
2151
- }
2152
- const PAGE_REF = {
2153
- message: "Confluence page id or URL:",
2154
- parse: parsePageId,
2155
- notFound: (raw) => `Could not find a page id in "${raw}".`
2156
- };
2157
- //#endregion
2158
- //#region src/cli/confluence.ts
2159
- function registerConfluence(confluence) {
2160
- confluence.description("Confluence commands");
2161
- 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));
2162
- 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));
2163
- 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));
2164
- 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));
2165
- 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));
2166
- return confluence;
2167
- }
2168
- //#endregion
2169
- //#region src/api/jira.ts
2170
- const FIELDS = [
2171
- "summary",
2172
- "description",
2173
- "issuetype",
2174
- "status",
2175
- "assignee",
2176
- "reporter",
2177
- "priority",
2178
- "labels",
2179
- "created",
2180
- "updated",
2181
- "attachment"
2182
- ].join(",");
2183
- const RECENT_ISSUES_CLAUSE = "updated >= -30d";
2184
- function browseUrl(site, key) {
2185
- return `${site}/browse/${key}`;
2186
- }
2187
- async function fetchIssue(client, site, key) {
2188
- const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
2189
- const comments = await fetchComments(client, key);
2190
- const f = issue.fields;
2191
- return {
2192
- key: issue.key,
2193
- url: browseUrl(site, issue.key),
2194
- summary: f.summary ?? "",
2195
- type: f.issuetype?.name ?? "",
2196
- status: f.status?.name ?? "",
2197
- statusCategory: f.status?.statusCategory?.key ?? "",
2198
- assignee: f.assignee?.displayName ?? "Unassigned",
2199
- reporter: f.reporter?.displayName ?? "",
2200
- priority: f.priority?.name ?? "",
2201
- labels: f.labels ?? [],
2202
- created: f.created ?? "",
2203
- updated: f.updated ?? "",
2204
- description: f.description ?? null,
2205
- comments,
2206
- attachments: (f.attachment ?? []).map((a) => ({
2207
- mediaId: a.id,
2208
- filename: a.filename,
2209
- url: a.content
2210
- }))
2211
- };
2212
- }
2213
- async function updateIssue(client, key, update) {
2214
- const fields = { description: update.description };
2215
- if (update.summary !== void 0) fields["summary"] = update.summary;
2216
- await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
2217
- }
2218
- const SEARCH_FIELDS = "summary,status,updated";
2219
- async function searchIssues(client, site, params) {
2220
- return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
2221
- }
2222
- async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
2223
- const query = new URLSearchParams({
2224
- jql,
2225
- maxResults: String(maxResults),
2226
- fields: SEARCH_FIELDS
2227
- });
2228
- if (nextPageToken) query.set("nextPageToken", nextPageToken);
2229
- return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
2230
- }
2231
- function toIssueSummary(site, i) {
2232
- return {
2233
- key: i.key,
2234
- status: i.fields?.status?.name ?? "",
2235
- statusCategory: i.fields?.status?.statusCategory?.key ?? "",
2236
- summary: decodeEntities(i.fields?.summary ?? ""),
2237
- updated: i.fields?.updated ?? "",
2238
- url: browseUrl(site, i.key)
2239
- };
2240
- }
2241
- function buildJql(params) {
2242
- if (params.jql) return params.jql;
2243
- const clauses = [];
2244
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
2245
- if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
2246
- if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
2247
- if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
2248
- if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
2249
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
2250
- }
2251
- const OPEN_CLAUSE = "statusCategory != Done";
2252
- function buildListJql(params) {
2253
- const clauses = ["assignee = currentUser()"];
2254
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
2255
- clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
2256
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
2257
- }
2258
- const LIST_PAGE_SIZE = 100;
2259
- const LIST_CAP = 500;
2260
- async function listAssignedIssues(client, site, params) {
2261
- const jql = buildListJql(params);
2262
- const issues = [];
2263
- let nextPageToken;
2264
- for (;;) {
2265
- const room = LIST_CAP - issues.length;
2266
- const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
2267
- const page = res.issues ?? [];
2268
- issues.push(...page.map((i) => toIssueSummary(site, i)));
2269
- if (res.isLast || !res.nextPageToken || page.length === 0) return {
2270
- issues,
2271
- truncated: false
2272
- };
2273
- if (issues.length >= LIST_CAP) return {
2274
- issues,
2275
- truncated: true
2276
- };
2277
- nextPageToken = res.nextPageToken;
2278
- }
2279
- }
2280
- const LIST_CATEGORY_ORDER = {
2281
- indeterminate: 0,
2282
- new: 1,
2283
- done: 2
2284
- };
2285
- const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
2286
- function sortByCategoryThenUpdated(issues) {
2287
- const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
2288
- const updatedMs = (i) => Date.parse(i.updated) || 0;
2289
- return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
2290
- }
2291
- const PROJECT_PAGE_SIZE = 50;
2292
- async function listProjects(client, site, query) {
2293
- const projects = [];
2294
- for (let startAt = 0;;) {
2295
- const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
2296
- const values = res.values ?? [];
2297
- for (const p of values) projects.push({
2298
- key: p.key,
2299
- name: p.name,
2300
- id: p.id,
2301
- type: p.projectTypeKey ?? "",
2302
- url: browseUrl(site, p.key)
2303
- });
2304
- if (res.isLast || values.length === 0) break;
2305
- startAt += values.length;
2306
- }
2307
- return projects;
2441
+ space: options.space
2442
+ }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2308
2443
  }
2309
- function projectSearchQuery(query, startAt) {
2310
- const params = new URLSearchParams({
2311
- orderBy: "key",
2312
- maxResults: String(PROJECT_PAGE_SIZE),
2313
- startAt: String(startAt)
2444
+ async function listPages(env, filter, empty, options) {
2445
+ const { session, term } = env;
2446
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2447
+ const limit = parseLimit(options.limit);
2448
+ const { pages, hasMore } = await searchPages(session, session.site, {
2449
+ ...filter,
2450
+ limit
2314
2451
  });
2315
- if (query) params.set("query", query);
2316
- return params.toString();
2317
- }
2318
- async function listStatuses(client, project) {
2319
- return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
2452
+ await runSearch(term, formatPageRows(pages, Date.now()), {
2453
+ json: options.json,
2454
+ copy: options.copy,
2455
+ out: options.out,
2456
+ empty,
2457
+ footer: hasMore ? searchFooter(limit) : void 0
2458
+ }, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2320
2459
  }
2321
- async function fetchProjectStatuses(client, project) {
2322
- let groups;
2323
- try {
2324
- groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
2325
- } catch (err) {
2326
- if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
2327
- throw err;
2328
- }
2329
- return groups.flatMap((g) => g.statuses ?? []);
2460
+ function formatPageRows(pages, nowMs) {
2461
+ return alignedRows(pages, nowMs, (p) => ({
2462
+ id: p.id,
2463
+ url: p.url,
2464
+ label: p.space,
2465
+ color: colorForSpace(p.space),
2466
+ text: p.title,
2467
+ timestamp: p.updated
2468
+ }));
2330
2469
  }
2331
- function toStatusSummary(s) {
2332
- return {
2333
- name: s.name,
2334
- id: s.id,
2335
- category: s.statusCategory?.name ?? "",
2336
- categoryKey: s.statusCategory?.key ?? ""
2337
- };
2470
+ const SPACE_COLORS = [
2471
+ kleur.cyan,
2472
+ kleur.yellow,
2473
+ kleur.green,
2474
+ kleur.magenta,
2475
+ kleur.blue
2476
+ ];
2477
+ function colorForSpace(space) {
2478
+ let hash = 0;
2479
+ for (const char of space) hash = hash * 31 + (char.codePointAt(0) ?? 0) >>> 0;
2480
+ return SPACE_COLORS[hash % SPACE_COLORS.length] ?? kleur.cyan;
2338
2481
  }
2339
- const CATEGORY_ORDER = {
2340
- new: 0,
2341
- indeterminate: 1,
2342
- done: 2
2482
+ const PAGE_NOUN = {
2483
+ singular: "page",
2484
+ plural: "pages"
2343
2485
  };
2344
- const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
2345
- function dedupeAndSortStatuses(statuses) {
2346
- const byNameCategory = /* @__PURE__ */ new Map();
2347
- for (const s of statuses) {
2348
- const key = `${s.name}\0${s.categoryKey}`;
2349
- if (!byNameCategory.has(key)) byNameCategory.set(key, s);
2350
- }
2351
- return [...byNameCategory.values()].sort((a, b) => {
2352
- const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
2353
- return rank(a) - rank(b) || a.name.localeCompare(b.name);
2354
- });
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));
2355
2489
  }
2356
- function jqlStringLiteral(value) {
2357
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2490
+ const PAGE_REF = {
2491
+ message: "Confluence page id or URL:",
2492
+ flag: "[page]",
2493
+ parse: parsePageId,
2494
+ notFound: (raw) => `Could not find a page id in "${raw}".`
2495
+ };
2496
+ //#endregion
2497
+ //#region src/cli/confluence.ts
2498
+ function registerConfluence(confluence) {
2499
+ confluence.description("Confluence commands");
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));
2505
+ return confluence;
2358
2506
  }
2359
- async function fetchComments(client, key) {
2360
- return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
2361
- author: c.author?.displayName ?? "",
2362
- created: c.created ?? "",
2363
- body: c.body ?? null
2364
- }));
2507
+ //#endregion
2508
+ //#region src/api/jira-url.ts
2509
+ function browseUrl(site, key) {
2510
+ return `${site}/browse/${key}`;
2365
2511
  }
2512
+ //#endregion
2513
+ //#region src/api/jira-createmeta.ts
2366
2514
  const CREATEMETA_PAGE_SIZE = 200;
2367
2515
  async function fetchCreateIssueTypes(client, project) {
2368
2516
  return (await pageCreateMeta(client, project, "", (page) => page.issueTypes ?? [])).map((t) => ({
@@ -2412,6 +2560,8 @@ async function createIssue(client, site, fields) {
2412
2560
  url: browseUrl(site, res.key)
2413
2561
  };
2414
2562
  }
2563
+ //#endregion
2564
+ //#region src/api/jira-users.ts
2415
2565
  async function searchAssignableUsers(client, project, query) {
2416
2566
  const params = new URLSearchParams({
2417
2567
  project,
@@ -2432,8 +2582,123 @@ function toJiraUser(u) {
2432
2582
  };
2433
2583
  }
2434
2584
  //#endregion
2585
+ //#region src/api/jira-projects.ts
2586
+ const PROJECT_PAGE_SIZE = 50;
2587
+ async function listProjects(client, site, query) {
2588
+ const projects = [];
2589
+ for (let startAt = 0;;) {
2590
+ const res = await client.getJson(`/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`);
2591
+ const values = res.values ?? [];
2592
+ for (const p of values) projects.push({
2593
+ key: p.key,
2594
+ name: p.name,
2595
+ id: p.id,
2596
+ type: p.projectTypeKey ?? "",
2597
+ url: browseUrl(site, p.key)
2598
+ });
2599
+ if (res.isLast || values.length === 0) break;
2600
+ startAt += values.length;
2601
+ }
2602
+ return projects;
2603
+ }
2604
+ function projectSearchQuery(query, startAt) {
2605
+ const params = new URLSearchParams({
2606
+ orderBy: "key",
2607
+ maxResults: String(PROJECT_PAGE_SIZE),
2608
+ startAt: String(startAt)
2609
+ });
2610
+ if (query) params.set("query", query);
2611
+ return params.toString();
2612
+ }
2613
+ //#endregion
2614
+ //#region src/commands/jira-create-resolve.ts
2615
+ async function resolveProject(ask, client, site, arg, strict) {
2616
+ if (arg) return arg.toUpperCase();
2617
+ if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
2618
+ return ask.searchPick({
2619
+ message: "Project:",
2620
+ source: async (term) => (await listProjects(client, site, term)).map((p) => ({
2621
+ name: `${p.key} ${p.name}`,
2622
+ value: p.key
2623
+ })),
2624
+ pageSize: 15
2625
+ });
2626
+ }
2627
+ async function resolveType(ask, project, types, arg, strict) {
2628
+ if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
2629
+ if (arg) {
2630
+ const found = matchType(types, arg);
2631
+ if (found) return found;
2632
+ const names = types.map((t) => t.name).join(", ");
2633
+ throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
2634
+ }
2635
+ if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
2636
+ return ask.pick({
2637
+ message: "Issue type:",
2638
+ choices: types.map((t) => ({
2639
+ name: t.name,
2640
+ value: t,
2641
+ description: t.description
2642
+ })),
2643
+ pageSize: 15
2644
+ });
2645
+ }
2646
+ function matchType(types, arg) {
2647
+ const needle = arg.trim().toLowerCase();
2648
+ return types.find((t) => t.id === arg.trim()) ?? types.find((t) => t.name.toLowerCase() === needle);
2649
+ }
2650
+ const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
2651
+ function userResolver(client, project) {
2652
+ return async (query) => {
2653
+ if (query === "me") return (await fetchMyself(client)).accountId;
2654
+ if (ACCOUNT_ID.test(query)) return query;
2655
+ const users = (await searchAssignableUsers(client, project, query)).filter((u) => u.active);
2656
+ const exact = users.filter((u) => u.displayName.toLowerCase() === query.toLowerCase() || u.email.toLowerCase() === query.toLowerCase());
2657
+ const candidates = exact.length === 1 ? exact : users;
2658
+ if (candidates.length === 1) return candidates[0].accountId;
2659
+ if (candidates.length === 0) throw new Error(`no assignable user matches "${query}".`);
2660
+ const names = candidates.map((u) => u.displayName).join(", ");
2661
+ throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
2662
+ };
2663
+ }
2664
+ //#endregion
2665
+ //#region src/create/encode-allowed-values.ts
2666
+ function allowedLabel(v) {
2667
+ return v.value ?? v.name ?? v.key ?? v.id ?? "";
2668
+ }
2669
+ function findAllowed(options, token) {
2670
+ const needle = token.toLowerCase();
2671
+ return options.find((v) => v.id === token) ?? options.find((v) => allowedLabel(v).toLowerCase() === needle);
2672
+ }
2673
+ function allowedList(options) {
2674
+ return options.map(allowedLabel).join(", ");
2675
+ }
2676
+ function matchAllowed(field, token) {
2677
+ const options = field.allowedValues ?? [];
2678
+ const hit = findAllowed(options, token);
2679
+ if (!hit) return { problem: `${field.name} has no value "${token}"; allowed: ${allowedList(options)}.` };
2680
+ return { value: { id: hit.id } };
2681
+ }
2682
+ function matchCascading(field, value) {
2683
+ const options = field.allowedValues ?? [];
2684
+ const [parentText = "", childText] = value.split(">").map((s) => s.trim());
2685
+ const parent = findAllowed(options, parentText);
2686
+ if (!parent) return { problem: `${field.name} has no value "${parentText}"; allowed: ${allowedList(options)}.` };
2687
+ if (!childText) return { value: { id: parent.id } };
2688
+ const children = parent.children ?? [];
2689
+ const child = findAllowed(children, childText);
2690
+ if (!child) {
2691
+ const under = allowedLabel(parent);
2692
+ return { problem: `${field.name} has no value "${childText}" under ${under}; allowed: ${allowedList(children)}.` };
2693
+ }
2694
+ return { value: {
2695
+ id: parent.id,
2696
+ child: { id: child.id }
2697
+ } };
2698
+ }
2699
+ //#endregion
2435
2700
  //#region src/create/encode.ts
2436
- const SERVER_FILLED = new Set([
2701
+ const SERVER_FILLED = /* @__PURE__ */ new Set([
2437
2702
  "project",
2438
2703
  "issuetype",
2439
2704
  "reporter"
@@ -2513,7 +2778,7 @@ async function encodeField(field, values, resolveUser) {
2513
2778
  }
2514
2779
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
2515
2780
  const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
2516
- const NAMED_TYPES = new Set([
2781
+ const NAMED_TYPES = /* @__PURE__ */ new Set([
2517
2782
  "priority",
2518
2783
  "version",
2519
2784
  "component",
@@ -2555,63 +2820,30 @@ async function encodeScalar(field, type, raw, resolveUser) {
2555
2820
  case "issuelink": return { value: { key: token.toUpperCase() } };
2556
2821
  default:
2557
2822
  if (NAMED_TYPES.has(type)) return { value: { name: token } };
2558
- return { value: looseValue(raw) };
2559
- }
2560
- }
2561
- function looseValue(raw) {
2562
- const trimmed = raw.trim();
2563
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
2564
- try {
2565
- return JSON.parse(trimmed);
2566
- } catch {
2567
- return raw;
2568
- }
2569
- }
2570
- function formatJiraDateTime(date) {
2571
- const pad = (n, width = 2) => String(n).padStart(width, "0");
2572
- const offsetMinutes = -date.getTimezoneOffset();
2573
- const sign = offsetMinutes < 0 ? "-" : "+";
2574
- const abs = Math.abs(offsetMinutes);
2575
- const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
2576
- const hms = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
2577
- const offset = `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
2578
- return `${ymd}T${hms}.${pad(date.getMilliseconds(), 3)}${offset}`;
2579
- }
2580
- function splitCommas(values) {
2581
- return values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
2582
- }
2583
- function allowedLabel(v) {
2584
- return v.value ?? v.name ?? v.key ?? v.id ?? "";
2585
- }
2586
- function findAllowed(options, token) {
2587
- const needle = token.toLowerCase();
2588
- return options.find((v) => v.id === token) ?? options.find((v) => allowedLabel(v).toLowerCase() === needle);
2589
- }
2590
- function allowedList(options) {
2591
- return options.map(allowedLabel).join(", ");
2592
- }
2593
- function matchAllowed(field, token) {
2594
- const options = field.allowedValues ?? [];
2595
- const hit = findAllowed(options, token);
2596
- if (!hit) return { problem: `${field.name} has no value "${token}"; allowed: ${allowedList(options)}.` };
2597
- return { value: { id: hit.id } };
2598
- }
2599
- function matchCascading(field, value) {
2600
- const options = field.allowedValues ?? [];
2601
- const [parentText = "", childText] = value.split(">").map((s) => s.trim());
2602
- const parent = findAllowed(options, parentText);
2603
- if (!parent) return { problem: `${field.name} has no value "${parentText}"; allowed: ${allowedList(options)}.` };
2604
- if (!childText) return { value: { id: parent.id } };
2605
- const children = parent.children ?? [];
2606
- const child = findAllowed(children, childText);
2607
- if (!child) {
2608
- const under = allowedLabel(parent);
2609
- return { problem: `${field.name} has no value "${childText}" under ${under}; allowed: ${allowedList(children)}.` };
2823
+ return { value: looseValue(raw) };
2610
2824
  }
2611
- return { value: {
2612
- id: parent.id,
2613
- child: { id: child.id }
2614
- } };
2825
+ }
2826
+ function looseValue(raw) {
2827
+ const trimmed = raw.trim();
2828
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
2829
+ try {
2830
+ return JSON.parse(trimmed);
2831
+ } catch {
2832
+ return raw;
2833
+ }
2834
+ }
2835
+ function formatJiraDateTime(date) {
2836
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
2837
+ const offsetMinutes = -date.getTimezoneOffset();
2838
+ const sign = offsetMinutes < 0 ? "-" : "+";
2839
+ const abs = Math.abs(offsetMinutes);
2840
+ const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
2841
+ const hms = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
2842
+ const offset = `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
2843
+ return `${ymd}T${hms}.${pad(date.getMilliseconds(), 3)}${offset}`;
2844
+ }
2845
+ function splitCommas(values) {
2846
+ return values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0);
2615
2847
  }
2616
2848
  //#endregion
2617
2849
  //#region src/create/format.ts
@@ -2727,16 +2959,16 @@ function formatIssueTypeRows(types) {
2727
2959
  //#endregion
2728
2960
  //#region src/create/prompt.ts
2729
2961
  const PROMPT_SOURCE = "prompt";
2730
- async function walkFields(meta, deps) {
2962
+ async function walkFields(ask, meta, deps) {
2731
2963
  const firstPass = meta.filter((f) => f.required && !SERVER_FILLED.has(f.fieldId));
2732
2964
  const inputs = [];
2733
2965
  for (const field of firstPass) {
2734
- const answer = await promptField(field, deps, true);
2966
+ const answer = await promptField(ask, field, deps, true);
2735
2967
  if (answer) inputs.push(answer);
2736
2968
  }
2737
2969
  const optional = meta.filter((f) => !firstPass.includes(f) && !SERVER_FILLED.has(f.fieldId));
2738
2970
  if (optional.length === 0) return inputs;
2739
- const chosen = await checkbox({
2971
+ const chosen = await ask.pickMany({
2740
2972
  message: "Set any optional fields?",
2741
2973
  choices: optional.map((f) => ({
2742
2974
  name: `${f.name} (${fieldTypeLabel(f.schema)})`,
@@ -2745,13 +2977,13 @@ async function walkFields(meta, deps) {
2745
2977
  pageSize: 15
2746
2978
  });
2747
2979
  for (const field of optional.filter((f) => chosen.includes(f.fieldId))) {
2748
- const answer = await promptField(field, deps, false);
2980
+ const answer = await promptField(ask, field, deps, false);
2749
2981
  if (answer) inputs.push(answer);
2750
2982
  }
2751
2983
  return inputs;
2752
2984
  }
2753
- async function promptField(field, deps, required) {
2754
- const answer = await askValues(field, deps, required);
2985
+ async function promptField(ask, field, deps, required) {
2986
+ const answer = await askValues(ask, field, deps, required);
2755
2987
  if (answer.values.length === 0) return void 0;
2756
2988
  return {
2757
2989
  name: field.fieldId,
@@ -2764,14 +2996,14 @@ const SKIP = {
2764
2996
  name: "(skip)",
2765
2997
  value: ""
2766
2998
  };
2767
- async function askValues(field, deps, required) {
2999
+ async function askValues(ask, field, deps, required) {
2768
3000
  const { schema } = field;
2769
3001
  const message = `${field.name}:`;
2770
3002
  const options = field.allowedValues ?? [];
2771
- if (schema.type === "option-with-child") return askCascading(field, required);
3003
+ if (schema.type === "option-with-child") return askCascading(ask, field, required);
2772
3004
  if (options.length > 0 && schema.type === "array") {
2773
3005
  const defaults = new Set(defaultIds(field));
2774
- return { values: await checkbox({
3006
+ return { values: await ask.pickMany({
2775
3007
  message,
2776
3008
  choices: options.map((v) => ({
2777
3009
  ...choice(v),
@@ -2783,7 +3015,7 @@ async function askValues(field, deps, required) {
2783
3015
  }
2784
3016
  if (options.length > 0) {
2785
3017
  const choices = options.map(choice);
2786
- const picked = await select({
3018
+ const picked = await ask.pick({
2787
3019
  message,
2788
3020
  choices: required ? choices : [SKIP, ...choices],
2789
3021
  default: defaultIds(field)[0],
@@ -2791,9 +3023,9 @@ async function askValues(field, deps, required) {
2791
3023
  });
2792
3024
  return { values: picked ? [picked] : [] };
2793
3025
  }
2794
- if (schema.type === "user") return askUser(field, deps, required);
3026
+ if (schema.type === "user") return askUser(ask, field, deps, required);
2795
3027
  if (isMultiLine(schema)) {
2796
- const text = await editor({
3028
+ const text = await ask.edit({
2797
3029
  message: `${field.name} (opens your editor, Markdown):`,
2798
3030
  postfix: ".md",
2799
3031
  default: primitiveDefault(field),
@@ -2802,7 +3034,7 @@ async function askValues(field, deps, required) {
2802
3034
  return { values: text.trim() ? [text] : [] };
2803
3035
  }
2804
3036
  const hint = inputHint(field);
2805
- const text = await input({
3037
+ const text = await ask.text({
2806
3038
  message: hint ? `${field.name} (${hint}):` : message,
2807
3039
  default: primitiveDefault(field),
2808
3040
  validate: async (v) => {
@@ -2840,10 +3072,10 @@ function primitiveDefault(field) {
2840
3072
  if (!field.hasDefaultValue || d == null) return void 0;
2841
3073
  return typeof d === "string" || typeof d === "number" ? String(d) : void 0;
2842
3074
  }
2843
- async function askCascading(field, required) {
3075
+ async function askCascading(ask, field, required) {
2844
3076
  const options = field.allowedValues ?? [];
2845
3077
  const parentChoices = options.map(choice);
2846
- const parentId = await select({
3078
+ const parentId = await ask.pick({
2847
3079
  message: `${field.name}:`,
2848
3080
  choices: required ? parentChoices : [SKIP, ...parentChoices],
2849
3081
  pageSize: 15
@@ -2852,7 +3084,7 @@ async function askCascading(field, required) {
2852
3084
  const parent = options.find((v) => v.id === parentId);
2853
3085
  const children = parent?.children ?? [];
2854
3086
  if (children.length === 0) return { values: [parentId] };
2855
- const childId = await select({
3087
+ const childId = await ask.pick({
2856
3088
  message: `${field.name} > ${allowedLabel(parent)}:`,
2857
3089
  choices: [{
2858
3090
  name: "(none)",
@@ -2870,10 +3102,10 @@ const UNASSIGNED = {
2870
3102
  name: "(unassigned)",
2871
3103
  value: ""
2872
3104
  };
2873
- async function askUser(field, deps, required) {
3105
+ async function askUser(ask, field, deps, required) {
2874
3106
  const fixed = required ? [ME] : [ME, UNASSIGNED];
2875
3107
  const seen = /* @__PURE__ */ new Map();
2876
- const picked = await search({
3108
+ const picked = await ask.searchPick({
2877
3109
  message: `${field.name} (type to search):`,
2878
3110
  source: async (term) => {
2879
3111
  if (!term) return fixed;
@@ -2915,7 +3147,7 @@ function summarizeText(text) {
2915
3147
  }
2916
3148
  //#endregion
2917
3149
  //#region src/commands/jira-create.ts
2918
- async function flagInputs(options) {
3150
+ async function flagInputs(files, options) {
2919
3151
  const inputs = [];
2920
3152
  const one = (name, value, source) => {
2921
3153
  if (value !== void 0) inputs.push({
@@ -2928,7 +3160,7 @@ async function flagInputs(options) {
2928
3160
  one("description", options.description, "--description");
2929
3161
  if (options.descriptionFile !== void 0) {
2930
3162
  if (options.description !== void 0) throw new Error("--description and --description-file cannot be used together.");
2931
- one("description", await readFile(options.descriptionFile, "utf8"), "--description-file");
3163
+ one("description", await files.readText(options.descriptionFile), "--description-file");
2932
3164
  }
2933
3165
  one("assignee", options.assignee, "--assignee");
2934
3166
  one("priority", options.priority, "--priority");
@@ -2955,17 +3187,16 @@ function parseFieldFlag(raw) {
2955
3187
  source: "--field"
2956
3188
  };
2957
3189
  }
2958
- async function jiraCreate(projectArg, typeArg, options) {
2959
- const inputs = await flagInputs(options);
2960
- const strict = inputs.length > 0 || options.input === false || !process.stdin.isTTY;
2961
- const auth = await requireAuth();
2962
- const client = new AtlassianClient(auth);
2963
- const project = await resolveProject(client, auth.site, projectArg, strict);
2964
- const type = await resolveType(project, await fetchCreateIssueTypes(client, project), typeArg, strict);
2965
- const meta = await fetchCreateFields(client, project, type.id);
2966
- const resolveUser = userResolver(client, project);
2967
- if (!strict) inputs.push(...await walkFields(meta, {
2968
- 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),
2969
3200
  validate: async (field, value) => {
2970
3201
  return (await encodeCreate([field], [{
2971
3202
  name: field.fieldId,
@@ -2983,23 +3214,23 @@ async function jiraCreate(projectArg, typeArg, options) {
2983
3214
  ...result.fields
2984
3215
  };
2985
3216
  if (options.dryRun) {
2986
- console.log(JSON.stringify({ fields }, null, 2));
3217
+ term.json({ fields });
2987
3218
  return;
2988
3219
  }
2989
3220
  if (!strict) {
2990
- for (const line of formatReview(meta, inputs)) console.log(line);
2991
- if (!await confirm({
3221
+ term.out(formatReview(meta, inputs));
3222
+ if (!await term.ask.confirm({
2992
3223
  message: `Create ${project} ${type.name}?`,
2993
3224
  default: true
2994
3225
  })) {
2995
- console.log("Aborted.");
3226
+ term.out("Aborted.");
2996
3227
  return;
2997
3228
  }
2998
3229
  }
2999
3230
  try {
3000
- const created = await createIssue(client, auth.site, fields);
3001
- if (options.json) console.log(JSON.stringify(created, null, 2));
3002
- 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}`);
3003
3234
  } catch (err) {
3004
3235
  throw describeRejection(err, meta);
3005
3236
  }
@@ -3025,112 +3256,221 @@ function describeRejection(err, meta) {
3025
3256
  ...perField
3026
3257
  ].join("\n"));
3027
3258
  }
3028
- async function resolveProject(client, site, arg, strict) {
3029
- if (arg) return arg.toUpperCase();
3030
- if (strict) throw new Error("A project key is required, e.g. `jira create BSC Bug --summary ...`.");
3031
- return search({
3032
- message: "Project:",
3033
- source: async (term) => (await listProjects(client, site, term)).map((p) => ({
3034
- name: `${p.key} ${p.name}`,
3035
- value: p.key
3036
- })),
3037
- pageSize: 15
3259
+ async function jiraFields({ session, term }, projectArg, typeArg, options) {
3260
+ const project = projectArg.toUpperCase();
3261
+ const types = await fetchCreateIssueTypes(session, project);
3262
+ if (!typeArg) {
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));
3266
+ return;
3267
+ }
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));
3271
+ }
3272
+ //#endregion
3273
+ //#region src/api/jira-search.ts
3274
+ const RECENT_ISSUES_CLAUSE = "updated >= -30d";
3275
+ const SEARCH_FIELDS = "summary,status,updated";
3276
+ async function searchIssues(client, site, params) {
3277
+ return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
3278
+ }
3279
+ async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
3280
+ const query = new URLSearchParams({
3281
+ jql,
3282
+ maxResults: String(maxResults),
3283
+ fields: SEARCH_FIELDS
3038
3284
  });
3285
+ if (nextPageToken) query.set("nextPageToken", nextPageToken);
3286
+ return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
3039
3287
  }
3040
- async function resolveType(project, types, arg, strict) {
3041
- if (types.length === 0) throw new Error(`You cannot create issues in ${project}.`);
3042
- if (arg) {
3043
- const found = matchType(types, arg);
3044
- if (found) return found;
3045
- const names = types.map((t) => t.name).join(", ");
3046
- throw new Error(`${project} has no issue type "${arg}"; available: ${names}.`);
3288
+ function toIssueSummary(site, i) {
3289
+ return {
3290
+ key: i.key,
3291
+ status: i.fields?.status?.name ?? "",
3292
+ statusCategory: i.fields?.status?.statusCategory?.key ?? "",
3293
+ summary: decodeEntities(i.fields?.summary ?? ""),
3294
+ updated: i.fields?.updated ?? "",
3295
+ url: browseUrl(site, i.key)
3296
+ };
3297
+ }
3298
+ function buildJql(params) {
3299
+ if (params.jql) return params.jql;
3300
+ const clauses = [];
3301
+ if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3302
+ if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
3303
+ if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
3304
+ if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
3305
+ if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
3306
+ return `${clauses.join(" AND ")} ORDER BY updated DESC`;
3307
+ }
3308
+ const OPEN_CLAUSE = "statusCategory != Done";
3309
+ function buildListJql(params) {
3310
+ const clauses = ["assignee = currentUser()"];
3311
+ if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3312
+ clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
3313
+ return `${clauses.join(" AND ")} ORDER BY updated DESC`;
3314
+ }
3315
+ const LIST_PAGE_SIZE = 100;
3316
+ const LIST_CAP = 500;
3317
+ async function listAssignedIssues(client, site, params) {
3318
+ const jql = buildListJql(params);
3319
+ const issues = [];
3320
+ let nextPageToken;
3321
+ for (;;) {
3322
+ const room = LIST_CAP - issues.length;
3323
+ const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
3324
+ const page = res.issues ?? [];
3325
+ issues.push(...page.map((i) => toIssueSummary(site, i)));
3326
+ if (res.isLast || !res.nextPageToken || page.length === 0) return {
3327
+ issues,
3328
+ truncated: false
3329
+ };
3330
+ if (issues.length >= LIST_CAP) return {
3331
+ issues,
3332
+ truncated: true
3333
+ };
3334
+ nextPageToken = res.nextPageToken;
3047
3335
  }
3048
- if (strict) throw new Error(`An issue type is required; ${project} has: ${types.map((t) => t.name).join(", ")}.`);
3049
- return select({
3050
- message: "Issue type:",
3051
- choices: types.map((t) => ({
3052
- name: t.name,
3053
- value: t,
3054
- description: t.description
3055
- })),
3056
- pageSize: 15
3057
- });
3058
3336
  }
3059
- function matchType(types, arg) {
3060
- const needle = arg.trim().toLowerCase();
3061
- return types.find((t) => t.id === arg.trim()) ?? types.find((t) => t.name.toLowerCase() === needle);
3337
+ const LIST_CATEGORY_ORDER = {
3338
+ indeterminate: 0,
3339
+ new: 1,
3340
+ done: 2
3341
+ };
3342
+ const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
3343
+ function sortByCategoryThenUpdated(issues) {
3344
+ const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
3345
+ const updatedMs = (i) => Date.parse(i.updated) || 0;
3346
+ return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
3062
3347
  }
3063
- const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
3064
- function userResolver(client, project) {
3065
- return async (query) => {
3066
- if (query === "me") return (await fetchMyself(client)).accountId;
3067
- if (ACCOUNT_ID.test(query)) return query;
3068
- const users = (await searchAssignableUsers(client, project, query)).filter((u) => u.active);
3069
- const exact = users.filter((u) => u.displayName.toLowerCase() === query.toLowerCase() || u.email.toLowerCase() === query.toLowerCase());
3070
- const candidates = exact.length === 1 ? exact : users;
3071
- if (candidates.length === 1) return candidates[0].accountId;
3072
- if (candidates.length === 0) throw new Error(`no assignable user matches "${query}".`);
3073
- const names = candidates.map((u) => u.displayName).join(", ");
3074
- throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
3348
+ function jqlStringLiteral(value) {
3349
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
3350
+ }
3351
+ //#endregion
3352
+ //#region src/api/jira-issues.ts
3353
+ const FIELDS = [
3354
+ "summary",
3355
+ "description",
3356
+ "issuetype",
3357
+ "status",
3358
+ "assignee",
3359
+ "reporter",
3360
+ "priority",
3361
+ "labels",
3362
+ "created",
3363
+ "updated",
3364
+ "attachment"
3365
+ ].join(",");
3366
+ async function fetchIssue(client, site, key) {
3367
+ const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
3368
+ const comments = await fetchComments(client, key);
3369
+ const f = issue.fields;
3370
+ return {
3371
+ key: issue.key,
3372
+ url: browseUrl(site, issue.key),
3373
+ summary: f.summary ?? "",
3374
+ type: f.issuetype?.name ?? "",
3375
+ status: f.status?.name ?? "",
3376
+ statusCategory: f.status?.statusCategory?.key ?? "",
3377
+ assignee: f.assignee?.displayName ?? "Unassigned",
3378
+ reporter: f.reporter?.displayName ?? "",
3379
+ priority: f.priority?.name ?? "",
3380
+ labels: f.labels ?? [],
3381
+ created: f.created ?? "",
3382
+ updated: f.updated ?? "",
3383
+ description: f.description ?? null,
3384
+ comments,
3385
+ attachments: (f.attachment ?? []).map((a) => ({
3386
+ mediaId: a.id,
3387
+ filename: a.filename,
3388
+ url: a.content
3389
+ }))
3075
3390
  };
3076
3391
  }
3077
- async function jiraFields(projectArg, typeArg, options) {
3078
- const client = new AtlassianClient(await requireAuth());
3079
- const project = projectArg.toUpperCase();
3080
- const types = await fetchCreateIssueTypes(client, project);
3081
- if (!typeArg) {
3082
- if (options.json) console.log(JSON.stringify(types, null, 2));
3083
- else if (types.length === 0) console.log(`You cannot create issues in ${project}.`);
3084
- else for (const line of formatIssueTypeRows(types)) console.log(line);
3085
- return;
3086
- }
3087
- const fields = await fetchCreateFields(client, project, (await resolveType(project, types, typeArg, true)).id);
3088
- if (options.json) console.log(JSON.stringify(fields, null, 2));
3089
- else for (const line of formatFieldRows(fields)) console.log(line);
3392
+ async function updateIssue(client, key, update) {
3393
+ const fields = { description: update.description };
3394
+ if (update.summary !== void 0) fields["summary"] = update.summary;
3395
+ await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
3396
+ }
3397
+ async function fetchComments(client, key) {
3398
+ return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
3399
+ author: c.author?.displayName ?? "",
3400
+ created: c.created ?? "",
3401
+ body: c.body ?? null
3402
+ }));
3090
3403
  }
3091
3404
  //#endregion
3092
- //#region src/commands/jira.ts
3093
- async function jiraProjects(query, options) {
3094
- const auth = await requireAuth();
3095
- const projects = await listProjects(new AtlassianClient(auth), auth.site, query);
3096
- if (options.json) {
3097
- console.log(JSON.stringify(projects, null, 2));
3098
- return;
3405
+ //#region src/api/jira-statuses.ts
3406
+ async function listStatuses(client, project) {
3407
+ return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await client.getJson("/rest/api/3/status")).map(toStatusSummary));
3408
+ }
3409
+ async function fetchProjectStatuses(client, project) {
3410
+ let groups;
3411
+ try {
3412
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
3413
+ } catch (err) {
3414
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
3415
+ throw err;
3099
3416
  }
3100
- if (projects.length === 0) {
3101
- console.log("No matching projects.");
3102
- return;
3417
+ return groups.flatMap((g) => g.statuses ?? []);
3418
+ }
3419
+ function toStatusSummary(s) {
3420
+ return {
3421
+ name: s.name,
3422
+ id: s.id,
3423
+ category: s.statusCategory?.name ?? "",
3424
+ categoryKey: s.statusCategory?.key ?? ""
3425
+ };
3426
+ }
3427
+ const CATEGORY_ORDER = {
3428
+ new: 0,
3429
+ indeterminate: 1,
3430
+ done: 2
3431
+ };
3432
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
3433
+ function dedupeAndSortStatuses(statuses) {
3434
+ const byNameCategory = /* @__PURE__ */ new Map();
3435
+ for (const s of statuses) {
3436
+ const key = `${s.name}\0${s.categoryKey}`;
3437
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
3103
3438
  }
3104
- for (const line of formatProjectRows(projects)) console.log(line);
3439
+ return [...byNameCategory.values()].sort((a, b) => {
3440
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
3441
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
3442
+ });
3443
+ }
3444
+ //#endregion
3445
+ //#region src/commands/jira.ts
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));
3105
3451
  }
3106
3452
  function formatProjectRows(projects) {
3107
3453
  const width = Math.max(...projects.map((p) => p.key.length));
3108
3454
  return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
3109
3455
  }
3110
- async function jiraStatuses(query, options) {
3111
- 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);
3112
3458
  if (query) {
3113
3459
  const needle = query.toLowerCase();
3114
3460
  statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
3115
3461
  }
3116
- if (options.json) {
3117
- console.log(JSON.stringify(statuses, null, 2));
3118
- return;
3119
- }
3120
- if (statuses.length === 0) {
3121
- console.log("No matching statuses.");
3122
- return;
3123
- }
3124
- 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));
3125
3465
  }
3126
3466
  function formatStatusRows(statuses) {
3127
3467
  const width = Math.max(...statuses.map((s) => s.name.length));
3128
3468
  return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
3129
3469
  }
3130
- async function jiraView(arg, options) {
3131
- const auth = await requireAuth();
3132
- const key = await resolveRef(arg, ISSUE_REF);
3133
- 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 });
3134
3474
  }
3135
3475
  const CATEGORY_COLORS = {
3136
3476
  new: kleur.cyan,
@@ -3160,35 +3500,44 @@ function formatIssueView(issue, nowMs, allComments) {
3160
3500
  ...attachmentSection(issue.attachments)
3161
3501
  ];
3162
3502
  }
3163
- async function jiraCopy(arg, options) {
3164
- const auth = await requireAuth();
3165
- const key = await resolveRef(arg, ISSUE_REF);
3166
- 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);
3167
3505
  }
3168
- async function jiraUpdate(arg, options) {
3169
- 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({
3170
3508
  message: "Path to the issue Markdown file:",
3509
+ flag: "[file]",
3171
3510
  required: true
3172
- }), "utf8"));
3173
- const auth = await requireAuth();
3174
- const client = new AtlassianClient(auth);
3175
- const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
3176
- 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 () => {
3177
3515
  const { current, next } = plan.headline;
3178
- await updateIssue(client, src.key, {
3516
+ await updateIssue(session, src.key, {
3179
3517
  description: plan.body,
3180
3518
  summary: next !== current ? next : void 0
3181
3519
  });
3182
- console.log(`Updated ${src.key}.`);
3520
+ term.out(`Updated ${src.key}.`);
3183
3521
  });
3184
3522
  }
3185
- async function jiraSearch(query, options) {
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));
3526
+ }
3527
+ const ISSUE_REF = {
3528
+ message: "Jira issue key or URL:",
3529
+ flag: "[issue]",
3530
+ parse: parseIssueKey,
3531
+ notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3532
+ };
3533
+ //#endregion
3534
+ //#region src/commands/jira-search.ts
3535
+ async function jiraSearch(env, query, options) {
3536
+ const { session, term } = env;
3186
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.");
3187
3538
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3188
- const auth = await requireAuth();
3189
- const client = new AtlassianClient(auth);
3190
3539
  const limit = parseLimit(options.limit);
3191
- const issues = await searchIssues(client, auth.site, {
3540
+ const issues = await searchIssues(session, session.site, {
3192
3541
  text: query,
3193
3542
  project: options.project,
3194
3543
  assignee: options.assignee,
@@ -3196,29 +3545,28 @@ async function jiraSearch(query, options) {
3196
3545
  jql: options.jql,
3197
3546
  limit
3198
3547
  });
3199
- await runSearch(formatIssueRows(issues, Date.now()), {
3548
+ await runSearch(term, formatIssueRows(issues, Date.now()), {
3200
3549
  json: options.json,
3201
3550
  copy: options.copy,
3202
3551
  out: options.out,
3203
3552
  empty: "No matching issues.",
3204
3553
  footer: issues.length === limit ? searchFooter(limit) : void 0
3205
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3554
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3206
3555
  }
3207
- async function jiraList(options) {
3556
+ async function jiraList(env, options) {
3557
+ const { session, term } = env;
3208
3558
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3209
- const auth = await requireAuth();
3210
- const client = new AtlassianClient(auth);
3211
- const { issues, truncated } = await listAssignedIssues(client, auth.site, {
3559
+ const { issues, truncated } = await listAssignedIssues(session, session.site, {
3212
3560
  all: options.all,
3213
3561
  project: options.project
3214
3562
  });
3215
- await runSearch(formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3563
+ await runSearch(term, formatIssueRows(sortByCategoryThenUpdated(issues), Date.now()), {
3216
3564
  json: options.json,
3217
3565
  copy: options.copy,
3218
3566
  out: options.out,
3219
3567
  empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
3220
3568
  footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
3221
- }, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
3569
+ }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3222
3570
  }
3223
3571
  function formatIssueRows(issues, nowMs) {
3224
3572
  return alignedRows(issues, nowMs, (i) => ({
@@ -3226,22 +3574,14 @@ function formatIssueRows(issues, nowMs) {
3226
3574
  url: i.url,
3227
3575
  label: i.status,
3228
3576
  color: colorForCategory(i.statusCategory),
3229
- text: i.summary
3577
+ text: i.summary,
3578
+ timestamp: i.updated
3230
3579
  }));
3231
3580
  }
3232
3581
  const ISSUE_NOUN = {
3233
3582
  singular: "issue",
3234
3583
  plural: "issues"
3235
3584
  };
3236
- async function copyIssue(client, site, key, out) {
3237
- console.log(`Fetching ${key} ...`);
3238
- await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
3239
- }
3240
- const ISSUE_REF = {
3241
- message: "Jira issue key or URL:",
3242
- parse: parseIssueKey,
3243
- notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3244
- };
3245
3585
  //#endregion
3246
3586
  //#region src/cli/jira.ts
3247
3587
  function collect(value, previous = []) {
@@ -3249,20 +3589,20 @@ function collect(value, previous = []) {
3249
3589
  }
3250
3590
  function registerJira(jira) {
3251
3591
  jira.description("Jira commands");
3252
- jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
3253
- 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));
3254
- 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));
3255
- 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));
3256
- 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));
3257
- 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));
3258
- 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));
3259
- 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));
3260
- 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));
3261
3601
  return jira;
3262
3602
  }
3263
3603
  //#endregion
3264
3604
  //#region package.json
3265
- var version = "1.11.0";
3605
+ var version = "1.13.0";
3266
3606
  //#endregion
3267
3607
  //#region src/cli/build.ts
3268
3608
  const PRODUCTS = {
@@ -3270,16 +3610,22 @@ const PRODUCTS = {
3270
3610
  confluence: registerConfluence,
3271
3611
  bitbucket: registerBitbucket
3272
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
+ }
3273
3619
  function buildAtlass() {
3274
3620
  const program = new Command().name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
3275
3621
  registerAuth(program.command("auth"));
3276
3622
  registerJira(program.command("jira"));
3277
3623
  registerConfluence(program.command("confluence").alias("conf"));
3278
3624
  registerBitbucket(program.command("bitbucket").alias("bb"));
3279
- return program;
3625
+ return withNoInput(program);
3280
3626
  }
3281
3627
  function buildStandalone(product) {
3282
- return PRODUCTS[product](new Command().name(product).version(version));
3628
+ return withNoInput(PRODUCTS[product](new Command().name(product).version(version)));
3283
3629
  }
3284
3630
  //#endregion
3285
3631
  export { buildStandalone as n, fail as r, buildAtlass as t };