@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.
- package/README.md +199 -42
- package/package.json +1 -1
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +12 -9
- package/src/commands/doctor.mjs +98 -5
- package/src/commands/hook.mjs +15 -8
- package/src/commands/login.mjs +323 -53
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +13 -9
- package/src/config.mjs +54 -3
- package/src/credentials.mjs +162 -0
- package/src/index.mjs +30 -9
- package/src/oauth.mjs +633 -0
package/src/commands/login.mjs
CHANGED
|
@@ -1,65 +1,144 @@
|
|
|
1
|
-
// `anyslate login` -
|
|
1
|
+
// `anyslate login` - two credential paths, one config file.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 verdict — a 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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
const path = join(dir, 'cli.json');
|
|
57
|
-
let existing = {};
|
|
120
|
+
function hostOf(root) {
|
|
58
121
|
try {
|
|
59
|
-
|
|
122
|
+
return new URL(root).host;
|
|
60
123
|
} catch {
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
147
|
+
const message =
|
|
148
|
+
formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
|
|
145
149
|
err.write(message);
|
|
146
150
|
recordRun(
|
|
147
151
|
{
|