forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8
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/CHANGELOG.md +24 -0
- package/bin/forge-gh-proxy.js +15 -0
- package/bin/forge-github-credential.js +21 -0
- package/bin/forge.js +16 -1
- package/docs/guides/MIGRATION.md +4 -3
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/VALIDATION.md +14 -0
- package/docs/reference/github-accounts.md +114 -38
- package/lib/bun-workflow-pins.js +129 -3
- package/lib/commands/github.js +234 -49
- package/lib/commands/push.js +298 -46
- package/lib/commands/release.js +18 -1
- package/lib/commands/test.js +8 -0
- package/lib/commands/validate.js +209 -69
- package/lib/gh-proxy.js +309 -0
- package/lib/github-context.js +160 -25
- package/lib/github-credential.js +47 -0
- package/lib/github-router.js +774 -0
- package/lib/native-gh.js +177 -0
- package/lib/npm-publish-workflow.js +5 -5
- package/lib/pr-monitor/flow-monitor.js +125 -26
- package/lib/protected-state-authority.js +214 -13
- package/lib/release-readiness.js +57 -41
- package/lib/shell-utils.js +41 -15
- package/lib/test-workflow.js +389 -0
- package/lib/validation-receipt.js +127 -12
- package/lib/workflow-templates/test.yml +654 -0
- package/package.json +7 -5
- package/scripts/check-forge-token.js +85 -67
- package/scripts/gen-embedded-assets.mjs +31 -5
- package/scripts/test-full-suite.js +69 -15
- package/scripts/test.js +10 -0
package/lib/commands/validate.js
CHANGED
|
@@ -95,7 +95,6 @@ function getExecOptions(rootDir = process.cwd(), fullSuite = false) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const ERROR_PATTERNS = {
|
|
98
|
-
COMMAND_NOT_FOUND: ['ENOENT', 'not found'],
|
|
99
98
|
NO_LOCK_FILE: ['requires an existing', 'package-lock'],
|
|
100
99
|
GIT_LISTING_UNAVAILABLE: [
|
|
101
100
|
'not a git repository',
|
|
@@ -113,9 +112,7 @@ const ERROR_PATTERNS = {
|
|
|
113
112
|
* @private
|
|
114
113
|
*/
|
|
115
114
|
function isCommandNotFound(error) {
|
|
116
|
-
return
|
|
117
|
-
error.message.includes(pattern),
|
|
118
|
-
);
|
|
115
|
+
return error?.code === 'ENOENT';
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
function isGitListingUnavailableError(error) {
|
|
@@ -360,7 +357,7 @@ async function runTypeCheck() {
|
|
|
360
357
|
* const result = await runLint();
|
|
361
358
|
* console.log(`Warnings: ${result.warnings}, Errors: ${result.errors}`);
|
|
362
359
|
*/
|
|
363
|
-
async function runLint(exec =
|
|
360
|
+
async function runLint(exec = secureExecFileSync) {
|
|
364
361
|
const startTime = Date.now();
|
|
365
362
|
|
|
366
363
|
try {
|
|
@@ -577,6 +574,21 @@ async function runSecurityScan() { // NOSONAR S3776
|
|
|
577
574
|
*/
|
|
578
575
|
function parseTestCounts(output) {
|
|
579
576
|
const text = String(output || '');
|
|
577
|
+
const effectiveBudgetMatch = /Full suite resource budget: requested=(default|\d+) effective=(\d+)/.exec(text);
|
|
578
|
+
const rejectedBudgetMatch = /Full suite resource budget: requested=(\d+) minimum=(\d+) outcome=rejected/.exec(text);
|
|
579
|
+
let resourceBudget;
|
|
580
|
+
if (effectiveBudgetMatch) {
|
|
581
|
+
resourceBudget = {
|
|
582
|
+
requested: effectiveBudgetMatch[1] === 'default' ? null : Number(effectiveBudgetMatch[1]),
|
|
583
|
+
effective: Number(effectiveBudgetMatch[2]),
|
|
584
|
+
};
|
|
585
|
+
} else if (rejectedBudgetMatch) {
|
|
586
|
+
resourceBudget = {
|
|
587
|
+
requested: Number(rejectedBudgetMatch[1]),
|
|
588
|
+
minimum: Number(rejectedBudgetMatch[2]),
|
|
589
|
+
outcome: 'rejected',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
580
592
|
const aggregateMatches = [...text.matchAll(
|
|
581
593
|
/Full suite aggregate: status=(PASS|FAIL|INCOMPLETE) tests=(\d+) assertions=\d+ passed=(\d+) failed=(\d+) errors=(\d+) skipped=(\d+)/g,
|
|
582
594
|
)]; // NOSONAR S5852 - fixed tokens and bounded numeric fields
|
|
@@ -585,6 +597,7 @@ function parseTestCounts(output) {
|
|
|
585
597
|
const total = parseNumber(aggregate, 2);
|
|
586
598
|
return {
|
|
587
599
|
status: aggregate[1],
|
|
600
|
+
resourceBudget,
|
|
588
601
|
passed: parseNumber(aggregate, 3),
|
|
589
602
|
failed: parseNumber(aggregate, 4),
|
|
590
603
|
errors: parseNumber(aggregate, 5),
|
|
@@ -598,7 +611,24 @@ function parseTestCounts(output) {
|
|
|
598
611
|
const skipped = parseNumber(/(\d+) skip/.exec(text)); // NOSONAR S5852 - bounded \d+ pattern
|
|
599
612
|
const ranMatch = /Ran (\d+) tests/.exec(text); // NOSONAR S5852 - bounded \d+ pattern
|
|
600
613
|
const total = ranMatch ? parseNumber(ranMatch, 1) : passed + failed + skipped;
|
|
601
|
-
return { passed, failed, skipped, total, testsFound: total > 0 };
|
|
614
|
+
return { passed, failed, skipped, total, testsFound: total > 0, resourceBudget };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function parseResourceBudget(args = []) {
|
|
618
|
+
let budget = null;
|
|
619
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
620
|
+
if (args[index] !== '--shards') continue;
|
|
621
|
+
const value = args[index + 1];
|
|
622
|
+
if (!/^[1-9]\d*$/.test(String(value ?? ''))) {
|
|
623
|
+
throw new TypeError('--shards must be a positive integer resource budget');
|
|
624
|
+
}
|
|
625
|
+
budget = Number(value);
|
|
626
|
+
if (!Number.isSafeInteger(budget)) {
|
|
627
|
+
throw new TypeError('--shards must be a positive integer resource budget');
|
|
628
|
+
}
|
|
629
|
+
index += 1;
|
|
630
|
+
}
|
|
631
|
+
return budget;
|
|
602
632
|
}
|
|
603
633
|
|
|
604
634
|
function isForgeCheckout(rootDir) {
|
|
@@ -614,17 +644,142 @@ function isForgeCheckout(rootDir) {
|
|
|
614
644
|
}
|
|
615
645
|
}
|
|
616
646
|
|
|
617
|
-
|
|
647
|
+
function isValidationRootAvailable(rootDir) {
|
|
648
|
+
try {
|
|
649
|
+
return fs.statSync(rootDir).isDirectory();
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function unavailableValidationRootResult(startTime) {
|
|
656
|
+
return {
|
|
657
|
+
success: false,
|
|
658
|
+
testsFound: false,
|
|
659
|
+
fullSuite: false,
|
|
660
|
+
duration: Date.now() - startTime,
|
|
661
|
+
passed: 0,
|
|
662
|
+
failed: 0,
|
|
663
|
+
total: 0,
|
|
664
|
+
message: 'Test execution failed: validation root directory is unavailable',
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function testExecutionErrorResult(error, startTime, rootDir, useFullSuite, timeout) {
|
|
669
|
+
const output = error.stdout || error.message;
|
|
670
|
+
const counts = parseTestCounts(output);
|
|
671
|
+
const budgetEvidence = counts.resourceBudget ? { resourceBudget: counts.resourceBudget } : {};
|
|
672
|
+
if (error.code === 'ETIMEDOUT' || (error.killed && error.signal === 'SIGTERM')) {
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
fullSuite: useFullSuite,
|
|
676
|
+
...budgetEvidence,
|
|
677
|
+
duration: Date.now() - startTime,
|
|
678
|
+
passed: 0,
|
|
679
|
+
failed: 0,
|
|
680
|
+
total: 0,
|
|
681
|
+
message: validationTimeoutMessage('Test execution', timeout),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (isCommandNotFound(error)) {
|
|
686
|
+
if (!isValidationRootAvailable(rootDir)) {
|
|
687
|
+
return { ...unavailableValidationRootResult(startTime), ...budgetEvidence };
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
success: true,
|
|
691
|
+
skipped: true,
|
|
692
|
+
testsFound: false,
|
|
693
|
+
fullSuite: useFullSuite,
|
|
694
|
+
...budgetEvidence,
|
|
695
|
+
duration: Date.now() - startTime,
|
|
696
|
+
passed: 0,
|
|
697
|
+
failed: 0,
|
|
698
|
+
total: 0,
|
|
699
|
+
message: 'Tests skipped: test runner not found. Install Node.js and Bun to run tests.',
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (counts.resourceBudget?.outcome === 'rejected') {
|
|
704
|
+
return {
|
|
705
|
+
success: false,
|
|
706
|
+
testsFound: false,
|
|
707
|
+
fullSuite: useFullSuite,
|
|
708
|
+
resourceBudget: counts.resourceBudget,
|
|
709
|
+
duration: Date.now() - startTime,
|
|
710
|
+
passed: 0,
|
|
711
|
+
failed: 0,
|
|
712
|
+
total: 0,
|
|
713
|
+
message: `Full suite resource budget rejected: requested=${counts.resourceBudget.requested} minimum=${counts.resourceBudget.minimum}`,
|
|
714
|
+
output,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
const passed = counts.passed;
|
|
718
|
+
const failed = counts.failed > 0 ? counts.failed : Math.max(1, counts.errors || 0);
|
|
719
|
+
const total = counts.total > 0 ? counts.total : passed + failed + counts.skipped;
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
success: false,
|
|
723
|
+
testsFound: counts.testsFound,
|
|
724
|
+
fullSuite: useFullSuite,
|
|
725
|
+
resourceBudget: counts.resourceBudget,
|
|
726
|
+
duration: Date.now() - startTime,
|
|
727
|
+
passed,
|
|
728
|
+
failed,
|
|
729
|
+
total,
|
|
730
|
+
message: `${failed}/${total} tests failed`,
|
|
731
|
+
output,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function runAllTests(exec = execFileSync, rootDir = process.cwd(), resourceBudget = null) {
|
|
618
736
|
const startTime = Date.now();
|
|
737
|
+
if (!isValidationRootAvailable(rootDir)) return unavailableValidationRootResult(startTime);
|
|
619
738
|
const useFullSuite = isForgeCheckout(rootDir);
|
|
739
|
+
if (resourceBudget !== null && !useFullSuite) {
|
|
740
|
+
return {
|
|
741
|
+
success: false,
|
|
742
|
+
testsFound: false,
|
|
743
|
+
fullSuite: false,
|
|
744
|
+
duration: Date.now() - startTime,
|
|
745
|
+
passed: 0,
|
|
746
|
+
failed: 0,
|
|
747
|
+
total: 0,
|
|
748
|
+
message: '--shards requires the canonical Forge full-suite runner',
|
|
749
|
+
};
|
|
750
|
+
}
|
|
620
751
|
const command = useFullSuite ? 'node' : 'bun';
|
|
621
752
|
const args = useFullSuite ? ['scripts/test-full-suite.js'] : ['test', '--timeout', '30000'];
|
|
753
|
+
if (useFullSuite && resourceBudget !== null) args.push('--shards', String(resourceBudget));
|
|
622
754
|
const execOptions = getExecOptions(rootDir, useFullSuite);
|
|
623
755
|
|
|
624
756
|
try {
|
|
625
757
|
const result = exec(command, args, execOptions); // NOSONAR S4036 - fixed CLI commands, no user input, developer tool context
|
|
626
758
|
|
|
627
|
-
const {
|
|
759
|
+
const {
|
|
760
|
+
status,
|
|
761
|
+
passed,
|
|
762
|
+
failed,
|
|
763
|
+
errors = 0,
|
|
764
|
+
skipped,
|
|
765
|
+
total,
|
|
766
|
+
testsFound,
|
|
767
|
+
resourceBudget: budgetEvidence,
|
|
768
|
+
} = parseTestCounts(result);
|
|
769
|
+
if (useFullSuite && status !== 'PASS') {
|
|
770
|
+
return {
|
|
771
|
+
success: false,
|
|
772
|
+
testsFound,
|
|
773
|
+
fullSuite: true,
|
|
774
|
+
resourceBudget: budgetEvidence,
|
|
775
|
+
duration: Date.now() - startTime,
|
|
776
|
+
passed,
|
|
777
|
+
failed,
|
|
778
|
+
skipped,
|
|
779
|
+
total,
|
|
780
|
+
message: 'Full suite did not report a passing terminal aggregate',
|
|
781
|
+
};
|
|
782
|
+
}
|
|
628
783
|
const success = failed === 0 && errors === 0 && (status === undefined || status === 'PASS');
|
|
629
784
|
|
|
630
785
|
// B2 (N1): a bun run that executes 0 tests must NEVER report PASS — that
|
|
@@ -647,64 +802,16 @@ async function runAllTests(exec = execFileSync, rootDir = process.cwd()) {
|
|
|
647
802
|
success,
|
|
648
803
|
testsFound: true,
|
|
649
804
|
fullSuite: useFullSuite,
|
|
805
|
+
resourceBudget: budgetEvidence,
|
|
650
806
|
duration: Date.now() - startTime,
|
|
651
807
|
passed,
|
|
652
808
|
failed,
|
|
653
809
|
skipped,
|
|
654
810
|
total,
|
|
655
|
-
message: success
|
|
656
|
-
? `All ${total} tests passed`
|
|
657
|
-
: `${failed + errors}/${total} tests failed`,
|
|
811
|
+
message: success ? `All ${total} tests passed` : `${failed + errors}/${total} tests failed`,
|
|
658
812
|
};
|
|
659
813
|
} catch (error) {
|
|
660
|
-
|
|
661
|
-
if (error.code === 'ETIMEDOUT' || (error.killed && error.signal === 'SIGTERM')) {
|
|
662
|
-
return {
|
|
663
|
-
success: false,
|
|
664
|
-
fullSuite: useFullSuite,
|
|
665
|
-
duration: Date.now() - startTime,
|
|
666
|
-
passed: 0,
|
|
667
|
-
failed: 0,
|
|
668
|
-
total: 0,
|
|
669
|
-
message: validationTimeoutMessage('Test execution', execOptions.timeout),
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Test runner absent — say so explicitly (do NOT silently pass).
|
|
674
|
-
if (isCommandNotFound(error)) {
|
|
675
|
-
return {
|
|
676
|
-
success: true,
|
|
677
|
-
skipped: true,
|
|
678
|
-
testsFound: false,
|
|
679
|
-
fullSuite: useFullSuite,
|
|
680
|
-
duration: Date.now() - startTime,
|
|
681
|
-
passed: 0,
|
|
682
|
-
failed: 0,
|
|
683
|
-
total: 0,
|
|
684
|
-
message: 'Tests skipped: test runner not found. Install Node.js and Bun to run tests.',
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Parse test failures from output
|
|
689
|
-
const output = error.stdout || error.message;
|
|
690
|
-
const counts = parseTestCounts(output);
|
|
691
|
-
const passed = counts.passed;
|
|
692
|
-
// bun exited non-zero: if the output shows no explicit failure count, treat
|
|
693
|
-
// it as at least one failure rather than swallowing the non-zero exit.
|
|
694
|
-
const failed = counts.failed > 0 ? counts.failed : Math.max(1, counts.errors || 0);
|
|
695
|
-
const total = counts.total > 0 ? counts.total : passed + failed + counts.skipped;
|
|
696
|
-
|
|
697
|
-
return {
|
|
698
|
-
success: false,
|
|
699
|
-
testsFound: counts.testsFound,
|
|
700
|
-
fullSuite: useFullSuite,
|
|
701
|
-
duration: Date.now() - startTime,
|
|
702
|
-
passed,
|
|
703
|
-
failed,
|
|
704
|
-
total,
|
|
705
|
-
message: `${failed}/${total} tests failed`,
|
|
706
|
-
output,
|
|
707
|
-
};
|
|
814
|
+
return testExecutionErrorResult(error, startTime, rootDir, useFullSuite, execOptions.timeout);
|
|
708
815
|
}
|
|
709
816
|
}
|
|
710
817
|
|
|
@@ -730,16 +837,27 @@ async function runAllTests(exec = execFileSync, rootDir = process.cwd()) {
|
|
|
730
837
|
* console.log(result.summary);
|
|
731
838
|
*/
|
|
732
839
|
async function executeValidate(options = {}) { // NOSONAR S3776
|
|
733
|
-
const {
|
|
840
|
+
const {
|
|
841
|
+
skip = [],
|
|
842
|
+
continueOnError = true,
|
|
843
|
+
rootDir = process.cwd(),
|
|
844
|
+
resourceBudget = null,
|
|
845
|
+
} = options || {};
|
|
846
|
+
const startTime = Date.now();
|
|
847
|
+
if (!isValidationRootAvailable(rootDir)) {
|
|
848
|
+
const checks = { tests: unavailableValidationRootResult(startTime) };
|
|
849
|
+
const result = buildResult(checks, [CHECK_TYPES.TESTS], [], startTime);
|
|
850
|
+
result.validationReceipt = false;
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
734
853
|
const receiptApi = options?.validationReceipt || validationReceipt;
|
|
854
|
+
const testRunner = options?.runAllTests || runAllTests;
|
|
735
855
|
const validationSnapshot = receiptApi.beginValidation(rootDir);
|
|
736
856
|
const receiptSnapshot = skip.length === 0 ? validationSnapshot : null;
|
|
737
857
|
|
|
738
858
|
const checks = {};
|
|
739
859
|
const failedChecks = [];
|
|
740
860
|
const errors = [];
|
|
741
|
-
const startTime = Date.now();
|
|
742
|
-
|
|
743
861
|
// 0. Conflict markers
|
|
744
862
|
if (!skip.includes(CHECK_TYPES.CONFLICT_MARKERS)) {
|
|
745
863
|
try {
|
|
@@ -807,7 +925,7 @@ async function executeValidate(options = {}) { // NOSONAR S3776
|
|
|
807
925
|
// 4. Tests
|
|
808
926
|
if (!skip.includes(CHECK_TYPES.TESTS)) {
|
|
809
927
|
try {
|
|
810
|
-
checks.tests = await
|
|
928
|
+
checks.tests = await testRunner(execFileSync, rootDir, resourceBudget);
|
|
811
929
|
if (!checks.tests.success && checks.tests.skipped !== true) {
|
|
812
930
|
failedChecks.push(CHECK_TYPES.TESTS);
|
|
813
931
|
if (!continueOnError) {
|
|
@@ -897,21 +1015,43 @@ function executeDebugMode({ fixAttempts = 0, claim } = {}) {
|
|
|
897
1015
|
module.exports = {
|
|
898
1016
|
name: 'validate',
|
|
899
1017
|
description: 'Run validation checks across type, lint, security, and tests',
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1018
|
+
usage: 'forge validate [--shards N]',
|
|
1019
|
+
flags: {
|
|
1020
|
+
'--shards N': 'Set the full-suite resource budget',
|
|
1021
|
+
},
|
|
1022
|
+
handler: async (args = [], _flags = {}, projectRoot = process.cwd(), deps = {}) => {
|
|
1023
|
+
let resourceBudget;
|
|
1024
|
+
try {
|
|
1025
|
+
resourceBudget = parseResourceBudget(args);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
return { success: false, error: error.message };
|
|
904
1028
|
}
|
|
905
|
-
|
|
1029
|
+
const result = await (deps.executeValidate || executeValidate)({
|
|
1030
|
+
rootDir: projectRoot,
|
|
1031
|
+
resourceBudget,
|
|
1032
|
+
});
|
|
1033
|
+
const errorParts = Array.isArray(result.errors) ? result.errors.filter(Boolean) : [];
|
|
1034
|
+
for (const check of Object.values(result.checks || {})) {
|
|
1035
|
+
if (check?.success !== false || check.skipped === true || !check.message) continue;
|
|
1036
|
+
if (!errorParts.some(error => error.endsWith(check.message))) errorParts.push(check.message);
|
|
1037
|
+
}
|
|
1038
|
+
const budget = result.checks?.tests?.resourceBudget;
|
|
1039
|
+
const budgetOutput = Number.isInteger(budget?.effective)
|
|
1040
|
+
? `Full suite resource budget: requested=${budget.requested ?? 'default'} effective=${budget.effective}`
|
|
1041
|
+
: null;
|
|
1042
|
+
const output = [result.success ? result.summary : null, budgetOutput].filter(Boolean).join('\n');
|
|
1043
|
+
const error = result.error || result.message || errorParts.join('; ') || result.summary;
|
|
906
1044
|
return {
|
|
907
1045
|
...result,
|
|
908
|
-
output:
|
|
1046
|
+
...(output ? { output } : {}),
|
|
1047
|
+
...(!result.success && error ? { error } : {}),
|
|
909
1048
|
};
|
|
910
1049
|
},
|
|
911
1050
|
runTypeCheck,
|
|
912
1051
|
runLint,
|
|
913
1052
|
runSecurityScan,
|
|
914
1053
|
runAllTests,
|
|
1054
|
+
parseResourceBudget,
|
|
915
1055
|
parseTestCounts,
|
|
916
1056
|
scanForConflictMarkers,
|
|
917
1057
|
executeValidate,
|
package/lib/gh-proxy.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync, spawnSync: nativeSpawnSync } = require('node:child_process');
|
|
4
|
+
const { createGithubTokenContext, readGithubAuto } = require('./github-context');
|
|
5
|
+
const {
|
|
6
|
+
commandName, exitCode, isForgeProxy, isLocalGhInvocation, resolveRealGh, runNativeGh, shortOptionValue,
|
|
7
|
+
withoutProxyMarker,
|
|
8
|
+
} = require('./native-gh');
|
|
9
|
+
const { assertGithubRouterCloneRegistered } = require('./github-router');
|
|
10
|
+
const { resolveLocalGithubTarget } = require('./commands/github');
|
|
11
|
+
|
|
12
|
+
const COMMAND_VALUE_FLAGS = new Set(['--hostname', '--repo', '-R', '--jq', '--template']);
|
|
13
|
+
const REPOSITORY_TARGET_POSITIONS = new Map([
|
|
14
|
+
['extension install', 2],
|
|
15
|
+
['issue transfer', 3],
|
|
16
|
+
['label clone', 2],
|
|
17
|
+
...['archive', 'clone', 'create', 'delete', 'edit', 'fork', 'new', 'set-default', 'sync', 'unarchive', 'view']
|
|
18
|
+
.map(verb => [`repo ${verb}`, 2]),
|
|
19
|
+
]);
|
|
20
|
+
const RESOURCE_TARGET_POSITIONS = new Map([
|
|
21
|
+
['co', 1],
|
|
22
|
+
['api', 1],
|
|
23
|
+
['agent-task view', 2],
|
|
24
|
+
...['clone', 'delete', 'edit', 'rename', 'view'].map(verb => [`gist ${verb}`, 2]),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function capturedRunner(realGh) {
|
|
28
|
+
return (command, args, options = {}) => {
|
|
29
|
+
if (command !== 'gh') return execFileSync(command, args, options);
|
|
30
|
+
const result = nativeSpawnSync(realGh, args, options);
|
|
31
|
+
if (result.error) throw result.error;
|
|
32
|
+
if (result.status !== 0) {
|
|
33
|
+
const error = new Error(`GitHub CLI exited with status ${result.status}.`);
|
|
34
|
+
Object.assign(error, { status: result.status, stdout: result.stdout, stderr: result.stderr });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return result.stdout || '';
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function environmentValue(environment, name, platform) {
|
|
42
|
+
if (platform !== 'win32') return environment[name] || undefined;
|
|
43
|
+
const key = Object.keys(environment).find(candidate => candidate.toUpperCase() === name);
|
|
44
|
+
return key ? (environment[key] || undefined) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resourceHostname(value) {
|
|
48
|
+
const text = String(value || '');
|
|
49
|
+
if (text.includes('://')) {
|
|
50
|
+
const url = new URL(text);
|
|
51
|
+
if (url.protocol !== 'https:' || url.port) throw new Error('Unsupported GitHub target URL.');
|
|
52
|
+
const hostname = url.hostname.toLowerCase();
|
|
53
|
+
return ['api.github.com', 'gist.github.com', 'uploads.github.com'].includes(hostname) ? 'github.com' : hostname;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function repositoryHostname(value) {
|
|
59
|
+
const text = String(value || '');
|
|
60
|
+
if (text.includes('://')) {
|
|
61
|
+
const url = new URL(text);
|
|
62
|
+
if (!['https:', 'ssh:'].includes(url.protocol) || (url.protocol === 'https:' && url.port)) {
|
|
63
|
+
throw new Error('Unsupported GitHub target URL.');
|
|
64
|
+
}
|
|
65
|
+
return url.hostname.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
const scpHost = /^(?:[^@\s/:]+@)?([^:\s/]+):[^/\s]+\/[^/\s]+$/.exec(text);
|
|
68
|
+
if (scpHost) return scpHost[1].toLowerCase();
|
|
69
|
+
if (text.includes(':')) throw new Error('Unknown GitHub repository destination.');
|
|
70
|
+
const parts = text.split('/');
|
|
71
|
+
if (parts.length === 2 && parts.every(Boolean)) return undefined;
|
|
72
|
+
if (parts.length === 3 && parts.every(Boolean)) return parts[0].toLowerCase();
|
|
73
|
+
throw new Error('Unknown GitHub repository destination.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function commandPath(args) {
|
|
77
|
+
const result = [];
|
|
78
|
+
let started = false;
|
|
79
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
80
|
+
const arg = args[index];
|
|
81
|
+
if (COMMAND_VALUE_FLAGS.has(arg)) { index += 1; continue; }
|
|
82
|
+
if (/^(?:--hostname|--repo|--jq|--template)=/.test(arg) || /^-R=?\S+/.test(arg)) continue;
|
|
83
|
+
if (arg === '--' || (started && arg.startsWith('-'))) break;
|
|
84
|
+
if (arg.startsWith('-')) continue;
|
|
85
|
+
started = true;
|
|
86
|
+
result.push(arg);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function flagTakesValue(help, flag) {
|
|
92
|
+
const matches = new Set();
|
|
93
|
+
for (const line of String(help || '').split(/\r?\n/)) {
|
|
94
|
+
const [declaration, description] = line.trim().split(/\s{2,}/, 2);
|
|
95
|
+
if (!description) continue;
|
|
96
|
+
const tokens = declaration.replaceAll(',', '').split(/\s+/);
|
|
97
|
+
if (tokens.includes(flag)) matches.add(tokens.some(token => !token.startsWith('-')));
|
|
98
|
+
}
|
|
99
|
+
return matches.size === 1 ? [...matches][0] : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function optionValueResolver(args, realGh, baseEnv, options) {
|
|
103
|
+
let help;
|
|
104
|
+
const readHelp = () => {
|
|
105
|
+
if (help !== undefined) return help;
|
|
106
|
+
if (options.readCommandHelp) help = String(options.readCommandHelp(commandPath(args)) || '');
|
|
107
|
+
else {
|
|
108
|
+
const pathParts = commandPath(args);
|
|
109
|
+
for (let length = pathParts.length; length > 0; length -= 1) {
|
|
110
|
+
try {
|
|
111
|
+
help = String((options.execFileSync || execFileSync)(realGh, ['help', ...pathParts.slice(0, length)], {
|
|
112
|
+
encoding: 'utf8', env: baseEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
|
|
113
|
+
}) || '');
|
|
114
|
+
break;
|
|
115
|
+
} catch { /* try the parent command */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return help || '';
|
|
119
|
+
};
|
|
120
|
+
const resolve = flag => flagTakesValue(readHelp(), flag);
|
|
121
|
+
resolve.help = readHelp;
|
|
122
|
+
return resolve;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isOptionValue(args, index, resolveOptionValue) {
|
|
126
|
+
const previous = args[index - 1];
|
|
127
|
+
if (!previous?.startsWith('-') || previous.includes('=')) return false;
|
|
128
|
+
if (COMMAND_VALUE_FLAGS.has(previous)) return true;
|
|
129
|
+
if (!previous.startsWith('--') && previous.length > 2) {
|
|
130
|
+
const option = shortOptionValue(previous, resolveOptionValue);
|
|
131
|
+
return Boolean(option && !option.attached);
|
|
132
|
+
}
|
|
133
|
+
const takesValue = resolveOptionValue(previous);
|
|
134
|
+
if (typeof takesValue !== 'boolean') throw new Error(`Unable to determine GitHub CLI option arity for ${previous}.`);
|
|
135
|
+
return takesValue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function positionalArguments(args, resolveOptionValue) {
|
|
139
|
+
const delimiter = args.indexOf('--');
|
|
140
|
+
return args.filter((arg, index) => arg !== '--' && ((delimiter >= 0 && index > delimiter)
|
|
141
|
+
|| (!arg.startsWith('-') && !isOptionValue(args, index, resolveOptionValue))));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function namedUrlPositions(help, positionalCount) {
|
|
145
|
+
const positions = new Set();
|
|
146
|
+
for (const line of String(help || '').split(/\r?\n/)) {
|
|
147
|
+
const usage = /^\s*(?:Usage:\s*)?gh\s+(.+)$/i.exec(line)?.[1];
|
|
148
|
+
if (!usage) continue;
|
|
149
|
+
const tokens = usage.match(/\[[^\]]*\]|\{[^}]*\}|<[^>]+>|\S+/g) || [];
|
|
150
|
+
let position = tokens.findIndex(token => /^[\[{<]/.test(token));
|
|
151
|
+
if (position < 0) continue;
|
|
152
|
+
for (const token of tokens.slice(position)) {
|
|
153
|
+
const placeholders = [...token.matchAll(/<([^>]+)>/g)].map(match => match[1]);
|
|
154
|
+
if (!placeholders.length) continue;
|
|
155
|
+
if (placeholders.some(name => /(?:^|[-_])urls?$/i.test(name))) {
|
|
156
|
+
positions.add(position);
|
|
157
|
+
if (placeholders.some(name => /(?:^|[-_])urls$/i.test(name))) {
|
|
158
|
+
for (let index = position + 1; index < positionalCount; index += 1) positions.add(index);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
position += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return positions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function positionalResourceHosts(args, command, resolveOptionValue, excludedPosition) {
|
|
168
|
+
if (!args.some(arg => arg.includes('://'))) return [];
|
|
169
|
+
const route = command.slice(0, 2).join(' ');
|
|
170
|
+
const position = RESOURCE_TARGET_POSITIONS.get(route) ?? RESOURCE_TARGET_POSITIONS.get(command[0]);
|
|
171
|
+
const help = position === undefined ? resolveOptionValue.help() : '';
|
|
172
|
+
if (position === undefined && !help.trim()) throw new Error('Unable to classify the GitHub CLI URL argument.');
|
|
173
|
+
const positions = position === undefined ? namedUrlPositions(help, args.length) : new Set([position]);
|
|
174
|
+
if (positions.size === 0) return [];
|
|
175
|
+
const values = positionalArguments(args, resolveOptionValue);
|
|
176
|
+
return [...positions].filter(index => index !== excludedPosition)
|
|
177
|
+
.map(index => resourceHostname(values[index])).filter(Boolean);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function repositoryFromShortOptions(arg, args, index, resolveOptionValue) {
|
|
181
|
+
if (!arg.startsWith('-') || arg.startsWith('--') || !arg.slice(1).includes('R')) return null;
|
|
182
|
+
const option = shortOptionValue(arg, resolveOptionValue);
|
|
183
|
+
if (option?.flag !== '-R') return null;
|
|
184
|
+
const next = args[index + 1];
|
|
185
|
+
return option.attached || (next && !next.startsWith('-') ? next : '');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function argumentDestinations(args, realGh, baseEnv, options) {
|
|
189
|
+
const hosts = [];
|
|
190
|
+
let explicit = false;
|
|
191
|
+
let remoteName;
|
|
192
|
+
const resolveOptionValue = optionValueResolver(args, realGh, baseEnv, options);
|
|
193
|
+
const command = commandPath(args);
|
|
194
|
+
const commandRoute = command.slice(0, 2).join(' ');
|
|
195
|
+
const delimiter = args.indexOf('--');
|
|
196
|
+
const end = delimiter < 0 ? args.length : delimiter;
|
|
197
|
+
for (let index = 0; index < end; index += 1) {
|
|
198
|
+
const arg = args[index];
|
|
199
|
+
let value;
|
|
200
|
+
if (arg === '--hostname' || arg === '--repo' || arg === '-R'
|
|
201
|
+
|| (commandRoute === 'repo create' && (arg === '--template' || arg === '-p'))) {
|
|
202
|
+
if (arg !== '--hostname') explicit = true;
|
|
203
|
+
value = args[++index];
|
|
204
|
+
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}.`);
|
|
205
|
+
} else if (arg.startsWith('--hostname=')) {
|
|
206
|
+
value = arg.slice('--hostname='.length);
|
|
207
|
+
if (!value) throw new Error('Missing value for --hostname.');
|
|
208
|
+
} else if (arg.startsWith('--repo=')) {
|
|
209
|
+
explicit = true;
|
|
210
|
+
value = arg.slice('--repo='.length);
|
|
211
|
+
if (!value) throw new Error('Missing value for --repo.');
|
|
212
|
+
} else if (commandRoute === 'repo create' && arg.startsWith('--template=')) {
|
|
213
|
+
explicit = true;
|
|
214
|
+
value = arg.slice('--template='.length);
|
|
215
|
+
if (!value) throw new Error('Missing value for --template.');
|
|
216
|
+
} else if (commandRoute === 'repo create' && /^-p=?\S+/.test(arg)) {
|
|
217
|
+
explicit = true;
|
|
218
|
+
value = arg.slice(2).replace(/^=/, '');
|
|
219
|
+
} else if (/^-R=?\S+/.test(arg)) {
|
|
220
|
+
explicit = true;
|
|
221
|
+
value = arg.slice(2).replace(/^=/, '');
|
|
222
|
+
} else {
|
|
223
|
+
value = repositoryFromShortOptions(arg, args, index, resolveOptionValue);
|
|
224
|
+
if (value === null) continue;
|
|
225
|
+
explicit = true;
|
|
226
|
+
if (!value) throw new Error('Missing value for -R.');
|
|
227
|
+
}
|
|
228
|
+
const host = arg.startsWith('--hostname') ? value.toLowerCase() : repositoryHostname(value);
|
|
229
|
+
if (host) hosts.push(host);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const repositoryPosition = REPOSITORY_TARGET_POSITIONS.get(commandRoute);
|
|
233
|
+
if (repositoryPosition !== undefined) {
|
|
234
|
+
const target = positionalArguments(args, resolveOptionValue)[repositoryPosition];
|
|
235
|
+
if (target) {
|
|
236
|
+
explicit = true;
|
|
237
|
+
const localName = ['repo create', 'repo new'].includes(commandRoute) && !/[/:]/.test(target);
|
|
238
|
+
const localRemote = commandRoute === 'repo set-default' && !/[/:]/.test(target);
|
|
239
|
+
if (localRemote) remoteName = target;
|
|
240
|
+
const host = localName || localRemote ? undefined : repositoryHostname(target);
|
|
241
|
+
if (host) hosts.push(host);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const resourceHosts = positionalResourceHosts(args, command, resolveOptionValue, repositoryPosition);
|
|
245
|
+
if (resourceHosts.length) { explicit = true; hosts.push(...resourceHosts); }
|
|
246
|
+
return { explicit, hosts, remoteName };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function selectedTarget(args, projectRoot, baseEnv, options, platform) {
|
|
250
|
+
const destinations = argumentDestinations(args, options.realGh, baseEnv, options);
|
|
251
|
+
const hosts = destinations.hosts;
|
|
252
|
+
const environmentHost = environmentValue(baseEnv, 'GH_HOST', platform);
|
|
253
|
+
const environmentRepository = environmentValue(baseEnv, 'GH_REPO', platform);
|
|
254
|
+
if (environmentHost) hosts.push(environmentHost.toLowerCase());
|
|
255
|
+
const repositoryHost = environmentRepository ? repositoryHostname(environmentRepository) : undefined;
|
|
256
|
+
if (repositoryHost) hosts.push(repositoryHost);
|
|
257
|
+
|
|
258
|
+
let local;
|
|
259
|
+
if (destinations.remoteName || (!destinations.explicit && !environmentRepository)) {
|
|
260
|
+
local = resolveLocalGithubTarget(projectRoot, options, destinations.remoteName);
|
|
261
|
+
if (!local) throw new Error('Unable to resolve the local GitHub repository host.');
|
|
262
|
+
hosts.push(local.hostname.toLowerCase());
|
|
263
|
+
}
|
|
264
|
+
if (hosts.length === 0) hosts.push('github.com');
|
|
265
|
+
const unique = [...new Set(hosts)];
|
|
266
|
+
if (unique.length !== 1 || unique[0] !== 'github.com') throw new Error('Automatic routing supports github.com only.');
|
|
267
|
+
return { local, environmentRepository };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function runGhProxy(args, projectRoot = process.cwd(), options = {}) {
|
|
271
|
+
const writeError = options.writeError || (value => process.stderr.write(value));
|
|
272
|
+
const realGh = (options.resolveExecutable || resolveRealGh)(options);
|
|
273
|
+
if (!realGh) {
|
|
274
|
+
writeError('GitHub CLI executable not found outside the Forge proxy.\n');
|
|
275
|
+
return 127;
|
|
276
|
+
}
|
|
277
|
+
const baseEnv = withoutProxyMarker(options.baseEnv || process.env);
|
|
278
|
+
const resolveOptionValue = optionValueResolver(args, realGh, baseEnv, options);
|
|
279
|
+
if (isLocalGhInvocation(args, resolveOptionValue)) {
|
|
280
|
+
return runNativeGh(args, projectRoot, { ...options, resolveExecutable: () => realGh });
|
|
281
|
+
}
|
|
282
|
+
const spawnSync = options.spawnSync || nativeSpawnSync;
|
|
283
|
+
const passthrough = () => exitCode(spawnSync(realGh, args, {
|
|
284
|
+
cwd: projectRoot, env: baseEnv, stdio: 'inherit', shell: false,
|
|
285
|
+
}));
|
|
286
|
+
try {
|
|
287
|
+
const automatic = (options.readAuto || readGithubAuto)(projectRoot, options);
|
|
288
|
+
if (automatic === false) return passthrough();
|
|
289
|
+
if (automatic !== true) throw new Error('Invalid automatic routing state.');
|
|
290
|
+
(options.assertRegistered || assertGithubRouterCloneRegistered)(projectRoot, options);
|
|
291
|
+
const platform = options.platform || process.platform;
|
|
292
|
+
const { local, environmentRepository } = selectedTarget(args, projectRoot, baseEnv, { ...options, realGh }, platform);
|
|
293
|
+
const childRepository = environmentRepository || (local ? `github.com/${local.repository}` : undefined);
|
|
294
|
+
const childRunner = (command, childArgs, childOptions) => spawnSync(command, childArgs, {
|
|
295
|
+
...childOptions,
|
|
296
|
+
...(childRepository ? { env: { ...childOptions.env, GH_REPO: childRepository } } : {}),
|
|
297
|
+
});
|
|
298
|
+
const context = (options.createContext || createGithubTokenContext)(projectRoot, {
|
|
299
|
+
...options, baseEnv, runner: options.runner || capturedRunner(realGh), childRunner,
|
|
300
|
+
});
|
|
301
|
+
if (!context.bound) throw new Error('Automatic routing requires a clone account binding.');
|
|
302
|
+
return exitCode(context.runChild(realGh, args, { cwd: projectRoot, stdio: 'inherit', shell: false }));
|
|
303
|
+
} catch {
|
|
304
|
+
writeError('Forge could not select this clone\'s GitHub account. Run forge github status.\n');
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { commandName, isForgeProxy, resolveRealGh, runGhProxy };
|