@juspay/shooter 1.7.0 → 1.8.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.
Files changed (122) hide show
  1. package/bin/shooter.cjs +522 -0
  2. package/build/client/_app/immutable/assets/0.BZLcOr5z.css +1 -0
  3. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.br +0 -0
  4. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{1R757KKW.js → B6b4w6vf.js} +1 -1
  6. package/build/client/_app/immutable/chunks/B6b4w6vf.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/{1R757KKW.js.gz → B6b4w6vf.js.gz} +0 -0
  8. package/build/client/_app/immutable/chunks/BEa4nlMF.js +3 -0
  9. package/build/client/_app/immutable/chunks/BEa4nlMF.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/BEa4nlMF.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/{82KkiYvP.js → C7SeOWDG.js} +1 -1
  12. package/build/client/_app/immutable/chunks/C7SeOWDG.js.br +0 -0
  13. package/build/client/_app/immutable/chunks/C7SeOWDG.js.gz +0 -0
  14. package/build/client/_app/immutable/chunks/DdpA9n1s.js +5 -0
  15. package/build/client/_app/immutable/chunks/DdpA9n1s.js.br +4 -0
  16. package/build/client/_app/immutable/chunks/DdpA9n1s.js.gz +0 -0
  17. package/build/client/_app/immutable/chunks/xs1Xl3_e.js +1 -0
  18. package/build/client/_app/immutable/chunks/xs1Xl3_e.js.br +0 -0
  19. package/build/client/_app/immutable/chunks/xs1Xl3_e.js.gz +0 -0
  20. package/build/client/_app/immutable/entry/{app.BBj54tOn.js → app.CP7226A7.js} +2 -2
  21. package/build/client/_app/immutable/entry/app.CP7226A7.js.br +0 -0
  22. package/build/client/_app/immutable/entry/app.CP7226A7.js.gz +0 -0
  23. package/build/client/_app/immutable/entry/start.mGPvkOah.js +1 -0
  24. package/build/client/_app/immutable/entry/start.mGPvkOah.js.br +2 -0
  25. package/build/client/_app/immutable/entry/start.mGPvkOah.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{0.tGEagUxT.js → 0.DwU44ZAj.js} +1 -1
  27. package/build/client/_app/immutable/nodes/0.DwU44ZAj.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/0.DwU44ZAj.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{1.CTMQXGCN.js → 1.CChG-n6d.js} +1 -1
  30. package/build/client/_app/immutable/nodes/1.CChG-n6d.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/1.CChG-n6d.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{2._DiTJ6NZ.js → 2.CzexDbwp.js} +1 -1
  33. package/build/client/_app/immutable/nodes/2.CzexDbwp.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/2.CzexDbwp.js.gz +0 -0
  35. package/build/client/_app/immutable/nodes/{3.BpFIHCgE.js → 3.DC3WghxB.js} +1 -1
  36. package/build/client/_app/immutable/nodes/3.DC3WghxB.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/3.DC3WghxB.js.gz +0 -0
  38. package/build/client/_app/immutable/nodes/{6.BnaLvG49.js → 6.C4aXlZQd.js} +1 -1
  39. package/build/client/_app/immutable/nodes/6.C4aXlZQd.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/6.C4aXlZQd.js.gz +0 -0
  41. package/build/client/_app/immutable/nodes/7.DfniCleW.js +4 -0
  42. package/build/client/_app/immutable/nodes/7.DfniCleW.js.br +0 -0
  43. package/build/client/_app/immutable/nodes/7.DfniCleW.js.gz +0 -0
  44. package/build/client/_app/immutable/nodes/{8.CT86dtkE.js → 8.D4AzZWcq.js} +2 -2
  45. package/build/client/_app/immutable/nodes/8.D4AzZWcq.js.br +0 -0
  46. package/build/client/_app/immutable/nodes/8.D4AzZWcq.js.gz +0 -0
  47. package/build/client/_app/immutable/nodes/{9.JmYAqwbO.js → 9.gV8oJWv_.js} +1 -1
  48. package/build/client/_app/immutable/nodes/9.gV8oJWv_.js.br +0 -0
  49. package/build/client/_app/immutable/nodes/9.gV8oJWv_.js.gz +0 -0
  50. package/build/client/_app/version.json +1 -1
  51. package/build/client/_app/version.json.br +0 -0
  52. package/build/client/_app/version.json.gz +0 -0
  53. package/build/server/chunks/{0-9YbbJhFw.js → 0-XyVDlEyN.js} +3 -3
  54. package/build/server/chunks/{0-9YbbJhFw.js.map → 0-XyVDlEyN.js.map} +1 -1
  55. package/build/server/chunks/{1-DULDAXgW.js → 1-C3vZx9QL.js} +2 -2
  56. package/build/server/chunks/{1-DULDAXgW.js.map → 1-C3vZx9QL.js.map} +1 -1
  57. package/build/server/chunks/{2-vevFT-GW.js → 2-LWO3Q9-s.js} +2 -2
  58. package/build/server/chunks/{2-vevFT-GW.js.map → 2-LWO3Q9-s.js.map} +1 -1
  59. package/build/server/chunks/{3-Bp3qDEwZ.js → 3-3WzO52IA.js} +2 -2
  60. package/build/server/chunks/{3-Bp3qDEwZ.js.map → 3-3WzO52IA.js.map} +1 -1
  61. package/build/server/chunks/{6-BsyQ_s8I.js → 6-DjPIWYcj.js} +2 -2
  62. package/build/server/chunks/{6-BsyQ_s8I.js.map → 6-DjPIWYcj.js.map} +1 -1
  63. package/build/server/chunks/{7-B2i23jNm.js → 7-DF5FUXhP.js} +3 -3
  64. package/build/server/chunks/{7-B2i23jNm.js.map → 7-DF5FUXhP.js.map} +1 -1
  65. package/build/server/chunks/{8-BHREyW3X.js → 8-CejJgM0l.js} +2 -2
  66. package/build/server/chunks/{8-BHREyW3X.js.map → 8-CejJgM0l.js.map} +1 -1
  67. package/build/server/chunks/{9-BqFD2wVL.js → 9-D1YMozmH.js} +2 -2
  68. package/build/server/chunks/{9-BqFD2wVL.js.map → 9-D1YMozmH.js.map} +1 -1
  69. package/build/server/chunks/{_page.svelte-D1Iaycsm.js → _page.svelte-DpUVuiqQ.js} +3 -13
  70. package/build/server/chunks/_page.svelte-DpUVuiqQ.js.map +1 -0
  71. package/build/server/chunks/{_server.ts-BQarRaho.js → _server.ts-CilRds58.js} +14 -11
  72. package/build/server/chunks/_server.ts-CilRds58.js.map +1 -0
  73. package/build/server/index.js +1 -1
  74. package/build/server/index.js.map +1 -1
  75. package/build/server/manifest.js +10 -10
  76. package/build/server/manifest.js.map +1 -1
  77. package/package.json +1 -1
  78. package/scripts/update-checker.cjs +196 -0
  79. package/scripts/update-state.cjs +235 -0
  80. package/src/app.css +23 -7
  81. package/src/lib/theme.css +39 -2
  82. package/src/routes/api/sessions/+server.ts +16 -10
  83. package/src/routes/session/[id]/+page.svelte +25 -14
  84. package/build/client/_app/immutable/assets/0.BafSE-sr.css +0 -1
  85. package/build/client/_app/immutable/assets/0.BafSE-sr.css.br +0 -0
  86. package/build/client/_app/immutable/assets/0.BafSE-sr.css.gz +0 -0
  87. package/build/client/_app/immutable/chunks/1R757KKW.js.br +0 -0
  88. package/build/client/_app/immutable/chunks/82KkiYvP.js.br +0 -0
  89. package/build/client/_app/immutable/chunks/82KkiYvP.js.gz +0 -0
  90. package/build/client/_app/immutable/chunks/C0VhPQz3.js +0 -3
  91. package/build/client/_app/immutable/chunks/C0VhPQz3.js.br +0 -0
  92. package/build/client/_app/immutable/chunks/C0VhPQz3.js.gz +0 -0
  93. package/build/client/_app/immutable/chunks/CRXYOhph.js +0 -5
  94. package/build/client/_app/immutable/chunks/CRXYOhph.js.br +0 -0
  95. package/build/client/_app/immutable/chunks/CRXYOhph.js.gz +0 -0
  96. package/build/client/_app/immutable/chunks/Dqgg-a0I.js +0 -1
  97. package/build/client/_app/immutable/chunks/Dqgg-a0I.js.br +0 -0
  98. package/build/client/_app/immutable/chunks/Dqgg-a0I.js.gz +0 -0
  99. package/build/client/_app/immutable/entry/app.BBj54tOn.js.br +0 -0
  100. package/build/client/_app/immutable/entry/app.BBj54tOn.js.gz +0 -0
  101. package/build/client/_app/immutable/entry/start.hE8cZTT9.js +0 -1
  102. package/build/client/_app/immutable/entry/start.hE8cZTT9.js.br +0 -2
  103. package/build/client/_app/immutable/entry/start.hE8cZTT9.js.gz +0 -0
  104. package/build/client/_app/immutable/nodes/0.tGEagUxT.js.br +0 -0
  105. package/build/client/_app/immutable/nodes/0.tGEagUxT.js.gz +0 -0
  106. package/build/client/_app/immutable/nodes/1.CTMQXGCN.js.br +0 -0
  107. package/build/client/_app/immutable/nodes/1.CTMQXGCN.js.gz +0 -0
  108. package/build/client/_app/immutable/nodes/2._DiTJ6NZ.js.br +0 -0
  109. package/build/client/_app/immutable/nodes/2._DiTJ6NZ.js.gz +0 -0
  110. package/build/client/_app/immutable/nodes/3.BpFIHCgE.js.br +0 -0
  111. package/build/client/_app/immutable/nodes/3.BpFIHCgE.js.gz +0 -0
  112. package/build/client/_app/immutable/nodes/6.BnaLvG49.js.br +0 -0
  113. package/build/client/_app/immutable/nodes/6.BnaLvG49.js.gz +0 -0
  114. package/build/client/_app/immutable/nodes/7.C9odxWmO.js +0 -4
  115. package/build/client/_app/immutable/nodes/7.C9odxWmO.js.br +0 -0
  116. package/build/client/_app/immutable/nodes/7.C9odxWmO.js.gz +0 -0
  117. package/build/client/_app/immutable/nodes/8.CT86dtkE.js.br +0 -0
  118. package/build/client/_app/immutable/nodes/8.CT86dtkE.js.gz +0 -0
  119. package/build/client/_app/immutable/nodes/9.JmYAqwbO.js.br +0 -0
  120. package/build/client/_app/immutable/nodes/9.JmYAqwbO.js.gz +0 -0
  121. package/build/server/chunks/_page.svelte-D1Iaycsm.js.map +0 -1
  122. package/build/server/chunks/_server.ts-BQarRaho.js.map +0 -1
package/bin/shooter.cjs CHANGED
@@ -85,6 +85,15 @@ function removePid() {
85
85
  } catch {}
86
86
  }
87
87
 
88
+ // ── Guard / auto-update constants ──────────────────────────────────
89
+ const GUARD_PID_FILE = path.join(SHOOTER_HOME, 'guard.pid');
90
+ const GUARD_LOG_FILE = path.join(LOG_DIR, 'guard.log');
91
+ const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
92
+ const UPDATE_FIRST_CHECK_MS = 30_000; // 30 seconds after start
93
+ const INSTALL_TIMEOUT_MS = 120_000;
94
+ const BUILD_TIMEOUT_MS = 300_000;
95
+ const HEALTH_TIMEOUT_MS = 30_000;
96
+
88
97
  // ── CLI argument parsing ────────────────────────────────────────────
89
98
  const args = process.argv.slice(2);
90
99
  const command = args[0] || 'start';
@@ -108,6 +117,12 @@ switch (command) {
108
117
  case 'setup':
109
118
  runSetup();
110
119
  break;
120
+ case 'update':
121
+ runUpdate(args[1]);
122
+ break;
123
+ case 'guard':
124
+ runGuard();
125
+ break;
111
126
  case 'version':
112
127
  case '--version':
113
128
  case '-v':
@@ -279,6 +294,10 @@ function startServer() {
279
294
  } else if (!noTunnel && !isCloudflaredAvailable()) {
280
295
  console.log(' (cloudflared not found — no tunnel. Install: brew install cloudflared)');
281
296
  }
297
+
298
+ // Spawn auto-update guard (detached) — only when launchd is managing
299
+ spawnGuard(child.pid, port);
300
+
282
301
  // Exit immediately — daemon is running, tunnel starts async
283
302
  process.exit(0);
284
303
  } else {
@@ -312,9 +331,13 @@ function startServer() {
312
331
  }, 3000);
313
332
  }
314
333
 
334
+ // Spawn auto-update guard (detached) — only when launchd is managing
335
+ spawnGuard(child.pid, port);
336
+
315
337
  child.on('error', (err) => {
316
338
  removePid();
317
339
  if (tunnelStarted) stopTunnel();
340
+ stopGuard();
318
341
  console.error('Failed to start Shooter server:', err.message);
319
342
  process.exit(1);
320
343
  });
@@ -322,6 +345,7 @@ function startServer() {
322
345
  child.on('exit', (code, signal) => {
323
346
  removePid();
324
347
  stopTunnel();
348
+ stopGuard();
325
349
  if (signal) {
326
350
  process.exit(128 + (signalCode(signal) || 1));
327
351
  }
@@ -333,6 +357,7 @@ function startServer() {
333
357
  process.on(sig, () => {
334
358
  child.kill(sig);
335
359
  stopTunnel();
360
+ stopGuard();
336
361
  });
337
362
  }
338
363
  }
@@ -351,6 +376,7 @@ function stopServer() {
351
376
 
352
377
  console.log(`Stopping Shooter (PID ${pid})...`);
353
378
  stopTunnel();
379
+ stopGuard();
354
380
  try {
355
381
  process.kill(pid, 'SIGTERM');
356
382
  // Wait briefly for clean shutdown
@@ -681,6 +707,493 @@ function runSetup() {
681
707
  });
682
708
  }
683
709
 
710
+ // ── update ──────────────────────────────────────────────────────────
711
+
712
+ function runUpdate(subcommand) {
713
+ const { checkForUpdate } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
714
+ const { recordCheck, isVersionSuppressed } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
715
+
716
+ const result = checkForUpdate(PKG_ROOT);
717
+ if (!result.checkFailed) recordCheck(result.latestVersion);
718
+
719
+ if (subcommand === 'check') {
720
+ // Just check, don't install
721
+ if (result.checkFailed) {
722
+ console.error(`Update check failed: ${result.error}`);
723
+ process.exitCode = 1;
724
+ } else if (result.updateAvailable) {
725
+ console.log(`Update available: ${result.currentVersion} → ${result.latestVersion}`);
726
+ console.log(` Current commit: ${result.currentCommit}`);
727
+ console.log(` Latest commit: ${result.latestCommit}`);
728
+ if (isVersionSuppressed(result.latestVersion)) {
729
+ console.log(` (version ${result.latestVersion} is temporarily suppressed — will retry in <24h)`);
730
+ }
731
+ } else {
732
+ console.log(`Already up to date: v${result.currentVersion} (${result.currentCommit})`);
733
+ }
734
+ return;
735
+ }
736
+
737
+ // Default: check + install
738
+ if (result.checkFailed) {
739
+ console.error(`Update check failed: ${result.error}`);
740
+ process.exitCode = 1;
741
+ return;
742
+ }
743
+ if (!result.updateAvailable) {
744
+ console.log(`Already up to date: v${result.currentVersion} (${result.currentCommit})`);
745
+ return;
746
+ }
747
+
748
+ // Only allow installs on the release branch
749
+ const { getCurrentBranch } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
750
+ const branch = getCurrentBranch(PKG_ROOT);
751
+ if (branch !== 'release') {
752
+ console.log(`Cannot update: currently on branch '${branch || 'unknown'}', updates only apply to 'release'.`);
753
+ console.log('Switch to the release branch and try again.');
754
+ return;
755
+ }
756
+
757
+ if (isVersionSuppressed(result.latestVersion)) {
758
+ console.log(`Update to ${result.latestVersion} is temporarily suppressed (previous failure).`);
759
+ console.log('Suppression expires within 24 hours. Use a fresh git pull to override.');
760
+ return;
761
+ }
762
+
763
+ console.log(`Updating: ${result.currentVersion} → ${result.latestVersion}...`);
764
+ const success = performUpdate(result);
765
+ if (success) {
766
+ console.log(`\nUpdate complete! Now running v${result.latestVersion}.`);
767
+
768
+ // Restart if server is running
769
+ const pid = readPid();
770
+ if (pid) {
771
+ if (isLaunchdManaging()) {
772
+ const uid = process.getuid ? process.getuid() : 501;
773
+ try {
774
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, {
775
+ timeout: 10_000,
776
+ stdio: 'pipe',
777
+ });
778
+ console.log('Signaled launchd to restart the server.');
779
+ } catch {
780
+ console.log('launchctl kickstart failed — the server may need a manual restart.');
781
+ }
782
+ } else {
783
+ try {
784
+ process.kill(pid, 'SIGTERM');
785
+ } catch {}
786
+ console.log('Server process terminated. Run "shooter start" to restart.');
787
+ }
788
+ }
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Perform the actual update: git pull → pnpm install → pnpm build.
794
+ * Returns true on success. On failure, rolls back and returns false.
795
+ */
796
+ function performUpdate(result) {
797
+ const { suppressVersion, recordSuccessfulUpdate } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
798
+
799
+ // Save current HEAD for rollback
800
+ let savedHead = '';
801
+ try {
802
+ savedHead = execSync('git rev-parse HEAD', {
803
+ cwd: PKG_ROOT,
804
+ encoding: 'utf8',
805
+ stdio: ['ignore', 'pipe', 'ignore'],
806
+ }).trim();
807
+ } catch {}
808
+
809
+ // 1. Git pull (fast-forward only)
810
+ try {
811
+ console.log(' Pulling latest changes...');
812
+ execSync('git pull --ff-only origin release', {
813
+ cwd: PKG_ROOT,
814
+ stdio: 'inherit',
815
+ timeout: 30_000,
816
+ });
817
+ } catch (err) {
818
+ console.error(' Git pull failed:', err.message || err);
819
+ suppressVersion(result.latestVersion, 'pull_failed');
820
+ return false;
821
+ }
822
+
823
+ // 2. Install dependencies
824
+ try {
825
+ console.log(' Installing dependencies...');
826
+ execSync('pnpm install --frozen-lockfile', {
827
+ cwd: PKG_ROOT,
828
+ stdio: 'inherit',
829
+ timeout: INSTALL_TIMEOUT_MS,
830
+ });
831
+ } catch (err) {
832
+ console.error(' pnpm install failed:', err.message || err);
833
+ rollback(savedHead);
834
+ suppressVersion(result.latestVersion, 'install_failed');
835
+ return false;
836
+ }
837
+
838
+ // 3. Build
839
+ try {
840
+ console.log(' Building...');
841
+ execSync('pnpm build', {
842
+ cwd: PKG_ROOT,
843
+ stdio: 'inherit',
844
+ timeout: BUILD_TIMEOUT_MS,
845
+ });
846
+ } catch (err) {
847
+ console.error(' Build failed:', err.message || err);
848
+ rollback(savedHead);
849
+ suppressVersion(result.latestVersion, 'build_failed');
850
+ return false;
851
+ }
852
+
853
+ recordSuccessfulUpdate(result.latestVersion, result.currentVersion);
854
+ return true;
855
+ }
856
+
857
+ /**
858
+ * Roll back to a previous commit after a failed update.
859
+ */
860
+ function rollback(savedHead) {
861
+ if (!savedHead) return;
862
+ console.log(' Rolling back to previous version...');
863
+ try {
864
+ execSync(`git reset --hard ${savedHead}`, {
865
+ cwd: PKG_ROOT,
866
+ stdio: 'inherit',
867
+ timeout: 10_000,
868
+ });
869
+ execSync('pnpm install --frozen-lockfile', {
870
+ cwd: PKG_ROOT,
871
+ stdio: 'inherit',
872
+ timeout: INSTALL_TIMEOUT_MS,
873
+ });
874
+ execSync('pnpm build', {
875
+ cwd: PKG_ROOT,
876
+ stdio: 'inherit',
877
+ timeout: BUILD_TIMEOUT_MS,
878
+ });
879
+ } catch (rollbackErr) {
880
+ console.error(' WARNING: Rollback failed:', rollbackErr.message || rollbackErr);
881
+ console.error(' Manual intervention may be required.');
882
+ }
883
+ }
884
+
885
+ // ── guard (hidden — spawned by start) ──────────────────────────────
886
+
887
+ function spawnGuard(parentPid, port) {
888
+ // Only spawn guard when launchd is managing the service
889
+ if (!isLaunchdManaging()) return;
890
+
891
+ const shooterBin = path.join(PKG_ROOT, 'bin', 'shooter.cjs');
892
+ try {
893
+ fs.mkdirSync(LOG_DIR, { recursive: true });
894
+ const logFd = fs.openSync(GUARD_LOG_FILE, 'a');
895
+
896
+ const child = spawn(
897
+ process.execPath,
898
+ [shooterBin, 'guard', '--parent-pid', String(parentPid), '--port', String(port)],
899
+ {
900
+ cwd: PKG_ROOT,
901
+ detached: true,
902
+ stdio: ['ignore', logFd, logFd],
903
+ env: {
904
+ ...process.env,
905
+ SHOOTER_PKG_ROOT: PKG_ROOT,
906
+ SHOOTER_HOME,
907
+ },
908
+ }
909
+ );
910
+
911
+ if (child.pid) {
912
+ fs.mkdirSync(SHOOTER_HOME, { recursive: true });
913
+ fs.writeFileSync(GUARD_PID_FILE, String(child.pid));
914
+ }
915
+ child.unref();
916
+ fs.closeSync(logFd);
917
+ } catch (err) {
918
+ // Non-fatal — server runs fine without guard
919
+ console.log(` (auto-update guard failed to start: ${err.message})`);
920
+ }
921
+ }
922
+
923
+ function stopGuard() {
924
+ try {
925
+ const pid = parseInt(fs.readFileSync(GUARD_PID_FILE, 'utf8').trim(), 10);
926
+ if (!isNaN(pid) && pid > 0) {
927
+ // Validate the PID is actually a guard process (prevent PID reuse attacks)
928
+ try {
929
+ const cmdline = execSync(`ps -p ${pid} -o command= 2>/dev/null`, {
930
+ encoding: 'utf8',
931
+ }).trim();
932
+ if (!cmdline.includes('shooter.cjs') || !cmdline.includes('guard')) {
933
+ // PID reused by an unrelated process — just clean up the stale pidfile
934
+ fs.unlinkSync(GUARD_PID_FILE);
935
+ return;
936
+ }
937
+ } catch {
938
+ // ps failed — process likely already exited
939
+ fs.unlinkSync(GUARD_PID_FILE);
940
+ return;
941
+ }
942
+ try {
943
+ process.kill(pid, 'SIGTERM');
944
+ } catch {}
945
+ }
946
+ } catch {}
947
+ try {
948
+ fs.unlinkSync(GUARD_PID_FILE);
949
+ } catch {}
950
+ }
951
+
952
+ function isLaunchdManaging() {
953
+ if (os.platform() !== 'darwin') return false;
954
+ try {
955
+ const uid = process.getuid ? process.getuid() : 501;
956
+ execSync(`launchctl print gui/${uid}/${LAUNCHD_LABEL} 2>/dev/null`, {
957
+ stdio: ['ignore', 'pipe', 'ignore'],
958
+ timeout: 5_000,
959
+ });
960
+ return true;
961
+ } catch {
962
+ return false;
963
+ }
964
+ }
965
+
966
+ function runGuard() {
967
+ // Parse guard-specific args
968
+ let parentPid = 0;
969
+ let port = DEFAULT_PORT;
970
+ for (let i = 1; i < args.length; i++) {
971
+ if (args[i] === '--parent-pid' && args[i + 1]) {
972
+ parentPid = parseInt(args[i + 1], 10);
973
+ i++;
974
+ } else if (args[i] === '--port' && args[i + 1]) {
975
+ port = parseInt(args[i + 1], 10);
976
+ i++;
977
+ }
978
+ }
979
+
980
+ if (!parentPid || parentPid <= 0) {
981
+ console.error('[guard] Invalid --parent-pid');
982
+ process.exit(1);
983
+ }
984
+
985
+ const { checkForUpdate, getCurrentBranch } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
986
+ const {
987
+ recordCheck,
988
+ isVersionSuppressed,
989
+ suppressVersion,
990
+ recordSuccessfulUpdate,
991
+ } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
992
+
993
+ const log = (msg) => console.log(`[guard] ${new Date().toISOString()} ${msg}`);
994
+
995
+ // Check if parent is alive
996
+ function isParentAlive() {
997
+ try {
998
+ process.kill(parentPid, 0);
999
+ return true;
1000
+ } catch {
1001
+ return false;
1002
+ }
1003
+ }
1004
+
1005
+ let updateInProgress = false;
1006
+ let updateRestartInProgress = false;
1007
+
1008
+ async function runUpdateCheck() {
1009
+ if (updateInProgress) return;
1010
+ updateInProgress = true;
1011
+
1012
+ try {
1013
+ // Only auto-update on release branch
1014
+ const branch = getCurrentBranch(PKG_ROOT);
1015
+ if (branch !== 'release') {
1016
+ log(`skipping update check — on branch '${branch}', not 'release'`);
1017
+ return;
1018
+ }
1019
+
1020
+ // 1. Check for update
1021
+ const result = checkForUpdate(PKG_ROOT);
1022
+ if (result.checkFailed) {
1023
+ log(`update check failed: ${result.error}`);
1024
+ return;
1025
+ }
1026
+ recordCheck(result.latestVersion);
1027
+
1028
+ if (!result.updateAvailable) {
1029
+ log(`up to date: v${result.currentVersion}`);
1030
+ return;
1031
+ }
1032
+
1033
+ if (isVersionSuppressed(result.latestVersion)) {
1034
+ log(`version ${result.latestVersion} is suppressed, skipping`);
1035
+ return;
1036
+ }
1037
+
1038
+ log(`update available: ${result.currentVersion} → ${result.latestVersion}`);
1039
+
1040
+ // 2. Save current HEAD for rollback
1041
+ let savedHead = '';
1042
+ try {
1043
+ savedHead = execSync('git rev-parse HEAD', {
1044
+ cwd: PKG_ROOT,
1045
+ encoding: 'utf8',
1046
+ stdio: ['ignore', 'pipe', 'ignore'],
1047
+ }).trim();
1048
+ } catch {}
1049
+
1050
+ // 3. Git pull
1051
+ try {
1052
+ execSync('git pull --ff-only origin release', {
1053
+ cwd: PKG_ROOT,
1054
+ encoding: 'utf8',
1055
+ stdio: ['ignore', 'pipe', 'pipe'],
1056
+ timeout: 30_000,
1057
+ });
1058
+ } catch (err) {
1059
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1060
+ log(`git pull failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1061
+ suppressVersion(result.latestVersion, 'pull_failed');
1062
+ return;
1063
+ }
1064
+
1065
+ // 4. Install dependencies
1066
+ try {
1067
+ log('installing dependencies...');
1068
+ execSync('pnpm install --frozen-lockfile', {
1069
+ cwd: PKG_ROOT,
1070
+ encoding: 'utf8',
1071
+ stdio: ['ignore', 'pipe', 'pipe'],
1072
+ timeout: INSTALL_TIMEOUT_MS,
1073
+ });
1074
+ } catch (err) {
1075
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1076
+ log(`pnpm install failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1077
+ guardRollback(savedHead);
1078
+ suppressVersion(result.latestVersion, 'install_failed');
1079
+ return;
1080
+ }
1081
+
1082
+ // 5. Build
1083
+ try {
1084
+ log('building...');
1085
+ execSync('pnpm build', {
1086
+ cwd: PKG_ROOT,
1087
+ encoding: 'utf8',
1088
+ stdio: ['ignore', 'pipe', 'pipe'],
1089
+ timeout: BUILD_TIMEOUT_MS,
1090
+ });
1091
+ } catch (err) {
1092
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1093
+ log(`build failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1094
+ guardRollback(savedHead);
1095
+ suppressVersion(result.latestVersion, 'build_failed');
1096
+ return;
1097
+ }
1098
+
1099
+ // 6. Restart via launchctl
1100
+ updateRestartInProgress = true;
1101
+ log('restarting via launchctl...');
1102
+ const uid = process.getuid ? process.getuid() : 501;
1103
+ try {
1104
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, {
1105
+ timeout: 10_000,
1106
+ stdio: 'pipe',
1107
+ });
1108
+ } catch {
1109
+ log('WARNING: launchctl kickstart failed');
1110
+ suppressVersion(result.latestVersion, 'restart_failed');
1111
+ updateRestartInProgress = false;
1112
+ return;
1113
+ }
1114
+
1115
+ // 7. Wait for healthy restart
1116
+ let healthy = false;
1117
+ const restartStart = Date.now();
1118
+ while (Date.now() - restartStart < HEALTH_TIMEOUT_MS) {
1119
+ await sleep(2000);
1120
+ try {
1121
+ const resp = await fetch(`http://localhost:${port}/api/health`, {
1122
+ signal: AbortSignal.timeout(3000),
1123
+ });
1124
+ if (resp.ok) {
1125
+ const data = await resp.json();
1126
+ if (data.version === result.latestVersion) {
1127
+ healthy = true;
1128
+ break;
1129
+ }
1130
+ }
1131
+ } catch {
1132
+ /* retry */
1133
+ }
1134
+ }
1135
+
1136
+ if (healthy) {
1137
+ log(`update successful: now running v${result.latestVersion}`);
1138
+ recordSuccessfulUpdate(result.latestVersion, result.currentVersion);
1139
+ // New server will spawn its own guard — exit this one
1140
+ process.exit(0);
1141
+ } else {
1142
+ log(`WARNING: server unhealthy after update to ${result.latestVersion}`);
1143
+ suppressVersion(result.latestVersion, 'unhealthy_after_restart');
1144
+ updateRestartInProgress = false;
1145
+ }
1146
+ } catch (err) {
1147
+ log(`update check error: ${err.message || err}`);
1148
+ } finally {
1149
+ updateInProgress = false;
1150
+ }
1151
+ }
1152
+
1153
+ function guardRollback(savedHead) {
1154
+ if (!savedHead) return;
1155
+ log('rolling back...');
1156
+ try {
1157
+ execSync(`git reset --hard ${savedHead}`, {
1158
+ cwd: PKG_ROOT,
1159
+ stdio: ['ignore', 'pipe', 'ignore'],
1160
+ timeout: 10_000,
1161
+ });
1162
+ execSync('pnpm install --frozen-lockfile', {
1163
+ cwd: PKG_ROOT,
1164
+ stdio: ['ignore', 'pipe', 'pipe'],
1165
+ timeout: INSTALL_TIMEOUT_MS,
1166
+ });
1167
+ execSync('pnpm build', {
1168
+ cwd: PKG_ROOT,
1169
+ stdio: ['ignore', 'pipe', 'pipe'],
1170
+ timeout: BUILD_TIMEOUT_MS,
1171
+ });
1172
+ } catch (rollbackErr) {
1173
+ log(`WARNING: rollback failed: ${rollbackErr.message}`);
1174
+ }
1175
+ }
1176
+
1177
+ // Parent process health monitor
1178
+ const parentCheckInterval = setInterval(() => {
1179
+ if (!isParentAlive() && !updateRestartInProgress) {
1180
+ log('parent process died, exiting guard');
1181
+ clearInterval(parentCheckInterval);
1182
+ process.exit(0);
1183
+ }
1184
+ }, 5_000);
1185
+
1186
+ // Schedule update checks
1187
+ setTimeout(() => void runUpdateCheck(), UPDATE_FIRST_CHECK_MS);
1188
+ setInterval(() => void runUpdateCheck(), UPDATE_CHECK_INTERVAL_MS);
1189
+
1190
+ log(`started (parent PID: ${parentPid}, port: ${port})`);
1191
+ }
1192
+
1193
+ function sleep(ms) {
1194
+ return new Promise((r) => setTimeout(r, ms));
1195
+ }
1196
+
684
1197
  // ── help ────────────────────────────────────────────────────────────
685
1198
 
686
1199
  function showHelp() {
@@ -699,6 +1212,8 @@ Commands:
699
1212
  logs Tail server logs
700
1213
  setup Quick setup (API key + build, ~60 seconds)
701
1214
  setup --push Add/reconfigure push notifications
1215
+ update Check for updates and install if available
1216
+ update check Check for updates without installing
702
1217
  version Show version number
703
1218
  help Show this help message
704
1219
 
@@ -706,6 +1221,11 @@ Start options:
706
1221
  -d, --daemon Run in background (detach from terminal)
707
1222
  --no-tunnel Don't start a Cloudflare Tunnel
708
1223
 
1224
+ Auto-update:
1225
+ When running as a LaunchAgent, Shooter automatically checks for updates
1226
+ every 2 hours. Updates are pulled from origin/release, built, and the
1227
+ server is restarted via launchctl. Terminal sessions survive restarts.
1228
+
709
1229
  Examples:
710
1230
  shooter Start the server + tunnel (foreground)
711
1231
  shooter start -d Start in background (daemon mode)
@@ -715,6 +1235,8 @@ Examples:
715
1235
  shooter logs Follow server logs
716
1236
  shooter setup Quick setup (~60s, push deferred)
717
1237
  shooter setup --push Add iOS/Android push notifications
1238
+ shooter update Check and install updates
1239
+ shooter update check Check for updates only
718
1240
  `.trim()
719
1241
  );
720
1242
  }