@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.
- package/README.md +18 -5
- package/package.json +1 -1
- 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
|
|
25
|
+
toast auth
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
The default auth flow opens a browser, completes GitHub auth through Toast, and
|
|
29
|
-
stores
|
|
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
|
|
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
|
|
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
|
|
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
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
|
-
|
|
62
|
+
if (repoFullName === true) throw new Error('Missing --repo')
|
|
62
63
|
|
|
63
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 ${
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
635
|
+
auth = await exchangeGithubToken({ context, options, apiUrl, scope })
|
|
529
636
|
} else if (options.device) {
|
|
530
|
-
auth = await runDeviceLogin({ context, apiUrl,
|
|
637
|
+
auth = await runDeviceLogin({ context, apiUrl, scope })
|
|
531
638
|
} else {
|
|
532
|
-
auth = await runBrowserLogin({ context, apiUrl,
|
|
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]
|
|
729
|
+
if (AUTH_COMMANDS.has(argv[2])) {
|
|
620
730
|
return await handleAuth({ ...context, commandArgs: argv.slice(3) })
|
|
621
731
|
}
|
|
622
732
|
|