@quonfig/cli 0.0.73 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { execFile as execFileCb, spawn } from 'node:child_process';
1
+ import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import * as util from 'node:util';
3
3
  const execFile = util.promisify(execFileCb);
4
4
  /**
@@ -365,11 +365,309 @@ export const gitSetRemote = async (dir, url) => {
365
365
  await runGit(['-C', dir, 'remote', 'set-url', 'origin', url]);
366
366
  }
367
367
  };
368
- export const gitPushForceLease = async (dir) => {
369
- await runGit(['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
368
+ const revParse = async (dir, rev) => {
369
+ try {
370
+ const { stdout } = await runGit(['-C', dir, 'rev-parse', '--verify', '--quiet', rev]);
371
+ return stdout.trim() || null;
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ };
377
+ const treePaths = async (dir, rev) => {
378
+ const { stdout } = await runGit(['-C', dir, 'ls-tree', '-r', '--name-only', rev]);
379
+ return stdout
380
+ .split('\n')
381
+ .map((s) => s.trim())
382
+ .filter(Boolean);
383
+ };
384
+ const countCommits = async (dir, rev) => {
385
+ const { stdout } = await runGit(['-C', dir, 'rev-list', '--count', rev]);
386
+ return Number.parseInt(stdout.trim(), 10) || 0;
387
+ };
388
+ /**
389
+ * Every file the validator owns at `ref` — config documents (`configs/`,
390
+ * `feature-flags/`, `segments/`, `log-levels/`) AND schemas (`schemas/`,
391
+ * `schemas-protected/`) — sorted.
392
+ *
393
+ * A workspace repo is FRESH exactly when this list is empty. It is NOT "the
394
+ * remote holds only the auto-init commit": provisioning commits `README.md`
395
+ * AND `quonfig.json`, so a commit-count test refuses every real workspace
396
+ * (plan project/plans/2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
397
+ * Schemas count because a UI-authored schema is content a local-wins rebase
398
+ * would silently overwrite, exactly like a flag.
399
+ *
400
+ * FAILS CLOSED: if the ref cannot be listed this throws. "I could not look"
401
+ * must never read as "the workspace is empty".
402
+ */
403
+ export const workspaceDocumentsAtRef = async (dir, ref) => {
404
+ // Dynamic import: `src/verify/` is copied on its own into the standalone
405
+ // pre-receive-hook build, so it must stay self-contained — and pulling the
406
+ // validator in statically would load it for every command that touches git.
407
+ const { KNOWN_DIRS } = await import('../verify/validate.js');
408
+ const paths = await treePaths(dir, ref);
409
+ return paths.filter((p) => KNOWN_DIRS.has(p.split('/')[0])).sort();
410
+ };
411
+ /** Subject of the one commit that puts the tree back to the customer's. */
412
+ const RECONCILE_SUBJECT = 'reconcile merge resolutions';
413
+ /** Subject when the only thing put back is the workspace's own pin file. */
414
+ const PIN_SUBJECT = "use the hosted workspace's quonfig.json";
415
+ /**
416
+ * `-c` args that keep the CUSTOMER's git config out of the commits bootstrap
417
+ * makes in the temporary worktree. A worktree shares the repo's config, so
418
+ * `commit.gpgsign=true` with an unusable `gpg.program` fails the rebase and a
419
+ * failing `pre-commit` under `core.hooksPath` fails the reconcile commit (both
420
+ * verified). Neither commit is the customer's to sign or to gate: the rebase
421
+ * replays commits they already made, and the reconcile commit only restores
422
+ * their own tree.
423
+ */
424
+ const GIT_UNCONFIGURED_ARGS = [
425
+ '-c',
426
+ 'commit.gpgsign=false',
427
+ '-c',
428
+ 'tag.gpgsign=false',
429
+ '-c',
430
+ 'core.hooksPath=/dev/null',
431
+ '-c',
432
+ 'rebase.autosquash=false',
433
+ '-c',
434
+ 'rerere.enabled=false',
435
+ ];
436
+ /**
437
+ * The identity the CLI commits as when nothing better is known: `qfg push`
438
+ * and `qfg migrate` stack their commits under it, and bootstrap's replayed
439
+ * commits get it as COMMITTER when the machine has no git identity at all.
440
+ */
441
+ export const MIGRATOR_IDENTITY = {
442
+ name: 'quonfig migrator',
443
+ email: 'migrator@quonfig.com',
444
+ };
445
+ /**
446
+ * Env that gives git a committer when it cannot find one itself, and nothing
447
+ * otherwise. A rebase writes a committer on every replayed commit, and git
448
+ * refuses ("empty ident name ... not allowed") on a machine with no
449
+ * `user.name` / `user.email` and no guessable one — every GitHub runner, and
450
+ * a fresh laptop. `git var GIT_COMMITTER_IDENT` is git's own check, so a
451
+ * customer whose identity works keeps it, guessed or configured.
452
+ */
453
+ const committerFallbackEnv = async (dir) => {
454
+ try {
455
+ await runGit(['-C', dir, 'var', 'GIT_COMMITTER_IDENT']);
456
+ return {};
457
+ }
458
+ catch {
459
+ return {
460
+ GIT_AUTHOR_EMAIL: MIGRATOR_IDENTITY.email,
461
+ GIT_AUTHOR_NAME: MIGRATOR_IDENTITY.name,
462
+ GIT_COMMITTER_EMAIL: MIGRATOR_IDENTITY.email,
463
+ GIT_COMMITTER_NAME: MIGRATOR_IDENTITY.name,
464
+ };
465
+ }
466
+ };
467
+ const hasMismatch = (m) => m.pinLost || m.content.length > 0;
468
+ const describeMismatch = (m) => [...m.content, ...(m.pinLost ? ["quonfig.json (the workspace's pin was lost)"] : [])].join(', ');
469
+ const treeMismatch = async (dir, opts) => {
470
+ const { stdout } = await runGit(['-C', dir, 'diff', '--no-renames', '--name-status', opts.local, opts.candidate]);
471
+ const content = [];
472
+ for (const line of stdout.split('\n').filter(Boolean)) {
473
+ const [status, ...rest] = line.split('\t');
474
+ const file = rest.join('\t');
475
+ // A file only the workspace had is expected to appear.
476
+ if (status === 'A' && opts.seededOnly.has(file))
477
+ continue;
478
+ // The workspace's own quonfig.json wins over a local one: it is the pin
479
+ // that names the hosted workspace. Checked separately, below.
480
+ if (file === 'quonfig.json' && opts.remoteQuonfigJson !== null)
481
+ continue;
482
+ content.push(`${status} ${file}`);
483
+ }
484
+ let pinLost = false;
485
+ if (opts.remoteQuonfigJson !== null) {
486
+ const candidatePin = await revParse(dir, `${opts.candidate}:quonfig.json`);
487
+ pinLost = candidatePin !== opts.remoteQuonfigJson;
488
+ }
489
+ return { content, pinLost };
370
490
  };
371
- export const gitPushForce = async (dir) => {
372
- await runGit(['-C', dir, 'push', 'origin', 'main', '--force']);
491
+ const removeWorktree = async (dir, worktree) => {
492
+ try {
493
+ await runGit(['-C', dir, 'worktree', 'remove', '--force', worktree]);
494
+ }
495
+ catch {
496
+ /* already gone, or never created */
497
+ }
498
+ try {
499
+ await runGit(['-C', dir, 'worktree', 'prune']);
500
+ }
501
+ catch {
502
+ /* best effort */
503
+ }
504
+ };
505
+ /**
506
+ * Run the workspace validator — the same one the server's pre-receive hook
507
+ * runs — over a checked-out tree, and throw with its errors if it would be
508
+ * rejected.
509
+ */
510
+ const requireWorkspaceValidates = async (tree) => {
511
+ const { validateWorkspace } = await import('../verify/validate.js');
512
+ const errors = validateWorkspace(tree).issues.filter((i) => i.severity === 'error');
513
+ if (errors.length === 0)
514
+ return;
515
+ const shown = errors.slice(0, 10).map((i) => ` ${i.file}: ${i.message}`);
516
+ if (errors.length > shown.length)
517
+ shown.push(` ...and ${errors.length - shown.length} more`);
518
+ const envHint = errors.some((i) => i.message.includes('not declared in quonfig.json'))
519
+ ? "\nThe workspace's own quonfig.json replaces yours, so an environment that exists only in your local file is not declared on the workspace. Create it in the Quonfig app (or drop the override), then run bootstrap again."
520
+ : '';
521
+ throw new Error(`Refusing to push: the workspace would reject this content.\n${shown.join('\n')}${envHint}\nNothing was pushed and your local repository is unchanged.`);
522
+ };
523
+ /**
524
+ * Land the local repo's history on `origin/main` WITHOUT ever force-pushing
525
+ * (plan 5.6 "Bootstrap keeps its history without force"; `main` is
526
+ * append-only, so a rewrite is not just forbidden, it silently wedges config
527
+ * delivery).
528
+ *
529
+ * `git fetch origin` is the caller's job. Then:
530
+ *
531
+ * 1. Replay the whole local history onto the remote head on a TEMPORARY
532
+ * worktree — never on the customer's branch. Whatever happens, their
533
+ * local refs and working tree are untouched.
534
+ * 2. Require the replayed tip's tree to equal the local tip's tree, ignoring
535
+ * files the workspace seeded that the local repo does not have. Rebasing
536
+ * linearises merges and `-X theirs` silently picks a side, so a
537
+ * hand-resolved merge conflict can otherwise ship the wrong content with
538
+ * exit 0 (13.2 risk 4).
539
+ * 3. If it differs, add ONE commit that sets the tree to the local tip's
540
+ * tree, keeping the seeded files and the workspace's `quonfig.json`. If
541
+ * that cannot be done cleanly, abort with nothing pushed.
542
+ * 4. Validate the tree that is ACTUALLY about to be pushed (unless
543
+ * `validate: false`), so content the workspace would reject fails here
544
+ * instead of at the server hook.
545
+ * 5. Plain `git push` of the result to `main`.
546
+ */
547
+ export const rebaseOntoOriginAndPush = async (dir, opts) => {
548
+ const localHead = await revParse(dir, 'HEAD');
549
+ if (localHead === null)
550
+ throw new Error('The local repository has no commits to push.');
551
+ const remoteHead = await revParse(dir, 'origin/main');
552
+ if (remoteHead === null) {
553
+ // Provisioning always creates `main` with auto_init, and the pre-receive
554
+ // hook rejects a zero-oid create (plan 5.6 item 1), so pushing the branch
555
+ // into existence is a dead end, not a fallback.
556
+ throw new Error('The workspace repository is not provisioned (it has no `main` branch).\nFinish creating the workspace in the Quonfig app, then run bootstrap again.');
557
+ }
558
+ const remotePaths = await treePaths(dir, remoteHead);
559
+ const localPaths = new Set(await treePaths(dir, localHead));
560
+ const seededOnly = new Set(remotePaths.filter((p) => !localPaths.has(p)));
561
+ const remoteQuonfigJson = remotePaths.includes('quonfig.json')
562
+ ? await revParse(dir, `${remoteHead}:quonfig.json`)
563
+ : null;
564
+ const { tmpdir } = await import('node:os');
565
+ const { join } = await import('node:path');
566
+ const worktree = join(tmpdir(), `qfg-bootstrap-${process.pid}-${Date.now()}`);
567
+ // The rebase keeps each commit's AUTHOR; this only decides the committer
568
+ // (and the author of the reconcile commit, which is the CLI's own).
569
+ const identityEnv = await committerFallbackEnv(dir);
570
+ // Ctrl-C between `worktree add` and the `finally` would leave the temp
571
+ // worktree registered in the customer's repo; drop it synchronously.
572
+ const onInterrupt = () => {
573
+ try {
574
+ execFileSync('git', [...GIT_SAFE_ARGS, '-C', dir, 'worktree', 'remove', '--force', worktree], { stdio: 'ignore' });
575
+ }
576
+ catch {
577
+ /* best effort — `git worktree prune` also clears it later */
578
+ }
579
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
580
+ process.exit(130);
581
+ };
582
+ process.once('SIGINT', onInterrupt);
583
+ try {
584
+ await runGit(['-C', dir, 'worktree', 'add', '--detach', worktree, localHead]);
585
+ try {
586
+ await runGit([
587
+ '-C',
588
+ worktree,
589
+ ...GIT_UNCONFIGURED_ARGS,
590
+ 'rebase',
591
+ '--root',
592
+ '--onto',
593
+ remoteHead,
594
+ '-X',
595
+ 'theirs',
596
+ '--committer-date-is-author-date',
597
+ ], { env: identityEnv });
598
+ }
599
+ catch (error) {
600
+ try {
601
+ await runGit(['-C', worktree, 'rebase', '--abort']);
602
+ }
603
+ catch {
604
+ /* nothing to abort */
605
+ }
606
+ throw new Error([
607
+ 'Could not replay your history onto the workspace repository.',
608
+ 'Nothing was pushed and your local repository is unchanged.',
609
+ '',
610
+ 'To land the files you have now WITHOUT their history, run:',
611
+ ` qfg push --dir ${dir}`,
612
+ '',
613
+ String(error),
614
+ ].join('\n'));
615
+ }
616
+ let candidate = await revParse(worktree, 'HEAD');
617
+ if (candidate === null)
618
+ throw new Error('The rebase produced no commit. Nothing was pushed.');
619
+ let reconcileSubject = null;
620
+ let mismatch = await treeMismatch(dir, { candidate, local: localHead, remoteQuonfigJson, seededOnly });
621
+ if (hasMismatch(mismatch)) {
622
+ // Honest subject: when the ONLY thing being put back is the workspace's
623
+ // own pin file, nothing was "reconciled" and saying so would be a lie.
624
+ reconcileSubject = mismatch.content.length > 0 ? RECONCILE_SUBJECT : PIN_SUBJECT;
625
+ const body = reconcileSubject === RECONCILE_SUBJECT
626
+ ? 'Sets the tree to the state of the local repository, keeping the files this workspace already had. Replaying merge commits one by one can otherwise resolve a conflict differently than you did.'
627
+ : 'The hosted workspace seeds its own quonfig.json, which carries the workspace pin and its environment list, so it wins over the one in the local repository.';
628
+ const keepFromRemote = [...seededOnly];
629
+ if (remoteQuonfigJson !== null && !seededOnly.has('quonfig.json'))
630
+ keepFromRemote.push('quonfig.json');
631
+ try {
632
+ await runGit(['-C', worktree, 'restore', `--source=${localHead}`, '--staged', '--worktree', '--', '.']);
633
+ if (keepFromRemote.length > 0) {
634
+ await runGit([
635
+ '-C',
636
+ worktree,
637
+ 'restore',
638
+ `--source=${remoteHead}`,
639
+ '--staged',
640
+ '--worktree',
641
+ '--',
642
+ ...keepFromRemote,
643
+ ]);
644
+ }
645
+ await runGit(['-C', worktree, ...GIT_UNCONFIGURED_ARGS, 'commit', '--no-verify', '-m', reconcileSubject, '-m', body], { env: identityEnv });
646
+ }
647
+ catch (error) {
648
+ throw new Error(`Could not reconcile your local content with the workspace repository: ${String(error)}\nNothing was pushed and your local repository is unchanged.`);
649
+ }
650
+ candidate = await revParse(worktree, 'HEAD');
651
+ if (candidate === null)
652
+ throw new Error('The reconcile commit produced no commit. Nothing was pushed.');
653
+ mismatch = await treeMismatch(dir, { candidate, local: localHead, remoteQuonfigJson, seededOnly });
654
+ if (hasMismatch(mismatch)) {
655
+ throw new Error(`Refusing to push: the result would not match your local files (${describeMismatch(mismatch)}).\nNothing was pushed and your local repository is unchanged.`);
656
+ }
657
+ }
658
+ // Validate what is ACTUALLY going to be pushed. The worktree holds exactly
659
+ // the candidate's tree, which is the local content plus the workspace's own
660
+ // quonfig.json — so an environment that exists only in the local pin file
661
+ // fails HERE, with a readable message, instead of at the server hook.
662
+ if (opts?.validate !== false)
663
+ await requireWorkspaceValidates(worktree);
664
+ await runGit(['-C', dir, 'push', 'origin', `${candidate}:refs/heads/main`]);
665
+ return { commitsRebased: await countCommits(dir, localHead), pushedSha: candidate, reconcileSubject };
666
+ }
667
+ finally {
668
+ process.off('SIGINT', onInterrupt);
669
+ await removeWorktree(dir, worktree);
670
+ }
373
671
  };
374
672
  export const hasAtLeastOneCommit = async (dir) => {
375
673
  try {
@@ -10,6 +10,37 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
+ export type RefUpdate = {
14
+ newOid: string;
15
+ oldOid: string;
16
+ refName: string;
17
+ };
18
+ export type HookEnv = Record<string, string | undefined>;
19
+ export type HookOptions = {
20
+ cwd?: string;
21
+ env: HookEnv;
22
+ log: (line: string) => void;
23
+ logErr: (line: string) => void;
24
+ };
25
+ /**
26
+ * The whole pre-receive decision, as a function: returns the process exit code
27
+ * (0 accept, 1 reject). Split out of runGitHook so tests can drive it against
28
+ * real bare repos without a subprocess.
29
+ */
30
+ export declare function runHookChecks(refs: readonly RefUpdate[], opts: HookOptions): number;
31
+ /** The `cli` sha this binary was built from; `dev` for an unstamped build. */
32
+ export declare function hookVersion(env?: HookEnv): string;
33
+ /**
34
+ * `main` is append-only: every update to refs/heads/main must have a non-zero
35
+ * old oid that is an ancestor of the new oid. A delete and a create are both
36
+ * rejected (provisioning creates `main` with auto_init; no writer creates it by
37
+ * push, and the zero-oid door is the only way to delete-then-recreate).
38
+ * Other refs are untouched. See plan 2026-09-17-tree-derived-cache 5.6.
39
+ */
40
+ export declare function checkAppendOnlyMain(refs: readonly RefUpdate[], env: HookEnv, cwd?: string): {
41
+ bypassReason?: string;
42
+ errors: string[];
43
+ };
13
44
  /**
14
45
  * Read all JSON config files from a git commit using `git show`.
15
46
  * Works in bare repos (no working tree needed).
@@ -10,7 +10,7 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- import { execFileSync } from 'node:child_process';
13
+ import { execFileSync, spawnSync } from 'node:child_process';
14
14
  import * as readline from 'node:readline';
15
15
  import { formatResult, validateFileMap, validateWorkspace } from './validate.js';
16
16
  async function main() {
@@ -57,36 +57,181 @@ async function runGitHook() {
57
57
  rl.on('line', (line) => {
58
58
  const parts = line.trim().split(' ');
59
59
  if (parts.length >= 3) {
60
- refs.push({ oldOid: parts[0], newOid: parts[1], refName: parts[2] });
60
+ refs.push({ newOid: parts[1], oldOid: parts[0], refName: parts[2] });
61
61
  }
62
62
  });
63
63
  rl.on('close', resolve);
64
64
  });
65
+ process.exit(runHookChecks(refs, { env: process.env, log: console.log, logErr: console.error }));
66
+ }
67
+ /**
68
+ * The whole pre-receive decision, as a function: returns the process exit code
69
+ * (0 accept, 1 reject). Split out of runGitHook so tests can drive it against
70
+ * real bare repos without a subprocess.
71
+ */
72
+ export function runHookChecks(refs, opts) {
73
+ const { cwd, env, log, logErr } = opts;
74
+ // Always the first line, so a rollout or rollback can be confirmed by
75
+ // something other than behaviour (plan 5.6 item 5).
76
+ log(`qfg-verify ${hookVersion(env)}`);
65
77
  if (refs.length === 0) {
66
- console.log('qfg-verify: no refs received');
67
- process.exit(0);
78
+ log('qfg-verify: no refs received');
79
+ return 0;
68
80
  }
69
- // Validate the latest pushed commit for each ref
70
81
  let hasErrors = false;
82
+ // Append-only `main` (qfg-jxml.21). Runs BEFORE content validation, but does
83
+ // not short-circuit it: an operator repair is still validated for content.
84
+ const appendOnly = checkAppendOnlyMain(refs, env, cwd);
85
+ if (appendOnly.bypassReason) {
86
+ logErr(`qfg-verify: operator rewrite bypass by ${env.GITEA_PUSHER_NAME ?? '?'}: ${appendOnly.bypassReason}`);
87
+ }
88
+ for (const message of appendOnly.errors) {
89
+ logErr(message);
90
+ hasErrors = true;
91
+ }
92
+ // Content validation: the latest pushed commit for each ref.
71
93
  for (const ref of refs) {
72
94
  // Skip deletions
73
- if (ref.newOid === '0000000000000000000000000000000000000000')
95
+ if (isZeroOid(ref.newOid))
74
96
  continue;
75
- console.log(`qfg-verify: validating ${ref.refName} (${ref.newOid.slice(0, 8)})`);
97
+ log(`qfg-verify: validating ${ref.refName} (${ref.newOid.slice(0, 8)})`);
76
98
  try {
77
- const files = readFilesFromCommit(ref.newOid);
99
+ const files = readFilesFromCommit(ref.newOid, cwd);
78
100
  const result = validateFileMap(files);
79
- console.log(formatResult(result));
101
+ log(formatResult(result));
80
102
  if (!result.valid) {
81
103
  hasErrors = true;
82
104
  }
83
105
  }
84
106
  catch (error) {
85
- console.error(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
107
+ logErr(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
86
108
  hasErrors = true;
87
109
  }
88
110
  }
89
- process.exit(hasErrors ? 1 : 0);
111
+ return hasErrors ? 1 : 0;
112
+ }
113
+ function isZeroOid(oid) {
114
+ return /^0+$/.test(oid);
115
+ }
116
+ /** The `cli` sha this binary was built from; `dev` for an unstamped build. */
117
+ export function hookVersion(env = process.env) {
118
+ return env.QFG_VERIFY_SHA?.trim() || 'dev';
119
+ }
120
+ const MAIN_REF = 'refs/heads/main';
121
+ const REWRITE_OPTION = 'quonfig-rewrite=';
122
+ /**
123
+ * `main` is append-only: every update to refs/heads/main must have a non-zero
124
+ * old oid that is an ancestor of the new oid. A delete and a create are both
125
+ * rejected (provisioning creates `main` with auto_init; no writer creates it by
126
+ * push, and the zero-oid door is the only way to delete-then-recreate).
127
+ * Other refs are untouched. See plan 2026-09-17-tree-derived-cache 5.6.
128
+ */
129
+ export function checkAppendOnlyMain(refs, env, cwd) {
130
+ const mainUpdates = refs.filter((ref) => ref.refName === MAIN_REF);
131
+ if (mainUpdates.length === 0 || !ffEnforcedForRepo(env))
132
+ return { errors: [] };
133
+ const bypassReason = operatorBypassReason(env);
134
+ if (bypassReason)
135
+ return { bypassReason, errors: [] };
136
+ const errors = [];
137
+ for (const ref of mainUpdates) {
138
+ try {
139
+ if (isZeroOid(ref.newOid)) {
140
+ errors.push(rejection('deleting main is not allowed'));
141
+ }
142
+ else if (isZeroOid(ref.oldOid)) {
143
+ errors.push(rejection('creating main by push is not allowed'));
144
+ }
145
+ else if (!isAncestor(ref.oldOid, ref.newOid, cwd)) {
146
+ errors.push(rejection(`${ref.newOid.slice(0, 8)} is not a descendant of the current tip ${ref.oldOid.slice(0, 8)}`));
147
+ }
148
+ }
149
+ catch (error) {
150
+ // The ancestry check itself failed: git exited 128 (old oid missing from
151
+ // the repo), git could not be spawned, ... Fail CLOSED, but as the normal
152
+ // rejection with the recovery recipe — an escaped throw reaches the
153
+ // pusher as an opaque `qfg-verify: fatal: ...` (qfg-jxml.21 review).
154
+ errors.push(rejection(`could not check that ${ref.newOid.slice(0, 8)} descends from the current tip ${ref.oldOid.slice(0, 8)}: ${error.message}`));
155
+ }
156
+ }
157
+ return { errors };
158
+ }
159
+ /** Rollout switch: enforce only for repos named in the allowlist; `*` = all. */
160
+ function ffEnforcedForRepo(env) {
161
+ const allowlist = (env.QUONFIG_HOOK_FF_ENFORCE ?? '')
162
+ .split(',')
163
+ .map((entry) => entry.trim().toLowerCase())
164
+ .filter(Boolean);
165
+ if (allowlist.length === 0)
166
+ return false;
167
+ if (allowlist.includes('*'))
168
+ return true;
169
+ // Gitea 1.25 hands the hook its repo via these (modules/repository/env.go).
170
+ const owner = (env.GITEA_REPO_USER_NAME ?? '').trim().toLowerCase();
171
+ const name = (env.GITEA_REPO_NAME ?? '').trim().toLowerCase();
172
+ if (!owner || !name)
173
+ return false;
174
+ return allowlist.includes(`${owner}/${name}`);
175
+ }
176
+ /**
177
+ * Operator bypass, two keys, BOTH required: the pusher is the operator admin
178
+ * account (the app pushes as admin too, so identity only excludes customers)
179
+ * AND the push declares a reason with `-o quonfig-rewrite=<reason>` (which is
180
+ * what excludes accidents). No flag-file fallback.
181
+ */
182
+ function operatorBypassReason(env) {
183
+ const operator = (env.QUONFIG_HOOK_OPERATOR ?? '').trim();
184
+ const pusher = (env.GITEA_PUSHER_NAME ?? '').trim();
185
+ if (!operator || !pusher || pusher.toLowerCase() !== operator.toLowerCase())
186
+ return undefined;
187
+ const count = Number.parseInt(env.GIT_PUSH_OPTION_COUNT ?? '', 10);
188
+ if (!Number.isInteger(count))
189
+ return undefined;
190
+ for (let index = 0; index < count; index++) {
191
+ const option = env[`GIT_PUSH_OPTION_${index}`] ?? '';
192
+ if (option.startsWith(REWRITE_OPTION)) {
193
+ const reason = option.slice(REWRITE_OPTION.length).trim();
194
+ if (reason)
195
+ return reason;
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+ /**
201
+ * `git merge-base --is-ancestor` via spawnSync, never simple-git: simple-git
202
+ * swallows a non-zero exit with no stderr, so a diverged pair would RESOLVE as
203
+ * "ancestor" and the rule would be vacuous (plan 12.2 S2). Exit 1 = not an
204
+ * ancestor; any other non-zero is an error and the push is rejected.
205
+ * The env is inherited as-is so Gitea's quarantine (GIT_QUARANTINE_PATH,
206
+ * GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES) still resolves the
207
+ * incoming objects.
208
+ */
209
+ function isAncestor(oldOid, newOid, cwd) {
210
+ const result = spawnSync('git', ['merge-base', '--is-ancestor', oldOid, newOid], { cwd, encoding: 'utf8' });
211
+ if (result.error)
212
+ throw result.error;
213
+ if (result.status === 0)
214
+ return true;
215
+ if (result.status === 1)
216
+ return false;
217
+ throw new Error(`git merge-base --is-ancestor exited ${result.status}: ${(result.stderr || '').trim()}`);
218
+ }
219
+ function rejection(what) {
220
+ return `qfg-verify: REJECTED ${MAIN_REF}: ${what}.
221
+
222
+ main is append-only: every push must fast-forward from the current tip. What to do:
223
+
224
+ * Lost a race with another writer -- pull and rebase, then push again:
225
+ qfg pull && qfg push (or: git pull --rebase origin main && git push origin main)
226
+
227
+ * Want the content from an earlier commit -- restore it as a FORWARD commit:
228
+ git restore --source=<sha> --staged --worktree -- .
229
+ git commit -m "restore <sha>"
230
+ qfg push
231
+
232
+ * This came from \`qfg workspace bootstrap\` on an older CLI, which force-pushes --
233
+ upgrade the CLI and run it again:
234
+ npm install -g @quonfig/cli@latest`;
90
235
  }
91
236
  /**
92
237
  * Read all JSON config files from a git commit using `git show`.
@@ -47,6 +47,8 @@ export interface ValidationResult {
47
47
  stats: ValidationStats;
48
48
  valid: boolean;
49
49
  }
50
+ /** Every directory this validator owns: config documents plus schemas. */
51
+ export declare const KNOWN_DIRS: Set<string>;
50
52
  /**
51
53
  * Validate an entire workspace directory on disk.
52
54
  */
@@ -157,9 +157,11 @@ const DIR_TO_TYPE = {
157
157
  segments: 'segment',
158
158
  'log-levels': 'log_level',
159
159
  };
160
+ /** The directories a config document can live in. Source of truth. */
160
161
  const CONFIG_DIRS = new Set(Object.keys(DIR_TO_TYPE));
161
162
  const SCHEMA_DIRS = new Set(['schemas', 'schemas-protected']);
162
- const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
163
+ /** Every directory this validator owns: config documents plus schemas. */
164
+ export const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
163
165
  // ── Ghost-file prevention (qfg-hbuy.4) ──────────────────────────────────
164
166
  // Within the validated content dirs (KNOWN_DIRS), every entry must be a
165
167
  // top-level, non-dot, lowercase-`.json` file. Anything else used to be
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.73";
1
+ declare const _default: "0.1.0";
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.73';
2
+ export default '0.1.0';
@@ -1600,7 +1600,7 @@
1600
1600
  "type": "boolean"
1601
1601
  },
1602
1602
  "env": {
1603
- "description": "environment to operate in (default: $QUONFIG_ENVIRONMENT)",
1603
+ "description": "environment to operate in (required; QUONFIG_ENVIRONMENT is never read)",
1604
1604
  "name": "env",
1605
1605
  "hasDynamicHelp": false,
1606
1606
  "multiple": false,
@@ -2176,7 +2176,8 @@
2176
2176
  "type": "option"
2177
2177
  },
2178
2178
  "environment": {
2179
- "description": "Which environment slug to evaluate. Honors QUONFIG_ENVIRONMENT.",
2179
+ "description": "Which environment slug to evaluate. Flag wins, then QUONFIG_ENVIRONMENT (serve hosts a service, so the var is its identity), then development.",
2180
+ "env": "QUONFIG_ENVIRONMENT",
2180
2181
  "name": "environment",
2181
2182
  "default": "development",
2182
2183
  "hasDynamicHelp": false,
@@ -3981,7 +3982,7 @@
3981
3982
  "type": "option"
3982
3983
  },
3983
3984
  "force": {
3984
- "description": "Force push even if remote already has commits",
3985
+ "description": "Accepted and ignored (no-op). Bootstrap never rewrites the workspace history.",
3985
3986
  "name": "force",
3986
3987
  "allowNo": false,
3987
3988
  "type": "boolean"
@@ -4149,5 +4150,5 @@
4149
4150
  ]
4150
4151
  }
4151
4152
  },
4152
- "version": "0.0.73"
4153
+ "version": "0.1.0"
4153
4154
  }
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.73",
4
+ "version": "0.1.0",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },