@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,471 @@
|
|
|
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 { spawnSync } from 'node:child_process';
|
|
12
|
+
import { mkdir, readFile, readdir, realpath, stat, writeFile, } from 'node:fs/promises';
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import { runShell } from './shell.js';
|
|
15
|
+
import { applyEdit, describeFailure, writeFileAtomic } from './edit.js';
|
|
16
|
+
import { decide } from './permissions.js';
|
|
17
|
+
const VIRTUAL_ROOT = '/workspace';
|
|
18
|
+
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const SKIP_DIRS = new Set([
|
|
20
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.turbo', '.venv', 'venv',
|
|
21
|
+
'__pycache__', '.mypy_cache', '.pytest_cache', 'target', '.cache', 'coverage',
|
|
22
|
+
'.gradle', '.idea', 'vendor', '.terraform',
|
|
23
|
+
]);
|
|
24
|
+
const MAX_WALK_ENTRIES = 20_000;
|
|
25
|
+
let ripgrepPath;
|
|
26
|
+
function findRipgrep() {
|
|
27
|
+
if (ripgrepPath !== undefined)
|
|
28
|
+
return ripgrepPath;
|
|
29
|
+
const probe = spawnSync('rg', ['--version'], { encoding: 'utf8', timeout: 3000 });
|
|
30
|
+
ripgrepPath = probe.status === 0 ? 'rg' : null;
|
|
31
|
+
return ripgrepPath;
|
|
32
|
+
}
|
|
33
|
+
function ripgrepSearch(root, needle, searchPath, glob, limit) {
|
|
34
|
+
const rg = findRipgrep();
|
|
35
|
+
if (!rg)
|
|
36
|
+
return null;
|
|
37
|
+
const args = [
|
|
38
|
+
'--fixed-strings',
|
|
39
|
+
'--json',
|
|
40
|
+
'--max-filesize', '2M',
|
|
41
|
+
...(glob ? ['--glob', glob] : []),
|
|
42
|
+
'--',
|
|
43
|
+
needle,
|
|
44
|
+
searchPath,
|
|
45
|
+
];
|
|
46
|
+
const result = spawnSync(rg, args, {
|
|
47
|
+
cwd: root,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 30_000,
|
|
50
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
if (result.status !== 0 && result.status !== 1)
|
|
53
|
+
return null;
|
|
54
|
+
if (result.error)
|
|
55
|
+
return null;
|
|
56
|
+
return parseRipgrepOutput(root, result.stdout ?? '', limit);
|
|
57
|
+
}
|
|
58
|
+
export function parseRipgrepOutput(root, stdout, limit) {
|
|
59
|
+
const matches = [];
|
|
60
|
+
for (const raw of stdout.split('\n')) {
|
|
61
|
+
if (!raw.trim() || matches.length >= limit) {
|
|
62
|
+
if (matches.length >= limit)
|
|
63
|
+
break;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let event;
|
|
67
|
+
try {
|
|
68
|
+
event = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (event?.type !== 'match')
|
|
74
|
+
continue;
|
|
75
|
+
const filePath = event.data?.path?.text;
|
|
76
|
+
const line = event.data?.line_number;
|
|
77
|
+
if (typeof filePath !== 'string' || !Number.isInteger(line))
|
|
78
|
+
continue;
|
|
79
|
+
const rel = relative(root, resolve(root, filePath));
|
|
80
|
+
matches.push({
|
|
81
|
+
path: `${VIRTUAL_ROOT}/${rel.split(sep).join('/')}`,
|
|
82
|
+
line,
|
|
83
|
+
text: String(event.data?.lines?.text ?? '').replace(/\n$/, '').slice(0, 500),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return matches;
|
|
87
|
+
}
|
|
88
|
+
function rewriteVirtualPaths(command, root) {
|
|
89
|
+
return command.replace(/\/workspace(?=\/|\s|$|["'`;)])/g, root);
|
|
90
|
+
}
|
|
91
|
+
export async function resolvePath(root, virtualPath) {
|
|
92
|
+
let candidate = virtualPath.replace(/\\/g, '/');
|
|
93
|
+
if (candidate === VIRTUAL_ROOT) {
|
|
94
|
+
candidate = root;
|
|
95
|
+
}
|
|
96
|
+
else if (candidate.startsWith(`${VIRTUAL_ROOT}/`)) {
|
|
97
|
+
candidate = join(root, candidate.slice(VIRTUAL_ROOT.length + 1));
|
|
98
|
+
}
|
|
99
|
+
else if (!isAbsolute(candidate)) {
|
|
100
|
+
candidate = join(root, candidate);
|
|
101
|
+
}
|
|
102
|
+
const requested = resolve(candidate);
|
|
103
|
+
let probe = requested;
|
|
104
|
+
let suffix = '';
|
|
105
|
+
for (;;) {
|
|
106
|
+
try {
|
|
107
|
+
probe = await realpath(probe);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
const parent = dirname(probe);
|
|
112
|
+
if (parent === probe)
|
|
113
|
+
break;
|
|
114
|
+
suffix = suffix ? join(probe.slice(parent.length + 1), suffix) : probe.slice(parent.length + 1);
|
|
115
|
+
probe = parent;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const real = suffix ? join(probe, suffix) : probe;
|
|
119
|
+
const realRoot = await realpath(root).catch(() => resolve(root));
|
|
120
|
+
const rel = relative(realRoot, real);
|
|
121
|
+
const outsideProject = rel.startsWith('..') || isAbsolute(rel);
|
|
122
|
+
return {
|
|
123
|
+
absolute: real,
|
|
124
|
+
relative: outsideProject ? real : rel === '' ? '.' : rel,
|
|
125
|
+
outsideProject,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function summarize(op, relPath, args) {
|
|
129
|
+
switch (op) {
|
|
130
|
+
case 'execute':
|
|
131
|
+
return String(args.command ?? '').slice(0, 200);
|
|
132
|
+
case 'write':
|
|
133
|
+
return `write ${relPath} (${String(args.content ?? '').length} bytes)`;
|
|
134
|
+
case 'edit':
|
|
135
|
+
return `edit ${relPath}`;
|
|
136
|
+
case 'upload':
|
|
137
|
+
return `write ${(args.files ?? []).length} file(s)`;
|
|
138
|
+
case 'mcp_call': {
|
|
139
|
+
const tool = String(args.tool ?? '');
|
|
140
|
+
const shown = tool.replace(/^mcp__/, '').replace('__', ' · ');
|
|
141
|
+
const keys = Object.keys((args.args ?? {})).slice(0, 4).join(', ');
|
|
142
|
+
return keys ? `${shown} (${keys})` : shown;
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
return `${op} ${relPath ?? ''}`.trim();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function classify(root, request, mode) {
|
|
149
|
+
const { op, args } = request;
|
|
150
|
+
const paths = op === 'upload'
|
|
151
|
+
? (args.files ?? []).map((f) => String(f.path ?? ''))
|
|
152
|
+
: op === 'download'
|
|
153
|
+
? (args.paths ?? []).map(String)
|
|
154
|
+
: args.path
|
|
155
|
+
? [String(args.path)]
|
|
156
|
+
: [];
|
|
157
|
+
let worst = null;
|
|
158
|
+
for (const p of paths) {
|
|
159
|
+
const resolved = await resolvePath(root, p);
|
|
160
|
+
const decision = decide({
|
|
161
|
+
op,
|
|
162
|
+
mode,
|
|
163
|
+
relPath: resolved.relative,
|
|
164
|
+
outsideProject: resolved.outsideProject,
|
|
165
|
+
command: args.command,
|
|
166
|
+
});
|
|
167
|
+
const rank = (d) => (d.verdict === 'deny' ? 2 : d.verdict === 'ask' ? 1 : 0);
|
|
168
|
+
if (!worst || rank(decision) > rank(worst.decision))
|
|
169
|
+
worst = { resolved, decision };
|
|
170
|
+
}
|
|
171
|
+
if (!worst) {
|
|
172
|
+
const decision = decide({
|
|
173
|
+
op,
|
|
174
|
+
mode,
|
|
175
|
+
relPath: null,
|
|
176
|
+
outsideProject: false,
|
|
177
|
+
command: args.command,
|
|
178
|
+
});
|
|
179
|
+
if (decision.verdict === 'deny')
|
|
180
|
+
return { approval: null, refusal: { ok: false, error: `Refused: ${decision.reason}` } };
|
|
181
|
+
if (decision.verdict === 'ask') {
|
|
182
|
+
return {
|
|
183
|
+
approval: {
|
|
184
|
+
request,
|
|
185
|
+
relPath: null,
|
|
186
|
+
reason: decision.reason,
|
|
187
|
+
summary: summarize(op, null, args),
|
|
188
|
+
category: decision.category,
|
|
189
|
+
},
|
|
190
|
+
refusal: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { approval: null, refusal: null };
|
|
194
|
+
}
|
|
195
|
+
if (worst.decision.verdict === 'deny') {
|
|
196
|
+
return { approval: null, refusal: { ok: false, error: `Refused: ${worst.decision.reason}` } };
|
|
197
|
+
}
|
|
198
|
+
if (worst.decision.verdict === 'ask') {
|
|
199
|
+
return {
|
|
200
|
+
approval: {
|
|
201
|
+
request,
|
|
202
|
+
relPath: worst.resolved.relative,
|
|
203
|
+
reason: worst.decision.reason,
|
|
204
|
+
summary: summarize(op, worst.resolved.relative, args),
|
|
205
|
+
category: worst.decision.category,
|
|
206
|
+
},
|
|
207
|
+
refusal: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { approval: null, refusal: null };
|
|
211
|
+
}
|
|
212
|
+
async function walk(root, dir, out, recursive) {
|
|
213
|
+
if (out.length >= MAX_WALK_ENTRIES)
|
|
214
|
+
return;
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (out.length >= MAX_WALK_ENTRIES)
|
|
224
|
+
return;
|
|
225
|
+
if (SKIP_DIRS.has(entry.name))
|
|
226
|
+
continue;
|
|
227
|
+
const abs = join(dir, entry.name);
|
|
228
|
+
const rel = relative(root, abs);
|
|
229
|
+
const isDir = entry.isDirectory();
|
|
230
|
+
let size;
|
|
231
|
+
if (!isDir) {
|
|
232
|
+
size = await stat(abs).then((s) => s.size).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
out.push({ path: `${VIRTUAL_ROOT}/${rel.split(sep).join('/')}`, is_dir: isDir, ...(size !== undefined ? { size } : {}) });
|
|
235
|
+
if (isDir && recursive)
|
|
236
|
+
await walk(root, abs, out, true);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function globToRegExp(pattern) {
|
|
240
|
+
let out = '';
|
|
241
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
242
|
+
const ch = pattern[i];
|
|
243
|
+
if (ch === '*') {
|
|
244
|
+
if (pattern[i + 1] === '*') {
|
|
245
|
+
if (pattern[i + 2] === '/') {
|
|
246
|
+
out += '(?:.*/)?';
|
|
247
|
+
i += 2;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
out += '.*';
|
|
251
|
+
i += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
out += '[^/]*';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (ch === '?') {
|
|
259
|
+
out += '[^/]';
|
|
260
|
+
}
|
|
261
|
+
else if ('\\^$+.()|{}[]'.includes(ch)) {
|
|
262
|
+
out += `\\${ch}`;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
out += ch;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return new RegExp(`^${out}$`);
|
|
269
|
+
}
|
|
270
|
+
function pathsTouchedBy(op, args) {
|
|
271
|
+
if (op === 'upload')
|
|
272
|
+
return (args.files ?? []).map((f) => String(f.path ?? ''));
|
|
273
|
+
if (op === 'download')
|
|
274
|
+
return (args.paths ?? []).map(String);
|
|
275
|
+
return args.path ? [String(args.path)] : [];
|
|
276
|
+
}
|
|
277
|
+
async function enforceScope(root, request, approvedOutside) {
|
|
278
|
+
if (approvedOutside || process.env.RIVINE_ALLOW_OUTSIDE_PROJECT)
|
|
279
|
+
return null;
|
|
280
|
+
for (const candidate of pathsTouchedBy(request.op, request.args)) {
|
|
281
|
+
const resolved = await resolvePath(root, candidate);
|
|
282
|
+
if (resolved.outsideProject) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: `outside_project — ${candidate} resolves outside the project directory ` +
|
|
286
|
+
'and was not approved',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
export async function execute(root, request, hooks = {}) {
|
|
293
|
+
const { op, args } = request;
|
|
294
|
+
const outOfScope = await enforceScope(root, request, hooks.approvedOutside === true);
|
|
295
|
+
if (outOfScope)
|
|
296
|
+
return outOfScope;
|
|
297
|
+
try {
|
|
298
|
+
switch (op) {
|
|
299
|
+
case 'ls': {
|
|
300
|
+
const target = await resolvePath(root, String(args.path ?? VIRTUAL_ROOT));
|
|
301
|
+
const entries = [];
|
|
302
|
+
await walk(root, target.absolute, entries, false);
|
|
303
|
+
return { ok: true, data: { entries } };
|
|
304
|
+
}
|
|
305
|
+
case 'read': {
|
|
306
|
+
const target = await resolvePath(root, String(args.path));
|
|
307
|
+
const info = await stat(target.absolute);
|
|
308
|
+
if (info.isDirectory())
|
|
309
|
+
return { ok: false, error: 'is_directory' };
|
|
310
|
+
if (info.size > MAX_READ_BYTES) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: `file_too_large (${info.size} bytes; cap is ${MAX_READ_BYTES})`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const buf = await readFile(target.absolute);
|
|
317
|
+
const text = buf.toString('utf8');
|
|
318
|
+
const isText = Buffer.compare(Buffer.from(text, 'utf8'), buf) === 0;
|
|
319
|
+
return isText
|
|
320
|
+
? { ok: true, data: { content: text, encoding: 'utf-8' } }
|
|
321
|
+
: { ok: true, data: { content: buf.toString('base64'), encoding: 'base64' } };
|
|
322
|
+
}
|
|
323
|
+
case 'write': {
|
|
324
|
+
const target = await resolvePath(root, String(args.path));
|
|
325
|
+
await mkdir(dirname(target.absolute), { recursive: true });
|
|
326
|
+
await writeFileAtomic(target.absolute, String(args.content ?? ''));
|
|
327
|
+
return { ok: true, data: { path: `${VIRTUAL_ROOT}/${target.relative}` } };
|
|
328
|
+
}
|
|
329
|
+
case 'edit': {
|
|
330
|
+
const target = await resolvePath(root, String(args.path));
|
|
331
|
+
const result = await applyEdit(target.absolute, {
|
|
332
|
+
oldString: String(args.old_string ?? ''),
|
|
333
|
+
newString: String(args.new_string ?? ''),
|
|
334
|
+
replaceAll: Boolean(args.replace_all),
|
|
335
|
+
});
|
|
336
|
+
if (!result.ok)
|
|
337
|
+
return { ok: false, error: describeFailure(result.error) };
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
data: {
|
|
341
|
+
path: `${VIRTUAL_ROOT}/${target.relative}`,
|
|
342
|
+
occurrences: result.value.occurrences,
|
|
343
|
+
matched: result.value.strategy,
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
case 'glob': {
|
|
348
|
+
const base = await resolvePath(root, String(args.path ?? VIRTUAL_ROOT));
|
|
349
|
+
const re = globToRegExp(String(args.pattern ?? '*'));
|
|
350
|
+
const entries = [];
|
|
351
|
+
await walk(root, base.absolute, entries, true);
|
|
352
|
+
const baseRel = relative(root, base.absolute);
|
|
353
|
+
const matches = entries.filter((e) => {
|
|
354
|
+
const rel = e.path.slice(VIRTUAL_ROOT.length + 1);
|
|
355
|
+
const fromBase = baseRel ? relative(baseRel, rel) : rel;
|
|
356
|
+
return re.test(fromBase) || re.test(rel) || re.test(basename(rel));
|
|
357
|
+
});
|
|
358
|
+
return { ok: true, data: { matches } };
|
|
359
|
+
}
|
|
360
|
+
case 'grep': {
|
|
361
|
+
const base = await resolvePath(root, String(args.path ?? VIRTUAL_ROOT));
|
|
362
|
+
const needle = String(args.pattern ?? '');
|
|
363
|
+
if (!needle)
|
|
364
|
+
return { ok: true, data: { matches: [] } };
|
|
365
|
+
const fast = ripgrepSearch(root, needle, base.absolute, args.glob ? String(args.glob) : null, 500);
|
|
366
|
+
if (fast)
|
|
367
|
+
return { ok: true, data: { matches: fast } };
|
|
368
|
+
const fileFilter = args.glob ? globToRegExp(String(args.glob)) : null;
|
|
369
|
+
const entries = [];
|
|
370
|
+
await walk(root, base.absolute, entries, true);
|
|
371
|
+
const matches = [];
|
|
372
|
+
for (const entry of entries) {
|
|
373
|
+
if (entry.is_dir || matches.length >= 500)
|
|
374
|
+
continue;
|
|
375
|
+
if ((entry.size ?? 0) > MAX_READ_BYTES)
|
|
376
|
+
continue;
|
|
377
|
+
const rel = entry.path.slice(VIRTUAL_ROOT.length + 1);
|
|
378
|
+
if (fileFilter && !fileFilter.test(rel) && !fileFilter.test(basename(rel)))
|
|
379
|
+
continue;
|
|
380
|
+
const abs = join(root, rel);
|
|
381
|
+
const content = await readFile(abs, 'utf8').catch(() => null);
|
|
382
|
+
if (content === null || !content.includes(needle))
|
|
383
|
+
continue;
|
|
384
|
+
content.split('\n').forEach((line, index) => {
|
|
385
|
+
if (matches.length < 500 && line.includes(needle)) {
|
|
386
|
+
matches.push({ path: entry.path, line: index + 1, text: line.slice(0, 500) });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, data: { matches } };
|
|
391
|
+
}
|
|
392
|
+
case 'execute': {
|
|
393
|
+
const command = rewriteVirtualPaths(String(args.command ?? ''), root);
|
|
394
|
+
const timeoutMs = Math.max(1, Number(args.timeout ?? 600)) * 1000;
|
|
395
|
+
const shell = await runShell(command, {
|
|
396
|
+
cwd: root,
|
|
397
|
+
timeoutMs,
|
|
398
|
+
onChunk: hooks.onOutput,
|
|
399
|
+
cols: process.stdout.columns || 120,
|
|
400
|
+
rows: process.stdout.rows || 30,
|
|
401
|
+
});
|
|
402
|
+
hooks.onShell?.(shell);
|
|
403
|
+
try {
|
|
404
|
+
const res = await shell.result;
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
data: {
|
|
408
|
+
output: res.output,
|
|
409
|
+
exit_code: res.exit_code,
|
|
410
|
+
truncated: res.truncated,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
hooks.onShell?.(null);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
case 'upload': {
|
|
419
|
+
const files = (args.files ?? []);
|
|
420
|
+
const results = [];
|
|
421
|
+
for (const file of files) {
|
|
422
|
+
const target = await resolvePath(root, String(file.path));
|
|
423
|
+
try {
|
|
424
|
+
await mkdir(dirname(target.absolute), { recursive: true });
|
|
425
|
+
await writeFile(target.absolute, Buffer.from(String(file.content_b64), 'base64'));
|
|
426
|
+
results.push({ path: file.path });
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
results.push({ path: file.path, error: err.message });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { ok: true, data: { results } };
|
|
433
|
+
}
|
|
434
|
+
case 'download': {
|
|
435
|
+
const paths = (args.paths ?? []);
|
|
436
|
+
const results = [];
|
|
437
|
+
for (const p of paths) {
|
|
438
|
+
const target = await resolvePath(root, String(p));
|
|
439
|
+
try {
|
|
440
|
+
const info = await stat(target.absolute);
|
|
441
|
+
if (info.isDirectory()) {
|
|
442
|
+
results.push({ path: p, error: 'is_directory' });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (info.size > MAX_READ_BYTES) {
|
|
446
|
+
results.push({ path: p, error: 'file_too_large' });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
results.push({ path: p, content_b64: (await readFile(target.absolute)).toString('base64') });
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
results.push({ path: p, error: 'file_not_found' });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, data: { results } };
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
return { ok: false, error: `unsupported operation: ${op}` };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
const e = err;
|
|
463
|
+
if (e.code === 'ENOENT')
|
|
464
|
+
return { ok: false, error: 'file_not_found' };
|
|
465
|
+
if (e.code === 'EACCES' || e.code === 'EPERM')
|
|
466
|
+
return { ok: false, error: 'permission_denied' };
|
|
467
|
+
if (e.code === 'EISDIR')
|
|
468
|
+
return { ok: false, error: 'is_directory' };
|
|
469
|
+
return { ok: false, error: e.message ?? String(err) };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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 { spawn, spawnSync } from 'node:child_process';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
function gitEnv() {
|
|
14
|
+
const env = { ...process.env };
|
|
15
|
+
delete env.GIT_DIR;
|
|
16
|
+
delete env.GIT_WORK_TREE;
|
|
17
|
+
delete env.GIT_INDEX_FILE;
|
|
18
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
19
|
+
delete env.GIT_COMMON_DIR;
|
|
20
|
+
env.GIT_PAGER = 'cat';
|
|
21
|
+
env.PAGER = 'cat';
|
|
22
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
23
|
+
return env;
|
|
24
|
+
}
|
|
25
|
+
function run(file, args, cwd, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
26
|
+
return new Promise((resolvePromise) => {
|
|
27
|
+
let child;
|
|
28
|
+
try {
|
|
29
|
+
child = spawn(file, args, { cwd, env: gitEnv() });
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
resolvePromise({ ok: false, output: err.message });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let out = '';
|
|
36
|
+
let settled = false;
|
|
37
|
+
const finish = (ok, text) => {
|
|
38
|
+
if (settled)
|
|
39
|
+
return;
|
|
40
|
+
settled = true;
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolvePromise({ ok, output: text.trim() });
|
|
43
|
+
};
|
|
44
|
+
child.stdout?.on('data', (c) => {
|
|
45
|
+
out += c.toString();
|
|
46
|
+
});
|
|
47
|
+
child.stderr?.on('data', (c) => {
|
|
48
|
+
out += c.toString();
|
|
49
|
+
});
|
|
50
|
+
child.on('error', (err) => finish(false, err.message));
|
|
51
|
+
child.on('close', (code) => finish(code === 0, out));
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
child.kill('SIGTERM');
|
|
54
|
+
finish(false, `${file} did not finish within ${Math.round(timeoutMs / 1000)}s`);
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const present = new Map();
|
|
59
|
+
export function hasBinary(name) {
|
|
60
|
+
const cached = present.get(name);
|
|
61
|
+
if (cached !== undefined)
|
|
62
|
+
return cached;
|
|
63
|
+
const probe = spawnSync(name, ['--version'], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: 3000,
|
|
66
|
+
env: gitEnv(),
|
|
67
|
+
});
|
|
68
|
+
const ok = probe.status === 0;
|
|
69
|
+
present.set(name, ok);
|
|
70
|
+
return ok;
|
|
71
|
+
}
|
|
72
|
+
export function resetBinaryProbeForTests() {
|
|
73
|
+
present.clear();
|
|
74
|
+
}
|
|
75
|
+
export async function isRepo(cwd) {
|
|
76
|
+
if (!hasBinary('git'))
|
|
77
|
+
return false;
|
|
78
|
+
const res = await run('git', ['rev-parse', '--is-inside-work-tree'], cwd, 5_000);
|
|
79
|
+
return res.ok && res.output.startsWith('true');
|
|
80
|
+
}
|
|
81
|
+
export function gitBranchLine(cwd = process.cwd()) {
|
|
82
|
+
if (!hasBinary('git'))
|
|
83
|
+
return '';
|
|
84
|
+
const res = spawnSync('git', ['status', '-sb', '--porcelain=v1'], {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 2000,
|
|
88
|
+
env: gitEnv(),
|
|
89
|
+
});
|
|
90
|
+
if (res.status !== 0 || !res.stdout)
|
|
91
|
+
return '';
|
|
92
|
+
const lines = res.stdout.split('\n');
|
|
93
|
+
const header = (lines[0] ?? '').replace(/^##\s*/, '').trim();
|
|
94
|
+
if (!header)
|
|
95
|
+
return '';
|
|
96
|
+
const changed = lines.slice(1).filter((l) => l.trim().length > 0).length;
|
|
97
|
+
return `${header} · ${changed === 0 ? 'clean' : `${changed} file(s) changed`}`;
|
|
98
|
+
}
|
|
99
|
+
export async function statusShort(cwd) {
|
|
100
|
+
if (!hasBinary('git'))
|
|
101
|
+
return { ok: false, output: 'git is not installed.' };
|
|
102
|
+
return run('git', ['status', '-sb'], cwd);
|
|
103
|
+
}
|
|
104
|
+
export async function diff(cwd, { full = false } = {}) {
|
|
105
|
+
if (!hasBinary('git'))
|
|
106
|
+
return { ok: false, output: 'git is not installed.' };
|
|
107
|
+
return run('git', full ? ['diff'] : ['diff', '--stat'], cwd);
|
|
108
|
+
}
|
|
109
|
+
export async function hasUpstream(cwd) {
|
|
110
|
+
const res = await run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], cwd, 5_000);
|
|
111
|
+
return res.ok && res.output.length > 0;
|
|
112
|
+
}
|
|
113
|
+
export async function prCreate(cwd, options = {}) {
|
|
114
|
+
if (!hasBinary('git'))
|
|
115
|
+
return { ok: false, output: 'git is not installed.' };
|
|
116
|
+
if (!(await isRepo(cwd)))
|
|
117
|
+
return { ok: false, output: 'Not a git repository.' };
|
|
118
|
+
if (!hasBinary('gh')) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
output: 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com and run `gh auth login`.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const auth = await run('gh', ['auth', 'status'], cwd, 10_000);
|
|
125
|
+
if (!auth.ok) {
|
|
126
|
+
return { ok: false, output: 'GitHub CLI is not signed in. Run `gh auth login` first.' };
|
|
127
|
+
}
|
|
128
|
+
const branch = await run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd, 5_000);
|
|
129
|
+
if (branch.ok && (branch.output === 'main' || branch.output === 'master')) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
output: `You are on ${branch.output}. Make a branch before opening a pull request.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!(await hasUpstream(cwd))) {
|
|
136
|
+
if (!options.push) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
output: `Branch "${branch.output}" has not been pushed. Run \`/pr push\` to push it ` +
|
|
140
|
+
'to origin and open a pull request.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const pushed = await run('git', ['push', '--set-upstream', 'origin', 'HEAD'], cwd, 120_000);
|
|
144
|
+
if (!pushed.ok)
|
|
145
|
+
return { ok: false, output: `Push failed: ${pushed.output}` };
|
|
146
|
+
}
|
|
147
|
+
const args = ['pr', 'create'];
|
|
148
|
+
if (options.title)
|
|
149
|
+
args.push('--title', options.title);
|
|
150
|
+
if (!options.title)
|
|
151
|
+
args.push('--fill');
|
|
152
|
+
if (options.body)
|
|
153
|
+
args.push('--body', options.body);
|
|
154
|
+
if (options.draft)
|
|
155
|
+
args.push('--draft');
|
|
156
|
+
return run('gh', args, cwd, 120_000);
|
|
157
|
+
}
|