@quonfig/cli 0.0.49 → 0.0.51
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 +50 -48
- package/dist/commands/generate.js +21 -5
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +28 -1
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.js +16 -5
- package/dist/commands/pull.js +2 -2
- package/dist/init/templates.js +59 -12
- package/dist/migrate/local-write.js +11 -1
- package/dist/migrate/migration-report.d.ts +16 -5
- package/dist/migrate/migration-report.js +351 -20
- package/dist/migrate/push-to-cloud.js +6 -1
- package/dist/migrate/quonfig-target/report.d.ts +45 -4
- package/dist/migrate/quonfig-target/report.js +5 -2
- package/dist/migrate/quonfig-target/ruleset.js +8 -6
- package/dist/migrate/source.d.ts +27 -0
- package/dist/migrate/sources/flagsmith/api.d.ts +115 -0
- package/dist/migrate/sources/flagsmith/api.js +402 -0
- package/dist/migrate/sources/flagsmith/translate.d.ts +72 -0
- package/dist/migrate/sources/flagsmith/translate.js +657 -0
- package/dist/migrate/sources/flagsmith/types.d.ts +282 -0
- package/dist/migrate/sources/flagsmith/types.js +18 -0
- package/dist/migrate/sources/flagsmith.d.ts +62 -0
- package/dist/migrate/sources/flagsmith.js +380 -23
- package/dist/migrate/sources/launchdarkly/api.d.ts +22 -1
- package/dist/migrate/sources/launchdarkly/api.js +23 -1
- package/dist/migrate/sources/launchdarkly/translate.js +42 -2
- package/dist/migrate/sources/launchdarkly/types.d.ts +19 -0
- package/dist/migrate/sources/launchdarkly.js +75 -4
- package/dist/util/friction-log.d.ts +1 -0
- package/dist/util/friction-log.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +158 -160
- package/package.json +2 -2
|
@@ -1,28 +1,385 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `qfg migrate --from flagsmith` — the MigrationSource implementation.
|
|
3
|
+
*
|
|
4
|
+
* Wires the Flagsmith fetcher (`flagsmith/api.ts` + `flagsmith/types.ts`) and
|
|
5
|
+
* converter (`flagsmith/translate.ts`) into the source-agnostic migration
|
|
6
|
+
* framework. Epic 1 implemented the read side (validateAuth, listEnvironments,
|
|
7
|
+
* fetchChanges yielding LegacyChange[] carrying raw Flagsmith JSON); Epic 3
|
|
8
|
+
* adds the translate() side — `translateFeature` and `translateSegment`
|
|
9
|
+
* dispatched by `raw.kind` (see `FlagsmithRaw` below). Report accumulators
|
|
10
|
+
* (`getSkippedConfigs`, `getCoercedSentinels`, `getConversionNotes`) thread
|
|
11
|
+
* the per-source notes into MIGRATION_REPORT.md.
|
|
12
|
+
*
|
|
13
|
+
* Design is frozen in `project/plans/migrator-flagsmith.md`. Notable choices
|
|
14
|
+
* for this epic — the contract Epic 3 will consume:
|
|
15
|
+
*
|
|
16
|
+
* - `LegacyChange.raw` is a discriminated union over `{kind: 'feature' | 'segment'}`.
|
|
17
|
+
* Features carry the full per-feature stitched bundle (env-default +
|
|
18
|
+
* segment-overrides + identity-overrides + feature-segment priority +
|
|
19
|
+
* inline MV options sorted by id asc). Segments are emitted separately
|
|
20
|
+
* (project-scoped, referenced from multiple features).
|
|
21
|
+
*
|
|
22
|
+
* - Tags are NOT separate LegacyChanges — they're inline `feature.tags[]` as
|
|
23
|
+
* integer IDs, plus a project-wide tag pool stashed in the source state for
|
|
24
|
+
* the converter to resolve id → label.
|
|
25
|
+
*
|
|
26
|
+
* - Edge identities are NOT separate LegacyChanges. Identity overrides are
|
|
27
|
+
* bundled inside the feature payload (`identity_overrides`); the identity
|
|
28
|
+
* `identifier` is already on each override row. Traits are runtime context
|
|
29
|
+
* the SDK sends — no server-side artifact to migrate (plan §5.5, D-F6).
|
|
30
|
+
*
|
|
31
|
+
* - Project metadata (`use_edge_identities`, `only_allow_lower_case_feature_names`,
|
|
32
|
+
* …) is captured during `validateAuth` and stashed in source state. The
|
|
33
|
+
* converter can read it via `getProjectMetadata()` (a Flagsmith-specific
|
|
34
|
+
* getter, not part of the MigrationSource interface).
|
|
35
|
+
*/
|
|
36
|
+
import { ConversionReport } from '../quonfig-target/report.js';
|
|
37
|
+
import { fetchProject, fetchSnapshot } from './flagsmith/api.js';
|
|
38
|
+
import { flagOutputPath, segmentOutputPath, translateFeature, translateSegment } from './flagsmith/translate.js';
|
|
12
39
|
const SOURCE_NAME = 'flagsmith';
|
|
40
|
+
const DEFAULT_PROJECT_ID = '1';
|
|
41
|
+
function resolveProjectId() {
|
|
42
|
+
return process.env.FLAGSMITH_PROJECT_ID ?? DEFAULT_PROJECT_ID;
|
|
43
|
+
}
|
|
44
|
+
const state = {
|
|
45
|
+
apiKey: null,
|
|
46
|
+
environments: [],
|
|
47
|
+
multivariateFeatures: [],
|
|
48
|
+
project: null,
|
|
49
|
+
projectId: resolveProjectId(),
|
|
50
|
+
report: new ConversionReport(),
|
|
51
|
+
segments: [],
|
|
52
|
+
tags: [],
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Override the Flagsmith project ID for this run. Called by `qfg migrate` from
|
|
56
|
+
* its `--project` flag (or the `FLAGSMITH_PROJECT_ID` env var) — mirrors
|
|
57
|
+
* `setLaunchDarklyProjectKey`. Flagsmith projects are numeric, but we accept
|
|
58
|
+
* either a number or a numeric-string and convert at the API boundary.
|
|
59
|
+
*/
|
|
60
|
+
export function setFlagsmithProjectId(projectId) {
|
|
61
|
+
state.projectId = String(projectId);
|
|
62
|
+
}
|
|
63
|
+
/** The project metadata captured by validateAuth, or null if we haven't authed yet. */
|
|
64
|
+
export function getFlagsmithProject() {
|
|
65
|
+
return state.project;
|
|
66
|
+
}
|
|
67
|
+
/** The tag pool from the last snapshot — Epic 3's converter resolves `feature.tags[]` IDs to labels via this. */
|
|
68
|
+
export function getFlagsmithTagPool() {
|
|
69
|
+
return [...state.tags];
|
|
70
|
+
}
|
|
71
|
+
class MissingAuthError extends Error {
|
|
72
|
+
constructor(operation) {
|
|
73
|
+
super(`flagsmith source ${operation} requires validateAuth(apiKey) to be called first (no API key configured).`);
|
|
74
|
+
this.name = 'MissingAuthError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function requireApiKey(operation) {
|
|
78
|
+
if (!state.apiKey)
|
|
79
|
+
throw new MissingAuthError(operation);
|
|
80
|
+
return state.apiKey;
|
|
81
|
+
}
|
|
82
|
+
/** Flagsmith env api_keys are random strings; we surface env `name` slugified. */
|
|
83
|
+
function slugifyEnvName(name) {
|
|
84
|
+
return name
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
87
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
88
|
+
}
|
|
89
|
+
async function validateAuthImpl(apiKey) {
|
|
90
|
+
// The cheapest authenticated probe is the project endpoint — 401s on a bad
|
|
91
|
+
// token, 404s on a bad project, and we need `use_edge_identities` for the
|
|
92
|
+
// snapshot walk anyway, so capture it here.
|
|
93
|
+
const project = await fetchProject(apiKey, state.projectId);
|
|
94
|
+
state.apiKey = apiKey;
|
|
95
|
+
state.project = project;
|
|
96
|
+
state.report = new ConversionReport();
|
|
97
|
+
state.environments = [];
|
|
98
|
+
state.multivariateFeatures = [];
|
|
99
|
+
state.segments = [];
|
|
100
|
+
state.tags = [];
|
|
101
|
+
}
|
|
102
|
+
async function listEnvironmentsImpl() {
|
|
103
|
+
const apiKey = requireApiKey('listEnvironments');
|
|
104
|
+
// We could do a dedicated fetchEnvironments call here, but fetchSnapshot
|
|
105
|
+
// will do that as part of fetchChanges anyway. To keep listEnvironments
|
|
106
|
+
// cheap (called by the migrate command before fetchChanges), we do a
|
|
107
|
+
// standalone env fetch and stash the results — fetchSnapshot will refresh
|
|
108
|
+
// them when it runs.
|
|
109
|
+
const { fetchEnvironments } = await import('./flagsmith/api.js');
|
|
110
|
+
const envs = await fetchEnvironments(apiKey, state.projectId);
|
|
111
|
+
state.environments = envs;
|
|
112
|
+
return [...new Set(envs.map((e) => slugifyEnvName(e.name)))];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Phase-1 config snapshot. Decision D2: always a full re-snapshot — we do not
|
|
116
|
+
* try to use Flagsmith's audit log for deltas (cursor stability across v2
|
|
117
|
+
* versioning rewrites is unverified). `sinceEpochMs` is currently unused;
|
|
118
|
+
* Epic 5 will use it for reporting "what changed since last run" without
|
|
119
|
+
* affecting the fetch itself.
|
|
120
|
+
*
|
|
121
|
+
* Yields one LegacyChange per feature (with the full per-env state bundle)
|
|
122
|
+
* followed by one LegacyChange per segment. Identity overrides are bundled
|
|
123
|
+
* inside the feature payload.
|
|
124
|
+
*/
|
|
125
|
+
async function* fetchChangesImpl() {
|
|
126
|
+
const apiKey = requireApiKey('fetchChanges');
|
|
127
|
+
const snapshot = await fetchSnapshot(apiKey, state.projectId);
|
|
128
|
+
state.project = snapshot.project;
|
|
129
|
+
state.environments = snapshot.environments;
|
|
130
|
+
state.segments = snapshot.segments;
|
|
131
|
+
state.tags = snapshot.tags;
|
|
132
|
+
state.multivariateFeatures = snapshot.features
|
|
133
|
+
.filter((bundle) => bundle.feature.type === 'MULTIVARIATE' && (bundle.feature.multivariate_options?.length ?? 0) > 0)
|
|
134
|
+
.map((bundle) => bundle.feature.name);
|
|
135
|
+
for (const featureBundle of snapshot.features) {
|
|
136
|
+
yield {
|
|
137
|
+
key: featureBundle.feature.name,
|
|
138
|
+
raw: { data: featureBundle, kind: 'feature' },
|
|
139
|
+
source: SOURCE_NAME,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
for (const segment of snapshot.segments) {
|
|
143
|
+
yield {
|
|
144
|
+
key: segment.name,
|
|
145
|
+
raw: { data: segment, kind: 'segment' },
|
|
146
|
+
source: SOURCE_NAME,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Dispatch by raw.kind to the right translator (plan §5.1). Last-resort
|
|
152
|
+
* safety net mirrors the LD source — one structurally broken feature must
|
|
153
|
+
* not abort the whole run, so conversion errors land as `skipped-config`
|
|
154
|
+
* report entries and translate returns `[]`.
|
|
155
|
+
*/
|
|
156
|
+
function translateImpl(change) {
|
|
157
|
+
const raw = change.raw;
|
|
158
|
+
if (!raw || typeof raw !== 'object' || !('kind' in raw))
|
|
159
|
+
return [];
|
|
160
|
+
try {
|
|
161
|
+
if (raw.kind === 'feature') {
|
|
162
|
+
const segmentNameById = new Map();
|
|
163
|
+
for (const seg of state.segments)
|
|
164
|
+
segmentNameById.set(seg.id, seg.name);
|
|
165
|
+
const envNameByApiKey = new Map();
|
|
166
|
+
for (const env of state.environments)
|
|
167
|
+
envNameByApiKey.set(env.api_key, env.name);
|
|
168
|
+
const out = translateFeature(raw.data, state.report, {
|
|
169
|
+
envNameByApiKey,
|
|
170
|
+
segmentNameById,
|
|
171
|
+
tags: state.tags,
|
|
172
|
+
});
|
|
173
|
+
return [{ contents: JSON.stringify(out, null, 2), path: flagOutputPath(raw.data.feature.name) }];
|
|
174
|
+
}
|
|
175
|
+
const out = translateSegment(raw.data, state.report);
|
|
176
|
+
return [{ contents: JSON.stringify(out, null, 2), path: segmentOutputPath(raw.data.name) }];
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
state.report.add('skipped-config', change.key ?? 'unknown', `conversion failed: ${message}`);
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Configs that could not be converted at all (matches LD `getSkippedConfigs`). */
|
|
185
|
+
function getSkippedConfigsImpl() {
|
|
186
|
+
const skipped = state.report.byCategory('skipped-config');
|
|
187
|
+
if (skipped.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
return {
|
|
190
|
+
entries: skipped.map((n) => ({ key: n.key, reason: n.detail })),
|
|
191
|
+
total: skipped.length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Per-flag rule-value coercions Flagsmith forced — every `enabled-false-non-boolean`
|
|
196
|
+
* + `cross-env-value-type-coerced` note is counted here so the existing
|
|
197
|
+
* "rule values were coerced" rollup in MIGRATION_REPORT.md surfaces them.
|
|
198
|
+
*
|
|
199
|
+
* The bookkeeping is per-Quonfig-env (the env-name the customer sees in the
|
|
200
|
+
* report and in the file tree). D-F1 notes carry `env "<api_key>"` in their
|
|
201
|
+
* detail string from `translate.ts`; we regex it out and map api_key →
|
|
202
|
+
* slugified env name via the captured environments. Notes that don't carry an
|
|
203
|
+
* env reference (cross-env D-F5; rare malformed details) bucket under a
|
|
204
|
+
* synthetic `_cross-env` key so they still surface but are visually distinct.
|
|
205
|
+
*/
|
|
206
|
+
function getCoercedSentinelsImpl() {
|
|
207
|
+
const enabledFalse = state.report.byCategory('enabled-false-non-boolean');
|
|
208
|
+
const crossEnv = state.report.byCategory('cross-env-value-type-coerced');
|
|
209
|
+
if (enabledFalse.length === 0 && crossEnv.length === 0)
|
|
210
|
+
return null;
|
|
211
|
+
const envSlugByApiKey = new Map();
|
|
212
|
+
for (const env of state.environments)
|
|
213
|
+
envSlugByApiKey.set(env.api_key, slugifyEnvName(env.name));
|
|
214
|
+
const byEnv = {};
|
|
215
|
+
let total = 0;
|
|
216
|
+
const addToEnv = (envKey, outputPath) => {
|
|
217
|
+
const env = byEnv[envKey] ?? {};
|
|
218
|
+
env[outputPath] = (env[outputPath] ?? 0) + 1;
|
|
219
|
+
byEnv[envKey] = env;
|
|
220
|
+
total += 1;
|
|
221
|
+
};
|
|
222
|
+
// D-F1: detail strings look like `... in env "<api_key>" ...` (env-default
|
|
223
|
+
// path), `... identity override "<id>" ...`, or `... segment override "<name>" ...`.
|
|
224
|
+
// Only the env-default flavor includes an api_key; the override flavors are
|
|
225
|
+
// implicitly all-envs (they fire once per override row, which is always tied
|
|
226
|
+
// to an env, but the detail doesn't surface it). For override rows the
|
|
227
|
+
// bucketing reflects what we can know: "all envs" (`_all-envs`).
|
|
228
|
+
for (const note of enabledFalse) {
|
|
229
|
+
const path = flagOutputPath(note.key);
|
|
230
|
+
const match = /env "([^"]+)"/.exec(note.detail);
|
|
231
|
+
if (match) {
|
|
232
|
+
const apiKey = match[1];
|
|
233
|
+
const slug = envSlugByApiKey.get(apiKey) ?? apiKey;
|
|
234
|
+
addToEnv(slug, path);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// identity/segment override rows: surface under a synthetic bucket so
|
|
238
|
+
// the section still names the flag even when env attribution is lossy.
|
|
239
|
+
addToEnv('_all-envs', path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// D-F5: cross-env divergence is a feature-wide finding — coerce target is
|
|
243
|
+
// string regardless of which env disagreed. Surface under a distinct bucket
|
|
244
|
+
// so the renderer doesn't show "0 from N envs" double-counting.
|
|
245
|
+
for (const note of crossEnv) {
|
|
246
|
+
addToEnv('_cross-env', flagOutputPath(note.key));
|
|
247
|
+
}
|
|
248
|
+
return { byEnv, total };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Currently no override sections are dropped due to env-id mismatch — Flagsmith
|
|
252
|
+
* has no equivalent of the Launch-source's archived/missing env case. Kept
|
|
253
|
+
* present so the report machinery's null-check still wires through.
|
|
254
|
+
*/
|
|
255
|
+
function getDroppedOverridesImpl() {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
/** The full conversion report — every skip/drop/coerce note from this run. */
|
|
259
|
+
export function getConversionReport() {
|
|
260
|
+
return state.report;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Walk every segment rule (recursively across nested `rules[]`) and collect
|
|
264
|
+
* every `condition.property` reference. These are the trait/context attribute
|
|
265
|
+
* names that downstream Quonfig callers will need to send at eval time
|
|
266
|
+
* (plan §5.5 / D-F6). The `'user.key'` Quonfig built-in is excluded — it's the
|
|
267
|
+
* caller-identity slot that's already required for any flag eval.
|
|
268
|
+
*/
|
|
269
|
+
function collectIdentityTraits() {
|
|
270
|
+
const traitToSegments = new Map();
|
|
271
|
+
const visitRule = (rule, segmentName) => {
|
|
272
|
+
for (const cond of rule.conditions ?? []) {
|
|
273
|
+
const prop = cond.property;
|
|
274
|
+
if (!prop || prop.length === 0)
|
|
275
|
+
continue;
|
|
276
|
+
if (prop === 'user.key')
|
|
277
|
+
continue;
|
|
278
|
+
const set = traitToSegments.get(prop) ?? new Set();
|
|
279
|
+
set.add(segmentName);
|
|
280
|
+
traitToSegments.set(prop, set);
|
|
281
|
+
}
|
|
282
|
+
for (const child of rule.rules ?? [])
|
|
283
|
+
visitRule(child, segmentName);
|
|
284
|
+
};
|
|
285
|
+
for (const segment of state.segments) {
|
|
286
|
+
for (const rule of segment.rules ?? [])
|
|
287
|
+
visitRule(rule, segment.name);
|
|
288
|
+
}
|
|
289
|
+
const sorted = new Map();
|
|
290
|
+
for (const [trait, segs] of [...traitToSegments.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
291
|
+
sorted.set(trait, [...segs].sort((a, b) => a.localeCompare(b)));
|
|
292
|
+
}
|
|
293
|
+
return sorted;
|
|
294
|
+
}
|
|
295
|
+
function getConversionNotesImpl() {
|
|
296
|
+
// Convert raw report notes — translate.ts embeds env api_keys in its
|
|
297
|
+
// detail strings; substitute them with the slugified user-facing env name
|
|
298
|
+
// so the report is readable to anyone who hasn't memorized Flagsmith's
|
|
299
|
+
// 22-character env api_keys. No-op when the api_key isn't in the env map.
|
|
300
|
+
const envSlugByApiKey = new Map();
|
|
301
|
+
for (const env of state.environments)
|
|
302
|
+
envSlugByApiKey.set(env.api_key, slugifyEnvName(env.name));
|
|
303
|
+
const decorate = (detail) => {
|
|
304
|
+
let out = detail.replaceAll(/env "([^"]+)"/g, (full, apiKey) => {
|
|
305
|
+
const slug = envSlugByApiKey.get(apiKey);
|
|
306
|
+
return slug ? `env "${slug}"` : full;
|
|
307
|
+
});
|
|
308
|
+
// Cross-env divergence details list api_keys directly as `apk=type` pairs
|
|
309
|
+
// (e.g. `Qr6Q...=int, iwz7...=string`) without surrounding quotes. Decode
|
|
310
|
+
// every known api_key in the detail to its slugified name.
|
|
311
|
+
for (const [apiKey, slug] of envSlugByApiKey) {
|
|
312
|
+
if (apiKey === slug)
|
|
313
|
+
continue;
|
|
314
|
+
out = out.replaceAll(apiKey, slug);
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
const notes = state.report.all().map((n) => ({ ...n, detail: decorate(n.detail) }));
|
|
319
|
+
// Synthesize rebucketed-rollout notes for every MULTIVARIATE feature so the
|
|
320
|
+
// dedicated "Users will be re-bucketed" section in MIGRATION_REPORT.md
|
|
321
|
+
// names each one (plan §5.4). Flagsmith and Quonfig hash buckets with
|
|
322
|
+
// different salts; identity-level assignments WILL change post-migration
|
|
323
|
+
// for every MV flag, regardless of whether the allocation has any per-env
|
|
324
|
+
// overrides.
|
|
325
|
+
for (const featureName of state.multivariateFeatures) {
|
|
326
|
+
notes.push({
|
|
327
|
+
category: 'rebucketed-rollout',
|
|
328
|
+
detail: 'multivariate feature; Flagsmith and Quonfig hash identities into buckets with different salts, so ' +
|
|
329
|
+
'individual identity → variation assignments will change after migration (the overall percentage allocation is preserved)',
|
|
330
|
+
key: featureName,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Identity-trait references — pure FYI, no action required server-side, but
|
|
334
|
+
// every trait must be sent by the SDK caller at eval time or matching rules
|
|
335
|
+
// will silently miss. Plan §5.5 / D-F6.
|
|
336
|
+
const traitMap = collectIdentityTraits();
|
|
337
|
+
for (const [trait, segments] of traitMap) {
|
|
338
|
+
notes.push({
|
|
339
|
+
category: 'identity-traits-referenced',
|
|
340
|
+
detail: segments.length === 1
|
|
341
|
+
? `referenced by segment "${segments[0]}" — your SDK callers must send this attribute on the evaluation context`
|
|
342
|
+
: `referenced by ${segments.length} segments (${segments.slice(0, 3).join(', ')}${segments.length > 3 ? ', …' : ''}) — your SDK callers must send this attribute on the evaluation context`,
|
|
343
|
+
key: trait,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return notes.length === 0 ? null : notes;
|
|
347
|
+
}
|
|
348
|
+
function getEnvironmentMapImpl() {
|
|
349
|
+
if (state.environments.length === 0)
|
|
350
|
+
return null;
|
|
351
|
+
const seen = new Set();
|
|
352
|
+
const entries = [];
|
|
353
|
+
for (const env of state.environments) {
|
|
354
|
+
const quonfigName = slugifyEnvName(env.name);
|
|
355
|
+
if (seen.has(quonfigName))
|
|
356
|
+
continue;
|
|
357
|
+
seen.add(quonfigName);
|
|
358
|
+
entries.push({ quonfigName, sourceName: env.name });
|
|
359
|
+
}
|
|
360
|
+
return entries.length === 0 ? null : entries;
|
|
361
|
+
}
|
|
13
362
|
export const flagsmithSource = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
|
|
17
|
-
},
|
|
18
|
-
listEnvironments() {
|
|
19
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
|
|
363
|
+
fetchChanges() {
|
|
364
|
+
return fetchChangesImpl();
|
|
20
365
|
},
|
|
366
|
+
getCoercedSentinels: getCoercedSentinelsImpl,
|
|
367
|
+
getConversionNotes: getConversionNotesImpl,
|
|
368
|
+
getDroppedOverrides: getDroppedOverridesImpl,
|
|
369
|
+
getEnvironmentMap: getEnvironmentMapImpl,
|
|
370
|
+
getSkippedConfigs: getSkippedConfigsImpl,
|
|
371
|
+
listEnvironments: listEnvironmentsImpl,
|
|
21
372
|
name: SOURCE_NAME,
|
|
22
|
-
translate
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
validateAuth() {
|
|
26
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
|
|
27
|
-
},
|
|
373
|
+
translate: translateImpl,
|
|
374
|
+
validateAuth: validateAuthImpl,
|
|
28
375
|
};
|
|
376
|
+
export function __resetFlagsmithSourceForTests() {
|
|
377
|
+
state.apiKey = null;
|
|
378
|
+
state.environments = [];
|
|
379
|
+
state.multivariateFeatures = [];
|
|
380
|
+
state.project = null;
|
|
381
|
+
state.projectId = resolveProjectId();
|
|
382
|
+
state.report = new ConversionReport();
|
|
383
|
+
state.segments = [];
|
|
384
|
+
state.tags = [];
|
|
385
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* limit, so we program against the headers: back off on 429 using
|
|
11
11
|
* `X-Ratelimit-Reset` (epoch-ms), pre-throttle on `X-Ratelimit-Global-Remaining`.
|
|
12
12
|
*/
|
|
13
|
-
import type { LDFlag, LDSegment, LDSnapshot } from './types.js';
|
|
13
|
+
import type { LDFlag, LDMember, LDSegment, LDSnapshot } from './types.js';
|
|
14
14
|
export declare function setLaunchDarklyBaseUrl(url: string): void;
|
|
15
15
|
export declare function selectLaunchDarklyBaseUrl(): string;
|
|
16
16
|
export declare function applyLaunchDarklyBaseUrl(): void;
|
|
@@ -32,6 +32,16 @@ export declare class LaunchDarklyApiError extends Error {
|
|
|
32
32
|
export declare function apiFetch(path: string, apiKey: string): Promise<unknown>;
|
|
33
33
|
/** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
|
|
34
34
|
export declare function fetchProjectEnvironments(apiKey: string, projectKey: string): Promise<string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Same call as `fetchProjectEnvironments` but returns the full `{key, name}`
|
|
37
|
+
* pairs. The display `name` is needed for the MIGRATION_REPORT.md "Environment
|
|
38
|
+
* mapping table" — the bare key alone hides the customer-facing env label
|
|
39
|
+
* (qfg-1t7m).
|
|
40
|
+
*/
|
|
41
|
+
export declare function fetchProjectEnvironmentsDetailed(apiKey: string, projectKey: string): Promise<Array<{
|
|
42
|
+
key: string;
|
|
43
|
+
name: string;
|
|
44
|
+
}>>;
|
|
35
45
|
export declare function fetchContextKinds(apiKey: string, projectKey: string): Promise<string[]>;
|
|
36
46
|
/**
|
|
37
47
|
* Full per-environment flag targeting. `?summary=0` plus repeated `?env=`
|
|
@@ -48,6 +58,17 @@ export declare function fetchContextKinds(apiKey: string, projectKey: string): P
|
|
|
48
58
|
export declare function fetchFlags(apiKey: string, projectKey: string, envKeys: string[], opts?: {
|
|
49
59
|
archived?: boolean;
|
|
50
60
|
}): Promise<LDFlag[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Account-wide member list — used to resolve flag `maintainerId` hex strings
|
|
63
|
+
* to readable emails for MIGRATION_REPORT.md (qfg-l8uz). Members live above
|
|
64
|
+
* projects in LD's hierarchy, so this call is project-independent.
|
|
65
|
+
*
|
|
66
|
+
* Some API tokens (project-scoped, or accounts with member visibility
|
|
67
|
+
* restrictions) can't read members and get a 403. Callers should treat the
|
|
68
|
+
* member map as best-effort and downgrade gracefully — the migration must not
|
|
69
|
+
* fail because we couldn't enrich the maintainer rollup.
|
|
70
|
+
*/
|
|
71
|
+
export declare function fetchMembers(apiKey: string): Promise<LDMember[]>;
|
|
51
72
|
/** Segments are a per-environment resource — caller loops env keys and de-dupes. */
|
|
52
73
|
export declare function fetchSegmentsForEnv(apiKey: string, projectKey: string, envKey: string): Promise<LDSegment[]>;
|
|
53
74
|
/**
|
|
@@ -134,9 +134,18 @@ async function paginate(firstPath, apiKey) {
|
|
|
134
134
|
}
|
|
135
135
|
/** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
|
|
136
136
|
export async function fetchProjectEnvironments(apiKey, projectKey) {
|
|
137
|
-
const items = await
|
|
137
|
+
const items = await fetchProjectEnvironmentsDetailed(apiKey, projectKey);
|
|
138
138
|
return items.map((e) => e.key);
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Same call as `fetchProjectEnvironments` but returns the full `{key, name}`
|
|
142
|
+
* pairs. The display `name` is needed for the MIGRATION_REPORT.md "Environment
|
|
143
|
+
* mapping table" — the bare key alone hides the customer-facing env label
|
|
144
|
+
* (qfg-1t7m).
|
|
145
|
+
*/
|
|
146
|
+
export async function fetchProjectEnvironmentsDetailed(apiKey, projectKey) {
|
|
147
|
+
return paginate(`/projects/${encodeURIComponent(projectKey)}/environments?limit=${PAGE_LIMIT}`, apiKey);
|
|
148
|
+
}
|
|
140
149
|
export async function fetchContextKinds(apiKey, projectKey) {
|
|
141
150
|
const data = (await apiFetch(`/projects/${encodeURIComponent(projectKey)}/context-kinds`, apiKey));
|
|
142
151
|
return Array.isArray(data.items) ? data.items.map((k) => k.key) : [];
|
|
@@ -161,6 +170,19 @@ export async function fetchFlags(apiKey, projectKey, envKeys, opts = {}) {
|
|
|
161
170
|
params.set('archived', 'true');
|
|
162
171
|
return paginate(`/flags/${encodeURIComponent(projectKey)}?${params}`, apiKey);
|
|
163
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Account-wide member list — used to resolve flag `maintainerId` hex strings
|
|
175
|
+
* to readable emails for MIGRATION_REPORT.md (qfg-l8uz). Members live above
|
|
176
|
+
* projects in LD's hierarchy, so this call is project-independent.
|
|
177
|
+
*
|
|
178
|
+
* Some API tokens (project-scoped, or accounts with member visibility
|
|
179
|
+
* restrictions) can't read members and get a 403. Callers should treat the
|
|
180
|
+
* member map as best-effort and downgrade gracefully — the migration must not
|
|
181
|
+
* fail because we couldn't enrich the maintainer rollup.
|
|
182
|
+
*/
|
|
183
|
+
export async function fetchMembers(apiKey) {
|
|
184
|
+
return paginate(`/members?limit=${PAGE_LIMIT}`, apiKey);
|
|
185
|
+
}
|
|
164
186
|
/** Segments are a per-environment resource — caller loops env keys and de-dupes. */
|
|
165
187
|
export async function fetchSegmentsForEnv(apiKey, projectKey, envKey) {
|
|
166
188
|
return paginate(`/segments/${encodeURIComponent(projectKey)}/${encodeURIComponent(envKey)}?limit=${PAGE_LIMIT}`, apiKey);
|
|
@@ -26,6 +26,26 @@ function slugifyEnvKey(key) {
|
|
|
26
26
|
.replaceAll(/[^\da-z]+/g, '-')
|
|
27
27
|
.replaceAll(/^-+|-+$/g, '');
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Gather every prerequisite edge declared across all of a flag's environments
|
|
31
|
+
* and dedupe by parent key — the orphan-parent view is per-flag, not
|
|
32
|
+
* per-environment. First occurrence wins for the variation index when an
|
|
33
|
+
* unusual config gates on the same parent at different variations in different
|
|
34
|
+
* envs (rare; the typical case is identical edges in every env).
|
|
35
|
+
*/
|
|
36
|
+
function collectPrerequisiteEdges(flag) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const edges = [];
|
|
39
|
+
for (const envState of Object.values(flag.environments)) {
|
|
40
|
+
for (const prereq of envState.prerequisites ?? []) {
|
|
41
|
+
if (seen.has(prereq.key))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(prereq.key);
|
|
44
|
+
edges.push({ parentKey: prereq.key, variation: prereq.variation });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return edges;
|
|
48
|
+
}
|
|
29
49
|
export function flagOutputPath(key) {
|
|
30
50
|
return `feature-flags/${normalizeKey(key)}.json`;
|
|
31
51
|
}
|
|
@@ -54,9 +74,16 @@ export function translateFlag(flag, report) {
|
|
|
54
74
|
};
|
|
55
75
|
// clientSideAvailability: feature flags are always client-visible in Quonfig,
|
|
56
76
|
// so `sendToClientSdk` is invalid on them — we cannot carry the dimension.
|
|
57
|
-
//
|
|
77
|
+
// Split the drop by usingEnvironmentId co-state (qfg-iv69):
|
|
78
|
+
// - usingEnvironmentId:true → flag is still client-visible, pure rollup noise.
|
|
79
|
+
// - usingEnvironmentId:false → flag was mobile-only, now server-only post-migration (must-fix).
|
|
58
80
|
if (flag.clientSideAvailability?.usingMobileKey) {
|
|
59
|
-
|
|
81
|
+
if (flag.clientSideAvailability.usingEnvironmentId) {
|
|
82
|
+
report.add('dropped-mobile-key-still-visible', flag.key, `clientSideAvailability.usingMobileKey was true; usingEnvironmentId was also true — flag remains client-visible via Quonfig's sendToClientSdk, the mobile-key dimension itself is dropped`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
report.add('dropped-mobile-key-now-server-only', flag.key, `clientSideAvailability.usingMobileKey was true but usingEnvironmentId was false — flag was mobile-only in LaunchDarkly and will not reach mobile clients post-migration; rebuild client-side access by hand before cutover`);
|
|
86
|
+
}
|
|
60
87
|
}
|
|
61
88
|
if (flag.maintainerId || flag.maintainerTeamKey) {
|
|
62
89
|
report.add('dropped-maintainer', flag.key, `maintainer ${flag.maintainerId ?? flag.maintainerTeamKey} dropped — Quonfig authorship lives in git history`);
|
|
@@ -64,6 +91,19 @@ export function translateFlag(flag, report) {
|
|
|
64
91
|
if (flag.customProperties && Object.keys(flag.customProperties).length > 0) {
|
|
65
92
|
report.add('dropped-custom-properties', flag.key, `customProperties (${Object.keys(flag.customProperties).join(', ')}) dropped — report-only in v1 (D6)`);
|
|
66
93
|
}
|
|
94
|
+
// Prerequisites — dedupe across environments by parent key so a flag gated on
|
|
95
|
+
// the same parent in N environments emits one note, not N (qfg-nb4n). The
|
|
96
|
+
// detail spells out every parent's variation index plus a one-line remediation
|
|
97
|
+
// so a human knows exactly how to restore the gate; the structured
|
|
98
|
+
// `prerequisites` payload feeds the inverted orphan-parent view in the report.
|
|
99
|
+
const prereqEdges = collectPrerequisiteEdges(flag);
|
|
100
|
+
if (prereqEdges.length > 0) {
|
|
101
|
+
const parentList = prereqEdges.map((e) => `\`${e.parentKey}\` = variation ${e.variation}`).join(', ');
|
|
102
|
+
const detail = `evaluated independently of ${prereqEdges.length} parent${prereqEdges.length === 1 ? '' : 's'} ` +
|
|
103
|
+
`(${parentList}). To preserve the gate, add a leading rule matching the same parent state, ` +
|
|
104
|
+
`or wrap reads in app code — Quonfig v1 has no cross-flag dependency operator.`;
|
|
105
|
+
report.add('dropped-prerequisite', flag.key, detail, { prerequisites: prereqEdges });
|
|
106
|
+
}
|
|
67
107
|
const variants = flag.variations.map((v) => {
|
|
68
108
|
const variant = {
|
|
69
109
|
value: toQuonfigValue(v.value, valueType),
|
|
@@ -173,6 +173,25 @@ export interface LDContextKindsResponse {
|
|
|
173
173
|
name?: string;
|
|
174
174
|
}>;
|
|
175
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* One row from `GET /members` — only the fields the migrator surfaces are
|
|
178
|
+
* typed. `_id` is what flag JSON references as `maintainerId`; `email` is the
|
|
179
|
+
* human-readable label we resolve into MIGRATION_REPORT.md.
|
|
180
|
+
*/
|
|
181
|
+
export interface LDMember {
|
|
182
|
+
_id: string;
|
|
183
|
+
email?: string;
|
|
184
|
+
}
|
|
185
|
+
/** `GET /members` — offset-paginated. */
|
|
186
|
+
export interface LDMembersResponse {
|
|
187
|
+
_links?: {
|
|
188
|
+
next?: {
|
|
189
|
+
href: string;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
items: LDMember[];
|
|
193
|
+
totalCount?: number;
|
|
194
|
+
}
|
|
176
195
|
/**
|
|
177
196
|
* The full Phase-1 config snapshot — what the fetcher stitches together and
|
|
178
197
|
* what `translate()` consumes. One of these is produced per migration run.
|