@quonfig/cli 0.0.15 → 0.0.17

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 (46) hide show
  1. package/README.md +85 -34
  2. package/dist/commands/init.d.ts +1 -0
  3. package/dist/commands/init.js +18 -0
  4. package/dist/commands/migrate.js +48 -2
  5. package/dist/commands/pull.d.ts +14 -1
  6. package/dist/commands/pull.js +64 -4
  7. package/dist/commands/push.d.ts +53 -0
  8. package/dist/commands/push.js +370 -0
  9. package/dist/commands/workspace/bootstrap.d.ts +7 -0
  10. package/dist/commands/workspace/bootstrap.js +58 -2
  11. package/dist/migrate/local-write.d.ts +8 -2
  12. package/dist/migrate/local-write.js +55 -7
  13. package/dist/migrate/migration-report.d.ts +18 -0
  14. package/dist/migrate/migration-report.js +64 -1
  15. package/dist/migrate/push-to-cloud.d.ts +14 -3
  16. package/dist/migrate/push-to-cloud.js +77 -6
  17. package/dist/migrate/source.d.ts +50 -1
  18. package/dist/migrate/sources/launch/translate.d.ts +27 -1
  19. package/dist/migrate/sources/launch/translate.js +123 -6
  20. package/dist/migrate/sources/launch/types.d.ts +3 -0
  21. package/dist/migrate/sources/launch/zod-to-json-schema.d.ts +9 -0
  22. package/dist/migrate/sources/launch/zod-to-json-schema.js +521 -0
  23. package/dist/migrate/sources/launch.js +52 -7
  24. package/dist/push/bare-path-diff.d.ts +40 -0
  25. package/dist/push/bare-path-diff.js +126 -0
  26. package/dist/push/confirm.d.ts +10 -0
  27. package/dist/push/confirm.js +108 -0
  28. package/dist/push/diff-summary.d.ts +56 -0
  29. package/dist/push/diff-summary.js +115 -0
  30. package/dist/push/identity-check.d.ts +53 -0
  31. package/dist/push/identity-check.js +135 -0
  32. package/dist/push/run-push.d.ts +107 -0
  33. package/dist/push/run-push.js +318 -0
  34. package/dist/util/get-client.js +5 -4
  35. package/dist/util/get-valid-token.d.ts +3 -1
  36. package/dist/util/get-valid-token.js +28 -3
  37. package/dist/util/gitea-api.d.ts +15 -1
  38. package/dist/util/gitea-api.js +1 -0
  39. package/dist/util/gitea-token-storage.d.ts +7 -0
  40. package/dist/util/quonfig-json.d.ts +44 -0
  41. package/dist/util/quonfig-json.js +65 -0
  42. package/dist/verify/validate.js +6 -2
  43. package/oclif.manifest.json +104 -1
  44. package/package.json +3 -3
  45. /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
  46. /package/dist/{migrate/push-strategy.js → util/clone-and-stack-push.js} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type { IdentifierMap } from './identifier-map.js';
2
+ import type { DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
2
3
  export interface MigrationReportCounts {
3
4
  environmentsMapped: number;
4
5
  flagsMigrated: number;
@@ -29,11 +30,28 @@ export interface FollowUpChecklist {
29
30
  export interface MigrationReportData {
30
31
  cleanMappings: CleanMapping[];
31
32
  counts: MigrationReportCounts;
33
+ /**
34
+ * Override sections that were dropped during translate() because the env.id was not
35
+ * present in the source's env map (e.g. archived/deleted Reforge envs). Null when
36
+ * nothing was dropped.
37
+ */
38
+ droppedOverrides?: DroppedOverrideSummary | null;
32
39
  dryRun: boolean;
40
+ /**
41
+ * Cross-type key collisions resolved by keeping the config side and deleting
42
+ * the non-config type(s). Null when none were detected. Customers should
43
+ * review each entry and clean up the source data to avoid the collision.
44
+ */
45
+ duplicateResolutions?: DuplicateResolutionSummary | null;
33
46
  environmentMap: EnvironmentMapEntry[];
34
47
  followUp: FollowUpChecklist;
35
48
  identifierMap: IdentifierMap;
36
49
  lossyMappings: LossyMapping[];
50
+ /**
51
+ * Configs soft-skipped by translate() because the source data was invalid
52
+ * (e.g. variant/valueType mismatch). Null when nothing was skipped.
53
+ */
54
+ skippedConfigs?: SkippedConfigSummary | null;
37
55
  source: string;
38
56
  unsupportedFeatures: UnsupportedFeature[];
39
57
  }
@@ -49,6 +49,59 @@ const renderIdentifierMap = (map) => {
49
49
  const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
50
50
  return [header, '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n');
51
51
  };
52
+ const renderDroppedOverrides = (dropped) => {
53
+ if (!dropped || dropped.total === 0)
54
+ return null;
55
+ const envIds = Object.keys(dropped.byEnv).sort();
56
+ const lines = [
57
+ '## Dropped override sections',
58
+ '',
59
+ `Dropped **${dropped.total}** override section(s) referencing **${envIds.length}** env ID(s) not present in the source's env list (likely archived/deleted). If any of these envs are still in use, restore them in the source system and re-run the migration.`,
60
+ ];
61
+ for (const envId of envIds) {
62
+ const perFlag = dropped.byEnv[envId];
63
+ const totalForEnv = Object.values(perFlag).reduce((s, n) => s + n, 0);
64
+ const flagCount = Object.keys(perFlag).length;
65
+ lines.push('', `### env-${envId} — ${totalForEnv} dropped from ${flagCount} flag(s)`, '');
66
+ const sorted = Object.keys(perFlag).sort();
67
+ for (const flagPath of sorted) {
68
+ lines.push(`- \`${flagPath}\` (${perFlag[flagPath]})`);
69
+ }
70
+ }
71
+ return lines.join('\n');
72
+ };
73
+ const renderDuplicateResolutions = (resolved) => {
74
+ if (!resolved || resolved.total === 0)
75
+ return null;
76
+ const lines = [
77
+ '## Resolved cross-type duplicates',
78
+ '',
79
+ `Reforge had **${resolved.total}** key(s) present as both a config and a feature_flag (or other types) at the same time. qfg requires globally-unique keys, so the migrator kept the **config** side and deleted the other type(s). Review each and clean up the source system so the collision stops recurring.`,
80
+ '',
81
+ ];
82
+ const sorted = [...resolved.entries].sort((a, b) => a.key.localeCompare(b.key));
83
+ for (const entry of sorted) {
84
+ lines.push(`- \`${entry.key}\` (${entry.collisionTypes.join(', ')}): kept \`${entry.kept}\`, deleted ${entry.deleted
85
+ .map((p) => `\`${p}\``)
86
+ .join(', ')}`);
87
+ }
88
+ return lines.join('\n');
89
+ };
90
+ const renderSkippedConfigs = (skipped) => {
91
+ if (!skipped || skipped.total === 0)
92
+ return null;
93
+ const lines = [
94
+ '## Skipped invalid configs',
95
+ '',
96
+ `Skipped **${skipped.total}** config(s) with structurally invalid source data — the migrator refused to emit them rather than ship broken data. Fix each in the source system and re-run.`,
97
+ '',
98
+ ];
99
+ const sorted = [...skipped.entries].sort((a, b) => a.key.localeCompare(b.key));
100
+ for (const entry of sorted) {
101
+ lines.push(`- \`${entry.key}\` — ${entry.reason}`);
102
+ }
103
+ return lines.join('\n');
104
+ };
52
105
  const renderFollowUp = (followUp) => {
53
106
  const must = followUp.mustFixBeforeCutover.length === 0
54
107
  ? NONE
@@ -73,7 +126,17 @@ export const buildMigrationReport = (data) => {
73
126
  if (data.dryRun) {
74
127
  sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
75
128
  }
76
- sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`, renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap), renderFollowUp(data.followUp));
129
+ sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`, renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap));
130
+ const dropped = renderDroppedOverrides(data.droppedOverrides);
131
+ if (dropped !== null)
132
+ sections.push(dropped);
133
+ const resolved = renderDuplicateResolutions(data.duplicateResolutions);
134
+ if (resolved !== null)
135
+ sections.push(resolved);
136
+ const skipped = renderSkippedConfigs(data.skippedConfigs);
137
+ if (skipped !== null)
138
+ sections.push(skipped);
139
+ sections.push(renderFollowUp(data.followUp));
77
140
  return sections.join('\n\n') + '\n';
78
141
  };
79
142
  export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
@@ -1,15 +1,26 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
- import { type CloneAndStackPushResult } from './push-strategy.js';
4
- import type { LegacyChange, MigrationSource } from './source.js';
3
+ import { type CloneAndStackPushResult } from '../util/clone-and-stack-push.js';
4
+ import type { DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
5
5
  export interface PushMigrationToCloudOptions {
6
6
  branch?: string;
7
7
  changes: LegacyChange[];
8
8
  commitMessage: string;
9
+ /**
10
+ * Source-side environments (slugified). Additively merged into the target
11
+ * workspace's quonfig.json so flag files that reference these envs verify
12
+ * cleanly. Existing target-only envs are preserved.
13
+ */
14
+ environments?: string[];
9
15
  importState: ImportState;
10
16
  localDir: string;
11
17
  remoteUrl: string;
12
18
  reportData: MigrationReportData;
13
19
  source: MigrationSource;
14
20
  }
15
- export declare const pushMigrationToCloud: (opts: PushMigrationToCloudOptions) => Promise<CloneAndStackPushResult>;
21
+ export interface PushMigrationToCloudResult extends CloneAndStackPushResult {
22
+ droppedOverrides: DroppedOverrideSummary | null;
23
+ duplicateResolutions: DuplicateResolutionSummary | null;
24
+ skippedConfigs: SkippedConfigSummary | null;
25
+ }
26
+ export declare const pushMigrationToCloud: (opts: PushMigrationToCloudOptions) => Promise<PushMigrationToCloudResult>;
@@ -2,24 +2,94 @@ 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 { writeMigrationReport } from './migration-report.js';
5
- import { cloneAndStackPush } from './push-strategy.js';
5
+ import { detectDuplicateKeys } from './sources/launch/translate.js';
6
+ import { cloneAndStackPush } from '../util/clone-and-stack-push.js';
6
7
  const writeQuonfigFiles = (dir, changes, source) => {
8
+ const livePaths = new Map();
7
9
  for (const change of changes) {
8
10
  const files = source.translate(change);
9
11
  for (const file of files) {
10
12
  const full = path.join(dir, file.path);
11
- fs.mkdirSync(path.dirname(full), { recursive: true });
12
- fs.writeFileSync(full, file.contents);
13
+ if (file.deleted) {
14
+ try {
15
+ fs.unlinkSync(full);
16
+ }
17
+ catch (error) {
18
+ if (error.code !== 'ENOENT')
19
+ throw error;
20
+ }
21
+ livePaths.delete(file.path);
22
+ }
23
+ else {
24
+ if (file.contents === undefined) {
25
+ throw new Error(`translate() emitted a write op for ${file.path} without contents`);
26
+ }
27
+ fs.mkdirSync(path.dirname(full), { recursive: true });
28
+ fs.writeFileSync(full, file.contents);
29
+ livePaths.set(file.path, true);
30
+ }
13
31
  }
14
32
  }
33
+ const resolutions = detectDuplicateKeys([...livePaths.keys()].map((p) => ({ path: p })));
34
+ for (const resolution of resolutions) {
35
+ for (const toDelete of resolution.deleted) {
36
+ try {
37
+ fs.unlinkSync(path.join(dir, toDelete));
38
+ }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT')
41
+ throw error;
42
+ }
43
+ livePaths.delete(toDelete);
44
+ }
45
+ }
46
+ return resolutions;
47
+ };
48
+ const QUONFIG_JSON_FILENAME = 'quonfig.json';
49
+ const mergeEnvironmentsIntoQuonfigJson = (dir, sourceEnvs) => {
50
+ const quonfigPath = path.join(dir, QUONFIG_JSON_FILENAME);
51
+ let existing = {};
52
+ if (fs.existsSync(quonfigPath)) {
53
+ try {
54
+ existing = JSON.parse(fs.readFileSync(quonfigPath, 'utf8'));
55
+ }
56
+ catch {
57
+ existing = {};
58
+ }
59
+ }
60
+ const existingEnvs = Array.isArray(existing.environments) ? existing.environments : [];
61
+ const merged = [...new Set([...existingEnvs, ...sourceEnvs])].sort();
62
+ if (existingEnvs.length === merged.length &&
63
+ existingEnvs.every((e, i) => e === merged[i])) {
64
+ return;
65
+ }
66
+ fs.writeFileSync(quonfigPath, JSON.stringify({ ...existing, environments: merged }, null, 2) + '\n', 'utf8');
15
67
  };
16
68
  export const pushMigrationToCloud = async (opts) => {
69
+ let droppedOverrides = null;
70
+ let duplicateResolutions = null;
71
+ let skippedConfigs = null;
17
72
  const cloneOpts = {
18
73
  applyDelta(dir) {
19
- writeQuonfigFiles(dir, opts.changes, opts.source);
74
+ if (opts.environments && opts.environments.length > 0) {
75
+ mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
76
+ }
77
+ const resolutionEntries = writeQuonfigFiles(dir, opts.changes, opts.source);
20
78
  removeQfFromGitignore(dir);
21
79
  writeImportState(dir, opts.importState);
22
- writeMigrationReport(dir, opts.reportData);
80
+ droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
81
+ skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
82
+ duplicateResolutions =
83
+ resolutionEntries.length > 0
84
+ ? { entries: resolutionEntries, total: resolutionEntries.length }
85
+ : null;
86
+ const reportData = {
87
+ ...opts.reportData,
88
+ ...(droppedOverrides ? { droppedOverrides } : {}),
89
+ ...(duplicateResolutions ? { duplicateResolutions } : {}),
90
+ ...(skippedConfigs ? { skippedConfigs } : {}),
91
+ };
92
+ writeMigrationReport(dir, reportData);
23
93
  },
24
94
  commitMessage: opts.commitMessage,
25
95
  localDir: opts.localDir,
@@ -27,5 +97,6 @@ export const pushMigrationToCloud = async (opts) => {
27
97
  };
28
98
  if (opts.branch !== undefined)
29
99
  cloneOpts.branch = opts.branch;
30
- return cloneAndStackPush(cloneOpts);
100
+ const result = await cloneAndStackPush(cloneOpts);
101
+ return { ...result, droppedOverrides, duplicateResolutions, skippedConfigs };
31
102
  };
@@ -5,11 +5,60 @@ export interface LegacyChange {
5
5
  source: string;
6
6
  }
7
7
  export interface QuonfigFile {
8
- contents: string;
8
+ /** File contents when writing. Ignored when deleted is true. */
9
+ contents?: string;
10
+ /** When true, the file at `path` should be removed (tombstone). Default false. */
11
+ deleted?: boolean;
9
12
  path: string;
10
13
  }
14
+ export interface DroppedOverrideSummary {
15
+ /** Per-envId → per-output-path count of dropped override sections. */
16
+ byEnv: Record<string, Record<string, number>>;
17
+ /** Total override sections dropped across every env + flag. */
18
+ total: number;
19
+ }
20
+ export interface SkippedConfigEntry {
21
+ /** Source key (pre-translate) that was skipped. */
22
+ key: string;
23
+ /** Human-readable reason (e.g. variant/valueType mismatch). */
24
+ reason: string;
25
+ }
26
+ export interface SkippedConfigSummary {
27
+ /** Individual skip records, in insertion order. */
28
+ entries: SkippedConfigEntry[];
29
+ /** Total configs skipped. */
30
+ total: number;
31
+ }
32
+ export interface DuplicateResolution {
33
+ /** All types that collided for this key (e.g. ['config', 'feature_flag']). */
34
+ collisionTypes: string[];
35
+ /** Paths that were deleted to resolve the collision. */
36
+ deleted: string[];
37
+ /** The path that was kept as the winning type. */
38
+ kept: string;
39
+ /** The qfg key that collided across types. */
40
+ key: string;
41
+ }
42
+ export interface DuplicateResolutionSummary {
43
+ entries: DuplicateResolution[];
44
+ total: number;
45
+ }
11
46
  export interface MigrationSource {
12
47
  fetchChanges(sinceEpochMs: null | number): AsyncIterable<LegacyChange>;
48
+ /**
49
+ * Optional post-translate accumulator. Returns any override sections that were
50
+ * dropped during translate() calls because the env.id was not present in the
51
+ * source's env map (e.g. archived/deleted Reforge envs). Returns null if nothing
52
+ * was dropped. Callers should consume + surface this before writing the migration
53
+ * report so customers can review the loss.
54
+ */
55
+ getDroppedOverrides?(): DroppedOverrideSummary | null;
56
+ /**
57
+ * Optional post-translate accumulator. Returns any configs that translate() soft-
58
+ * skipped due to invalid source data (e.g. variant/valueType mismatch). Null when
59
+ * nothing was skipped.
60
+ */
61
+ getSkippedConfigs?(): SkippedConfigSummary | null;
13
62
  listEnvironments(): Promise<string[]>;
14
63
  name: string;
15
64
  translate(change: LegacyChange): QuonfigFile[];
@@ -1,6 +1,32 @@
1
1
  import type { LaunchChangeEntry, LaunchChangeGroup, LaunchConfig } from './types.js';
2
+ /**
3
+ * Thrown by transformConfig when the source config is structurally invalid
4
+ * (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
5
+ * qfg document from it. Callers in the source layer catch this specifically
6
+ * to soft-skip the config with a warning rather than aborting the whole run.
7
+ */
8
+ export declare class InvalidSourceConfigError extends Error {
9
+ constructor(message: string);
10
+ }
2
11
  export declare function slugify(value: string): string;
3
12
  export declare function isLegacyLogLevel(type: string): boolean;
4
- export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>): Record<string, unknown>;
13
+ export declare function normalizeLogLevelKey(key: string): string;
14
+ export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void): Record<string, unknown>;
5
15
  export declare function getOutputPath(type: string, key: string): string;
16
+ /**
17
+ * Walks the set of written files looking for cross-type collisions (the same
18
+ * key appearing in multiple type-dirs). Reforge occasionally produces a config
19
+ * and a feature_flag with the same key simultaneously — qfg requires globally-
20
+ * unique keys, so we resolve the collision by keeping the config side and
21
+ * marking the other side(s) for deletion. Callers should fs.unlinkSync the
22
+ * paths in each resolution's `deleted` array and surface the resolutions in
23
+ * stderr + MIGRATION_REPORT.md so the customer can clean up the source data.
24
+ *
25
+ * When the collision does NOT include 'config' (an unexpected pattern we have
26
+ * not observed in production data), we still throw so the case surfaces for
27
+ * review rather than silently picking a tiebreak.
28
+ */
29
+ export declare function detectDuplicateKeys(files: Array<{
30
+ path: string;
31
+ }>): import('../../source.js').DuplicateResolution[];
6
32
  export declare function groupChanges(changes: LaunchChangeEntry[]): LaunchChangeGroup[];
@@ -1,3 +1,16 @@
1
+ import { zodToJsonSchema } from './zod-to-json-schema.js';
2
+ /**
3
+ * Thrown by transformConfig when the source config is structurally invalid
4
+ * (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
5
+ * qfg document from it. Callers in the source layer catch this specifically
6
+ * to soft-skip the config with a warning rather than aborting the whole run.
7
+ */
8
+ export class InvalidSourceConfigError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'InvalidSourceConfigError';
12
+ }
13
+ }
1
14
  export function slugify(value) {
2
15
  return value
3
16
  .toLowerCase()
@@ -7,6 +20,9 @@ export function slugify(value) {
7
20
  export function isLegacyLogLevel(type) {
8
21
  return type.toUpperCase() === 'LOG_LEVEL';
9
22
  }
23
+ export function normalizeLogLevelKey(key) {
24
+ return key.startsWith('log-level.') ? key : `log-level.${key}`;
25
+ }
10
26
  function zeroValue(valueType) {
11
27
  switch (valueType) {
12
28
  case 'bool': {
@@ -106,7 +122,7 @@ function normalizeJsonValuesInConfig(out) {
106
122
  }
107
123
  }
108
124
  }
109
- export function transformConfig(config, envIdMap) {
125
+ export function transformConfig(config, envIdMap, onDroppedEnv) {
110
126
  const out = JSON.parse(JSON.stringify(config));
111
127
  delete out.changedBy;
112
128
  delete out.modifiedAt;
@@ -121,24 +137,61 @@ export function transformConfig(config, envIdMap) {
121
137
  if (typeof out.valueType === 'string' && out.valueType.toUpperCase() === 'LOG_LEVEL_V2') {
122
138
  out.valueType = 'log_level';
123
139
  }
140
+ if (out.type === 'log_level' && typeof out.key === 'string') {
141
+ out.key = normalizeLogLevelKey(out.key);
142
+ }
124
143
  if (typeof out.type === 'string' && out.type.toLowerCase() === 'schema') {
125
- throw new Error(`Schema-type configs are not yet supported by qfg migrate --from launch. ` +
126
- `File a bead to prioritize this: https://github.com/quonfig/cli/issues/new?title=qfg+migrate+schema+configs`);
144
+ const defaultSection = out.default;
145
+ const firstRule = defaultSection?.rules?.[0];
146
+ const innerValue = firstRule?.value?.value;
147
+ if (!innerValue || innerValue.schemaType !== 'ZOD' || typeof innerValue.schema !== 'string') {
148
+ throw new Error(`Unsupported schema payload for ${String(out.key ?? 'unknown')}`);
149
+ }
150
+ const key = String(out.key ?? 'unknown');
151
+ let converted;
152
+ try {
153
+ converted = zodToJsonSchema(innerValue.schema);
154
+ }
155
+ catch (error) {
156
+ const msg = error instanceof Error ? error.message : String(error);
157
+ throw new Error(`Schema conversion failed for "${key}": ${msg}\n--- Zod source ---\n${innerValue.schema}\n--- end ---`);
158
+ }
159
+ return converted.schema;
127
160
  }
128
161
  if (Array.isArray(out.environments)) {
162
+ const kept = [];
129
163
  for (const env of out.environments) {
130
164
  if (typeof env.id === 'string') {
131
- const name = envIdMap[env.id] ?? `env-${env.id}`;
165
+ const name = envIdMap[env.id];
166
+ if (name === undefined) {
167
+ onDroppedEnv?.(env.id);
168
+ continue;
169
+ }
132
170
  env.id = slugify(name);
133
171
  }
172
+ kept.push(env);
173
+ }
174
+ out.environments = kept;
175
+ }
176
+ if (typeof out.valueType === 'string' &&
177
+ Array.isArray(out.variants) &&
178
+ !['provided', 'schema', 'weighted_values'].includes(out.valueType)) {
179
+ const configKey = String(out.key ?? 'unknown');
180
+ for (const [i, variant] of out.variants.entries()) {
181
+ const v = variant?.value;
182
+ if (v && typeof v.type === 'string' && v.type !== out.valueType) {
183
+ throw new InvalidSourceConfigError(`Variant type mismatch for "${configKey}" at variants[${i}]: value type "${v.type}" does not match config valueType "${String(out.valueType)}"`);
184
+ }
134
185
  }
135
186
  }
136
187
  if (!out.default && typeof out.valueType === 'string') {
188
+ const variants = Array.isArray(out.variants) ? out.variants : [];
189
+ const firstVariantValue = variants.length > 0 && variants[0] && typeof variants[0] === 'object' ? variants[0].value : undefined;
137
190
  out.default = {
138
191
  rules: [
139
192
  {
140
193
  criteria: [{ operator: 'ALWAYS_TRUE' }],
141
- value: zeroValue(out.valueType),
194
+ value: firstVariantValue ?? zeroValue(out.valueType),
142
195
  },
143
196
  ],
144
197
  };
@@ -153,7 +206,7 @@ export function getOutputPath(type, key) {
153
206
  return `feature-flags/${key}.json`;
154
207
  }
155
208
  case 'LOG_LEVEL_V2': {
156
- return `log-levels/${key}.json`;
209
+ return `log-levels/${normalizeLogLevelKey(key)}.json`;
157
210
  }
158
211
  case 'SCHEMA': {
159
212
  return `schemas/${key}.json`;
@@ -166,6 +219,70 @@ export function getOutputPath(type, key) {
166
219
  }
167
220
  }
168
221
  }
222
+ const PATH_DIR_TO_TYPE = {
223
+ configs: 'config',
224
+ 'feature-flags': 'feature_flag',
225
+ 'log-levels': 'log_level',
226
+ schemas: 'schema',
227
+ segments: 'segment',
228
+ };
229
+ const TYPE_TO_DIR = Object.fromEntries(Object.entries(PATH_DIR_TO_TYPE).map(([dir, type]) => [type, dir]));
230
+ /**
231
+ * Walks the set of written files looking for cross-type collisions (the same
232
+ * key appearing in multiple type-dirs). Reforge occasionally produces a config
233
+ * and a feature_flag with the same key simultaneously — qfg requires globally-
234
+ * unique keys, so we resolve the collision by keeping the config side and
235
+ * marking the other side(s) for deletion. Callers should fs.unlinkSync the
236
+ * paths in each resolution's `deleted` array and surface the resolutions in
237
+ * stderr + MIGRATION_REPORT.md so the customer can clean up the source data.
238
+ *
239
+ * When the collision does NOT include 'config' (an unexpected pattern we have
240
+ * not observed in production data), we still throw so the case surfaces for
241
+ * review rather than silently picking a tiebreak.
242
+ */
243
+ export function detectDuplicateKeys(files) {
244
+ const keyToTypes = new Map();
245
+ for (const { path } of files) {
246
+ const firstSlash = path.indexOf('/');
247
+ if (firstSlash < 0)
248
+ continue;
249
+ const dir = path.slice(0, firstSlash);
250
+ const type = PATH_DIR_TO_TYPE[dir];
251
+ if (!type)
252
+ continue;
253
+ const file = path.slice(firstSlash + 1);
254
+ const key = file.replace(/\.json$/, '');
255
+ const set = keyToTypes.get(key) ?? new Set();
256
+ set.add(type);
257
+ keyToTypes.set(key, set);
258
+ }
259
+ const resolutions = [];
260
+ const unexpected = [];
261
+ for (const [key, types] of [...keyToTypes.entries()].sort(([a], [b]) => a.localeCompare(b))) {
262
+ if (types.size <= 1)
263
+ continue;
264
+ if (!types.has('config')) {
265
+ unexpected.push(`"${key}" (types: ${[...types].sort().join(', ')})`);
266
+ continue;
267
+ }
268
+ const kept = `configs/${key}.json`;
269
+ const deleted = [];
270
+ for (const type of [...types].sort()) {
271
+ if (type === 'config')
272
+ continue;
273
+ const dir = TYPE_TO_DIR[type];
274
+ if (!dir)
275
+ continue;
276
+ deleted.push(`${dir}/${key}.json`);
277
+ }
278
+ resolutions.push({ collisionTypes: [...types].sort(), deleted, kept, key });
279
+ }
280
+ if (unexpected.length > 0) {
281
+ throw new Error(`Unexpected cross-type key collision — no 'config' side to tiebreak on. ` +
282
+ `Please file a bead with the source data details:\n ${unexpected.join('\n ')}`);
283
+ }
284
+ return resolutions;
285
+ }
169
286
  export function groupChanges(changes) {
170
287
  const groups = new Map();
171
288
  for (const change of changes) {
@@ -35,6 +35,9 @@ export interface LaunchConfig {
35
35
  sendToClientSdk?: boolean;
36
36
  type: string;
37
37
  valueType: string;
38
+ variants?: Array<{
39
+ value?: LaunchValue;
40
+ }>;
38
41
  }
39
42
  export interface LaunchChangeEntry {
40
43
  changedAt: number;
@@ -0,0 +1,9 @@
1
+ export type JsonSchema = boolean | JsonSchemaObject;
2
+ export interface JsonSchemaObject {
3
+ [key: string]: unknown;
4
+ }
5
+ export interface ZodToJsonSchemaResult {
6
+ schema: JsonSchema;
7
+ warnings: string[];
8
+ }
9
+ export declare function zodToJsonSchema(source: string): ZodToJsonSchemaResult;