@toast-ninja/toast-cli 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +18 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +132 -25
package/README.md CHANGED
@@ -22,25 +22,38 @@ npm install -g ./packages/toast-cli
22
22
  ## Review CI Auth
23
23
 
24
24
  ```bash
25
- toast review-ci auth --repo toast-ninja/backend
25
+ toast auth
26
26
  ```
27
27
 
28
28
  The default auth flow opens a browser, completes GitHub auth through Toast, and
29
- stores a repo-scoped Toast token after the localhost callback returns.
29
+ stores an org-scoped Toast token after the localhost callback returns. When run
30
+ inside a GitHub checkout, `toast auth` infers the org from `origin`.
31
+
32
+ Use `--org` when authenticating outside a repo or when you need an explicit
33
+ scope:
34
+
35
+ ```bash
36
+ toast auth --org toast-ninja
37
+ ```
30
38
 
31
39
  If browser auth is not available, use GitHub device auth explicitly:
32
40
 
33
41
  ```bash
34
- toast review-ci auth --repo toast-ninja/backend --device
42
+ toast auth --device
35
43
  ```
36
44
 
37
45
  For automation or local debugging with an existing GitHub token:
38
46
 
39
47
  ```bash
40
- toast review-ci auth --repo toast-ninja/backend --github-token "$GITHUB_TOKEN"
48
+ toast auth --org toast-ninja --github-token "$GITHUB_TOKEN"
41
49
  ```
42
50
 
43
- That token must be a user token that can prove active membership in the repo owner org. GitHub Actions' repository `GITHUB_TOKEN` is not a user login token; use `TOAST_TOKEN` in CI.
51
+ That token must be a user token that can prove active membership in the org.
52
+ GitHub Actions' repository `GITHUB_TOKEN` is not a user login token; use
53
+ `TOAST_TOKEN` in CI.
54
+
55
+ The older `toast review-ci auth --repo OWNER/REPO` command still works as a
56
+ compatibility alias.
44
57
 
45
58
  ## Review CI
46
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toast-ninja/toast-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Toast CLI for agent CI workflows",
5
5
  "bin": {
6
6
  "toast": "bin/toast.js"
package/src/cli.js CHANGED
@@ -26,6 +26,7 @@ const REVIEW_CI_SKILL_PATH = path.join(
26
26
  'SKILL.md',
27
27
  )
28
28
  const REVIEW_CI_COMMANDS = new Set(['review-ci'])
29
+ const AUTH_COMMANDS = new Set(['auth', 'login'])
29
30
 
30
31
  const sleepDefault = ms =>
31
32
  new Promise(resolve => {
@@ -58,13 +59,107 @@ const parseArgs = args => {
58
59
  }
59
60
 
60
61
  const parseRepo = repoFullName => {
61
- const [owner, repo] = String(repoFullName || '').split('/')
62
+ if (repoFullName === true) throw new Error('Missing --repo')
62
63
 
63
- if (!owner || !repo) throw new Error('--repo must be OWNER/REPO')
64
+ const parts = String(repoFullName || '').split('/')
65
+ const [owner, repo] = parts
66
+
67
+ if (parts.length !== 2 || !owner || !repo) {
68
+ throw new Error('--repo must be OWNER/REPO')
69
+ }
64
70
 
65
71
  return { owner, repo, fullName: `${owner}/${repo}` }
66
72
  }
67
73
 
74
+ const parseOrg = org => {
75
+ if (org === true) throw new Error('Missing --org')
76
+
77
+ const normalizedOrg = String(org || '').trim()
78
+
79
+ if (!normalizedOrg || normalizedOrg.includes('/')) {
80
+ throw new Error('--org must be a GitHub organization or owner name')
81
+ }
82
+
83
+ return normalizedOrg
84
+ }
85
+
86
+ const parseGithubRemoteUrl = remoteUrl => {
87
+ const value = String(remoteUrl || '').trim()
88
+ const sshMatch = value.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/)
89
+
90
+ if (sshMatch) return parseRepo(`${sshMatch[1]}/${sshMatch[2]}`)
91
+
92
+ try {
93
+ const url = new URL(value)
94
+
95
+ if (url.hostname !== 'github.com') return null
96
+
97
+ const parts = url.pathname
98
+ .replace(/^\/+/, '')
99
+ .replace(/\/+$/, '')
100
+ .replace(/\.git$/, '')
101
+ .split('/')
102
+
103
+ if (parts.length !== 2) return null
104
+
105
+ return parseRepo(`${parts[0]}/${parts[1]}`)
106
+ } catch (e) {
107
+ return null
108
+ }
109
+ }
110
+
111
+ const getGitRemoteUrlDefault = () =>
112
+ childProcess
113
+ .execFileSync('git', ['remote', 'get-url', 'origin'], {
114
+ encoding: 'utf8',
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ })
117
+ .trim()
118
+
119
+ const getAuthScope = ({ options, getGitRemoteUrl }) => {
120
+ const explicitOrg = options.org ? parseOrg(options.org) : null
121
+ const explicitRepo = options.repo ? parseRepo(options.repo) : null
122
+
123
+ if (explicitOrg && explicitRepo && explicitOrg !== explicitRepo.owner) {
124
+ throw new Error('--org must match --repo owner')
125
+ }
126
+
127
+ if (explicitOrg) {
128
+ return {
129
+ org: explicitOrg,
130
+ ...(explicitRepo ? { repo: explicitRepo.fullName } : {}),
131
+ }
132
+ }
133
+
134
+ if (explicitRepo) {
135
+ return {
136
+ org: explicitRepo.owner,
137
+ repo: explicitRepo.fullName,
138
+ }
139
+ }
140
+
141
+ let inferredRepo = null
142
+
143
+ try {
144
+ inferredRepo = parseGithubRemoteUrl(getGitRemoteUrl())
145
+ } catch (e) {
146
+ inferredRepo = null
147
+ }
148
+
149
+ if (!inferredRepo) {
150
+ throw new Error('Missing --org (or run from a GitHub repo with origin set)')
151
+ }
152
+
153
+ return {
154
+ org: inferredRepo.owner,
155
+ }
156
+ }
157
+
158
+ const getAuthScopeBody = scope => ({
159
+ org: scope.org,
160
+ ...(scope.repo ? { repo: scope.repo } : {}),
161
+ })
162
+
68
163
  const requireOption = (options, key) => {
69
164
  if (!options[key]) throw new Error(`Missing --${key}`)
70
165
 
@@ -114,12 +209,18 @@ const parseBrowserCallbackAuth = ({ requestUrl, expectedState }) => {
114
209
  token_type: callbackUrl.searchParams.get('token_type'),
115
210
  expires_in: Number(callbackUrl.searchParams.get('expires_in')),
116
211
  github_login: callbackUrl.searchParams.get('github_login'),
212
+ org: callbackUrl.searchParams.get('org'),
117
213
  repo: callbackUrl.searchParams.get('repo'),
118
214
  }
119
215
 
120
216
  if (auth.code) return { code: auth.code }
121
217
 
122
- if (!auth.token || !auth.github_login || !auth.repo || !auth.expires_in) {
218
+ if (
219
+ !auth.token ||
220
+ !auth.github_login ||
221
+ (!auth.org && !auth.repo) ||
222
+ !auth.expires_in
223
+ ) {
123
224
  throw new Error('Review CI auth callback was missing credentials')
124
225
  }
125
226
 
@@ -236,9 +337,7 @@ const requestReviewState = async ({
236
337
  const token = getToken({ options, env, config })
237
338
 
238
339
  if (!token)
239
- throw new Error(
240
- 'Missing Toast token. Run `toast review-ci auth` or set TOAST_TOKEN.',
241
- )
340
+ throw new Error('Missing Toast token. Run `toast auth` or set TOAST_TOKEN.')
242
341
 
243
342
  const requestOptions = {
244
343
  method: 'GET',
@@ -370,7 +469,12 @@ const handleWait = async context => {
370
469
  }
371
470
 
372
471
  const completeLogin = ({ context, auth, apiUrl }) => {
373
- const existingConfig = context.readConfig()
472
+ const existingConfig = { ...context.readConfig() }
473
+ const org = auth.org || (auth.repo ? parseRepo(auth.repo).owner : null)
474
+
475
+ if (!org) throw new Error('Login response was missing org')
476
+ delete existingConfig.repo
477
+
374
478
  const expiresAt = new Date(
375
479
  toMillis(context.now()) + auth.expires_in * 1000,
376
480
  ).toISOString()
@@ -378,18 +482,18 @@ const completeLogin = ({ context, auth, apiUrl }) => {
378
482
  context.writeConfig({
379
483
  ...existingConfig,
380
484
  apiUrl,
381
- repo: auth.repo,
485
+ org,
382
486
  token: auth.token,
383
487
  githubLogin: auth.github_login,
384
488
  expiresAt,
385
489
  })
386
- context.stdout.write(`Authenticated ${auth.github_login} for ${auth.repo}\n`)
490
+ context.stdout.write(`Authenticated ${auth.github_login} for ${org}\n`)
387
491
  context.stdout.write(
388
492
  'Agent setup: run `toast review-ci instructions --install-codex` to install Review CI guidance.\n',
389
493
  )
390
494
  }
391
495
 
392
- const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
496
+ const exchangeGithubToken = async ({ context, options, apiUrl, scope }) => {
393
497
  const githubToken =
394
498
  options['github-token'] === true
395
499
  ? context.env.GITHUB_TOKEN
@@ -401,7 +505,7 @@ const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
401
505
  method: 'POST',
402
506
  url: `${apiUrl}/api/review-ci/auth/github`,
403
507
  body: {
404
- repo: repo.fullName,
508
+ ...getAuthScopeBody(scope),
405
509
  github_access_token: githubToken,
406
510
  },
407
511
  })
@@ -413,13 +517,11 @@ const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
413
517
  return response.body
414
518
  }
415
519
 
416
- const runDeviceLogin = async ({ context, apiUrl, repo }) => {
520
+ const runDeviceLogin = async ({ context, apiUrl, scope }) => {
417
521
  const startResponse = await context.request({
418
522
  method: 'POST',
419
523
  url: `${apiUrl}/api/review-ci/auth/github/device/start`,
420
- body: {
421
- repo: repo.fullName,
422
- },
524
+ body: getAuthScopeBody(scope),
423
525
  })
424
526
 
425
527
  if (startResponse.statusCode >= 400) {
@@ -440,7 +542,7 @@ const runDeviceLogin = async ({ context, apiUrl, repo }) => {
440
542
  method: 'POST',
441
543
  url: `${apiUrl}/api/review-ci/auth/github/device/complete`,
442
544
  body: {
443
- repo: repo.fullName,
545
+ ...getAuthScopeBody(scope),
444
546
  device_code: device.device_code,
445
547
  },
446
548
  })
@@ -459,7 +561,7 @@ const runDeviceLogin = async ({ context, apiUrl, repo }) => {
459
561
  throw new Error('Device login timed out')
460
562
  }
461
563
 
462
- const runBrowserLogin = async ({ context, apiUrl, repo }) => {
564
+ const runBrowserLogin = async ({ context, apiUrl, scope }) => {
463
565
  const callback = await context.createBrowserCallbackServer()
464
566
 
465
567
  try {
@@ -467,9 +569,7 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
467
569
  method: 'POST',
468
570
  url: `${apiUrl}/api/review-ci/auth/github/browser/start`,
469
571
  body: {
470
- repo: repo.fullName,
471
- owner: repo.owner,
472
- repository: repo.repo,
572
+ ...getAuthScopeBody(scope),
473
573
  redirect_uri: callback.redirectUri,
474
574
  state: callback.state,
475
575
  },
@@ -505,6 +605,7 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
505
605
  url: `${apiUrl}/api/review-ci/auth/github/browser/complete`,
506
606
  body: {
507
607
  code: callbackAuth.code,
608
+ ...getAuthScopeBody(scope),
508
609
  },
509
610
  })
510
611
 
@@ -523,16 +624,19 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
523
624
  const handleAuth = async context => {
524
625
  const config = context.readConfig()
525
626
  const { options } = parseArgs(context.commandArgs || context.argv.slice(3))
526
- const repo = parseRepo(requireOption(options, 'repo'))
627
+ const scope = getAuthScope({
628
+ options,
629
+ getGitRemoteUrl: context.getGitRemoteUrl,
630
+ })
527
631
  const apiUrl = getApiUrl({ options, env: context.env, config })
528
632
  let auth
529
633
 
530
634
  if (options['github-token']) {
531
- auth = await exchangeGithubToken({ context, options, apiUrl, repo })
635
+ auth = await exchangeGithubToken({ context, options, apiUrl, scope })
532
636
  } else if (options.device) {
533
- auth = await runDeviceLogin({ context, apiUrl, repo })
637
+ auth = await runDeviceLogin({ context, apiUrl, scope })
534
638
  } else {
535
- auth = await runBrowserLogin({ context, apiUrl, repo })
639
+ auth = await runBrowserLogin({ context, apiUrl, scope })
536
640
  }
537
641
 
538
642
  completeLogin({ context, auth, apiUrl })
@@ -572,6 +676,7 @@ const handleInstructions = async context => {
572
676
 
573
677
  const printHelp = stdout => {
574
678
  stdout.write(`Usage:
679
+ toast auth [--org ORG] [--device|--github-token TOKEN]
575
680
  toast review-ci auth --repo OWNER/REPO [--device|--github-token TOKEN]
576
681
  toast review-ci status --repo OWNER/REPO --pr NUMBER --head SHA
577
682
  toast review-ci next --repo OWNER/REPO --pr NUMBER --head SHA
@@ -601,6 +706,7 @@ const main = async ({
601
706
  now = () => new Date(),
602
707
  createBrowserCallbackServer = createBrowserCallbackServerDefault,
603
708
  openBrowser = openBrowserDefault,
709
+ getGitRemoteUrl = getGitRemoteUrlDefault,
604
710
  stdout = process.stdout,
605
711
  stderr = process.stderr,
606
712
  }) => {
@@ -614,12 +720,13 @@ const main = async ({
614
720
  now,
615
721
  createBrowserCallbackServer,
616
722
  openBrowser,
723
+ getGitRemoteUrl,
617
724
  stdout,
618
725
  stderr,
619
726
  }
620
727
 
621
728
  try {
622
- if (argv[2] === 'login') {
729
+ if (AUTH_COMMANDS.has(argv[2])) {
623
730
  return await handleAuth({ ...context, commandArgs: argv.slice(3) })
624
731
  }
625
732