@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,222 @@
1
+ import { isWorkingTreeClean as defaultIsWorkingTreeClean } from '../util/git-ops.js';
2
+ import { detectCollisions, readIdentifierMap } from './identifier-map.js';
3
+ const BROWSER_LANGUAGE_ALIASES = new Set(['javascript-browser', 'js-browser', 'browser', 'browser-js']);
4
+ const checkLegacyApiKey = async (ctx) => {
5
+ if (!ctx.apiKey) {
6
+ return {
7
+ message: `Skipped -- no --api-key provided for source "${ctx.from}".`,
8
+ name: 'legacy-api-key',
9
+ passed: true,
10
+ };
11
+ }
12
+ const validate = ctx.validateSourceAuth;
13
+ if (!validate) {
14
+ return {
15
+ fix: `Auth validation for source "${ctx.from}" is not yet implemented. Run without --api-key to skip this check, or upgrade the CLI.`,
16
+ message: `No validator available for source "${ctx.from}".`,
17
+ name: 'legacy-api-key',
18
+ passed: false,
19
+ };
20
+ }
21
+ try {
22
+ const ok = await validate(ctx.from, ctx.apiKey);
23
+ if (ok) {
24
+ return {
25
+ message: `${ctx.from} API key accepted.`,
26
+ name: 'legacy-api-key',
27
+ passed: true,
28
+ };
29
+ }
30
+ return {
31
+ fix: `Double-check the ${ctx.from} API key you passed to --api-key. Generate a fresh read-only token in the ${ctx.from} dashboard and retry.`,
32
+ message: `${ctx.from} API key was rejected.`,
33
+ name: 'legacy-api-key',
34
+ passed: false,
35
+ };
36
+ }
37
+ catch (error) {
38
+ const msg = error instanceof Error ? error.message : String(error);
39
+ return {
40
+ fix: `Check network connectivity to the ${ctx.from} API, then retry.`,
41
+ message: `${ctx.from} API key validation errored: ${msg}`,
42
+ name: 'legacy-api-key',
43
+ passed: false,
44
+ };
45
+ }
46
+ };
47
+ const checkQfgLogin = async (ctx) => {
48
+ const loader = ctx.loadSession;
49
+ if (!loader) {
50
+ return {
51
+ fix: 'Run `qfg login`.',
52
+ message: 'No session loader wired -- assume not logged in.',
53
+ name: 'qfg-login',
54
+ passed: false,
55
+ };
56
+ }
57
+ const session = await loader();
58
+ if (!session) {
59
+ return {
60
+ fix: 'Run `qfg login` to authenticate with Quonfig.',
61
+ message: 'No Quonfig session found. Run `qfg login`.',
62
+ name: 'qfg-login',
63
+ passed: false,
64
+ };
65
+ }
66
+ if (session.expiresAt <= Date.now()) {
67
+ return {
68
+ fix: 'Run `qfg login` again to refresh the session.',
69
+ message: 'Quonfig session is expired.',
70
+ name: 'qfg-login',
71
+ passed: false,
72
+ };
73
+ }
74
+ return {
75
+ message: 'Quonfig session is valid.',
76
+ name: 'qfg-login',
77
+ passed: true,
78
+ };
79
+ };
80
+ const checkWorkspaceProvisioned = async (ctx) => {
81
+ const getRepo = ctx.getWorkspaceGitRepo;
82
+ if (!getRepo) {
83
+ return {
84
+ fix: 'Pass --workspace or run `qfg workspace use <slug>` so the doctor can look up the target workspace.',
85
+ message: 'Cannot look up workspace -- no workspace selected.',
86
+ name: 'workspace-provisioned',
87
+ passed: false,
88
+ };
89
+ }
90
+ try {
91
+ const repo = await getRepo();
92
+ if (!repo) {
93
+ return {
94
+ fix: 'Create or provision the workspace in the Quonfig UI (its gitRepoFullName must be set) before running with --push.',
95
+ message: 'Target workspace has no gitRepoFullName set.',
96
+ name: 'workspace-provisioned',
97
+ passed: false,
98
+ };
99
+ }
100
+ return {
101
+ message: `Workspace git repo: ${repo}.`,
102
+ name: 'workspace-provisioned',
103
+ passed: true,
104
+ };
105
+ }
106
+ catch (error) {
107
+ const msg = error instanceof Error ? error.message : String(error);
108
+ return {
109
+ fix: 'Check that the workspace exists and that your session has access to it.',
110
+ message: `Workspace lookup failed: ${msg}`,
111
+ name: 'workspace-provisioned',
112
+ passed: false,
113
+ };
114
+ }
115
+ };
116
+ const checkSdkDatadirSupport = (ctx) => {
117
+ const language = (ctx.language ?? '').toLowerCase();
118
+ if (BROWSER_LANGUAGE_ALIASES.has(language)) {
119
+ return {
120
+ fix: 'Use Flow B (--push to the workspace git repo). Browser JavaScript SDKs do not read from a local datadir.',
121
+ message: `Browser JavaScript SDK has no datadir mode -- Flow B (--push) is required.`,
122
+ name: 'sdk-datadir-support',
123
+ passed: false,
124
+ };
125
+ }
126
+ return {
127
+ message: language
128
+ ? `SDK language "${language}" supports datadir mode.`
129
+ : 'No SDK language specified; assuming datadir-capable.',
130
+ name: 'sdk-datadir-support',
131
+ passed: true,
132
+ };
133
+ };
134
+ const checkGitWorkingTreeClean = async (ctx) => {
135
+ const fn = ctx.isWorkingTreeClean ?? defaultIsWorkingTreeClean;
136
+ try {
137
+ const clean = await fn(ctx.dir);
138
+ if (clean) {
139
+ return {
140
+ message: `Working tree at ${ctx.dir} is clean.`,
141
+ name: 'git-working-tree-clean',
142
+ passed: true,
143
+ };
144
+ }
145
+ return {
146
+ fix: 'Commit or stash uncommitted changes in the output directory before running the migration.',
147
+ message: `Working tree at ${ctx.dir} has uncommitted changes.`,
148
+ name: 'git-working-tree-clean',
149
+ passed: false,
150
+ };
151
+ }
152
+ catch {
153
+ return {
154
+ message: `Output directory ${ctx.dir} is not a git repo -- skipping working-tree check.`,
155
+ name: 'git-working-tree-clean',
156
+ passed: true,
157
+ };
158
+ }
159
+ };
160
+ const checkIdentifierCollisions = (ctx) => {
161
+ const reader = ctx.readExistingMap ?? readIdentifierMap;
162
+ const map = reader(ctx.dir);
163
+ if (!map) {
164
+ return {
165
+ message: 'No existing identifier map found -- nothing to collide with yet.',
166
+ name: 'identifier-collisions',
167
+ passed: true,
168
+ };
169
+ }
170
+ const collisions = detectCollisions(map);
171
+ if (collisions.length === 0) {
172
+ return {
173
+ message: `Existing identifier map has ${Object.keys(map).length} entr${Object.keys(map).length === 1 ? 'y' : 'ies'} with no case-insensitive collisions.`,
174
+ name: 'identifier-collisions',
175
+ passed: true,
176
+ };
177
+ }
178
+ const lines = collisions.map((c) => ` - "${c.quonfigKeyA}" vs "${c.quonfigKeyB}" (legacy keys "${c.legacyKeyA}", "${c.legacyKeyB}")`);
179
+ return {
180
+ fix: 'Rename one of the colliding Quonfig keys in the identifier map so they differ by more than just case.',
181
+ message: `Found ${collisions.length} case-insensitive collision${collisions.length === 1 ? '' : 's'} in existing identifier map:\n${lines.join('\n')}`,
182
+ name: 'identifier-collisions',
183
+ passed: false,
184
+ };
185
+ };
186
+ export const runDoctor = async (ctx) => {
187
+ const checks = [
188
+ await checkLegacyApiKey(ctx),
189
+ await checkQfgLogin(ctx),
190
+ await checkWorkspaceProvisioned(ctx),
191
+ checkSdkDatadirSupport(ctx),
192
+ await checkGitWorkingTreeClean(ctx),
193
+ checkIdentifierCollisions(ctx),
194
+ ];
195
+ return {
196
+ checks,
197
+ passed: checks.every((c) => c.passed),
198
+ };
199
+ };
200
+ export const formatHumanReport = (report) => {
201
+ const lines = [];
202
+ for (const check of report.checks) {
203
+ const label = check.passed ? 'pass' : 'fail';
204
+ lines.push(` ${label} ${check.name}`);
205
+ const messageLines = check.message.split('\n');
206
+ for (const line of messageLines) {
207
+ lines.push(` ${line}`);
208
+ }
209
+ if (!check.passed && check.fix) {
210
+ lines.push(` Fix: ${check.fix}`);
211
+ }
212
+ }
213
+ lines.push('');
214
+ if (report.passed) {
215
+ lines.push('All checks passed. Ready to migrate.');
216
+ }
217
+ else {
218
+ const failed = report.checks.filter((c) => !c.passed).length;
219
+ lines.push(`${failed} check${failed === 1 ? '' : 's'} failed. See "Fix:" hints above.`);
220
+ }
221
+ return lines.join('\n');
222
+ };
@@ -0,0 +1,14 @@
1
+ export type IdentifierMap = Record<string, string>;
2
+ export interface CollisionPair {
3
+ legacyKeyA: string;
4
+ legacyKeyB: string;
5
+ quonfigKeyA: string;
6
+ quonfigKeyB: string;
7
+ }
8
+ export declare class IdentifierMapCollisionError extends Error {
9
+ collisions: CollisionPair[];
10
+ constructor(collisions: CollisionPair[]);
11
+ }
12
+ export declare function detectCollisions(mapping: IdentifierMap): CollisionPair[];
13
+ export declare function writeIdentifierMap(outputDir: string, mapping: IdentifierMap): void;
14
+ export declare function readIdentifierMap(outputDir: string): IdentifierMap | null;
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class IdentifierMapCollisionError extends Error {
4
+ collisions;
5
+ constructor(collisions) {
6
+ const lines = collisions.map((c) => ` - "${c.legacyKeyA}" → "${c.quonfigKeyA}" collides with "${c.legacyKeyB}" → "${c.quonfigKeyB}" (differ only in case)`);
7
+ super(`Identifier map has ${collisions.length} case-insensitive collision${collisions.length === 1 ? '' : 's'} — refusing to write .qf/identifier-map.json:\n${lines.join('\n')}`);
8
+ this.name = 'IdentifierMapCollisionError';
9
+ this.collisions = collisions;
10
+ }
11
+ }
12
+ export function detectCollisions(mapping) {
13
+ const legacyKeys = Object.keys(mapping).sort();
14
+ const collisions = [];
15
+ for (let i = 0; i < legacyKeys.length; i++) {
16
+ const legacyA = legacyKeys[i];
17
+ const quonfigA = mapping[legacyA];
18
+ for (let j = i + 1; j < legacyKeys.length; j++) {
19
+ const legacyB = legacyKeys[j];
20
+ const quonfigB = mapping[legacyB];
21
+ if (quonfigA !== quonfigB && quonfigA.toLowerCase() === quonfigB.toLowerCase()) {
22
+ collisions.push({
23
+ legacyKeyA: legacyA,
24
+ legacyKeyB: legacyB,
25
+ quonfigKeyA: quonfigA,
26
+ quonfigKeyB: quonfigB,
27
+ });
28
+ }
29
+ }
30
+ }
31
+ return collisions;
32
+ }
33
+ function identifierMapPath(outputDir) {
34
+ return path.join(outputDir, '.qf', 'identifier-map.json');
35
+ }
36
+ export function writeIdentifierMap(outputDir, mapping) {
37
+ const collisions = detectCollisions(mapping);
38
+ if (collisions.length > 0) {
39
+ throw new IdentifierMapCollisionError(collisions);
40
+ }
41
+ const sorted = {};
42
+ for (const key of Object.keys(mapping).sort()) {
43
+ sorted[key] = mapping[key];
44
+ }
45
+ const filePath = identifierMapPath(outputDir);
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
48
+ }
49
+ export function readIdentifierMap(outputDir) {
50
+ const filePath = identifierMapPath(outputDir);
51
+ if (!fs.existsSync(filePath))
52
+ return null;
53
+ const raw = fs.readFileSync(filePath, 'utf8');
54
+ return JSON.parse(raw);
55
+ }
@@ -0,0 +1,14 @@
1
+ export interface ImportState {
2
+ lastProcessedAt?: number | string;
3
+ source: string;
4
+ sourceWorkspaceId?: string;
5
+ }
6
+ export declare class CrossSourceError extends Error {
7
+ readonly requestedSource: string;
8
+ readonly storedSource: string;
9
+ constructor(storedSource: string, requestedSource: string);
10
+ }
11
+ export declare function writeImportState(outputDir: string, state: ImportState): void;
12
+ export declare function readImportState(outputDir: string): ImportState | null;
13
+ export declare function assertSourceMatches(outputDir: string, requestedSource: string): void;
14
+ export declare function removeQfFromGitignore(outputDir: string): void;
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class CrossSourceError extends Error {
4
+ requestedSource;
5
+ storedSource;
6
+ constructor(storedSource, requestedSource) {
7
+ super(`This directory was migrated from ${storedSource}. ` +
8
+ `Pass --from ${storedSource} or use --reset to reimport from scratch ` +
9
+ `(requested --from ${requestedSource}).`);
10
+ this.name = 'CrossSourceError';
11
+ this.storedSource = storedSource;
12
+ this.requestedSource = requestedSource;
13
+ }
14
+ }
15
+ function importStatePath(outputDir) {
16
+ return path.join(outputDir, '.qf', 'import-state.json');
17
+ }
18
+ export function writeImportState(outputDir, state) {
19
+ const filePath = importStatePath(outputDir);
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ const serialized = { source: state.source };
22
+ if (state.lastProcessedAt !== undefined)
23
+ serialized.lastProcessedAt = state.lastProcessedAt;
24
+ if (state.sourceWorkspaceId !== undefined)
25
+ serialized.sourceWorkspaceId = state.sourceWorkspaceId;
26
+ fs.writeFileSync(filePath, JSON.stringify(serialized, null, 2) + '\n', 'utf8');
27
+ }
28
+ export function readImportState(outputDir) {
29
+ const filePath = importStatePath(outputDir);
30
+ if (!fs.existsSync(filePath))
31
+ return null;
32
+ const raw = fs.readFileSync(filePath, 'utf8');
33
+ const parsed = JSON.parse(raw);
34
+ if (typeof parsed.source !== 'string' || parsed.source.length === 0)
35
+ return null;
36
+ return parsed;
37
+ }
38
+ export function assertSourceMatches(outputDir, requestedSource) {
39
+ const state = readImportState(outputDir);
40
+ if (!state)
41
+ return;
42
+ if (state.source === requestedSource)
43
+ return;
44
+ throw new CrossSourceError(state.source, requestedSource);
45
+ }
46
+ export function removeQfFromGitignore(outputDir) {
47
+ const gitignorePath = path.join(outputDir, '.gitignore');
48
+ if (!fs.existsSync(gitignorePath))
49
+ return;
50
+ const original = fs.readFileSync(gitignorePath, 'utf8');
51
+ const trailingNewline = original.endsWith('\n');
52
+ const filtered = original
53
+ .split('\n')
54
+ .filter((line) => line.trim() !== '.qf' && line.trim() !== '.qf/')
55
+ .join('\n');
56
+ const next = trailingNewline && !filtered.endsWith('\n') && filtered.length > 0 ? filtered + '\n' : filtered;
57
+ if (next === original)
58
+ return;
59
+ fs.writeFileSync(gitignorePath, next, 'utf8');
60
+ }
@@ -0,0 +1,26 @@
1
+ import { type ImportState } from './import-state.js';
2
+ import { type MigrationReportData } from './migration-report.js';
3
+ import { type PushIdentity } from './push-strategy.js';
4
+ import type { LegacyChange, MigrationSource } from './source.js';
5
+ export interface ApplyLocalMigrationOptions {
6
+ /** Commit author. Defaults to the migrator identity. */
7
+ author?: PushIdentity;
8
+ /** Initial branch name when initializing a fresh repo. Defaults to `main`. */
9
+ branch?: string;
10
+ changes: LegacyChange[];
11
+ commitMessage: string;
12
+ /** Environments discovered from the source (slugified). Written to `quonfig.json` if missing. */
13
+ environments: string[];
14
+ importState: ImportState;
15
+ localDir: string;
16
+ reportData: MigrationReportData;
17
+ source: MigrationSource;
18
+ }
19
+ export interface ApplyLocalMigrationResult {
20
+ /** `'initialized'` if we had to `git init` the target, `'reused'` if it was already a repo. */
21
+ action: 'initialized' | 'reused';
22
+ commitSha: string | null;
23
+ /** `false` if translate produced no net changes — nothing was committed. */
24
+ committed: boolean;
25
+ }
26
+ export declare const applyLocalMigration: (opts: ApplyLocalMigrationOptions) => Promise<ApplyLocalMigrationResult>;
@@ -0,0 +1,85 @@
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
+ import { removeQfFromGitignore, writeImportState } from './import-state.js';
6
+ import { writeMigrationReport } from './migration-report.js';
7
+ import { MIGRATOR_IDENTITY } from './push-strategy.js';
8
+ const execFile = util.promisify(execFileCb);
9
+ const isGitRepo = async (dir) => {
10
+ try {
11
+ await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ };
18
+ const hasStagedChanges = async (dir) => {
19
+ const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
20
+ return stdout.trim().length > 0;
21
+ };
22
+ const gitCommitFromStdin = (cwd, message, env) => new Promise((resolve, reject) => {
23
+ const child = spawn('git', ['-C', cwd, 'commit', '-F', '-'], { env });
24
+ let stderr = '';
25
+ child.stderr.on('data', (d) => {
26
+ stderr += d.toString();
27
+ });
28
+ child.on('error', reject);
29
+ child.on('close', (code) => {
30
+ if (code === 0)
31
+ resolve();
32
+ else
33
+ reject(new Error(`git commit exited ${code}: ${stderr}`));
34
+ });
35
+ child.stdin.end(message);
36
+ });
37
+ const ensureQuonfigJson = (dir, environments) => {
38
+ const filePath = path.join(dir, 'quonfig.json');
39
+ if (fs.existsSync(filePath))
40
+ return;
41
+ const sorted = [...new Set(environments)].sort();
42
+ fs.writeFileSync(filePath, JSON.stringify({ environments: sorted }, null, 2) + '\n', 'utf8');
43
+ };
44
+ const ensureLocalRepo = async (localDir, branch) => {
45
+ fs.mkdirSync(localDir, { recursive: true });
46
+ if (await isGitRepo(localDir))
47
+ return 'reused';
48
+ await execFile('git', ['init', `--initial-branch=${branch}`, localDir]);
49
+ return 'initialized';
50
+ };
51
+ const writeQuonfigFiles = (dir, changes, source) => {
52
+ for (const change of changes) {
53
+ const files = source.translate(change);
54
+ for (const file of files) {
55
+ const full = path.join(dir, file.path);
56
+ fs.mkdirSync(path.dirname(full), { recursive: true });
57
+ fs.writeFileSync(full, file.contents);
58
+ }
59
+ }
60
+ };
61
+ export const applyLocalMigration = async (opts) => {
62
+ const branch = opts.branch ?? 'main';
63
+ const author = opts.author ?? MIGRATOR_IDENTITY;
64
+ const action = await ensureLocalRepo(opts.localDir, branch);
65
+ await execFile('git', ['-C', opts.localDir, 'config', 'user.name', author.name]);
66
+ await execFile('git', ['-C', opts.localDir, 'config', 'user.email', author.email]);
67
+ ensureQuonfigJson(opts.localDir, opts.environments);
68
+ writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
69
+ removeQfFromGitignore(opts.localDir);
70
+ writeImportState(opts.localDir, opts.importState);
71
+ writeMigrationReport(opts.localDir, opts.reportData);
72
+ await execFile('git', ['-C', opts.localDir, 'add', '--all']);
73
+ if (!(await hasStagedChanges(opts.localDir))) {
74
+ return { action, committed: false, commitSha: null };
75
+ }
76
+ await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
77
+ ...process.env,
78
+ GIT_AUTHOR_EMAIL: author.email,
79
+ GIT_AUTHOR_NAME: author.name,
80
+ GIT_COMMITTER_EMAIL: author.email,
81
+ GIT_COMMITTER_NAME: author.name,
82
+ });
83
+ const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
84
+ return { action, commitSha: sha.trim(), committed: true };
85
+ };
@@ -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,85 @@
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})`, `_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));
77
+ return sections.join('\n\n') + '\n';
78
+ };
79
+ export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
80
+ export const writeMigrationReport = (outputDir, data) => {
81
+ fs.mkdirSync(outputDir, { recursive: true });
82
+ const filePath = migrationReportPath(outputDir);
83
+ fs.writeFileSync(filePath, buildMigrationReport(data), 'utf8');
84
+ return filePath;
85
+ };
@@ -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>;