ampcode-connector 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ bunx ampcode-connector # start
10
10
 
11
11
  Requires [Bun](https://bun.sh) 1.3+. Config at `./config.yaml` or `~/.config/ampcode-connector/config.yaml` — see [`config.example.yaml`](config.example.yaml).
12
12
 
13
+ `setup` writes `amp.url` to Amp's canonical settings file (`~/.config/amp/settings.json`, or `AMP_SETTINGS_FILE` if set). Amp tokens are stored in `~/.local/share/amp/secrets.json`.
14
+
13
15
  ## License
14
16
 
15
17
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth/oauth.ts CHANGED
@@ -36,18 +36,8 @@ export async function token(config: OAuthConfig, account = 0): Promise<string |
36
36
 
37
37
  if (store.fresh(creds)) return creds.accessToken;
38
38
 
39
- try {
40
- return (await refresh(config, creds.refreshToken, account)).accessToken;
41
- } catch (err) {
42
- logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
43
- try {
44
- await Bun.sleep(1000);
45
- return (await refresh(config, creds.refreshToken, account)).accessToken;
46
- } catch (retryErr) {
47
- logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
48
- return null;
49
- }
50
- }
39
+ const refreshed = await refreshWithRetry(config, creds.refreshToken, account);
40
+ return refreshed?.accessToken ?? null;
51
41
  }
52
42
 
53
43
  export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken: string; account: number } | null> {
@@ -61,6 +51,7 @@ export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken:
61
51
  const refreshed = await refresh(config, c.refreshToken, account);
62
52
  return { accessToken: refreshed.accessToken, account };
63
53
  } catch (err) {
54
+ handleRefreshFailure(config, account, err);
64
55
  logger.debug(`${config.providerName}:${account} refresh failed in tokenFromAny`, { error: String(err) });
65
56
  }
66
57
  }
@@ -160,6 +151,29 @@ async function refresh(config: OAuthConfig, refreshToken: string, account = 0):
160
151
  return credentials;
161
152
  }
162
153
 
154
+ async function refreshWithRetry(
155
+ config: OAuthConfig,
156
+ refreshToken: string,
157
+ account: number,
158
+ ): Promise<Credentials | null> {
159
+ try {
160
+ return await refresh(config, refreshToken, account);
161
+ } catch (err) {
162
+ if (handleRefreshFailure(config, account, err)) return null;
163
+
164
+ logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
165
+
166
+ try {
167
+ await Bun.sleep(1000);
168
+ return await refresh(config, refreshToken, account);
169
+ } catch (retryErr) {
170
+ handleRefreshFailure(config, account, retryErr);
171
+ logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
172
+ return null;
173
+ }
174
+ }
175
+ }
176
+
163
177
  async function exchange(config: OAuthConfig, params: Record<string, string>): Promise<Record<string, unknown>> {
164
178
  const all: Record<string, string> = { client_id: config.clientId, ...params };
165
179
  if (config.clientSecret) all.client_secret = config.clientSecret;
@@ -173,12 +187,83 @@ async function exchange(config: OAuthConfig, params: Record<string, string>): Pr
173
187
 
174
188
  if (!res.ok) {
175
189
  const text = await res.text();
176
- throw new Error(`${config.providerName} token exchange failed (${res.status}): ${text}`);
190
+ const oauthError = parseOAuthError(text);
191
+ throw new TokenExchangeError(config.providerName, res.status, text, oauthError.code, oauthError.description);
177
192
  }
178
193
 
179
194
  return (await res.json()) as Record<string, unknown>;
180
195
  }
181
196
 
197
+ class TokenExchangeError extends Error {
198
+ readonly status: number;
199
+ readonly responseBody: string;
200
+ readonly errorCode: string | null;
201
+ readonly errorDescription: string | null;
202
+
203
+ constructor(
204
+ providerName: string,
205
+ status: number,
206
+ responseBody: string,
207
+ errorCode: string | null,
208
+ errorDescription: string | null,
209
+ ) {
210
+ super(`${providerName} token exchange failed (${status}): ${responseBody}`);
211
+ this.name = "TokenExchangeError";
212
+ this.status = status;
213
+ this.responseBody = responseBody;
214
+ this.errorCode = errorCode;
215
+ this.errorDescription = errorDescription;
216
+ }
217
+ }
218
+
219
+ function parseOAuthError(responseBody: string): { code: string | null; description: string | null } {
220
+ try {
221
+ const parsed = JSON.parse(responseBody) as { error?: unknown; error_description?: unknown };
222
+ const code = typeof parsed.error === "string" ? parsed.error : null;
223
+ const description = typeof parsed.error_description === "string" ? parsed.error_description : null;
224
+ return { code, description };
225
+ } catch {
226
+ return { code: null, description: null };
227
+ }
228
+ }
229
+
230
+ /** Handles terminal refresh failures and returns true when retry should stop. */
231
+ function handleRefreshFailure(config: OAuthConfig, account: number, err: unknown): boolean {
232
+ if (!isInvalidRefreshTokenError(err)) return false;
233
+ if (!store.get(config.providerName, account)) return false;
234
+
235
+ store.remove(config.providerName, account);
236
+ logger.warn(`Removed invalid refresh token for ${config.providerName}:${account}; re-login required.`, {
237
+ error: String(err),
238
+ });
239
+ return true;
240
+ }
241
+
242
+ function isInvalidRefreshTokenError(err: unknown): boolean {
243
+ if (!(err instanceof TokenExchangeError)) return false;
244
+ if (err.status !== 400 && err.status !== 401) return false;
245
+
246
+ if (err.errorCode === "invalid_grant") return true;
247
+
248
+ const description = err.errorDescription?.toLowerCase() ?? "";
249
+ const hasRefreshTokenContext = description.includes("refresh token");
250
+ const indicatesInvalidState =
251
+ description.includes("invalid") ||
252
+ description.includes("not found") ||
253
+ description.includes("expired") ||
254
+ description.includes("revoked");
255
+
256
+ if (hasRefreshTokenContext && indicatesInvalidState) return true;
257
+
258
+ const body = err.responseBody.toLowerCase();
259
+ return (
260
+ body.includes("invalid_grant") ||
261
+ body.includes("invalid refresh token") ||
262
+ body.includes("refresh token not found") ||
263
+ body.includes("refresh token is invalid")
264
+ );
265
+ }
266
+
182
267
  function parseTokenFields(raw: Record<string, unknown>, config: OAuthConfig): Credentials {
183
268
  if (typeof raw.access_token !== "string" || !raw.access_token) {
184
269
  throw new Error(`${config.providerName} token response missing access_token`);
package/src/cli/setup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Auto-configure Amp CLI to route through ampcode-connector. */
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { loadConfig } from "../config/config.ts";
@@ -10,14 +10,26 @@ import * as status from "./status.ts";
10
10
  const AMP_SECRETS_DIR = join(homedir(), ".local", "share", "amp");
11
11
  const AMP_SECRETS_PATH = join(AMP_SECRETS_DIR, "secrets.json");
12
12
 
13
- const AMP_SETTINGS_PATHS = [
14
- join(homedir(), ".config", "amp", "settings.json"),
15
- join(homedir(), ".amp", "settings.json"),
16
- ];
13
+ const AMP_SETTINGS_PATH = join(homedir(), ".config", "amp", "settings.json");
14
+ const AMP_LEGACY_SETTINGS_PATH = join(homedir(), ".amp", "settings.json");
17
15
 
18
- function ampSettingsPaths(): string[] {
16
+ function ampSettingsPath(): string {
19
17
  const envPath = process.env.AMP_SETTINGS_FILE;
20
- return envPath ? [envPath] : AMP_SETTINGS_PATHS;
18
+ return envPath || AMP_SETTINGS_PATH;
19
+ }
20
+
21
+ function warnLegacySettingsFile(): void {
22
+ if (process.env.AMP_SETTINGS_FILE || !existsSync(AMP_LEGACY_SETTINGS_PATH)) return;
23
+ try {
24
+ if (lstatSync(AMP_LEGACY_SETTINGS_PATH).isSymbolicLink()) return;
25
+ } catch {
26
+ return;
27
+ }
28
+
29
+ line(
30
+ `${s.yellow}!${s.reset} Legacy settings file detected at ${s.dim}${AMP_LEGACY_SETTINGS_PATH}${s.reset}. ` +
31
+ `Prefer a single source of truth at ${s.dim}${AMP_SETTINGS_PATH}${s.reset}.`,
32
+ );
21
33
  }
22
34
 
23
35
  function readJson(path: string): Record<string, unknown> {
@@ -88,14 +100,15 @@ export async function setup(): Promise<void> {
88
100
  line(`${s.bold}ampcode-connector setup${s.reset}`);
89
101
  line();
90
102
 
91
- // Step 1: Configure amp.url in all settings files
92
- for (const settingsPath of ampSettingsPaths()) {
93
- const settings = readJson(settingsPath);
94
- if (settings["amp.url"] === proxyUrl) continue;
103
+ // Step 1: Configure amp.url in canonical settings file
104
+ const settingsPath = ampSettingsPath();
105
+ const settings = readJson(settingsPath);
106
+ if (settings["amp.url"] !== proxyUrl) {
95
107
  settings["amp.url"] = proxyUrl;
96
108
  writeJson(settingsPath, settings);
97
109
  }
98
- line(`${s.green}ok${s.reset} amp.url = ${s.cyan}${proxyUrl}${s.reset}`);
110
+ line(`${s.green}ok${s.reset} amp.url = ${s.cyan}${proxyUrl}${s.reset} ${s.dim}${settingsPath}${s.reset}`);
111
+ warnLegacySettingsFile();
99
112
 
100
113
  // Step 2: Amp API key
101
114
  const existingKey = findAmpApiKey(proxyUrl);