@quonfig/cli 0.0.11 → 0.0.12
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 +154 -28
- package/dist/commands/migrate/doctor.d.ts +12 -0
- package/dist/commands/migrate/doctor.js +63 -0
- package/dist/commands/migrate/status.d.ts +10 -0
- package/dist/commands/migrate/status.js +112 -0
- package/dist/commands/migrate.d.ts +20 -0
- package/dist/commands/migrate.js +256 -0
- package/dist/migrate/doctor.d.ts +27 -0
- package/dist/migrate/doctor.js +220 -0
- package/dist/migrate/identifier-map.d.ts +14 -0
- package/dist/migrate/identifier-map.js +55 -0
- package/dist/migrate/import-state.d.ts +14 -0
- package/dist/migrate/import-state.js +60 -0
- package/dist/migrate/migration-report.d.ts +42 -0
- package/dist/migrate/migration-report.js +93 -0
- package/dist/migrate/push-strategy.d.ts +34 -0
- package/dist/migrate/push-strategy.js +133 -0
- package/dist/migrate/push-to-cloud.d.ts +15 -0
- package/dist/migrate/push-to-cloud.js +31 -0
- package/dist/migrate/registry.d.ts +6 -0
- package/dist/migrate/registry.js +25 -0
- package/dist/migrate/source.d.ts +22 -0
- package/dist/migrate/source.js +12 -0
- package/dist/migrate/sources/flagsmith.d.ts +2 -0
- package/dist/migrate/sources/flagsmith.js +18 -0
- package/dist/migrate/sources/launch/api.d.ts +7 -0
- package/dist/migrate/sources/launch/api.js +75 -0
- package/dist/migrate/sources/launch/translate.d.ts +6 -0
- package/dist/migrate/sources/launch/translate.js +190 -0
- package/dist/migrate/sources/launch/types.d.ts +68 -0
- package/dist/migrate/sources/launch/types.js +1 -0
- package/dist/migrate/sources/launch.d.ts +3 -0
- package/dist/migrate/sources/launch.js +70 -0
- package/dist/migrate/sources/launchdarkly.d.ts +2 -0
- package/dist/migrate/sources/launchdarkly.js +18 -0
- package/dist/ui/get-confirmation.d.ts +2 -1
- package/dist/ui/get-confirmation.js +9 -1
- package/dist/ui/get-value.d.ts +1 -1
- package/dist/ui/get-value.js +3 -2
- package/dist/util/is-interactive.d.ts +21 -1
- package/dist/util/is-interactive.js +21 -1
- package/dist/verify/validate.js +6 -1
- package/oclif.manifest.json +287 -1
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IdentifierMap } from './identifier-map.js';
|
|
2
|
+
export interface MigrationReportCounts {
|
|
3
|
+
environmentsMapped: number;
|
|
4
|
+
flagsMigrated: number;
|
|
5
|
+
itemsSkipped: number;
|
|
6
|
+
segmentsMigrated: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CleanMapping {
|
|
9
|
+
legacyKey: string;
|
|
10
|
+
quonfigKey: string;
|
|
11
|
+
}
|
|
12
|
+
export interface LossyMapping {
|
|
13
|
+
legacyKey: string;
|
|
14
|
+
quonfigKey: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
export interface UnsupportedFeature {
|
|
18
|
+
feature: string;
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface EnvironmentMapEntry {
|
|
22
|
+
quonfigName: string;
|
|
23
|
+
sourceName: string;
|
|
24
|
+
}
|
|
25
|
+
export interface FollowUpChecklist {
|
|
26
|
+
mustFixBeforeCutover: string[];
|
|
27
|
+
reviewPostCutover: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface MigrationReportData {
|
|
30
|
+
cleanMappings: CleanMapping[];
|
|
31
|
+
counts: MigrationReportCounts;
|
|
32
|
+
dryRun: boolean;
|
|
33
|
+
environmentMap: EnvironmentMapEntry[];
|
|
34
|
+
followUp: FollowUpChecklist;
|
|
35
|
+
identifierMap: IdentifierMap;
|
|
36
|
+
lossyMappings: LossyMapping[];
|
|
37
|
+
source: string;
|
|
38
|
+
unsupportedFeatures: UnsupportedFeature[];
|
|
39
|
+
}
|
|
40
|
+
export declare const buildMigrationReport: (data: MigrationReportData) => string;
|
|
41
|
+
export declare const migrationReportPath: (outputDir: string) => string;
|
|
42
|
+
export declare const writeMigrationReport: (outputDir: string, data: MigrationReportData) => string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const NONE = '_(none)_';
|
|
4
|
+
const renderCounts = (counts) => [
|
|
5
|
+
'## Counts',
|
|
6
|
+
'',
|
|
7
|
+
`- Flags migrated: ${counts.flagsMigrated}`,
|
|
8
|
+
`- Segments migrated: ${counts.segmentsMigrated}`,
|
|
9
|
+
`- Environments mapped: ${counts.environmentsMapped}`,
|
|
10
|
+
`- Items skipped: ${counts.itemsSkipped}`,
|
|
11
|
+
].join('\n');
|
|
12
|
+
const renderCleanMappings = (entries) => {
|
|
13
|
+
const header = '## Clean mapping list';
|
|
14
|
+
if (entries.length === 0)
|
|
15
|
+
return `${header}\n\n${NONE}`;
|
|
16
|
+
const sorted = [...entries].sort((a, b) => a.legacyKey.localeCompare(b.legacyKey));
|
|
17
|
+
const lines = sorted.map((e) => `- \`${e.legacyKey}\` → \`${e.quonfigKey}\``);
|
|
18
|
+
return [header, '', ...lines].join('\n');
|
|
19
|
+
};
|
|
20
|
+
const renderLossyMappings = (entries) => {
|
|
21
|
+
const header = '## Lossy mapping list';
|
|
22
|
+
if (entries.length === 0)
|
|
23
|
+
return `${header}\n\n${NONE}`;
|
|
24
|
+
const sorted = [...entries].sort((a, b) => a.legacyKey.localeCompare(b.legacyKey));
|
|
25
|
+
const lines = sorted.map((e) => `- \`${e.legacyKey}\` → \`${e.quonfigKey}\` — ${e.reason}`);
|
|
26
|
+
return [header, '', ...lines].join('\n');
|
|
27
|
+
};
|
|
28
|
+
const renderUnsupported = (entries) => {
|
|
29
|
+
const header = '## Unsupported feature list';
|
|
30
|
+
if (entries.length === 0)
|
|
31
|
+
return `${header}\n\n${NONE}`;
|
|
32
|
+
const lines = entries.map((e) => (e.note ? `- **${e.feature}** — ${e.note}` : `- ${e.feature}`));
|
|
33
|
+
return [header, '', ...lines].join('\n');
|
|
34
|
+
};
|
|
35
|
+
const renderEnvironmentMap = (entries) => {
|
|
36
|
+
const header = '## Environment mapping table';
|
|
37
|
+
if (entries.length === 0)
|
|
38
|
+
return `${header}\n\n${NONE}`;
|
|
39
|
+
const sorted = [...entries].sort((a, b) => a.sourceName.localeCompare(b.sourceName));
|
|
40
|
+
const rows = sorted.map((e) => `| ${e.sourceName} | ${e.quonfigName} |`);
|
|
41
|
+
return [header, '', '| Source | Quonfig |', '| --- | --- |', ...rows].join('\n');
|
|
42
|
+
};
|
|
43
|
+
const renderIdentifierMap = (map) => {
|
|
44
|
+
const header = '## Identifier map';
|
|
45
|
+
const keys = Object.keys(map);
|
|
46
|
+
if (keys.length === 0)
|
|
47
|
+
return `${header}\n\n${NONE}`;
|
|
48
|
+
const sorted = [...keys].sort();
|
|
49
|
+
const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
|
|
50
|
+
return [header, '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n');
|
|
51
|
+
};
|
|
52
|
+
const renderFollowUp = (followUp) => {
|
|
53
|
+
const must = followUp.mustFixBeforeCutover.length === 0
|
|
54
|
+
? NONE
|
|
55
|
+
: followUp.mustFixBeforeCutover.map((item) => `- [ ] ${item}`).join('\n');
|
|
56
|
+
const review = followUp.reviewPostCutover.length === 0
|
|
57
|
+
? NONE
|
|
58
|
+
: followUp.reviewPostCutover.map((item) => `- [ ] ${item}`).join('\n');
|
|
59
|
+
return [
|
|
60
|
+
'## Follow-up checklist',
|
|
61
|
+
'',
|
|
62
|
+
'### Must fix before cutover',
|
|
63
|
+
'',
|
|
64
|
+
must,
|
|
65
|
+
'',
|
|
66
|
+
'### Review post-cutover',
|
|
67
|
+
'',
|
|
68
|
+
review,
|
|
69
|
+
].join('\n');
|
|
70
|
+
};
|
|
71
|
+
export const buildMigrationReport = (data) => {
|
|
72
|
+
const sections = [];
|
|
73
|
+
if (data.dryRun) {
|
|
74
|
+
sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
|
|
75
|
+
}
|
|
76
|
+
sections.push(`# Migration Report (from ${data.source})`);
|
|
77
|
+
sections.push(`_Reflects only the changes produced by this run. Re-running overwrites this file._`);
|
|
78
|
+
sections.push(renderCounts(data.counts));
|
|
79
|
+
sections.push(renderCleanMappings(data.cleanMappings));
|
|
80
|
+
sections.push(renderLossyMappings(data.lossyMappings));
|
|
81
|
+
sections.push(renderUnsupported(data.unsupportedFeatures));
|
|
82
|
+
sections.push(renderEnvironmentMap(data.environmentMap));
|
|
83
|
+
sections.push(renderIdentifierMap(data.identifierMap));
|
|
84
|
+
sections.push(renderFollowUp(data.followUp));
|
|
85
|
+
return sections.join('\n\n') + '\n';
|
|
86
|
+
};
|
|
87
|
+
export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
|
|
88
|
+
export const writeMigrationReport = (outputDir, data) => {
|
|
89
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
90
|
+
const filePath = migrationReportPath(outputDir);
|
|
91
|
+
fs.writeFileSync(filePath, buildMigrationReport(data), 'utf8');
|
|
92
|
+
return filePath;
|
|
93
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const MIGRATOR_IDENTITY: {
|
|
2
|
+
readonly name: "quonfig migrator";
|
|
3
|
+
readonly email: "migrator@quonfig.com";
|
|
4
|
+
};
|
|
5
|
+
export interface PushIdentity {
|
|
6
|
+
email: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CloneAndStackPushOptions {
|
|
10
|
+
/** Called once `localDir` is a clone synced with origin/main. Must write/delete files under `localDir` — do NOT commit. */
|
|
11
|
+
applyDelta: (localDir: string) => Promise<void> | void;
|
|
12
|
+
/** Commit author. Defaults to the migrator identity. */
|
|
13
|
+
author?: PushIdentity;
|
|
14
|
+
/** Branch to track and push. Defaults to `main`. */
|
|
15
|
+
branch?: string;
|
|
16
|
+
/** Single commit message for the delta. */
|
|
17
|
+
commitMessage: string;
|
|
18
|
+
/** Where to clone into, or an existing clone to reuse. */
|
|
19
|
+
localDir: string;
|
|
20
|
+
/** Gitea repo URL (may include token). */
|
|
21
|
+
remoteUrl: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CloneAndStackPushResult {
|
|
24
|
+
/** `'cloned'` if we had to clone fresh, `'reused'` if the local dir was already a clone of the remote. */
|
|
25
|
+
action: 'cloned' | 'reused';
|
|
26
|
+
/** SHA of the new commit, if one was made. */
|
|
27
|
+
commitSha: string | null;
|
|
28
|
+
/** `false` if `applyDelta` produced no changes — nothing was committed or pushed. */
|
|
29
|
+
committed: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare class PushConflictError extends Error {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
export declare const cloneAndStackPush: (opts: CloneAndStackPushOptions) => Promise<CloneAndStackPushResult>;
|
|
@@ -0,0 +1,133 @@
|
|
|
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) || '.', [
|
|
86
|
+
'clone',
|
|
87
|
+
'--branch',
|
|
88
|
+
branch,
|
|
89
|
+
remoteUrl,
|
|
90
|
+
localDir,
|
|
91
|
+
]);
|
|
92
|
+
return 'cloned';
|
|
93
|
+
};
|
|
94
|
+
const hasStagedChanges = async (dir) => {
|
|
95
|
+
const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
|
|
96
|
+
return stdout.trim().length > 0;
|
|
97
|
+
};
|
|
98
|
+
export const cloneAndStackPush = async (opts) => {
|
|
99
|
+
const branch = opts.branch ?? 'main';
|
|
100
|
+
const author = opts.author ?? MIGRATOR_IDENTITY;
|
|
101
|
+
const action = await ensureCloneOrReuse(opts.remoteUrl, opts.localDir, branch);
|
|
102
|
+
// Pin committer identity on the repo so commits are reproducible regardless of host git config.
|
|
103
|
+
await runGit(opts.localDir, ['config', 'user.name', author.name]);
|
|
104
|
+
await runGit(opts.localDir, ['config', 'user.email', author.email]);
|
|
105
|
+
await opts.applyDelta(opts.localDir);
|
|
106
|
+
await runGit(opts.localDir, ['add', '--all']);
|
|
107
|
+
if (!(await hasStagedChanges(opts.localDir))) {
|
|
108
|
+
return { action, committed: false, commitSha: null };
|
|
109
|
+
}
|
|
110
|
+
await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
|
|
111
|
+
...process.env,
|
|
112
|
+
GIT_AUTHOR_NAME: author.name,
|
|
113
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
114
|
+
GIT_COMMITTER_NAME: author.name,
|
|
115
|
+
GIT_COMMITTER_EMAIL: author.email,
|
|
116
|
+
});
|
|
117
|
+
const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
|
|
118
|
+
try {
|
|
119
|
+
// Plain push — no force, no force-with-lease. If the remote has advanced, we fail
|
|
120
|
+
// and surface a conflict; we do NOT clobber UI edits.
|
|
121
|
+
await runGit(opts.localDir, ['push', 'origin', branch]);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const e = error;
|
|
125
|
+
const stderr = e.stderr ?? '';
|
|
126
|
+
const combined = `${e.message}\n${stderr}`;
|
|
127
|
+
if (/non-fast-forward|fetch first|rejected/i.test(combined)) {
|
|
128
|
+
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)}`);
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return { action, committed: true, commitSha: sha.trim() };
|
|
133
|
+
};
|
|
@@ -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,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,18 @@
|
|
|
1
|
+
import { NotYetImplementedError } from '../source.js';
|
|
2
|
+
const SOURCE_NAME = 'flagsmith';
|
|
3
|
+
export const flagsmithSource = {
|
|
4
|
+
// eslint-disable-next-line require-yield
|
|
5
|
+
async *fetchChanges() {
|
|
6
|
+
throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
|
|
7
|
+
},
|
|
8
|
+
listEnvironments() {
|
|
9
|
+
throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
|
|
10
|
+
},
|
|
11
|
+
name: SOURCE_NAME,
|
|
12
|
+
translate() {
|
|
13
|
+
throw new NotYetImplementedError(SOURCE_NAME, 'translate');
|
|
14
|
+
},
|
|
15
|
+
validateAuth() {
|
|
16
|
+
throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -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[];
|