@anyslate/cli 0.1.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.
@@ -1,4 +1,4 @@
1
- // `anyslate hook <subcommand>` submit a lifecycle event to the Activity feed.
1
+ // `anyslate hook <subcommand>` - submit a lifecycle event to the Activity feed.
2
2
  //
3
3
  // Subcommands:
4
4
  // anyslate hook session-start
@@ -8,41 +8,77 @@
8
8
  // Hooks fail open: any error short-circuits to a stderr warning + exit 0 so a
9
9
  // misconfigured CLI never breaks the parent Claude Code session. Pass
10
10
  // --strict to opt into exit 1 on failure (useful for setup verification).
11
+ //
12
+ // Fail-open is correct policy. Fail-SILENT was the bug: Claude Code files
13
+ // `exit 0 + stderr` as `hook_success` and nothing reads the stderr. So every
14
+ // run is now persisted (W7) and, after N consecutive failures, SessionStart
15
+ // escalates through a channel that actually renders.
16
+ //
17
+ // Tool-level errors (HTTP 200 + result.isError) now count as failures and are
18
+ // therefore visible to --strict — previously a 403 handle denial printed
19
+ // nothing and exited 0.
11
20
 
12
- import { loadConfig, requireToken } from '../config.mjs';
13
- import { callTool } from '../mcp-client.mjs';
21
+ import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
22
+ import { formatCallFailure } from '../mcp-client.mjs';
23
+ import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
14
24
  import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
15
- import { readStdin } from '../stdin.mjs';
25
+ import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
26
+ import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
27
+ import { VERSION } from '../version.mjs';
28
+ import { makeIo } from '../io.mjs';
16
29
 
17
30
  const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
18
31
 
19
32
  /**
20
33
  * @param {string[]} argv arguments after `hook`
34
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
21
35
  * @returns {Promise<number>}
22
36
  */
23
- export async function runHook(argv) {
37
+ export async function runHook(argv, deps = {}) {
38
+ const env = deps.env ?? process.env;
39
+ const { out, err } = makeIo(deps);
24
40
  const sub = argv[0];
25
41
  if (!sub || !ALLOWED.has(sub)) {
26
- process.stderr.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
42
+ err.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
27
43
  return 2;
28
44
  }
29
45
 
30
46
  const flags = parseFlags(argv.slice(1));
31
47
  const strict = flags.strict;
32
- const cfg = loadConfig();
48
+ const cfg = loadConfig(env);
33
49
 
34
- const tokenCheck = requireToken(cfg);
35
- if (!tokenCheck.ok) {
36
- process.stderr.write(`anyslate hook ${sub}: ${tokenCheck.error}\n`);
50
+ if (cfg.disabled) {
51
+ err.write(`${DISABLED_NOTICE}\n`);
52
+ return 0;
53
+ }
54
+
55
+ const prefix = `anyslate hook ${sub}`;
56
+ const fail = (message, extra = {}) => {
57
+ err.write(message);
58
+ const state = recordRun(
59
+ { command: `hook ${sub}`, ok: false, apiUrl: cfg.apiUrl, version: VERSION, error: message.trim(), exitCode: strict ? 1 : 0, ...extra },
60
+ env,
61
+ );
62
+ escalateIfNeeded(out, sub, state, message.trim());
37
63
  return strict ? 1 : 0;
64
+ };
65
+
66
+ const notice = apiUrlNormalizationNotice(cfg);
67
+ if (notice) err.write(notice);
68
+
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`);
38
75
  }
39
76
 
40
77
  let stdinRaw = '';
41
78
  try {
42
- stdinRaw = await readStdin();
79
+ stdinRaw = await readStdin(deps.stdin ?? process.stdin, { timeoutMs: stdinTimeoutFromEnv(env) });
43
80
  } catch (e) {
44
- process.stderr.write(`anyslate hook ${sub}: stdin read failed (${e?.message ?? e})\n`);
45
- return strict ? 1 : 0;
81
+ return fail(`${prefix}: stdin read failed (${e?.message ?? e})\n`);
46
82
  }
47
83
 
48
84
  const event = parseHookEvent(stdinRaw);
@@ -63,27 +99,49 @@ export async function runHook(argv) {
63
99
  if (submission.sessionIdHint) args.session_id_hint = submission.sessionIdHint;
64
100
 
65
101
  try {
66
- const res = await callTool({
67
- apiUrl: cfg.apiUrl,
68
- token: cfg.mcpToken,
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,
69
107
  toolName: 'activity_submit',
70
108
  args,
109
+ fetchImpl: deps.fetchImpl,
71
110
  });
111
+ if (res.authWarning) err.write(`${res.authWarning}\n`);
72
112
  if (!res.ok) {
73
- const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
74
- process.stderr.write(`anyslate hook ${sub}: server ${res.status} — ${msg}\n`);
75
- return strict ? 1 : 0;
113
+ return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
114
+ status: res.status,
115
+ isError: !!res.isError,
116
+ networkError: !!res.networkError,
117
+ });
76
118
  }
77
- if (process.env.ANYSLATE_VERBOSE) {
78
- process.stdout.write(`${JSON.stringify(res.data)}\n`);
119
+ recordRun(
120
+ { command: `hook ${sub}`, ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 },
121
+ env,
122
+ );
123
+ if (env.ANYSLATE_VERBOSE) {
124
+ out.write(`${JSON.stringify(res.data)}\n`);
79
125
  }
80
126
  return 0;
81
127
  } catch (e) {
82
- process.stderr.write(`anyslate hook ${sub}: request failed (${e?.message ?? e})\n`);
83
- return strict ? 1 : 0;
128
+ return fail(`${prefix}: request failed (${e?.message ?? e})\n`);
84
129
  }
85
130
  }
86
131
 
132
+ /**
133
+ * Escalate through a channel Claude Code renders — plain stderr on an exit-0
134
+ * hook is filed as `hook_success` and read by nobody.
135
+ *
136
+ * Only from SessionStart: PostToolUse exit 2 is a real blocking channel and
137
+ * must stay reserved for hard failures.
138
+ */
139
+ function escalateIfNeeded(out, sub, state, error) {
140
+ if (sub !== 'session-start') return;
141
+ if (!shouldEscalate(state)) return;
142
+ out.write(escalationPayload({ ...state, error }));
143
+ }
144
+
87
145
  /** @param {string[]} argv */
88
146
  function parseFlags(argv) {
89
147
  const out = { strict: false };
@@ -1,67 +1,404 @@
1
- // `anyslate login` write `~/.anyslate/cli.json` with the user's MCP token.
1
+ // `anyslate login` - two credential paths, one config file.
2
2
  //
3
- // Mint the token in the desktop app at Settings → AI Memory → Connect → Mint
4
- // MCP Token (Professional tier only). 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.
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.
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.
11
24
 
12
- import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
25
  import { join } from 'node:path';
26
+ import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
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';
41
+ import { makeIo } from '../io.mjs';
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.';
15
49
 
16
50
  /**
17
51
  * @param {string[]} argv arguments after `login`
52
+ * @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv,
53
+ * openBrowserImpl?: typeof openBrowser,
54
+ * onAuthorizeUrl?: (url: string, ctx: object) => unknown}} [deps]
18
55
  * @returns {Promise<number>}
19
56
  */
20
- export async function runLogin(argv) {
57
+ export async function runLogin(argv, deps = {}) {
21
58
  const flags = parseFlags(argv);
22
- if (!flags.token) {
23
- process.stderr.write('usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]\n');
59
+ if (flags.help) {
60
+ makeIo(deps).err.write(`${USAGE}\n`);
24
61
  return 2;
25
62
  }
63
+ return flags.token ? runTokenLogin(flags, deps) : runOauthLogin(flags, deps);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Shared
68
+ // ---------------------------------------------------------------------------
69
+
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`,
83
+ );
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
+ }
26
94
 
27
- const dir = join(homedir(), '.anyslate');
28
- const path = join(dir, 'cli.json');
29
- let existing = {};
95
+ function hostOf(root) {
30
96
  try {
31
- existing = JSON.parse(readFileSync(path, 'utf8')) || {};
97
+ return new URL(root).host;
32
98
  } catch {
33
- existing = {};
99
+ return root;
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);
117
+
118
+ // --- 1. Token format ----------------------------------------------------
119
+ if (!isValidTokenFormat(flags.token)) {
120
+ const msg =
121
+ `anyslate: that doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(flags.token)}").\n` +
122
+ 'anyslate: mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.\n';
123
+ if (!flags.force) {
124
+ err.write(msg);
125
+ return 1;
126
+ }
127
+ err.write(msg);
128
+ err.write('anyslate: --force given — writing anyway.\n');
129
+ }
130
+
131
+ // --- 2. URL shape (MUST precede any token verdict) ----------------------
132
+ const { shape, root } = resolveRoot(flags, existing, io);
133
+ if (!shape.ok) {
134
+ err.write(`${shape.message}\n`);
135
+ if (!flags.force) return 1;
136
+ err.write('anyslate: --force given — writing anyway.\n');
137
+ }
138
+
139
+ // --- 3. Live probe ------------------------------------------------------
140
+ let verified = null;
141
+ if (flags.noVerify) {
142
+ err.write('anyslate: --no-verify given — skipping the live connection check.\n');
143
+ } else {
144
+ verified = await probeVerify({ root, token: flags.token, fetchImpl });
145
+ if (!verified.ok) {
146
+ err.write(`${verified.message}\n`);
147
+ if (!flags.force) {
148
+ err.write(
149
+ 'anyslate: nothing was written. Re-run with a working --token/--api-url, or `--force` to write anyway, or `--no-verify` to skip the check.\n',
150
+ );
151
+ return 1;
152
+ }
153
+ err.write('anyslate: --force given — writing anyway.\n');
154
+ } else {
155
+ out.write(`${verified.message}\n`);
156
+ // --- 4. Scope check (warn, never block) -----------------------------
157
+ const warning = scopeWarning(verified.scopes);
158
+ if (warning) err.write(`${warning}\n`);
159
+ }
34
160
  }
35
161
 
162
+ // --- 5. Write only on success (or --force / --no-verify) ----------------
36
163
  const next = {
37
164
  ...existing,
38
165
  mcp_token: flags.token,
39
166
  handle: flags.handle ?? existing.handle ?? null,
40
- apiUrl: flags.apiUrl ?? existing.apiUrl ?? 'https://mcp.anyslate.io',
167
+ apiUrl: root,
41
168
  };
169
+ delete next.api_url; // collapse the legacy alias so only one key can drift
42
170
 
43
171
  try {
44
- mkdirSync(dir, { recursive: true, mode: 0o700 });
45
- writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
172
+ writeConfigFile(next, env);
46
173
  } catch (e) {
47
- process.stderr.write(`anyslate login: write failed (${e?.message ?? e})\n`);
174
+ err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
48
175
  return 1;
49
176
  }
50
177
 
51
- process.stdout.write(`anyslate: wrote ${path}\n`);
52
- process.stdout.write(` apiUrl: ${next.apiUrl}\n`);
53
- process.stdout.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
178
+ out.write(`anyslate: wrote ${path}\n`);
179
+ out.write(` apiUrl: ${next.apiUrl}\n`);
180
+ out.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
181
+ if (verified?.ok && verified.defaultHandleId) {
182
+ out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
183
+ }
184
+ out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
54
185
  return 0;
55
186
  }
56
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
+
57
384
  /** @param {string[]} argv */
58
385
  function parseFlags(argv) {
59
- const out = {};
386
+ const out = { force: false, noVerify: false, noBrowser: false, help: false };
60
387
  for (let i = 0; i < argv.length; i += 1) {
61
388
  const a = argv[i];
62
389
  if (a === '--token' && argv[i + 1]) out.token = argv[++i];
63
390
  else if (a === '--handle' && argv[i + 1]) out.handle = argv[++i];
64
391
  else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
392
+ else if (a === '--force') out.force = true;
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;
65
400
  }
66
401
  return out;
67
402
  }
403
+
404
+ export const __testing = { parseFlags };