@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.
- package/README.md +54 -53
- package/dist/commands/override.js +7 -3
- package/dist/commands/serve.js +6 -3
- package/dist/commands/workspace/bootstrap.d.ts +0 -8
- package/dist/commands/workspace/bootstrap.js +87 -95
- package/dist/util/bootstrap-target.d.ts +40 -0
- package/dist/util/bootstrap-target.js +46 -0
- package/dist/util/clone-and-stack-push.d.ts +1 -4
- package/dist/util/clone-and-stack-push.js +2 -5
- package/dist/util/git-ops.d.ts +60 -2
- package/dist/util/git-ops.js +303 -5
- package/dist/verify/standalone.d.ts +31 -0
- package/dist/verify/standalone.js +156 -11
- package/dist/verify/validate.d.ts +2 -0
- package/dist/verify/validate.js +3 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +5 -4
- package/package.json +1 -1
package/dist/util/git-ops.js
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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({
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
95
|
+
if (isZeroOid(ref.newOid))
|
|
74
96
|
continue;
|
|
75
|
-
|
|
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
|
-
|
|
101
|
+
log(formatResult(result));
|
|
80
102
|
if (!result.valid) {
|
|
81
103
|
hasErrors = true;
|
|
82
104
|
}
|
|
83
105
|
}
|
|
84
106
|
catch (error) {
|
|
85
|
-
|
|
107
|
+
logErr(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
|
|
86
108
|
hasErrors = true;
|
|
87
109
|
}
|
|
88
110
|
}
|
|
89
|
-
|
|
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
|
*/
|
package/dist/verify/validate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
2
|
+
export default '0.1.0';
|
package/oclif.manifest.json
CHANGED
|
@@ -1600,7 +1600,7 @@
|
|
|
1600
1600
|
"type": "boolean"
|
|
1601
1601
|
},
|
|
1602
1602
|
"env": {
|
|
1603
|
-
"description": "environment to operate in (
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|