@quonfig/cli 0.0.32 → 0.0.33

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 CHANGED
@@ -14,7 +14,7 @@ $ npm install -g @quonfig/cli
14
14
  $ qfg COMMAND
15
15
  running command...
16
16
  $ qfg (--version)
17
- @quonfig/cli/0.0.32 linux-x64 node-v24.4.1
17
+ @quonfig/cli/0.0.33 linux-x64 node-v24.4.1
18
18
  $ qfg --help [COMMAND]
19
19
  USAGE
20
20
  $ qfg COMMAND
@@ -91,7 +91,7 @@ EXAMPLES
91
91
  $ qfg config-schema --json-schema # full JSON Schema document (copy into your editor)
92
92
  ```
93
93
 
94
- _See code: [src/commands/config-schema.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/config-schema.ts)_
94
+ _See code: [src/commands/config-schema.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/config-schema.ts)_
95
95
 
96
96
  ## `qfg create NAME`
97
97
 
@@ -176,7 +176,7 @@ EXAMPLES
176
176
  $ qfg set-rollout my.new.flag --environment production --true-percent 20
177
177
  ```
178
178
 
179
- _See code: [src/commands/create.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/create.ts)_
179
+ _See code: [src/commands/create.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/create.ts)_
180
180
 
181
181
  ## `qfg delete NAME`
182
182
 
@@ -222,7 +222,7 @@ EXAMPLES
222
222
  $ qfg delete my.config --yes
223
223
  ```
224
224
 
225
- _See code: [src/commands/delete.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/delete.ts)_
225
+ _See code: [src/commands/delete.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/delete.ts)_
226
226
 
227
227
  ## `qfg flag info [NAME]`
228
228
 
@@ -425,7 +425,7 @@ EXAMPLES
425
425
  $ qfg generate --targets node-ts -o ./dist # combine with targets
426
426
  ```
427
427
 
428
- _See code: [src/commands/generate.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/generate.ts)_
428
+ _See code: [src/commands/generate.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/generate.ts)_
429
429
 
430
430
  ## `qfg generate-new-hex-key`
431
431
 
@@ -448,7 +448,7 @@ EXAMPLES
448
448
  $ qfg generate-new-hex-key
449
449
  ```
450
450
 
451
- _See code: [src/commands/generate-new-hex-key.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/generate-new-hex-key.ts)_
451
+ _See code: [src/commands/generate-new-hex-key.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/generate-new-hex-key.ts)_
452
452
 
453
453
  ## `qfg get [NAME]`
454
454
 
@@ -480,7 +480,7 @@ EXAMPLES
480
480
  $ qfg get my.config.name --environment=production
481
481
  ```
482
482
 
483
- _See code: [src/commands/get.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/get.ts)_
483
+ _See code: [src/commands/get.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/get.ts)_
484
484
 
485
485
  ## `qfg info [NAME]`
486
486
 
@@ -528,7 +528,7 @@ EXAMPLES
528
528
  $ qfg info my.config.name --exclude-evaluations # skip 24h stats
529
529
  ```
530
530
 
531
- _See code: [src/commands/info.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/info.ts)_
531
+ _See code: [src/commands/info.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/info.ts)_
532
532
 
533
533
  ## `qfg init [DIRECTORY]`
534
534
 
@@ -568,7 +568,7 @@ EXAMPLES
568
568
  $ qfg init --dry-run
569
569
  ```
570
570
 
571
- _See code: [src/commands/init.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/init.ts)_
571
+ _See code: [src/commands/init.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/init.ts)_
572
572
 
573
573
  ## `qfg interactive`
574
574
 
@@ -598,7 +598,7 @@ EXAMPLES
598
598
  $ qfg
599
599
  ```
600
600
 
601
- _See code: [src/commands/interactive.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/interactive.ts)_
601
+ _See code: [src/commands/interactive.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/interactive.ts)_
602
602
 
603
603
  ## `qfg list`
604
604
 
@@ -638,7 +638,7 @@ EXAMPLES
638
638
  $ qfg list --feature-flags
639
639
  ```
640
640
 
641
- _See code: [src/commands/list.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/list.ts)_
641
+ _See code: [src/commands/list.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/list.ts)_
642
642
 
643
643
  ## `qfg log-level NAME`
644
644
 
@@ -703,7 +703,7 @@ EXAMPLES
703
703
  $ qfg log-level log-level.my-app --target=Chatty --value=INFO --environment=production
704
704
  ```
705
705
 
706
- _See code: [src/commands/log-level.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/log-level.ts)_
706
+ _See code: [src/commands/log-level.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/log-level.ts)_
707
707
 
708
708
  ## `qfg login`
709
709
 
@@ -728,7 +728,7 @@ EXAMPLES
728
728
  $ qfg login --profile myprofile
729
729
  ```
730
730
 
731
- _See code: [src/commands/login.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/login.ts)_
731
+ _See code: [src/commands/login.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/login.ts)_
732
732
 
733
733
  ## `qfg logout`
734
734
 
@@ -751,7 +751,7 @@ EXAMPLES
751
751
  $ qfg logout
752
752
  ```
753
753
 
754
- _See code: [src/commands/logout.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/logout.ts)_
754
+ _See code: [src/commands/logout.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/logout.ts)_
755
755
 
756
756
  ## `qfg migrate`
757
757
 
@@ -805,7 +805,7 @@ EXAMPLES
805
805
  $ qfg migrate --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-repo --staging
806
806
  ```
807
807
 
808
- _See code: [src/commands/migrate.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/migrate.ts)_
808
+ _See code: [src/commands/migrate.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/migrate.ts)_
809
809
 
810
810
  ## `qfg migrate doctor`
811
811
 
@@ -843,7 +843,7 @@ EXAMPLES
843
843
  $ qfg migrate doctor --json
844
844
  ```
845
845
 
846
- _See code: [src/commands/migrate/doctor.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/migrate/doctor.ts)_
846
+ _See code: [src/commands/migrate/doctor.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/migrate/doctor.ts)_
847
847
 
848
848
  ## `qfg migrate my-code`
849
849
 
@@ -875,7 +875,7 @@ EXAMPLES
875
875
  $ qfg migrate my-code --dry-run
876
876
  ```
877
877
 
878
- _See code: [src/commands/migrate/my-code.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/migrate/my-code.ts)_
878
+ _See code: [src/commands/migrate/my-code.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/migrate/my-code.ts)_
879
879
 
880
880
  ## `qfg migrate status`
881
881
 
@@ -906,7 +906,7 @@ EXAMPLES
906
906
  $ qfg migrate status --json
907
907
  ```
908
908
 
909
- _See code: [src/commands/migrate/status.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/migrate/status.ts)_
909
+ _See code: [src/commands/migrate/status.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/migrate/status.ts)_
910
910
 
911
911
  ## `qfg override [NAME] [VALUE]`
912
912
 
@@ -961,7 +961,7 @@ EXAMPLES
961
961
  $ qfg override my.flag true --env=staging
962
962
  ```
963
963
 
964
- _See code: [src/commands/override.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/override.ts)_
964
+ _See code: [src/commands/override.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/override.ts)_
965
965
 
966
966
  ## `qfg pull`
967
967
 
@@ -1005,7 +1005,7 @@ EXAMPLES
1005
1005
  $ qfg pull # uses QUONFIG_DIR env var
1006
1006
  ```
1007
1007
 
1008
- _See code: [src/commands/pull.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/pull.ts)_
1008
+ _See code: [src/commands/pull.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/pull.ts)_
1009
1009
 
1010
1010
  ## `qfg push`
1011
1011
 
@@ -1053,7 +1053,7 @@ EXAMPLES
1053
1053
  $ qfg push --dir ./our-config --yes
1054
1054
  ```
1055
1055
 
1056
- _See code: [src/commands/push.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/push.ts)_
1056
+ _See code: [src/commands/push.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/push.ts)_
1057
1057
 
1058
1058
  ## `qfg schema NAME`
1059
1059
 
@@ -1091,7 +1091,7 @@ EXAMPLES
1091
1091
  $ qfg schema my-schema --set-json-schema=@schemas/my-schema.json --protected
1092
1092
  ```
1093
1093
 
1094
- _See code: [src/commands/schema.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/schema.ts)_
1094
+ _See code: [src/commands/schema.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/schema.ts)_
1095
1095
 
1096
1096
  ## `qfg sdk-key`
1097
1097
 
@@ -1118,7 +1118,7 @@ EXAMPLES
1118
1118
  $ qfg sdk-key revoke <key-id>
1119
1119
  ```
1120
1120
 
1121
- _See code: [src/commands/sdk-key.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/sdk-key.ts)_
1121
+ _See code: [src/commands/sdk-key.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/sdk-key.ts)_
1122
1122
 
1123
1123
  ## `qfg sdk-key create`
1124
1124
 
@@ -1150,7 +1150,7 @@ EXAMPLES
1150
1150
  $ qfg sdk-key create --environment staging --type browser
1151
1151
  ```
1152
1152
 
1153
- _See code: [src/commands/sdk-key/create.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/sdk-key/create.ts)_
1153
+ _See code: [src/commands/sdk-key/create.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/sdk-key/create.ts)_
1154
1154
 
1155
1155
  ## `qfg sdk-key list`
1156
1156
 
@@ -1179,7 +1179,7 @@ EXAMPLES
1179
1179
  $ qfg sdk-key list --environment production
1180
1180
  ```
1181
1181
 
1182
- _See code: [src/commands/sdk-key/list.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/sdk-key/list.ts)_
1182
+ _See code: [src/commands/sdk-key/list.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/sdk-key/list.ts)_
1183
1183
 
1184
1184
  ## `qfg sdk-key revoke KEYID`
1185
1185
 
@@ -1206,7 +1206,7 @@ EXAMPLES
1206
1206
  $ qfg sdk-key revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
1207
1207
  ```
1208
1208
 
1209
- _See code: [src/commands/sdk-key/revoke.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/sdk-key/revoke.ts)_
1209
+ _See code: [src/commands/sdk-key/revoke.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/sdk-key/revoke.ts)_
1210
1210
 
1211
1211
  ## `qfg set-default [NAME]`
1212
1212
 
@@ -1277,7 +1277,7 @@ EXAMPLES
1277
1277
  $ qfg pull && qfg config-schema
1278
1278
  ```
1279
1279
 
1280
- _See code: [src/commands/set-default.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/set-default.ts)_
1280
+ _See code: [src/commands/set-default.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/set-default.ts)_
1281
1281
 
1282
1282
  ## `qfg set-rollout [NAME]`
1283
1283
 
@@ -1354,7 +1354,7 @@ EXAMPLES
1354
1354
  $ qfg set-rollout my.variant.flag --environment production --weights "a:33,b:33,c:34" --hash-by user.id
1355
1355
  ```
1356
1356
 
1357
- _See code: [src/commands/set-rollout.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/set-rollout.ts)_
1357
+ _See code: [src/commands/set-rollout.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/set-rollout.ts)_
1358
1358
 
1359
1359
  ## `qfg sync`
1360
1360
 
@@ -1386,7 +1386,7 @@ EXAMPLES
1386
1386
  $ qfg sync --watch --dir ./our-config --interval 30
1387
1387
  ```
1388
1388
 
1389
- _See code: [src/commands/sync.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/sync.ts)_
1389
+ _See code: [src/commands/sync.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/sync.ts)_
1390
1390
 
1391
1391
  ## `qfg toggle [NAME]`
1392
1392
 
@@ -1488,7 +1488,7 @@ EXAMPLES
1488
1488
  $ qfg verify --json
1489
1489
  ```
1490
1490
 
1491
- _See code: [src/commands/verify.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/verify.ts)_
1491
+ _See code: [src/commands/verify.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/verify.ts)_
1492
1492
 
1493
1493
  ## `qfg whoami`
1494
1494
 
@@ -1511,7 +1511,7 @@ EXAMPLES
1511
1511
  $ qfg whoami
1512
1512
  ```
1513
1513
 
1514
- _See code: [src/commands/whoami.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/whoami.ts)_
1514
+ _See code: [src/commands/whoami.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/whoami.ts)_
1515
1515
 
1516
1516
  ## `qfg workspace`
1517
1517
 
@@ -1534,7 +1534,7 @@ EXAMPLES
1534
1534
  $ qfg workspace
1535
1535
  ```
1536
1536
 
1537
- _See code: [src/commands/workspace.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/workspace.ts)_
1537
+ _See code: [src/commands/workspace.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/workspace.ts)_
1538
1538
 
1539
1539
  ## `qfg workspace bootstrap`
1540
1540
 
@@ -1567,7 +1567,7 @@ EXAMPLES
1567
1567
  $ qfg workspace bootstrap --dir ./our-config --skip-validate
1568
1568
  ```
1569
1569
 
1570
- _See code: [src/commands/workspace/bootstrap.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/workspace/bootstrap.ts)_
1570
+ _See code: [src/commands/workspace/bootstrap.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/workspace/bootstrap.ts)_
1571
1571
 
1572
1572
  ## `qfg workspace create SLUG`
1573
1573
 
@@ -1601,7 +1601,7 @@ EXAMPLES
1601
1601
  $ qfg workspace create my-team --org 11111111-1111-1111-1111-111111111111
1602
1602
  ```
1603
1603
 
1604
- _See code: [src/commands/workspace/create.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/workspace/create.ts)_
1604
+ _See code: [src/commands/workspace/create.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/workspace/create.ts)_
1605
1605
 
1606
1606
  ## `qfg workspace switch [SLUG]`
1607
1607
 
@@ -1629,7 +1629,7 @@ EXAMPLES
1629
1629
  $ qfg workspace switch acme/production
1630
1630
  ```
1631
1631
 
1632
- _See code: [src/commands/workspace/switch.ts](https://github.com/quonfig/cli/blob/v0.0.32/src/commands/workspace/switch.ts)_
1632
+ _See code: [src/commands/workspace/switch.ts](https://github.com/quonfig/cli/blob/v0.0.33/src/commands/workspace/switch.ts)_
1633
1633
  <!-- commandsstop -->
1634
1634
 
1635
1635
  ## Local Development
@@ -4,8 +4,9 @@ import path from 'node:path';
4
4
  import { BaseCommand } from '../index.js';
5
5
  import { CrossSourceError, assertSourceMatches, readImportState } from '../migrate/import-state.js';
6
6
  import { applyLocalMigration } from '../migrate/local-write.js';
7
- import { PushConflictError } from '../util/clone-and-stack-push.js';
8
- import { pushMigrationToCloud } from '../migrate/push-to-cloud.js';
7
+ import { buildPushConflictSuggestion } from '../migrate/migrate-suggestion.js';
8
+ import { PushConflictError, PushHookRejectedError } from '../util/clone-and-stack-push.js';
9
+ import { MigratorVerifyError, pushMigrationToCloud } from '../migrate/push-to-cloud.js';
9
10
  import { UnknownSourceError, getSource } from '../migrate/registry.js';
10
11
  import { NotYetImplementedError, } from '../migrate/source.js';
11
12
  import { applyLaunchBaseUrl } from '../migrate/sources/launch/api.js';
@@ -171,8 +172,16 @@ export default class Migrate extends BaseCommand {
171
172
  };
172
173
  }
173
174
  catch (error) {
175
+ if (error instanceof PushHookRejectedError) {
176
+ return this.err(`${error.message}\n\nThe remote validation hook rejected the push. Fix the errors reported above (typically in the source system or by editing the local files in ${dir}), then re-run with --push. You can re-run \`qfg verify ${dir}\` locally to confirm the fixes before pushing.`);
177
+ }
174
178
  if (error instanceof PushConflictError) {
175
- return this.err(`${error.message}\n\nRe-run \`qfg migrate --from ${flags.from} --workspace ${workspaceId} --push\` to pick up remote changes before retrying.`);
179
+ return this.err(`${error.message}\n\n${buildPushConflictSuggestion({ from: flags.from, userWorkspaceFlag: flags.workspace })}`);
180
+ }
181
+ if (error instanceof MigratorVerifyError) {
182
+ return this.err(`${error.message}\n\nThe migrated workspace was written to ${dir} but was NOT pushed. ` +
183
+ `Fix the issues above (typically in the source system or by editing the local files), then re-run with --push. ` +
184
+ `You can re-run \`qfg verify ${dir}\` locally to confirm the fixes before pushing.`);
176
185
  }
177
186
  throw error;
178
187
  }
@@ -213,6 +222,7 @@ export default class Migrate extends BaseCommand {
213
222
  // detectDuplicateKeys). The source accumulator holds the data regardless.
214
223
  warnAboutDroppedOverrides(this, source.getDroppedOverrides?.() ?? null);
215
224
  warnAboutSkippedConfigs(this, source.getSkippedConfigs?.() ?? null);
225
+ warnAboutCoercedSentinels(this, source.getCoercedSentinels?.() ?? null);
216
226
  warnAboutDuplicateResolutions(this, duplicateResolutionsForWarn);
217
227
  }
218
228
  }
@@ -283,6 +293,21 @@ function warnAboutDuplicateResolutions(cmd, resolved) {
283
293
  }
284
294
  cmd.warn('Full detail is also written to MIGRATION_REPORT.md.');
285
295
  }
296
+ function warnAboutCoercedSentinels(cmd, coerced) {
297
+ if (!coerced || coerced.total === 0)
298
+ return;
299
+ const envIds = Object.keys(coerced.byEnv).sort();
300
+ cmd.warn(`Coerced ${coerced.total} sentinel rule value(s) from Launch's "no value set yet" sentinel ({type:"string", value:""}) to the typed default for the surrounding config. Without coercion the qfg-verify hook would reject these as type-mismatches and fail-stop the entire push:`);
301
+ for (const envId of envIds) {
302
+ const perFlag = coerced.byEnv[envId];
303
+ const totalForEnv = Object.values(perFlag).reduce((s, n) => s + n, 0);
304
+ cmd.warn(` env-${envId}: ${totalForEnv} coerced from ${Object.keys(perFlag).length} config(s)`);
305
+ for (const flagPath of Object.keys(perFlag).sort()) {
306
+ cmd.warn(` - ${flagPath} (${perFlag[flagPath]})`);
307
+ }
308
+ }
309
+ cmd.warn(`If you want a real default for these rules, set one in the source system and re-run the migration. Full detail is also written to MIGRATION_REPORT.md.`);
310
+ }
286
311
  function warnAboutSkippedConfigs(cmd, skipped) {
287
312
  if (!skipped || skipped.total === 0)
288
313
  return;
@@ -1,7 +1,7 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
3
  import { type PushIdentity } from '../util/clone-and-stack-push.js';
4
- import type { DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
4
+ import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
5
5
  export interface ApplyLocalMigrationOptions {
6
6
  /** Commit author. Defaults to the migrator identity. */
7
7
  author?: PushIdentity;
@@ -19,6 +19,8 @@ export interface ApplyLocalMigrationOptions {
19
19
  export interface ApplyLocalMigrationResult {
20
20
  /** `'initialized'` if we had to `git init` the target, `'reused'` if it was already a repo. */
21
21
  action: 'initialized' | 'reused';
22
+ /** Sentinel rule values coerced to typed defaults during translate. Null if none. */
23
+ coercedSentinels: CoercedSentinelSummary | null;
22
24
  commitSha: string | null;
23
25
  /** `false` if translate produced no net changes — nothing was committed. */
24
26
  committed: boolean;
@@ -85,9 +85,11 @@ export const applyLocalMigration = async (opts) => {
85
85
  writeImportState(opts.localDir, opts.importState);
86
86
  const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
87
87
  const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
88
+ const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
88
89
  const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
89
90
  const reportData = {
90
91
  ...opts.reportData,
92
+ ...(coercedSentinels ? { coercedSentinels } : {}),
91
93
  ...(droppedOverrides ? { droppedOverrides } : {}),
92
94
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
93
95
  ...(skippedConfigs ? { skippedConfigs } : {}),
@@ -95,7 +97,15 @@ export const applyLocalMigration = async (opts) => {
95
97
  writeMigrationReport(opts.localDir, reportData);
96
98
  await runGit(['-C', opts.localDir, 'add', '--all']);
97
99
  if (!(await hasStagedChanges(opts.localDir))) {
98
- return { action, committed: false, commitSha: null, droppedOverrides, duplicateResolutions, skippedConfigs };
100
+ return {
101
+ action,
102
+ coercedSentinels,
103
+ committed: false,
104
+ commitSha: null,
105
+ droppedOverrides,
106
+ duplicateResolutions,
107
+ skippedConfigs,
108
+ };
99
109
  }
100
110
  await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
101
111
  stdin: opts.commitMessage,
@@ -109,6 +119,7 @@ export const applyLocalMigration = async (opts) => {
109
119
  const { stdout: sha } = await runGit(['-C', opts.localDir, 'rev-parse', 'HEAD']);
110
120
  return {
111
121
  action,
122
+ coercedSentinels,
112
123
  commitSha: sha.trim(),
113
124
  committed: true,
114
125
  droppedOverrides,
@@ -0,0 +1,12 @@
1
+ export interface PushConflictSuggestionInput {
2
+ from: string;
3
+ /**
4
+ * The original `--workspace` flag string the user typed, e.g.
5
+ * `test-organization/semgrep-test-1`. Pass `undefined` if the user did not
6
+ * pass `--workspace` (and the command resolved the workspace via the saved
7
+ * profile). Never pass the resolved workspace UUID — the suggestion is meant
8
+ * to be a copy-paste of the user's original invocation.
9
+ */
10
+ userWorkspaceFlag: string | undefined;
11
+ }
12
+ export declare function buildPushConflictSuggestion({ from, userWorkspaceFlag }: PushConflictSuggestionInput): string;
@@ -0,0 +1,4 @@
1
+ export function buildPushConflictSuggestion({ from, userWorkspaceFlag }) {
2
+ const workspaceArg = userWorkspaceFlag ? ` --workspace ${userWorkspaceFlag}` : '';
3
+ return `Re-run \`qfg migrate --from ${from}${workspaceArg} --push\` to pick up remote changes before retrying.`;
4
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IdentifierMap } from './identifier-map.js';
2
- import type { DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
2
+ import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
3
3
  export interface MigrationReportCounts {
4
4
  environmentsMapped: number;
5
5
  flagsMigrated: number;
@@ -29,6 +29,12 @@ export interface FollowUpChecklist {
29
29
  }
30
30
  export interface MigrationReportData {
31
31
  cleanMappings: CleanMapping[];
32
+ /**
33
+ * Rule values that translate() coerced from a sentinel like Launch's
34
+ * empty-string "no value set yet" to the typed default. Null when nothing
35
+ * was coerced.
36
+ */
37
+ coercedSentinels?: CoercedSentinelSummary | null;
32
38
  counts: MigrationReportCounts;
33
39
  /**
34
40
  * Override sections that were dropped during translate() because the env.id was not
@@ -87,6 +87,27 @@ const renderDuplicateResolutions = (resolved) => {
87
87
  }
88
88
  return lines.join('\n');
89
89
  };
90
+ const renderCoercedSentinels = (coerced) => {
91
+ if (!coerced || coerced.total === 0)
92
+ return null;
93
+ const envIds = Object.keys(coerced.byEnv).sort();
94
+ const lines = [
95
+ '## Coerced sentinel rule values',
96
+ '',
97
+ `Coerced **${coerced.total}** rule value(s) from Launch's "no value set yet" sentinel ({type:"string", value:""}) to the typed default for the surrounding config. The qfg-verify hook would otherwise reject these as type-mismatches and fail-stop the entire push. Affected envs: **${envIds.length}**. Review each and set a real default in the source system if needed.`,
98
+ ];
99
+ for (const envId of envIds) {
100
+ const perFlag = coerced.byEnv[envId];
101
+ const totalForEnv = Object.values(perFlag).reduce((s, n) => s + n, 0);
102
+ const flagCount = Object.keys(perFlag).length;
103
+ lines.push('', `### env-${envId} — ${totalForEnv} coerced from ${flagCount} config(s)`, '');
104
+ const sorted = Object.keys(perFlag).sort();
105
+ for (const flagPath of sorted) {
106
+ lines.push(`- \`${flagPath}\` (${perFlag[flagPath]})`);
107
+ }
108
+ }
109
+ return lines.join('\n');
110
+ };
90
111
  const renderSkippedConfigs = (skipped) => {
91
112
  if (!skipped || skipped.total === 0)
92
113
  return null;
@@ -136,6 +157,9 @@ export const buildMigrationReport = (data) => {
136
157
  const skipped = renderSkippedConfigs(data.skippedConfigs);
137
158
  if (skipped !== null)
138
159
  sections.push(skipped);
160
+ const coerced = renderCoercedSentinels(data.coercedSentinels);
161
+ if (coerced !== null)
162
+ sections.push(coerced);
139
163
  sections.push(renderFollowUp(data.followUp));
140
164
  return sections.join('\n\n') + '\n';
141
165
  };
@@ -1,7 +1,19 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
3
  import { type CloneAndStackPushResult } from '../util/clone-and-stack-push.js';
4
- import type { DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
4
+ import { type ValidationResult } from '../verify/validate.js';
5
+ import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
6
+ /**
7
+ * Thrown when the migrator's freshly-written workspace fails the same checks
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
11
+ * round-trip is rejected (qfg-52qg).
12
+ */
13
+ export declare class MigratorVerifyError extends Error {
14
+ readonly result: ValidationResult;
15
+ constructor(result: ValidationResult);
16
+ }
5
17
  export interface PushMigrationToCloudOptions {
6
18
  branch?: string;
7
19
  changes: LegacyChange[];
@@ -19,6 +31,7 @@ export interface PushMigrationToCloudOptions {
19
31
  source: MigrationSource;
20
32
  }
21
33
  export interface PushMigrationToCloudResult extends CloneAndStackPushResult {
34
+ coercedSentinels: CoercedSentinelSummary | null;
22
35
  droppedOverrides: DroppedOverrideSummary | null;
23
36
  duplicateResolutions: DuplicateResolutionSummary | null;
24
37
  skippedConfigs: SkippedConfigSummary | null;
@@ -4,6 +4,25 @@ 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
6
  import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
7
+ import { formatResult, validateWorkspace } from '../verify/validate.js';
8
+ /**
9
+ * Thrown when the migrator's freshly-written workspace fails the same checks
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
13
+ * round-trip is rejected (qfg-52qg).
14
+ */
15
+ export class MigratorVerifyError extends Error {
16
+ result;
17
+ constructor(result) {
18
+ const errorCount = result.issues.filter((i) => i.severity === 'error').length;
19
+ super(`Migrator output failed qfg verify: ${errorCount} error(s) in ${result.filesChecked} file(s). ` +
20
+ `Refusing to push — the same errors would be rejected by the server pre-receive hook.\n\n` +
21
+ formatResult(result));
22
+ this.name = 'MigratorVerifyError';
23
+ this.result = result;
24
+ }
25
+ }
7
26
  const writeQuonfigFiles = (dir, changes, source) => {
8
27
  const livePaths = new Map();
9
28
  for (const change of changes) {
@@ -65,6 +84,7 @@ const mergeEnvironmentsIntoQuonfigJson = (dir, sourceEnvs) => {
65
84
  fs.writeFileSync(quonfigPath, JSON.stringify({ ...existing, environments: merged }, null, 2) + '\n', 'utf8');
66
85
  };
67
86
  export const pushMigrationToCloud = async (opts) => {
87
+ let coercedSentinels = null;
68
88
  let droppedOverrides = null;
69
89
  let duplicateResolutions = null;
70
90
  let skippedConfigs = null;
@@ -78,15 +98,24 @@ export const pushMigrationToCloud = async (opts) => {
78
98
  writeImportState(dir, opts.importState);
79
99
  droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
80
100
  skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
101
+ coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
81
102
  duplicateResolutions =
82
103
  resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
83
104
  const reportData = {
84
105
  ...opts.reportData,
106
+ ...(coercedSentinels ? { coercedSentinels } : {}),
85
107
  ...(droppedOverrides ? { droppedOverrides } : {}),
86
108
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
87
109
  ...(skippedConfigs ? { skippedConfigs } : {}),
88
110
  };
89
111
  writeMigrationReport(dir, reportData);
112
+ // Pre-flight: run the same validation the Gitea pre-receive `qfg-verify`
113
+ // hook will run after the push lands. Failing here saves a full
114
+ // git-push round-trip and gives the customer a local, fixable error
115
+ // (qfg-52qg).
116
+ const verifyResult = validateWorkspace(dir);
117
+ if (!verifyResult.valid)
118
+ throw new MigratorVerifyError(verifyResult);
90
119
  },
91
120
  commitMessage: opts.commitMessage,
92
121
  localDir: opts.localDir,
@@ -95,5 +124,5 @@ export const pushMigrationToCloud = async (opts) => {
95
124
  if (opts.branch !== undefined)
96
125
  cloneOpts.branch = opts.branch;
97
126
  const result = await cloneAndStackPush(cloneOpts);
98
- return { ...result, droppedOverrides, duplicateResolutions, skippedConfigs };
127
+ return { ...result, coercedSentinels, droppedOverrides, duplicateResolutions, skippedConfigs };
99
128
  };
@@ -17,6 +17,19 @@ export interface DroppedOverrideSummary {
17
17
  /** Total override sections dropped across every env + flag. */
18
18
  total: number;
19
19
  }
20
+ /**
21
+ * qfg-gpnd: Per-config tally of rule values that were coerced from a Launch
22
+ * "no value set yet" sentinel ({type:'string', value:''}) to the typed default
23
+ * because the surrounding config valueType was non-string. Tracking is per
24
+ * envId (or 'default' for the default section) so customers can see which
25
+ * environments were affected.
26
+ */
27
+ export interface CoercedSentinelSummary {
28
+ /** Per-envId → per-output-path count of coerced sentinel rule values. */
29
+ byEnv: Record<string, Record<string, number>>;
30
+ /** Total rule values coerced across every env + config. */
31
+ total: number;
32
+ }
20
33
  export interface SkippedConfigEntry {
21
34
  /** Source key (pre-translate) that was skipped. */
22
35
  key: string;
@@ -45,6 +58,12 @@ export interface DuplicateResolutionSummary {
45
58
  }
46
59
  export interface MigrationSource {
47
60
  fetchChanges(sinceEpochMs: null | number): AsyncIterable<LegacyChange>;
61
+ /**
62
+ * Optional post-translate accumulator. Returns any rule values that translate()
63
+ * coerced from a sentinel like Launch's empty-string "no value set yet" to the
64
+ * typed default. Null when nothing was coerced.
65
+ */
66
+ getCoercedSentinels?(): CoercedSentinelSummary | null;
48
67
  /**
49
68
  * Optional post-translate accumulator. Returns any override sections that were
50
69
  * dropped during translate() calls because the env.id was not present in the
@@ -11,7 +11,7 @@ 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
- export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void): Record<string, unknown>;
14
+ export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void, onCoercedSentinel?: (envId: string, valueType: string) => void): Record<string, unknown>;
15
15
  export declare function getOutputPath(type: string, key: string): string;
16
16
  /**
17
17
  * Walks the set of written files looking for cross-type collisions (the same
@@ -102,6 +102,30 @@ function normalizeRules(rules, configKey, path) {
102
102
  }
103
103
  }
104
104
  }
105
+ // qfg-gpnd: Launch emits {type:'string', value:''} as a "no value set yet"
106
+ // sentinel even when the surrounding config valueType is non-string (most
107
+ // commonly seen on catch-all/ALWAYS_TRUE rules). The qfg-verify hook rejects
108
+ // these as type-mismatches and fail-stops the entire push. Coerce the sentinel
109
+ // to the typed default so the otherwise-valid config still ships, and surface
110
+ // each coercion via the callback so the migrator can report it.
111
+ function coerceSentinelRules(rules, valueType, onCoerced) {
112
+ if (!Array.isArray(rules))
113
+ return;
114
+ if (valueType === 'string')
115
+ return;
116
+ for (const rule of rules) {
117
+ if (!rule || typeof rule !== 'object')
118
+ continue;
119
+ const r = rule;
120
+ const v = r.value;
121
+ if (!v || typeof v !== 'object')
122
+ continue;
123
+ if (v.type === 'string' && v.value === '') {
124
+ r.value = zeroValue(valueType);
125
+ onCoerced();
126
+ }
127
+ }
128
+ }
105
129
  function normalizeJsonValuesInConfig(out) {
106
130
  const configKey = String(out.key ?? 'unknown');
107
131
  const defaultSection = out.default;
@@ -122,7 +146,7 @@ function normalizeJsonValuesInConfig(out) {
122
146
  }
123
147
  }
124
148
  }
125
- export function transformConfig(config, envIdMap, onDroppedEnv) {
149
+ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentinel) {
126
150
  const out = JSON.parse(JSON.stringify(config));
127
151
  delete out.changedBy;
128
152
  delete out.modifiedAt;
@@ -137,6 +161,13 @@ export function transformConfig(config, envIdMap, onDroppedEnv) {
137
161
  if (typeof out.valueType === 'string' && out.valueType.toUpperCase() === 'LOG_LEVEL_V2') {
138
162
  out.valueType = 'log_level';
139
163
  }
164
+ // qfg-ol8y: Launch passes through `valueType: 'not_set_value_type'` for
165
+ // segments where the field was never explicitly set. qfg-verify rejects
166
+ // that since segments must have valueType 'bool'. Segments in qfg are
167
+ // unconditionally boolean ("is the user in the segment?"), so force-set.
168
+ if (typeof out.type === 'string' && out.type.toLowerCase() === 'segment') {
169
+ out.valueType = 'bool';
170
+ }
140
171
  if (out.type === 'log_level' && typeof out.key === 'string') {
141
172
  out.key = normalizeLogLevelKey(out.key);
142
173
  }
@@ -199,6 +230,19 @@ export function transformConfig(config, envIdMap, onDroppedEnv) {
199
230
  ],
200
231
  };
201
232
  }
233
+ if (typeof out.valueType === 'string') {
234
+ const valueType = out.valueType;
235
+ const defaultSection = out.default;
236
+ if (defaultSection && Array.isArray(defaultSection.rules)) {
237
+ coerceSentinelRules(defaultSection.rules, valueType, () => onCoercedSentinel?.('default', valueType));
238
+ }
239
+ if (Array.isArray(out.environments)) {
240
+ for (const env of out.environments) {
241
+ const envId = typeof env.id === 'string' ? env.id : 'unknown';
242
+ coerceSentinelRules(env.rules, valueType, () => onCoercedSentinel?.(envId, valueType));
243
+ }
244
+ }
245
+ }
202
246
  normalizeJsonValuesInConfig(out);
203
247
  return out;
204
248
  }
@@ -1,7 +1,13 @@
1
1
  import { fetchAllChangeHistory, fetchEnvironments } from './launch/api.js';
2
2
  import { InvalidSourceConfigError, getOutputPath, isLegacyLogLevel, slugify, transformConfig, } from './launch/translate.js';
3
3
  const SOURCE_NAME = 'launch';
4
- const state = { apiKey: null, droppedOverrides: new Map(), envIdMap: null, skippedConfigs: [] };
4
+ const state = {
5
+ apiKey: null,
6
+ coercedSentinels: new Map(),
7
+ droppedOverrides: new Map(),
8
+ envIdMap: null,
9
+ skippedConfigs: [],
10
+ };
5
11
  class MissingAuthError extends Error {
6
12
  constructor(operation) {
7
13
  super(`launch source ${operation} requires validateAuth(apiKey) to be called first (no API key configured).`);
@@ -58,6 +64,13 @@ function translateImpl(change) {
58
64
  state.droppedOverrides.set(envId, perFlag);
59
65
  }
60
66
  perFlag.set(outputPath, (perFlag.get(outputPath) ?? 0) + 1);
67
+ }, (envId) => {
68
+ let perFlag = state.coercedSentinels.get(envId);
69
+ if (!perFlag) {
70
+ perFlag = new Map();
71
+ state.coercedSentinels.set(envId, perFlag);
72
+ }
73
+ perFlag.set(outputPath, (perFlag.get(outputPath) ?? 0) + 1);
61
74
  });
62
75
  }
63
76
  catch (error) {
@@ -85,6 +98,22 @@ function getDroppedOverridesImpl() {
85
98
  }
86
99
  return { byEnv, total };
87
100
  }
101
+ function getCoercedSentinelsImpl() {
102
+ if (state.coercedSentinels.size === 0)
103
+ return null;
104
+ const byEnv = {};
105
+ let total = 0;
106
+ for (const envId of [...state.coercedSentinels.keys()].sort()) {
107
+ const perFlag = state.coercedSentinels.get(envId);
108
+ const record = {};
109
+ for (const [flagPath, count] of [...perFlag.entries()].sort(([a], [b]) => a.localeCompare(b))) {
110
+ record[flagPath] = count;
111
+ total += count;
112
+ }
113
+ byEnv[envId] = record;
114
+ }
115
+ return { byEnv, total };
116
+ }
88
117
  function getSkippedConfigsImpl() {
89
118
  if (state.skippedConfigs.length === 0)
90
119
  return null;
@@ -95,11 +124,13 @@ async function validateAuthImpl(apiKey) {
95
124
  state.apiKey = apiKey;
96
125
  state.droppedOverrides = new Map();
97
126
  state.skippedConfigs = [];
127
+ state.coercedSentinels = new Map();
98
128
  }
99
129
  export const launchSource = {
100
130
  fetchChanges(sinceEpochMs) {
101
131
  return fetchChangesImpl(sinceEpochMs);
102
132
  },
133
+ getCoercedSentinels: getCoercedSentinelsImpl,
103
134
  getDroppedOverrides: getDroppedOverridesImpl,
104
135
  getSkippedConfigs: getSkippedConfigsImpl,
105
136
  listEnvironments: listEnvironmentsImpl,
@@ -112,4 +143,5 @@ export function __resetLaunchSourceForTests() {
112
143
  state.envIdMap = null;
113
144
  state.droppedOverrides = new Map();
114
145
  state.skippedConfigs = [];
146
+ state.coercedSentinels = new Map();
115
147
  }
@@ -31,4 +31,7 @@ export interface CloneAndStackPushResult {
31
31
  export declare class PushConflictError extends Error {
32
32
  constructor(message: string);
33
33
  }
34
+ export declare class PushHookRejectedError extends Error {
35
+ constructor(message: string);
36
+ }
34
37
  export declare const cloneAndStackPush: (opts: CloneAndStackPushOptions) => Promise<CloneAndStackPushResult>;
@@ -12,6 +12,12 @@ export class PushConflictError extends Error {
12
12
  this.name = 'PushConflictError';
13
13
  }
14
14
  }
15
+ export class PushHookRejectedError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'PushHookRejectedError';
19
+ }
20
+ }
15
21
  const runGit = async (cwd, args) => {
16
22
  try {
17
23
  return await runGitSafe(args, { cwd });
@@ -103,8 +109,15 @@ export const cloneAndStackPush = async (opts) => {
103
109
  const e = error;
104
110
  const stderr = e.stderr ?? '';
105
111
  const combined = `${e.message}\n${stderr}`;
112
+ const detail = redactToken(stderr || e.message);
113
+ // Pre-receive / update hook rejections are functionally distinct from fast-forward
114
+ // conflicts: nothing the user can do by re-fetching will help. Route them to a
115
+ // different lead-in so the hook output (e.g. qfg-verify FAILED ...) is the call to action.
116
+ if (/hook declined|\[remote rejected]/i.test(combined)) {
117
+ throw new PushHookRejectedError(`Push to origin/${branch} was rejected by the remote validation hook. Fix the errors below and push again. Original git error:\n${detail}`);
118
+ }
106
119
  if (/non-fast-forward|fetch first|rejected/i.test(combined)) {
107
- throw new PushConflictError(`Push to origin/${branch} was rejected: the remote has changes we do not have locally. Re-run the migrator to pick up those changes, then push again. Original git error:\n${redactToken(stderr || e.message)}`);
120
+ throw new PushConflictError(`Push to origin/${branch} was rejected: the remote has changes we do not have locally. Re-run the migrator to pick up those changes, then push again. Original git error:\n${detail}`);
108
121
  }
109
122
  throw error;
110
123
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.32";
1
+ declare const _default: "0.0.33";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.0.32';
2
+ export default '0.0.33';
@@ -2910,5 +2910,5 @@
2910
2910
  ]
2911
2911
  }
2912
2912
  },
2913
- "version": "0.0.32"
2913
+ "version": "0.0.33"
2914
2914
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.32",
4
+ "version": "0.0.33",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },