gent-cli 14.0.0 → 15.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "14.0.0",
3
+ "version": "15.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -402,63 +402,83 @@ const up = (n) => `\x1b[${n}A`;
402
402
  * @param {boolean} opts.altScreen use the alternate screen buffer
403
403
  * @returns {Promise<void>}
404
404
  */
405
- function play(sceneName, { loop = false, footer = true, goodbye = false, altScreen = false, maxTicks = Infinity } = {}) {
406
- return new Promise((resolve) => {
407
- const scene = SCENES[sceneName] || SCENES.idle;
408
- const cv = new Canvas(CV_W, CV_H);
409
- const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
410
- const totalTicks = loop ? Infinity : Math.min(scene.cycle + 1, maxTicks); // one clean cycle, capped
411
- let t = 0;
412
- let printed = false;
413
- let done = false;
414
-
415
- const footerLine = () => footer
416
- ? chalk.gray(' ') +
417
- (loop ? chalk.gray('Ctrl+C to leave') : C.body('Genti')) +
418
- chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
419
- : '';
420
-
421
- const paint = () => {
422
- cv.clear();
423
- scene.fn(cv, t, state);
424
- if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
425
- state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
426
- }
427
- const lines = cv.render().split('\n');
428
- lines.push(footerLine());
429
- const block = lines.map(l => CLEAR_LINE + l).join('\n');
430
- if (altScreen) {
431
- process.stdout.write(HOME + block);
432
- } else {
433
- if (printed) process.stdout.write(up(lines.length));
434
- process.stdout.write(block + '\n');
435
- }
436
- printed = true;
437
- };
438
-
439
- const finish = () => {
440
- if (done) return;
441
- done = true;
442
- clearInterval(timer);
443
- process.removeListener('SIGINT', onSig);
444
- process.stdout.write(SHOW + (altScreen ? LEAVE_ALT : ''));
445
- if (goodbye) {
446
- console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
447
- }
448
- resolve();
449
- };
405
+ function runLoop(sceneName, {
406
+ loop = false, footer = true, goodbye = false, altScreen = false,
407
+ maxTicks = Infinity, clearOnStop = false, exitOnSig = false,
408
+ } = {}) {
409
+ const scene = SCENES[sceneName] || SCENES.idle;
410
+ const cv = new Canvas(CV_W, CV_H);
411
+ const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
412
+ const totalTicks = loop ? Infinity : Math.min(scene.cycle + 1, maxTicks);
413
+ const blockH = CV_H + (footer ? 1 : 0);
414
+ let t = 0;
415
+ let printed = false;
416
+ let done = false;
417
+ let resolveFn;
418
+ const promise = new Promise((r) => { resolveFn = r; });
419
+
420
+ const footerLine = () => footer
421
+ ? chalk.gray(' ') +
422
+ (loop ? chalk.gray('Ctrl+C to cancel') : C.body('Genti')) +
423
+ chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
424
+ : '';
425
+
426
+ const paint = () => {
427
+ cv.clear();
428
+ scene.fn(cv, t, state);
429
+ if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
430
+ state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
431
+ }
432
+ const lines = cv.render().split('\n');
433
+ lines.push(footerLine());
434
+ const block = lines.map(l => CLEAR_LINE + l).join('\n');
435
+ if (altScreen) {
436
+ process.stdout.write(HOME + block);
437
+ } else {
438
+ if (printed) process.stdout.write(up(lines.length));
439
+ process.stdout.write(block + '\n');
440
+ }
441
+ printed = true;
442
+ };
443
+
444
+ // Erase the inline block so the caller can print clean output in its place.
445
+ const eraseBlock = () => {
446
+ if (!printed) return;
447
+ process.stdout.write(up(blockH));
448
+ for (let i = 0; i < blockH; i++) process.stdout.write(CLEAR_LINE + (i < blockH - 1 ? '\n' : ''));
449
+ process.stdout.write(up(blockH - 1));
450
+ };
451
+
452
+ const stop = () => {
453
+ if (done) return;
454
+ done = true;
455
+ clearInterval(timer);
456
+ process.removeListener('SIGINT', onSig);
457
+ if (clearOnStop && !altScreen) eraseBlock();
458
+ process.stdout.write(SHOW + (altScreen ? LEAVE_ALT : ''));
459
+ if (goodbye) {
460
+ console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
461
+ }
462
+ resolveFn();
463
+ };
450
464
 
451
- const onSig = () => finish();
465
+ const onSig = () => { stop(); if (exitOnSig) process.exit(130); };
452
466
 
453
- process.stdout.write((altScreen ? ENTER_ALT + HOME : '') + HIDE);
467
+ process.stdout.write((altScreen ? ENTER_ALT + HOME : '') + HIDE);
468
+ paint();
469
+ const timer = setInterval(() => {
470
+ t++;
471
+ if (t >= totalTicks) { paint(); return stop(); }
454
472
  paint();
455
- const timer = setInterval(() => {
456
- t++;
457
- if (t >= totalTicks) { paint(); return finish(); }
458
- paint();
459
- }, FRAME_MS);
460
- process.on('SIGINT', onSig);
461
- });
473
+ }, FRAME_MS);
474
+ process.on('SIGINT', onSig);
475
+
476
+ return { promise, stop };
477
+ }
478
+
479
+ // Play a scene to completion (or until Ctrl+C when looping). Resolves when done.
480
+ function play(sceneName, opts = {}) {
481
+ return runLoop(sceneName, opts).promise;
462
482
  }
463
483
 
464
484
  /**
@@ -528,6 +548,32 @@ async function celebrate(scene) {
528
548
  } catch (_) { /* ignore — decoration only */ }
529
549
  }
530
550
 
551
+ /**
552
+ * Public: run `task` while Genti animates as the live loader — carrying the
553
+ * crate to the cloud on push, home on pull, etc. The animation loops until the
554
+ * task settles, then erases itself so the command can print its own result.
555
+ *
556
+ * Safe + transparent: with no TTY / GENT_NO_PET / CI it simply awaits the task
557
+ * with no animation. Always returns (or throws) exactly what `task` does.
558
+ *
559
+ * @param {string} scene 'push' | 'pull' | 'merge' | …
560
+ * @param {() => Promise<any>} task the real async work (e.g. the network call)
561
+ * @returns {Promise<any>}
562
+ */
563
+ async function during(scene, task) {
564
+ if (!process.stdout.isTTY || process.env.GENT_NO_PET || process.env.CI || !SCENES[scene]) {
565
+ return task();
566
+ }
567
+ const anim = runLoop(scene, { loop: true, footer: true, altScreen: false, clearOnStop: true, exitOnSig: true });
568
+ try {
569
+ return await task();
570
+ } finally {
571
+ anim.stop();
572
+ await anim.promise;
573
+ }
574
+ }
575
+
531
576
  module.exports = petCommand;
532
577
  module.exports.celebrate = celebrate;
533
578
  module.exports.banner = banner;
579
+ module.exports.during = during;
@@ -73,14 +73,15 @@ async function pull(remoteName, branchName, options) {
73
73
 
74
74
  // 1. Fetch commits + objects for this branch in a single call. `since`
75
75
  // lets the server send only what we don't have on a fast-forward.
76
- spinner.text = `Fetching updates for ${branch}...`;
76
+ // Genti walks a crate home from the cloud while we fetch.
77
+ spinner.stop();
77
78
  let pullData;
78
79
  try {
79
80
  const pullUrl = buildRepoUrl(API_ENDPOINTS.REPO_PULL, repoInfo);
80
81
  const query = localHead
81
82
  ? `?branch=${encodeURIComponent(branch)}&since=${encodeURIComponent(localHead)}`
82
83
  : `?branch=${encodeURIComponent(branch)}`;
83
- pullData = await apiClient.get(pullUrl + query);
84
+ pullData = await pet.during('pull', () => apiClient.get(pullUrl + query));
84
85
  } catch (error) {
85
86
  if (error.response?.status === 404) {
86
87
  spinner.succeed(chalk.green('Remote branch not found — nothing to pull'));
@@ -138,7 +139,6 @@ async function pull(remoteName, branchName, options) {
138
139
 
139
140
  spinner.succeed(chalk.green(`Fast-forward: ${newCount} new commit(s)`));
140
141
  console.log(chalk.gray(` ${remote}/${branch} → ${remoteHead.substring(0, 7)}`));
141
- await pet.celebrate('pull');
142
142
  } else {
143
143
  // Diverged — need 3-way merge
144
144
  spinner.text = 'Branches diverged, merging...';
@@ -194,7 +194,6 @@ async function pull(remoteName, branchName, options) {
194
194
  } else {
195
195
  spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
196
196
  console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
197
- await pet.celebrate('pull');
198
197
  }
199
198
  }
200
199
  } catch (error) {
@@ -232,20 +232,19 @@ async function push(remoteName, branchName, options) {
232
232
  tags: tagsToPush
233
233
  };
234
234
 
235
- // Send to backend
235
+ // Send to backend — Genti carries the crate to the cloud while we wait.
236
236
  const pushUrl = buildRepoUrl(API_ENDPOINTS.REPO_PUSH, repoInfo);
237
- const response = await apiClient.post(pushUrl, payload);
237
+ spinner.stop();
238
+ const response = await pet.during('push', () => apiClient.post(pushUrl, payload));
238
239
 
239
240
  // Update remote ref
240
241
  config.remoteRefs[`${remote}/${branch}`] = localHead;
241
242
  await writeJSON(configPath, config);
242
243
 
243
- spinner.succeed(chalk.green(`Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
244
+ console.log(chalk.green(`✔ Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
244
245
  console.log(chalk.gray(` ${localHead.substring(0, 7)} → ${remote}/${branch}`));
245
246
  console.log(chalk.gray(` ${packBlobs.length} blob(s), ${packTrees.length} tree(s) transferred`));
246
247
 
247
- await pet.celebrate('push');
248
-
249
248
  } catch (error) {
250
249
  spinner.fail(chalk.red('Push failed'));
251
250