@quonfig/cli 0.0.46 → 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -66
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.js +68 -8
- package/dist/migrate/local-write.js +4 -0
- package/dist/migrate/migration-report.d.ts +9 -0
- package/dist/migrate/migration-report.js +68 -0
- package/dist/migrate/push-to-cloud.js +2 -0
- package/dist/migrate/quonfig-target/operators.d.ts +27 -0
- package/dist/migrate/quonfig-target/operators.js +71 -0
- package/dist/migrate/quonfig-target/report.d.ts +59 -0
- package/dist/migrate/quonfig-target/report.js +39 -0
- package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
- package/dist/migrate/quonfig-target/ruleset.js +192 -0
- package/dist/migrate/quonfig-target/values.d.ts +38 -0
- package/dist/migrate/quonfig-target/values.js +77 -0
- package/dist/migrate/source-api-key.d.ts +33 -0
- package/dist/migrate/source-api-key.js +43 -0
- package/dist/migrate/source.d.ts +10 -0
- package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
- package/dist/migrate/sources/launchdarkly/api.js +195 -0
- package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
- package/dist/migrate/sources/launchdarkly/audit.js +172 -0
- package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
- package/dist/migrate/sources/launchdarkly/translate.js +209 -0
- package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
- package/dist/migrate/sources/launchdarkly/types.js +10 -0
- package/dist/migrate/sources/launchdarkly.d.ts +35 -0
- package/dist/migrate/sources/launchdarkly.js +295 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +178 -161
- package/package.json +5 -5
|
@@ -1,18 +1,300 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `qfg migrate --from launchdarkly` — the MigrationSource implementation.
|
|
3
|
+
*
|
|
4
|
+
* Wires the LaunchDarkly fetcher (`launchdarkly/api.ts`) and converter
|
|
5
|
+
* (`launchdarkly/translate.ts`) into the source-agnostic migration framework.
|
|
6
|
+
* Both write modes (`--dir`, `--push`) are inherited for free — this source
|
|
7
|
+
* only has to produce `QuonfigFile[]`.
|
|
8
|
+
*
|
|
9
|
+
* Design is frozen in `launchdarkly.README.md` / `project/plans/migrator-launch-darkly.md`.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { ConversionReport } from '../quonfig-target/report.js';
|
|
15
|
+
import { fetchProjectEnvironments, fetchSegmentsForEnv, fetchSnapshot } from './launchdarkly/api.js';
|
|
16
|
+
import { auditEntryToLegacyChange, buildFlagAuditSpec, getCommitMetaForAuditEntry, probeRetentionHorizon, walkAuditLog, } from './launchdarkly/audit.js';
|
|
17
|
+
import { flagOutputPath, segmentOutputPath, translateFlag, translateSegment } from './launchdarkly/translate.js';
|
|
2
18
|
const SOURCE_NAME = 'launchdarkly';
|
|
19
|
+
/**
|
|
20
|
+
* Resume cursor for a crashed Phase-2 walk. Lives in `os.tmpdir()` keyed by
|
|
21
|
+
* project key — NOT inside the target workspace, because `--push` mode requires
|
|
22
|
+
* the local clone dir to be empty before cloning (and a checkpoint sitting in
|
|
23
|
+
* `.qf/` would clobber that). OS-tmpdir is fine for in-session crash recovery;
|
|
24
|
+
* cross-reboot recovery is out of scope for this walker.
|
|
25
|
+
*/
|
|
26
|
+
function auditCheckpointFilename(projectKey) {
|
|
27
|
+
const safeKey = projectKey.replaceAll(/[^\w.-]+/g, '_');
|
|
28
|
+
return `qfg-ld-audit-${safeKey}.json`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* LaunchDarkly requires a project key on every endpoint. The env var
|
|
32
|
+
* `LAUNCHDARKLY_PROJECT_KEY` (default `default`) is the module-load fallback;
|
|
33
|
+
* the `qfg migrate` command overrides it per-run via `setLaunchDarklyProjectKey()`
|
|
34
|
+
* from its `--project` flag (Epic 5 write-mode wiring).
|
|
35
|
+
*/
|
|
36
|
+
function resolveProjectKey() {
|
|
37
|
+
return process.env.LAUNCHDARKLY_PROJECT_KEY ?? 'default';
|
|
38
|
+
}
|
|
39
|
+
const state = {
|
|
40
|
+
apiKey: null,
|
|
41
|
+
environments: [],
|
|
42
|
+
fullSummary: false,
|
|
43
|
+
projectKey: resolveProjectKey(),
|
|
44
|
+
report: new ConversionReport(),
|
|
45
|
+
retentionHorizon: null,
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Override the LaunchDarkly project key for this run. Called by the `qfg migrate`
|
|
49
|
+
* command from its `--project` flag (which itself falls back to the
|
|
50
|
+
* `LAUNCHDARKLY_PROJECT_KEY` env var). Mirrors `applyLaunchDarklyBaseUrl()` —
|
|
51
|
+
* the command layer threads run-scoped config into the source singleton.
|
|
52
|
+
*/
|
|
53
|
+
export function setLaunchDarklyProjectKey(projectKey) {
|
|
54
|
+
state.projectKey = projectKey;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Enable Phase-2 history backfill for this run. When set, `fetchChanges` walks
|
|
58
|
+
* the LaunchDarkly audit log instead of taking a current-state snapshot, and
|
|
59
|
+
* `validateAuth` runs the retention pre-flight. Called by `qfg migrate` from
|
|
60
|
+
* its `--full-summary` flag.
|
|
61
|
+
*/
|
|
62
|
+
export function setLaunchDarklyFullSummary(on) {
|
|
63
|
+
state.fullSummary = on;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The retention pre-flight result from the last full-summary `validateAuth`,
|
|
67
|
+
* or null. The command reads this and tells the user the real history horizon
|
|
68
|
+
* BEFORE the slow audit walk starts (plan §4.1.1).
|
|
69
|
+
*/
|
|
70
|
+
export function getLaunchDarklyRetentionHorizon() {
|
|
71
|
+
return state.retentionHorizon;
|
|
72
|
+
}
|
|
73
|
+
class MissingAuthError extends Error {
|
|
74
|
+
constructor(operation) {
|
|
75
|
+
super(`launchdarkly source ${operation} requires validateAuth(apiKey) to be called first (no API key configured).`);
|
|
76
|
+
this.name = 'MissingAuthError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function requireApiKey(operation) {
|
|
80
|
+
if (!state.apiKey)
|
|
81
|
+
throw new MissingAuthError(operation);
|
|
82
|
+
return state.apiKey;
|
|
83
|
+
}
|
|
84
|
+
function slugifyEnvKey(key) {
|
|
85
|
+
return key
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
88
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
89
|
+
}
|
|
90
|
+
async function validateAuthImpl(apiKey) {
|
|
91
|
+
// A cheap authenticated call — 401s on a bad token, 404s on a bad project.
|
|
92
|
+
await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
93
|
+
state.apiKey = apiKey;
|
|
94
|
+
state.report = new ConversionReport();
|
|
95
|
+
state.environments = [];
|
|
96
|
+
state.retentionHorizon = null;
|
|
97
|
+
// Plan §4.1.1: under --full-summary, probe the audit-log retention window
|
|
98
|
+
// here — the earliest authenticated hook — so the command can tell the user
|
|
99
|
+
// the real history horizon BEFORE the slow Phase-2 walk starts. The walk can
|
|
100
|
+
// take hours; nobody should wait that long expecting two years and silently
|
|
101
|
+
// get thirty days.
|
|
102
|
+
if (state.fullSummary) {
|
|
103
|
+
state.retentionHorizon = await probeRetentionHorizon(apiKey, { spec: buildFlagAuditSpec(state.projectKey) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function listEnvironmentsImpl() {
|
|
107
|
+
const apiKey = requireApiKey('listEnvironments');
|
|
108
|
+
const envKeys = await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
109
|
+
state.environments = envKeys.map((k) => slugifyEnvKey(k));
|
|
110
|
+
return [...new Set(state.environments)];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Phase-1 config snapshot. Decision D2: always a full re-snapshot — we do not
|
|
114
|
+
* trust the LaunchDarkly audit-log cursor for delta correctness. `sinceEpochMs`
|
|
115
|
+
* still feeds the framework's "what changed since last run" reporting, but the
|
|
116
|
+
* fetch itself is unconditional. Every flag and segment is yielded as one
|
|
117
|
+
* `LegacyChange`; the converter runs in `translate()`.
|
|
118
|
+
*
|
|
119
|
+
* Under `--full-summary` this delegates to the Phase-2 audit-log walk instead.
|
|
120
|
+
*/
|
|
121
|
+
async function* fetchChangesImpl() {
|
|
122
|
+
const apiKey = requireApiKey('fetchChanges');
|
|
123
|
+
if (state.fullSummary) {
|
|
124
|
+
yield* fetchAuditHistory(apiKey);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const snapshot = await fetchSnapshot(apiKey, state.projectKey);
|
|
128
|
+
state.environments = snapshot.environments.map((k) => slugifyEnvKey(k));
|
|
129
|
+
for (const flag of snapshot.flags) {
|
|
130
|
+
yield { key: flag.key, raw: { data: flag, kind: 'flag' }, source: SOURCE_NAME };
|
|
131
|
+
}
|
|
132
|
+
for (const segment of snapshot.segments) {
|
|
133
|
+
yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Path to this run's audit-walk resume cursor (always under os.tmpdir; see filename comment). */
|
|
137
|
+
function auditCheckpointPath() {
|
|
138
|
+
return path.join(os.tmpdir(), auditCheckpointFilename(state.projectKey));
|
|
139
|
+
}
|
|
140
|
+
/** Read the persisted `before` cursor from a crashed walk, or undefined if none. */
|
|
141
|
+
function readAuditCheckpoint(checkpointPath) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(fs.readFileSync(checkpointPath, 'utf8'));
|
|
144
|
+
return typeof parsed.before === 'number' ? parsed.before : undefined;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// No checkpoint (fresh run) or an unreadable one — start from newest.
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function writeAuditCheckpoint(checkpointPath, before) {
|
|
152
|
+
fs.writeFileSync(checkpointPath, JSON.stringify({ before }, null, 2) + '\n', 'utf8');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Phase-2 history backfill (`--full-summary`). Walks the LaunchDarkly audit log
|
|
156
|
+
* newest-to-oldest, reifying each flag change into a `LegacyChange` that carries
|
|
157
|
+
* both the point-in-time flag snapshot (for `translate()`) and the originating
|
|
158
|
+
* audit entry (for `getCommitMeta()`). The walk is checkpointed to an os.tmpdir
|
|
159
|
+
* file so a crashed multi-hour run resumes instead of restarting; the
|
|
160
|
+
* checkpoint is cleared once the walk completes.
|
|
161
|
+
*
|
|
162
|
+
* The audit log is flag-scoped (plan §4.1), so current-state segments are still
|
|
163
|
+
* re-snapshotted and appended — `--full-summary` must not silently drop them.
|
|
164
|
+
* Flag changes are emitted oldest-first so the write paths build chronological
|
|
165
|
+
* git history.
|
|
166
|
+
*/
|
|
167
|
+
async function* fetchAuditHistory(apiKey) {
|
|
168
|
+
const spec = buildFlagAuditSpec(state.projectKey);
|
|
169
|
+
const checkpointPath = auditCheckpointPath();
|
|
170
|
+
const startBefore = readAuditCheckpoint(checkpointPath);
|
|
171
|
+
// The audit log is newest-first; collect then reverse so changes commit in
|
|
172
|
+
// chronological order.
|
|
173
|
+
const flagChanges = [];
|
|
174
|
+
for await (const entry of walkAuditLog(apiKey, {
|
|
175
|
+
spec,
|
|
176
|
+
...(startBefore === undefined ? {} : { startBefore }),
|
|
177
|
+
onCheckpoint(before) {
|
|
178
|
+
writeAuditCheckpoint(checkpointPath, before);
|
|
179
|
+
},
|
|
180
|
+
})) {
|
|
181
|
+
const change = auditEntryToLegacyChange(entry);
|
|
182
|
+
if (change)
|
|
183
|
+
flagChanges.push(change);
|
|
184
|
+
}
|
|
185
|
+
// Walk finished cleanly — drop the resume cursor so a later run starts fresh.
|
|
186
|
+
if (fs.existsSync(checkpointPath))
|
|
187
|
+
fs.rmSync(checkpointPath);
|
|
188
|
+
flagChanges.reverse();
|
|
189
|
+
for (const change of flagChanges)
|
|
190
|
+
yield change;
|
|
191
|
+
// Re-snapshot current-state segments (flag-scoped audit log can't carry them).
|
|
192
|
+
const envKeys = await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
193
|
+
state.environments = envKeys.map((k) => slugifyEnvKey(k));
|
|
194
|
+
const segmentsByKey = new Map();
|
|
195
|
+
for (const envKey of envKeys) {
|
|
196
|
+
// eslint-disable-next-line no-await-in-loop
|
|
197
|
+
const segs = await fetchSegmentsForEnv(apiKey, state.projectKey, envKey);
|
|
198
|
+
for (const seg of segs) {
|
|
199
|
+
if (!segmentsByKey.has(seg.key))
|
|
200
|
+
segmentsByKey.set(seg.key, seg);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const segment of segmentsByKey.values()) {
|
|
204
|
+
yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function translateImpl(change) {
|
|
208
|
+
const raw = change.raw;
|
|
209
|
+
if (!raw || typeof raw !== 'object' || !('kind' in raw))
|
|
210
|
+
return [];
|
|
211
|
+
try {
|
|
212
|
+
if (raw.kind === 'flag') {
|
|
213
|
+
const out = translateFlag(raw.data, state.report);
|
|
214
|
+
return [{ contents: JSON.stringify(out, null, 2), path: flagOutputPath(raw.data.key) }];
|
|
215
|
+
}
|
|
216
|
+
const out = translateSegment(raw.data, state.report);
|
|
217
|
+
return [{ contents: JSON.stringify(out, null, 2), path: segmentOutputPath(raw.data.key) }];
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// Last-resort safety net: one structurally broken flag/segment must not
|
|
221
|
+
// abort the whole run. Record it and keep going (plan §5.4 — nothing silent).
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
state.report.add('skipped-config', change.key ?? 'unknown', `conversion failed: ${message}`);
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Configs that could not be converted at all. Partial-conversion notes
|
|
229
|
+
* (dropped prerequisites, re-bucketed rollouts, etc.) live on the full
|
|
230
|
+
* `ConversionReport` — see `getConversionReport()` — and are wired into the
|
|
231
|
+
* lossy/unsupported sections of `MIGRATION_REPORT.md` by the write-mode epic.
|
|
232
|
+
*/
|
|
233
|
+
function getSkippedConfigsImpl() {
|
|
234
|
+
const skipped = state.report.byCategory('skipped-config');
|
|
235
|
+
if (skipped.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
return {
|
|
238
|
+
entries: skipped.map((n) => ({ key: n.key, reason: n.detail })),
|
|
239
|
+
total: skipped.length,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** The full conversion report — every skip/drop/coerce note from this run. */
|
|
243
|
+
export function getConversionReport() {
|
|
244
|
+
return state.report;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Every structured conversion note from this run — re-bucketed rollouts,
|
|
248
|
+
* dropped prerequisites, lossy individual-target conversions, etc. The write
|
|
249
|
+
* paths (`local-write.ts` / `push-to-cloud.ts`) thread this into
|
|
250
|
+
* `MIGRATION_REPORT.md`'s "Users will be re-bucketed" + "Conversion notes"
|
|
251
|
+
* sections (plan §5.4 — nothing is silently dropped). `skipped-config` notes
|
|
252
|
+
* are included here too; the report renderer routes them to their own
|
|
253
|
+
* dedicated section.
|
|
254
|
+
*/
|
|
255
|
+
function getConversionNotesImpl() {
|
|
256
|
+
const notes = state.report.all();
|
|
257
|
+
return notes.length === 0 ? null : notes;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Per-change commit metadata for `--full-summary` (plan §7). Reifies the
|
|
261
|
+
* original LaunchDarkly member, timestamp and description from the audit entry
|
|
262
|
+
* carried on the change's `raw` payload. Returns null for current-state changes
|
|
263
|
+
* (segments, or a flag with no `auditEntry`) so the write paths fall back to
|
|
264
|
+
* the migrator identity.
|
|
265
|
+
*/
|
|
266
|
+
function getCommitMetaImpl(change) {
|
|
267
|
+
const raw = change.raw;
|
|
268
|
+
if (!raw || typeof raw !== 'object' || !('auditEntry' in raw))
|
|
269
|
+
return null;
|
|
270
|
+
return getCommitMetaForAuditEntry(raw.auditEntry);
|
|
271
|
+
}
|
|
3
272
|
export const launchdarklySource = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
|
|
7
|
-
},
|
|
8
|
-
listEnvironments() {
|
|
9
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
|
|
273
|
+
fetchChanges() {
|
|
274
|
+
return fetchChangesImpl();
|
|
10
275
|
},
|
|
276
|
+
getCommitMeta: getCommitMetaImpl,
|
|
277
|
+
getConversionNotes: getConversionNotesImpl,
|
|
278
|
+
getSkippedConfigs: getSkippedConfigsImpl,
|
|
279
|
+
listEnvironments: listEnvironmentsImpl,
|
|
11
280
|
name: SOURCE_NAME,
|
|
12
|
-
translate
|
|
13
|
-
|
|
14
|
-
},
|
|
15
|
-
validateAuth() {
|
|
16
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
|
|
17
|
-
},
|
|
281
|
+
translate: translateImpl,
|
|
282
|
+
validateAuth: validateAuthImpl,
|
|
18
283
|
};
|
|
284
|
+
export function __resetLaunchDarklySourceForTests() {
|
|
285
|
+
state.apiKey = null;
|
|
286
|
+
state.environments = [];
|
|
287
|
+
state.fullSummary = false;
|
|
288
|
+
state.projectKey = resolveProjectKey();
|
|
289
|
+
state.report = new ConversionReport();
|
|
290
|
+
state.retentionHorizon = null;
|
|
291
|
+
// Clear any stray checkpoint from a previous test's resumability assertion.
|
|
292
|
+
try {
|
|
293
|
+
const cp = auditCheckpointPath();
|
|
294
|
+
if (fs.existsSync(cp))
|
|
295
|
+
fs.rmSync(cp);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* best-effort */
|
|
299
|
+
}
|
|
300
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.0.
|
|
1
|
+
declare const _default: "0.0.48";
|
|
2
2
|
export default _default;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
|
|
2
|
-
export default '0.0.
|
|
2
|
+
export default '0.0.48';
|