@quonfig/cli 0.0.49 → 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.
Files changed (35) hide show
  1. package/README.md +50 -48
  2. package/dist/commands/generate.js +21 -5
  3. package/dist/commands/info.d.ts +1 -0
  4. package/dist/commands/info.js +28 -1
  5. package/dist/commands/migrate.d.ts +1 -1
  6. package/dist/commands/migrate.js +16 -5
  7. package/dist/commands/pull.js +2 -2
  8. package/dist/init/templates.js +59 -12
  9. package/dist/migrate/local-write.js +11 -1
  10. package/dist/migrate/migration-report.d.ts +16 -5
  11. package/dist/migrate/migration-report.js +351 -20
  12. package/dist/migrate/push-to-cloud.js +6 -1
  13. package/dist/migrate/quonfig-target/report.d.ts +45 -4
  14. package/dist/migrate/quonfig-target/report.js +5 -2
  15. package/dist/migrate/quonfig-target/ruleset.js +8 -6
  16. package/dist/migrate/source.d.ts +27 -0
  17. package/dist/migrate/sources/flagsmith/api.d.ts +115 -0
  18. package/dist/migrate/sources/flagsmith/api.js +402 -0
  19. package/dist/migrate/sources/flagsmith/translate.d.ts +72 -0
  20. package/dist/migrate/sources/flagsmith/translate.js +657 -0
  21. package/dist/migrate/sources/flagsmith/types.d.ts +282 -0
  22. package/dist/migrate/sources/flagsmith/types.js +18 -0
  23. package/dist/migrate/sources/flagsmith.d.ts +62 -0
  24. package/dist/migrate/sources/flagsmith.js +380 -23
  25. package/dist/migrate/sources/launchdarkly/api.d.ts +22 -1
  26. package/dist/migrate/sources/launchdarkly/api.js +23 -1
  27. package/dist/migrate/sources/launchdarkly/translate.js +42 -2
  28. package/dist/migrate/sources/launchdarkly/types.d.ts +19 -0
  29. package/dist/migrate/sources/launchdarkly.js +75 -4
  30. package/dist/util/friction-log.d.ts +1 -0
  31. package/dist/util/friction-log.js +6 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/oclif.manifest.json +158 -160
  35. package/package.json +2 -2
@@ -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,20 +26,56 @@ export type ConversionNoteCategory =
26
26
  | 'dropped-maintainer'
27
27
  /** `customProperties` dropped — report-only in v1 (D6). */
28
28
  | 'dropped-custom-properties'
29
- /** clientSideAvailability mobile-key dimension dropped. */
30
- | 'dropped-mobile-key-availability'
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. */
34
44
  | 'unexportable-segment-membership'
35
45
  /** An AI Config enumerated but not emitted (out of v1 scope, D9). */
36
- | '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';
37
64
  export interface ConversionNote {
38
65
  category: ConversionNoteCategory;
39
66
  /** Human-readable detail — names the exact thing lost and why. */
40
67
  detail: string;
41
68
  /** Source key the note is about (flag or segment key). */
42
69
  key: string;
70
+ /**
71
+ * Structured prereq edges on `dropped-prerequisite` notes — the renderer uses
72
+ * this to produce the inverted "orphaned parent → downstream consumers" view
73
+ * (qfg-nb4n). Absent on every other category.
74
+ */
75
+ prerequisites?: Array<{
76
+ parentKey: string;
77
+ variation: number;
78
+ }>;
43
79
  }
44
80
  /**
45
81
  * Collects conversion notes during a migration run. A provider source holds
@@ -49,7 +85,12 @@ export interface ConversionNote {
49
85
  export declare class ConversionReport {
50
86
  private readonly notes;
51
87
  get size(): number;
52
- add(category: ConversionNoteCategory, key: string, detail: string): void;
88
+ add(category: ConversionNoteCategory, key: string, detail: string, extras?: {
89
+ prerequisites?: Array<{
90
+ parentKey: string;
91
+ variation: number;
92
+ }>;
93
+ }): void;
53
94
  /** All notes, in insertion order. */
54
95
  all(): ConversionNote[];
55
96
  /** 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
- this.notes.push({ category, detail, key });
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
- if (env.prerequisites && env.prerequisites.length > 0) {
159
- ctx.report.add('dropped-prerequisite', ctx.sourceKey, `${env.prerequisites.length} prerequisite(s) dropped (${env.prerequisites
160
- .map((p) => p.key)
161
- .join(', ')}) — Quonfig has no cross-flag dependency operator`);
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} target(s) converted to a leading PROP_IS_ONE_OF rule on ${kind}.key`);
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);
@@ -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
@@ -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>;