@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.
- package/README.md +44 -44
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +28 -1
- package/dist/commands/pull.js +2 -2
- package/dist/migrate/local-write.js +24 -3
- package/dist/migrate/migration-report.d.ts +16 -5
- package/dist/migrate/migration-report.js +279 -9
- package/dist/migrate/push-to-cloud.js +6 -1
- package/dist/migrate/quonfig-target/report.d.ts +27 -3
- package/dist/migrate/quonfig-target/report.js +5 -2
- package/dist/migrate/quonfig-target/ruleset.js +8 -6
- package/dist/migrate/source.d.ts +27 -0
- package/dist/migrate/sources/launchdarkly/api.d.ts +22 -1
- package/dist/migrate/sources/launchdarkly/api.js +23 -1
- package/dist/migrate/sources/launchdarkly/translate.js +42 -2
- package/dist/migrate/sources/launchdarkly/types.d.ts +19 -0
- package/dist/migrate/sources/launchdarkly.js +75 -4
- package/dist/util/clone-and-stack-push.js +8 -2
- package/dist/util/friction-log.d.ts +1 -0
- package/dist/util/friction-log.js +6 -1
- package/dist/util/git-ops.d.ts +6 -0
- package/dist/util/git-ops.js +8 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
state.
|
|
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', '--
|
|
39
|
-
return
|
|
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
|
|
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;
|
package/dist/util/git-ops.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/util/git-ops.js
CHANGED
|
@@ -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', '--
|
|
69
|
-
return
|
|
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.
|
|
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.
|
|
2
|
+
export default '0.0.50';
|
package/oclif.manifest.json
CHANGED
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.
|
|
4
|
+
"version": "0.0.50",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|