#!/usr/bin/env node const fs = require("node:fs/promises"); const path = require("node:path"); const API_BASE_URL = "https://api.split.io/internal/api/v2"; const MAX_FLAG_PAGE_SIZE = 50; const MAX_SEGMENT_PAGE_SIZE = 50; const MAX_SEGMENT_KEYS_PAGE_SIZE = 100; /** * Calculates the unsigned MurmurHash3 value Split uses for percentage bucket evaluation. */ function murmur3Hash32(value, seed = 0) { let h1 = seed >>> 0; const remainder = value.length & 3; const bytes = value.length - remainder; const c1 = 0xcc9e2d51; const c2 = 0x1b873593; let index = 0; while (index < bytes) { let k1 = (value.charCodeAt(index) & 0xff) | ((value.charCodeAt(index + 1) & 0xff) << 8) | ((value.charCodeAt(index + 2) & 0xff) << 16) | ((value.charCodeAt(index + 3) & 0xff) << 24); index += 4; k1 = Math.imul(k1, c1); k1 = (k1 << 15) | (k1 >>> 17); k1 = Math.imul(k1, c2); h1 ^= k1; h1 = (h1 << 13) | (h1 >>> 19); h1 = Math.imul(h1, 5) + 0xe6546b64; } let k1 = 0; switch (remainder) { case 3: k1 ^= (value.charCodeAt(index + 2) & 0xff) << 16; // falls through case 2: k1 ^= (value.charCodeAt(index + 1) & 0xff) << 8; // falls through case 1: k1 ^= value.charCodeAt(index) & 0xff; k1 = Math.imul(k1, c1); k1 = (k1 << 15) | (k1 >>> 17); k1 = Math.imul(k1, c2); h1 ^= k1; break; default: break; } h1 ^= value.length; h1 ^= h1 >>> 16; h1 = Math.imul(h1, 0x85ebca6b); h1 ^= h1 >>> 13; h1 = Math.imul(h1, 0xc2b2ae35); h1 ^= h1 >>> 16; return h1 >>> 0; } /** * Converts a Split target key and seed into a one-based rollout bucket. */ function bucket(key, seed) { return (murmur3Hash32(String(key), Number(seed) || 0) % 100) + 1; } /** * Parses CLI arguments and environment variables into exporter options. */ function parseArgs(argv) { const args = { apiKey: process.env.HARNESS_SPLIT_ADMIN_API_KEY || process.env.SPLIT_ADMIN_API_KEY, sdkKey: process.env.HARNESS_SPLIT_SDK_KEY || process.env.HARNESS_SPLIT_SDK_API_KEY || process.env.SPLIT_SDK_API_KEY, outputDir: "harness-feature-flags-export", workspace: null, environment: null, includeSegments: true, bodyshopMap: null }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const next = argv[index + 1]; switch (arg) { case "--api-key": args.apiKey = next; index += 1; break; case "--sdk-key": args.sdkKey = next; index += 1; break; case "--output-dir": args.outputDir = next; index += 1; break; case "--workspace": args.workspace = next; index += 1; break; case "--environment": args.environment = next; index += 1; break; case "--bodyshop-map": args.bodyshopMap = next; index += 1; break; case "--skip-segments": args.includeSegments = false; break; case "--help": case "-h": printHelp(); process.exit(0); break; default: throw new Error(`Unknown argument: ${arg}`); } } if (!args.apiKey && !args.sdkKey) { throw new Error("Missing API key. Set HARNESS_SPLIT_ADMIN_API_KEY, HARNESS_SPLIT_SDK_KEY, --api-key, or --sdk-key."); } return args; } /** * Prints CLI usage for admin-key and SDK-key export modes. */ function printHelp() { console.log(` Export Harness/Split feature flags for migration into bodyshop. Usage: node scripts/export-harness-feature-flags.js --workspace Default --environment Production Options: --api-key Split/Harness Admin API key. Defaults to HARNESS_SPLIT_ADMIN_API_KEY. --sdk-key Split/Harness SDK API key. Used when Admin API is not available. --workspace Workspace/project to export. Omit to export all workspaces. --environment Environment to export. Omit to export all environments. --output-dir Output directory. Defaults to harness-feature-flags-export. --bodyshop-map Optional JSON mapping of Harness target key to bodyshop UUID. --skip-segments Do not expand segment-targeted flags. `); } /** * Fetches JSON from the Harness/Split Admin API and throws detailed HTTP errors. */ async function requestJson(url, apiKey) { const response = await fetchWithFallbackAuth(url, apiKey); if (!response.ok) { const body = await response.text(); throw new Error(`Harness request failed ${response.status} ${response.statusText}: ${url}\n${body}`); } return response.json(); } /** * Fetches JSON from the SDK splitChanges endpoint. */ async function requestSdkJson(url, sdkKey) { const response = await fetch(url, { headers: { Authorization: `Bearer ${sdkKey}`, Accept: "application/json", "Accept-Encoding": "identity" } }); if (!response.ok) { const body = await response.text(); throw new Error(`Harness SDK request failed ${response.status} ${response.statusText}: ${url}\n${body}`); } return response.json(); } /** * Tries Bearer auth first and falls back to the Split x-api-key header. */ async function fetchWithFallbackAuth(url, apiKey) { const bearerResponse = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" } }); if (bearerResponse.status !== 401) { return bearerResponse; } return fetch(url, { headers: { "x-api-key": apiKey, "Content-Type": "application/json" } }); } /** * Normalizes common paginated API response collection shapes into an array. */ function getItems(response) { if (Array.isArray(response)) { return response; } if (Array.isArray(response.objects)) { return response.objects; } if (Array.isArray(response.results)) { return response.results; } return []; } /** * Fetches all pages from a Split Admin API collection endpoint. */ async function fetchPaged(apiKey, url, limit) { const results = []; let offset = 0; while (true) { const separator = url.includes("?") ? "&" : "?"; const response = await requestJson(`${url}${separator}offset=${offset}&limit=${limit}`, apiKey); const items = getItems(response); results.push(...items); const totalCount = response.totalCount ?? response.total; if (typeof totalCount === "number" && results.length >= totalCount) { break; } if (items.length < limit || Array.isArray(response)) { break; } offset += limit; } return results; } /** * Applies optional workspace or environment name/id filters. */ function matchesFilter(value, filter) { if (!filter) { return true; } return value.id === filter || value.name === filter; } /** * Makes a workspace/environment name safe for export file names. */ function safeFileName(value) { return String(value) .replace(/[^a-z0-9._-]+/gi, "_") .replace(/^_+|_+$/g, ""); } /** * Writes pretty JSON and creates parent directories as needed. */ async function writeJson(filePath, value) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); } /** * Loads an optional Harness target key to bodyshop UUID map. */ async function loadBodyshopMap(filePath) { if (!filePath) { return new Map(); } const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return new Map(parsed.map((entry) => [entry.key ?? entry.harnessKey ?? entry.customerKey, entry.bodyshopid ?? entry.bodyshopId])); } return new Map(Object.entries(parsed)); } /** * Parses Split treatment configuration payloads into JSON when possible. */ function getTreatmentConfig(treatment) { if (treatment.configurations === undefined || treatment.configurations === "") { return null; } if (typeof treatment.configurations !== "string") { return treatment.configurations; } try { return JSON.parse(treatment.configurations); } catch { return treatment.configurations; } } /** * Checks whether a rule bucket assigns 100 percent of traffic to one treatment. */ function isFullTreatmentBucket(bucket) { return bucket && bucket.size === 100 && typeof bucket.treatment === "string"; } /** * Returns a rule's single full-treatment bucket when it is simple enough to export. */ function getSingleFullTreatment(rule) { if (!Array.isArray(rule?.buckets) || rule.buckets.length !== 1) { return null; } return isFullTreatmentBucket(rule.buckets[0]) ? rule.buckets[0].treatment : null; } /** * Returns the one treatment that receives 100 percent of SDK partitions. */ function getFullPartitionTreatment(partitions) { const fullPartitions = (partitions || []).filter((partition) => partition.size === 100); return fullPartitions.length === 1 ? fullPartitions[0].treatment : null; } /** * Evaluates an SDK percentage rollout for one known target key. */ function getPartitionTreatmentForKey({ key, seed, partitions }) { const totalSize = (partitions || []).reduce((sum, partition) => sum + partition.size, 0); if (totalSize !== 100 || !key || seed == null) { return null; } const keyBucket = bucket(key, seed); let upperBound = 0; for (const partition of partitions) { upperBound += partition.size; if (keyBucket <= upperBound) { return { treatment: partition.treatment, bucket: keyBucket, source: "sdk.partition" }; } } return null; } /** * Extracts matchers from a Split Admin API rule. */ function getRuleMatchers(rule) { const matchers = rule?.condition?.matchers; return Array.isArray(matchers) ? matchers : []; } /** * Adds or replaces an assignment, keeping the highest-priority source for a key/flag pair. */ function addAssignment(assignments, assignment) { const key = `${assignment.workspaceId}:${assignment.environmentId}:${assignment.customerKey}:${assignment.name}`; const existing = assignments.get(key); if (!existing || assignment.priority < existing.priority) { assignments.set(key, assignment); } } /** * Converts boolean-ish and custom treatment values into database-ready text. */ function normalizeTreatment(treatment) { if (treatment === true) { return "on"; } if (treatment === false) { return "off"; } if (typeof treatment === "string") { const trimmed = treatment.trim(); if (!trimmed) { return "control"; } const lowered = trimmed.toLowerCase(); if (lowered === "true") { return "on"; } if (lowered === "false") { return "off"; } if (["on", "off", "control"].includes(lowered)) { return lowered; } return trimmed; } if (treatment == null) { return "control"; } const fallback = String(treatment).trim(); return fallback || "control"; } /** * Collects explicit per-treatment target key assignments from an Admin API flag definition. */ function collectDirectTreatmentAssignments({ assignments, definition, workspace, environment }) { for (const treatment of definition.treatments || []) { const treatmentName = normalizeTreatment(treatment.name); const config = getTreatmentConfig(treatment); for (const customerKey of treatment.keys || []) { addAssignment(assignments, { workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, customerKey, imexshopid: customerKey, name: definition.name, treatment: treatmentName, config, source: "treatment.keys", priority: 0 }); } } } /** * Expands per-treatment segment assignments when segment keys were exported. */ function collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) { for (const treatment of definition.treatments || []) { const treatmentName = normalizeTreatment(treatment.name); const config = getTreatmentConfig(treatment); for (const segmentName of treatment.segments || []) { const segmentKeys = segmentKeysByName.get(segmentName); if (!segmentKeys) { unmappedRules.push({ workspace: workspace.name, environment: environment.name, flag: definition.name, source: "treatment.segments", segment: segmentName, reason: "Segment keys were not exported." }); continue; } for (const customerKey of segmentKeys) { addAssignment(assignments, { workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, customerKey, imexshopid: customerKey, name: definition.name, treatment: treatmentName, config, source: `treatment.segment:${segmentName}`, priority: 10 }); } } } } /** * Converts simple list/segment rules into fixed bodyshop assignments. */ function collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) { for (const [ruleIndex, rule] of (definition.rules || []).entries()) { const treatment = getSingleFullTreatment(rule); const matchers = getRuleMatchers(rule); if (!treatment) { unmappedRules.push({ workspace: workspace.name, environment: environment.name, flag: definition.name, source: `rules[${ruleIndex}]`, reason: "Rule is not a single 100% treatment bucket.", rule }); continue; } let expanded = false; for (const matcher of matchers) { if (matcher.type === "IN_SEGMENT" && matcher.string) { const segmentKeys = segmentKeysByName.get(matcher.string); if (!segmentKeys) { unmappedRules.push({ workspace: workspace.name, environment: environment.name, flag: definition.name, source: `rules[${ruleIndex}]`, segment: matcher.string, reason: "Segment keys were not exported.", rule }); continue; } for (const customerKey of segmentKeys) { addAssignment(assignments, { workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, customerKey, imexshopid: customerKey, name: definition.name, treatment: normalizeTreatment(treatment), config: null, source: `rule.segment:${matcher.string}`, priority: 20 + ruleIndex }); } expanded = true; } if (matcher.type === "IN_LIST_STRING" && Array.isArray(matcher.strings) && !matcher.attribute) { for (const customerKey of matcher.strings) { addAssignment(assignments, { workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, customerKey, imexshopid: customerKey, name: definition.name, treatment: normalizeTreatment(treatment), config: null, source: "rule.key-list", priority: 20 + ruleIndex }); } expanded = true; } } if (!expanded) { unmappedRules.push({ workspace: workspace.name, environment: environment.name, flag: definition.name, source: `rules[${ruleIndex}]`, reason: "Rule uses attributes, percentages, dependencies, or another matcher that cannot be converted to fixed customer assignments.", rule }); } } } /** * Records all-traffic default rules that are not bodyshop-specific assignments. */ function collectGlobalDefaults({ globalDefaults, definition, workspace, environment }) { const defaultRule = definition.defaultRule || definition.default_rule || []; if (!Array.isArray(defaultRule) || defaultRule.length !== 1 || !isFullTreatmentBucket(defaultRule[0])) { return; } globalDefaults.push({ workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, name: definition.name, treatment: normalizeTreatment(defaultRule[0].treatment), source: "defaultRule" }); } /** * Converts an Admin API flag definition into the export's canonical definition shape. */ function normalizeFlagDefinition(definition, workspace, environment) { return { workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, name: definition.name, description: definition.description || null, defaultTreatment: normalizeTreatment(definition.defaultTreatment), baselineTreatment: normalizeTreatment(definition.baselineTreatment), killed: Boolean(definition.killed), trafficAllocation: definition.trafficAllocation ?? null, treatments: (definition.treatments || []).map((treatment) => ({ name: treatment.name, description: treatment.description || null, config: getTreatmentConfig(treatment) })) }; } /** * Parses SDK treatment config for one treatment name. */ function parseSdkConfig(configurations, treatment) { const rawConfig = configurations?.[treatment]; if (rawConfig === undefined || rawConfig === "") { return null; } if (typeof rawConfig !== "string") { return rawConfig; } try { return JSON.parse(rawConfig); } catch { return rawConfig; } } /** * Converts an SDK splitChanges flag definition into the canonical export shape. */ function normalizeSdkFlagDefinition(definition) { const treatments = new Set([definition.defaultTreatment, definition.baselineTreatment]); for (const condition of definition.conditions || []) { for (const partition of condition.partitions || []) { if (partition.treatment) { treatments.add(partition.treatment); } } } return { workspaceId: null, workspaceName: "sdk-export", environmentId: null, environmentName: "sdk-key-environment", name: definition.name, description: null, defaultTreatment: normalizeTreatment(definition.defaultTreatment), baselineTreatment: normalizeTreatment(definition.baselineTreatment), killed: Boolean(definition.killed), trafficAllocation: definition.trafficAllocation ?? null, treatments: Array.from(treatments) .filter(Boolean) .map((treatment) => ({ name: treatment, description: null, config: parseSdkConfig(definition.configurations, treatment) })) }; } /** * Converts SDK splitChanges conditions into fixed target-key assignments where possible. */ function collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults }) { for (const [conditionIndex, condition] of (definition.conditions || []).entries()) { const matchers = condition.matcherGroup?.matchers || []; if (matchers.length !== 1) { unmappedRules.push({ workspace: "sdk-export", environment: "sdk-key-environment", flag: definition.name, source: `conditions[${conditionIndex}]`, reason: "Condition is not a single matcher.", condition }); continue; } const matcher = matchers[0]; if (matcher.matcherType === "ALL_KEYS") { const treatment = getFullPartitionTreatment(condition.partitions); if (!treatment) { unmappedRules.push({ workspace: "sdk-export", environment: "sdk-key-environment", flag: definition.name, source: `conditions[${conditionIndex}]`, reason: "ALL_KEYS percentage rollout cannot be converted without a known target key list.", condition }); continue; } globalDefaults.push({ workspaceId: null, workspaceName: "sdk-export", environmentId: null, environmentName: "sdk-key-environment", name: definition.name, treatment: normalizeTreatment(treatment), source: "ALL_KEYS" }); continue; } if (matcher.matcherType === "WHITELIST" && !matcher.keySelector?.attribute) { for (const customerKey of matcher.whitelistMatcherData?.whitelist || []) { const resolved = getFullPartitionTreatment(condition.partitions) ? { treatment: getFullPartitionTreatment(condition.partitions), source: "sdk.whitelist" } : getPartitionTreatmentForKey({ key: customerKey, seed: definition.seed, partitions: condition.partitions }); if (!resolved?.treatment) { unmappedRules.push({ workspace: "sdk-export", environment: "sdk-key-environment", flag: definition.name, source: `conditions[${conditionIndex}]`, reason: "Whitelist percentage rollout could not be bucketed.", customerKey, condition }); continue; } addAssignment(assignments, { workspaceId: null, workspaceName: "sdk-export", environmentId: null, environmentName: "sdk-key-environment", customerKey, imexshopid: customerKey, bodyshopid: bodyshopMap.get(customerKey) || null, name: definition.name, treatment: normalizeTreatment(resolved.treatment), config: parseSdkConfig(definition.configurations, resolved.treatment), source: resolved.source, bucket: resolved.bucket, priority: conditionIndex }); } continue; } unmappedRules.push({ workspace: "sdk-export", environment: "sdk-key-environment", flag: definition.name, source: `conditions[${conditionIndex}]`, reason: `SDK matcher ${matcher.matcherType} cannot be converted to fixed customer assignments.`, condition }); } } /** * Escapes a value for use as a single-quoted SQL literal. */ function sqlString(value) { return `'${String(value).replaceAll("'", "''")}'`; } /** * Builds import SQL that maps exported target keys to bodyshops by imexshopid. */ function buildImportSql(assignments) { const rows = assignments.filter((assignment) => assignment.bodyshopid || assignment.imexshopid || assignment.customerKey); if (rows.length === 0) { return "-- No bodyshop assignments were found.\n"; } const values = rows .map((assignment) => { const imexshopid = assignment.imexshopid || assignment.customerKey; const config = assignment.config === null ? "NULL::jsonb" : `${sqlString(JSON.stringify(assignment.config))}::jsonb`; return `(${sqlString(imexshopid)}, ${sqlString(assignment.name)}, ${sqlString(assignment.treatment)}, ${config})`; }) .join(",\n "); const imexshopidValues = Array.from(new Set(rows.map((assignment) => assignment.imexshopid || assignment.customerKey))) .map((imexshopid) => `(${sqlString(imexshopid)})`) .join(",\n "); const flagNameValues = Array.from(new Set(rows.map((assignment) => assignment.name))) .map((name) => `(${sqlString(name)})`) .join(",\n "); return `WITH "exported_flags" ("imexshopid", "name", "treatment", "config") AS (\n VALUES\n ${values}\n),\n"matched_flags" AS (\n SELECT\n "bodyshops"."id" AS "bodyshopid",\n "exported_flags"."name",\n "exported_flags"."treatment",\n "exported_flags"."config"\n FROM "exported_flags"\n INNER JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid")\n INNER JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flags"."name"\n)\nINSERT INTO "public"."bodyshop_feature_flags" ("bodyshopid", "name", "treatment", "config")\nSELECT "bodyshopid", "name", "treatment", "config"\nFROM "matched_flags"\nON CONFLICT ("bodyshopid", "name") DO UPDATE\nSET\n "treatment" = EXCLUDED."treatment",\n "config" = EXCLUDED."config";\n\nWITH "exported_imexshopids" ("imexshopid") AS (\n VALUES\n ${imexshopidValues}\n)\nSELECT "exported_imexshopids"."imexshopid" AS "unmatched_imexshopid"\nFROM "exported_imexshopids"\nLEFT JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_imexshopids"."imexshopid")\nWHERE "bodyshops"."id" IS NULL\nORDER BY "exported_imexshopids"."imexshopid";\n\nWITH "exported_flag_names" ("name") AS (\n VALUES\n ${flagNameValues}\n)\nSELECT "exported_flag_names"."name" AS "unmatched_feature_flag"\nFROM "exported_flag_names"\nLEFT JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flag_names"."name"\nWHERE "feature_flags"."name" IS NULL\nORDER BY "exported_flag_names"."name";\n`; } /** * Exports segment definitions and target keys for an Admin API environment. */ async function exportSegments({ apiKey, workspace, environment, outputDir }) { const segmentKeysByName = new Map(); const segments = await fetchPaged( apiKey, `${API_BASE_URL}/segments/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`, MAX_SEGMENT_PAGE_SIZE ); await writeJson( path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_segments.json`), segments ); for (const segment of segments) { const keys = []; let offset = 0; while (true) { const response = await requestJson( `${API_BASE_URL}/segments/${encodeURIComponent(environment.id)}/${encodeURIComponent(segment.name)}/keys?offset=${offset}&limit=${MAX_SEGMENT_KEYS_PAGE_SIZE}`, apiKey ); const pageKeys = (response.keys || []).map((entry) => (typeof entry === "string" ? entry : entry.key)).filter(Boolean); keys.push(...pageKeys); if (pageKeys.length < MAX_SEGMENT_KEYS_PAGE_SIZE) { break; } offset += MAX_SEGMENT_KEYS_PAGE_SIZE; } segmentKeysByName.set(segment.name, keys); } return { segments, segmentKeysByName, segmentKeys: Object.fromEntries(segmentKeysByName) }; } /** * Runs a full Admin API export across matching workspaces and environments. */ async function exportAdmin(args) { const outputDir = path.resolve(args.outputDir); const bodyshopMap = await loadBodyshopMap(args.bodyshopMap); await fs.mkdir(outputDir, { recursive: true }); const workspaces = (await fetchPaged(args.apiKey, `${API_BASE_URL}/workspaces`, 200)).filter((workspace) => matchesFilter(workspace, args.workspace) ); const normalizedFlags = []; const allSegments = []; const allSegmentKeys = {}; const assignments = new Map(); const unmappedRules = []; const globalDefaults = []; await writeJson(path.join(outputDir, "raw", "workspaces.json"), workspaces); for (const workspace of workspaces) { const environments = (await requestJson(`${API_BASE_URL}/environments/ws/${encodeURIComponent(workspace.id)}`, args.apiKey)).filter( (environment) => matchesFilter(environment, args.environment) ); await writeJson(path.join(outputDir, "raw", `${safeFileName(workspace.name)}_environments.json`), environments); for (const environment of environments) { const { segments, segmentKeysByName, segmentKeys } = args.includeSegments ? await exportSegments({ apiKey: args.apiKey, workspace, environment, outputDir }) : { segments: [], segmentKeysByName: new Map(), segmentKeys: {} }; allSegments.push( ...segments.map((segment) => ({ workspaceId: workspace.id, workspaceName: workspace.name, environmentId: environment.id, environmentName: environment.name, ...segment })) ); allSegmentKeys[`${workspace.name}/${environment.name}`] = segmentKeys; const definitions = await fetchPaged( args.apiKey, `${API_BASE_URL}/splits/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`, MAX_FLAG_PAGE_SIZE ); await writeJson( path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_flag_definitions.json`), definitions ); for (const definition of definitions) { normalizedFlags.push(normalizeFlagDefinition(definition, workspace, environment)); collectGlobalDefaults({ globalDefaults, definition, workspace, environment }); collectDirectTreatmentAssignments({ assignments, definition, workspace, environment }); collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }); collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }); } } } const normalizedAssignments = Array.from(assignments.values()) .map((assignment) => ({ ...assignment, imexshopid: assignment.imexshopid || assignment.customerKey, bodyshopid: bodyshopMap.get(assignment.customerKey) || null })) .sort((a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name)); await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags); await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments); await writeJson(path.join(outputDir, "segments.json"), allSegments); await writeJson(path.join(outputDir, "segment_keys.json"), allSegmentKeys); await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults); await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules); await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments)); console.log(`Export complete: ${outputDir}`); console.log(`Flags: ${normalizedFlags.length}`); console.log(`Customer assignments: ${normalizedAssignments.length}`); console.log(`Global defaults: ${globalDefaults.length}`); console.log(`Unmapped rules: ${unmappedRules.length}`); } /** * Runs an SDK-key export using the splitChanges endpoint. */ async function exportSdk(args) { const sdkKey = args.sdkKey || args.apiKey; const outputDir = path.resolve(args.outputDir); const bodyshopMap = await loadBodyshopMap(args.bodyshopMap); await fs.mkdir(outputDir, { recursive: true }); const splitChanges = await requestSdkJson("https://sdk.split.io/api/splitChanges?since=-1", sdkKey); const normalizedFlags = []; const assignments = new Map(); const unmappedRules = []; const globalDefaults = []; await writeJson(path.join(outputDir, "raw", "sdk_split_changes.json"), splitChanges); for (const definition of splitChanges.splits || []) { normalizedFlags.push(normalizeSdkFlagDefinition(definition)); collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults }); } const normalizedAssignments = Array.from(assignments.values()).sort( (a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name) ); await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags); await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments); await writeJson(path.join(outputDir, "segments.json"), []); await writeJson(path.join(outputDir, "segment_keys.json"), {}); await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults); await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules); await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments)); console.log(`SDK export complete: ${outputDir}`); console.log(`Flags: ${normalizedFlags.length}`); console.log(`Customer assignments: ${normalizedAssignments.length}`); console.log(`Global defaults: ${globalDefaults.length}`); console.log(`Unmapped rules: ${unmappedRules.length}`); } /** * Entrypoint that selects Admin API export or SDK fallback mode. */ async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.apiKey && args.sdkKey) { await exportSdk(args); return; } try { await exportAdmin(args); } catch (error) { if (!/401 Unauthorized/.test(error.message) || !(args.sdkKey || args.apiKey)) { throw error; } console.warn("Admin API key was unauthorized; falling back to SDK splitChanges export."); await exportSdk(args); } } if (require.main === module) { main().catch((error) => { console.error(error.message); process.exit(1); }); } module.exports = { buildImportSql, normalizeTreatment, sqlString };