/ src / screens / Doctor.tsx
Doctor.tsx
  1  import React, { useCallback, useEffect, useState } from 'react'
  2  import { Box, Text, useInput } from 'ink'
  3  import { Select } from '@inkjs/ui'
  4  import { getTheme } from '../utils/theme.js'
  5  import { ConfigureNpmPrefix } from './ConfigureNpmPrefix.js'
  6  import { platform } from 'process'
  7  import {
  8    checkNpmPermissions,
  9    getDefaultNpmPrefix,
 10    getPermissionsCommand,
 11  } from '../utils/autoUpdater.js'
 12  import { saveGlobalConfig, getGlobalConfig } from '../utils/config.js'
 13  import { logEvent } from '../services/statsig.js'
 14  import { PRODUCT_NAME } from '../constants/product.js'
 15  import { PressEnterToContinue } from '../components/PressEnterToContinue.js'
 16  
 17  type Props = {
 18    onDone: () => void
 19    doctorMode?: boolean
 20  }
 21  
 22  type Option = {
 23    label: string
 24    value: 'auto' | 'manual' | 'ignore'
 25    description: string
 26  }
 27  
 28  export function Doctor({ onDone, doctorMode = false }: Props): React.ReactNode {
 29    const [hasPermissions, setHasPermissions] = useState<boolean | null>(null)
 30    const [npmPrefix, setNpmPrefix] = useState<string | null>(null)
 31    const [selectedOption, setSelectedOption] = useState<Option['value'] | null>(
 32      null,
 33    )
 34    const [customPrefix, setCustomPrefix] = useState<string>(
 35      getDefaultNpmPrefix(),
 36    )
 37    const theme = getTheme()
 38    const [showingPermissionsMessage, setShowingPermissionsMessage] =
 39      useState(false)
 40  
 41    const options: Option[] = [
 42      {
 43        label: `Manually fix permissions on current npm prefix (Recommended)`,
 44        value: 'manual',
 45        description:
 46          platform === 'win32'
 47            ? 'Uses icacls to grant write permissions'
 48            : 'Uses sudo to change ownership',
 49      },
 50      {
 51        label: 'Create new npm prefix directory',
 52        value: 'auto',
 53        description:
 54          'Creates a new directory for global npm packages in your home directory',
 55      },
 56      {
 57        label: 'Skip configuration until next session',
 58        value: 'ignore',
 59        description: 'Skip this warning (you will be reminded again later)',
 60      },
 61    ]
 62  
 63    const checkPermissions = useCallback(async () => {
 64      const result = await checkNpmPermissions()
 65      logEvent('tengu_auto_updater_permissions_check', {
 66        hasPermissions: result.hasPermissions.toString(),
 67        npmPrefix: result.npmPrefix ?? 'null',
 68      })
 69      setHasPermissions(result.hasPermissions)
 70      if (result.npmPrefix) {
 71        setNpmPrefix(result.npmPrefix)
 72      }
 73      if (result.hasPermissions) {
 74        const config = getGlobalConfig()
 75        saveGlobalConfig({
 76          ...config,
 77          autoUpdaterStatus: 'enabled',
 78        })
 79        if (!doctorMode) {
 80          onDone()
 81        }
 82      }
 83    }, [onDone, doctorMode])
 84  
 85    useEffect(() => {
 86      logEvent('tengu_auto_updater_config_start', {})
 87      checkPermissions()
 88    }, [checkPermissions])
 89  
 90    useInput(
 91      (_input, key) => {
 92        if (
 93          (showingPermissionsMessage ||
 94            (doctorMode && hasPermissions === true)) &&
 95          key.return
 96        ) {
 97          onDone()
 98        }
 99      },
100      {
101        isActive:
102          showingPermissionsMessage || (doctorMode && hasPermissions === true),
103      },
104    )
105  
106    if (hasPermissions === null) {
107      return (
108        <Box paddingX={1} paddingTop={1}>
109          <Text color={theme.secondaryText}>Checking npm permissions…</Text>
110        </Box>
111      )
112    }
113  
114    if (hasPermissions === true) {
115      if (doctorMode) {
116        return (
117          <Box flexDirection="column" gap={1} paddingX={1} paddingTop={1}>
118            <Text color={theme.success}>✓ npm permissions: OK</Text>
119            <Text>Your installation is healthy and ready for auto-updates.</Text>
120            <PressEnterToContinue />
121          </Box>
122        )
123      }
124      return (
125        <Box paddingX={1} paddingTop={1}>
126          <Text color={theme.success}>✓ Auto-updates enabled</Text>
127        </Box>
128      )
129    }
130    return (
131      <Box
132        borderColor={theme.permission}
133        borderStyle="round"
134        flexDirection="column"
135        gap={1}
136        paddingX={1}
137        paddingTop={1}
138      >
139        <Text bold color={theme.permission}>
140          Enable automatic updates?
141        </Text>
142        <Text>
143          {PRODUCT_NAME} can&apos;t update itself because it doesn&apos;t have
144          permissions. Do you want to fix this to get automatic updates?
145        </Text>
146        <Box flexDirection="column">
147          {!selectedOption && (
148            <Box marginLeft={2}>
149              <Text>Select an option below to fix the permissions issue:</Text>
150              <Select
151                options={options}
152                onChange={(value: string) => {
153                  if (
154                    value !== 'auto' &&
155                    value !== 'manual' &&
156                    value !== 'ignore'
157                  )
158                    return
159                  setSelectedOption(value)
160  
161                  // Log option selection
162                  logEvent('tengu_auto_updater_config_option_selected', {
163                    option: value as 'auto' | 'manual' | 'ignore',
164                    npmPrefix: npmPrefix ?? 'null',
165                  })
166  
167                  if (value === 'manual') {
168                    const config = getGlobalConfig()
169                    saveGlobalConfig({
170                      ...config,
171                      autoUpdaterStatus: 'not_configured',
172                    })
173                    setShowingPermissionsMessage(true)
174                  } else if (value === 'ignore') {
175                    const config = getGlobalConfig()
176                    saveGlobalConfig({
177                      ...config,
178                      autoUpdaterStatus: 'not_configured',
179                    })
180                    onDone()
181                  }
182                }}
183              />
184            </Box>
185          )}
186  
187          {selectedOption === 'auto' && (
188            <Box marginLeft={2}>
189              <ConfigureNpmPrefix
190                customPrefix={customPrefix}
191                onCustomPrefixChange={setCustomPrefix}
192                onSuccess={checkPermissions}
193                onCancel={onDone}
194              />
195            </Box>
196          )}
197  
198          {selectedOption === 'manual' && (
199            <>
200              <Box marginLeft={4} flexDirection="column">
201                <Text>Run this command in your terminal:</Text>
202                <Box flexDirection="row" gap={1}>
203                  <Text color={theme.warning}>
204                    {getPermissionsCommand(npmPrefix ?? '')}
205                  </Text>
206                </Box>
207                <Box flexDirection="row" gap={1}>
208                  <Text color={theme.suggestion}>
209                    After running the command, restart {PRODUCT_NAME}
210                  </Text>
211                </Box>
212              </Box>
213              <PressEnterToContinue />
214            </>
215          )}
216        </Box>
217      </Box>
218    )
219  }