@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
|
@@ -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
|
{
|
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
|
|
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
|
-
|
|
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>`
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
verification fails. --
|
|
42
|
-
|
|
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,
|
|
46
|
-
|
|
47
|
-
|
|
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':
|