@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.
@@ -110,20 +110,34 @@ const renderDuplicateResolutions = (resolved) => {
110
110
  }
111
111
  return lines.join('\n');
112
112
  };
113
- const renderCoercedSentinels = (coerced) => {
113
+ const FLAGSMITH_ENV_BUCKET_LABEL = {
114
+ '_all-envs': 'Override rows (all environments)',
115
+ '_cross-env': 'Cross-environment value-type divergence (no single env owns this)',
116
+ };
117
+ const renderCoercedSentinels = (coerced, source) => {
114
118
  if (!coerced || coerced.total === 0)
115
119
  return null;
116
120
  const envIds = Object.keys(coerced.byEnv).sort();
117
- const lines = [
118
- '## Coerced sentinel rule values',
119
- '',
120
- `Coerced **${coerced.total}** rule value(s) from Launch's "no value set yet" sentinel ({type:"string", value:""}) to the typed default for the surrounding config. The qfg-verify hook would otherwise reject these as type-mismatches and fail-stop the entire push. Affected envs: **${envIds.length}**. Review each and set a real default in the source system if needed.`,
121
- ];
121
+ const intro = source === 'flagsmith'
122
+ ? `Coerced **${coerced.total}** rule value(s) from Flagsmith's runtime semantics into a concrete served value. ` +
123
+ `Two flavors are bucketed here:\n\n` +
124
+ `1. **D-F1: \`enabled=false\` on non-boolean features.** Flagsmith would have ignored the stored value and ` +
125
+ `served your code's hardcoded default. Quonfig always serves a value, so the migrator emits the stored ` +
126
+ `\`feature_state_value\` and surfaces every affected flag below.\n` +
127
+ `2. **D-F5: cross-environment value-type divergence.** Flagsmith allowed the same flag to be a string in one ` +
128
+ `env and an int in another; Quonfig requires one \`valueType\` per flag. The migrator coerced to \`string\`.\n\n` +
129
+ `Review each entry below and set a real default in the source system if the value is wrong.`
130
+ : `Coerced **${coerced.total}** rule value(s) from Launch's "no value set yet" sentinel ` +
131
+ `({type:"string", value:""}) to the typed default for the surrounding config. The qfg-verify hook would ` +
132
+ `otherwise reject these as type-mismatches and fail-stop the entire push. Affected envs: ` +
133
+ `**${envIds.length}**. Review each and set a real default in the source system if needed.`;
134
+ const lines = ['## Coerced sentinel rule values', '', intro];
122
135
  for (const envId of envIds) {
123
136
  const perFlag = coerced.byEnv[envId];
124
137
  const totalForEnv = Object.values(perFlag).reduce((s, n) => s + n, 0);
125
138
  const flagCount = Object.keys(perFlag).length;
126
- lines.push('', `### env-${envId} — ${totalForEnv} coerced from ${flagCount} config(s)`, '');
139
+ const heading = FLAGSMITH_ENV_BUCKET_LABEL[envId] ?? `env-${envId}`;
140
+ lines.push('', `### ${heading} — ${totalForEnv} coerced from ${flagCount} config(s)`, '');
127
141
  const sorted = Object.keys(perFlag).sort();
128
142
  for (const flagPath of sorted) {
129
143
  lines.push(`- \`${flagPath}\` (${perFlag[flagPath]})`);
@@ -155,6 +169,7 @@ const renderSkippedConfigs = (skipped) => {
155
169
  */
156
170
  const CONVERSION_NOTE_HEADINGS = {
157
171
  'ai-config-skipped': 'AI Configs skipped (out of v1 scope)',
172
+ 'cross-env-value-type-coerced': 'Cross-environment value-type coerced to string',
158
173
  'dropped-custom-properties': 'Dropped custom properties',
159
174
  'dropped-experiment-metadata': 'Dropped experiment metadata',
160
175
  'dropped-maintainer': 'Dropped maintainer metadata',
@@ -162,6 +177,9 @@ const CONVERSION_NOTE_HEADINGS = {
162
177
  'dropped-mobile-key-still-visible': 'Dropped mobile-key availability (still client-visible)',
163
178
  'dropped-prerequisite': 'Dropped prerequisites',
164
179
  'dropped-private-attribute': 'Dropped private attributes',
180
+ 'enabled-false-non-boolean': 'enabled=false on non-boolean features',
181
+ 'identity-override-as-rule': 'Identity overrides converted to leading rules',
182
+ 'identity-traits-referenced': 'Identity traits referenced by segment rules',
165
183
  'individual-target-as-rule': 'Individual targets converted to rules',
166
184
  'skipped-rule': 'Skipped rules (unsupported operators)',
167
185
  'unexportable-segment-membership': 'Unexportable segment membership',
@@ -248,14 +266,15 @@ const renderBeforeYouCutOver = (notes) => {
248
266
  }
249
267
  return lines.join('\n');
250
268
  };
251
- const renderRebucketedRollouts = (notes) => {
269
+ const renderRebucketedRollouts = (notes, source) => {
252
270
  const rollouts = (notes ?? []).filter((n) => n.category === 'rebucketed-rollout');
253
271
  if (rollouts.length === 0)
254
272
  return null;
273
+ const sourceLabel = source === 'flagsmith' ? 'Flagsmith' : source === 'launchdarkly' ? 'LaunchDarkly' : source;
255
274
  const lines = [
256
275
  '## Users will be re-bucketed',
257
276
  '',
258
- `**${rollouts.length}** flag(s)/segment(s) use a percentage rollout. LaunchDarkly and Quonfig hash buckets ` +
277
+ `**${rollouts.length}** flag(s)/segment(s) use a percentage rollout. ${sourceLabel} and Quonfig hash buckets ` +
259
278
  `differently, so which users land in which bucket *will change* after migration — the rollout percentage is ` +
260
279
  `preserved, but individual user assignments are not. Review these before cutover so the re-bucketing is not a surprise:`,
261
280
  '',
@@ -451,6 +470,45 @@ const LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX = [
451
470
  '- Maintainer is no longer a field on flags. `git log path/to/flag.json` is the',
452
471
  ' new source of truth.',
453
472
  ].join('\n');
473
+ /**
474
+ * Flagsmith-specific behavioral-differences appendix (plan §5.5). Same shape
475
+ * as the LD version above — static documentation about gaps a customer reading
476
+ * MIGRATION_REPORT.md should know about independently of their specific data.
477
+ */
478
+ const FLAGSMITH_BEHAVIORAL_DIFFERENCES_APPENDIX = [
479
+ '## Behavioral differences post-cutover (independent of this import)',
480
+ '',
481
+ '- `enabled=false` semantics differ. Flagsmith ignores `feature_state_value` when',
482
+ " `enabled=false` and the SDK serves your code's hardcoded default. Quonfig always",
483
+ ' serves the configured value. For boolean features the migrator emits `false`; for',
484
+ ' non-boolean features it emits the stored `feature_state_value` and surfaces every',
485
+ ' affected flag under "Coerced sentinel rule values" above. Review each before',
486
+ ' cutover — Flagsmith\'s "off" behavior does NOT round-trip.',
487
+ '- Flagsmith has no individual-target primitive in Quonfig v1. Identity overrides',
488
+ ' are emitted as leading `PROP_IS_ONE_OF` rules on `user.key`. Adding/removing an',
489
+ ' identity is now a config edit (qfg set / web UI), not a per-identity row.',
490
+ '- Identity traits are runtime context Flagsmith infers from the trait directory.',
491
+ ' Quonfig has no server-side trait store — your SDK callers must send every trait',
492
+ ' attribute referenced by a segment rule on the eval context. See "Identity traits',
493
+ ' referenced by segment rules" above for the exhaustive list.',
494
+ '- Multivariate bucketing salts differ between Flagsmith and Quonfig. The percentage',
495
+ ' allocation is preserved, but identity → variation assignments WILL change. See',
496
+ ' "Users will be re-bucketed" above.',
497
+ '- MODULO and PERCENTAGE_SPLIT segment-condition operators have no Quonfig equivalent.',
498
+ ' Rules using them were dropped with `skipped-rule` notes; rebuild by hand if you',
499
+ ' need the segmentation.',
500
+ '- Segment rule combinators `type=ANY` and `type=NONE` (within-rule OR / NAND) have',
501
+ ' no Quonfig equivalent — Quonfig criteria within a single rule are AND-only. Those',
502
+ ' rules were dropped with `skipped-rule` notes.',
503
+ "- `:semver`-suffixed values are mapped to Quonfig's `PROP_SEMVER_*` operators.",
504
+ ' Inclusive variants (GTE/LTE) have no semver equivalent and were dropped.',
505
+ '- Soft-deleted features in Flagsmith are functionally hard-deleted from the public',
506
+ ' API (404 on GET, absent from list responses). The migrator cannot enumerate them;',
507
+ ' any features deleted before this snapshot was taken are simply not present in the',
508
+ ' Quonfig workspace.',
509
+ '- Feature owners, project metadata, change requests, and scheduled changes are out',
510
+ ' of v1 scope. None round-trip into Quonfig.',
511
+ ].join('\n');
454
512
  const renderFollowUp = (followUp) => {
455
513
  const must = followUp.mustFixBeforeCutover.length === 0
456
514
  ? NONE
@@ -489,10 +547,10 @@ export const buildMigrationReport = (data) => {
489
547
  const skipped = renderSkippedConfigs(data.skippedConfigs);
490
548
  if (skipped !== null)
491
549
  sections.push(skipped);
492
- const coerced = renderCoercedSentinels(data.coercedSentinels);
550
+ const coerced = renderCoercedSentinels(data.coercedSentinels, data.source);
493
551
  if (coerced !== null)
494
552
  sections.push(coerced);
495
- const rebucketed = renderRebucketedRollouts(data.conversionNotes);
553
+ const rebucketed = renderRebucketedRollouts(data.conversionNotes, data.source);
496
554
  if (rebucketed !== null)
497
555
  sections.push(rebucketed);
498
556
  const conversionNotes = renderConversionNotes(data.conversionNotes, data.maintainerMap);
@@ -502,6 +560,9 @@ export const buildMigrationReport = (data) => {
502
560
  if (data.source === 'launchdarkly') {
503
561
  sections.push(LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX);
504
562
  }
563
+ if (data.source === 'flagsmith') {
564
+ sections.push(FLAGSMITH_BEHAVIORAL_DIFFERENCES_APPENDIX);
565
+ }
505
566
  return sections.join('\n\n') + '\n';
506
567
  };
507
568
  export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
@@ -43,7 +43,24 @@ export type ConversionNoteCategory =
43
43
  /** A big/synced segment whose membership is not exportable via REST. */
44
44
  | 'unexportable-segment-membership'
45
45
  /** An AI Config enumerated but not emitted (out of v1 scope, D9). */
46
- | 'ai-config-skipped';
46
+ | 'ai-config-skipped'
47
+ /**
48
+ * Flagsmith D-F1: a non-boolean feature was `enabled=false`. Flagsmith would have
49
+ * served the SDK-side default (not visible to the migrator); Quonfig will now
50
+ * serve the stored `feature_state_value`. The "coerced sentinel" framing in
51
+ * `getCoercedSentinels()` is reused so the per-env counts surface in the report.
52
+ */
53
+ | 'enabled-false-non-boolean'
54
+ /** Flagsmith D-F5: same feature has different value types across envs. Coerced to string. */
55
+ | 'cross-env-value-type-coerced'
56
+ /** Flagsmith identity override emitted as a leading PROP_IS_ONE_OF rule on the identifier attribute. */
57
+ | 'identity-override-as-rule'
58
+ /**
59
+ * Flagsmith identity-trait extraction: a context attribute referenced by a segment rule
60
+ * (plan §5.5 / D-F6). Pure FYI — your SDK callers will need to send this attribute at
61
+ * eval time. The `key` is the trait name; `detail` lists the segments referencing it.
62
+ */
63
+ | 'identity-traits-referenced';
47
64
  export interface ConversionNote {
48
65
  category: ConversionNoteCategory;
49
66
  /** Human-readable detail — names the exact thing lost and why. */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Flagsmith REST API client — Phase-1 config snapshot.
3
+ *
4
+ * Flagsmith has no bulk-export endpoint, so the fetcher stitches per-resource
5
+ * calls: project → environments → features → per-env featurestates → segments
6
+ * → feature-segments (priority) → edge identities → edge identity overrides
7
+ * → tags.
8
+ *
9
+ * Auth is `Authorization: Api-Key <token>` — note the `Api-Key` prefix (not
10
+ * `Bearer`, and not bare-token like LaunchDarkly). Rate-limiting headers are
11
+ * unpublished; we program against the response: back off on 429 using
12
+ * `Retry-After` (standard) or `X-RateLimit-Reset` (Flagsmith's variant), and
13
+ * step the inter-request throttle up briefly when we hit one (mirrors the
14
+ * Phase-2 generator's empirical backoff loop). On any non-429 error we throw
15
+ * immediately — only rate limiting is retried.
16
+ *
17
+ * Pagination is standard Django REST `{count, next, previous, results}` with
18
+ * absolute `next` URLs. Edge-identities listing is the one exception: it uses
19
+ * `{results, last_evaluated_key}` cursor pagination via a query-string param.
20
+ */
21
+ import type { FlagsmithEdgeIdentity, FlagsmithEdgeIdentityOverride, FlagsmithEnvironment, FlagsmithFeature, FlagsmithFeatureSegment, FlagsmithFeatureState, FlagsmithProject, FlagsmithSegment, FlagsmithSnapshot, FlagsmithTag } from './types.js';
22
+ export declare function setFlagsmithBaseUrl(url: string): void;
23
+ export declare function selectFlagsmithBaseUrl(): string;
24
+ export declare function applyFlagsmithBaseUrl(): void;
25
+ export declare function __setSleepForTests(fn: (ms: number) => Promise<void>): void;
26
+ export declare function __resetSleepForTests(): void;
27
+ export declare class FlagsmithApiError extends Error {
28
+ readonly status: number;
29
+ constructor(status: number, statusText: string, url: string, body: string);
30
+ }
31
+ /**
32
+ * Single GET with `Api-Key` auth, 429 backoff against `Retry-After` /
33
+ * `X-RateLimit-Reset`, and an exponential fallback. Non-429 errors throw
34
+ * immediately — only rate limiting is retried.
35
+ */
36
+ export declare function apiFetch(path: string, apiKey: string): Promise<unknown>;
37
+ /** `GET /projects/{id}/` — used as the cheap auth probe AND as a source of `use_edge_identities`. */
38
+ export declare function fetchProject(apiKey: string, projectId: number | string): Promise<FlagsmithProject>;
39
+ /** `GET /environments/?project={id}` — paginated env list. */
40
+ export declare function fetchEnvironments(apiKey: string, projectId: number | string): Promise<FlagsmithEnvironment[]>;
41
+ /**
42
+ * `GET /projects/{pk}/features/` — paginated feature list. Each result
43
+ * includes inline `multivariate_options[]` which we sort ASC by id (API6) so
44
+ * downstream code sees variations in definition order, not the reverse order
45
+ * the API returns.
46
+ */
47
+ export declare function fetchFeatures(apiKey: string, projectId: number | string): Promise<FlagsmithFeature[]>;
48
+ /**
49
+ * `GET /features/featurestates/?environment={env_id}` — per-env featurestates.
50
+ *
51
+ * This is the unified listing that returns BOTH env-default rows
52
+ * (`feature_segment == null && identity == null`) AND segment-override rows
53
+ * (`feature_segment != null`). The plan §4.1 suggests
54
+ * `/environments/{api_key}/featurestates/`, but empirically on v2-versioned
55
+ * envs that endpoint silently omits segment-override rows. The
56
+ * `/features/featurestates/?environment=` path is the only read endpoint that
57
+ * returns both on a v2 env, so we use it uniformly across v1 and v2 envs.
58
+ *
59
+ * Identity overrides are NOT in this listing on edge-enabled projects — see
60
+ * `fetchEdgeIdentityOverrides`.
61
+ */
62
+ export declare function fetchEnvFeatureStates(apiKey: string, envId: number): Promise<FlagsmithFeatureState[]>;
63
+ /** `GET /projects/{pk}/segments/` — project-scoped segment pool. */
64
+ export declare function fetchSegments(apiKey: string, projectId: number | string): Promise<FlagsmithSegment[]>;
65
+ /**
66
+ * `GET /features/feature-segments/?environment={env_id}&feature={feature_id}` —
67
+ * the per-env, per-feature segment priority list. Required by the converter
68
+ * to order the segment-override rules (Quonfig is a strict first-match list).
69
+ *
70
+ * The endpoint REJECTS calls without `feature` (`{feature: ["This field is
71
+ * required."]}`), so we have to walk every feature × env combo. This is N×E
72
+ * calls in the worst case — acceptable for the corpus (~110 features × 2 envs
73
+ * = ~220 calls) and roughly the same order as fetchEnvFeatureStates.
74
+ */
75
+ export declare function fetchFeatureSegments(apiKey: string, envId: number, featureId: number): Promise<FlagsmithFeatureSegment[]>;
76
+ /**
77
+ * `GET /environments/{api_key}/edge-identities/` — paginated edge-identity
78
+ * listing. Pagination here is CURSOR-based (`last_evaluated_key`) not page
79
+ * numbers — the Flagsmith dynamo-backed edge store can't offset-paginate.
80
+ *
81
+ * The fetcher uses this only to surface identifiers for the report; the
82
+ * actual identity-override featurestates come from
83
+ * `fetchEdgeIdentityOverrides`, which is a one-call-per-env shortcut.
84
+ */
85
+ export declare function fetchEdgeIdentities(apiKey: string, envApiKey: string): Promise<FlagsmithEdgeIdentity[]>;
86
+ /**
87
+ * `GET /environments/{api_key}/edge-identity-overrides` — every identity
88
+ * override in the env, flat. Each row is `{identifier, identity_uuid,
89
+ * feature_state}` so we can index by feature without walking N identities.
90
+ *
91
+ * Note: the path has NO trailing slash (unlike most Flagsmith paths). This
92
+ * endpoint does not paginate in the verified shape — it returns
93
+ * `{results: [...]}` directly.
94
+ */
95
+ export declare function fetchEdgeIdentityOverrides(apiKey: string, envApiKey: string): Promise<FlagsmithEdgeIdentityOverride[]>;
96
+ /** `GET /projects/{pk}/tags/` — project-scoped tag pool. */
97
+ export declare function fetchTags(apiKey: string, projectId: number | string): Promise<FlagsmithTag[]>;
98
+ /**
99
+ * Stitch the whole Phase-1 snapshot: project → environments → features →
100
+ * per-env featurestates (split into env-default + segment-overrides) → segments
101
+ * → feature-segments (per env, per feature with segment overrides) → edge
102
+ * identity overrides → tags.
103
+ *
104
+ * Walk count grows as 1 (project) + 1 (envs) + 1 (segments) + 1 (tags) + E ×
105
+ * (1 featurestates + 1 edge-id-overrides + S features-with-segov ×
106
+ * feature-segments). For the 110-feature, 2-env, 5 features-with-segov live
107
+ * corpus: ~1 + 1 + 1 + 1 + 2 × (1 + 1 + 5) = ~17 calls. The featurestates
108
+ * endpoint is paginated under 100/page so larger projects multiply that
109
+ * featurestates leg.
110
+ *
111
+ * `sinceEpochMs` is currently unused — decision D2 says Phase 1 is always a
112
+ * full re-snapshot. It survives in the signature so reporting (Epic 5) can
113
+ * compute "what's new since last run" against the same snapshot.
114
+ */
115
+ export declare function fetchSnapshot(apiKey: string, projectId: number | string): Promise<FlagsmithSnapshot>;