@anyslate/cli 0.2.0 → 0.3.0
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 +100 -16
- 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 +298 -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/doctor.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
requireToken,
|
|
27
27
|
} from '../config.mjs';
|
|
28
28
|
import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
|
|
29
|
+
import { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
|
|
29
30
|
import { lastRunPath, readLastRun } from '../runlog.mjs';
|
|
30
31
|
import { callTool } from '../mcp-client.mjs';
|
|
31
32
|
import { VERSION, USER_AGENT } from '../version.mjs';
|
|
@@ -128,9 +129,21 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// ---- 2. Token present --------------------------------------------------
|
|
132
|
+
// "Present" now has three shapes: an env/static token, a live OAuth access
|
|
133
|
+
// token, or OAuth credentials whose access token has aged out but which carry
|
|
134
|
+
// a refresh token. The third is NOT a failure — the refresh happens further
|
|
135
|
+
// down, once the URL and reachability checks have cleared.
|
|
136
|
+
const oauthPresent = hasOauthCredentials(cfg.oauth);
|
|
131
137
|
const tokenCheck = requireToken(cfg);
|
|
132
|
-
if (!tokenCheck.ok) {
|
|
138
|
+
if (!tokenCheck.ok && !oauthPresent) {
|
|
133
139
|
add(FAIL, 'token-present', 'No MCP token configured.', tokenCheck.error);
|
|
140
|
+
} else if (!tokenCheck.ok) {
|
|
141
|
+
add(
|
|
142
|
+
WARN,
|
|
143
|
+
'token-present',
|
|
144
|
+
'OAuth access token has expired; a refresh is needed before the next call.',
|
|
145
|
+
'This is normal — access tokens live one hour. The refresh is exercised by the oauth-refresh check below.',
|
|
146
|
+
);
|
|
134
147
|
} else {
|
|
135
148
|
add(
|
|
136
149
|
PASS,
|
|
@@ -139,6 +152,44 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
139
152
|
);
|
|
140
153
|
}
|
|
141
154
|
|
|
155
|
+
// ---- 2a. Auth mode -----------------------------------------------------
|
|
156
|
+
// Pure config inspection, no network. Which credential the CLI would use is
|
|
157
|
+
// the first thing anyone debugging "capture stopped" needs to know, and it
|
|
158
|
+
// must be answerable even when the host is down.
|
|
159
|
+
if (cfg.sources.mcpToken === 'env' && cfg.mcpToken) {
|
|
160
|
+
add(PASS, 'auth-mode', 'Static token from ANYSLATE_MCP_TOKEN (env) — no refresh, never expires.');
|
|
161
|
+
if (oauthPresent) {
|
|
162
|
+
add(
|
|
163
|
+
WARN,
|
|
164
|
+
'auth-override',
|
|
165
|
+
'ANYSLATE_MCP_TOKEN is overriding a stored OAuth session.',
|
|
166
|
+
'Unset ANYSLATE_MCP_TOKEN to go back to the browser-issued credential.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
} else if (oauthPresent) {
|
|
170
|
+
const mins = minutesUntilExpiry(cfg.oauth);
|
|
171
|
+
const when =
|
|
172
|
+
mins == null
|
|
173
|
+
? 'expiry unknown (no parseable expires_at)'
|
|
174
|
+
: mins >= 0
|
|
175
|
+
? `expires in ${mins} min (${cfg.oauth.expires_at})`
|
|
176
|
+
: `expired ${Math.abs(mins)} min ago (${cfg.oauth.expires_at})`;
|
|
177
|
+
if (!cfg.oauth.refresh_token) {
|
|
178
|
+
add(
|
|
179
|
+
WARN,
|
|
180
|
+
'auth-mode',
|
|
181
|
+
`OAuth (browser sign-in), ${when}, no refresh token stored.`,
|
|
182
|
+
'Without a refresh token the session dies at expiry. Run `anyslate login` to get one.',
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
add(PASS, 'auth-mode', `OAuth (browser sign-in), ${when}, refresh token stored.`);
|
|
186
|
+
}
|
|
187
|
+
} else if (cfg.staticToken) {
|
|
188
|
+
add(PASS, 'auth-mode', `Static MCP token from ${cfg.sources.mcpToken} — no refresh, never expires.`);
|
|
189
|
+
} else {
|
|
190
|
+
add(SKIP, 'auth-mode', 'No credentials to classify.');
|
|
191
|
+
}
|
|
192
|
+
|
|
142
193
|
// ---- 2b. Handle FORMAT — never gated behind a server round trip ---------
|
|
143
194
|
// This is the exact live failure that produced a 100% invisible capture
|
|
144
195
|
// loss: cli/README.md told users to export `ANYSLATE_HANDLE=h_xxx`, but real
|
|
@@ -171,6 +222,8 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
171
222
|
'Mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.',
|
|
172
223
|
);
|
|
173
224
|
}
|
|
225
|
+
} else if (oauthPresent) {
|
|
226
|
+
add(SKIP, 'token-format', 'OAuth access token is past its expiry; the format is checked after it is refreshed.');
|
|
174
227
|
} else {
|
|
175
228
|
add(SKIP, 'token-format', 'No token to check.');
|
|
176
229
|
}
|
|
@@ -224,10 +277,49 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
224
277
|
add(SKIP, 'reachability', 'URL shape failed; not probing.');
|
|
225
278
|
}
|
|
226
279
|
|
|
280
|
+
// ---- 5b. OAuth refresh -------------------------------------------------
|
|
281
|
+
// Deliberately AFTER url-shape and reachability. A refresh against an
|
|
282
|
+
// unreachable host fails for a reason that has nothing to do with the
|
|
283
|
+
// credential, and reporting that as "refresh is broken" sends the user
|
|
284
|
+
// hunting for the wrong bug.
|
|
285
|
+
let bearer = cfg.mcpToken;
|
|
286
|
+
if (shape.ok && reachable && oauthPresent && cfg.sources.mcpToken !== 'env') {
|
|
287
|
+
const wasStale = isExpired(cfg.oauth) || flags.refresh;
|
|
288
|
+
const resolved = await resolveBearer({ cfg, env, fetchImpl, force: flags.refresh });
|
|
289
|
+
if (resolved.ok) {
|
|
290
|
+
bearer = resolved.token;
|
|
291
|
+
if (resolved.refreshed) {
|
|
292
|
+
add(PASS, 'oauth-refresh', 'Refreshed the access token successfully; the rotated refresh token was persisted.');
|
|
293
|
+
} else if (wasStale) {
|
|
294
|
+
add(PASS, 'oauth-refresh', 'Access token was already renewed by another process; nothing to do.');
|
|
295
|
+
} else {
|
|
296
|
+
add(
|
|
297
|
+
PASS,
|
|
298
|
+
'oauth-refresh',
|
|
299
|
+
'Refresh token is stored and the access token is still valid — refresh not exercised.',
|
|
300
|
+
'Run `anyslate doctor --refresh` to force a real refresh round trip (it rotates the refresh token).',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
add(
|
|
305
|
+
FAIL,
|
|
306
|
+
'oauth-refresh',
|
|
307
|
+
`OAuth refresh failed: ${String(resolved.message).replace(/^anyslate: /, '')}`,
|
|
308
|
+
'Run `anyslate login` to sign in again. Refresh tokens live 30 days and are single-use (rotated on every refresh).',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
} else if (oauthPresent && cfg.sources.mcpToken === 'env') {
|
|
312
|
+
add(SKIP, 'oauth-refresh', 'ANYSLATE_MCP_TOKEN is in use; the stored OAuth session is not consulted.');
|
|
313
|
+
} else if (oauthPresent) {
|
|
314
|
+
add(SKIP, 'oauth-refresh', 'Skipped — the URL and reachability checks must pass before a refresh is meaningful.');
|
|
315
|
+
} else {
|
|
316
|
+
add(SKIP, 'oauth-refresh', 'Not an OAuth session.');
|
|
317
|
+
}
|
|
318
|
+
|
|
227
319
|
// ---- 6. Token valid ----------------------------------------------------
|
|
228
320
|
let verified = null;
|
|
229
|
-
if (shape.ok &&
|
|
230
|
-
verified = await probeVerify({ root, token:
|
|
321
|
+
if (shape.ok && reachable && bearer) {
|
|
322
|
+
verified = await probeVerify({ root, token: bearer, fetchImpl });
|
|
231
323
|
if (verified.ok) {
|
|
232
324
|
add(PASS, 'token-valid', `${verified.message.replace(/^anyslate: /, '')}`);
|
|
233
325
|
} else {
|
|
@@ -345,7 +437,7 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
345
437
|
if (verified?.ok) {
|
|
346
438
|
const res = await callTool({
|
|
347
439
|
apiUrl: root,
|
|
348
|
-
token:
|
|
440
|
+
token: bearer,
|
|
349
441
|
toolName: 'activity_submit',
|
|
350
442
|
args: {
|
|
351
443
|
source: 'api',
|
|
@@ -513,9 +605,10 @@ function indent(block) {
|
|
|
513
605
|
|
|
514
606
|
/** @param {string[]} argv */
|
|
515
607
|
function parseFlags(argv) {
|
|
516
|
-
const out = { deep: false };
|
|
608
|
+
const out = { deep: false, refresh: false };
|
|
517
609
|
for (const a of argv) {
|
|
518
610
|
if (a === '--deep') out.deep = true;
|
|
611
|
+
else if (a === '--refresh') out.refresh = true;
|
|
519
612
|
}
|
|
520
613
|
return out;
|
|
521
614
|
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// nothing and exited 0.
|
|
20
20
|
|
|
21
21
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { formatCallFailure } from '../mcp-client.mjs';
|
|
23
|
+
import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
|
|
23
24
|
import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
|
|
24
25
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
25
26
|
import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
|
|
@@ -65,9 +66,12 @@ export async function runHook(argv, deps = {}) {
|
|
|
65
66
|
const notice = apiUrlNormalizationNotice(cfg);
|
|
66
67
|
if (notice) err.write(notice);
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Credential PRESENCE only. An expired OAuth access token is not "missing" —
|
|
70
|
+
// callToolWithAuth refreshes it below — so gating on `cfg.mcpToken` here (as
|
|
71
|
+
// this did before OAuth) would turn every hour-old session into a silent
|
|
72
|
+
// no-capture with a misleading "no token configured" in the run log.
|
|
73
|
+
if (cfg.authMode === 'none') {
|
|
74
|
+
return fail(`${prefix}: ${requireToken({ mcpToken: null }).error}\n`);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
let stdinRaw = '';
|
|
@@ -95,15 +99,18 @@ export async function runHook(argv, deps = {}) {
|
|
|
95
99
|
if (submission.sessionIdHint) args.session_id_hint = submission.sessionIdHint;
|
|
96
100
|
|
|
97
101
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
// Never opens a browser: this path runs unattended inside Claude Code.
|
|
103
|
+
// A refresh failure surfaces as an ordinary failure and still exits 0.
|
|
104
|
+
const res = await callToolWithAuth({
|
|
105
|
+
cfg,
|
|
106
|
+
env,
|
|
101
107
|
toolName: 'activity_submit',
|
|
102
108
|
args,
|
|
103
109
|
fetchImpl: deps.fetchImpl,
|
|
104
110
|
});
|
|
111
|
+
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
105
112
|
if (!res.ok) {
|
|
106
|
-
return fail(formatCallFailure(prefix, res), {
|
|
113
|
+
return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
|
|
107
114
|
status: res.status,
|
|
108
115
|
isError: !!res.isError,
|
|
109
116
|
networkError: !!res.networkError,
|
package/src/commands/login.mjs
CHANGED
|
@@ -1,65 +1,119 @@
|
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the service root exactly as the static path always has: an explicit
|
|
72
|
+
* --api-url wins, else the stored value is inherited AND re-normalized so a
|
|
73
|
+
* previously-broken `/mcp`-suffixed apiUrl cannot survive a re-login.
|
|
74
|
+
*/
|
|
75
|
+
function resolveRoot(flags, existing, io) {
|
|
76
|
+
const merged = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
|
|
77
|
+
const shape = checkUrlShape(merged);
|
|
78
|
+
const root = shape.ok ? shape.root : normalizeApiRoot(merged);
|
|
79
|
+
if (shape.ok && shape.normalized) {
|
|
80
|
+
io.out.write(
|
|
81
|
+
`anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
82
|
+
`anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
|
|
52
83
|
);
|
|
53
84
|
}
|
|
85
|
+
return { shape, root };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function noteIfCaptureDisabled(env, io) {
|
|
89
|
+
if (!isCaptureDisabled(env)) return;
|
|
90
|
+
io.err.write(
|
|
91
|
+
'anyslate: note — ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
54
94
|
|
|
55
|
-
|
|
56
|
-
const path = join(dir, 'cli.json');
|
|
57
|
-
let existing = {};
|
|
95
|
+
function hostOf(root) {
|
|
58
96
|
try {
|
|
59
|
-
|
|
97
|
+
return new URL(root).host;
|
|
60
98
|
} catch {
|
|
61
|
-
|
|
99
|
+
return root;
|
|
62
100
|
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Static token path (behaviour unchanged)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async function runTokenLogin(flags, deps) {
|
|
108
|
+
const env = deps.env ?? process.env;
|
|
109
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
110
|
+
const io = makeIo(deps);
|
|
111
|
+
const { out, err } = io;
|
|
112
|
+
|
|
113
|
+
noteIfCaptureDisabled(env, io);
|
|
114
|
+
|
|
115
|
+
const path = join(anyslateDir(env), 'cli.json');
|
|
116
|
+
const existing = readConfigFile(env);
|
|
63
117
|
|
|
64
118
|
// --- 1. Token format ----------------------------------------------------
|
|
65
119
|
if (!isValidTokenFormat(flags.token)) {
|
|
@@ -75,24 +129,12 @@ export async function runLogin(argv, deps = {}) {
|
|
|
75
129
|
}
|
|
76
130
|
|
|
77
131
|
// --- 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);
|
|
132
|
+
const { shape, root } = resolveRoot(flags, existing, io);
|
|
83
133
|
if (!shape.ok) {
|
|
84
134
|
err.write(`${shape.message}\n`);
|
|
85
135
|
if (!flags.force) return 1;
|
|
86
136
|
err.write('anyslate: --force given — writing anyway.\n');
|
|
87
137
|
}
|
|
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
138
|
|
|
97
139
|
// --- 3. Live probe ------------------------------------------------------
|
|
98
140
|
let verified = null;
|
|
@@ -127,8 +169,7 @@ export async function runLogin(argv, deps = {}) {
|
|
|
127
169
|
delete next.api_url; // collapse the legacy alias so only one key can drift
|
|
128
170
|
|
|
129
171
|
try {
|
|
130
|
-
|
|
131
|
-
writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
172
|
+
writeConfigFile(next, env);
|
|
132
173
|
} catch (e) {
|
|
133
174
|
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
134
175
|
return 1;
|
|
@@ -144,9 +185,205 @@ export async function runLogin(argv, deps = {}) {
|
|
|
144
185
|
return 0;
|
|
145
186
|
}
|
|
146
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// OAuth browser path
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async function runOauthLogin(flags, deps) {
|
|
193
|
+
const env = deps.env ?? process.env;
|
|
194
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
195
|
+
const io = makeIo(deps);
|
|
196
|
+
const { out, err } = io;
|
|
197
|
+
|
|
198
|
+
if (flags.timeoutInvalid) {
|
|
199
|
+
err.write('anyslate: --timeout takes a positive number of seconds.\n');
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
noteIfCaptureDisabled(env, io);
|
|
204
|
+
|
|
205
|
+
const existing = readConfigFile(env);
|
|
206
|
+
const { shape, root } = resolveRoot(flags, existing, io);
|
|
207
|
+
if (!shape.ok) {
|
|
208
|
+
err.write(`${shape.message}\n`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const host = hostOf(root);
|
|
212
|
+
|
|
213
|
+
// --- 1. Discovery. No guessed endpoint paths, ever. ---------------------
|
|
214
|
+
const discovery = await discover({ root, fetchImpl });
|
|
215
|
+
if (!discovery.ok) {
|
|
216
|
+
err.write(`${discovery.message}\n`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- 2. Client id: cached per root, because DCR is 10/hour --------------
|
|
221
|
+
const clients = existing.oauth_clients && typeof existing.oauth_clients === 'object' ? existing.oauth_clients : {};
|
|
222
|
+
let clientId = typeof clients[root] === 'string' && clients[root] ? clients[root] : null;
|
|
223
|
+
let registered = false;
|
|
224
|
+
if (clientId) {
|
|
225
|
+
out.write(`anyslate: reusing this CLI's registered OAuth client for ${host}.\n`);
|
|
226
|
+
} else {
|
|
227
|
+
const reg = await registerClient({
|
|
228
|
+
registrationEndpoint: discovery.registrationEndpoint,
|
|
229
|
+
redirectUri: REGISTERED_REDIRECT_URI,
|
|
230
|
+
fetchImpl,
|
|
231
|
+
});
|
|
232
|
+
if (!reg.ok) {
|
|
233
|
+
err.write(`${reg.message}\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
clientId = reg.clientId;
|
|
237
|
+
registered = true;
|
|
238
|
+
// Persist immediately, BEFORE the browser round trip. A user who closes the
|
|
239
|
+
// consent tab must not burn a second registration on their next attempt.
|
|
240
|
+
try {
|
|
241
|
+
writeConfigFile({ ...readConfigFile(env), oauth_clients: { ...clients, [root]: clientId } }, env);
|
|
242
|
+
} catch {
|
|
243
|
+
err.write('anyslate: warning — could not cache the client registration; the next login will register again.\n');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- 3. PKCE + state ----------------------------------------------------
|
|
248
|
+
const pkce = generatePkce();
|
|
249
|
+
const state = generateState();
|
|
250
|
+
|
|
251
|
+
// --- 4. Loopback listener on an ephemeral port --------------------------
|
|
252
|
+
const timeoutMs = (flags.timeout ?? DEFAULT_CALLBACK_TIMEOUT_S) * 1000;
|
|
253
|
+
let listener;
|
|
254
|
+
try {
|
|
255
|
+
listener = await startCallbackServer({ state, timeoutMs });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
err.write(`anyslate: could not bind a loopback port for the OAuth callback (${e?.message ?? e}).\n`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
263
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
264
|
+
clientId,
|
|
265
|
+
redirectUri: listener.redirectUri,
|
|
266
|
+
codeChallenge: pkce.challenge,
|
|
267
|
+
state,
|
|
268
|
+
resource: discovery.resource,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --- 5. Browser -------------------------------------------------------
|
|
272
|
+
out.write(`anyslate: signing in to ${host}${registered ? ' (registered this CLI)' : ''}.\n`);
|
|
273
|
+
if (flags.noBrowser) {
|
|
274
|
+
out.write('anyslate: --no-browser given. Open this URL to authorize:\n');
|
|
275
|
+
} else {
|
|
276
|
+
const opened = (deps.openBrowserImpl ?? openBrowser)(authorizeUrl);
|
|
277
|
+
out.write(
|
|
278
|
+
opened.ok
|
|
279
|
+
? 'anyslate: opened your browser. If nothing appeared, open this URL:\n'
|
|
280
|
+
: `anyslate: could not launch a browser (${opened.error ?? 'unknown error'}). Open this URL:\n`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// Printed on BOTH paths. A browser that reported success but silently failed
|
|
284
|
+
// to appear would otherwise leave the user at a hung prompt with no way in.
|
|
285
|
+
out.write(`\n ${authorizeUrl}\n\n`);
|
|
286
|
+
out.write(`anyslate: waiting up to ${Math.round(timeoutMs / 1000)}s for the callback on ${listener.redirectUri} …\n`);
|
|
287
|
+
|
|
288
|
+
await deps.onAuthorizeUrl?.(authorizeUrl, { redirectUri: listener.redirectUri, state, root });
|
|
289
|
+
|
|
290
|
+
// --- 6. Callback (state validated inside the listener) ----------------
|
|
291
|
+
const callback = await listener.waitForResult();
|
|
292
|
+
if (!callback.ok) {
|
|
293
|
+
err.write(`${callback.message}\n`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- 7. Token exchange ------------------------------------------------
|
|
298
|
+
const exchanged = await exchangeCode({
|
|
299
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
300
|
+
code: callback.code,
|
|
301
|
+
redirectUri: listener.redirectUri,
|
|
302
|
+
codeVerifier: pkce.verifier,
|
|
303
|
+
clientId,
|
|
304
|
+
resource: discovery.resource,
|
|
305
|
+
fetchImpl,
|
|
306
|
+
});
|
|
307
|
+
if (!exchanged.ok) {
|
|
308
|
+
err.write(`${exchanged.message}\n`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const oauth = {
|
|
313
|
+
client_id: clientId,
|
|
314
|
+
access_token: exchanged.tokens.access_token,
|
|
315
|
+
refresh_token: exchanged.tokens.refresh_token,
|
|
316
|
+
expires_at: exchanged.tokens.expires_at,
|
|
317
|
+
// Cached so an unattended refresh costs one request rather than three.
|
|
318
|
+
// Bound to `root` so switching environments re-discovers instead of
|
|
319
|
+
// reusing dev's token endpoint against prod.
|
|
320
|
+
token_endpoint: discovery.tokenEndpoint,
|
|
321
|
+
resource: discovery.resource,
|
|
322
|
+
root,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// --- 8. Verify, exactly as the static path does -----------------------
|
|
326
|
+
let verified = null;
|
|
327
|
+
if (flags.noVerify) {
|
|
328
|
+
err.write('anyslate: --no-verify given — skipping the live connection check.\n');
|
|
329
|
+
} else {
|
|
330
|
+
verified = await probeVerify({ root, token: oauth.access_token, fetchImpl });
|
|
331
|
+
if (verified.ok) {
|
|
332
|
+
out.write(`${verified.message}\n`);
|
|
333
|
+
const warning = scopeWarning(verified.scopes);
|
|
334
|
+
if (warning) err.write(`${warning}\n`);
|
|
335
|
+
} else {
|
|
336
|
+
err.write(`${verified.message}\n`);
|
|
337
|
+
if (!flags.force) {
|
|
338
|
+
err.write('anyslate: nothing was written. Re-run `anyslate login`, or pass --force to write anyway.\n');
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- 9. Persist -------------------------------------------------------
|
|
346
|
+
const path = join(anyslateDir(env), 'cli.json');
|
|
347
|
+
let handle = null;
|
|
348
|
+
try {
|
|
349
|
+
const current = readConfigFile(env);
|
|
350
|
+
handle = flags.handle ?? current.handle ?? null;
|
|
351
|
+
const next = {
|
|
352
|
+
...current,
|
|
353
|
+
apiUrl: root,
|
|
354
|
+
handle,
|
|
355
|
+
oauth_clients: { ...(current.oauth_clients ?? {}), [root]: clientId },
|
|
356
|
+
oauth,
|
|
357
|
+
};
|
|
358
|
+
delete next.api_url;
|
|
359
|
+
writeConfigFile(next, env);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
362
|
+
return 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
out.write(`anyslate: wrote ${path}\n`);
|
|
366
|
+
out.write(` apiUrl: ${root}\n`);
|
|
367
|
+
out.write(' auth: oauth (browser)\n');
|
|
368
|
+
out.write(` handle: ${handle ?? '(none — bearer token only)'}\n`);
|
|
369
|
+
out.write(
|
|
370
|
+
` access token expires ${oauth.expires_at}${
|
|
371
|
+
oauth.refresh_token ? '; it refreshes automatically' : ' (no refresh token issued)'
|
|
372
|
+
}\n`,
|
|
373
|
+
);
|
|
374
|
+
if (verified?.ok && verified.defaultHandleId) {
|
|
375
|
+
out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
|
|
376
|
+
}
|
|
377
|
+
out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
|
|
378
|
+
return 0;
|
|
379
|
+
} finally {
|
|
380
|
+
await listener.close();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
147
384
|
/** @param {string[]} argv */
|
|
148
385
|
function parseFlags(argv) {
|
|
149
|
-
const out = { force: false, noVerify: false };
|
|
386
|
+
const out = { force: false, noVerify: false, noBrowser: false, help: false };
|
|
150
387
|
for (let i = 0; i < argv.length; i += 1) {
|
|
151
388
|
const a = argv[i];
|
|
152
389
|
if (a === '--token' && argv[i + 1]) out.token = argv[++i];
|
|
@@ -154,6 +391,14 @@ function parseFlags(argv) {
|
|
|
154
391
|
else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
|
|
155
392
|
else if (a === '--force') out.force = true;
|
|
156
393
|
else if (a === '--no-verify' || a === '--skip-verify') out.noVerify = true;
|
|
394
|
+
else if (a === '--no-browser') out.noBrowser = true;
|
|
395
|
+
else if (a === '--timeout' && argv[i + 1]) {
|
|
396
|
+
const seconds = Number(argv[++i]);
|
|
397
|
+
if (Number.isFinite(seconds) && seconds > 0) out.timeout = seconds;
|
|
398
|
+
else out.timeoutInvalid = true;
|
|
399
|
+
} else if (a === '--help' || a === '-h') out.help = true;
|
|
157
400
|
}
|
|
158
401
|
return out;
|
|
159
402
|
}
|
|
403
|
+
|
|
404
|
+
export const __testing = { parseFlags };
|