@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.
Files changed (32) hide show
  1. package/README.md +71 -66
  2. package/dist/commands/migrate.d.ts +2 -0
  3. package/dist/commands/migrate.js +68 -8
  4. package/dist/migrate/local-write.js +4 -0
  5. package/dist/migrate/migration-report.d.ts +9 -0
  6. package/dist/migrate/migration-report.js +68 -0
  7. package/dist/migrate/push-to-cloud.js +2 -0
  8. package/dist/migrate/quonfig-target/operators.d.ts +27 -0
  9. package/dist/migrate/quonfig-target/operators.js +71 -0
  10. package/dist/migrate/quonfig-target/report.d.ts +59 -0
  11. package/dist/migrate/quonfig-target/report.js +39 -0
  12. package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
  13. package/dist/migrate/quonfig-target/ruleset.js +192 -0
  14. package/dist/migrate/quonfig-target/values.d.ts +38 -0
  15. package/dist/migrate/quonfig-target/values.js +77 -0
  16. package/dist/migrate/source-api-key.d.ts +33 -0
  17. package/dist/migrate/source-api-key.js +43 -0
  18. package/dist/migrate/source.d.ts +10 -0
  19. package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
  20. package/dist/migrate/sources/launchdarkly/api.js +195 -0
  21. package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
  22. package/dist/migrate/sources/launchdarkly/audit.js +172 -0
  23. package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
  24. package/dist/migrate/sources/launchdarkly/translate.js +209 -0
  25. package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
  26. package/dist/migrate/sources/launchdarkly/types.js +10 -0
  27. package/dist/migrate/sources/launchdarkly.d.ts +35 -0
  28. package/dist/migrate/sources/launchdarkly.js +295 -13
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/oclif.manifest.json +178 -161
  32. package/package.json +5 -5
@@ -1,18 +1,300 @@
1
- import { NotYetImplementedError } from '../source.js';
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
- // eslint-disable-next-line require-yield
5
- async *fetchChanges() {
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
- throw new NotYetImplementedError(SOURCE_NAME, 'translate');
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.46";
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.46';
2
+ export default '0.0.48';