@toast-ninja/toast-cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 ORG/REPO` command still works as a
56
+ compatibility alias.
44
57
 
45
58
  ## Review CI
46
59
 
@@ -93,7 +106,7 @@ toast review-ci instructions
93
106
  Before using `gh` to rediscover review context, run:
94
107
 
95
108
  ```bash
96
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
109
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA
97
110
  ```
98
111
 
99
112
  Fix every unresolved thread/comment listed by URL. Reply with what changed,
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.4",
4
4
  "description": "Toast CLI for agent CI workflows",
5
5
  "bin": {
6
6
  "toast": "bin/toast.js"
@@ -10,7 +10,7 @@ Use Toast Review CI before rediscovering review context with `gh`.
10
10
  1. Run:
11
11
 
12
12
  ```bash
13
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
13
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA
14
14
  ```
15
15
 
16
16
  2. Fix every `blocking_items` entry. Prefer the URL and body from Toast over a fresh GitHub search.
@@ -19,7 +19,7 @@ toast review-ci next --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
19
19
  5. Push the fix, then wait on the exact new head:
20
20
 
21
21
  ```bash
22
- toast review-ci wait --repo OWNER/REPO --pr NUMBER --head HEAD_SHA
22
+ toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA
23
23
  ```
24
24
 
25
25
  Only report completion after Review CI returns `acknowledged`.
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 ORG/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 name')
81
+ }
82
+
83
+ return normalizedOrg
84
+ }
85
+
86
+ const parseGithubRemoteUrl = remoteUrl => {
87
+ const value = String(remoteUrl || '').trim()
88
+ const sshMatch = value.match(/^[^@/:]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/)
89
+
90
+ if (sshMatch) return parseRepo(`${sshMatch[1]}/${sshMatch[2]}`)
91
+
92
+ try {
93
+ const url = new URL(value)
94
+
95
+ if (!['git:', 'http:', 'https:', 'ssh:'].includes(url.protocol)) 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,10 +676,11 @@ const handleInstructions = async context => {
572
676
 
573
677
  const printHelp = stdout => {
574
678
  stdout.write(`Usage:
575
- toast review-ci auth --repo OWNER/REPO [--device|--github-token TOKEN]
576
- toast review-ci status --repo OWNER/REPO --pr NUMBER --head SHA
577
- toast review-ci next --repo OWNER/REPO --pr NUMBER --head SHA
578
- toast review-ci wait --repo OWNER/REPO --pr NUMBER --head SHA
679
+ toast auth [--org ORG] [--device|--github-token TOKEN]
680
+ toast review-ci auth --repo ORG/REPO [--device|--github-token TOKEN]
681
+ toast review-ci status --repo ORG/REPO --pr NUMBER --head SHA
682
+ toast review-ci next --repo ORG/REPO --pr NUMBER --head SHA
683
+ toast review-ci wait --repo ORG/REPO --pr NUMBER --head SHA
579
684
  toast review-ci instructions [--install-codex]
580
685
  `)
581
686
  }
@@ -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