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.
@@ -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 ERROR_PATTERNS.COMMAND_NOT_FOUND.some(pattern =>
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 = execFileSync) {
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
- async function runAllTests(exec = execFileSync, rootDir = process.cwd()) {
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 { status, passed, failed, errors = 0, skipped, total, testsFound } = parseTestCounts(result);
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
- // Check for timeout
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 { skip = [], continueOnError = true, rootDir = process.cwd() } = options || {};
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 runAllTests(execFileSync, rootDir);
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
- handler: async () => {
901
- const result = await executeValidate();
902
- if (!result.success) {
903
- return result;
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: result.summary,
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,
@@ -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 };