@quonfig/cli 0.0.48 → 0.0.50

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.
@@ -26,6 +26,26 @@ function slugifyEnvKey(key) {
26
26
  .replaceAll(/[^\da-z]+/g, '-')
27
27
  .replaceAll(/^-+|-+$/g, '');
28
28
  }
29
+ /**
30
+ * Gather every prerequisite edge declared across all of a flag's environments
31
+ * and dedupe by parent key — the orphan-parent view is per-flag, not
32
+ * per-environment. First occurrence wins for the variation index when an
33
+ * unusual config gates on the same parent at different variations in different
34
+ * envs (rare; the typical case is identical edges in every env).
35
+ */
36
+ function collectPrerequisiteEdges(flag) {
37
+ const seen = new Set();
38
+ const edges = [];
39
+ for (const envState of Object.values(flag.environments)) {
40
+ for (const prereq of envState.prerequisites ?? []) {
41
+ if (seen.has(prereq.key))
42
+ continue;
43
+ seen.add(prereq.key);
44
+ edges.push({ parentKey: prereq.key, variation: prereq.variation });
45
+ }
46
+ }
47
+ return edges;
48
+ }
29
49
  export function flagOutputPath(key) {
30
50
  return `feature-flags/${normalizeKey(key)}.json`;
31
51
  }
@@ -54,9 +74,16 @@ export function translateFlag(flag, report) {
54
74
  };
55
75
  // clientSideAvailability: feature flags are always client-visible in Quonfig,
56
76
  // so `sendToClientSdk` is invalid on them — we cannot carry the dimension.
57
- // Report the mobile-key drop rather than silently losing it.
77
+ // Split the drop by usingEnvironmentId co-state (qfg-iv69):
78
+ // - usingEnvironmentId:true → flag is still client-visible, pure rollup noise.
79
+ // - usingEnvironmentId:false → flag was mobile-only, now server-only post-migration (must-fix).
58
80
  if (flag.clientSideAvailability?.usingMobileKey) {
59
- report.add('dropped-mobile-key-availability', flag.key, `clientSideAvailability.usingMobileKey was true — Quonfig has one client-visibility bool, the mobile-key dimension is dropped`);
81
+ if (flag.clientSideAvailability.usingEnvironmentId) {
82
+ report.add('dropped-mobile-key-still-visible', flag.key, `clientSideAvailability.usingMobileKey was true; usingEnvironmentId was also true — flag remains client-visible via Quonfig's sendToClientSdk, the mobile-key dimension itself is dropped`);
83
+ }
84
+ else {
85
+ report.add('dropped-mobile-key-now-server-only', flag.key, `clientSideAvailability.usingMobileKey was true but usingEnvironmentId was false — flag was mobile-only in LaunchDarkly and will not reach mobile clients post-migration; rebuild client-side access by hand before cutover`);
86
+ }
60
87
  }
61
88
  if (flag.maintainerId || flag.maintainerTeamKey) {
62
89
  report.add('dropped-maintainer', flag.key, `maintainer ${flag.maintainerId ?? flag.maintainerTeamKey} dropped — Quonfig authorship lives in git history`);
@@ -64,6 +91,19 @@ export function translateFlag(flag, report) {
64
91
  if (flag.customProperties && Object.keys(flag.customProperties).length > 0) {
65
92
  report.add('dropped-custom-properties', flag.key, `customProperties (${Object.keys(flag.customProperties).join(', ')}) dropped — report-only in v1 (D6)`);
66
93
  }
94
+ // Prerequisites — dedupe across environments by parent key so a flag gated on
95
+ // the same parent in N environments emits one note, not N (qfg-nb4n). The
96
+ // detail spells out every parent's variation index plus a one-line remediation
97
+ // so a human knows exactly how to restore the gate; the structured
98
+ // `prerequisites` payload feeds the inverted orphan-parent view in the report.
99
+ const prereqEdges = collectPrerequisiteEdges(flag);
100
+ if (prereqEdges.length > 0) {
101
+ const parentList = prereqEdges.map((e) => `\`${e.parentKey}\` = variation ${e.variation}`).join(', ');
102
+ const detail = `evaluated independently of ${prereqEdges.length} parent${prereqEdges.length === 1 ? '' : 's'} ` +
103
+ `(${parentList}). To preserve the gate, add a leading rule matching the same parent state, ` +
104
+ `or wrap reads in app code — Quonfig v1 has no cross-flag dependency operator.`;
105
+ report.add('dropped-prerequisite', flag.key, detail, { prerequisites: prereqEdges });
106
+ }
67
107
  const variants = flag.variations.map((v) => {
68
108
  const variant = {
69
109
  value: toQuonfigValue(v.value, valueType),
@@ -173,6 +173,25 @@ export interface LDContextKindsResponse {
173
173
  name?: string;
174
174
  }>;
175
175
  }
176
+ /**
177
+ * One row from `GET /members` — only the fields the migrator surfaces are
178
+ * typed. `_id` is what flag JSON references as `maintainerId`; `email` is the
179
+ * human-readable label we resolve into MIGRATION_REPORT.md.
180
+ */
181
+ export interface LDMember {
182
+ _id: string;
183
+ email?: string;
184
+ }
185
+ /** `GET /members` — offset-paginated. */
186
+ export interface LDMembersResponse {
187
+ _links?: {
188
+ next?: {
189
+ href: string;
190
+ };
191
+ };
192
+ items: LDMember[];
193
+ totalCount?: number;
194
+ }
176
195
  /**
177
196
  * The full Phase-1 config snapshot — what the fetcher stitches together and
178
197
  * what `translate()` consumes. One of these is produced per migration run.
@@ -12,7 +12,7 @@ import * as fs from 'node:fs';
12
12
  import * as os from 'node:os';
13
13
  import * as path from 'node:path';
14
14
  import { ConversionReport } from '../quonfig-target/report.js';
15
- import { fetchProjectEnvironments, fetchSegmentsForEnv, fetchSnapshot } from './launchdarkly/api.js';
15
+ import { fetchMembers, fetchProjectEnvironments, fetchProjectEnvironmentsDetailed, fetchSegmentsForEnv, fetchSnapshot, } from './launchdarkly/api.js';
16
16
  import { auditEntryToLegacyChange, buildFlagAuditSpec, getCommitMetaForAuditEntry, probeRetentionHorizon, walkAuditLog, } from './launchdarkly/audit.js';
17
17
  import { flagOutputPath, segmentOutputPath, translateFlag, translateSegment } from './launchdarkly/translate.js';
18
18
  const SOURCE_NAME = 'launchdarkly';
@@ -39,7 +39,9 @@ function resolveProjectKey() {
39
39
  const state = {
40
40
  apiKey: null,
41
41
  environments: [],
42
+ environmentSourceNames: [],
42
43
  fullSummary: false,
44
+ maintainerMap: null,
43
45
  projectKey: resolveProjectKey(),
44
46
  report: new ConversionReport(),
45
47
  retentionHorizon: null,
@@ -89,11 +91,15 @@ function slugifyEnvKey(key) {
89
91
  }
90
92
  async function validateAuthImpl(apiKey) {
91
93
  // A cheap authenticated call — 401s on a bad token, 404s on a bad project.
92
- await fetchProjectEnvironments(apiKey, state.projectKey);
94
+ // Use the detailed call so we can stash {key, name} for the report's env
95
+ // mapping table (qfg-1t7m) without a second round-trip.
96
+ const detailed = await fetchProjectEnvironmentsDetailed(apiKey, state.projectKey);
93
97
  state.apiKey = apiKey;
94
98
  state.report = new ConversionReport();
95
99
  state.environments = [];
100
+ state.environmentSourceNames = detailed;
96
101
  state.retentionHorizon = null;
102
+ state.maintainerMap = null;
97
103
  // Plan §4.1.1: under --full-summary, probe the audit-log retention window
98
104
  // here — the earliest authenticated hook — so the command can tell the user
99
105
  // the real history horizon BEFORE the slow Phase-2 walk starts. The walk can
@@ -103,10 +109,33 @@ async function validateAuthImpl(apiKey) {
103
109
  state.retentionHorizon = await probeRetentionHorizon(apiKey, { spec: buildFlagAuditSpec(state.projectKey) });
104
110
  }
105
111
  }
112
+ /**
113
+ * qfg-l8uz: best-effort `/members` lookup so MIGRATION_REPORT.md can render
114
+ * `maintainerId → email`. Project-scoped tokens or accounts that hide member
115
+ * data will 403 here — that's fine, the migration must not fail just because
116
+ * the rollup loses a label. Returns null on any error, the empty map when the
117
+ * endpoint returns no members, and a stripped map otherwise (members without
118
+ * an email are skipped — we have nothing useful to render for them).
119
+ */
120
+ async function loadMaintainerMap(apiKey) {
121
+ try {
122
+ const members = await fetchMembers(apiKey);
123
+ const map = {};
124
+ for (const member of members) {
125
+ if (member.email)
126
+ map[member._id] = member.email;
127
+ }
128
+ return map;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
106
134
  async function listEnvironmentsImpl() {
107
135
  const apiKey = requireApiKey('listEnvironments');
108
- const envKeys = await fetchProjectEnvironments(apiKey, state.projectKey);
109
- state.environments = envKeys.map((k) => slugifyEnvKey(k));
136
+ const detailed = await fetchProjectEnvironmentsDetailed(apiKey, state.projectKey);
137
+ state.environmentSourceNames = detailed;
138
+ state.environments = detailed.map((e) => slugifyEnvKey(e.key));
110
139
  return [...new Set(state.environments)];
111
140
  }
112
141
  /**
@@ -120,6 +149,11 @@ async function listEnvironmentsImpl() {
120
149
  */
121
150
  async function* fetchChangesImpl() {
122
151
  const apiKey = requireApiKey('fetchChanges');
152
+ // qfg-l8uz: load the account-wide member directory once so the report can
153
+ // render maintainerId → email pairs. Deliberately best-effort — a failed
154
+ // call (project-scoped token, hidden member data) just leaves the rollup
155
+ // showing opaque hex IDs.
156
+ state.maintainerMap = await loadMaintainerMap(apiKey);
123
157
  if (state.fullSummary) {
124
158
  yield* fetchAuditHistory(apiKey);
125
159
  return;
@@ -243,6 +277,39 @@ function getSkippedConfigsImpl() {
243
277
  export function getConversionReport() {
244
278
  return state.report;
245
279
  }
280
+ /**
281
+ * qfg-l8uz: maintainerId → email map produced by `/members` during
282
+ * validateAuth, or null when the call failed (project-scoped token, account
283
+ * hides members, etc.). The report renderer surfaces this as a sub-table on
284
+ * `## Identifier map` and decorates `dropped-maintainer` rollups so a reader
285
+ * sees real emails alongside the opaque hex IDs.
286
+ */
287
+ function getMaintainerMapImpl() {
288
+ if (state.maintainerMap === null)
289
+ return null;
290
+ return Object.keys(state.maintainerMap).length === 0 ? null : { ...state.maintainerMap };
291
+ }
292
+ /**
293
+ * qfg-1t7m: source-name → Quonfig-name pairs for the report's "Environment
294
+ * mapping table". The source name is LD's user-facing env display name; the
295
+ * Quonfig name is the slugified env key. De-duped on the slugified key — two
296
+ * source envs that collapse to the same slug get one row (the first wins),
297
+ * since that is the row that will exist in quonfig.json.
298
+ */
299
+ function getEnvironmentMapImpl() {
300
+ if (state.environmentSourceNames.length === 0)
301
+ return null;
302
+ const seen = new Set();
303
+ const entries = [];
304
+ for (const env of state.environmentSourceNames) {
305
+ const quonfigName = slugifyEnvKey(env.key);
306
+ if (seen.has(quonfigName))
307
+ continue;
308
+ seen.add(quonfigName);
309
+ entries.push({ quonfigName, sourceName: env.name });
310
+ }
311
+ return entries.length === 0 ? null : entries;
312
+ }
246
313
  /**
247
314
  * Every structured conversion note from this run — re-bucketed rollouts,
248
315
  * dropped prerequisites, lossy individual-target conversions, etc. The write
@@ -275,6 +342,8 @@ export const launchdarklySource = {
275
342
  },
276
343
  getCommitMeta: getCommitMetaImpl,
277
344
  getConversionNotes: getConversionNotesImpl,
345
+ getEnvironmentMap: getEnvironmentMapImpl,
346
+ getMaintainerMap: getMaintainerMapImpl,
278
347
  getSkippedConfigs: getSkippedConfigsImpl,
279
348
  listEnvironments: listEnvironmentsImpl,
280
349
  name: SOURCE_NAME,
@@ -284,7 +353,9 @@ export const launchdarklySource = {
284
353
  export function __resetLaunchDarklySourceForTests() {
285
354
  state.apiKey = null;
286
355
  state.environments = [];
356
+ state.environmentSourceNames = [];
287
357
  state.fullSummary = false;
358
+ state.maintainerMap = null;
288
359
  state.projectKey = resolveProjectKey();
289
360
  state.report = new ConversionReport();
290
361
  state.retentionHorizon = null;
@@ -33,10 +33,16 @@ const runGit = async (cwd, args) => {
33
33
  throw wrapped;
34
34
  }
35
35
  };
36
+ /**
37
+ * True only when `dir` is itself the root of a git repo. Uses `--show-prefix`
38
+ * (relative path from enclosing toplevel down to `dir`, empty when `dir` IS
39
+ * the toplevel) to avoid cross-platform path-string pitfalls — see the
40
+ * matching comment in cli/src/migrate/local-write.ts (qfg-wu85).
41
+ */
36
42
  const isGitRepo = async (dir) => {
37
43
  try {
38
- await runGitSafe(['-C', dir, 'rev-parse', '--git-dir']);
39
- return true;
44
+ const { stdout } = await runGitSafe(['-C', dir, 'rev-parse', '--show-prefix']);
45
+ return stdout.trim() === '';
40
46
  }
41
47
  catch {
42
48
  return false;
@@ -4,6 +4,7 @@ export interface FrictionEntry {
4
4
  exitCode: number;
5
5
  ts: string;
6
6
  }
7
+ export declare function getDefaultFrictionLogPath(home?: string): string;
7
8
  export declare function getFrictionLogPath(envValue?: string | undefined, cwd?: string): null | string;
8
9
  export declare function buildFrictionEntry(opts: {
9
10
  argv?: string[];
@@ -1,5 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
4
+ export function getDefaultFrictionLogPath(home = os.homedir()) {
5
+ return path.join(home, '.qfg', 'friction.log');
6
+ }
3
7
  export function getFrictionLogPath(envValue, cwd = process.cwd()) {
4
8
  if (envValue === undefined)
5
9
  return null;
@@ -7,7 +11,7 @@ export function getFrictionLogPath(envValue, cwd = process.cwd()) {
7
11
  if (trimmed === '' || trimmed === 'false' || trimmed === '0')
8
12
  return null;
9
13
  if (trimmed === 'true' || trimmed === '1') {
10
- return path.join(cwd, '.qfg-friction.log');
14
+ return getDefaultFrictionLogPath();
11
15
  }
12
16
  return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
13
17
  }
@@ -23,6 +27,7 @@ export function buildFrictionEntry(opts) {
23
27
  };
24
28
  }
25
29
  export function appendFrictionEntry(logPath, entry) {
30
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
26
31
  fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
27
32
  }
28
33
  const MAX_STDERR_CAPTURE = 4000;
@@ -40,6 +40,12 @@ export interface SpawnGitOptions {
40
40
  */
41
41
  export declare const spawnGit: (args: string[], options?: SpawnGitOptions) => Promise<void>;
42
42
  export declare const gitClone: (repoUrl: string, dir: string) => Promise<void>;
43
+ /**
44
+ * True only when `dir` is itself the root of a git repo. Uses `--show-prefix`
45
+ * (relative path from enclosing toplevel down to `dir`, empty when `dir` IS
46
+ * the toplevel) to avoid cross-platform path-string pitfalls — see the
47
+ * matching comment in cli/src/migrate/local-write.ts (qfg-wu85).
48
+ */
43
49
  export declare const isGitRepo: (dir: string) => Promise<boolean>;
44
50
  export declare const getRemoteUrl: (dir: string) => Promise<string | null>;
45
51
  /**
@@ -63,10 +63,16 @@ export const spawnGit = (args, options) => new Promise((resolve, reject) => {
63
63
  export const gitClone = async (repoUrl, dir) => {
64
64
  await runGit(['clone', repoUrl, dir]);
65
65
  };
66
+ /**
67
+ * True only when `dir` is itself the root of a git repo. Uses `--show-prefix`
68
+ * (relative path from enclosing toplevel down to `dir`, empty when `dir` IS
69
+ * the toplevel) to avoid cross-platform path-string pitfalls — see the
70
+ * matching comment in cli/src/migrate/local-write.ts (qfg-wu85).
71
+ */
66
72
  export const isGitRepo = async (dir) => {
67
73
  try {
68
- await runGit(['-C', dir, 'rev-parse', '--git-dir']);
69
- return true;
74
+ const { stdout } = await runGit(['-C', dir, 'rev-parse', '--show-prefix']);
75
+ return stdout.trim() === '';
70
76
  }
71
77
  catch {
72
78
  return false;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.48";
1
+ declare const _default: "0.0.50";
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.48';
2
+ export default '0.0.50';
@@ -3625,5 +3625,5 @@
3625
3625
  ]
3626
3626
  }
3627
3627
  },
3628
- "version": "0.0.48"
3628
+ "version": "0.0.50"
3629
3629
  }
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.48",
4
+ "version": "0.0.50",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },