atris 3.72.4 → 3.73.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 +3 -1
- package/atris/skills/commerce/SKILL.md +24 -3
- package/bin/atris.js +10 -0
- package/commands/gm.js +17 -6
- package/commands/member.js +59 -9
- package/commands/pay.js +329 -0
- package/commands/task.js +55 -18
- package/commands/trust.js +117 -0
- package/commands/zen.js +17 -0
- package/lib/gm-bet.js +1443 -162
- package/lib/gm-phone-page.js +460 -357
- package/lib/gm-phone.js +74 -13
- package/lib/known-commands.js +1 -1
- package/lib/site.js +9 -1
- package/lib/try-repo.js +442 -0
- package/lib/zen-apply.js +28 -4
- package/lib/zen-fleet.js +100 -0
- package/lib/zen.js +41 -6
- package/package.json +1 -1
- package/scripts/det/xsearch-bench.js +1 -0
package/lib/gm-phone.js
CHANGED
|
@@ -303,11 +303,20 @@ function tokenMatches(given, token) {
|
|
|
303
303
|
// look-alike hostname pointed at the Mac gets nothing.
|
|
304
304
|
function checkRequest(req, { token, hosts = [] }) {
|
|
305
305
|
const host = String(req.headers.host || '').toLowerCase();
|
|
306
|
-
|
|
306
|
+
let url;
|
|
307
|
+
try {
|
|
308
|
+
const target = req.url || '/';
|
|
309
|
+
url = new URL(target, 'http://local');
|
|
310
|
+
if (!target.startsWith('/') || target.startsWith('//') || /[\\#\0]/.test(target) || decodeURIComponent(url.pathname).includes('\0')) throw new Error('url');
|
|
311
|
+
decodeURIComponent(target);
|
|
312
|
+
} catch {
|
|
313
|
+
return { ok: false, status: 400, reason: 'url' };
|
|
314
|
+
}
|
|
307
315
|
if (!hosts.includes(host)) return { ok: false, status: 403, reason: 'host' };
|
|
308
316
|
const header = req.headers['x-gm-token'];
|
|
309
317
|
if (req.method === 'GET') {
|
|
310
318
|
if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
|
|
319
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0) return { ok: false, status: 400, reason: 'body' };
|
|
311
320
|
return { ok: true, path: url.pathname };
|
|
312
321
|
}
|
|
313
322
|
if (req.method === 'POST') {
|
|
@@ -324,10 +333,10 @@ function readBody(req) {
|
|
|
324
333
|
let size = 0;
|
|
325
334
|
const chunks = [];
|
|
326
335
|
req.on('data', (chunk) => {
|
|
336
|
+
if (size > BODY_MAX) return;
|
|
327
337
|
size += chunk.length;
|
|
328
338
|
if (size > BODY_MAX) {
|
|
329
339
|
reject(new Error('too_large'));
|
|
330
|
-
req.destroy();
|
|
331
340
|
return;
|
|
332
341
|
}
|
|
333
342
|
chunks.push(chunk);
|
|
@@ -488,22 +497,29 @@ function createPhoneHandler(deps) {
|
|
|
488
497
|
return { status: ok ? 200 : 500, body: { ok, ack: ok ? 'undone, target back how it was' : 'could not undo' } };
|
|
489
498
|
}
|
|
490
499
|
|
|
491
|
-
// think shows the next
|
|
492
|
-
//
|
|
493
|
-
//
|
|
500
|
+
// think shows the next card at once (a choice of ideas, one idea, or a
|
|
501
|
+
// question when none is ready); pick opens one idea from a choice; sharper
|
|
502
|
+
// asks for a new bet; answer saves the owner's answer to the question on
|
|
503
|
+
// screen; do it needs a second tap because it starts
|
|
504
|
+
// agents; not now only marks the bet skipped.
|
|
494
505
|
async function betAction(p, body) {
|
|
495
506
|
let out;
|
|
496
507
|
if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
|
|
508
|
+
else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || '') });
|
|
509
|
+
else if (p === '/bet/report') out = bet.report({ value: body.value });
|
|
510
|
+
else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
|
|
511
|
+
else if (p === '/bet/steer') out = await bet.talk({ member: String(body.member || ''), text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
|
|
512
|
+
else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
|
|
497
513
|
else if (p === '/bet/skip') out = bet.skip();
|
|
498
514
|
else if (p === '/bet/do') {
|
|
499
515
|
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
500
516
|
out = await bet.doIt();
|
|
501
517
|
} else if (p === '/bet/nice') {
|
|
502
518
|
out = bet.nice();
|
|
503
|
-
} else if (p === '/bet/
|
|
504
|
-
//
|
|
519
|
+
} else if (p === '/bet/yes') {
|
|
520
|
+
// a yes unblocks agents, so it needs a second tap
|
|
505
521
|
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
506
|
-
out =
|
|
522
|
+
out = await bet.answerYes();
|
|
507
523
|
} else return { status: 404, body: { ok: false, reason: 'not_found' } };
|
|
508
524
|
return { status: out.ok ? 200 : 409, body: out };
|
|
509
525
|
}
|
|
@@ -521,8 +537,10 @@ function createPhoneHandler(deps) {
|
|
|
521
537
|
};
|
|
522
538
|
|
|
523
539
|
async function handle(req, res) {
|
|
524
|
-
|
|
525
|
-
|
|
540
|
+
// the token is read on every request, so a rotated link is dead at once
|
|
541
|
+
const token = typeof deps.token === 'function' ? deps.token() : deps.token;
|
|
542
|
+
const check = checkRequest(req, { token: token || '', hosts: deps.hosts });
|
|
543
|
+
if (!check.ok) return send(res, check.status, check.status === 400 ? 'bad request' : check.status === 403 ? 'forbidden' : 'method not allowed', 'text/plain; charset=utf-8');
|
|
526
544
|
try {
|
|
527
545
|
if (req.method === 'GET' && check.path === '/') {
|
|
528
546
|
const nonce = crypto.randomBytes(16).toString('base64');
|
|
@@ -532,13 +550,15 @@ function createPhoneHandler(deps) {
|
|
|
532
550
|
}
|
|
533
551
|
if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
|
|
534
552
|
if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
|
|
553
|
+
if (req.method === 'GET' && check.path === '/bet/team') return bet ? send(res, 200, bet.teamTable()) : send(res, 404, { ok: false, reason: 'not_found' });
|
|
535
554
|
if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
|
|
536
|
-
if (
|
|
555
|
+
if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
|
|
537
556
|
let body;
|
|
538
557
|
try {
|
|
539
558
|
body = JSON.parse(await readBody(req) || '{}');
|
|
540
|
-
|
|
541
|
-
|
|
559
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('bad_body');
|
|
560
|
+
} catch (err) {
|
|
561
|
+
return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
|
|
542
562
|
}
|
|
543
563
|
if (check.path.startsWith('/bet/')) {
|
|
544
564
|
const out = await betAction(check.path, body || {});
|
|
@@ -636,6 +656,45 @@ function makeStepRunner({ cliPath, cwd, logDir }) {
|
|
|
636
656
|
});
|
|
637
657
|
}
|
|
638
658
|
|
|
659
|
+
// The phone link's token, one per workspace, kept in the home state dir so a
|
|
660
|
+
// restart gives the same link. Only this user can read the file (0600).
|
|
661
|
+
// rotate (atris gm phone --new-link) makes a new one; the old link stops working.
|
|
662
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/;
|
|
663
|
+
function phoneToken(workspaceRoot, { stateDir, rotate = false } = {}) {
|
|
664
|
+
const file = path.join(stateDir, 'gm-phone-links.json');
|
|
665
|
+
let links = {};
|
|
666
|
+
try {
|
|
667
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
668
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) links = parsed;
|
|
669
|
+
} catch {
|
|
670
|
+
links = {};
|
|
671
|
+
}
|
|
672
|
+
const saved = links[workspaceRoot] && links[workspaceRoot].token;
|
|
673
|
+
let token = saved;
|
|
674
|
+
let reused = true;
|
|
675
|
+
if (rotate || typeof saved !== 'string' || !TOKEN_SHAPE.test(saved)) {
|
|
676
|
+
token = crypto.randomBytes(32).toString('base64url');
|
|
677
|
+
reused = false;
|
|
678
|
+
links[workspaceRoot] = { token, created_at: new Date().toISOString() };
|
|
679
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
680
|
+
fs.writeFileSync(file, `${JSON.stringify(links, null, 2)}\n`, { mode: 0o600 });
|
|
681
|
+
}
|
|
682
|
+
// a file loosened by hand is tightened again
|
|
683
|
+
try { fs.chmodSync(file, 0o600); } catch { /* read-only state dir */ }
|
|
684
|
+
return { token, reused };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// The saved token for this workspace, or null; read fresh on every request.
|
|
688
|
+
function readPhoneToken(workspaceRoot, { stateDir } = {}) {
|
|
689
|
+
try {
|
|
690
|
+
const links = JSON.parse(fs.readFileSync(path.join(stateDir, 'gm-phone-links.json'), 'utf8'));
|
|
691
|
+
const t = links && links[workspaceRoot] && links[workspaceRoot].token;
|
|
692
|
+
return typeof t === 'string' && TOKEN_SHAPE.test(t) ? t : null;
|
|
693
|
+
} catch {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
639
698
|
// Snapshot of the target file, so the undo puts back exactly what was there.
|
|
640
699
|
function targetSnapshot(workspaceRoot, dir) {
|
|
641
700
|
const gmTarget = require('./gm-target');
|
|
@@ -671,6 +730,8 @@ module.exports = {
|
|
|
671
730
|
liveLine,
|
|
672
731
|
loadPhoneView,
|
|
673
732
|
makeStepRunner,
|
|
733
|
+
phoneToken,
|
|
734
|
+
readPhoneToken,
|
|
674
735
|
publicCard,
|
|
675
736
|
resolveChoice,
|
|
676
737
|
reviewerCommand,
|
package/lib/known-commands.js
CHANGED
|
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout', 'zen',
|
|
7
7
|
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
|
-
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
|
|
8
|
+
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
|
|
9
9
|
|
|
10
10
|
// Damerau-Levenshtein edit distance between two short strings. Counts an
|
|
11
11
|
// adjacent transposition (e.g. "taks" -> "task") as a single edit, since
|
package/lib/site.js
CHANGED
|
@@ -111,8 +111,16 @@ function serveSite(dir, port = 4321) {
|
|
|
111
111
|
const port = server.address() && server.address().port;
|
|
112
112
|
const host = String(req.headers.host || '').toLowerCase();
|
|
113
113
|
if (![`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`].includes(host)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
114
|
+
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); res.end('method not allowed'); return; }
|
|
115
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0) { res.writeHead(400); res.end('bad request'); return; }
|
|
114
116
|
let rel;
|
|
115
|
-
try {
|
|
117
|
+
try {
|
|
118
|
+
const target = req.url || '/';
|
|
119
|
+
new URL(target, 'http://localhost');
|
|
120
|
+
if (!target.startsWith('/') || target.startsWith('//') || /[\\#\0]/.test(target)) throw new Error('url');
|
|
121
|
+
decodeURIComponent(target);
|
|
122
|
+
rel = decodeURIComponent(target.split('?')[0]);
|
|
123
|
+
}
|
|
116
124
|
catch { res.writeHead(400); res.end('bad request'); return; }
|
|
117
125
|
// A null byte makes the fs calls below throw synchronously and crash the server.
|
|
118
126
|
if (rel.includes('\0')) { res.writeHead(400); res.end('bad request'); return; }
|
package/lib/try-repo.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Try page: someone pastes a public GitHub link and sees their repo's atris map.
|
|
4
|
+
// The repo is shallow-cloned into a throwaway folder with no git credentials,
|
|
5
|
+
// `atris init --minimal` runs there, a map with file:line refs is built from the
|
|
6
|
+
// tree, and the folder is deleted. Nothing is ever pushed or written to GitHub.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { detectProjectContext } = require('../commands/init');
|
|
15
|
+
|
|
16
|
+
const LIMITS = Object.freeze({ timeoutMs: 60_000, maxBytes: 200 * 1024 * 1024 });
|
|
17
|
+
const ATRIS_BIN = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
18
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'atris', '.atris', 'vendor', 'dist', 'build', 'target', '__pycache__', '.venv', 'venv', '.next', 'coverage']);
|
|
19
|
+
const LANGUAGES = {
|
|
20
|
+
'.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript', '.jsx': 'JavaScript',
|
|
21
|
+
'.ts': 'TypeScript', '.tsx': 'TypeScript', '.py': 'Python', '.go': 'Go', '.rs': 'Rust',
|
|
22
|
+
'.rb': 'Ruby', '.java': 'Java', '.kt': 'Kotlin', '.swift': 'Swift', '.c': 'C', '.h': 'C',
|
|
23
|
+
'.cc': 'C++', '.cpp': 'C++', '.hpp': 'C++', '.cs': 'C#', '.php': 'PHP', '.ex': 'Elixir',
|
|
24
|
+
'.exs': 'Elixir', '.scala': 'Scala', '.sh': 'Shell', '.vue': 'Vue', '.svelte': 'Svelte',
|
|
25
|
+
};
|
|
26
|
+
const SYMBOL_PATTERNS = {
|
|
27
|
+
JavaScript: [/^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, /^\s*(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/, /^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/, /^\s*export\s+const\s+([A-Za-z_$][\w$]*)/],
|
|
28
|
+
Python: [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/, /^class\s+([A-Za-z_]\w*)/],
|
|
29
|
+
Go: [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*[([]/, /^type\s+([A-Za-z_]\w*)\s+(?:struct|interface)/],
|
|
30
|
+
Rust: [/^\s*pub(?:\([^)]*\))?\s+(?:async\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)/, /^fn\s+([A-Za-z_]\w*)/],
|
|
31
|
+
Ruby: [/^\s*(?:class|module)\s+([A-Z]\w*)/, /^\s*def\s+(?:self\.)?([a-z_]\w*[?!]?)/],
|
|
32
|
+
Java: [/^\s*public\s+(?:final\s+|abstract\s+)*(?:class|interface|enum|record)\s+([A-Za-z_]\w*)/],
|
|
33
|
+
Kotlin: [/^\s*(?:data\s+|sealed\s+|open\s+)?(?:class|object|interface)\s+([A-Za-z_]\w*)/, /^fun\s+([A-Za-z_]\w*)/],
|
|
34
|
+
};
|
|
35
|
+
SYMBOL_PATTERNS.TypeScript = SYMBOL_PATTERNS.JavaScript;
|
|
36
|
+
const ENTRY_NAMES = /^(?:index|main|app|server|cli|lib|mod|__main__|manage)\.[a-z]+$/i;
|
|
37
|
+
|
|
38
|
+
function fail(message, code) {
|
|
39
|
+
const error = new Error(message);
|
|
40
|
+
error.code = code;
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Accepts https://github.com/o/r, github.com/o/r, git@github.com:o/r.git, or o/r.
|
|
45
|
+
// Anything else is refused before a single network call.
|
|
46
|
+
function parseRepoUrl(input) {
|
|
47
|
+
let text = String(input || '').trim();
|
|
48
|
+
if (!text) throw fail('Paste a public GitHub link, like github.com/owner/repo.', 'bad_url');
|
|
49
|
+
text = text.replace(/^git@github\.com:/i, 'github.com/').replace(/^https?:\/\//i, '').replace(/^www\./i, '');
|
|
50
|
+
const parts = text.split(/[?#]/)[0].split('/').filter(Boolean);
|
|
51
|
+
if (parts.length > 2 || /\./.test(parts[0] || '')) {
|
|
52
|
+
if (!/^github\.com$/i.test(parts[0] || '')) throw fail('Only public GitHub links work here.', 'bad_host');
|
|
53
|
+
parts.shift();
|
|
54
|
+
}
|
|
55
|
+
const owner = parts[0] || '';
|
|
56
|
+
const repo = (parts[1] || '').replace(/\.git$/i, '');
|
|
57
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) || !/^[A-Za-z0-9._-]{1,100}$/.test(repo) || /^\.+$/.test(repo)) {
|
|
58
|
+
throw fail('That does not look like a GitHub repo link. Try github.com/owner/repo.', 'bad_url');
|
|
59
|
+
}
|
|
60
|
+
const slug = `${owner}/${repo}`;
|
|
61
|
+
return { owner, repo, slug, webUrl: `https://github.com/${slug}`, cloneUrl: `https://github.com/${slug}.git` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getJson(url, { timeoutMs = 10_000 } = {}) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const req = https.get(url, { headers: { 'User-Agent': 'atris-try', Accept: 'application/vnd.github+json' }, timeout: timeoutMs }, (res) => {
|
|
67
|
+
let body = '';
|
|
68
|
+
res.setEncoding('utf8');
|
|
69
|
+
res.on('data', (chunk) => { if (body.length < 1_000_000) body += chunk; });
|
|
70
|
+
res.on('end', () => {
|
|
71
|
+
let json = null;
|
|
72
|
+
try { json = JSON.parse(body); } catch {}
|
|
73
|
+
resolve({ status: res.statusCode, json });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
req.on('timeout', () => req.destroy(fail('GitHub took too long to answer.', 'timeout')));
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Asks GitHub without credentials. A private repo answers 404 here, and the
|
|
82
|
+
// reported size lets us refuse a huge repo before cloning. A rate-limited or
|
|
83
|
+
// unreachable API returns null; the clone itself still has no credentials and
|
|
84
|
+
// a live size watch, so the caps hold either way.
|
|
85
|
+
async function checkPublicRepo(slug, { fetchJson = getJson, maxBytes = LIMITS.maxBytes } = {}) {
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetchJson(`https://api.github.com/repos/${slug}`);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (res.status === 404) throw fail('That repo is private or does not exist. Only public repos work here.', 'not_public');
|
|
93
|
+
if (res.status !== 200 || !res.json) return null;
|
|
94
|
+
if (res.json.private) throw fail('That repo is private. Only public repos work here.', 'not_public');
|
|
95
|
+
const sizeBytes = Number(res.json.size || 0) * 1024;
|
|
96
|
+
if (sizeBytes > maxBytes) throw fail(reasonText('too_big', maxBytes), 'too_big');
|
|
97
|
+
return { sizeBytes, defaultBranch: res.json.default_branch || null, stars: res.json.stargazers_count || 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Built from scratch, never copied from process.env, so no token on the host
|
|
101
|
+
// can reach git or atris. Isolated git config means no credential helper runs.
|
|
102
|
+
function sandboxEnv(home) {
|
|
103
|
+
return {
|
|
104
|
+
PATH: process.env.PATH || '/usr/bin:/bin',
|
|
105
|
+
HOME: home,
|
|
106
|
+
USER: 'visitor',
|
|
107
|
+
LANG: 'C.UTF-8',
|
|
108
|
+
TMPDIR: home,
|
|
109
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
110
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
111
|
+
GIT_CONFIG_GLOBAL: os.devNull,
|
|
112
|
+
GIT_ASKPASS: 'false',
|
|
113
|
+
CI: '1',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dirBytes(dir, stopAt = Infinity) {
|
|
118
|
+
let total = 0;
|
|
119
|
+
const stack = [dir];
|
|
120
|
+
while (stack.length) {
|
|
121
|
+
const current = stack.pop();
|
|
122
|
+
let entries;
|
|
123
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const full = path.join(current, entry.name);
|
|
126
|
+
if (entry.isDirectory()) stack.push(full);
|
|
127
|
+
else if (entry.isFile()) {
|
|
128
|
+
try { total += fs.lstatSync(full).size; } catch {}
|
|
129
|
+
if (total > stopAt) return total;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return total;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Runs a command and kills its whole process group at the deadline or when the
|
|
137
|
+
// watched folder grows past maxBytes.
|
|
138
|
+
function runBounded(cmd, args, { cwd, env, deadline, watchDir, maxBytes }) {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const remaining = deadline - Date.now();
|
|
141
|
+
if (remaining <= 0) return resolve({ code: null, reason: 'timeout', stderr: '' });
|
|
142
|
+
const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
|
|
143
|
+
let stderr = '';
|
|
144
|
+
let reason = null;
|
|
145
|
+
child.stdout.on('data', () => {});
|
|
146
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < 4000) stderr += chunk; });
|
|
147
|
+
const kill = (why) => {
|
|
148
|
+
if (reason) return;
|
|
149
|
+
reason = why;
|
|
150
|
+
try { process.platform === 'win32' ? child.kill('SIGKILL') : process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
151
|
+
};
|
|
152
|
+
const timer = setTimeout(() => kill('timeout'), remaining);
|
|
153
|
+
const watch = watchDir && maxBytes ? setInterval(() => {
|
|
154
|
+
if (dirBytes(watchDir, maxBytes) > maxBytes) kill('too_big');
|
|
155
|
+
}, 250) : null;
|
|
156
|
+
child.on('error', (error) => { stderr += String(error.message || error); });
|
|
157
|
+
child.on('close', (code) => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
if (watch) clearInterval(watch);
|
|
160
|
+
resolve({ code, reason, stderr });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function walkRepo(root, { maxFiles = 20_000 } = {}) {
|
|
166
|
+
const files = [];
|
|
167
|
+
const stack = [''];
|
|
168
|
+
while (stack.length && files.length < maxFiles) {
|
|
169
|
+
const rel = stack.pop();
|
|
170
|
+
let entries;
|
|
171
|
+
try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
172
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) stack.push(childRel);
|
|
177
|
+
} else if (entry.isFile()) {
|
|
178
|
+
let size = 0;
|
|
179
|
+
try { size = fs.lstatSync(path.join(root, childRel)).size; } catch {}
|
|
180
|
+
files.push({ rel: childRel, size });
|
|
181
|
+
if (files.length >= maxFiles) break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return files;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function findSymbols(root, rel, language, limit) {
|
|
189
|
+
const patterns = SYMBOL_PATTERNS[language];
|
|
190
|
+
if (!patterns) return [];
|
|
191
|
+
let text;
|
|
192
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return []; }
|
|
193
|
+
const symbols = [];
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
const lines = text.split('\n');
|
|
196
|
+
for (let i = 0; i < lines.length && symbols.length < limit; i++) {
|
|
197
|
+
for (const pattern of patterns) {
|
|
198
|
+
const match = lines[i].match(pattern);
|
|
199
|
+
if (match && !seen.has(match[1])) {
|
|
200
|
+
seen.add(match[1]);
|
|
201
|
+
symbols.push({ file: rel, line: i + 1, name: match[1] });
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return symbols;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Examples, docs, and fixtures are real files but not where the program lives,
|
|
210
|
+
// so they never count as a place to start reading.
|
|
211
|
+
function isSidePath(rel) {
|
|
212
|
+
return rel.split('/').slice(0, -1).some((part) => /^(examples?|samples?|docs?|demos?|benchmarks?|bench|fixtures?|testdata)$/i.test(part));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isTestPath(rel) {
|
|
216
|
+
return /(^|\/)(tests?|__tests__|spec)(\/|\.[a-z]+$|$)|[._-](test|spec)\.[a-z]+$|_test\.go$|^test_.*\.py$|\/test_[^/]*\.py$/i.test(rel);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The map itself: what the repo is made of, where to start reading, and the
|
|
220
|
+
// first named things in the files that matter most, each with a file:line ref.
|
|
221
|
+
function buildRepoMap(root, { maxSymbolFiles = 12, symbolsPerFile = 5 } = {}) {
|
|
222
|
+
const files = walkRepo(root);
|
|
223
|
+
const profile = detectProjectContext(root);
|
|
224
|
+
const byLanguage = new Map();
|
|
225
|
+
const byDir = new Map();
|
|
226
|
+
const codeByDir = new Map();
|
|
227
|
+
let bytes = 0;
|
|
228
|
+
let testFiles = 0;
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
bytes += file.size;
|
|
231
|
+
const language = LANGUAGES[path.extname(file.rel).toLowerCase()];
|
|
232
|
+
if (language) byLanguage.set(language, (byLanguage.get(language) || 0) + 1);
|
|
233
|
+
const top = file.rel.includes('/') ? `${file.rel.split('/')[0]}/` : '(top level)';
|
|
234
|
+
byDir.set(top, (byDir.get(top) || 0) + 1);
|
|
235
|
+
if (isTestPath(file.rel)) testFiles++;
|
|
236
|
+
else if (language && !isSidePath(file.rel) && top !== '(top level)') codeByDir.set(top, (codeByDir.get(top) || 0) + 1);
|
|
237
|
+
}
|
|
238
|
+
const languages = [...byLanguage.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, files: count }));
|
|
239
|
+
const topDirs = [...byDir.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([name, count]) => ({ name, files: count }));
|
|
240
|
+
const codeDirs = [...codeByDir.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, files: count }));
|
|
241
|
+
const codeFiles = files.filter((file) => LANGUAGES[path.extname(file.rel).toLowerCase()] && file.size < 512 * 1024 && !isTestPath(file.rel) && !isSidePath(file.rel));
|
|
242
|
+
const depth = (rel) => rel.split('/').length;
|
|
243
|
+
const entryPoints = codeFiles
|
|
244
|
+
.filter((file) => ENTRY_NAMES.test(path.basename(file.rel)) && depth(file.rel) <= 3)
|
|
245
|
+
.sort((a, b) => depth(a.rel) - depth(b.rel) || a.rel.localeCompare(b.rel))
|
|
246
|
+
.slice(0, 6)
|
|
247
|
+
.map((file) => file.rel);
|
|
248
|
+
const ranked = [
|
|
249
|
+
...entryPoints,
|
|
250
|
+
...codeFiles
|
|
251
|
+
.filter((file) => !entryPoints.includes(file.rel))
|
|
252
|
+
.sort((a, b) => depth(a.rel) - depth(b.rel) || b.size - a.size)
|
|
253
|
+
.map((file) => file.rel),
|
|
254
|
+
];
|
|
255
|
+
const symbols = [];
|
|
256
|
+
for (const rel of ranked) {
|
|
257
|
+
if (symbols.length >= maxSymbolFiles * symbolsPerFile) break;
|
|
258
|
+
const found = findSymbols(root, rel, LANGUAGES[path.extname(rel).toLowerCase()], symbolsPerFile);
|
|
259
|
+
if (found.length) symbols.push(...found);
|
|
260
|
+
if (new Set(symbols.map((s) => s.file)).size >= maxSymbolFiles) break;
|
|
261
|
+
}
|
|
262
|
+
const readme = files.find((file) => /^readme(\.|$)/i.test(file.rel));
|
|
263
|
+
return {
|
|
264
|
+
files: files.length,
|
|
265
|
+
bytes,
|
|
266
|
+
testFiles,
|
|
267
|
+
type: profile.type,
|
|
268
|
+
framework: profile.framework,
|
|
269
|
+
testCommand: profile.testCommand,
|
|
270
|
+
languages,
|
|
271
|
+
topDirs,
|
|
272
|
+
codeDirs,
|
|
273
|
+
entryPoints,
|
|
274
|
+
readme: readme ? readme.rel : null,
|
|
275
|
+
symbols,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Three moves atris would make first, picked from what the map shows.
|
|
280
|
+
function nextMoves(map) {
|
|
281
|
+
const moves = [];
|
|
282
|
+
const biggest = (map.codeDirs || [])[0];
|
|
283
|
+
moves.push(biggest
|
|
284
|
+
? `Fill in the map for ${biggest.name}, the biggest code folder with ${biggest.files} files, so any agent finds code there in one hop.`
|
|
285
|
+
: 'Fill in the map with line refs for every file, so any agent finds code in one hop.');
|
|
286
|
+
moves.push(map.testFiles
|
|
287
|
+
? `Run your ${map.testFiles} test files before every change and block the change if they fail.`
|
|
288
|
+
: 'Add a first test so every change has a check that can fail.');
|
|
289
|
+
moves.push(map.entryPoints.length
|
|
290
|
+
? `Start the task list from ${map.entryPoints[0]}, where the program begins.`
|
|
291
|
+
: 'Start a task list from the readme, one small change at a time.');
|
|
292
|
+
return moves;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function renderMapMarkdown(map, repo) {
|
|
296
|
+
const lines = [`# MAP.md`, '', `> Built by the atris try page from ${repo.webUrl}.`, ''];
|
|
297
|
+
lines.push(`## What this is`, '', `- ${map.files} files, ${map.languages.map((l) => `${l.name} (${l.files})`).join(', ') || 'no code files found'}`);
|
|
298
|
+
if (map.type !== 'unknown') lines.push(`- project type: ${map.type}${map.framework && !['none', map.type].includes(map.framework) ? ` (${map.framework})` : ''}`);
|
|
299
|
+
lines.push(`- test files: ${map.testFiles}`, '');
|
|
300
|
+
if (map.entryPoints.length) lines.push('## Start here', '', ...map.entryPoints.map((rel) => `- \`${rel}:1\``), '');
|
|
301
|
+
lines.push('## Folders', '', ...map.topDirs.map((dir) => `- \`${dir.name}\` ${dir.files} files`), '');
|
|
302
|
+
if (map.symbols.length) {
|
|
303
|
+
lines.push('## Where things live', '');
|
|
304
|
+
for (const symbol of map.symbols) lines.push(`- \`${symbol.file}:${symbol.line}\` \`${symbol.name}\``);
|
|
305
|
+
lines.push('');
|
|
306
|
+
}
|
|
307
|
+
return lines.join('\n');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function escapeHtml(text) {
|
|
311
|
+
return String(text).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const PAGE_STYLE = 'body{font:16px/1.5 system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 20px;color:#111}code,pre{font:14px ui-monospace,monospace}pre{background:#f5f5f4;padding:16px;overflow:auto;border-radius:6px}input{font:inherit;padding:8px;width:70%}button{font:inherit;padding:8px 14px}.err{color:#a1260d}';
|
|
315
|
+
|
|
316
|
+
function page(title, body) {
|
|
317
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${PAGE_STYLE}</style></head><body>${body}</body></html>`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderFormHtml({ error = '', value = '' } = {}) {
|
|
321
|
+
return page('see your repo through atris', `<h1>see your repo through atris</h1>
|
|
322
|
+
<p>paste a public github link. in about a minute you get the map atris would build for your code.</p>
|
|
323
|
+
<form action="/try" method="get"><input name="repo" placeholder="github.com/owner/repo" value="${escapeHtml(value)}" required> <button>map it</button></form>
|
|
324
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderResultHtml(result) {
|
|
328
|
+
if (!result.ok) return renderFormHtml({ error: result.error, value: result.input || '' });
|
|
329
|
+
const { map, repo } = result;
|
|
330
|
+
const found = `${map.files} files, mostly ${map.languages.slice(0, 3).map((l) => l.name).join(', ') || 'docs and config'}, with ${map.testFiles} test files.`;
|
|
331
|
+
return page(`${repo.slug} through atris`, `<h1>${escapeHtml(repo.slug)}</h1>
|
|
332
|
+
<h2>what atris found in your code</h2>
|
|
333
|
+
<p>${escapeHtml(found)}</p>
|
|
334
|
+
<pre>${escapeHtml(result.markdown)}</pre>
|
|
335
|
+
<h2>three moves atris would make next</h2>
|
|
336
|
+
<ol>${result.moves.map((move) => `<li>${escapeHtml(move)}</li>`).join('')}</ol>
|
|
337
|
+
<p>run it on your machine:</p>
|
|
338
|
+
<pre>npm i -g atris</pre>
|
|
339
|
+
<p><small>mapped in ${(result.ms / 1000).toFixed(1)}s. the copy was deleted after mapping. nothing was written to github.</small></p>`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function reasonText(reason, maxBytes) {
|
|
343
|
+
if (reason === 'too_big') return `That repo is over ${Math.max(1, Math.round(maxBytes / 1048576))} MB. The try page maps smaller repos.`;
|
|
344
|
+
return 'That repo took longer than a minute to map. Try a smaller one.';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function tryRepo(input, opts = {}) {
|
|
348
|
+
const started = Date.now();
|
|
349
|
+
const timeoutMs = opts.timeoutMs || LIMITS.timeoutMs;
|
|
350
|
+
const maxBytes = opts.maxBytes || LIMITS.maxBytes;
|
|
351
|
+
const deadline = started + timeoutMs;
|
|
352
|
+
let sandbox = null;
|
|
353
|
+
try {
|
|
354
|
+
const repo = parseRepoUrl(input);
|
|
355
|
+
await checkPublicRepo(repo.slug, { fetchJson: opts.fetchJson, maxBytes });
|
|
356
|
+
sandbox = fs.mkdtempSync(path.join(opts.tmpRoot || os.tmpdir(), 'atris-try-'));
|
|
357
|
+
const home = path.join(sandbox, 'home');
|
|
358
|
+
const dir = path.join(sandbox, 'repo');
|
|
359
|
+
fs.mkdirSync(home);
|
|
360
|
+
const env = sandboxEnv(home);
|
|
361
|
+
const clone = await runBounded('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--no-tags', '--', opts.cloneUrl || repo.cloneUrl, dir], { cwd: sandbox, env, deadline, watchDir: sandbox, maxBytes });
|
|
362
|
+
if (clone.reason) throw fail(reasonText(clone.reason, maxBytes), clone.reason);
|
|
363
|
+
if (clone.code !== 0) throw fail('Could not copy that repo. It may be private, empty, or gone.', 'clone_failed');
|
|
364
|
+
if (dirBytes(dir, maxBytes) > maxBytes) throw fail(reasonText('too_big', maxBytes), 'too_big');
|
|
365
|
+
const init = await runBounded(process.execPath, [ATRIS_BIN, 'init', '--minimal', '--yes', '--no-commit'], { cwd: dir, env, deadline });
|
|
366
|
+
if (init.reason) throw fail(reasonText(init.reason, maxBytes), init.reason);
|
|
367
|
+
if (init.code !== 0) throw fail('atris could not set up in that repo.', 'init_failed');
|
|
368
|
+
const map = buildRepoMap(dir);
|
|
369
|
+
const markdown = renderMapMarkdown(map, repo);
|
|
370
|
+
fs.writeFileSync(path.join(dir, 'atris', 'MAP.md'), markdown);
|
|
371
|
+
const result = { ok: true, input, repo, map, markdown, moves: nextMoves(map), ms: Date.now() - started };
|
|
372
|
+
result.html = renderResultHtml(result);
|
|
373
|
+
return result;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const result = { ok: false, input, code: error.code || 'error', error: error.code ? error.message : 'Something went wrong mapping that repo.', ms: Date.now() - started };
|
|
376
|
+
result.html = renderResultHtml(result);
|
|
377
|
+
return result;
|
|
378
|
+
} finally {
|
|
379
|
+
if (sandbox && !opts.keep) fs.rmSync(sandbox, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Small public server. Each visitor gets one map at a time and a few per window,
|
|
384
|
+
// and the whole box runs a bounded number of maps at once.
|
|
385
|
+
function createTryServer({ run = tryRepo, perWindow = 5, windowMs = 10 * 60_000, maxConcurrent = 2, trustProxy = false } = {}) {
|
|
386
|
+
const visits = new Map();
|
|
387
|
+
const busy = new Set();
|
|
388
|
+
let running = 0;
|
|
389
|
+
const send = (res, status, html) => {
|
|
390
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
391
|
+
res.end(html);
|
|
392
|
+
};
|
|
393
|
+
return http.createServer(async (req, res) => {
|
|
394
|
+
const url = new URL(req.url, 'http://localhost');
|
|
395
|
+
if (req.method !== 'GET') return send(res, 405, renderFormHtml({ error: 'Use the form.' }));
|
|
396
|
+
if (url.pathname === '/') return send(res, 200, renderFormHtml());
|
|
397
|
+
if (url.pathname !== '/try') return send(res, 404, renderFormHtml({ error: 'Nothing here.' }));
|
|
398
|
+
const forwarded = trustProxy ? String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() : '';
|
|
399
|
+
const ip = forwarded || req.socket.remoteAddress || 'unknown';
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const recent = (visits.get(ip) || []).filter((t) => now - t < windowMs);
|
|
402
|
+
if (busy.has(ip) || recent.length >= perWindow) {
|
|
403
|
+
return send(res, 429, renderFormHtml({ error: 'You have mapped a few repos already. Try again in a few minutes.' }));
|
|
404
|
+
}
|
|
405
|
+
if (running >= maxConcurrent) return send(res, 503, renderFormHtml({ error: 'The page is busy right now. Try again in a minute.' }));
|
|
406
|
+
recent.push(now);
|
|
407
|
+
visits.set(ip, recent);
|
|
408
|
+
busy.add(ip);
|
|
409
|
+
running++;
|
|
410
|
+
try {
|
|
411
|
+
const result = await run(url.searchParams.get('repo') || '');
|
|
412
|
+
send(res, result.ok ? 200 : 400, result.html);
|
|
413
|
+
} finally {
|
|
414
|
+
busy.delete(ip);
|
|
415
|
+
running--;
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Host the page: `PORT=8080 TRY_TRUST_PROXY=1 node lib/try-repo.js`.
|
|
421
|
+
// Trust the proxy header only behind a proxy you run, or visitors can fake it.
|
|
422
|
+
function startTryServer(env = process.env) {
|
|
423
|
+
const port = Number(env.PORT) || 8787;
|
|
424
|
+
const host = env.HOST || '0.0.0.0';
|
|
425
|
+
const server = createTryServer({ trustProxy: env.TRY_TRUST_PROXY === '1' });
|
|
426
|
+
server.listen(port, host, () => console.log(`try page listening on http://${host}:${server.address().port}`));
|
|
427
|
+
return server;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (require.main === module) startTryServer();
|
|
431
|
+
|
|
432
|
+
module.exports = {
|
|
433
|
+
parseRepoUrl,
|
|
434
|
+
checkPublicRepo,
|
|
435
|
+
sandboxEnv,
|
|
436
|
+
buildRepoMap,
|
|
437
|
+
renderMapMarkdown,
|
|
438
|
+
renderResultHtml,
|
|
439
|
+
nextMoves,
|
|
440
|
+
tryRepo,
|
|
441
|
+
createTryServer,
|
|
442
|
+
};
|