@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,256 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BaseCommand } from '../index.js';
|
|
5
|
+
import { CrossSourceError, assertSourceMatches, readImportState, } from '../migrate/import-state.js';
|
|
6
|
+
import { PushConflictError } from '../migrate/push-strategy.js';
|
|
7
|
+
import { pushMigrationToCloud } from '../migrate/push-to-cloud.js';
|
|
8
|
+
import { UnknownSourceError, getSource } from '../migrate/registry.js';
|
|
9
|
+
import { NotYetImplementedError } from '../migrate/source.js';
|
|
10
|
+
import { applyLaunchBaseUrl } from '../migrate/sources/launch/api.js';
|
|
11
|
+
import { mintGiteaToken } from '../util/gitea-api.js';
|
|
12
|
+
import { displayUrl } from '../util/git-ops.js';
|
|
13
|
+
import { getActiveProfile, loadAuthConfig, resolveWorkspaceId, } from '../util/token-storage.js';
|
|
14
|
+
const DEFAULT_DIR = 'quonfig-config';
|
|
15
|
+
export default class Migrate extends BaseCommand {
|
|
16
|
+
static description = 'Migrate flags and configs from a legacy feature-flag system into a Quonfig workspace.\n\n' +
|
|
17
|
+
'Currently supported sources:\n' +
|
|
18
|
+
' launch — Reforge Launch (ported from launch-migrator)\n\n' +
|
|
19
|
+
'See https://docs.quonfig.com/docs/migrating/from-launch for the step-by-step guide,\n' +
|
|
20
|
+
'and https://docs.quonfig.com/docs/migrating/troubleshooting when something goes sideways.';
|
|
21
|
+
static examples = [
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-config',
|
|
23
|
+
'<%= config.bin %> <%= command.id %> --from launch --api-key $LAUNCH_API_KEY --workspace acme-prod --push',
|
|
24
|
+
'<%= config.bin %> <%= command.id %> --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-config --reset',
|
|
25
|
+
'<%= config.bin %> <%= command.id %> --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-config --dry-run',
|
|
26
|
+
'<%= config.bin %> <%= command.id %> --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-config --staging',
|
|
27
|
+
];
|
|
28
|
+
static flags = {
|
|
29
|
+
'api-key': Flags.string({
|
|
30
|
+
description: 'API key for the legacy source (or set LAUNCH_API_KEY)',
|
|
31
|
+
env: 'LAUNCH_API_KEY',
|
|
32
|
+
}),
|
|
33
|
+
dir: Flags.string({
|
|
34
|
+
description: 'Target local workspace directory. Defaults to cwd if it looks like a Quonfig workspace, otherwise ./quonfig-config.',
|
|
35
|
+
}),
|
|
36
|
+
'dry-run': Flags.boolean({
|
|
37
|
+
default: false,
|
|
38
|
+
description: 'Fetch and summarize changes without writing anything',
|
|
39
|
+
}),
|
|
40
|
+
from: Flags.string({
|
|
41
|
+
description: 'Legacy source to migrate from',
|
|
42
|
+
options: ['launch', 'launchdarkly', 'flagsmith'],
|
|
43
|
+
required: true,
|
|
44
|
+
}),
|
|
45
|
+
push: Flags.boolean({
|
|
46
|
+
default: false,
|
|
47
|
+
description: 'After migrating to a local dir, also push to the given --workspace on Quonfig cloud',
|
|
48
|
+
}),
|
|
49
|
+
recent: Flags.integer({
|
|
50
|
+
description: 'Import only the last N changes (useful for tire-kicking)',
|
|
51
|
+
}),
|
|
52
|
+
reset: Flags.boolean({
|
|
53
|
+
default: false,
|
|
54
|
+
description: 'Ignore the delta cursor and re-import everything from scratch',
|
|
55
|
+
}),
|
|
56
|
+
since: Flags.string({
|
|
57
|
+
description: 'Override the delta cursor (epoch milliseconds or ISO-8601 timestamp)',
|
|
58
|
+
}),
|
|
59
|
+
staging: Flags.boolean({
|
|
60
|
+
default: false,
|
|
61
|
+
description: 'Hit the staging API for the source (dev-only)',
|
|
62
|
+
}),
|
|
63
|
+
workspace: Flags.string({
|
|
64
|
+
description: 'Quonfig cloud workspace slug to push to. Requires `qfg login` and is typically combined with --push.',
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
async run() {
|
|
68
|
+
const { flags } = await this.parse(Migrate);
|
|
69
|
+
if (flags.dir && flags.workspace && !flags.push) {
|
|
70
|
+
return this.err('`--dir` and `--workspace` are mutually exclusive unless `--push` is also passed. ' +
|
|
71
|
+
'Pass `--push` to migrate locally and then push to the cloud workspace, or drop one of the flags.');
|
|
72
|
+
}
|
|
73
|
+
if (!flags['api-key']) {
|
|
74
|
+
return this.err('--api-key is required. For --from launch you can also set LAUNCH_API_KEY in your environment.');
|
|
75
|
+
}
|
|
76
|
+
let workspaceContext = null;
|
|
77
|
+
if (flags.push) {
|
|
78
|
+
const resolved = await this.resolvePushWorkspace(flags.workspace);
|
|
79
|
+
if (typeof resolved === 'string')
|
|
80
|
+
return this.err(resolved);
|
|
81
|
+
workspaceContext = resolved;
|
|
82
|
+
}
|
|
83
|
+
const dir = resolveTargetDir(flags.dir, process.cwd());
|
|
84
|
+
if (!flags.reset) {
|
|
85
|
+
try {
|
|
86
|
+
assertSourceMatches(dir, flags.from);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof CrossSourceError)
|
|
90
|
+
return this.err(error.message);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (flags.from === 'launch') {
|
|
95
|
+
applyLaunchBaseUrl(flags.staging);
|
|
96
|
+
}
|
|
97
|
+
let source;
|
|
98
|
+
try {
|
|
99
|
+
source = getSource(flags.from);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof UnknownSourceError)
|
|
103
|
+
return this.err(error.message);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await source.validateAuth(flags['api-key']);
|
|
108
|
+
await source.listEnvironments();
|
|
109
|
+
const existing = readImportState(dir);
|
|
110
|
+
const sinceEpochMs = computeSince(flags, existing);
|
|
111
|
+
const changes = [];
|
|
112
|
+
for await (const change of source.fetchChanges(sinceEpochMs)) {
|
|
113
|
+
changes.push(change);
|
|
114
|
+
}
|
|
115
|
+
const toProcess = flags.recent === undefined ? changes : changes.slice(-flags.recent);
|
|
116
|
+
const payload = {
|
|
117
|
+
dir,
|
|
118
|
+
dryRun: flags['dry-run'],
|
|
119
|
+
fetched: changes.length,
|
|
120
|
+
from: flags.from,
|
|
121
|
+
processed: toProcess.length,
|
|
122
|
+
since: sinceEpochMs,
|
|
123
|
+
};
|
|
124
|
+
if (flags['dry-run']) {
|
|
125
|
+
this.log(`Dry run: would migrate ${toProcess.length} change(s) from ${flags.from} into ${dir}.`);
|
|
126
|
+
return payload;
|
|
127
|
+
}
|
|
128
|
+
if (flags.push && workspaceContext) {
|
|
129
|
+
const { workspaceId } = workspaceContext;
|
|
130
|
+
this.log('Connecting to Gitea...');
|
|
131
|
+
let repoUrl;
|
|
132
|
+
try {
|
|
133
|
+
const tokenData = await mintGiteaToken(workspaceId, 'write', 'bootstrap');
|
|
134
|
+
repoUrl = tokenData.repoUrl;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return this.err(`Could not get Gitea credentials for workspace ${workspaceId}: ${String(error)}\n` +
|
|
138
|
+
'Make sure the workspace is fully provisioned in the Quonfig app before pushing.');
|
|
139
|
+
}
|
|
140
|
+
this.verboseLog('Migrate', { repoUrl: displayUrl(repoUrl) });
|
|
141
|
+
const latestChangedAt = latestChangedAtOf(toProcess, sinceEpochMs ?? undefined);
|
|
142
|
+
const importState = { source: flags.from };
|
|
143
|
+
if (latestChangedAt !== undefined)
|
|
144
|
+
importState.lastProcessedAt = latestChangedAt;
|
|
145
|
+
this.log(`Pushing ${toProcess.length} change(s) to workspace ${workspaceId} (clone-and-stack)...`);
|
|
146
|
+
try {
|
|
147
|
+
const result = await pushMigrationToCloud({
|
|
148
|
+
changes: toProcess,
|
|
149
|
+
commitMessage: buildCommitMessage(flags.from, toProcess.length),
|
|
150
|
+
importState,
|
|
151
|
+
localDir: dir,
|
|
152
|
+
remoteUrl: repoUrl,
|
|
153
|
+
reportData: buildReportData(flags.from, toProcess.length),
|
|
154
|
+
source,
|
|
155
|
+
});
|
|
156
|
+
this.log(result.committed
|
|
157
|
+
? `Pushed ${toProcess.length} change(s). commit=${result.commitSha?.slice(0, 8) ?? ''} action=${result.action}`
|
|
158
|
+
: `No net changes produced by this run. Nothing to commit or push.`);
|
|
159
|
+
return {
|
|
160
|
+
...payload,
|
|
161
|
+
action: result.action,
|
|
162
|
+
commitSha: result.commitSha,
|
|
163
|
+
committed: result.committed,
|
|
164
|
+
pushed: true,
|
|
165
|
+
workspaceId,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof PushConflictError) {
|
|
170
|
+
return this.err(`${error.message}\n\nRe-run \`qfg migrate --from ${flags.from} --workspace ${workspaceId} --push\` to pick up remote changes before retrying.`);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.log(`Fetched ${changes.length} change(s) from ${flags.from}; processing ${toProcess.length}.\n` +
|
|
176
|
+
'Writing files to a local dir without --push ships in follow-on beads (see qfg-zfl.10).');
|
|
177
|
+
return payload;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof NotYetImplementedError)
|
|
181
|
+
return this.err(error.message);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async resolvePushWorkspace(slugOrId) {
|
|
186
|
+
const authConfig = await loadAuthConfig();
|
|
187
|
+
if (!authConfig)
|
|
188
|
+
return 'Not logged in. Run `qfg login` first, then re-run with --push.';
|
|
189
|
+
if (slugOrId) {
|
|
190
|
+
const resolved = resolveWorkspaceId(authConfig, slugOrId);
|
|
191
|
+
return { authConfig, workspaceId: resolved ?? slugOrId };
|
|
192
|
+
}
|
|
193
|
+
const activeProfile = getActiveProfile();
|
|
194
|
+
const profile = authConfig.profiles[activeProfile] ||
|
|
195
|
+
authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
196
|
+
if (!profile) {
|
|
197
|
+
return 'No active profile found. Pass --workspace <slug> or run `qfg login` first.';
|
|
198
|
+
}
|
|
199
|
+
return { authConfig, workspaceId: profile.workspace };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function latestChangedAtOf(changes, fallback) {
|
|
203
|
+
let latest;
|
|
204
|
+
for (const change of changes) {
|
|
205
|
+
if (typeof change.changedAt === 'number' && (latest === undefined || change.changedAt > latest)) {
|
|
206
|
+
latest = change.changedAt;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return latest ?? fallback;
|
|
210
|
+
}
|
|
211
|
+
function buildCommitMessage(source, count) {
|
|
212
|
+
return `migrator: import ${count} change(s) from ${source}`;
|
|
213
|
+
}
|
|
214
|
+
function buildReportData(source, count) {
|
|
215
|
+
return {
|
|
216
|
+
cleanMappings: [],
|
|
217
|
+
counts: {
|
|
218
|
+
environmentsMapped: 0,
|
|
219
|
+
flagsMigrated: count,
|
|
220
|
+
itemsSkipped: 0,
|
|
221
|
+
segmentsMigrated: 0,
|
|
222
|
+
},
|
|
223
|
+
dryRun: false,
|
|
224
|
+
environmentMap: [],
|
|
225
|
+
followUp: { mustFixBeforeCutover: [], reviewPostCutover: [] },
|
|
226
|
+
identifierMap: {},
|
|
227
|
+
lossyMappings: [],
|
|
228
|
+
source,
|
|
229
|
+
unsupportedFeatures: [],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function resolveTargetDir(dirFlag, cwd) {
|
|
233
|
+
if (dirFlag)
|
|
234
|
+
return path.resolve(cwd, dirFlag);
|
|
235
|
+
if (fs.existsSync(path.join(cwd, 'quonfig.json')))
|
|
236
|
+
return cwd;
|
|
237
|
+
return path.resolve(cwd, DEFAULT_DIR);
|
|
238
|
+
}
|
|
239
|
+
function computeSince(flags, existing) {
|
|
240
|
+
if (flags.reset)
|
|
241
|
+
return null;
|
|
242
|
+
if (flags.since) {
|
|
243
|
+
const asNumber = Number(flags.since);
|
|
244
|
+
if (Number.isFinite(asNumber) && flags.since.trim() !== '')
|
|
245
|
+
return asNumber;
|
|
246
|
+
const asDate = new Date(flags.since).getTime();
|
|
247
|
+
return Number.isFinite(asDate) ? asDate : null;
|
|
248
|
+
}
|
|
249
|
+
const value = existing?.lastProcessedAt;
|
|
250
|
+
if (value === undefined)
|
|
251
|
+
return null;
|
|
252
|
+
if (typeof value === 'number')
|
|
253
|
+
return value;
|
|
254
|
+
const asDate = new Date(value).getTime();
|
|
255
|
+
return Number.isFinite(asDate) ? asDate : null;
|
|
256
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type IdentifierMap } from './identifier-map.js';
|
|
2
|
+
export interface DoctorCheck {
|
|
3
|
+
fix?: string;
|
|
4
|
+
message: string;
|
|
5
|
+
name: string;
|
|
6
|
+
passed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface DoctorReport {
|
|
9
|
+
checks: DoctorCheck[];
|
|
10
|
+
passed: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface DoctorSession {
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
export interface DoctorContext {
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
dir: string;
|
|
18
|
+
from: string;
|
|
19
|
+
getWorkspaceGitRepo?: () => Promise<null | string>;
|
|
20
|
+
isWorkingTreeClean?: (dir: string) => Promise<boolean>;
|
|
21
|
+
language?: string;
|
|
22
|
+
loadSession?: () => Promise<DoctorSession | null>;
|
|
23
|
+
readExistingMap?: (dir: string) => IdentifierMap | null;
|
|
24
|
+
validateSourceAuth?: (from: string, apiKey: string) => Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
export declare const runDoctor: (ctx: DoctorContext) => Promise<DoctorReport>;
|
|
27
|
+
export declare const formatHumanReport: (report: DoctorReport) => string;
|
|
@@ -0,0 +1,220 @@
|
|
|
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 ? `SDK language "${language}" supports datadir mode.` : 'No SDK language specified; assuming datadir-capable.',
|
|
128
|
+
name: 'sdk-datadir-support',
|
|
129
|
+
passed: true,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const checkGitWorkingTreeClean = async (ctx) => {
|
|
133
|
+
const fn = ctx.isWorkingTreeClean ?? defaultIsWorkingTreeClean;
|
|
134
|
+
try {
|
|
135
|
+
const clean = await fn(ctx.dir);
|
|
136
|
+
if (clean) {
|
|
137
|
+
return {
|
|
138
|
+
message: `Working tree at ${ctx.dir} is clean.`,
|
|
139
|
+
name: 'git-working-tree-clean',
|
|
140
|
+
passed: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
fix: 'Commit or stash uncommitted changes in the output directory before running the migration.',
|
|
145
|
+
message: `Working tree at ${ctx.dir} has uncommitted changes.`,
|
|
146
|
+
name: 'git-working-tree-clean',
|
|
147
|
+
passed: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return {
|
|
152
|
+
message: `Output directory ${ctx.dir} is not a git repo -- skipping working-tree check.`,
|
|
153
|
+
name: 'git-working-tree-clean',
|
|
154
|
+
passed: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const checkIdentifierCollisions = (ctx) => {
|
|
159
|
+
const reader = ctx.readExistingMap ?? readIdentifierMap;
|
|
160
|
+
const map = reader(ctx.dir);
|
|
161
|
+
if (!map) {
|
|
162
|
+
return {
|
|
163
|
+
message: 'No existing identifier map found -- nothing to collide with yet.',
|
|
164
|
+
name: 'identifier-collisions',
|
|
165
|
+
passed: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const collisions = detectCollisions(map);
|
|
169
|
+
if (collisions.length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
message: `Existing identifier map has ${Object.keys(map).length} entr${Object.keys(map).length === 1 ? 'y' : 'ies'} with no case-insensitive collisions.`,
|
|
172
|
+
name: 'identifier-collisions',
|
|
173
|
+
passed: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const lines = collisions.map((c) => ` - "${c.quonfigKeyA}" vs "${c.quonfigKeyB}" (legacy keys "${c.legacyKeyA}", "${c.legacyKeyB}")`);
|
|
177
|
+
return {
|
|
178
|
+
fix: 'Rename one of the colliding Quonfig keys in the identifier map so they differ by more than just case.',
|
|
179
|
+
message: `Found ${collisions.length} case-insensitive collision${collisions.length === 1 ? '' : 's'} in existing identifier map:\n${lines.join('\n')}`,
|
|
180
|
+
name: 'identifier-collisions',
|
|
181
|
+
passed: false,
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
export const runDoctor = async (ctx) => {
|
|
185
|
+
const checks = [
|
|
186
|
+
await checkLegacyApiKey(ctx),
|
|
187
|
+
await checkQfgLogin(ctx),
|
|
188
|
+
await checkWorkspaceProvisioned(ctx),
|
|
189
|
+
checkSdkDatadirSupport(ctx),
|
|
190
|
+
await checkGitWorkingTreeClean(ctx),
|
|
191
|
+
checkIdentifierCollisions(ctx),
|
|
192
|
+
];
|
|
193
|
+
return {
|
|
194
|
+
checks,
|
|
195
|
+
passed: checks.every((c) => c.passed),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
export const formatHumanReport = (report) => {
|
|
199
|
+
const lines = [];
|
|
200
|
+
for (const check of report.checks) {
|
|
201
|
+
const label = check.passed ? 'pass' : 'fail';
|
|
202
|
+
lines.push(` ${label} ${check.name}`);
|
|
203
|
+
const messageLines = check.message.split('\n');
|
|
204
|
+
for (const line of messageLines) {
|
|
205
|
+
lines.push(` ${line}`);
|
|
206
|
+
}
|
|
207
|
+
if (!check.passed && check.fix) {
|
|
208
|
+
lines.push(` Fix: ${check.fix}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
lines.push('');
|
|
212
|
+
if (report.passed) {
|
|
213
|
+
lines.push('All checks passed. Ready to migrate.');
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const failed = report.checks.filter((c) => !c.passed).length;
|
|
217
|
+
lines.push(`${failed} check${failed === 1 ? '' : 's'} failed. See "Fix:" hints above.`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
};
|
|
@@ -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
|
+
}
|