@quonfig/cli 0.0.44 → 0.0.46
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 +54 -49
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.js +25 -1
- package/dist/commands/pull.js +45 -12
- package/dist/commands/push.js +86 -9
- package/dist/init/schema.d.ts +1 -1
- package/dist/init/schema.js +1 -1
- package/dist/init/templates.js +5 -5
- package/dist/migrate/local-write.d.ts +61 -1
- package/dist/migrate/local-write.js +134 -1
- package/dist/migrate/push-to-cloud.d.ts +11 -2
- package/dist/migrate/push-to-cloud.js +78 -42
- package/dist/migrate/source.d.ts +24 -0
- package/dist/migrate/sources/launch.js +21 -0
- package/dist/push/config-push-client.d.ts +15 -1
- package/dist/push/config-push-client.js +102 -0
- package/dist/push/git-pack.d.ts +70 -0
- package/dist/push/git-pack.js +127 -0
- package/dist/push/identity-check.d.ts +27 -6
- package/dist/push/identity-check.js +74 -19
- package/dist/push/run-push.d.ts +133 -1
- package/dist/push/run-push.js +183 -46
- package/dist/util/clone-and-stack-push.d.ts +40 -9
- package/dist/util/clone-and-stack-push.js +59 -20
- package/dist/util/git-ops.d.ts +9 -0
- package/dist/util/git-ops.js +38 -0
- package/dist/util/resolve-workspace-dir.d.ts +44 -0
- package/dist/util/resolve-workspace-dir.js +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +165 -159
- package/package.json +3 -3
package/dist/commands/push.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
2
|
import { Flags } from '@oclif/core';
|
|
4
3
|
import { BaseCommand } from '../index.js';
|
|
5
4
|
import { mintGiteaToken } from '../util/gitea-api.js';
|
|
6
|
-
import { dirtyTrackedFiles, getOriginMainSha, getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, isLocalBehindOrDivergedFromRemote, runGit, } from '../util/git-ops.js';
|
|
5
|
+
import { dirtyTrackedFiles, getAllRemoteUrls, getOriginMainSha, getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, isLocalBehindOrDivergedFromRemote, runGit, } from '../util/git-ops.js';
|
|
7
6
|
import { PushFatalError, runPush, } from '../push/run-push.js';
|
|
8
7
|
import { computeBarePathDiff } from '../push/bare-path-diff.js';
|
|
9
8
|
import { computeClonePathDiff } from '../push/clone-path-diff.js';
|
|
10
|
-
import { callConfigsPush } from '../push/config-push-client.js';
|
|
9
|
+
import { callConfigsGitPush, callConfigsPush } from '../push/config-push-client.js';
|
|
10
|
+
import { resolveWorkspaceDir } from '../util/resolve-workspace-dir.js';
|
|
11
11
|
import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
|
|
12
12
|
export default class Push extends BaseCommand {
|
|
13
13
|
static description = `Push local config changes up to your workspace on Quonfig cloud.
|
|
@@ -18,7 +18,7 @@ Enforces three guards before touching the remote:
|
|
|
18
18
|
3. A diff summary is shown; destructive changes (10+ deletes, >=25% of
|
|
19
19
|
files, or an unpinned dir) require typing the workspace slug to confirm.
|
|
20
20
|
|
|
21
|
-
qfg push #
|
|
21
|
+
qfg push # resolves dir from cwd, QUONFIG_DIR, or --dir
|
|
22
22
|
qfg push --dir ./our-config
|
|
23
23
|
qfg push --dir ./our-config --workspace acme-prod
|
|
24
24
|
qfg push --dir ./our-config --yes # skip normal Y/N (never skips typed-slug)
|
|
@@ -30,7 +30,7 @@ Enforces three guards before touching the remote:
|
|
|
30
30
|
];
|
|
31
31
|
static flags = {
|
|
32
32
|
dir: Flags.string({
|
|
33
|
-
description: 'Local directory to push (defaults to QUONFIG_DIR env var)',
|
|
33
|
+
description: 'Local directory to push (defaults to cwd / nearest ancestor with quonfig.json / QUONFIG_DIR env var)',
|
|
34
34
|
}),
|
|
35
35
|
workspace: Flags.string({
|
|
36
36
|
description: 'Workspace slug or UUID (defaults to active profile)',
|
|
@@ -54,11 +54,15 @@ Enforces three guards before touching the remote:
|
|
|
54
54
|
};
|
|
55
55
|
async run() {
|
|
56
56
|
const { flags } = await this.parse(Push);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const resolved = resolveWorkspaceDir({
|
|
58
|
+
flagDir: flags.dir,
|
|
59
|
+
envDir: process.env.QUONFIG_DIR,
|
|
60
|
+
cwd: process.cwd(),
|
|
61
|
+
});
|
|
62
|
+
if (resolved.kind === 'error') {
|
|
63
|
+
return this.err(resolved.message);
|
|
60
64
|
}
|
|
61
|
-
const resolvedDir =
|
|
65
|
+
const resolvedDir = resolved.dir;
|
|
62
66
|
// Resolve requested target. --workspace > QUONFIG_WORKSPACE > active OAuth
|
|
63
67
|
// profile. Supports API-key mode for headless runs so `qfg push` behaves
|
|
64
68
|
// the same way `qfg create` does (both resolve via resolve-workspace.ts).
|
|
@@ -166,6 +170,9 @@ export function buildRealDeps(cmd, orgSlug) {
|
|
|
166
170
|
const url = await getRemoteUrl(dir);
|
|
167
171
|
return url ?? undefined;
|
|
168
172
|
},
|
|
173
|
+
async getAllRemoteUrls(dir) {
|
|
174
|
+
return getAllRemoteUrls(dir);
|
|
175
|
+
},
|
|
169
176
|
async setRemoteOrigin(dir, url) {
|
|
170
177
|
await gitSetRemote(dir, url);
|
|
171
178
|
},
|
|
@@ -238,6 +245,75 @@ export function buildRealDeps(cmd, orgSlug) {
|
|
|
238
245
|
return;
|
|
239
246
|
return getOriginMainSha(dir);
|
|
240
247
|
},
|
|
248
|
+
// Pack-push (qfg-7429.4) — clone-path now ships actual git objects
|
|
249
|
+
// via `configs.gitPush`. All five hooks below are no-ops on bare
|
|
250
|
+
// path (the dispatch chooses configs.push instead) and only fire
|
|
251
|
+
// when the local dir is a clone whose origin matches the backend.
|
|
252
|
+
async getCurrentBranch(dir) {
|
|
253
|
+
const { getCurrentBranch } = await import('../push/git-pack.js');
|
|
254
|
+
return getCurrentBranch(dir);
|
|
255
|
+
},
|
|
256
|
+
async getHeadSha(dir) {
|
|
257
|
+
const { stdout } = await runGit(['-C', dir, 'rev-parse', 'HEAD']);
|
|
258
|
+
return stdout.trim();
|
|
259
|
+
},
|
|
260
|
+
async getRemoteBranchSha(dir, branchName) {
|
|
261
|
+
try {
|
|
262
|
+
const { stdout } = await runGit(['-C', dir, 'rev-parse', `origin/${branchName}`]);
|
|
263
|
+
const sha = stdout.trim();
|
|
264
|
+
return sha.length > 0 ? sha : undefined;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// No remote-tracking ref yet — brand-new branch. The pack-push
|
|
268
|
+
// dispatch interprets `undefined` as zero-OID on the wire.
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
async buildPack(dir, expectedSha, newSha) {
|
|
273
|
+
const { buildPack } = await import('../push/git-pack.js');
|
|
274
|
+
return buildPack(dir, expectedSha, newSha);
|
|
275
|
+
},
|
|
276
|
+
async countCommitsBetween(dir, expectedSha, newSha) {
|
|
277
|
+
if (expectedSha === newSha)
|
|
278
|
+
return 0;
|
|
279
|
+
const ZERO = '0000000000000000000000000000000000000000';
|
|
280
|
+
const range = expectedSha === ZERO ? newSha : `${expectedSha}..${newSha}`;
|
|
281
|
+
try {
|
|
282
|
+
const { stdout } = await runGit(['-C', dir, 'rev-list', '--count', range]);
|
|
283
|
+
return Number.parseInt(stdout.trim(), 10) || 0;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
async getCommitOneline(dir, sha) {
|
|
290
|
+
// qfg-7429.5: surface the offending commit's identity alongside
|
|
291
|
+
// the server's §6 recovery hint. Returns '' if the commit isn't
|
|
292
|
+
// present locally (best-effort — the recovery message is the
|
|
293
|
+
// load-bearing piece).
|
|
294
|
+
try {
|
|
295
|
+
const { stdout } = await runGit(['-C', dir, 'log', '-1', '--pretty=oneline', sha]);
|
|
296
|
+
return stdout.trim();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return '';
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
async getTreeShaForRef(dir, ref) {
|
|
303
|
+
// qfg-7429.6: pack-push conflict handler uses this to detect the
|
|
304
|
+
// legacy orphan-commit state — local commit SHA != origin's, but
|
|
305
|
+
// the underlying tree content matches. Return undefined on any
|
|
306
|
+
// git failure so the caller falls back to the "real conflict"
|
|
307
|
+
// message rather than aborting on an opaque rev-parse error.
|
|
308
|
+
try {
|
|
309
|
+
const { stdout } = await runGit(['-C', dir, 'rev-parse', `${ref}^{tree}`]);
|
|
310
|
+
const sha = stdout.trim();
|
|
311
|
+
return sha.length > 0 ? sha : undefined;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
241
317
|
};
|
|
242
318
|
const deps = {
|
|
243
319
|
async mintWriteToken(requestedTarget) {
|
|
@@ -257,6 +333,7 @@ export function buildRealDeps(cmd, orgSlug) {
|
|
|
257
333
|
},
|
|
258
334
|
gitOps,
|
|
259
335
|
pushToServer: (input) => callConfigsPush(input, orgSlug),
|
|
336
|
+
pushPackToServer: (input) => callConfigsGitPush(input, orgSlug),
|
|
260
337
|
log,
|
|
261
338
|
errLog,
|
|
262
339
|
};
|
package/dist/init/schema.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* JSON Schema for Quonfig StoredConfig files.
|
|
3
3
|
*
|
|
4
4
|
* This is the machine-readable schema for files in configs/, feature-flags/,
|
|
5
|
-
* segments/, and log-levels/. Served at api.quonfig.com/schemas/stored-config.json
|
|
5
|
+
* segments/, and log-levels/. Served at api.quonfig.com/schemas/v1/stored-config.json
|
|
6
6
|
* and printed by `qfg config-schema --json-schema`.
|
|
7
7
|
*
|
|
8
8
|
* Derived from the Zod schemas in verify/validate.ts — keep in sync.
|
package/dist/init/schema.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* JSON Schema for Quonfig StoredConfig files.
|
|
3
3
|
*
|
|
4
4
|
* This is the machine-readable schema for files in configs/, feature-flags/,
|
|
5
|
-
* segments/, and log-levels/. Served at api.quonfig.com/schemas/stored-config.json
|
|
5
|
+
* segments/, and log-levels/. Served at api.quonfig.com/schemas/v1/stored-config.json
|
|
6
6
|
* and printed by `qfg config-schema --json-schema`.
|
|
7
7
|
*
|
|
8
8
|
* Derived from the Zod schemas in verify/validate.ts — keep in sync.
|
package/dist/init/templates.js
CHANGED
|
@@ -40,7 +40,7 @@ qfg list
|
|
|
40
40
|
|
|
41
41
|
## Schema
|
|
42
42
|
|
|
43
|
-
Config files reference the hosted JSON Schema at <https://api.quonfig.com/schemas/stored-config.json> via their \`$schema\` field. Editors that support JSON Schema will provide autocomplete and validation automatically.
|
|
43
|
+
Config files reference the hosted JSON Schema at <https://api.quonfig.com/schemas/v1/stored-config.json> via their \`$schema\` field. Editors that support JSON Schema will provide autocomplete and validation automatically.
|
|
44
44
|
|
|
45
45
|
## Validation
|
|
46
46
|
|
|
@@ -59,12 +59,12 @@ This is a Quonfig workspace repository. Configuration is stored as JSON files in
|
|
|
59
59
|
|
|
60
60
|
## JSON Schema
|
|
61
61
|
|
|
62
|
-
The canonical schema for config files is hosted at <https://api.quonfig.com/schemas/stored-config.json>. Use it as the source of truth for field names, types, enums, and constraints.
|
|
62
|
+
The canonical schema for config files is hosted at <https://api.quonfig.com/schemas/v1/stored-config.json>. Use it as the source of truth for field names, types, enums, and constraints.
|
|
63
63
|
|
|
64
64
|
Config files can reference it for editor support:
|
|
65
65
|
|
|
66
66
|
\`\`\`json
|
|
67
|
-
{ "$schema": "https://api.quonfig.com/schemas/stored-config.json", "key": "my.config", ... }
|
|
67
|
+
{ "$schema": "https://api.quonfig.com/schemas/v1/stored-config.json", "key": "my.config", ... }
|
|
68
68
|
\`\`\`
|
|
69
69
|
|
|
70
70
|
## Directory Structure
|
|
@@ -84,7 +84,7 @@ Files must live in the directory matching their \`type\`. The filename (without
|
|
|
84
84
|
|
|
85
85
|
\`\`\`json
|
|
86
86
|
{
|
|
87
|
-
"$schema": "https://api.quonfig.com/schemas/stored-config.json",
|
|
87
|
+
"$schema": "https://api.quonfig.com/schemas/v1/stored-config.json",
|
|
88
88
|
"key": "my-app.timeout",
|
|
89
89
|
"type": "config",
|
|
90
90
|
"valueType": "int",
|
|
@@ -118,7 +118,7 @@ The \`default\` block (and each environment override) contains a \`rules\` array
|
|
|
118
118
|
| Segment | \`IN_SEG\`, \`NOT_IN_SEG\` | \`valueToMatch\` (segment key) |
|
|
119
119
|
| Other | \`IN_INT_RANGE\`, \`LOOKUP_KEY_IN\`, \`LOOKUP_KEY_NOT_IN\` | \`valueToMatch\` |
|
|
120
120
|
|
|
121
|
-
See <https://api.quonfig.com/schemas/stored-config.json> for the full operator enum and value type definitions.
|
|
121
|
+
See <https://api.quonfig.com/schemas/v1/stored-config.json> for the full operator enum and value type definitions.
|
|
122
122
|
|
|
123
123
|
## Environments
|
|
124
124
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ImportState } from './import-state.js';
|
|
2
2
|
import { type MigrationReportData } from './migration-report.js';
|
|
3
|
-
import { type PushIdentity } from '../util/clone-and-stack-push.js';
|
|
3
|
+
import { type CommitSpec, type PushIdentity } from '../util/clone-and-stack-push.js';
|
|
4
4
|
import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolution, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
|
|
5
5
|
export interface ApplyLocalMigrationOptions {
|
|
6
6
|
/** Commit author. Defaults to the migrator identity. */
|
|
@@ -10,6 +10,12 @@ export interface ApplyLocalMigrationOptions {
|
|
|
10
10
|
changes: LegacyChange[];
|
|
11
11
|
/** Environments discovered from the source (slugified). Written to `quonfig.json` if missing. */
|
|
12
12
|
environments: string[];
|
|
13
|
+
/**
|
|
14
|
+
* qfg-wbkj: when true, produce one git commit per change (audit-log mode) +
|
|
15
|
+
* one final state-file commit on top. Requires `source.getCommitMeta` to be
|
|
16
|
+
* defined. See pushMigrationToCloud's matching option.
|
|
17
|
+
*/
|
|
18
|
+
fullHistory?: boolean;
|
|
13
19
|
importState: ImportState;
|
|
14
20
|
localDir: string;
|
|
15
21
|
/**
|
|
@@ -48,4 +54,58 @@ interface WriteQuonfigFilesResult {
|
|
|
48
54
|
export declare const buildMigrationCounts: (livePaths: string[], environmentsMapped: number, itemsSkipped: number) => import("./migration-report.js").MigrationReportCounts;
|
|
49
55
|
export declare const buildMigrationCommitMessage: (source: string, counts: import("./migration-report.js").MigrationReportCounts) => string;
|
|
50
56
|
export declare const applyLocalMigration: (opts: ApplyLocalMigrationOptions) => Promise<ApplyLocalMigrationResult>;
|
|
57
|
+
/**
|
|
58
|
+
* qfg-wbkj: accumulator for source-level summaries that the final state-file
|
|
59
|
+
* commit collects from the source after all per-change commits have run.
|
|
60
|
+
* Used by both push-to-cloud and local-write to surface back to callers.
|
|
61
|
+
*/
|
|
62
|
+
export interface AuditAccumulator {
|
|
63
|
+
coercedSentinels: CoercedSentinelSummary | null;
|
|
64
|
+
droppedOverrides: DroppedOverrideSummary | null;
|
|
65
|
+
duplicateResolutions: DuplicateResolutionSummary | null;
|
|
66
|
+
skippedConfigs: SkippedConfigSummary | null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* qfg-wbkj: build one CommitSpec per change, each carrying the original
|
|
70
|
+
* source-side author/date/message when `getCommitMeta` returns metadata. Used
|
|
71
|
+
* by `--full-summary` from both the local-write and push-to-cloud paths.
|
|
72
|
+
*/
|
|
73
|
+
export declare const buildAuditPerChangeCommits: (changes: LegacyChange[], source: MigrationSource) => CommitSpec[];
|
|
74
|
+
export interface BuildAuditFinalCommitOptions {
|
|
75
|
+
changes: LegacyChange[];
|
|
76
|
+
environments: string[];
|
|
77
|
+
importState: ImportState;
|
|
78
|
+
/** Receives the accumulated summaries once the commit's apply() has run. */
|
|
79
|
+
onAccumulatorUpdate?: (acc: AuditAccumulator) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Optional hook to run AFTER writeImportState + writeMigrationReport but before
|
|
82
|
+
* the commit lands. Push-to-cloud uses this to run validateWorkspace (and to
|
|
83
|
+
* merge source-side environments into the existing quonfig.json). Throwing here
|
|
84
|
+
* aborts the commit cleanly.
|
|
85
|
+
*/
|
|
86
|
+
postWrite?: (dir: string) => Promise<void> | void;
|
|
87
|
+
/**
|
|
88
|
+
* Optional hook to run BEFORE bookkeeping (state file + report). Push-to-cloud
|
|
89
|
+
* uses this to merge source-side environments into the cloned quonfig.json.
|
|
90
|
+
*/
|
|
91
|
+
preWrite?: (dir: string) => Promise<void> | void;
|
|
92
|
+
reportData: MigrationReportData;
|
|
93
|
+
source: MigrationSource;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* qfg-wbkj: build the final state-file commit for audit-log mode. Writes
|
|
97
|
+
* `.qf/import-state.json` + `MIGRATION_REPORT.md` with cumulative counts read
|
|
98
|
+
* from disk, and surfaces the source's accumulated dropped/skipped/coerced
|
|
99
|
+
* summaries via `onAccumulatorUpdate`. The `preWrite` / `postWrite` hooks let
|
|
100
|
+
* push-to-cloud merge environments and run server-side-equivalent validation
|
|
101
|
+
* without local-write needing to know about either.
|
|
102
|
+
*/
|
|
103
|
+
export declare const buildAuditFinalCommit: (opts: BuildAuditFinalCommitOptions) => CommitSpec;
|
|
104
|
+
/**
|
|
105
|
+
* Walk the type-dir top-levels we care about, returning all `<type-dir>/*.json`
|
|
106
|
+
* paths on disk. Used to recompute cumulative counts in audit-log mode where
|
|
107
|
+
* `writeQuonfigFiles` runs per-change and its in-memory `livePaths` reflects
|
|
108
|
+
* only the last slice.
|
|
109
|
+
*/
|
|
110
|
+
export declare const collectLivePathsOnDisk: (dir: string) => string[];
|
|
51
111
|
export {};
|
|
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
4
|
import { writeMigrationReport } from './migration-report.js';
|
|
5
5
|
import { detectDuplicateKeys } from './sources/launch/translate.js';
|
|
6
|
-
import { MIGRATOR_IDENTITY } from '../util/clone-and-stack-push.js';
|
|
6
|
+
import { MIGRATOR_IDENTITY, stackCommits } from '../util/clone-and-stack-push.js';
|
|
7
7
|
import { runGit, spawnGit } from '../util/git-ops.js';
|
|
8
8
|
const isGitRepo = async (dir) => {
|
|
9
9
|
try {
|
|
@@ -170,9 +170,16 @@ export const buildMigrationCommitMessage = (source, counts) => {
|
|
|
170
170
|
export const applyLocalMigration = async (opts) => {
|
|
171
171
|
const branch = opts.branch ?? 'main';
|
|
172
172
|
const author = opts.author ?? MIGRATOR_IDENTITY;
|
|
173
|
+
if (opts.fullHistory && !opts.source.getCommitMeta) {
|
|
174
|
+
throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
|
|
175
|
+
`Drop --full-summary or use a source (e.g. launch) that supports it.`);
|
|
176
|
+
}
|
|
173
177
|
const action = await ensureLocalRepo(opts.localDir, branch);
|
|
174
178
|
await runGit(['-C', opts.localDir, 'config', 'user.name', author.name]);
|
|
175
179
|
await runGit(['-C', opts.localDir, 'config', 'user.email', author.email]);
|
|
180
|
+
if (opts.fullHistory) {
|
|
181
|
+
return runFullHistoryLocal(opts, action);
|
|
182
|
+
}
|
|
176
183
|
ensureQuonfigJson(opts.localDir, opts.environments);
|
|
177
184
|
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
|
|
178
185
|
removeQfFromGitignore(opts.localDir);
|
|
@@ -225,3 +232,129 @@ export const applyLocalMigration = async (opts) => {
|
|
|
225
232
|
skippedConfigs,
|
|
226
233
|
};
|
|
227
234
|
};
|
|
235
|
+
const runFullHistoryLocal = async (opts, action) => {
|
|
236
|
+
ensureQuonfigJson(opts.localDir, opts.environments);
|
|
237
|
+
const acc = {
|
|
238
|
+
coercedSentinels: null,
|
|
239
|
+
droppedOverrides: null,
|
|
240
|
+
duplicateResolutions: null,
|
|
241
|
+
skippedConfigs: null,
|
|
242
|
+
};
|
|
243
|
+
const commits = [
|
|
244
|
+
...buildAuditPerChangeCommits(opts.changes, opts.source),
|
|
245
|
+
buildAuditFinalCommit({
|
|
246
|
+
changes: opts.changes,
|
|
247
|
+
environments: opts.environments,
|
|
248
|
+
importState: opts.importState,
|
|
249
|
+
onAccumulatorUpdate: (a) => Object.assign(acc, a),
|
|
250
|
+
reportData: opts.reportData,
|
|
251
|
+
source: opts.source,
|
|
252
|
+
}),
|
|
253
|
+
];
|
|
254
|
+
const { commitShas } = await stackCommits(opts.localDir, commits);
|
|
255
|
+
return {
|
|
256
|
+
action,
|
|
257
|
+
coercedSentinels: acc.coercedSentinels,
|
|
258
|
+
commitSha: commitShas.at(-1) ?? null,
|
|
259
|
+
committed: commitShas.length > 0,
|
|
260
|
+
droppedOverrides: acc.droppedOverrides,
|
|
261
|
+
duplicateResolutions: acc.duplicateResolutions,
|
|
262
|
+
skippedConfigs: acc.skippedConfigs,
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* qfg-wbkj: build one CommitSpec per change, each carrying the original
|
|
267
|
+
* source-side author/date/message when `getCommitMeta` returns metadata. Used
|
|
268
|
+
* by `--full-summary` from both the local-write and push-to-cloud paths.
|
|
269
|
+
*/
|
|
270
|
+
export const buildAuditPerChangeCommits = (changes, source) => changes.map((change, index) => {
|
|
271
|
+
const meta = source.getCommitMeta?.(change) ?? null;
|
|
272
|
+
const apply = async (dir) => {
|
|
273
|
+
writeQuonfigFiles(dir, [change], source);
|
|
274
|
+
};
|
|
275
|
+
if (!meta) {
|
|
276
|
+
return {
|
|
277
|
+
apply,
|
|
278
|
+
author: MIGRATOR_IDENTITY,
|
|
279
|
+
message: `migrator: imported change ${index + 1} of ${changes.length} from ${source.name}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { apply, author: meta.author, authorDate: meta.date, message: meta.message };
|
|
283
|
+
});
|
|
284
|
+
/**
|
|
285
|
+
* qfg-wbkj: build the final state-file commit for audit-log mode. Writes
|
|
286
|
+
* `.qf/import-state.json` + `MIGRATION_REPORT.md` with cumulative counts read
|
|
287
|
+
* from disk, and surfaces the source's accumulated dropped/skipped/coerced
|
|
288
|
+
* summaries via `onAccumulatorUpdate`. The `preWrite` / `postWrite` hooks let
|
|
289
|
+
* push-to-cloud merge environments and run server-side-equivalent validation
|
|
290
|
+
* without local-write needing to know about either.
|
|
291
|
+
*/
|
|
292
|
+
export const buildAuditFinalCommit = (opts) => ({
|
|
293
|
+
async apply(dir) {
|
|
294
|
+
if (opts.preWrite)
|
|
295
|
+
await opts.preWrite(dir);
|
|
296
|
+
// qfg-wbkj follow-up: in audit-log mode, writeQuonfigFiles runs per-change
|
|
297
|
+
// and cannot detect cross-change duplicate keys (e.g. the same key existing
|
|
298
|
+
// as both a config and a feature_flag in source history). Resolve here over
|
|
299
|
+
// the cumulative on-disk tree — same kept/deleted semantics as the
|
|
300
|
+
// collapsed path — so validateWorkspace doesn't reject the push. The
|
|
301
|
+
// deletion lands in this final state-file commit under migrator identity,
|
|
302
|
+
// not in any audit commit.
|
|
303
|
+
const initialPaths = collectLivePathsOnDisk(dir);
|
|
304
|
+
const resolutionEntries = detectDuplicateKeys(initialPaths.map((p) => ({ path: p })));
|
|
305
|
+
for (const resolution of resolutionEntries) {
|
|
306
|
+
for (const toDelete of resolution.deleted) {
|
|
307
|
+
try {
|
|
308
|
+
fs.unlinkSync(path.join(dir, toDelete));
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error.code !== 'ENOENT')
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const livePaths = collectLivePathsOnDisk(dir);
|
|
317
|
+
const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
318
|
+
const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
319
|
+
const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
320
|
+
const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
321
|
+
removeQfFromGitignore(dir);
|
|
322
|
+
writeImportState(dir, opts.importState);
|
|
323
|
+
const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
324
|
+
const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
|
|
325
|
+
const reportData = {
|
|
326
|
+
...opts.reportData,
|
|
327
|
+
counts,
|
|
328
|
+
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
329
|
+
...(droppedOverrides ? { droppedOverrides } : {}),
|
|
330
|
+
...(duplicateResolutions ? { duplicateResolutions } : {}),
|
|
331
|
+
...(skippedConfigs ? { skippedConfigs } : {}),
|
|
332
|
+
};
|
|
333
|
+
writeMigrationReport(dir, reportData);
|
|
334
|
+
opts.onAccumulatorUpdate?.({ coercedSentinels, droppedOverrides, duplicateResolutions, skippedConfigs });
|
|
335
|
+
if (opts.postWrite)
|
|
336
|
+
await opts.postWrite(dir);
|
|
337
|
+
},
|
|
338
|
+
author: MIGRATOR_IDENTITY,
|
|
339
|
+
message: `migrator: imported ${opts.changes.length} change(s) from ${opts.source.name} (audit log)`,
|
|
340
|
+
});
|
|
341
|
+
/**
|
|
342
|
+
* Walk the type-dir top-levels we care about, returning all `<type-dir>/*.json`
|
|
343
|
+
* paths on disk. Used to recompute cumulative counts in audit-log mode where
|
|
344
|
+
* `writeQuonfigFiles` runs per-change and its in-memory `livePaths` reflects
|
|
345
|
+
* only the last slice.
|
|
346
|
+
*/
|
|
347
|
+
export const collectLivePathsOnDisk = (dir) => {
|
|
348
|
+
const prefixes = ['feature-flags', 'configs', 'segments', 'schemas', 'log-levels'];
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const prefix of prefixes) {
|
|
351
|
+
const typeDir = path.join(dir, prefix);
|
|
352
|
+
if (!fs.existsSync(typeDir))
|
|
353
|
+
continue;
|
|
354
|
+
for (const entry of fs.readdirSync(typeDir)) {
|
|
355
|
+
if (entry.endsWith('.json'))
|
|
356
|
+
out.push(`${prefix}/${entry}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
};
|
|
@@ -6,8 +6,8 @@ import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutio
|
|
|
6
6
|
/**
|
|
7
7
|
* Thrown when the migrator's freshly-written workspace fails the same checks
|
|
8
8
|
* that the Gitea pre-receive `qfg-verify` hook will run server-side. We raise
|
|
9
|
-
* this client-side after `
|
|
10
|
-
* so the customer sees the failure locally rather than after a full push
|
|
9
|
+
* this client-side after the commit's `apply` writes files but before commit +
|
|
10
|
+
* push, so the customer sees the failure locally rather than after a full push
|
|
11
11
|
* round-trip is rejected (qfg-52qg).
|
|
12
12
|
*/
|
|
13
13
|
export declare class MigratorVerifyError extends Error {
|
|
@@ -23,6 +23,15 @@ export interface PushMigrationToCloudOptions {
|
|
|
23
23
|
* cleanly. Existing target-only envs are preserved.
|
|
24
24
|
*/
|
|
25
25
|
environments?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* qfg-wbkj: when true, produce one git commit per change (audit-log mode) —
|
|
28
|
+
* each commit authored as the original Launch user with their `changedAt` as
|
|
29
|
+
* GIT_AUTHOR_DATE and `summary` as the message. One final state-file commit
|
|
30
|
+
* (migrator identity, now) lands on top carrying .qf/import-state.json +
|
|
31
|
+
* MIGRATION_REPORT.md so the audit-log commits aren't polluted by bookkeeping
|
|
32
|
+
* churn. Requires that `source.getCommitMeta` be defined.
|
|
33
|
+
*/
|
|
34
|
+
fullHistory?: boolean;
|
|
26
35
|
importState: ImportState;
|
|
27
36
|
localDir: string;
|
|
28
37
|
remoteUrl: string;
|
|
@@ -1,15 +1,15 @@
|
|
|
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
|
+
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
5
5
|
import { writeMigrationReport } from './migration-report.js';
|
|
6
6
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
7
7
|
import { formatResult, validateWorkspace } from '../verify/validate.js';
|
|
8
8
|
/**
|
|
9
9
|
* Thrown when the migrator's freshly-written workspace fails the same checks
|
|
10
10
|
* that the Gitea pre-receive `qfg-verify` hook will run server-side. We raise
|
|
11
|
-
* this client-side after `
|
|
12
|
-
* so the customer sees the failure locally rather than after a full push
|
|
11
|
+
* this client-side after the commit's `apply` writes files but before commit +
|
|
12
|
+
* push, so the customer sees the failure locally rather than after a full push
|
|
13
13
|
* round-trip is rejected (qfg-52qg).
|
|
14
14
|
*/
|
|
15
15
|
export class MigratorVerifyError extends Error {
|
|
@@ -42,52 +42,88 @@ const mergeEnvironmentsIntoQuonfigJson = (dir, sourceEnvs) => {
|
|
|
42
42
|
}
|
|
43
43
|
fs.writeFileSync(quonfigPath, JSON.stringify({ ...existing, environments: merged }, null, 2) + '\n', 'utf8');
|
|
44
44
|
};
|
|
45
|
+
const verifyOnDisk = (dir) => {
|
|
46
|
+
// qfg-52qg: client-side verify against the cumulative tree before commit
|
|
47
|
+
// lands. The Gitea pre-receive hook runs against the final HEAD; failing
|
|
48
|
+
// here matches what the server would reject and avoids a wasted push.
|
|
49
|
+
const verifyResult = validateWorkspace(dir);
|
|
50
|
+
if (!verifyResult.valid)
|
|
51
|
+
throw new MigratorVerifyError(verifyResult);
|
|
52
|
+
};
|
|
45
53
|
export const pushMigrationToCloud = async (opts) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
if (opts.fullHistory && !opts.source.getCommitMeta) {
|
|
55
|
+
throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
|
|
56
|
+
`Drop --full-summary or use a source (e.g. launch) that supports it.`);
|
|
57
|
+
}
|
|
58
|
+
const acc = {
|
|
59
|
+
coercedSentinels: null,
|
|
60
|
+
droppedOverrides: null,
|
|
61
|
+
duplicateResolutions: null,
|
|
62
|
+
skippedConfigs: null,
|
|
63
|
+
};
|
|
50
64
|
let computedCommitMessage = `migrator: imported 0 objects from ${opts.source.name}`;
|
|
65
|
+
const mergeEnvsIfPresent = (dir) => {
|
|
66
|
+
if (opts.environments && opts.environments.length > 0) {
|
|
67
|
+
mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const commits = opts.fullHistory
|
|
71
|
+
? [
|
|
72
|
+
...buildAuditPerChangeCommits(opts.changes, opts.source),
|
|
73
|
+
buildAuditFinalCommit({
|
|
74
|
+
changes: opts.changes,
|
|
75
|
+
environments: opts.environments ?? [],
|
|
76
|
+
importState: opts.importState,
|
|
77
|
+
onAccumulatorUpdate: (a) => Object.assign(acc, a),
|
|
78
|
+
postWrite: verifyOnDisk,
|
|
79
|
+
preWrite: mergeEnvsIfPresent,
|
|
80
|
+
reportData: opts.reportData,
|
|
81
|
+
source: opts.source,
|
|
82
|
+
}),
|
|
83
|
+
]
|
|
84
|
+
: [
|
|
85
|
+
{
|
|
86
|
+
message: () => computedCommitMessage,
|
|
87
|
+
async apply(dir) {
|
|
88
|
+
mergeEnvsIfPresent(dir);
|
|
89
|
+
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
90
|
+
removeQfFromGitignore(dir);
|
|
91
|
+
writeImportState(dir, opts.importState);
|
|
92
|
+
acc.droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
93
|
+
acc.skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
94
|
+
acc.coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
95
|
+
acc.duplicateResolutions =
|
|
96
|
+
resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
97
|
+
const skippedTotal = (acc.skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
98
|
+
const environmentsCount = opts.environments?.length ?? 0;
|
|
99
|
+
const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
|
|
100
|
+
computedCommitMessage = buildMigrationCommitMessage(opts.source.name, counts);
|
|
101
|
+
const reportData = {
|
|
102
|
+
...opts.reportData,
|
|
103
|
+
counts,
|
|
104
|
+
...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
|
|
105
|
+
...(acc.droppedOverrides ? { droppedOverrides: acc.droppedOverrides } : {}),
|
|
106
|
+
...(acc.duplicateResolutions ? { duplicateResolutions: acc.duplicateResolutions } : {}),
|
|
107
|
+
...(acc.skippedConfigs ? { skippedConfigs: acc.skippedConfigs } : {}),
|
|
108
|
+
};
|
|
109
|
+
writeMigrationReport(dir, reportData);
|
|
110
|
+
verifyOnDisk(dir);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
];
|
|
51
114
|
const cloneOpts = {
|
|
52
|
-
|
|
53
|
-
if (opts.environments && opts.environments.length > 0) {
|
|
54
|
-
mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
|
|
55
|
-
}
|
|
56
|
-
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
57
|
-
removeQfFromGitignore(dir);
|
|
58
|
-
writeImportState(dir, opts.importState);
|
|
59
|
-
droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
60
|
-
skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
61
|
-
coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
62
|
-
duplicateResolutions =
|
|
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);
|
|
68
|
-
const reportData = {
|
|
69
|
-
...opts.reportData,
|
|
70
|
-
counts,
|
|
71
|
-
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
72
|
-
...(droppedOverrides ? { droppedOverrides } : {}),
|
|
73
|
-
...(duplicateResolutions ? { duplicateResolutions } : {}),
|
|
74
|
-
...(skippedConfigs ? { skippedConfigs } : {}),
|
|
75
|
-
};
|
|
76
|
-
writeMigrationReport(dir, reportData);
|
|
77
|
-
// Pre-flight: run the same validation the Gitea pre-receive `qfg-verify`
|
|
78
|
-
// hook will run after the push lands. Failing here saves a full
|
|
79
|
-
// git-push round-trip and gives the customer a local, fixable error
|
|
80
|
-
// (qfg-52qg).
|
|
81
|
-
const verifyResult = validateWorkspace(dir);
|
|
82
|
-
if (!verifyResult.valid)
|
|
83
|
-
throw new MigratorVerifyError(verifyResult);
|
|
84
|
-
},
|
|
85
|
-
commitMessage: () => computedCommitMessage,
|
|
115
|
+
commits,
|
|
86
116
|
localDir: opts.localDir,
|
|
87
117
|
remoteUrl: opts.remoteUrl,
|
|
88
118
|
};
|
|
89
119
|
if (opts.branch !== undefined)
|
|
90
120
|
cloneOpts.branch = opts.branch;
|
|
91
121
|
const result = await cloneAndStackPush(cloneOpts);
|
|
92
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
...result,
|
|
124
|
+
coercedSentinels: acc.coercedSentinels,
|
|
125
|
+
droppedOverrides: acc.droppedOverrides,
|
|
126
|
+
duplicateResolutions: acc.duplicateResolutions,
|
|
127
|
+
skippedConfigs: acc.skippedConfigs,
|
|
128
|
+
};
|
|
93
129
|
};
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -56,6 +56,24 @@ export interface DuplicateResolutionSummary {
|
|
|
56
56
|
entries: DuplicateResolution[];
|
|
57
57
|
total: number;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* qfg-wbkj: Per-change commit metadata for --full-summary mode. Sources that
|
|
61
|
+
* carry author + timestamp + summary per change (e.g. Launch's change-history)
|
|
62
|
+
* implement getCommitMeta() so push-to-cloud can author one git commit per
|
|
63
|
+
* change with the original Launch user as author and `changedAt` as
|
|
64
|
+
* GIT_AUTHOR_DATE. Sources without this method are rejected at the command
|
|
65
|
+
* layer when --full-summary is passed.
|
|
66
|
+
*/
|
|
67
|
+
export interface CommitMeta {
|
|
68
|
+
author: {
|
|
69
|
+
email: string;
|
|
70
|
+
name: string;
|
|
71
|
+
};
|
|
72
|
+
/** Author date — any value `new Date(...)` accepts (Date, ISO string, or epoch ms). */
|
|
73
|
+
date: Date | number | string;
|
|
74
|
+
/** Non-empty commit message. Empty source-side summaries must be replaced with a fallback by the source. */
|
|
75
|
+
message: string;
|
|
76
|
+
}
|
|
59
77
|
export interface MigrationSource {
|
|
60
78
|
fetchChanges(sinceEpochMs: null | number): AsyncIterable<LegacyChange>;
|
|
61
79
|
/**
|
|
@@ -64,6 +82,12 @@ export interface MigrationSource {
|
|
|
64
82
|
* typed default. Null when nothing was coerced.
|
|
65
83
|
*/
|
|
66
84
|
getCoercedSentinels?(): CoercedSentinelSummary | null;
|
|
85
|
+
/**
|
|
86
|
+
* Optional. qfg-wbkj: per-change commit metadata used by --full-summary. Returns
|
|
87
|
+
* null if the change carries no usable author/date/summary (e.g. legacy or
|
|
88
|
+
* synthesized entries) and the caller should fall back to the migrator identity.
|
|
89
|
+
*/
|
|
90
|
+
getCommitMeta?(change: LegacyChange): CommitMeta | null;
|
|
67
91
|
/**
|
|
68
92
|
* Optional post-translate accumulator. Returns any override sections that were
|
|
69
93
|
* dropped during translate() calls because the env.id was not present in the
|