@beunec/rivine 0.1.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 +423 -0
- package/action.yml +165 -0
- package/dist/api/workspace.js +152 -0
- package/dist/auth/device.js +73 -0
- package/dist/auth/keychain.js +88 -0
- package/dist/auth/pkce.js +142 -0
- package/dist/auth/session.js +101 -0
- package/dist/config.js +37 -0
- package/dist/index.js +300 -0
- package/dist/mcp/config.js +73 -0
- package/dist/mcp/manager.js +154 -0
- package/dist/mcp/oauth.js +234 -0
- package/dist/mcp/relevance.js +74 -0
- package/dist/protocol/edit.js +247 -0
- package/dist/protocol/fsExecutor.js +471 -0
- package/dist/protocol/git.js +157 -0
- package/dist/protocol/permissions.js +210 -0
- package/dist/protocol/runner.js +325 -0
- package/dist/protocol/sessionKey.js +57 -0
- package/dist/protocol/shell.js +260 -0
- package/dist/protocol/snapshot.js +84 -0
- package/dist/protocol/stream.js +41 -0
- package/dist/runHeadless.js +140 -0
- package/dist/session.js +57 -0
- package/dist/ui/App.js +444 -0
- package/dist/ui/components.js +92 -0
- package/dist/ui/headless.js +130 -0
- package/dist/ui/render.js +109 -0
- package/dist/ui/slashCommands.js +57 -0
- package/dist/ui/theme.js +82 -0
- package/package.json +45 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { openBrowser } from '../auth/pkce.js';
|
|
14
|
+
import { loadSession, mcpAccount, saveSession } from '../auth/keychain.js';
|
|
15
|
+
const PORTS = [1455, 1456, 1457, 1458, 1459, 1460];
|
|
16
|
+
const FLOW_TIMEOUT_MS = 5 * 60_000;
|
|
17
|
+
const SAFE_SCOPES = new Set([
|
|
18
|
+
'openid',
|
|
19
|
+
'profile',
|
|
20
|
+
'email',
|
|
21
|
+
'offline_access',
|
|
22
|
+
'mcp',
|
|
23
|
+
'mcp:read',
|
|
24
|
+
'mcp:tools',
|
|
25
|
+
'tools',
|
|
26
|
+
'tools:read',
|
|
27
|
+
'read',
|
|
28
|
+
]);
|
|
29
|
+
const RENEW_MARGIN_MS = 60_000;
|
|
30
|
+
async function freePort() {
|
|
31
|
+
for (const candidate of PORTS) {
|
|
32
|
+
const free = await new Promise((resolve) => {
|
|
33
|
+
const probe = createServer();
|
|
34
|
+
probe.once('error', () => resolve(false));
|
|
35
|
+
probe.once('listening', () => probe.close(() => resolve(true)));
|
|
36
|
+
probe.listen(candidate, '127.0.0.1');
|
|
37
|
+
});
|
|
38
|
+
if (free)
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Ports ${PORTS[0]}-${PORTS[PORTS.length - 1]} are all in use. Close another sign-in and retry.`);
|
|
42
|
+
}
|
|
43
|
+
function base64url(buf) {
|
|
44
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
45
|
+
}
|
|
46
|
+
async function discover(serverUrl) {
|
|
47
|
+
let origin;
|
|
48
|
+
try {
|
|
49
|
+
origin = new URL(serverUrl).origin;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const candidates = [
|
|
55
|
+
`${origin}/.well-known/oauth-authorization-server`,
|
|
56
|
+
`${origin}/.well-known/openid-configuration`,
|
|
57
|
+
];
|
|
58
|
+
for (const url of candidates) {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
continue;
|
|
63
|
+
const meta = (await res.json());
|
|
64
|
+
if (meta?.authorization_endpoint && meta?.token_endpoint)
|
|
65
|
+
return meta;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
async function register(meta, redirectUri) {
|
|
73
|
+
if (!meta.registration_endpoint)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(meta.registration_endpoint, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
client_name: 'Rivine Code CLI',
|
|
81
|
+
redirect_uris: [redirectUri],
|
|
82
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
83
|
+
response_types: ['code'],
|
|
84
|
+
token_endpoint_auth_method: 'none',
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
return null;
|
|
89
|
+
const body = (await res.json());
|
|
90
|
+
return body.client_id ?? null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function escapeHtml(text) {
|
|
97
|
+
return text
|
|
98
|
+
.replace(/&/g, '&')
|
|
99
|
+
.replace(/</g, '<')
|
|
100
|
+
.replace(/>/g, '>')
|
|
101
|
+
.replace(/"/g, '"')
|
|
102
|
+
.replace(/'/g, ''');
|
|
103
|
+
}
|
|
104
|
+
const PAGE = (rawTitle, rawDetail) => {
|
|
105
|
+
const title = escapeHtml(rawTitle);
|
|
106
|
+
const detail = escapeHtml(rawDetail);
|
|
107
|
+
return `<!doctype html>
|
|
108
|
+
<html><head><meta charset="utf-8"><title>${title}</title></head>
|
|
109
|
+
<body style="font-family:system-ui,sans-serif;display:grid;place-items:center;height:100vh;margin:0;background:#f4f4f0">
|
|
110
|
+
<div style="text-align:center"><h1 style="font-size:17px;margin:0 0 8px">${title}</h1>
|
|
111
|
+
<p style="font-size:13px;color:#555;margin:0">${detail}</p></div></body></html>`;
|
|
112
|
+
};
|
|
113
|
+
export async function loginToServer(serverName, serverUrl, onUrl) {
|
|
114
|
+
const meta = await discover(serverUrl);
|
|
115
|
+
if (!meta?.authorization_endpoint || !meta.token_endpoint) {
|
|
116
|
+
throw new Error(`${serverName} does not advertise an OAuth authorization server. ` +
|
|
117
|
+
'If it uses a static API key, use: rivine mcp add --token-env <VAR>');
|
|
118
|
+
}
|
|
119
|
+
const port = await freePort();
|
|
120
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
121
|
+
const existing = (await loadSession(mcpAccount(serverName)));
|
|
122
|
+
const clientId = existing?.clientId ?? (await register(meta, redirectUri));
|
|
123
|
+
if (!clientId) {
|
|
124
|
+
throw new Error(`${serverName} requires a client id and does not support dynamic registration. ` +
|
|
125
|
+
'Register this CLI with the provider and set the token via --token-env instead.');
|
|
126
|
+
}
|
|
127
|
+
const verifier = base64url(randomBytes(32));
|
|
128
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
129
|
+
const state = base64url(randomBytes(16));
|
|
130
|
+
const code = await new Promise((resolve, reject) => {
|
|
131
|
+
const server = createServer((req, res) => {
|
|
132
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
133
|
+
if (url.pathname !== '/callback') {
|
|
134
|
+
res.writeHead(404).end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const finish = (status, page, done) => {
|
|
138
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' }).end(page);
|
|
139
|
+
res.on('finish', () => {
|
|
140
|
+
server.close();
|
|
141
|
+
done();
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const error = url.searchParams.get('error');
|
|
145
|
+
if (error) {
|
|
146
|
+
const detail = url.searchParams.get('error_description') || error;
|
|
147
|
+
finish(400, PAGE('Sign-in was cancelled', detail), () => reject(new Error(detail)));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const returned = url.searchParams.get('code') ?? '';
|
|
151
|
+
if (!returned || url.searchParams.get('state') !== state) {
|
|
152
|
+
finish(400, PAGE('Sign-in could not be verified', 'Please run the command again.'), () => reject(new Error('The sign-in response did not match this terminal.')));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
finish(200, PAGE(`Connected to ${serverName}`, 'You can close this tab.'), () => resolve(returned));
|
|
156
|
+
});
|
|
157
|
+
server.on('error', (err) => reject(err));
|
|
158
|
+
server.listen(port, '127.0.0.1', () => {
|
|
159
|
+
const authorize = new URL(meta.authorization_endpoint);
|
|
160
|
+
authorize.searchParams.set('response_type', 'code');
|
|
161
|
+
authorize.searchParams.set('client_id', clientId);
|
|
162
|
+
authorize.searchParams.set('redirect_uri', redirectUri);
|
|
163
|
+
authorize.searchParams.set('code_challenge', challenge);
|
|
164
|
+
authorize.searchParams.set('code_challenge_method', 'S256');
|
|
165
|
+
authorize.searchParams.set('state', state);
|
|
166
|
+
const scopes = (meta.scopes_supported ?? []).filter((s) => SAFE_SCOPES.has(s));
|
|
167
|
+
if (scopes.length)
|
|
168
|
+
authorize.searchParams.set('scope', scopes.join(' '));
|
|
169
|
+
const href = authorize.toString();
|
|
170
|
+
onUrl(href);
|
|
171
|
+
openBrowser(href);
|
|
172
|
+
});
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
server.close();
|
|
175
|
+
reject(new Error('Timed out waiting for the browser. Run the command again.'));
|
|
176
|
+
}, FLOW_TIMEOUT_MS).unref();
|
|
177
|
+
});
|
|
178
|
+
const token = await exchange(meta.token_endpoint, {
|
|
179
|
+
grant_type: 'authorization_code',
|
|
180
|
+
code,
|
|
181
|
+
redirect_uri: redirectUri,
|
|
182
|
+
client_id: clientId,
|
|
183
|
+
code_verifier: verifier,
|
|
184
|
+
});
|
|
185
|
+
await saveSession({ ...token, clientId, tokenEndpoint: meta.token_endpoint }, mcpAccount(serverName));
|
|
186
|
+
}
|
|
187
|
+
async function exchange(tokenEndpoint, body) {
|
|
188
|
+
const res = await fetch(tokenEndpoint, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
191
|
+
body: new URLSearchParams(body).toString(),
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
throw new Error(`The authorization server refused the request (HTTP ${res.status}).`);
|
|
195
|
+
}
|
|
196
|
+
const parsed = (await res.json());
|
|
197
|
+
if (!parsed.access_token)
|
|
198
|
+
throw new Error('The authorization server returned no access token.');
|
|
199
|
+
return {
|
|
200
|
+
accessToken: parsed.access_token,
|
|
201
|
+
refreshToken: parsed.refresh_token,
|
|
202
|
+
expiresAt: Date.now() + (parsed.expires_in ?? 3600) * 1000,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export async function tokenFor(serverName) {
|
|
206
|
+
const stored = (await loadSession(mcpAccount(serverName)));
|
|
207
|
+
if (!stored)
|
|
208
|
+
return null;
|
|
209
|
+
if (stored.expiresAt - RENEW_MARGIN_MS > Date.now())
|
|
210
|
+
return stored.accessToken;
|
|
211
|
+
if (!stored.refreshToken || !stored.tokenEndpoint || !stored.clientId) {
|
|
212
|
+
return stored.accessToken;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const renewed = await exchange(stored.tokenEndpoint, {
|
|
216
|
+
grant_type: 'refresh_token',
|
|
217
|
+
refresh_token: stored.refreshToken,
|
|
218
|
+
client_id: stored.clientId,
|
|
219
|
+
});
|
|
220
|
+
await saveSession({
|
|
221
|
+
...renewed,
|
|
222
|
+
refreshToken: renewed.refreshToken ?? stored.refreshToken,
|
|
223
|
+
clientId: stored.clientId,
|
|
224
|
+
tokenEndpoint: stored.tokenEndpoint,
|
|
225
|
+
}, mcpAccount(serverName));
|
|
226
|
+
return renewed.accessToken;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function isLoggedIn(serverName) {
|
|
233
|
+
return (await loadSession(mcpAccount(serverName))) !== null;
|
|
234
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_TOOLS_PER_TURN = 24;
|
|
12
|
+
const RELEVANCE_THRESHOLD = 2;
|
|
13
|
+
const NOISE = new Set([
|
|
14
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'from',
|
|
15
|
+
'is', 'are', 'was', 'be', 'been', 'it', 'this', 'that', 'these', 'those', 'as', 'by',
|
|
16
|
+
'i', 'we', 'you', 'me', 'my', 'our', 'your', 'can', 'please', 'add', 'make', 'use', 'using',
|
|
17
|
+
'get', 'set', 'new', 'all', 'any', 'if', 'then', 'do', 'does', 'not', 'no', 'yes',
|
|
18
|
+
'file', 'files', 'code', 'run', 'fix', 'update', 'create', 'write', 'read', 'change',
|
|
19
|
+
'tool', 'tools', 'server', 'data', 'list', 'name', 'value', 'return', 'returns', 'result',
|
|
20
|
+
]);
|
|
21
|
+
function tokenize(text) {
|
|
22
|
+
const out = new Set();
|
|
23
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
24
|
+
if ((raw.length > 2 || raw === 'db') && !NOISE.has(raw))
|
|
25
|
+
out.add(raw);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function serverVocabulary(tools) {
|
|
30
|
+
const words = new Set();
|
|
31
|
+
for (const tool of tools) {
|
|
32
|
+
for (const word of tokenize(tool.name.replace(/^mcp__/, '').replace(/__/g, ' '))) {
|
|
33
|
+
words.add(word);
|
|
34
|
+
}
|
|
35
|
+
for (const word of tokenize(tool.description))
|
|
36
|
+
words.add(word);
|
|
37
|
+
}
|
|
38
|
+
return words;
|
|
39
|
+
}
|
|
40
|
+
export function selectRelevantTools(message, all) {
|
|
41
|
+
if (all.length === 0)
|
|
42
|
+
return { tools: [], servers: [], skipped: [] };
|
|
43
|
+
const byServer = new Map();
|
|
44
|
+
for (const tool of all) {
|
|
45
|
+
const list = byServer.get(tool.server) ?? [];
|
|
46
|
+
list.push(tool);
|
|
47
|
+
byServer.set(tool.server, list);
|
|
48
|
+
}
|
|
49
|
+
const prompt = tokenize(message);
|
|
50
|
+
const lowered = message.toLowerCase();
|
|
51
|
+
const scored = [];
|
|
52
|
+
for (const [server, tools] of byServer) {
|
|
53
|
+
const named = lowered.includes(server.toLowerCase());
|
|
54
|
+
let overlap = 0;
|
|
55
|
+
const vocabulary = serverVocabulary(tools);
|
|
56
|
+
for (const word of prompt)
|
|
57
|
+
if (vocabulary.has(word))
|
|
58
|
+
overlap += 1;
|
|
59
|
+
scored.push({ server, tools, score: named ? Number.POSITIVE_INFINITY : overlap });
|
|
60
|
+
}
|
|
61
|
+
scored.sort((a, b) => b.score - a.score);
|
|
62
|
+
const chosen = [];
|
|
63
|
+
const servers = [];
|
|
64
|
+
const skipped = [];
|
|
65
|
+
for (const entry of scored) {
|
|
66
|
+
if (entry.score < RELEVANCE_THRESHOLD || chosen.length + entry.tools.length > MAX_TOOLS_PER_TURN) {
|
|
67
|
+
skipped.push(entry.server);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
chosen.push(...entry.tools);
|
|
71
|
+
servers.push(entry.server);
|
|
72
|
+
}
|
|
73
|
+
return { tools: chosen, servers, skipped };
|
|
74
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
function replaceLiteral(haystack, needle, replacement, all) {
|
|
15
|
+
if (all)
|
|
16
|
+
return haystack.split(needle).join(replacement);
|
|
17
|
+
const at = haystack.indexOf(needle);
|
|
18
|
+
if (at < 0)
|
|
19
|
+
return haystack;
|
|
20
|
+
const body = indentContinuation(haystack, at, needle, replacement);
|
|
21
|
+
return haystack.slice(0, at) + body + haystack.slice(at + needle.length);
|
|
22
|
+
}
|
|
23
|
+
function indentContinuation(haystack, matchIndex, oldString, newString) {
|
|
24
|
+
if (!newString.includes('\n'))
|
|
25
|
+
return newString;
|
|
26
|
+
if (oldString.includes('\n'))
|
|
27
|
+
return newString;
|
|
28
|
+
const lineStart = haystack.lastIndexOf('\n', matchIndex - 1) + 1;
|
|
29
|
+
const prefix = haystack.slice(lineStart, matchIndex);
|
|
30
|
+
if (prefix.length > 0 && !/^[ \t]+$/.test(prefix))
|
|
31
|
+
return newString;
|
|
32
|
+
const lines = newString.split('\n');
|
|
33
|
+
return [
|
|
34
|
+
lines[0],
|
|
35
|
+
...lines.slice(1).map((l) => (l.length > 0 ? prefix + l : l)),
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
function countOccurrences(haystack, needle) {
|
|
39
|
+
if (!needle)
|
|
40
|
+
return 0;
|
|
41
|
+
return haystack.split(needle).length - 1;
|
|
42
|
+
}
|
|
43
|
+
function dominantEnding(text) {
|
|
44
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
45
|
+
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
46
|
+
return crlf > lf ? '\r\n' : '\n';
|
|
47
|
+
}
|
|
48
|
+
function toLf(text) {
|
|
49
|
+
return text.replace(/\r\n/g, '\n');
|
|
50
|
+
}
|
|
51
|
+
function withEnding(text, ending) {
|
|
52
|
+
return ending === '\r\n' ? toLf(text).replace(/\n/g, '\r\n') : toLf(text);
|
|
53
|
+
}
|
|
54
|
+
function findIndentedBlock(haystackLines, needleLines) {
|
|
55
|
+
const wanted = needleLines.map((l) => l.trim());
|
|
56
|
+
const found = [];
|
|
57
|
+
for (let i = 0; i + wanted.length <= haystackLines.length; i += 1) {
|
|
58
|
+
let matches = true;
|
|
59
|
+
for (let j = 0; j < wanted.length; j += 1) {
|
|
60
|
+
if (haystackLines[i + j].trim() !== wanted[j]) {
|
|
61
|
+
matches = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!matches)
|
|
66
|
+
continue;
|
|
67
|
+
const firstReal = wanted.findIndex((l) => l.length > 0);
|
|
68
|
+
if (firstReal < 0)
|
|
69
|
+
continue;
|
|
70
|
+
const haveIndent = haystackLines[i + firstReal].match(/^[ \t]*/)[0];
|
|
71
|
+
const wantIndent = needleLines[firstReal].match(/^[ \t]*/)[0];
|
|
72
|
+
if (!haveIndent.startsWith(wantIndent) && !wantIndent.startsWith(haveIndent)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
found.push({ start: i, end: i + wanted.length, haveIndent, wantIndent });
|
|
76
|
+
if (found.length > 1)
|
|
77
|
+
return 'ambiguous';
|
|
78
|
+
}
|
|
79
|
+
return found[0] ?? null;
|
|
80
|
+
}
|
|
81
|
+
function describeNearMiss(content, oldString) {
|
|
82
|
+
const needleLines = toLf(oldString).split('\n').filter((l) => l.trim().length > 0);
|
|
83
|
+
if (needleLines.length === 0)
|
|
84
|
+
return 'the search text was blank';
|
|
85
|
+
const anchor = needleLines[0].trim();
|
|
86
|
+
const lines = toLf(content).split('\n');
|
|
87
|
+
const exact = lines.findIndex((l) => l.trim() === anchor);
|
|
88
|
+
if (exact >= 0) {
|
|
89
|
+
return (`its first line matches at line ${exact + 1}, so the difference is further down — ` +
|
|
90
|
+
're-read the file around that line and copy the block exactly');
|
|
91
|
+
}
|
|
92
|
+
let best = -1;
|
|
93
|
+
let bestScore = 0;
|
|
94
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
95
|
+
const candidate = lines[i].trim();
|
|
96
|
+
if (!candidate)
|
|
97
|
+
continue;
|
|
98
|
+
let shared = 0;
|
|
99
|
+
while (shared < candidate.length && shared < anchor.length && candidate[shared] === anchor[shared]) {
|
|
100
|
+
shared += 1;
|
|
101
|
+
}
|
|
102
|
+
if (shared > bestScore) {
|
|
103
|
+
bestScore = shared;
|
|
104
|
+
best = i;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (best >= 0 && bestScore >= Math.min(8, anchor.length / 2)) {
|
|
108
|
+
return `the closest line is ${best + 1}: ${JSON.stringify(lines[best].trim().slice(0, 120))}`;
|
|
109
|
+
}
|
|
110
|
+
return 'nothing in the file resembles it — the path may be wrong, or the file may already have been changed';
|
|
111
|
+
}
|
|
112
|
+
export function planEdit(content, request) {
|
|
113
|
+
const { oldString, newString, replaceAll } = request;
|
|
114
|
+
if (!oldString)
|
|
115
|
+
return { ok: false, error: { kind: 'empty_old_string' } };
|
|
116
|
+
if (oldString === newString)
|
|
117
|
+
return { ok: false, error: { kind: 'no_op' } };
|
|
118
|
+
const exact = countOccurrences(content, oldString);
|
|
119
|
+
if (exact === 1 || (exact > 1 && replaceAll)) {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
value: {
|
|
123
|
+
content: replaceLiteral(content, oldString, newString, replaceAll),
|
|
124
|
+
occurrences: exact,
|
|
125
|
+
strategy: 'exact',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (exact > 1) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: {
|
|
133
|
+
kind: 'ambiguous',
|
|
134
|
+
occurrences: exact,
|
|
135
|
+
hint: 'include more surrounding context so the snippet is unique, or pass replace_all',
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const ending = dominantEnding(content);
|
|
140
|
+
const normalizedContent = toLf(content);
|
|
141
|
+
const normalizedOld = toLf(oldString);
|
|
142
|
+
const viaEndings = countOccurrences(normalizedContent, normalizedOld);
|
|
143
|
+
if (viaEndings === 1 || (viaEndings > 1 && replaceAll)) {
|
|
144
|
+
const updated = replaceLiteral(normalizedContent, normalizedOld, toLf(newString), replaceAll);
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
value: {
|
|
148
|
+
content: withEnding(updated, ending),
|
|
149
|
+
occurrences: viaEndings,
|
|
150
|
+
strategy: 'line-endings',
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (viaEndings > 1) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: {
|
|
158
|
+
kind: 'ambiguous',
|
|
159
|
+
occurrences: viaEndings,
|
|
160
|
+
hint: 'include more surrounding context so the snippet is unique, or pass replace_all',
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const contentLines = normalizedContent.split('\n');
|
|
165
|
+
const needleLines = normalizedOld.split('\n');
|
|
166
|
+
const block = findIndentedBlock(contentLines, needleLines);
|
|
167
|
+
if (block === 'ambiguous') {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: {
|
|
171
|
+
kind: 'ambiguous',
|
|
172
|
+
occurrences: 2,
|
|
173
|
+
hint: 'it appears more than once ignoring indentation — include more surrounding context',
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (block) {
|
|
178
|
+
const replacementLines = toLf(newString)
|
|
179
|
+
.split('\n')
|
|
180
|
+
.map((line) => {
|
|
181
|
+
if (line.length === 0)
|
|
182
|
+
return line;
|
|
183
|
+
const body = line.startsWith(block.wantIndent)
|
|
184
|
+
? line.slice(block.wantIndent.length)
|
|
185
|
+
: line.replace(/^[ \t]*/, '');
|
|
186
|
+
return block.haveIndent + body;
|
|
187
|
+
});
|
|
188
|
+
const merged = [
|
|
189
|
+
...contentLines.slice(0, block.start),
|
|
190
|
+
...replacementLines,
|
|
191
|
+
...contentLines.slice(block.end),
|
|
192
|
+
].join('\n');
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
value: { content: withEnding(merged, ending), occurrences: 1, strategy: 'indentation' },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: { kind: 'not_found', hint: describeNearMiss(content, oldString) },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export async function writeFileAtomic(path, content) {
|
|
204
|
+
let mode;
|
|
205
|
+
try {
|
|
206
|
+
mode = (await stat(path)).mode;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
}
|
|
210
|
+
const temp = join(dirname(path), `.${randomBytes(6).toString('hex')}.rivine.tmp`);
|
|
211
|
+
try {
|
|
212
|
+
await writeFile(temp, content, mode === undefined ? 'utf8' : { encoding: 'utf8', mode });
|
|
213
|
+
await rename(temp, path);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
await unlink(temp).catch(() => { });
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function describeFailure(error) {
|
|
221
|
+
switch (error.kind) {
|
|
222
|
+
case 'file_not_found':
|
|
223
|
+
return 'file_not_found';
|
|
224
|
+
case 'empty_old_string':
|
|
225
|
+
return 'empty_old_string — old_string must contain the text to replace';
|
|
226
|
+
case 'no_op':
|
|
227
|
+
return 'no_op — old_string and new_string are identical';
|
|
228
|
+
case 'ambiguous':
|
|
229
|
+
return `multiple_occurrences (${error.occurrences}) — ${error.hint}`;
|
|
230
|
+
case 'not_found':
|
|
231
|
+
return `string_not_found — ${error.hint}`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export async function applyEdit(path, request) {
|
|
235
|
+
let current;
|
|
236
|
+
try {
|
|
237
|
+
current = await readFile(path, 'utf8');
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return { ok: false, error: { kind: 'file_not_found' } };
|
|
241
|
+
}
|
|
242
|
+
const planned = planEdit(current, request);
|
|
243
|
+
if (!planned.ok)
|
|
244
|
+
return planned;
|
|
245
|
+
await writeFileAtomic(path, planned.value.content);
|
|
246
|
+
return planned;
|
|
247
|
+
}
|