@quonfig/cli 0.0.48 → 0.0.50
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 +44 -44
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +28 -1
- package/dist/commands/pull.js +2 -2
- package/dist/migrate/local-write.js +24 -3
- package/dist/migrate/migration-report.d.ts +16 -5
- package/dist/migrate/migration-report.js +279 -9
- package/dist/migrate/push-to-cloud.js +6 -1
- package/dist/migrate/quonfig-target/report.d.ts +27 -3
- 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/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/clone-and-stack-push.js +8 -2
- package/dist/util/friction-log.d.ts +1 -0
- package/dist/util/friction-log.js +6 -1
- 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 +1 -1
- package/package.json +1 -1
|
@@ -43,14 +43,34 @@ const renderEnvironmentMap = (entries) => {
|
|
|
43
43
|
const rows = sorted.map((e) => `| ${e.sourceName} | ${e.quonfigName} |`);
|
|
44
44
|
return [header, '', '| Source | Quonfig |', '| --- | --- |', ...rows].join('\n');
|
|
45
45
|
};
|
|
46
|
-
const renderIdentifierMap = (map) => {
|
|
46
|
+
const renderIdentifierMap = (map, maintainerMap) => {
|
|
47
47
|
const header = '## Identifier map';
|
|
48
|
+
const sections = [];
|
|
48
49
|
const keys = Object.keys(map);
|
|
49
|
-
if (keys.length
|
|
50
|
+
if (keys.length > 0) {
|
|
51
|
+
const sorted = [...keys].sort();
|
|
52
|
+
const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
|
|
53
|
+
sections.push(['### Legacy keys', '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n'));
|
|
54
|
+
}
|
|
55
|
+
const maintainerIds = maintainerMap ? Object.keys(maintainerMap) : [];
|
|
56
|
+
if (maintainerIds.length > 0) {
|
|
57
|
+
const sorted = [...maintainerIds].sort();
|
|
58
|
+
const rows = sorted.map((id) => `| \`${id}\` | ${maintainerMap[id]} |`);
|
|
59
|
+
sections.push([
|
|
60
|
+
'### Maintainer IDs',
|
|
61
|
+
'',
|
|
62
|
+
`Resolved **${sorted.length}** maintainer ID${sorted.length === 1 ? '' : 's'} via the LaunchDarkly /members ` +
|
|
63
|
+
`endpoint. Use this table to read 'Dropped maintainer metadata' below — each \`maintainerId\` there maps to the ` +
|
|
64
|
+
`email here.`,
|
|
65
|
+
'',
|
|
66
|
+
'| maintainerId | email |',
|
|
67
|
+
'| --- | --- |',
|
|
68
|
+
...rows,
|
|
69
|
+
].join('\n'));
|
|
70
|
+
}
|
|
71
|
+
if (sections.length === 0)
|
|
50
72
|
return `${header}\n\n${NONE}`;
|
|
51
|
-
|
|
52
|
-
const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
|
|
53
|
-
return [header, '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n');
|
|
73
|
+
return [header, '', ...sections].join('\n\n');
|
|
54
74
|
};
|
|
55
75
|
const renderDroppedOverrides = (dropped) => {
|
|
56
76
|
if (!dropped || dropped.total === 0)
|
|
@@ -138,13 +158,96 @@ const CONVERSION_NOTE_HEADINGS = {
|
|
|
138
158
|
'dropped-custom-properties': 'Dropped custom properties',
|
|
139
159
|
'dropped-experiment-metadata': 'Dropped experiment metadata',
|
|
140
160
|
'dropped-maintainer': 'Dropped maintainer metadata',
|
|
141
|
-
'dropped-mobile-key-
|
|
161
|
+
'dropped-mobile-key-now-server-only': 'Dropped mobile-key availability (now server-only)',
|
|
162
|
+
'dropped-mobile-key-still-visible': 'Dropped mobile-key availability (still client-visible)',
|
|
142
163
|
'dropped-prerequisite': 'Dropped prerequisites',
|
|
143
164
|
'dropped-private-attribute': 'Dropped private attributes',
|
|
144
165
|
'individual-target-as-rule': 'Individual targets converted to rules',
|
|
145
166
|
'skipped-rule': 'Skipped rules (unsupported operators)',
|
|
146
167
|
'unexportable-segment-membership': 'Unexportable segment membership',
|
|
147
168
|
};
|
|
169
|
+
const SIGNAL_SPECS = {
|
|
170
|
+
'dropped-mobile-key-now-server-only': {
|
|
171
|
+
render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} ${count === 1 ? 'will' : 'will'} stop reaching mobile clients** ` +
|
|
172
|
+
`(${keys.map((k) => `\`${k}\``).join(', ')}). LaunchDarkly had \`usingMobileKey:true\` with ` +
|
|
173
|
+
`\`usingEnvironmentId:false\` — these were mobile-only and won't be visible to mobile SDKs post-migration. ` +
|
|
174
|
+
`Rebuild client-side access by hand before cutover.`,
|
|
175
|
+
},
|
|
176
|
+
'dropped-prerequisite': {
|
|
177
|
+
render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} lost cross-flag dependencies** (${keys.map((k) => `\`${k}\``).join(', ')}). ` +
|
|
178
|
+
`The Quonfig copy now serves its variations independently of its parent flag. ` +
|
|
179
|
+
`Review §'Dropped prerequisites' below.`,
|
|
180
|
+
},
|
|
181
|
+
'rebucketed-rollout': {
|
|
182
|
+
render: (count) => `**${count} flag${count === 1 ? '' : 's'} will re-bucket users.** Coordinate comms or drain affected ` +
|
|
183
|
+
`rollouts — see 'Users will be re-bucketed' below.`,
|
|
184
|
+
},
|
|
185
|
+
'skipped-rule': {
|
|
186
|
+
render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} had rules skipped** (${keys.map((k) => `\`${k}\``).join(', ')}) — ` +
|
|
187
|
+
`a clause used an unsupported operator. Rebuild the rule by hand before cutover.`,
|
|
188
|
+
},
|
|
189
|
+
'unexportable-segment-membership': {
|
|
190
|
+
render: (count, keys) => `**${count} segment${count === 1 ? '' : 's'} ${count === 1 ? 'has' : 'have'} missing membership** (${keys
|
|
191
|
+
.map((k) => `\`${k}\``)
|
|
192
|
+
.join(', ')}). Re-author or sync from your IdP before cutover.`,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const SIGNAL_ORDER = [
|
|
196
|
+
'dropped-mobile-key-now-server-only',
|
|
197
|
+
'dropped-prerequisite',
|
|
198
|
+
'rebucketed-rollout',
|
|
199
|
+
'unexportable-segment-membership',
|
|
200
|
+
'skipped-rule',
|
|
201
|
+
];
|
|
202
|
+
const MAX_EXAMPLE_KEYS = 3;
|
|
203
|
+
const groupKeysByCategory = (notes) => {
|
|
204
|
+
const map = new Map();
|
|
205
|
+
for (const note of notes) {
|
|
206
|
+
const list = map.get(note.category) ?? [];
|
|
207
|
+
list.push(note.key);
|
|
208
|
+
map.set(note.category, list);
|
|
209
|
+
}
|
|
210
|
+
return map;
|
|
211
|
+
};
|
|
212
|
+
const renderBeforeYouCutOver = (notes) => {
|
|
213
|
+
if (!notes || notes.length === 0)
|
|
214
|
+
return null;
|
|
215
|
+
const byCategory = groupKeysByCategory(notes);
|
|
216
|
+
const items = [];
|
|
217
|
+
for (const category of SIGNAL_ORDER) {
|
|
218
|
+
const keys = byCategory.get(category);
|
|
219
|
+
if (!keys || keys.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
const sortedKeys = [...keys].sort((a, b) => a.localeCompare(b));
|
|
222
|
+
const example = sortedKeys.slice(0, MAX_EXAMPLE_KEYS);
|
|
223
|
+
const truncated = sortedKeys.length > MAX_EXAMPLE_KEYS ? [...example, '…'] : example;
|
|
224
|
+
items.push(SIGNAL_SPECS[category].render(sortedKeys.length, truncated));
|
|
225
|
+
}
|
|
226
|
+
if (items.length === 0)
|
|
227
|
+
return null;
|
|
228
|
+
const lines = [
|
|
229
|
+
'## Before you cut over',
|
|
230
|
+
'',
|
|
231
|
+
`${items.length} thing${items.length === 1 ? ' needs' : 's need'} a human decision before flipping the SDK:`,
|
|
232
|
+
'',
|
|
233
|
+
];
|
|
234
|
+
items.forEach((item, idx) => {
|
|
235
|
+
lines.push(`${idx + 1}. ${item}`);
|
|
236
|
+
});
|
|
237
|
+
const ignoreParts = [];
|
|
238
|
+
const maintainerCount = byCategory.get('dropped-maintainer')?.length ?? 0;
|
|
239
|
+
if (maintainerCount > 0) {
|
|
240
|
+
ignoreParts.push(`dropped maintainer metadata (${maintainerCount} entries — git authorship replaces it)`);
|
|
241
|
+
}
|
|
242
|
+
const mobileKeyCount = byCategory.get('dropped-mobile-key-still-visible')?.length ?? 0;
|
|
243
|
+
if (mobileKeyCount > 0) {
|
|
244
|
+
ignoreParts.push(`dropped mobile-key availability (${mobileKeyCount} entries — all still client-visible via usingEnvironmentId)`);
|
|
245
|
+
}
|
|
246
|
+
if (ignoreParts.length > 0) {
|
|
247
|
+
lines.push('', `You can ignore: ${ignoreParts.join(', ')}.`);
|
|
248
|
+
}
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
};
|
|
148
251
|
const renderRebucketedRollouts = (notes) => {
|
|
149
252
|
const rollouts = (notes ?? []).filter((n) => n.category === 'rebucketed-rollout');
|
|
150
253
|
if (rollouts.length === 0)
|
|
@@ -163,7 +266,77 @@ const renderRebucketedRollouts = (notes) => {
|
|
|
163
266
|
}
|
|
164
267
|
return lines.join('\n');
|
|
165
268
|
};
|
|
166
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Categories that are pure informational noise (drowned out the signal in the
|
|
271
|
+
* 172-note LD migration report — see qfg-ve5w). Render as a 1-line rollup +
|
|
272
|
+
* collapsible `<details>` block instead of N top-level bullets.
|
|
273
|
+
*/
|
|
274
|
+
const COLLAPSED_CATEGORIES = {
|
|
275
|
+
'dropped-maintainer': (count) => `${count} flag${count === 1 ? '' : 's'} had a maintainer ID dropped. Intentional — Quonfig authorship lives in ` +
|
|
276
|
+
`git history. No action required.`,
|
|
277
|
+
'dropped-mobile-key-still-visible': (count) => `${count} flag${count === 1 ? '' : 's'} had \`usingMobileKey:true\` collapsed into the single \`sendToClientSdk\` ` +
|
|
278
|
+
`boolean. Each flag is still client-visible via \`usingEnvironmentId\`. No action required.`,
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Render the `dropped-prerequisite` subsection (qfg-nb4n): one multi-line entry
|
|
282
|
+
* per child flag with nested parent bullets naming each variation index, plus
|
|
283
|
+
* a trailing inverted view that lists each orphaned parent and its downstream
|
|
284
|
+
* consumers. Returns the rendered lines (without the `### …` header — that is
|
|
285
|
+
* pushed by the caller so the section grouping stays consistent).
|
|
286
|
+
*/
|
|
287
|
+
const renderDroppedPrerequisiteBody = (notes) => {
|
|
288
|
+
const sorted = [...notes].sort((a, b) => a.key.localeCompare(b.key));
|
|
289
|
+
const lines = [];
|
|
290
|
+
// Forward view: child flag → parents.
|
|
291
|
+
for (const note of sorted) {
|
|
292
|
+
const edges = note.prerequisites;
|
|
293
|
+
if (!edges || edges.length === 0) {
|
|
294
|
+
// Backward-compat fallback when a future caller forgets the structured payload.
|
|
295
|
+
lines.push(`- \`${note.key}\` — ${note.detail}`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
lines.push(`- \`${note.key}\` evaluated independently of its ${edges.length} parent${edges.length === 1 ? '' : 's'}:`);
|
|
299
|
+
for (const edge of edges) {
|
|
300
|
+
lines.push(` - was gated on \`${edge.parentKey}\` = variation ${edge.variation}`);
|
|
301
|
+
}
|
|
302
|
+
lines.push(' To preserve the gate, add a leading rule matching the same parent state, or wrap reads in app code.');
|
|
303
|
+
}
|
|
304
|
+
// Inverted view: parent → children that depended on it.
|
|
305
|
+
const parentToChildren = new Map();
|
|
306
|
+
for (const note of sorted) {
|
|
307
|
+
for (const edge of note.prerequisites ?? []) {
|
|
308
|
+
const set = parentToChildren.get(edge.parentKey) ?? new Set();
|
|
309
|
+
set.add(note.key);
|
|
310
|
+
parentToChildren.set(edge.parentKey, set);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (parentToChildren.size > 0) {
|
|
314
|
+
lines.push('', '#### Orphaned parent flags (inverted view)', '');
|
|
315
|
+
const parents = [...parentToChildren.keys()].sort((a, b) => a.localeCompare(b));
|
|
316
|
+
for (const parent of parents) {
|
|
317
|
+
const children = [...(parentToChildren.get(parent) ?? [])].sort((a, b) => a.localeCompare(b));
|
|
318
|
+
const childList = children.map((c) => `\`${c}\``).join(', ');
|
|
319
|
+
lines.push(`- **\`${parent}\`** is now an orphaned dependency. Downstream flags that depended on it: ${childList}.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return lines;
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* qfg-l8uz: substitute every `<hex maintainerId>` in a dropped-maintainer
|
|
326
|
+
* detail with `<id> (<email>)` so the per-flag rollup reads as a human label
|
|
327
|
+
* instead of an opaque hex string. No-op when the map is empty/missing or the
|
|
328
|
+
* ID isn't in it (team-key drops, partial member visibility) — the unsubstituted
|
|
329
|
+
* line is still informative.
|
|
330
|
+
*/
|
|
331
|
+
const decorateMaintainerDetail = (detail, maintainerMap) => {
|
|
332
|
+
if (!maintainerMap)
|
|
333
|
+
return detail;
|
|
334
|
+
return detail.replaceAll(/[\da-f]{24}/g, (match) => {
|
|
335
|
+
const email = maintainerMap[match];
|
|
336
|
+
return email ? `${match} (${email})` : match;
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
const renderConversionNotes = (notes, maintainerMap) => {
|
|
167
340
|
// `skipped-config` (own section) and `rebucketed-rollout` (own section) are
|
|
168
341
|
// rendered elsewhere — everything else is grouped by category here.
|
|
169
342
|
const grouped = (notes ?? []).filter((n) => n.category !== 'rebucketed-rollout' && n.category !== 'skipped-config');
|
|
@@ -182,12 +355,102 @@ const renderConversionNotes = (notes) => {
|
|
|
182
355
|
continue;
|
|
183
356
|
lines.push('', `### ${CONVERSION_NOTE_HEADINGS[category]}`, '');
|
|
184
357
|
const sorted = [...forCategory].sort((a, b) => a.key.localeCompare(b.key));
|
|
358
|
+
const rollup = COLLAPSED_CATEGORIES[category];
|
|
359
|
+
if (rollup) {
|
|
360
|
+
lines.push(rollup(sorted.length), '', '<details><summary>Per-flag list</summary>', '');
|
|
361
|
+
for (const note of sorted) {
|
|
362
|
+
const detail = category === 'dropped-maintainer' ? decorateMaintainerDetail(note.detail, maintainerMap) : note.detail;
|
|
363
|
+
lines.push(`- \`${note.key}\` — ${detail}`);
|
|
364
|
+
}
|
|
365
|
+
lines.push('', '</details>');
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (category === 'dropped-prerequisite') {
|
|
369
|
+
lines.push(...renderDroppedPrerequisiteBody(sorted));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
185
372
|
for (const note of sorted) {
|
|
186
373
|
lines.push(`- \`${note.key}\` — ${note.detail}`);
|
|
187
374
|
}
|
|
188
375
|
}
|
|
189
376
|
return lines.join('\n');
|
|
190
377
|
};
|
|
378
|
+
/**
|
|
379
|
+
* Maps each lossy conversion-note category to a Follow-up checklist bucket plus
|
|
380
|
+
* a 1-line remediation hint. Categories not listed here (or `skipped-config` /
|
|
381
|
+
* housekeeping categories like `dropped-maintainer`) are intentionally not
|
|
382
|
+
* surfaced as checklist items — they get their own dedicated report sections
|
|
383
|
+
* or are pure-metadata losses that don't require human follow-up.
|
|
384
|
+
*/
|
|
385
|
+
const FOLLOW_UP_BUCKETS = {
|
|
386
|
+
'dropped-mobile-key-now-server-only': {
|
|
387
|
+
bucket: 'mustFixBeforeCutover',
|
|
388
|
+
hint: (key) => `\`${key}\` — flag was mobile-only in LaunchDarkly (usingMobileKey:true, usingEnvironmentId:false) and will not reach mobile SDKs after migration; restore client-side access by hand.`,
|
|
389
|
+
},
|
|
390
|
+
'dropped-prerequisite': {
|
|
391
|
+
bucket: 'mustFixBeforeCutover',
|
|
392
|
+
hint: (key) => `\`${key}\` — restore the dropped prerequisite gate manually; Quonfig v1 has no cross-flag dependency operator.`,
|
|
393
|
+
},
|
|
394
|
+
'individual-target-as-rule': {
|
|
395
|
+
bucket: 'reviewPostCutover',
|
|
396
|
+
hint: (key) => `\`${key}\` — individual user targets are now a leading rule; the LaunchDarkly targets pane affordance is gone.`,
|
|
397
|
+
},
|
|
398
|
+
'rebucketed-rollout': {
|
|
399
|
+
bucket: 'reviewPostCutover',
|
|
400
|
+
hint: (key) => `\`${key}\` — percentage rollout will re-bucket users post-migration; communicate before cutover.`,
|
|
401
|
+
},
|
|
402
|
+
'skipped-rule': {
|
|
403
|
+
bucket: 'mustFixBeforeCutover',
|
|
404
|
+
hint: (key) => `\`${key}\` — a rule clause used an unsupported operator and was skipped; rebuild the rule by hand.`,
|
|
405
|
+
},
|
|
406
|
+
'unexportable-segment-membership': {
|
|
407
|
+
bucket: 'mustFixBeforeCutover',
|
|
408
|
+
hint: (key) => `\`${key}\` — segment shell migrated but membership is empty (LD big/synced segment); repopulate members before cutover.`,
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Folds conversion notes into a followUp checklist. De-duplicates by hint
|
|
413
|
+
* string so a flag with two dropped prerequisites doesn't produce two
|
|
414
|
+
* identical checkbox lines.
|
|
415
|
+
*/
|
|
416
|
+
export const deriveFollowUpFromConversionNotes = (base, notes) => {
|
|
417
|
+
const mustFix = new Set(base.mustFixBeforeCutover);
|
|
418
|
+
const review = new Set(base.reviewPostCutover);
|
|
419
|
+
for (const note of notes ?? []) {
|
|
420
|
+
const mapping = FOLLOW_UP_BUCKETS[note.category];
|
|
421
|
+
if (!mapping)
|
|
422
|
+
continue;
|
|
423
|
+
const line = mapping.hint(note.key);
|
|
424
|
+
if (mapping.bucket === 'mustFixBeforeCutover')
|
|
425
|
+
mustFix.add(line);
|
|
426
|
+
else
|
|
427
|
+
review.add(line);
|
|
428
|
+
}
|
|
429
|
+
return { mustFixBeforeCutover: [...mustFix], reviewPostCutover: [...review] };
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* Static, content-stable appendix describing v1 behavioral gaps between
|
|
433
|
+
* LaunchDarkly and Quonfig (plan §5.4 / qfg-ox7m). Documentation, not data —
|
|
434
|
+
* does not vary across runs. Rendered only when source === 'launchdarkly'.
|
|
435
|
+
*/
|
|
436
|
+
const LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX = [
|
|
437
|
+
'## Behavioral differences post-cutover (independent of this import)',
|
|
438
|
+
'',
|
|
439
|
+
'- LaunchDarkly evaluates targets/contextTargets *before* rules. Quonfig has no',
|
|
440
|
+
' individual-target primitive; user/context targets are now a leading PROP_IS_ONE_OF',
|
|
441
|
+
' rule. Adding/removing individuals is now a config edit (qfg set / web UI), not a',
|
|
442
|
+
' separate targeting UI pane.',
|
|
443
|
+
"- LaunchDarkly's 'off' toggle disappears. A flag is on iff at least one rule",
|
|
444
|
+
' matches; there is no global kill-switch unless you author one as a top rule.',
|
|
445
|
+
'- offVariation does not exist in Quonfig. The off-state behavior of any flag',
|
|
446
|
+
' that was on=false in LD has been baked into a single ALWAYS_TRUE rule serving',
|
|
447
|
+
' that variation.',
|
|
448
|
+
'- Mobile SDK keys: LaunchDarkly distinguished mobile-key vs environment-id',
|
|
449
|
+
' availability. Quonfig has one sendToClientSdk bool — your iOS/Android and',
|
|
450
|
+
' browser-JS SDKs now share the same client-visibility setting.',
|
|
451
|
+
'- Maintainer is no longer a field on flags. `git log path/to/flag.json` is the',
|
|
452
|
+
' new source of truth.',
|
|
453
|
+
].join('\n');
|
|
191
454
|
const renderFollowUp = (followUp) => {
|
|
192
455
|
const must = followUp.mustFixBeforeCutover.length === 0
|
|
193
456
|
? NONE
|
|
@@ -212,7 +475,11 @@ export const buildMigrationReport = (data) => {
|
|
|
212
475
|
if (data.dryRun) {
|
|
213
476
|
sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
|
|
214
477
|
}
|
|
215
|
-
sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._
|
|
478
|
+
sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`);
|
|
479
|
+
const beforeYouCutOver = renderBeforeYouCutOver(data.conversionNotes);
|
|
480
|
+
if (beforeYouCutOver !== null)
|
|
481
|
+
sections.push(beforeYouCutOver);
|
|
482
|
+
sections.push(renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap, data.maintainerMap));
|
|
216
483
|
const dropped = renderDroppedOverrides(data.droppedOverrides);
|
|
217
484
|
if (dropped !== null)
|
|
218
485
|
sections.push(dropped);
|
|
@@ -228,10 +495,13 @@ export const buildMigrationReport = (data) => {
|
|
|
228
495
|
const rebucketed = renderRebucketedRollouts(data.conversionNotes);
|
|
229
496
|
if (rebucketed !== null)
|
|
230
497
|
sections.push(rebucketed);
|
|
231
|
-
const conversionNotes = renderConversionNotes(data.conversionNotes);
|
|
498
|
+
const conversionNotes = renderConversionNotes(data.conversionNotes, data.maintainerMap);
|
|
232
499
|
if (conversionNotes !== null)
|
|
233
500
|
sections.push(conversionNotes);
|
|
234
501
|
sections.push(renderFollowUp(data.followUp));
|
|
502
|
+
if (data.source === 'launchdarkly') {
|
|
503
|
+
sections.push(LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX);
|
|
504
|
+
}
|
|
235
505
|
return sections.join('\n\n') + '\n';
|
|
236
506
|
};
|
|
237
507
|
export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
4
|
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
5
|
-
import { writeMigrationReport } from './migration-report.js';
|
|
5
|
+
import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
|
|
6
6
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
7
7
|
import { formatResult, validateWorkspace } from '../verify/validate.js';
|
|
8
8
|
/**
|
|
@@ -95,6 +95,8 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
95
95
|
acc.duplicateResolutions =
|
|
96
96
|
resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
97
97
|
const conversionNotes = opts.source.getConversionNotes?.() ?? null;
|
|
98
|
+
const environmentMap = opts.source.getEnvironmentMap?.() ?? null;
|
|
99
|
+
const maintainerMap = opts.source.getMaintainerMap?.() ?? null;
|
|
98
100
|
const skippedTotal = (acc.skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
99
101
|
const environmentsCount = opts.environments?.length ?? 0;
|
|
100
102
|
const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
|
|
@@ -102,10 +104,13 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
102
104
|
const reportData = {
|
|
103
105
|
...opts.reportData,
|
|
104
106
|
counts,
|
|
107
|
+
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
108
|
+
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
105
109
|
...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
|
|
106
110
|
...(conversionNotes && conversionNotes.length > 0 ? { conversionNotes } : {}),
|
|
107
111
|
...(acc.droppedOverrides ? { droppedOverrides: acc.droppedOverrides } : {}),
|
|
108
112
|
...(acc.duplicateResolutions ? { duplicateResolutions: acc.duplicateResolutions } : {}),
|
|
113
|
+
...(maintainerMap && Object.keys(maintainerMap).length > 0 ? { maintainerMap } : {}),
|
|
109
114
|
...(acc.skippedConfigs ? { skippedConfigs: acc.skippedConfigs } : {}),
|
|
110
115
|
};
|
|
111
116
|
writeMigrationReport(dir, reportData);
|
|
@@ -26,8 +26,18 @@ export type ConversionNoteCategory =
|
|
|
26
26
|
| 'dropped-maintainer'
|
|
27
27
|
/** `customProperties` dropped — report-only in v1 (D6). */
|
|
28
28
|
| 'dropped-custom-properties'
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `usingMobileKey:true` collapsed into Quonfig's single client-visibility bool,
|
|
31
|
+
* but the flag is still client-visible because `usingEnvironmentId` is also true.
|
|
32
|
+
* No action required — pure rollup noise.
|
|
33
|
+
*/
|
|
34
|
+
| 'dropped-mobile-key-still-visible'
|
|
35
|
+
/**
|
|
36
|
+
* `usingMobileKey:true` but `usingEnvironmentId:false` — the flag was
|
|
37
|
+
* mobile-only in LD and will no longer reach mobile clients after migration.
|
|
38
|
+
* Must-fix before cutover.
|
|
39
|
+
*/
|
|
40
|
+
| 'dropped-mobile-key-now-server-only'
|
|
31
41
|
/** A percentage rollout that will re-bucket users post-migration (plan §5.4). */
|
|
32
42
|
| 'rebucketed-rollout'
|
|
33
43
|
/** A big/synced segment whose membership is not exportable via REST. */
|
|
@@ -40,6 +50,15 @@ export interface ConversionNote {
|
|
|
40
50
|
detail: string;
|
|
41
51
|
/** Source key the note is about (flag or segment key). */
|
|
42
52
|
key: string;
|
|
53
|
+
/**
|
|
54
|
+
* Structured prereq edges on `dropped-prerequisite` notes — the renderer uses
|
|
55
|
+
* this to produce the inverted "orphaned parent → downstream consumers" view
|
|
56
|
+
* (qfg-nb4n). Absent on every other category.
|
|
57
|
+
*/
|
|
58
|
+
prerequisites?: Array<{
|
|
59
|
+
parentKey: string;
|
|
60
|
+
variation: number;
|
|
61
|
+
}>;
|
|
43
62
|
}
|
|
44
63
|
/**
|
|
45
64
|
* Collects conversion notes during a migration run. A provider source holds
|
|
@@ -49,7 +68,12 @@ export interface ConversionNote {
|
|
|
49
68
|
export declare class ConversionReport {
|
|
50
69
|
private readonly notes;
|
|
51
70
|
get size(): number;
|
|
52
|
-
add(category: ConversionNoteCategory, key: string, detail: string
|
|
71
|
+
add(category: ConversionNoteCategory, key: string, detail: string, extras?: {
|
|
72
|
+
prerequisites?: Array<{
|
|
73
|
+
parentKey: string;
|
|
74
|
+
variation: number;
|
|
75
|
+
}>;
|
|
76
|
+
}): void;
|
|
53
77
|
/** All notes, in insertion order. */
|
|
54
78
|
all(): ConversionNote[];
|
|
55
79
|
/** Notes of one category, in insertion order. */
|
|
@@ -19,8 +19,11 @@ export class ConversionReport {
|
|
|
19
19
|
get size() {
|
|
20
20
|
return this.notes.length;
|
|
21
21
|
}
|
|
22
|
-
add(category, key, detail) {
|
|
23
|
-
|
|
22
|
+
add(category, key, detail, extras) {
|
|
23
|
+
const note = { category, detail, key };
|
|
24
|
+
if (extras?.prerequisites)
|
|
25
|
+
note.prerequisites = extras.prerequisites;
|
|
26
|
+
this.notes.push(note);
|
|
24
27
|
}
|
|
25
28
|
/** All notes, in insertion order. */
|
|
26
29
|
all() {
|
|
@@ -155,11 +155,10 @@ export function collapseEnvironment(env, ctx) {
|
|
|
155
155
|
return [{ criteria: [ALWAYS_TRUE], value: variationValue(env.offVariation ?? 0, ctx) }];
|
|
156
156
|
}
|
|
157
157
|
const rules = [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
158
|
+
// Prerequisites are dropped (no cross-flag dependency operator in Quonfig v1)
|
|
159
|
+
// but their reporting is owned by the caller (translateFlag), which dedupes
|
|
160
|
+
// across environments and emits one enriched note per child flag (qfg-nb4n).
|
|
161
|
+
// collapseEnvironment intentionally stays silent here.
|
|
163
162
|
// Individual + context targets evaluate before rules in LaunchDarkly, so
|
|
164
163
|
// they become leading rules. Lossy: the LD UI affordance is gone (plan §5.4).
|
|
165
164
|
const allTargets = [...(env.targets ?? []), ...(env.contextTargets ?? [])];
|
|
@@ -177,7 +176,10 @@ export function collapseEnvironment(env, ctx) {
|
|
|
177
176
|
],
|
|
178
177
|
value: variationValue(target.variation, ctx),
|
|
179
178
|
});
|
|
180
|
-
ctx.report.add('individual-target-as-rule', ctx.sourceKey, `${target.values.length} ${kind}
|
|
179
|
+
ctx.report.add('individual-target-as-rule', ctx.sourceKey, `${target.values.length} ${kind} key(s) inlined into a leading PROP_IS_ONE_OF rule on ${kind}.key. ` +
|
|
180
|
+
`**Trade-off:** you lost the LD 'targets' UI pane — adding/removing an individual now requires a ` +
|
|
181
|
+
`config edit (qfg set / web UI), not a separate UI. If this was used for beta-list-style toggling, ` +
|
|
182
|
+
`consider authoring a \`beta_user\` context attribute instead.`);
|
|
181
183
|
}
|
|
182
184
|
for (const rule of env.rules ?? []) {
|
|
183
185
|
const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -12,6 +12,17 @@ export interface QuonfigFile {
|
|
|
12
12
|
deleted?: boolean;
|
|
13
13
|
path: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* One row of the "Environment mapping table" in MIGRATION_REPORT.md. Sources
|
|
17
|
+
* implement `getEnvironmentMap()` to expose the source-name → Quonfig-name
|
|
18
|
+
* pairs they translated; the report renderer just formats the rows.
|
|
19
|
+
*/
|
|
20
|
+
export interface EnvironmentMapEntry {
|
|
21
|
+
/** The Quonfig-side environment name (slugified key). */
|
|
22
|
+
quonfigName: string;
|
|
23
|
+
/** The user-facing source-system name (e.g. LD env display name). */
|
|
24
|
+
sourceName: string;
|
|
25
|
+
}
|
|
15
26
|
export interface DroppedOverrideSummary {
|
|
16
27
|
/** Per-envId → per-output-path count of dropped override sections. */
|
|
17
28
|
byEnv: Record<string, Record<string, number>>;
|
|
@@ -106,6 +117,22 @@ export interface MigrationSource {
|
|
|
106
117
|
* report so customers can review the loss.
|
|
107
118
|
*/
|
|
108
119
|
getDroppedOverrides?(): DroppedOverrideSummary | null;
|
|
120
|
+
/**
|
|
121
|
+
* qfg-1t7m: source-name → Quonfig-name pairs for the "Environment mapping table"
|
|
122
|
+
* section of MIGRATION_REPORT.md. Returns the rows the source actually
|
|
123
|
+
* translated this run; callers thread these into report data. Sources that
|
|
124
|
+
* don't expose user-facing env names omit this and the table renders `_(none)_`.
|
|
125
|
+
*/
|
|
126
|
+
getEnvironmentMap?(): EnvironmentMapEntry[] | null;
|
|
127
|
+
/**
|
|
128
|
+
* qfg-l8uz: maintainerId → email pairs for the report's "Identifier map"
|
|
129
|
+
* section. Sources that fetch a member directory (e.g. LD's /members) expose
|
|
130
|
+
* the lookup here so the report renderer can swap opaque IDs for readable
|
|
131
|
+
* emails and decorate the `dropped-maintainer` rollup. Null when the source
|
|
132
|
+
* couldn't fetch members (auth scope, hidden member data) or has none to
|
|
133
|
+
* surface — the rest of the report still renders.
|
|
134
|
+
*/
|
|
135
|
+
getMaintainerMap?(): null | Record<string, string>;
|
|
109
136
|
/**
|
|
110
137
|
* Optional post-translate accumulator. Returns any configs that translate() soft-
|
|
111
138
|
* skipped due to invalid source data (e.g. variant/valueType mismatch). Null when
|
|
@@ -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);
|