@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/README.md +313 -19
- package/package.json +6 -6
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +61 -18
- package/src/commands/doctor.mjs +616 -0
- package/src/commands/hook.mjs +81 -23
- package/src/commands/login.mjs +362 -25
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +136 -26
- package/src/config.mjs +162 -13
- package/src/credentials.mjs +162 -0
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +61 -6
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/oauth.mjs +633 -0
- package/src/runlog.mjs +196 -0
- package/src/stdin.mjs +85 -15
- package/src/verify.mjs +262 -0
- package/src/version.mjs +21 -0
- package/templates/git/post-commit +71 -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `anyslate upload-artifact`
|
|
1
|
+
// `anyslate upload-artifact` - upload a file (or stdin) as an MCP artifact.
|
|
2
2
|
//
|
|
3
3
|
// Usage:
|
|
4
4
|
// anyslate upload-artifact --session <id> --kind code_block --file ./diff.patch
|
|
@@ -6,63 +6,122 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Returns the cloud://artifact/<id> URI on stdout (one per line) so callers
|
|
8
8
|
// can pipe it into a subsequent `anyslate checkpoint --note ...` if desired.
|
|
9
|
+
//
|
|
10
|
+
// W11: `--file` used to `readFileSync(path, 'utf8')`, which silently mangles
|
|
11
|
+
// binaries (measured: 64 raw bytes in → 124 bytes uploaded, exit 0). The
|
|
12
|
+
// `upload_artifact` MCP tool is a TEXT channel, so we detect and refuse rather
|
|
13
|
+
// than pretend. Both `--file` and stdin honour the same 5 MB cap; the stdin
|
|
14
|
+
// path must never fall back to readStdin's 1 MB default.
|
|
9
15
|
|
|
10
16
|
import { readFileSync } from 'node:fs';
|
|
11
17
|
import { basename } from 'node:path';
|
|
12
|
-
import { loadConfig, requireToken } from '../config.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
18
|
+
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
19
|
+
import { formatCallFailure } from '../mcp-client.mjs';
|
|
20
|
+
import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
|
|
21
|
+
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
22
|
+
import { recordRun } from '../runlog.mjs';
|
|
23
|
+
import { VERSION } from '../version.mjs';
|
|
24
|
+
import { makeIo } from '../io.mjs';
|
|
15
25
|
|
|
16
26
|
const ALLOWED_KINDS = new Set([
|
|
17
27
|
'code_block', 'file_path', 'error_message', 'shell_command',
|
|
18
28
|
'config_snippet', 'url_reference', 'fenced_quote',
|
|
19
29
|
]);
|
|
20
30
|
|
|
21
|
-
const MAX_CONTENT_BYTES = 5_000_000;
|
|
31
|
+
export const MAX_CONTENT_BYTES = 5_000_000;
|
|
32
|
+
|
|
33
|
+
/** Uploading over a pipe may legitimately be slow; give stdin more idle room. */
|
|
34
|
+
const STDIN_IDLE_TIMEOUT_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A Buffer round-trips through utf8 iff it *is* valid utf8. Node replaces
|
|
38
|
+
* invalid sequences with U+FFFD, so re-encoding produces different bytes.
|
|
39
|
+
*
|
|
40
|
+
* @param {Buffer} buf
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function isValidUtf8(buf) {
|
|
44
|
+
return Buffer.compare(Buffer.from(buf.toString('utf8'), 'utf8'), buf) === 0;
|
|
45
|
+
}
|
|
22
46
|
|
|
23
47
|
/**
|
|
24
48
|
* @param {string[]} argv arguments after `upload-artifact`
|
|
49
|
+
* @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, stdin?: NodeJS.ReadableStream}} [deps]
|
|
25
50
|
* @returns {Promise<number>}
|
|
26
51
|
*/
|
|
27
|
-
export async function runUploadArtifact(argv) {
|
|
52
|
+
export async function runUploadArtifact(argv, deps = {}) {
|
|
53
|
+
const env = deps.env ?? process.env;
|
|
54
|
+
const { out, err } = makeIo(deps);
|
|
28
55
|
const flags = parseFlags(argv);
|
|
29
56
|
if (!flags.session || !flags.kind) {
|
|
30
|
-
|
|
31
|
-
|
|
57
|
+
err.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
|
|
58
|
+
err.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
|
|
32
59
|
return 2;
|
|
33
60
|
}
|
|
34
61
|
if (!ALLOWED_KINDS.has(flags.kind)) {
|
|
35
|
-
|
|
62
|
+
err.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
|
|
36
63
|
return 2;
|
|
37
64
|
}
|
|
38
65
|
|
|
39
|
-
const cfg = loadConfig();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
const cfg = loadConfig(env);
|
|
67
|
+
if (cfg.disabled) {
|
|
68
|
+
err.write(`${DISABLED_NOTICE}\n`);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const notice = apiUrlNormalizationNotice(cfg);
|
|
73
|
+
if (notice) err.write(notice);
|
|
74
|
+
|
|
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);
|
|
43
80
|
return 1;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
let content;
|
|
47
84
|
let pathHint = flags.pathHint;
|
|
48
85
|
if (flags.file) {
|
|
86
|
+
let buf;
|
|
49
87
|
try {
|
|
50
|
-
|
|
51
|
-
if (!pathHint) pathHint = basename(flags.file);
|
|
88
|
+
buf = readFileSync(flags.file);
|
|
52
89
|
} catch (e) {
|
|
53
|
-
|
|
90
|
+
err.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
if (buf.byteLength > MAX_CONTENT_BYTES) {
|
|
94
|
+
err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
|
|
54
95
|
return 1;
|
|
55
96
|
}
|
|
97
|
+
if (!isValidUtf8(buf)) {
|
|
98
|
+
err.write(
|
|
99
|
+
`anyslate: ${flags.file} is not valid UTF-8. Binary artifacts are not supported by upload-artifact.\n`,
|
|
100
|
+
);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
content = buf.toString('utf8');
|
|
104
|
+
if (!pathHint) pathHint = basename(flags.file);
|
|
56
105
|
} else {
|
|
57
|
-
|
|
106
|
+
// The 5 MB cap MUST be passed explicitly — readStdin's default is 1 MB and
|
|
107
|
+
// silently truncates.
|
|
108
|
+
try {
|
|
109
|
+
content = await readStdin(deps.stdin ?? process.stdin, {
|
|
110
|
+
maxBytes: MAX_CONTENT_BYTES,
|
|
111
|
+
timeoutMs: stdinTimeoutFromEnv(env, STDIN_IDLE_TIMEOUT_MS),
|
|
112
|
+
});
|
|
113
|
+
} catch (e) {
|
|
114
|
+
err.write(`anyslate upload-artifact: stdin read failed (${e?.message ?? e})\n`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
58
117
|
}
|
|
59
118
|
|
|
60
119
|
if (!content || !content.length) {
|
|
61
|
-
|
|
120
|
+
err.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
|
|
62
121
|
return 1;
|
|
63
122
|
}
|
|
64
123
|
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
|
|
65
|
-
|
|
124
|
+
err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
|
|
66
125
|
return 1;
|
|
67
126
|
}
|
|
68
127
|
|
|
@@ -76,25 +135,76 @@ export async function runUploadArtifact(argv) {
|
|
|
76
135
|
if (pathHint) args.path_hint = pathHint;
|
|
77
136
|
|
|
78
137
|
try {
|
|
79
|
-
const res = await
|
|
80
|
-
|
|
81
|
-
|
|
138
|
+
const res = await callToolWithAuth({
|
|
139
|
+
cfg,
|
|
140
|
+
env,
|
|
82
141
|
toolName: 'upload_artifact',
|
|
83
142
|
args,
|
|
143
|
+
fetchImpl: deps.fetchImpl,
|
|
84
144
|
});
|
|
145
|
+
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
85
146
|
if (!res.ok) {
|
|
86
|
-
const
|
|
87
|
-
|
|
147
|
+
const message =
|
|
148
|
+
formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
|
|
149
|
+
err.write(message);
|
|
150
|
+
recordRun(
|
|
151
|
+
{
|
|
152
|
+
command: 'upload-artifact',
|
|
153
|
+
ok: false,
|
|
154
|
+
apiUrl: cfg.apiUrl,
|
|
155
|
+
status: res.status,
|
|
156
|
+
isError: !!res.isError,
|
|
157
|
+
networkError: !!res.networkError,
|
|
158
|
+
error: message.trim(),
|
|
159
|
+
version: VERSION,
|
|
160
|
+
exitCode: 1,
|
|
161
|
+
},
|
|
162
|
+
env,
|
|
163
|
+
);
|
|
88
164
|
return 1;
|
|
89
165
|
}
|
|
90
|
-
|
|
166
|
+
recordRun({ command: 'upload-artifact', ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 }, env);
|
|
167
|
+
out.write(`${formatUploadArtifactOutput(res.data)}\n`);
|
|
91
168
|
return 0;
|
|
92
169
|
} catch (e) {
|
|
93
|
-
|
|
170
|
+
const message = `anyslate upload-artifact: request failed (${e?.message ?? e})\n`;
|
|
171
|
+
err.write(message);
|
|
172
|
+
recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: message.trim(), version: VERSION, exitCode: 1 }, env);
|
|
94
173
|
return 1;
|
|
95
174
|
}
|
|
96
175
|
}
|
|
97
176
|
|
|
177
|
+
/**
|
|
178
|
+
* @param {unknown} data
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function formatUploadArtifactOutput(data) {
|
|
182
|
+
const uri = extractArtifactUri(data);
|
|
183
|
+
return uri || JSON.stringify(data);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} data
|
|
188
|
+
* @returns {string|null}
|
|
189
|
+
*/
|
|
190
|
+
function extractArtifactUri(data) {
|
|
191
|
+
if (typeof data === 'string') {
|
|
192
|
+
return data.startsWith('cloud://artifact/') ? data : null;
|
|
193
|
+
}
|
|
194
|
+
if (!data || typeof data !== 'object') return null;
|
|
195
|
+
for (const key of ['cloud_uri', 'cloudUri', 'uri', 'artifact_uri', 'artifactUri']) {
|
|
196
|
+
const v = data[key];
|
|
197
|
+
if (typeof v === 'string' && v.startsWith('cloud://artifact/')) return v;
|
|
198
|
+
}
|
|
199
|
+
const artifact = data.artifact;
|
|
200
|
+
if (artifact && typeof artifact === 'object') {
|
|
201
|
+
const nestedUri = extractArtifactUri(artifact);
|
|
202
|
+
if (nestedUri) return nestedUri;
|
|
203
|
+
}
|
|
204
|
+
const id = data.artifact_id || data.artifactId || data.id;
|
|
205
|
+
return typeof id === 'string' && id.length > 0 ? `cloud://artifact/${id}` : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
98
208
|
/** @param {string[]} argv */
|
|
99
209
|
function parseFlags(argv) {
|
|
100
210
|
const out = {};
|
package/src/config.mjs
CHANGED
|
@@ -5,12 +5,28 @@
|
|
|
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
|
//
|
|
11
20
|
// All file reads tolerate missing / unreadable files; the CLI fails open so
|
|
12
21
|
// `anyslate hook session-start` never breaks a Claude Code session because of
|
|
13
|
-
// a missing config
|
|
22
|
+
// a missing config - it just no-ops with a warning to stderr.
|
|
23
|
+
//
|
|
24
|
+
// URL normalization (W1): the CLI wants the service ROOT and appends `/mcp`
|
|
25
|
+
// itself. A config whose apiUrl already ends in `/mcp` produced `/mcp/mcp` and
|
|
26
|
+
// a permanent 404 that masked every token verdict (the 404 fires at
|
|
27
|
+
// app.notFound() BEFORE auth middleware). We therefore normalize on the READ
|
|
28
|
+
// path, not only in `login`, so already-broken configs self-heal without a
|
|
29
|
+
// re-login.
|
|
14
30
|
|
|
15
31
|
import { readFileSync } from 'node:fs';
|
|
16
32
|
import { homedir } from 'node:os';
|
|
@@ -18,22 +34,89 @@ import { join } from 'node:path';
|
|
|
18
34
|
|
|
19
35
|
export const DEFAULT_API_URL = 'https://mcp.anyslate.io';
|
|
20
36
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(
|
|
24
|
-
|
|
37
|
+
/** Where the desktop app / CLI keep their state. `ANYSLATE_HOME` overrides. */
|
|
38
|
+
export function anyslateDir(env = process.env) {
|
|
39
|
+
if (env.ANYSLATE_HOME && env.ANYSLATE_HOME.trim()) return env.ANYSLATE_HOME.trim();
|
|
40
|
+
return join(homedir(), '.anyslate');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function configPaths(env = process.env) {
|
|
44
|
+
const dir = anyslateDir(env);
|
|
45
|
+
return [join(dir, 'cli.json'), join(dir, 'session.json')];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Strip trailing slashes AND every trailing `/mcp` segment.
|
|
50
|
+
*
|
|
51
|
+
* `(\/mcp)+$` — not `\/mcp$`. The single-segment form leaves an already
|
|
52
|
+
* double-suffixed `…/mcp/mcp` broken (defect #45).
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} u
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeApiRoot(u) {
|
|
58
|
+
return String(u ?? '')
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(/\/+$/, '')
|
|
61
|
+
.replace(/(\/mcp)+$/i, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The dedicated capture kill switch (Q5). Deliberately NOT implemented by
|
|
66
|
+
* changing the falsy-coalescing of ANYSLATE_MCP_TOKEN — an empty string there
|
|
67
|
+
* still falls through to the file token, and scripts that clear variables by
|
|
68
|
+
* emptying them must keep working.
|
|
69
|
+
*
|
|
70
|
+
* @param {NodeJS.ProcessEnv} env
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function isCaptureDisabled(env = process.env) {
|
|
74
|
+
const v = env.ANYSLATE_DISABLE;
|
|
75
|
+
if (v === undefined || v === null) return false;
|
|
76
|
+
const s = String(v).trim().toLowerCase();
|
|
77
|
+
if (s === '' || s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const DISABLED_NOTICE =
|
|
82
|
+
'anyslate: ANYSLATE_DISABLE is set — capture disabled for this shell (no network call made).';
|
|
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
|
+
}
|
|
25
100
|
|
|
26
101
|
/**
|
|
27
102
|
* @param {NodeJS.ProcessEnv} env
|
|
28
103
|
* @param {() => string[]} [paths] override for tests
|
|
29
|
-
* @returns {{
|
|
104
|
+
* @returns {{
|
|
105
|
+
* apiUrl: string, apiUrlRaw: string, apiUrlNormalized: boolean,
|
|
106
|
+
* mcpToken: string|null, handle: string|null,
|
|
107
|
+
* oauth: object|null, staticToken: string|null, authMode: 'env'|'oauth'|'static'|'none',
|
|
108
|
+
* oauthClients: Record<string, string>,
|
|
109
|
+
* source: string, sources: {apiUrl: string, mcpToken: string, handle: string},
|
|
110
|
+
* disabled: boolean
|
|
111
|
+
* }}
|
|
30
112
|
*/
|
|
31
113
|
export function loadConfig(env = process.env, paths) {
|
|
32
|
-
const candidates = paths ? paths() :
|
|
114
|
+
const candidates = paths ? paths() : configPaths(env);
|
|
33
115
|
|
|
34
|
-
/** @type {{apiUrl?: string, mcp_token?: string, handle?: string}} */
|
|
116
|
+
/** @type {{apiUrl?: string, api_url?: string, mcp_token?: string, token?: string, handle?: string}} */
|
|
35
117
|
let fileConfig = {};
|
|
36
118
|
let source = 'env';
|
|
119
|
+
let filePath = null;
|
|
37
120
|
for (const p of candidates) {
|
|
38
121
|
try {
|
|
39
122
|
const raw = readFileSync(p, 'utf8');
|
|
@@ -41,18 +124,84 @@ export function loadConfig(env = process.env, paths) {
|
|
|
41
124
|
if (parsed && typeof parsed === 'object') {
|
|
42
125
|
fileConfig = parsed;
|
|
43
126
|
source = p;
|
|
127
|
+
filePath = p;
|
|
44
128
|
break;
|
|
45
129
|
}
|
|
46
130
|
} catch {
|
|
47
|
-
// ignore
|
|
131
|
+
// ignore - fall through to next candidate
|
|
48
132
|
}
|
|
49
133
|
}
|
|
50
134
|
|
|
51
|
-
|
|
52
|
-
|
|
135
|
+
// Per-key provenance. `source` alone is misleading: it names the file that
|
|
136
|
+
// parsed, while an env var may still be winning for an individual key
|
|
137
|
+
// (defect #38 / W4 check #1).
|
|
138
|
+
const layerOf = (envValue, fileValue) => {
|
|
139
|
+
if (envValue) return 'env';
|
|
140
|
+
if (fileValue) return filePath ?? 'default';
|
|
141
|
+
return 'default';
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const apiUrlRaw = String(
|
|
145
|
+
env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL,
|
|
146
|
+
).trim();
|
|
147
|
+
const apiUrl = normalizeApiRoot(apiUrlRaw);
|
|
148
|
+
const apiUrlNormalized = apiUrl !== apiUrlRaw.replace(/\/+$/, '');
|
|
149
|
+
|
|
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;
|
|
53
157
|
const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
|
|
54
158
|
|
|
55
|
-
|
|
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
|
+
|
|
170
|
+
return {
|
|
171
|
+
apiUrl,
|
|
172
|
+
apiUrlRaw,
|
|
173
|
+
apiUrlNormalized,
|
|
174
|
+
mcpToken,
|
|
175
|
+
handle,
|
|
176
|
+
oauth,
|
|
177
|
+
oauthClients,
|
|
178
|
+
staticToken,
|
|
179
|
+
authMode,
|
|
180
|
+
source,
|
|
181
|
+
sources: {
|
|
182
|
+
apiUrl: layerOf(env.ANYSLATE_API_URL, fileConfig.apiUrl || fileConfig.api_url),
|
|
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),
|
|
186
|
+
handle: layerOf(env.ANYSLATE_HANDLE, fileConfig.handle),
|
|
187
|
+
},
|
|
188
|
+
disabled: isCaptureDisabled(env),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Exact user-facing notice for a rewritten apiUrl. Silent correction teaches
|
|
194
|
+
* the user nothing, so every command that resolves a rewritten URL says so.
|
|
195
|
+
*
|
|
196
|
+
* @param {{apiUrlRaw: string, apiUrl: string, apiUrlNormalized: boolean}} cfg
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
export function apiUrlNormalizationNotice(cfg) {
|
|
200
|
+
if (!cfg?.apiUrlNormalized) return null;
|
|
201
|
+
return (
|
|
202
|
+
`anyslate: apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
203
|
+
`anyslate: using "${cfg.apiUrl}". Run \`anyslate login --api-url ${cfg.apiUrl}\` to persist.\n`
|
|
204
|
+
);
|
|
56
205
|
}
|
|
57
206
|
|
|
58
207
|
/**
|
|
@@ -64,7 +213,7 @@ export function requireToken(cfg) {
|
|
|
64
213
|
return {
|
|
65
214
|
ok: false,
|
|
66
215
|
error:
|
|
67
|
-
'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.',
|
|
68
217
|
};
|
|
69
218
|
}
|
|
70
219
|
return { ok: true };
|