@quonfig/cli 0.0.33 → 0.0.35
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 +39 -36
- package/dist/commands/migrate.js +17 -10
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +42 -2
- package/dist/commands/push.js +24 -1
- package/dist/migrate/local-write.d.ts +19 -2
- package/dist/migrate/local-write.js +102 -4
- package/dist/migrate/migration-report.d.ts +3 -0
- package/dist/migrate/migration-report.js +3 -0
- package/dist/migrate/push-to-cloud.d.ts +4 -1
- package/dist/migrate/push-to-cloud.js +9 -44
- package/dist/migrate/sources/launch/translate.d.ts +16 -0
- package/dist/migrate/sources/launch/translate.js +112 -5
- package/dist/push/run-push.d.ts +17 -0
- package/dist/push/run-push.js +30 -0
- package/dist/util/clone-and-stack-push.d.ts +6 -2
- package/dist/util/clone-and-stack-push.js +9 -2
- package/dist/util/git-ops.d.ts +40 -0
- package/dist/util/git-ops.js +82 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +7 -1
- package/package.json +1 -1
|
@@ -32,11 +32,54 @@ const ensureLocalRepo = async (localDir, branch) => {
|
|
|
32
32
|
await runGit(['init', `--initial-branch=${branch}`, localDir]);
|
|
33
33
|
return 'initialized';
|
|
34
34
|
};
|
|
35
|
-
|
|
35
|
+
export class MigratorKeyCollisionError extends Error {
|
|
36
|
+
destPath;
|
|
37
|
+
firstSourceKey;
|
|
38
|
+
secondSourceKey;
|
|
39
|
+
constructor(destPath, firstSourceKey, secondSourceKey) {
|
|
40
|
+
super(`Two source keys collide on destination ${destPath} after key normalization: ` +
|
|
41
|
+
`"${firstSourceKey}" and "${secondSourceKey}". Quonfig keys must be globally unique within a type tree. ` +
|
|
42
|
+
`Rename one in the source system and re-run.`);
|
|
43
|
+
this.destPath = destPath;
|
|
44
|
+
this.firstSourceKey = firstSourceKey;
|
|
45
|
+
this.secondSourceKey = secondSourceKey;
|
|
46
|
+
this.name = 'MigratorKeyCollisionError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const ensureNoNestedPath = (relativePath) => {
|
|
50
|
+
// qfg-qhk1: every output must be exactly `<type-dir>/<flat-key>.json` —
|
|
51
|
+
// never nested. If we ever emit a nested path, refuse to write so we surface
|
|
52
|
+
// it loudly rather than silently producing dirs that lose the flat-file
|
|
53
|
+
// contract (and break tombstone cleanup on subsequent runs).
|
|
54
|
+
const parts = relativePath.split('/');
|
|
55
|
+
if (parts.length !== 2) {
|
|
56
|
+
throw new Error(`Migrator refusing to write nested path "${relativePath}" — outputs must be flat <type-dir>/<key>.json. ` +
|
|
57
|
+
`This is a bug in the source's translate() — it should normalize key separators before computing the path.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const rmdirIfEmpty = (dir) => {
|
|
61
|
+
try {
|
|
62
|
+
fs.rmdirSync(dir);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const e = error;
|
|
66
|
+
// ENOTEMPTY: still has contents (expected, leave alone). ENOENT: already gone.
|
|
67
|
+
if (e.code !== 'ENOTEMPTY' && e.code !== 'ENOENT')
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
export const writeQuonfigFiles = (dir, changes, source) => {
|
|
36
72
|
const livePaths = new Map();
|
|
73
|
+
// qfg-qhk1: track which source key wrote each destination path. If a second,
|
|
74
|
+
// different source key tries to write to the same destination, that is a
|
|
75
|
+
// post-normalization collision (e.g. source had keys `foo/bar` and `foo.bar`,
|
|
76
|
+
// both normalize to `foo.bar`). Throw with a clear message.
|
|
77
|
+
const pathOwners = new Map();
|
|
37
78
|
for (const change of changes) {
|
|
38
79
|
const files = source.translate(change);
|
|
80
|
+
const sourceKey = change.key ?? '(unknown)';
|
|
39
81
|
for (const file of files) {
|
|
82
|
+
ensureNoNestedPath(file.path);
|
|
40
83
|
const full = path.join(dir, file.path);
|
|
41
84
|
if (file.deleted) {
|
|
42
85
|
try {
|
|
@@ -47,14 +90,25 @@ const writeQuonfigFiles = (dir, changes, source) => {
|
|
|
47
90
|
throw error;
|
|
48
91
|
}
|
|
49
92
|
livePaths.delete(file.path);
|
|
93
|
+
pathOwners.delete(file.path);
|
|
94
|
+
// qfg-qhk1: clean up any empty parent dirs left over from prior runs
|
|
95
|
+
// that wrote under a nested layout. (New runs never create nested
|
|
96
|
+
// dirs because of normalizeKey + ensureNoNestedPath, but stale dirs
|
|
97
|
+
// from older versions on disk would otherwise persist.)
|
|
98
|
+
rmdirIfEmpty(path.dirname(full));
|
|
50
99
|
}
|
|
51
100
|
else {
|
|
52
101
|
if (file.contents === undefined) {
|
|
53
102
|
throw new Error(`translate() emitted a write op for ${file.path} without contents`);
|
|
54
103
|
}
|
|
104
|
+
const existingOwner = pathOwners.get(file.path);
|
|
105
|
+
if (existingOwner !== undefined && existingOwner !== sourceKey) {
|
|
106
|
+
throw new MigratorKeyCollisionError(file.path, existingOwner, sourceKey);
|
|
107
|
+
}
|
|
55
108
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
56
109
|
fs.writeFileSync(full, file.contents);
|
|
57
110
|
livePaths.set(file.path, true);
|
|
111
|
+
pathOwners.set(file.path, sourceKey);
|
|
58
112
|
}
|
|
59
113
|
}
|
|
60
114
|
}
|
|
@@ -71,7 +125,47 @@ const writeQuonfigFiles = (dir, changes, source) => {
|
|
|
71
125
|
livePaths.delete(toDelete);
|
|
72
126
|
}
|
|
73
127
|
}
|
|
74
|
-
return resolutions;
|
|
128
|
+
return { livePaths: [...livePaths.keys()], resolutions };
|
|
129
|
+
};
|
|
130
|
+
const countsFromLivePaths = (livePaths) => {
|
|
131
|
+
let flagsMigrated = 0;
|
|
132
|
+
let configsMigrated = 0;
|
|
133
|
+
let segmentsMigrated = 0;
|
|
134
|
+
let schemasMigrated = 0;
|
|
135
|
+
let logLevelsMigrated = 0;
|
|
136
|
+
for (const p of livePaths) {
|
|
137
|
+
if (p.startsWith('feature-flags/'))
|
|
138
|
+
flagsMigrated++;
|
|
139
|
+
else if (p.startsWith('configs/'))
|
|
140
|
+
configsMigrated++;
|
|
141
|
+
else if (p.startsWith('segments/'))
|
|
142
|
+
segmentsMigrated++;
|
|
143
|
+
else if (p.startsWith('schemas/'))
|
|
144
|
+
schemasMigrated++;
|
|
145
|
+
else if (p.startsWith('log-levels/'))
|
|
146
|
+
logLevelsMigrated++;
|
|
147
|
+
}
|
|
148
|
+
return { configsMigrated, flagsMigrated, logLevelsMigrated, schemasMigrated, segmentsMigrated };
|
|
149
|
+
};
|
|
150
|
+
export const buildMigrationCounts = (livePaths, environmentsMapped, itemsSkipped) => ({
|
|
151
|
+
...countsFromLivePaths(livePaths),
|
|
152
|
+
environmentsMapped,
|
|
153
|
+
itemsSkipped,
|
|
154
|
+
});
|
|
155
|
+
export const buildMigrationCommitMessage = (source, counts) => {
|
|
156
|
+
const parts = [];
|
|
157
|
+
if (counts.flagsMigrated > 0)
|
|
158
|
+
parts.push(`${counts.flagsMigrated} flag(s)`);
|
|
159
|
+
if (counts.configsMigrated > 0)
|
|
160
|
+
parts.push(`${counts.configsMigrated} config(s)`);
|
|
161
|
+
if (counts.segmentsMigrated > 0)
|
|
162
|
+
parts.push(`${counts.segmentsMigrated} segment(s)`);
|
|
163
|
+
if (counts.schemasMigrated > 0)
|
|
164
|
+
parts.push(`${counts.schemasMigrated} schema(s)`);
|
|
165
|
+
if (counts.logLevelsMigrated > 0)
|
|
166
|
+
parts.push(`${counts.logLevelsMigrated} log-level(s)`);
|
|
167
|
+
const summary = parts.length === 0 ? 'no objects' : parts.join(', ');
|
|
168
|
+
return `migrator: imported ${summary} from ${source}`;
|
|
75
169
|
};
|
|
76
170
|
export const applyLocalMigration = async (opts) => {
|
|
77
171
|
const branch = opts.branch ?? 'main';
|
|
@@ -80,15 +174,18 @@ export const applyLocalMigration = async (opts) => {
|
|
|
80
174
|
await runGit(['-C', opts.localDir, 'config', 'user.name', author.name]);
|
|
81
175
|
await runGit(['-C', opts.localDir, 'config', 'user.email', author.email]);
|
|
82
176
|
ensureQuonfigJson(opts.localDir, opts.environments);
|
|
83
|
-
const resolutionEntries = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
|
|
177
|
+
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
|
|
84
178
|
removeQfFromGitignore(opts.localDir);
|
|
85
179
|
writeImportState(opts.localDir, opts.importState);
|
|
86
180
|
const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
87
181
|
const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
88
182
|
const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
89
183
|
const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
184
|
+
const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
185
|
+
const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
|
|
90
186
|
const reportData = {
|
|
91
187
|
...opts.reportData,
|
|
188
|
+
counts,
|
|
92
189
|
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
93
190
|
...(droppedOverrides ? { droppedOverrides } : {}),
|
|
94
191
|
...(duplicateResolutions ? { duplicateResolutions } : {}),
|
|
@@ -107,8 +204,9 @@ export const applyLocalMigration = async (opts) => {
|
|
|
107
204
|
skippedConfigs,
|
|
108
205
|
};
|
|
109
206
|
}
|
|
207
|
+
const commitMessage = buildMigrationCommitMessage(opts.source.name, counts);
|
|
110
208
|
await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
|
|
111
|
-
stdin:
|
|
209
|
+
stdin: commitMessage,
|
|
112
210
|
env: {
|
|
113
211
|
GIT_AUTHOR_EMAIL: author.email,
|
|
114
212
|
GIT_AUTHOR_NAME: author.name,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { IdentifierMap } from './identifier-map.js';
|
|
2
2
|
import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
|
|
3
3
|
export interface MigrationReportCounts {
|
|
4
|
+
configsMigrated: number;
|
|
4
5
|
environmentsMapped: number;
|
|
5
6
|
flagsMigrated: number;
|
|
6
7
|
itemsSkipped: number;
|
|
8
|
+
logLevelsMigrated: number;
|
|
9
|
+
schemasMigrated: number;
|
|
7
10
|
segmentsMigrated: number;
|
|
8
11
|
}
|
|
9
12
|
export interface CleanMapping {
|
|
@@ -5,7 +5,10 @@ const renderCounts = (counts) => [
|
|
|
5
5
|
'## Counts',
|
|
6
6
|
'',
|
|
7
7
|
`- Flags migrated: ${counts.flagsMigrated}`,
|
|
8
|
+
`- Configs migrated: ${counts.configsMigrated}`,
|
|
8
9
|
`- Segments migrated: ${counts.segmentsMigrated}`,
|
|
10
|
+
`- Schemas migrated: ${counts.schemasMigrated}`,
|
|
11
|
+
`- Log levels migrated: ${counts.logLevelsMigrated}`,
|
|
9
12
|
`- Environments mapped: ${counts.environmentsMapped}`,
|
|
10
13
|
`- Items skipped: ${counts.itemsSkipped}`,
|
|
11
14
|
].join('\n');
|
|
@@ -17,7 +17,6 @@ export declare class MigratorVerifyError extends Error {
|
|
|
17
17
|
export interface PushMigrationToCloudOptions {
|
|
18
18
|
branch?: string;
|
|
19
19
|
changes: LegacyChange[];
|
|
20
|
-
commitMessage: string;
|
|
21
20
|
/**
|
|
22
21
|
* Source-side environments (slugified). Additively merged into the target
|
|
23
22
|
* workspace's quonfig.json so flag files that reference these envs verify
|
|
@@ -27,6 +26,10 @@ export interface PushMigrationToCloudOptions {
|
|
|
27
26
|
importState: ImportState;
|
|
28
27
|
localDir: string;
|
|
29
28
|
remoteUrl: string;
|
|
29
|
+
/**
|
|
30
|
+
* Base report data. The migrator overrides `counts` after writing files,
|
|
31
|
+
* since counts must reflect what was actually written to disk (qfg-7eig).
|
|
32
|
+
*/
|
|
30
33
|
reportData: MigrationReportData;
|
|
31
34
|
source: MigrationSource;
|
|
32
35
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
|
+
import { buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles } from './local-write.js';
|
|
4
5
|
import { writeMigrationReport } from './migration-report.js';
|
|
5
|
-
import { detectDuplicateKeys } from './sources/launch/translate.js';
|
|
6
6
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
7
7
|
import { formatResult, validateWorkspace } from '../verify/validate.js';
|
|
8
8
|
/**
|
|
@@ -23,47 +23,6 @@ export class MigratorVerifyError extends Error {
|
|
|
23
23
|
this.result = result;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
const writeQuonfigFiles = (dir, changes, source) => {
|
|
27
|
-
const livePaths = new Map();
|
|
28
|
-
for (const change of changes) {
|
|
29
|
-
const files = source.translate(change);
|
|
30
|
-
for (const file of files) {
|
|
31
|
-
const full = path.join(dir, file.path);
|
|
32
|
-
if (file.deleted) {
|
|
33
|
-
try {
|
|
34
|
-
fs.unlinkSync(full);
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
if (error.code !== 'ENOENT')
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
livePaths.delete(file.path);
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
if (file.contents === undefined) {
|
|
44
|
-
throw new Error(`translate() emitted a write op for ${file.path} without contents`);
|
|
45
|
-
}
|
|
46
|
-
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
47
|
-
fs.writeFileSync(full, file.contents);
|
|
48
|
-
livePaths.set(file.path, true);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
const resolutions = detectDuplicateKeys([...livePaths.keys()].map((p) => ({ path: p })));
|
|
53
|
-
for (const resolution of resolutions) {
|
|
54
|
-
for (const toDelete of resolution.deleted) {
|
|
55
|
-
try {
|
|
56
|
-
fs.unlinkSync(path.join(dir, toDelete));
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
if (error.code !== 'ENOENT')
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
livePaths.delete(toDelete);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return resolutions;
|
|
66
|
-
};
|
|
67
26
|
const QUONFIG_JSON_FILENAME = 'quonfig.json';
|
|
68
27
|
const mergeEnvironmentsIntoQuonfigJson = (dir, sourceEnvs) => {
|
|
69
28
|
const quonfigPath = path.join(dir, QUONFIG_JSON_FILENAME);
|
|
@@ -88,12 +47,13 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
88
47
|
let droppedOverrides = null;
|
|
89
48
|
let duplicateResolutions = null;
|
|
90
49
|
let skippedConfigs = null;
|
|
50
|
+
let computedCommitMessage = `migrator: imported 0 objects from ${opts.source.name}`;
|
|
91
51
|
const cloneOpts = {
|
|
92
52
|
applyDelta(dir) {
|
|
93
53
|
if (opts.environments && opts.environments.length > 0) {
|
|
94
54
|
mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
|
|
95
55
|
}
|
|
96
|
-
const resolutionEntries = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
56
|
+
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
97
57
|
removeQfFromGitignore(dir);
|
|
98
58
|
writeImportState(dir, opts.importState);
|
|
99
59
|
droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
@@ -101,8 +61,13 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
101
61
|
coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
102
62
|
duplicateResolutions =
|
|
103
63
|
resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
64
|
+
const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
65
|
+
const environmentsCount = opts.environments?.length ?? 0;
|
|
66
|
+
const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
|
|
67
|
+
computedCommitMessage = buildMigrationCommitMessage(opts.source.name, counts);
|
|
104
68
|
const reportData = {
|
|
105
69
|
...opts.reportData,
|
|
70
|
+
counts,
|
|
106
71
|
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
107
72
|
...(droppedOverrides ? { droppedOverrides } : {}),
|
|
108
73
|
...(duplicateResolutions ? { duplicateResolutions } : {}),
|
|
@@ -117,7 +82,7 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
117
82
|
if (!verifyResult.valid)
|
|
118
83
|
throw new MigratorVerifyError(verifyResult);
|
|
119
84
|
},
|
|
120
|
-
commitMessage:
|
|
85
|
+
commitMessage: () => computedCommitMessage,
|
|
121
86
|
localDir: opts.localDir,
|
|
122
87
|
remoteUrl: opts.remoteUrl,
|
|
123
88
|
};
|
|
@@ -11,6 +11,22 @@ export declare class InvalidSourceConfigError extends Error {
|
|
|
11
11
|
export declare function slugify(value: string): string;
|
|
12
12
|
export declare function isLegacyLogLevel(type: string): boolean;
|
|
13
13
|
export declare function normalizeLogLevelKey(key: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* qfg-qhk1: Quonfig keys cannot contain `/` — keys are stored as flat dotted
|
|
16
|
+
* filenames, and a `/` in a source key would create a nested directory tree
|
|
17
|
+
* (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
|
|
18
|
+
* destination is always flat.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizeKey(key: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* qfg-l18w: Operators Quonfig accepts in rule criteria. Mirrors the
|
|
23
|
+
* `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
|
|
24
|
+
* config-schemas.ts). If we emit an operator outside this set, qfg verify
|
|
25
|
+
* fails — which is what was happening for `PROP_SEMVER_*` in the FormHealth
|
|
26
|
+
* dry-run before this check existed. Keep this list in lockstep with the
|
|
27
|
+
* verify schema; a CI test in this file enforces parity.
|
|
28
|
+
*/
|
|
29
|
+
export declare const QUONFIG_SUPPORTED_OPERATORS: Set<string>;
|
|
14
30
|
export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void, onCoercedSentinel?: (envId: string, valueType: string) => void): Record<string, unknown>;
|
|
15
31
|
export declare function getOutputPath(type: string, key: string): string;
|
|
16
32
|
/**
|
|
@@ -23,6 +23,106 @@ export function isLegacyLogLevel(type) {
|
|
|
23
23
|
export function normalizeLogLevelKey(key) {
|
|
24
24
|
return key.startsWith('log-level.') ? key : `log-level.${key}`;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* qfg-qhk1: Quonfig keys cannot contain `/` — keys are stored as flat dotted
|
|
28
|
+
* filenames, and a `/` in a source key would create a nested directory tree
|
|
29
|
+
* (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
|
|
30
|
+
* destination is always flat.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeKey(key) {
|
|
33
|
+
return key.replaceAll('/', '.');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* qfg-l18w: Operators Quonfig accepts in rule criteria. Mirrors the
|
|
37
|
+
* `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
|
|
38
|
+
* config-schemas.ts). If we emit an operator outside this set, qfg verify
|
|
39
|
+
* fails — which is what was happening for `PROP_SEMVER_*` in the FormHealth
|
|
40
|
+
* dry-run before this check existed. Keep this list in lockstep with the
|
|
41
|
+
* verify schema; a CI test in this file enforces parity.
|
|
42
|
+
*/
|
|
43
|
+
export const QUONFIG_SUPPORTED_OPERATORS = new Set([
|
|
44
|
+
'ALWAYS_TRUE',
|
|
45
|
+
'PROP_IS_ONE_OF',
|
|
46
|
+
'PROP_IS_NOT_ONE_OF',
|
|
47
|
+
'PROP_STARTS_WITH_ONE_OF',
|
|
48
|
+
'PROP_DOES_NOT_START_WITH_ONE_OF',
|
|
49
|
+
'PROP_ENDS_WITH_ONE_OF',
|
|
50
|
+
'PROP_DOES_NOT_END_WITH_ONE_OF',
|
|
51
|
+
'PROP_CONTAINS_ONE_OF',
|
|
52
|
+
'PROP_DOES_NOT_CONTAIN_ONE_OF',
|
|
53
|
+
'PROP_LESS_THAN',
|
|
54
|
+
'PROP_LESS_THAN_OR_EQUAL',
|
|
55
|
+
'PROP_GREATER_THAN',
|
|
56
|
+
'PROP_GREATER_THAN_OR_EQUAL',
|
|
57
|
+
'PROP_BEFORE',
|
|
58
|
+
'PROP_AFTER',
|
|
59
|
+
'PROP_MATCHES',
|
|
60
|
+
'PROP_DOES_NOT_MATCH',
|
|
61
|
+
'IN_SEG',
|
|
62
|
+
'NOT_IN_SEG',
|
|
63
|
+
'IN_INT_RANGE',
|
|
64
|
+
'LOOKUP_KEY_IN',
|
|
65
|
+
'LOOKUP_KEY_NOT_IN',
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* qfg-l18w: Walk every rule criterion in a transformed config and fail-fast at
|
|
69
|
+
* migrate time if it carries an operator Quonfig does not understand. The
|
|
70
|
+
* primary offender today is `PROP_SEMVER_*` (Launch supports semver
|
|
71
|
+
* comparisons; Quonfig does not have a semver operator at all). Without this
|
|
72
|
+
* check the migrator silently writes invalid JSON and the failure surfaces
|
|
73
|
+
* later at qfg-verify — the customer's local file is broken and re-runs
|
|
74
|
+
* re-clobber any manual fix.
|
|
75
|
+
*
|
|
76
|
+
* Routes through `InvalidSourceConfigError` so the failure is per-config (the
|
|
77
|
+
* rest of the run still produces a usable workspace, and the offending key
|
|
78
|
+
* lands in MIGRATION_REPORT.md's "Skipped invalid configs" section with a
|
|
79
|
+
* specific reason — exactly what the customer needs to take to support).
|
|
80
|
+
*/
|
|
81
|
+
function collectUnsupportedOperators(rules) {
|
|
82
|
+
if (!Array.isArray(rules))
|
|
83
|
+
return [];
|
|
84
|
+
const found = [];
|
|
85
|
+
for (const rule of rules) {
|
|
86
|
+
if (!rule || typeof rule !== 'object')
|
|
87
|
+
continue;
|
|
88
|
+
const r = rule;
|
|
89
|
+
if (!Array.isArray(r.criteria))
|
|
90
|
+
continue;
|
|
91
|
+
for (const crit of r.criteria) {
|
|
92
|
+
if (!crit || typeof crit !== 'object')
|
|
93
|
+
continue;
|
|
94
|
+
const c = crit;
|
|
95
|
+
if (typeof c.operator !== 'string')
|
|
96
|
+
continue;
|
|
97
|
+
if (!QUONFIG_SUPPORTED_OPERATORS.has(c.operator)) {
|
|
98
|
+
found.push(c.operator);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
function validateOperators(out) {
|
|
105
|
+
const offenders = new Set();
|
|
106
|
+
const defaultSection = out.default;
|
|
107
|
+
if (defaultSection) {
|
|
108
|
+
for (const op of collectUnsupportedOperators(defaultSection.rules))
|
|
109
|
+
offenders.add(op);
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(out.environments)) {
|
|
112
|
+
for (const env of out.environments) {
|
|
113
|
+
for (const op of collectUnsupportedOperators(env.rules))
|
|
114
|
+
offenders.add(op);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (offenders.size === 0)
|
|
118
|
+
return;
|
|
119
|
+
const key = String(out.key ?? 'unknown');
|
|
120
|
+
const sorted = [...offenders].sort();
|
|
121
|
+
throw new InvalidSourceConfigError(`Unsupported operator(s) for "${key}": ${sorted.join(', ')}. ` +
|
|
122
|
+
`Quonfig does not currently translate these from the source schema. ` +
|
|
123
|
+
`Recreate the rule in the source system using a supported operator (see qfg config-schema), ` +
|
|
124
|
+
`or contact support@quonfig.com if you need this operator added to Quonfig.`);
|
|
125
|
+
}
|
|
26
126
|
function zeroValue(valueType) {
|
|
27
127
|
switch (valueType) {
|
|
28
128
|
case 'bool': {
|
|
@@ -148,6 +248,11 @@ function normalizeJsonValuesInConfig(out) {
|
|
|
148
248
|
}
|
|
149
249
|
export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentinel) {
|
|
150
250
|
const out = JSON.parse(JSON.stringify(config));
|
|
251
|
+
// qfg-qhk1: normalize `/` to `.` in the key so the JSON `key` field matches
|
|
252
|
+
// the flat dotted filename the migrator will write.
|
|
253
|
+
if (typeof out.key === 'string') {
|
|
254
|
+
out.key = normalizeKey(out.key);
|
|
255
|
+
}
|
|
151
256
|
delete out.changedBy;
|
|
152
257
|
delete out.modifiedAt;
|
|
153
258
|
delete out.createdAt;
|
|
@@ -244,25 +349,27 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
|
|
|
244
349
|
}
|
|
245
350
|
}
|
|
246
351
|
normalizeJsonValuesInConfig(out);
|
|
352
|
+
validateOperators(out);
|
|
247
353
|
return out;
|
|
248
354
|
}
|
|
249
355
|
export function getOutputPath(type, key) {
|
|
250
356
|
const normalizedType = type.toUpperCase();
|
|
357
|
+
const k = normalizeKey(key);
|
|
251
358
|
switch (normalizedType) {
|
|
252
359
|
case 'FEATURE_FLAG': {
|
|
253
|
-
return `feature-flags/${
|
|
360
|
+
return `feature-flags/${k}.json`;
|
|
254
361
|
}
|
|
255
362
|
case 'LOG_LEVEL_V2': {
|
|
256
|
-
return `log-levels/${normalizeLogLevelKey(
|
|
363
|
+
return `log-levels/${normalizeLogLevelKey(k)}.json`;
|
|
257
364
|
}
|
|
258
365
|
case 'SCHEMA': {
|
|
259
|
-
return `schemas/${
|
|
366
|
+
return `schemas/${k}.json`;
|
|
260
367
|
}
|
|
261
368
|
case 'SEGMENT': {
|
|
262
|
-
return `segments/${
|
|
369
|
+
return `segments/${k}.json`;
|
|
263
370
|
}
|
|
264
371
|
default: {
|
|
265
|
-
return `configs/${
|
|
372
|
+
return `configs/${k}.json`;
|
|
266
373
|
}
|
|
267
374
|
}
|
|
268
375
|
}
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -42,6 +42,23 @@ export interface GitOps {
|
|
|
42
42
|
getRemoteOriginUrl(dir: string): Promise<string | undefined>;
|
|
43
43
|
/** Returns true if the dir has a `.git/` (worktree or repo). */
|
|
44
44
|
isGitRepo(dir: string): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Returns true if origin/main has commits the local HEAD does not have
|
|
47
|
+
* (local strictly behind, or diverged). False when local is up-to-date
|
|
48
|
+
* or strictly ahead of origin/main, or when origin/main is unknown.
|
|
49
|
+
*
|
|
50
|
+
* The clone-path stale-HEAD guard (qfg-fboj) refuses to push in either
|
|
51
|
+
* "behind" or "diverged" state because both produce a diff that ships
|
|
52
|
+
* REVERSAL deltas to the server, silently undoing remote-newer commits.
|
|
53
|
+
*/
|
|
54
|
+
isLocalBehindRemote(dir: string): Promise<boolean>;
|
|
55
|
+
/**
|
|
56
|
+
* Tracked files (relative paths) with working-tree or staged changes.
|
|
57
|
+
* Used by the clone-path dirty-tree warning to surface uncommitted
|
|
58
|
+
* edits the user may believe are being pushed (qfg-fboj). Untracked
|
|
59
|
+
* files are excluded — same rule as `dirtyTrackedFiles` in git-ops.ts.
|
|
60
|
+
*/
|
|
61
|
+
dirtyTrackedFiles(dir: string): Promise<string[]>;
|
|
45
62
|
/** Set origin to `url` (add if missing, set-url if present). */
|
|
46
63
|
setRemoteOrigin(dir: string, url: string): Promise<void>;
|
|
47
64
|
}
|
package/dist/push/run-push.js
CHANGED
|
@@ -136,6 +136,15 @@ export async function runPush(input, deps) {
|
|
|
136
136
|
await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
|
|
137
137
|
log('Fetching from remote...');
|
|
138
138
|
await deps.gitOps.fetch(input.dir);
|
|
139
|
+
// Guard 4 (qfg-fboj): refuse to push when local HEAD is behind or has
|
|
140
|
+
// diverged from origin/main. Without this guard the HEAD-vs-origin
|
|
141
|
+
// diff produces REVERSAL deltas that silently undo whatever was
|
|
142
|
+
// committed to the cloud since the user last pulled. Tell the user
|
|
143
|
+
// to run `qfg pull` first; the standalone `--ff-only` step keeps
|
|
144
|
+
// push from doing surprising merges on the user's behalf.
|
|
145
|
+
if (await deps.gitOps.isLocalBehindRemote(input.dir)) {
|
|
146
|
+
throw new PushFatalError('Local HEAD is behind origin/main. Run `qfg pull` first to merge remote changes, then re-run `qfg push`.', 'STALE_HEAD');
|
|
147
|
+
}
|
|
139
148
|
// Migration cleanup (qfg-0fn): older `qfg pull` runs wrote the
|
|
140
149
|
// workspace pin to the working tree without committing. The next
|
|
141
150
|
// diff would miss it. Sweep up that legacy state IF the only dirty
|
|
@@ -153,6 +162,27 @@ export async function runPush(input, deps) {
|
|
|
153
162
|
catch {
|
|
154
163
|
/* non-fatal */
|
|
155
164
|
}
|
|
165
|
+
// Guard 5 (qfg-fboj): warn loudly about any tracked file with
|
|
166
|
+
// uncommitted edits beyond the workspace pin. The clone path pushes
|
|
167
|
+
// HEAD content, not the working tree, so these edits are silently
|
|
168
|
+
// dropped from the push. Listing them in the log gives the user a
|
|
169
|
+
// chance to ctrl-C, commit, and retry instead of believing they
|
|
170
|
+
// shipped what's on disk. Untracked files are intentionally not
|
|
171
|
+
// included — same rule as dirtyTrackedFiles in git-ops.ts.
|
|
172
|
+
try {
|
|
173
|
+
const dirty = await deps.gitOps.dirtyTrackedFiles(input.dir);
|
|
174
|
+
const noisyDirty = dirty.filter((f) => f !== 'quonfig.json');
|
|
175
|
+
if (noisyDirty.length > 0) {
|
|
176
|
+
log('');
|
|
177
|
+
log('Warning: working tree has uncommitted changes that will NOT be included in this push:');
|
|
178
|
+
for (const f of noisyDirty)
|
|
179
|
+
log(` ${f}`);
|
|
180
|
+
log('Commit them with `git add` + `git commit` and re-run `qfg push` if you meant to ship them.');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* non-fatal */
|
|
185
|
+
}
|
|
156
186
|
}
|
|
157
187
|
const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
|
|
158
188
|
const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
|
|
@@ -13,8 +13,12 @@ export interface CloneAndStackPushOptions {
|
|
|
13
13
|
author?: PushIdentity;
|
|
14
14
|
/** Branch to track and push. Defaults to `main`. */
|
|
15
15
|
branch?: string;
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Commit message for the delta. Pass a function when the message depends on
|
|
18
|
+
* counts that are only known after `applyDelta` runs (qfg-7eig). The function
|
|
19
|
+
* is invoked after applyDelta but before commit.
|
|
20
|
+
*/
|
|
21
|
+
commitMessage: string | (() => string);
|
|
18
22
|
/** Where to clone into, or an existing clone to reuse. */
|
|
19
23
|
localDir: string;
|
|
20
24
|
/** Gitea repo URL (may include token). */
|
|
@@ -62,7 +62,13 @@ const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
|
|
|
62
62
|
await runGit(localDir, ['merge', '--ff-only', `origin/${branch}`]);
|
|
63
63
|
return 'reused';
|
|
64
64
|
}
|
|
65
|
-
throw new Error(`Local dir ${localDir} is a git repo but its origin (${origin ? redactToken(origin) : 'unset'}) does not match the target remote
|
|
65
|
+
throw new Error(`Local dir ${localDir} is a git repo but its origin (${origin ? redactToken(origin) : 'unset'}) does not match the target remote.\n` +
|
|
66
|
+
`Refuse to clobber.\n\n` +
|
|
67
|
+
`Most likely cause: you ran \`qfg migrate --dir ${localDir}\` first (which \`git init\`s a fresh repo with no remote) and are now trying to \`--push\` from the same dir.\n\n` +
|
|
68
|
+
`Pick one of:\n` +
|
|
69
|
+
` - Re-run with \`--push\` against a NEW empty/non-existent path (the canonical flow).\n` +
|
|
70
|
+
` - Run \`qfg pull --workspace <org/slug> --dir ${localDir}-clone\` to seed a fresh clone, then \`qfg migrate --dir ${localDir}-clone --workspace <org/slug> --push\`.\n` +
|
|
71
|
+
`Docs: https://docs.quonfig.com/docs/migrating/from-launch (Flow B).`);
|
|
66
72
|
}
|
|
67
73
|
if (fs.existsSync(localDir)) {
|
|
68
74
|
const entries = fs.readdirSync(localDir);
|
|
@@ -90,8 +96,9 @@ export const cloneAndStackPush = async (opts) => {
|
|
|
90
96
|
if (!(await hasStagedChanges(opts.localDir))) {
|
|
91
97
|
return { action, committed: false, commitSha: null };
|
|
92
98
|
}
|
|
99
|
+
const commitMessage = typeof opts.commitMessage === 'function' ? opts.commitMessage() : opts.commitMessage;
|
|
93
100
|
await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
|
|
94
|
-
stdin:
|
|
101
|
+
stdin: commitMessage,
|
|
95
102
|
env: {
|
|
96
103
|
GIT_AUTHOR_NAME: author.name,
|
|
97
104
|
GIT_AUTHOR_EMAIL: author.email,
|
package/dist/util/git-ops.d.ts
CHANGED
|
@@ -101,6 +101,20 @@ export declare const gitFetch: (dir: string) => Promise<void>;
|
|
|
101
101
|
* Returns true if origin/main has commits that can be fast-forwarded into the local branch.
|
|
102
102
|
*/
|
|
103
103
|
export declare const canFastForward: (dir: string) => Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* Returns true iff origin/main is NOT an ancestor of the local HEAD —
|
|
106
|
+
* covering both "local strictly behind" and "diverged" in one boolean.
|
|
107
|
+
*
|
|
108
|
+
* Used by the clone-path stale-HEAD guard in `qfg push` (qfg-fboj):
|
|
109
|
+
* either of those two states would otherwise produce a `HEAD..origin/main`
|
|
110
|
+
* diff that ships REVERSAL deltas to the server, silently undoing
|
|
111
|
+
* remote-newer commits. Both must refuse.
|
|
112
|
+
*
|
|
113
|
+
* Returns false on any git failure (no `origin/main` ref yet, no `.git/`,
|
|
114
|
+
* etc.) so the caller falls through to its other guards rather than
|
|
115
|
+
* aborting on an opaque error.
|
|
116
|
+
*/
|
|
117
|
+
export declare const isLocalBehindOrDivergedFromRemote: (dir: string) => Promise<boolean>;
|
|
104
118
|
/**
|
|
105
119
|
* Returns true if local has commits not reachable from origin/main (diverged).
|
|
106
120
|
*/
|
|
@@ -120,3 +134,29 @@ export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
|
|
|
120
134
|
* Get the URL stripped of credentials for display purposes.
|
|
121
135
|
*/
|
|
122
136
|
export declare const displayUrl: (url: string) => string;
|
|
137
|
+
/**
|
|
138
|
+
* Outcome of `gitPullRebase`. Three branches the caller MUST distinguish
|
|
139
|
+
* — silent failure here is what made qfg-4tey a P1 (qfg pull exit 0 with
|
|
140
|
+
* no recovery path).
|
|
141
|
+
*/
|
|
142
|
+
export type GitPullRebaseResult = {
|
|
143
|
+
kind: 'clean';
|
|
144
|
+
commitsRebased: number;
|
|
145
|
+
} | {
|
|
146
|
+
kind: 'conflicts';
|
|
147
|
+
conflictedFiles: string[];
|
|
148
|
+
} | {
|
|
149
|
+
kind: 'failed';
|
|
150
|
+
reason: string;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* `git pull --rebase origin main`. Replays local commits on top of the
|
|
154
|
+
* remote tip. On conflicts, leaves the repo in rebase-in-progress state
|
|
155
|
+
* with `<<<<<<<` / `=======` / `>>>>>>>` markers planted by git so the
|
|
156
|
+
* user can resolve via standard git tools (`git rebase --continue` /
|
|
157
|
+
* `git rebase --abort`).
|
|
158
|
+
*
|
|
159
|
+
* Caller is responsible for surfacing recovery instructions; this function
|
|
160
|
+
* only reports the outcome.
|
|
161
|
+
*/
|
|
162
|
+
export declare const gitPullRebase: (dir: string) => Promise<GitPullRebaseResult>;
|