@quonfig/cli 0.0.22 → 0.0.23

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.22 linux-x64 node-v24.4.1
17
+ @quonfig/cli/0.0.23 linux-x64 node-v24.4.1
18
18
  $ qfg --help [COMMAND]
19
19
  USAGE
20
20
  $ qfg COMMAND
@@ -92,7 +92,7 @@ EXAMPLES
92
92
  $ qfg config-schema --json-schema # full JSON Schema document (copy into your editor)
93
93
  ```
94
94
 
95
- _See code: [src/commands/config-schema.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/config-schema.ts)_
95
+ _See code: [src/commands/config-schema.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/config-schema.ts)_
96
96
 
97
97
  ## `qfg create NAME`
98
98
 
@@ -177,7 +177,7 @@ EXAMPLES
177
177
  $ qfg set-rollout my.new.flag --environment production --true-percent 20
178
178
  ```
179
179
 
180
- _See code: [src/commands/create.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/create.ts)_
180
+ _See code: [src/commands/create.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/create.ts)_
181
181
 
182
182
  ## `qfg delete NAME`
183
183
 
@@ -223,7 +223,7 @@ EXAMPLES
223
223
  $ qfg delete my.config --yes
224
224
  ```
225
225
 
226
- _See code: [src/commands/delete.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/delete.ts)_
226
+ _See code: [src/commands/delete.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/delete.ts)_
227
227
 
228
228
  ## `qfg flag info [NAME]`
229
229
 
@@ -426,7 +426,7 @@ EXAMPLES
426
426
  $ qfg generate --targets node-ts -o ./dist # combine with targets
427
427
  ```
428
428
 
429
- _See code: [src/commands/generate.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/generate.ts)_
429
+ _See code: [src/commands/generate.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/generate.ts)_
430
430
 
431
431
  ## `qfg generate-new-hex-key`
432
432
 
@@ -449,7 +449,7 @@ EXAMPLES
449
449
  $ qfg generate-new-hex-key
450
450
  ```
451
451
 
452
- _See code: [src/commands/generate-new-hex-key.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/generate-new-hex-key.ts)_
452
+ _See code: [src/commands/generate-new-hex-key.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/generate-new-hex-key.ts)_
453
453
 
454
454
  ## `qfg get [NAME]`
455
455
 
@@ -481,7 +481,7 @@ EXAMPLES
481
481
  $ qfg get my.config.name --environment=production
482
482
  ```
483
483
 
484
- _See code: [src/commands/get.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/get.ts)_
484
+ _See code: [src/commands/get.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/get.ts)_
485
485
 
486
486
  ## `qfg info [NAME]`
487
487
 
@@ -529,7 +529,7 @@ EXAMPLES
529
529
  $ qfg info my.config.name --exclude-evaluations # skip 24h stats
530
530
  ```
531
531
 
532
- _See code: [src/commands/info.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/info.ts)_
532
+ _See code: [src/commands/info.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/info.ts)_
533
533
 
534
534
  ## `qfg init [DIRECTORY]`
535
535
 
@@ -569,7 +569,7 @@ EXAMPLES
569
569
  $ qfg init --dry-run
570
570
  ```
571
571
 
572
- _See code: [src/commands/init.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/init.ts)_
572
+ _See code: [src/commands/init.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/init.ts)_
573
573
 
574
574
  ## `qfg interactive`
575
575
 
@@ -599,7 +599,7 @@ EXAMPLES
599
599
  $ qfg
600
600
  ```
601
601
 
602
- _See code: [src/commands/interactive.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/interactive.ts)_
602
+ _See code: [src/commands/interactive.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/interactive.ts)_
603
603
 
604
604
  ## `qfg list`
605
605
 
@@ -639,7 +639,7 @@ EXAMPLES
639
639
  $ qfg list --feature-flags
640
640
  ```
641
641
 
642
- _See code: [src/commands/list.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/list.ts)_
642
+ _See code: [src/commands/list.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/list.ts)_
643
643
 
644
644
  ## `qfg log-level NAME`
645
645
 
@@ -704,7 +704,7 @@ EXAMPLES
704
704
  $ qfg log-level log-level.my-app --target=Chatty --value=INFO --environment=production
705
705
  ```
706
706
 
707
- _See code: [src/commands/log-level.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/log-level.ts)_
707
+ _See code: [src/commands/log-level.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/log-level.ts)_
708
708
 
709
709
  ## `qfg login`
710
710
 
@@ -729,7 +729,7 @@ EXAMPLES
729
729
  $ qfg login --profile myprofile
730
730
  ```
731
731
 
732
- _See code: [src/commands/login.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/login.ts)_
732
+ _See code: [src/commands/login.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/login.ts)_
733
733
 
734
734
  ## `qfg logout`
735
735
 
@@ -752,7 +752,7 @@ EXAMPLES
752
752
  $ qfg logout
753
753
  ```
754
754
 
755
- _See code: [src/commands/logout.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/logout.ts)_
755
+ _See code: [src/commands/logout.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/logout.ts)_
756
756
 
757
757
  ## `qfg mcp`
758
758
 
@@ -785,7 +785,7 @@ EXAMPLES
785
785
  $ qfg mcp --url http://local-app.quonfig-staging.com:3003/api/v1/mcp
786
786
  ```
787
787
 
788
- _See code: [src/commands/mcp.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/mcp.ts)_
788
+ _See code: [src/commands/mcp.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/mcp.ts)_
789
789
 
790
790
  ## `qfg migrate`
791
791
 
@@ -839,7 +839,7 @@ EXAMPLES
839
839
  $ qfg migrate --from launch --api-key $LAUNCH_API_KEY --dir ./quonfig-config --staging
840
840
  ```
841
841
 
842
- _See code: [src/commands/migrate.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/migrate.ts)_
842
+ _See code: [src/commands/migrate.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/migrate.ts)_
843
843
 
844
844
  ## `qfg migrate doctor`
845
845
 
@@ -877,7 +877,7 @@ EXAMPLES
877
877
  $ qfg migrate doctor --json
878
878
  ```
879
879
 
880
- _See code: [src/commands/migrate/doctor.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/migrate/doctor.ts)_
880
+ _See code: [src/commands/migrate/doctor.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/migrate/doctor.ts)_
881
881
 
882
882
  ## `qfg migrate my-code`
883
883
 
@@ -909,7 +909,7 @@ EXAMPLES
909
909
  $ qfg migrate my-code --dry-run
910
910
  ```
911
911
 
912
- _See code: [src/commands/migrate/my-code.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/migrate/my-code.ts)_
912
+ _See code: [src/commands/migrate/my-code.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/migrate/my-code.ts)_
913
913
 
914
914
  ## `qfg migrate status`
915
915
 
@@ -940,7 +940,7 @@ EXAMPLES
940
940
  $ qfg migrate status --json
941
941
  ```
942
942
 
943
- _See code: [src/commands/migrate/status.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/migrate/status.ts)_
943
+ _See code: [src/commands/migrate/status.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/migrate/status.ts)_
944
944
 
945
945
  ## `qfg override [NAME] [VALUE]`
946
946
 
@@ -995,7 +995,7 @@ EXAMPLES
995
995
  $ qfg override my.flag true --env=staging
996
996
  ```
997
997
 
998
- _See code: [src/commands/override.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/override.ts)_
998
+ _See code: [src/commands/override.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/override.ts)_
999
999
 
1000
1000
  ## `qfg pull`
1001
1001
 
@@ -1039,7 +1039,7 @@ EXAMPLES
1039
1039
  $ qfg pull # uses QUONFIG_DIR env var
1040
1040
  ```
1041
1041
 
1042
- _See code: [src/commands/pull.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/pull.ts)_
1042
+ _See code: [src/commands/pull.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/pull.ts)_
1043
1043
 
1044
1044
  ## `qfg push`
1045
1045
 
@@ -1087,7 +1087,7 @@ EXAMPLES
1087
1087
  $ qfg push --dir ./our-config --yes
1088
1088
  ```
1089
1089
 
1090
- _See code: [src/commands/push.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/push.ts)_
1090
+ _See code: [src/commands/push.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/push.ts)_
1091
1091
 
1092
1092
  ## `qfg schema NAME`
1093
1093
 
@@ -1125,7 +1125,7 @@ EXAMPLES
1125
1125
  $ qfg schema my-schema --set-json-schema=@schemas/my-schema.json --protected
1126
1126
  ```
1127
1127
 
1128
- _See code: [src/commands/schema.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/schema.ts)_
1128
+ _See code: [src/commands/schema.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/schema.ts)_
1129
1129
 
1130
1130
  ## `qfg sdk-key`
1131
1131
 
@@ -1152,7 +1152,7 @@ EXAMPLES
1152
1152
  $ qfg sdk-key revoke <key-id>
1153
1153
  ```
1154
1154
 
1155
- _See code: [src/commands/sdk-key.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/sdk-key.ts)_
1155
+ _See code: [src/commands/sdk-key.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/sdk-key.ts)_
1156
1156
 
1157
1157
  ## `qfg sdk-key create`
1158
1158
 
@@ -1184,7 +1184,7 @@ EXAMPLES
1184
1184
  $ qfg sdk-key create --environment staging --type browser
1185
1185
  ```
1186
1186
 
1187
- _See code: [src/commands/sdk-key/create.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/sdk-key/create.ts)_
1187
+ _See code: [src/commands/sdk-key/create.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/sdk-key/create.ts)_
1188
1188
 
1189
1189
  ## `qfg sdk-key list`
1190
1190
 
@@ -1213,7 +1213,7 @@ EXAMPLES
1213
1213
  $ qfg sdk-key list --environment production
1214
1214
  ```
1215
1215
 
1216
- _See code: [src/commands/sdk-key/list.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/sdk-key/list.ts)_
1216
+ _See code: [src/commands/sdk-key/list.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/sdk-key/list.ts)_
1217
1217
 
1218
1218
  ## `qfg sdk-key revoke KEYID`
1219
1219
 
@@ -1240,7 +1240,7 @@ EXAMPLES
1240
1240
  $ qfg sdk-key revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
1241
1241
  ```
1242
1242
 
1243
- _See code: [src/commands/sdk-key/revoke.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/sdk-key/revoke.ts)_
1243
+ _See code: [src/commands/sdk-key/revoke.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/sdk-key/revoke.ts)_
1244
1244
 
1245
1245
  ## `qfg set-default [NAME]`
1246
1246
 
@@ -1311,7 +1311,7 @@ EXAMPLES
1311
1311
  $ qfg pull && qfg config-schema
1312
1312
  ```
1313
1313
 
1314
- _See code: [src/commands/set-default.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/set-default.ts)_
1314
+ _See code: [src/commands/set-default.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/set-default.ts)_
1315
1315
 
1316
1316
  ## `qfg set-rollout [NAME]`
1317
1317
 
@@ -1388,7 +1388,7 @@ EXAMPLES
1388
1388
  $ qfg set-rollout my.variant.flag --environment production --weights "a:33,b:33,c:34" --hash-by user.id
1389
1389
  ```
1390
1390
 
1391
- _See code: [src/commands/set-rollout.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/set-rollout.ts)_
1391
+ _See code: [src/commands/set-rollout.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/set-rollout.ts)_
1392
1392
 
1393
1393
  ## `qfg sync`
1394
1394
 
@@ -1420,7 +1420,7 @@ EXAMPLES
1420
1420
  $ qfg sync --watch --dir ./our-config --interval 30
1421
1421
  ```
1422
1422
 
1423
- _See code: [src/commands/sync.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/sync.ts)_
1423
+ _See code: [src/commands/sync.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/sync.ts)_
1424
1424
 
1425
1425
  ## `qfg toggle [NAME]`
1426
1426
 
@@ -1522,7 +1522,7 @@ EXAMPLES
1522
1522
  $ qfg verify --json
1523
1523
  ```
1524
1524
 
1525
- _See code: [src/commands/verify.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/verify.ts)_
1525
+ _See code: [src/commands/verify.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/verify.ts)_
1526
1526
 
1527
1527
  ## `qfg whoami`
1528
1528
 
@@ -1545,7 +1545,7 @@ EXAMPLES
1545
1545
  $ qfg whoami
1546
1546
  ```
1547
1547
 
1548
- _See code: [src/commands/whoami.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/whoami.ts)_
1548
+ _See code: [src/commands/whoami.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/whoami.ts)_
1549
1549
 
1550
1550
  ## `qfg workspace`
1551
1551
 
@@ -1568,7 +1568,7 @@ EXAMPLES
1568
1568
  $ qfg workspace
1569
1569
  ```
1570
1570
 
1571
- _See code: [src/commands/workspace.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/workspace.ts)_
1571
+ _See code: [src/commands/workspace.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/workspace.ts)_
1572
1572
 
1573
1573
  ## `qfg workspace bootstrap`
1574
1574
 
@@ -1601,7 +1601,7 @@ EXAMPLES
1601
1601
  $ qfg workspace bootstrap --dir ./our-config --skip-validate
1602
1602
  ```
1603
1603
 
1604
- _See code: [src/commands/workspace/bootstrap.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/workspace/bootstrap.ts)_
1604
+ _See code: [src/commands/workspace/bootstrap.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/workspace/bootstrap.ts)_
1605
1605
 
1606
1606
  ## `qfg workspace create SLUG`
1607
1607
 
@@ -1635,7 +1635,7 @@ EXAMPLES
1635
1635
  $ qfg workspace create my-team --org 11111111-1111-1111-1111-111111111111
1636
1636
  ```
1637
1637
 
1638
- _See code: [src/commands/workspace/create.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/workspace/create.ts)_
1638
+ _See code: [src/commands/workspace/create.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/workspace/create.ts)_
1639
1639
 
1640
1640
  ## `qfg workspace switch [SLUG]`
1641
1641
 
@@ -1663,7 +1663,7 @@ EXAMPLES
1663
1663
  $ qfg workspace switch my-workspace-slug
1664
1664
  ```
1665
1665
 
1666
- _See code: [src/commands/workspace/switch.ts](https://github.com/quonfig/cli/blob/v0.0.22/src/commands/workspace/switch.ts)_
1666
+ _See code: [src/commands/workspace/switch.ts](https://github.com/quonfig/cli/blob/v0.0.23/src/commands/workspace/switch.ts)_
1667
1667
  <!-- commandsstop -->
1668
1668
 
1669
1669
  ## Local Development
@@ -186,9 +186,9 @@ CLI shortcuts (no JSON editing needed for simple cases):
186
186
  async maybeWriteQuonfigDir(dir) {
187
187
  if (process.env.QUONFIG_DIR)
188
188
  return; // already set via env
189
- const os = await import('node:os');
190
189
  const pathMod = await import('node:path');
191
- const configFilePath = pathMod.join(os.homedir(), '.quonfig', 'config');
190
+ const { getQuonfigConfigHome } = await import('../util/quonfig-home.js');
191
+ const configFilePath = pathMod.join(getQuonfigConfigHome(), 'config');
192
192
  try {
193
193
  const existing = await fs.promises.readFile(configFilePath, 'utf8').catch(() => '');
194
194
  if (existing.includes('QUONFIG_DIR') || existing.includes('quonfig_dir'))
@@ -1,8 +1,7 @@
1
1
  import type { JsonObj } from '../result.js';
2
2
  import { BaseCommand } from '../index.js';
3
3
  import { GiteaTokenResponse } from '../util/gitea-api.js';
4
- import type { GiteaTokenMintResult } from '../push/run-push.js';
5
- import { runPush } from '../push/run-push.js';
4
+ import { runPush, type GiteaTokenMintResult } from '../push/run-push.js';
6
5
  export default class Push extends BaseCommand {
7
6
  static description: string;
8
7
  static examples: string[];
@@ -34,20 +33,13 @@ export declare function giteaResponseToMintResult(resp: GiteaTokenResponse): Git
34
33
  *
35
34
  * Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
36
35
  * finally block so the bare-path probe clone is removed even on errors.
36
+ *
37
+ * As of qfg-azk.13 the push code path no longer mints a write-scoped Gitea
38
+ * token; the actual commit is performed server-side by the `configs.push`
39
+ * oRPC procedure. We still mint a READ token to authenticate the bare-path
40
+ * probe-clone and the clone-path `git fetch`.
37
41
  */
38
42
  export declare function buildRealDeps(cmd: Push): {
39
43
  deps: Parameters<typeof runPush>[1];
40
44
  cleanup: () => Promise<void>;
41
45
  };
42
- /**
43
- * Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
44
- * temp push-clone scratch directories. Used as the `applyDelta` callback for
45
- * bare-path pushes — we mirror local files into a fresh clone so that
46
- * `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
47
- * (deletes, modifies, adds) we intend.
48
- *
49
- * IMPORTANT: this deletes files in destDir that are not in sourceDir so that
50
- * deletions make it into the push. Without this the clone-and-stack produces
51
- * an "adds-only" delta and destructive heuristics would never fire in the UI.
52
- */
53
- export declare function copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
@@ -5,9 +5,10 @@ import * as util from 'node:util';
5
5
  import { Flags } from '@oclif/core';
6
6
  import { BaseCommand } from '../index.js';
7
7
  import { mintGiteaToken } from '../util/gitea-api.js';
8
- import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, } from '../util/git-ops.js';
9
- import { PushFatalError, runPush } from '../push/run-push.js';
8
+ import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo } from '../util/git-ops.js';
9
+ import { PushFatalError, runPush, } from '../push/run-push.js';
10
10
  import { computeBarePathDiff } from '../push/bare-path-diff.js';
11
+ import { callConfigsPush } from '../push/config-push-client.js';
11
12
  import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
12
13
  const execFile = util.promisify(execFileCb);
13
14
  export default class Push extends BaseCommand {
@@ -127,6 +128,11 @@ export function giteaResponseToMintResult(resp) {
127
128
  *
128
129
  * Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
129
130
  * finally block so the bare-path probe clone is removed even on errors.
131
+ *
132
+ * As of qfg-azk.13 the push code path no longer mints a write-scoped Gitea
133
+ * token; the actual commit is performed server-side by the `configs.push`
134
+ * oRPC procedure. We still mint a READ token to authenticate the bare-path
135
+ * probe-clone and the clone-path `git fetch`.
130
136
  */
131
137
  export function buildRealDeps(cmd) {
132
138
  const log = (line) => cmd.log(line);
@@ -187,12 +193,6 @@ export function buildRealDeps(cmd) {
187
193
  const probe = await ensureBarePathProbe(dir);
188
194
  return probe.deltas;
189
195
  },
190
- async push(dir) {
191
- await execFile('git', ['-C', dir, 'push', 'origin', 'main']);
192
- },
193
- async getLocalAuthor(dir) {
194
- return readLocalAuthor(dir);
195
- },
196
196
  async countFilesInRemote(dir) {
197
197
  if (await isGitRepo(dir)) {
198
198
  const fromGit = await countTrackedFilesAtRef(dir, 'origin/main');
@@ -207,9 +207,11 @@ export function buildRealDeps(cmd) {
207
207
  };
208
208
  const deps = {
209
209
  async mintWriteToken(requestedTarget) {
210
- const resp = await mintGiteaToken(requestedTarget, 'write', 'push');
211
- // Stash the authenticated URL so the bare-path gitOps methods can
212
- // probe-clone it without a second mint.
210
+ // Read scope: this token authenticates probe-clone and clone-path
211
+ // fetch. The actual commit goes through the server via configs.push,
212
+ // which is authorized off the user's WorkOS session — no write PAT
213
+ // needed in the push code path (qfg-azk.13).
214
+ const resp = await mintGiteaToken(requestedTarget, 'read', 'pull');
213
215
  authenticatedRepoUrl = resp.repoUrl;
214
216
  return giteaResponseToMintResult(resp);
215
217
  },
@@ -220,7 +222,7 @@ export function buildRealDeps(cmd) {
220
222
  return { errors };
221
223
  },
222
224
  gitOps,
223
- copyDirMirror,
225
+ pushToServer: callConfigsPush,
224
226
  log,
225
227
  errLog,
226
228
  };
@@ -237,26 +239,20 @@ export function buildRealDeps(cmd) {
237
239
  return { deps, cleanup };
238
240
  }
239
241
  /**
240
- * Diff the working tree (what we are about to push) against origin/main. We
241
- * use `git diff --name-status HEAD..origin/main` with inverted semantics:
242
- * Added here = present locally (in HEAD), absent in origin/main.
243
- * Deleted = absent locally, present in origin/main.
244
- * Modified = content differs.
242
+ * Diff the working tree (what we are about to push) against origin/main and
243
+ * fill in before/after JSON content for each delta as required by the
244
+ * `configs.push` wire shape (qfg-azk.13).
245
+ *
246
+ * Added — afterJson = working tree
247
+ * Modified — beforeJson = `git show origin/main:<path>`, afterJson = working tree
248
+ * Deleted — beforeJson = `git show origin/main:<path>`
245
249
  *
246
250
  * `git diff A..B` reports B relative to A. So `HEAD..origin/main` tells us
247
251
  * what origin has that we don't. We want "local vs remote" with local as the
248
252
  * baseline — i.e. origin/main..HEAD.
249
253
  */
250
254
  async function diffHeadVsOrigin(dir) {
251
- // origin/main..HEAD reports changes HEAD has relative to origin/main.
252
- // 'A' -> added locally, 'D' -> deleted locally, 'M' -> modified locally.
253
- const { stdout } = await execFile('git', [
254
- '-C',
255
- dir,
256
- 'diff',
257
- '--name-status',
258
- 'origin/main..HEAD',
259
- ]);
255
+ const { stdout } = await execFile('git', ['-C', dir, 'diff', '--name-status', 'origin/main..HEAD']);
260
256
  const deltas = [];
261
257
  for (const raw of stdout.split('\n')) {
262
258
  const line = raw.trim();
@@ -266,95 +262,54 @@ async function diffHeadVsOrigin(dir) {
266
262
  const pathStr = rest.join(' ');
267
263
  if (!pathStr)
268
264
  continue;
269
- if (status.startsWith('A'))
270
- deltas.push({ kind: 'added', path: pathStr });
271
- else if (status.startsWith('D'))
272
- deltas.push({ kind: 'deleted', path: pathStr });
265
+ if (status.startsWith('A')) {
266
+ // eslint-disable-next-line no-await-in-loop
267
+ const afterJson = await readWorkingTreeFile(dir, pathStr);
268
+ deltas.push({ kind: 'added', path: pathStr, ...(afterJson === undefined ? {} : { afterJson }) });
269
+ }
270
+ else if (status.startsWith('D')) {
271
+ // eslint-disable-next-line no-await-in-loop
272
+ const beforeJson = await showAtRef(dir, 'origin/main', pathStr);
273
+ deltas.push({ kind: 'deleted', path: pathStr, ...(beforeJson === undefined ? {} : { beforeJson }) });
274
+ }
273
275
  else if (status.startsWith('M') || status.startsWith('R') || status.startsWith('C')) {
274
- // Treat renames / copies as modifications for diff-summary purposes.
275
- deltas.push({ kind: 'modified', path: pathStr });
276
+ // eslint-disable-next-line no-await-in-loop
277
+ const beforeJson = await showAtRef(dir, 'origin/main', pathStr);
278
+ // eslint-disable-next-line no-await-in-loop
279
+ const afterJson = await readWorkingTreeFile(dir, pathStr);
280
+ deltas.push({
281
+ kind: 'modified',
282
+ path: pathStr,
283
+ ...(beforeJson === undefined ? {} : { beforeJson }),
284
+ ...(afterJson === undefined ? {} : { afterJson }),
285
+ });
276
286
  }
277
287
  }
278
288
  return deltas;
279
289
  }
280
- async function countTrackedFilesAtRef(dir, ref) {
290
+ async function showAtRef(dir, ref, relPath) {
281
291
  try {
282
- const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
283
- return stdout.split('\n').filter((l) => l.trim().length > 0).length;
292
+ const { stdout } = await execFile('git', ['-C', dir, 'show', `${ref}:${relPath}`]);
293
+ return stdout;
284
294
  }
285
295
  catch {
286
- return 0;
296
+ return undefined;
287
297
  }
288
298
  }
289
- async function readLocalAuthor(dir) {
299
+ async function readWorkingTreeFile(dir, relPath) {
290
300
  try {
291
- const { stdout: name } = await execFile('git', ['-C', dir, 'config', 'user.name']);
292
- const { stdout: email } = await execFile('git', ['-C', dir, 'config', 'user.email']);
293
- const n = name.trim();
294
- const e = email.trim();
295
- if (!n || !e)
296
- return undefined;
297
- return { name: n, email: e };
301
+ return await fs.promises.readFile(path.join(dir, relPath), 'utf8');
298
302
  }
299
303
  catch {
300
304
  return undefined;
301
305
  }
302
306
  }
303
- /**
304
- * Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
305
- * temp push-clone scratch directories. Used as the `applyDelta` callback for
306
- * bare-path pushes — we mirror local files into a fresh clone so that
307
- * `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
308
- * (deletes, modifies, adds) we intend.
309
- *
310
- * IMPORTANT: this deletes files in destDir that are not in sourceDir so that
311
- * deletions make it into the push. Without this the clone-and-stack produces
312
- * an "adds-only" delta and destructive heuristics would never fire in the UI.
313
- */
314
- export async function copyDirMirror(sourceDir, destDir) {
315
- // Collect source entries (except .git, .quonfig-push-clone-*)
316
- const sourceRel = collectRelPaths(sourceDir);
317
- const destRel = collectRelPaths(destDir);
318
- // Delete anything in dest that isn't in source
319
- for (const rel of destRel) {
320
- if (!sourceRel.has(rel)) {
321
- const full = path.join(destDir, rel);
322
- try {
323
- fs.rmSync(full, { force: true });
324
- }
325
- catch {
326
- /* ignore */
327
- }
328
- }
307
+ async function countTrackedFilesAtRef(dir, ref) {
308
+ try {
309
+ const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
310
+ return stdout.split('\n').filter((l) => l.trim().length > 0).length;
329
311
  }
330
- // Copy / overwrite everything from source into dest
331
- for (const rel of sourceRel) {
332
- const src = path.join(sourceDir, rel);
333
- const dst = path.join(destDir, rel);
334
- fs.mkdirSync(path.dirname(dst), { recursive: true });
335
- fs.copyFileSync(src, dst);
312
+ catch {
313
+ return 0;
336
314
  }
337
315
  }
338
- function collectRelPaths(root) {
339
- const out = new Set();
340
- if (!fs.existsSync(root))
341
- return out;
342
- const walk = (dir, prefix) => {
343
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
344
- if (entry.name === '.git')
345
- continue;
346
- if (entry.name.startsWith('.quonfig-push-clone-'))
347
- continue;
348
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
349
- const full = path.join(dir, entry.name);
350
- if (entry.isDirectory()) {
351
- walk(full, rel);
352
- }
353
- else if (entry.isFile() || entry.isSymbolicLink()) {
354
- out.add(rel);
355
- }
356
- }
357
- };
358
- walk(root, '');
359
- return out;
360
- }
@@ -298,8 +298,10 @@ export function storedConfigJsonSchema() {
298
298
  type: 'string',
299
299
  description: 'References a schema in schemas/ or schemas-protected/. The referenced file must exist.',
300
300
  },
301
- accessLevel: { type: 'string' },
302
- protection: { type: 'string' },
301
+ access: {
302
+ enum: ['support', 'standard', 'protected-env', 'protected-all-envs'],
303
+ description: 'Edit-access tier for this config. Defaults to "standard" when absent. See protecting-access.md.',
304
+ },
303
305
  tags: {
304
306
  type: 'array',
305
307
  items: { type: 'string' },
@@ -54,35 +54,37 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
54
54
  }
55
55
  throw error;
56
56
  }
57
- const localFiles = collectFilesWithHash(localDir);
58
- const remoteFiles = collectFilesWithHash(scratchDir);
57
+ const localFiles = collectFiles(localDir);
58
+ const remoteFiles = collectFiles(scratchDir);
59
59
  const deltas = [];
60
- for (const [rel, localHash] of localFiles) {
61
- const remoteHash = remoteFiles.get(rel);
62
- if (remoteHash === undefined) {
63
- deltas.push({ kind: 'added', path: rel });
60
+ for (const [rel, local] of localFiles) {
61
+ const remote = remoteFiles.get(rel);
62
+ if (remote === undefined) {
63
+ deltas.push({ kind: 'added', path: rel, afterJson: local.content });
64
64
  }
65
- else if (remoteHash !== localHash) {
66
- deltas.push({ kind: 'modified', path: rel });
65
+ else if (remote.hash !== local.hash) {
66
+ deltas.push({ kind: 'modified', path: rel, beforeJson: remote.content, afterJson: local.content });
67
67
  }
68
68
  }
69
- for (const rel of remoteFiles.keys()) {
69
+ for (const [rel, remote] of remoteFiles) {
70
70
  if (!localFiles.has(rel)) {
71
- deltas.push({ kind: 'deleted', path: rel });
71
+ deltas.push({ kind: 'deleted', path: rel, beforeJson: remote.content });
72
72
  }
73
73
  }
74
74
  return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
75
75
  }
76
76
  /**
77
- * Walk `root` and return a map of relative-path -> content-hash for every
78
- * non-dotfile file under it.
77
+ * Walk `root` and return a map of relative-path -> {content, hash} for every
78
+ * non-dotfile file under it. The content string is needed by qfg-azk.13's
79
+ * `configs.push` wire shape; the hash is used to short-circuit the
80
+ * unchanged-file case without comparing full bodies.
79
81
  *
80
82
  * We skip any entry whose name starts with a `.`, which covers `.git/`,
81
83
  * `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
82
84
  * `run-push.ts` creates next to the local workspace. Keeping the rule simple
83
85
  * and symmetric on both sides prevents one-off false positives.
84
86
  */
85
- function collectFilesWithHash(root) {
87
+ function collectFiles(root) {
86
88
  const out = new Map();
87
89
  if (!fs.existsSync(root))
88
90
  return out;
@@ -104,8 +106,8 @@ function collectFilesWithHash(root) {
104
106
  }
105
107
  else if (entry.isFile() || entry.isSymbolicLink()) {
106
108
  try {
107
- const buf = fs.readFileSync(full);
108
- out.set(rel, hashBytes(buf));
109
+ const content = fs.readFileSync(full, 'utf8');
110
+ out.set(rel, { content, hash: hashString(content) });
109
111
  }
110
112
  catch {
111
113
  /* ignore unreadable files — they won't appear on either side */
@@ -121,6 +123,6 @@ function collectFilesWithHash(root) {
121
123
  * fingerprint so we can say "file A on local matches file A on remote". The
122
124
  * contents are small JSON in practice, so sha1 is plenty fast.
123
125
  */
124
- function hashBytes(buf) {
125
- return createHash('sha1').update(buf).digest('hex');
126
+ function hashString(s) {
127
+ return createHash('sha1').update(s).digest('hex');
126
128
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
3
+ *
4
+ * Server-side spec lives at
5
+ * `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
6
+ * oRPC error shape into a tagged union the CLI can pattern-match on:
7
+ *
8
+ * - 200 → `{kind: 'success', commitSha}`
9
+ * - 403 with `data.denials` → `{kind: 'denied', denials}`
10
+ * - 409 → `{kind: 'conflict', message}`
11
+ * - 400 → `{kind: 'bad-request', message}`
12
+ * - anything else → throws so runPush surfaces a generic PushFatalError.
13
+ *
14
+ * The auth header is a WorkOS access token (or a `qf_uk_*` API key when
15
+ * `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
16
+ */
17
+ import type { ConfigPushInput, ConfigPushResult } from './run-push.js';
18
+ export declare const callConfigsPush: (input: ConfigPushInput) => Promise<ConfigPushResult>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
3
+ *
4
+ * Server-side spec lives at
5
+ * `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
6
+ * oRPC error shape into a tagged union the CLI can pattern-match on:
7
+ *
8
+ * - 200 → `{kind: 'success', commitSha}`
9
+ * - 403 with `data.denials` → `{kind: 'denied', denials}`
10
+ * - 409 → `{kind: 'conflict', message}`
11
+ * - 400 → `{kind: 'bad-request', message}`
12
+ * - anything else → throws so runPush surfaces a generic PushFatalError.
13
+ *
14
+ * The auth header is a WorkOS access token (or a `qf_uk_*` API key when
15
+ * `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
16
+ */
17
+ import { getApiUrl } from '../util/domain-urls.js';
18
+ import { getValidAccessToken } from '../util/get-valid-token.js';
19
+ export const callConfigsPush = async (input) => {
20
+ const accessToken = await getValidAccessToken();
21
+ const apiUrl = getApiUrl();
22
+ const res = await fetch(`${apiUrl}/api/v1/configs/push`, {
23
+ method: 'POST',
24
+ headers: {
25
+ Authorization: `Bearer ${accessToken}`,
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ body: JSON.stringify({ json: input }),
29
+ });
30
+ if (res.ok) {
31
+ const body = (await res.json());
32
+ const commitSha = body.json?.commitSha;
33
+ if (!commitSha) {
34
+ throw new Error(`configs.push returned 200 but no commitSha (body=${JSON.stringify(body)})`);
35
+ }
36
+ return { kind: 'success', commitSha };
37
+ }
38
+ const text = await res.text().catch(() => '');
39
+ let parsed;
40
+ try {
41
+ const body = JSON.parse(text);
42
+ parsed = body.json ?? body;
43
+ }
44
+ catch {
45
+ /* keep parsed undefined */
46
+ }
47
+ const message = parsed?.message ?? text ?? `HTTP ${res.status}`;
48
+ if (res.status === 403) {
49
+ const denials = extractDenials(parsed?.data);
50
+ if (denials.length > 0) {
51
+ return { kind: 'denied', denials };
52
+ }
53
+ }
54
+ if (res.status === 409) {
55
+ return { kind: 'conflict', message };
56
+ }
57
+ if (res.status === 400) {
58
+ return { kind: 'bad-request', message };
59
+ }
60
+ throw new Error(`configs.push failed (HTTP ${res.status}): ${message}`);
61
+ };
62
+ function extractDenials(data) {
63
+ if (!data || typeof data !== 'object')
64
+ return [];
65
+ const denials = data.denials;
66
+ if (!Array.isArray(denials))
67
+ return [];
68
+ const out = [];
69
+ for (const raw of denials) {
70
+ if (!raw || typeof raw !== 'object')
71
+ continue;
72
+ const r = raw;
73
+ if (typeof r.path !== 'string' || typeof r.requiredPermission !== 'string')
74
+ continue;
75
+ out.push({
76
+ path: r.path,
77
+ reason: typeof r.reason === 'string' ? r.reason : `Missing required permission: ${r.requiredPermission}`,
78
+ requiredPermission: r.requiredPermission,
79
+ });
80
+ }
81
+ return out;
82
+ }
@@ -14,6 +14,17 @@
14
14
  */
15
15
  /** A single file-level change between the local working tree and remote HEAD. */
16
16
  export interface FileDelta {
17
+ /**
18
+ * Local content for `added`/`modified`. Absent for `deleted`. Required by
19
+ * the `configs.push` server endpoint when sending the delta over the wire.
20
+ */
21
+ afterJson?: string;
22
+ /**
23
+ * Remote content for `modified`/`deleted`. Absent for `added`. Required by
24
+ * the `configs.push` server endpoint to deep-diff per-environment rule
25
+ * changes for authorization.
26
+ */
27
+ beforeJson?: string;
17
28
  /**
18
29
  * `added` — present locally, absent on remote
19
30
  * `modified` — present in both, content differs
@@ -16,14 +16,7 @@
16
16
  * Top-level directories we present as named rows. Anything else funnels into
17
17
  * `other`. Keeping this closed-set avoids one-off typos silently looking fine.
18
18
  */
19
- const KNOWN_GROUPS = [
20
- 'configs',
21
- 'feature-flags',
22
- 'segments',
23
- 'schemas',
24
- 'schemas-protected',
25
- 'log-levels',
26
- ];
19
+ const KNOWN_GROUPS = ['configs', 'feature-flags', 'segments', 'schemas', 'schemas-protected', 'log-levels'];
27
20
  const DESTRUCTIVE_DELETE_COUNT = 10;
28
21
  const DESTRUCTIVE_DELETE_RATIO = 0.25;
29
22
  function emptyCounts() {
@@ -18,7 +18,6 @@
18
18
  * `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
- import { PushIdentity } from '../util/clone-and-stack-push.js';
22
21
  import { FileDelta } from './diff-summary.js';
23
22
  export interface GiteaTokenMintResult {
24
23
  expiresAt: string | null;
@@ -32,23 +31,52 @@ export interface GitOps {
32
31
  countFilesInRemote(dir: string): Promise<number>;
33
32
  /**
34
33
  * Produce the list of file deltas to render in the Guard 3 summary and
35
- * (for the bare path) commit. For the clone path this is HEAD vs origin/main.
36
- * For the bare path the caller hands us the deltas itself.
34
+ * send to the server. Each delta carries before/after JSON content per
35
+ * the `configs.push` wire shape (qfg-azk.13). For the clone path this is
36
+ * HEAD vs origin/main; for the bare path it's local vs probe-clone.
37
37
  */
38
38
  diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
39
39
  /** `git fetch origin` in the given dir. */
40
40
  fetch(dir: string): Promise<void>;
41
- /** Read local git config for author identity (used for bare-path commits). */
42
- getLocalAuthor(dir: string): Promise<PushIdentity | undefined>;
43
41
  /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
44
42
  getRemoteOriginUrl(dir: string): Promise<string | undefined>;
45
43
  /** Returns true if the dir has a `.git/` (worktree or repo). */
46
44
  isGitRepo(dir: string): Promise<boolean>;
47
- /** `git push origin main`. Throws with a PushConflict-ish message on non-ff. */
48
- push(dir: string): Promise<void>;
49
45
  /** Set origin to `url` (add if missing, set-url if present). */
50
46
  setRemoteOrigin(dir: string, url: string): Promise<void>;
51
47
  }
48
+ /** Server-side `kind` enum (matches `FileDeltaSchema` in app-quonfig). */
49
+ export type ServerFileKind = 'add' | 'delete' | 'modify';
50
+ export interface ServerFileDelta {
51
+ afterJson?: string;
52
+ beforeJson?: string;
53
+ kind: ServerFileKind;
54
+ path: string;
55
+ }
56
+ export interface ConfigPushInput {
57
+ expectedSha?: string;
58
+ files: ServerFileDelta[];
59
+ message?: string;
60
+ workspaceId: string;
61
+ }
62
+ export type ConfigPushResult = {
63
+ kind: 'bad-request';
64
+ message: string;
65
+ } | {
66
+ kind: 'conflict';
67
+ message: string;
68
+ } | {
69
+ commitSha: string;
70
+ kind: 'success';
71
+ } | {
72
+ denials: PushDenial[];
73
+ kind: 'denied';
74
+ };
75
+ export interface PushDenial {
76
+ path: string;
77
+ reason: string;
78
+ requiredPermission: string;
79
+ }
52
80
  export type ConfirmIO = {
53
81
  input?: NodeJS.ReadableStream;
54
82
  output?: NodeJS.WritableStream;
@@ -70,18 +98,30 @@ export interface RunPushInput {
70
98
  export interface RunPushDeps {
71
99
  /** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */
72
100
  confirmIO?: ConfirmIO;
73
- /**
74
- * Copy files from `sourceDir` into `destDir`, skipping `.git/`. Used by the
75
- * bare path as the `applyDelta` callback handed to `cloneAndStackPush`.
76
- */
77
- copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
78
101
  /** Error logger; defaults to console.error. */
79
102
  errLog?: (line: string) => void;
80
103
  gitOps: GitOps;
81
104
  /** Logger; defaults to console.log. */
82
105
  log?: (line: string) => void;
83
- /** Mint a write token via the backend. */
106
+ /**
107
+ * Resolve the backend's identity for this workspace. Returns repoUrl,
108
+ * workspaceSlug, workspaceId — used by the identity check and (for
109
+ * read-only auth) by the bare-path probe-clone and clone-path fetch.
110
+ *
111
+ * Named `mintWriteToken` for historical reasons; as of qfg-azk.13 the
112
+ * push code path no longer mints a write-scoped Gitea token (server-side
113
+ * commit via `configs.push`), so the real implementation now mints a
114
+ * read-scope token. The name is preserved to keep the test harness shape
115
+ * stable across the qfg-azk.13 transition.
116
+ */
84
117
  mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
118
+ /**
119
+ * Call the server-side `configs.push` oRPC procedure. Returns a tagged
120
+ * union so callers can distinguish success / per-file denials / a stale
121
+ * expectedSha conflict / a path allow-list violation without parsing
122
+ * HTTP status codes.
123
+ */
124
+ pushToServer(input: ConfigPushInput): Promise<ConfigPushResult>;
85
125
  /** Run `qfg validate` semantics. Should throw on error. */
86
126
  validate(dir: string): Promise<{
87
127
  errors: string[];
@@ -19,8 +19,6 @@
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
21
  import * as fs from 'node:fs';
22
- import * as path from 'node:path';
23
- import { MIGRATOR_IDENTITY, cloneAndStackPush } from '../util/clone-and-stack-push.js';
24
22
  import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
25
23
  import { checkIdentity } from './identity-check.js';
26
24
  import { confirmTypedSlug, confirmYesNo } from './confirm.js';
@@ -118,19 +116,18 @@ export async function runPush(input, deps) {
118
116
  // repo into a scratch dir inside `.quonfig-push-clone/` and stack a commit
119
117
  // that mirrors the local dir's contents on top.
120
118
  const isClonePath = hasGit && remoteOriginUrl !== undefined && sameRepo(remoteOriginUrl, backend.repoUrl);
119
+ const dispatchedAs = isClonePath ? 'clone-path' : 'bare-path';
120
+ // Clone path: align origin with the backend so the gitOps.fetch + diff
121
+ // walk authenticates against the read URL we minted. The bare path's
122
+ // probe-clone path bypasses local origin entirely.
121
123
  if (isClonePath) {
122
- return doClonePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
124
+ await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
125
+ log('Fetching from remote...');
126
+ await deps.gitOps.fetch(input.dir);
123
127
  }
124
- return doBarePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
125
- }
126
- async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
127
- // Point origin at the authenticated URL so `git fetch` / `git push` work
128
- // without touching global credential helpers.
129
- await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
130
- log('Fetching from remote...');
131
- await deps.gitOps.fetch(input.dir);
132
128
  const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
133
129
  const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
130
+ const unpinned = repoPinSlug === undefined;
134
131
  const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
135
132
  log('');
136
133
  log(summary.renderText({
@@ -140,7 +137,6 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
140
137
  localDir: input.dir,
141
138
  }));
142
139
  log('');
143
- // Short-circuit: literally nothing to push.
144
140
  if (summary.totals.filesTouched === 0) {
145
141
  return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
146
142
  }
@@ -155,141 +151,57 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
155
151
  log('Aborted, nothing pushed.');
156
152
  return { kind: 'aborted', reason: 'user declined at confirm prompt' };
157
153
  }
158
- log('Pushing to origin/main...');
159
- try {
160
- await deps.gitOps.push(input.dir);
161
- }
162
- catch (error) {
163
- const msg = error instanceof Error ? error.message : String(error);
164
- if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
165
- throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
154
+ const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
155
+ const commitMessage = withPushedViaTrailer(baseMessage);
156
+ const serverFiles = deltas.map((d) => ({
157
+ path: d.path,
158
+ kind: toServerKind(d.kind),
159
+ ...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
160
+ ...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
161
+ }));
162
+ log('Sending push to Quonfig cloud...');
163
+ const result = await deps.pushToServer({
164
+ workspaceId: backend.workspaceId,
165
+ files: serverFiles,
166
+ message: commitMessage,
167
+ });
168
+ if (result.kind === 'denied') {
169
+ errLog(`Push denied for ${result.denials.length} file(s):`);
170
+ for (const d of result.denials) {
171
+ errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
166
172
  }
167
- throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
173
+ throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
174
+ }
175
+ if (result.kind === 'conflict') {
176
+ throw new PushFatalError(`Remote moved while preparing this push (${result.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
177
+ }
178
+ if (result.kind === 'bad-request') {
179
+ throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
168
180
  }
169
- // Offer to pin the workspace slug in quonfig.json if missing. Non-fatal.
170
181
  if (unpinned && !input.noPinWrite) {
171
182
  try {
172
183
  await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
173
184
  log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
174
185
  }
175
186
  catch {
176
- // non-fatal; pin is advisory
187
+ /* non-fatal; pin is advisory */
177
188
  }
178
189
  }
179
- return { kind: 'pushed', dispatchedAs: 'clone-path' };
190
+ return { kind: 'pushed', dispatchedAs, commitSha: result.commitSha };
180
191
  }
181
- async function doBarePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
182
- // Scratch clone location: sibling `.quonfig-push-clone-<timestamp>` so we
183
- // don't clobber or re-use anything from a previous run.
184
- const scratchDir = path.join(path.dirname(path.resolve(input.dir)), `.quonfig-push-clone-${Date.now()}`);
185
- // Local author for the migrated commit. Prefer the local git config in
186
- // `input.dir` so attribution matches what the user has set. If it is not
187
- // available, fall back to the migrator identity but tell the user — the plan
188
- // requires this be loud.
189
- let author = MIGRATOR_IDENTITY;
190
- try {
191
- const local = await deps.gitOps.getLocalAuthor(input.dir);
192
- if (local && local.name && local.email) {
193
- author = local;
194
- }
195
- else {
196
- log(`Warning: no local git user.name/user.email in ${input.dir}; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
197
- }
198
- }
199
- catch {
200
- log(`Warning: could not read local git user identity; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
201
- }
202
- // We need the deltas for the Guard 3 summary BEFORE we decide to commit and
203
- // push. To produce them we need the scratch clone in hand (so we can compare
204
- // file-by-file). We clone into the scratch dir, take the diff, show the
205
- // summary, confirm, then apply the delta via `cloneAndStackPush`.
206
- //
207
- // Rather than re-implement cloneAndStackPush's clone step here, we let it
208
- // own the clone-commit-push pipeline and pass a deltas callback that runs
209
- // AFTER the clone (it receives the localDir/scratchDir) but BEFORE the
210
- // commit. `cloneAndStackPush` only exposes an `applyDelta` hook (write files
211
- // then return), so we split the flow: first a probe clone to take the
212
- // diff, then the real clone-and-stack-push. This is simpler than rewiring
213
- // cloneAndStackPush's API.
214
- // Probe clone.
215
- await deps.gitOps.fetch(input.dir) // no-op for bare path if not a repo; deps.gitOps may short-circuit
216
- .catch(() => {
217
- // bare-path doesn't require a local fetch — swallow the error
218
- });
219
- // We don't have a probe clone helper; instead, ask the deps for a bare-path
220
- // delta computation. The test double will produce this from a fixture; the
221
- // real implementation walks both trees after ensuring a scratch clone exists.
222
- // This is provided by the command glue layer.
223
- const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
224
- const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
225
- const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
226
- log('');
227
- log(summary.renderText({
228
- workspaceSlug: backend.workspaceSlug,
229
- repoUrl: stripAuth(backend.repoUrl),
230
- branch: 'main',
231
- localDir: input.dir,
232
- }));
233
- log('');
234
- if (summary.totals.filesTouched === 0) {
235
- return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
236
- }
237
- const ok = await decideConfirm({
238
- destructive: summary.isDestructive,
239
- requiresTypedSlug,
240
- yes: input.yes,
241
- workspaceSlug: backend.workspaceSlug,
242
- confirmIO: deps.confirmIO,
243
- });
244
- if (!ok) {
245
- log('Aborted, nothing pushed.');
246
- return { kind: 'aborted', reason: 'user declined at confirm prompt' };
247
- }
248
- const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
249
- const commitMessage = withPushedViaTrailer(baseMessage);
250
- log('Pushing via clone-and-stack...');
251
- try {
252
- const result = await cloneAndStackPush({
253
- remoteUrl: backend.repoUrl,
254
- localDir: scratchDir,
255
- author,
256
- commitMessage,
257
- async applyDelta(clonedDir) {
258
- // The caller promised deps.copyDirMirror copies `input.dir` into
259
- // `clonedDir`, skipping `.git/`. cloneAndStackPush will `git add --all`
260
- // after this returns.
261
- await deps.copyDirMirror(input.dir, clonedDir);
262
- },
263
- });
264
- if (unpinned && !input.noPinWrite) {
265
- try {
266
- await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
267
- log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
268
- }
269
- catch {
270
- // non-fatal
271
- }
192
+ const toServerKind = (kind) => {
193
+ switch (kind) {
194
+ case 'added': {
195
+ return 'add';
272
196
  }
273
- return { kind: 'pushed', dispatchedAs: 'bare-path', commitSha: result.commitSha };
274
- }
275
- catch (error) {
276
- const msg = error instanceof Error ? error.message : String(error);
277
- if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
278
- throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
197
+ case 'deleted': {
198
+ return 'delete';
279
199
  }
280
- throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
281
- }
282
- finally {
283
- // Best-effort cleanup of the scratch clone. Ignore failures so a cleanup
284
- // problem doesn't mask a real error.
285
- try {
286
- fs.rmSync(scratchDir, { recursive: true, force: true });
287
- }
288
- catch {
289
- /* ignore */
200
+ case 'modified': {
201
+ return 'modify';
290
202
  }
291
203
  }
292
- }
204
+ };
293
205
  /**
294
206
  * Map the guard outputs to the right prompt:
295
207
  * - Typed-slug (always, never skipped by --yes) when identity demanded it
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import { getDomain } from './domain-urls.js';
4
+ import { getQuonfigConfigHome } from './quonfig-home.js';
5
5
  const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
6
- const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
6
+ const getQuonfigDir = () => getQuonfigConfigHome();
7
7
  const getGiteaTokenFile = () => {
8
8
  const domain = getDomain();
9
9
  const filename = domain === 'quonfig.com' ? 'gitea-tokens.json' : `gitea-tokens-${domain.replaceAll('.', '-')}.json`;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Resolve the directory the CLI uses for tokens, profile config, and gitea
3
+ * tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
4
+ * isolated config dir) can redirect away from `~/.quonfig/`.
5
+ */
6
+ export declare const getQuonfigConfigHome: () => string;
@@ -0,0 +1,8 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * Resolve the directory the CLI uses for tokens, profile config, and gitea
5
+ * tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
6
+ * isolated config dir) can redirect away from `~/.quonfig/`.
7
+ */
8
+ export const getQuonfigConfigHome = () => process.env.QUONFIG_CONFIG_HOME || path.join(os.homedir(), '.quonfig');
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import { getDomain } from './domain-urls.js';
5
- const getQuonfigDir = (options) => options?.quonfigDir || path.join(os.homedir(), '.quonfig');
4
+ import { getQuonfigConfigHome } from './quonfig-home.js';
5
+ const getQuonfigDir = (options) => options?.quonfigDir || getQuonfigConfigHome();
6
6
  const getTokenFile = (options) => {
7
7
  const domain = getDomain();
8
8
  const filename = domain === 'quonfig.com' ? 'tokens.json' : `tokens-${domain.replaceAll('.', '-')}.json`;
@@ -118,6 +118,7 @@ const VariantSchema = z.object({
118
118
  value: ValueSchema,
119
119
  description: z.string().optional(),
120
120
  });
121
+ const AccessSchema = z.enum(['support', 'standard', 'protected-env', 'protected-all-envs']);
121
122
  const StoredConfigSchema = z
122
123
  .object({
123
124
  id: z.string().optional(),
@@ -129,8 +130,7 @@ const StoredConfigSchema = z
129
130
  description: z.string().optional(),
130
131
  sendToClientSdk: z.boolean().optional(),
131
132
  schemaKey: z.string().optional(),
132
- accessLevel: z.string().optional(),
133
- protection: z.string().optional(),
133
+ access: AccessSchema.optional(),
134
134
  default: z.object({
135
135
  rules: z.array(ConfigRuleSchema),
136
136
  }),
@@ -2983,5 +2983,5 @@
2983
2983
  ]
2984
2984
  }
2985
2985
  },
2986
- "version": "0.0.22"
2986
+ "version": "0.0.23"
2987
2987
  }
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.22",
4
+ "version": "0.0.23",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },