@quonfig/cli 0.0.11 → 0.0.13

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 (106) hide show
  1. package/README.md +214 -30
  2. package/dist/codegen/json-schema-to-zod.js +7 -5
  3. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +2 -2
  4. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -2
  5. package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -6
  6. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +2 -2
  7. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +6 -6
  8. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +2 -2
  9. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +5 -5
  10. package/dist/codegen/local-config-reader.js +0 -1
  11. package/dist/codegen/schema-extractor.js +5 -2
  12. package/dist/commands/info.js +4 -1
  13. package/dist/commands/init.js +12 -2
  14. package/dist/commands/interactive.d.ts +1 -0
  15. package/dist/commands/interactive.js +8 -0
  16. package/dist/commands/login.js +9 -3
  17. package/dist/commands/migrate/doctor.d.ts +12 -0
  18. package/dist/commands/migrate/doctor.js +63 -0
  19. package/dist/commands/migrate/my-code.d.ts +11 -0
  20. package/dist/commands/migrate/my-code.js +80 -0
  21. package/dist/commands/migrate/status.d.ts +10 -0
  22. package/dist/commands/migrate/status.js +99 -0
  23. package/dist/commands/migrate.d.ts +20 -0
  24. package/dist/commands/migrate.js +277 -0
  25. package/dist/commands/override.js +6 -3
  26. package/dist/commands/profile.d.ts +1 -1
  27. package/dist/commands/profile.js +1 -1
  28. package/dist/commands/pull.d.ts +2 -2
  29. package/dist/commands/pull.js +38 -41
  30. package/dist/commands/schema.d.ts +3 -3
  31. package/dist/commands/schema.js +37 -37
  32. package/dist/commands/sdk-key/create.js +12 -14
  33. package/dist/commands/sdk-key/revoke.d.ts +1 -1
  34. package/dist/commands/sdk-key/revoke.js +2 -4
  35. package/dist/commands/set-default.js +7 -5
  36. package/dist/commands/set-rollout.d.ts +1 -1
  37. package/dist/commands/set-rollout.js +16 -13
  38. package/dist/commands/sync.d.ts +3 -3
  39. package/dist/commands/sync.js +30 -26
  40. package/dist/commands/whoami.js +1 -5
  41. package/dist/commands/workspace/bootstrap.js +7 -7
  42. package/dist/commands/workspace/switch.js +2 -2
  43. package/dist/init/init-workspace.d.ts +2 -2
  44. package/dist/init/init-workspace.js +32 -18
  45. package/dist/init/samples.d.ts +1 -1
  46. package/dist/init/samples.js +2 -2
  47. package/dist/init/schema.js +24 -15
  48. package/dist/interactive-prompt.js +1 -10
  49. package/dist/migrate/doctor.d.ts +27 -0
  50. package/dist/migrate/doctor.js +222 -0
  51. package/dist/migrate/identifier-map.d.ts +14 -0
  52. package/dist/migrate/identifier-map.js +55 -0
  53. package/dist/migrate/import-state.d.ts +14 -0
  54. package/dist/migrate/import-state.js +60 -0
  55. package/dist/migrate/local-write.d.ts +26 -0
  56. package/dist/migrate/local-write.js +85 -0
  57. package/dist/migrate/migration-report.d.ts +42 -0
  58. package/dist/migrate/migration-report.js +85 -0
  59. package/dist/migrate/push-strategy.d.ts +34 -0
  60. package/dist/migrate/push-strategy.js +127 -0
  61. package/dist/migrate/push-to-cloud.d.ts +15 -0
  62. package/dist/migrate/push-to-cloud.js +31 -0
  63. package/dist/migrate/registry.d.ts +6 -0
  64. package/dist/migrate/registry.js +25 -0
  65. package/dist/migrate/source.d.ts +22 -0
  66. package/dist/migrate/source.js +12 -0
  67. package/dist/migrate/sources/flagsmith.d.ts +2 -0
  68. package/dist/migrate/sources/flagsmith.js +28 -0
  69. package/dist/migrate/sources/launch/api.d.ts +7 -0
  70. package/dist/migrate/sources/launch/api.js +75 -0
  71. package/dist/migrate/sources/launch/translate.d.ts +6 -0
  72. package/dist/migrate/sources/launch/translate.js +190 -0
  73. package/dist/migrate/sources/launch/types.d.ts +68 -0
  74. package/dist/migrate/sources/launch/types.js +1 -0
  75. package/dist/migrate/sources/launch.d.ts +3 -0
  76. package/dist/migrate/sources/launch.js +70 -0
  77. package/dist/migrate/sources/launchdarkly.d.ts +2 -0
  78. package/dist/migrate/sources/launchdarkly.js +18 -0
  79. package/dist/quonfig.d.ts +1 -1
  80. package/dist/quonfig.js +5 -9
  81. package/dist/ui/get-confirmation.d.ts +2 -1
  82. package/dist/ui/get-confirmation.js +9 -1
  83. package/dist/ui/get-value.d.ts +1 -1
  84. package/dist/ui/get-value.js +4 -3
  85. package/dist/util/config-value-dto.js +17 -1
  86. package/dist/util/encryption.js +4 -1
  87. package/dist/util/friction-log.d.ts +1 -1
  88. package/dist/util/friction-log.js +5 -1
  89. package/dist/util/get-client.js +51 -3
  90. package/dist/util/get-valid-token.d.ts +4 -0
  91. package/dist/util/get-valid-token.js +17 -2
  92. package/dist/util/git-ops.js +7 -7
  93. package/dist/util/gitea-api.d.ts +2 -2
  94. package/dist/util/gitea-api.js +5 -3
  95. package/dist/util/gitea-token-storage.d.ts +2 -2
  96. package/dist/util/gitea-token-storage.js +1 -1
  97. package/dist/util/is-interactive.d.ts +21 -1
  98. package/dist/util/is-interactive.js +21 -1
  99. package/dist/util/oauth-client.js +1 -1
  100. package/dist/util/token-storage.js +2 -2
  101. package/dist/verify/standalone.d.ts +0 -1
  102. package/dist/verify/standalone.js +22 -23
  103. package/dist/verify/validate.d.ts +8 -8
  104. package/dist/verify/validate.js +256 -245
  105. package/oclif.manifest.json +365 -3
  106. package/package.json +6 -6
@@ -0,0 +1,127 @@
1
+ import { execFile as execFileCb, spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as util from 'node:util';
5
+ const execFile = util.promisify(execFileCb);
6
+ const gitCommitFromStdin = (cwd, message, env) => new Promise((resolve, reject) => {
7
+ const child = spawn('git', ['-C', cwd, 'commit', '-F', '-'], { env });
8
+ let stderr = '';
9
+ child.stderr.on('data', (d) => {
10
+ stderr += d.toString();
11
+ });
12
+ child.on('error', reject);
13
+ child.on('close', (code) => {
14
+ if (code === 0)
15
+ resolve();
16
+ else
17
+ reject(new Error(`git commit exited ${code}: ${stderr}`));
18
+ });
19
+ child.stdin.end(message);
20
+ });
21
+ export const MIGRATOR_IDENTITY = {
22
+ name: 'quonfig migrator',
23
+ email: 'migrator@quonfig.com',
24
+ };
25
+ const redactToken = (s) => s.replaceAll(/:([^\s/@]+)@/g, ':***@');
26
+ export class PushConflictError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'PushConflictError';
30
+ }
31
+ }
32
+ const runGit = async (cwd, args) => {
33
+ try {
34
+ return await execFile('git', args, { cwd });
35
+ }
36
+ catch (error) {
37
+ const e = error;
38
+ const stderr = redactToken(e.stderr ?? '');
39
+ const stdout = redactToken(e.stdout ?? '');
40
+ const message = redactToken(e.message || String(error));
41
+ const wrapped = new Error(`${message}\n${stderr}`.trim());
42
+ wrapped.stderr = stderr;
43
+ wrapped.stdout = stdout;
44
+ throw wrapped;
45
+ }
46
+ };
47
+ const isGitRepo = async (dir) => {
48
+ try {
49
+ await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ const getOriginUrl = async (dir) => {
57
+ try {
58
+ const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
59
+ return stdout.trim() || null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ };
65
+ const normalizeRemoteUrl = (url) => redactToken(url.replace(/\.git$/, '').trim());
66
+ const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
67
+ if (fs.existsSync(localDir) && (await isGitRepo(localDir))) {
68
+ const origin = await getOriginUrl(localDir);
69
+ if (origin && normalizeRemoteUrl(origin) === normalizeRemoteUrl(remoteUrl)) {
70
+ // Reuse: fetch + ff-merge
71
+ await runGit(localDir, ['fetch', 'origin', branch]);
72
+ await runGit(localDir, ['checkout', branch]);
73
+ await runGit(localDir, ['merge', '--ff-only', `origin/${branch}`]);
74
+ return 'reused';
75
+ }
76
+ throw new Error(`Local dir ${localDir} is a git repo but its origin (${origin ? redactToken(origin) : 'unset'}) does not match the target remote. Refuse to clobber — pass an empty/non-existent path or a matching clone.`);
77
+ }
78
+ if (fs.existsSync(localDir)) {
79
+ const entries = fs.readdirSync(localDir);
80
+ if (entries.length > 0) {
81
+ throw new Error(`Local dir ${localDir} exists and is not empty, but is not a git clone of ${redactToken(remoteUrl)}. Refuse to clobber.`);
82
+ }
83
+ }
84
+ fs.mkdirSync(path.dirname(localDir), { recursive: true });
85
+ await runGit(path.dirname(localDir) || '.', ['clone', '--branch', branch, remoteUrl, localDir]);
86
+ return 'cloned';
87
+ };
88
+ const hasStagedChanges = async (dir) => {
89
+ const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
90
+ return stdout.trim().length > 0;
91
+ };
92
+ export const cloneAndStackPush = async (opts) => {
93
+ const branch = opts.branch ?? 'main';
94
+ const author = opts.author ?? MIGRATOR_IDENTITY;
95
+ const action = await ensureCloneOrReuse(opts.remoteUrl, opts.localDir, branch);
96
+ // Pin committer identity on the repo so commits are reproducible regardless of host git config.
97
+ await runGit(opts.localDir, ['config', 'user.name', author.name]);
98
+ await runGit(opts.localDir, ['config', 'user.email', author.email]);
99
+ await opts.applyDelta(opts.localDir);
100
+ await runGit(opts.localDir, ['add', '--all']);
101
+ if (!(await hasStagedChanges(opts.localDir))) {
102
+ return { action, committed: false, commitSha: null };
103
+ }
104
+ await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
105
+ ...process.env,
106
+ GIT_AUTHOR_NAME: author.name,
107
+ GIT_AUTHOR_EMAIL: author.email,
108
+ GIT_COMMITTER_NAME: author.name,
109
+ GIT_COMMITTER_EMAIL: author.email,
110
+ });
111
+ const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
112
+ try {
113
+ // Plain push — no force, no force-with-lease. If the remote has advanced, we fail
114
+ // and surface a conflict; we do NOT clobber UI edits.
115
+ await runGit(opts.localDir, ['push', 'origin', branch]);
116
+ }
117
+ catch (error) {
118
+ const e = error;
119
+ const stderr = e.stderr ?? '';
120
+ const combined = `${e.message}\n${stderr}`;
121
+ if (/non-fast-forward|fetch first|rejected/i.test(combined)) {
122
+ throw new PushConflictError(`Push to origin/${branch} was rejected: the remote has changes we do not have locally. Re-run the migrator to pick up those changes, then push again. Original git error:\n${redactToken(stderr || e.message)}`);
123
+ }
124
+ throw error;
125
+ }
126
+ return { action, committed: true, commitSha: sha.trim() };
127
+ };
@@ -0,0 +1,15 @@
1
+ import { type ImportState } from './import-state.js';
2
+ import { type MigrationReportData } from './migration-report.js';
3
+ import { type CloneAndStackPushResult } from './push-strategy.js';
4
+ import type { LegacyChange, MigrationSource } from './source.js';
5
+ export interface PushMigrationToCloudOptions {
6
+ branch?: string;
7
+ changes: LegacyChange[];
8
+ commitMessage: string;
9
+ importState: ImportState;
10
+ localDir: string;
11
+ remoteUrl: string;
12
+ reportData: MigrationReportData;
13
+ source: MigrationSource;
14
+ }
15
+ export declare const pushMigrationToCloud: (opts: PushMigrationToCloudOptions) => Promise<CloneAndStackPushResult>;
@@ -0,0 +1,31 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
+ import { writeMigrationReport } from './migration-report.js';
5
+ import { cloneAndStackPush } from './push-strategy.js';
6
+ const writeQuonfigFiles = (dir, changes, source) => {
7
+ for (const change of changes) {
8
+ const files = source.translate(change);
9
+ for (const file of files) {
10
+ const full = path.join(dir, file.path);
11
+ fs.mkdirSync(path.dirname(full), { recursive: true });
12
+ fs.writeFileSync(full, file.contents);
13
+ }
14
+ }
15
+ };
16
+ export const pushMigrationToCloud = async (opts) => {
17
+ const cloneOpts = {
18
+ applyDelta(dir) {
19
+ writeQuonfigFiles(dir, opts.changes, opts.source);
20
+ removeQfFromGitignore(dir);
21
+ writeImportState(dir, opts.importState);
22
+ writeMigrationReport(dir, opts.reportData);
23
+ },
24
+ commitMessage: opts.commitMessage,
25
+ localDir: opts.localDir,
26
+ remoteUrl: opts.remoteUrl,
27
+ };
28
+ if (opts.branch !== undefined)
29
+ cloneOpts.branch = opts.branch;
30
+ return cloneAndStackPush(cloneOpts);
31
+ };
@@ -0,0 +1,6 @@
1
+ import { type MigrationSource } from './source.js';
2
+ export declare class UnknownSourceError extends Error {
3
+ constructor(name: string);
4
+ }
5
+ export declare function getSource(name: string): MigrationSource;
6
+ export declare function listSources(): string[];
@@ -0,0 +1,25 @@
1
+ import { flagsmithSource } from './sources/flagsmith.js';
2
+ import { launchSource } from './sources/launch.js';
3
+ import { launchdarklySource } from './sources/launchdarkly.js';
4
+ const SOURCES = {
5
+ flagsmith: flagsmithSource,
6
+ launch: launchSource,
7
+ launchdarkly: launchdarklySource,
8
+ };
9
+ export class UnknownSourceError extends Error {
10
+ constructor(name) {
11
+ const supported = Object.keys(SOURCES).sort().join(', ');
12
+ super(`Unknown migration source "${name}". Supported: ${supported}.`);
13
+ this.name = 'UnknownSourceError';
14
+ }
15
+ }
16
+ export function getSource(name) {
17
+ const source = SOURCES[name];
18
+ if (!source) {
19
+ throw new UnknownSourceError(name);
20
+ }
21
+ return source;
22
+ }
23
+ export function listSources() {
24
+ return Object.keys(SOURCES).sort();
25
+ }
@@ -0,0 +1,22 @@
1
+ export interface LegacyChange {
2
+ changedAt?: number;
3
+ key?: string;
4
+ raw: unknown;
5
+ source: string;
6
+ }
7
+ export interface QuonfigFile {
8
+ contents: string;
9
+ path: string;
10
+ }
11
+ export interface MigrationSource {
12
+ fetchChanges(sinceEpochMs: null | number): AsyncIterable<LegacyChange>;
13
+ listEnvironments(): Promise<string[]>;
14
+ name: string;
15
+ translate(change: LegacyChange): QuonfigFile[];
16
+ validateAuth(apiKey: string): Promise<void>;
17
+ }
18
+ export declare class NotYetImplementedError extends Error {
19
+ readonly issueUrl: string;
20
+ readonly sourceName: string;
21
+ constructor(sourceName: string, operation: string);
22
+ }
@@ -0,0 +1,12 @@
1
+ export class NotYetImplementedError extends Error {
2
+ issueUrl;
3
+ sourceName;
4
+ constructor(sourceName, operation) {
5
+ const issueUrl = `https://github.com/quonfig/cli/issues/new?title=qfg+migrate+--from+${encodeURIComponent(sourceName)}`;
6
+ super(`qfg migrate --from ${sourceName} is not yet implemented (${operation}). ` +
7
+ `File a bead to prioritize this source: ${issueUrl}`);
8
+ this.name = 'NotYetImplementedError';
9
+ this.sourceName = sourceName;
10
+ this.issueUrl = issueUrl;
11
+ }
12
+ }
@@ -0,0 +1,2 @@
1
+ import { type MigrationSource } from '../source.js';
2
+ export declare const flagsmithSource: MigrationSource;
@@ -0,0 +1,28 @@
1
+ // Stub — `qfg migrate --from flagsmith` is not yet wired up. Before replacing this with a real
2
+ // implementation, investigate:
3
+ // - Flagsmith API shape: what the admin/REST API returns for features, segments, identities,
4
+ // environments, and multivariate variations, and how that maps onto Quonfig's JSON schema.
5
+ // - Delta/cursor support: whether Flagsmith exposes an audit-log or change-feed endpoint with a
6
+ // stable cursor we can persist in `.qf/import-state.json` for re-runnable incremental
7
+ // imports, or whether we have to full-reimport + diff on every run (as is likely for LD).
8
+ // - Auth model: API key scopes (server-side vs. admin), org/project/environment hierarchy.
9
+ // - Schema gaps: Flagsmith's identity overrides and segment rules vs. Quonfig's rule operators
10
+ // — document any unsupported primitives the way `launchdarkly.README.md` does for LD.
11
+ import { NotYetImplementedError } from '../source.js';
12
+ const SOURCE_NAME = 'flagsmith';
13
+ export const flagsmithSource = {
14
+ // eslint-disable-next-line require-yield
15
+ async *fetchChanges() {
16
+ throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
17
+ },
18
+ listEnvironments() {
19
+ throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
20
+ },
21
+ name: SOURCE_NAME,
22
+ translate() {
23
+ throw new NotYetImplementedError(SOURCE_NAME, 'translate');
24
+ },
25
+ validateAuth() {
26
+ throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
27
+ },
28
+ };
@@ -0,0 +1,7 @@
1
+ import type { LaunchChangeEntry, LaunchChangeHistoryResponse } from './types.js';
2
+ export declare function setLaunchBaseUrl(url: string): void;
3
+ export declare function selectLaunchBaseUrl(staging: boolean): string;
4
+ export declare function applyLaunchBaseUrl(staging: boolean): void;
5
+ export declare function fetchEnvironments(apiKey: string): Promise<Record<string, string>>;
6
+ export declare function fetchChangeHistoryPage(apiKey: string, cursor?: string): Promise<LaunchChangeHistoryResponse>;
7
+ export declare function fetchAllChangeHistory(apiKey: string, sinceEpochMs?: number): Promise<LaunchChangeEntry[]>;
@@ -0,0 +1,75 @@
1
+ const PROD_BASE_URL = 'https://api.reforge.com';
2
+ const STAGING_BASE_URL = 'https://api.goatsofreforge.com';
3
+ const PAGE_LIMIT = 50;
4
+ let baseUrl = PROD_BASE_URL;
5
+ export function setLaunchBaseUrl(url) {
6
+ baseUrl = url;
7
+ }
8
+ export function selectLaunchBaseUrl(staging) {
9
+ return process.env.LAUNCH_API_URL ?? (staging ? STAGING_BASE_URL : PROD_BASE_URL);
10
+ }
11
+ export function applyLaunchBaseUrl(staging) {
12
+ baseUrl = selectLaunchBaseUrl(staging);
13
+ }
14
+ function makeToken(apiKey) {
15
+ return Buffer.from(`authuser:${apiKey}`).toString('base64');
16
+ }
17
+ async function apiFetch(path, apiKey) {
18
+ const url = `${baseUrl}${path}`;
19
+ const token = makeToken(apiKey);
20
+ const res = await fetch(url, {
21
+ headers: {
22
+ Accept: 'application/json',
23
+ Authorization: `Basic ${token}`,
24
+ },
25
+ });
26
+ if (!res.ok) {
27
+ const body = await res.text().catch(() => '(no body)');
28
+ throw new Error(`Launch API request failed: ${res.status} ${res.statusText} — ${url}\n${body}`);
29
+ }
30
+ return res.json();
31
+ }
32
+ export async function fetchEnvironments(apiKey) {
33
+ const data = (await apiFetch('/api/v1/project-environments', apiKey));
34
+ const envMap = {};
35
+ for (const env of data.envs) {
36
+ envMap[String(env.id)] = env.name;
37
+ }
38
+ return envMap;
39
+ }
40
+ export async function fetchChangeHistoryPage(apiKey, cursor) {
41
+ const params = new URLSearchParams({
42
+ expands: 'changedBy',
43
+ includeNewVersion: 'true',
44
+ includeSummary: 'true',
45
+ limit: String(PAGE_LIMIT),
46
+ });
47
+ if (cursor) {
48
+ params.set('cursor', cursor);
49
+ }
50
+ return (await apiFetch(`/api/v1/change-history?${params}`, apiKey));
51
+ }
52
+ export async function fetchAllChangeHistory(apiKey, sinceEpochMs) {
53
+ const collected = [];
54
+ const seenCursors = new Set();
55
+ let cursor;
56
+ let done = false;
57
+ while (!done) {
58
+ // eslint-disable-next-line no-await-in-loop
59
+ const page = await fetchChangeHistoryPage(apiKey, cursor);
60
+ if (page.changes.length === 0)
61
+ break;
62
+ for (const change of page.changes) {
63
+ if (sinceEpochMs !== undefined && change.changedAt <= sinceEpochMs) {
64
+ done = true;
65
+ break;
66
+ }
67
+ collected.push(change);
68
+ }
69
+ if (done || !page.cursor || seenCursors.has(page.cursor))
70
+ break;
71
+ seenCursors.add(page.cursor);
72
+ cursor = page.cursor;
73
+ }
74
+ return collected.reverse();
75
+ }
@@ -0,0 +1,6 @@
1
+ import type { LaunchChangeEntry, LaunchChangeGroup, LaunchConfig } from './types.js';
2
+ export declare function slugify(value: string): string;
3
+ export declare function isLegacyLogLevel(type: string): boolean;
4
+ export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>): Record<string, unknown>;
5
+ export declare function getOutputPath(type: string, key: string): string;
6
+ export declare function groupChanges(changes: LaunchChangeEntry[]): LaunchChangeGroup[];
@@ -0,0 +1,190 @@
1
+ export function slugify(value) {
2
+ return value
3
+ .toLowerCase()
4
+ .replaceAll(/[^\da-z]+/g, '-')
5
+ .replaceAll(/^-+|-+$/g, '');
6
+ }
7
+ export function isLegacyLogLevel(type) {
8
+ return type.toUpperCase() === 'LOG_LEVEL';
9
+ }
10
+ function zeroValue(valueType) {
11
+ switch (valueType) {
12
+ case 'bool': {
13
+ return { type: 'bool', value: false };
14
+ }
15
+ case 'double': {
16
+ return { type: 'double', value: '0' };
17
+ }
18
+ case 'duration': {
19
+ return { type: 'duration', value: 'PT0S' };
20
+ }
21
+ case 'int': {
22
+ return { type: 'int', value: '0' };
23
+ }
24
+ case 'json': {
25
+ return { type: 'json', value: {} };
26
+ }
27
+ case 'log_level': {
28
+ return { type: 'log_level', value: 'WARN' };
29
+ }
30
+ case 'string': {
31
+ return { type: 'string', value: '' };
32
+ }
33
+ case 'string_list': {
34
+ return { type: 'string_list', value: [] };
35
+ }
36
+ default: {
37
+ return { type: 'string', value: '' };
38
+ }
39
+ }
40
+ }
41
+ function parseJsonString(raw, configKey, path) {
42
+ try {
43
+ return JSON.parse(raw);
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Failed to parse stringified json value for config "${configKey}" at ${path}: ${message}. Raw value: ${JSON.stringify(raw)}`);
48
+ }
49
+ }
50
+ function normalizeLaunchValue(value, configKey, path) {
51
+ if (!value || typeof value !== 'object')
52
+ return;
53
+ const v = value;
54
+ if (v.type === 'json' && typeof v.value === 'string') {
55
+ v.value = parseJsonString(v.value, configKey, path);
56
+ return;
57
+ }
58
+ if (v.type === 'weighted_values' && Array.isArray(v.value)) {
59
+ const entries = v.value;
60
+ for (const [i, entry] of entries.entries()) {
61
+ if (entry && typeof entry === 'object' && entry.value) {
62
+ normalizeLaunchValue(entry.value, configKey, `${path}.weightedValues[${i}].value`);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ function normalizeRules(rules, configKey, path) {
68
+ if (!Array.isArray(rules))
69
+ return;
70
+ for (const [i, rule] of rules.entries()) {
71
+ if (!rule || typeof rule !== 'object')
72
+ continue;
73
+ const r = rule;
74
+ if (r.value) {
75
+ normalizeLaunchValue(r.value, configKey, `${path}[${i}].value`);
76
+ }
77
+ if (Array.isArray(r.criteria)) {
78
+ for (const [j, crit] of r.criteria.entries()) {
79
+ if (!crit || typeof crit !== 'object')
80
+ continue;
81
+ const c = crit;
82
+ if (c.valueToMatch) {
83
+ normalizeLaunchValue(c.valueToMatch, configKey, `${path}[${i}].criteria[${j}].valueToMatch`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ function normalizeJsonValuesInConfig(out) {
90
+ const configKey = String(out.key ?? 'unknown');
91
+ const defaultSection = out.default;
92
+ if (defaultSection && Array.isArray(defaultSection.rules)) {
93
+ normalizeRules(defaultSection.rules, configKey, 'default.rules');
94
+ }
95
+ if (Array.isArray(out.environments)) {
96
+ for (const [i, env] of out.environments.entries()) {
97
+ const envId = typeof env.id === 'string' ? env.id : String(i);
98
+ normalizeRules(env.rules, configKey, `environments[${envId}].rules`);
99
+ }
100
+ }
101
+ if (Array.isArray(out.variants)) {
102
+ for (const [i, variant] of out.variants.entries()) {
103
+ if (variant && variant.value) {
104
+ normalizeLaunchValue(variant.value, configKey, `variants[${i}].value`);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ export function transformConfig(config, envIdMap) {
110
+ const out = JSON.parse(JSON.stringify(config));
111
+ delete out.changedBy;
112
+ delete out.modifiedAt;
113
+ delete out.createdAt;
114
+ delete out.deleted;
115
+ if (out.type === 'feature_flag') {
116
+ delete out.sendToClientSdk;
117
+ }
118
+ if (typeof out.type === 'string' && out.type.toUpperCase() === 'LOG_LEVEL_V2') {
119
+ out.type = 'log_level';
120
+ }
121
+ if (typeof out.valueType === 'string' && out.valueType.toUpperCase() === 'LOG_LEVEL_V2') {
122
+ out.valueType = 'log_level';
123
+ }
124
+ 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`);
127
+ }
128
+ if (Array.isArray(out.environments)) {
129
+ for (const env of out.environments) {
130
+ if (typeof env.id === 'string') {
131
+ const name = envIdMap[env.id] ?? `env-${env.id}`;
132
+ env.id = slugify(name);
133
+ }
134
+ }
135
+ }
136
+ if (!out.default && typeof out.valueType === 'string') {
137
+ out.default = {
138
+ rules: [
139
+ {
140
+ criteria: [{ operator: 'ALWAYS_TRUE' }],
141
+ value: zeroValue(out.valueType),
142
+ },
143
+ ],
144
+ };
145
+ }
146
+ normalizeJsonValuesInConfig(out);
147
+ return out;
148
+ }
149
+ export function getOutputPath(type, key) {
150
+ const normalizedType = type.toUpperCase();
151
+ switch (normalizedType) {
152
+ case 'FEATURE_FLAG': {
153
+ return `feature-flags/${key}.json`;
154
+ }
155
+ case 'LOG_LEVEL_V2': {
156
+ return `log-levels/${key}.json`;
157
+ }
158
+ case 'SCHEMA': {
159
+ return `schemas/${key}.json`;
160
+ }
161
+ case 'SEGMENT': {
162
+ return `segments/${key}.json`;
163
+ }
164
+ default: {
165
+ return `configs/${key}.json`;
166
+ }
167
+ }
168
+ }
169
+ export function groupChanges(changes) {
170
+ const groups = new Map();
171
+ for (const change of changes) {
172
+ const groupKey = `${change.changedAt}:${change.changedBy.id}`;
173
+ if (!groups.has(groupKey)) {
174
+ groups.set(groupKey, {
175
+ changedAt: change.changedAt,
176
+ changedBy: change.changedBy,
177
+ changes: [],
178
+ groupKey,
179
+ summary: '',
180
+ });
181
+ }
182
+ groups.get(groupKey).changes.push(change);
183
+ }
184
+ for (const group of groups.values()) {
185
+ group.summary = group.changes
186
+ .map((c) => (c.deleted ? `Delete ${c.key}` : (c.summary ?? `Update ${c.key}`)))
187
+ .join('\n');
188
+ }
189
+ return [...groups.values()].sort((a, b) => a.changedAt - b.changedAt);
190
+ }
@@ -0,0 +1,68 @@
1
+ export interface LaunchValue {
2
+ type: string;
3
+ value: unknown;
4
+ }
5
+ export interface LaunchCriterion {
6
+ operator: string;
7
+ propertyName?: string;
8
+ valueToMatch?: LaunchValue;
9
+ }
10
+ export interface LaunchRule {
11
+ criteria: LaunchCriterion[];
12
+ value: LaunchValue;
13
+ }
14
+ export interface LaunchEnvironmentRow {
15
+ id: string;
16
+ rules: LaunchRule[];
17
+ }
18
+ export interface LaunchChangedBy {
19
+ email: string;
20
+ fullName?: string;
21
+ id: string;
22
+ type: string;
23
+ }
24
+ export interface LaunchConfig {
25
+ allowableValues?: LaunchValue[];
26
+ changedBy?: LaunchChangedBy;
27
+ default?: {
28
+ rules: LaunchRule[];
29
+ };
30
+ environments: LaunchEnvironmentRow[];
31
+ id: string;
32
+ key: string;
33
+ projectId: string;
34
+ schemaKey?: string;
35
+ sendToClientSdk?: boolean;
36
+ type: string;
37
+ valueType: string;
38
+ }
39
+ export interface LaunchChangeEntry {
40
+ changedAt: number;
41
+ changedBy: LaunchChangedBy;
42
+ deleted: boolean;
43
+ key: string;
44
+ newConfig?: LaunchConfig;
45
+ newConfigId: number | string;
46
+ previousConfigId?: number | string;
47
+ summary?: string;
48
+ type: string;
49
+ version?: number | string;
50
+ }
51
+ export interface LaunchChangeHistoryResponse {
52
+ changes: LaunchChangeEntry[];
53
+ cursor?: string;
54
+ }
55
+ export interface LaunchProjectEnvironmentsResponse {
56
+ envs: Array<{
57
+ id: number;
58
+ name: string;
59
+ }>;
60
+ projectId: number;
61
+ }
62
+ export interface LaunchChangeGroup {
63
+ changedAt: number;
64
+ changedBy: LaunchChangedBy;
65
+ changes: LaunchChangeEntry[];
66
+ groupKey: string;
67
+ summary: string;
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type MigrationSource } from '../source.js';
2
+ export declare const launchSource: MigrationSource;
3
+ export declare function __resetLaunchSourceForTests(): void;