#!/usr/bin/env node import { basename, join } from "node:path"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { spawn } from "node:child_process"; const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5"; const DEFAULT_REPOSITORY = "t8y2/dbx"; const DEFAULT_CONCURRENCY = 3; function parseArgs(argv) { const args = { apiBase: process.env.ATOMGIT_API_BASE || DEFAULT_API_BASE, repository: process.env.ATOMGIT_REPOSITORY || DEFAULT_REPOSITORY, token: process.env.ATOMGIT_TOKEN || "", githubReleasePath: "", assetsDir: "", skipExistingAssets: true, concurrency: Number.parseInt(process.env.ATOMGIT_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10), }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--github-release") { args.githubReleasePath = argv[++i]; } else if (arg === "--assets-dir") { args.assetsDir = argv[++i]; } else if (arg === "--repository") { args.repository = argv[++i]; } else if (arg === "--api-base") { args.apiBase = argv[++i]; } else if (arg === "--replace-assets") { args.skipExistingAssets = false; } else { throw new Error(`Unknown argument: ${arg}`); } } if (!args.token) { throw new Error("ATOMGIT_TOKEN is required."); } if (!args.githubReleasePath || !args.assetsDir) { throw new Error("Usage: sync-atomgit-release.mjs --github-release --assets-dir "); } if (!args.repository.includes("/")) { throw new Error(`Invalid AtomGit repository: ${args.repository}`); } if (!Number.isInteger(args.concurrency) || args.concurrency < 1) { throw new Error("ATOMGIT_UPLOAD_CONCURRENCY must be a positive integer."); } return args; } async function main() { const args = parseArgs(process.argv.slice(2)); const [owner, repo] = args.repository.split("/", 2); const release = JSON.parse(readFileSync(args.githubReleasePath, "utf8")); const tag = release.tagName || release.tag_name; if (!tag) { throw new Error("GitHub release JSON is missing tagName."); } const client = new AtomGitClient({ apiBase: args.apiBase, token: args.token, owner, repo }); const atomRelease = await ensureRelease(client, release); const existingAssets = atomGitAssetNames(atomRelease); const assets = localAssets(args.assetsDir); if (assets.length === 0) { console.log("No release assets found to sync."); return; } const pendingAssets = assets.filter((assetPath) => { const name = basename(assetPath); if (args.skipExistingAssets && existingAssets.has(name)) { console.log(`Skipping existing AtomGit asset: ${name}`); return false; } return true; }); console.log(`Uploading ${pendingAssets.length} asset(s) with concurrency ${args.concurrency}.`); await mapWithConcurrency(pendingAssets, args.concurrency, (assetPath) => uploadAsset(client, tag, assetPath)); } async function ensureRelease(client, githubRelease) { const tag = githubRelease.tagName || githubRelease.tag_name; const payload = { tag_name: tag, name: githubRelease.name || tag, body: githubRelease.body || "", target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "", prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease), }; const existing = await client.getRelease(tag); if (existing) { console.log(`Updating existing AtomGit release: ${tag}`); return client.updateRelease(tag, payload); } console.log(`Creating AtomGit release: ${tag}`); return client.createRelease(payload); } async function uploadAsset(client, tag, filePath) { const name = basename(filePath); const size = statSync(filePath).size; console.log(`Uploading ${name} (${size} bytes)`); const uploadTarget = await client.getUploadTarget(tag, name); const contentType = contentTypeFor(name); const uploadHeaders = uploadTarget.headers.length > 0 ? uploadTarget.headers : [ ["Authorization", `Bearer ${client.token}`], ["X-Api-Version", "2023-02-21"], ]; const contentTypeHeader = headerValue(uploadHeaders, "Content-Type") || contentType; const curlHeaders = uploadHeaders .filter(([key]) => key.toLowerCase() !== "content-type") .flatMap(([key, value]) => ["--header", `${key}: ${value}`]); // AtomGit returns a per-file upload URL plus required upload headers. // Try raw PUT first and fall back to multipart POST for API-compatible deployments. const putStatus = await runCurl([ "--fail-with-body", "--location", "--show-error", "--retry", "3", "--retry-delay", "5", "--request", "PUT", ...curlHeaders, "--header", `Content-Type: ${contentTypeHeader}`, "--upload-file", filePath, uploadTarget.url, ]); if (putStatus === 0) { return; } console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`); const postStatus = await runCurl([ "--fail-with-body", "--location", "--show-error", "--retry", "3", "--retry-delay", "5", "--request", "POST", ...curlHeaders, "--form", `file=@${filePath};filename=${name};type=${contentTypeHeader}`, uploadTarget.url, ]); if (postStatus !== 0) { throw new Error(`Failed to upload ${name} to AtomGit.`); } } function runCurl(args) { return new Promise((resolve, reject) => { const child = spawn("curl", args, { stdio: "inherit" }); child.once("error", reject); child.once("close", (code) => resolve(code ?? 1)); }); } async function mapWithConcurrency(items, concurrency, worker) { let nextIndex = 0; async function runWorker() { while (nextIndex < items.length) { const index = nextIndex++; await worker(items[index]); } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker)); } function localAssets(dir) { return readdirSync(dir) .map((name) => join(dir, name)) .filter((path) => statSync(path).isFile()) .sort((a, b) => statSync(a).size - statSync(b).size || basename(a).localeCompare(basename(b))); } function atomGitAssetNames(release) { const names = new Set(); const groups = [ release?.assets, release?.attach_files, release?.attachFiles, release?.files, release?.attachments, ]; for (const group of groups) { if (!Array.isArray(group)) { continue; } for (const item of group) { const name = item?.name || item?.file_name || item?.filename || item?.title; if (name) { names.add(name); } } } return names; } function contentTypeFor(name) { if (name.endsWith(".json")) return "application/json"; if (name.endsWith(".zip")) return "application/zip"; if (name.endsWith(".gz") || name.endsWith(".tgz")) return "application/gzip"; if (name.endsWith(".dmg")) return "application/x-apple-diskimage"; if (name.endsWith(".exe") || name.endsWith(".msi")) return "application/octet-stream"; if (name.endsWith(".deb")) return "application/vnd.debian.binary-package"; if (name.endsWith(".rpm")) return "application/x-rpm"; if (name.endsWith(".AppImage")) return "application/octet-stream"; return "application/octet-stream"; } function headerValue(headers, name) { const normalizedName = name.toLowerCase(); const entry = headers.find(([key]) => key.toLowerCase() === normalizedName); return entry ? entry[1] : ""; } class AtomGitClient { constructor({ apiBase, token, owner, repo }) { this.apiBase = apiBase.replace(/\/+$/, ""); this.token = token; this.owner = owner; this.repo = repo; } async getRelease(tag) { const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, null, { allow404: true, }); if (res.status === 404) { return null; } return parseJsonResponse(res); } async createRelease(payload) { const res = await this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, payload, { acceptStatuses: [409, 422], }); if (res.status === 409 || res.status === 422) { return this.updateRelease(payload.tag_name, payload); } return parseJsonResponse(res); } async updateRelease(tag, payload) { const res = await this.request("PATCH", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, payload); return parseJsonResponse(res); } async getUploadTarget(tag, fileName) { const params = new URLSearchParams({ file_name: fileName }); const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}/upload_url?${params}`); const body = await parseJsonResponse(res); if (body && typeof body === "object" && !Array.isArray(body)) { console.log(`AtomGit upload_url response keys: ${Object.keys(body).sort().join(", ")}`); if (body.headers && typeof body.headers === "object") { console.log(`AtomGit upload header keys: ${Object.keys(body.headers).sort().join(", ")}`); } } const uploadUrl = typeof body === "string" ? body : body.url || body.upload_url || body.uploadUrl; if (!uploadUrl) { throw new Error(`AtomGit upload_url response did not include an upload URL: ${JSON.stringify(body)}`); } console.log(`AtomGit upload target: ${sanitizeUrlForLog(uploadUrl)}`); return { url: uploadUrl, headers: normalizeUploadHeaders(typeof body === "string" ? null : body.headers), }; } async request(method, path, body = null, requestOptions = {}) { const headers = { Accept: "application/json", Authorization: `Bearer ${this.token}`, "X-Api-Version": "2023-02-21", }; const fetchOptions = { method, headers }; if (body) { const cleanBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== "")); headers["Content-Type"] = "application/json"; fetchOptions.body = JSON.stringify(cleanBody); } const res = await fetch(`${this.apiBase}${path}`, fetchOptions); const acceptedStatus = requestOptions.acceptStatuses?.includes(res.status); if (!res.ok && !(requestOptions.allow404 && res.status === 404) && !acceptedStatus) { const text = await res.text(); throw new Error(`AtomGit API ${method} ${path} failed with ${res.status}: ${text}`); } return res; } } async function parseJsonResponse(res) { const text = await res.text(); if (!text.trim()) { return {}; } try { return JSON.parse(text); } catch { return text; } } function normalizeUploadHeaders(headers) { if (!headers || typeof headers !== "object" || Array.isArray(headers)) { return []; } return Object.entries(headers) .filter(([key, value]) => key && value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, String(value)]); } function sanitizeUrlForLog(value) { try { const url = new URL(value); return `${url.origin}${url.pathname}`; } catch { return ""; } } main().catch((error) => { console.error(error); process.exit(1); });