@quonfig/cli 0.0.46 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +71 -66
  2. package/dist/commands/migrate.d.ts +2 -0
  3. package/dist/commands/migrate.js +68 -8
  4. package/dist/migrate/local-write.js +4 -0
  5. package/dist/migrate/migration-report.d.ts +9 -0
  6. package/dist/migrate/migration-report.js +68 -0
  7. package/dist/migrate/push-to-cloud.js +2 -0
  8. package/dist/migrate/quonfig-target/operators.d.ts +27 -0
  9. package/dist/migrate/quonfig-target/operators.js +71 -0
  10. package/dist/migrate/quonfig-target/report.d.ts +59 -0
  11. package/dist/migrate/quonfig-target/report.js +39 -0
  12. package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
  13. package/dist/migrate/quonfig-target/ruleset.js +192 -0
  14. package/dist/migrate/quonfig-target/values.d.ts +38 -0
  15. package/dist/migrate/quonfig-target/values.js +77 -0
  16. package/dist/migrate/source-api-key.d.ts +33 -0
  17. package/dist/migrate/source-api-key.js +43 -0
  18. package/dist/migrate/source.d.ts +10 -0
  19. package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
  20. package/dist/migrate/sources/launchdarkly/api.js +195 -0
  21. package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
  22. package/dist/migrate/sources/launchdarkly/audit.js +172 -0
  23. package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
  24. package/dist/migrate/sources/launchdarkly/translate.js +209 -0
  25. package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
  26. package/dist/migrate/sources/launchdarkly/types.js +10 -0
  27. package/dist/migrate/sources/launchdarkly.d.ts +35 -0
  28. package/dist/migrate/sources/launchdarkly.js +295 -13
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/oclif.manifest.json +178 -161
  32. package/package.json +5 -5
@@ -0,0 +1,172 @@
1
+ /**
2
+ * LaunchDarkly REST API client — Phase-2 history backfill (`--full-summary`).
3
+ *
4
+ * Phase 1 (`api.ts`) is a fast current-state snapshot. Phase 2 is the slow,
5
+ * resumable audit-log walk that reifies every historical flag change into its
6
+ * own git commit with the original author, date and message.
7
+ *
8
+ * Hard constraints, straight from `project/plans/migrator-launch-darkly.md`
9
+ * §4.1 / §4.1.1 — all surfaced to the user, none worked around silently:
10
+ *
11
+ * - The audit log is a *forward event stream*, not a versioned object store.
12
+ * There is no point-in-time endpoint; each `/auditlog/{id}` entry carries the
13
+ * full `currentVersion` of the resource as of that change, which is exactly
14
+ * what we need to reify a commit.
15
+ * - `/auditlog` caps `limit` at 20 and is account-wide. A large account is tens
16
+ * of thousands of events → 10k–50k+ calls → hours. The walk MUST be
17
+ * checkpointed/resumable — `walkAuditLog` cursors on `before` and emits an
18
+ * `onCheckpoint` after every page so a crashed run resumes where it stopped.
19
+ * - Retention is plan-gated: LD Developer plans retain only the last 30 days.
20
+ * `probeRetentionHorizon` is the up-front pre-flight — it tells the user the
21
+ * real horizon BEFORE the slow phase starts, so nobody waits hours expecting
22
+ * two years and silently gets thirty days.
23
+ * - `delta` is null for semantic-patch updates, so we rely on `currentVersion`,
24
+ * never `delta`.
25
+ */
26
+ import { apiFetch } from './api.js';
27
+ const SOURCE_NAME = 'launchdarkly';
28
+ /** `/auditlog` hard-caps `limit` at 20 — see plan §4.1. */
29
+ const AUDIT_PAGE_LIMIT = 20;
30
+ const DAY_MS = 86_400_000;
31
+ /**
32
+ * Retention boundaries the pre-flight probe brackets against, in days. The
33
+ * 30-day mark is the LD Developer-plan retention ceiling; the rest bracket the
34
+ * "how far does this account actually retain" question cheaply (one `limit=1`
35
+ * call each) instead of walking the whole log just to find its floor.
36
+ */
37
+ const RETENTION_PROBE_DAYS = [30, 90, 180, 365, 730];
38
+ /** The LD audit-log resource spec that scopes the walk to every flag in a project. */
39
+ export function buildFlagAuditSpec(projectKey) {
40
+ return `proj/${projectKey}:env/*:flag/*`;
41
+ }
42
+ /** One page of the `/auditlog` listing. `limit` is pinned to the API's max of 20. */
43
+ export async function fetchAuditLogPage(apiKey, opts = {}) {
44
+ const params = new URLSearchParams({ limit: String(AUDIT_PAGE_LIMIT) });
45
+ if (opts.before !== undefined)
46
+ params.set('before', String(opts.before));
47
+ if (opts.after !== undefined)
48
+ params.set('after', String(opts.after));
49
+ if (opts.spec)
50
+ params.set('spec', opts.spec);
51
+ const data = (await apiFetch(`/auditlog?${params}`, apiKey));
52
+ return { _links: data._links, items: Array.isArray(data.items) ? data.items : [] };
53
+ }
54
+ /** Hydrate one listing row into a full entry — this is the call that carries `currentVersion`. */
55
+ export async function fetchAuditLogEntry(apiKey, id) {
56
+ return (await apiFetch(`/auditlog/${encodeURIComponent(id)}`, apiKey));
57
+ }
58
+ /**
59
+ * Walk the audit log newest-to-oldest, hydrating every listing row. Pages on
60
+ * the `before` cursor (the oldest `date` seen so far) so the walk is resumable:
61
+ * pass `startBefore` to resume, and persist each `onCheckpoint(before)` value.
62
+ *
63
+ * Terminates on the first empty page; an `after` floor stops it early. A
64
+ * non-decreasing cursor (a misbehaving API or a same-ms tie at a page edge)
65
+ * also terminates the walk rather than looping forever.
66
+ */
67
+ export async function* walkAuditLog(apiKey, opts = {}) {
68
+ let before = opts.startBefore;
69
+ const seenCursors = new Set();
70
+ while (true) {
71
+ // eslint-disable-next-line no-await-in-loop
72
+ const page = await fetchAuditLogPage(apiKey, {
73
+ ...(opts.after === undefined ? {} : { after: opts.after }),
74
+ ...(before === undefined ? {} : { before }),
75
+ ...(opts.spec ? { spec: opts.spec } : {}),
76
+ });
77
+ if (page.items.length === 0)
78
+ break;
79
+ let crossedFloor = false;
80
+ for (const item of page.items) {
81
+ if (opts.after !== undefined && item.date <= opts.after) {
82
+ crossedFloor = true;
83
+ break;
84
+ }
85
+ // eslint-disable-next-line no-await-in-loop
86
+ yield await fetchAuditLogEntry(apiKey, item._id);
87
+ }
88
+ if (crossedFloor)
89
+ break;
90
+ // The oldest entry on the page is the cursor for the next (older) page.
91
+ // Empty-page guard above (line 161) means at(-1) is defined here; bang for TS.
92
+ const nextBefore = page.items.at(-1).date;
93
+ // eslint-disable-next-line no-await-in-loop
94
+ if (opts.onCheckpoint)
95
+ await opts.onCheckpoint(nextBefore);
96
+ if ((before !== undefined && nextBefore >= before) || seenCursors.has(nextBefore))
97
+ break;
98
+ seenCursors.add(nextBefore);
99
+ before = nextBefore;
100
+ }
101
+ }
102
+ /**
103
+ * Up-front retention pre-flight (plan §4.1.1). Brackets the account's retention
104
+ * window with one cheap `limit=1` probe per boundary instead of walking the
105
+ * whole log. An empty 30-day probe means the account retains ≤ 30 days — almost
106
+ * certainly an LD Developer plan, and pre-window history is permanently gone.
107
+ */
108
+ export async function probeRetentionHorizon(apiKey, opts = {}) {
109
+ const now = opts.now ?? Date.now();
110
+ let oldestReachableMs = null;
111
+ for (const days of RETENTION_PROBE_DAYS) {
112
+ // eslint-disable-next-line no-await-in-loop
113
+ const page = await fetchAuditLogPage(apiKey, {
114
+ before: now - days * DAY_MS,
115
+ ...(opts.spec ? { spec: opts.spec } : {}),
116
+ });
117
+ // First empty boundary is the retention floor — stop bracketing deeper.
118
+ if (page.items.length === 0)
119
+ break;
120
+ oldestReachableMs = page.items[0].date;
121
+ }
122
+ const label = oldestReachableMs === null
123
+ ? 'LaunchDarkly retained ≤ 30 days of audit history (likely a Developer plan) — pre-window history is permanently gone.'
124
+ : `LaunchDarkly audit history reaches back to at least ${new Date(oldestReachableMs).toISOString().slice(0, 10)}.`;
125
+ return { developerPlanLikely: oldestReachableMs === null, label, oldestReachableMs };
126
+ }
127
+ /** True when an audit entry's `currentVersion` is shaped like an `LDFlag`. */
128
+ function isFlagVersion(version) {
129
+ return (typeof version === 'object' &&
130
+ version !== null &&
131
+ typeof version.key === 'string' &&
132
+ Array.isArray(version.variations));
133
+ }
134
+ /**
135
+ * Map a hydrated audit entry to a `LegacyChange`. The `currentVersion` flag
136
+ * snapshot is carried as the `{kind: 'flag', data}` payload `translate()`
137
+ * already understands, so Phase-2 history reuses the Phase-1 converter
138
+ * unchanged. Returns null for entries that carry no flag `currentVersion`
139
+ * (deleted resources, non-flag audit rows) — those are skipped, not crashed.
140
+ */
141
+ export function auditEntryToLegacyChange(entry) {
142
+ if (!isFlagVersion(entry.currentVersion))
143
+ return null;
144
+ return {
145
+ changedAt: entry.date,
146
+ key: entry.currentVersion.key,
147
+ raw: { auditEntry: entry, data: entry.currentVersion, kind: 'flag' },
148
+ source: SOURCE_NAME,
149
+ };
150
+ }
151
+ function actorName(member) {
152
+ const full = [member.firstName, member.lastName].filter(Boolean).join(' ').trim();
153
+ return full.length > 0 ? full : (member.email ?? 'LaunchDarkly user');
154
+ }
155
+ /**
156
+ * Reify an audit entry's original author, timestamp and description into commit
157
+ * metadata for `--full-summary` (plan §7). Returns null when the entry has no
158
+ * human member (token / integration actors) so the caller falls back to the
159
+ * migrator identity rather than inventing an author.
160
+ */
161
+ export function getCommitMetaForAuditEntry(entry) {
162
+ const member = entry.member;
163
+ if (!member || !member.email)
164
+ return null;
165
+ const described = entry.shortDescription?.trim() || entry.description?.trim();
166
+ // Empirically many LD audit entries carry no human description; fall back to
167
+ // a stable, key-aware message so `git log` stays readable.
168
+ const message = described && described.length > 0
169
+ ? described
170
+ : `migrator: update ${entry.currentVersion?.key ?? entry.name ?? 'flag'}`;
171
+ return { author: { email: member.email, name: actorName(member) }, date: entry.date, message };
172
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * LaunchDarkly converter — LaunchDarkly flag/segment JSON → `QuonfigFile[]`.
3
+ *
4
+ * This is the provider-specific topology collapse (plan §3.1, Epic 3). It calls
5
+ * the shared `quonfig-target/` verb library for the parts that are not
6
+ * LaunchDarkly-specific (operator mapping, value coercion, ordered-ruleset
7
+ * collapse) and handles the LaunchDarkly-shaped structure here: variations →
8
+ * variants, per-environment state → `environments[]`, the gap dispositions of
9
+ * plan §5.4.
10
+ */
11
+ import { ConversionReport } from '../../quonfig-target/report.js';
12
+ import type { LDFlag, LDSegment } from './types.js';
13
+ export declare function flagOutputPath(key: string): string;
14
+ export declare function segmentOutputPath(key: string): string;
15
+ /**
16
+ * Translate one LaunchDarkly flag into a Quonfig feature-flag file.
17
+ *
18
+ * Structure:
19
+ * - `variations[]` → typed `variants[]`
20
+ * - per-environment state → `environments[]` (each collapsed via the verb lib)
21
+ * - `default.rules` → a baseline ALWAYS_TRUE rule serving variation 0; any
22
+ * environment LaunchDarkly did not define falls back to this.
23
+ *
24
+ * Gap dispositions (plan §5.4) are recorded on `report` — nothing silent.
25
+ */
26
+ export declare function translateFlag(flag: LDFlag, report: ConversionReport): Record<string, unknown>;
27
+ /**
28
+ * Translate one LaunchDarkly segment into a Quonfig segment file.
29
+ *
30
+ * Quonfig segments are boolean ("is the context in the segment?") and have no
31
+ * per-environment state, so everything collapses into `default.rules`,
32
+ * first-match-wins, matching LaunchDarkly's evaluation precedence:
33
+ * excluded → included → rules → (not in segment).
34
+ *
35
+ * Big/synced segments (`unbounded: true`) cannot have their membership
36
+ * exported via the REST API — the shell + rules are emitted and the loss is
37
+ * reported (plan §5.4).
38
+ */
39
+ export declare function translateSegment(segment: LDSegment, report: ConversionReport): Record<string, unknown>;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * LaunchDarkly converter — LaunchDarkly flag/segment JSON → `QuonfigFile[]`.
3
+ *
4
+ * This is the provider-specific topology collapse (plan §3.1, Epic 3). It calls
5
+ * the shared `quonfig-target/` verb library for the parts that are not
6
+ * LaunchDarkly-specific (operator mapping, value coercion, ordered-ruleset
7
+ * collapse) and handles the LaunchDarkly-shaped structure here: variations →
8
+ * variants, per-environment state → `environments[]`, the gap dispositions of
9
+ * plan §5.4.
10
+ */
11
+ import { clauseToCriterion, collapseEnvironment, } from '../../quonfig-target/ruleset.js';
12
+ import { inferFlagValueType, toQuonfigValue } from '../../quonfig-target/values.js';
13
+ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
14
+ /**
15
+ * LaunchDarkly keys are already flat and URL-safe, but normalize `/` → `.`
16
+ * defensively so a key can never create a nested directory tree (matches the
17
+ * flat-path contract `local-write.ts` enforces).
18
+ */
19
+ function normalizeKey(key) {
20
+ return key.replaceAll('/', '.');
21
+ }
22
+ /** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
23
+ function slugifyEnvKey(key) {
24
+ return key
25
+ .toLowerCase()
26
+ .replaceAll(/[^\da-z]+/g, '-')
27
+ .replaceAll(/^-+|-+$/g, '');
28
+ }
29
+ export function flagOutputPath(key) {
30
+ return `feature-flags/${normalizeKey(key)}.json`;
31
+ }
32
+ export function segmentOutputPath(key) {
33
+ return `segments/${normalizeKey(key)}.json`;
34
+ }
35
+ /**
36
+ * Translate one LaunchDarkly flag into a Quonfig feature-flag file.
37
+ *
38
+ * Structure:
39
+ * - `variations[]` → typed `variants[]`
40
+ * - per-environment state → `environments[]` (each collapsed via the verb lib)
41
+ * - `default.rules` → a baseline ALWAYS_TRUE rule serving variation 0; any
42
+ * environment LaunchDarkly did not define falls back to this.
43
+ *
44
+ * Gap dispositions (plan §5.4) are recorded on `report` — nothing silent.
45
+ */
46
+ export function translateFlag(flag, report) {
47
+ const variationValues = flag.variations.map((v) => v.value);
48
+ const valueType = inferFlagValueType(flag.kind, variationValues);
49
+ const ctx = {
50
+ report,
51
+ sourceKey: flag.key,
52
+ valueType,
53
+ variationValues,
54
+ };
55
+ // clientSideAvailability: feature flags are always client-visible in Quonfig,
56
+ // so `sendToClientSdk` is invalid on them — we cannot carry the dimension.
57
+ // Report the mobile-key drop rather than silently losing it.
58
+ if (flag.clientSideAvailability?.usingMobileKey) {
59
+ report.add('dropped-mobile-key-availability', flag.key, `clientSideAvailability.usingMobileKey was true — Quonfig has one client-visibility bool, the mobile-key dimension is dropped`);
60
+ }
61
+ if (flag.maintainerId || flag.maintainerTeamKey) {
62
+ report.add('dropped-maintainer', flag.key, `maintainer ${flag.maintainerId ?? flag.maintainerTeamKey} dropped — Quonfig authorship lives in git history`);
63
+ }
64
+ if (flag.customProperties && Object.keys(flag.customProperties).length > 0) {
65
+ report.add('dropped-custom-properties', flag.key, `customProperties (${Object.keys(flag.customProperties).join(', ')}) dropped — report-only in v1 (D6)`);
66
+ }
67
+ const variants = flag.variations.map((v) => {
68
+ const variant = {
69
+ value: toQuonfigValue(v.value, valueType),
70
+ };
71
+ if (v.name !== undefined)
72
+ variant.name = v.name;
73
+ if (v.description !== undefined)
74
+ variant.description = v.description;
75
+ return variant;
76
+ });
77
+ const environments = Object.entries(flag.environments)
78
+ .map(([envKey, envState]) => ({
79
+ id: slugifyEnvKey(envKey),
80
+ rules: collapseEnvironment(envState, ctx),
81
+ }))
82
+ // Stable output order regardless of JSON key iteration order.
83
+ .sort((a, b) => a.id.localeCompare(b.id));
84
+ // Baseline default: any environment LaunchDarkly did not define serves
85
+ // variation 0. LaunchDarkly state is entirely per-environment, so there is
86
+ // no source-side "default" — this is a documented, conservative choice.
87
+ const out = {
88
+ key: normalizeKey(flag.key),
89
+ type: 'feature_flag',
90
+ valueType,
91
+ default: {
92
+ rules: [{ criteria: [ALWAYS_TRUE], value: toQuonfigValue(variationValues[0], valueType) }],
93
+ },
94
+ environments,
95
+ variants,
96
+ };
97
+ if (flag.name !== undefined)
98
+ out.name = flag.name;
99
+ if (flag.description !== undefined)
100
+ out.description = flag.description;
101
+ if (flag.tags && flag.tags.length > 0)
102
+ out.tags = [...flag.tags];
103
+ return out;
104
+ }
105
+ /**
106
+ * Translate one LaunchDarkly segment into a Quonfig segment file.
107
+ *
108
+ * Quonfig segments are boolean ("is the context in the segment?") and have no
109
+ * per-environment state, so everything collapses into `default.rules`,
110
+ * first-match-wins, matching LaunchDarkly's evaluation precedence:
111
+ * excluded → included → rules → (not in segment).
112
+ *
113
+ * Big/synced segments (`unbounded: true`) cannot have their membership
114
+ * exported via the REST API — the shell + rules are emitted and the loss is
115
+ * reported (plan §5.4).
116
+ */
117
+ export function translateSegment(segment, report) {
118
+ const ctx = {
119
+ report,
120
+ sourceKey: segment.key,
121
+ valueType: 'bool',
122
+ variationValues: [],
123
+ };
124
+ const rules = [];
125
+ if (segment.unbounded) {
126
+ report.add('unexportable-segment-membership', segment.key, `big/synced segment (unbounded) — membership is not exportable via the LaunchDarkly REST API; only the rule shell is imported`);
127
+ }
128
+ const keyListRule = (kind, values, inSegment) => ({
129
+ criteria: [
130
+ {
131
+ operator: 'PROP_IS_ONE_OF',
132
+ propertyName: `${kind}.key`,
133
+ valueToMatch: { type: 'string_list', value: [...values] },
134
+ },
135
+ ],
136
+ value: { type: 'bool', value: inSegment },
137
+ });
138
+ // Excluded contexts win over included — match LaunchDarkly precedence.
139
+ if (segment.excluded && segment.excluded.length > 0)
140
+ rules.push(keyListRule('user', segment.excluded, false));
141
+ for (const ec of segment.excludedContexts ?? []) {
142
+ if (ec.values.length > 0)
143
+ rules.push(keyListRule(ec.contextKind, ec.values, false));
144
+ }
145
+ if (segment.included && segment.included.length > 0)
146
+ rules.push(keyListRule('user', segment.included, true));
147
+ for (const ic of segment.includedContexts ?? []) {
148
+ if (ic.values.length > 0)
149
+ rules.push(keyListRule(ic.contextKind, ic.values, true));
150
+ }
151
+ for (const rule of segment.rules ?? []) {
152
+ const built = buildSegmentRule(rule, ctx);
153
+ if (built !== null)
154
+ rules.push(built);
155
+ }
156
+ // Trailing catch-all: anyone not matched above is not in the segment.
157
+ rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
158
+ const out = {
159
+ key: normalizeKey(segment.key),
160
+ type: 'segment',
161
+ valueType: 'bool',
162
+ default: { rules },
163
+ environments: [],
164
+ variants: [],
165
+ };
166
+ if (segment.name !== undefined)
167
+ out.name = segment.name;
168
+ if (segment.description !== undefined)
169
+ out.description = segment.description;
170
+ if (segment.tags && segment.tags.length > 0)
171
+ out.tags = [...segment.tags];
172
+ return out;
173
+ }
174
+ /**
175
+ * Build a Quonfig rule from a LaunchDarkly segment rule. A segment rule's
176
+ * clauses AND together and the rule means "in the segment". A rule-level
177
+ * `weight` is a percentage of matching contexts — converted to a
178
+ * `weighted_values` bool split (and reported as re-bucketing). Returns `null`
179
+ * when a clause cannot be mapped (the whole rule is dropped — see `buildRule`).
180
+ */
181
+ function buildSegmentRule(rule, ctx) {
182
+ // Reuse the clause-mapping verb via a tiny inline AND loop so segment rules
183
+ // get the exact same operator handling (and whole-rule-drop) as flag rules.
184
+ const criteria = [];
185
+ for (const clause of rule.clauses) {
186
+ const criterion = clauseToCriterion(clause, ctx);
187
+ if (criterion === null)
188
+ return null;
189
+ criteria.push(criterion);
190
+ }
191
+ const value = rule.weight === undefined
192
+ ? { type: 'bool', value: true }
193
+ : {
194
+ type: 'weighted_values',
195
+ value: {
196
+ hashByPropertyName: rule.bucketBy
197
+ ? `user.${rule.bucketBy.replace(/^\//, '').replaceAll('/', '.')}`
198
+ : 'user.key',
199
+ weightedValues: [
200
+ { value: { type: 'bool', value: true }, weight: rule.weight },
201
+ { value: { type: 'bool', value: false }, weight: Math.max(0, 100_000 - rule.weight) },
202
+ ],
203
+ },
204
+ };
205
+ if (rule.weight !== undefined) {
206
+ ctx.report.add('rebucketed-rollout', ctx.sourceKey, `segment rule has a ${rule.weight / 1000}% weight — converted to a weighted bool split; users will be re-bucketed`);
207
+ }
208
+ return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
209
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Clean typed structs for the slice of the LaunchDarkly REST API the
3
+ * Phase-1 config-snapshot fetcher walks. Field names mirror LaunchDarkly's
4
+ * JSON exactly; optional fields are the ones LD omits when unset.
5
+ *
6
+ * Reference: https://apidocs.launchdarkly.com/ — `GET /flags/{proj}?env=`,
7
+ * `GET /segments/{proj}/{env}`, `GET /projects/{proj}/environments`,
8
+ * `GET /projects/{proj}/context-kinds`.
9
+ */
10
+ /** A LaunchDarkly clause — one predicate inside a rule (rule clauses AND together). */
11
+ export interface LDClause {
12
+ _id?: string;
13
+ /** JSON-pointer-or-bare attribute name, e.g. "email" or "/address/city". */
14
+ attribute: string;
15
+ /** Context kind the attribute belongs to. Absent ⇒ "user". */
16
+ contextKind?: string;
17
+ negate?: boolean;
18
+ /** LaunchDarkly operator name, e.g. "in", "segmentMatch", "semVerLessThan". */
19
+ op: string;
20
+ values: unknown[];
21
+ }
22
+ /** One weighted variation inside a rollout. */
23
+ export interface LDWeightedVariation {
24
+ untracked?: boolean;
25
+ /** 0-based index into the flag's variations[]. */
26
+ variation: number;
27
+ /** Integer weight; weights in a rollout sum to 100000 (= 100%). */
28
+ weight: number;
29
+ }
30
+ /** A percentage rollout — served by a rule or by the fallthrough. */
31
+ export interface LDRollout {
32
+ /** Attribute to hash on. Absent ⇒ the context key. May be a JSON pointer. */
33
+ bucketBy?: string;
34
+ contextKind?: string;
35
+ /** "experiment" marks an experiment rollout; v1 drops the experiment framing. */
36
+ kind?: string;
37
+ seed?: number;
38
+ variations: LDWeightedVariation[];
39
+ }
40
+ /** A targeting rule inside a per-environment flag state. */
41
+ export interface LDRule {
42
+ _id?: string;
43
+ clauses: LDClause[];
44
+ description?: string;
45
+ /** Exactly one of `variation` / `rollout` is set. */
46
+ rollout?: LDRollout;
47
+ trackEvents?: boolean;
48
+ variation?: number;
49
+ }
50
+ /** Individual-target list — specific context keys pinned to one variation. */
51
+ export interface LDTarget {
52
+ /** Absent on the legacy user-only `targets` array ⇒ "user". */
53
+ contextKind?: string;
54
+ values: string[];
55
+ variation: number;
56
+ }
57
+ /** Single variation/default pointer or a rollout. */
58
+ export interface LDVariationOrRollout {
59
+ rollout?: LDRollout;
60
+ variation?: number;
61
+ }
62
+ /** Per-environment flag state, keyed by environment key in `flag.environments`. */
63
+ export interface LDFlagEnvironment {
64
+ archived?: boolean;
65
+ contextTargets?: LDTarget[];
66
+ fallthrough?: LDVariationOrRollout;
67
+ /** Index into variations[] served when `on` is false. */
68
+ offVariation?: number;
69
+ /** The `on` toggle that sits above the rule engine. */
70
+ on: boolean;
71
+ prerequisites?: Array<{
72
+ key: string;
73
+ variation: number;
74
+ }>;
75
+ rules?: LDRule[];
76
+ salt?: string;
77
+ targets?: LDTarget[];
78
+ version?: number;
79
+ }
80
+ /** A flag variation — one of the typed values the flag can serve. */
81
+ export interface LDVariation {
82
+ _id?: string;
83
+ description?: string;
84
+ name?: string;
85
+ value: unknown;
86
+ }
87
+ /** A LaunchDarkly feature flag as returned by `GET /flags/{proj}?env=...`. */
88
+ export interface LDFlag {
89
+ clientSideAvailability?: {
90
+ usingEnvironmentId?: boolean;
91
+ usingMobileKey?: boolean;
92
+ };
93
+ customProperties?: Record<string, {
94
+ name?: string;
95
+ value: string[];
96
+ }>;
97
+ description?: string;
98
+ /** Map of environment key → per-environment state. */
99
+ environments: Record<string, LDFlagEnvironment>;
100
+ key: string;
101
+ /** "boolean" or "multivariate". */
102
+ kind: string;
103
+ maintainerId?: string;
104
+ maintainerTeamKey?: string;
105
+ name?: string;
106
+ tags?: string[];
107
+ temporary?: boolean;
108
+ variations: LDVariation[];
109
+ }
110
+ /** A LaunchDarkly segment as returned by `GET /segments/{proj}/{env}`. */
111
+ export interface LDSegment {
112
+ description?: string;
113
+ excluded?: string[];
114
+ excludedContexts?: Array<{
115
+ contextKind: string;
116
+ values: string[];
117
+ }>;
118
+ included?: string[];
119
+ includedContexts?: Array<{
120
+ contextKind: string;
121
+ values: string[];
122
+ }>;
123
+ key: string;
124
+ name?: string;
125
+ rules?: Array<{
126
+ _id?: string;
127
+ bucketBy?: string;
128
+ clauses: LDClause[];
129
+ weight?: number;
130
+ }>;
131
+ tags?: string[];
132
+ /** Big/synced segment — membership is not exportable via the REST API. */
133
+ unbounded?: boolean;
134
+ unboundedContextKind?: string;
135
+ }
136
+ /** `GET /projects/{proj}/environments` — offset-paginated. */
137
+ export interface LDEnvironmentsResponse {
138
+ _links?: {
139
+ next?: {
140
+ href: string;
141
+ };
142
+ };
143
+ items: Array<{
144
+ key: string;
145
+ name: string;
146
+ }>;
147
+ totalCount?: number;
148
+ }
149
+ /** `GET /flags/{proj}?env=...` — offset-paginated. */
150
+ export interface LDFlagsResponse {
151
+ _links?: {
152
+ next?: {
153
+ href: string;
154
+ };
155
+ };
156
+ items: LDFlag[];
157
+ totalCount?: number;
158
+ }
159
+ /** `GET /segments/{proj}/{env}` — offset-paginated. */
160
+ export interface LDSegmentsResponse {
161
+ _links?: {
162
+ next?: {
163
+ href: string;
164
+ };
165
+ };
166
+ items: LDSegment[];
167
+ totalCount?: number;
168
+ }
169
+ /** `GET /projects/{proj}/context-kinds`. */
170
+ export interface LDContextKindsResponse {
171
+ items: Array<{
172
+ key: string;
173
+ name?: string;
174
+ }>;
175
+ }
176
+ /**
177
+ * The full Phase-1 config snapshot — what the fetcher stitches together and
178
+ * what `translate()` consumes. One of these is produced per migration run.
179
+ */
180
+ export interface LDSnapshot {
181
+ contextKinds: string[];
182
+ /** Environment keys, in API order. */
183
+ environments: string[];
184
+ flags: LDFlag[];
185
+ /** Segments de-duplicated by key across environments. */
186
+ segments: LDSegment[];
187
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Clean typed structs for the slice of the LaunchDarkly REST API the
3
+ * Phase-1 config-snapshot fetcher walks. Field names mirror LaunchDarkly's
4
+ * JSON exactly; optional fields are the ones LD omits when unset.
5
+ *
6
+ * Reference: https://apidocs.launchdarkly.com/ — `GET /flags/{proj}?env=`,
7
+ * `GET /segments/{proj}/{env}`, `GET /projects/{proj}/environments`,
8
+ * `GET /projects/{proj}/context-kinds`.
9
+ */
10
+ export {};
@@ -1,2 +1,37 @@
1
+ /**
2
+ * `qfg migrate --from launchdarkly` — the MigrationSource implementation.
3
+ *
4
+ * Wires the LaunchDarkly fetcher (`launchdarkly/api.ts`) and converter
5
+ * (`launchdarkly/translate.ts`) into the source-agnostic migration framework.
6
+ * Both write modes (`--dir`, `--push`) are inherited for free — this source
7
+ * only has to produce `QuonfigFile[]`.
8
+ *
9
+ * Design is frozen in `launchdarkly.README.md` / `project/plans/migrator-launch-darkly.md`.
10
+ */
11
+ import { ConversionReport } from '../quonfig-target/report.js';
1
12
  import { type MigrationSource } from '../source.js';
13
+ import { type RetentionHorizon } from './launchdarkly/audit.js';
14
+ /**
15
+ * Override the LaunchDarkly project key for this run. Called by the `qfg migrate`
16
+ * command from its `--project` flag (which itself falls back to the
17
+ * `LAUNCHDARKLY_PROJECT_KEY` env var). Mirrors `applyLaunchDarklyBaseUrl()` —
18
+ * the command layer threads run-scoped config into the source singleton.
19
+ */
20
+ export declare function setLaunchDarklyProjectKey(projectKey: string): void;
21
+ /**
22
+ * Enable Phase-2 history backfill for this run. When set, `fetchChanges` walks
23
+ * the LaunchDarkly audit log instead of taking a current-state snapshot, and
24
+ * `validateAuth` runs the retention pre-flight. Called by `qfg migrate` from
25
+ * its `--full-summary` flag.
26
+ */
27
+ export declare function setLaunchDarklyFullSummary(on: boolean): void;
28
+ /**
29
+ * The retention pre-flight result from the last full-summary `validateAuth`,
30
+ * or null. The command reads this and tells the user the real history horizon
31
+ * BEFORE the slow audit walk starts (plan §4.1.1).
32
+ */
33
+ export declare function getLaunchDarklyRetentionHorizon(): null | RetentionHorizon;
34
+ /** The full conversion report — every skip/drop/coerce note from this run. */
35
+ export declare function getConversionReport(): ConversionReport;
2
36
  export declare const launchdarklySource: MigrationSource;
37
+ export declare function __resetLaunchDarklySourceForTests(): void;