#!/usr/bin/env node // tools/sync-cli/src/cli.js import { createHmac } from "node:crypto"; import { writeFile } from "node:fs/promises"; // src/domain/hash.js function fnv1a(text) { let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619) >>> 0; } return hash.toString(16).padStart(8, "0"); } function canonicalJson(value) { if (value === null || typeof value !== "object") { return JSON.stringify(value === void 0 ? null : value); } if (Array.isArray(value)) { return `[${value.map(canonicalJson).join(",")}]`; } const keys = Object.keys(value).filter((key) => value[key] !== void 0).sort(); const body = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`); return `{${body.join(",")}}`; } // src/domain/steps.js var GATES = /* @__PURE__ */ new Set(["CONDITION", "CONDITION_BLOCK"]); var LOG_ACTION_TYPES = /* @__PURE__ */ new Set(["codebarrel.action.log"]); var SCHEDULED_TRIGGERS = /* @__PURE__ */ new Set(["jira.jql.scheduled", "jira.scheduled.trigger"]); var DAYS_PER_MONTH = 30.44; var WEEKS_PER_MONTH = DAYS_PER_MONTH / 7; function costProfile(trigger, components) { const top = Array.isArray(components) ? components.filter((c) => c && typeof c === "object") : []; const triggerConditions = countAll(Array.isArray(trigger?.conditions) ? trigger.conditions : []); const total = countAll(top); let branches = 0; let logActions = 0; walk(top, (component) => { if (component.component === "BRANCH") branches += 1; if (LOG_ACTION_TYPES.has(component.type)) logActions += 1; }); const gateIndex = top.findIndex((c) => GATES.has(c.component)); const gate = gateIndex >= 0 ? top[gateIndex].component : null; const perRunMax = 1 + triggerConditions + total; let perRunMin = perRunMax; if (gateIndex >= 0) { const before = countAll(top.slice(0, gateIndex)); const gateCost = gate === "CONDITION_BLOCK" ? 2 : 1; perRunMin = Math.min(perRunMax, 1 + triggerConditions + before + gateCost); } const type = String(trigger?.type ?? ""); const value = trigger?.value && typeof trigger.value === "object" ? trigger.value : {}; const schedule = SCHEDULED_TRIGGERS.has(type) ? parseSchedule(value.schedule) : null; const jql = typeof value.jql === "string" ? value.jql.trim() : ""; const perIssueJql = SCHEDULED_TRIGGERS.has(type) && jql && String(value.executionMode ?? "").toLowerCase() !== "nosearch" ? jql.slice(0, 2e3) : null; return { perRunMin, perRunMax, gate, gateIndex, branches, logActions, schedule, perIssueJql }; } function countAll(list) { let n = 0; walk(list, () => { n += 1; }); return n; } function walk(list, visit) { for (const component of list) { if (!component || typeof component !== "object") continue; visit(component); walk(Array.isArray(component.conditions) ? component.conditions : [], visit); walk(Array.isArray(component.children) ? component.children : [], visit); } } function parseSchedule(schedule) { if (!schedule || typeof schedule !== "object") return null; const rrule = typeof schedule.rRule === "string" ? schedule.rRule : ""; if (/RRULE:/i.test(rrule)) { const runs = runsFromRRule(rrule); if (runs !== null) return { runsPerMonth: round(runs), source: "rrule" }; } const cron = typeof schedule.cronExpression === "string" ? schedule.cronExpression.trim() : ""; if (cron) { const runs = runsFromCron(cron); if (runs !== null) return { runsPerMonth: round(runs), source: "cron" }; } const interval = Number(schedule.rateInterval); if (Number.isFinite(interval) && interval > 0) { return { runsPerMonth: round(DAYS_PER_MONTH * 86400 / interval), source: "interval" }; } return null; } function runsFromRRule(text) { const line = String(text).split(/\r?\n/).find((l) => /^RRULE:/i.test(l.trim())); if (!line) return null; const parts = Object.fromEntries( line.trim().replace(/^RRULE:/i, "").split(";").map((p) => p.split("=")).filter((p) => p.length === 2).map(([k, v]) => [k.toUpperCase(), v]) ); const freq = (parts.FREQ ?? "").toUpperCase(); const interval = Math.max(1, Number(parts.INTERVAL) || 1); const count = (key) => parts[key] ? parts[key].split(",").filter(Boolean).length : 0; const days = count("BYDAY"); const hours = Math.max(1, count("BYHOUR")); const minutes = Math.max(1, count("BYMINUTE")); const dayShare = days ? days / 7 : 1; switch (freq) { case "MINUTELY": return DAYS_PER_MONTH * 24 * 60 / interval * dayShare; case "HOURLY": return DAYS_PER_MONTH * 24 / interval * dayShare * minutes; case "DAILY": return DAYS_PER_MONTH / interval * dayShare * hours * minutes; case "WEEKLY": return WEEKS_PER_MONTH / interval * Math.max(1, days) * hours * minutes; case "MONTHLY": return 1 / interval * Math.max(1, count("BYMONTHDAY") || days) * hours * minutes; case "YEARLY": return 1 / 12 / interval; default: return null; } } function runsFromCron(expression) { const fields = String(expression).trim().split(/\s+/); if (fields.length < 6) return null; const [sec, min, hour, dom, month, dow] = fields; const perDay = cardinality(sec, 0, 59) * cardinality(min, 0, 59) * cardinality(hour, 0, 23); if (!perDay) return null; const months = cardinality(month, 1, 12); const monthShare = months ? months / 12 : 1; let daysPerMonth = DAYS_PER_MONTH; if (isRestricted(dow)) daysPerMonth = cardinality(dow, 1, 7) * WEEKS_PER_MONTH; else if (isRestricted(dom)) daysPerMonth = cardinality(dom, 1, 31); return perDay * daysPerMonth * monthShare; } function isRestricted(field) { return field !== "*" && field !== "?"; } function cardinality(field, lo, hi) { if (field === "*" || field === "?") return hi - lo + 1; let total = 0; for (const part of String(field).split(",")) { if (/[LW#]/i.test(part)) { total += 1; continue; } const [range, stepText] = part.split("/"); const step = stepText ? Number(stepText) : 1; if (!Number.isFinite(step) || step <= 0) return 0; let start = lo; let end = hi; if (range !== "*" && range !== "?") { const bounds = range.split("-"); const asNumber = (v) => /^\d+$/.test(v) ? Number(v) : NaN; if (bounds.length === 2) { start = asNumber(bounds[0]); end = asNumber(bounds[1]); if (Number.isNaN(start) || Number.isNaN(end)) { const span = nameSpan(bounds[0], bounds[1]); if (!span) return 0; total += Math.floor((span - 1) / step) + 1; continue; } } else { start = asNumber(range); if (Number.isNaN(start)) { if (!/^[A-Za-z]{3}$/.test(range)) return 0; total += 1; continue; } end = stepText ? hi : start; } } if (end < start) return 0; total += Math.floor((end - start) / step) + 1; } return total; } var DAY_NAMES = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; var MONTH_NAMES = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; function nameSpan(a, b) { for (const names of [DAY_NAMES, MONTH_NAMES]) { const i = names.indexOf(String(a).toUpperCase()); const j = names.indexOf(String(b).toUpperCase()); if (i >= 0 && j >= i) return j - i + 1; } return 0; } function round(value) { return Math.round(value * 100) / 100; } // src/domain/normalize.js var ExportError = class extends Error { constructor(code, message) { super(message); this.name = "ExportError"; this.code = code; } }; var TRIGGERS = { "jira.issue.event.trigger:created": { slug: "issueCreated", label: "Issue created" }, "jira.issue.event.trigger:updated": { slug: "issueUpdated", label: "Issue updated" }, "jira.issue.event.trigger:transitioned": { slug: "issueTransitioned", label: "Issue transitioned" }, "jira.issue.event.trigger:commented": { slug: "issueCommented", label: "Issue commented" }, "jira.issue.event.trigger:deleted": { slug: "issueDeleted", label: "Issue deleted" }, "jira.issue.event.trigger:assigned": { slug: "issueAssigned", label: "Issue assigned" }, "jira.issue.event.trigger:linked": { slug: "issueLinked", label: "Issue linked" }, "jira.issue.field.changed": { slug: "fieldChanged", label: "Field value changed" }, "jira.manual.trigger.issue": { slug: "manual", label: "Manual trigger" }, "jira.scheduled.trigger": { slug: "scheduled", label: "Scheduled" }, "jira.incoming.webhook": { slug: "incomingWebhook", label: "Incoming webhook" }, "jira.sla.threshold.trigger": { slug: "slaThreshold", label: "SLA threshold breached" }, "jira.approval.completed.trigger": { slug: "approvalCompleted", label: "Approval completed" }, "jira.proforma.form.submitted.trigger": { slug: "formSubmitted", label: "Form submitted" }, "cmdb.object.trigger": { slug: "assetsObjectChanged", label: "Assets object changed" }, "devops.commit.event.trigger:created": { slug: "commitCreated", label: "Commit created" }, "devops.branch.event.trigger:created": { slug: "branchCreated", label: "Branch created" }, "devops.pullrequest.event.trigger:created": { slug: "pullRequestCreated", label: "Pull request created" }, "devops.pullrequest.event.trigger:merged": { slug: "pullRequestMerged", label: "Pull request merged" }, "devops.build.event.trigger:statechange": { slug: "buildStatusChanged", label: "Build status changed" }, "devops.deploy.event.trigger:statechange": { slug: "deploymentStatusChanged", label: "Deployment status changed" } }; var PROJECT_ARI = /:project\/(\d+)$/; var SITE_ARI = /::site\/([0-9a-fA-F-]+)$/; var IDENTITY_KEYS = /* @__PURE__ */ new Set([ "id", "parentId", "conditionParentId", "checksum", "connectionId", "schemaVersion" ]); var SCOPE_VALUE_KEYS = /* @__PURE__ */ new Set(["eventFilters"]); var MAX_RULES_PER_UPLOAD = 5e3; function parseExport(input) { const doc = toObject(input); const rawRules = extractRules(doc); if (rawRules.length === 0) { throw new ExportError("NO_RULES", "The export does not contain any rules."); } if (rawRules.length > MAX_RULES_PER_UPLOAD) { throw new ExportError( "TOO_MANY_RULES", `The export contains ${rawRules.length} rules; the limit per upload is ${MAX_RULES_PER_UPLOAD}. Export rules in segments.` ); } const warnings = []; const rules = rawRules.map((raw, index) => normalizeRule(raw, index, warnings)); const seen = /* @__PURE__ */ new Set(); for (const rule of rules) { if (seen.has(rule.id)) { warnings.push(`Duplicate rule id "${rule.id}" in export; the last occurrence wins.`); } seen.add(rule.id); } return { rules, ruleCount: rules.length, connectionCount: Array.isArray(doc?.connections) ? doc.connections.length : 0, cloudId: detectCloudId(rawRules), warnings }; } function toObject(input) { if (typeof input !== "string") { return input; } const text = input.trim(); if (!text) { throw new ExportError("EMPTY", "Paste the contents of an Automation export first."); } try { return JSON.parse(text); } catch (error) { throw new ExportError("INVALID_JSON", "The pasted text is not valid JSON."); } } function extractRules(doc) { if (Array.isArray(doc)) { return doc.filter(looksLikeRule); } if (doc && typeof doc === "object") { if (Array.isArray(doc.rules)) { return doc.rules.filter(looksLikeRule); } if (doc.rule && looksLikeRule(doc.rule)) { return [doc.rule]; } if (looksLikeRule(doc)) { return [doc]; } } throw new ExportError( "UNRECOGNISED", 'Unrecognised format. Expected a Jira Automation export with a "rules" array.' ); } function looksLikeRule(value) { return Boolean( value && typeof value === "object" && typeof value.name === "string" && (value.trigger || Array.isArray(value.components)) ); } function normalizeRule(raw, index, warnings = []) { const id = String(raw.idUuid ?? raw.uuid ?? raw.id ?? `index-${index}`); const components = Array.isArray(raw.components) ? raw.components : []; const trigger = raw.trigger && typeof raw.trigger === "object" ? raw.trigger : null; if (!trigger) { warnings.push(`Rule "${raw.name}" has no trigger.`); } const stats = { actions: 0, conditions: 0, branches: 0, depth: 0 }; const componentTypes = []; let usesConnections = Boolean(trigger?.connectionId); walkComponents(components, 1, (component, depth) => { const kind = String(component.component ?? "UNKNOWN"); componentTypes.push(`${kind}:${component.type ?? "unknown"}`); if (kind === "ACTION") stats.actions += 1; else if (kind === "CONDITION" || kind === "CONDITION_BLOCK") stats.conditions += 1; else if (kind === "BRANCH") stats.branches += 1; if (depth > stats.depth) stats.depth = depth; if (component.connectionId) usesConnections = true; }); const triggerType = String(trigger?.type ?? "unknown"); return { id, numericId: typeof raw.id === "number" ? raw.id : null, name: String(raw.name ?? "(unnamed rule)"), description: typeof raw.description === "string" ? raw.description : "", state: normalizeState(raw.state), scope: parseScope(raw), trigger: { type: triggerType, label: triggerLabel(triggerType) }, stats, componentTypes, labels: normalizeLabels(raw.labels), authorAccountId: raw.authorAccountId ? String(raw.authorAccountId) : null, actorAccountId: actorId(raw.actor), created: toIso(raw.created), updated: toIso(raw.updated), notifyOnError: raw.notifyOnError ? String(raw.notifyOnError) : null, writeAccessType: raw.writeAccessType ? String(raw.writeAccessType) : null, canOtherRuleTrigger: Boolean(raw.canOtherRuleTrigger), usesConnections, fingerprint: fingerprintLogic(trigger, components), cost: costProfile(trigger, components) }; } function walkComponents(list, depth, visit) { for (const component of list) { if (!component || typeof component !== "object") continue; visit(component, depth); walkComponents(Array.isArray(component.children) ? component.children : [], depth + 1, visit); walkComponents(Array.isArray(component.conditions) ? component.conditions : [], depth + 1, visit); } } function normalizeState(state) { const value = String(state ?? "").toUpperCase(); return value === "ENABLED" || value === "DISABLED" ? value : "UNKNOWN"; } function normalizeLabels(labels) { if (!Array.isArray(labels)) return []; return labels.map((label) => typeof label === "string" ? label : label?.name ?? label?.id).filter((label) => label !== void 0 && label !== null).map(String); } function actorId(actor) { if (!actor) return null; if (typeof actor === "string") return actor; return actor.value ? String(actor.value) : actor.actor ? String(actor.actor) : null; } function parseScope(raw) { const aris = [].concat(raw.ruleScope?.resources ?? []).concat(raw.ruleScopeARIs ?? []).filter((ari) => typeof ari === "string"); const projectIds = aris.map((ari) => ari.match(PROJECT_ARI)?.[1]).filter(Boolean); for (const project of Array.isArray(raw.projects) ? raw.projects : []) { const id = project?.projectId ?? project?.id; if (id !== void 0 && id !== null) projectIds.push(String(id)); } const unique = [...new Set(projectIds)]; if (unique.length === 1) return { type: "PROJECT", projectIds: unique }; if (unique.length > 1) return { type: "MULTI_PROJECT", projectIds: unique }; if (aris.some((ari) => SITE_ARI.test(ari))) return { type: "GLOBAL", projectIds: [] }; if (aris.length === 0 && Array.isArray(raw.projects)) return { type: "GLOBAL", projectIds: [] }; return { type: "UNKNOWN", projectIds: [] }; } var CLOUD_ID_IN_ARI = /^ari:cloud:[a-z-]+:([0-9a-fA-F-]{36}):|::site\/([0-9a-fA-F-]{36})$/; function detectCloudId(rawRules) { for (const raw of rawRules) { const aris = [].concat(raw.ruleScope?.resources ?? [], raw.ruleScopeARIs ?? []); for (const ari of aris) { const match = typeof ari === "string" ? ari.match(CLOUD_ID_IN_ARI) : null; if (match) return match[1] ?? match[2]; } } return null; } function triggerLabel(type) { if (TRIGGERS[type]) return TRIGGERS[type].label; return type.replace(/^(jira|devops|cmdb|codebarrel)\./, "").replace(/\.trigger/, "").replace(/[.:]/g, " ").trim() || "unknown"; } function toIso(value) { if (value === null || value === void 0 || value === "") return null; const date = typeof value === "number" ? new Date(value) : new Date(String(value)); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function fingerprintLogic(trigger, components) { const logic = { trigger: trigger ? stripIdentity(trigger) : null, components: components.map(stripIdentity) }; return fnv1a(canonicalJson(logic)); } function stripIdentity(node) { if (Array.isArray(node)) return node.map(stripIdentity); if (!node || typeof node !== "object") return node; const out = {}; for (const [key, value] of Object.entries(node)) { if (IDENTITY_KEYS.has(key) || SCOPE_VALUE_KEYS.has(key)) continue; out[key] = stripIdentity(value); } return out; } // src/domain/sync-protocol.js var PROTOCOL_VERSION = 1; var HEADERS = { version: "x-rulekeeper-version", timestamp: "x-rulekeeper-timestamp", signature: "x-rulekeeper-signature" }; var SIGNATURE_PREFIX = "v1="; var MAX_BODY_BYTES = 4 * 1024 * 1024; var SYNC_SOURCE = "SyncCliSource"; function signingInput(timestamp, body) { return `${timestamp}.${body}`; } function buildSyncBody({ rules, warnings = [], site = null, cloudId = null, generatedAt = /* @__PURE__ */ new Date() }) { return JSON.stringify({ version: PROTOCOL_VERSION, source: SYNC_SOURCE, site, cloudId, generatedAt: generatedAt.toISOString(), rules, warnings }); } // tools/sync-cli/src/automation-api.js var DEFAULT_PAGE_SIZE = 100; var MAX_PAGES = 1e3; var AutomationApiError = class extends Error { constructor(message, { status = null, url = null } = {}) { super(message); this.name = "AutomationApiError"; this.status = status; this.url = url; } }; function basicAuth(email, token) { return `Basic ${Buffer.from(`${email}:${token}`, "utf8").toString("base64")}`; } function apiBase(cloudId) { return `https://api.atlassian.com/automation/public/jira/${cloudId}/rest/v1`; } async function getCloudId(site, { fetchImpl = fetch } = {}) { const url = `https://${site}/_edge/tenant_info`; const response = await fetchImpl(url, { headers: { Accept: "application/json" } }); if (!response.ok) { throw new AutomationApiError(`Could not resolve cloud id for ${site} (HTTP ${response.status})`, { status: response.status, url }); } const json = await response.json(); if (!json?.cloudId) throw new AutomationApiError(`tenant_info for ${site} has no cloudId`, { url }); return json.cloudId; } async function fetchWithRetry(url, init, { fetchImpl = fetch, retries = 5, sleep = defaultSleep, log = () => { } } = {}) { let attempt = 0; for (; ; ) { const response = await fetchImpl(url, init); const retryable = response.status === 429 || response.status >= 500; if (!retryable || attempt >= retries) return response; const retryAfter = Number(response.headers?.get?.("retry-after")); const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : Math.min(3e4, 1e3 * 2 ** attempt); log(`HTTP ${response.status} from ${shortUrl(url)}; retrying in ${Math.round(delayMs / 1e3)}s`); await sleep(delayMs); attempt += 1; } } async function listRuleSummaries({ cloudId, auth, fetchImpl = fetch, log = () => { }, pageSize = DEFAULT_PAGE_SIZE, sleep }) { const base = apiBase(cloudId); const summaries = []; const seenCursors = /* @__PURE__ */ new Set(); let url = `${base}/rule/summary?limit=${pageSize}`; for (let page = 0; page < MAX_PAGES && url; page += 1) { const response = await fetchWithRetry(url, { headers: { Accept: "application/json", Authorization: auth } }, { fetchImpl, log, sleep }); if (!response.ok) { throw new AutomationApiError(await describeFailure(response, url), { status: response.status, url }); } const json = await response.json(); const data = Array.isArray(json?.data) ? json.data : []; summaries.push(...data); log(`page ${page + 1}: ${data.length} rule summaries (total ${summaries.length})`); url = nextPageUrl(base, json?.links?.next, seenCursors); if (data.length === 0) break; } return summaries; } function nextPageUrl(base, next, seenCursors = /* @__PURE__ */ new Set()) { if (!next || typeof next !== "string") return null; let url; if (/^https?:\/\//.test(next)) url = next; else if (next.startsWith("?")) url = `${base}/rule/summary${next}`; else if (next.startsWith("/")) url = `https://api.atlassian.com${next}`; else url = `${base}/rule/summary?${next.replace(/^&/, "")}`; const cursor = new URL(url).searchParams.get("cursor"); if (!cursor || seenCursors.has(cursor)) return null; seenCursors.add(cursor); return url; } async function getRule({ cloudId, uuid, auth, fetchImpl = fetch, log = () => { }, sleep }) { const url = `${apiBase(cloudId)}/rule/${encodeURIComponent(uuid)}`; const response = await fetchWithRetry(url, { headers: { Accept: "application/json", Authorization: auth } }, { fetchImpl, log, sleep }); if (!response.ok) { throw new AutomationApiError(await describeFailure(response, url), { status: response.status, url }); } const json = await response.json(); return json?.rule && typeof json.rule === "object" ? json.rule : json; } async function fetchAllRules({ site, email, token, cloudId = null, concurrency = 4, fetchImpl = fetch, log = () => { }, sleep }) { const auth = basicAuth(email, token); const resolvedCloudId = cloudId ?? await getCloudId(site, { fetchImpl }); log(`cloud id: ${resolvedCloudId}`); const summaries = await listRuleSummaries({ cloudId: resolvedCloudId, auth, fetchImpl, log, sleep }); const rules = new Array(summaries.length); let next = 0; let done = 0; const worker = async () => { while (next < summaries.length) { const index = next; next += 1; const summary = summaries[index]; rules[index] = await getRule({ cloudId: resolvedCloudId, uuid: summary.uuid, auth, fetchImpl, log, sleep }); done += 1; if (done % 25 === 0 || done === summaries.length) log(`fetched ${done}/${summaries.length} rules`); } }; await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, summaries.length || 1)) }, worker)); return { cloudId: resolvedCloudId, rules, summaries }; } async function describeFailure(response, url) { let detail = ""; try { detail = (await response.text()).slice(0, 300); } catch (error) { detail = ""; } const hints = { 401: "check ATLASSIAN_EMAIL and the API token", 403: "the account needs Jira administrator rights on this site, and the token must not be a scoped token without Automation access", 404: "check the site name; the cloud id did not resolve to an Automation-enabled Jira" }; const hint = hints[response.status] ? ` (${hints[response.status]})` : ""; return `HTTP ${response.status} from ${shortUrl(url)}${hint}${detail ? `: ${detail}` : ""}`; } function shortUrl(url) { return url.replace(/^https:\/\/api\.atlassian\.com\/automation\/public\/jira\/[^/]+/, "\u2026"); } function defaultSleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // tools/sync-cli/src/cli.js var USAGE = `rule-keeper-sync: push your Jira Automation rules to Rule Keeper Usage: rule-keeper-sync --site .atlassian.net --email \\ --url [options] Secrets are read from environment variables, never from arguments: ATLASSIAN_API_TOKEN API token of the admin account (required) RULE_KEEPER_SYNC_SECRET secret generated in the Sync tab (required unless --dry-run) Options: --site Jira site, e.g. acme.atlassian.net (env RK_SITE) --email Atlassian account e-mail (env RK_EMAIL) --url Rule Keeper web trigger URL (env RK_SYNC_URL) --cloud-id Skip tenant lookup (env RK_CLOUD_ID) --concurrency Parallel rule downloads, default 4 --dry-run Download and normalise, do not push --out Also write an export-style file usable for manual upload --json Print the final summary as JSON on stdout --quiet No progress output -h, --help Show this help `; function parseArgs(argv, env = {}) { const options = { site: env.RK_SITE ?? null, email: env.RK_EMAIL ?? null, url: env.RK_SYNC_URL ?? null, cloudId: env.RK_CLOUD_ID ?? null, token: env.ATLASSIAN_API_TOKEN ?? null, secret: env.RULE_KEEPER_SYNC_SECRET ?? null, concurrency: 4, dryRun: false, out: null, json: false, quiet: false, help: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const value = () => { const v = argv[i + 1]; if (v === void 0 || v.startsWith("--")) throw new ConfigError(`${arg} needs a value`); i += 1; return v; }; switch (arg) { case "--site": options.site = value(); break; case "--email": options.email = value(); break; case "--url": options.url = value(); break; case "--cloud-id": options.cloudId = value(); break; case "--concurrency": options.concurrency = Number(value()); break; case "--dry-run": options.dryRun = true; break; case "--out": options.out = value(); break; case "--json": options.json = true; break; case "--quiet": options.quiet = true; break; case "-h": case "--help": options.help = true; break; default: if (arg.startsWith("--")) throw new ConfigError(`unknown option ${arg}`); throw new ConfigError(`unexpected argument ${arg}`); } } return options; } var ConfigError = class extends Error { constructor(message) { super(message); this.name = "ConfigError"; } }; function validateOptions(options) { const missing = []; if (!options.site) missing.push("--site (or RK_SITE)"); if (!options.email) missing.push("--email (or RK_EMAIL)"); if (!options.token) missing.push("ATLASSIAN_API_TOKEN"); if (!options.dryRun) { if (!options.url) missing.push("--url (or RK_SYNC_URL)"); if (!options.secret) missing.push("RULE_KEEPER_SYNC_SECRET"); } if (missing.length) throw new ConfigError(`missing: ${missing.join(", ")}`); if (options.site.includes("/")) throw new ConfigError("--site must be a host name like acme.atlassian.net, without https://"); if (options.url && !/^https:\/\//.test(options.url)) throw new ConfigError("--url must start with https://"); if (!Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 16) { throw new ConfigError("--concurrency must be between 1 and 16"); } return options; } function buildSignedRequest({ secret, body, now = Date.now() }) { const timestamp = Math.floor(now / 1e3); const mac = createHmac("sha256", secret).update(signingInput(timestamp, body)).digest("hex"); return { method: "POST", headers: { "content-type": "application/json", [HEADERS.version]: String(PROTOCOL_VERSION), [HEADERS.timestamp]: String(timestamp), [HEADERS.signature]: `${SIGNATURE_PREFIX}${mac}` }, body }; } function explainAppResponse(status) { switch (status) { case 200: return "accepted"; case 401: return "unauthorized: wrong RULE_KEEPER_SYNC_SECRET, clock skew above 5 minutes, or a replayed request"; case 403: return "sync is not configured in the app: generate a secret in the Sync tab first"; case 400: return "bad request: the CLI and the app disagree on the protocol; update the CLI"; case 413: return "payload too large: export rules in segments or contact support"; case 405: return "method not allowed: the URL is not the Rule Keeper web trigger"; case 402: return "the Rule Keeper license on this site is inactive: renew it in Manage apps"; default: return `unexpected HTTP ${status}`; } } async function runSync(options, { fetchImpl = fetch, log = () => { }, now = () => Date.now(), writeFileImpl = writeFile, sleep } = {}) { validateOptions(options); log(`site ${options.site}, account ${options.email}`); const { cloudId, rules: rawRules } = await fetchAllRules({ site: options.site, email: options.email, token: options.token, cloudId: options.cloudId, concurrency: options.concurrency, fetchImpl, log, sleep }); if (rawRules.length === 0) { throw new AutomationApiError("the API returned no rules; the account may lack admin rights on this site"); } const exportDoc = { cloud: true, rules: rawRules, connections: [] }; const parsed = parseExport(exportDoc); log(`normalised ${parsed.ruleCount} rules (${parsed.warnings.length} parser notes)`); let out = null; if (options.out) { await writeFileImpl(options.out, JSON.stringify(exportDoc, null, 2), "utf8"); out = options.out; log(`wrote export-style file ${options.out}`); } if (options.dryRun) { return { ruleCount: parsed.ruleCount, pushed: false, status: null, out, warnings: parsed.warnings, cloudId }; } const body = buildSyncBody({ rules: parsed.rules, warnings: parsed.warnings, site: options.site, cloudId, generatedAt: new Date(now()) }); const request = buildSignedRequest({ secret: options.secret, body, now: now() }); log(`pushing ${Math.round(body.length / 1024)} KB to Rule Keeper`); const response = await fetchImpl(options.url, request); const status = response.status; if (status !== 200) { const error = new Error(`Rule Keeper rejected the snapshot: ${explainAppResponse(status)}`); error.name = "AppRejectedError"; error.status = status; throw error; } log("snapshot accepted"); return { ruleCount: parsed.ruleCount, pushed: true, status, out, warnings: parsed.warnings, cloudId }; } async function main(argv = process.argv.slice(2), env = process.env, { stdout = process.stdout, stderr = process.stderr } = {}) { let options; try { options = parseArgs(argv, env); } catch (error) { stderr.write(`${error.message} ${USAGE}`); return 2; } if (options.help) { stdout.write(USAGE); return 0; } const log = options.quiet ? () => { } : (line) => stderr.write(`[rule-keeper-sync] ${line} `); try { const result = await runSync(options, { log }); const summary = options.dryRun ? `dry run: ${result.ruleCount} rules normalised${result.out ? `, written to ${result.out}` : ""}` : `done: ${result.ruleCount} rules pushed to Rule Keeper`; if (options.json) stdout.write(`${JSON.stringify(result)} `); else stdout.write(`${summary} `); return 0; } catch (error) { if (error instanceof ConfigError) { stderr.write(`configuration error: ${error.message} ${USAGE}`); return 2; } if (error instanceof AutomationApiError) { stderr.write(`Atlassian API error: ${error.message} `); return 3; } if (error.name === "AppRejectedError") { stderr.write(`${error.message} `); return 4; } stderr.write(`unexpected error: ${error.stack ?? error.message} `); return 5; } } // tools/sync-cli/bin/rule-keeper-sync.js main().then((code) => { process.exitCode = code; });