@quonfig/cli 0.0.47 → 0.0.49
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 +71 -66
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.js +68 -8
- package/dist/migrate/local-write.js +17 -2
- package/dist/migrate/migration-report.d.ts +9 -0
- package/dist/migrate/migration-report.js +68 -0
- package/dist/migrate/push-to-cloud.js +2 -0
- package/dist/migrate/quonfig-target/operators.d.ts +27 -0
- package/dist/migrate/quonfig-target/operators.js +71 -0
- package/dist/migrate/quonfig-target/report.d.ts +59 -0
- package/dist/migrate/quonfig-target/report.js +39 -0
- package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
- package/dist/migrate/quonfig-target/ruleset.js +192 -0
- package/dist/migrate/quonfig-target/values.d.ts +38 -0
- package/dist/migrate/quonfig-target/values.js +77 -0
- package/dist/migrate/source-api-key.d.ts +33 -0
- package/dist/migrate/source-api-key.js +43 -0
- package/dist/migrate/source.d.ts +10 -0
- package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
- package/dist/migrate/sources/launchdarkly/api.js +195 -0
- package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
- package/dist/migrate/sources/launchdarkly/audit.js +172 -0
- package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
- package/dist/migrate/sources/launchdarkly/translate.js +209 -0
- package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
- package/dist/migrate/sources/launchdarkly/types.js +10 -0
- package/dist/migrate/sources/launchdarkly.d.ts +35 -0
- package/dist/migrate/sources/launchdarkly.js +295 -13
- package/dist/util/clone-and-stack-push.js +8 -2
- 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 +178 -161
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean typed structs for the slice of the LaunchDarkly REST API the
|
|
3
|
+
* Phase-1 config-snapshot fetcher walks. Field names mirror LaunchDarkly's
|
|
4
|
+
* JSON exactly; optional fields are the ones LD omits when unset.
|
|
5
|
+
*
|
|
6
|
+
* Reference: https://apidocs.launchdarkly.com/ — `GET /flags/{proj}?env=`,
|
|
7
|
+
* `GET /segments/{proj}/{env}`, `GET /projects/{proj}/environments`,
|
|
8
|
+
* `GET /projects/{proj}/context-kinds`.
|
|
9
|
+
*/
|
|
10
|
+
/** A LaunchDarkly clause — one predicate inside a rule (rule clauses AND together). */
|
|
11
|
+
export interface LDClause {
|
|
12
|
+
_id?: string;
|
|
13
|
+
/** JSON-pointer-or-bare attribute name, e.g. "email" or "/address/city". */
|
|
14
|
+
attribute: string;
|
|
15
|
+
/** Context kind the attribute belongs to. Absent ⇒ "user". */
|
|
16
|
+
contextKind?: string;
|
|
17
|
+
negate?: boolean;
|
|
18
|
+
/** LaunchDarkly operator name, e.g. "in", "segmentMatch", "semVerLessThan". */
|
|
19
|
+
op: string;
|
|
20
|
+
values: unknown[];
|
|
21
|
+
}
|
|
22
|
+
/** One weighted variation inside a rollout. */
|
|
23
|
+
export interface LDWeightedVariation {
|
|
24
|
+
untracked?: boolean;
|
|
25
|
+
/** 0-based index into the flag's variations[]. */
|
|
26
|
+
variation: number;
|
|
27
|
+
/** Integer weight; weights in a rollout sum to 100000 (= 100%). */
|
|
28
|
+
weight: number;
|
|
29
|
+
}
|
|
30
|
+
/** A percentage rollout — served by a rule or by the fallthrough. */
|
|
31
|
+
export interface LDRollout {
|
|
32
|
+
/** Attribute to hash on. Absent ⇒ the context key. May be a JSON pointer. */
|
|
33
|
+
bucketBy?: string;
|
|
34
|
+
contextKind?: string;
|
|
35
|
+
/** "experiment" marks an experiment rollout; v1 drops the experiment framing. */
|
|
36
|
+
kind?: string;
|
|
37
|
+
seed?: number;
|
|
38
|
+
variations: LDWeightedVariation[];
|
|
39
|
+
}
|
|
40
|
+
/** A targeting rule inside a per-environment flag state. */
|
|
41
|
+
export interface LDRule {
|
|
42
|
+
_id?: string;
|
|
43
|
+
clauses: LDClause[];
|
|
44
|
+
description?: string;
|
|
45
|
+
/** Exactly one of `variation` / `rollout` is set. */
|
|
46
|
+
rollout?: LDRollout;
|
|
47
|
+
trackEvents?: boolean;
|
|
48
|
+
variation?: number;
|
|
49
|
+
}
|
|
50
|
+
/** Individual-target list — specific context keys pinned to one variation. */
|
|
51
|
+
export interface LDTarget {
|
|
52
|
+
/** Absent on the legacy user-only `targets` array ⇒ "user". */
|
|
53
|
+
contextKind?: string;
|
|
54
|
+
values: string[];
|
|
55
|
+
variation: number;
|
|
56
|
+
}
|
|
57
|
+
/** Single variation/default pointer or a rollout. */
|
|
58
|
+
export interface LDVariationOrRollout {
|
|
59
|
+
rollout?: LDRollout;
|
|
60
|
+
variation?: number;
|
|
61
|
+
}
|
|
62
|
+
/** Per-environment flag state, keyed by environment key in `flag.environments`. */
|
|
63
|
+
export interface LDFlagEnvironment {
|
|
64
|
+
archived?: boolean;
|
|
65
|
+
contextTargets?: LDTarget[];
|
|
66
|
+
fallthrough?: LDVariationOrRollout;
|
|
67
|
+
/** Index into variations[] served when `on` is false. */
|
|
68
|
+
offVariation?: number;
|
|
69
|
+
/** The `on` toggle that sits above the rule engine. */
|
|
70
|
+
on: boolean;
|
|
71
|
+
prerequisites?: Array<{
|
|
72
|
+
key: string;
|
|
73
|
+
variation: number;
|
|
74
|
+
}>;
|
|
75
|
+
rules?: LDRule[];
|
|
76
|
+
salt?: string;
|
|
77
|
+
targets?: LDTarget[];
|
|
78
|
+
version?: number;
|
|
79
|
+
}
|
|
80
|
+
/** A flag variation — one of the typed values the flag can serve. */
|
|
81
|
+
export interface LDVariation {
|
|
82
|
+
_id?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
name?: string;
|
|
85
|
+
value: unknown;
|
|
86
|
+
}
|
|
87
|
+
/** A LaunchDarkly feature flag as returned by `GET /flags/{proj}?env=...`. */
|
|
88
|
+
export interface LDFlag {
|
|
89
|
+
clientSideAvailability?: {
|
|
90
|
+
usingEnvironmentId?: boolean;
|
|
91
|
+
usingMobileKey?: boolean;
|
|
92
|
+
};
|
|
93
|
+
customProperties?: Record<string, {
|
|
94
|
+
name?: string;
|
|
95
|
+
value: string[];
|
|
96
|
+
}>;
|
|
97
|
+
description?: string;
|
|
98
|
+
/** Map of environment key → per-environment state. */
|
|
99
|
+
environments: Record<string, LDFlagEnvironment>;
|
|
100
|
+
key: string;
|
|
101
|
+
/** "boolean" or "multivariate". */
|
|
102
|
+
kind: string;
|
|
103
|
+
maintainerId?: string;
|
|
104
|
+
maintainerTeamKey?: string;
|
|
105
|
+
name?: string;
|
|
106
|
+
tags?: string[];
|
|
107
|
+
temporary?: boolean;
|
|
108
|
+
variations: LDVariation[];
|
|
109
|
+
}
|
|
110
|
+
/** A LaunchDarkly segment as returned by `GET /segments/{proj}/{env}`. */
|
|
111
|
+
export interface LDSegment {
|
|
112
|
+
description?: string;
|
|
113
|
+
excluded?: string[];
|
|
114
|
+
excludedContexts?: Array<{
|
|
115
|
+
contextKind: string;
|
|
116
|
+
values: string[];
|
|
117
|
+
}>;
|
|
118
|
+
included?: string[];
|
|
119
|
+
includedContexts?: Array<{
|
|
120
|
+
contextKind: string;
|
|
121
|
+
values: string[];
|
|
122
|
+
}>;
|
|
123
|
+
key: string;
|
|
124
|
+
name?: string;
|
|
125
|
+
rules?: Array<{
|
|
126
|
+
_id?: string;
|
|
127
|
+
bucketBy?: string;
|
|
128
|
+
clauses: LDClause[];
|
|
129
|
+
weight?: number;
|
|
130
|
+
}>;
|
|
131
|
+
tags?: string[];
|
|
132
|
+
/** Big/synced segment — membership is not exportable via the REST API. */
|
|
133
|
+
unbounded?: boolean;
|
|
134
|
+
unboundedContextKind?: string;
|
|
135
|
+
}
|
|
136
|
+
/** `GET /projects/{proj}/environments` — offset-paginated. */
|
|
137
|
+
export interface LDEnvironmentsResponse {
|
|
138
|
+
_links?: {
|
|
139
|
+
next?: {
|
|
140
|
+
href: string;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
items: Array<{
|
|
144
|
+
key: string;
|
|
145
|
+
name: string;
|
|
146
|
+
}>;
|
|
147
|
+
totalCount?: number;
|
|
148
|
+
}
|
|
149
|
+
/** `GET /flags/{proj}?env=...` — offset-paginated. */
|
|
150
|
+
export interface LDFlagsResponse {
|
|
151
|
+
_links?: {
|
|
152
|
+
next?: {
|
|
153
|
+
href: string;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
items: LDFlag[];
|
|
157
|
+
totalCount?: number;
|
|
158
|
+
}
|
|
159
|
+
/** `GET /segments/{proj}/{env}` — offset-paginated. */
|
|
160
|
+
export interface LDSegmentsResponse {
|
|
161
|
+
_links?: {
|
|
162
|
+
next?: {
|
|
163
|
+
href: string;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
items: LDSegment[];
|
|
167
|
+
totalCount?: number;
|
|
168
|
+
}
|
|
169
|
+
/** `GET /projects/{proj}/context-kinds`. */
|
|
170
|
+
export interface LDContextKindsResponse {
|
|
171
|
+
items: Array<{
|
|
172
|
+
key: string;
|
|
173
|
+
name?: string;
|
|
174
|
+
}>;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The full Phase-1 config snapshot — what the fetcher stitches together and
|
|
178
|
+
* what `translate()` consumes. One of these is produced per migration run.
|
|
179
|
+
*/
|
|
180
|
+
export interface LDSnapshot {
|
|
181
|
+
contextKinds: string[];
|
|
182
|
+
/** Environment keys, in API order. */
|
|
183
|
+
environments: string[];
|
|
184
|
+
flags: LDFlag[];
|
|
185
|
+
/** Segments de-duplicated by key across environments. */
|
|
186
|
+
segments: LDSegment[];
|
|
187
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean typed structs for the slice of the LaunchDarkly REST API the
|
|
3
|
+
* Phase-1 config-snapshot fetcher walks. Field names mirror LaunchDarkly's
|
|
4
|
+
* JSON exactly; optional fields are the ones LD omits when unset.
|
|
5
|
+
*
|
|
6
|
+
* Reference: https://apidocs.launchdarkly.com/ — `GET /flags/{proj}?env=`,
|
|
7
|
+
* `GET /segments/{proj}/{env}`, `GET /projects/{proj}/environments`,
|
|
8
|
+
* `GET /projects/{proj}/context-kinds`.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -1,2 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qfg migrate --from launchdarkly` — the MigrationSource implementation.
|
|
3
|
+
*
|
|
4
|
+
* Wires the LaunchDarkly fetcher (`launchdarkly/api.ts`) and converter
|
|
5
|
+
* (`launchdarkly/translate.ts`) into the source-agnostic migration framework.
|
|
6
|
+
* Both write modes (`--dir`, `--push`) are inherited for free — this source
|
|
7
|
+
* only has to produce `QuonfigFile[]`.
|
|
8
|
+
*
|
|
9
|
+
* Design is frozen in `launchdarkly.README.md` / `project/plans/migrator-launch-darkly.md`.
|
|
10
|
+
*/
|
|
11
|
+
import { ConversionReport } from '../quonfig-target/report.js';
|
|
1
12
|
import { type MigrationSource } from '../source.js';
|
|
13
|
+
import { type RetentionHorizon } from './launchdarkly/audit.js';
|
|
14
|
+
/**
|
|
15
|
+
* Override the LaunchDarkly project key for this run. Called by the `qfg migrate`
|
|
16
|
+
* command from its `--project` flag (which itself falls back to the
|
|
17
|
+
* `LAUNCHDARKLY_PROJECT_KEY` env var). Mirrors `applyLaunchDarklyBaseUrl()` —
|
|
18
|
+
* the command layer threads run-scoped config into the source singleton.
|
|
19
|
+
*/
|
|
20
|
+
export declare function setLaunchDarklyProjectKey(projectKey: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Enable Phase-2 history backfill for this run. When set, `fetchChanges` walks
|
|
23
|
+
* the LaunchDarkly audit log instead of taking a current-state snapshot, and
|
|
24
|
+
* `validateAuth` runs the retention pre-flight. Called by `qfg migrate` from
|
|
25
|
+
* its `--full-summary` flag.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setLaunchDarklyFullSummary(on: boolean): void;
|
|
28
|
+
/**
|
|
29
|
+
* The retention pre-flight result from the last full-summary `validateAuth`,
|
|
30
|
+
* or null. The command reads this and tells the user the real history horizon
|
|
31
|
+
* BEFORE the slow audit walk starts (plan §4.1.1).
|
|
32
|
+
*/
|
|
33
|
+
export declare function getLaunchDarklyRetentionHorizon(): null | RetentionHorizon;
|
|
34
|
+
/** The full conversion report — every skip/drop/coerce note from this run. */
|
|
35
|
+
export declare function getConversionReport(): ConversionReport;
|
|
2
36
|
export declare const launchdarklySource: MigrationSource;
|
|
37
|
+
export declare function __resetLaunchDarklySourceForTests(): void;
|
|
@@ -1,18 +1,300 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `qfg migrate --from launchdarkly` — the MigrationSource implementation.
|
|
3
|
+
*
|
|
4
|
+
* Wires the LaunchDarkly fetcher (`launchdarkly/api.ts`) and converter
|
|
5
|
+
* (`launchdarkly/translate.ts`) into the source-agnostic migration framework.
|
|
6
|
+
* Both write modes (`--dir`, `--push`) are inherited for free — this source
|
|
7
|
+
* only has to produce `QuonfigFile[]`.
|
|
8
|
+
*
|
|
9
|
+
* Design is frozen in `launchdarkly.README.md` / `project/plans/migrator-launch-darkly.md`.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { ConversionReport } from '../quonfig-target/report.js';
|
|
15
|
+
import { fetchProjectEnvironments, fetchSegmentsForEnv, fetchSnapshot } from './launchdarkly/api.js';
|
|
16
|
+
import { auditEntryToLegacyChange, buildFlagAuditSpec, getCommitMetaForAuditEntry, probeRetentionHorizon, walkAuditLog, } from './launchdarkly/audit.js';
|
|
17
|
+
import { flagOutputPath, segmentOutputPath, translateFlag, translateSegment } from './launchdarkly/translate.js';
|
|
2
18
|
const SOURCE_NAME = 'launchdarkly';
|
|
19
|
+
/**
|
|
20
|
+
* Resume cursor for a crashed Phase-2 walk. Lives in `os.tmpdir()` keyed by
|
|
21
|
+
* project key — NOT inside the target workspace, because `--push` mode requires
|
|
22
|
+
* the local clone dir to be empty before cloning (and a checkpoint sitting in
|
|
23
|
+
* `.qf/` would clobber that). OS-tmpdir is fine for in-session crash recovery;
|
|
24
|
+
* cross-reboot recovery is out of scope for this walker.
|
|
25
|
+
*/
|
|
26
|
+
function auditCheckpointFilename(projectKey) {
|
|
27
|
+
const safeKey = projectKey.replaceAll(/[^\w.-]+/g, '_');
|
|
28
|
+
return `qfg-ld-audit-${safeKey}.json`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* LaunchDarkly requires a project key on every endpoint. The env var
|
|
32
|
+
* `LAUNCHDARKLY_PROJECT_KEY` (default `default`) is the module-load fallback;
|
|
33
|
+
* the `qfg migrate` command overrides it per-run via `setLaunchDarklyProjectKey()`
|
|
34
|
+
* from its `--project` flag (Epic 5 write-mode wiring).
|
|
35
|
+
*/
|
|
36
|
+
function resolveProjectKey() {
|
|
37
|
+
return process.env.LAUNCHDARKLY_PROJECT_KEY ?? 'default';
|
|
38
|
+
}
|
|
39
|
+
const state = {
|
|
40
|
+
apiKey: null,
|
|
41
|
+
environments: [],
|
|
42
|
+
fullSummary: false,
|
|
43
|
+
projectKey: resolveProjectKey(),
|
|
44
|
+
report: new ConversionReport(),
|
|
45
|
+
retentionHorizon: null,
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Override the LaunchDarkly project key for this run. Called by the `qfg migrate`
|
|
49
|
+
* command from its `--project` flag (which itself falls back to the
|
|
50
|
+
* `LAUNCHDARKLY_PROJECT_KEY` env var). Mirrors `applyLaunchDarklyBaseUrl()` —
|
|
51
|
+
* the command layer threads run-scoped config into the source singleton.
|
|
52
|
+
*/
|
|
53
|
+
export function setLaunchDarklyProjectKey(projectKey) {
|
|
54
|
+
state.projectKey = projectKey;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Enable Phase-2 history backfill for this run. When set, `fetchChanges` walks
|
|
58
|
+
* the LaunchDarkly audit log instead of taking a current-state snapshot, and
|
|
59
|
+
* `validateAuth` runs the retention pre-flight. Called by `qfg migrate` from
|
|
60
|
+
* its `--full-summary` flag.
|
|
61
|
+
*/
|
|
62
|
+
export function setLaunchDarklyFullSummary(on) {
|
|
63
|
+
state.fullSummary = on;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The retention pre-flight result from the last full-summary `validateAuth`,
|
|
67
|
+
* or null. The command reads this and tells the user the real history horizon
|
|
68
|
+
* BEFORE the slow audit walk starts (plan §4.1.1).
|
|
69
|
+
*/
|
|
70
|
+
export function getLaunchDarklyRetentionHorizon() {
|
|
71
|
+
return state.retentionHorizon;
|
|
72
|
+
}
|
|
73
|
+
class MissingAuthError extends Error {
|
|
74
|
+
constructor(operation) {
|
|
75
|
+
super(`launchdarkly source ${operation} requires validateAuth(apiKey) to be called first (no API key configured).`);
|
|
76
|
+
this.name = 'MissingAuthError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function requireApiKey(operation) {
|
|
80
|
+
if (!state.apiKey)
|
|
81
|
+
throw new MissingAuthError(operation);
|
|
82
|
+
return state.apiKey;
|
|
83
|
+
}
|
|
84
|
+
function slugifyEnvKey(key) {
|
|
85
|
+
return key
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
88
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
89
|
+
}
|
|
90
|
+
async function validateAuthImpl(apiKey) {
|
|
91
|
+
// A cheap authenticated call — 401s on a bad token, 404s on a bad project.
|
|
92
|
+
await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
93
|
+
state.apiKey = apiKey;
|
|
94
|
+
state.report = new ConversionReport();
|
|
95
|
+
state.environments = [];
|
|
96
|
+
state.retentionHorizon = null;
|
|
97
|
+
// Plan §4.1.1: under --full-summary, probe the audit-log retention window
|
|
98
|
+
// here — the earliest authenticated hook — so the command can tell the user
|
|
99
|
+
// the real history horizon BEFORE the slow Phase-2 walk starts. The walk can
|
|
100
|
+
// take hours; nobody should wait that long expecting two years and silently
|
|
101
|
+
// get thirty days.
|
|
102
|
+
if (state.fullSummary) {
|
|
103
|
+
state.retentionHorizon = await probeRetentionHorizon(apiKey, { spec: buildFlagAuditSpec(state.projectKey) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function listEnvironmentsImpl() {
|
|
107
|
+
const apiKey = requireApiKey('listEnvironments');
|
|
108
|
+
const envKeys = await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
109
|
+
state.environments = envKeys.map((k) => slugifyEnvKey(k));
|
|
110
|
+
return [...new Set(state.environments)];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Phase-1 config snapshot. Decision D2: always a full re-snapshot — we do not
|
|
114
|
+
* trust the LaunchDarkly audit-log cursor for delta correctness. `sinceEpochMs`
|
|
115
|
+
* still feeds the framework's "what changed since last run" reporting, but the
|
|
116
|
+
* fetch itself is unconditional. Every flag and segment is yielded as one
|
|
117
|
+
* `LegacyChange`; the converter runs in `translate()`.
|
|
118
|
+
*
|
|
119
|
+
* Under `--full-summary` this delegates to the Phase-2 audit-log walk instead.
|
|
120
|
+
*/
|
|
121
|
+
async function* fetchChangesImpl() {
|
|
122
|
+
const apiKey = requireApiKey('fetchChanges');
|
|
123
|
+
if (state.fullSummary) {
|
|
124
|
+
yield* fetchAuditHistory(apiKey);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const snapshot = await fetchSnapshot(apiKey, state.projectKey);
|
|
128
|
+
state.environments = snapshot.environments.map((k) => slugifyEnvKey(k));
|
|
129
|
+
for (const flag of snapshot.flags) {
|
|
130
|
+
yield { key: flag.key, raw: { data: flag, kind: 'flag' }, source: SOURCE_NAME };
|
|
131
|
+
}
|
|
132
|
+
for (const segment of snapshot.segments) {
|
|
133
|
+
yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Path to this run's audit-walk resume cursor (always under os.tmpdir; see filename comment). */
|
|
137
|
+
function auditCheckpointPath() {
|
|
138
|
+
return path.join(os.tmpdir(), auditCheckpointFilename(state.projectKey));
|
|
139
|
+
}
|
|
140
|
+
/** Read the persisted `before` cursor from a crashed walk, or undefined if none. */
|
|
141
|
+
function readAuditCheckpoint(checkpointPath) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(fs.readFileSync(checkpointPath, 'utf8'));
|
|
144
|
+
return typeof parsed.before === 'number' ? parsed.before : undefined;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// No checkpoint (fresh run) or an unreadable one — start from newest.
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function writeAuditCheckpoint(checkpointPath, before) {
|
|
152
|
+
fs.writeFileSync(checkpointPath, JSON.stringify({ before }, null, 2) + '\n', 'utf8');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Phase-2 history backfill (`--full-summary`). Walks the LaunchDarkly audit log
|
|
156
|
+
* newest-to-oldest, reifying each flag change into a `LegacyChange` that carries
|
|
157
|
+
* both the point-in-time flag snapshot (for `translate()`) and the originating
|
|
158
|
+
* audit entry (for `getCommitMeta()`). The walk is checkpointed to an os.tmpdir
|
|
159
|
+
* file so a crashed multi-hour run resumes instead of restarting; the
|
|
160
|
+
* checkpoint is cleared once the walk completes.
|
|
161
|
+
*
|
|
162
|
+
* The audit log is flag-scoped (plan §4.1), so current-state segments are still
|
|
163
|
+
* re-snapshotted and appended — `--full-summary` must not silently drop them.
|
|
164
|
+
* Flag changes are emitted oldest-first so the write paths build chronological
|
|
165
|
+
* git history.
|
|
166
|
+
*/
|
|
167
|
+
async function* fetchAuditHistory(apiKey) {
|
|
168
|
+
const spec = buildFlagAuditSpec(state.projectKey);
|
|
169
|
+
const checkpointPath = auditCheckpointPath();
|
|
170
|
+
const startBefore = readAuditCheckpoint(checkpointPath);
|
|
171
|
+
// The audit log is newest-first; collect then reverse so changes commit in
|
|
172
|
+
// chronological order.
|
|
173
|
+
const flagChanges = [];
|
|
174
|
+
for await (const entry of walkAuditLog(apiKey, {
|
|
175
|
+
spec,
|
|
176
|
+
...(startBefore === undefined ? {} : { startBefore }),
|
|
177
|
+
onCheckpoint(before) {
|
|
178
|
+
writeAuditCheckpoint(checkpointPath, before);
|
|
179
|
+
},
|
|
180
|
+
})) {
|
|
181
|
+
const change = auditEntryToLegacyChange(entry);
|
|
182
|
+
if (change)
|
|
183
|
+
flagChanges.push(change);
|
|
184
|
+
}
|
|
185
|
+
// Walk finished cleanly — drop the resume cursor so a later run starts fresh.
|
|
186
|
+
if (fs.existsSync(checkpointPath))
|
|
187
|
+
fs.rmSync(checkpointPath);
|
|
188
|
+
flagChanges.reverse();
|
|
189
|
+
for (const change of flagChanges)
|
|
190
|
+
yield change;
|
|
191
|
+
// Re-snapshot current-state segments (flag-scoped audit log can't carry them).
|
|
192
|
+
const envKeys = await fetchProjectEnvironments(apiKey, state.projectKey);
|
|
193
|
+
state.environments = envKeys.map((k) => slugifyEnvKey(k));
|
|
194
|
+
const segmentsByKey = new Map();
|
|
195
|
+
for (const envKey of envKeys) {
|
|
196
|
+
// eslint-disable-next-line no-await-in-loop
|
|
197
|
+
const segs = await fetchSegmentsForEnv(apiKey, state.projectKey, envKey);
|
|
198
|
+
for (const seg of segs) {
|
|
199
|
+
if (!segmentsByKey.has(seg.key))
|
|
200
|
+
segmentsByKey.set(seg.key, seg);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const segment of segmentsByKey.values()) {
|
|
204
|
+
yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function translateImpl(change) {
|
|
208
|
+
const raw = change.raw;
|
|
209
|
+
if (!raw || typeof raw !== 'object' || !('kind' in raw))
|
|
210
|
+
return [];
|
|
211
|
+
try {
|
|
212
|
+
if (raw.kind === 'flag') {
|
|
213
|
+
const out = translateFlag(raw.data, state.report);
|
|
214
|
+
return [{ contents: JSON.stringify(out, null, 2), path: flagOutputPath(raw.data.key) }];
|
|
215
|
+
}
|
|
216
|
+
const out = translateSegment(raw.data, state.report);
|
|
217
|
+
return [{ contents: JSON.stringify(out, null, 2), path: segmentOutputPath(raw.data.key) }];
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// Last-resort safety net: one structurally broken flag/segment must not
|
|
221
|
+
// abort the whole run. Record it and keep going (plan §5.4 — nothing silent).
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
state.report.add('skipped-config', change.key ?? 'unknown', `conversion failed: ${message}`);
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Configs that could not be converted at all. Partial-conversion notes
|
|
229
|
+
* (dropped prerequisites, re-bucketed rollouts, etc.) live on the full
|
|
230
|
+
* `ConversionReport` — see `getConversionReport()` — and are wired into the
|
|
231
|
+
* lossy/unsupported sections of `MIGRATION_REPORT.md` by the write-mode epic.
|
|
232
|
+
*/
|
|
233
|
+
function getSkippedConfigsImpl() {
|
|
234
|
+
const skipped = state.report.byCategory('skipped-config');
|
|
235
|
+
if (skipped.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
return {
|
|
238
|
+
entries: skipped.map((n) => ({ key: n.key, reason: n.detail })),
|
|
239
|
+
total: skipped.length,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** The full conversion report — every skip/drop/coerce note from this run. */
|
|
243
|
+
export function getConversionReport() {
|
|
244
|
+
return state.report;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Every structured conversion note from this run — re-bucketed rollouts,
|
|
248
|
+
* dropped prerequisites, lossy individual-target conversions, etc. The write
|
|
249
|
+
* paths (`local-write.ts` / `push-to-cloud.ts`) thread this into
|
|
250
|
+
* `MIGRATION_REPORT.md`'s "Users will be re-bucketed" + "Conversion notes"
|
|
251
|
+
* sections (plan §5.4 — nothing is silently dropped). `skipped-config` notes
|
|
252
|
+
* are included here too; the report renderer routes them to their own
|
|
253
|
+
* dedicated section.
|
|
254
|
+
*/
|
|
255
|
+
function getConversionNotesImpl() {
|
|
256
|
+
const notes = state.report.all();
|
|
257
|
+
return notes.length === 0 ? null : notes;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Per-change commit metadata for `--full-summary` (plan §7). Reifies the
|
|
261
|
+
* original LaunchDarkly member, timestamp and description from the audit entry
|
|
262
|
+
* carried on the change's `raw` payload. Returns null for current-state changes
|
|
263
|
+
* (segments, or a flag with no `auditEntry`) so the write paths fall back to
|
|
264
|
+
* the migrator identity.
|
|
265
|
+
*/
|
|
266
|
+
function getCommitMetaImpl(change) {
|
|
267
|
+
const raw = change.raw;
|
|
268
|
+
if (!raw || typeof raw !== 'object' || !('auditEntry' in raw))
|
|
269
|
+
return null;
|
|
270
|
+
return getCommitMetaForAuditEntry(raw.auditEntry);
|
|
271
|
+
}
|
|
3
272
|
export const launchdarklySource = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
|
|
7
|
-
},
|
|
8
|
-
listEnvironments() {
|
|
9
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
|
|
273
|
+
fetchChanges() {
|
|
274
|
+
return fetchChangesImpl();
|
|
10
275
|
},
|
|
276
|
+
getCommitMeta: getCommitMetaImpl,
|
|
277
|
+
getConversionNotes: getConversionNotesImpl,
|
|
278
|
+
getSkippedConfigs: getSkippedConfigsImpl,
|
|
279
|
+
listEnvironments: listEnvironmentsImpl,
|
|
11
280
|
name: SOURCE_NAME,
|
|
12
|
-
translate
|
|
13
|
-
|
|
14
|
-
},
|
|
15
|
-
validateAuth() {
|
|
16
|
-
throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
|
|
17
|
-
},
|
|
281
|
+
translate: translateImpl,
|
|
282
|
+
validateAuth: validateAuthImpl,
|
|
18
283
|
};
|
|
284
|
+
export function __resetLaunchDarklySourceForTests() {
|
|
285
|
+
state.apiKey = null;
|
|
286
|
+
state.environments = [];
|
|
287
|
+
state.fullSummary = false;
|
|
288
|
+
state.projectKey = resolveProjectKey();
|
|
289
|
+
state.report = new ConversionReport();
|
|
290
|
+
state.retentionHorizon = null;
|
|
291
|
+
// Clear any stray checkpoint from a previous test's resumability assertion.
|
|
292
|
+
try {
|
|
293
|
+
const cp = auditCheckpointPath();
|
|
294
|
+
if (fs.existsSync(cp))
|
|
295
|
+
fs.rmSync(cp);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* best-effort */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -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;
|
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.49";
|
|
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.49';
|