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.
@@ -1,12 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const { execFileSync } = require('node:child_process');
4
+ const { isManagedCredentialHelperValue, isOwnedCredentialHelperValue, isUsableCredentialHelperValue } = require('./github-router');
5
+ const { resolveRealGh } = require('./native-gh');
4
6
 
5
7
  const ACCOUNT_KEY = 'github.account';
8
+ const AUTO_KEY = 'github.auto';
9
+ const CREDENTIAL_HELPER_KEY = 'credential.https://github.com.helper';
6
10
  const HOST = 'github.com';
7
11
  const LOGIN_RE = /^(?=.{1,39}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
8
- const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_host', 'gh_repo']);
9
- const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token']);
12
+ const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_enterprise_token', 'github_enterprise_token', 'gh_host', 'gh_repo']);
13
+ const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_enterprise_token', 'github_enterprise_token']);
10
14
 
11
15
  class GithubContextError extends Error {
12
16
  constructor(code, message) {
@@ -29,16 +33,17 @@ function safeLogin(login) {
29
33
  }
30
34
 
31
35
  function runnerFor(options) {
32
- return options.runner || options._runner || options._exec || options.execFileSync || defaultRunner;
33
- }
34
-
35
- function defaultRunner(command, args, options = {}) {
36
- return execFileSync(command, args, {
37
- encoding: 'utf8',
38
- stdio: ['ignore', 'pipe', 'pipe'],
39
- windowsHide: true,
40
- ...options,
41
- });
36
+ const injected = options.runner || options._runner || options._exec;
37
+ if (injected) return injected;
38
+ const execute = options.execFileSync || execFileSync;
39
+ const resolve = options.resolveRealGh || resolveRealGh;
40
+ return (command, args, runOptions = {}) => {
41
+ const executable = command === 'gh' ? resolve({ env: runOptions.env }) : command;
42
+ if (!executable) throw Object.assign(new Error('GitHub CLI was not found.'), { code: 'ENOENT' });
43
+ return execute(executable, args, {
44
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...runOptions,
45
+ });
46
+ };
42
47
  }
43
48
 
44
49
  function errorText(error) {
@@ -86,35 +91,50 @@ function gitConfig(projectRoot, args, options, absentIsOkay = false) {
86
91
  windowsHide: true,
87
92
  });
88
93
  } catch (error) {
89
- if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error)))) {
90
- return '';
94
+ if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key|not a git repository|--local can only be used/i.test(errorText(error)))) {
95
+ return null;
91
96
  }
92
97
  throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to read this clone-local GitHub account binding.');
93
98
  }
94
99
  }
95
100
 
96
- function readGithubAccount(projectRoot, options = {}) {
97
- const output = gitConfig(projectRoot, ['--get', ACCOUNT_KEY], options, true);
98
- const value = output == null ? '' : output.toString().trim();
99
- return value || null;
101
+ function readLocalValues(projectRoot, key, options = {}) {
102
+ const output = gitConfig(projectRoot, ['--get-all', key], options, true);
103
+ if (output == null) return [];
104
+ return output.toString().replace(/\r?\n$/, '').split(/\r?\n/);
100
105
  }
101
106
 
102
- function writeGithubAccount(projectRoot, login, options = {}) {
103
- if (!validateGithubLogin(login)) {
104
- throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
105
- }
107
+ function runGitConfig(projectRoot, args, options, message) {
106
108
  try {
107
- runnerFor(options)('git', ['config', '--local', '--replace-all', ACCOUNT_KEY, login], {
109
+ return runnerFor(options)('git', ['config', '--local', ...args], {
108
110
  cwd: projectRoot,
109
111
  encoding: 'utf8',
110
112
  stdio: ['ignore', 'pipe', 'pipe'],
111
113
  windowsHide: true,
112
114
  });
113
- } catch (_error) {
114
- throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to write the clone-local GitHub account binding.');
115
+ } catch {
116
+ throw safeCommandFailure('GIT_CONFIG_FAILED', message);
115
117
  }
116
118
  }
117
119
 
120
+ function readGithubAccount(projectRoot, options = {}) {
121
+ const values = readLocalValues(projectRoot, ACCOUNT_KEY, options);
122
+ if (values.length === 0) return null;
123
+ if (values.length !== 1) {
124
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'The clone-local github.account value must be unique.');
125
+ }
126
+ const value = values[0].trim();
127
+ return value || null;
128
+ }
129
+
130
+ function writeGithubAccount(projectRoot, login, options = {}) {
131
+ if (!validateGithubLogin(login)) {
132
+ throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
133
+ }
134
+ runGitConfig(projectRoot, ['--replace-all', ACCOUNT_KEY, login], options,
135
+ 'Unable to write the clone-local GitHub account binding.');
136
+ }
137
+
118
138
  function unsetGithubAccount(projectRoot, options = {}) {
119
139
  try {
120
140
  runnerFor(options)('git', ['config', '--local', '--unset-all', ACCOUNT_KEY], {
@@ -129,6 +149,91 @@ function unsetGithubAccount(projectRoot, options = {}) {
129
149
  }
130
150
  }
131
151
 
152
+ function readGithubAuto(projectRoot, options = {}) {
153
+ const values = readLocalValues(projectRoot, AUTO_KEY, options);
154
+ if (values.length === 0) return false;
155
+ if (values.length !== 1) return null;
156
+ const value = values[0].toLowerCase();
157
+ if (value === 'true') return true;
158
+ if (value === 'false') return false;
159
+ return null;
160
+ }
161
+
162
+ function ownsCredentialHelper(value, options) {
163
+ return (options.isOwnedCredentialHelper || isOwnedCredentialHelperValue)(value);
164
+ }
165
+
166
+ function hasGithubAutoCredentialHelper(projectRoot, options = {}) {
167
+ const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
168
+ return helpers.length === 2 && helpers[0] === ''
169
+ && (options.isOwnedCredentialHelper || isUsableCredentialHelperValue)(helpers[1], options);
170
+ }
171
+
172
+ function isForgeCredentialHelper(value, options) {
173
+ return ownsCredentialHelper(value, options)
174
+ || (options.isManagedCredentialHelper || isManagedCredentialHelperValue)(value, options);
175
+ }
176
+
177
+ function restoreLocalValues(projectRoot, snapshots, options) {
178
+ for (const [key, values] of snapshots) {
179
+ try {
180
+ unsetLocalKey(projectRoot, key, options);
181
+ for (const value of values) runnerFor(options)('git', ['config', '--local', '--add', key, value], { cwd: projectRoot, stdio: 'ignore' });
182
+ } catch { /* best effort rollback */ }
183
+ }
184
+ }
185
+
186
+ function unsetLocalKey(projectRoot, key, options) {
187
+ try {
188
+ runnerFor(options)('git', ['config', '--local', '--unset-all', key], {
189
+ cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
190
+ });
191
+ } catch (error) {
192
+ if (error?.status === 5 || error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error))) return;
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ function assertGithubAutoAvailable(projectRoot, options = {}) {
198
+ const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
199
+ const replaceable = helpers.length === 2 && helpers[0] === ''
200
+ && isForgeCredentialHelper(helpers[1], options);
201
+ if (helpers.length && !replaceable) {
202
+ throw safeCommandFailure('GIT_CREDENTIAL_HELPER_CONFLICT', 'A clone-local GitHub credential helper is already configured.');
203
+ }
204
+ }
205
+
206
+ function enableGithubAuto(projectRoot, options = {}) {
207
+ assertGithubAutoAvailable(projectRoot, options);
208
+ const helperValue = options.credentialHelperValue;
209
+ if (!ownsCredentialHelper(helperValue || '', options)) {
210
+ throw safeCommandFailure('GIT_CONFIG_FAILED', 'Forge did not provide a valid credential helper command.');
211
+ }
212
+ const previousHelpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
213
+ const previousAuto = readLocalValues(projectRoot, AUTO_KEY, options);
214
+ try {
215
+ runGitConfig(projectRoot, ['--replace-all', CREDENTIAL_HELPER_KEY, ''], options, 'Unable to enable automatic GitHub routing.');
216
+ runGitConfig(projectRoot, ['--add', CREDENTIAL_HELPER_KEY, helperValue], options, 'Unable to enable automatic GitHub routing.');
217
+ runGitConfig(projectRoot, ['--replace-all', AUTO_KEY, 'true'], options, 'Unable to enable automatic GitHub routing.');
218
+ } catch (error) {
219
+ restoreLocalValues(projectRoot, [[CREDENTIAL_HELPER_KEY, previousHelpers], [AUTO_KEY, previousAuto]], options);
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ function disableGithubAuto(projectRoot, options = {}) {
225
+ const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
226
+ const previousAuto = readLocalValues(projectRoot, AUTO_KEY, options);
227
+ const owned = helpers.length === 2 && helpers[0] === '' && isForgeCredentialHelper(helpers[1], options);
228
+ const keys = [AUTO_KEY, ...(owned ? [CREDENTIAL_HELPER_KEY] : [])];
229
+ try {
230
+ for (const key of keys) unsetLocalKey(projectRoot, key, options);
231
+ } catch {
232
+ restoreLocalValues(projectRoot, [[AUTO_KEY, previousAuto], [CREDENTIAL_HELPER_KEY, helpers]], options);
233
+ throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to disable automatic GitHub routing.');
234
+ }
235
+ }
236
+
132
237
  function withoutGithubEnvironment(environment) {
133
238
  const result = {};
134
239
  for (const [key, value] of Object.entries(environment || {})) {
@@ -177,6 +282,20 @@ function retrieveGithubToken(login, options) {
177
282
  return token;
178
283
  }
179
284
 
285
+ function createGithubTokenContext(projectRoot, options = {}) {
286
+ const account = (options.readAccount || readGithubAccount)(projectRoot, options);
287
+ if (!validateGithubLogin(account)) {
288
+ throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'No selected GitHub account is available.');
289
+ }
290
+ const baseEnv = { ...(options.baseEnv || options.env || process.env) };
291
+ const token = retrieveGithubToken(account, { ...options, baseEnv });
292
+ return createPublicContext(publicStatus('selected', account, null), token, baseEnv, options);
293
+ }
294
+
295
+ function writeGithubCredential(projectRoot, write, options = {}) {
296
+ createGithubTokenContext(projectRoot, options).writeCredential(write);
297
+ }
298
+
180
299
  function loginFromOutput(output) {
181
300
  const text = output == null ? '' : output.toString().trim();
182
301
  if (!text) return null;
@@ -270,9 +389,16 @@ function createPublicContext(status, token, baseEnv, options) {
270
389
  throw safeGithubCommandFailure(error);
271
390
  }
272
391
  };
392
+ const writeCredential = (write) => {
393
+ if (status.state === 'unbound' || typeof write !== 'function') {
394
+ throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'No selected GitHub account is available.');
395
+ }
396
+ write(`username=${status.account}\npassword=${token}\n\n`);
397
+ };
273
398
  Object.defineProperties(context, {
274
399
  runChild: { value: runChild, enumerable: false },
275
400
  runGh: { value: runGh, enumerable: false },
401
+ writeCredential: { value: writeCredential, enumerable: false },
276
402
  getStatus: { value: () => ({ ...status }), enumerable: false },
277
403
  });
278
404
  return context;
@@ -296,12 +422,21 @@ function createGithubContext(projectRoot, options = {}) {
296
422
 
297
423
  module.exports = {
298
424
  ACCOUNT_KEY,
425
+ AUTO_KEY,
426
+ CREDENTIAL_HELPER_KEY,
299
427
  GithubContextError,
428
+ assertGithubAutoAvailable,
300
429
  createGithubContext,
430
+ createGithubTokenContext,
431
+ disableGithubAuto,
432
+ enableGithubAuto,
433
+ hasGithubAutoCredentialHelper,
301
434
  prepareGithubAccount,
302
435
  prepareGithubContext: createGithubContext,
303
436
  readGithubAccount,
437
+ readGithubAuto,
304
438
  resolveGithubLogin,
439
+ writeGithubCredential,
305
440
  unsetGithubAccount,
306
441
  validateGithubLogin,
307
442
  writeGithubAccount,
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const { readGithubAuto, writeGithubCredential } = require('./github-context');
4
+ const { assertGithubRouterCloneRegistered } = require('./github-router');
5
+
6
+ function parseRequest(input) {
7
+ if (typeof input !== 'string' || input.length > 64 * 1024) return null;
8
+ const request = Object.create(null);
9
+ for (const line of input.split(/\r?\n/)) {
10
+ if (!line) break;
11
+ const separator = line.indexOf('=');
12
+ if (separator < 1) return null;
13
+ request[line.slice(0, separator)] = line.slice(separator + 1);
14
+ }
15
+ return request;
16
+ }
17
+
18
+ function runCredentialHelper(operation, options = {}) {
19
+ if (!['get', 'store', 'erase'].includes(operation)) return 1;
20
+ if (operation !== 'get') return 0;
21
+ const request = parseRequest(options.input || '');
22
+ const host = request?.host?.toLowerCase();
23
+ if (request?.protocol !== 'https' || (host !== 'github.com' && host !== 'github.com:443')) return 0;
24
+ const projectRoot = options.projectRoot || process.cwd();
25
+ const write = options.write || (value => process.stdout.write(value));
26
+ const writeError = options.writeError || (value => process.stderr.write(value));
27
+ try {
28
+ const automatic = (options.readAuto || readGithubAuto)(projectRoot, options);
29
+ if (automatic === false) return 0;
30
+ if (automatic !== true) throw new Error('Automatic GitHub routing configuration is invalid.');
31
+ if (!options.createContext || options.assertRegistered) {
32
+ (options.assertRegistered || assertGithubRouterCloneRegistered)(projectRoot, options);
33
+ }
34
+ if (options.createContext) {
35
+ options.createContext(projectRoot, options).writeCredential(write);
36
+ } else {
37
+ writeGithubCredential(projectRoot, write, options);
38
+ }
39
+ return 0;
40
+ } catch {
41
+ write('quit=true\n\n');
42
+ writeError('Forge could not resolve this clone\'s GitHub account. Run forge github status.\n');
43
+ return 1;
44
+ }
45
+ }
46
+
47
+ module.exports = { parseRequest, runCredentialHelper };