@aryacode/dsh-aryacode-polish 0.6.2 → 0.7.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/CHANGELOG.md +26 -0
- package/client/face.js +252 -25
- package/docs/github-workspaces.md +24 -0
- package/entries/github.js +218 -3
- package/lib/client.js +253 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.3] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A sign-out that only looked like one.** gh auth logout --user removes the
|
|
8
|
+
account it names, and a machine with a second account in the CLI keyring makes
|
|
9
|
+
that one active, so the dialog quietly carried on as somebody else while the
|
|
10
|
+
sidebar still named the old account. Signing out now reports the account the
|
|
11
|
+
CLI fell back to, offers **Sign out all** when more than one account exists,
|
|
12
|
+
and the footer line follows the dialog immediately instead of waiting for the
|
|
13
|
+
next window focus.
|
|
14
|
+
|
|
15
|
+
## [0.7.0] - 2026-10-28
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Sign in and sign out of GitHub from the dialog.** Sign-in is still the GitHub
|
|
20
|
+
CLI's own session — this bundle stores no token — but it no longer sends the
|
|
21
|
+
owner to a terminal: the dialog runs `gh auth login --web`, shows the one-time
|
|
22
|
+
code it prints with a copy button and the device page, and refreshes the
|
|
23
|
+
repository list as soon as the CLI reports success. A signed-in machine gets a
|
|
24
|
+
two-step **Sign out** (`gh auth logout --hostname github.com --user <login>`),
|
|
25
|
+
and a pending sign-in can be cancelled. The footer row shows which state the
|
|
26
|
+
machine is in, so the answer to "am I signed in?" is visible without opening
|
|
27
|
+
anything.
|
|
28
|
+
|
|
3
29
|
## [0.6.2] - 2026-10-28
|
|
4
30
|
|
|
5
31
|
### Fixed
|
package/client/face.js
CHANGED
|
@@ -678,6 +678,29 @@ const githubStore = (() => {
|
|
|
678
678
|
}
|
|
679
679
|
})()
|
|
680
680
|
|
|
681
|
+
/**
|
|
682
|
+
* The last GitHub status anyone read, shared by the dialog and the footer line.
|
|
683
|
+
*
|
|
684
|
+
* The footer used to re-read only on window focus, so signing out in the dialog
|
|
685
|
+
* left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
|
|
686
|
+
* signed in as another account). One store, written by every read, keeps them equal.
|
|
687
|
+
*/
|
|
688
|
+
const githubStatusStore = (() => {
|
|
689
|
+
let value
|
|
690
|
+
const listeners = new Set()
|
|
691
|
+
return {
|
|
692
|
+
get: () => value,
|
|
693
|
+
subscribe(listener) {
|
|
694
|
+
listeners.add(listener)
|
|
695
|
+
return () => { listeners.delete(listener) }
|
|
696
|
+
},
|
|
697
|
+
set(next) {
|
|
698
|
+
value = next
|
|
699
|
+
for (const listener of listeners) listener()
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
})()
|
|
703
|
+
|
|
681
704
|
/**
|
|
682
705
|
* Post one action to the GitHub route.
|
|
683
706
|
* @param action - `status`, `repos` or `clone`.
|
|
@@ -747,6 +770,20 @@ const GITHUB_LIST_STYLE = {
|
|
|
747
770
|
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
748
771
|
}
|
|
749
772
|
|
|
773
|
+
/** A secondary button in the sign-in block, sized like the harness's own. */
|
|
774
|
+
const GITHUB_BUTTON_STYLE = {
|
|
775
|
+
display: 'inline-flex',
|
|
776
|
+
alignItems: 'center',
|
|
777
|
+
justifyContent: 'center',
|
|
778
|
+
padding: '4px 10px',
|
|
779
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
|
|
780
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
781
|
+
background: 'transparent',
|
|
782
|
+
color: 'inherit',
|
|
783
|
+
font: 'inherit',
|
|
784
|
+
cursor: 'pointer',
|
|
785
|
+
}
|
|
786
|
+
|
|
750
787
|
const GITHUB_ROW_STYLE = {
|
|
751
788
|
display: 'flex',
|
|
752
789
|
alignItems: 'center',
|
|
@@ -762,8 +799,7 @@ const GITHUB_ROW_STYLE = {
|
|
|
762
799
|
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
763
800
|
}
|
|
764
801
|
|
|
765
|
-
const GITHUB_INPUT_STYLE = {
|
|
766
|
-
width: '100%',
|
|
802
|
+
const GITHUB_INPUT_STYLE = { width: '100%',
|
|
767
803
|
boxSizing: 'border-box',
|
|
768
804
|
padding: '6px 8px',
|
|
769
805
|
font: 'inherit',
|
|
@@ -793,6 +829,33 @@ function GitHubPanel(props) {
|
|
|
793
829
|
const [query, setQuery] = React.useState('')
|
|
794
830
|
const [error, setError] = React.useState('')
|
|
795
831
|
const [busy, setBusy] = React.useState('')
|
|
832
|
+
const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
|
|
833
|
+
const [confirmOut, setConfirmOut] = React.useState(false)
|
|
834
|
+
const [confirmAll, setConfirmAll] = React.useState(false)
|
|
835
|
+
const [notice, setNotice] = React.useState('')
|
|
836
|
+
const [copied, setCopied] = React.useState(false)
|
|
837
|
+
|
|
838
|
+
/** Put the one-time code on the clipboard, so it can be pasted in the browser. */
|
|
839
|
+
const copyCode = () => {
|
|
840
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
841
|
+
if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
|
|
842
|
+
Promise.resolve(clipboard.writeText(signIn.code))
|
|
843
|
+
.then(() => {
|
|
844
|
+
setCopied(true)
|
|
845
|
+
setTimeout(() => setCopied(false), 2000)
|
|
846
|
+
})
|
|
847
|
+
.catch(() => setCopied(false))
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Re-read the session, and the repository list when it is signed in. */
|
|
851
|
+
const refresh = () => Promise.resolve()
|
|
852
|
+
.then(() => githubCall('status'))
|
|
853
|
+
.then(status => {
|
|
854
|
+
setAccount(status)
|
|
855
|
+
githubStatusStore.set(status)
|
|
856
|
+
if (status.signedIn !== true) return undefined
|
|
857
|
+
return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
|
|
858
|
+
})
|
|
796
859
|
|
|
797
860
|
React.useEffect(() => {
|
|
798
861
|
if (!open || !enabled) return undefined
|
|
@@ -801,17 +864,9 @@ function GitHubPanel(props) {
|
|
|
801
864
|
setError('')
|
|
802
865
|
setBusy('')
|
|
803
866
|
setQuery('')
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!live) return undefined
|
|
808
|
-
setAccount(status)
|
|
809
|
-
if (status.signedIn !== true) return undefined
|
|
810
|
-
return githubCall('repos').then(list => {
|
|
811
|
-
if (!live) return
|
|
812
|
-
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
813
|
-
})
|
|
814
|
-
})
|
|
867
|
+
setConfirmOut(false)
|
|
868
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
869
|
+
refresh()
|
|
815
870
|
.then(() => { if (live) setPhase('ready') })
|
|
816
871
|
.catch(reason => {
|
|
817
872
|
if (!live) return
|
|
@@ -821,6 +876,29 @@ function GitHubPanel(props) {
|
|
|
821
876
|
return () => { live = false }
|
|
822
877
|
}, [open, enabled])
|
|
823
878
|
|
|
879
|
+
// The device flow is a long poll: ask the Host what the CLI child reports.
|
|
880
|
+
// `starting` has to be polled too — the Host answers `starting` the moment the
|
|
881
|
+
// child is spawned and only reports the code a tick later, so polling on
|
|
882
|
+
// `pending` alone left the dialog asking for a code that had already arrived.
|
|
883
|
+
React.useEffect(() => {
|
|
884
|
+
if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
|
|
885
|
+
const timer = setInterval(() => {
|
|
886
|
+
Promise.resolve()
|
|
887
|
+
.then(() => githubCall('loginStatus'))
|
|
888
|
+
.then(answer => {
|
|
889
|
+
setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
|
|
890
|
+
if (answer.state === 'succeeded') {
|
|
891
|
+
setPhase('loading')
|
|
892
|
+
return refresh().then(() => setPhase('ready'))
|
|
893
|
+
}
|
|
894
|
+
if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
|
|
895
|
+
return undefined
|
|
896
|
+
})
|
|
897
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
898
|
+
}, 2000)
|
|
899
|
+
return () => clearInterval(timer)
|
|
900
|
+
}, [open, signIn.state])
|
|
901
|
+
|
|
824
902
|
React.useEffect(() => {
|
|
825
903
|
if (!open) return undefined
|
|
826
904
|
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
@@ -865,6 +943,78 @@ function GitHubPanel(props) {
|
|
|
865
943
|
})
|
|
866
944
|
}
|
|
867
945
|
|
|
946
|
+
/**
|
|
947
|
+
* Start the CLI's device flow. The Host runs it, so this dialog only renders
|
|
948
|
+
* the code the CLI printed and the page it wants opened.
|
|
949
|
+
*/
|
|
950
|
+
const beginSignIn = () => {
|
|
951
|
+
setError('')
|
|
952
|
+
setSignIn({ state: 'starting', code: '', url: '', reason: '' })
|
|
953
|
+
Promise.resolve()
|
|
954
|
+
.then(() => githubCall('login'))
|
|
955
|
+
.then(answer => setSignIn({
|
|
956
|
+
state: answer.state ?? 'failed',
|
|
957
|
+
code: answer.code ?? '',
|
|
958
|
+
url: answer.url ?? '',
|
|
959
|
+
reason: answer.reason ?? '',
|
|
960
|
+
}))
|
|
961
|
+
.catch(reason => {
|
|
962
|
+
setSignIn({ state: 'failed', code: '', url: '', reason: '' })
|
|
963
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
964
|
+
})
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const cancelSignIn = () => {
|
|
968
|
+
Promise.resolve()
|
|
969
|
+
.then(() => githubCall('loginCancel'))
|
|
970
|
+
.then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
971
|
+
.catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Sign the CLI out, which is what every read in this feature depends on. */
|
|
975
|
+
const signOutNow = () => {
|
|
976
|
+
setConfirmOut(false)
|
|
977
|
+
setBusy('sign-out')
|
|
978
|
+
setError('')
|
|
979
|
+
setNotice('')
|
|
980
|
+
Promise.resolve()
|
|
981
|
+
.then(() => githubCall('logout'))
|
|
982
|
+
.then(answer => {
|
|
983
|
+
const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
|
|
984
|
+
setAccount(undefined)
|
|
985
|
+
setRepos([])
|
|
986
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
987
|
+
setNotice(remaining === ''
|
|
988
|
+
? `Signed out of ${String(answer.login)}.`
|
|
989
|
+
: `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
|
|
990
|
+
return refresh()
|
|
991
|
+
})
|
|
992
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
993
|
+
.then(() => setBusy(''))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Sign every account in the CLI's keyring out, so no silent fallback is left. */
|
|
997
|
+
const signOutAllNow = () => {
|
|
998
|
+
setConfirmAll(false)
|
|
999
|
+
setBusy('sign-out-all')
|
|
1000
|
+
setError('')
|
|
1001
|
+
setNotice('')
|
|
1002
|
+
Promise.resolve()
|
|
1003
|
+
.then(() => githubCall('logoutAll'))
|
|
1004
|
+
.then(answer => {
|
|
1005
|
+
const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
|
|
1006
|
+
setAccount(undefined)
|
|
1007
|
+
setRepos([])
|
|
1008
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
1009
|
+
setNotice(logins.length === 0
|
|
1010
|
+
? 'No GitHub account was signed in.'
|
|
1011
|
+
: `Signed out of ${logins.join(', ')}.`)
|
|
1012
|
+
return refresh()
|
|
1013
|
+
})
|
|
1014
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
1015
|
+
.then(() => setBusy(''))
|
|
1016
|
+
}
|
|
1017
|
+
|
|
868
1018
|
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
869
1019
|
key: repo.fullName,
|
|
870
1020
|
type: 'button',
|
|
@@ -892,18 +1042,45 @@ function GitHubPanel(props) {
|
|
|
892
1042
|
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
893
1043
|
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
894
1044
|
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
895
|
-
React.createElement('
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1045
|
+
React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
|
|
1046
|
+
signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
|
|
1047
|
+
? React.createElement('button', {
|
|
1048
|
+
type: 'button',
|
|
1049
|
+
'data-polish-github': 'signoutall',
|
|
1050
|
+
disabled: busy === 'sign-out-all',
|
|
1051
|
+
onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
|
|
1052
|
+
title: 'Sign every GitHub account in the CLI out of this machine',
|
|
1053
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1054
|
+
}, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
|
|
1055
|
+
: null,
|
|
1056
|
+
signedIn
|
|
1057
|
+
? React.createElement('button', {
|
|
1058
|
+
type: 'button',
|
|
1059
|
+
'data-polish-github': 'signout',
|
|
1060
|
+
disabled: busy === 'sign-out',
|
|
1061
|
+
onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
|
|
1062
|
+
title: 'Sign the GitHub CLI out of this machine',
|
|
1063
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1064
|
+
}, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
|
|
1065
|
+
: null,
|
|
1066
|
+
React.createElement('button', {
|
|
1067
|
+
type: 'button',
|
|
1068
|
+
onClick: () => githubStore.set(false),
|
|
1069
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
1070
|
+
'aria-label': 'Close',
|
|
1071
|
+
}, '×'))),
|
|
901
1072
|
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
902
1073
|
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
903
1074
|
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
904
1075
|
!signedIn && phase !== 'loading'
|
|
905
|
-
? '
|
|
1076
|
+
? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
|
|
906
1077
|
: null),
|
|
1078
|
+
notice === ''
|
|
1079
|
+
? null
|
|
1080
|
+
: React.createElement('div', {
|
|
1081
|
+
'data-polish-github': 'notice',
|
|
1082
|
+
style: { marginTop: '6px', opacity: 0.85 },
|
|
1083
|
+
}, notice),
|
|
907
1084
|
signedIn
|
|
908
1085
|
? React.createElement('input', {
|
|
909
1086
|
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
@@ -917,6 +1094,48 @@ function GitHubPanel(props) {
|
|
|
917
1094
|
error !== ''
|
|
918
1095
|
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
919
1096
|
: null,
|
|
1097
|
+
!signedIn && phase !== 'loading'
|
|
1098
|
+
? React.createElement('div', { style: { marginTop: '12px' } },
|
|
1099
|
+
signIn.state === 'pending' || signIn.state === 'starting'
|
|
1100
|
+
? React.createElement('div', null,
|
|
1101
|
+
React.createElement('div', { style: { marginBottom: '8px' } },
|
|
1102
|
+
signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
|
|
1103
|
+
signIn.code === ''
|
|
1104
|
+
? null
|
|
1105
|
+
: React.createElement('div', {
|
|
1106
|
+
'data-polish-github': 'code',
|
|
1107
|
+
style: {
|
|
1108
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
1109
|
+
fontSize: '18px',
|
|
1110
|
+
letterSpacing: '2px',
|
|
1111
|
+
padding: '8px 10px',
|
|
1112
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1113
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
1114
|
+
},
|
|
1115
|
+
}, signIn.code),
|
|
1116
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
|
|
1117
|
+
React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
|
|
1118
|
+
copied ? 'Copied' : 'Copy code'),
|
|
1119
|
+
React.createElement('a', {
|
|
1120
|
+
href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
|
|
1121
|
+
target: '_blank',
|
|
1122
|
+
rel: 'noreferrer',
|
|
1123
|
+
style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
|
|
1124
|
+
}, 'Open the device page'),
|
|
1125
|
+
React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
|
|
1126
|
+
: React.createElement('div', null,
|
|
1127
|
+
React.createElement('button', {
|
|
1128
|
+
type: 'button',
|
|
1129
|
+
onClick: beginSignIn,
|
|
1130
|
+
// The attribute belongs on the element: inside `style` it is ignored,
|
|
1131
|
+
// which is exactly why the first verification could not find this button.
|
|
1132
|
+
'data-polish-github': 'signin',
|
|
1133
|
+
style: GITHUB_BUTTON_STYLE,
|
|
1134
|
+
}, 'Sign in with GitHub'),
|
|
1135
|
+
signIn.state === 'failed' && signIn.reason !== ''
|
|
1136
|
+
? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
|
|
1137
|
+
: null))
|
|
1138
|
+
: null,
|
|
920
1139
|
signedIn
|
|
921
1140
|
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
922
1141
|
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
@@ -961,6 +1180,9 @@ const FOOT_ROW_STYLE = {
|
|
|
961
1180
|
*/
|
|
962
1181
|
function GithubStatus(props) {
|
|
963
1182
|
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
1183
|
+
// The dialog publishes every status it reads, so a sign-out in the dialog lands
|
|
1184
|
+
// in this line immediately instead of waiting for the next window focus.
|
|
1185
|
+
const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
|
|
964
1186
|
const [status, setStatus] = React.useState(undefined)
|
|
965
1187
|
React.useEffect(() => {
|
|
966
1188
|
if (!enabled) return undefined
|
|
@@ -968,7 +1190,11 @@ function GithubStatus(props) {
|
|
|
968
1190
|
const read = () => {
|
|
969
1191
|
Promise.resolve()
|
|
970
1192
|
.then(() => githubCall('status'))
|
|
971
|
-
.then(next => {
|
|
1193
|
+
.then(next => {
|
|
1194
|
+
if (!live) return
|
|
1195
|
+
setStatus(next)
|
|
1196
|
+
githubStatusStore.set(next)
|
|
1197
|
+
})
|
|
972
1198
|
.catch(() => { if (live) setStatus({ signedIn: false }) })
|
|
973
1199
|
}
|
|
974
1200
|
read()
|
|
@@ -980,15 +1206,16 @@ function GithubStatus(props) {
|
|
|
980
1206
|
}
|
|
981
1207
|
}, [enabled])
|
|
982
1208
|
if (!enabled) return null
|
|
983
|
-
const
|
|
984
|
-
const
|
|
1209
|
+
const current = shared === undefined ? status : shared
|
|
1210
|
+
const signedIn = current !== undefined && current.signedIn === true
|
|
1211
|
+
const label = current === undefined
|
|
985
1212
|
? 'GitHub · checking…'
|
|
986
|
-
: signedIn ? `GitHub · ${String(
|
|
1213
|
+
: signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
|
|
987
1214
|
return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
|
|
988
1215
|
React.createElement('button', {
|
|
989
1216
|
type: 'button',
|
|
990
1217
|
'data-polish-github': 'status',
|
|
991
|
-
'data-polish-github-state': signedIn ? 'in' :
|
|
1218
|
+
'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
|
|
992
1219
|
title: signedIn
|
|
993
1220
|
? 'Add a workspace from GitHub'
|
|
994
1221
|
: 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
|
|
@@ -31,6 +31,30 @@ server rejects a bearer token for clone, which is why the header carries
|
|
|
31
31
|
`x-access-token` basic auth. Every line of git output is scrubbed of the token
|
|
32
32
|
before it can reach the browser, and the route answers `cache-control: no-store`.
|
|
33
33
|
|
|
34
|
+
## Signing in and out
|
|
35
|
+
|
|
36
|
+
The session belongs to the GitHub CLI, and both directions stay inside the app:
|
|
37
|
+
|
|
38
|
+
- **Sign in** runs `gh auth login --hostname github.com --git-protocol https
|
|
39
|
+
--web --clipboard --skip-ssh-key` on the Host. With piped stdio that command
|
|
40
|
+
needs no terminal: it prints `! One-time code (XXXX-XXXX) copied to clipboard`
|
|
41
|
+
and `https://github.com/login/device`, then polls GitHub until the code is
|
|
42
|
+
approved. The dialog shows exactly that code with a copy button and the device
|
|
43
|
+
page, polls `loginStatus` every two seconds, and refreshes the repository list
|
|
44
|
+
the moment the CLI reports success. A flow that is never approved is killed
|
|
45
|
+
after five minutes, and cancelling it kills the child immediately; the child
|
|
46
|
+
also dies with the feature, because a pending sign-in is a live process.
|
|
47
|
+
- **Sign out** runs `gh auth logout --hostname github.com --user <login>` after a
|
|
48
|
+
second confirming click, then re-reads the session. "not logged in" counts as
|
|
49
|
+
success, because that is the state the click asked for.
|
|
50
|
+
- **Where you see it** is the sidebar foot: `GitHub · <login>` or
|
|
51
|
+
`GitHub · signed out`, on the same line as the balance. The dialog repeats it,
|
|
52
|
+
and the footer row re-reads on window focus so a `gh auth login` run in a
|
|
53
|
+
terminal shows up without a restart.
|
|
54
|
+
|
|
55
|
+
Nothing about this stores a credential of its own: the code is a one-time device
|
|
56
|
+
code, and the token never leaves the CLI's own credential store.
|
|
57
|
+
|
|
34
58
|
## How the entry point works
|
|
35
59
|
|
|
36
60
|
The "+" menu's **Add workspace…** entry only appears when something occupies
|
package/entries/github.js
CHANGED
|
@@ -114,6 +114,189 @@ export async function ghToken(run = runCommand) {
|
|
|
114
114
|
return token === '' ? undefined : token
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Pull the one-time code out of what `gh auth login --web` prints.
|
|
119
|
+
*
|
|
120
|
+
* The CLI writes `! One-time code (AE22-76C7) copied to clipboard` to stderr —
|
|
121
|
+
* the dash is the one the browser form accepts, so it is kept verbatim.
|
|
122
|
+
* @param text - captured output.
|
|
123
|
+
* @returns the code, or undefined when the line has not arrived yet.
|
|
124
|
+
*/
|
|
125
|
+
export function parseDeviceCode(text) {
|
|
126
|
+
const match = /one-time code \(([A-Z0-9]{4}-[A-Z0-9]{4})\)/i.exec(String(text))
|
|
127
|
+
return match === null ? undefined : match[1].toUpperCase()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Pull the verification URL out of what `gh auth login --web` prints.
|
|
132
|
+
* @param text - captured output.
|
|
133
|
+
* @returns the URL, defaulting to GitHub's device page.
|
|
134
|
+
*/
|
|
135
|
+
export function parseDeviceUrl(text) {
|
|
136
|
+
const match = /(https:\/\/github\.com\/login\/device)/.exec(String(text))
|
|
137
|
+
return match === null ? 'https://github.com/login/device' : match[1]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The single sign-in this process is running, if any. */
|
|
141
|
+
let loginSession
|
|
142
|
+
|
|
143
|
+
/** Default ceiling on a pending sign-in: the CLI polls until the code is used. */
|
|
144
|
+
export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Start the GitHub CLI's device flow for this machine.
|
|
148
|
+
*
|
|
149
|
+
* `gh auth login --web` needs no terminal: with piped stdio it prints the
|
|
150
|
+
* one-time code and then polls GitHub until the code is approved, which is
|
|
151
|
+
* exactly the shape a dialog can render. The child is kept alive here so the
|
|
152
|
+
* browser can poll for the outcome, and killed when the flow is cancelled,
|
|
153
|
+
* times out, or the feature is disposed.
|
|
154
|
+
* @param options - spawn override plus the timeout.
|
|
155
|
+
* @returns the code and URL to show, or the reason it could not start.
|
|
156
|
+
*/
|
|
157
|
+
export function startLogin(options = {}) {
|
|
158
|
+
if (loginSession !== undefined && loginSession.state === 'pending') {
|
|
159
|
+
return { ok: true, value: { state: 'pending', code: loginSession.code, url: loginSession.url } }
|
|
160
|
+
}
|
|
161
|
+
const spawnImpl = options.spawn ?? spawn
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS
|
|
163
|
+
const session = { state: 'starting', code: undefined, url: 'https://github.com/login/device', reason: '', child: undefined, timer: undefined }
|
|
164
|
+
let text = ''
|
|
165
|
+
const read = chunk => {
|
|
166
|
+
text += chunk.toString()
|
|
167
|
+
const code = parseDeviceCode(text)
|
|
168
|
+
if (code !== undefined && session.code === undefined) {
|
|
169
|
+
session.code = code
|
|
170
|
+
session.url = parseDeviceUrl(text)
|
|
171
|
+
session.state = 'pending'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const child = spawnImpl('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web', '--clipboard', '--skip-ssh-key'], {
|
|
176
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
177
|
+
windowsHide: true,
|
|
178
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '' },
|
|
179
|
+
})
|
|
180
|
+
session.child = child
|
|
181
|
+
child.stdout?.setEncoding?.('utf8')
|
|
182
|
+
child.stderr?.setEncoding?.('utf8')
|
|
183
|
+
child.stdout?.on?.('data', read)
|
|
184
|
+
child.stderr?.on?.('data', read)
|
|
185
|
+
child.on?.('error', error => {
|
|
186
|
+
session.state = 'failed'
|
|
187
|
+
session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
|
|
188
|
+
})
|
|
189
|
+
child.on?.('close', code => {
|
|
190
|
+
if (session.state === 'cancelled') return
|
|
191
|
+
if (code === 0) {
|
|
192
|
+
session.state = 'succeeded'
|
|
193
|
+
session.reason = ''
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
session.state = 'failed'
|
|
197
|
+
const parsed = parseDeviceCode(text)
|
|
198
|
+
session.reason = parsed === undefined
|
|
199
|
+
? 'the GitHub CLI stopped before showing a one-time code'
|
|
200
|
+
: 'the one-time code was not approved'
|
|
201
|
+
})
|
|
202
|
+
session.timer = setTimeout(() => {
|
|
203
|
+
if (session.state !== 'pending' && session.state !== 'starting') return
|
|
204
|
+
session.state = 'failed'
|
|
205
|
+
session.reason = 'the one-time code expired before it was approved'
|
|
206
|
+
session.child?.kill?.()
|
|
207
|
+
}, timeoutMs)
|
|
208
|
+
session.timer?.unref?.()
|
|
209
|
+
loginSession = session
|
|
210
|
+
return { ok: true, value: { state: session.state, code: session.code, url: session.url } }
|
|
211
|
+
} catch (error) {
|
|
212
|
+
session.state = 'failed'
|
|
213
|
+
session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
|
|
214
|
+
loginSession = session
|
|
215
|
+
return { ok: false, error: session.reason }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Report the sign-in this process is running.
|
|
221
|
+
* @returns the state, plus the code and URL while it is pending.
|
|
222
|
+
*/
|
|
223
|
+
export function loginStatus() {
|
|
224
|
+
if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
|
|
225
|
+
return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Stop a pending sign-in and forget it.
|
|
230
|
+
* @returns the state after cancelling.
|
|
231
|
+
*/
|
|
232
|
+
export function cancelLogin() {
|
|
233
|
+
if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
|
|
234
|
+
if (loginSession.timer !== undefined) clearTimeout(loginSession.timer)
|
|
235
|
+
try {
|
|
236
|
+
loginSession.child?.kill?.()
|
|
237
|
+
} catch {
|
|
238
|
+
// A child that already exited is the normal case here.
|
|
239
|
+
}
|
|
240
|
+
loginSession.state = 'cancelled'
|
|
241
|
+
loginSession.reason = 'sign-in was cancelled'
|
|
242
|
+
return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Sign the GitHub CLI out, which is what this feature's sign-in state reads.
|
|
247
|
+
* @param login - the account to remove; the CLI prompts without it.
|
|
248
|
+
* @param run - command runner, injected by tests.
|
|
249
|
+
* @returns ok when the account is gone (including when it was never there).
|
|
250
|
+
*/
|
|
251
|
+
export async function signOut(login, run = runCommand) {
|
|
252
|
+
const args = ['auth', 'logout', '--hostname', 'github.com']
|
|
253
|
+
if (typeof login === 'string' && login !== '') args.push('--user', login)
|
|
254
|
+
const answer = await run('gh', args)
|
|
255
|
+
if (answer.code === 0) return { ok: true }
|
|
256
|
+
const text = `${answer.stdout}${answer.stderr}`.trim()
|
|
257
|
+
if (/not logged in/i.test(text)) return { ok: true }
|
|
258
|
+
return { ok: false, error: text === '' ? 'the GitHub CLI could not sign out' : text }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read every account the GitHub CLI holds for github.com.
|
|
263
|
+
*
|
|
264
|
+
* A machine can carry more than one account in the CLI keyring, and `gh auth
|
|
265
|
+
* logout --user` only removes the one it names — the CLI then makes another one
|
|
266
|
+
* active, which is why a sign-out can appear to do nothing.
|
|
267
|
+
*
|
|
268
|
+
* @param run - command runner, injected by tests.
|
|
269
|
+
* @returns the accounts in the CLI's own order, the active one first when known.
|
|
270
|
+
*/
|
|
271
|
+
export async function listAccounts(run = runCommand) {
|
|
272
|
+
const answer = await run('gh', ['auth', 'status'])
|
|
273
|
+
const text = `${answer.stdout}${answer.stderr}`
|
|
274
|
+
const logins = []
|
|
275
|
+
const pattern = /Logged in to github\.com account ([^\s]+)/g
|
|
276
|
+
let match = pattern.exec(text)
|
|
277
|
+
while (match !== null) {
|
|
278
|
+
if (logins.indexOf(match[1]) === -1) logins.push(match[1])
|
|
279
|
+
match = pattern.exec(text)
|
|
280
|
+
}
|
|
281
|
+
return logins
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Sign every account out of the GitHub CLI.
|
|
286
|
+
* @param logins - accounts to remove.
|
|
287
|
+
* @param run - command runner, injected by tests.
|
|
288
|
+
* @returns ok when no account is left, else the first failure.
|
|
289
|
+
*/
|
|
290
|
+
export async function signOutAll(logins, run = runCommand) {
|
|
291
|
+
const removed = []
|
|
292
|
+
for (const login of logins) {
|
|
293
|
+
const answer = await signOut(login, run)
|
|
294
|
+
if (answer.ok !== true) return { ok: false, error: answer.error, removed }
|
|
295
|
+
removed.push(login)
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, removed }
|
|
298
|
+
}
|
|
299
|
+
|
|
117
300
|
/**
|
|
118
301
|
* Read the login that owns a token.
|
|
119
302
|
* @param token - GitHub access token.
|
|
@@ -257,19 +440,49 @@ export async function cloneRepo(owner, name, token, options = {}) {
|
|
|
257
440
|
*/
|
|
258
441
|
export async function handleRequest(body, deps = {}) {
|
|
259
442
|
const action = body?.action
|
|
443
|
+
// Sign-in is reachable while signed out: it is how a machine gets there.
|
|
444
|
+
if (action === 'login') return (deps.startLogin ?? startLogin)()
|
|
445
|
+
if (action === 'loginStatus') return { ok: true, value: (deps.loginStatus ?? loginStatus)() }
|
|
446
|
+
if (action === 'loginCancel') return { ok: true, value: (deps.cancelLogin ?? cancelLogin)() }
|
|
447
|
+
// Signing everything out is reachable while signed in, so it belongs above the
|
|
448
|
+
// token gate together with the sign-in verbs.
|
|
449
|
+
if (action === 'logoutAll') {
|
|
450
|
+
const accounts = await (deps.accounts ?? listAccounts)()
|
|
451
|
+
if (accounts.length === 0) return { ok: true, value: { logins: [] } }
|
|
452
|
+
const answer = await (deps.signOutAll ?? signOutAll)(accounts)
|
|
453
|
+
if (answer.ok !== true) return { ok: false, error: answer.error }
|
|
454
|
+
return { ok: true, value: { logins: answer.removed } }
|
|
455
|
+
}
|
|
260
456
|
const token = await (deps.token ?? ghToken)()
|
|
261
457
|
if (action === 'status') {
|
|
458
|
+
const accounts = await (deps.accounts ?? listAccounts)().catch(() => [])
|
|
262
459
|
if (token === undefined) {
|
|
263
460
|
return {
|
|
264
461
|
ok: true,
|
|
265
|
-
value: {
|
|
462
|
+
value: {
|
|
463
|
+
signedIn: false,
|
|
464
|
+
login: '',
|
|
465
|
+
accounts: [],
|
|
466
|
+
root: cloneRoot(deps.home),
|
|
467
|
+
reason: 'The GitHub CLI is not signed in on this machine. Sign in from this dialog, or run `gh auth login` in a terminal.',
|
|
468
|
+
},
|
|
266
469
|
}
|
|
267
470
|
}
|
|
268
471
|
const login = await (deps.login ?? fetchLogin)(token)
|
|
269
|
-
return { ok: true, value: { signedIn: true, login, root: cloneRoot(deps.home), reason: '' } }
|
|
472
|
+
return { ok: true, value: { signedIn: true, login, accounts, root: cloneRoot(deps.home), reason: '' } }
|
|
270
473
|
}
|
|
271
474
|
if (token === undefined) {
|
|
272
|
-
return { ok: false, error: 'Sign in
|
|
475
|
+
return { ok: false, error: 'Sign in to GitHub first: use Sign in here, or run `gh auth login` in a terminal.' }
|
|
476
|
+
}
|
|
477
|
+
if (action === 'logout') {
|
|
478
|
+
const login = await (deps.login ?? fetchLogin)(token)
|
|
479
|
+
const answer = await (deps.signOut ?? signOut)(login)
|
|
480
|
+
if (answer.ok !== true) return { ok: false, error: answer.error }
|
|
481
|
+
// The CLI may fall back to another account in its keyring; report which one,
|
|
482
|
+
// so the dialog can say what is signed in now instead of going silent.
|
|
483
|
+
const after = await (deps.token ?? ghToken)().catch(() => undefined)
|
|
484
|
+
const remaining = after === undefined ? '' : await (deps.login ?? fetchLogin)(after).catch(() => '')
|
|
485
|
+
return { ok: true, value: { login, remaining } }
|
|
273
486
|
}
|
|
274
487
|
if (action === 'repos') {
|
|
275
488
|
const repos = await (deps.repos ?? fetchRepos)(token)
|
|
@@ -307,6 +520,8 @@ export const githubFeature = {
|
|
|
307
520
|
name: 'polish-github',
|
|
308
521
|
inject: ['connection'],
|
|
309
522
|
apply(ctx, config = {}) {
|
|
523
|
+
// A pending device flow holds a child process: it must die with the feature.
|
|
524
|
+
ctx.effect(() => () => { cancelLogin() })
|
|
310
525
|
ctx.effect(() => ctx.connection.fetch.register({
|
|
311
526
|
path: GITHUB_ROUTE,
|
|
312
527
|
// Without a method list the platform would only route GET, which is why the
|
package/lib/client.js
CHANGED
|
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
]
|
|
42
42
|
const ROW_ID = "polish"
|
|
43
43
|
const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
|
|
44
|
-
const CLIENT_VERSION = "0.
|
|
44
|
+
const CLIENT_VERSION = "0.7.3"
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* The two pure decisions behind the balance line: which wallet to show, and how
|
|
@@ -845,6 +845,29 @@ window.__ModuleLoader__.load({
|
|
|
845
845
|
}
|
|
846
846
|
})()
|
|
847
847
|
|
|
848
|
+
/**
|
|
849
|
+
* The last GitHub status anyone read, shared by the dialog and the footer line.
|
|
850
|
+
*
|
|
851
|
+
* The footer used to re-read only on window focus, so signing out in the dialog
|
|
852
|
+
* left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
|
|
853
|
+
* signed in as another account). One store, written by every read, keeps them equal.
|
|
854
|
+
*/
|
|
855
|
+
const githubStatusStore = (() => {
|
|
856
|
+
let value
|
|
857
|
+
const listeners = new Set()
|
|
858
|
+
return {
|
|
859
|
+
get: () => value,
|
|
860
|
+
subscribe(listener) {
|
|
861
|
+
listeners.add(listener)
|
|
862
|
+
return () => { listeners.delete(listener) }
|
|
863
|
+
},
|
|
864
|
+
set(next) {
|
|
865
|
+
value = next
|
|
866
|
+
for (const listener of listeners) listener()
|
|
867
|
+
},
|
|
868
|
+
}
|
|
869
|
+
})()
|
|
870
|
+
|
|
848
871
|
/**
|
|
849
872
|
* Post one action to the GitHub route.
|
|
850
873
|
* @param action - `status`, `repos` or `clone`.
|
|
@@ -914,6 +937,20 @@ window.__ModuleLoader__.load({
|
|
|
914
937
|
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
915
938
|
}
|
|
916
939
|
|
|
940
|
+
/** A secondary button in the sign-in block, sized like the harness's own. */
|
|
941
|
+
const GITHUB_BUTTON_STYLE = {
|
|
942
|
+
display: 'inline-flex',
|
|
943
|
+
alignItems: 'center',
|
|
944
|
+
justifyContent: 'center',
|
|
945
|
+
padding: '4px 10px',
|
|
946
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
|
|
947
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
948
|
+
background: 'transparent',
|
|
949
|
+
color: 'inherit',
|
|
950
|
+
font: 'inherit',
|
|
951
|
+
cursor: 'pointer',
|
|
952
|
+
}
|
|
953
|
+
|
|
917
954
|
const GITHUB_ROW_STYLE = {
|
|
918
955
|
display: 'flex',
|
|
919
956
|
alignItems: 'center',
|
|
@@ -929,8 +966,7 @@ window.__ModuleLoader__.load({
|
|
|
929
966
|
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
930
967
|
}
|
|
931
968
|
|
|
932
|
-
const GITHUB_INPUT_STYLE = {
|
|
933
|
-
width: '100%',
|
|
969
|
+
const GITHUB_INPUT_STYLE = { width: '100%',
|
|
934
970
|
boxSizing: 'border-box',
|
|
935
971
|
padding: '6px 8px',
|
|
936
972
|
font: 'inherit',
|
|
@@ -960,6 +996,33 @@ window.__ModuleLoader__.load({
|
|
|
960
996
|
const [query, setQuery] = React.useState('')
|
|
961
997
|
const [error, setError] = React.useState('')
|
|
962
998
|
const [busy, setBusy] = React.useState('')
|
|
999
|
+
const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
|
|
1000
|
+
const [confirmOut, setConfirmOut] = React.useState(false)
|
|
1001
|
+
const [confirmAll, setConfirmAll] = React.useState(false)
|
|
1002
|
+
const [notice, setNotice] = React.useState('')
|
|
1003
|
+
const [copied, setCopied] = React.useState(false)
|
|
1004
|
+
|
|
1005
|
+
/** Put the one-time code on the clipboard, so it can be pasted in the browser. */
|
|
1006
|
+
const copyCode = () => {
|
|
1007
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
1008
|
+
if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
|
|
1009
|
+
Promise.resolve(clipboard.writeText(signIn.code))
|
|
1010
|
+
.then(() => {
|
|
1011
|
+
setCopied(true)
|
|
1012
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1013
|
+
})
|
|
1014
|
+
.catch(() => setCopied(false))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Re-read the session, and the repository list when it is signed in. */
|
|
1018
|
+
const refresh = () => Promise.resolve()
|
|
1019
|
+
.then(() => githubCall('status'))
|
|
1020
|
+
.then(status => {
|
|
1021
|
+
setAccount(status)
|
|
1022
|
+
githubStatusStore.set(status)
|
|
1023
|
+
if (status.signedIn !== true) return undefined
|
|
1024
|
+
return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
|
|
1025
|
+
})
|
|
963
1026
|
|
|
964
1027
|
React.useEffect(() => {
|
|
965
1028
|
if (!open || !enabled) return undefined
|
|
@@ -968,17 +1031,9 @@ window.__ModuleLoader__.load({
|
|
|
968
1031
|
setError('')
|
|
969
1032
|
setBusy('')
|
|
970
1033
|
setQuery('')
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
if (!live) return undefined
|
|
975
|
-
setAccount(status)
|
|
976
|
-
if (status.signedIn !== true) return undefined
|
|
977
|
-
return githubCall('repos').then(list => {
|
|
978
|
-
if (!live) return
|
|
979
|
-
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
980
|
-
})
|
|
981
|
-
})
|
|
1034
|
+
setConfirmOut(false)
|
|
1035
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
1036
|
+
refresh()
|
|
982
1037
|
.then(() => { if (live) setPhase('ready') })
|
|
983
1038
|
.catch(reason => {
|
|
984
1039
|
if (!live) return
|
|
@@ -988,6 +1043,29 @@ window.__ModuleLoader__.load({
|
|
|
988
1043
|
return () => { live = false }
|
|
989
1044
|
}, [open, enabled])
|
|
990
1045
|
|
|
1046
|
+
// The device flow is a long poll: ask the Host what the CLI child reports.
|
|
1047
|
+
// `starting` has to be polled too — the Host answers `starting` the moment the
|
|
1048
|
+
// child is spawned and only reports the code a tick later, so polling on
|
|
1049
|
+
// `pending` alone left the dialog asking for a code that had already arrived.
|
|
1050
|
+
React.useEffect(() => {
|
|
1051
|
+
if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
|
|
1052
|
+
const timer = setInterval(() => {
|
|
1053
|
+
Promise.resolve()
|
|
1054
|
+
.then(() => githubCall('loginStatus'))
|
|
1055
|
+
.then(answer => {
|
|
1056
|
+
setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
|
|
1057
|
+
if (answer.state === 'succeeded') {
|
|
1058
|
+
setPhase('loading')
|
|
1059
|
+
return refresh().then(() => setPhase('ready'))
|
|
1060
|
+
}
|
|
1061
|
+
if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
|
|
1062
|
+
return undefined
|
|
1063
|
+
})
|
|
1064
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
1065
|
+
}, 2000)
|
|
1066
|
+
return () => clearInterval(timer)
|
|
1067
|
+
}, [open, signIn.state])
|
|
1068
|
+
|
|
991
1069
|
React.useEffect(() => {
|
|
992
1070
|
if (!open) return undefined
|
|
993
1071
|
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
@@ -1032,6 +1110,78 @@ window.__ModuleLoader__.load({
|
|
|
1032
1110
|
})
|
|
1033
1111
|
}
|
|
1034
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Start the CLI's device flow. The Host runs it, so this dialog only renders
|
|
1115
|
+
* the code the CLI printed and the page it wants opened.
|
|
1116
|
+
*/
|
|
1117
|
+
const beginSignIn = () => {
|
|
1118
|
+
setError('')
|
|
1119
|
+
setSignIn({ state: 'starting', code: '', url: '', reason: '' })
|
|
1120
|
+
Promise.resolve()
|
|
1121
|
+
.then(() => githubCall('login'))
|
|
1122
|
+
.then(answer => setSignIn({
|
|
1123
|
+
state: answer.state ?? 'failed',
|
|
1124
|
+
code: answer.code ?? '',
|
|
1125
|
+
url: answer.url ?? '',
|
|
1126
|
+
reason: answer.reason ?? '',
|
|
1127
|
+
}))
|
|
1128
|
+
.catch(reason => {
|
|
1129
|
+
setSignIn({ state: 'failed', code: '', url: '', reason: '' })
|
|
1130
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const cancelSignIn = () => {
|
|
1135
|
+
Promise.resolve()
|
|
1136
|
+
.then(() => githubCall('loginCancel'))
|
|
1137
|
+
.then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
1138
|
+
.catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Sign the CLI out, which is what every read in this feature depends on. */
|
|
1142
|
+
const signOutNow = () => {
|
|
1143
|
+
setConfirmOut(false)
|
|
1144
|
+
setBusy('sign-out')
|
|
1145
|
+
setError('')
|
|
1146
|
+
setNotice('')
|
|
1147
|
+
Promise.resolve()
|
|
1148
|
+
.then(() => githubCall('logout'))
|
|
1149
|
+
.then(answer => {
|
|
1150
|
+
const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
|
|
1151
|
+
setAccount(undefined)
|
|
1152
|
+
setRepos([])
|
|
1153
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
1154
|
+
setNotice(remaining === ''
|
|
1155
|
+
? `Signed out of ${String(answer.login)}.`
|
|
1156
|
+
: `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
|
|
1157
|
+
return refresh()
|
|
1158
|
+
})
|
|
1159
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
1160
|
+
.then(() => setBusy(''))
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Sign every account in the CLI's keyring out, so no silent fallback is left. */
|
|
1164
|
+
const signOutAllNow = () => {
|
|
1165
|
+
setConfirmAll(false)
|
|
1166
|
+
setBusy('sign-out-all')
|
|
1167
|
+
setError('')
|
|
1168
|
+
setNotice('')
|
|
1169
|
+
Promise.resolve()
|
|
1170
|
+
.then(() => githubCall('logoutAll'))
|
|
1171
|
+
.then(answer => {
|
|
1172
|
+
const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
|
|
1173
|
+
setAccount(undefined)
|
|
1174
|
+
setRepos([])
|
|
1175
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
1176
|
+
setNotice(logins.length === 0
|
|
1177
|
+
? 'No GitHub account was signed in.'
|
|
1178
|
+
: `Signed out of ${logins.join(', ')}.`)
|
|
1179
|
+
return refresh()
|
|
1180
|
+
})
|
|
1181
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
1182
|
+
.then(() => setBusy(''))
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1035
1185
|
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
1036
1186
|
key: repo.fullName,
|
|
1037
1187
|
type: 'button',
|
|
@@ -1059,18 +1209,45 @@ window.__ModuleLoader__.load({
|
|
|
1059
1209
|
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
1060
1210
|
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
1061
1211
|
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
1062
|
-
React.createElement('
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1212
|
+
React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
|
|
1213
|
+
signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
|
|
1214
|
+
? React.createElement('button', {
|
|
1215
|
+
type: 'button',
|
|
1216
|
+
'data-polish-github': 'signoutall',
|
|
1217
|
+
disabled: busy === 'sign-out-all',
|
|
1218
|
+
onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
|
|
1219
|
+
title: 'Sign every GitHub account in the CLI out of this machine',
|
|
1220
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1221
|
+
}, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
|
|
1222
|
+
: null,
|
|
1223
|
+
signedIn
|
|
1224
|
+
? React.createElement('button', {
|
|
1225
|
+
type: 'button',
|
|
1226
|
+
'data-polish-github': 'signout',
|
|
1227
|
+
disabled: busy === 'sign-out',
|
|
1228
|
+
onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
|
|
1229
|
+
title: 'Sign the GitHub CLI out of this machine',
|
|
1230
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1231
|
+
}, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
|
|
1232
|
+
: null,
|
|
1233
|
+
React.createElement('button', {
|
|
1234
|
+
type: 'button',
|
|
1235
|
+
onClick: () => githubStore.set(false),
|
|
1236
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
1237
|
+
'aria-label': 'Close',
|
|
1238
|
+
}, '×'))),
|
|
1068
1239
|
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
1069
1240
|
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
1070
1241
|
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
1071
1242
|
!signedIn && phase !== 'loading'
|
|
1072
|
-
? '
|
|
1243
|
+
? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
|
|
1073
1244
|
: null),
|
|
1245
|
+
notice === ''
|
|
1246
|
+
? null
|
|
1247
|
+
: React.createElement('div', {
|
|
1248
|
+
'data-polish-github': 'notice',
|
|
1249
|
+
style: { marginTop: '6px', opacity: 0.85 },
|
|
1250
|
+
}, notice),
|
|
1074
1251
|
signedIn
|
|
1075
1252
|
? React.createElement('input', {
|
|
1076
1253
|
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
@@ -1084,6 +1261,48 @@ window.__ModuleLoader__.load({
|
|
|
1084
1261
|
error !== ''
|
|
1085
1262
|
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
1086
1263
|
: null,
|
|
1264
|
+
!signedIn && phase !== 'loading'
|
|
1265
|
+
? React.createElement('div', { style: { marginTop: '12px' } },
|
|
1266
|
+
signIn.state === 'pending' || signIn.state === 'starting'
|
|
1267
|
+
? React.createElement('div', null,
|
|
1268
|
+
React.createElement('div', { style: { marginBottom: '8px' } },
|
|
1269
|
+
signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
|
|
1270
|
+
signIn.code === ''
|
|
1271
|
+
? null
|
|
1272
|
+
: React.createElement('div', {
|
|
1273
|
+
'data-polish-github': 'code',
|
|
1274
|
+
style: {
|
|
1275
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
1276
|
+
fontSize: '18px',
|
|
1277
|
+
letterSpacing: '2px',
|
|
1278
|
+
padding: '8px 10px',
|
|
1279
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1280
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
1281
|
+
},
|
|
1282
|
+
}, signIn.code),
|
|
1283
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
|
|
1284
|
+
React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
|
|
1285
|
+
copied ? 'Copied' : 'Copy code'),
|
|
1286
|
+
React.createElement('a', {
|
|
1287
|
+
href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
|
|
1288
|
+
target: '_blank',
|
|
1289
|
+
rel: 'noreferrer',
|
|
1290
|
+
style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
|
|
1291
|
+
}, 'Open the device page'),
|
|
1292
|
+
React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
|
|
1293
|
+
: React.createElement('div', null,
|
|
1294
|
+
React.createElement('button', {
|
|
1295
|
+
type: 'button',
|
|
1296
|
+
onClick: beginSignIn,
|
|
1297
|
+
// The attribute belongs on the element: inside `style` it is ignored,
|
|
1298
|
+
// which is exactly why the first verification could not find this button.
|
|
1299
|
+
'data-polish-github': 'signin',
|
|
1300
|
+
style: GITHUB_BUTTON_STYLE,
|
|
1301
|
+
}, 'Sign in with GitHub'),
|
|
1302
|
+
signIn.state === 'failed' && signIn.reason !== ''
|
|
1303
|
+
? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
|
|
1304
|
+
: null))
|
|
1305
|
+
: null,
|
|
1087
1306
|
signedIn
|
|
1088
1307
|
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
1089
1308
|
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
@@ -1128,6 +1347,9 @@ window.__ModuleLoader__.load({
|
|
|
1128
1347
|
*/
|
|
1129
1348
|
function GithubStatus(props) {
|
|
1130
1349
|
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
1350
|
+
// The dialog publishes every status it reads, so a sign-out in the dialog lands
|
|
1351
|
+
// in this line immediately instead of waiting for the next window focus.
|
|
1352
|
+
const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
|
|
1131
1353
|
const [status, setStatus] = React.useState(undefined)
|
|
1132
1354
|
React.useEffect(() => {
|
|
1133
1355
|
if (!enabled) return undefined
|
|
@@ -1135,7 +1357,11 @@ window.__ModuleLoader__.load({
|
|
|
1135
1357
|
const read = () => {
|
|
1136
1358
|
Promise.resolve()
|
|
1137
1359
|
.then(() => githubCall('status'))
|
|
1138
|
-
.then(next => {
|
|
1360
|
+
.then(next => {
|
|
1361
|
+
if (!live) return
|
|
1362
|
+
setStatus(next)
|
|
1363
|
+
githubStatusStore.set(next)
|
|
1364
|
+
})
|
|
1139
1365
|
.catch(() => { if (live) setStatus({ signedIn: false }) })
|
|
1140
1366
|
}
|
|
1141
1367
|
read()
|
|
@@ -1147,15 +1373,16 @@ window.__ModuleLoader__.load({
|
|
|
1147
1373
|
}
|
|
1148
1374
|
}, [enabled])
|
|
1149
1375
|
if (!enabled) return null
|
|
1150
|
-
const
|
|
1151
|
-
const
|
|
1376
|
+
const current = shared === undefined ? status : shared
|
|
1377
|
+
const signedIn = current !== undefined && current.signedIn === true
|
|
1378
|
+
const label = current === undefined
|
|
1152
1379
|
? 'GitHub · checking…'
|
|
1153
|
-
: signedIn ? `GitHub · ${String(
|
|
1380
|
+
: signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
|
|
1154
1381
|
return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
|
|
1155
1382
|
React.createElement('button', {
|
|
1156
1383
|
type: 'button',
|
|
1157
1384
|
'data-polish-github': 'status',
|
|
1158
|
-
'data-polish-github-state': signedIn ? 'in' :
|
|
1385
|
+
'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
|
|
1159
1386
|
title: signedIn
|
|
1160
1387
|
? 'Add a workspace from GitHub'
|
|
1161
1388
|
: 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryacode/dsh-aryacode-polish",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|