@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.
- package/README.md +71 -66
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.js +68 -8
- package/dist/migrate/local-write.js +4 -0
- package/dist/migrate/migration-report.d.ts +9 -0
- package/dist/migrate/migration-report.js +68 -0
- package/dist/migrate/push-to-cloud.js +2 -0
- package/dist/migrate/quonfig-target/operators.d.ts +27 -0
- package/dist/migrate/quonfig-target/operators.js +71 -0
- package/dist/migrate/quonfig-target/report.d.ts +59 -0
- package/dist/migrate/quonfig-target/report.js +39 -0
- package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
- package/dist/migrate/quonfig-target/ruleset.js +192 -0
- package/dist/migrate/quonfig-target/values.d.ts +38 -0
- package/dist/migrate/quonfig-target/values.js +77 -0
- package/dist/migrate/source-api-key.d.ts +33 -0
- package/dist/migrate/source-api-key.js +43 -0
- package/dist/migrate/source.d.ts +10 -0
- package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
- package/dist/migrate/sources/launchdarkly/api.js +195 -0
- package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
- package/dist/migrate/sources/launchdarkly/audit.js +172 -0
- package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
- package/dist/migrate/sources/launchdarkly/translate.js +209 -0
- package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
- package/dist/migrate/sources/launchdarkly/types.js +10 -0
- package/dist/migrate/sources/launchdarkly.d.ts +35 -0
- package/dist/migrate/sources/launchdarkly.js +295 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +178 -161
- 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
|
+
}
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -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 {};
|