#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); const MUTATIONS = new Set(["POST", "PUT", "PATCH", "DELETE"]); export function parseArgs(argv) { const args = [...argv]; let method = "GET"; if (METHODS.has(args[0]?.toUpperCase())) method = args.shift().toUpperCase(); const options = { method, path: undefined, bodyFile: undefined, profile: undefined, paginate: false, maxPages: 100, allowMutation: false, }; while (args.length) { const arg = args.shift(); switch (arg) { case "--body": options.bodyFile = requiredValue(arg, args.shift()); break; case "--profile": options.profile = requiredValue(arg, args.shift()); break; case "--paginate": options.paginate = true; break; case "--max-pages": { const value = Number(requiredValue(arg, args.shift())); if (!Number.isSafeInteger(value) || value < 1) throw new Error("--max-pages must be a positive integer"); options.maxPages = value; break; } case "--allow-mutation": options.allowMutation = true; break; case "--help": options.help = true; break; default: if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`); if (options.path) throw new Error(`Unexpected argument: ${arg}`); options.path = arg; } } if (options.help) return options; if (!options.path) throw new Error("Request path is required"); if (MUTATIONS.has(method) && !options.allowMutation) throw new Error(`${method} requires --allow-mutation`); if (options.bodyFile && method === "GET") throw new Error("GET does not accept --body"); if (options.paginate && method !== "GET") throw new Error("--paginate requires GET"); return options; } function requiredValue(option, value) { if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); return value; } export function selectConnection(profiles, profileName, env = process.env) { const selected = profileName ? profiles.find((profile) => profile.name === profileName) : profiles.find((profile) => profile.is_default) ?? profiles[0]; if (profileName && !selected && (!env.ATLASSIAN_BASE_URL || !env.ATLASSIAN_EMAIL)) { throw new Error(`Unknown Atlassian profile: ${profileName}`); } const baseUrl = env.ATLASSIAN_BASE_URL ?? selected?.base_url; const email = env.ATLASSIAN_EMAIL ?? selected?.email; if (!baseUrl || !email) throw new Error("Atlassian base URL and email are unavailable"); return { baseUrl, email }; } export function resolveRequestUrl(path, baseUrl) { const base = new URL(baseUrl); if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(base.hostname)) { throw new Error(`Refusing non-HTTPS Atlassian base URL: ${base.origin}`); } const url = new URL(path, base); if (url.origin !== base.origin) throw new Error(`Refusing to send Atlassian credentials to ${url.origin}`); return url; } export function nextPageUrl(body, currentUrl) { const linked = body?._links?.next ?? body?.links?.next ?? body?.nextPage ?? (typeof body?.next === "string" ? body.next : undefined); if (linked) return new URL(linked, currentUrl); if (Number.isFinite(body?.startAt) && Number.isFinite(body?.maxResults) && Number.isFinite(body?.total)) { const startAt = body.startAt + body.maxResults; if (startAt < body.total) { const next = new URL(currentUrl); next.searchParams.set("startAt", String(startAt)); return next; } } if (body?.nextPageToken && body?.isLast !== true) { const next = new URL(currentUrl); next.searchParams.set("nextPageToken", body.nextPageToken); return next; } return undefined; } export async function requestJson(url, init, fetchImpl = fetch) { const response = await fetchImpl(url, { ...init, redirect: "manual" }); const text = await response.text(); if (response.status >= 300 && response.status < 400) { throw new Error(`${init.method} ${url}: redirect ${response.status} refused`); } if (!response.ok) throw new Error(`${init.method} ${url}: HTTP ${response.status}${text ? `\n${text}` : ""}`); if (!text) return null; try { return JSON.parse(text); } catch { return text; } } export async function runRequest(options, connection, token, fetchImpl = fetch) { if (!token) throw new Error("ATLASSIAN_API_TOKEN or JIRA_API_TOKEN is required"); const firstUrl = resolveRequestUrl(options.path, connection.baseUrl); const headers = { Accept: "application/json", Authorization: `Basic ${Buffer.from(`${connection.email}:${token}`).toString("base64")}`, }; let body; if (options.bodyFile) { const input = await readFile(options.bodyFile, "utf8"); body = JSON.stringify(JSON.parse(input)); headers["Content-Type"] = "application/json"; } const init = { method: options.method, headers, body }; if (!options.paginate) return requestJson(firstUrl, init, fetchImpl); const pages = []; let url = firstUrl; for (let page = 1; ; page++) { pages.push(await requestJson(url, init, fetchImpl)); const next = nextPageUrl(pages.at(-1), url); if (!next) return pages; if (page >= options.maxPages) throw new Error(`Pagination exceeded --max-pages ${options.maxPages}`); url = resolveRequestUrl(next.href, connection.baseUrl); } } function loadProfiles() { const result = spawnSync("atlassian-cli", ["auth", "list", "--format", "json"], { encoding: "utf8", windowsHide: true, }); if (result.status !== 0) throw new Error(result.stderr.trim() || "atlassian-cli auth list failed"); return JSON.parse(result.stdout); } function usage() { return `Usage: node atlassian-request.mjs [METHOD] PATH [options] METHOD defaults to GET. PATH must resolve to the configured Atlassian origin. On Windows Bash, use relative paths without a leading slash. Options: --body FILE JSON request body --profile NAME atlassian-cli profile --paginate fetch all GET pages and output an array of responses --max-pages NUMBER pagination limit, default 100 --allow-mutation required for POST, PUT, PATCH, and DELETE --help show this help`; } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { console.log(usage()); return; } const profiles = process.env.ATLASSIAN_BASE_URL && process.env.ATLASSIAN_EMAIL ? [] : loadProfiles(); const connection = selectConnection(profiles, options.profile); const token = process.env.ATLASSIAN_API_TOKEN ?? process.env.JIRA_API_TOKEN; const result = await runRequest(options, connection, token); console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)); } if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { main().catch((error) => { console.error(error.message); process.exit(1); }); }