Files
bodyshop/scripts/export-harness-feature-flags.js

1048 lines
32 KiB
JavaScript

#!/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 <key> Split/Harness Admin API key. Defaults to HARNESS_SPLIT_ADMIN_API_KEY.
--sdk-key <key> Split/Harness SDK API key. Used when Admin API is not available.
--workspace <name|id> Workspace/project to export. Omit to export all workspaces.
--environment <name|id> Environment to export. Omit to export all environments.
--output-dir <path> Output directory. Defaults to harness-feature-flags-export.
--bodyshop-map <path> 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
};