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:
Mani bharadwaj
2026-06-16 19:44:17 +05:30
committed by GitHub
parent b8329e3e79
commit 9001078cb2
9 changed files with 163 additions and 22 deletions
@@ -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,