@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.
package/src/auth.mjs ADDED
@@ -0,0 +1,310 @@
1
+ // Bearer resolution and refresh for every command that talks to the server.
2
+ //
3
+ // THIS IS THE LOAD-BEARING PART OF THE OAUTH WORK. A static `as_mcp_` token
4
+ // never expires, so `loadConfig().mcpToken` was the whole auth story. An OAuth
5
+ // access token lives 3600 seconds, and hooks fire unattended in
6
+ // non-interactive shells — so a token WILL expire mid-session, in a process
7
+ // with no terminal, no browser, and a hard "must exit 0" contract.
8
+ //
9
+ // The rules that fall out of that:
10
+ //
11
+ // * Refresh PROACTIVELY, 5 minutes ahead of expiry, before the call. Waiting
12
+ // for the 401 spends a round trip and, on the hook path, risks the 15s
13
+ // budget.
14
+ // * On a 401, refresh EXACTLY ONCE and retry EXACTLY ONCE. A loop here is a
15
+ // self-inflicted rate limit against an endpoint that is already refusing us.
16
+ // * Persist the rotated refresh token immediately (rotation is on server-side).
17
+ // * NEVER open a browser. This module is imported by `hook`; a browser launch
18
+ // from a lifecycle hook would spray tabs across the user's screen and hang
19
+ // a process that must not hang.
20
+ // * Never throw. Callers are fail-open paths; a rejected promise here would
21
+ // become an uncaught exception in a hook and a non-zero exit.
22
+
23
+ import { readConfigFile, updateConfigFile, withRefreshLock } from './credentials.mjs';
24
+ import { callTool } from './mcp-client.mjs';
25
+ import { discover, refreshAccessToken, REFRESH_SKEW_MS } from './oauth.mjs';
26
+
27
+ export { REFRESH_SKEW_MS };
28
+
29
+ /** Everything a hook/checkpoint/upload path is allowed to say about auth. */
30
+ export const RELOGIN_HINT = 'Run `anyslate login` to sign in again.';
31
+
32
+ /**
33
+ * @param {unknown} expiresAt ISO 8601
34
+ * @returns {number|null} epoch ms, or null when unparseable
35
+ */
36
+ export function expiryMs(expiresAt) {
37
+ if (typeof expiresAt !== 'string' || !expiresAt) return null;
38
+ const t = Date.parse(expiresAt);
39
+ return Number.isFinite(t) ? t : null;
40
+ }
41
+
42
+ /**
43
+ * Is this credential inside the refresh window (or already dead)?
44
+ *
45
+ * An UNPARSEABLE or ABSENT expiry counts as expiring. Treating "I do not know"
46
+ * as "still valid" is how a config written by a future version, or hand-edited,
47
+ * turns into a silent 401 loop.
48
+ *
49
+ * @param {{expires_at?: string}|null|undefined} oauth
50
+ * @param {number} [skewMs]
51
+ * @param {number} [now]
52
+ */
53
+ export function isExpiring(oauth, skewMs = REFRESH_SKEW_MS, now = Date.now()) {
54
+ if (!oauth?.access_token) return true;
55
+ const t = expiryMs(oauth.expires_at);
56
+ if (t == null) return true;
57
+ return t - now <= skewMs;
58
+ }
59
+
60
+ /** Hard expiry, no skew — used for reporting, not for refresh decisions. */
61
+ export function isExpired(oauth, now = Date.now()) {
62
+ return isExpiring(oauth, 0, now);
63
+ }
64
+
65
+ /** Minutes until expiry, for human output. Negative means already expired. */
66
+ export function minutesUntilExpiry(oauth, now = Date.now()) {
67
+ const t = expiryMs(oauth?.expires_at);
68
+ if (t == null) return null;
69
+ return Math.round((t - now) / 60_000);
70
+ }
71
+
72
+ /** Does this config hold OAuth credentials worth using or refreshing? */
73
+ export function hasOauthCredentials(oauth) {
74
+ return !!(oauth && (oauth.access_token || oauth.refresh_token));
75
+ }
76
+
77
+ /**
78
+ * Resolve the endpoints used for a refresh.
79
+ *
80
+ * `login` caches `token_endpoint`/`resource`/`root` alongside the tokens, so
81
+ * the common refresh costs ONE request rather than three. The cache is only
82
+ * trusted when it was recorded for the root we are actually calling — a
83
+ * `--api-url` switch from prod to dev must re-discover, because the two issue
84
+ * different client ids and different `resource` values.
85
+ *
86
+ * @returns {Promise<{ok: true, tokenEndpoint: string, resource: string}
87
+ * | {ok: false, code: string, message: string}>}
88
+ */
89
+ async function refreshEndpoints({ oauth, root, fetchImpl }) {
90
+ if (oauth?.root === root && oauth?.token_endpoint && oauth?.resource) {
91
+ return { ok: true, tokenEndpoint: oauth.token_endpoint, resource: oauth.resource };
92
+ }
93
+ const found = await discover({ root, fetchImpl });
94
+ if (!found.ok) return found;
95
+ return { ok: true, tokenEndpoint: found.tokenEndpoint, resource: found.resource };
96
+ }
97
+
98
+ /**
99
+ * Perform one refresh, under the lock, persisting the rotated token.
100
+ *
101
+ * `staleToken` is the access token that just failed. It exists because expiry
102
+ * alone cannot decide whether a refresh is still needed: a token the server
103
+ * rejected with a 401 may have an `expires_at` an hour in the future (revoked
104
+ * early, clock skew, a config restored from a backup). Without it, the
105
+ * "somebody else already refreshed" short-circuit hands the caller back the
106
+ * very token that just 401'd, and the retry fails identically.
107
+ *
108
+ * @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
109
+ * staleToken?: string|null}} opts
110
+ * @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
111
+ * | {ok: false, code: string, message: string}>}
112
+ */
113
+ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null }) {
114
+ return withRefreshLock(
115
+ async () => {
116
+ // Re-read INSIDE the lock. While we queued, a sibling hook may have done
117
+ // the whole refresh already — redeeming our (now-replaced) refresh token
118
+ // would fail and, worse, would overwrite theirs.
119
+ const disk = readConfigFile(env);
120
+ const oauth = disk.oauth && typeof disk.oauth === 'object' ? disk.oauth : null;
121
+
122
+ if (!hasOauthCredentials(oauth)) {
123
+ return { ok: false, code: 'no_oauth_credentials', message: 'anyslate: no OAuth credentials to refresh.' };
124
+ }
125
+
126
+ // Skip only when the on-disk token is BOTH unexpired AND different from
127
+ // the one we are replacing — i.e. genuinely somebody else's fresh token.
128
+ const supersededByPeer = staleToken ? oauth.access_token && oauth.access_token !== staleToken : true;
129
+ if (supersededByPeer && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
130
+ return { ok: true, token: oauth.access_token, rotated: false, oauth };
131
+ }
132
+ if (!oauth.refresh_token) {
133
+ return {
134
+ ok: false,
135
+ code: 'no_refresh_token',
136
+ message: `anyslate: the stored OAuth session has no refresh token and its access token has expired. ${RELOGIN_HINT}`,
137
+ };
138
+ }
139
+
140
+ const endpoints = await refreshEndpoints({ oauth, root, fetchImpl });
141
+ if (!endpoints.ok) return endpoints;
142
+
143
+ const res = await refreshAccessToken({
144
+ tokenEndpoint: endpoints.tokenEndpoint,
145
+ refreshToken: oauth.refresh_token,
146
+ clientId: oauth.client_id,
147
+ resource: endpoints.resource,
148
+ fetchImpl,
149
+ now,
150
+ });
151
+ if (!res.ok) {
152
+ return {
153
+ ok: false,
154
+ code: res.code || 'refresh_failed',
155
+ message: `${res.message} ${RELOGIN_HINT}`,
156
+ };
157
+ }
158
+
159
+ // Persist BEFORE returning. Rotation means the old refresh token is dead
160
+ // the instant the server answered; a crash between here and the next
161
+ // write would lose the only usable credential.
162
+ const next = {
163
+ ...oauth,
164
+ access_token: res.tokens.access_token,
165
+ // A response that omits refresh_token means "keep the one you have".
166
+ refresh_token: res.tokens.refresh_token || oauth.refresh_token,
167
+ expires_at: res.tokens.expires_at,
168
+ token_endpoint: endpoints.tokenEndpoint,
169
+ resource: endpoints.resource,
170
+ root,
171
+ };
172
+ try {
173
+ updateConfigFile((current) => ({ ...current, oauth: { ...(current.oauth ?? {}), ...next } }), env);
174
+ } catch (e) {
175
+ return {
176
+ ok: false,
177
+ code: 'persist_failed',
178
+ message: `anyslate: refreshed the OAuth session but could not write the config (${e?.message ?? e}).`,
179
+ };
180
+ }
181
+ return { ok: true, token: next.access_token, rotated: true, oauth: next };
182
+ },
183
+ { env },
184
+ );
185
+ }
186
+
187
+ /**
188
+ * The bearer for the next call, refreshing if needed.
189
+ *
190
+ * Precedence mirrors loadConfig: ANYSLATE_MCP_TOKEN → OAuth → static
191
+ * `mcp_token` → nothing. The one deliberate divergence: loadConfig cannot
192
+ * refresh (it is synchronous), so an EXPIRED OAuth credential falls through
193
+ * there while here it is refreshed and preferred. If the refresh fails and a
194
+ * static token also exists, we fall back to it rather than failing the call —
195
+ * with `warning` set, so the caller can say so.
196
+ *
197
+ * @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean, now?: number}} opts
198
+ * @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
199
+ * | {ok: false, code: string, message: string, mode: string}>}
200
+ */
201
+ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now }) {
202
+ if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
203
+ return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
204
+ }
205
+
206
+ const oauth = cfg?.oauth ?? null;
207
+ if (hasOauthCredentials(oauth)) {
208
+ if (!force && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
209
+ return { ok: true, token: oauth.access_token, mode: 'oauth', refreshed: false };
210
+ }
211
+ // `force` means "the token we just used was rejected" — name it, so the
212
+ // lock's peer-refresh check cannot hand the same rejected token back.
213
+ const refreshed = await refreshCredentials({
214
+ env,
215
+ root: cfg.apiUrl,
216
+ fetchImpl,
217
+ now,
218
+ staleToken: force ? (oauth.access_token ?? null) : null,
219
+ });
220
+ if (refreshed.ok) {
221
+ return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
222
+ }
223
+ if (cfg.staticToken) {
224
+ return {
225
+ ok: true,
226
+ token: cfg.staticToken,
227
+ mode: 'static',
228
+ refreshed: false,
229
+ warning: `anyslate: OAuth refresh failed (${refreshed.code}) — falling back to the static token in your config.`,
230
+ };
231
+ }
232
+ return { ...refreshed, mode: 'oauth' };
233
+ }
234
+
235
+ if (cfg?.mcpToken) {
236
+ return { ok: true, token: cfg.mcpToken, mode: 'static', refreshed: false };
237
+ }
238
+
239
+ return {
240
+ ok: false,
241
+ mode: 'none',
242
+ code: 'no_credentials',
243
+ message:
244
+ 'no credentials configured. Run `anyslate login` to sign in with your browser, or `anyslate login --token <BEARER>` for CI. Mint a token in the AnySlate desktop app at Avatar (top-right) → API Tokens.',
245
+ };
246
+ }
247
+
248
+ /**
249
+ * `callTool` with the auth lifecycle wrapped around it: proactive refresh, then
250
+ * at most one refresh-and-retry on a 401.
251
+ *
252
+ * The retry is gated on `mode === 'oauth'`: a static token that 401s is revoked
253
+ * or mistyped, and re-sending it cannot help.
254
+ *
255
+ * @param {{cfg: object, env: NodeJS.ProcessEnv, toolName: string, args: object,
256
+ * fetchImpl?: typeof fetch, timeoutMs?: number}} opts
257
+ * @returns {Promise<object>} the callTool result, plus `authMode` / `authWarning`,
258
+ * or an auth failure shaped like a callTool failure (`authError: true`).
259
+ */
260
+ export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs }) {
261
+ const first = await resolveBearer({ cfg, env, fetchImpl });
262
+ if (!first.ok) {
263
+ return { ok: false, status: 0, data: first.message, raw: null, authError: true, authMode: first.mode };
264
+ }
265
+
266
+ const invoke = (token) =>
267
+ callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
268
+
269
+ let res = await invoke(first.token);
270
+ res.authMode = first.mode;
271
+ if (first.warning) res.authWarning = first.warning;
272
+
273
+ const retryable = !res.ok && res.status === 401 && first.mode === 'oauth' && !first.refreshed;
274
+ if (!retryable) return res;
275
+
276
+ const second = await resolveBearer({ cfg, env, fetchImpl, force: true });
277
+ if (!second.ok) {
278
+ res.authError = true;
279
+ res.authRefreshFailed = second.message;
280
+ return res;
281
+ }
282
+ const retried = await invoke(second.token);
283
+ retried.authMode = second.mode;
284
+ retried.authRetried = true;
285
+ if (second.warning) retried.authWarning = second.warning;
286
+ return retried;
287
+ }
288
+
289
+ /**
290
+ * The failure line for a result that failed on AUTH rather than on transport.
291
+ *
292
+ * `formatCallFailure` renders `server ${status} — ${data}`, which for an auth
293
+ * failure that never reached the server would print `server 0` — an HTTP status
294
+ * that did not happen. Returns null when this is an ordinary failure and the
295
+ * existing formatter should be used instead.
296
+ *
297
+ * @param {string} prefix
298
+ * @param {object} res
299
+ * @returns {string|null}
300
+ */
301
+ export function formatAuthFailure(prefix, res) {
302
+ if (!res?.authError) return null;
303
+ if (res.authRefreshFailed) {
304
+ return `${prefix}: server ${res.status} and the OAuth refresh failed — ${res.authRefreshFailed}\n`;
305
+ }
306
+ if (res.status === 0 && !res.networkError) {
307
+ return `${prefix}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}\n`;
308
+ }
309
+ return null;
310
+ }
@@ -1,11 +1,22 @@
1
- // `anyslate checkpoint` submit an explicit checkpoint to the Activity feed.
1
+ // `anyslate checkpoint` - submit an explicit checkpoint to the Activity feed.
2
2
  //
3
3
  // Unlike `hook`, this is user-initiated, so we surface failures directly
4
4
  // (exit 1 on error). Use this in CI or one-off scripts where you actually
5
5
  // want to know if the submit failed.
6
+ //
7
+ // W2: "failure" now includes tool-level errors (HTTP 200 + result.isError).
8
+ // Previously a handle denial printed `{"error":"handle_not_found","status":401}`
9
+ // to STDOUT and exited 0 — documented as fail-closed, actually fail-open, and
10
+ // on the stream a caller would pipe into the next command.
11
+ //
12
+ // stdout carries results only. Every error goes to stderr.
6
13
 
7
- import { loadConfig, requireToken } from '../config.mjs';
8
- import { callTool } from '../mcp-client.mjs';
14
+ import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
15
+ import { formatCallFailure } from '../mcp-client.mjs';
16
+ import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
17
+ import { recordRun } from '../runlog.mjs';
18
+ import { VERSION } from '../version.mjs';
19
+ import { makeIo } from '../io.mjs';
9
20
 
10
21
  const ALLOWED_KINDS = new Set([
11
22
  'topic_shift', 'decision_committed', 'task_completed', 'task_added',
@@ -14,25 +25,38 @@ const ALLOWED_KINDS = new Set([
14
25
 
15
26
  /**
16
27
  * @param {string[]} argv arguments after `checkpoint`
28
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
17
29
  * @returns {Promise<number>}
18
30
  */
19
- export async function runCheckpoint(argv) {
31
+ export async function runCheckpoint(argv, deps = {}) {
32
+ const env = deps.env ?? process.env;
33
+ const { out, err } = makeIo(deps);
20
34
  const flags = parseFlags(argv);
21
35
  if (!flags.note) {
22
- process.stderr.write('usage: anyslate checkpoint --note <text> [--kind <kind>] [--session <id>] [--host <hint>] [--source <source>]\n');
23
- process.stderr.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
36
+ err.write('usage: anyslate checkpoint --note <text> [--kind <kind>] [--session <id>] [--host <hint>] [--source <source>]\n');
37
+ err.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
24
38
  return 2;
25
39
  }
26
40
  const kind = flags.kind || 'milestone';
27
41
  if (!ALLOWED_KINDS.has(kind)) {
28
- process.stderr.write(`anyslate checkpoint: unsupported kind: ${kind}\n`);
42
+ err.write(`anyslate checkpoint: unsupported kind: ${kind}\n`);
29
43
  return 2;
30
44
  }
31
45
 
32
- const cfg = loadConfig();
33
- const tokenCheck = requireToken(cfg);
34
- if (!tokenCheck.ok) {
35
- process.stderr.write(`anyslate checkpoint: ${tokenCheck.error}\n`);
46
+ const cfg = loadConfig(env);
47
+ if (cfg.disabled) {
48
+ err.write(`${DISABLED_NOTICE}\n`);
49
+ return 0;
50
+ }
51
+
52
+ const notice = apiUrlNormalizationNotice(cfg);
53
+ if (notice) err.write(notice);
54
+
55
+ // Presence only — an expired OAuth token is refreshed by callToolWithAuth.
56
+ if (cfg.authMode === 'none') {
57
+ const error = requireToken({ mcpToken: null }).error;
58
+ err.write(`anyslate checkpoint: ${error}\n`);
59
+ recordRun({ command: 'checkpoint', ok: false, apiUrl: cfg.apiUrl, error, version: VERSION, exitCode: 1 }, env);
36
60
  return 1;
37
61
  }
38
62
 
@@ -52,21 +76,40 @@ export async function runCheckpoint(argv) {
52
76
  if (flags.session) args.session_id_hint = flags.session;
53
77
 
54
78
  try {
55
- const res = await callTool({
56
- apiUrl: cfg.apiUrl,
57
- token: cfg.mcpToken,
79
+ const res = await callToolWithAuth({
80
+ cfg,
81
+ env,
58
82
  toolName: 'activity_submit',
59
83
  args,
84
+ fetchImpl: deps.fetchImpl,
60
85
  });
86
+ if (res.authWarning) err.write(`${res.authWarning}\n`);
61
87
  if (!res.ok) {
62
- const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
63
- process.stderr.write(`anyslate checkpoint: server ${res.status} — ${msg}\n`);
88
+ const message = formatAuthFailure('anyslate checkpoint', res) ?? formatCallFailure('anyslate checkpoint', res);
89
+ err.write(message);
90
+ recordRun(
91
+ {
92
+ command: 'checkpoint',
93
+ ok: false,
94
+ apiUrl: cfg.apiUrl,
95
+ status: res.status,
96
+ isError: !!res.isError,
97
+ networkError: !!res.networkError,
98
+ error: message.trim(),
99
+ version: VERSION,
100
+ exitCode: 1,
101
+ },
102
+ env,
103
+ );
64
104
  return 1;
65
105
  }
66
- process.stdout.write(`${JSON.stringify(res.data)}\n`);
106
+ recordRun({ command: 'checkpoint', ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 }, env);
107
+ out.write(`${JSON.stringify(res.data)}\n`);
67
108
  return 0;
68
109
  } catch (e) {
69
- process.stderr.write(`anyslate checkpoint: request failed (${e?.message ?? e})\n`);
110
+ const message = `anyslate checkpoint: request failed (${e?.message ?? e})\n`;
111
+ err.write(message);
112
+ recordRun({ command: 'checkpoint', ok: false, apiUrl: cfg.apiUrl, error: message.trim(), version: VERSION, exitCode: 1 }, env);
70
113
  return 1;
71
114
  }
72
115
  }