@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/src/config.mjs CHANGED
@@ -5,6 +5,15 @@
5
5
  // 2. ~/.anyslate/cli.json (preferred CLI config)
6
6
  // 3. ~/.anyslate/session.json (legacy, only the `mcp_token` / `handle` keys)
7
7
  //
8
+ // BEARER precedence specifically (`mcpToken`), narrower than the above:
9
+ // ANYSLATE_MCP_TOKEN → oauth.access_token (present AND unexpired) → mcp_token
10
+ //
11
+ // `loadConfig` is synchronous, so it cannot refresh — an OAuth credential past
12
+ // its expiry falls through here as if absent. That is deliberate: the raw
13
+ // `oauth` block is returned alongside, and src/auth.mjs (async) is what decides
14
+ // to refresh it. Making this function "smart" would mean every config read
15
+ // could block on the network, including inside `doctor`'s own diagnostics.
16
+ //
8
17
  // Defaults:
9
18
  // - apiUrl → https://mcp.anyslate.io
10
19
  //
@@ -72,12 +81,31 @@ export function isCaptureDisabled(env = process.env) {
72
81
  export const DISABLED_NOTICE =
73
82
  'anyslate: ANYSLATE_DISABLE is set — capture disabled for this shell (no network call made).';
74
83
 
84
+ /**
85
+ * Is this stored OAuth credential still usable without a refresh?
86
+ * An absent or unparseable `expires_at` counts as expired — "I cannot tell"
87
+ * must never resolve to "still good".
88
+ *
89
+ * @param {{access_token?: string, expires_at?: string}|null|undefined} oauth
90
+ * @param {number} [now]
91
+ * @returns {boolean}
92
+ */
93
+ export function oauthTokenUsable(oauth, now = Date.now()) {
94
+ if (!oauth || typeof oauth !== 'object') return false;
95
+ if (typeof oauth.access_token !== 'string' || !oauth.access_token) return false;
96
+ const t = Date.parse(String(oauth.expires_at ?? ''));
97
+ if (!Number.isFinite(t)) return false;
98
+ return t > now;
99
+ }
100
+
75
101
  /**
76
102
  * @param {NodeJS.ProcessEnv} env
77
103
  * @param {() => string[]} [paths] override for tests
78
104
  * @returns {{
79
105
  * apiUrl: string, apiUrlRaw: string, apiUrlNormalized: boolean,
80
106
  * mcpToken: string|null, handle: string|null,
107
+ * oauth: object|null, staticToken: string|null, authMode: 'env'|'oauth'|'static'|'none',
108
+ * oauthClients: Record<string, string>,
81
109
  * source: string, sources: {apiUrl: string, mcpToken: string, handle: string},
82
110
  * disabled: boolean
83
111
  * }}
@@ -119,19 +147,42 @@ export function loadConfig(env = process.env, paths) {
119
147
  const apiUrl = normalizeApiRoot(apiUrlRaw);
120
148
  const apiUrlNormalized = apiUrl !== apiUrlRaw.replace(/\/+$/, '');
121
149
 
122
- const mcpToken = env.ANYSLATE_MCP_TOKEN || fileConfig.mcp_token || fileConfig.token || null;
150
+ const oauth = fileConfig.oauth && typeof fileConfig.oauth === 'object' ? fileConfig.oauth : null;
151
+ const oauthClients =
152
+ fileConfig.oauth_clients && typeof fileConfig.oauth_clients === 'object' ? fileConfig.oauth_clients : {};
153
+ const staticToken = fileConfig.mcp_token || fileConfig.token || null;
154
+ const liveOauthToken = oauthTokenUsable(oauth) ? oauth.access_token : null;
155
+
156
+ const mcpToken = env.ANYSLATE_MCP_TOKEN || liveOauthToken || staticToken || null;
123
157
  const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
124
158
 
159
+ /** Which layer supplied the bearer — what `doctor` renders as the auth mode. */
160
+ const authMode = env.ANYSLATE_MCP_TOKEN
161
+ ? 'env'
162
+ : liveOauthToken
163
+ ? 'oauth'
164
+ : staticToken
165
+ ? 'static'
166
+ : oauth
167
+ ? 'oauth' // credentials exist but need a refresh; auth.mjs handles it
168
+ : 'none';
169
+
125
170
  return {
126
171
  apiUrl,
127
172
  apiUrlRaw,
128
173
  apiUrlNormalized,
129
174
  mcpToken,
130
175
  handle,
176
+ oauth,
177
+ oauthClients,
178
+ staticToken,
179
+ authMode,
131
180
  source,
132
181
  sources: {
133
182
  apiUrl: layerOf(env.ANYSLATE_API_URL, fileConfig.apiUrl || fileConfig.api_url),
134
- mcpToken: layerOf(env.ANYSLATE_MCP_TOKEN, fileConfig.mcp_token || fileConfig.token),
183
+ // The bearer's provenance follows the same three-way precedence as the
184
+ // value itself, so `doctor` cannot report "from file" for an env token.
185
+ mcpToken: layerOf(env.ANYSLATE_MCP_TOKEN, liveOauthToken || staticToken),
135
186
  handle: layerOf(env.ANYSLATE_HANDLE, fileConfig.handle),
136
187
  },
137
188
  disabled: isCaptureDisabled(env),
@@ -162,7 +213,7 @@ export function requireToken(cfg) {
162
213
  return {
163
214
  ok: false,
164
215
  error:
165
- 'no MCP token configured. Run `anyslate login --token <BEARER>` or set ANYSLATE_MCP_TOKEN. Mint a token in the AnySlate desktop app at Avatar (top-right) → API Tokens.',
216
+ 'no MCP token configured. Run `anyslate login` to sign in with your browser, or `anyslate login --token <BEARER>` / set ANYSLATE_MCP_TOKEN for CI. Mint a token in the AnySlate desktop app at Avatar (top-right) → API Tokens.',
166
217
  };
167
218
  }
168
219
  return { ok: true };
@@ -0,0 +1,162 @@
1
+ // Credential store for `~/.anyslate/cli.json`.
2
+ //
3
+ // WHY THIS IS ITS OWN MODULE. Before OAuth, exactly one command wrote this
4
+ // file (`login`), once, interactively. With refresh tokens, ANY command can
5
+ // write it — and hooks fire in parallel. Claude Code can run SessionStart,
6
+ // PostToolUse and Stop from separate processes within the same second, and a
7
+ // rotated refresh token that loses a write race is unrecoverable: the server
8
+ // has already marked the old one replaced (oauth_refresh_tokens.replaced_by_id),
9
+ // so the user silently drops to "re-login required" with no error anywhere.
10
+ //
11
+ // Two mechanisms, both required:
12
+ //
13
+ // 1. ATOMIC WRITE. Serialize to `cli.json.tmp-<pid>-<ts>` in the same
14
+ // directory, then rename(2) over the target. rename is atomic within a
15
+ // filesystem, so a concurrent reader sees either the whole old file or
16
+ // the whole new one — never a half-written one. Writing in place would
17
+ // let a reader observe a truncated file and conclude "no credentials".
18
+ //
19
+ // 2. READ-MODIFY-WRITE UNDER A LOCK. Every mutation re-reads from disk
20
+ // first and merges, so a racing process's rotated token survives. The
21
+ // lock (O_CREAT|O_EXCL, which is atomic) keeps two processes from both
22
+ // deciding to refresh and racing the server. Lock acquisition is
23
+ // best-effort with a stale-lock breaker: a crashed process must not wedge
24
+ // capture forever, so failing to take the lock proceeds unlocked rather
25
+ // than throwing.
26
+
27
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { anyslateDir } from './config.mjs';
30
+
31
+ export const CONFIG_FILE = 'cli.json';
32
+ export const LOCK_FILE = 'cli-refresh.lock';
33
+
34
+ /** A lock older than this belonged to a process that died holding it. */
35
+ export const LOCK_STALE_MS = 30_000;
36
+ export const LOCK_WAIT_MS = 10_000;
37
+
38
+ export function cliConfigPath(env = process.env) {
39
+ return join(anyslateDir(env), CONFIG_FILE);
40
+ }
41
+
42
+ /**
43
+ * @param {NodeJS.ProcessEnv} [env]
44
+ * @returns {Record<string, any>}
45
+ */
46
+ export function readConfigFile(env = process.env) {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(cliConfigPath(env), 'utf8'));
49
+ return parsed && typeof parsed === 'object' ? parsed : {};
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Serialize + rename. Never writes the target path in place.
57
+ *
58
+ * @param {Record<string, any>} next
59
+ * @param {NodeJS.ProcessEnv} [env]
60
+ * @returns {string} the path written
61
+ */
62
+ export function writeConfigFile(next, env = process.env) {
63
+ const dir = anyslateDir(env);
64
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
65
+ const path = join(dir, CONFIG_FILE);
66
+ const tmp = join(dir, `${CONFIG_FILE}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
67
+ try {
68
+ writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
69
+ renameSync(tmp, path);
70
+ } catch (e) {
71
+ try {
72
+ unlinkSync(tmp);
73
+ } catch {
74
+ /* the temp file may not exist */
75
+ }
76
+ throw e;
77
+ }
78
+ return path;
79
+ }
80
+
81
+ /**
82
+ * Read-modify-write. `mutate` receives the CURRENT on-disk object (not a
83
+ * snapshot the caller took earlier) and returns the object to persist.
84
+ *
85
+ * @param {(current: Record<string, any>) => Record<string, any>} mutate
86
+ * @param {NodeJS.ProcessEnv} [env]
87
+ * @returns {Record<string, any>} the persisted object
88
+ */
89
+ export function updateConfigFile(mutate, env = process.env) {
90
+ const current = readConfigFile(env);
91
+ const next = mutate({ ...current }) ?? current;
92
+ writeConfigFile(next, env);
93
+ return next;
94
+ }
95
+
96
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
+
98
+ /**
99
+ * Run `fn` holding the refresh lock when possible.
100
+ *
101
+ * `fn` is given `{held}` so it can tell "I am the only refresher" from "I gave
102
+ * up waiting". It ALWAYS runs: a lock we could not take is a reason to be
103
+ * careful, never a reason to skip capture.
104
+ *
105
+ * @template T
106
+ * @param {(state: {held: boolean}) => Promise<T>|T} fn
107
+ * @param {{env?: NodeJS.ProcessEnv, waitMs?: number, staleMs?: number}} [opts]
108
+ * @returns {Promise<T>}
109
+ */
110
+ export async function withRefreshLock(fn, opts = {}) {
111
+ const env = opts.env ?? process.env;
112
+ const waitMs = opts.waitMs ?? LOCK_WAIT_MS;
113
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
114
+
115
+ let lockPath = null;
116
+ let held = false;
117
+ try {
118
+ const dir = anyslateDir(env);
119
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
120
+ lockPath = join(dir, LOCK_FILE);
121
+ } catch {
122
+ return fn({ held: false });
123
+ }
124
+
125
+ const deadline = Date.now() + waitMs;
126
+ while (Date.now() < deadline) {
127
+ try {
128
+ const fd = openSync(lockPath, 'wx', 0o600);
129
+ try {
130
+ writeFileSync(fd, `${process.pid} ${new Date().toISOString()}`);
131
+ } finally {
132
+ closeSync(fd);
133
+ }
134
+ held = true;
135
+ break;
136
+ } catch (e) {
137
+ if (e?.code !== 'EEXIST') break; // unwritable dir — proceed unlocked
138
+ let broke = false;
139
+ try {
140
+ if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
141
+ unlinkSync(lockPath);
142
+ broke = true;
143
+ }
144
+ } catch {
145
+ /* the holder released it between stat and unlink — just retry */
146
+ }
147
+ if (!broke) await sleep(40);
148
+ }
149
+ }
150
+
151
+ try {
152
+ return await fn({ held });
153
+ } finally {
154
+ if (held) {
155
+ try {
156
+ unlinkSync(lockPath);
157
+ } catch {
158
+ /* already gone */
159
+ }
160
+ }
161
+ }
162
+ }
package/src/index.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // anyslate hook <session-start|post-tool-use|stop>
4
4
  // anyslate checkpoint --note "..."
5
5
  // anyslate upload-artifact --session <id> --kind <kind> [--file <path>]
6
- // anyslate login --token <bearer>
6
+ // anyslate login [--api-url <root>] | login --token <bearer>
7
+ // anyslate logout
7
8
  // anyslate doctor
8
9
  // anyslate version
9
10
  // anyslate help
@@ -12,6 +13,7 @@ import { runHook } from './commands/hook.mjs';
12
13
  import { runCheckpoint } from './commands/checkpoint.mjs';
13
14
  import { runUploadArtifact } from './commands/upload-artifact.mjs';
14
15
  import { runLogin } from './commands/login.mjs';
16
+ import { runLogout } from './commands/logout.mjs';
15
17
  import { runDoctor } from './commands/doctor.mjs';
16
18
  import { isCaptureDisabled, DISABLED_NOTICE } from './config.mjs';
17
19
  import { VERSION } from './version.mjs';
@@ -34,18 +36,35 @@ usage:
34
36
  Upload a UTF-8 file (or stdin, up to 5 MB) as an MCP artifact.
35
37
  Prints cloud://artifact/<id>.
36
38
 
39
+ anyslate login [--api-url <URL>] [--no-browser] [--timeout <seconds>]
40
+ [--handle <ID>]
41
+ Sign in through your browser (OAuth 2.1 + PKCE). Opens the consent page,
42
+ waits on a loopback listener, then saves credentials to
43
+ ~/.anyslate/cli.json (mode 0600). Defaults to production; --api-url takes
44
+ the service ROOT (no /mcp suffix) and every OAuth endpoint is read from
45
+ that root's discovery documents. --no-browser prints the URL instead of
46
+ opening it. --timeout is how long to wait for the callback (default 180).
47
+ The access token is refreshed automatically as it nears expiry.
48
+
37
49
  anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]
38
50
  [--force] [--no-verify]
39
- Verify the token against the server, then save credentials to
40
- ~/.anyslate/cli.json (mode 0600). Exits non-zero and writes nothing if
41
- verification fails. --api-url takes the service ROOT (no /mcp suffix).
42
- --force writes anyway; --no-verify skips the live check.
51
+ Static-token sign-in, for CI and air-gapped setups. Verifies the token
52
+ against the server, then writes the same config file. Exits non-zero and
53
+ writes nothing if verification fails. --force writes anyway; --no-verify
54
+ skips the live check.
55
+
56
+ anyslate logout [--local]
57
+ Revoke the OAuth session server-side (best effort), then remove the
58
+ stored credentials. apiUrl, handle and the cached client registration are
59
+ kept. --local skips revocation and only clears the local file.
43
60
 
44
- anyslate doctor [--deep]
45
- Diagnose the whole setup: config layers, token format, URL shape,
46
- reachability, token validity, scopes, handle binding, PATH, Claude Code
47
- hook wiring, and the last run outcome. Exits non-zero on any FAIL.
61
+ anyslate doctor [--deep] [--refresh]
62
+ Diagnose the whole setup: config layers, auth mode (OAuth vs static
63
+ token) and token expiry, token format, URL shape, reachability, token
64
+ validity, scopes, handle binding, PATH, Claude Code hook wiring, and the
65
+ last run outcome. Exits non-zero on any FAIL.
48
66
  --deep additionally writes one probe row to your Activity feed.
67
+ --refresh forces a real OAuth refresh round trip (rotates the token).
49
68
 
50
69
  env:
51
70
  ANYSLATE_API_URL override config (service ROOT, e.g. dev workers.dev URL)
@@ -85,6 +104,8 @@ export async function main(argv) {
85
104
  return runUploadArtifact(argv.slice(1));
86
105
  case 'login':
87
106
  return runLogin(argv.slice(1));
107
+ case 'logout':
108
+ return runLogout(argv.slice(1));
88
109
  case 'doctor':
89
110
  return runDoctor(argv.slice(1));
90
111
  case 'version':