@anyslate/cli 0.2.0 → 0.3.1

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.
@@ -1,65 +1,144 @@
1
- // `anyslate login` - validate credentials, then write `~/.anyslate/cli.json`.
1
+ // `anyslate login` - two credential paths, one config file.
2
2
  //
3
- // Mint the token in the desktop app at Avatar (top-right) → API Tokens →
4
- // Create Token (available on every current tier). Then:
3
+ // anyslate login OAuth browser flow (default, humans)
4
+ // anyslate login --api-url <root> ... against dev / a local wrangler
5
+ // anyslate login --token as_mcp_… static token (CI, air-gapped, scripts)
5
6
  //
6
- // anyslate login --token <BEARER>
7
- // anyslate login --token <BEARER> --handle <HANDLE_ID>
8
- // anyslate login --token <BEARER> --api-url https://anyslate-mcp-service-development.<workers-dev-url>
7
+ // The static-token path is UNCHANGED and stays first-class. It is the only one
8
+ // that works where no browser exists, and a long-lived token is the right shape
9
+ // for a CI secret. `--token` is therefore the switch between the two paths: its
10
+ // presence selects the static flow, its absence selects OAuth.
9
11
  //
10
- // The file is written with mode 0600 - only the current user can read it.
11
- //
12
- // W3: `login` used to perform ZERO network I/O its imports were three
13
- // node: builtins. It accepted a token that is not a token and a hostname that
14
- // cannot resolve, printing success and exiting 0 for both. It now:
15
- // 1. checks token format,
16
- // 2. resolves URL shape (MUST precede any token verdict — a wrong URL 404s
17
- // before auth middleware and masks the real answer),
18
- // 3. runs one live GET {root}/mcp/auth/verify,
19
- // 4. warns on a missing memory:write scope,
20
- // 5. writes the file ONLY on success.
12
+ // Ordering in the static path is load-bearing and is preserved verbatim:
13
+ // 1. token format,
14
+ // 2. URL shape (MUST precede any token verdicta wrong URL 404s before auth
15
+ // middleware and masks the real answer),
16
+ // 3. one live GET {root}/mcp/auth/verify,
17
+ // 4. scope warning,
18
+ // 5. write ONLY on success.
21
19
  // `--force` writes anyway; `--no-verify` skips the probe (air-gapped setup).
20
+ //
21
+ // The OAuth path ends at the same place — one `/mcp/auth/verify` probe, the same
22
+ // printed verdict line — so "logged in" means exactly one thing regardless of
23
+ // how the credential was obtained.
22
24
 
23
- import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
24
25
  import { join } from 'node:path';
25
26
  import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
26
27
  import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
28
+ import { readConfigFile, writeConfigFile } from '../credentials.mjs';
29
+ import {
30
+ DEFAULT_CALLBACK_TIMEOUT_S,
31
+ REGISTERED_REDIRECT_URI,
32
+ buildAuthorizeUrl,
33
+ discover,
34
+ exchangeCode,
35
+ generatePkce,
36
+ generateState,
37
+ openBrowser,
38
+ registerClient,
39
+ startCallbackServer,
40
+ } from '../oauth.mjs';
27
41
  import { makeIo } from '../io.mjs';
28
42
 
43
+ export const USAGE =
44
+ 'usage: anyslate login [--api-url <URL>] [--no-browser] [--timeout <seconds>] [--handle <ID>]\n' +
45
+ ' anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]\n' +
46
+ '\n' +
47
+ ' With no --token, `login` opens your browser and signs you in with OAuth.\n' +
48
+ ' --token keeps the static-token path for CI and air-gapped setups.';
49
+
29
50
  /**
30
51
  * @param {string[]} argv arguments after `login`
31
- * @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv}} [deps]
52
+ * @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv,
53
+ * openBrowserImpl?: typeof openBrowser,
54
+ * onAuthorizeUrl?: (url: string, ctx: object) => unknown}} [deps]
32
55
  * @returns {Promise<number>}
33
56
  */
34
57
  export async function runLogin(argv, deps = {}) {
35
- const env = deps.env ?? process.env;
36
- const fetchImpl = deps.fetchImpl ?? fetch;
37
- const { out, err } = makeIo(deps);
38
58
  const flags = parseFlags(argv);
39
-
40
- if (!flags.token) {
41
- err.write(
42
- 'usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]\n',
43
- );
59
+ if (flags.help) {
60
+ makeIo(deps).err.write(`${USAGE}\n`);
44
61
  return 2;
45
62
  }
63
+ return flags.token ? runTokenLogin(flags, deps) : runOauthLogin(flags, deps);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Shared
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Resolve the service root for `login`.
72
+ *
73
+ * `--api-url` wins. When it is ABSENT the answer is always production — the
74
+ * stored value is deliberately NOT inherited.
75
+ *
76
+ * Inheriting it (the pre-0.3.1 behaviour) meant that once anything had written
77
+ * a non-production apiUrl — a past `--api-url` run, or a stale config from an
78
+ * older build — a bare `anyslate login` silently kept signing in to that
79
+ * environment. A user who typed the shortest possible command got a non-obvious
80
+ * endpoint, and a non-production hostname ended up in the URL printed to the
81
+ * terminal and opened in a browser. Sign-in is the one place the target must be
82
+ * explicit rather than sticky: pass `--api-url` for dev or local, omit it for
83
+ * production. Every other command still reads the stored apiUrl as before —
84
+ * this override applies to `login` only.
85
+ */
86
+ function resolveRoot(flags, existing, io) {
87
+ const stored = existing.apiUrl ?? existing.api_url ?? null;
88
+ const merged = flags.apiUrl ?? DEFAULT_API_URL;
89
+ const shape = checkUrlShape(merged);
90
+ const root = shape.ok ? shape.root : normalizeApiRoot(merged);
46
91
 
47
- if (isCaptureDisabled(env)) {
48
- // login is setup, not capture it still runs, but say so, because the
49
- // shell it was run in will capture nothing afterwards.
50
- err.write(
51
- 'anyslate: note ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
92
+ // Say so when we are switching them off a stored non-production endpoint, so
93
+ // the change of target is never silent in either direction.
94
+ if (!flags.apiUrl && stored) {
95
+ const storedRoot = normalizeApiRoot(stored);
96
+ if (storedRoot && storedRoot !== root) {
97
+ io.out.write(
98
+ `anyslate: signing in to production (${root}).\n` +
99
+ `anyslate: your config points at ${storedRoot} — pass \`--api-url ${storedRoot}\` to sign in there instead.\n`,
100
+ );
101
+ }
102
+ }
103
+
104
+ if (shape.ok && shape.normalized) {
105
+ io.out.write(
106
+ `anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
107
+ `anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
52
108
  );
53
109
  }
110
+ return { shape, root };
111
+ }
112
+
113
+ function noteIfCaptureDisabled(env, io) {
114
+ if (!isCaptureDisabled(env)) return;
115
+ io.err.write(
116
+ 'anyslate: note — ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
117
+ );
118
+ }
54
119
 
55
- const dir = anyslateDir(env);
56
- const path = join(dir, 'cli.json');
57
- let existing = {};
120
+ function hostOf(root) {
58
121
  try {
59
- existing = JSON.parse(readFileSync(path, 'utf8')) || {};
122
+ return new URL(root).host;
60
123
  } catch {
61
- existing = {};
124
+ return root;
62
125
  }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Static token path (behaviour unchanged)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ async function runTokenLogin(flags, deps) {
133
+ const env = deps.env ?? process.env;
134
+ const fetchImpl = deps.fetchImpl ?? fetch;
135
+ const io = makeIo(deps);
136
+ const { out, err } = io;
137
+
138
+ noteIfCaptureDisabled(env, io);
139
+
140
+ const path = join(anyslateDir(env), 'cli.json');
141
+ const existing = readConfigFile(env);
63
142
 
64
143
  // --- 1. Token format ----------------------------------------------------
65
144
  if (!isValidTokenFormat(flags.token)) {
@@ -75,24 +154,12 @@ export async function runLogin(argv, deps = {}) {
75
154
  }
76
155
 
77
156
  // --- 2. URL shape (MUST precede any token verdict) ----------------------
78
- // Preserve the merge semantics `login --token <new>` alone keeps the
79
- // stored apiUrl — but re-normalize the merged value so a previously-bad
80
- // apiUrl cannot survive a re-login.
81
- const mergedUrl = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
82
- const shape = checkUrlShape(mergedUrl);
157
+ const { shape, root } = resolveRoot(flags, existing, io);
83
158
  if (!shape.ok) {
84
159
  err.write(`${shape.message}\n`);
85
160
  if (!flags.force) return 1;
86
161
  err.write('anyslate: --force given — writing anyway.\n');
87
162
  }
88
- const root = shape.ok ? shape.root : normalizeApiRoot(mergedUrl);
89
-
90
- if (shape.ok && shape.normalized) {
91
- out.write(
92
- `anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
93
- `anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
94
- );
95
- }
96
163
 
97
164
  // --- 3. Live probe ------------------------------------------------------
98
165
  let verified = null;
@@ -127,8 +194,7 @@ export async function runLogin(argv, deps = {}) {
127
194
  delete next.api_url; // collapse the legacy alias so only one key can drift
128
195
 
129
196
  try {
130
- mkdirSync(dir, { recursive: true, mode: 0o700 });
131
- writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
197
+ writeConfigFile(next, env);
132
198
  } catch (e) {
133
199
  err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
134
200
  return 1;
@@ -144,9 +210,205 @@ export async function runLogin(argv, deps = {}) {
144
210
  return 0;
145
211
  }
146
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // OAuth browser path
215
+ // ---------------------------------------------------------------------------
216
+
217
+ async function runOauthLogin(flags, deps) {
218
+ const env = deps.env ?? process.env;
219
+ const fetchImpl = deps.fetchImpl ?? fetch;
220
+ const io = makeIo(deps);
221
+ const { out, err } = io;
222
+
223
+ if (flags.timeoutInvalid) {
224
+ err.write('anyslate: --timeout takes a positive number of seconds.\n');
225
+ return 2;
226
+ }
227
+
228
+ noteIfCaptureDisabled(env, io);
229
+
230
+ const existing = readConfigFile(env);
231
+ const { shape, root } = resolveRoot(flags, existing, io);
232
+ if (!shape.ok) {
233
+ err.write(`${shape.message}\n`);
234
+ return 1;
235
+ }
236
+ const host = hostOf(root);
237
+
238
+ // --- 1. Discovery. No guessed endpoint paths, ever. ---------------------
239
+ const discovery = await discover({ root, fetchImpl });
240
+ if (!discovery.ok) {
241
+ err.write(`${discovery.message}\n`);
242
+ return 1;
243
+ }
244
+
245
+ // --- 2. Client id: cached per root, because DCR is 10/hour --------------
246
+ const clients = existing.oauth_clients && typeof existing.oauth_clients === 'object' ? existing.oauth_clients : {};
247
+ let clientId = typeof clients[root] === 'string' && clients[root] ? clients[root] : null;
248
+ let registered = false;
249
+ if (clientId) {
250
+ out.write(`anyslate: reusing this CLI's registered OAuth client for ${host}.\n`);
251
+ } else {
252
+ const reg = await registerClient({
253
+ registrationEndpoint: discovery.registrationEndpoint,
254
+ redirectUri: REGISTERED_REDIRECT_URI,
255
+ fetchImpl,
256
+ });
257
+ if (!reg.ok) {
258
+ err.write(`${reg.message}\n`);
259
+ return 1;
260
+ }
261
+ clientId = reg.clientId;
262
+ registered = true;
263
+ // Persist immediately, BEFORE the browser round trip. A user who closes the
264
+ // consent tab must not burn a second registration on their next attempt.
265
+ try {
266
+ writeConfigFile({ ...readConfigFile(env), oauth_clients: { ...clients, [root]: clientId } }, env);
267
+ } catch {
268
+ err.write('anyslate: warning — could not cache the client registration; the next login will register again.\n');
269
+ }
270
+ }
271
+
272
+ // --- 3. PKCE + state ----------------------------------------------------
273
+ const pkce = generatePkce();
274
+ const state = generateState();
275
+
276
+ // --- 4. Loopback listener on an ephemeral port --------------------------
277
+ const timeoutMs = (flags.timeout ?? DEFAULT_CALLBACK_TIMEOUT_S) * 1000;
278
+ let listener;
279
+ try {
280
+ listener = await startCallbackServer({ state, timeoutMs });
281
+ } catch (e) {
282
+ err.write(`anyslate: could not bind a loopback port for the OAuth callback (${e?.message ?? e}).\n`);
283
+ return 1;
284
+ }
285
+
286
+ try {
287
+ const authorizeUrl = buildAuthorizeUrl({
288
+ authorizationEndpoint: discovery.authorizationEndpoint,
289
+ clientId,
290
+ redirectUri: listener.redirectUri,
291
+ codeChallenge: pkce.challenge,
292
+ state,
293
+ resource: discovery.resource,
294
+ });
295
+
296
+ // --- 5. Browser -------------------------------------------------------
297
+ out.write(`anyslate: signing in to ${host}${registered ? ' (registered this CLI)' : ''}.\n`);
298
+ if (flags.noBrowser) {
299
+ out.write('anyslate: --no-browser given. Open this URL to authorize:\n');
300
+ } else {
301
+ const opened = (deps.openBrowserImpl ?? openBrowser)(authorizeUrl);
302
+ out.write(
303
+ opened.ok
304
+ ? 'anyslate: opened your browser. If nothing appeared, open this URL:\n'
305
+ : `anyslate: could not launch a browser (${opened.error ?? 'unknown error'}). Open this URL:\n`,
306
+ );
307
+ }
308
+ // Printed on BOTH paths. A browser that reported success but silently failed
309
+ // to appear would otherwise leave the user at a hung prompt with no way in.
310
+ out.write(`\n ${authorizeUrl}\n\n`);
311
+ out.write(`anyslate: waiting up to ${Math.round(timeoutMs / 1000)}s for the callback on ${listener.redirectUri} …\n`);
312
+
313
+ await deps.onAuthorizeUrl?.(authorizeUrl, { redirectUri: listener.redirectUri, state, root });
314
+
315
+ // --- 6. Callback (state validated inside the listener) ----------------
316
+ const callback = await listener.waitForResult();
317
+ if (!callback.ok) {
318
+ err.write(`${callback.message}\n`);
319
+ return 1;
320
+ }
321
+
322
+ // --- 7. Token exchange ------------------------------------------------
323
+ const exchanged = await exchangeCode({
324
+ tokenEndpoint: discovery.tokenEndpoint,
325
+ code: callback.code,
326
+ redirectUri: listener.redirectUri,
327
+ codeVerifier: pkce.verifier,
328
+ clientId,
329
+ resource: discovery.resource,
330
+ fetchImpl,
331
+ });
332
+ if (!exchanged.ok) {
333
+ err.write(`${exchanged.message}\n`);
334
+ return 1;
335
+ }
336
+
337
+ const oauth = {
338
+ client_id: clientId,
339
+ access_token: exchanged.tokens.access_token,
340
+ refresh_token: exchanged.tokens.refresh_token,
341
+ expires_at: exchanged.tokens.expires_at,
342
+ // Cached so an unattended refresh costs one request rather than three.
343
+ // Bound to `root` so switching environments re-discovers instead of
344
+ // reusing dev's token endpoint against prod.
345
+ token_endpoint: discovery.tokenEndpoint,
346
+ resource: discovery.resource,
347
+ root,
348
+ };
349
+
350
+ // --- 8. Verify, exactly as the static path does -----------------------
351
+ let verified = null;
352
+ if (flags.noVerify) {
353
+ err.write('anyslate: --no-verify given — skipping the live connection check.\n');
354
+ } else {
355
+ verified = await probeVerify({ root, token: oauth.access_token, fetchImpl });
356
+ if (verified.ok) {
357
+ out.write(`${verified.message}\n`);
358
+ const warning = scopeWarning(verified.scopes);
359
+ if (warning) err.write(`${warning}\n`);
360
+ } else {
361
+ err.write(`${verified.message}\n`);
362
+ if (!flags.force) {
363
+ err.write('anyslate: nothing was written. Re-run `anyslate login`, or pass --force to write anyway.\n');
364
+ return 1;
365
+ }
366
+ err.write('anyslate: --force given — writing anyway.\n');
367
+ }
368
+ }
369
+
370
+ // --- 9. Persist -------------------------------------------------------
371
+ const path = join(anyslateDir(env), 'cli.json');
372
+ let handle = null;
373
+ try {
374
+ const current = readConfigFile(env);
375
+ handle = flags.handle ?? current.handle ?? null;
376
+ const next = {
377
+ ...current,
378
+ apiUrl: root,
379
+ handle,
380
+ oauth_clients: { ...(current.oauth_clients ?? {}), [root]: clientId },
381
+ oauth,
382
+ };
383
+ delete next.api_url;
384
+ writeConfigFile(next, env);
385
+ } catch (e) {
386
+ err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
387
+ return 1;
388
+ }
389
+
390
+ out.write(`anyslate: wrote ${path}\n`);
391
+ out.write(` apiUrl: ${root}\n`);
392
+ out.write(' auth: oauth (browser)\n');
393
+ out.write(` handle: ${handle ?? '(none — bearer token only)'}\n`);
394
+ out.write(
395
+ ` access token expires ${oauth.expires_at}${
396
+ oauth.refresh_token ? '; it refreshes automatically' : ' (no refresh token issued)'
397
+ }\n`,
398
+ );
399
+ if (verified?.ok && verified.defaultHandleId) {
400
+ out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
401
+ }
402
+ out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
403
+ return 0;
404
+ } finally {
405
+ await listener.close();
406
+ }
407
+ }
408
+
147
409
  /** @param {string[]} argv */
148
410
  function parseFlags(argv) {
149
- const out = { force: false, noVerify: false };
411
+ const out = { force: false, noVerify: false, noBrowser: false, help: false };
150
412
  for (let i = 0; i < argv.length; i += 1) {
151
413
  const a = argv[i];
152
414
  if (a === '--token' && argv[i + 1]) out.token = argv[++i];
@@ -154,6 +416,14 @@ function parseFlags(argv) {
154
416
  else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
155
417
  else if (a === '--force') out.force = true;
156
418
  else if (a === '--no-verify' || a === '--skip-verify') out.noVerify = true;
419
+ else if (a === '--no-browser') out.noBrowser = true;
420
+ else if (a === '--timeout' && argv[i + 1]) {
421
+ const seconds = Number(argv[++i]);
422
+ if (Number.isFinite(seconds) && seconds > 0) out.timeout = seconds;
423
+ else out.timeoutInvalid = true;
424
+ } else if (a === '--help' || a === '-h') out.help = true;
157
425
  }
158
426
  return out;
159
427
  }
428
+
429
+ export const __testing = { parseFlags };
@@ -0,0 +1,131 @@
1
+ // `anyslate logout` - revoke the OAuth session, then clear local credentials.
2
+ //
3
+ // This was genuinely missing. Before OAuth the only way to "log out" was to
4
+ // hand-edit or delete `~/.anyslate/cli.json`, which also threw away the apiUrl
5
+ // and handle — so the documented recovery was "delete the file and set
6
+ // everything up again". With refresh tokens that gap becomes a security
7
+ // problem, not just an annoyance: a 30-day refresh token stays live on disk
8
+ // with no supported way to invalidate it.
9
+ //
10
+ // ORDER MATTERS, AND SO DOES FAILING SOFT. Revoke first (best effort), clear
11
+ // second, ALWAYS. If revocation is skipped on a network error the user is left
12
+ // holding a credential they believe is dead. If clearing is skipped because
13
+ // revocation failed, `logout` becomes unusable exactly when it is most needed —
14
+ // offline, or against a server that is down. So a failed revoke is reported and
15
+ // the local credential is removed regardless.
16
+ //
17
+ // What is deliberately NOT removed: `apiUrl`, `handle`, and the cached
18
+ // `oauth_clients` map. The client registration is not a credential (it grants
19
+ // nothing on its own), and dropping it would burn one of the 10-per-hour
20
+ // Dynamic Client Registrations on the next login.
21
+
22
+ import { join } from 'node:path';
23
+ import { anyslateDir, loadConfig } from '../config.mjs';
24
+ import { readConfigFile, writeConfigFile } from '../credentials.mjs';
25
+ import { discover, revocationEndpointFor, revokeToken } from '../oauth.mjs';
26
+ import { makeIo } from '../io.mjs';
27
+
28
+ /**
29
+ * @param {string[]} argv arguments after `logout`
30
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
31
+ * @returns {Promise<number>}
32
+ */
33
+ export async function runLogout(argv = [], deps = {}) {
34
+ const env = deps.env ?? process.env;
35
+ const fetchImpl = deps.fetchImpl ?? fetch;
36
+ const { out, err } = makeIo(deps);
37
+ const flags = parseFlags(argv);
38
+
39
+ const cfg = loadConfig(env);
40
+ const current = readConfigFile(env);
41
+ const oauth = current.oauth && typeof current.oauth === 'object' ? current.oauth : null;
42
+ const hadOauth = !!(oauth && (oauth.access_token || oauth.refresh_token));
43
+ const hadStatic = !!(current.mcp_token || current.token);
44
+
45
+ if (!hadOauth && !hadStatic) {
46
+ out.write('anyslate: no stored credentials — nothing to log out of.\n');
47
+ if (env.ANYSLATE_MCP_TOKEN) {
48
+ out.write('anyslate: note — ANYSLATE_MCP_TOKEN is set in your environment; unset it to fully sign out.\n');
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ // --- 1. Revoke (best effort) --------------------------------------------
54
+ if (hadOauth && !flags.local) {
55
+ const root = oauth.root || cfg.apiUrl;
56
+ const discovery = await discover({ root, fetchImpl });
57
+ const endpoint = revocationEndpointFor(discovery.ok ? discovery : null, root);
58
+
59
+ // Revoke the refresh token first: it is the long-lived one (30 days), and
60
+ // it is what an attacker with a stale config file would actually use.
61
+ const targets = [
62
+ oauth.refresh_token ? { token: oauth.refresh_token, hint: 'refresh_token' } : null,
63
+ oauth.access_token ? { token: oauth.access_token, hint: 'access_token' } : null,
64
+ ].filter(Boolean);
65
+
66
+ let revoked = 0;
67
+ let failed = null;
68
+ for (const t of targets) {
69
+ const res = await revokeToken({
70
+ revocationEndpoint: endpoint,
71
+ token: t.token,
72
+ clientId: oauth.client_id,
73
+ tokenTypeHint: t.hint,
74
+ fetchImpl,
75
+ });
76
+ if (res.ok) revoked += 1;
77
+ else failed = res.detail ?? 'unknown error';
78
+ }
79
+
80
+ if (revoked === targets.length) {
81
+ out.write(`anyslate: revoked the OAuth session at ${hostOf(endpoint)}.\n`);
82
+ } else {
83
+ err.write(
84
+ `anyslate: warning — could not revoke the OAuth session at ${hostOf(endpoint)} (${failed}). ` +
85
+ 'Clearing the local credentials anyway; revoke the token in the app if that matters.\n',
86
+ );
87
+ }
88
+ } else if (hadOauth && flags.local) {
89
+ err.write('anyslate: --local given — skipping revocation; the server-side session stays live until it expires.\n');
90
+ }
91
+
92
+ // --- 2. Clear (always) ---------------------------------------------------
93
+ const path = join(anyslateDir(env), 'cli.json');
94
+ try {
95
+ const disk = readConfigFile(env);
96
+ delete disk.oauth;
97
+ delete disk.mcp_token;
98
+ delete disk.token;
99
+ writeConfigFile(disk, env);
100
+ } catch (e) {
101
+ err.write(`anyslate logout: could not update ${path} (${e?.message ?? e})\n`);
102
+ return 1;
103
+ }
104
+
105
+ const cleared = [hadOauth && 'OAuth session', hadStatic && 'static token'].filter(Boolean).join(' and ');
106
+ out.write(`anyslate: cleared the ${cleared} from ${path}.\n`);
107
+ out.write('anyslate: apiUrl and handle were kept. Run `anyslate login` to sign in again.\n');
108
+ if (env.ANYSLATE_MCP_TOKEN) {
109
+ err.write(
110
+ 'anyslate: warning — ANYSLATE_MCP_TOKEN is still set in your environment and overrides the config, so capture keeps working. Unset it to fully sign out.\n',
111
+ );
112
+ }
113
+ return 0;
114
+ }
115
+
116
+ function hostOf(url) {
117
+ try {
118
+ return new URL(url).host;
119
+ } catch {
120
+ return String(url);
121
+ }
122
+ }
123
+
124
+ /** @param {string[]} argv */
125
+ function parseFlags(argv) {
126
+ const out = { local: false };
127
+ for (const a of argv) {
128
+ if (a === '--local' || a === '--no-revoke') out.local = true;
129
+ }
130
+ return out;
131
+ }
@@ -16,7 +16,8 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { basename } from 'node:path';
18
18
  import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
19
- import { callTool, formatCallFailure } from '../mcp-client.mjs';
19
+ import { formatCallFailure } from '../mcp-client.mjs';
20
+ import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
20
21
  import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
21
22
  import { recordRun } from '../runlog.mjs';
22
23
  import { VERSION } from '../version.mjs';
@@ -71,10 +72,11 @@ export async function runUploadArtifact(argv, deps = {}) {
71
72
  const notice = apiUrlNormalizationNotice(cfg);
72
73
  if (notice) err.write(notice);
73
74
 
74
- const tokenCheck = requireToken(cfg);
75
- if (!tokenCheck.ok) {
76
- err.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
77
- recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: tokenCheck.error, version: VERSION, exitCode: 1 }, env);
75
+ // Presence only — an expired OAuth token is refreshed by callToolWithAuth.
76
+ if (cfg.authMode === 'none') {
77
+ const error = requireToken({ mcpToken: null }).error;
78
+ err.write(`anyslate upload-artifact: ${error}\n`);
79
+ recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error, version: VERSION, exitCode: 1 }, env);
78
80
  return 1;
79
81
  }
80
82
 
@@ -133,15 +135,17 @@ export async function runUploadArtifact(argv, deps = {}) {
133
135
  if (pathHint) args.path_hint = pathHint;
134
136
 
135
137
  try {
136
- const res = await callTool({
137
- apiUrl: cfg.apiUrl,
138
- token: cfg.mcpToken,
138
+ const res = await callToolWithAuth({
139
+ cfg,
140
+ env,
139
141
  toolName: 'upload_artifact',
140
142
  args,
141
143
  fetchImpl: deps.fetchImpl,
142
144
  });
145
+ if (res.authWarning) err.write(`${res.authWarning}\n`);
143
146
  if (!res.ok) {
144
- const message = formatCallFailure('anyslate upload-artifact', res);
147
+ const message =
148
+ formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
145
149
  err.write(message);
146
150
  recordRun(
147
151
  {