@quonfig/cli 0.0.50 → 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.
@@ -1,28 +1,385 @@
1
- // Stub — `qfg migrate --from flagsmith` is not yet wired up. Before replacing this with a real
2
- // implementation, investigate:
3
- // - Flagsmith API shape: what the admin/REST API returns for features, segments, identities,
4
- // environments, and multivariate variations, and how that maps onto Quonfig's JSON schema.
5
- // - Delta/cursor support: whether Flagsmith exposes an audit-log or change-feed endpoint with a
6
- // stable cursor we can persist in `.qf/import-state.json` for re-runnable incremental
7
- // imports, or whether we have to full-reimport + diff on every run (as is likely for LD).
8
- // - Auth model: API key scopes (server-side vs. admin), org/project/environment hierarchy.
9
- // - Schema gaps: Flagsmith's identity overrides and segment rules vs. Quonfig's rule operators
10
- // — document any unsupported primitives the way `launchdarkly.README.md` does for LD.
11
- import { NotYetImplementedError } from '../source.js';
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
- // eslint-disable-next-line require-yield
15
- async *fetchChanges() {
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
- throw new NotYetImplementedError(SOURCE_NAME, 'translate');
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
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.50";
1
+ declare const _default: "0.0.51";
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.50';
2
+ export default '0.0.51';
@@ -499,7 +499,7 @@
499
499
  "type": "boolean"
500
500
  },
501
501
  "dir": {
502
- "description": "Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var). When omitted, fetches the workspace from the server using your active credentials.",
502
+ "description": "Path to local QUONFIG_DIR (defaults to QUONFIG_DIR env var). When omitted, auto-detects a quonfig.json in the current directory; if none, fetches the workspace from the server.",
503
503
  "env": "QUONFIG_DIR",
504
504
  "name": "dir",
505
505
  "hasDynamicHelp": false,
@@ -1390,10 +1390,8 @@
1390
1390
  "type": "boolean"
1391
1391
  },
1392
1392
  "project": {
1393
- "description": "LaunchDarkly project key to migrate from (or set LAUNCHDARKLY_PROJECT_KEY). Scoped to `--from launchdarkly`; ignored by other sources.",
1394
- "env": "LAUNCHDARKLY_PROJECT_KEY",
1393
+ "description": "Source-system project identifier. For --from launchdarkly this is the project KEY (default: \"default\"; env: LAUNCHDARKLY_PROJECT_KEY). For --from flagsmith this is the numeric project ID (env: FLAGSMITH_PROJECT_ID). Ignored by --from launch.",
1395
1394
  "name": "project",
1396
- "default": "default",
1397
1395
  "hasDynamicHelp": false,
1398
1396
  "multiple": false,
1399
1397
  "type": "option"
@@ -3625,5 +3623,5 @@
3625
3623
  ]
3626
3624
  }
3627
3625
  },
3628
- "version": "0.0.50"
3626
+ "version": "0.0.51"
3629
3627
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.50",
4
+ "version": "0.0.51",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },
@@ -21,7 +21,7 @@
21
21
  "@oclif/core": "^3.18.2",
22
22
  "@oclif/plugin-help": "^6",
23
23
  "@oclif/plugin-plugins": "^4",
24
- "@quonfig/node": "^0.0.24",
24
+ "@quonfig/node": "^0.0.28",
25
25
  "acorn": "^8.10.0",
26
26
  "acorn-walk": "^8.3.5",
27
27
  "chalk": "^5.4.1",