claude-code/commands/remote-setup/remote-setup.tsx

187 lines
21 KiB
TypeScript
Raw Permalink Normal View History

import { execa } from 'execa';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { Select } from '../../components/CustomSelect/index.js';
import { Dialog } from '../../components/design-system/Dialog.js';
import { LoadingState } from '../../components/design-system/LoadingState.js';
import { Box, Text } from '../../ink.js';
import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString } from '../../services/analytics/index.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { openBrowser } from '../../utils/browser.js';
import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js';
import { createDefaultEnvironment, getCodeWebUrl, type ImportTokenError, importGithubToken, isSignedIn, RedactedGithubToken } from './api.js';
type CheckResult = {
status: 'not_signed_in';
} | {
status: 'has_gh_token';
token: RedactedGithubToken;
} | {
status: 'gh_not_installed';
} | {
status: 'gh_not_authenticated';
};
async function checkLoginState(): Promise<CheckResult> {
if (!(await isSignedIn())) {
return {
status: 'not_signed_in'
};
}
const ghStatus = await getGhAuthStatus();
if (ghStatus === 'not_installed') {
return {
status: 'gh_not_installed'
};
}
if (ghStatus === 'not_authenticated') {
return {
status: 'gh_not_authenticated'
};
}
// ghStatus === 'authenticated'. getGhAuthStatus spawns with stdout:'ignore'
// (telemetry-safe); spawn once more with stdout:'pipe' to read the token.
const {
stdout
} = await execa('gh', ['auth', 'token'], {
stdout: 'pipe',
stderr: 'ignore',
timeout: 5000,
reject: false
});
const trimmed = stdout.trim();
if (!trimmed) {
return {
status: 'gh_not_authenticated'
};
}
return {
status: 'has_gh_token',
token: new RedactedGithubToken(trimmed)
};
}
function errorMessage(err: ImportTokenError, codeUrl: string): string {
switch (err.kind) {
case 'not_signed_in':
return `Login failed. Please visit ${codeUrl} and login using the GitHub App`;
case 'invalid_token':
return 'GitHub rejected that token. Run `gh auth login` and try again.';
case 'server':
return `Server error (${err.status}). Try again in a moment.`;
case 'network':
return "Couldn't reach the server. Check your connection.";
}
}
type Step = {
name: 'checking';
} | {
name: 'confirm';
token: RedactedGithubToken;
} | {
name: 'uploading';
};
function Web({
onDone
}: {
onDone: LocalJSXCommandOnDone;
}) {
const [step, setStep] = useState<Step>({
name: 'checking'
});
useEffect(() => {
logEvent('tengu_remote_setup_started', {});
void checkLoginState().then(async result => {
switch (result.status) {
case 'not_signed_in':
logEvent('tengu_remote_setup_result', {
result: 'not_signed_in' as SafeString
});
onDone('Not signed in to Claude. Run /login first.');
return;
case 'gh_not_installed':
case 'gh_not_authenticated':
{
const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`;
await openBrowser(url);
logEvent('tengu_remote_setup_result', {
result: result.status as SafeString
});
onDone(result.status === 'gh_not_installed' ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`);
return;
}
case 'has_gh_token':
setStep({
name: 'confirm',
token: result.token
});
}
});
// onDone is stable across renders; intentionally not in deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCancel = () => {
logEvent('tengu_remote_setup_result', {
result: 'cancelled' as SafeString
});
onDone();
};
const handleConfirm = async (token: RedactedGithubToken) => {
setStep({
name: 'uploading'
});
const result = await importGithubToken(token);
if (!result.ok) {
logEvent('tengu_remote_setup_result', {
result: 'import_failed' as SafeString,
error_kind: result.error.kind as SafeString
});
onDone(errorMessage(result.error, getCodeWebUrl()));
return;
}
// Token import succeeded. Environment creation is best-effort — if it
// fails, the web state machine routes to env-setup on landing, which is
// one extra click but still better than the OAuth dance.
await createDefaultEnvironment();
const url = getCodeWebUrl();
await openBrowser(url);
logEvent('tengu_remote_setup_result', {
result: 'success' as SafeString
});
onDone(`Connected as ${result.result.github_username}. Opened ${url}`);
};
if (step.name === 'checking') {
return <LoadingState message="Checking login status…" />;
}
if (step.name === 'uploading') {
return <LoadingState message="Connecting GitHub to Claude…" />;
}
const token = step.token;
return <Dialog title="Connect Claude on the web to GitHub?" onCancel={handleCancel} hideInputGuide>
<Box flexDirection="column">
<Text>
Claude on the web requires connecting to your GitHub account to clone
and push code on your behalf.
</Text>
<Text dimColor>
Your local credentials are used to authenticate with GitHub
</Text>
</Box>
<Select options={[{
label: 'Continue',
value: 'send'
}, {
label: 'Cancel',
value: 'cancel'
}]} onChange={value => {
if (value === 'send') {
void handleConfirm(token);
} else {
handleCancel();
}
}} onCancel={handleCancel} />
</Dialog>;
}
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
return <Web onDone={onDone} />;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJleGVjYSIsIlJlYWN0IiwidXNlRWZmZWN0IiwidXNlU3RhdGUiLCJTZWxlY3QiLCJEaWFsb2ciLCJMb2FkaW5nU3RhdGUiLCJCb3giLCJUZXh0IiwibG9nRXZlbnQiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTIiwiU2FmZVN0cmluZyIsIkxvY2FsSlNYQ29tbWFuZE9uRG9uZSIsIm9wZW5Ccm93c2VyIiwiZ2V0R2hBdXRoU3RhdHVzIiwiY3JlYXRlRGVmYXVsdEVudmlyb25tZW50IiwiZ2V0Q29kZVdlYlVybCIsIkltcG9ydFRva2VuRXJyb3IiLCJpbXBvcnRHaXRodWJUb2tlbiIsImlzU2lnbmVkSW4iLCJSZWRhY3RlZEdpdGh1YlRva2VuIiwiQ2hlY2tSZXN1bHQiLCJzdGF0dXMiLCJ0b2tlbiIsImNoZWNrTG9naW5TdGF0ZSIsIlByb21pc2UiLCJnaFN0YXR1cyIsInN0ZG91dCIsInN0ZGVyciIsInRpbWVvdXQiLCJyZWplY3QiLCJ0cmltbWVkIiwidHJpbSIsImVycm9yTWVzc2FnZSIsImVyciIsImNvZGVVcmwiLCJraW5kIiwiU3RlcCIsIm5hbWUiLCJXZWIiLCJvbkRvbmUiLCJzdGVwIiwic2V0U3RlcCIsInRoZW4iLCJyZXN1bHQiLCJ1cmwiLCJoYW5kbGVDYW5jZWwiLCJoYW5kbGVDb25maXJtIiwib2siLCJlcnJvcl9raW5kIiwiZXJyb3IiLCJnaXRodWJfdXNlcm5hbWUiLCJsYWJlbCIsInZhbHVlIiwiY2FsbCIsIlJlYWN0Tm9kZSJdLCJzb3VyY2VzIjpbInJlbW90ZS1zZXR1cC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZXhlY2EgfSBmcm9tICdleGVjYSdcbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlRWZmZWN0LCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgU2VsZWN0IH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9DdXN0b21TZWxlY3QvaW5kZXguanMnXG5pbXBvcnQgeyBEaWFsb2cgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL2Rlc2lnbi1zeXN0ZW0vRGlhbG9nLmpzJ1xuaW1wb3J0IHsgTG9hZGluZ1N0YXRlIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9kZXNpZ24tc3lzdGVtL0xvYWRpbmdTdGF0ZS5qcydcbmltcG9ydCB7IEJveCwgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcbmltcG9ydCB7XG4gIGxvZ0V2ZW50LFxuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMgYXMgU2FmZVN0cmluZyxcbn0gZnJvbSAnLi4vLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHsgb3BlbkJyb3dzZXIgfSBmcm9tICcuLi8uLi91dGlscy9icm93c2VyLmpzJ1xuaW1wb3J0IHsgZ2V0R2hBdXRoU3RhdHVzIH0gZnJvbSAnLi4vLi4vdXRpbHMvZ2l0aHViL2doQXV0aFN0YXR1cy5qcydcbmltcG9ydCB7XG4gIGNyZWF0ZURlZmF1bHRFbnZpcm9ubWVudCxcbiAgZ2V0Q29kZVdlYlVybCxcbiAgdHlwZSBJbXBvcnRUb2tlbkVycm9yLFxuICBpbXBvcnRHaXRodWJUb2tlbixcbiAgaXNTaWduZWRJbixcbiAgUmVkYWN0ZWRHaXRodWJUb2tlbixcbn0gZnJvbSAnLi9hcGkuanMnXG5cbnR5cGUgQ2hlY2tSZXN1bHQgPVxuICB8IHsgc3RhdHVzOiAnbm90X3NpZ25lZF9pbicgfVxuICB8IHsgc3RhdHVzOiAnaGFzX2doX3Rva2VuJzsgdG9rZW46IFJlZGFjdGVkR2l0aHViVG9rZW4gfVxuICB8IHsgc3RhdHVzOiAnZ2hfbm90X2luc3RhbGxlZCcgfVxuICB8IHsgc3RhdHVzOiAnZ2hfbm90X2F1dGhlbnRpY2F0ZWQnIH1cblxuYXN5bmMgZnVuY3Rpb24gY2hlY2tMb2dpblN0YXRlKCk6IFByb21pc2U8Q2hlY2tSZXN1bHQ+IHtcbiAgaWYgKCEoYXdhaXQgaXNTaWduZWRJbigpKSkge1xuICAgIHJldHVybiB7IHN0YXR1czogJ25vdF9zaWduZWRfaW4nIH1cbiAgfVxuXG4gIGNvbnN0IGdoU3RhdHVzID0gYXdhaXQgZ2V0R2hBdXRoU3RhdHVzKClcbiAgaWYgKGdoU3RhdHVzID09PSAnbm90X2luc3RhbGxlZCcpIHtcbiAgICByZXR1cm4geyBzdGF0dXM6ICdnaF9ub3RfaW5zdGFsbGVkJyB9XG4gIH1cbiAgaWYgKGdoU3RhdHVzID09PSAnbm90X2F1dGhlbnRpY2F0ZWQnKSB7XG4gICAgcmV0dXJuIHsgc3RhdHVzOiAnZ2hfbm90X2F1dGhlbnRpY2F0ZWQnIH1cbiAgfVxuXG4gIC8vIGdoU3RhdHVzID09PSAnYXV0aGVudGljYXRlZCcuIGdldEdoQXV0aFN0YXR1cyBzcGF3bnMgd2l0aCBzdGRvdXQ6J2lnbm9yZSdcbiAgLy8gKHRlbGVtZXRyeS1zYWZlKTsgc3Bhd24gb25jZSBtb3JlIHdpdGggc3Rkb3V0OidwaXBlJyB0byByZWFkIHRoZSB0b2tlbi5cbiAgY29uc3QgeyBzdGRvdXQgfSA9IGF3YWl0IGV4ZWNhKCdnaCcsIFsnYXV0aCcsICd0b2tlbiddLCB7XG4gICAgc3Rkb3V0OiAncGlwZScsXG4gICAgc3RkZXJyOiAnaWdub3JlJyxcbiAgICB0aW1lb3V0OiA1MDAwLFxuICAgIHJlamVjdDogZmFsc2UsXG4gIH0pXG4gIGNvbnN0IHRyaW1tZWQgPSBzdGRvdXQudHJpbSgpXG4gIGlmICghdHJpbW1lZCkge1xuICAgIHJldHVybiB7IHN0YXR1czogJ2doX25vdF9hdXRoZW50aWNhdGVkJyB9XG4gIH1cbiAgcmV0dXJuIHsgc3RhdHVzOiAnaGFzX2doX3Rva2VuJywgdG9rZW46IG5ldyBSZWRhY3RlZEdpdGh1YlRva2VuKHRyaW1tZWQpIH1cbn1cblxuZnVuY3Rpb24gZXJyb3JNZXNzYWdlKGVycjogSW1wb3J0VG9rZW5FcnJvciwgY29kZVVybDogc3RyaW5nKTogc3RyaW5nIHtcbiAgc3dpdGNoIChlcnIua2luZCkge1xuICAgIGNhc2UgJ25vdF9zaWduZWRfaW4nOlxuICAgICAgcmV0dXJuIGBMb2dpbiBmYWlsZWQuIFBsZWFzZSB2aXNpdCAke2NvZGVVcmx9IGFuZCBsb2dpbiB1c2luZyB0aGUgR2l0SHViIEFwcGBcbiAgICBjYXNlICdpbnZhbGlkX3Rva2VuJzpcbiAgICAgIHJldHVybiAnR2l0SHViIHJlamVjdGVkIHRoYXQgdG9rZW4uIFJ1biBgZ2ggYXV0aCBsb2dpbmAgYW5kIHRyeSBhZ2Fpbi4nXG4gICAgY2FzZSAnc2VydmVyJzpcbiAgICAgIHJldHVybiB