@toast-ninja/toast-cli 0.1.1 → 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 +136 -26
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.1",
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',
@@ -334,9 +433,12 @@ const handleWait = async context => {
334
433
  if (options.interval) parseDurationMs(options.interval)
335
434
  const startedAt = toMillis(context.now())
336
435
 
337
- while (toMillis(context.now()) - startedAt <= timeoutMs) {
436
+ while (true) {
338
437
  const elapsedMs = toMillis(context.now()) - startedAt
339
438
  const remainingMs = timeoutMs - elapsedMs
439
+
440
+ if (remainingMs <= 0) break
441
+
340
442
  const waitTimeoutSeconds = Math.max(
341
443
  1,
342
444
  Math.min(DEFAULT_WAIT_REQUEST_SECONDS, Math.ceil(remainingMs / 1000)),
@@ -367,7 +469,12 @@ const handleWait = async context => {
367
469
  }
368
470
 
369
471
  const completeLogin = ({ context, auth, apiUrl }) => {
370
- 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
+
371
478
  const expiresAt = new Date(
372
479
  toMillis(context.now()) + auth.expires_in * 1000,
373
480
  ).toISOString()
@@ -375,18 +482,18 @@ const completeLogin = ({ context, auth, apiUrl }) => {
375
482
  context.writeConfig({
376
483
  ...existingConfig,
377
484
  apiUrl,
378
- repo: auth.repo,
485
+ org,
379
486
  token: auth.token,
380
487
  githubLogin: auth.github_login,
381
488
  expiresAt,
382
489
  })
383
- context.stdout.write(`Authenticated ${auth.github_login} for ${auth.repo}\n`)
490
+ context.stdout.write(`Authenticated ${auth.github_login} for ${org}\n`)
384
491
  context.stdout.write(
385
492
  'Agent setup: run `toast review-ci instructions --install-codex` to install Review CI guidance.\n',
386
493
  )
387
494
  }
388
495
 
389
- const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
496
+ const exchangeGithubToken = async ({ context, options, apiUrl, scope }) => {
390
497
  const githubToken =
391
498
  options['github-token'] === true
392
499
  ? context.env.GITHUB_TOKEN
@@ -398,7 +505,7 @@ const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
398
505
  method: 'POST',
399
506
  url: `${apiUrl}/api/review-ci/auth/github`,
400
507
  body: {
401
- repo: repo.fullName,
508
+ ...getAuthScopeBody(scope),
402
509
  github_access_token: githubToken,
403
510
  },
404
511
  })
@@ -410,13 +517,11 @@ const exchangeGithubToken = async ({ context, options, apiUrl, repo }) => {
410
517
  return response.body
411
518
  }
412
519
 
413
- const runDeviceLogin = async ({ context, apiUrl, repo }) => {
520
+ const runDeviceLogin = async ({ context, apiUrl, scope }) => {
414
521
  const startResponse = await context.request({
415
522
  method: 'POST',
416
523
  url: `${apiUrl}/api/review-ci/auth/github/device/start`,
417
- body: {
418
- repo: repo.fullName,
419
- },
524
+ body: getAuthScopeBody(scope),
420
525
  })
421
526
 
422
527
  if (startResponse.statusCode >= 400) {
@@ -437,7 +542,7 @@ const runDeviceLogin = async ({ context, apiUrl, repo }) => {
437
542
  method: 'POST',
438
543
  url: `${apiUrl}/api/review-ci/auth/github/device/complete`,
439
544
  body: {
440
- repo: repo.fullName,
545
+ ...getAuthScopeBody(scope),
441
546
  device_code: device.device_code,
442
547
  },
443
548
  })
@@ -456,7 +561,7 @@ const runDeviceLogin = async ({ context, apiUrl, repo }) => {
456
561
  throw new Error('Device login timed out')
457
562
  }
458
563
 
459
- const runBrowserLogin = async ({ context, apiUrl, repo }) => {
564
+ const runBrowserLogin = async ({ context, apiUrl, scope }) => {
460
565
  const callback = await context.createBrowserCallbackServer()
461
566
 
462
567
  try {
@@ -464,9 +569,7 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
464
569
  method: 'POST',
465
570
  url: `${apiUrl}/api/review-ci/auth/github/browser/start`,
466
571
  body: {
467
- repo: repo.fullName,
468
- owner: repo.owner,
469
- repository: repo.repo,
572
+ ...getAuthScopeBody(scope),
470
573
  redirect_uri: callback.redirectUri,
471
574
  state: callback.state,
472
575
  },
@@ -502,6 +605,7 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
502
605
  url: `${apiUrl}/api/review-ci/auth/github/browser/complete`,
503
606
  body: {
504
607
  code: callbackAuth.code,
608
+ ...getAuthScopeBody(scope),
505
609
  },
506
610
  })
507
611
 
@@ -520,16 +624,19 @@ const runBrowserLogin = async ({ context, apiUrl, repo }) => {
520
624
  const handleAuth = async context => {
521
625
  const config = context.readConfig()
522
626
  const { options } = parseArgs(context.commandArgs || context.argv.slice(3))
523
- const repo = parseRepo(requireOption(options, 'repo'))
627
+ const scope = getAuthScope({
628
+ options,
629
+ getGitRemoteUrl: context.getGitRemoteUrl,
630
+ })
524
631
  const apiUrl = getApiUrl({ options, env: context.env, config })
525
632
  let auth
526
633
 
527
634
  if (options['github-token']) {
528
- auth = await exchangeGithubToken({ context, options, apiUrl, repo })
635
+ auth = await exchangeGithubToken({ context, options, apiUrl, scope })
529
636
  } else if (options.device) {
530
- auth = await runDeviceLogin({ context, apiUrl, repo })
637
+ auth = await runDeviceLogin({ context, apiUrl, scope })
531
638
  } else {
532
- auth = await runBrowserLogin({ context, apiUrl, repo })
639
+ auth = await runBrowserLogin({ context, apiUrl, scope })
533
640
  }
534
641
 
535
642
  completeLogin({ context, auth, apiUrl })
@@ -569,6 +676,7 @@ const handleInstructions = async context => {
569
676
 
570
677
  const printHelp = stdout => {
571
678
  stdout.write(`Usage:
679
+ toast auth [--org ORG] [--device|--github-token TOKEN]
572
680
  toast review-ci auth --repo OWNER/REPO [--device|--github-token TOKEN]
573
681
  toast review-ci status --repo OWNER/REPO --pr NUMBER --head SHA
574
682
  toast review-ci next --repo OWNER/REPO --pr NUMBER --head SHA
@@ -598,6 +706,7 @@ const main = async ({
598
706
  now = () => new Date(),
599
707
  createBrowserCallbackServer = createBrowserCallbackServerDefault,
600
708
  openBrowser = openBrowserDefault,
709
+ getGitRemoteUrl = getGitRemoteUrlDefault,
601
710
  stdout = process.stdout,
602
711
  stderr = process.stderr,
603
712
  }) => {
@@ -611,12 +720,13 @@ const main = async ({
611
720
  now,
612
721
  createBrowserCallbackServer,
613
722
  openBrowser,
723
+ getGitRemoteUrl,
614
724
  stdout,
615
725
  stderr,
616
726
  }
617
727
 
618
728
  try {
619
- if (argv[2] === 'login') {
729
+ if (AUTH_COMMANDS.has(argv[2])) {
620
730
  return await handleAuth({ ...context, commandArgs: argv.slice(3) })
621
731
  }
622
732