promptgraph-mcp 2.5.0 → 2.6.1
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 +31 -10
- package/api.js +4 -2
- package/bundle-counts.js +3 -2
- package/chunker.js +1 -1
- package/commands/bundle.js +150 -0
- package/commands/doctor.js +15 -0
- package/commands/import.js +7 -0
- package/commands/init.js +37 -0
- package/commands/marketplace.js +114 -0
- package/commands/reindex.js +10 -0
- package/commands/search.js +55 -0
- package/commands/setup.js +19 -0
- package/commands/status.js +110 -0
- package/commands/train.js +18 -0
- package/commands/update.js +49 -0
- package/commands/validate.js +63 -0
- package/db.js +125 -111
- package/github-import.js +10 -8
- package/index.js +20 -598
- package/indexer.js +1 -8
- package/marketplace.js +44 -7
- package/package.json +3 -2
- package/search.js +20 -5
- package/src/reranker/reranker.js +76 -11
- package/src/store/index.js +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { spawnSync } = await import('child_process');
|
|
6
|
+
const { createRequire } = await import('module');
|
|
7
|
+
const https = (await import('https')).default;
|
|
8
|
+
const req = createRequire(import.meta.url);
|
|
9
|
+
const currentVersion = req('../package.json').version;
|
|
10
|
+
|
|
11
|
+
const spin = (await import('../cli.js')).spinner('Checking latest version...');
|
|
12
|
+
spin.start();
|
|
13
|
+
let latest = null;
|
|
14
|
+
try {
|
|
15
|
+
latest = await new Promise((res, rej) => {
|
|
16
|
+
const r = https.get('https://registry.npmjs.org/promptgraph-mcp/latest',
|
|
17
|
+
{ headers: { Accept: 'application/json' }, timeout: 8000 },
|
|
18
|
+
(resp) => {
|
|
19
|
+
let d = ''; resp.setEncoding('utf8');
|
|
20
|
+
resp.on('data', c => d += c);
|
|
21
|
+
resp.on('end', () => { try { res(JSON.parse(d).version); } catch { rej(new Error('bad response')); } });
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
r.on('error', rej);
|
|
25
|
+
r.on('timeout', () => { r.destroy(new Error('timeout')); });
|
|
26
|
+
});
|
|
27
|
+
} catch {}
|
|
28
|
+
spin.stop();
|
|
29
|
+
|
|
30
|
+
if (!latest) { error('Could not reach npm registry. Check your network.'); process.exit(1); }
|
|
31
|
+
if (latest === currentVersion) {
|
|
32
|
+
success(`Already on latest version ${chalk.white.bold('v' + currentVersion)}`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
info(`Current: ${chalk.gray('v' + currentVersion)} → Latest: ${chalk.white.bold('v' + latest)}`);
|
|
37
|
+
const updateSpin = (await import('../cli.js')).spinner(`Installing promptgraph-mcp@latest (v${latest})...`);
|
|
38
|
+
updateSpin.start();
|
|
39
|
+
const result = spawnSync('npm', ['install', '-g', 'promptgraph-mcp@latest'], { encoding: 'utf8', stdio: 'pipe', shell: true });
|
|
40
|
+
updateSpin.stop();
|
|
41
|
+
|
|
42
|
+
if (result.status !== 0) {
|
|
43
|
+
error('Update failed:');
|
|
44
|
+
console.log(chalk.gray(result.stderr || result.stdout));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
success(`Updated to ${chalk.white.bold('v' + latest)}`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export default async function handler(args, bin) {
|
|
6
|
+
const { validateSkill } = await import('../validator.js');
|
|
7
|
+
const file = args[1];
|
|
8
|
+
if (!file) { error('Usage: ' + bin + ' validate <skill.md>'); process.exit(1); }
|
|
9
|
+
|
|
10
|
+
const raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
11
|
+
|
|
12
|
+
if (raw) {
|
|
13
|
+
const { filterWithClassifier, isSkillFile: _isSkill } = await import('../parser.js');
|
|
14
|
+
const { hardFilter } = await import('../src/filter/hard-filter.js');
|
|
15
|
+
const { loadModel } = await import('../src/filter/train.js');
|
|
16
|
+
const { embed } = await import('../embedder.js');
|
|
17
|
+
const { classify } = await import('../src/filter/classifier.js');
|
|
18
|
+
|
|
19
|
+
const hfResult = hardFilter(file, raw);
|
|
20
|
+
const willIndex = _isSkill(file, raw);
|
|
21
|
+
const scoreLabel = willIndex ? chalk.green('✓ will be indexed') : chalk.red('✗ will be skipped by indexer');
|
|
22
|
+
console.log(chalk.bold('\n Indexing check: ') + scoreLabel);
|
|
23
|
+
|
|
24
|
+
const signals = [];
|
|
25
|
+
if (!hfResult.pass) {
|
|
26
|
+
signals.push(chalk.red(`✗ hard filter: ${hfResult.reason}`));
|
|
27
|
+
} else {
|
|
28
|
+
signals.push(chalk.green('✓ hard filter passed'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const centroids = loadModel();
|
|
32
|
+
if (centroids) {
|
|
33
|
+
try {
|
|
34
|
+
const vec = await embed(raw);
|
|
35
|
+
const decision = classify(vec, centroids, raw, file);
|
|
36
|
+
const pct = (decision.score * 100).toFixed(0);
|
|
37
|
+
if (decision.label === 'skill') signals.push(chalk.green(`✓ classifier: skill (${pct}%)`));
|
|
38
|
+
else if (decision.label === 'unsure') signals.push(chalk.yellow(`? classifier: unsure (${pct}%)`));
|
|
39
|
+
else signals.push(chalk.red(`✗ classifier: reject (${pct}%)`));
|
|
40
|
+
} catch {
|
|
41
|
+
signals.push(chalk.gray(' classifier: embed failed (skip)'));
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
signals.push(chalk.gray(' classifier: no model (run `pg train`)'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (signals.length) {
|
|
48
|
+
signals.forEach(s => console.log(' ' + s));
|
|
49
|
+
}
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = validateSkill(file);
|
|
54
|
+
result.warnings.forEach(w => console.log(chalk.yellow('⚠') + ' ' + chalk.gray(w)));
|
|
55
|
+
if (result.ok) {
|
|
56
|
+
success('Skill is valid — ready to publish');
|
|
57
|
+
process.exit(0);
|
|
58
|
+
} else {
|
|
59
|
+
error('Validation failed:');
|
|
60
|
+
result.errors.forEach(e => console.log(' ' + chalk.red('•') + ' ' + e));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/db.js
CHANGED
|
@@ -7,124 +7,138 @@ const DB_PATH = path.join(PROMPTGRAPH_DIR, 'promptgraph.db');
|
|
|
7
7
|
|
|
8
8
|
let _db = null;
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
10
|
+
const MIGRATIONS = [
|
|
11
|
+
{
|
|
12
|
+
version: 1, description: 'initial schema',
|
|
13
|
+
up: db => db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
description TEXT,
|
|
18
|
+
path TEXT NOT NULL,
|
|
19
|
+
source TEXT NOT NULL,
|
|
20
|
+
content TEXT NOT NULL,
|
|
21
|
+
hash TEXT
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
skill_id TEXT NOT NULL,
|
|
26
|
+
chunk_index INTEGER NOT NULL,
|
|
27
|
+
text TEXT NOT NULL,
|
|
28
|
+
embedding BLOB NOT NULL,
|
|
29
|
+
UNIQUE(skill_id, chunk_index)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
32
|
+
from_skill TEXT NOT NULL,
|
|
33
|
+
to_skill TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (from_skill, to_skill)
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS ratings (
|
|
37
|
+
skill_id TEXT PRIMARY KEY,
|
|
38
|
+
uses INTEGER DEFAULT 0,
|
|
39
|
+
success INTEGER DEFAULT 0,
|
|
40
|
+
fail INTEGER DEFAULT 0
|
|
41
|
+
);
|
|
42
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
43
|
+
id UNINDEXED, name, description, content,
|
|
44
|
+
content='skills', content_rowid='rowid'
|
|
45
|
+
);
|
|
46
|
+
`),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
version: 2, description: 'add hash column',
|
|
50
|
+
up: db => {
|
|
51
|
+
const cols = db.pragma('table_info(skills)').map(c => c.name);
|
|
52
|
+
if (!cols.includes('hash')) db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
version: 3, description: 'add registry metadata columns',
|
|
57
|
+
up: db => {
|
|
58
|
+
const cols = db.pragma('table_info(skills)').map(c => c.name);
|
|
59
|
+
for (const [col, def] of [
|
|
60
|
+
['version', 'TEXT'],
|
|
61
|
+
['author', 'TEXT'],
|
|
62
|
+
['license', 'TEXT'],
|
|
63
|
+
['updated_at', 'TEXT'],
|
|
64
|
+
['downloads', 'INTEGER DEFAULT 0'],
|
|
65
|
+
['verified', 'INTEGER DEFAULT 0'],
|
|
66
|
+
['trust_level', "TEXT DEFAULT 'unknown'"],
|
|
67
|
+
['rating', 'REAL DEFAULT 0'],
|
|
68
|
+
['rating_count', 'INTEGER DEFAULT 0'],
|
|
69
|
+
['popularity', 'REAL DEFAULT 0'],
|
|
70
|
+
['last_update', 'TEXT'],
|
|
71
|
+
]) {
|
|
72
|
+
if (!cols.includes(col)) db.exec(`ALTER TABLE skills ADD COLUMN ${col} ${def}`);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
version: 4, description: 'registry_entries table',
|
|
78
|
+
up: db => db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS registry_entries (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
trust_level TEXT DEFAULT 'unknown',
|
|
82
|
+
downloads INTEGER DEFAULT 0,
|
|
83
|
+
rating REAL DEFAULT 0,
|
|
84
|
+
rating_count INTEGER DEFAULT 0,
|
|
85
|
+
popularity REAL DEFAULT 0,
|
|
86
|
+
last_update TEXT
|
|
87
|
+
);
|
|
88
|
+
`),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
version: 5, description: 'convert TEXT embeddings to Float32 BLOB',
|
|
92
|
+
up: db => {
|
|
93
|
+
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
|
94
|
+
if (textEmbeddings?.n > 0) {
|
|
95
|
+
const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
|
|
96
|
+
const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
|
|
97
|
+
db.transaction(() => {
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const vec = JSON.parse(row.embedding);
|
|
100
|
+
upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
function getSchemaVersion(db) {
|
|
110
|
+
try {
|
|
111
|
+
return db.prepare('SELECT MAX(version) as v FROM _schema_version').get().v || 0
|
|
112
|
+
} catch {
|
|
113
|
+
return 0
|
|
64
114
|
}
|
|
115
|
+
}
|
|
65
116
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!cols.includes('author')) {
|
|
71
|
-
db.exec('ALTER TABLE skills ADD COLUMN author TEXT');
|
|
72
|
-
}
|
|
73
|
-
if (!cols.includes('license')) {
|
|
74
|
-
db.exec('ALTER TABLE skills ADD COLUMN license TEXT');
|
|
75
|
-
}
|
|
76
|
-
if (!cols.includes('updated_at')) {
|
|
77
|
-
db.exec('ALTER TABLE skills ADD COLUMN updated_at TEXT');
|
|
78
|
-
}
|
|
79
|
-
if (!cols.includes('downloads')) {
|
|
80
|
-
db.exec('ALTER TABLE skills ADD COLUMN downloads INTEGER DEFAULT 0');
|
|
81
|
-
}
|
|
82
|
-
if (!cols.includes('verified')) {
|
|
83
|
-
db.exec('ALTER TABLE skills ADD COLUMN verified INTEGER DEFAULT 0');
|
|
84
|
-
}
|
|
85
|
-
if (!cols.includes('trust_level')) {
|
|
86
|
-
db.exec('ALTER TABLE skills ADD COLUMN trust_level TEXT DEFAULT \'unknown\'');
|
|
87
|
-
}
|
|
88
|
-
if (!cols.includes('rating')) {
|
|
89
|
-
db.exec('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
|
|
90
|
-
}
|
|
91
|
-
if (!cols.includes('rating_count')) {
|
|
92
|
-
db.exec('ALTER TABLE skills ADD COLUMN rating_count INTEGER DEFAULT 0');
|
|
93
|
-
}
|
|
94
|
-
if (!cols.includes('popularity')) {
|
|
95
|
-
db.exec('ALTER TABLE skills ADD COLUMN popularity REAL DEFAULT 0');
|
|
96
|
-
}
|
|
97
|
-
if (!cols.includes('last_update')) {
|
|
98
|
-
db.exec('ALTER TABLE skills ADD COLUMN last_update TEXT');
|
|
99
|
-
}
|
|
117
|
+
function runMigrations(db) {
|
|
118
|
+
const current = getSchemaVersion(db)
|
|
119
|
+
const pending = MIGRATIONS.filter(m => m.version > current).sort((a, b) => a.version - b.version)
|
|
120
|
+
if (!pending.length) return
|
|
100
121
|
|
|
101
|
-
|
|
102
|
-
db.exec(`
|
|
103
|
-
CREATE TABLE IF NOT EXISTS registry_entries (
|
|
104
|
-
id TEXT PRIMARY KEY,
|
|
105
|
-
trust_level TEXT DEFAULT 'unknown',
|
|
106
|
-
downloads INTEGER DEFAULT 0,
|
|
107
|
-
rating REAL DEFAULT 0,
|
|
108
|
-
rating_count INTEGER DEFAULT 0,
|
|
109
|
-
popularity REAL DEFAULT 0,
|
|
110
|
-
last_update TEXT
|
|
111
|
-
);
|
|
112
|
-
`);
|
|
122
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT)`)
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
|
116
|
-
if (textEmbeddings?.n > 0) {
|
|
117
|
-
const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
|
|
118
|
-
const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
|
|
124
|
+
for (const migration of pending) {
|
|
119
125
|
db.transaction(() => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
})()
|
|
125
|
-
console.error(`[PromptGraph]
|
|
126
|
+
migration.up(db)
|
|
127
|
+
db.prepare('INSERT INTO _schema_version (version, description, applied_at) VALUES (?, ?, ?)').run(
|
|
128
|
+
migration.version, migration.description, new Date().toISOString()
|
|
129
|
+
)
|
|
130
|
+
})()
|
|
131
|
+
console.error(`[PromptGraph] DB migrated to v${migration.version}: ${migration.description}`)
|
|
126
132
|
}
|
|
133
|
+
}
|
|
127
134
|
|
|
135
|
+
export function getDb() {
|
|
136
|
+
if (_db) return _db;
|
|
137
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
138
|
+
const db = new Database(DB_PATH);
|
|
139
|
+
_db = db;
|
|
140
|
+
db.pragma('journal_mode = WAL');
|
|
141
|
+
runMigrations(db);
|
|
128
142
|
return db;
|
|
129
143
|
}
|
|
130
144
|
|
package/github-import.js
CHANGED
|
@@ -25,21 +25,22 @@ function getRepoStats(ownerRepo) {
|
|
|
25
25
|
return repoStats.get(ownerRepo)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE) {
|
|
28
|
+
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE, redirects = 0) {
|
|
29
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
29
30
|
return new Promise((res, rej) => {
|
|
30
31
|
const token = process.env.GITHUB_TOKEN;
|
|
31
32
|
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
32
33
|
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
33
34
|
const req = https.get(url, { headers }, r => {
|
|
34
35
|
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
35
|
-
return streamDownload(r.headers.location, maxSize).then(res, rej);
|
|
36
|
+
return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
|
|
36
37
|
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
37
38
|
const cl = parseInt(r.headers['content-length'], 10);
|
|
38
39
|
if (!isNaN(cl) && cl > maxSize) {
|
|
39
40
|
r.resume();
|
|
40
41
|
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
const chunks = []
|
|
43
44
|
let total = 0
|
|
44
45
|
r.setEncoding('utf8')
|
|
45
46
|
r.on('data', c => {
|
|
@@ -48,15 +49,16 @@ function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE) {
|
|
|
48
49
|
r.destroy()
|
|
49
50
|
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
chunks.push(c)
|
|
52
53
|
})
|
|
53
|
-
r.on('end', () => res(
|
|
54
|
+
r.on('end', () => res(chunks.join('')))
|
|
54
55
|
})
|
|
55
56
|
req.on('error', rej)
|
|
56
57
|
})
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
async function httpsGet(url) {
|
|
60
|
+
async function httpsGet(url, redirects = 0) {
|
|
61
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
60
62
|
await githubRateLimiter.acquire()
|
|
61
63
|
const token = process.env.GITHUB_TOKEN;
|
|
62
64
|
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
@@ -64,9 +66,9 @@ async function httpsGet(url) {
|
|
|
64
66
|
return new Promise((res, rej) => {
|
|
65
67
|
const req = https.get(url, { headers }, r => {
|
|
66
68
|
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
67
|
-
return httpsGet(r.headers.location).then(res, rej);
|
|
69
|
+
return httpsGet(r.headers.location, redirects + 1).then(res, rej);
|
|
68
70
|
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
69
|
-
|
|
71
|
+
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
70
72
|
});
|
|
71
73
|
req.on('error', rej);
|
|
72
74
|
});
|