atlass 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +67 -308
  2. package/dist/cli.mjs +1180 -817
  3. package/package.json +9 -1
package/dist/cli.mjs CHANGED
@@ -7,10 +7,10 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { Entry } from "@napi-rs/keyring";
8
8
  import { marked } from "marked";
9
9
  import { randomUUID } from "node:crypto";
10
- //#region package.json
11
- var version = "1.6.0";
12
- //#endregion
10
+ import kleur from "kleur";
11
+ import { common, createLowlight } from "lowlight";
13
12
  //#region src/api/client.ts
13
+ const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
14
14
  var AtlassianClient = class {
15
15
  site;
16
16
  authHeader;
@@ -47,7 +47,7 @@ var AtlassianClient = class {
47
47
  body: JSON.stringify(body)
48
48
  })).json();
49
49
  }
50
- async put(path, body) {
50
+ async putNoContent(path, body) {
51
51
  await this.request(path, {
52
52
  method: "PUT",
53
53
  headers: {
@@ -65,17 +65,21 @@ var AtlassianClient = class {
65
65
  method: "POST",
66
66
  headers: {
67
67
  Accept: "application/json",
68
- "X-Atlassian-Token": "nocheck"
68
+ ...DISABLE_XSRF_CHECK
69
69
  },
70
70
  body: form
71
71
  })).json();
72
72
  }
73
- async getBinary(url) {
74
- const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
73
+ async getBinary(urlOrPath) {
74
+ const path = urlOrPath.startsWith("http") ? pathAndQuery(urlOrPath) : urlOrPath;
75
75
  const res = await this.request(path, { headers: { Accept: "*/*" } });
76
76
  return new Uint8Array(await res.arrayBuffer());
77
77
  }
78
78
  };
79
+ function pathAndQuery(absoluteUrl) {
80
+ const u = new URL(absoluteUrl);
81
+ return u.pathname + u.search;
82
+ }
79
83
  var HttpError = class extends Error {
80
84
  status;
81
85
  constructor(status, message) {
@@ -87,21 +91,26 @@ var HttpError = class extends Error {
87
91
  function httpError(status, path, body = "") {
88
92
  if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
89
93
  if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
90
- if (status === 409) return new HttpError(status, `Conflict (409): ${extractError(body) || "the page changed on the server"}`);
94
+ if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
91
95
  if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
92
- if (status === 400) return new HttpError(status, `Bad request (400): ${extractError(body) || path}`);
93
- return new HttpError(status, `Request failed (${status}): ${extractError(body) || path}`);
96
+ if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`);
97
+ return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`);
94
98
  }
95
- function extractError(body) {
99
+ function extractErrorMessage(body) {
96
100
  if (!body) return "";
97
- try {
98
- const json = JSON.parse(body);
99
- if (json.errorMessages?.length) return json.errorMessages.join("; ");
100
- if (json.message) return json.message;
101
- if (json.error?.message) return json.error.message;
102
- } catch {}
101
+ const json = tryParseJson(body);
102
+ if (json?.errorMessages?.length) return json.errorMessages.join("; ");
103
+ if (json?.message) return json.message;
104
+ if (json?.error?.message) return json.error.message;
103
105
  return body.slice(0, 300);
104
106
  }
107
+ function tryParseJson(text) {
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
105
114
  //#endregion
106
115
  //#region src/config.ts
107
116
  function configDir() {
@@ -130,7 +139,7 @@ async function writeConfig(config) {
130
139
  async function clearConfig() {
131
140
  await rm(configPath(), { force: true });
132
141
  }
133
- function normalizeSite(input) {
142
+ function siteOrigin(input) {
134
143
  let value = input.trim();
135
144
  if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
136
145
  return new URL(value).origin;
@@ -142,6 +151,13 @@ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
142
151
  function entry(key) {
143
152
  return new Entry(SERVICE, key);
144
153
  }
154
+ function deleteEntryIfPresent(key) {
155
+ try {
156
+ entry(key).deleteCredential();
157
+ } catch {
158
+ return;
159
+ }
160
+ }
145
161
  function saveToken(email, token) {
146
162
  entry(email).setPassword(token);
147
163
  }
@@ -149,9 +165,7 @@ function readToken(email) {
149
165
  return entry(email).getPassword();
150
166
  }
151
167
  function deleteToken(email) {
152
- try {
153
- entry(email).deleteCredential();
154
- } catch {}
168
+ deleteEntryIfPresent(email);
155
169
  }
156
170
  function bitbucketKey(email) {
157
171
  return `${email}:bitbucket`;
@@ -163,9 +177,7 @@ function readBitbucketToken(email) {
163
177
  return entry(bitbucketKey(email)).getPassword();
164
178
  }
165
179
  function deleteBitbucketToken(email) {
166
- try {
167
- entry(bitbucketKey(email)).deleteCredential();
168
- } catch {}
180
+ deleteEntryIfPresent(bitbucketKey(email));
169
181
  }
170
182
  async function requireAuth() {
171
183
  const config = await readConfig();
@@ -194,7 +206,7 @@ async function requireBitbucketAuth() {
194
206
  //#endregion
195
207
  //#region src/commands/auth.ts
196
208
  async function login() {
197
- const site = normalizeSite(await input({
209
+ const site = siteOrigin(await input({
198
210
  message: "Atlassian site (e.g. acme.atlassian.net):",
199
211
  required: true
200
212
  }));
@@ -222,10 +234,11 @@ async function login() {
222
234
  async function logout() {
223
235
  const config = await readConfig();
224
236
  if (config?.email) deleteToken(config.email);
225
- if (config?.bitbucket && config.email) await writeConfig({
237
+ const bitbucketLogin = config?.bitbucket && config.email ? {
226
238
  email: config.email,
227
239
  bitbucket: config.bitbucket
228
- });
240
+ } : null;
241
+ if (bitbucketLogin) await writeConfig(bitbucketLogin);
229
242
  else await clearConfig();
230
243
  console.log("Logged out. Credentials removed.");
231
244
  }
@@ -242,6 +255,8 @@ async function status() {
242
255
  }
243
256
  //#endregion
244
257
  //#region src/api/bitbucket.ts
258
+ const BITBUCKET_MAX_PAGELEN = 100;
259
+ const BUILD_NUMBER_SCAN_LIMIT = 1e3;
245
260
  function pipelineStatus(state) {
246
261
  if (!state) return "";
247
262
  if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
@@ -250,88 +265,91 @@ function pipelineStatus(state) {
250
265
  function pipelinesQuery(limit) {
251
266
  return new URLSearchParams({
252
267
  sort: "-created_on",
253
- pagelen: String(Math.min(Math.max(limit, 1), 100))
268
+ pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
254
269
  }).toString();
255
270
  }
256
- function elapsedSeconds(startOn, endOn) {
271
+ function wallClockSeconds(startOn, endOn) {
257
272
  if (!startOn || !endOn) return null;
258
273
  const start = Date.parse(startOn);
259
274
  const end = Date.parse(endOn);
260
275
  if (Number.isNaN(start) || Number.isNaN(end)) return null;
261
276
  return Math.floor((end - start) / 1e3);
262
277
  }
263
- const MAX_SCAN = 1e3;
264
- function repoPath(ref) {
265
- return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
266
- }
267
- function toPath(url) {
268
- const u = new URL(url);
269
- return u.pathname + u.search;
270
- }
271
- async function* paginate(client, firstPath) {
272
- let path = firstPath;
273
- while (path) {
274
- const page = await client.getJson(path);
275
- for (const value of page.values ?? []) yield value;
276
- path = page.next ? toPath(page.next) : null;
277
- }
278
+ function shortHash(hash) {
279
+ return hash?.slice(0, 7) ?? "";
278
280
  }
279
- function mapPipeline(p) {
281
+ function toPipelineSummary(p) {
280
282
  return {
281
283
  buildNumber: p.build_number,
282
284
  status: pipelineStatus(p.state),
283
285
  ref: p.target?.ref_name ?? "",
284
- commit: p.target?.commit?.hash?.slice(0, 7) ?? "",
285
- durationSeconds: elapsedSeconds(p.created_on, p.completed_on),
286
+ commit: shortHash(p.target?.commit?.hash),
287
+ durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
286
288
  createdOn: p.created_on ?? "",
287
289
  creator: p.creator?.display_name ?? "",
288
290
  uuid: p.uuid
289
291
  };
290
292
  }
291
- function mapDetail(p) {
293
+ function toPipelineDetail(p) {
292
294
  return {
293
- ...mapPipeline(p),
295
+ ...toPipelineSummary(p),
294
296
  repo: p.repository?.full_name ?? "",
295
297
  trigger: p.trigger?.name ?? ""
296
298
  };
297
299
  }
300
+ function toStepSummary(step) {
301
+ return {
302
+ name: step.name ?? "",
303
+ status: pipelineStatus(step.state),
304
+ durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
305
+ };
306
+ }
307
+ function repoPath(ref) {
308
+ return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
309
+ }
310
+ async function* walkPages(client, firstPath) {
311
+ let path = firstPath;
312
+ while (path) {
313
+ const page = await client.getJson(path);
314
+ for (const value of page.values ?? []) yield value;
315
+ path = page.next ? pathAndQuery(page.next) : null;
316
+ }
317
+ }
298
318
  async function listPipelines(client, ref, limit) {
299
319
  const out = [];
300
320
  const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
301
- for await (const value of paginate(client, first)) {
302
- out.push(mapPipeline(value));
321
+ for await (const value of walkPages(client, first)) {
322
+ out.push(toPipelineSummary(value));
303
323
  if (out.length >= limit) break;
304
324
  }
305
325
  return out;
306
326
  }
307
327
  async function getPipeline(client, ref, buildNumber) {
308
- const base = repoPath(ref);
328
+ const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
329
+ if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
330
+ return toPipelineDetail(found);
331
+ }
332
+ async function fetchByBuildNumber(client, ref, buildNumber) {
309
333
  try {
310
- return mapDetail(await client.getJson(`${base}/${encodeURIComponent(String(buildNumber))}`));
334
+ return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
311
335
  } catch (err) {
312
- if (!(err instanceof HttpError) || err.status !== 400 && err.status !== 404) throw err;
336
+ if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
337
+ throw err;
313
338
  }
314
- const found = await scanForBuild(client, ref, buildNumber);
315
- if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${MAX_SCAN} most recent runs. It may be too old.`);
316
- return mapDetail(found);
317
339
  }
318
- async function scanForBuild(client, ref, buildNumber) {
319
- const first = `${repoPath(ref)}?${pipelinesQuery(100)}`;
340
+ async function findInRecentRuns(client, ref, buildNumber) {
341
+ const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
320
342
  let scanned = 0;
321
- for await (const pipeline of paginate(client, first)) {
343
+ for await (const pipeline of walkPages(client, first)) {
322
344
  if (pipeline.build_number === buildNumber) return pipeline;
323
- if (++scanned >= MAX_SCAN) break;
345
+ if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
324
346
  }
325
347
  return null;
326
348
  }
327
349
  async function listSteps(client, ref, pipelineId) {
328
350
  const out = [];
329
351
  const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
330
- for await (const step of paginate(client, first)) out.push({
331
- name: step.name ?? "",
332
- status: pipelineStatus(step.state),
333
- durationSeconds: elapsedSeconds(step.started_on, step.completed_on)
334
- });
352
+ for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
335
353
  return out;
336
354
  }
337
355
  //#endregion
@@ -344,6 +362,11 @@ function formatDuration(seconds) {
344
362
  if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
345
363
  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
346
364
  }
365
+ function formatDateTime(iso) {
366
+ const date = new Date(iso);
367
+ if (Number.isNaN(date.getTime())) return iso;
368
+ return date.toISOString().replace("T", " ").slice(0, 16);
369
+ }
347
370
  const MINUTE = 60;
348
371
  const HOUR = 60 * MINUTE;
349
372
  const DAY = 24 * HOUR;
@@ -375,6 +398,9 @@ function parsePageId(input) {
375
398
  if (fromQuery) return fromQuery[1];
376
399
  return null;
377
400
  }
401
+ function isExternalHref(href) {
402
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
403
+ }
378
404
  function resolveRepo(flag, config) {
379
405
  if (flag) {
380
406
  if (flag.includes("/")) {
@@ -398,11 +424,13 @@ function resolveRepo(flag, config) {
398
424
  };
399
425
  throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
400
426
  }
427
+ const DEFAULT_LIMIT = 25;
428
+ const MAX_LIMIT = 100;
401
429
  function parseLimit(value) {
402
- if (!value) return 25;
430
+ if (!value) return DEFAULT_LIMIT;
403
431
  const n = Number.parseInt(value, 10);
404
432
  if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
405
- return Math.min(n, 100);
433
+ return Math.min(n, MAX_LIMIT);
406
434
  }
407
435
  //#endregion
408
436
  //#region src/commands/bitbucket.ts
@@ -440,10 +468,11 @@ async function bitbucketLogin() {
440
468
  async function bitbucketLogout() {
441
469
  const config = await readConfig();
442
470
  if (config?.email) deleteBitbucketToken(config.email);
443
- if (config?.site && config.email) await writeConfig({
471
+ const jiraLogin = config?.site && config.email ? {
444
472
  site: config.site,
445
473
  email: config.email
446
- });
474
+ } : null;
475
+ if (jiraLogin) await writeConfig(jiraLogin);
447
476
  else await clearConfig();
448
477
  console.log("Logged out of Bitbucket. Credentials removed.");
449
478
  }
@@ -513,8 +542,7 @@ function formatStepRows(steps) {
513
542
  function printPipelineDetail(detail, steps, nowMs) {
514
543
  console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
515
544
  if (detail.repo) console.log(`Repo: ${detail.repo}`);
516
- const refLine = detail.ref ? detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref : detail.commit || "-";
517
- console.log(`Ref: ${refLine}`);
545
+ console.log(`Ref: ${refWithCommit(detail)}`);
518
546
  if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
519
547
  console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
520
548
  const by = detail.creator ? ` by ${detail.creator}` : "";
@@ -525,6 +553,10 @@ function printPipelineDetail(detail, steps, nowMs) {
525
553
  for (const line of formatStepRows(steps)) console.log(line);
526
554
  }
527
555
  }
556
+ function refWithCommit(detail) {
557
+ if (!detail.ref) return detail.commit || "-";
558
+ return detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref;
559
+ }
528
560
  async function verifyWorkspace(client, workspace) {
529
561
  try {
530
562
  return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
@@ -549,8 +581,27 @@ async function withScopeHint(fn) {
549
581
  }
550
582
  //#endregion
551
583
  //#region src/adf/from-markdown.ts
584
+ function mediaNode(source, alt) {
585
+ const attrs = { ...source };
586
+ if (alt) attrs["alt"] = alt;
587
+ return {
588
+ type: "mediaSingle",
589
+ attrs: { layout: "center" },
590
+ content: [{
591
+ type: "media",
592
+ attrs
593
+ }]
594
+ };
595
+ }
596
+ function imageHrefs(md) {
597
+ const hrefs = /* @__PURE__ */ new Set();
598
+ marked.walkTokens(marked.lexer(md), (token) => {
599
+ if (token.type === "image") hrefs.add(token.href);
600
+ });
601
+ return [...hrefs];
602
+ }
552
603
  function markdownToAdf(md, options = {}) {
553
- const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
604
+ const ctx = { resolveImage: options.resolveImage ?? externalMedia };
554
605
  return {
555
606
  type: "doc",
556
607
  version: 1,
@@ -570,8 +621,8 @@ function block(token, ctx) {
570
621
  attrs: { level: clampLevel$1(token.depth) },
571
622
  content: inline(token.tokens ?? [], ctx)
572
623
  }];
573
- case "paragraph": return paragraph(token.tokens ?? [], ctx);
574
- case "text": return paragraph(token.tokens ?? [textToken(token.text)], ctx);
624
+ case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
625
+ case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
575
626
  case "list": return [list(token, ctx)];
576
627
  case "code": return [codeBlock(token)];
577
628
  case "blockquote": return [{
@@ -580,11 +631,10 @@ function block(token, ctx) {
580
631
  }];
581
632
  case "table": return [table(token, ctx)];
582
633
  case "hr": return [{ type: "rule" }];
583
- case "html": return [];
584
634
  default: return [];
585
635
  }
586
636
  }
587
- function paragraph(tokens, ctx) {
637
+ function paragraphsSplitAtImages(tokens, ctx) {
588
638
  const out = [];
589
639
  let buffer = [];
590
640
  const flush = () => {
@@ -615,7 +665,7 @@ function list(token, ctx) {
615
665
  localId: randomUUID(),
616
666
  state: item.checked ? "DONE" : "TODO"
617
667
  },
618
- content: inline(itemInline(item), ctx)
668
+ content: inline(taskItemInline(item), ctx)
619
669
  }))
620
670
  };
621
671
  const node = {
@@ -628,16 +678,18 @@ function list(token, ctx) {
628
678
  }
629
679
  function listItem(item, ctx) {
630
680
  const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
631
- if (children.length === 0) children.push({
632
- type: "paragraph",
633
- content: []
634
- });
635
681
  return {
636
682
  type: "listItem",
637
- content: children
683
+ content: children.length > 0 ? children : [emptyParagraph()]
684
+ };
685
+ }
686
+ function emptyParagraph() {
687
+ return {
688
+ type: "paragraph",
689
+ content: []
638
690
  };
639
691
  }
640
- function itemInline(item) {
692
+ function taskItemInline(item) {
641
693
  const first = item.tokens.find((t) => t.type === "text");
642
694
  if (first && "tokens" in first && first.tokens) return first.tokens;
643
695
  return [textToken(item.text)];
@@ -686,13 +738,13 @@ function inlineNode(token, ctx, marks) {
686
738
  if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
687
739
  return textNode(t.text, marks);
688
740
  }
689
- case "strong": return inline(token.tokens, ctx, withMark(marks, { type: "strong" }));
690
- case "em": return inline(token.tokens, ctx, withMark(marks, { type: "em" }));
691
- case "del": return inline(token.tokens, ctx, withMark(marks, { type: "strike" }));
692
- case "codespan": return textNode(token.text, withMark(marks, { type: "code" }));
741
+ case "strong": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strong" }));
742
+ case "em": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "em" }));
743
+ case "del": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strike" }));
744
+ case "codespan": return textNode(token.text, withUniqueMark(marks, { type: "code" }));
693
745
  case "link": {
694
746
  const link = token;
695
- return inline(link.tokens, ctx, withMark(marks, {
747
+ return inline(link.tokens, ctx, withUniqueMark(marks, {
696
748
  type: "link",
697
749
  attrs: { href: link.href }
698
750
  }));
@@ -711,25 +763,14 @@ function textNode(text, marks) {
711
763
  if (marks.length > 0) node.marks = marks;
712
764
  return [node];
713
765
  }
714
- function withMark(marks, mark) {
766
+ function withUniqueMark(marks, mark) {
715
767
  return [...marks.filter((m) => m.type !== mark.type), mark];
716
768
  }
717
- function defaultResolveImage(href, alt) {
718
- return {
719
- type: "mediaSingle",
720
- attrs: { layout: "center" },
721
- content: [{
722
- type: "media",
723
- attrs: alt ? {
724
- type: "external",
725
- url: href,
726
- alt
727
- } : {
728
- type: "external",
729
- url: href
730
- }
731
- }]
732
- };
769
+ function externalMedia(href, alt) {
770
+ return mediaNode({
771
+ type: "external",
772
+ url: href
773
+ }, alt);
733
774
  }
734
775
  function textToken(text) {
735
776
  return {
@@ -743,108 +784,349 @@ function clampLevel$1(value) {
743
784
  return Math.min(6, Math.max(1, Math.trunc(value) || 1));
744
785
  }
745
786
  //#endregion
746
- //#region src/adf/to-markdown.ts
747
- function adfToMarkdown(doc, options = {}) {
748
- if (!doc) return "";
749
- const ctx = { resolveMedia: options.resolveMedia };
750
- return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
751
- }
752
- function renderBlocks(nodes, ctx, indent) {
753
- return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
754
- }
755
- function renderBlock(node, ctx, indent) {
756
- switch (node.type) {
757
- case "paragraph": return indent + renderInline(node.content ?? [], ctx);
758
- case "heading": {
759
- const level = clampLevel(node.attrs?.["level"]);
760
- return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
787
+ //#region src/util/html.ts
788
+ const MAX_CODE_POINT = 1114111;
789
+ const NAMED = {
790
+ amp: "&",
791
+ lt: "<",
792
+ gt: ">",
793
+ quot: "\"",
794
+ apos: "'",
795
+ nbsp: " "
796
+ };
797
+ function decodeEntities(text) {
798
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
799
+ if (body[0] === "#") {
800
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
801
+ return isCodePoint(code) ? String.fromCodePoint(code) : match;
761
802
  }
762
- case "bulletList": return renderList(node, ctx, indent, "bullet");
763
- case "orderedList": return renderList(node, ctx, indent, "ordered");
764
- case "taskList": return renderTaskList(node, ctx, indent);
765
- case "decisionList": return renderDecisionList(node, ctx, indent);
766
- case "codeBlock": return renderCodeBlock(node, indent);
767
- case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
768
- case "panel": return renderPanel(node, ctx, indent);
769
- case "rule": return `${indent}---`;
770
- case "table": return renderTable(node, ctx);
771
- case "mediaSingle":
772
- case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
773
- case "media": return indent + renderMedia(node, ctx);
774
- case "expand":
775
- case "nestedExpand": return renderExpand(node, ctx, indent);
776
- default:
777
- if (node.content?.length) return renderBlocks(node.content, ctx, indent);
778
- return node.text ? indent + node.text : "";
779
- }
803
+ return NAMED[body.toLowerCase()] ?? match;
804
+ });
780
805
  }
781
- function renderList(node, ctx, indent, kind) {
782
- const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
783
- return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
784
- return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
785
- }).join("\n");
806
+ function isCodePoint(code) {
807
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
786
808
  }
787
- function renderListItem(item, ctx, indent, marker) {
788
- const childIndent = `${indent}${" ".repeat(marker.length)}`;
789
- const rendered = (item.content ?? []).map((b, i) => renderBlock(b, ctx, i === 0 ? "" : childIndent));
790
- const parts = [];
791
- rendered.forEach((text, i) => {
792
- if (i === 0) parts.push(`${indent}${marker}${text}`);
793
- else parts.push(text);
794
- });
795
- return parts.join("\n");
809
+ //#endregion
810
+ //#region src/api/confluence.ts
811
+ async function fetchPage(client, site, id) {
812
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
813
+ const names = new UserNames(client);
814
+ const [spaceKey, attachments, comments, author] = await Promise.all([
815
+ fetchSpaceKey(client, page.spaceId),
816
+ fetchAttachments(client, id),
817
+ fetchComments$1(client, id, names),
818
+ names.resolve(page.version?.authorId ?? page.authorId)
819
+ ]);
820
+ const webui = page._links?.webui ?? "";
821
+ return {
822
+ id: page.id,
823
+ title: page.title,
824
+ spaceKey,
825
+ version: page.version?.number ?? 0,
826
+ author,
827
+ createdAt: page.createdAt ?? "",
828
+ updatedAt: page.version?.createdAt ?? "",
829
+ url: webui ? `${site}/wiki${webui}` : "",
830
+ body: parseAdfJson(page.body?.atlas_doc_format?.value),
831
+ attachments,
832
+ comments
833
+ };
796
834
  }
797
- function renderTaskList(node, ctx, indent) {
798
- return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
799
- return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
800
- }).join("\n");
835
+ async function fetchPageState(client, id) {
836
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
837
+ return {
838
+ version: page.version?.number ?? 0,
839
+ title: page.title,
840
+ body: parseAdfJson(page.body?.atlas_doc_format?.value)
841
+ };
801
842
  }
802
- function renderDecisionList(node, ctx, indent) {
803
- return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
843
+ const UNKNOWN_SIZE = -1;
844
+ async function listAttachments(client, id) {
845
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
846
+ filename: a.title ?? a.id,
847
+ fileId: mediaNodeId(a),
848
+ size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
849
+ }));
804
850
  }
805
- function renderCodeBlock(node, indent) {
806
- const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
807
- const code = (node.content ?? []).map((n) => n.text ?? "").join("");
808
- const fence = "```";
809
- return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
851
+ async function uploadAttachment(client, pageId, filename, bytes) {
852
+ return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
810
853
  }
811
- const PANEL_LABELS = {
812
- info: "Info",
813
- note: "Note",
814
- warning: "Warning",
815
- success: "Success",
816
- error: "Error"
817
- };
818
- function renderPanel(node, ctx, indent) {
819
- return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
854
+ async function fileIdByListing(client, pageId, filename) {
855
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
856
+ if (match) return match.fileId;
857
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
820
858
  }
821
- function renderExpand(node, ctx, indent) {
822
- return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
859
+ async function updatePage(client, id, params) {
860
+ return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
861
+ id,
862
+ status: "current",
863
+ title: params.title,
864
+ body: {
865
+ representation: "atlas_doc_format",
866
+ value: JSON.stringify(params.body)
867
+ },
868
+ version: {
869
+ number: params.nextVersion,
870
+ message: params.message
871
+ }
872
+ })).version?.number ?? params.nextVersion;
823
873
  }
824
- function renderTable(node, ctx) {
825
- const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
826
- if (rows.length === 0) return "";
827
- const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCell(cell, ctx)));
828
- const cols = Math.max(...grid.map((r) => r.length));
829
- const pad = (r) => {
830
- const copy = [...r];
831
- while (copy.length < cols) copy.push("");
832
- return copy;
874
+ async function searchPages(client, site, params) {
875
+ const cql = buildCql(params);
876
+ const query = new URLSearchParams({
877
+ cql,
878
+ limit: String(params.limit),
879
+ expand: "space"
880
+ });
881
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
882
+ const serverPageWasFull = results.length === params.limit;
883
+ return {
884
+ pages: results.filter((r) => r.content?.id).map((r) => ({
885
+ id: r.content?.id ?? "",
886
+ space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
887
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
888
+ url: r.url ? `${site}/wiki${r.url}` : ""
889
+ })),
890
+ hasMore: serverPageWasFull
833
891
  };
834
- const header = pad(grid[0] ?? []);
835
- const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
836
- for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
837
- return lines.join("\n");
838
892
  }
839
- function renderCell(cell, ctx) {
840
- return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
893
+ function buildCql(params) {
894
+ if (params.cql) return params.cql;
895
+ const clauses = ["type = page"];
896
+ if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
897
+ if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
898
+ return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
841
899
  }
842
- function renderMedia(node, ctx) {
843
- const attrs = node.attrs ?? {};
844
- const alt = attrs.alt ?? "";
845
- const resolved = ctx.resolveMedia?.(attrs);
846
- if (resolved) return `![${alt}](${resolved})`;
847
- return `[embedded media: ${alt || attrs.id || "media"}]`;
900
+ function cqlStringLiteral(value) {
901
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
902
+ }
903
+ async function fetchSpaceKey(client, spaceId) {
904
+ if (!spaceId) return "";
905
+ try {
906
+ return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
907
+ } catch {
908
+ return "";
909
+ }
910
+ }
911
+ async function fetchAttachments(client, id) {
912
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
913
+ mediaId: mediaNodeId(a),
914
+ filename: a.title ?? a.id,
915
+ url: withWikiContextPath(a.downloadLink ?? "")
916
+ }));
917
+ }
918
+ function mediaNodeId(a) {
919
+ return a.fileId ?? a.id;
920
+ }
921
+ async function fetchComments$1(client, id, names) {
922
+ const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
923
+ return Promise.all(res.results.map(async (c) => ({
924
+ author: await names.resolve(c.version?.authorId),
925
+ created: c.version?.createdAt ?? "",
926
+ body: parseAdfJson(c.body?.atlas_doc_format?.value)
927
+ })));
928
+ }
929
+ function withWikiContextPath(link) {
930
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
931
+ return `/wiki${link}`;
932
+ }
933
+ function parseAdfJson(value) {
934
+ if (!value) return null;
935
+ try {
936
+ return JSON.parse(value);
937
+ } catch {
938
+ return null;
939
+ }
940
+ }
941
+ var UserNames = class {
942
+ client;
943
+ cache = /* @__PURE__ */ new Map();
944
+ constructor(client) {
945
+ this.client = client;
946
+ }
947
+ async resolve(accountId) {
948
+ if (!accountId) return "";
949
+ const cached = this.cache.get(accountId);
950
+ if (cached !== void 0) return cached;
951
+ const name = await this.fetchDisplayName(accountId) ?? accountId;
952
+ this.cache.set(accountId, name);
953
+ return name;
954
+ }
955
+ async fetchDisplayName(accountId) {
956
+ try {
957
+ return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+ };
963
+ //#endregion
964
+ //#region src/commands/resolve-ref.ts
965
+ async function resolveRef(arg, kind) {
966
+ const raw = arg ?? await input({
967
+ message: kind.message,
968
+ required: true
969
+ });
970
+ const ref = kind.parse(raw);
971
+ if (!ref) throw new Error(kind.notFound(raw));
972
+ return ref;
973
+ }
974
+ //#endregion
975
+ //#region src/commands/search-run.ts
976
+ const COPY_CONCURRENCY = 5;
977
+ async function runSearch(rows, options, noun, copyOne) {
978
+ if (options.json) {
979
+ console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
980
+ return;
981
+ }
982
+ if (rows.length === 0) {
983
+ console.log(`No matching ${noun.plural}.`);
984
+ return;
985
+ }
986
+ if (options.copy) {
987
+ if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
988
+ await copySelected(rows, noun, copyOne);
989
+ return;
990
+ }
991
+ for (const row of rows) console.log(formatRow(row));
992
+ if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
993
+ }
994
+ async function copySelected(rows, noun, copyOne) {
995
+ if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
996
+ const selected = await checkbox({
997
+ message: `Select ${noun.plural} to copy:`,
998
+ choices: rows.map((r) => ({
999
+ name: formatRow(r),
1000
+ value: r.id
1001
+ })),
1002
+ pageSize: 20
1003
+ });
1004
+ if (selected.length === 0) {
1005
+ console.log("Nothing selected.");
1006
+ return;
1007
+ }
1008
+ let copied = 0;
1009
+ const failures = [];
1010
+ const queue = [...selected];
1011
+ async function worker() {
1012
+ for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
1013
+ await copyOne(id);
1014
+ copied++;
1015
+ } catch (err) {
1016
+ failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
1017
+ }
1018
+ }
1019
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
1020
+ const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
1021
+ if (failures.length === 0) console.log(summary);
1022
+ else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1023
+ }
1024
+ function formatRow(row) {
1025
+ const room = (process.stdout.columns ?? 80) - row.fixedColumns.length - 2;
1026
+ const text = room > 0 ? truncate(row.freeText, room) : "";
1027
+ return text ? `${row.fixedColumns} ${text}` : row.fixedColumns;
1028
+ }
1029
+ function truncate(text, max) {
1030
+ const clean = text.replace(/\s+/g, " ").trim();
1031
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
1032
+ }
1033
+ //#endregion
1034
+ //#region src/adf/to-markdown.ts
1035
+ function adfToMarkdown(doc, options = {}) {
1036
+ if (!doc) return "";
1037
+ const ctx = { resolveMedia: options.resolveMedia };
1038
+ return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
1039
+ }
1040
+ function renderBlocks(nodes, ctx, indent) {
1041
+ return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
1042
+ }
1043
+ function renderBlock(node, ctx, indent) {
1044
+ switch (node.type) {
1045
+ case "paragraph": return indent + renderInline(node.content ?? [], ctx);
1046
+ case "heading": {
1047
+ const level = clampLevel(node.attrs?.["level"]);
1048
+ return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
1049
+ }
1050
+ case "bulletList": return renderList(node, ctx, indent, "bullet");
1051
+ case "orderedList": return renderList(node, ctx, indent, "ordered");
1052
+ case "taskList": return renderTaskList(node, ctx, indent);
1053
+ case "decisionList": return renderDecisionList(node, ctx, indent);
1054
+ case "codeBlock": return renderCodeBlock(node, indent);
1055
+ case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
1056
+ case "panel": return renderPanel(node, ctx, indent);
1057
+ case "rule": return `${indent}---`;
1058
+ case "table": return renderTable(node, ctx);
1059
+ case "mediaSingle":
1060
+ case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
1061
+ case "media": return indent + renderMedia(node, ctx);
1062
+ case "expand":
1063
+ case "nestedExpand": return renderExpand(node, ctx, indent);
1064
+ default:
1065
+ if (node.content?.length) return renderBlocks(node.content, ctx, indent);
1066
+ return node.text ? indent + node.text : "";
1067
+ }
1068
+ }
1069
+ function renderList(node, ctx, indent, kind) {
1070
+ const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
1071
+ return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
1072
+ return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
1073
+ }).join("\n");
1074
+ }
1075
+ function renderListItem(item, ctx, indent, marker) {
1076
+ const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
1077
+ return (item.content ?? []).map((block, i) => i === 0 ? `${indent}${marker}${renderBlock(block, ctx, "")}` : renderBlock(block, ctx, continuationIndent)).join("\n");
1078
+ }
1079
+ function renderTaskList(node, ctx, indent) {
1080
+ return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
1081
+ return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
1082
+ }).join("\n");
1083
+ }
1084
+ function renderDecisionList(node, ctx, indent) {
1085
+ return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
1086
+ }
1087
+ function renderCodeBlock(node, indent) {
1088
+ const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
1089
+ const code = (node.content ?? []).map((n) => n.text ?? "").join("");
1090
+ const fence = "```";
1091
+ return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
1092
+ }
1093
+ const PANEL_LABELS = {
1094
+ info: "Info",
1095
+ note: "Note",
1096
+ warning: "Warning",
1097
+ success: "Success",
1098
+ error: "Error"
1099
+ };
1100
+ function renderPanel(node, ctx, indent) {
1101
+ return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
1102
+ }
1103
+ function renderExpand(node, ctx, indent) {
1104
+ return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
1105
+ }
1106
+ function renderTable(node, ctx) {
1107
+ const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
1108
+ if (rows.length === 0) return "";
1109
+ const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
1110
+ const cols = Math.max(...grid.map((r) => r.length));
1111
+ const pad = (r) => {
1112
+ const copy = [...r];
1113
+ while (copy.length < cols) copy.push("");
1114
+ return copy;
1115
+ };
1116
+ const header = pad(grid[0] ?? []);
1117
+ const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
1118
+ for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
1119
+ return lines.join("\n");
1120
+ }
1121
+ function renderCellOnOneLine(cell, ctx) {
1122
+ return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
1123
+ }
1124
+ function renderMedia(node, ctx) {
1125
+ const attrs = node.attrs ?? {};
1126
+ const alt = attrs.alt ?? "";
1127
+ const resolved = ctx.resolveMedia?.(attrs);
1128
+ if (resolved) return `![${alt}](${resolved})`;
1129
+ return `[embedded media: ${alt || attrs.id || "media"}]`;
848
1130
  }
849
1131
  function renderInline(nodes, ctx) {
850
1132
  return nodes.map((n) => renderInlineNode(n, ctx)).join("");
@@ -860,7 +1142,7 @@ function renderInlineNode(node, ctx) {
860
1142
  const short = node.attrs?.["shortName"];
861
1143
  return typeof short === "string" ? short : "";
862
1144
  }
863
- case "date": return formatDate$1(node.attrs?.["timestamp"]);
1145
+ case "date": return formatDate(node.attrs?.["timestamp"]);
864
1146
  case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
865
1147
  case "inlineCard": {
866
1148
  const url = node.attrs?.["url"];
@@ -908,226 +1190,82 @@ function clampLevel(value) {
908
1190
  function toNumber(value) {
909
1191
  return typeof value === "number" ? value : void 0;
910
1192
  }
911
- function formatDate$1(timestamp) {
1193
+ function formatDate(timestamp) {
912
1194
  const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
913
1195
  if (Number.isNaN(ms)) return "";
914
1196
  return new Date(ms).toISOString().slice(0, 10);
915
1197
  }
916
1198
  //#endregion
917
1199
  //#region src/api/attachments.ts
918
- async function downloadAttachments(client, attachments, assetsDir, assetsDirName) {
919
- if (attachments.length === 0) return [];
920
- await mkdir(assetsDir, { recursive: true });
921
- const used = /* @__PURE__ */ new Set();
922
- const results = [];
923
- for (const att of attachments) {
924
- const savedAs = uniqueName(safeName(att.filename), used);
925
- try {
926
- const bytes = await client.getBinary(att.url);
927
- await writeFile(join(assetsDir, savedAs), bytes);
928
- results.push({
929
- ...att,
930
- relativePath: `${assetsDirName}/${savedAs}`
931
- });
932
- } catch (err) {
933
- console.warn(` ! could not download ${att.filename}: ${err.message}`);
934
- }
935
- }
936
- return results;
937
- }
938
- function safeName(name) {
939
- return basename(name).replace(/[/\\]/g, "_") || "attachment";
940
- }
941
- function uniqueName(name, used) {
942
- if (!used.has(name)) {
943
- used.add(name);
944
- return name;
945
- }
946
- const dot = name.lastIndexOf(".");
947
- const stem = dot > 0 ? name.slice(0, dot) : name;
948
- const ext = dot > 0 ? name.slice(dot) : "";
949
- let i = 1;
950
- let candidate = `${stem}-${i}${ext}`;
951
- while (used.has(candidate)) {
952
- i += 1;
953
- candidate = `${stem}-${i}${ext}`;
954
- }
955
- used.add(candidate);
956
- return candidate;
957
- }
958
- //#endregion
959
- //#region src/util/html.ts
960
- const NAMED = {
961
- amp: "&",
962
- lt: "<",
963
- gt: ">",
964
- quot: "\"",
965
- apos: "'",
966
- nbsp: " "
967
- };
968
- function decodeEntities(text) {
969
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
970
- if (body[0] === "#") {
971
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
972
- return Number.isFinite(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
973
- }
974
- return NAMED[body.toLowerCase()] ?? match;
975
- });
1200
+ function mediaResolver(downloaded) {
1201
+ const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
1202
+ const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
1203
+ return (media) => {
1204
+ if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
1205
+ if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
1206
+ };
976
1207
  }
977
1208
  //#endregion
978
- //#region src/api/confluence.ts
979
- async function fetchPage(client, site, id) {
980
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
981
- const names = new UserNames(client);
982
- const [spaceKey, attachments, comments, author] = await Promise.all([
983
- fetchSpaceKey(client, page.spaceId),
984
- fetchAttachments(client, id),
985
- fetchComments$1(client, id, names),
986
- names.resolve(page.version?.authorId ?? page.authorId)
987
- ]);
988
- const webui = page._links?.webui ?? "";
1209
+ //#region src/markdown/copied-document.ts
1210
+ const COMMENTS_HEADING = "## Comments";
1211
+ const ATTACHMENTS_HEADING = "## Attachments";
1212
+ const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
1213
+ const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
1214
+ const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
1215
+ const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
1216
+ const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
1217
+ function render(doc) {
1218
+ return `${[
1219
+ frontmatter(doc.fields),
1220
+ `# ${doc.title}`,
1221
+ doc.body,
1222
+ commentsSection(doc.comments),
1223
+ attachmentsSection(doc.attachments)
1224
+ ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
1225
+ }
1226
+ function parse(text) {
1227
+ const match = text.match(FRONTMATTER_BLOCK);
1228
+ if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
1229
+ const fields = parseFrontmatter(match[1] ?? "");
1230
+ const lines = text.slice(match[0].length).split("\n");
1231
+ const h1 = leadingH1(lines);
1232
+ const start = h1 ? h1.index + 1 : 0;
1233
+ const trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
1234
+ const end = trailing === -1 ? lines.length : trailing;
1235
+ const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
989
1236
  return {
990
- id: page.id,
991
- title: page.title,
992
- spaceKey,
993
- version: page.version?.number ?? 0,
994
- author,
995
- createdAt: page.createdAt ?? "",
996
- updatedAt: page.version?.createdAt ?? "",
997
- url: webui ? `${site}/wiki${webui}` : "",
998
- body: parseAdf(page.body?.atlas_doc_format?.value),
999
- attachments,
1000
- comments
1237
+ fields,
1238
+ title: h1?.title ?? fallbackTitle,
1239
+ body: lines.slice(start, end).join("\n").trim()
1001
1240
  };
1002
1241
  }
1003
- async function fetchPageState(client, id) {
1004
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1242
+ function parseIssueSource(text) {
1243
+ const source = parse(text);
1244
+ const key = scalarString(source.fields["key"]);
1245
+ if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
1005
1246
  return {
1006
- version: page.version?.number ?? 0,
1007
- title: page.title,
1008
- body: parseAdf(page.body?.atlas_doc_format?.value)
1247
+ ...source,
1248
+ key,
1249
+ updatedAtCopy: scalarString(source.fields["updated"])
1009
1250
  };
1010
1251
  }
1011
- async function listAttachments(client, id) {
1012
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1013
- filename: a.title ?? a.id,
1014
- fileId: a.fileId ?? a.id,
1015
- size: typeof a.fileSize === "number" ? a.fileSize : -1
1016
- }));
1017
- }
1018
- async function uploadAttachment(client, pageId, filename, bytes) {
1019
- const fileId = (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId;
1020
- if (fileId) return fileId;
1021
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1022
- if (match) return match.fileId;
1023
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
1024
- }
1025
- async function updatePage(client, id, params) {
1026
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
1027
- id,
1028
- status: "current",
1029
- title: params.title,
1030
- body: {
1031
- representation: "atlas_doc_format",
1032
- value: JSON.stringify(params.body)
1033
- },
1034
- version: {
1035
- number: params.version,
1036
- message: params.message
1037
- }
1038
- })).version?.number ?? params.version;
1039
- }
1040
- async function searchPages(client, site, params) {
1041
- const cql = buildCql(params);
1042
- const query = new URLSearchParams({
1043
- cql,
1044
- limit: String(params.limit),
1045
- expand: "space"
1046
- });
1047
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
1252
+ function parsePageSource(text) {
1253
+ const source = parse(text);
1254
+ const id = scalarString(source.fields["id"]);
1255
+ if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
1256
+ const rawVersion = scalarString(source.fields["version"]);
1257
+ const version = rawVersion === "" ? NaN : Number(rawVersion);
1258
+ if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
1048
1259
  return {
1049
- pages: results.filter((r) => r.content?.id).map((r) => ({
1050
- id: r.content?.id ?? "",
1051
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
1052
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
1053
- url: r.url ? `${site}/wiki${r.url}` : ""
1054
- })),
1055
- hasMore: results.length === params.limit
1260
+ ...source,
1261
+ id,
1262
+ version
1056
1263
  };
1057
1264
  }
1058
- function buildCql(params) {
1059
- if (params.cql) return params.cql;
1060
- const clauses = ["type = page"];
1061
- if (params.space) clauses.push(`space = ${cqlValue(params.space)}`);
1062
- if (params.text) clauses.push(`text ~ ${cqlValue(params.text)}`);
1063
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
1064
- }
1065
- function cqlValue(value) {
1066
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1067
- }
1068
- async function fetchSpaceKey(client, spaceId) {
1069
- if (!spaceId) return "";
1070
- try {
1071
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
1072
- } catch {
1073
- return "";
1074
- }
1075
- }
1076
- async function fetchAttachments(client, id) {
1077
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1078
- id: a.fileId ?? a.id,
1079
- filename: a.title ?? a.id,
1080
- url: normalizeDownloadLink(a.downloadLink ?? "")
1081
- }));
1082
- }
1083
- async function fetchComments$1(client, id, names) {
1084
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
1085
- return Promise.all(res.results.map(async (c) => ({
1086
- author: await names.resolve(c.version?.authorId),
1087
- created: c.version?.createdAt ?? "",
1088
- body: parseAdf(c.body?.atlas_doc_format?.value)
1089
- })));
1090
- }
1091
- function normalizeDownloadLink(link) {
1092
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1093
- return `/wiki${link}`;
1094
- }
1095
- function parseAdf(value) {
1096
- if (!value) return null;
1097
- try {
1098
- return JSON.parse(value);
1099
- } catch {
1100
- return null;
1101
- }
1102
- }
1103
- var UserNames = class {
1104
- client;
1105
- cache = /* @__PURE__ */ new Map();
1106
- constructor(client) {
1107
- this.client = client;
1108
- }
1109
- async resolve(accountId) {
1110
- if (!accountId) return "";
1111
- const cached = this.cache.get(accountId);
1112
- if (cached !== void 0) return cached;
1113
- let name = accountId;
1114
- try {
1115
- const user = await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`);
1116
- if (user.displayName) name = user.displayName;
1117
- } catch {}
1118
- this.cache.set(accountId, name);
1119
- return name;
1120
- }
1121
- };
1122
- //#endregion
1123
- //#region src/markdown/document.ts
1124
- function mediaResolver(downloaded) {
1125
- const byId = new Map(downloaded.map((d) => [d.id, d.relativePath]));
1126
- const byName = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
1127
- return (media) => {
1128
- if (media.id && byId.has(media.id)) return byId.get(media.id);
1129
- if (media.alt && byName.has(media.alt)) return byName.get(media.alt);
1130
- };
1265
+ function scalarString(value) {
1266
+ if (typeof value === "string") return value;
1267
+ if (typeof value === "number") return String(value);
1268
+ return "";
1131
1269
  }
1132
1270
  function frontmatter(fields) {
1133
1271
  const lines = ["---"];
@@ -1138,111 +1276,195 @@ function frontmatter(fields) {
1138
1276
  lines.push("---");
1139
1277
  return lines.join("\n");
1140
1278
  }
1279
+ function parseFrontmatter(block) {
1280
+ const fields = {};
1281
+ const lines = block.split("\n");
1282
+ for (let i = 0; i < lines.length; i++) {
1283
+ const m = lines[i]?.match(FRONTMATTER_KEY);
1284
+ if (!m) continue;
1285
+ const key = m[1] ?? "";
1286
+ const raw = (m[2] ?? "").trim();
1287
+ if (raw === "[]") fields[key] = [];
1288
+ else if (raw === "") {
1289
+ const items = [];
1290
+ for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
1291
+ fields[key] = items;
1292
+ } else fields[key] = scalar(raw);
1293
+ }
1294
+ return fields;
1295
+ }
1296
+ function scalar(raw) {
1297
+ if (raw.startsWith("\"")) return unquote(raw);
1298
+ if (BARE_NUMBER.test(raw)) return Number(raw);
1299
+ return raw;
1300
+ }
1141
1301
  function quote(value) {
1142
1302
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
1143
1303
  }
1144
- function attachmentsSection(downloaded) {
1145
- if (downloaded.length === 0) return "";
1146
- return [
1147
- "## Attachments",
1148
- "",
1149
- ...downloaded.map((d) => `- [${d.filename}](${d.relativePath})`)
1150
- ].join("\n");
1304
+ function unquote(value) {
1305
+ if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
1306
+ return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1307
+ }
1308
+ function leadingH1(lines) {
1309
+ for (const [index, line] of lines.entries()) {
1310
+ if (line.startsWith("# ")) return {
1311
+ index,
1312
+ title: line.slice(2).trim()
1313
+ };
1314
+ if (line.trim().length > 0) return null;
1315
+ }
1316
+ return null;
1151
1317
  }
1152
- function commentsSection(comments, resolveMedia) {
1318
+ function commentsSection(comments) {
1153
1319
  if (comments.length === 0) return "";
1154
1320
  return [
1155
- "## Comments",
1321
+ COMMENTS_HEADING,
1156
1322
  "",
1157
1323
  comments.map((c) => {
1158
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDate(c.created)}` : ""}`;
1159
- const body = adfToMarkdown(c.body, { resolveMedia });
1160
- return body ? `${heading}\n\n${body}` : heading;
1324
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
1325
+ return c.body ? `${heading}\n\n${c.body}` : heading;
1161
1326
  }).join("\n\n")
1162
1327
  ].join("\n");
1163
1328
  }
1164
- function formatDate(iso) {
1165
- const date = new Date(iso);
1166
- if (Number.isNaN(date.getTime())) return iso;
1167
- return date.toISOString().replace("T", " ").slice(0, 16);
1168
- }
1169
- function joinSections(sections) {
1170
- return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
1329
+ function attachmentsSection(attachments) {
1330
+ if (attachments.length === 0) return "";
1331
+ return [
1332
+ ATTACHMENTS_HEADING,
1333
+ "",
1334
+ ...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
1335
+ ].join("\n");
1171
1336
  }
1172
1337
  //#endregion
1173
- //#region src/markdown/update-source.ts
1174
- function parseUpdateSource(content) {
1175
- const { fields, bodyTitle, body } = splitFile(content);
1176
- const id = fields["id"];
1177
- if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
1178
- const version = Number(fields["version"]);
1179
- if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
1180
- return {
1181
- id,
1182
- version,
1183
- frontTitle: fields["title"] ?? "",
1184
- bodyTitle,
1185
- body
1186
- };
1338
+ //#region src/copy/plan.ts
1339
+ function planIssueCopy(issue, out) {
1340
+ return planCopy({
1341
+ baseName: issue.key,
1342
+ fields: {
1343
+ key: issue.key,
1344
+ type: issue.type,
1345
+ status: issue.status,
1346
+ assignee: issue.assignee,
1347
+ reporter: issue.reporter,
1348
+ priority: issue.priority,
1349
+ labels: issue.labels,
1350
+ created: issue.created,
1351
+ updated: issue.updated,
1352
+ url: issue.url
1353
+ },
1354
+ title: issue.summary,
1355
+ body: issue.description,
1356
+ comments: issue.comments,
1357
+ attachments: issue.attachments
1358
+ }, out);
1359
+ }
1360
+ function planPageCopy(page, out) {
1361
+ return planCopy({
1362
+ baseName: `${page.id}-${slugify(page.title)}`,
1363
+ fields: {
1364
+ title: page.title,
1365
+ id: page.id,
1366
+ space: page.spaceKey,
1367
+ version: page.version,
1368
+ author: page.author,
1369
+ created: page.createdAt,
1370
+ updated: page.updatedAt,
1371
+ url: page.url
1372
+ },
1373
+ title: page.title,
1374
+ body: page.body,
1375
+ comments: page.comments,
1376
+ attachments: page.attachments
1377
+ }, out);
1378
+ }
1379
+ function renderCopy(plan, landed) {
1380
+ const resolveMedia = mediaResolver(landed);
1381
+ return render({
1382
+ fields: plan.fields,
1383
+ title: plan.title,
1384
+ body: adfToMarkdown(plan.body, { resolveMedia }),
1385
+ comments: plan.comments.map((c) => ({
1386
+ author: c.author,
1387
+ created: c.created,
1388
+ body: adfToMarkdown(c.body, { resolveMedia })
1389
+ })),
1390
+ attachments: landed
1391
+ });
1187
1392
  }
1188
- function parseJiraUpdateSource(content) {
1189
- const { fields, bodyTitle, body } = splitFile(content);
1190
- const key = fields["key"];
1191
- if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
1393
+ function planCopy(source, out) {
1394
+ const { baseName, attachments, ...content } = source;
1395
+ const target = outputTarget(baseName, out);
1192
1396
  return {
1193
- key,
1194
- updated: fields["updated"] ?? "",
1195
- bodyTitle,
1196
- body
1397
+ ...content,
1398
+ filePath: target.filePath,
1399
+ assetsDir: target.assetsDir,
1400
+ downloads: planDownloads(attachments, target)
1197
1401
  };
1198
1402
  }
1199
- function splitFile(content) {
1200
- const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
1201
- if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
1202
- const fields = parseFrontmatter(match[1] ?? "");
1203
- const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
1403
+ function outputTarget(base, out) {
1404
+ let filePath;
1405
+ if (!out) filePath = resolve(`${base}.md`);
1406
+ else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
1407
+ else filePath = resolve(out, `${base}.md`);
1408
+ const dir = dirname(filePath);
1409
+ const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
1204
1410
  return {
1205
- fields,
1206
- bodyTitle,
1207
- body
1411
+ filePath,
1412
+ assetsDir: join(dir, assetsDirName),
1413
+ assetsDirName
1208
1414
  };
1209
1415
  }
1210
- function parseFrontmatter(block) {
1211
- const out = {};
1212
- for (const line of block.split("\n")) {
1213
- const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
1214
- if (!m) continue;
1215
- const key = m[1] ?? "";
1216
- let value = (m[2] ?? "").trim();
1217
- if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1218
- out[key] = value;
1219
- }
1220
- return out;
1416
+ function slugify(title) {
1417
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
1418
+ }
1419
+ function planDownloads(attachments, target) {
1420
+ const used = /* @__PURE__ */ new Set();
1421
+ return attachments.map((att) => {
1422
+ const savedAs = uniqueName(bareFilename(att.filename), used);
1423
+ return {
1424
+ ...att,
1425
+ relativePath: `${target.assetsDirName}/${savedAs}`,
1426
+ path: join(target.assetsDir, savedAs)
1427
+ };
1428
+ });
1429
+ }
1430
+ function bareFilename(name) {
1431
+ return basename(name).replace(/[/\\]/g, "_") || "attachment";
1432
+ }
1433
+ function uniqueName(name, used) {
1434
+ let candidate = name;
1435
+ for (let suffix = 1; used.has(candidate); suffix++) candidate = numberedName(name, suffix);
1436
+ used.add(candidate);
1437
+ return candidate;
1438
+ }
1439
+ function numberedName(name, suffix) {
1440
+ const dot = name.lastIndexOf(".");
1441
+ return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
1221
1442
  }
1222
- function splitBody(rest, fallbackTitle) {
1223
- const lines = rest.split("\n");
1224
- let bodyTitle = fallbackTitle;
1225
- let start = 0;
1226
- for (let i = 0; i < lines.length; i++) {
1227
- const line = lines[i] ?? "";
1228
- if (line.startsWith("# ")) {
1229
- bodyTitle = line.slice(2).trim();
1230
- start = i + 1;
1231
- break;
1232
- }
1233
- if (line.trim().length > 0) break;
1234
- }
1235
- let end = lines.length;
1236
- for (let i = start; i < lines.length; i++) if (/^## (Comments|Attachments)\s*$/.test(lines[i] ?? "")) {
1237
- end = i;
1238
- break;
1443
+ //#endregion
1444
+ //#region src/copy/run.ts
1445
+ async function runCopy(plan, fetchBytes) {
1446
+ const landed = await download(plan, fetchBytes);
1447
+ await mkdir(dirname(plan.filePath), { recursive: true });
1448
+ await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
1449
+ console.log(wroteLine(plan.filePath, landed.length));
1450
+ }
1451
+ async function download(plan, fetchBytes) {
1452
+ if (plan.downloads.length === 0) return [];
1453
+ await mkdir(plan.assetsDir, { recursive: true });
1454
+ const landed = [];
1455
+ for (const { path, ...attachment } of plan.downloads) try {
1456
+ await writeFile(path, await fetchBytes(attachment.url));
1457
+ landed.push(attachment);
1458
+ } catch (err) {
1459
+ console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
1239
1460
  }
1240
- const body = lines.slice(start, end).join("\n").trim();
1241
- return {
1242
- bodyTitle,
1243
- body
1244
- };
1461
+ return landed;
1462
+ }
1463
+ function wroteLine(filePath, attachmentCount) {
1464
+ return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
1245
1465
  }
1466
+ //#endregion
1467
+ //#region src/adf/lossy.ts
1246
1468
  const LOSSY_LABELS = {
1247
1469
  panel: "panel",
1248
1470
  expand: "expand",
@@ -1258,10 +1480,10 @@ const JIRA_LOSSY_LABELS = {
1258
1480
  media: "image",
1259
1481
  mediaInline: "image"
1260
1482
  };
1261
- function findLossyNodes(node, labels = LOSSY_LABELS) {
1483
+ function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
1262
1484
  const counts = /* @__PURE__ */ new Map();
1263
1485
  const visit = (n) => {
1264
- const label = labels[n.type];
1486
+ const label = lossyLabels[n.type];
1265
1487
  if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
1266
1488
  for (const child of n.content ?? []) visit(child);
1267
1489
  };
@@ -1272,87 +1494,206 @@ function formatLossy(counts) {
1272
1494
  return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
1273
1495
  }
1274
1496
  //#endregion
1275
- //#region src/util/output-path.ts
1276
- function resolveOutput(defaultBase, out) {
1277
- let filePath;
1278
- if (!out) filePath = resolve(`${defaultBase}.md`);
1279
- else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
1280
- else filePath = resolve(out, `${defaultBase}.md`);
1281
- const dir = dirname(filePath);
1282
- const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
1497
+ //#region src/update/plan.ts
1498
+ function planIssueUpdate(source, issue, options) {
1499
+ const images = imageHrefs(source.body).map((href) => ({
1500
+ href,
1501
+ kind: isExternalHref(href) ? "external" : "unsupported"
1502
+ }));
1503
+ const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
1504
+ const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
1505
+ const refusals = [...unsupported.length > 0 ? [`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${unsupported.join(", ")}`] : [], ...emptyBodyRefusal(body)];
1506
+ return withVerdict({
1507
+ noun: "issue",
1508
+ id: source.key,
1509
+ headline: headline("summary", issue.summary, source.title, options.summary),
1510
+ revision: revision(source.updatedAtCopy, issue.updated),
1511
+ lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
1512
+ images,
1513
+ uploads: [],
1514
+ body,
1515
+ refusals
1516
+ }, options.force ?? false);
1517
+ }
1518
+ function planPageUpdate(source, state, attachments, localImages, options) {
1519
+ const collection = `contentId-${source.id}`;
1520
+ const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
1521
+ const byHref = new Map(entries.map((e) => [e.href, e]));
1522
+ const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
1523
+ const entry = byHref.get(href);
1524
+ if (!entry || entry.kind === "missing") return void 0;
1525
+ if (entry.kind === "external") return externalMedia(href, alt);
1526
+ return mediaNode({
1527
+ type: "file",
1528
+ id: entry.kind === "reuse" ? entry.fileId : href,
1529
+ collection
1530
+ }, alt);
1531
+ } });
1532
+ const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
1533
+ const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
1534
+ return withVerdict({
1535
+ noun: "page",
1536
+ id: source.id,
1537
+ headline: headline("title", state.title, source.title, options.title),
1538
+ revision: revision(`v${source.version}`, `v${state.version}`),
1539
+ lossy: findLossyNodes(state.body),
1540
+ images: entries.map(({ href, kind }) => ({
1541
+ href,
1542
+ kind
1543
+ })),
1544
+ uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
1545
+ href: e.href,
1546
+ path: e.path,
1547
+ filename: e.filename
1548
+ }] : []),
1549
+ body,
1550
+ refusals
1551
+ }, options.force ?? false);
1552
+ }
1553
+ function pageImage(href, localImages, attachments) {
1554
+ if (isExternalHref(href)) return {
1555
+ href,
1556
+ kind: "external"
1557
+ };
1558
+ const local = localImages.find((l) => l.href === href);
1559
+ if (!local || local.size === void 0) return {
1560
+ href,
1561
+ kind: "missing"
1562
+ };
1563
+ const existing = attachments.find((a) => a.filename === local.filename);
1564
+ if (existing && existing.size === local.size) return {
1565
+ href,
1566
+ kind: "reuse",
1567
+ fileId: existing.fileId
1568
+ };
1283
1569
  return {
1284
- filePath,
1285
- assetsDir: join(dir, assetsDirName),
1286
- assetsDirName
1570
+ href,
1571
+ kind: existing ? "changed" : "upload",
1572
+ path: local.path,
1573
+ filename: local.filename
1287
1574
  };
1288
1575
  }
1289
- function slugify(title) {
1290
- return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
1576
+ function withUploadedIds(doc, ids) {
1577
+ if (!doc.content) return doc;
1578
+ return {
1579
+ ...doc,
1580
+ content: doc.content.map((n) => withUploadedId(n, ids))
1581
+ };
1291
1582
  }
1292
- //#endregion
1293
- //#region src/commands/search-run.ts
1294
- const COPY_CONCURRENCY = 5;
1295
- async function runSearch(rows, options, noun, copyOne) {
1296
- if (options.json) {
1297
- console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
1298
- return;
1299
- }
1300
- if (rows.length === 0) {
1301
- console.log(`No matching ${noun.plural}.`);
1302
- return;
1303
- }
1304
- if (options.copy) {
1305
- if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
1306
- await copySelected(rows, noun, copyOne);
1307
- return;
1308
- }
1309
- for (const row of rows) console.log(formatRow(row));
1310
- if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
1583
+ function withUploadedId(node, ids) {
1584
+ const id = node.type === "media" ? node.attrs?.["id"] : void 0;
1585
+ const swapped = typeof id === "string" && ids.has(id) ? {
1586
+ ...node,
1587
+ attrs: {
1588
+ ...node.attrs,
1589
+ id: ids.get(id)
1590
+ }
1591
+ } : node;
1592
+ if (!swapped.content) return swapped;
1593
+ return {
1594
+ ...swapped,
1595
+ content: swapped.content.map((n) => withUploadedId(n, ids))
1596
+ };
1311
1597
  }
1312
- async function copySelected(rows, noun, copyOne) {
1313
- if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
1314
- const selected = await checkbox({
1315
- message: `Select ${noun.plural} to copy:`,
1316
- choices: rows.map((r) => ({
1317
- name: formatRow(r),
1318
- value: r.id
1319
- })),
1320
- pageSize: 20
1321
- });
1322
- if (selected.length === 0) {
1323
- console.log("Nothing selected.");
1598
+ function formatPlan(plan) {
1599
+ const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
1600
+ if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
1601
+ const counts = imageCounts(plan.images);
1602
+ if (counts) lines.push(row("images", counts));
1603
+ for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
1604
+ if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
1605
+ if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
1606
+ lines.push(" nothing was written (dry run)");
1607
+ return lines;
1608
+ }
1609
+ const IMAGE_COUNT_LABELS = [
1610
+ ["upload", "new"],
1611
+ ["changed", "changed"],
1612
+ ["reuse", "reused"],
1613
+ ["external", "external"],
1614
+ ["unsupported", "unsupported"],
1615
+ ["missing", "missing"]
1616
+ ];
1617
+ function imageCounts(images) {
1618
+ return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
1619
+ n: images.filter((i) => i.kind === kind).length,
1620
+ label
1621
+ })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
1622
+ }
1623
+ function row(label, value) {
1624
+ return ` ${`${label}:`.padEnd(9)}${value}`;
1625
+ }
1626
+ function headline(label, current, title, useTitle) {
1627
+ return {
1628
+ label,
1629
+ current,
1630
+ next: useTitle && title ? title : current
1631
+ };
1632
+ }
1633
+ function revision(local, server) {
1634
+ return {
1635
+ local: local || "unknown",
1636
+ server: server || "unknown",
1637
+ stale: local !== server
1638
+ };
1639
+ }
1640
+ function revisionText(revision) {
1641
+ return `copied at ${revision.local}, server now ${revision.server}`;
1642
+ }
1643
+ function emptyBodyRefusal(body) {
1644
+ if (body.content && body.content.length > 0) return [];
1645
+ return ["Refusing to update: the converted body is empty."];
1646
+ }
1647
+ function withVerdict(plan, force) {
1648
+ return {
1649
+ ...plan,
1650
+ verdict: verdict(plan, force)
1651
+ };
1652
+ }
1653
+ function verdict(plan, force) {
1654
+ if (plan.refusals.length > 0) return {
1655
+ kind: "refuse",
1656
+ message: plan.refusals.join("\n")
1657
+ };
1658
+ const noun = plan.noun;
1659
+ if (plan.revision.stale && !force) return {
1660
+ kind: "refuse",
1661
+ message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
1662
+ };
1663
+ if (plan.lossy.size > 0 && !force) return {
1664
+ kind: "confirm",
1665
+ message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
1666
+ };
1667
+ return { kind: "proceed" };
1668
+ }
1669
+ function capitalize(text) {
1670
+ return text.charAt(0).toUpperCase() + text.slice(1);
1671
+ }
1672
+ //#endregion
1673
+ //#region src/update/run.ts
1674
+ async function runPlan(plan, options, push) {
1675
+ if (options.dryRun) {
1676
+ for (const line of formatPlan(plan)) console.log(line);
1324
1677
  return;
1325
1678
  }
1326
- let copied = 0;
1327
- const failures = [];
1328
- const queue = [...selected];
1329
- async function worker() {
1330
- for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
1331
- await copyOne(id);
1332
- copied++;
1333
- } catch (err) {
1334
- failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
1679
+ const verdict = plan.verdict;
1680
+ if (verdict.kind === "refuse") throw new Error(verdict.message);
1681
+ if (verdict.kind === "confirm") {
1682
+ if (!await confirm({
1683
+ message: verdict.message,
1684
+ default: false
1685
+ })) {
1686
+ console.log("Aborted.");
1687
+ return;
1335
1688
  }
1336
1689
  }
1337
- await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
1338
- const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
1339
- if (failures.length === 0) console.log(summary);
1340
- else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1341
- }
1342
- function formatRow(row) {
1343
- const room = (process.stdout.columns ?? 80) - row.prefix.length - 2;
1344
- const text = room > 0 ? truncate(row.text, room) : "";
1345
- return text ? `${row.prefix} ${text}` : row.prefix;
1346
- }
1347
- function truncate(text, max) {
1348
- const clean = text.replace(/\s+/g, " ").trim();
1349
- return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
1690
+ await push();
1350
1691
  }
1351
1692
  //#endregion
1352
1693
  //#region src/commands/confluence.ts
1353
1694
  async function confluenceCopy(arg, options) {
1354
1695
  const auth = await requireAuth();
1355
- const id = await resolveId(arg);
1696
+ const id = await resolveRef(arg, PAGE_REF);
1356
1697
  await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
1357
1698
  }
1358
1699
  async function confluenceUpdate(arg, options) {
@@ -1360,50 +1701,44 @@ async function confluenceUpdate(arg, options) {
1360
1701
  message: "Path to the page Markdown file:",
1361
1702
  required: true
1362
1703
  });
1363
- const src = parseUpdateSource(await readFile(file, "utf8"));
1704
+ const src = parsePageSource(await readFile(file, "utf8"));
1364
1705
  const client = new AtlassianClient(await requireAuth());
1365
1706
  const state = await fetchPageState(client, src.id);
1366
- if (state.version !== src.version && !options.force) throw new Error(`Page changed on the server since you copied it (local v${src.version}, server v${state.version}). Re-copy the page or pass --force.`);
1367
- const dir = dirname(resolve(file));
1368
- const attachments = await listAttachments(client, src.id);
1369
- const plan = await planImages(dir, collectImageHrefs(src.body), attachments);
1370
- const lossy = findLossyNodes(state.body);
1371
- const nextVersion = state.version + 1;
1372
- const newTitle = options.title && src.bodyTitle ? src.bodyTitle : state.title;
1373
- if (options.dryRun) {
1374
- printDryRun$1(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
1375
- return;
1376
- }
1377
- if (lossy.size > 0 && !options.force) {
1378
- if (!await confirm({
1379
- message: `This page contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1380
- default: false
1381
- })) {
1382
- console.log("Aborted.");
1383
- return;
1707
+ const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
1708
+ await runPlan(plan, options, async () => {
1709
+ const ids = /* @__PURE__ */ new Map();
1710
+ for (const upload of plan.uploads) {
1711
+ console.log(`Uploading ${upload.filename} ...`);
1712
+ const bytes = await readFile(upload.path);
1713
+ ids.set(upload.href, await uploadAttachment(client, src.id, upload.filename, bytes));
1384
1714
  }
1385
- }
1386
- const collection = `contentId-${src.id}`;
1387
- const fileIds = /* @__PURE__ */ new Map();
1388
- for (const [href, entry] of plan) if (entry.kind === "upload") {
1389
- console.log(`Uploading ${entry.filename} ...`);
1390
- fileIds.set(href, await uploadAttachment(client, src.id, entry.filename, await readFile(entry.path)));
1391
- } else if (entry.kind === "reuse") fileIds.set(href, entry.fileId);
1392
- const body = markdownToAdf(src.body, { resolveImage: (href, alt) => {
1393
- const entry = plan.get(href);
1394
- if (!entry) return void 0;
1395
- if (entry.kind === "external") return externalMedia(href, alt);
1396
- const fileId = fileIds.get(href);
1397
- return fileId ? fileMedia(fileId, collection, alt) : void 0;
1398
- } });
1399
- if (!body.content || body.content.length === 0) throw new Error("Refusing to update: the converted body is empty.");
1400
- const version = await updatePage(client, src.id, {
1401
- title: newTitle,
1402
- version: nextVersion,
1403
- body,
1404
- message: options.message ?? "Updated via atlass"
1715
+ const version = await updatePage(client, src.id, {
1716
+ title: plan.headline.next,
1717
+ nextVersion: state.version + 1,
1718
+ body: withUploadedIds(plan.body, ids),
1719
+ message: options.message ?? "Updated via atlass"
1720
+ });
1721
+ console.log(`Updated page ${src.id} to version ${version}.`);
1405
1722
  });
1406
- console.log(`Updated page ${src.id} to version ${version}.`);
1723
+ }
1724
+ async function statLocalImages(dir, md) {
1725
+ const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
1726
+ return Promise.all(hrefs.map(async (href) => {
1727
+ const path = isAbsolute(href) ? href : resolve(dir, href);
1728
+ return {
1729
+ href,
1730
+ path,
1731
+ filename: basename(path),
1732
+ ...await fileSize(path)
1733
+ };
1734
+ }));
1735
+ }
1736
+ async function fileSize(path) {
1737
+ try {
1738
+ return { size: (await stat(path)).size };
1739
+ } catch {
1740
+ return {};
1741
+ }
1407
1742
  }
1408
1743
  async function confluenceSearch(query, options) {
1409
1744
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
@@ -1419,8 +1754,8 @@ async function confluenceSearch(query, options) {
1419
1754
  });
1420
1755
  await runSearch(pages.map((p) => ({
1421
1756
  id: p.id,
1422
- prefix: `${p.id} ${p.space}`,
1423
- text: p.title,
1757
+ fixedColumns: `${p.id} ${p.space}`,
1758
+ freeText: p.title,
1424
1759
  json: {
1425
1760
  id: p.id,
1426
1761
  space: p.space,
@@ -1440,127 +1775,13 @@ async function confluenceSearch(query, options) {
1440
1775
  }
1441
1776
  async function copyPage(client, site, id, out) {
1442
1777
  console.log(`Fetching page ${id} ...`);
1443
- const page = await fetchPage(client, site, id);
1444
- const target = resolveOutput(`${page.id}-${slugify(page.title)}`, out);
1445
- const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
1446
- const resolveMedia = mediaResolver(downloaded);
1447
- const document = joinSections([
1448
- frontmatter({
1449
- title: page.title,
1450
- id: page.id,
1451
- space: page.spaceKey,
1452
- version: page.version,
1453
- author: page.author,
1454
- created: page.createdAt,
1455
- updated: page.updatedAt,
1456
- url: page.url
1457
- }),
1458
- `# ${page.title}`,
1459
- adfToMarkdown(page.body, { resolveMedia }),
1460
- commentsSection(page.comments, resolveMedia),
1461
- attachmentsSection(downloaded)
1462
- ]);
1463
- await writeFile(target.filePath, document, "utf8");
1464
- const suffix = downloaded.length > 0 ? ` (+${downloaded.length} attachment${downloaded.length === 1 ? "" : "s"})` : "";
1465
- console.log(`Wrote ${target.filePath}${suffix}`);
1466
- }
1467
- async function resolveId(arg) {
1468
- const raw = arg ?? await input({
1469
- message: "Confluence page id or URL:",
1470
- required: true
1471
- });
1472
- const id = parsePageId(raw);
1473
- if (!id) throw new Error(`Could not find a page id in "${raw}".`);
1474
- return id;
1475
- }
1476
- function collectImageHrefs(md) {
1477
- const hrefs = /* @__PURE__ */ new Set();
1478
- marked.walkTokens(marked.lexer(md), (token) => {
1479
- if (token.type === "image") hrefs.add(token.href);
1480
- });
1481
- return [...hrefs];
1482
- }
1483
- async function planImages(dir, hrefs, attachments) {
1484
- const byName = new Map(attachments.map((a) => [a.filename, a]));
1485
- const plan = /* @__PURE__ */ new Map();
1486
- const missing = [];
1487
- for (const href of hrefs) {
1488
- if (isExternal(href)) {
1489
- plan.set(href, { kind: "external" });
1490
- continue;
1491
- }
1492
- const path = isAbsolute(href) ? href : resolve(dir, href);
1493
- let size;
1494
- try {
1495
- size = (await stat(path)).size;
1496
- } catch {
1497
- missing.push(href);
1498
- continue;
1499
- }
1500
- const filename = basename(path);
1501
- const existing = byName.get(filename);
1502
- if (existing && existing.size === size) plan.set(href, {
1503
- kind: "reuse",
1504
- fileId: existing.fileId,
1505
- filename
1506
- });
1507
- else plan.set(href, {
1508
- kind: "upload",
1509
- path,
1510
- filename,
1511
- existed: existing !== void 0
1512
- });
1513
- }
1514
- if (missing.length > 0) throw new Error(`Image file(s) not found: ${missing.join(", ")}`);
1515
- return plan;
1516
- }
1517
- function isExternal(href) {
1518
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
1519
- }
1520
- function externalMedia(href, alt) {
1521
- const attrs = {
1522
- type: "external",
1523
- url: href
1524
- };
1525
- if (alt) attrs["alt"] = alt;
1526
- return {
1527
- type: "mediaSingle",
1528
- attrs: { layout: "center" },
1529
- content: [{
1530
- type: "media",
1531
- attrs
1532
- }]
1533
- };
1534
- }
1535
- function fileMedia(fileId, collection, alt) {
1536
- const attrs = {
1537
- type: "file",
1538
- id: fileId,
1539
- collection
1540
- };
1541
- if (alt) attrs["alt"] = alt;
1542
- return {
1543
- type: "mediaSingle",
1544
- attrs: { layout: "center" },
1545
- content: [{
1546
- type: "media",
1547
- attrs
1548
- }]
1549
- };
1550
- }
1551
- function printDryRun$1(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
1552
- const entries = [...plan.values()];
1553
- const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
1554
- const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
1555
- const reused = entries.filter((e) => e.kind === "reuse").length;
1556
- const external = entries.filter((e) => e.kind === "external").length;
1557
- console.log(`Dry run for page ${id} "${currentTitle}"`);
1558
- console.log(` version: ${currentVersion} -> ${nextVersion}`);
1559
- if (newTitle !== currentTitle) console.log(` title: "${currentTitle}" -> "${newTitle}"`);
1560
- console.log(` images: ${added} new, ${changed} changed, ${reused} reused, ${external} external`);
1561
- if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1562
- console.log(" nothing was written (dry run)");
1778
+ await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
1563
1779
  }
1780
+ const PAGE_REF = {
1781
+ message: "Confluence page id or URL:",
1782
+ parse: parsePageId,
1783
+ notFound: (raw) => `Could not find a page id in "${raw}".`
1784
+ };
1564
1785
  //#endregion
1565
1786
  //#region src/api/jira.ts
1566
1787
  const FIELDS = [
@@ -1576,6 +1797,7 @@ const FIELDS = [
1576
1797
  "updated",
1577
1798
  "attachment"
1578
1799
  ].join(",");
1800
+ const RECENT_ISSUES_CLAUSE = "updated >= -30d";
1579
1801
  function browseUrl(site, key) {
1580
1802
  return `${site}/browse/${key}`;
1581
1803
  }
@@ -1589,6 +1811,7 @@ async function fetchIssue(client, site, key) {
1589
1811
  summary: f.summary ?? "",
1590
1812
  type: f.issuetype?.name ?? "",
1591
1813
  status: f.status?.name ?? "",
1814
+ statusCategory: f.status?.statusCategory?.key ?? "",
1592
1815
  assignee: f.assignee?.displayName ?? "Unassigned",
1593
1816
  reporter: f.reporter?.displayName ?? "",
1594
1817
  priority: f.priority?.name ?? "",
@@ -1598,7 +1821,7 @@ async function fetchIssue(client, site, key) {
1598
1821
  description: f.description ?? null,
1599
1822
  comments,
1600
1823
  attachments: (f.attachment ?? []).map((a) => ({
1601
- id: a.id,
1824
+ mediaId: a.id,
1602
1825
  filename: a.filename,
1603
1826
  url: a.content
1604
1827
  }))
@@ -1607,7 +1830,7 @@ async function fetchIssue(client, site, key) {
1607
1830
  async function updateIssue(client, key, update) {
1608
1831
  const fields = { description: update.description };
1609
1832
  if (update.summary !== void 0) fields["summary"] = update.summary;
1610
- await client.put(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1833
+ await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1611
1834
  }
1612
1835
  async function searchIssues(client, site, params) {
1613
1836
  const jql = buildJql(params);
@@ -1626,11 +1849,11 @@ async function searchIssues(client, site, params) {
1626
1849
  function buildJql(params) {
1627
1850
  if (params.jql) return params.jql;
1628
1851
  const clauses = [];
1629
- if (params.project) clauses.push(`project = ${jqlValue(params.project)}`);
1630
- if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlValue(params.assignee)}`);
1631
- if (params.status) clauses.push(`status = ${jqlValue(params.status)}`);
1632
- if (params.text) clauses.push(`text ~ ${jqlValue(params.text)}`);
1633
- if (clauses.length === 0) clauses.push("updated >= -30d");
1852
+ if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
1853
+ if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
1854
+ if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
1855
+ if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
1856
+ if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
1634
1857
  return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1635
1858
  }
1636
1859
  const PROJECT_PAGE_SIZE = 50;
@@ -1686,7 +1909,7 @@ const CATEGORY_ORDER = {
1686
1909
  indeterminate: 1,
1687
1910
  done: 2
1688
1911
  };
1689
- const CATEGORY_LAST = Object.keys(CATEGORY_ORDER).length;
1912
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
1690
1913
  function dedupeAndSortStatuses(statuses) {
1691
1914
  const byNameCategory = /* @__PURE__ */ new Map();
1692
1915
  for (const s of statuses) {
@@ -1694,11 +1917,11 @@ function dedupeAndSortStatuses(statuses) {
1694
1917
  if (!byNameCategory.has(key)) byNameCategory.set(key, s);
1695
1918
  }
1696
1919
  return [...byNameCategory.values()].sort((a, b) => {
1697
- const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? CATEGORY_LAST;
1920
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
1698
1921
  return rank(a) - rank(b) || a.name.localeCompare(b.name);
1699
1922
  });
1700
1923
  }
1701
- function jqlValue(value) {
1924
+ function jqlStringLiteral(value) {
1702
1925
  return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1703
1926
  }
1704
1927
  async function fetchComments(client, key) {
@@ -1709,6 +1932,148 @@ async function fetchComments(client, key) {
1709
1932
  }));
1710
1933
  }
1711
1934
  //#endregion
1935
+ //#region src/markdown/highlight.ts
1936
+ const HEADING = /^#{1,6} /;
1937
+ const QUOTE = /^(\s*)>( |$)/;
1938
+ const LIST = /^(\s*)(-|\d+\.) (\[[ x]\] )?/;
1939
+ const RULE = /^\s*---$/;
1940
+ const TABLE_ROW = /^\|.*\|$/;
1941
+ const TABLE_SEPARATOR = /^\|( --- \|)+$/;
1942
+ const CELL_DIVIDER = /(?<!\\)\|/;
1943
+ const DETAILS_OPEN = /^(\s*)<details><summary>(.*)<\/summary>$/;
1944
+ const DETAILS_CLOSE = /^\s*<\/details>$/;
1945
+ const FENCE_OPEN = /^((?:\s|> |- |\d+\. )*)```(\S*)$/;
1946
+ const FENCE = "```";
1947
+ const INLINE = /(`[^`]+`)|\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(?!\*)(.+?)(?<!\*)\*(?!\*)|~~(.+?)~~/g;
1948
+ const TOKEN_STYLES = {
1949
+ keyword: kleur.magenta,
1950
+ built_in: kleur.magenta,
1951
+ literal: kleur.magenta,
1952
+ string: kleur.green,
1953
+ regexp: kleur.green,
1954
+ addition: kleur.green,
1955
+ number: kleur.yellow,
1956
+ symbol: kleur.yellow,
1957
+ meta: kleur.yellow,
1958
+ attr: kleur.yellow,
1959
+ comment: kleur.dim,
1960
+ quote: kleur.dim,
1961
+ deletion: kleur.dim,
1962
+ title: kleur.cyan,
1963
+ type: kleur.cyan,
1964
+ section: kleur.cyan
1965
+ };
1966
+ const lowlight = createLowlight(common);
1967
+ function highlightMarkdown(md) {
1968
+ const lines = md.split("\n");
1969
+ const out = [];
1970
+ for (let i = 0; i < lines.length; i++) {
1971
+ const line = lines[i] ?? "";
1972
+ const fence = line.match(FENCE_OPEN);
1973
+ if (!fence) {
1974
+ out.push(highlightLine(line, lines[i + 1]));
1975
+ continue;
1976
+ }
1977
+ const [, prefix = "", lang = ""] = fence;
1978
+ const styledPrefix = prefix.replace(/\S+/g, (marker) => kleur.dim(marker));
1979
+ out.push(`${styledPrefix}${kleur.dim(`${FENCE}${lang}`)}`);
1980
+ const code = [];
1981
+ const prefixed = [];
1982
+ let j = i + 1;
1983
+ for (; j < lines.length; j++) {
1984
+ const raw = lines[j] ?? "";
1985
+ const hasPrefix = raw.startsWith(prefix) || raw === prefix.trimEnd();
1986
+ const body = raw.startsWith(prefix) ? raw.slice(prefix.length) : hasPrefix ? "" : raw;
1987
+ if (body === FENCE) break;
1988
+ code.push(body);
1989
+ prefixed.push(hasPrefix);
1990
+ }
1991
+ if (code.length > 0) highlightCode(code.join("\n"), lang).split("\n").forEach((codeLine, k) => {
1992
+ const lead = prefixed[k] ? styledPrefix : "";
1993
+ out.push(codeLine ? `${lead}${codeLine}` : lead.trimEnd());
1994
+ });
1995
+ if (j < lines.length) {
1996
+ const lead = (lines[j] ?? "").startsWith(prefix) ? styledPrefix : "";
1997
+ out.push(`${lead}${kleur.dim(FENCE)}`);
1998
+ }
1999
+ i = j;
2000
+ }
2001
+ return out.join("\n");
2002
+ }
2003
+ function highlightCode(code, lang) {
2004
+ if (!lang || !lowlight.registered(lang)) return styleEachLine(code, kleur.yellow);
2005
+ return renderTree(lowlight.highlight(lang, code), (text) => text);
2006
+ }
2007
+ function renderTree(node, style) {
2008
+ if (node.type === "text") return styleEachLine(node.value, style);
2009
+ if (node.type !== "root" && node.type !== "element") return "";
2010
+ const own = node.type === "element" ? tokenStyle(node.properties["className"]) : void 0;
2011
+ const next = own ? (text) => style(own(text)) : style;
2012
+ return node.children.map((child) => renderTree(child, next)).join("");
2013
+ }
2014
+ function tokenStyle(className) {
2015
+ if (!Array.isArray(className)) return void 0;
2016
+ for (const name of className) {
2017
+ if (typeof name !== "string" || !name.startsWith("hljs-")) continue;
2018
+ const style = TOKEN_STYLES[name.slice(5)];
2019
+ if (style) return style;
2020
+ }
2021
+ }
2022
+ function styleEachLine(text, style) {
2023
+ return text.split("\n").map((line) => line ? style(line) : line).join("\n");
2024
+ }
2025
+ function highlightLine(line, nextLine) {
2026
+ const quote = line.match(QUOTE);
2027
+ if (quote) {
2028
+ const [whole, indent = ""] = quote;
2029
+ const rest = line.slice(whole.length);
2030
+ const gap = whole.endsWith(" ") ? " " : "";
2031
+ return `${indent}${kleur.dim(">")}${gap}${highlightLine(rest, stripPrefix(nextLine, whole))}`;
2032
+ }
2033
+ const list = line.match(LIST);
2034
+ if (list) {
2035
+ const [whole, indent = "", marker = "", checkbox] = list;
2036
+ const rest = line.slice(whole.length);
2037
+ const box = checkbox ? `${kleur.bold(checkbox.trim())} ` : "";
2038
+ return `${indent}${kleur.dim(marker)} ${box}${highlightLine(rest, void 0)}`;
2039
+ }
2040
+ if (HEADING.test(line)) return kleur.bold().cyan(line);
2041
+ if (RULE.test(line)) return kleur.dim(line);
2042
+ if (TABLE_SEPARATOR.test(line)) return kleur.dim(line);
2043
+ if (TABLE_ROW.test(line)) return tableRow(line, nextLine !== void 0 && TABLE_SEPARATOR.test(nextLine));
2044
+ const details = line.match(DETAILS_OPEN);
2045
+ if (details) {
2046
+ const [, indent = "", title = ""] = details;
2047
+ return `${indent}${kleur.dim("<details><summary>")}${highlightInline(title)}${kleur.dim("</summary>")}`;
2048
+ }
2049
+ if (DETAILS_CLOSE.test(line)) return kleur.dim(line);
2050
+ return highlightInline(line);
2051
+ }
2052
+ function stripPrefix(line, prefix) {
2053
+ return line?.startsWith(prefix) ? line.slice(prefix.length) : void 0;
2054
+ }
2055
+ function tableRow(line, header) {
2056
+ const styled = line.split(CELL_DIVIDER).slice(1, -1).map((cell) => header ? kleur.bold(highlightInline(cell)) : highlightInline(cell));
2057
+ const pipe = kleur.dim("|");
2058
+ return `${pipe}${styled.join(pipe)}${pipe}`;
2059
+ }
2060
+ function highlightInline(text) {
2061
+ return text.replace(INLINE, (match, code, linkText, url, boldItalic, bold, italic, strike) => {
2062
+ if (code !== void 0) return kleur.yellow(code);
2063
+ if (linkText !== void 0 && url !== void 0) return link(linkText, url);
2064
+ if (boldItalic !== void 0) return kleur.bold().italic(match);
2065
+ if (bold !== void 0) return kleur.bold(`**${highlightInline(bold)}**`);
2066
+ if (italic !== void 0) return kleur.italic(`*${highlightInline(italic)}*`);
2067
+ if (strike !== void 0) return kleur.strikethrough(`~~${highlightInline(strike)}~~`);
2068
+ return match;
2069
+ });
2070
+ }
2071
+ function link(text, url) {
2072
+ const open = kleur.dim("[");
2073
+ const middle = kleur.dim("](");
2074
+ return `${open}${highlightInline(text)}${middle}${kleur.dim().underline(url)}${kleur.dim(")")}`;
2075
+ }
2076
+ //#endregion
1712
2077
  //#region src/commands/jira.ts
1713
2078
  async function jiraProjects(query, options) {
1714
2079
  const auth = await requireAuth();
@@ -1747,45 +2112,92 @@ function formatStatusRows(statuses) {
1747
2112
  const width = Math.max(...statuses.map((s) => s.name.length));
1748
2113
  return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
1749
2114
  }
2115
+ async function jiraView(arg, options) {
2116
+ const auth = await requireAuth();
2117
+ const key = await resolveRef(arg, ISSUE_REF);
2118
+ const issue = await fetchIssue(new AtlassianClient(auth), auth.site, key);
2119
+ for (const line of formatIssueView(issue, Date.now(), options.allComments ?? false)) console.log(line);
2120
+ }
2121
+ const CATEGORY_COLORS = {
2122
+ new: kleur.cyan,
2123
+ indeterminate: kleur.yellow,
2124
+ done: kleur.green
2125
+ };
2126
+ function formatIssueView(issue, nowMs, allComments) {
2127
+ return [
2128
+ `${kleur.bold(issue.key)} ${issue.summary}`,
2129
+ ...fieldLines(issue, nowMs),
2130
+ ...bodyLines(adfToMarkdown(issue.description)),
2131
+ ...commentSection(issue.comments, allComments),
2132
+ ...attachmentSection(issue.attachments)
2133
+ ];
2134
+ }
2135
+ function fieldLines(issue, nowMs) {
2136
+ const colorStatus = CATEGORY_COLORS[issue.statusCategory] ?? ((text) => text);
2137
+ const kept = [
2138
+ ["Type", issue.type],
2139
+ ["Status", issue.status && colorStatus(issue.status)],
2140
+ ["Assignee", issue.assignee],
2141
+ ["Reporter", issue.reporter],
2142
+ ["Priority", issue.priority],
2143
+ ["Labels", issue.labels.join(", ")],
2144
+ ["Created", dateWithAge(issue.created, nowMs)],
2145
+ ["Updated", dateWithAge(issue.updated, nowMs)],
2146
+ ["URL", issue.url]
2147
+ ].filter(([, value]) => value !== "");
2148
+ const width = Math.max(...kept.map(([label]) => label.length)) + 1;
2149
+ return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
2150
+ }
2151
+ function dateWithAge(iso, nowMs) {
2152
+ if (!iso) return "";
2153
+ return `${formatDateTime(iso).slice(0, 10)} (${relativeTime(iso, nowMs)})`;
2154
+ }
2155
+ function bodyLines(body) {
2156
+ return body ? ["", highlightMarkdown(body)] : [];
2157
+ }
2158
+ function commentSection(comments, allComments) {
2159
+ if (comments.length === 0) return [];
2160
+ const visible = allComments ? comments : comments.slice(-5);
2161
+ const heading = comments.length - visible.length > 0 ? `Comments (${comments.length}, showing last ${visible.length} — --all-comments for all)` : `Comments (${comments.length})`;
2162
+ const lines = ["", kleur.bold(heading)];
2163
+ for (const comment of visible) {
2164
+ const body = adfToMarkdown(comment.body);
2165
+ lines.push("", commentHeader(comment), ...body ? [highlightMarkdown(body)] : []);
2166
+ }
2167
+ return lines;
2168
+ }
2169
+ function commentHeader(comment) {
2170
+ return `─ ${kleur.bold(comment.author || "Unknown")}${comment.created ? ` · ${kleur.dim(formatDateTime(comment.created))}` : ""}`;
2171
+ }
2172
+ function attachmentSection(attachments) {
2173
+ if (attachments.length === 0) return [];
2174
+ return [
2175
+ "",
2176
+ kleur.bold("Attachments"),
2177
+ ...attachments.map((a) => `- ${a.filename}`)
2178
+ ];
2179
+ }
1750
2180
  async function jiraCopy(arg, options) {
1751
2181
  const auth = await requireAuth();
1752
- const key = await resolveKey(arg);
2182
+ const key = await resolveRef(arg, ISSUE_REF);
1753
2183
  await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
1754
2184
  }
1755
2185
  async function jiraUpdate(arg, options) {
1756
- const src = parseJiraUpdateSource(await readFile(arg ?? await input({
2186
+ const src = parseIssueSource(await readFile(arg ?? await input({
1757
2187
  message: "Path to the issue Markdown file:",
1758
2188
  required: true
1759
2189
  }), "utf8"));
1760
2190
  const auth = await requireAuth();
1761
2191
  const client = new AtlassianClient(auth);
1762
- const issue = await fetchIssue(client, auth.site, src.key);
1763
- const stale = issue.updated !== src.updated;
1764
- const { local, external } = classifyImages(src.body);
1765
- const lossy = findLossyNodes(issue.description, JIRA_LOSSY_LABELS);
1766
- const newSummary = options.summary && src.bodyTitle ? src.bodyTitle : issue.summary;
1767
- if (options.dryRun) {
1768
- printDryRun(src.key, issue.summary, newSummary, stale, external.length, local, lossy);
1769
- return;
1770
- }
1771
- if (local.length > 0) throw new Error(`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${local.join(", ")}`);
1772
- if (stale && !options.force) throw new Error(`Issue changed on the server since you copied it (local ${src.updated || "unknown"}, server ${issue.updated}). Re-copy the issue or pass --force.`);
1773
- if (lossy.size > 0 && !options.force) {
1774
- if (!await confirm({
1775
- message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1776
- default: false
1777
- })) {
1778
- console.log("Aborted.");
1779
- return;
1780
- }
1781
- }
1782
- const description = markdownToAdf(src.body);
1783
- if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
1784
- await updateIssue(client, src.key, {
1785
- description,
1786
- summary: options.summary && newSummary !== issue.summary ? newSummary : void 0
2192
+ const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
2193
+ await runPlan(plan, options, async () => {
2194
+ const { current, next } = plan.headline;
2195
+ await updateIssue(client, src.key, {
2196
+ description: plan.body,
2197
+ summary: next !== current ? next : void 0
2198
+ });
2199
+ console.log(`Updated ${src.key}.`);
1787
2200
  });
1788
- console.log(`Updated ${src.key}.`);
1789
2201
  }
1790
2202
  async function jiraSearch(query, options) {
1791
2203
  if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
@@ -1803,8 +2215,8 @@ async function jiraSearch(query, options) {
1803
2215
  });
1804
2216
  await runSearch(issues.map((i) => ({
1805
2217
  id: i.key,
1806
- prefix: `${i.key} ${i.status}`,
1807
- text: i.summary,
2218
+ fixedColumns: `${i.key} ${i.status}`,
2219
+ freeText: i.summary,
1808
2220
  json: {
1809
2221
  key: i.key,
1810
2222
  status: i.status,
@@ -1824,70 +2236,16 @@ async function jiraSearch(query, options) {
1824
2236
  }
1825
2237
  async function copyIssue(client, site, key, out) {
1826
2238
  console.log(`Fetching ${key} ...`);
1827
- const issue = await fetchIssue(client, site, key);
1828
- const target = resolveOutput(issue.key, out);
1829
- const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
1830
- const resolveMedia = mediaResolver(downloaded);
1831
- const document = joinSections([
1832
- frontmatter({
1833
- key: issue.key,
1834
- type: issue.type,
1835
- status: issue.status,
1836
- assignee: issue.assignee,
1837
- reporter: issue.reporter,
1838
- priority: issue.priority,
1839
- labels: issue.labels,
1840
- created: issue.created,
1841
- updated: issue.updated,
1842
- url: issue.url
1843
- }),
1844
- `# ${issue.summary}`,
1845
- adfToMarkdown(issue.description, { resolveMedia }),
1846
- commentsSection(issue.comments, resolveMedia),
1847
- attachmentsSection(downloaded)
1848
- ]);
1849
- await writeFile(target.filePath, document, "utf8");
1850
- report(target.filePath, downloaded.length);
1851
- }
1852
- async function resolveKey(arg) {
1853
- const raw = arg ?? await input({
1854
- message: "Jira issue key or URL:",
1855
- required: true
1856
- });
1857
- const key = parseIssueKey(raw);
1858
- if (!key) throw new Error(`Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`);
1859
- return key;
1860
- }
1861
- function report(filePath, assetCount) {
1862
- const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
1863
- console.log(`Wrote ${filePath}${suffix}`);
1864
- }
1865
- function classifyImages(md) {
1866
- const local = [];
1867
- const external = [];
1868
- const seen = /* @__PURE__ */ new Set();
1869
- marked.walkTokens(marked.lexer(md), (token) => {
1870
- if (token.type !== "image") return;
1871
- const href = token.href;
1872
- if (seen.has(href)) return;
1873
- seen.add(href);
1874
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) external.push(href);
1875
- else local.push(href);
1876
- });
1877
- return {
1878
- local,
1879
- external
1880
- };
1881
- }
1882
- function printDryRun(key, currentSummary, newSummary, stale, externalImages, localImages, lossy) {
1883
- console.log(`Dry run for ${key} "${currentSummary}"`);
1884
- if (newSummary !== currentSummary) console.log(` summary: "${currentSummary}" -> "${newSummary}"`);
1885
- if (externalImages > 0) console.log(` images: ${externalImages} external`);
1886
- if (localImages.length > 0) console.log(` blocked: ${localImages.length} local image(s) not supported (edit text only)`);
1887
- if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1888
- if (stale) console.log(` stale: server changed since copy (would refuse without --force)`);
1889
- console.log(" nothing was written (dry run)");
2239
+ await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
1890
2240
  }
2241
+ const ISSUE_REF = {
2242
+ message: "Jira issue key or URL:",
2243
+ parse: parseIssueKey,
2244
+ notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
2245
+ };
2246
+ //#endregion
2247
+ //#region package.json
2248
+ var version = "1.7.0";
1891
2249
  //#endregion
1892
2250
  //#region src/cli.ts
1893
2251
  const program = new Command();
@@ -1899,6 +2257,7 @@ auth.command("status").description("Show the current login").action(run(status))
1899
2257
  const jira = program.command("jira").description("Jira commands");
1900
2258
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
1901
2259
  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));
2260
+ 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").action(run(jiraView));
1902
2261
  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));
1903
2262
  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));
1904
2263
  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));
@@ -1912,6 +2271,7 @@ bitbucket.command("logout").description("Remove stored Bitbucket credentials").a
1912
2271
  bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
1913
2272
  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));
1914
2273
  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));
2274
+ const SIGINT_EXIT_CODE = 130;
1915
2275
  program.parseAsync().catch(fail);
1916
2276
  function run(fn) {
1917
2277
  return async (...args) => {
@@ -1923,9 +2283,12 @@ function run(fn) {
1923
2283
  };
1924
2284
  }
1925
2285
  function fail(err) {
1926
- if (err instanceof Error && err.name === "ExitPromptError") process.exit(130);
2286
+ if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
1927
2287
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1928
2288
  process.exit(1);
1929
2289
  }
2290
+ function isPromptInterrupt(err) {
2291
+ return err instanceof Error && err.name === "ExitPromptError";
2292
+ }
1930
2293
  //#endregion
1931
2294
  export {};