hearback 0.1.0 → 0.1.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.
Files changed (2) hide show
  1. package/bin/hearback.js +171 -14
  2. package/package.json +1 -1
package/bin/hearback.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
- import { execSync } from 'node:child_process';
6
+ import { execSync, spawn } from 'node:child_process';
7
+ import { platform } from 'node:os';
7
8
 
8
9
  const RESET = '\x1b[0m';
9
10
  const BOLD = '\x1b[1m';
@@ -18,13 +19,50 @@ function done(msg) { log(` ${GREEN}✓${RESET} ${msg}`); }
18
19
  function info(msg) { log(` ${DIM}${msg}${RESET}`); }
19
20
  function warn(msg) { log(` ${YELLOW}⚠${RESET} ${msg}`); }
20
21
 
22
+ // Readline with line-buffering — works with both interactive TTY and piped
23
+ // input. `rl.question()` has a bug on piped stdin where only the first prompt
24
+ // returns; using `rl.on('line')` + a queue avoids that.
25
+ let _rl = null;
26
+ const _pendingLines = [];
27
+ const _pendingAsks = [];
28
+
29
+ function getRl() {
30
+ if (!_rl) {
31
+ _rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
32
+ _rl.on('line', (line) => {
33
+ if (_pendingAsks.length > 0) {
34
+ const resolver = _pendingAsks.shift();
35
+ resolver(line.trim());
36
+ } else {
37
+ _pendingLines.push(line);
38
+ }
39
+ });
40
+ _rl.on('close', () => {
41
+ // Flush any waiters with empty strings (avoids hanging forever)
42
+ while (_pendingAsks.length > 0) {
43
+ _pendingAsks.shift()('');
44
+ }
45
+ });
46
+ }
47
+ return _rl;
48
+ }
49
+
50
+ function closeRl() {
51
+ if (_rl) {
52
+ _rl.close();
53
+ _rl = null;
54
+ }
55
+ }
56
+
21
57
  async function ask(question) {
22
- const rl = createInterface({ input: process.stdin, output: process.stdout });
58
+ getRl();
59
+ process.stdout.write(` ${question} `);
23
60
  return new Promise((resolve) => {
24
- rl.question(` ${question} `, (answer) => {
25
- rl.close();
26
- resolve(answer.trim());
27
- });
61
+ if (_pendingLines.length > 0) {
62
+ resolve(_pendingLines.shift().trim());
63
+ } else {
64
+ _pendingAsks.push(resolve);
65
+ }
28
66
  });
29
67
  }
30
68
 
@@ -207,6 +245,53 @@ function injectNextjsWidget(content, endpoint) {
207
245
  return result;
208
246
  }
209
247
 
248
+ // --- Token helpers ---
249
+
250
+ /** Returns true if the `gh` CLI is installed and authenticated. */
251
+ function hasGhCli() {
252
+ try {
253
+ execSync('gh auth status', { stdio: ['ignore', 'pipe', 'pipe'] });
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /** Read token from `gh auth token`. Returns null on failure. */
261
+ function getGhToken() {
262
+ try {
263
+ return execSync('gh auth token', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ /** Open a URL in the user's default browser. Silent-fails if unavailable. */
270
+ function openBrowser(url) {
271
+ const cmd = platform() === 'darwin' ? 'open'
272
+ : platform() === 'win32' ? 'start'
273
+ : 'xdg-open';
274
+ try {
275
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
276
+ return true;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Build a GitHub fine-grained token URL with as much pre-filled as possible.
284
+ * Sadly GitHub does NOT support pre-filling permissions via URL params, but
285
+ * we can pre-fill the name + description to save time.
286
+ */
287
+ function buildTokenUrl(repoName) {
288
+ const params = new URLSearchParams({
289
+ name: `hearback-${repoName.split('/').pop()}`,
290
+ description: 'Token for hearback feedback widget',
291
+ });
292
+ return `https://github.com/settings/personal-access-tokens/new?${params}`;
293
+ }
294
+
210
295
  // --- Detect project type ---
211
296
 
212
297
  function detectProject(cwd) {
@@ -352,7 +437,8 @@ ${BOLD}Usage:${RESET}
352
437
 
353
438
  const pkgToInstall = project.type === 'agent' ? 'hearback-agent-skill' : 'hearback-server';
354
439
  try {
355
- execSync(`npm install ${pkgToInstall}`, { cwd, stdio: 'pipe' });
440
+ // stdio: ['ignore', 'pipe', 'pipe'] — don't let npm consume our piped stdin
441
+ execSync(`npm install ${pkgToInstall}`, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
356
442
  done(`${pkgToInstall} installed`);
357
443
  } catch {
358
444
  warn(`Could not install ${pkgToInstall} (not yet published to npm)`);
@@ -449,23 +535,88 @@ ${BOLD}Usage:${RESET}
449
535
  }
450
536
  }
451
537
 
452
- // Env vars
538
+ // Step 5 (was 4.5): GitHub token — interactive, with multiple fast paths
539
+ step(5, 'GitHub token');
540
+
453
541
  const envFile = existsSync(join(cwd, '.env.local')) ? '.env.local' : '.env';
454
542
  const envPath = join(cwd, envFile);
455
543
  const envContent = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
544
+ const hasExistingToken = /GITHUB_TOKEN=\S+/.test(envContent) || /FEEDBACK_GITHUB_TOKEN=\S+/.test(envContent);
456
545
 
546
+ let token = '';
547
+
548
+ if (hasExistingToken) {
549
+ info(`${envFile} already has a GITHUB_TOKEN — skipping token setup`);
550
+ } else {
551
+ info('hearback needs a GitHub token with Issues: read-write (and Contents: read-write for screenshots)');
552
+ log('');
553
+ log(` ${BOLD}How would you like to provide the token?${RESET}`);
554
+ log(` ${DIM}1)${RESET} Reuse my gh CLI token ${hasGhCli() ? GREEN + '(detected)' + RESET : DIM + '(gh CLI not found)' + RESET}`);
555
+ log(` ${DIM}2)${RESET} Generate a new fine-grained token on GitHub (opens browser)`);
556
+ log(` ${DIM}3)${RESET} I'll paste one manually`);
557
+ log(` ${DIM}4)${RESET} Skip — I'll add it to ${envFile} later`);
558
+ log('');
559
+ const choice = (await ask('Choose [1/2/3/4]:')).trim() || '1';
560
+
561
+ if (choice === '1') {
562
+ if (!hasGhCli()) {
563
+ warn('gh CLI not found or not authenticated. Install: https://cli.github.com/');
564
+ info('Falling back to option 2 (browser)');
565
+ } else {
566
+ const ghToken = getGhToken();
567
+ if (ghToken) {
568
+ token = ghToken;
569
+ done('Using gh CLI token');
570
+ warn('Note: gh CLI tokens have broad permissions. For stricter scoping, choose option 2 next time.');
571
+ } else {
572
+ warn('Could not read gh token');
573
+ }
574
+ }
575
+ }
576
+
577
+ if (!token && (choice === '2' || choice === '1')) {
578
+ const url = buildTokenUrl(repo);
579
+ info('Opening GitHub in your browser...');
580
+ info(`If it does not open, paste this URL manually:`);
581
+ log(` ${DIM}${url}${RESET}`);
582
+ log('');
583
+ info(`Set these permissions:`);
584
+ log(` ${DIM}- Repository access: Only select repositories → ${repo}${RESET}`);
585
+ log(` ${DIM}- Permissions → Issues: Read and write${RESET}`);
586
+ log(` ${DIM}- Permissions → Contents: Read and write (for screenshots)${RESET}`);
587
+ log('');
588
+ openBrowser(url);
589
+ token = (await ask('Paste your token (starts with github_pat_...):')).trim();
590
+ }
591
+
592
+ if (!token && choice === '3') {
593
+ token = (await ask('Paste your token:')).trim();
594
+ }
595
+
596
+ if (choice === '4' || (!token && choice !== '1' && choice !== '2' && choice !== '3')) {
597
+ info(`Skipping. Add GITHUB_TOKEN to ${envFile} before running the dev server.`);
598
+ }
599
+ }
600
+
601
+ // Write env vars
457
602
  const newVars = [];
458
603
  if (!envContent.includes('FEEDBACK_REPO')) newVars.push(`FEEDBACK_REPO=${repo}`);
459
- if (!envContent.includes('GITHUB_TOKEN')) newVars.push(`GITHUB_TOKEN= # Create at https://github.com/settings/tokens?type=beta (needs issues:write + contents:write)`);
604
+ if (!envContent.includes('GITHUB_TOKEN')) {
605
+ if (token) {
606
+ newVars.push(`GITHUB_TOKEN=${token}`);
607
+ } else {
608
+ newVars.push(`GITHUB_TOKEN= # Generate at https://github.com/settings/personal-access-tokens/new (Issues: read/write, Contents: read/write)`);
609
+ }
610
+ }
460
611
 
461
612
  if (newVars.length > 0) {
462
613
  const addition = '\n# hearback\n' + newVars.join('\n') + '\n';
463
614
  writeFileSync(envPath, envContent + addition);
464
- done(`Added env vars to ${envFile}`);
615
+ done(token ? `Wrote FEEDBACK_REPO + GITHUB_TOKEN to ${envFile}` : `Wrote FEEDBACK_REPO to ${envFile}`);
465
616
  }
466
617
 
467
- // Step 5: Notification workflow
468
- step(5, 'Notification loop');
618
+ // Step 6: Notification workflow
619
+ step(6, 'Notification loop');
469
620
  const wantNotify = await confirm('Set up email notifications when issues are fixed?');
470
621
 
471
622
  if (wantNotify) {
@@ -477,10 +628,16 @@ ${BOLD}Usage:${RESET}
477
628
  }
478
629
 
479
630
  // Done
631
+ closeRl();
480
632
  log(`\n${GREEN}${BOLD}✓ hearback is ready!${RESET}\n`);
481
633
  log(`${DIM}Next steps:`);
482
- log(` 1. Add your GITHUB_TOKEN to ${envFile}`);
483
- log(` 2. Run your dev server and test the feedback button${RESET}\n`);
634
+ if (!token && !hasExistingToken) {
635
+ log(` 1. Add your GITHUB_TOKEN to ${envFile}`);
636
+ log(` 2. Run your dev server and test the feedback button${RESET}\n`);
637
+ } else {
638
+ log(` 1. Run your dev server (e.g. npm run dev / bun dev)`);
639
+ log(` 2. Look for the feedback button in the bottom-right corner${RESET}\n`);
640
+ }
484
641
  }
485
642
 
486
643
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hearback",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Add user feedback → GitHub Issues → email notification loop to any product with one command. Works with Next.js, Express, Hono, and AI agents.",
5
5
  "type": "module",
6
6
  "bin": {