@anyslate/cli 0.1.0 → 0.2.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 +229 -19
- package/package.json +6 -6
- package/src/commands/checkpoint.mjs +53 -13
- package/src/commands/doctor.mjs +523 -0
- package/src/commands/hook.mjs +70 -19
- package/src/commands/login.mjs +106 -14
- package/src/commands/upload-artifact.mjs +127 -21
- package/src/config.mjs +110 -12
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +39 -5
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- 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
package/src/commands/login.mjs
CHANGED
|
@@ -1,30 +1,58 @@
|
|
|
1
|
-
// `anyslate login`
|
|
1
|
+
// `anyslate login` - validate credentials, then write `~/.anyslate/cli.json`.
|
|
2
2
|
//
|
|
3
|
-
// Mint the token in the desktop app at
|
|
4
|
-
//
|
|
3
|
+
// Mint the token in the desktop app at Avatar (top-right) → API Tokens →
|
|
4
|
+
// Create Token (available on every current tier). Then:
|
|
5
5
|
//
|
|
6
6
|
// anyslate login --token <BEARER>
|
|
7
7
|
// anyslate login --token <BEARER> --handle <HANDLE_ID>
|
|
8
8
|
// anyslate login --token <BEARER> --api-url https://anyslate-mcp-service-development.<workers-dev-url>
|
|
9
9
|
//
|
|
10
|
-
// The file is written with mode 0600
|
|
10
|
+
// The file is written with mode 0600 - only the current user can read it.
|
|
11
|
+
//
|
|
12
|
+
// W3: `login` used to perform ZERO network I/O — its imports were three
|
|
13
|
+
// node: builtins. It accepted a token that is not a token and a hostname that
|
|
14
|
+
// cannot resolve, printing success and exiting 0 for both. It now:
|
|
15
|
+
// 1. checks token format,
|
|
16
|
+
// 2. resolves URL shape (MUST precede any token verdict — a wrong URL 404s
|
|
17
|
+
// before auth middleware and masks the real answer),
|
|
18
|
+
// 3. runs one live GET {root}/mcp/auth/verify,
|
|
19
|
+
// 4. warns on a missing memory:write scope,
|
|
20
|
+
// 5. writes the file ONLY on success.
|
|
21
|
+
// `--force` writes anyway; `--no-verify` skips the probe (air-gapped setup).
|
|
11
22
|
|
|
12
23
|
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
24
|
import { join } from 'node:path';
|
|
25
|
+
import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
|
|
26
|
+
import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
|
|
27
|
+
import { makeIo } from '../io.mjs';
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
30
|
* @param {string[]} argv arguments after `login`
|
|
31
|
+
* @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv}} [deps]
|
|
18
32
|
* @returns {Promise<number>}
|
|
19
33
|
*/
|
|
20
|
-
export async function runLogin(argv) {
|
|
34
|
+
export async function runLogin(argv, deps = {}) {
|
|
35
|
+
const env = deps.env ?? process.env;
|
|
36
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
37
|
+
const { out, err } = makeIo(deps);
|
|
21
38
|
const flags = parseFlags(argv);
|
|
39
|
+
|
|
22
40
|
if (!flags.token) {
|
|
23
|
-
|
|
41
|
+
err.write(
|
|
42
|
+
'usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]\n',
|
|
43
|
+
);
|
|
24
44
|
return 2;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
if (isCaptureDisabled(env)) {
|
|
48
|
+
// login is setup, not capture — it still runs, but say so, because the
|
|
49
|
+
// shell it was run in will capture nothing afterwards.
|
|
50
|
+
err.write(
|
|
51
|
+
'anyslate: note — ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dir = anyslateDir(env);
|
|
28
56
|
const path = join(dir, 'cli.json');
|
|
29
57
|
let existing = {};
|
|
30
58
|
try {
|
|
@@ -33,35 +61,99 @@ export async function runLogin(argv) {
|
|
|
33
61
|
existing = {};
|
|
34
62
|
}
|
|
35
63
|
|
|
64
|
+
// --- 1. Token format ----------------------------------------------------
|
|
65
|
+
if (!isValidTokenFormat(flags.token)) {
|
|
66
|
+
const msg =
|
|
67
|
+
`anyslate: that doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(flags.token)}").\n` +
|
|
68
|
+
'anyslate: mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.\n';
|
|
69
|
+
if (!flags.force) {
|
|
70
|
+
err.write(msg);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
err.write(msg);
|
|
74
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- 2. URL shape (MUST precede any token verdict) ----------------------
|
|
78
|
+
// Preserve the merge semantics — `login --token <new>` alone keeps the
|
|
79
|
+
// stored apiUrl — but re-normalize the merged value so a previously-bad
|
|
80
|
+
// apiUrl cannot survive a re-login.
|
|
81
|
+
const mergedUrl = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
|
|
82
|
+
const shape = checkUrlShape(mergedUrl);
|
|
83
|
+
if (!shape.ok) {
|
|
84
|
+
err.write(`${shape.message}\n`);
|
|
85
|
+
if (!flags.force) return 1;
|
|
86
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
87
|
+
}
|
|
88
|
+
const root = shape.ok ? shape.root : normalizeApiRoot(mergedUrl);
|
|
89
|
+
|
|
90
|
+
if (shape.ok && shape.normalized) {
|
|
91
|
+
out.write(
|
|
92
|
+
`anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
93
|
+
`anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- 3. Live probe ------------------------------------------------------
|
|
98
|
+
let verified = null;
|
|
99
|
+
if (flags.noVerify) {
|
|
100
|
+
err.write('anyslate: --no-verify given — skipping the live connection check.\n');
|
|
101
|
+
} else {
|
|
102
|
+
verified = await probeVerify({ root, token: flags.token, fetchImpl });
|
|
103
|
+
if (!verified.ok) {
|
|
104
|
+
err.write(`${verified.message}\n`);
|
|
105
|
+
if (!flags.force) {
|
|
106
|
+
err.write(
|
|
107
|
+
'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',
|
|
108
|
+
);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
err.write('anyslate: --force given — writing anyway.\n');
|
|
112
|
+
} else {
|
|
113
|
+
out.write(`${verified.message}\n`);
|
|
114
|
+
// --- 4. Scope check (warn, never block) -----------------------------
|
|
115
|
+
const warning = scopeWarning(verified.scopes);
|
|
116
|
+
if (warning) err.write(`${warning}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- 5. Write only on success (or --force / --no-verify) ----------------
|
|
36
121
|
const next = {
|
|
37
122
|
...existing,
|
|
38
123
|
mcp_token: flags.token,
|
|
39
124
|
handle: flags.handle ?? existing.handle ?? null,
|
|
40
|
-
apiUrl:
|
|
125
|
+
apiUrl: root,
|
|
41
126
|
};
|
|
127
|
+
delete next.api_url; // collapse the legacy alias so only one key can drift
|
|
42
128
|
|
|
43
129
|
try {
|
|
44
130
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
131
|
writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
46
132
|
} catch (e) {
|
|
47
|
-
|
|
133
|
+
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
48
134
|
return 1;
|
|
49
135
|
}
|
|
50
136
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
137
|
+
out.write(`anyslate: wrote ${path}\n`);
|
|
138
|
+
out.write(` apiUrl: ${next.apiUrl}\n`);
|
|
139
|
+
out.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
|
|
140
|
+
if (verified?.ok && verified.defaultHandleId) {
|
|
141
|
+
out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
|
|
142
|
+
}
|
|
143
|
+
out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
|
|
54
144
|
return 0;
|
|
55
145
|
}
|
|
56
146
|
|
|
57
147
|
/** @param {string[]} argv */
|
|
58
148
|
function parseFlags(argv) {
|
|
59
|
-
const out = {};
|
|
149
|
+
const out = { force: false, noVerify: false };
|
|
60
150
|
for (let i = 0; i < argv.length; i += 1) {
|
|
61
151
|
const a = argv[i];
|
|
62
152
|
if (a === '--token' && argv[i + 1]) out.token = argv[++i];
|
|
63
153
|
else if (a === '--handle' && argv[i + 1]) out.handle = argv[++i];
|
|
64
154
|
else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
|
|
155
|
+
else if (a === '--force') out.force = true;
|
|
156
|
+
else if (a === '--no-verify' || a === '--skip-verify') out.noVerify = true;
|
|
65
157
|
}
|
|
66
158
|
return out;
|
|
67
159
|
}
|
|
@@ -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,120 @@
|
|
|
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 { callTool } from '../mcp-client.mjs';
|
|
14
|
-
import { readStdin } from '../stdin.mjs';
|
|
18
|
+
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
19
|
+
import { callTool, formatCallFailure } from '../mcp-client.mjs';
|
|
20
|
+
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
21
|
+
import { recordRun } from '../runlog.mjs';
|
|
22
|
+
import { VERSION } from '../version.mjs';
|
|
23
|
+
import { makeIo } from '../io.mjs';
|
|
15
24
|
|
|
16
25
|
const ALLOWED_KINDS = new Set([
|
|
17
26
|
'code_block', 'file_path', 'error_message', 'shell_command',
|
|
18
27
|
'config_snippet', 'url_reference', 'fenced_quote',
|
|
19
28
|
]);
|
|
20
29
|
|
|
21
|
-
const MAX_CONTENT_BYTES = 5_000_000;
|
|
30
|
+
export const MAX_CONTENT_BYTES = 5_000_000;
|
|
31
|
+
|
|
32
|
+
/** Uploading over a pipe may legitimately be slow; give stdin more idle room. */
|
|
33
|
+
const STDIN_IDLE_TIMEOUT_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Buffer round-trips through utf8 iff it *is* valid utf8. Node replaces
|
|
37
|
+
* invalid sequences with U+FFFD, so re-encoding produces different bytes.
|
|
38
|
+
*
|
|
39
|
+
* @param {Buffer} buf
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isValidUtf8(buf) {
|
|
43
|
+
return Buffer.compare(Buffer.from(buf.toString('utf8'), 'utf8'), buf) === 0;
|
|
44
|
+
}
|
|
22
45
|
|
|
23
46
|
/**
|
|
24
47
|
* @param {string[]} argv arguments after `upload-artifact`
|
|
48
|
+
* @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, stdin?: NodeJS.ReadableStream}} [deps]
|
|
25
49
|
* @returns {Promise<number>}
|
|
26
50
|
*/
|
|
27
|
-
export async function runUploadArtifact(argv) {
|
|
51
|
+
export async function runUploadArtifact(argv, deps = {}) {
|
|
52
|
+
const env = deps.env ?? process.env;
|
|
53
|
+
const { out, err } = makeIo(deps);
|
|
28
54
|
const flags = parseFlags(argv);
|
|
29
55
|
if (!flags.session || !flags.kind) {
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
err.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
|
|
57
|
+
err.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
|
|
32
58
|
return 2;
|
|
33
59
|
}
|
|
34
60
|
if (!ALLOWED_KINDS.has(flags.kind)) {
|
|
35
|
-
|
|
61
|
+
err.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
|
|
36
62
|
return 2;
|
|
37
63
|
}
|
|
38
64
|
|
|
39
|
-
const cfg = loadConfig();
|
|
65
|
+
const cfg = loadConfig(env);
|
|
66
|
+
if (cfg.disabled) {
|
|
67
|
+
err.write(`${DISABLED_NOTICE}\n`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const notice = apiUrlNormalizationNotice(cfg);
|
|
72
|
+
if (notice) err.write(notice);
|
|
73
|
+
|
|
40
74
|
const tokenCheck = requireToken(cfg);
|
|
41
75
|
if (!tokenCheck.ok) {
|
|
42
|
-
|
|
76
|
+
err.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
|
|
77
|
+
recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: tokenCheck.error, version: VERSION, exitCode: 1 }, env);
|
|
43
78
|
return 1;
|
|
44
79
|
}
|
|
45
80
|
|
|
46
81
|
let content;
|
|
47
82
|
let pathHint = flags.pathHint;
|
|
48
83
|
if (flags.file) {
|
|
84
|
+
let buf;
|
|
49
85
|
try {
|
|
50
|
-
|
|
51
|
-
if (!pathHint) pathHint = basename(flags.file);
|
|
86
|
+
buf = readFileSync(flags.file);
|
|
52
87
|
} catch (e) {
|
|
53
|
-
|
|
88
|
+
err.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (buf.byteLength > MAX_CONTENT_BYTES) {
|
|
92
|
+
err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
|
|
54
93
|
return 1;
|
|
55
94
|
}
|
|
95
|
+
if (!isValidUtf8(buf)) {
|
|
96
|
+
err.write(
|
|
97
|
+
`anyslate: ${flags.file} is not valid UTF-8. Binary artifacts are not supported by upload-artifact.\n`,
|
|
98
|
+
);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
content = buf.toString('utf8');
|
|
102
|
+
if (!pathHint) pathHint = basename(flags.file);
|
|
56
103
|
} else {
|
|
57
|
-
|
|
104
|
+
// The 5 MB cap MUST be passed explicitly — readStdin's default is 1 MB and
|
|
105
|
+
// silently truncates.
|
|
106
|
+
try {
|
|
107
|
+
content = await readStdin(deps.stdin ?? process.stdin, {
|
|
108
|
+
maxBytes: MAX_CONTENT_BYTES,
|
|
109
|
+
timeoutMs: stdinTimeoutFromEnv(env, STDIN_IDLE_TIMEOUT_MS),
|
|
110
|
+
});
|
|
111
|
+
} catch (e) {
|
|
112
|
+
err.write(`anyslate upload-artifact: stdin read failed (${e?.message ?? e})\n`);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
58
115
|
}
|
|
59
116
|
|
|
60
117
|
if (!content || !content.length) {
|
|
61
|
-
|
|
118
|
+
err.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
|
|
62
119
|
return 1;
|
|
63
120
|
}
|
|
64
121
|
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
|
|
65
|
-
|
|
122
|
+
err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
|
|
66
123
|
return 1;
|
|
67
124
|
}
|
|
68
125
|
|
|
@@ -81,20 +138,69 @@ export async function runUploadArtifact(argv) {
|
|
|
81
138
|
token: cfg.mcpToken,
|
|
82
139
|
toolName: 'upload_artifact',
|
|
83
140
|
args,
|
|
141
|
+
fetchImpl: deps.fetchImpl,
|
|
84
142
|
});
|
|
85
143
|
if (!res.ok) {
|
|
86
|
-
const
|
|
87
|
-
|
|
144
|
+
const message = formatCallFailure('anyslate upload-artifact', res);
|
|
145
|
+
err.write(message);
|
|
146
|
+
recordRun(
|
|
147
|
+
{
|
|
148
|
+
command: 'upload-artifact',
|
|
149
|
+
ok: false,
|
|
150
|
+
apiUrl: cfg.apiUrl,
|
|
151
|
+
status: res.status,
|
|
152
|
+
isError: !!res.isError,
|
|
153
|
+
networkError: !!res.networkError,
|
|
154
|
+
error: message.trim(),
|
|
155
|
+
version: VERSION,
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
},
|
|
158
|
+
env,
|
|
159
|
+
);
|
|
88
160
|
return 1;
|
|
89
161
|
}
|
|
90
|
-
|
|
162
|
+
recordRun({ command: 'upload-artifact', ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 }, env);
|
|
163
|
+
out.write(`${formatUploadArtifactOutput(res.data)}\n`);
|
|
91
164
|
return 0;
|
|
92
165
|
} catch (e) {
|
|
93
|
-
|
|
166
|
+
const message = `anyslate upload-artifact: request failed (${e?.message ?? e})\n`;
|
|
167
|
+
err.write(message);
|
|
168
|
+
recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: message.trim(), version: VERSION, exitCode: 1 }, env);
|
|
94
169
|
return 1;
|
|
95
170
|
}
|
|
96
171
|
}
|
|
97
172
|
|
|
173
|
+
/**
|
|
174
|
+
* @param {unknown} data
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function formatUploadArtifactOutput(data) {
|
|
178
|
+
const uri = extractArtifactUri(data);
|
|
179
|
+
return uri || JSON.stringify(data);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {unknown} data
|
|
184
|
+
* @returns {string|null}
|
|
185
|
+
*/
|
|
186
|
+
function extractArtifactUri(data) {
|
|
187
|
+
if (typeof data === 'string') {
|
|
188
|
+
return data.startsWith('cloud://artifact/') ? data : null;
|
|
189
|
+
}
|
|
190
|
+
if (!data || typeof data !== 'object') return null;
|
|
191
|
+
for (const key of ['cloud_uri', 'cloudUri', 'uri', 'artifact_uri', 'artifactUri']) {
|
|
192
|
+
const v = data[key];
|
|
193
|
+
if (typeof v === 'string' && v.startsWith('cloud://artifact/')) return v;
|
|
194
|
+
}
|
|
195
|
+
const artifact = data.artifact;
|
|
196
|
+
if (artifact && typeof artifact === 'object') {
|
|
197
|
+
const nestedUri = extractArtifactUri(artifact);
|
|
198
|
+
if (nestedUri) return nestedUri;
|
|
199
|
+
}
|
|
200
|
+
const id = data.artifact_id || data.artifactId || data.id;
|
|
201
|
+
return typeof id === 'string' && id.length > 0 ? `cloud://artifact/${id}` : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
98
204
|
/** @param {string[]} argv */
|
|
99
205
|
function parseFlags(argv) {
|
|
100
206
|
const out = {};
|
package/src/config.mjs
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// All file reads tolerate missing / unreadable files; the CLI fails open so
|
|
12
12
|
// `anyslate hook session-start` never breaks a Claude Code session because of
|
|
13
|
-
// a missing config
|
|
13
|
+
// a missing config - it just no-ops with a warning to stderr.
|
|
14
|
+
//
|
|
15
|
+
// URL normalization (W1): the CLI wants the service ROOT and appends `/mcp`
|
|
16
|
+
// itself. A config whose apiUrl already ends in `/mcp` produced `/mcp/mcp` and
|
|
17
|
+
// a permanent 404 that masked every token verdict (the 404 fires at
|
|
18
|
+
// app.notFound() BEFORE auth middleware). We therefore normalize on the READ
|
|
19
|
+
// path, not only in `login`, so already-broken configs self-heal without a
|
|
20
|
+
// re-login.
|
|
14
21
|
|
|
15
22
|
import { readFileSync } from 'node:fs';
|
|
16
23
|
import { homedir } from 'node:os';
|
|
@@ -18,22 +25,70 @@ import { join } from 'node:path';
|
|
|
18
25
|
|
|
19
26
|
export const DEFAULT_API_URL = 'https://mcp.anyslate.io';
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(
|
|
24
|
-
|
|
28
|
+
/** Where the desktop app / CLI keep their state. `ANYSLATE_HOME` overrides. */
|
|
29
|
+
export function anyslateDir(env = process.env) {
|
|
30
|
+
if (env.ANYSLATE_HOME && env.ANYSLATE_HOME.trim()) return env.ANYSLATE_HOME.trim();
|
|
31
|
+
return join(homedir(), '.anyslate');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function configPaths(env = process.env) {
|
|
35
|
+
const dir = anyslateDir(env);
|
|
36
|
+
return [join(dir, 'cli.json'), join(dir, 'session.json')];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip trailing slashes AND every trailing `/mcp` segment.
|
|
41
|
+
*
|
|
42
|
+
* `(\/mcp)+$` — not `\/mcp$`. The single-segment form leaves an already
|
|
43
|
+
* double-suffixed `…/mcp/mcp` broken (defect #45).
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} u
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeApiRoot(u) {
|
|
49
|
+
return String(u ?? '')
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/\/+$/, '')
|
|
52
|
+
.replace(/(\/mcp)+$/i, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The dedicated capture kill switch (Q5). Deliberately NOT implemented by
|
|
57
|
+
* changing the falsy-coalescing of ANYSLATE_MCP_TOKEN — an empty string there
|
|
58
|
+
* still falls through to the file token, and scripts that clear variables by
|
|
59
|
+
* emptying them must keep working.
|
|
60
|
+
*
|
|
61
|
+
* @param {NodeJS.ProcessEnv} env
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isCaptureDisabled(env = process.env) {
|
|
65
|
+
const v = env.ANYSLATE_DISABLE;
|
|
66
|
+
if (v === undefined || v === null) return false;
|
|
67
|
+
const s = String(v).trim().toLowerCase();
|
|
68
|
+
if (s === '' || s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const DISABLED_NOTICE =
|
|
73
|
+
'anyslate: ANYSLATE_DISABLE is set — capture disabled for this shell (no network call made).';
|
|
25
74
|
|
|
26
75
|
/**
|
|
27
76
|
* @param {NodeJS.ProcessEnv} env
|
|
28
77
|
* @param {() => string[]} [paths] override for tests
|
|
29
|
-
* @returns {{
|
|
78
|
+
* @returns {{
|
|
79
|
+
* apiUrl: string, apiUrlRaw: string, apiUrlNormalized: boolean,
|
|
80
|
+
* mcpToken: string|null, handle: string|null,
|
|
81
|
+
* source: string, sources: {apiUrl: string, mcpToken: string, handle: string},
|
|
82
|
+
* disabled: boolean
|
|
83
|
+
* }}
|
|
30
84
|
*/
|
|
31
85
|
export function loadConfig(env = process.env, paths) {
|
|
32
|
-
const candidates = paths ? paths() :
|
|
86
|
+
const candidates = paths ? paths() : configPaths(env);
|
|
33
87
|
|
|
34
|
-
/** @type {{apiUrl?: string, mcp_token?: string, handle?: string}} */
|
|
88
|
+
/** @type {{apiUrl?: string, api_url?: string, mcp_token?: string, token?: string, handle?: string}} */
|
|
35
89
|
let fileConfig = {};
|
|
36
90
|
let source = 'env';
|
|
91
|
+
let filePath = null;
|
|
37
92
|
for (const p of candidates) {
|
|
38
93
|
try {
|
|
39
94
|
const raw = readFileSync(p, 'utf8');
|
|
@@ -41,18 +96,61 @@ export function loadConfig(env = process.env, paths) {
|
|
|
41
96
|
if (parsed && typeof parsed === 'object') {
|
|
42
97
|
fileConfig = parsed;
|
|
43
98
|
source = p;
|
|
99
|
+
filePath = p;
|
|
44
100
|
break;
|
|
45
101
|
}
|
|
46
102
|
} catch {
|
|
47
|
-
// ignore
|
|
103
|
+
// ignore - fall through to next candidate
|
|
48
104
|
}
|
|
49
105
|
}
|
|
50
106
|
|
|
51
|
-
|
|
107
|
+
// Per-key provenance. `source` alone is misleading: it names the file that
|
|
108
|
+
// parsed, while an env var may still be winning for an individual key
|
|
109
|
+
// (defect #38 / W4 check #1).
|
|
110
|
+
const layerOf = (envValue, fileValue) => {
|
|
111
|
+
if (envValue) return 'env';
|
|
112
|
+
if (fileValue) return filePath ?? 'default';
|
|
113
|
+
return 'default';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const apiUrlRaw = String(
|
|
117
|
+
env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL,
|
|
118
|
+
).trim();
|
|
119
|
+
const apiUrl = normalizeApiRoot(apiUrlRaw);
|
|
120
|
+
const apiUrlNormalized = apiUrl !== apiUrlRaw.replace(/\/+$/, '');
|
|
121
|
+
|
|
52
122
|
const mcpToken = env.ANYSLATE_MCP_TOKEN || fileConfig.mcp_token || fileConfig.token || null;
|
|
53
123
|
const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
|
|
54
124
|
|
|
55
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
apiUrl,
|
|
127
|
+
apiUrlRaw,
|
|
128
|
+
apiUrlNormalized,
|
|
129
|
+
mcpToken,
|
|
130
|
+
handle,
|
|
131
|
+
source,
|
|
132
|
+
sources: {
|
|
133
|
+
apiUrl: layerOf(env.ANYSLATE_API_URL, fileConfig.apiUrl || fileConfig.api_url),
|
|
134
|
+
mcpToken: layerOf(env.ANYSLATE_MCP_TOKEN, fileConfig.mcp_token || fileConfig.token),
|
|
135
|
+
handle: layerOf(env.ANYSLATE_HANDLE, fileConfig.handle),
|
|
136
|
+
},
|
|
137
|
+
disabled: isCaptureDisabled(env),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Exact user-facing notice for a rewritten apiUrl. Silent correction teaches
|
|
143
|
+
* the user nothing, so every command that resolves a rewritten URL says so.
|
|
144
|
+
*
|
|
145
|
+
* @param {{apiUrlRaw: string, apiUrl: string, apiUrlNormalized: boolean}} cfg
|
|
146
|
+
* @returns {string|null}
|
|
147
|
+
*/
|
|
148
|
+
export function apiUrlNormalizationNotice(cfg) {
|
|
149
|
+
if (!cfg?.apiUrlNormalized) return null;
|
|
150
|
+
return (
|
|
151
|
+
`anyslate: apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
152
|
+
`anyslate: using "${cfg.apiUrl}". Run \`anyslate login --api-url ${cfg.apiUrl}\` to persist.\n`
|
|
153
|
+
);
|
|
56
154
|
}
|
|
57
155
|
|
|
58
156
|
/**
|
|
@@ -64,7 +162,7 @@ export function requireToken(cfg) {
|
|
|
64
162
|
return {
|
|
65
163
|
ok: false,
|
|
66
164
|
error:
|
|
67
|
-
'no MCP token configured. Run `anyslate login --token <BEARER>` or set ANYSLATE_MCP_TOKEN. Mint a token
|
|
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.',
|
|
68
166
|
};
|
|
69
167
|
}
|
|
70
168
|
return { ok: true };
|