@quonfig/cli 0.0.47 → 0.0.49
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 +17 -2
- 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/util/clone-and-stack-push.js +8 -2
- package/dist/util/git-ops.d.ts +6 -0
- package/dist/util/git-ops.js +8 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +178 -161
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared verb: collapse a provider's targeting model into Quonfig's ordered,
|
|
3
|
+
* first-match-wins rule list.
|
|
4
|
+
*
|
|
5
|
+
* Part of the `quonfig-target/` verb library (plan §3.1, D1). The hard part of
|
|
6
|
+
* every converter is topology translation into Quonfig's specific shape — no
|
|
7
|
+
* "flag off" toggle, no individual-target primitive, no within-rule OR. These
|
|
8
|
+
* verbs do the LaunchDarkly collapse; a second provider with a different
|
|
9
|
+
* topology composes them differently.
|
|
10
|
+
*/
|
|
11
|
+
import { mapLaunchDarklyOperator } from './operators.js';
|
|
12
|
+
import { toQuonfigValue } from './values.js';
|
|
13
|
+
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
14
|
+
/** Operators whose `valueToMatch` is a `string_list` of every clause value. */
|
|
15
|
+
const STRING_LIST_OPERATORS = new Set([
|
|
16
|
+
'PROP_CONTAINS_ONE_OF',
|
|
17
|
+
'PROP_DOES_NOT_CONTAIN_ONE_OF',
|
|
18
|
+
'PROP_DOES_NOT_END_WITH_ONE_OF',
|
|
19
|
+
'PROP_DOES_NOT_START_WITH_ONE_OF',
|
|
20
|
+
'PROP_ENDS_WITH_ONE_OF',
|
|
21
|
+
'PROP_IS_NOT_ONE_OF',
|
|
22
|
+
'PROP_IS_ONE_OF',
|
|
23
|
+
'PROP_STARTS_WITH_ONE_OF',
|
|
24
|
+
]);
|
|
25
|
+
const NUMERIC_OPERATORS = new Set([
|
|
26
|
+
'PROP_GREATER_THAN',
|
|
27
|
+
'PROP_GREATER_THAN_OR_EQUAL',
|
|
28
|
+
'PROP_LESS_THAN',
|
|
29
|
+
'PROP_LESS_THAN_OR_EQUAL',
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* LaunchDarkly attribute → Quonfig property name. The context kind becomes a
|
|
33
|
+
* namespace prefix (plan §5.1: `organization` + `plan` → `organization.plan`),
|
|
34
|
+
* defaulting to `user`. A nested JSON-pointer attribute (`/address/city`)
|
|
35
|
+
* becomes a dotted path (`address.city`).
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeAttribute(contextKind, attribute) {
|
|
38
|
+
const kind = contextKind && contextKind.length > 0 ? contextKind : 'user';
|
|
39
|
+
const dotted = attribute.replace(/^\//, '').replaceAll('/', '.');
|
|
40
|
+
return `${kind}.${dotted}`;
|
|
41
|
+
}
|
|
42
|
+
/** Build a `valueToMatch` typed value for a non-presence, non-segment clause. */
|
|
43
|
+
function valueToMatchFor(operator, clause, ctx) {
|
|
44
|
+
if (STRING_LIST_OPERATORS.has(operator)) {
|
|
45
|
+
return { type: 'string_list', value: clause.values.map(String) };
|
|
46
|
+
}
|
|
47
|
+
if (operator === 'IN_SEG' || operator === 'NOT_IN_SEG') {
|
|
48
|
+
if (clause.values.length > 1) {
|
|
49
|
+
ctx.report.add('skipped-rule', ctx.sourceKey, `segmentMatch clause listed ${clause.values.length} segments; Quonfig IN_SEG matches one — only "${String(clause.values[0])}" kept, the rest dropped`);
|
|
50
|
+
}
|
|
51
|
+
return { type: 'string', value: String(clause.values[0] ?? '') };
|
|
52
|
+
}
|
|
53
|
+
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
54
|
+
if (clause.values.length > 1) {
|
|
55
|
+
ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause listed ${clause.values.length} patterns; Quonfig PROP_MATCHES takes one — only the first kept`);
|
|
56
|
+
}
|
|
57
|
+
return { type: 'string', value: String(clause.values[0] ?? '') };
|
|
58
|
+
}
|
|
59
|
+
if (NUMERIC_OPERATORS.has(operator)) {
|
|
60
|
+
const raw = clause.values[0];
|
|
61
|
+
const num = typeof raw === 'number' ? raw : Number(raw);
|
|
62
|
+
return { type: Number.isInteger(num) ? 'int' : 'double', value: num };
|
|
63
|
+
}
|
|
64
|
+
if (operator === 'PROP_BEFORE' || operator === 'PROP_AFTER') {
|
|
65
|
+
const raw = clause.values[0];
|
|
66
|
+
// LD before/after take an epoch-ms number or an RFC3339 string; the SDK's
|
|
67
|
+
// dateToMillis accepts both, so pass the native shape straight through.
|
|
68
|
+
return typeof raw === 'number' ? { type: 'int', value: raw } : { type: 'string', value: String(raw) };
|
|
69
|
+
}
|
|
70
|
+
// Semver operators — the SDK requires a string value.
|
|
71
|
+
return { type: 'string', value: String(clause.values[0] ?? '') };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Map one provider clause to a Quonfig criterion, or `null` when the clause's
|
|
75
|
+
* operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
|
|
76
|
+
* A null result means the whole containing rule must be dropped: keeping a
|
|
77
|
+
* partial AND would silently broaden targeting.
|
|
78
|
+
*/
|
|
79
|
+
export function clauseToCriterion(clause, ctx) {
|
|
80
|
+
const mapping = mapLaunchDarklyOperator(clause.op, Boolean(clause.negate));
|
|
81
|
+
if ('skip' in mapping) {
|
|
82
|
+
ctx.report.add('skipped-rule', ctx.sourceKey, `clause on "${clause.attribute}" skipped: ${mapping.reason}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const { operator } = mapping;
|
|
86
|
+
const propertyName = normalizeAttribute(clause.contextKind, clause.attribute);
|
|
87
|
+
const criterion = { operator, propertyName };
|
|
88
|
+
criterion.valueToMatch = valueToMatchFor(operator, clause, ctx);
|
|
89
|
+
return criterion;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
|
|
93
|
+
* served value. Returns `null` when any clause cannot be mapped — the rule is
|
|
94
|
+
* dropped wholesale and reported, rather than silently broadened.
|
|
95
|
+
*/
|
|
96
|
+
export function buildRule(clauses, value, ctx) {
|
|
97
|
+
const criteria = [];
|
|
98
|
+
for (const clause of clauses) {
|
|
99
|
+
const criterion = clauseToCriterion(clause, ctx);
|
|
100
|
+
if (criterion === null)
|
|
101
|
+
return null;
|
|
102
|
+
criteria.push(criterion);
|
|
103
|
+
}
|
|
104
|
+
// A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
|
|
105
|
+
return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
|
|
106
|
+
}
|
|
107
|
+
/** Resolve a variation index to its typed Quonfig value. */
|
|
108
|
+
function variationValue(index, ctx) {
|
|
109
|
+
const raw = ctx.variationValues[index];
|
|
110
|
+
return toQuonfigValue(raw, ctx.valueType);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Convert a provider rollout to a Quonfig `weighted_values`. The experiment
|
|
114
|
+
* framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
|
|
115
|
+
* and every rollout is recorded as re-bucketing (LD and Quonfig hash
|
|
116
|
+
* differently, so post-migration bucket assignments differ).
|
|
117
|
+
*/
|
|
118
|
+
export function rolloutToWeightedValues(rollout, ctx) {
|
|
119
|
+
if (rollout.kind === 'experiment') {
|
|
120
|
+
ctx.report.add('dropped-experiment-metadata', ctx.sourceKey, `experiment rollout converted to a plain weighted_values; experiment kind${rollout.seed === undefined ? '' : ` and seed ${rollout.seed}`} dropped`);
|
|
121
|
+
}
|
|
122
|
+
const hashByPropertyName = rollout.bucketBy
|
|
123
|
+
? normalizeAttribute(rollout.contextKind, rollout.bucketBy)
|
|
124
|
+
: `${rollout.contextKind && rollout.contextKind.length > 0 ? rollout.contextKind : 'user'}.key`;
|
|
125
|
+
ctx.report.add('rebucketed-rollout', ctx.sourceKey, `percentage rollout hashed on "${hashByPropertyName}" — LaunchDarkly and Quonfig bucket differently, so users will be re-bucketed`);
|
|
126
|
+
return {
|
|
127
|
+
type: 'weighted_values',
|
|
128
|
+
value: {
|
|
129
|
+
hashByPropertyName,
|
|
130
|
+
weightedValues: rollout.variations.map((wv) => ({
|
|
131
|
+
value: variationValue(wv.variation, ctx),
|
|
132
|
+
weight: wv.weight,
|
|
133
|
+
})),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Resolve a `variation`-or-`rollout` pointer (rule served value / fallthrough). */
|
|
138
|
+
export function servedValue(voR, ctx) {
|
|
139
|
+
if (voR.rollout)
|
|
140
|
+
return rolloutToWeightedValues(voR.rollout, ctx);
|
|
141
|
+
return variationValue(voR.variation ?? 0, ctx);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Collapse a single LaunchDarkly per-environment flag state into an ordered,
|
|
145
|
+
* first-match-wins Quonfig rule list.
|
|
146
|
+
*
|
|
147
|
+
* - `on: false` → one ALWAYS_TRUE rule serving the `offVariation` value.
|
|
148
|
+
* - `on: true` → individual/context targets become leading PROP_IS_ONE_OF
|
|
149
|
+
* rules; `rules[]` translate in order; `fallthrough` becomes a trailing
|
|
150
|
+
* ALWAYS_TRUE rule. Prerequisites are dropped + reported (no Quonfig
|
|
151
|
+
* cross-flag dependency operator in v1).
|
|
152
|
+
*/
|
|
153
|
+
export function collapseEnvironment(env, ctx) {
|
|
154
|
+
if (!env.on) {
|
|
155
|
+
return [{ criteria: [ALWAYS_TRUE], value: variationValue(env.offVariation ?? 0, ctx) }];
|
|
156
|
+
}
|
|
157
|
+
const rules = [];
|
|
158
|
+
if (env.prerequisites && env.prerequisites.length > 0) {
|
|
159
|
+
ctx.report.add('dropped-prerequisite', ctx.sourceKey, `${env.prerequisites.length} prerequisite(s) dropped (${env.prerequisites
|
|
160
|
+
.map((p) => p.key)
|
|
161
|
+
.join(', ')}) — Quonfig has no cross-flag dependency operator`);
|
|
162
|
+
}
|
|
163
|
+
// Individual + context targets evaluate before rules in LaunchDarkly, so
|
|
164
|
+
// they become leading rules. Lossy: the LD UI affordance is gone (plan §5.4).
|
|
165
|
+
const allTargets = [...(env.targets ?? []), ...(env.contextTargets ?? [])];
|
|
166
|
+
for (const target of allTargets) {
|
|
167
|
+
if (target.values.length === 0)
|
|
168
|
+
continue;
|
|
169
|
+
const kind = target.contextKind && target.contextKind.length > 0 ? target.contextKind : 'user';
|
|
170
|
+
rules.push({
|
|
171
|
+
criteria: [
|
|
172
|
+
{
|
|
173
|
+
operator: 'PROP_IS_ONE_OF',
|
|
174
|
+
propertyName: `${kind}.key`,
|
|
175
|
+
valueToMatch: { type: 'string_list', value: [...target.values] },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
value: variationValue(target.variation, ctx),
|
|
179
|
+
});
|
|
180
|
+
ctx.report.add('individual-target-as-rule', ctx.sourceKey, `${target.values.length} ${kind} target(s) converted to a leading PROP_IS_ONE_OF rule on ${kind}.key`);
|
|
181
|
+
}
|
|
182
|
+
for (const rule of env.rules ?? []) {
|
|
183
|
+
const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
|
|
184
|
+
const built = buildRule(rule.clauses, value, ctx);
|
|
185
|
+
if (built !== null)
|
|
186
|
+
rules.push(built);
|
|
187
|
+
}
|
|
188
|
+
if (env.fallthrough) {
|
|
189
|
+
rules.push({ criteria: [ALWAYS_TRUE], value: servedValue(env.fallthrough, ctx) });
|
|
190
|
+
}
|
|
191
|
+
return rules;
|
|
192
|
+
}
|
|
@@ -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
|
+
}
|