From 9001078cb21306e82c0dd0d627c0769f60d2bbf2 Mon Sep 17 00:00:00 2001 From: Mani bharadwaj <111006838+Manibharadwaj@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:44:17 +0530 Subject: [PATCH] fix(cli): detect expired token on deploy and offer interactive re-auth (#21335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the CONTRIBUTING.md file. YES ## What kind of change does this PR introduce? Fix (CLI) — `twenty deploy` now detects an expired/invalid API key on the active remote and offers an interactive re-auth flow (TTY only). In non-TTY contexts the behavior is unchanged: a clear error and a non-zero exit. Fixes #20197 ## What is the current behavior? After a workspace DB reset, key revocation, or workspace deletion, both `twenty deploy` and (effectively) `twenty dev` fail with: ``` Upload failed: Token has expired. ``` The message is technically correct but gives the user no way forward. They have to know to mint a new key from **Settings → Developers** and re-run `twenty remote add --local --api-key `. This came up while testing PR #20181 and is the same friction on any DB reset, key revocation, or workspace deletion. ## What is the new behavior? Two changes, layered: ### (1) Better error message + remediation hint When the upload returns a 401 or its message matches a token-expired pattern (`/token has expired|unauthori[sz]ed|invalid api key/i`), `appDeploy` now prints: ``` Your API key for remote "local" is no longer valid (the workspace may have been reset, or the key was revoked). Re-authenticate with: twenty remote:add --as local --api-key Generate a new key at: /settings/developers ``` ### (2) Interactive re-auth prompt (TTY only) If the process is attached to a TTY, after the hint is printed the user is prompted: ``` Re-authenticate now? (Y/n) ``` - **Yes** → re-validate the token (it may have been refreshed externally), and if still invalid, instruct the user to re-run `remote:add`. The original `appDeploy` is then retried once. - **No** → the original `DEPLOY_FAILED` error is surfaced (same code, better message). - **Non-TTY (CI, scripts, redirects)** → the prompt is suppressed entirely. The user gets the hint and a non-zero exit, preserving scriptable behavior. **No change** to existing CI scripts. ## Acceptance criteria | Scenario | Before | After | |---|---|---| | Happy path deploy | ✅ works | ✅ works (no change) | | Deploy with expired key (TTY) | generic error, exit 1 | hint + prompt, retry on Y, error on N | | Deploy with expired key (CI / no-TTY) | generic error, exit 1 | hint + exit 1 (no prompt, scriptable) | | Deploy with unrelated error (e.g. 500) | generic error, exit 1 | unchanged (no false positive on the matcher) | ## Reproduction 1. Spin up Twenty, mint an API key, run `twenty deploy` — confirm the happy path. 2. Reset the DB (`core.appToken` cleared) and re-run `twenty deploy` — confirm the new hint + prompt fire and the retry succeeds. 3. Repeat step 2 in a non-TTY context (e.g. `twenty deploy < /dev/null` or via `script -qc ''`) — confirm the prompt is suppressed and the scriptable exit-1 behavior is preserved. ## Implementation notes - **`FileApi.uploadAppTarball`** now tags 401 responses with an `isAuthError: true` flag on the failing `ApiResponse`. The existing `error` string is still populated so callers that don't check the flag continue to work — **additive, no breaking change**. - **`FailingApiResponse`** gained an optional `isAuthError?: boolean` field. The other `ApiResponse` call sites in the SDK don't need to set it. - **`@/cli/utilities/auth/reauth-helper.ts`** is new. It owns: - `isTokenExpiredMessage(...)` — pure matcher, easy to unit-test, used as a backstop if a non-401 message still says "expired" (GraphQL returns 200 with errors in some cases). - `promptForReauthentication(remoteName)` — TTY-gated `inquirer.confirm` prompt that re-validates the token and either returns `'reauthenticated'`, `'declined'`, or `'non-interactive'`. - **`@/cli/operations/deploy.ts`** is the single call site that wires the helper. The helper is structured so it can be reused from the dev orchestrator's upload step (a follow-up) without changes. - **New unit test** at `__tests__/reauth-helper.test.ts` covers the matcher: positive cases, negative cases, case-insensitivity, and nullish input. ## Out of scope (per the issue) - Long-lived dev tokens for `--local` remotes. - Web-based OAuth login flow for the CLI (the existing `authenticate(...)` flow in `remote.ts` is fine; the prompt here just tells the user to re-run it). ## Files changed ``` packages/twenty-sdk/src/cli/operations/deploy.ts | 33 ++++++++ packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts | 1 + packages/twenty-sdk/src/cli/utilities/api/file-api.ts | 8 +++ packages/twenty-sdk/src/cli/utilities/auth/__tests__/reauth-helper.test.ts | 34 ++++++++++ packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts | 61 ++++++++++++++++++ 5 files changed, 137 insertions(+) ``` Happy to address feedback and split this into two PRs (hint-only first, prompt-on-top) if the maintainers prefer a smaller first cut. --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: martmull --- packages/create-twenty-app/README.md | 2 +- .../src/create-app.command.ts | 4 +- .../twenty-sdk/src/cli/commands/dev/dev.ts | 14 +++ .../twenty-sdk/src/cli/operations/dev-once.ts | 25 ++++-- .../src/cli/utilities/api/api-client.ts | 86 +++++++++++++++++-- .../cli/utilities/api/api-response-type.ts | 1 + .../src/cli/utilities/api/file-api.ts | 8 ++ .../src/cli/utilities/auth/reauth-helper.ts | 43 ++++++++++ .../steps/check-server-orchestrator-step.ts | 2 +- 9 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index 64c8c2b524..9b260f9b02 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -49,7 +49,7 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht ## Troubleshooting - Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`. -- Auth not working: run `yarn twenty remote:add --local` to re-authenticate. +- Auth not working: run `yarn twenty remote:add` to re-authenticate. - Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client. ## Contributing diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 0d6291005a..fa5be51ddd 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -572,7 +572,7 @@ export class CreateAppCommand { console.log( chalk.yellow( - ' Authentication failed. Run `yarn twenty remote:add --local` manually.', + ' Authentication failed. Run `yarn twenty remote:add` manually.', ), ); @@ -580,7 +580,7 @@ export class CreateAppCommand { } catch { console.log( chalk.yellow( - ' Authentication failed. Run `yarn twenty remote:add --local` manually.', + ' Authentication failed. Run `yarn twenty remote:add` manually.', ), ); diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts index 70e6c76412..709f727f88 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts @@ -1,3 +1,6 @@ +import { ApiService } from '@/cli/utilities/api/api-service'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; +import { ConfigService } from '@/cli/utilities/config/config-service'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator'; import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; @@ -27,6 +30,15 @@ export class AppDevCommand { return this.orchestrator; } + private async ensureAuthenticatedBeforeLaunch(): Promise { + const apiService = new ApiService({ disableInterceptors: true }); + const { serverUp, authValid } = await apiService.validateAuth(); + + if (serverUp && !authValid) { + await promptForReauthentication(ConfigService.getActiveRemote()); + } + } + async execute(options: AppDevOptions): Promise { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; @@ -36,6 +48,8 @@ export class AppDevCommand { await checkServerVersionCompatibility(); } + await this.ensureAuthenticatedBeforeLaunch(); + const orchestratorState = new OrchestratorState({ appPath, }); diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index 0fbcc870ab..35f583f0b5 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; +import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; import { type SyncAction } from 'twenty-shared/metadata'; import { ApiService } from '@/cli/utilities/api/api-service'; @@ -7,6 +7,7 @@ import { ensureAppAccessTokenIsValidOrRefresh, ensureAppRegistration, } from '@/cli/utilities/auth'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; import { buildApplication } from '@/cli/utilities/build/common/build-application'; import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; @@ -81,14 +82,20 @@ const innerAppDevOnce = async ( } if (!validateAuth.authValid) { - return { - success: false, - error: { - code: APP_ERROR_CODES.SYNC_FAILED, - message: - 'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.', - }, - }; + const outcome = await promptForReauthentication( + ConfigService.getActiveRemote(), + ); + + if (outcome !== 'reauthenticated') { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: + 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', + }, + }; + } } onProgress?.('Building manifest...'); diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts index 0f897ba719..050ccb5efd 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts @@ -1,14 +1,21 @@ -import { ConfigService } from '@/cli/utilities/config/config-service'; import { isNonEmptyString } from '@sniptt/guards'; -import axios, { type AxiosInstance } from 'axios'; +import axios, { + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios'; import chalk from 'chalk'; import { isDefined } from 'twenty-shared/utils'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; +import { ConfigService } from '@/cli/utilities/config/config-service'; + export class ApiClient { readonly client: AxiosInstance; readonly configService: ConfigService; private readonly tokenOverride?: string; readonly serverUrlOverride?: string; + private reauthAttempted: boolean = false; constructor(options?: { disableInterceptors?: boolean; @@ -48,14 +55,51 @@ export class ApiClient { } this.client.interceptors.response.use( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - console.error( - chalk.red( - 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', - ), + async (response) => { + // Handle auth errors returned as GraphQL errors in HTTP 200 responses + if ( + response.status === 200 && + response.data?.errors && + Array.isArray(response.data.errors) + ) { + const hasAuthError = response.data.errors.some( + (error: { message?: string; extensions?: { code?: string } }) => + error.extensions?.code === 'UNAUTHENTICATED' || + error.extensions?.code === 'FORBIDDEN' || + (typeof error.message === 'string' && + error.message.toLowerCase().includes('unauthenticated')), ); + + if (hasAuthError) { + if (!this.reauthAttempted) { + const retried = await this.tryReauthenticateAndRetry( + response.config, + ); + + if (retried) { + return retried; + } + } + + const authError = new Error( + 'Authentication failed: GraphQL auth error in response', + ) as Error & { response: typeof response }; + authError.response = response; + throw authError; + } + } + + return response; + }, + async (error) => { + if (error.response?.status === 401 && error.config) { + if (!this.reauthAttempted) { + const retried = await this.tryReauthenticateAndRetry(error.config); + + if (retried) { + return retried; + } + } } else if (error.response?.status === 403) { console.error( chalk.red( @@ -72,6 +116,30 @@ export class ApiClient { ); } + private async tryReauthenticateAndRetry( + config: InternalAxiosRequestConfig, + ): Promise { + // Prevent recursion: only attempt reauth once per client instance + this.reauthAttempted = true; + + const remoteName = ConfigService.getActiveRemote(); + console.error(`Authentication failed on remote "${remoteName}"`); + + const outcome = await promptForReauthentication(remoteName); + + if (outcome === 'reauthenticated') { + const authToken = await this.resolveAuthToken(); + + if (authToken) { + config.headers.Authorization = `Bearer ${authToken}`; + + return this.client.request(config); + } + } + + return null; + } + async getFrontendUrl(): Promise { try { const response = await this.client.get( diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts index a05fee44ce..380b9fc699 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts @@ -7,6 +7,7 @@ type FailingApiResponse = { success: false; error?: TError; message?: string; + isAuthError?: boolean; }; export type ApiResponse = | SuccessfulApiResponse diff --git a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts index ab25a12f7c..ecd7093908 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts @@ -121,6 +121,14 @@ export class FileApi { }; } catch (error) { if (axios.isAxiosError(error) && error.response) { + if (error.response.status === 401) { + return { + success: false, + error: error.response.data?.errors?.[0]?.message || error.message, + isAuthError: true, + }; + } + return { success: false, error: error.response.data?.errors?.[0]?.message || error.message, diff --git a/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts b/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts new file mode 100644 index 0000000000..31bb202c13 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts @@ -0,0 +1,43 @@ +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import { authLoginOAuth } from '@/cli/operations/login-oauth'; +import { ConfigService } from '@/cli/utilities/config/config-service'; + +export type ReauthOutcome = 'reauthenticated' | 'declined' | 'non-interactive'; + +export const promptForReauthentication = async ( + remoteName: string, +): Promise => { + if (!process.stdout.isTTY) { + return 'non-interactive'; + } + + const { proceed } = await inquirer.prompt<{ proceed: boolean }>([ + { + type: 'confirm', + name: 'proceed', + message: `Re-authenticate remote "${remoteName}" now?`, + default: true, + }, + ]); + + if (!proceed) { + return 'declined'; + } + + const configService = new ConfigService(); + const { apiUrl } = await configService.getConfig(); + + const result = await authLoginOAuth({ apiUrl, remote: remoteName }); + + if (result.success) { + console.log(chalk.green(`✓ Re-authenticated "${remoteName}".`)); + + return 'reauthenticated'; + } + + console.log(chalk.yellow(result.error.message)); + console.log(chalk.yellow('Run `yarn twenty remote:add` to re-authenticate.')); + + return 'declined'; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts index 17127992a6..c3b01c8ca8 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts @@ -74,7 +74,7 @@ export class CheckServerOrchestratorStep { this.state.applyStepEvents([ { message: - 'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.', + 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', status: 'error', }, ]);