@quonfig/cli 0.0.45 → 0.0.47

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.
@@ -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 # uses QUONFIG_DIR + profile workspace
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 dir = flags.dir || process.env.QUONFIG_DIR;
58
- if (!dir) {
59
- return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
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 = path.resolve(dir);
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
  };
@@ -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 `applyDelta` writes files but before commit + push,
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 `applyDelta` writes files but before commit + push,
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
- let coercedSentinels = null;
47
- let droppedOverrides = null;
48
- let duplicateResolutions = null;
49
- let skippedConfigs = null;
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
- applyDelta(dir) {
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 { ...result, coercedSentinels, droppedOverrides, duplicateResolutions, skippedConfigs };
122
+ return {
123
+ ...result,
124
+ coercedSentinels: acc.coercedSentinels,
125
+ droppedOverrides: acc.droppedOverrides,
126
+ duplicateResolutions: acc.duplicateResolutions,
127
+ skippedConfigs: acc.skippedConfigs,
128
+ };
93
129
  };
@@ -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
@@ -82,6 +82,26 @@ function translateImpl(change) {
82
82
  }
83
83
  return [{ contents: JSON.stringify(transformed, null, 2), path: outputPath }];
84
84
  }
85
+ function getCommitMetaImpl(change) {
86
+ const raw = change.raw;
87
+ if (!raw || typeof raw !== 'object')
88
+ return null;
89
+ if (!raw.changedBy || typeof raw.changedAt !== 'number')
90
+ return null;
91
+ const email = raw.changedBy.email;
92
+ const name = raw.changedBy.fullName ?? raw.changedBy.email;
93
+ // qfg-wbkj: empirically a large fraction of Launch history entries carry no
94
+ // human-authored summary. Fall back to a stable, key-aware message instead
95
+ // of an empty commit body so `git log feature-flags/<key>.json` is still
96
+ // readable when scrolled through.
97
+ const trimmed = raw.summary?.trim();
98
+ const message = trimmed && trimmed.length > 0 ? raw.summary : buildFallbackMessage(raw);
99
+ return { author: { email, name }, date: raw.changedAt, message };
100
+ }
101
+ function buildFallbackMessage(raw) {
102
+ const verb = raw.deleted ? 'delete' : raw.previousConfigId === undefined ? 'create' : 'update';
103
+ return `migrator: ${verb} ${raw.key}`;
104
+ }
85
105
  function getDroppedOverridesImpl() {
86
106
  if (state.droppedOverrides.size === 0)
87
107
  return null;
@@ -131,6 +151,7 @@ export const launchSource = {
131
151
  return fetchChangesImpl(sinceEpochMs);
132
152
  },
133
153
  getCoercedSentinels: getCoercedSentinelsImpl,
154
+ getCommitMeta: getCommitMetaImpl,
134
155
  getDroppedOverrides: getDroppedOverridesImpl,
135
156
  getSkippedConfigs: getSkippedConfigsImpl,
136
157
  listEnvironments: listEnvironmentsImpl,
@@ -14,5 +14,19 @@
14
14
  * The auth header is a WorkOS access token (or a `qf_uk_*` API key when
15
15
  * `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
16
16
  */
17
- import type { ConfigPushInput, ConfigPushResult } from './run-push.js';
17
+ import type { ConfigPushInput, ConfigPushResult, GitPushInput, GitPushResult } from './run-push.js';
18
18
  export declare const callConfigsPush: (input: ConfigPushInput, orgSlug: string) => Promise<ConfigPushResult>;
19
+ /**
20
+ * Pack-push wire client for `configs.gitPush` (qfg-7429.4). Mirrors
21
+ * `callConfigsPush` shape — same oRPC `{json: ...}` envelope, same
22
+ * status-to-tagged-union mapping. Two differences:
23
+ *
24
+ * - The pack is base64-encoded so the JSON envelope stays text-safe;
25
+ * the server-side handler (`git-push-handler.ts`) decodes it via
26
+ * `Buffer.from(input.pack, 'base64')`.
27
+ * - 403 denials carry `{commitSha, path, reason, requiredPermission}`
28
+ * plus an optional `suggestedRecovery` block for the GitHub-fork
29
+ * dead-end UX (see §6 of the design plan and qfg-7429.5 for the
30
+ * polished renderer).
31
+ */
32
+ export declare const callConfigsGitPush: (input: GitPushInput, orgSlug: string) => Promise<GitPushResult>;