fix(cli): detect expired token on deploy and offer interactive re-auth (#21335)
## 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 <NEW_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 <NEW_KEY> Generate a new key at: <SERVER_URL>/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<TError>`** 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 <noreply@anthropic.com> Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@@ -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<AxiosResponse | null> {
|
||||
// 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<string | null> {
|
||||
try {
|
||||
const response = await this.client.get(
|
||||
|
||||
@@ -7,6 +7,7 @@ type FailingApiResponse<TError = unknown> = {
|
||||
success: false;
|
||||
error?: TError;
|
||||
message?: string;
|
||||
isAuthError?: boolean;
|
||||
};
|
||||
export type ApiResponse<TData = unknown, TError = unknown> =
|
||||
| SuccessfulApiResponse<TData>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user