@quonfig/cli 0.0.10 → 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/get.js +46 -63
- 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,112 @@
|
|
|
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 { readImportState } from '../../migrate/import-state.js';
|
|
6
|
+
const FLAGS_DIR = 'feature-flags';
|
|
7
|
+
const SEGMENTS_DIR = 'segments';
|
|
8
|
+
const WORKSPACE_FILE = 'quonfig.json';
|
|
9
|
+
const NO_STATE_MESSAGE = 'No migration state found. Run qfg migrate --from <source> first.';
|
|
10
|
+
function countJsonFiles(dir) {
|
|
11
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
12
|
+
return 0;
|
|
13
|
+
return fs
|
|
14
|
+
.readdirSync(dir)
|
|
15
|
+
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
|
16
|
+
.length;
|
|
17
|
+
}
|
|
18
|
+
function countEnvironments(dir) {
|
|
19
|
+
const file = path.join(dir, WORKSPACE_FILE);
|
|
20
|
+
if (!fs.existsSync(file))
|
|
21
|
+
return 0;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
24
|
+
return Array.isArray(parsed.environments) ? parsed.environments.length : 0;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function workspaceCounts(dir) {
|
|
31
|
+
return {
|
|
32
|
+
environments: countEnvironments(dir),
|
|
33
|
+
flags: countJsonFiles(path.join(dir, FLAGS_DIR)),
|
|
34
|
+
segments: countJsonFiles(path.join(dir, SEGMENTS_DIR)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function toIsoTimestamp(value) {
|
|
38
|
+
if (value == null)
|
|
39
|
+
return null;
|
|
40
|
+
const date = typeof value === 'number' ? new Date(value) : new Date(value);
|
|
41
|
+
if (Number.isNaN(date.getTime()))
|
|
42
|
+
return null;
|
|
43
|
+
return date.toISOString();
|
|
44
|
+
}
|
|
45
|
+
function nextHint(source, counts) {
|
|
46
|
+
if (counts.flags === 0 && counts.segments === 0) {
|
|
47
|
+
return `Run \`qfg migrate --from ${source}\` to import flags and segments.`;
|
|
48
|
+
}
|
|
49
|
+
return `Run \`qfg migrate my-code --from ${source}\` to update your SDK call sites, or re-run \`qfg migrate --from ${source}\` to pick up changes.`;
|
|
50
|
+
}
|
|
51
|
+
function formatHuman(payload) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`Source: ${payload.source}`);
|
|
54
|
+
lines.push(`Source workspace: ${payload.sourceWorkspaceId ?? '(not recorded)'}`);
|
|
55
|
+
lines.push(`Last processed at: ${payload.lastProcessedAtIso ?? '(not recorded)'}`);
|
|
56
|
+
lines.push(`Workspace dir: ${payload.dir}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push('Counts:');
|
|
59
|
+
lines.push(` Flags: ${payload.counts.flags}`);
|
|
60
|
+
lines.push(` Segments: ${payload.counts.segments}`);
|
|
61
|
+
lines.push(` Environments: ${payload.counts.environments}`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push(`Next: ${payload.next}`);
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
export default class MigrateStatus extends BaseCommand {
|
|
67
|
+
static description = 'Show the status of a workspace migrated with `qfg migrate`. Reads .qf/import-state.json and summarizes source, counts, and next steps.';
|
|
68
|
+
static examples = [
|
|
69
|
+
'<%= config.bin %> <%= command.id %>',
|
|
70
|
+
'<%= config.bin %> <%= command.id %> --dir ./my-workspace',
|
|
71
|
+
'<%= config.bin %> <%= command.id %> --json',
|
|
72
|
+
];
|
|
73
|
+
static flags = {
|
|
74
|
+
dir: Flags.string({
|
|
75
|
+
default: '.',
|
|
76
|
+
description: 'Workspace directory to inspect',
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
async run() {
|
|
80
|
+
const { flags } = await this.parse(MigrateStatus);
|
|
81
|
+
const dir = path.resolve(flags.dir);
|
|
82
|
+
const state = readImportState(dir);
|
|
83
|
+
if (!state) {
|
|
84
|
+
const payload = {
|
|
85
|
+
dir,
|
|
86
|
+
found: false,
|
|
87
|
+
message: NO_STATE_MESSAGE,
|
|
88
|
+
};
|
|
89
|
+
if (this.jsonEnabled())
|
|
90
|
+
return payload;
|
|
91
|
+
this.log(NO_STATE_MESSAGE);
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
const counts = workspaceCounts(dir);
|
|
95
|
+
const lastProcessedAtIso = toIsoTimestamp(state.lastProcessedAt);
|
|
96
|
+
const next = nextHint(state.source, counts);
|
|
97
|
+
const payload = {
|
|
98
|
+
counts,
|
|
99
|
+
dir,
|
|
100
|
+
found: true,
|
|
101
|
+
lastProcessedAt: state.lastProcessedAt ?? null,
|
|
102
|
+
lastProcessedAtIso,
|
|
103
|
+
next,
|
|
104
|
+
source: state.source,
|
|
105
|
+
sourceWorkspaceId: state.sourceWorkspaceId ?? null,
|
|
106
|
+
};
|
|
107
|
+
if (this.jsonEnabled())
|
|
108
|
+
return payload;
|
|
109
|
+
this.log(formatHuman(payload));
|
|
110
|
+
return payload;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JsonObj } from '../result.js';
|
|
2
|
+
import { BaseCommand } from '../index.js';
|
|
3
|
+
export default class Migrate extends BaseCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
'api-key': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
8
|
+
dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
9
|
+
'dry-run': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
10
|
+
from: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
11
|
+
push: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
12
|
+
recent: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
13
|
+
reset: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
14
|
+
since: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
15
|
+
staging: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
16
|
+
workspace: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<JsonObj | void>;
|
|
19
|
+
private resolvePushWorkspace;
|
|
20
|
+
}
|
|
@@ -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;
|