@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,38 @@
1
+ /**
2
+ * Shared verb: provider variation value → typed Quonfig `{type, value}`.
3
+ *
4
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). Quonfig values
5
+ * are a discriminated union on `type`; this verb infers the type from the raw
6
+ * JSON a provider hands us and the flag's declared value type.
7
+ */
8
+ /** A Quonfig value type — mirrors `ValueTypeSchema` in app-quonfig's config-schemas.ts. */
9
+ export type QuonfigValueType = 'bool' | 'double' | 'int' | 'json' | 'string' | 'string_list';
10
+ /** A typed Quonfig value object as it appears in stored config JSON. */
11
+ export interface QuonfigValue {
12
+ type: QuonfigValueType;
13
+ value: unknown;
14
+ }
15
+ /**
16
+ * Infer the Quonfig value type for a single raw value.
17
+ * - boolean → bool
18
+ * - integer number → int; non-integer number → double
19
+ * - string → string
20
+ * - object / array → json
21
+ */
22
+ export declare function inferValueType(raw: unknown): QuonfigValueType;
23
+ /**
24
+ * Infer the value type for a whole flag from its variation values.
25
+ *
26
+ * LaunchDarkly boolean flags are unambiguously `bool`. For multivariate flags
27
+ * the variations are homogeneous in practice; we widen `int` → `double` if any
28
+ * variation is fractional so a flag with `[0, 1, 2.5]` lands on `double`
29
+ * rather than silently truncating.
30
+ */
31
+ export declare function inferFlagValueType(kind: string, variationValues: unknown[]): QuonfigValueType;
32
+ /**
33
+ * Coerce a raw provider value into a typed Quonfig value object under the
34
+ * flag's declared value type. The declared type wins — a `double` flag whose
35
+ * variation happens to be `2` still serializes as `{type: 'double', value: 2}`
36
+ * so the stored config is internally consistent.
37
+ */
38
+ export declare function toQuonfigValue(raw: unknown, valueType: QuonfigValueType): QuonfigValue;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared verb: provider variation value → typed Quonfig `{type, value}`.
3
+ *
4
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). Quonfig values
5
+ * are a discriminated union on `type`; this verb infers the type from the raw
6
+ * JSON a provider hands us and the flag's declared value type.
7
+ */
8
+ /**
9
+ * Infer the Quonfig value type for a single raw value.
10
+ * - boolean → bool
11
+ * - integer number → int; non-integer number → double
12
+ * - string → string
13
+ * - object / array → json
14
+ */
15
+ export function inferValueType(raw) {
16
+ if (typeof raw === 'boolean')
17
+ return 'bool';
18
+ if (typeof raw === 'number')
19
+ return Number.isInteger(raw) ? 'int' : 'double';
20
+ if (typeof raw === 'string')
21
+ return 'string';
22
+ // null, objects, and arrays all serialize as Quonfig `json`.
23
+ return 'json';
24
+ }
25
+ /**
26
+ * Infer the value type for a whole flag from its variation values.
27
+ *
28
+ * LaunchDarkly boolean flags are unambiguously `bool`. For multivariate flags
29
+ * the variations are homogeneous in practice; we widen `int` → `double` if any
30
+ * variation is fractional so a flag with `[0, 1, 2.5]` lands on `double`
31
+ * rather than silently truncating.
32
+ */
33
+ export function inferFlagValueType(kind, variationValues) {
34
+ if (kind === 'boolean')
35
+ return 'bool';
36
+ if (variationValues.length === 0)
37
+ return 'string';
38
+ const types = variationValues.map((v) => inferValueType(v));
39
+ if (types.every((t) => t === 'bool'))
40
+ return 'bool';
41
+ if (types.every((t) => t === 'int' || t === 'double')) {
42
+ return types.includes('double') ? 'double' : 'int';
43
+ }
44
+ if (types.every((t) => t === 'string'))
45
+ return 'string';
46
+ // Mixed or object/array variations → json (json can hold any of them).
47
+ return 'json';
48
+ }
49
+ /**
50
+ * Coerce a raw provider value into a typed Quonfig value object under the
51
+ * flag's declared value type. The declared type wins — a `double` flag whose
52
+ * variation happens to be `2` still serializes as `{type: 'double', value: 2}`
53
+ * so the stored config is internally consistent.
54
+ */
55
+ export function toQuonfigValue(raw, valueType) {
56
+ switch (valueType) {
57
+ case 'bool': {
58
+ return { type: 'bool', value: Boolean(raw) };
59
+ }
60
+ case 'double':
61
+ case 'int': {
62
+ return { type: valueType, value: typeof raw === 'number' ? raw : Number(raw) };
63
+ }
64
+ case 'json': {
65
+ return { type: 'json', value: raw };
66
+ }
67
+ case 'string_list': {
68
+ return { type: 'string_list', value: Array.isArray(raw) ? raw.map(String) : [] };
69
+ }
70
+ case 'string': {
71
+ return { type: 'string', value: typeof raw === 'string' ? raw : String(raw) };
72
+ }
73
+ default: {
74
+ return { type: 'string', value: String(raw) };
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Decision D8 (project/plans/migrator-launch-darkly.md §9.1): the `qfg migrate`
3
+ * command's API-key flag is generalized from the launch-specific `--api-key` /
4
+ * `LAUNCH_API_KEY` into a provider-agnostic `--source-api-key` /
5
+ * `QUONFIG_MIGRATE_API_KEY`, with a per-provider env fallback. `--api-key` /
6
+ * `LAUNCH_API_KEY` stay as deprecated aliases for the `launch` source.
7
+ *
8
+ * Precedence (highest first):
9
+ * 1. `--source-api-key` flag (oclif also fills it from QUONFIG_MIGRATE_API_KEY)
10
+ * 2. `--api-key` flag (oclif also fills it from LAUNCH_API_KEY; deprecated)
11
+ * 3. per-provider env var (LAUNCHDARKLY_API_KEY, FLAGSMITH_API_KEY, ...)
12
+ */
13
+ /**
14
+ * Per-provider env-var fallback, lowest precedence. Keyed by `--from` value so
15
+ * every supported source has an obvious, source-named env var. `launch`'s entry
16
+ * is redundant with the `--api-key` flag's own `env: LAUNCH_API_KEY` binding,
17
+ * but is kept so the map is a complete record of every provider's env var.
18
+ */
19
+ export declare const PROVIDER_API_KEY_ENV: Record<string, string>;
20
+ export interface SourceApiKeyInput {
21
+ /** `--api-key` flag value (oclif also fills this from LAUNCH_API_KEY). Deprecated alias. */
22
+ apiKeyFlag?: string;
23
+ /** Process env, injectable for tests. Defaults to `process.env`. */
24
+ env?: NodeJS.ProcessEnv;
25
+ /** `--from` value — selects the per-provider env-var fallback. */
26
+ from: string;
27
+ /** `--source-api-key` flag value (oclif also fills this from QUONFIG_MIGRATE_API_KEY). */
28
+ sourceApiKeyFlag?: string;
29
+ }
30
+ /** Resolve the source API key from flags + env, or `undefined` if none is configured. */
31
+ export declare function resolveSourceApiKey(input: SourceApiKeyInput): string | undefined;
32
+ /** One-line guidance for the "no key configured" error, naming every accepted input. */
33
+ export declare function missingSourceApiKeyMessage(from: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Decision D8 (project/plans/migrator-launch-darkly.md §9.1): the `qfg migrate`
3
+ * command's API-key flag is generalized from the launch-specific `--api-key` /
4
+ * `LAUNCH_API_KEY` into a provider-agnostic `--source-api-key` /
5
+ * `QUONFIG_MIGRATE_API_KEY`, with a per-provider env fallback. `--api-key` /
6
+ * `LAUNCH_API_KEY` stay as deprecated aliases for the `launch` source.
7
+ *
8
+ * Precedence (highest first):
9
+ * 1. `--source-api-key` flag (oclif also fills it from QUONFIG_MIGRATE_API_KEY)
10
+ * 2. `--api-key` flag (oclif also fills it from LAUNCH_API_KEY; deprecated)
11
+ * 3. per-provider env var (LAUNCHDARKLY_API_KEY, FLAGSMITH_API_KEY, ...)
12
+ */
13
+ /**
14
+ * Per-provider env-var fallback, lowest precedence. Keyed by `--from` value so
15
+ * every supported source has an obvious, source-named env var. `launch`'s entry
16
+ * is redundant with the `--api-key` flag's own `env: LAUNCH_API_KEY` binding,
17
+ * but is kept so the map is a complete record of every provider's env var.
18
+ */
19
+ export const PROVIDER_API_KEY_ENV = {
20
+ flagsmith: 'FLAGSMITH_API_KEY',
21
+ launch: 'LAUNCH_API_KEY',
22
+ launchdarkly: 'LAUNCHDARKLY_API_KEY',
23
+ };
24
+ /** Resolve the source API key from flags + env, or `undefined` if none is configured. */
25
+ export function resolveSourceApiKey(input) {
26
+ if (input.sourceApiKeyFlag)
27
+ return input.sourceApiKeyFlag;
28
+ if (input.apiKeyFlag)
29
+ return input.apiKeyFlag;
30
+ const env = input.env ?? process.env;
31
+ const providerEnv = PROVIDER_API_KEY_ENV[input.from];
32
+ if (providerEnv && env[providerEnv])
33
+ return env[providerEnv];
34
+ return undefined;
35
+ }
36
+ /** One-line guidance for the "no key configured" error, naming every accepted input. */
37
+ export function missingSourceApiKeyMessage(from) {
38
+ const providerEnv = PROVIDER_API_KEY_ENV[from];
39
+ const providerHint = providerEnv ? ` For --from ${from} you can also set ${providerEnv}.` : '';
40
+ return ('--source-api-key is required (or set QUONFIG_MIGRATE_API_KEY).' +
41
+ providerHint +
42
+ ' The legacy --api-key / LAUNCH_API_KEY flag still works for --from launch.');
43
+ }
@@ -1,3 +1,4 @@
1
+ import type { ConversionNote } from './quonfig-target/report.js';
1
2
  export interface LegacyChange {
2
3
  changedAt?: number;
3
4
  key?: string;
@@ -88,6 +89,15 @@ export interface MigrationSource {
88
89
  * synthesized entries) and the caller should fall back to the migrator identity.
89
90
  */
90
91
  getCommitMeta?(change: LegacyChange): CommitMeta | null;
92
+ /**
93
+ * Optional post-translate accumulator. Returns the structured conversion notes
94
+ * collected during translate() — re-bucketed rollouts, dropped prerequisites,
95
+ * lossy individual-target conversions, etc. (the LaunchDarkly converter set).
96
+ * The write paths thread these into MIGRATION_REPORT.md's "Users will be
97
+ * re-bucketed" + "Conversion notes" sections. Null or empty when nothing
98
+ * notable happened during conversion.
99
+ */
100
+ getConversionNotes?(): ConversionNote[] | null;
91
101
  /**
92
102
  * Optional post-translate accumulator. Returns any override sections that were
93
103
  * dropped during translate() calls because the env.id was not present in the
@@ -0,0 +1,59 @@
1
+ /**
2
+ * LaunchDarkly REST API client — Phase-1 config snapshot.
3
+ *
4
+ * LaunchDarkly has no bulk "export everything" endpoint, so the fetcher
5
+ * stitches per-resource calls: environments, context-kinds, flags (full
6
+ * per-env targeting via `?summary=0&env=`), and segments (per environment).
7
+ *
8
+ * Auth is a raw API token in the `Authorization` header — no `Bearer` prefix.
9
+ * Rate limiting is a per-account rolling 10-second window with an unpublished
10
+ * limit, so we program against the headers: back off on 429 using
11
+ * `X-Ratelimit-Reset` (epoch-ms), pre-throttle on `X-Ratelimit-Global-Remaining`.
12
+ */
13
+ import type { LDFlag, LDSegment, LDSnapshot } from './types.js';
14
+ export declare function setLaunchDarklyBaseUrl(url: string): void;
15
+ export declare function selectLaunchDarklyBaseUrl(): string;
16
+ export declare function applyLaunchDarklyBaseUrl(): void;
17
+ export declare function __setSleepForTests(fn: (ms: number) => Promise<void>): void;
18
+ export declare function __resetSleepForTests(): void;
19
+ export declare class LaunchDarklyApiError extends Error {
20
+ readonly status: number;
21
+ constructor(status: number, statusText: string, url: string, body: string);
22
+ }
23
+ /**
24
+ * Single GET with header auth, 429 backoff against `X-Ratelimit-Reset`, and a
25
+ * pre-throttle when `X-Ratelimit-Global-Remaining` runs low. Non-429 errors
26
+ * throw immediately — only rate limiting is retried.
27
+ *
28
+ * Exported so the Phase-2 audit-log walker (`audit.ts`) reuses the exact same
29
+ * rate-limit handling — `/auditlog` is account-wide and the slowest endpoint,
30
+ * so it must not bypass the shared 429 backoff.
31
+ */
32
+ export declare function apiFetch(path: string, apiKey: string): Promise<unknown>;
33
+ /** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
34
+ export declare function fetchProjectEnvironments(apiKey: string, projectKey: string): Promise<string[]>;
35
+ export declare function fetchContextKinds(apiKey: string, projectKey: string): Promise<string[]>;
36
+ /**
37
+ * Full per-environment flag targeting. `?summary=0` plus repeated `?env=`
38
+ * returns rules, clauses, targets, fallthrough, offVariation, prerequisites
39
+ * and rollouts for every named environment in one response — there is no
40
+ * "all environments" wildcard, so env keys are templated in explicitly.
41
+ *
42
+ * `opts.archived` flips the list to *only* archived flags. LaunchDarkly's flag
43
+ * list endpoint hides archived flags by default and has no "both" mode, so a
44
+ * full picture is two calls. The migration runtime does not want archived flags
45
+ * (`fetchSnapshot` omits this), but the fixture-corpus exporter needs them so
46
+ * the `state-archived` edge case is in the canonical corpus.
47
+ */
48
+ export declare function fetchFlags(apiKey: string, projectKey: string, envKeys: string[], opts?: {
49
+ archived?: boolean;
50
+ }): Promise<LDFlag[]>;
51
+ /** Segments are a per-environment resource — caller loops env keys and de-dupes. */
52
+ export declare function fetchSegmentsForEnv(apiKey: string, projectKey: string, envKey: string): Promise<LDSegment[]>;
53
+ /**
54
+ * Stitches the whole Phase-1 snapshot: environments → context-kinds → flags
55
+ * (one call, all envs) → segments (per env, de-duplicated by key). This is the
56
+ * ~60-call "fast, lossless for current state" path; it always re-fetches
57
+ * everything (decision D2 — we do not trust the LD audit cursor for deltas).
58
+ */
59
+ export declare function fetchSnapshot(apiKey: string, projectKey: string): Promise<LDSnapshot>;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * LaunchDarkly REST API client — Phase-1 config snapshot.
3
+ *
4
+ * LaunchDarkly has no bulk "export everything" endpoint, so the fetcher
5
+ * stitches per-resource calls: environments, context-kinds, flags (full
6
+ * per-env targeting via `?summary=0&env=`), and segments (per environment).
7
+ *
8
+ * Auth is a raw API token in the `Authorization` header — no `Bearer` prefix.
9
+ * Rate limiting is a per-account rolling 10-second window with an unpublished
10
+ * limit, so we program against the headers: back off on 429 using
11
+ * `X-Ratelimit-Reset` (epoch-ms), pre-throttle on `X-Ratelimit-Global-Remaining`.
12
+ */
13
+ const PROD_BASE_URL = 'https://app.launchdarkly.com/api/v2';
14
+ const PAGE_LIMIT = 20;
15
+ /** Below this many remaining requests we pre-throttle to avoid a 429 storm. */
16
+ const RATE_LIMIT_FLOOR = 5;
17
+ /** Fallback wait when a 429 carries no usable reset header. */
18
+ const DEFAULT_BACKOFF_MS = 2000;
19
+ /** Hard cap on retries for a single request before we give up. */
20
+ const MAX_RETRIES = 6;
21
+ let baseUrl = PROD_BASE_URL;
22
+ export function setLaunchDarklyBaseUrl(url) {
23
+ baseUrl = url;
24
+ }
25
+ export function selectLaunchDarklyBaseUrl() {
26
+ return process.env.LAUNCHDARKLY_API_URL ?? PROD_BASE_URL;
27
+ }
28
+ export function applyLaunchDarklyBaseUrl() {
29
+ baseUrl = selectLaunchDarklyBaseUrl();
30
+ }
31
+ /** Real timer-backed sleep — swapped out in tests so MSW runs don't actually wait. */
32
+ function timerSleep(ms) {
33
+ return new Promise((resolve) => {
34
+ setTimeout(resolve, ms);
35
+ });
36
+ }
37
+ /** Test seam: a no-op sleep is swapped in so MSW tests don't actually wait. */
38
+ let sleepImpl = timerSleep;
39
+ export function __setSleepForTests(fn) {
40
+ sleepImpl = fn;
41
+ }
42
+ export function __resetSleepForTests() {
43
+ sleepImpl = timerSleep;
44
+ }
45
+ export class LaunchDarklyApiError extends Error {
46
+ status;
47
+ constructor(status, statusText, url, body) {
48
+ super(`LaunchDarkly API request failed: ${status} ${statusText} — ${url}\n${body}`);
49
+ this.name = 'LaunchDarklyApiError';
50
+ this.status = status;
51
+ }
52
+ }
53
+ /** Wait derived from a 429 response's rate-limit headers. */
54
+ function backoffMsFromHeaders(headers) {
55
+ const reset = headers.get('X-Ratelimit-Reset');
56
+ if (reset) {
57
+ const resetMs = Number(reset);
58
+ if (Number.isFinite(resetMs)) {
59
+ // Header is an absolute epoch-ms timestamp; wait until then (clamped ≥ 0).
60
+ return Math.max(0, resetMs - Date.now());
61
+ }
62
+ }
63
+ return DEFAULT_BACKOFF_MS;
64
+ }
65
+ /**
66
+ * Single GET with header auth, 429 backoff against `X-Ratelimit-Reset`, and a
67
+ * pre-throttle when `X-Ratelimit-Global-Remaining` runs low. Non-429 errors
68
+ * throw immediately — only rate limiting is retried.
69
+ *
70
+ * Exported so the Phase-2 audit-log walker (`audit.ts`) reuses the exact same
71
+ * rate-limit handling — `/auditlog` is account-wide and the slowest endpoint,
72
+ * so it must not bypass the shared 429 backoff.
73
+ */
74
+ export async function apiFetch(path, apiKey) {
75
+ const url = path.startsWith('http') ? path : `${baseUrl}${path}`;
76
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
77
+ // eslint-disable-next-line no-await-in-loop
78
+ const res = await fetch(url, {
79
+ headers: {
80
+ Accept: 'application/json',
81
+ Authorization: apiKey,
82
+ },
83
+ });
84
+ if (res.status === 429) {
85
+ if (attempt === MAX_RETRIES) {
86
+ // eslint-disable-next-line no-await-in-loop
87
+ const body = await res.text().catch(() => '(no body)');
88
+ throw new LaunchDarklyApiError(429, res.statusText, url, body);
89
+ }
90
+ // eslint-disable-next-line no-await-in-loop
91
+ await sleepImpl(backoffMsFromHeaders(res.headers));
92
+ continue;
93
+ }
94
+ if (!res.ok) {
95
+ // eslint-disable-next-line no-await-in-loop
96
+ const body = await res.text().catch(() => '(no body)');
97
+ throw new LaunchDarklyApiError(res.status, res.statusText, url, body);
98
+ }
99
+ // Pre-throttle: if the account-wide budget is nearly spent, pause briefly
100
+ // before the next call rather than walking straight into a 429.
101
+ const remaining = res.headers.get('X-Ratelimit-Global-Remaining');
102
+ if (remaining !== null && Number(remaining) <= RATE_LIMIT_FLOOR) {
103
+ // eslint-disable-next-line no-await-in-loop
104
+ await sleepImpl(backoffMsFromHeaders(res.headers));
105
+ }
106
+ return res.json();
107
+ }
108
+ // Unreachable — the loop either returns or throws.
109
+ throw new LaunchDarklyApiError(429, 'Too Many Requests', url, 'retries exhausted');
110
+ }
111
+ /**
112
+ * Walks an offset-paginated collection endpoint. LaunchDarkly returns a
113
+ * `_links.next.href` while more pages remain; we stop when it is absent or a
114
+ * short page is returned.
115
+ */
116
+ async function paginate(firstPath, apiKey) {
117
+ const collected = [];
118
+ let path = firstPath;
119
+ const seen = new Set();
120
+ while (path && !seen.has(path)) {
121
+ seen.add(path);
122
+ // eslint-disable-next-line no-await-in-loop
123
+ const page = (await apiFetch(path, apiKey));
124
+ if (Array.isArray(page.items))
125
+ collected.push(...page.items);
126
+ const next = page._links?.next?.href;
127
+ // `next.href` is server-relative ("/api/v2/..."); strip the shared prefix
128
+ // so it composes with our configurable base URL.
129
+ path = next ? next.replace(/^\/api\/v2/, '') : undefined;
130
+ if (page.items.length < PAGE_LIMIT)
131
+ break;
132
+ }
133
+ return collected;
134
+ }
135
+ /** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
136
+ export async function fetchProjectEnvironments(apiKey, projectKey) {
137
+ const items = await paginate(`/projects/${encodeURIComponent(projectKey)}/environments?limit=${PAGE_LIMIT}`, apiKey);
138
+ return items.map((e) => e.key);
139
+ }
140
+ export async function fetchContextKinds(apiKey, projectKey) {
141
+ const data = (await apiFetch(`/projects/${encodeURIComponent(projectKey)}/context-kinds`, apiKey));
142
+ return Array.isArray(data.items) ? data.items.map((k) => k.key) : [];
143
+ }
144
+ /**
145
+ * Full per-environment flag targeting. `?summary=0` plus repeated `?env=`
146
+ * returns rules, clauses, targets, fallthrough, offVariation, prerequisites
147
+ * and rollouts for every named environment in one response — there is no
148
+ * "all environments" wildcard, so env keys are templated in explicitly.
149
+ *
150
+ * `opts.archived` flips the list to *only* archived flags. LaunchDarkly's flag
151
+ * list endpoint hides archived flags by default and has no "both" mode, so a
152
+ * full picture is two calls. The migration runtime does not want archived flags
153
+ * (`fetchSnapshot` omits this), but the fixture-corpus exporter needs them so
154
+ * the `state-archived` edge case is in the canonical corpus.
155
+ */
156
+ export async function fetchFlags(apiKey, projectKey, envKeys, opts = {}) {
157
+ const params = new URLSearchParams({ limit: String(PAGE_LIMIT), summary: '0' });
158
+ for (const env of envKeys)
159
+ params.append('env', env);
160
+ if (opts.archived)
161
+ params.set('archived', 'true');
162
+ return paginate(`/flags/${encodeURIComponent(projectKey)}?${params}`, apiKey);
163
+ }
164
+ /** Segments are a per-environment resource — caller loops env keys and de-dupes. */
165
+ export async function fetchSegmentsForEnv(apiKey, projectKey, envKey) {
166
+ return paginate(`/segments/${encodeURIComponent(projectKey)}/${encodeURIComponent(envKey)}?limit=${PAGE_LIMIT}`, apiKey);
167
+ }
168
+ /**
169
+ * Stitches the whole Phase-1 snapshot: environments → context-kinds → flags
170
+ * (one call, all envs) → segments (per env, de-duplicated by key). This is the
171
+ * ~60-call "fast, lossless for current state" path; it always re-fetches
172
+ * everything (decision D2 — we do not trust the LD audit cursor for deltas).
173
+ */
174
+ export async function fetchSnapshot(apiKey, projectKey) {
175
+ const environments = await fetchProjectEnvironments(apiKey, projectKey);
176
+ const contextKinds = await fetchContextKinds(apiKey, projectKey);
177
+ const flags = await fetchFlags(apiKey, projectKey, environments);
178
+ const segmentsByKey = new Map();
179
+ for (const envKey of environments) {
180
+ // eslint-disable-next-line no-await-in-loop
181
+ const segs = await fetchSegmentsForEnv(apiKey, projectKey, envKey);
182
+ for (const seg of segs) {
183
+ // First env to define a segment wins; LD segments are per-env but the
184
+ // key is stable, and v1 imports one shell per key (plan §5.4).
185
+ if (!segmentsByKey.has(seg.key))
186
+ segmentsByKey.set(seg.key, seg);
187
+ }
188
+ }
189
+ return {
190
+ contextKinds,
191
+ environments,
192
+ flags,
193
+ segments: [...segmentsByKey.values()],
194
+ };
195
+ }
@@ -0,0 +1,161 @@
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 type { CommitMeta, LegacyChange } from '../../source.js';
27
+ import type { LDFlag } from './types.js';
28
+ /** The human actor on an audit-log entry. Absent for token / integration actors. */
29
+ export interface LDAuditActor {
30
+ _id?: string;
31
+ email?: string;
32
+ firstName?: string;
33
+ lastName?: string;
34
+ }
35
+ /** A row from the `/auditlog` listing — enough to page and to hydrate. */
36
+ export interface LDAuditLogListEntry {
37
+ _id: string;
38
+ /** Epoch ms — the change timestamp and the `before` pagination cursor. */
39
+ date: number;
40
+ name?: string;
41
+ titleVerb?: string;
42
+ }
43
+ /**
44
+ * A fully hydrated `/auditlog/{id}` entry. `currentVersion` is the full
45
+ * resource representation as of this change — for a flag entry it is an
46
+ * `LDFlag`, which `translate()` consumes directly.
47
+ */
48
+ export interface LDAuditLogEntry extends LDAuditLogListEntry {
49
+ app?: {
50
+ name?: string;
51
+ };
52
+ comment?: string;
53
+ currentVersion?: LDFlag;
54
+ /** Null for semantic-patch updates — never relied on; see file header. */
55
+ delta?: unknown;
56
+ description?: string;
57
+ member?: LDAuditActor;
58
+ previousVersion?: unknown;
59
+ shortDescription?: string;
60
+ target?: {
61
+ name?: string;
62
+ resources?: string[];
63
+ };
64
+ token?: {
65
+ name?: string;
66
+ };
67
+ }
68
+ interface LDAuditLogPage {
69
+ _links?: {
70
+ next?: {
71
+ href: string;
72
+ };
73
+ };
74
+ items: LDAuditLogListEntry[];
75
+ }
76
+ /** Result of the up-front retention pre-flight (plan §4.1.1). */
77
+ export interface RetentionHorizon {
78
+ /**
79
+ * True when no audit entry older than 30 days was found — the account is
80
+ * almost certainly on an LD Developer plan and pre-window history is gone.
81
+ */
82
+ developerPlanLikely: boolean;
83
+ /** Human-readable summary for the CLI to print before the slow phase. */
84
+ label: string;
85
+ /**
86
+ * Epoch ms of the oldest entry the bracket probe could reach, or null when
87
+ * nothing older than 30 days is retained.
88
+ */
89
+ oldestReachableMs: null | number;
90
+ }
91
+ export interface WalkAuditLogOptions {
92
+ /** Floor — stop once the stream reaches entries at or before this epoch ms. */
93
+ after?: number;
94
+ /**
95
+ * Resumable checkpoint hook — invoked after every listing page with the
96
+ * `before` cursor to restart from. Persist this so a crashed multi-hour walk
97
+ * resumes instead of restarting.
98
+ */
99
+ onCheckpoint?: (before: number) => Promise<void> | void;
100
+ /** LD resource spec, e.g. `proj/<key>:env/*:flag/*`. See `buildFlagAuditSpec`. */
101
+ spec?: string;
102
+ /** Resume cursor — start strictly older than this epoch ms instead of newest. */
103
+ startBefore?: number;
104
+ }
105
+ /** The LD audit-log resource spec that scopes the walk to every flag in a project. */
106
+ export declare function buildFlagAuditSpec(projectKey: string): string;
107
+ /** One page of the `/auditlog` listing. `limit` is pinned to the API's max of 20. */
108
+ export declare function fetchAuditLogPage(apiKey: string, opts?: {
109
+ after?: number;
110
+ before?: number;
111
+ spec?: string;
112
+ }): Promise<LDAuditLogPage>;
113
+ /** Hydrate one listing row into a full entry — this is the call that carries `currentVersion`. */
114
+ export declare function fetchAuditLogEntry(apiKey: string, id: string): Promise<LDAuditLogEntry>;
115
+ /**
116
+ * Walk the audit log newest-to-oldest, hydrating every listing row. Pages on
117
+ * the `before` cursor (the oldest `date` seen so far) so the walk is resumable:
118
+ * pass `startBefore` to resume, and persist each `onCheckpoint(before)` value.
119
+ *
120
+ * Terminates on the first empty page; an `after` floor stops it early. A
121
+ * non-decreasing cursor (a misbehaving API or a same-ms tie at a page edge)
122
+ * also terminates the walk rather than looping forever.
123
+ */
124
+ export declare function walkAuditLog(apiKey: string, opts?: WalkAuditLogOptions): AsyncIterable<LDAuditLogEntry>;
125
+ /**
126
+ * Up-front retention pre-flight (plan §4.1.1). Brackets the account's retention
127
+ * window with one cheap `limit=1` probe per boundary instead of walking the
128
+ * whole log. An empty 30-day probe means the account retains ≤ 30 days — almost
129
+ * certainly an LD Developer plan, and pre-window history is permanently gone.
130
+ */
131
+ export declare function probeRetentionHorizon(apiKey: string, opts?: {
132
+ now?: number;
133
+ spec?: string;
134
+ }): Promise<RetentionHorizon>;
135
+ /**
136
+ * The `LegacyChange.raw` payload for a Phase-2 audit change. Extends the
137
+ * Phase-1 `{kind: 'flag', data}` shape `translate()` already understands with
138
+ * the originating `auditEntry`, so the source's `getCommitMeta` can reify the
139
+ * original author / date / message without a second lookup.
140
+ */
141
+ export interface LaunchDarklyFlagAuditRaw {
142
+ auditEntry: LDAuditLogEntry;
143
+ data: LDFlag;
144
+ kind: 'flag';
145
+ }
146
+ /**
147
+ * Map a hydrated audit entry to a `LegacyChange`. The `currentVersion` flag
148
+ * snapshot is carried as the `{kind: 'flag', data}` payload `translate()`
149
+ * already understands, so Phase-2 history reuses the Phase-1 converter
150
+ * unchanged. Returns null for entries that carry no flag `currentVersion`
151
+ * (deleted resources, non-flag audit rows) — those are skipped, not crashed.
152
+ */
153
+ export declare function auditEntryToLegacyChange(entry: LDAuditLogEntry): LegacyChange | null;
154
+ /**
155
+ * Reify an audit entry's original author, timestamp and description into commit
156
+ * metadata for `--full-summary` (plan §7). Returns null when the entry has no
157
+ * human member (token / integration actors) so the caller falls back to the
158
+ * migrator identity rather than inventing an author.
159
+ */
160
+ export declare function getCommitMetaForAuditEntry(entry: LDAuditLogEntry): CommitMeta | null;
161
+ export {};