@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 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
- Promise.resolve()
805
- .then(() => githubCall('status'))
806
- .then(status => {
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('button', {
896
- type: 'button',
897
- onClick: () => githubStore.set(false),
898
- style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
899
- 'aria-label': 'Close',
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
- ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
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 => { if (live) setStatus(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 signedIn = status !== undefined && status.signedIn === true
984
- const label = status === undefined
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(status.login)}` : 'GitHub · signed out'
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' : status === undefined ? 'checking' : 'out',
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: { signedIn: false, login: '', root: cloneRoot(deps.home), reason: 'The GitHub CLI is missing or not signed in: run `gh auth login` in a terminal.' },
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 with the GitHub CLI first: run `gh auth login` in a terminal, then try again.' }
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.6.2"
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
- Promise.resolve()
972
- .then(() => githubCall('status'))
973
- .then(status => {
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('button', {
1063
- type: 'button',
1064
- onClick: () => githubStore.set(false),
1065
- style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
1066
- 'aria-label': 'Close',
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
- ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
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 => { if (live) setStatus(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 signedIn = status !== undefined && status.signedIn === true
1151
- const label = status === undefined
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(status.login)}` : 'GitHub · signed out'
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' : status === undefined ? 'checking' : 'out',
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.6.2",
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",