agentproc 0.2.1 → 0.4.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/package.json +2 -2
- package/src/cli.js +346 -99
- package/src/conformance.test.js +29 -0
- package/src/hub.js +483 -0
- package/src/hub.test.js +345 -0
- package/src/runner.js +239 -18
package/src/hub.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Hub client — fetch and manage profile directories from the official Hub.
|
|
4
|
+
*
|
|
5
|
+
* The Hub lives at https://github.com/jeffkit/agentproc/tree/main/hub/
|
|
6
|
+
* Profiles are cached locally at ~/.agentproc/cache/hub/<name>/ with a
|
|
7
|
+
* 24-hour TTL. Pass refresh=true to force re-fetch.
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
* HUB_REPO — 'jeffkit/agentproc'
|
|
11
|
+
* HUB_REF — 'main'
|
|
12
|
+
* HUB_CACHE_TTL_SECS — 24 hours
|
|
13
|
+
* cacheDir(name) — Path to the local cache directory for a profile
|
|
14
|
+
* fetchProfile(name, opts) -> Promise<string>
|
|
15
|
+
* listProfiles(opts) -> Promise<Array<{name, description, cli, tested}>>
|
|
16
|
+
* showReadme(name, opts) -> Promise<string>
|
|
17
|
+
* installProfile(name, targetDir, opts) -> Promise<string>
|
|
18
|
+
*
|
|
19
|
+
* All network access is via global fetch() (Node 18+). Zero dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
|
|
26
|
+
const HUB_REPO = 'jeffkit/agentproc';
|
|
27
|
+
const HUB_REF = 'main';
|
|
28
|
+
const HUB_CACHE_TTL_SECS = 24 * 60 * 60; // 24 hours
|
|
29
|
+
|
|
30
|
+
const GITHUB_API = (subpath) =>
|
|
31
|
+
`https://api.github.com/repos/${HUB_REPO}/contents/${subpath}?ref=${HUB_REF}`;
|
|
32
|
+
const GITHUB_TREES = `https://api.github.com/repos/${HUB_REPO}/git/trees/${HUB_REF}?recursive=1`;
|
|
33
|
+
const GITHUB_RAW = (p) =>
|
|
34
|
+
`https://raw.githubusercontent.com/${HUB_REPO}/${HUB_REF}/${p}`;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Cache helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function cacheRoot() {
|
|
41
|
+
// Prefer process.env.HOME (overridable in tests, set by sudo -E etc.),
|
|
42
|
+
// fall back to os.homedir() (cached at first call on some platforms).
|
|
43
|
+
const home = process.env.HOME || os.homedir();
|
|
44
|
+
return path.join(home, '.agentproc', 'cache', 'hub');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cacheDir(name) {
|
|
48
|
+
return path.join(cacheRoot(), name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cacheAgeSecs(name) {
|
|
52
|
+
const marker = path.join(cacheDir(name), '.cache-meta.json');
|
|
53
|
+
if (!fs.existsSync(marker)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const meta = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
56
|
+
const ts = meta.fetched_at || 0;
|
|
57
|
+
return Math.max(0, Date.now() / 1000 - ts);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeCacheMeta(name) {
|
|
64
|
+
const dir = cacheDir(name);
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(dir, '.cache-meta.json'),
|
|
68
|
+
JSON.stringify({ fetched_at: Date.now() / 1000, ref: HUB_REF }),
|
|
69
|
+
'utf8'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// HTTP helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Custom error type for hub fetch failures. Carries a short, user-facing
|
|
79
|
+
* `hint` with remediation, so the CLI can print something helpful instead
|
|
80
|
+
* of a raw Node stack trace.
|
|
81
|
+
*/
|
|
82
|
+
class HubError extends Error {
|
|
83
|
+
constructor(message, { hint = '', cause = null, status = 0 } = {}) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'HubError';
|
|
86
|
+
this.hint = hint;
|
|
87
|
+
this.status = status;
|
|
88
|
+
if (cause) this.cause = cause;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function authHeaders({ json = false } = {}) {
|
|
93
|
+
// Optional: an explicit token raises GitHub's anonymous rate limit from
|
|
94
|
+
// 60 req/hour to 5,000. We accept either GITHUB_TOKEN (the env var GitHub
|
|
95
|
+
// Actions injects) or GH_TOKEN (what `gh` CLI users typically have).
|
|
96
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
|
97
|
+
const h = { 'User-Agent': 'agentproc-cli' };
|
|
98
|
+
if (json) h.Accept = 'application/vnd.github+json';
|
|
99
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
100
|
+
return h;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function httpGetJson(url) {
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = await fetch(url, { headers: authHeaders({ json: true }) });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new HubError(
|
|
109
|
+
`could not reach GitHub while fetching hub profile`,
|
|
110
|
+
{
|
|
111
|
+
status: 0,
|
|
112
|
+
cause: e,
|
|
113
|
+
hint: [
|
|
114
|
+
'This is usually a transient network issue. Try:',
|
|
115
|
+
' 1. Re-run the command (often succeeds on retry).',
|
|
116
|
+
' 2. If your network requires a proxy, set HTTPS_PROXY.',
|
|
117
|
+
' 3. To avoid the network entirely, run against a local checkout:',
|
|
118
|
+
' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
|
|
119
|
+
].join('\n'),
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!r.ok) {
|
|
124
|
+
const text = await r.text().catch(() => '');
|
|
125
|
+
if (r.status === 403 || r.status === 429) {
|
|
126
|
+
const authed = !!(process.env.GITHUB_TOKEN || process.env.GH_TOKEN);
|
|
127
|
+
throw new HubError(
|
|
128
|
+
`GitHub rate-limited the hub fetch (HTTP ${r.status})`,
|
|
129
|
+
{
|
|
130
|
+
status: r.status,
|
|
131
|
+
hint: authed
|
|
132
|
+
? [
|
|
133
|
+
'Your GITHUB_TOKEN is set but still rate-limited. Wait a few minutes and retry,',
|
|
134
|
+
'or run against a local checkout instead:',
|
|
135
|
+
' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
|
|
136
|
+
'',
|
|
137
|
+
`Not sure the profile name is right? Check with: agentproc hub list`,
|
|
138
|
+
].join('\n')
|
|
139
|
+
: [
|
|
140
|
+
'GitHub limits anonymous hub fetches to ~60/hour. To raise this to 5,000/hour:',
|
|
141
|
+
' export GITHUB_TOKEN=$(gh auth token) # if you have the GitHub CLI',
|
|
142
|
+
' # or set GITHUB_TOKEN to any personal access token',
|
|
143
|
+
'',
|
|
144
|
+
'To skip the network entirely, run against a local checkout:',
|
|
145
|
+
' git clone https://github.com/jeffkit/agentproc && cd agentproc',
|
|
146
|
+
' agentproc --profile ./hub/<name>/profile.yaml --prompt "hi"',
|
|
147
|
+
'',
|
|
148
|
+
`Not sure the profile name is right? Check with: agentproc hub list`,
|
|
149
|
+
].join('\n'),
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (r.status === 404) {
|
|
154
|
+
throw new HubError(`profile not found on GitHub (HTTP 404)`, {
|
|
155
|
+
status: 404,
|
|
156
|
+
hint: 'Check the profile name with `agentproc hub list`. (Typos are case-sensitive.)',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
throw new HubError(`GitHub returned HTTP ${r.status} for hub fetch`, {
|
|
160
|
+
status: r.status,
|
|
161
|
+
hint: text.slice(0, 200) || 'No additional detail from GitHub.',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return r.json();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function httpGetText(url) {
|
|
168
|
+
const r = await fetch(url, { headers: authHeaders({ json: false }) });
|
|
169
|
+
if (!r.ok) {
|
|
170
|
+
// raw.githubusercontent.com is essentially unrate-limited; a failure
|
|
171
|
+
// here is more likely a genuine 404 (profile file missing) than 403.
|
|
172
|
+
throw new HubError(`fetch failed (HTTP ${r.status}) for ${url}`, {
|
|
173
|
+
status: r.status,
|
|
174
|
+
hint: 'Profile files should exist in the hub repo. Try `agentproc hub list` to verify.',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return r.text();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fetch the entire repo tree (1 API call, returns all paths under hub/).
|
|
182
|
+
* Cached in memory for the lifetime of the process.
|
|
183
|
+
* @returns {Promise<Array<{path: string, type: 'blob'|'tree'}>>}
|
|
184
|
+
*/
|
|
185
|
+
let _treeCache = null;
|
|
186
|
+
async function getTree() {
|
|
187
|
+
if (_treeCache) return _treeCache;
|
|
188
|
+
const data = await httpGetJson(GITHUB_TREES);
|
|
189
|
+
if (!data || !Array.isArray(data.tree)) {
|
|
190
|
+
throw new Error('unexpected tree API response');
|
|
191
|
+
}
|
|
192
|
+
_treeCache = data.tree
|
|
193
|
+
.filter((e) => e && typeof e === 'object')
|
|
194
|
+
.map((e) => ({
|
|
195
|
+
path: String(e.path || ''),
|
|
196
|
+
type: String(e.type || ''), // 'blob' or 'tree'
|
|
197
|
+
}));
|
|
198
|
+
return _treeCache;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* List top-level entries under a hub subpath (e.g. 'hub/' → all profile dirs).
|
|
203
|
+
* @param {string} subpath e.g. 'hub' or 'hub/claude-code'
|
|
204
|
+
* @returns {Promise<Array<{name: string, type: 'file'|'dir'}>>}
|
|
205
|
+
*/
|
|
206
|
+
async function listRemoteFiles(subpath) {
|
|
207
|
+
if (!subpath.endsWith('/')) subpath = subpath + '/';
|
|
208
|
+
const tree = await getTree();
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const e of tree) {
|
|
212
|
+
if (!e.path.startsWith(subpath)) continue;
|
|
213
|
+
const name = e.path.slice(subpath.length).split('/')[0];
|
|
214
|
+
if (!name || seen.has(name)) continue;
|
|
215
|
+
seen.add(name);
|
|
216
|
+
// Determine type: is there a path equal to subpath+name with type 'tree'?
|
|
217
|
+
const isDir = tree.some((t) => t.path === subpath + name && t.type === 'tree');
|
|
218
|
+
out.push({
|
|
219
|
+
name,
|
|
220
|
+
type: isDir ? 'dir' : 'file',
|
|
221
|
+
path: e.path,
|
|
222
|
+
download_url: '',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* List actual files inside a hub/<name>/ directory.
|
|
230
|
+
* @param {string} name
|
|
231
|
+
* @returns {Promise<Array<{name: string, path: string}>>}
|
|
232
|
+
*/
|
|
233
|
+
async function listRemoteProfileFiles(name) {
|
|
234
|
+
const prefix = `hub/${name}/`;
|
|
235
|
+
const tree = await getTree();
|
|
236
|
+
return tree
|
|
237
|
+
.filter((e) => e.type === 'blob' && e.path.startsWith(prefix))
|
|
238
|
+
.map((e) => ({
|
|
239
|
+
name: e.path.slice(prefix.length).split('/').pop(),
|
|
240
|
+
path: e.path,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* List top-level profile names (the directories directly under hub/).
|
|
246
|
+
* Cheap: uses the same in-memory tree cache as getTree(), so calling this
|
|
247
|
+
* after listRemoteProfileFiles does not cost an extra API request.
|
|
248
|
+
* @returns {Promise<string[]>}
|
|
249
|
+
*/
|
|
250
|
+
async function listProfileNames() {
|
|
251
|
+
const tree = await getTree();
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
for (const e of tree) {
|
|
254
|
+
if (!e.path.startsWith('hub/')) continue;
|
|
255
|
+
const seg = e.path.slice('hub/'.length).split('/')[0];
|
|
256
|
+
// Directories prefixed with `_` (e.g. `_shared`) hold bridge utilities,
|
|
257
|
+
// not profiles — exclude them from listings and "did you mean" suggestions.
|
|
258
|
+
if (seg && !seg.startsWith('_') && !seen.has(seg)) seen.add(seg);
|
|
259
|
+
}
|
|
260
|
+
return [...seen].sort();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Lightweight "did you mean" hint using edit distance + prefix matching.
|
|
265
|
+
* Returns the best candidate name, or '' if none is close enough.
|
|
266
|
+
*
|
|
267
|
+
* Two paths to a match:
|
|
268
|
+
* 1. Prefix match — `claude` matches `claude-code`, `echo` matches
|
|
269
|
+
* `echo-agent`. This is the common typo pattern (user forgot a suffix).
|
|
270
|
+
* Only accepts an unambiguous prefix — if multiple candidates share
|
|
271
|
+
* the prefix, none is returned (better no suggestion than a wrong one).
|
|
272
|
+
* 2. Edit distance — tolerate ~1/3 of the input length in edits. Catches
|
|
273
|
+
* transpositions (`calude`) and small typos (`coudex` → `codex`).
|
|
274
|
+
*/
|
|
275
|
+
function suggestCloseName(input, candidates) {
|
|
276
|
+
if (!input || !candidates || candidates.length === 0) return '';
|
|
277
|
+
|
|
278
|
+
const n = input.toLowerCase();
|
|
279
|
+
|
|
280
|
+
// Path 1: unique prefix match.
|
|
281
|
+
const prefixMatches = candidates.filter(c => c.toLowerCase().startsWith(n));
|
|
282
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
283
|
+
|
|
284
|
+
// Path 2: edit distance. Threshold scales with input length:
|
|
285
|
+
// - short (≤6): allow 1 edit (typos in `agy`, `codex`)
|
|
286
|
+
// - medium (7-12): allow 2 edits (transpositions in `calude-code`)
|
|
287
|
+
// - long (>12): allow 3 edits
|
|
288
|
+
const threshold = input.length <= 6 ? 1 : input.length <= 12 ? 2 : 3;
|
|
289
|
+
let best = '';
|
|
290
|
+
let bestDist = Infinity;
|
|
291
|
+
for (const c of candidates) {
|
|
292
|
+
const dist = editDistance(n, c.toLowerCase());
|
|
293
|
+
if (dist < bestDist) { bestDist = dist; best = c; }
|
|
294
|
+
}
|
|
295
|
+
if (best && bestDist <= threshold) return best;
|
|
296
|
+
return '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function editDistance(a, b) {
|
|
300
|
+
const m = a.length, n = b.length;
|
|
301
|
+
if (m === 0) return n;
|
|
302
|
+
if (n === 0) return m;
|
|
303
|
+
const prev = new Array(n + 1);
|
|
304
|
+
const curr = new Array(n + 1);
|
|
305
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
306
|
+
for (let i = 1; i <= m; i++) {
|
|
307
|
+
curr[0] = i;
|
|
308
|
+
for (let j = 1; j <= n; j++) {
|
|
309
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
310
|
+
curr[j] = Math.min(
|
|
311
|
+
prev[j] + 1, // deletion
|
|
312
|
+
curr[j - 1] + 1, // insertion
|
|
313
|
+
prev[j - 1] + cost // substitution
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j];
|
|
317
|
+
}
|
|
318
|
+
return prev[n];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function downloadFile(remotePath, localPath) {
|
|
322
|
+
const text = await httpGetText(GITHUB_RAW(remotePath));
|
|
323
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
324
|
+
fs.writeFileSync(localPath, text, 'utf8');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Public API
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Fetch a profile directory to local cache. Returns the cache path.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} name
|
|
335
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
336
|
+
* @returns {Promise<string>} absolute cache path
|
|
337
|
+
*/
|
|
338
|
+
async function fetchProfile(name, opts = {}) {
|
|
339
|
+
const { refresh = false, onLog = null } = opts;
|
|
340
|
+
|
|
341
|
+
// On refresh, also clear the in-memory tree cache so we see new files
|
|
342
|
+
// (e.g. profiles added since the process started).
|
|
343
|
+
if (refresh) _treeCache = null;
|
|
344
|
+
|
|
345
|
+
const age = cacheAgeSecs(name);
|
|
346
|
+
const dir = cacheDir(name);
|
|
347
|
+
const profileYaml = path.join(dir, 'profile.yaml');
|
|
348
|
+
|
|
349
|
+
if (!refresh && age !== null && age < HUB_CACHE_TTL_SECS && fs.existsSync(profileYaml)) {
|
|
350
|
+
if (onLog) onLog(`using cached profile: ${dir} (age ${Math.floor(age)}s)`);
|
|
351
|
+
return dir;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (onLog) {
|
|
355
|
+
if (refresh) {
|
|
356
|
+
onLog(`refreshing profile '${name}' from ${HUB_REPO}:${HUB_REF}...`);
|
|
357
|
+
} else {
|
|
358
|
+
onLog(`fetching profile '${name}' from ${HUB_REPO}:${HUB_REF}...`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const entries = await listRemoteProfileFiles(name);
|
|
363
|
+
if (entries.length === 0) {
|
|
364
|
+
// getTree succeeded (otherwise listRemoteProfileFiles would have thrown
|
|
365
|
+
// a HubError already). So the name is genuinely wrong — surface the list
|
|
366
|
+
// of available names so the user can correct the typo.
|
|
367
|
+
const known = await listProfileNames();
|
|
368
|
+
const suggestion = suggestCloseName(name, known);
|
|
369
|
+
const hint = suggestion
|
|
370
|
+
? [`Did you mean \`${suggestion}\`?`, '', 'Available profiles:', ...known.map(n => ` - ${n}`)].join('\n')
|
|
371
|
+
: ['Available profiles:', ...known.map(n => ` - ${n}`)].join('\n');
|
|
372
|
+
throw new HubError(`profile '${name}' not found in hub`, {
|
|
373
|
+
status: 404,
|
|
374
|
+
hint,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Clear cache, then re-download every file in the profile directory.
|
|
379
|
+
if (fs.existsSync(dir)) {
|
|
380
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
383
|
+
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
const local = path.join(dir, entry.name);
|
|
386
|
+
await downloadFile(entry.path, local);
|
|
387
|
+
if (onLog) onLog(` - ${entry.name}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
writeCacheMeta(name);
|
|
391
|
+
return dir;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* List profiles in the official hub.
|
|
396
|
+
*
|
|
397
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
398
|
+
* @returns {Promise<Array<{name: string, description: string, cli: string, tested: string}>>}
|
|
399
|
+
*/
|
|
400
|
+
async function listProfiles(opts = {}) {
|
|
401
|
+
const { onLog = null } = opts;
|
|
402
|
+
const entries = await listRemoteFiles('hub');
|
|
403
|
+
const profiles = [];
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
if (entry.type !== 'dir') continue;
|
|
406
|
+
const name = entry.name;
|
|
407
|
+
// Skip utility directories like `_shared/` — they hold shared bridge
|
|
408
|
+
// helpers, not a runnable profile (no profile.yaml).
|
|
409
|
+
if (name.startsWith('_')) continue;
|
|
410
|
+
try {
|
|
411
|
+
const yamlText = await httpGetText(GITHUB_RAW(`hub/${name}/profile.yaml`));
|
|
412
|
+
const { parseYaml } = require('./cli.js');
|
|
413
|
+
const data = parseYaml(yamlText);
|
|
414
|
+
profiles.push({
|
|
415
|
+
name: String(data.name || name),
|
|
416
|
+
description: String(data.description || ''),
|
|
417
|
+
cli: String(data.cli || ''),
|
|
418
|
+
tested: String(data.tested || 'unverified'),
|
|
419
|
+
});
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (onLog) onLog(`warning: could not read metadata for ${name}: ${e.message}`);
|
|
422
|
+
profiles.push({
|
|
423
|
+
name,
|
|
424
|
+
description: '(failed to read metadata)',
|
|
425
|
+
cli: '',
|
|
426
|
+
tested: 'unverified',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return profiles;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Return the README.md content for a profile.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} name
|
|
437
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
438
|
+
* @returns {Promise<string>}
|
|
439
|
+
*/
|
|
440
|
+
async function showReadme(name, opts = {}) {
|
|
441
|
+
const dir = await fetchProfile(name, opts);
|
|
442
|
+
const readme = path.join(dir, 'README.md');
|
|
443
|
+
if (!fs.existsSync(readme)) {
|
|
444
|
+
return `(no README.md for profile '${name}')`;
|
|
445
|
+
}
|
|
446
|
+
return fs.readFileSync(readme, 'utf8');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Copy a cached profile into targetDir/<name>/.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} name
|
|
453
|
+
* @param {string} targetDir
|
|
454
|
+
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
455
|
+
* @returns {Promise<string>} destination path
|
|
456
|
+
*/
|
|
457
|
+
async function installProfile(name, targetDir, opts = {}) {
|
|
458
|
+
const cached = await fetchProfile(name, opts);
|
|
459
|
+
const dest = path.join(targetDir, name);
|
|
460
|
+
if (fs.existsSync(dest)) {
|
|
461
|
+
throw new Error(`target already exists: ${dest}`);
|
|
462
|
+
}
|
|
463
|
+
fs.cpSync(cached, dest, { recursive: true });
|
|
464
|
+
// Drop our cache meta file from the installed copy.
|
|
465
|
+
const meta = path.join(dest, '.cache-meta.json');
|
|
466
|
+
if (fs.existsSync(meta)) fs.unlinkSync(meta);
|
|
467
|
+
if (opts.onLog) opts.onLog(`installed to: ${dest}`);
|
|
468
|
+
return dest;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
HUB_REPO,
|
|
473
|
+
HUB_REF,
|
|
474
|
+
HUB_CACHE_TTL_SECS,
|
|
475
|
+
HubError,
|
|
476
|
+
cacheRoot,
|
|
477
|
+
cacheDir,
|
|
478
|
+
cacheAgeSecs,
|
|
479
|
+
fetchProfile,
|
|
480
|
+
listProfiles,
|
|
481
|
+
showReadme,
|
|
482
|
+
installProfile,
|
|
483
|
+
};
|