atlass 1.0.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.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # atlass
2
+
3
+ CLI to copy Jira issues and Confluence pages to Markdown.
4
+
5
+ Fetches an issue or page from Atlassian Cloud, converts its rich content to
6
+ Markdown, writes a `.md` file with YAML frontmatter, and downloads any
7
+ attachments alongside it.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm install
13
+ pnpm build
14
+ pnpm link --global # exposes the `atlass` binary
15
+ ```
16
+
17
+ Or run from source without linking:
18
+
19
+ ```bash
20
+ node dist/cli.mjs <command>
21
+ ```
22
+
23
+ ## Authentication
24
+
25
+ Atlassian Cloud only. Auth uses your account email plus an API token
26
+ (Basic auth). Create a token at
27
+ `https://id.atlassian.com/manage-profile/security/api-tokens`.
28
+
29
+ ```bash
30
+ atlass auth login
31
+ ```
32
+
33
+ You are prompted for:
34
+
35
+ - site, e.g. `acme.atlassian.net`
36
+ - account email
37
+ - API token
38
+
39
+ The login is verified against `/rest/api/3/myself` before anything is saved.
40
+ The site and email are stored in `~/.config/atlass/config.json`
41
+ (or `$XDG_CONFIG_HOME/atlass/config.json`). The API token is stored in the OS
42
+ keyring (service `atlass`), never on disk.
43
+
44
+ ```bash
45
+ atlass auth status # show current site/email and whether a token is stored
46
+ atlass auth logout # remove config and delete the token from the keyring
47
+ ```
48
+
49
+ Only one account is supported at a time.
50
+
51
+ ## Usage
52
+
53
+ ### Copy a Jira issue
54
+
55
+ ```bash
56
+ atlass jira copy PROJ-123
57
+ atlass jira copy https://acme.atlassian.net/browse/PROJ-123
58
+ atlass jira copy # prompts for the key or URL
59
+ ```
60
+
61
+ Accepts an issue key or any URL containing one. Writes `PROJ-123.md` to the
62
+ current directory.
63
+
64
+ ### Copy a Confluence page
65
+
66
+ ```bash
67
+ atlass confluence copy 123456
68
+ atlass confluence copy https://acme.atlassian.net/wiki/spaces/DEV/pages/123456/Title
69
+ atlass confluence copy # prompts for the id or URL
70
+ ```
71
+
72
+ Accepts a numeric page id or a page URL. Writes `123456-title-slug.md` to the
73
+ current directory.
74
+
75
+ ### Output location
76
+
77
+ By default files are written to the current directory, named after the issue
78
+ key (Jira) or `<id>-<title-slug>` (Confluence). Use `--out` to override:
79
+
80
+ ```bash
81
+ atlass jira copy PROJ-123 --out ./tickets/ # directory: ./tickets/PROJ-123.md
82
+ atlass jira copy PROJ-123 --out ./bug.md # explicit file path
83
+ ```
84
+
85
+ Existing files are overwritten.
86
+
87
+ ## Output format
88
+
89
+ Each file is:
90
+
91
+ - YAML frontmatter with metadata
92
+ - an H1 heading (issue summary / page title)
93
+ - the body, converted from ADF to Markdown
94
+ - a `## Comments` section (Jira comments / Confluence footer comments)
95
+ - an `## Attachments` section linking every downloaded file
96
+
97
+ Jira frontmatter: `key`, `type`, `status`, `assignee`, `reporter`, `priority`,
98
+ `labels`, `created`, `updated`, `url`.
99
+
100
+ Confluence frontmatter: `title`, `id`, `space`, `version`, `author`, `created`,
101
+ `updated`, `url`.
102
+
103
+ Example (Jira):
104
+
105
+ ```markdown
106
+ ---
107
+ key: "PROJ-123"
108
+ type: "Bug"
109
+ status: "In Progress"
110
+ assignee: "Dana Scully"
111
+ reporter: "Fox Mulder"
112
+ priority: "High"
113
+ labels:
114
+ - "regression"
115
+ url: "https://acme.atlassian.net/browse/PROJ-123"
116
+ ---
117
+
118
+ # Login button does nothing on Safari
119
+
120
+ Steps to reproduce...
121
+
122
+ ## Comments
123
+
124
+ ### Fox Mulder - 2025-07-01 10:30
125
+
126
+ Reproduced on 17.5.
127
+
128
+ ## Attachments
129
+
130
+ - [screenshot.png](PROJ-123.assets/screenshot.png)
131
+ ```
132
+
133
+ ### Attachments
134
+
135
+ All attachments are downloaded into a sibling `<name>.assets/` folder and
136
+ listed under `## Attachments`. Inline images are linked to the local copy where
137
+ they can be matched:
138
+
139
+ - Confluence media nodes carry the attachment file id, so inline images resolve
140
+ reliably.
141
+ - Jira media nodes usually expose only a filename, so inline images resolve when
142
+ that filename matches an attachment; otherwise they render as
143
+ `[embedded media: ...]` and the file is still captured in the attachments
144
+ folder and section.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ pnpm test # run unit tests
150
+ pnpm check # format, lint, typecheck (add --fix to auto-fix)
151
+ pnpm build # build dist/cli.mjs
152
+ pnpm dev # build in watch mode
153
+ ```
154
+
155
+ ### Layout
156
+
157
+ - `src/cli.ts` command wiring (commander)
158
+ - `src/commands/` `auth`, `jira`, `confluence` command handlers
159
+ - `src/api/` fetch client, Jira and Confluence endpoints, attachment downloader
160
+ - `src/adf/` ADF to Markdown converter (unit tested)
161
+ - `src/markdown/` frontmatter, comments, attachments, media resolver
162
+ - `src/config.ts`, `src/credentials.ts` config file and keyring
163
+ - `src/util/` key/id parsing and output path resolution
164
+
165
+ The ADF to Markdown converter in `src/adf/to-markdown.ts` is a single hand
166
+ rolled walker shared by both commands. Confluence page bodies are requested as
167
+ `atlas_doc_format` so they flow through the same converter as Jira.
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { input, password } from "@inquirer/prompts";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { Entry } from "@napi-rs/keyring";
8
+ //#region package.json
9
+ var version = "1.0.0";
10
+ //#endregion
11
+ //#region src/api/client.ts
12
+ var AtlassianClient = class {
13
+ site;
14
+ authHeader;
15
+ constructor(auth) {
16
+ this.site = auth.site;
17
+ const basic = Buffer.from(`${auth.email}:${auth.token}`).toString("base64");
18
+ this.authHeader = `Basic ${basic}`;
19
+ }
20
+ async request(path, accept) {
21
+ const res = await fetch(`${this.site}${path}`, { headers: {
22
+ Authorization: this.authHeader,
23
+ Accept: accept
24
+ } });
25
+ if (!res.ok) throw httpError(res.status, path);
26
+ return res;
27
+ }
28
+ async getJson(path) {
29
+ return (await this.request(path, "application/json")).json();
30
+ }
31
+ async getBinary(url) {
32
+ const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
33
+ const res = await this.request(path, "*/*");
34
+ return new Uint8Array(await res.arrayBuffer());
35
+ }
36
+ };
37
+ function httpError(status, path) {
38
+ if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
39
+ if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
40
+ return /* @__PURE__ */ new Error(`Request failed (${status}): ${path}`);
41
+ }
42
+ //#endregion
43
+ //#region src/config.ts
44
+ function configDir() {
45
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "atlass");
46
+ }
47
+ function configPath() {
48
+ return join(configDir(), "config.json");
49
+ }
50
+ async function readConfig() {
51
+ try {
52
+ const raw = await readFile(configPath(), "utf8");
53
+ const parsed = JSON.parse(raw);
54
+ if (!parsed.site || !parsed.email) return null;
55
+ return {
56
+ site: parsed.site,
57
+ email: parsed.email
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ async function writeConfig(config) {
64
+ await mkdir(configDir(), { recursive: true });
65
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
66
+ }
67
+ async function clearConfig() {
68
+ await rm(configPath(), { force: true });
69
+ }
70
+ function normalizeSite(input) {
71
+ let value = input.trim();
72
+ if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
73
+ return new URL(value).origin;
74
+ }
75
+ //#endregion
76
+ //#region src/credentials.ts
77
+ const SERVICE = "atlass";
78
+ function entry(email) {
79
+ return new Entry(SERVICE, email);
80
+ }
81
+ function saveToken(email, token) {
82
+ entry(email).setPassword(token);
83
+ }
84
+ function readToken(email) {
85
+ return entry(email).getPassword();
86
+ }
87
+ function deleteToken(email) {
88
+ try {
89
+ entry(email).deleteCredential();
90
+ } catch {}
91
+ }
92
+ async function requireAuth() {
93
+ const config = await readConfig();
94
+ if (!config) throw new Error("Not logged in. Run `atlass auth login` first.");
95
+ const token = readToken(config.email);
96
+ if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
97
+ return {
98
+ ...config,
99
+ token
100
+ };
101
+ }
102
+ //#endregion
103
+ //#region src/commands/auth.ts
104
+ async function login() {
105
+ const site = normalizeSite(await input({
106
+ message: "Atlassian site (e.g. acme.atlassian.net):",
107
+ required: true
108
+ }));
109
+ const email = await input({
110
+ message: "Account email:",
111
+ required: true
112
+ });
113
+ const token = await password({
114
+ message: "API token (from id.atlassian.com/manage-profile/security/api-tokens):",
115
+ mask: true
116
+ });
117
+ const me = await new AtlassianClient({
118
+ site,
119
+ email,
120
+ token
121
+ }).getJson("/rest/api/3/myself");
122
+ await writeConfig({
123
+ site,
124
+ email
125
+ });
126
+ saveToken(email, token);
127
+ console.log(`Logged in as ${me.displayName} on ${site}.`);
128
+ }
129
+ async function logout() {
130
+ const config = await readConfig();
131
+ if (config) deleteToken(config.email);
132
+ await clearConfig();
133
+ console.log("Logged out. Credentials removed.");
134
+ }
135
+ async function status() {
136
+ const config = await readConfig();
137
+ if (!config) {
138
+ console.log("Not logged in. Run `atlass auth login`.");
139
+ return;
140
+ }
141
+ const hasToken = readToken(config.email) !== null;
142
+ console.log(`Site: ${config.site}`);
143
+ console.log(`Email: ${config.email}`);
144
+ console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
145
+ }
146
+ //#endregion
147
+ //#region src/adf/to-markdown.ts
148
+ function adfToMarkdown(doc, options = {}) {
149
+ if (!doc) return "";
150
+ const ctx = { resolveMedia: options.resolveMedia };
151
+ return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
152
+ }
153
+ function renderBlocks(nodes, ctx, indent) {
154
+ return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
155
+ }
156
+ function renderBlock(node, ctx, indent) {
157
+ switch (node.type) {
158
+ case "paragraph": return indent + renderInline(node.content ?? [], ctx);
159
+ case "heading": {
160
+ const level = clampLevel(node.attrs?.["level"]);
161
+ return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
162
+ }
163
+ case "bulletList": return renderList(node, ctx, indent, "bullet");
164
+ case "orderedList": return renderList(node, ctx, indent, "ordered");
165
+ case "taskList": return renderTaskList(node, ctx, indent);
166
+ case "decisionList": return renderDecisionList(node, ctx, indent);
167
+ case "codeBlock": return renderCodeBlock(node, indent);
168
+ case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
169
+ case "panel": return renderPanel(node, ctx, indent);
170
+ case "rule": return `${indent}---`;
171
+ case "table": return renderTable(node, ctx);
172
+ case "mediaSingle":
173
+ case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
174
+ case "media": return indent + renderMedia(node, ctx);
175
+ case "expand":
176
+ case "nestedExpand": return renderExpand(node, ctx, indent);
177
+ default:
178
+ if (node.content?.length) return renderBlocks(node.content, ctx, indent);
179
+ return node.text ? indent + node.text : "";
180
+ }
181
+ }
182
+ function renderList(node, ctx, indent, kind) {
183
+ const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
184
+ return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
185
+ return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
186
+ }).join("\n");
187
+ }
188
+ function renderListItem(item, ctx, indent, marker) {
189
+ const childIndent = `${indent}${" ".repeat(marker.length)}`;
190
+ const rendered = (item.content ?? []).map((b, i) => renderBlock(b, ctx, i === 0 ? "" : childIndent));
191
+ const parts = [];
192
+ rendered.forEach((text, i) => {
193
+ if (i === 0) parts.push(`${indent}${marker}${text}`);
194
+ else parts.push(text);
195
+ });
196
+ return parts.join("\n");
197
+ }
198
+ function renderTaskList(node, ctx, indent) {
199
+ return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
200
+ return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
201
+ }).join("\n");
202
+ }
203
+ function renderDecisionList(node, ctx, indent) {
204
+ return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
205
+ }
206
+ function renderCodeBlock(node, indent) {
207
+ const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
208
+ const code = (node.content ?? []).map((n) => n.text ?? "").join("");
209
+ const fence = "```";
210
+ return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
211
+ }
212
+ const PANEL_LABELS = {
213
+ info: "Info",
214
+ note: "Note",
215
+ warning: "Warning",
216
+ success: "Success",
217
+ error: "Error"
218
+ };
219
+ function renderPanel(node, ctx, indent) {
220
+ return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
221
+ }
222
+ function renderExpand(node, ctx, indent) {
223
+ return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
224
+ }
225
+ function renderTable(node, ctx) {
226
+ const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
227
+ if (rows.length === 0) return "";
228
+ const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCell(cell, ctx)));
229
+ const cols = Math.max(...grid.map((r) => r.length));
230
+ const pad = (r) => {
231
+ const copy = [...r];
232
+ while (copy.length < cols) copy.push("");
233
+ return copy;
234
+ };
235
+ const header = pad(grid[0] ?? []);
236
+ const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
237
+ for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
238
+ return lines.join("\n");
239
+ }
240
+ function renderCell(cell, ctx) {
241
+ return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
242
+ }
243
+ function renderMedia(node, ctx) {
244
+ const attrs = node.attrs ?? {};
245
+ const alt = attrs.alt ?? "";
246
+ const resolved = ctx.resolveMedia?.(attrs);
247
+ if (resolved) return `![${alt}](${resolved})`;
248
+ return `[embedded media: ${alt || attrs.id || "media"}]`;
249
+ }
250
+ function renderInline(nodes, ctx) {
251
+ return nodes.map((n) => renderInlineNode(n, ctx)).join("");
252
+ }
253
+ function renderInlineNode(node, ctx) {
254
+ switch (node.type) {
255
+ case "text": return applyMarks(node.text ?? "", node.marks ?? []);
256
+ case "hardBreak": return " \n";
257
+ case "mention": return (typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : "") || "@unknown";
258
+ case "emoji": {
259
+ const text = node.attrs?.["text"];
260
+ if (typeof text === "string" && text.length > 0) return text;
261
+ const short = node.attrs?.["shortName"];
262
+ return typeof short === "string" ? short : "";
263
+ }
264
+ case "date": return formatDate$1(node.attrs?.["timestamp"]);
265
+ case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
266
+ case "inlineCard": {
267
+ const url = node.attrs?.["url"];
268
+ if (typeof url === "string") return `[${url}](${url})`;
269
+ return "";
270
+ }
271
+ case "media": return renderMedia(node, ctx);
272
+ default: return node.text ?? "";
273
+ }
274
+ }
275
+ function applyMarks(text, marks) {
276
+ if (text.length === 0) return text;
277
+ let out = text;
278
+ let href;
279
+ for (const mark of marks) switch (mark.type) {
280
+ case "code":
281
+ out = `\`${out}\``;
282
+ break;
283
+ case "strong":
284
+ out = `**${out}**`;
285
+ break;
286
+ case "em":
287
+ out = `*${out}*`;
288
+ break;
289
+ case "strike":
290
+ out = `~~${out}~~`;
291
+ break;
292
+ case "link": {
293
+ const value = mark.attrs?.["href"];
294
+ if (typeof value === "string") href = value;
295
+ break;
296
+ }
297
+ default: break;
298
+ }
299
+ if (href) out = `[${out}](${href})`;
300
+ return out;
301
+ }
302
+ function prefixLines(text, prefix) {
303
+ return text.split("\n").map((line) => line.length > 0 ? prefix + line : prefix.trimEnd()).join("\n");
304
+ }
305
+ function clampLevel(value) {
306
+ const n = toNumber(value) ?? 1;
307
+ return Math.min(6, Math.max(1, n));
308
+ }
309
+ function toNumber(value) {
310
+ return typeof value === "number" ? value : void 0;
311
+ }
312
+ function formatDate$1(timestamp) {
313
+ const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
314
+ if (Number.isNaN(ms)) return "";
315
+ return new Date(ms).toISOString().slice(0, 10);
316
+ }
317
+ //#endregion
318
+ //#region src/api/attachments.ts
319
+ async function downloadAttachments(client, attachments, assetsDir, assetsDirName) {
320
+ if (attachments.length === 0) return [];
321
+ await mkdir(assetsDir, { recursive: true });
322
+ const used = /* @__PURE__ */ new Set();
323
+ const results = [];
324
+ for (const att of attachments) {
325
+ const savedAs = uniqueName(safeName(att.filename), used);
326
+ try {
327
+ const bytes = await client.getBinary(att.url);
328
+ await writeFile(join(assetsDir, savedAs), bytes);
329
+ results.push({
330
+ ...att,
331
+ relativePath: `${assetsDirName}/${savedAs}`
332
+ });
333
+ } catch (err) {
334
+ console.warn(` ! could not download ${att.filename}: ${err.message}`);
335
+ }
336
+ }
337
+ return results;
338
+ }
339
+ function safeName(name) {
340
+ return basename(name).replace(/[/\\]/g, "_") || "attachment";
341
+ }
342
+ function uniqueName(name, used) {
343
+ if (!used.has(name)) {
344
+ used.add(name);
345
+ return name;
346
+ }
347
+ const dot = name.lastIndexOf(".");
348
+ const stem = dot > 0 ? name.slice(0, dot) : name;
349
+ const ext = dot > 0 ? name.slice(dot) : "";
350
+ let i = 1;
351
+ let candidate = `${stem}-${i}${ext}`;
352
+ while (used.has(candidate)) {
353
+ i += 1;
354
+ candidate = `${stem}-${i}${ext}`;
355
+ }
356
+ used.add(candidate);
357
+ return candidate;
358
+ }
359
+ //#endregion
360
+ //#region src/api/confluence.ts
361
+ async function fetchPage(client, site, id) {
362
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
363
+ const names = new UserNames(client);
364
+ const [spaceKey, attachments, comments, author] = await Promise.all([
365
+ fetchSpaceKey(client, page.spaceId),
366
+ fetchAttachments(client, id),
367
+ fetchComments$1(client, id, names),
368
+ names.resolve(page.version?.authorId ?? page.authorId)
369
+ ]);
370
+ const webui = page._links?.webui ?? "";
371
+ return {
372
+ id: page.id,
373
+ title: page.title,
374
+ spaceKey,
375
+ version: page.version?.number ?? 0,
376
+ author,
377
+ createdAt: page.createdAt ?? "",
378
+ updatedAt: page.version?.createdAt ?? "",
379
+ url: webui ? `${site}/wiki${webui}` : "",
380
+ body: parseAdf(page.body?.atlas_doc_format?.value),
381
+ attachments,
382
+ comments
383
+ };
384
+ }
385
+ async function fetchSpaceKey(client, spaceId) {
386
+ if (!spaceId) return "";
387
+ try {
388
+ return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
389
+ } catch {
390
+ return "";
391
+ }
392
+ }
393
+ async function fetchAttachments(client, id) {
394
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
395
+ id: a.fileId ?? a.id,
396
+ filename: a.title ?? a.id,
397
+ url: normalizeDownloadLink(a.downloadLink ?? "")
398
+ }));
399
+ }
400
+ async function fetchComments$1(client, id, names) {
401
+ const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
402
+ return Promise.all(res.results.map(async (c) => ({
403
+ author: await names.resolve(c.version?.authorId),
404
+ created: c.version?.createdAt ?? "",
405
+ body: parseAdf(c.body?.atlas_doc_format?.value)
406
+ })));
407
+ }
408
+ function normalizeDownloadLink(link) {
409
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
410
+ return `/wiki${link}`;
411
+ }
412
+ function parseAdf(value) {
413
+ if (!value) return null;
414
+ try {
415
+ return JSON.parse(value);
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+ var UserNames = class {
421
+ client;
422
+ cache = /* @__PURE__ */ new Map();
423
+ constructor(client) {
424
+ this.client = client;
425
+ }
426
+ async resolve(accountId) {
427
+ if (!accountId) return "";
428
+ const cached = this.cache.get(accountId);
429
+ if (cached !== void 0) return cached;
430
+ let name = accountId;
431
+ try {
432
+ const user = await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`);
433
+ if (user.displayName) name = user.displayName;
434
+ } catch {}
435
+ this.cache.set(accountId, name);
436
+ return name;
437
+ }
438
+ };
439
+ //#endregion
440
+ //#region src/markdown/document.ts
441
+ function mediaResolver(downloaded) {
442
+ const byId = new Map(downloaded.map((d) => [d.id, d.relativePath]));
443
+ const byName = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
444
+ return (media) => {
445
+ if (media.id && byId.has(media.id)) return byId.get(media.id);
446
+ if (media.alt && byName.has(media.alt)) return byName.get(media.alt);
447
+ };
448
+ }
449
+ function frontmatter(fields) {
450
+ const lines = ["---"];
451
+ for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) if (value.length === 0) lines.push(`${key}: []`);
452
+ else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
453
+ else if (typeof value === "number") lines.push(`${key}: ${value}`);
454
+ else lines.push(`${key}: ${quote(value)}`);
455
+ lines.push("---");
456
+ return lines.join("\n");
457
+ }
458
+ function quote(value) {
459
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
460
+ }
461
+ function attachmentsSection(downloaded) {
462
+ if (downloaded.length === 0) return "";
463
+ return [
464
+ "## Attachments",
465
+ "",
466
+ ...downloaded.map((d) => `- [${d.filename}](${d.relativePath})`)
467
+ ].join("\n");
468
+ }
469
+ function commentsSection(comments, resolveMedia) {
470
+ if (comments.length === 0) return "";
471
+ return [
472
+ "## Comments",
473
+ "",
474
+ comments.map((c) => {
475
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDate(c.created)}` : ""}`;
476
+ const body = adfToMarkdown(c.body, { resolveMedia });
477
+ return body ? `${heading}\n\n${body}` : heading;
478
+ }).join("\n\n")
479
+ ].join("\n");
480
+ }
481
+ function formatDate(iso) {
482
+ const date = new Date(iso);
483
+ if (Number.isNaN(date.getTime())) return iso;
484
+ return date.toISOString().replace("T", " ").slice(0, 16);
485
+ }
486
+ function joinSections(sections) {
487
+ return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
488
+ }
489
+ //#endregion
490
+ //#region src/util/output-path.ts
491
+ function resolveOutput(defaultBase, out) {
492
+ let filePath;
493
+ if (!out) filePath = resolve(`${defaultBase}.md`);
494
+ else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
495
+ else filePath = resolve(out, `${defaultBase}.md`);
496
+ const dir = dirname(filePath);
497
+ const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
498
+ return {
499
+ filePath,
500
+ assetsDir: join(dir, assetsDirName),
501
+ assetsDirName
502
+ };
503
+ }
504
+ function slugify(title) {
505
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
506
+ }
507
+ //#endregion
508
+ //#region src/util/parse.ts
509
+ function parseIssueKey(input) {
510
+ const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
511
+ return match ? match[0] : null;
512
+ }
513
+ function parsePageId(input) {
514
+ const trimmed = input.trim();
515
+ if (/^\d+$/.test(trimmed)) return trimmed;
516
+ const fromPath = trimmed.match(/\/pages\/(\d+)/);
517
+ if (fromPath) return fromPath[1];
518
+ const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
519
+ if (fromQuery) return fromQuery[1];
520
+ return null;
521
+ }
522
+ //#endregion
523
+ //#region src/commands/confluence.ts
524
+ async function confluenceCopy(arg, options) {
525
+ const auth = await requireAuth();
526
+ const id = await resolveId(arg);
527
+ const client = new AtlassianClient(auth);
528
+ console.log(`Fetching page ${id} ...`);
529
+ const page = await fetchPage(client, auth.site, id);
530
+ const target = resolveOutput(`${page.id}-${slugify(page.title)}`, options.out);
531
+ const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
532
+ const resolveMedia = mediaResolver(downloaded);
533
+ const document = joinSections([
534
+ frontmatter({
535
+ title: page.title,
536
+ id: page.id,
537
+ space: page.spaceKey,
538
+ version: page.version,
539
+ author: page.author,
540
+ created: page.createdAt,
541
+ updated: page.updatedAt,
542
+ url: page.url
543
+ }),
544
+ `# ${page.title}`,
545
+ adfToMarkdown(page.body, { resolveMedia }),
546
+ commentsSection(page.comments, resolveMedia),
547
+ attachmentsSection(downloaded)
548
+ ]);
549
+ await writeFile(target.filePath, document, "utf8");
550
+ const suffix = downloaded.length > 0 ? ` (+${downloaded.length} attachment${downloaded.length === 1 ? "" : "s"})` : "";
551
+ console.log(`Wrote ${target.filePath}${suffix}`);
552
+ }
553
+ async function resolveId(arg) {
554
+ const raw = arg ?? await input({
555
+ message: "Confluence page id or URL:",
556
+ required: true
557
+ });
558
+ const id = parsePageId(raw);
559
+ if (!id) throw new Error(`Could not find a page id in "${raw}".`);
560
+ return id;
561
+ }
562
+ //#endregion
563
+ //#region src/api/jira.ts
564
+ const FIELDS = [
565
+ "summary",
566
+ "description",
567
+ "issuetype",
568
+ "status",
569
+ "assignee",
570
+ "reporter",
571
+ "priority",
572
+ "labels",
573
+ "created",
574
+ "updated",
575
+ "attachment"
576
+ ].join(",");
577
+ async function fetchIssue(client, site, key) {
578
+ const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
579
+ const comments = await fetchComments(client, key);
580
+ const f = issue.fields;
581
+ return {
582
+ key: issue.key,
583
+ url: `${site}/browse/${issue.key}`,
584
+ summary: f.summary ?? "",
585
+ type: f.issuetype?.name ?? "",
586
+ status: f.status?.name ?? "",
587
+ assignee: f.assignee?.displayName ?? "Unassigned",
588
+ reporter: f.reporter?.displayName ?? "",
589
+ priority: f.priority?.name ?? "",
590
+ labels: f.labels ?? [],
591
+ created: f.created ?? "",
592
+ updated: f.updated ?? "",
593
+ description: f.description ?? null,
594
+ comments,
595
+ attachments: (f.attachment ?? []).map((a) => ({
596
+ id: a.id,
597
+ filename: a.filename,
598
+ url: a.content
599
+ }))
600
+ };
601
+ }
602
+ async function fetchComments(client, key) {
603
+ return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
604
+ author: c.author?.displayName ?? "",
605
+ created: c.created ?? "",
606
+ body: c.body ?? null
607
+ }));
608
+ }
609
+ //#endregion
610
+ //#region src/commands/jira.ts
611
+ async function jiraCopy(arg, options) {
612
+ const auth = await requireAuth();
613
+ const key = await resolveKey(arg);
614
+ const client = new AtlassianClient(auth);
615
+ console.log(`Fetching ${key} ...`);
616
+ const issue = await fetchIssue(client, auth.site, key);
617
+ const target = resolveOutput(issue.key, options.out);
618
+ const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
619
+ const resolveMedia = mediaResolver(downloaded);
620
+ const document = joinSections([
621
+ frontmatter({
622
+ key: issue.key,
623
+ type: issue.type,
624
+ status: issue.status,
625
+ assignee: issue.assignee,
626
+ reporter: issue.reporter,
627
+ priority: issue.priority,
628
+ labels: issue.labels,
629
+ created: issue.created,
630
+ updated: issue.updated,
631
+ url: issue.url
632
+ }),
633
+ `# ${issue.summary}`,
634
+ adfToMarkdown(issue.description, { resolveMedia }),
635
+ commentsSection(issue.comments, resolveMedia),
636
+ attachmentsSection(downloaded)
637
+ ]);
638
+ await writeFile(target.filePath, document, "utf8");
639
+ report(target.filePath, downloaded.length);
640
+ }
641
+ async function resolveKey(arg) {
642
+ const raw = arg ?? await input({
643
+ message: "Jira issue key or URL:",
644
+ required: true
645
+ });
646
+ const key = parseIssueKey(raw);
647
+ if (!key) throw new Error(`Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`);
648
+ return key;
649
+ }
650
+ function report(filePath, assetCount) {
651
+ const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
652
+ console.log(`Wrote ${filePath}${suffix}`);
653
+ }
654
+ //#endregion
655
+ //#region src/cli.ts
656
+ const program = new Command();
657
+ program.name("atlass").description("Copy Jira issues and Confluence pages to Markdown.").version(version);
658
+ const auth = program.command("auth").description("Manage Atlassian credentials");
659
+ auth.command("login").description("Store site, email, and API token").action(run(login));
660
+ auth.command("logout").description("Remove stored credentials").action(run(logout));
661
+ auth.command("status").description("Show the current login").action(run(status));
662
+ program.command("jira").description("Jira commands").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));
663
+ program.command("confluence").description("Confluence commands").command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
664
+ program.parseAsync().catch(fail);
665
+ function run(fn) {
666
+ return async (...args) => {
667
+ try {
668
+ await fn(...args);
669
+ } catch (err) {
670
+ fail(err);
671
+ }
672
+ };
673
+ }
674
+ function fail(err) {
675
+ if (err instanceof Error && err.name === "ExitPromptError") process.exit(130);
676
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
677
+ process.exit(1);
678
+ }
679
+ //#endregion
680
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "atlass",
3
+ "version": "1.0.0",
4
+ "description": "CLI to copy Jira issues and Confluence pages to Markdown.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "atlass": "./dist/cli.mjs"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./dist/cli.mjs",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "vp pack",
22
+ "dev": "vp pack --watch",
23
+ "test": "vp test",
24
+ "check": "vp check",
25
+ "prepublishOnly": "vp run build",
26
+ "prepare": "vp config",
27
+ "release": "bumpp"
28
+ },
29
+ "dependencies": {
30
+ "@inquirer/prompts": "^8.5.2",
31
+ "@napi-rs/keyring": "^1.3.0",
32
+ "commander": "^15.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^25.6.2",
36
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
37
+ "bumpp": "^11.1.0",
38
+ "typescript": "^6.0.3",
39
+ "vite-plus": "^0.1.20"
40
+ },
41
+ "packageManager": "pnpm@11.9.0"
42
+ }