joinhive 2.0.0 → 2.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 +5 -3
- package/bin/derive-evm-key.mjs +13 -0
- package/bin/hive +31 -8
- package/bin/hive-buzz.mjs +73 -0
- package/bin/hive-join.mjs +342 -91
- package/bin/hive-key.mjs +131 -0
- package/bin/hive-net.mjs +23 -0
- package/daemon/fanout.mjs +5 -2
- package/daemon/hived.mjs +39 -3
- package/docs/SUMMARY.md +1 -0
- package/docs/cli.md +4 -1
- package/docs/quickstart.md +9 -0
- package/docs/runbook.md +52 -0
- package/package.json +3 -2
- package/server/api.mjs +62 -1
- package/server/join-page.mjs +8 -5
- package/server/provision.mjs +140 -4
- package/server/rewarder.mjs +1 -0
- package/server/supervisor.mjs +54 -1
- package/shared/prompt.mjs +168 -0
package/server/provision.mjs
CHANGED
|
@@ -11,10 +11,12 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync, ren
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
|
13
13
|
import nacl from 'tweetnacl';
|
|
14
|
-
import { sealSecrets } from '../shared/sealed.mjs';
|
|
14
|
+
import { sealSecrets, openSecrets } from '../shared/sealed.mjs';
|
|
15
15
|
import { signedFetch } from '../shared/nip98.mjs';
|
|
16
16
|
import { verifyAuthTag } from '../shared/nip-oa.mjs';
|
|
17
|
-
import { validateConfig } from '../shared/config-schema.mjs';
|
|
17
|
+
import { validateConfig, DEFAULTS, OPENAI_COMPAT_BASES } from '../shared/config-schema.mjs';
|
|
18
|
+
import { EV } from '../shared/events.mjs';
|
|
19
|
+
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
18
20
|
|
|
19
21
|
const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
|
|
20
22
|
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
@@ -23,12 +25,13 @@ const GENESIS_JELLY = 500;
|
|
|
23
25
|
const GENESIS_ETH = 0.05;
|
|
24
26
|
|
|
25
27
|
export class Provisioner {
|
|
26
|
-
constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, log = console.log }) {
|
|
28
|
+
constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, supervisorPort = 8787, log = console.log }) {
|
|
27
29
|
this.dataDir = dataDir;
|
|
28
30
|
this.relayUrl = relayUrl;
|
|
29
31
|
this.kek = kek;
|
|
30
32
|
this.stewardKey = stewardKey; // nostr privkey hex — relay owner, mints invites
|
|
31
33
|
this.boxSecretKey = boxSecretKey; // X25519 secret (Uint8Array) for sealed payloads
|
|
34
|
+
this.supervisorPort = supervisorPort;
|
|
32
35
|
this.log = log;
|
|
33
36
|
this.invitesPath = join(dataDir, 'invites.json');
|
|
34
37
|
this.registryPath = join(dataDir, 'registry.json');
|
|
@@ -102,6 +105,9 @@ export class Provisioner {
|
|
|
102
105
|
const state = prior || { name, owner_pubkey: req.owner_pubkey, steps: {}, created_at: Math.floor(Date.now() / 1000) };
|
|
103
106
|
const done = (step) => !!state.steps[step];
|
|
104
107
|
const mark = (step, extra = true) => { state.steps[step] = extra; writeAtomic(statePath, JSON.stringify(state, null, 2)); };
|
|
108
|
+
// Steps added after a bee first completed provisioning must not retro-fire
|
|
109
|
+
// on its idempotent re-POSTs (a years-old bee getting a "welcome" is wrong).
|
|
110
|
+
const preexisting = !!(prior && prior.steps && prior.steps.done);
|
|
105
111
|
|
|
106
112
|
// 1. invite
|
|
107
113
|
if (!done('invite_checked')) {
|
|
@@ -143,6 +149,33 @@ export class Provisioner {
|
|
|
143
149
|
}
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
// 3b. Channel membership with role "bot" — this is what makes the bee
|
|
153
|
+
// appear in the Buzz desktop Agents directory (its relay listing only
|
|
154
|
+
// surfaces pubkeys whose relay-signed kind-39002 membership carries a
|
|
155
|
+
// bot-role p-tag; the NIP-OA pair alone is NOT enough). Must run
|
|
156
|
+
// BEFORE the daemon's first boot: adding a NEW member with a role is
|
|
157
|
+
// open to any authenticated user, changing an EXISTING member's role
|
|
158
|
+
// needs channel admin. The daemon's own 9021 re-join later is a
|
|
159
|
+
// membership no-op, so the role sticks. Best-effort: a failure is
|
|
160
|
+
// recorded, never fatal (the admin `rebot` endpoint is the retrofit).
|
|
161
|
+
if (!done('bot_role')) {
|
|
162
|
+
if (preexisting) mark('bot_role', 'skipped-preexisting');
|
|
163
|
+
else {
|
|
164
|
+
try {
|
|
165
|
+
const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
|
|
166
|
+
for (const chName of Object.values(DEFAULTS.channels)) {
|
|
167
|
+
const chId = await steward.ensureChannel(chName);
|
|
168
|
+
const r = await steward.publish(9000, '', [['h', chId], ['p', beePubkey], ['role', 'bot']]);
|
|
169
|
+
if (!r.ok && !/duplicate|already/i.test(r.message)) throw new Error(`add-member(bot) to ${chName}: ${r.message || 'rejected'}`);
|
|
170
|
+
}
|
|
171
|
+
mark('bot_role');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
this.log(`bot_role for ${name} failed (bee works, Agents-tab listing needs admin rebot): ${String(e.message).slice(0, 160)}`);
|
|
174
|
+
mark('bot_role', `failed: ${String(e.message).slice(0, 120)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
146
179
|
// 4. secrets: open the client's nacl.box, re-seal at rest under the KEK.
|
|
147
180
|
if (!done('secrets_stored')) {
|
|
148
181
|
const s = req.sealed || {};
|
|
@@ -155,7 +188,9 @@ export class Provisioner {
|
|
|
155
188
|
if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
|
|
156
189
|
let secrets;
|
|
157
190
|
try { secrets = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
|
|
158
|
-
|
|
191
|
+
// Echo-first onboarding: a keyless bee is legal ONLY as an echo bee
|
|
192
|
+
// (it heartbeats but computes nothing); `hive key set` upgrades it.
|
|
193
|
+
if (!secrets.llm_api_key && req.provider !== 'echo') throw httpErr(400, 'sealed payload missing llm_api_key');
|
|
159
194
|
writeFileSync(join(home, 'secrets.enc.json'), JSON.stringify(sealSecrets(this.kek, secrets)), { mode: 0o600 });
|
|
160
195
|
mark('secrets_stored');
|
|
161
196
|
}
|
|
@@ -168,6 +203,10 @@ export class Provisioner {
|
|
|
168
203
|
...(req.base_url ? { base_url: req.base_url } : {}),
|
|
169
204
|
...(req.model_extract ? { model_extract: req.model_extract } : {}),
|
|
170
205
|
...(req.model_compute ? { model_compute: req.model_compute } : {}),
|
|
206
|
+
// Echo-first: the CLI says explicitly that this echo bee is WAITING
|
|
207
|
+
// for a brain (vs a deliberate echo test bee, which never sets this).
|
|
208
|
+
// The daemon mutes compute/extract on this flag; `hive key set` clears it.
|
|
209
|
+
...(req.awaiting_key && req.provider === 'echo' ? { awaiting_key: true } : {}),
|
|
171
210
|
relay: this.relayUrl,
|
|
172
211
|
poll_secs: 10,
|
|
173
212
|
owner_pubkey: req.owner_pubkey,
|
|
@@ -232,11 +271,106 @@ export class Provisioner {
|
|
|
232
271
|
mark('grants_queued');
|
|
233
272
|
}
|
|
234
273
|
|
|
274
|
+
// 9. Welcome moment: a steward-signed intent FOR the new member, so their
|
|
275
|
+
// first feed isn't empty — other bees introduce themselves within a
|
|
276
|
+
// tick or two (origin "welcome" makes profile-mismatched bees eligible;
|
|
277
|
+
// the election still caps how many answer). Best-effort: relay trouble
|
|
278
|
+
// must never fail an otherwise-complete provision.
|
|
279
|
+
if (!done('welcome_posted')) {
|
|
280
|
+
if (preexisting) mark('welcome_posted', 'skipped-preexisting');
|
|
281
|
+
else {
|
|
282
|
+
try {
|
|
283
|
+
const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
|
|
284
|
+
const stewardPub = getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex')));
|
|
285
|
+
const ownerName = String(req.owner_name || name).slice(0, 40);
|
|
286
|
+
const logsId = await steward.ensureChannel(DEFAULTS.channels.logs);
|
|
287
|
+
const intentsId = await steward.ensureChannel(DEFAULTS.channels.intents);
|
|
288
|
+
const w = await steward.sendMessage(logsId, JSON.stringify({
|
|
289
|
+
type: EV.INTENT,
|
|
290
|
+
intent: `welcome ${ownerName} to the hive: introduce yourself briefly and offer ONE concrete thing you could do for them, based on what your owner is into`,
|
|
291
|
+
origin: 'welcome', for: req.owner_pubkey, by: stewardPub,
|
|
292
|
+
}));
|
|
293
|
+
if (!w.ok) throw new Error(w.message || 'welcome intent rejected');
|
|
294
|
+
await steward.sendMessage(intentsId, `🐝 ${name}.bee just joined the hive — say hi to ${ownerName}`);
|
|
295
|
+
mark('welcome_posted');
|
|
296
|
+
} catch (e) {
|
|
297
|
+
this.log(`welcome for ${name} failed (non-fatal): ${String(e.message).slice(0, 160)}`);
|
|
298
|
+
mark('welcome_posted', `failed: ${String(e.message).slice(0, 120)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
235
303
|
mark('done');
|
|
236
304
|
this.log(`provisioned bee ${name} (${beePubkey.slice(0, 12)}) for ${String(req.owner_name || '')}`);
|
|
237
305
|
return this.status(name);
|
|
238
306
|
}
|
|
239
307
|
|
|
308
|
+
// ---- key upgrade: echo-first bees get their brain AFTER the first win ------
|
|
309
|
+
// req: {provider, base_url?, model_extract?, model_compute?, sealed:{nonce,box,client_pub}}
|
|
310
|
+
// Signed by the owner. Merges llm_api_key into the at-rest secrets (the
|
|
311
|
+
// wallet mnemonic stays), rewrites config, and bounces the daemon so the
|
|
312
|
+
// new engine boots. A re-POST of /api/bees can NOT do this: secrets_stored
|
|
313
|
+
// and config_written are completed steps and never re-run.
|
|
314
|
+
async setKey(name, req, signerPubkey) {
|
|
315
|
+
const home = join(this.dataDir, 'bees', name);
|
|
316
|
+
const state = loadJson(join(home, 'provision.json'), null);
|
|
317
|
+
if (!state) throw httpErr(404, 'unknown bee');
|
|
318
|
+
if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s key');
|
|
319
|
+
const provider = String(req.provider || '').toLowerCase();
|
|
320
|
+
if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) throw httpErr(400, `provider must be anthropic|openai|openrouter|hermes, got "${provider}"`);
|
|
321
|
+
|
|
322
|
+
const s = req.sealed || {};
|
|
323
|
+
const opened = nacl.box.open(
|
|
324
|
+
Buffer.from(s.box || '', 'base64'),
|
|
325
|
+
Buffer.from(s.nonce || '', 'base64'),
|
|
326
|
+
Buffer.from(s.client_pub || '', 'base64'),
|
|
327
|
+
this.boxSecretKey,
|
|
328
|
+
);
|
|
329
|
+
if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
|
|
330
|
+
let incoming;
|
|
331
|
+
try { incoming = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
|
|
332
|
+
if (!incoming.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
|
|
333
|
+
|
|
334
|
+
const encPath = join(home, 'secrets.enc.json');
|
|
335
|
+
const existing = existsSync(encPath) ? openSecrets(this.kek, loadJson(encPath, null)) : {};
|
|
336
|
+
const tmp = `${encPath}.tmp`;
|
|
337
|
+
writeFileSync(tmp, JSON.stringify(sealSecrets(this.kek, { ...existing, llm_api_key: incoming.llm_api_key })), { mode: 0o600 });
|
|
338
|
+
renameSync(tmp, encPath);
|
|
339
|
+
|
|
340
|
+
const cfgPath = join(home, 'config.json');
|
|
341
|
+
const cfg = loadJson(cfgPath, {});
|
|
342
|
+
const next = {
|
|
343
|
+
...cfg,
|
|
344
|
+
provider,
|
|
345
|
+
base_url: req.base_url || OPENAI_COMPAT_BASES[provider] || undefined,
|
|
346
|
+
...(req.model_extract ? { model_extract: req.model_extract } : {}),
|
|
347
|
+
...(req.model_compute ? { model_compute: req.model_compute } : {}),
|
|
348
|
+
};
|
|
349
|
+
delete next.awaiting_key;
|
|
350
|
+
if (!next.base_url) delete next.base_url;
|
|
351
|
+
const { errors } = validateConfig(next, { requireBee: true });
|
|
352
|
+
if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
|
|
353
|
+
writeAtomic(cfgPath, JSON.stringify(next, null, 2));
|
|
354
|
+
|
|
355
|
+
// Bounce the daemon so it re-reads config + secrets. Supervisor endpoint
|
|
356
|
+
// first; fall back to signalling the pid from the last heartbeat (same
|
|
357
|
+
// container) — the supervisor's exit handler respawns either way.
|
|
358
|
+
let restarted = 'none';
|
|
359
|
+
try {
|
|
360
|
+
const r = await fetch(`http://127.0.0.1:${this.supervisorPort}/restart`, {
|
|
361
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
362
|
+
body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000),
|
|
363
|
+
});
|
|
364
|
+
if (r.ok) restarted = 'supervisor';
|
|
365
|
+
} catch {}
|
|
366
|
+
if (restarted === 'none') {
|
|
367
|
+
const hb = loadJson(join(home, 'heartbeat.json'), {});
|
|
368
|
+
if (hb.pid) { try { process.kill(hb.pid, 'SIGTERM'); restarted = 'signal'; } catch {} }
|
|
369
|
+
}
|
|
370
|
+
this.log(`key set for ${name}: provider ${provider}, restart via ${restarted}`);
|
|
371
|
+
return { ...this.status(name), restarted };
|
|
372
|
+
}
|
|
373
|
+
|
|
240
374
|
status(name) {
|
|
241
375
|
const home = join(this.dataDir, 'bees', name);
|
|
242
376
|
const state = loadJson(join(home, 'provision.json'), null);
|
|
@@ -250,7 +384,9 @@ export class Provisioner {
|
|
|
250
384
|
return {
|
|
251
385
|
name,
|
|
252
386
|
bee_pubkey: state.steps.bee_key || null,
|
|
387
|
+
owner_pubkey: state.owner_pubkey || null,
|
|
253
388
|
steps: Object.keys(state.steps),
|
|
389
|
+
brain: cfg.awaiting_key ? 'awaiting-key' : (cfg.provider || null),
|
|
254
390
|
daemon: hb.at ? { last_tick_at: hb.at, pid: hb.pid, paused: hb.paused || false } : null,
|
|
255
391
|
grants: grant,
|
|
256
392
|
budget: spend ? { date: spend.date, jelly_spent: spend.jelly_spent, daily_cap: cfg.spend?.jelly_daily_cap ?? 15 } : { daily_cap: cfg.spend?.jelly_daily_cap ?? 15 },
|
package/server/rewarder.mjs
CHANGED
|
@@ -184,6 +184,7 @@ export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
|
|
|
184
184
|
const servedKeys = {}; // author -> Set(intentKey)
|
|
185
185
|
for (const r of results) {
|
|
186
186
|
if (!isBee(r.author)) continue;
|
|
187
|
+
if (isBee(r.for)) continue; // serving another AGENT is loop debris, not service
|
|
187
188
|
if (downed.has(r.id)) continue;
|
|
188
189
|
if (reports[r.author]?.size) continue;
|
|
189
190
|
const keys = servedKeys[r.author] = servedKeys[r.author] || new Set();
|
package/server/supervisor.mjs
CHANGED
|
@@ -64,8 +64,18 @@ const readSecrets = (home) => {
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
// Regenerate the roster from disk truth. Idempotent; provisioning re-runs it.
|
|
67
|
+
// Bee rows not backed by a live dir are dropped (retired bees leave the
|
|
68
|
+
// fan-out roster and the leaderboard resolution).
|
|
67
69
|
const rebuildRegistry = () => {
|
|
68
70
|
const reg = loadJson(REGISTRY, {});
|
|
71
|
+
const live = new Set();
|
|
72
|
+
for (const name of listBeeDirs()) {
|
|
73
|
+
const id = loadJson(join(BEES_DIR, name, 'identity.json'), {});
|
|
74
|
+
if (id.pubkey) live.add(id.pubkey);
|
|
75
|
+
}
|
|
76
|
+
for (const [pk, row] of Object.entries(reg)) {
|
|
77
|
+
if (row?.is_bee && !live.has(pk)) delete reg[pk];
|
|
78
|
+
}
|
|
69
79
|
for (const name of listBeeDirs()) {
|
|
70
80
|
const home = join(BEES_DIR, name);
|
|
71
81
|
const identity = loadJson(join(home, 'identity.json'), {});
|
|
@@ -133,6 +143,12 @@ const spawnBee = (name) => {
|
|
|
133
143
|
entry.pid = null;
|
|
134
144
|
entry.child = null;
|
|
135
145
|
if (shuttingDown) return;
|
|
146
|
+
// Retired while running: the dir is archived away — forget, don't restart.
|
|
147
|
+
if (!existsSync(join(home, 'config.json'))) {
|
|
148
|
+
log(`bee ${name} gone from disk (retired) — not restarting`);
|
|
149
|
+
bees.delete(name);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
136
152
|
const now = Date.now();
|
|
137
153
|
entry.restarts = [...entry.restarts.filter((t) => now - t < 10 * 60_000), now];
|
|
138
154
|
if (entry.restarts.length > 10) {
|
|
@@ -150,7 +166,17 @@ const spawnBee = (name) => {
|
|
|
150
166
|
|
|
151
167
|
const rescan = () => {
|
|
152
168
|
rebuildRegistry();
|
|
153
|
-
|
|
169
|
+
const present = new Set(listBeeDirs());
|
|
170
|
+
// Reap retired bees: dir gone (archived by the retire endpoint) -> stop the
|
|
171
|
+
// child and forget it. The registry rebuild below already dropped its row.
|
|
172
|
+
for (const [name, e] of bees) {
|
|
173
|
+
if (present.has(name)) continue;
|
|
174
|
+
log(`bee ${name} retired — stopping`);
|
|
175
|
+
try { e.child?.kill('SIGTERM'); } catch {}
|
|
176
|
+
e.state = 'retired';
|
|
177
|
+
bees.delete(name);
|
|
178
|
+
}
|
|
179
|
+
for (const name of present) {
|
|
154
180
|
const e = bees.get(name);
|
|
155
181
|
if (!e || (!e.child && e.state !== 'backoff')) {
|
|
156
182
|
if (e) { e.restarts = []; e.backoffMs = 1000; }
|
|
@@ -190,6 +216,33 @@ createServer((req, res) => {
|
|
|
190
216
|
res.end(JSON.stringify({ respawned: true }));
|
|
191
217
|
return;
|
|
192
218
|
}
|
|
219
|
+
if (url.pathname === '/restart' && req.method === 'POST') {
|
|
220
|
+
// Restart ONE bee with fresh config+secrets from disk (used by the api
|
|
221
|
+
// worker after `hive key set` rewrites them). Internal port only.
|
|
222
|
+
let body = '';
|
|
223
|
+
req.on('data', (c) => { body += c; });
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
let name = '';
|
|
226
|
+
try { name = String(JSON.parse(body || '{}').name || ''); } catch {}
|
|
227
|
+
const e = bees.get(name);
|
|
228
|
+
if (!name || (!e && !existsSync(join(BEES_DIR, name, 'config.json')))) {
|
|
229
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
230
|
+
res.end('{"error":"unknown bee"}');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (e) { e.restarts = []; e.backoffMs = 1000; }
|
|
234
|
+
if (e?.child) {
|
|
235
|
+
try { e.child.kill('SIGTERM'); } catch {} // exit handler respawns
|
|
236
|
+
} else {
|
|
237
|
+
if (e) e.state = 'stopped';
|
|
238
|
+
spawnBee(name);
|
|
239
|
+
}
|
|
240
|
+
log(`restart requested for bee ${name}`);
|
|
241
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
242
|
+
res.end(JSON.stringify({ restarting: name }));
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
193
246
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
194
247
|
res.end('{"error":"not found"}');
|
|
195
248
|
}).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// shared/prompt — the CLI's interactive moments (select/ask/confirm/pause)
|
|
2
|
+
// and progress rendering (spinner/checkpoints), hand-rolled to keep the pack
|
|
3
|
+
// dependency-light.
|
|
4
|
+
//
|
|
5
|
+
// TTY rules:
|
|
6
|
+
// - The arrow-key select needs a real TTY; when stdin/stdout isn't one
|
|
7
|
+
// (CI, pipes, heredocs) every prompt degrades to numbered/plain line
|
|
8
|
+
// input with identical semantics. install-remote.sh re-attaches /dev/tty
|
|
9
|
+
// before exec'ing the join, so `curl | bash` lands on the TTY path.
|
|
10
|
+
// - Ctrl-C during a raw-mode select exits 130 — a half-answered wizard must
|
|
11
|
+
// never half-provision.
|
|
12
|
+
// Every function accepts {input, output} for tests (fake streams exercise the
|
|
13
|
+
// non-TTY paths without a terminal).
|
|
14
|
+
import { createInterface } from 'node:readline/promises';
|
|
15
|
+
|
|
16
|
+
const isTTY = (s) => !!(s && s.isTTY);
|
|
17
|
+
const DIM = '\x1b[2m';
|
|
18
|
+
const CYAN = '\x1b[36m';
|
|
19
|
+
const RESET = '\x1b[0m';
|
|
20
|
+
|
|
21
|
+
const line = async ({ input, output, prompt }) => {
|
|
22
|
+
// On stdin EOF (</dev/null, closed pipes) a pending question() neither
|
|
23
|
+
// resolves nor rejects — the event loop just drains and node exits 0
|
|
24
|
+
// mid-flow. Two guards: an input that ALREADY ended ('end' fires once, so
|
|
25
|
+
// a second readline on it would wait forever) throws straight away, and a
|
|
26
|
+
// mid-question EOF is turned into a rejection by racing the close event.
|
|
27
|
+
// Every caller maps the rejection to "use the default".
|
|
28
|
+
if (!input || input.readableEnded || input.destroyed || input.closed) {
|
|
29
|
+
output.write(`${prompt}\n`);
|
|
30
|
+
throw new Error('input closed');
|
|
31
|
+
}
|
|
32
|
+
const rl = createInterface({ input, output });
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([
|
|
35
|
+
rl.question(prompt),
|
|
36
|
+
new Promise((_, reject) => rl.once('close', () => reject(new Error('input closed')))),
|
|
37
|
+
]);
|
|
38
|
+
} finally { rl.close(); }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// One line of text. Empty input takes `def`; `validate` (sync or async)
|
|
42
|
+
// returns true or an error string and re-prompts on failure.
|
|
43
|
+
export const ask = async ({ prompt, def = '', validate = null, input = process.stdin, output = process.stdout }) => {
|
|
44
|
+
for (;;) {
|
|
45
|
+
let v;
|
|
46
|
+
try { v = (await line({ input, output, prompt: `? ${prompt}${def ? ` ${DIM}(${def})${RESET}` : ''} › ` })).trim(); }
|
|
47
|
+
catch { v = ''; }
|
|
48
|
+
if (!v) v = def;
|
|
49
|
+
if (!validate) return v;
|
|
50
|
+
const ok = await validate(v);
|
|
51
|
+
if (ok === true) return v;
|
|
52
|
+
output.write(` ✗ ${ok || 'invalid'}\n`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const confirm = async ({ prompt, def = true, input = process.stdin, output = process.stdout }) => {
|
|
57
|
+
let v;
|
|
58
|
+
try { v = (await line({ input, output, prompt: `? ${prompt} ${def ? '(Y/n)' : '(y/N)'} › ` })).trim().toLowerCase(); }
|
|
59
|
+
catch { v = ''; }
|
|
60
|
+
if (!v) return def;
|
|
61
|
+
return v === 'y' || v === 'yes';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Hard pause: nothing continues until the human presses Enter.
|
|
65
|
+
export const pause = async ({ prompt, input = process.stdin, output = process.stdout }) => {
|
|
66
|
+
try { await line({ input, output, prompt: `${prompt} ` }); } catch {}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Arrow-key select on a TTY; numbered list everywhere else.
|
|
70
|
+
// options: [{label, hint?, value}] -> resolves to the chosen option's value.
|
|
71
|
+
export const select = async ({ title, options, defaultIndex = 0, input = process.stdin, output = process.stdout }) => {
|
|
72
|
+
if (!Array.isArray(options) || !options.length) throw new Error('select needs options');
|
|
73
|
+
let idx = Math.min(Math.max(defaultIndex, 0), options.length - 1);
|
|
74
|
+
|
|
75
|
+
if (!isTTY(input) || !isTTY(output) || typeof input.setRawMode !== 'function') {
|
|
76
|
+
output.write(`? ${title}\n`);
|
|
77
|
+
options.forEach((o, i) => output.write(` ${i + 1}. ${o.label}${o.hint ? ` ${DIM}— ${o.hint}${RESET}` : ''}${i === idx ? ' (default)' : ''}\n`));
|
|
78
|
+
let v;
|
|
79
|
+
try { v = (await line({ input, output, prompt: ` pick 1-${options.length} › ` })).trim(); } catch { v = ''; }
|
|
80
|
+
const n = Number(v);
|
|
81
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) idx = n - 1;
|
|
82
|
+
return options[idx].value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lines = options.length + 1;
|
|
86
|
+
const render = (first) => {
|
|
87
|
+
if (!first) output.write(`\x1b[${lines}A`);
|
|
88
|
+
output.write(`\x1b[0J? ${title} ${DIM}(↑/↓, Enter)${RESET}\n`);
|
|
89
|
+
options.forEach((o, i) => {
|
|
90
|
+
const on = i === idx;
|
|
91
|
+
const label = on ? `${CYAN}❯ ${o.label}${RESET}` : ` ${o.label}`;
|
|
92
|
+
output.write(` ${label}${o.hint ? ` ${DIM}— ${o.hint}${RESET}` : ''}\n`);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
render(true);
|
|
97
|
+
input.setRawMode(true);
|
|
98
|
+
input.resume();
|
|
99
|
+
const picked = await new Promise((resolve) => {
|
|
100
|
+
const cleanup = () => { input.off('data', onData); input.setRawMode(false); input.pause(); };
|
|
101
|
+
const onData = (buf) => {
|
|
102
|
+
const s = buf.toString();
|
|
103
|
+
if (s === '\x03') { cleanup(); output.write('\n'); process.exit(130); }
|
|
104
|
+
if (s === '\r' || s === '\n') { cleanup(); return resolve(idx); }
|
|
105
|
+
if (/^[1-9]$/.test(s) && Number(s) <= options.length) { idx = Number(s) - 1; cleanup(); return resolve(idx); }
|
|
106
|
+
if (s === '\x1b[A' || s === 'k') idx = (idx - 1 + options.length) % options.length;
|
|
107
|
+
else if (s === '\x1b[B' || s === 'j' || s === '\t') idx = (idx + 1) % options.length;
|
|
108
|
+
else return;
|
|
109
|
+
render(false);
|
|
110
|
+
};
|
|
111
|
+
input.on('data', onData);
|
|
112
|
+
});
|
|
113
|
+
output.write(`\x1b[${lines}A\x1b[0J? ${title} › ${CYAN}${options[picked].label}${RESET}\n`);
|
|
114
|
+
return options[picked].value;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
118
|
+
|
|
119
|
+
// Single-line spinner. On non-TTY output each text() prints its own line.
|
|
120
|
+
export const spinner = (text, { output = process.stdout } = {}) => {
|
|
121
|
+
const tty = isTTY(output);
|
|
122
|
+
let cur = text;
|
|
123
|
+
let i = 0;
|
|
124
|
+
let timer = null;
|
|
125
|
+
const draw = () => output.write(`\r\x1b[K${CYAN}${FRAMES[i = (i + 1) % FRAMES.length]}${RESET} ${cur}`);
|
|
126
|
+
if (tty) { draw(); timer = setInterval(draw, 100); } else output.write(`… ${cur}\n`);
|
|
127
|
+
return {
|
|
128
|
+
text(t) { cur = t; if (!tty) output.write(`… ${t}\n`); },
|
|
129
|
+
stop(final) {
|
|
130
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
131
|
+
if (tty) output.write('\r\x1b[K');
|
|
132
|
+
if (final) output.write(`${final}\n`);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Named checkpoints on one live line: `invite ✓ → bee key ⠸ → grants`.
|
|
138
|
+
// items: [{key, label}]. set(key, 'active'|'done'|'skip') re-renders; stop()
|
|
139
|
+
// finalizes the line. Non-TTY output prints one line per completed step.
|
|
140
|
+
export const checkpoints = (items, { output = process.stdout } = {}) => {
|
|
141
|
+
const tty = isTTY(output);
|
|
142
|
+
const state = new Map();
|
|
143
|
+
let spin = 0;
|
|
144
|
+
let timer = null;
|
|
145
|
+
const renderLine = () => items.map(({ key, label }) => {
|
|
146
|
+
const st = state.get(key);
|
|
147
|
+
if (st === 'done') return `${label} ✓`;
|
|
148
|
+
if (st === 'skip') return `${label} ⤼`;
|
|
149
|
+
if (st === 'active') return `${label} ${CYAN}${FRAMES[spin % FRAMES.length]}${RESET}`;
|
|
150
|
+
return `${DIM}${label}${RESET}`;
|
|
151
|
+
}).join(' → ');
|
|
152
|
+
const draw = () => { spin++; output.write(`\r\x1b[K▸ ${renderLine()}`); };
|
|
153
|
+
if (tty) { draw(); timer = setInterval(draw, 120); }
|
|
154
|
+
return {
|
|
155
|
+
set(key, st = 'done') {
|
|
156
|
+
if (state.get(key) === st) return;
|
|
157
|
+
state.set(key, st);
|
|
158
|
+
if (tty) draw();
|
|
159
|
+
else if (st === 'done' || st === 'skip') output.write(` ${st === 'done' ? '✓' : '⤼'} ${items.find((i) => i.key === key)?.label || key}\n`);
|
|
160
|
+
},
|
|
161
|
+
has(key) { return state.has(key); },
|
|
162
|
+
stop(final) {
|
|
163
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
164
|
+
if (tty) output.write('\r\x1b[K');
|
|
165
|
+
if (final) output.write(`${final}\n`);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
};
|