@juspay/shooter 1.7.1 → 1.9.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 (154) hide show
  1. package/.claude/hooks/notifier.cjs +481 -134
  2. package/bin/shooter.cjs +570 -12
  3. package/build/client/_app/immutable/assets/{0.B5tfAY8Y.css → 0.BZLcOr5z.css} +1 -1
  4. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.br +0 -0
  5. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.gz +0 -0
  6. package/build/client/_app/immutable/chunks/3EfvnCrr.js +1 -0
  7. package/build/client/_app/immutable/chunks/3EfvnCrr.js.br +0 -0
  8. package/build/client/_app/immutable/chunks/3EfvnCrr.js.gz +0 -0
  9. package/build/client/_app/immutable/chunks/{CBvZimn-.js → C2Qh_9aV.js} +1 -1
  10. package/build/client/_app/immutable/chunks/C2Qh_9aV.js.br +0 -0
  11. package/build/client/_app/immutable/chunks/C2Qh_9aV.js.gz +0 -0
  12. package/build/client/_app/immutable/chunks/C2yx8lo8.js +3 -0
  13. package/build/client/_app/immutable/chunks/C2yx8lo8.js.br +0 -0
  14. package/build/client/_app/immutable/chunks/C2yx8lo8.js.gz +0 -0
  15. package/build/client/_app/immutable/chunks/DdpA9n1s.js +5 -0
  16. package/build/client/_app/immutable/chunks/DdpA9n1s.js.br +4 -0
  17. package/build/client/_app/immutable/chunks/DdpA9n1s.js.gz +0 -0
  18. package/build/client/_app/immutable/chunks/{D347PuJK.js → klpn9j-A.js} +1 -1
  19. package/build/client/_app/immutable/chunks/klpn9j-A.js.br +0 -0
  20. package/build/client/_app/immutable/chunks/klpn9j-A.js.gz +0 -0
  21. package/build/client/_app/immutable/chunks/xs1Xl3_e.js +1 -0
  22. package/build/client/_app/immutable/chunks/xs1Xl3_e.js.br +0 -0
  23. package/build/client/_app/immutable/chunks/xs1Xl3_e.js.gz +0 -0
  24. package/build/client/_app/immutable/entry/{app.C_IPOstn.js → app.Bfisx3a0.js} +2 -2
  25. package/build/client/_app/immutable/entry/app.Bfisx3a0.js.br +0 -0
  26. package/build/client/_app/immutable/entry/app.Bfisx3a0.js.gz +0 -0
  27. package/build/client/_app/immutable/entry/start.BfgeeQYG.js +1 -0
  28. package/build/client/_app/immutable/entry/start.BfgeeQYG.js.br +2 -0
  29. package/build/client/_app/immutable/entry/start.BfgeeQYG.js.gz +0 -0
  30. package/build/client/_app/immutable/nodes/{0.DRy3hSaJ.js → 0.Vg8bq-s8.js} +1 -1
  31. package/build/client/_app/immutable/nodes/0.Vg8bq-s8.js.br +0 -0
  32. package/build/client/_app/immutable/nodes/0.Vg8bq-s8.js.gz +0 -0
  33. package/build/client/_app/immutable/nodes/{1.BL16K8Rb.js → 1.B7kXtFIl.js} +1 -1
  34. package/build/client/_app/immutable/nodes/1.B7kXtFIl.js.br +0 -0
  35. package/build/client/_app/immutable/nodes/1.B7kXtFIl.js.gz +0 -0
  36. package/build/client/_app/immutable/nodes/{2.iOKTlmTJ.js → 2.DVFe_SN2.js} +2 -2
  37. package/build/client/_app/immutable/nodes/2.DVFe_SN2.js.br +0 -0
  38. package/build/client/_app/immutable/nodes/2.DVFe_SN2.js.gz +0 -0
  39. package/build/client/_app/immutable/nodes/{3.GuF9Fr8V.js → 3.Deb3vtJl.js} +3 -3
  40. package/build/client/_app/immutable/nodes/3.Deb3vtJl.js.br +0 -0
  41. package/build/client/_app/immutable/nodes/3.Deb3vtJl.js.gz +0 -0
  42. package/build/client/_app/immutable/nodes/5.BN2SM61w.js +1 -0
  43. package/build/client/_app/immutable/nodes/5.BN2SM61w.js.br +0 -0
  44. package/build/client/_app/immutable/nodes/5.BN2SM61w.js.gz +0 -0
  45. package/build/client/_app/immutable/nodes/{6.DSC53pZZ.js → 6.CS_KYbQ7.js} +1 -1
  46. package/build/client/_app/immutable/nodes/6.CS_KYbQ7.js.br +0 -0
  47. package/build/client/_app/immutable/nodes/6.CS_KYbQ7.js.gz +0 -0
  48. package/build/client/_app/immutable/nodes/7.CEiUUm74.js +4 -0
  49. package/build/client/_app/immutable/nodes/7.CEiUUm74.js.br +0 -0
  50. package/build/client/_app/immutable/nodes/7.CEiUUm74.js.gz +0 -0
  51. package/build/client/_app/immutable/nodes/{8.Dn1iVaxc.js → 8.DGStHrkF.js} +2 -2
  52. package/build/client/_app/immutable/nodes/8.DGStHrkF.js.br +0 -0
  53. package/build/client/_app/immutable/nodes/8.DGStHrkF.js.gz +0 -0
  54. package/build/client/_app/immutable/nodes/{9.D3nURtQJ.js → 9.CbIw97FV.js} +1 -1
  55. package/build/client/_app/immutable/nodes/9.CbIw97FV.js.br +0 -0
  56. package/build/client/_app/immutable/nodes/9.CbIw97FV.js.gz +0 -0
  57. package/build/client/_app/version.json +1 -1
  58. package/build/client/_app/version.json.br +0 -0
  59. package/build/client/_app/version.json.gz +0 -0
  60. package/build/server/chunks/{0-D82zFKZh.js → 0-ikRVFZQS.js} +3 -3
  61. package/build/server/chunks/{0-D82zFKZh.js.map → 0-ikRVFZQS.js.map} +1 -1
  62. package/build/server/chunks/{1-BmaGmZUH.js → 1-CG-fUUbt.js} +2 -2
  63. package/build/server/chunks/{1-BmaGmZUH.js.map → 1-CG-fUUbt.js.map} +1 -1
  64. package/build/server/chunks/{2-T5uLjTgt.js → 2-BaV1q6GP.js} +2 -2
  65. package/build/server/chunks/{2-T5uLjTgt.js.map → 2-BaV1q6GP.js.map} +1 -1
  66. package/build/server/chunks/{3-D6FHwoMf.js → 3-BpzQzk4m.js} +2 -2
  67. package/build/server/chunks/{3-D6FHwoMf.js.map → 3-BpzQzk4m.js.map} +1 -1
  68. package/build/server/chunks/{5-Bj49x3to.js → 5-DRhcUdp_.js} +2 -2
  69. package/build/server/chunks/{5-Bj49x3to.js.map → 5-DRhcUdp_.js.map} +1 -1
  70. package/build/server/chunks/{6-Yh_tXC7x.js → 6-Bx-SE48t.js} +2 -2
  71. package/build/server/chunks/{6-Yh_tXC7x.js.map → 6-Bx-SE48t.js.map} +1 -1
  72. package/build/server/chunks/{7-Dmz1unig.js → 7-LeGA4bt3.js} +3 -3
  73. package/build/server/chunks/{7-Dmz1unig.js.map → 7-LeGA4bt3.js.map} +1 -1
  74. package/build/server/chunks/{8-Bf6s5ssE.js → 8-CXwtG-B-.js} +2 -2
  75. package/build/server/chunks/{8-Bf6s5ssE.js.map → 8-CXwtG-B-.js.map} +1 -1
  76. package/build/server/chunks/{9-AHy0sACd.js → 9-DBztKl9o.js} +2 -2
  77. package/build/server/chunks/{9-AHy0sACd.js.map → 9-DBztKl9o.js.map} +1 -1
  78. package/build/server/chunks/{_page.svelte-D1Iaycsm.js → _page.svelte-DpUVuiqQ.js} +3 -13
  79. package/build/server/chunks/_page.svelte-DpUVuiqQ.js.map +1 -0
  80. package/build/server/chunks/{_server.ts-A9_tRR-K.js → _server.ts-9P1PrkiL.js} +2 -2
  81. package/build/server/chunks/{_server.ts-A9_tRR-K.js.map → _server.ts-9P1PrkiL.js.map} +1 -1
  82. package/build/server/chunks/{_server.ts-G8OeADGj.js → _server.ts-BAPg2K8u.js} +5 -2
  83. package/build/server/chunks/_server.ts-BAPg2K8u.js.map +1 -0
  84. package/build/server/chunks/{_server.ts-BQarRaho.js → _server.ts-CilRds58.js} +14 -11
  85. package/build/server/chunks/_server.ts-CilRds58.js.map +1 -0
  86. package/build/server/chunks/{library-apns-Cf-E-DhM.js → library-apns-CUDtjHQk.js} +3 -2
  87. package/build/server/chunks/library-apns-CUDtjHQk.js.map +1 -0
  88. package/build/server/index.js +1 -1
  89. package/build/server/index.js.map +1 -1
  90. package/build/server/manifest.js +13 -13
  91. package/build/server/manifest.js.map +1 -1
  92. package/package.json +1 -1
  93. package/scripts/update-checker.cjs +196 -0
  94. package/scripts/update-state.cjs +235 -0
  95. package/server.ts +55 -3
  96. package/src/lib/modules/client/activity/summarizer.ts +1 -1
  97. package/src/lib/modules/client/dashboard/summarizer.ts +1 -2
  98. package/src/lib/modules/client/neurolink/cdn.ts +6 -0
  99. package/src/lib/modules/client/terminal/LaunchSheet.svelte +5 -12
  100. package/src/lib/modules/server/apn/library-apns.ts +1 -0
  101. package/src/lib/modules/server/fcm/fcm-service.ts +1 -0
  102. package/src/lib/theme.css +33 -1
  103. package/src/lib/types/apn.ts +1 -0
  104. package/src/routes/api/notify/+server.ts +2 -0
  105. package/src/routes/api/sessions/+server.ts +16 -10
  106. package/src/routes/neurolink/+page.svelte +4 -6
  107. package/src/routes/session/[id]/+page.svelte +25 -14
  108. package/build/client/_app/immutable/assets/0.B5tfAY8Y.css.br +0 -0
  109. package/build/client/_app/immutable/assets/0.B5tfAY8Y.css.gz +0 -0
  110. package/build/client/_app/immutable/chunks/Bkcn25gz.js +0 -3
  111. package/build/client/_app/immutable/chunks/Bkcn25gz.js.br +0 -0
  112. package/build/client/_app/immutable/chunks/Bkcn25gz.js.gz +0 -0
  113. package/build/client/_app/immutable/chunks/CBvZimn-.js.br +0 -0
  114. package/build/client/_app/immutable/chunks/CBvZimn-.js.gz +0 -0
  115. package/build/client/_app/immutable/chunks/CRXYOhph.js +0 -5
  116. package/build/client/_app/immutable/chunks/CRXYOhph.js.br +0 -0
  117. package/build/client/_app/immutable/chunks/CRXYOhph.js.gz +0 -0
  118. package/build/client/_app/immutable/chunks/D347PuJK.js.br +0 -0
  119. package/build/client/_app/immutable/chunks/D347PuJK.js.gz +0 -0
  120. package/build/client/_app/immutable/chunks/DQM017d5.js +0 -1
  121. package/build/client/_app/immutable/chunks/DQM017d5.js.br +0 -0
  122. package/build/client/_app/immutable/chunks/DQM017d5.js.gz +0 -0
  123. package/build/client/_app/immutable/chunks/Dqgg-a0I.js +0 -1
  124. package/build/client/_app/immutable/chunks/Dqgg-a0I.js.br +0 -0
  125. package/build/client/_app/immutable/chunks/Dqgg-a0I.js.gz +0 -0
  126. package/build/client/_app/immutable/entry/app.C_IPOstn.js.br +0 -0
  127. package/build/client/_app/immutable/entry/app.C_IPOstn.js.gz +0 -0
  128. package/build/client/_app/immutable/entry/start.BfrjAeOs.js +0 -1
  129. package/build/client/_app/immutable/entry/start.BfrjAeOs.js.br +0 -2
  130. package/build/client/_app/immutable/entry/start.BfrjAeOs.js.gz +0 -0
  131. package/build/client/_app/immutable/nodes/0.DRy3hSaJ.js.br +0 -0
  132. package/build/client/_app/immutable/nodes/0.DRy3hSaJ.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/1.BL16K8Rb.js.br +0 -0
  134. package/build/client/_app/immutable/nodes/1.BL16K8Rb.js.gz +0 -0
  135. package/build/client/_app/immutable/nodes/2.iOKTlmTJ.js.br +0 -0
  136. package/build/client/_app/immutable/nodes/2.iOKTlmTJ.js.gz +0 -0
  137. package/build/client/_app/immutable/nodes/3.GuF9Fr8V.js.br +0 -0
  138. package/build/client/_app/immutable/nodes/3.GuF9Fr8V.js.gz +0 -0
  139. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js +0 -1
  140. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js.gz +0 -0
  142. package/build/client/_app/immutable/nodes/6.DSC53pZZ.js.br +0 -0
  143. package/build/client/_app/immutable/nodes/6.DSC53pZZ.js.gz +0 -0
  144. package/build/client/_app/immutable/nodes/7.C8lL01pY.js +0 -4
  145. package/build/client/_app/immutable/nodes/7.C8lL01pY.js.br +0 -0
  146. package/build/client/_app/immutable/nodes/7.C8lL01pY.js.gz +0 -0
  147. package/build/client/_app/immutable/nodes/8.Dn1iVaxc.js.br +0 -0
  148. package/build/client/_app/immutable/nodes/8.Dn1iVaxc.js.gz +0 -0
  149. package/build/client/_app/immutable/nodes/9.D3nURtQJ.js.br +0 -0
  150. package/build/client/_app/immutable/nodes/9.D3nURtQJ.js.gz +0 -0
  151. package/build/server/chunks/_page.svelte-D1Iaycsm.js.map +0 -1
  152. package/build/server/chunks/_server.ts-BQarRaho.js.map +0 -1
  153. package/build/server/chunks/_server.ts-G8OeADGj.js.map +0 -1
  154. package/build/server/chunks/library-apns-Cf-E-DhM.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':
@@ -130,6 +145,35 @@ function hasFlag(flag) {
130
145
  return args.includes(flag);
131
146
  }
132
147
 
148
+ // Keep in sync with `parsePortArg` in server.ts.
149
+ function parsePortFlag() {
150
+ const isValid = (n) => Number.isInteger(n) && n >= 0 && n < 65536;
151
+ const fail = (raw) => {
152
+ console.error(`Error: invalid --port value "${raw}" — expected an integer in 0-65535.`);
153
+ process.exit(2);
154
+ };
155
+ for (let i = 0; i < args.length; i++) {
156
+ const a = args[i];
157
+ if ((a === '--port' || a === '-p') && args[i + 1] !== undefined) {
158
+ const raw = args[i + 1];
159
+ const n = parseInt(raw, 10);
160
+ if (!isValid(n)) fail(raw);
161
+ return n;
162
+ } else if (a.startsWith('--port=')) {
163
+ const raw = a.slice('--port='.length);
164
+ const n = parseInt(raw, 10);
165
+ if (!isValid(n)) fail(raw);
166
+ return n;
167
+ } else if (a.startsWith('-p=')) {
168
+ const raw = a.slice('-p='.length);
169
+ const n = parseInt(raw, 10);
170
+ if (!isValid(n)) fail(raw);
171
+ return n;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+
133
177
  function isCloudflaredAvailable() {
134
178
  try {
135
179
  execSync('which cloudflared', { stdio: 'ignore' });
@@ -216,9 +260,12 @@ function startServer() {
216
260
 
217
261
  const daemon = hasFlag('--daemon') || hasFlag('-d');
218
262
  const noTunnel = hasFlag('--no-tunnel');
219
- const port = resolvePort();
263
+ const cliPort = parsePortFlag();
264
+ const port = cliPort ?? resolvePort();
220
265
 
221
- // Check if port is already in use (no external tools needed)
266
+ // Fail-fast pre-flight so a busy port is surfaced before we spawn the
267
+ // server and start the Cloudflare Tunnel — keeping tunnel and listen
268
+ // port in sync.
222
269
  try {
223
270
  execSync(
224
271
  `"${process.execPath}" -e "const s=require('net').createServer();s.listen(${parseInt(port, 10)},()=>s.close());s.on('error',e=>{if(e.code==='EADDRINUSE')process.exit(1)})"`,
@@ -226,7 +273,7 @@ function startServer() {
226
273
  );
227
274
  } catch {
228
275
  console.error(`Error: Port ${port} is already in use.`);
229
- console.error('Stop the existing process or set a different PORT in ~/.shooter/.env');
276
+ console.error('Stop the existing process, pass --port <num>, or set PORT in ~/.shooter/.env');
230
277
  process.exit(1);
231
278
  }
232
279
 
@@ -256,6 +303,7 @@ function startServer() {
256
303
  stdio: ['ignore', logFd, logFd],
257
304
  env: {
258
305
  ...process.env,
306
+ PORT: String(port),
259
307
  SHOOTER_PKG_ROOT: PKG_ROOT,
260
308
  SHOOTER_HOME,
261
309
  },
@@ -279,6 +327,10 @@ function startServer() {
279
327
  } else if (!noTunnel && !isCloudflaredAvailable()) {
280
328
  console.log(' (cloudflared not found — no tunnel. Install: brew install cloudflared)');
281
329
  }
330
+
331
+ // Spawn auto-update guard (detached) — only when launchd is managing
332
+ spawnGuard(child.pid, port);
333
+
282
334
  // Exit immediately — daemon is running, tunnel starts async
283
335
  process.exit(0);
284
336
  } else {
@@ -292,6 +344,7 @@ function startServer() {
292
344
  stdio: 'inherit',
293
345
  env: {
294
346
  ...process.env,
347
+ PORT: String(port),
295
348
  SHOOTER_PKG_ROOT: PKG_ROOT,
296
349
  SHOOTER_HOME,
297
350
  },
@@ -312,9 +365,13 @@ function startServer() {
312
365
  }, 3000);
313
366
  }
314
367
 
368
+ // Spawn auto-update guard (detached) — only when launchd is managing
369
+ spawnGuard(child.pid, port);
370
+
315
371
  child.on('error', (err) => {
316
372
  removePid();
317
373
  if (tunnelStarted) stopTunnel();
374
+ stopGuard();
318
375
  console.error('Failed to start Shooter server:', err.message);
319
376
  process.exit(1);
320
377
  });
@@ -322,6 +379,7 @@ function startServer() {
322
379
  child.on('exit', (code, signal) => {
323
380
  removePid();
324
381
  stopTunnel();
382
+ stopGuard();
325
383
  if (signal) {
326
384
  process.exit(128 + (signalCode(signal) || 1));
327
385
  }
@@ -333,6 +391,7 @@ function startServer() {
333
391
  process.on(sig, () => {
334
392
  child.kill(sig);
335
393
  stopTunnel();
394
+ stopGuard();
336
395
  });
337
396
  }
338
397
  }
@@ -351,6 +410,7 @@ function stopServer() {
351
410
 
352
411
  console.log(`Stopping Shooter (PID ${pid})...`);
353
412
  stopTunnel();
413
+ stopGuard();
354
414
  try {
355
415
  process.kill(pid, 'SIGTERM');
356
416
  // Wait briefly for clean shutdown
@@ -681,6 +741,493 @@ function runSetup() {
681
741
  });
682
742
  }
683
743
 
744
+ // ── update ──────────────────────────────────────────────────────────
745
+
746
+ function runUpdate(subcommand) {
747
+ const { checkForUpdate } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
748
+ const { recordCheck, isVersionSuppressed } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
749
+
750
+ const result = checkForUpdate(PKG_ROOT);
751
+ if (!result.checkFailed) recordCheck(result.latestVersion);
752
+
753
+ if (subcommand === 'check') {
754
+ // Just check, don't install
755
+ if (result.checkFailed) {
756
+ console.error(`Update check failed: ${result.error}`);
757
+ process.exitCode = 1;
758
+ } else if (result.updateAvailable) {
759
+ console.log(`Update available: ${result.currentVersion} → ${result.latestVersion}`);
760
+ console.log(` Current commit: ${result.currentCommit}`);
761
+ console.log(` Latest commit: ${result.latestCommit}`);
762
+ if (isVersionSuppressed(result.latestVersion)) {
763
+ console.log(` (version ${result.latestVersion} is temporarily suppressed — will retry in <24h)`);
764
+ }
765
+ } else {
766
+ console.log(`Already up to date: v${result.currentVersion} (${result.currentCommit})`);
767
+ }
768
+ return;
769
+ }
770
+
771
+ // Default: check + install
772
+ if (result.checkFailed) {
773
+ console.error(`Update check failed: ${result.error}`);
774
+ process.exitCode = 1;
775
+ return;
776
+ }
777
+ if (!result.updateAvailable) {
778
+ console.log(`Already up to date: v${result.currentVersion} (${result.currentCommit})`);
779
+ return;
780
+ }
781
+
782
+ // Only allow installs on the release branch
783
+ const { getCurrentBranch } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
784
+ const branch = getCurrentBranch(PKG_ROOT);
785
+ if (branch !== 'release') {
786
+ console.log(`Cannot update: currently on branch '${branch || 'unknown'}', updates only apply to 'release'.`);
787
+ console.log('Switch to the release branch and try again.');
788
+ return;
789
+ }
790
+
791
+ if (isVersionSuppressed(result.latestVersion)) {
792
+ console.log(`Update to ${result.latestVersion} is temporarily suppressed (previous failure).`);
793
+ console.log('Suppression expires within 24 hours. Use a fresh git pull to override.');
794
+ return;
795
+ }
796
+
797
+ console.log(`Updating: ${result.currentVersion} → ${result.latestVersion}...`);
798
+ const success = performUpdate(result);
799
+ if (success) {
800
+ console.log(`\nUpdate complete! Now running v${result.latestVersion}.`);
801
+
802
+ // Restart if server is running
803
+ const pid = readPid();
804
+ if (pid) {
805
+ if (isLaunchdManaging()) {
806
+ const uid = process.getuid ? process.getuid() : 501;
807
+ try {
808
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, {
809
+ timeout: 10_000,
810
+ stdio: 'pipe',
811
+ });
812
+ console.log('Signaled launchd to restart the server.');
813
+ } catch {
814
+ console.log('launchctl kickstart failed — the server may need a manual restart.');
815
+ }
816
+ } else {
817
+ try {
818
+ process.kill(pid, 'SIGTERM');
819
+ } catch {}
820
+ console.log('Server process terminated. Run "shooter start" to restart.');
821
+ }
822
+ }
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Perform the actual update: git pull → pnpm install → pnpm build.
828
+ * Returns true on success. On failure, rolls back and returns false.
829
+ */
830
+ function performUpdate(result) {
831
+ const { suppressVersion, recordSuccessfulUpdate } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
832
+
833
+ // Save current HEAD for rollback
834
+ let savedHead = '';
835
+ try {
836
+ savedHead = execSync('git rev-parse HEAD', {
837
+ cwd: PKG_ROOT,
838
+ encoding: 'utf8',
839
+ stdio: ['ignore', 'pipe', 'ignore'],
840
+ }).trim();
841
+ } catch {}
842
+
843
+ // 1. Git pull (fast-forward only)
844
+ try {
845
+ console.log(' Pulling latest changes...');
846
+ execSync('git pull --ff-only origin release', {
847
+ cwd: PKG_ROOT,
848
+ stdio: 'inherit',
849
+ timeout: 30_000,
850
+ });
851
+ } catch (err) {
852
+ console.error(' Git pull failed:', err.message || err);
853
+ suppressVersion(result.latestVersion, 'pull_failed');
854
+ return false;
855
+ }
856
+
857
+ // 2. Install dependencies
858
+ try {
859
+ console.log(' Installing dependencies...');
860
+ execSync('pnpm install --frozen-lockfile', {
861
+ cwd: PKG_ROOT,
862
+ stdio: 'inherit',
863
+ timeout: INSTALL_TIMEOUT_MS,
864
+ });
865
+ } catch (err) {
866
+ console.error(' pnpm install failed:', err.message || err);
867
+ rollback(savedHead);
868
+ suppressVersion(result.latestVersion, 'install_failed');
869
+ return false;
870
+ }
871
+
872
+ // 3. Build
873
+ try {
874
+ console.log(' Building...');
875
+ execSync('pnpm build', {
876
+ cwd: PKG_ROOT,
877
+ stdio: 'inherit',
878
+ timeout: BUILD_TIMEOUT_MS,
879
+ });
880
+ } catch (err) {
881
+ console.error(' Build failed:', err.message || err);
882
+ rollback(savedHead);
883
+ suppressVersion(result.latestVersion, 'build_failed');
884
+ return false;
885
+ }
886
+
887
+ recordSuccessfulUpdate(result.latestVersion, result.currentVersion);
888
+ return true;
889
+ }
890
+
891
+ /**
892
+ * Roll back to a previous commit after a failed update.
893
+ */
894
+ function rollback(savedHead) {
895
+ if (!savedHead) return;
896
+ console.log(' Rolling back to previous version...');
897
+ try {
898
+ execSync(`git reset --hard ${savedHead}`, {
899
+ cwd: PKG_ROOT,
900
+ stdio: 'inherit',
901
+ timeout: 10_000,
902
+ });
903
+ execSync('pnpm install --frozen-lockfile', {
904
+ cwd: PKG_ROOT,
905
+ stdio: 'inherit',
906
+ timeout: INSTALL_TIMEOUT_MS,
907
+ });
908
+ execSync('pnpm build', {
909
+ cwd: PKG_ROOT,
910
+ stdio: 'inherit',
911
+ timeout: BUILD_TIMEOUT_MS,
912
+ });
913
+ } catch (rollbackErr) {
914
+ console.error(' WARNING: Rollback failed:', rollbackErr.message || rollbackErr);
915
+ console.error(' Manual intervention may be required.');
916
+ }
917
+ }
918
+
919
+ // ── guard (hidden — spawned by start) ──────────────────────────────
920
+
921
+ function spawnGuard(parentPid, port) {
922
+ // Only spawn guard when launchd is managing the service
923
+ if (!isLaunchdManaging()) return;
924
+
925
+ const shooterBin = path.join(PKG_ROOT, 'bin', 'shooter.cjs');
926
+ try {
927
+ fs.mkdirSync(LOG_DIR, { recursive: true });
928
+ const logFd = fs.openSync(GUARD_LOG_FILE, 'a');
929
+
930
+ const child = spawn(
931
+ process.execPath,
932
+ [shooterBin, 'guard', '--parent-pid', String(parentPid), '--port', String(port)],
933
+ {
934
+ cwd: PKG_ROOT,
935
+ detached: true,
936
+ stdio: ['ignore', logFd, logFd],
937
+ env: {
938
+ ...process.env,
939
+ SHOOTER_PKG_ROOT: PKG_ROOT,
940
+ SHOOTER_HOME,
941
+ },
942
+ }
943
+ );
944
+
945
+ if (child.pid) {
946
+ fs.mkdirSync(SHOOTER_HOME, { recursive: true });
947
+ fs.writeFileSync(GUARD_PID_FILE, String(child.pid));
948
+ }
949
+ child.unref();
950
+ fs.closeSync(logFd);
951
+ } catch (err) {
952
+ // Non-fatal — server runs fine without guard
953
+ console.log(` (auto-update guard failed to start: ${err.message})`);
954
+ }
955
+ }
956
+
957
+ function stopGuard() {
958
+ try {
959
+ const pid = parseInt(fs.readFileSync(GUARD_PID_FILE, 'utf8').trim(), 10);
960
+ if (!isNaN(pid) && pid > 0) {
961
+ // Validate the PID is actually a guard process (prevent PID reuse attacks)
962
+ try {
963
+ const cmdline = execSync(`ps -p ${pid} -o command= 2>/dev/null`, {
964
+ encoding: 'utf8',
965
+ }).trim();
966
+ if (!cmdline.includes('shooter.cjs') || !cmdline.includes('guard')) {
967
+ // PID reused by an unrelated process — just clean up the stale pidfile
968
+ fs.unlinkSync(GUARD_PID_FILE);
969
+ return;
970
+ }
971
+ } catch {
972
+ // ps failed — process likely already exited
973
+ fs.unlinkSync(GUARD_PID_FILE);
974
+ return;
975
+ }
976
+ try {
977
+ process.kill(pid, 'SIGTERM');
978
+ } catch {}
979
+ }
980
+ } catch {}
981
+ try {
982
+ fs.unlinkSync(GUARD_PID_FILE);
983
+ } catch {}
984
+ }
985
+
986
+ function isLaunchdManaging() {
987
+ if (os.platform() !== 'darwin') return false;
988
+ try {
989
+ const uid = process.getuid ? process.getuid() : 501;
990
+ execSync(`launchctl print gui/${uid}/${LAUNCHD_LABEL} 2>/dev/null`, {
991
+ stdio: ['ignore', 'pipe', 'ignore'],
992
+ timeout: 5_000,
993
+ });
994
+ return true;
995
+ } catch {
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ function runGuard() {
1001
+ // Parse guard-specific args
1002
+ let parentPid = 0;
1003
+ let port = DEFAULT_PORT;
1004
+ for (let i = 1; i < args.length; i++) {
1005
+ if (args[i] === '--parent-pid' && args[i + 1]) {
1006
+ parentPid = parseInt(args[i + 1], 10);
1007
+ i++;
1008
+ } else if (args[i] === '--port' && args[i + 1]) {
1009
+ port = parseInt(args[i + 1], 10);
1010
+ i++;
1011
+ }
1012
+ }
1013
+
1014
+ if (!parentPid || parentPid <= 0) {
1015
+ console.error('[guard] Invalid --parent-pid');
1016
+ process.exit(1);
1017
+ }
1018
+
1019
+ const { checkForUpdate, getCurrentBranch } = require(path.join(PKG_ROOT, 'scripts', 'update-checker.cjs'));
1020
+ const {
1021
+ recordCheck,
1022
+ isVersionSuppressed,
1023
+ suppressVersion,
1024
+ recordSuccessfulUpdate,
1025
+ } = require(path.join(PKG_ROOT, 'scripts', 'update-state.cjs'));
1026
+
1027
+ const log = (msg) => console.log(`[guard] ${new Date().toISOString()} ${msg}`);
1028
+
1029
+ // Check if parent is alive
1030
+ function isParentAlive() {
1031
+ try {
1032
+ process.kill(parentPid, 0);
1033
+ return true;
1034
+ } catch {
1035
+ return false;
1036
+ }
1037
+ }
1038
+
1039
+ let updateInProgress = false;
1040
+ let updateRestartInProgress = false;
1041
+
1042
+ async function runUpdateCheck() {
1043
+ if (updateInProgress) return;
1044
+ updateInProgress = true;
1045
+
1046
+ try {
1047
+ // Only auto-update on release branch
1048
+ const branch = getCurrentBranch(PKG_ROOT);
1049
+ if (branch !== 'release') {
1050
+ log(`skipping update check — on branch '${branch}', not 'release'`);
1051
+ return;
1052
+ }
1053
+
1054
+ // 1. Check for update
1055
+ const result = checkForUpdate(PKG_ROOT);
1056
+ if (result.checkFailed) {
1057
+ log(`update check failed: ${result.error}`);
1058
+ return;
1059
+ }
1060
+ recordCheck(result.latestVersion);
1061
+
1062
+ if (!result.updateAvailable) {
1063
+ log(`up to date: v${result.currentVersion}`);
1064
+ return;
1065
+ }
1066
+
1067
+ if (isVersionSuppressed(result.latestVersion)) {
1068
+ log(`version ${result.latestVersion} is suppressed, skipping`);
1069
+ return;
1070
+ }
1071
+
1072
+ log(`update available: ${result.currentVersion} → ${result.latestVersion}`);
1073
+
1074
+ // 2. Save current HEAD for rollback
1075
+ let savedHead = '';
1076
+ try {
1077
+ savedHead = execSync('git rev-parse HEAD', {
1078
+ cwd: PKG_ROOT,
1079
+ encoding: 'utf8',
1080
+ stdio: ['ignore', 'pipe', 'ignore'],
1081
+ }).trim();
1082
+ } catch {}
1083
+
1084
+ // 3. Git pull
1085
+ try {
1086
+ execSync('git pull --ff-only origin release', {
1087
+ cwd: PKG_ROOT,
1088
+ encoding: 'utf8',
1089
+ stdio: ['ignore', 'pipe', 'pipe'],
1090
+ timeout: 30_000,
1091
+ });
1092
+ } catch (err) {
1093
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1094
+ log(`git pull failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1095
+ suppressVersion(result.latestVersion, 'pull_failed');
1096
+ return;
1097
+ }
1098
+
1099
+ // 4. Install dependencies
1100
+ try {
1101
+ log('installing dependencies...');
1102
+ execSync('pnpm install --frozen-lockfile', {
1103
+ cwd: PKG_ROOT,
1104
+ encoding: 'utf8',
1105
+ stdio: ['ignore', 'pipe', 'pipe'],
1106
+ timeout: INSTALL_TIMEOUT_MS,
1107
+ });
1108
+ } catch (err) {
1109
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1110
+ log(`pnpm install failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1111
+ guardRollback(savedHead);
1112
+ suppressVersion(result.latestVersion, 'install_failed');
1113
+ return;
1114
+ }
1115
+
1116
+ // 5. Build
1117
+ try {
1118
+ log('building...');
1119
+ execSync('pnpm build', {
1120
+ cwd: PKG_ROOT,
1121
+ encoding: 'utf8',
1122
+ stdio: ['ignore', 'pipe', 'pipe'],
1123
+ timeout: BUILD_TIMEOUT_MS,
1124
+ });
1125
+ } catch (err) {
1126
+ const stderr = err.stderr ? err.stderr.toString().trim().slice(-500) : '';
1127
+ log(`build failed: ${err.message}${stderr ? '\n' + stderr : ''}`);
1128
+ guardRollback(savedHead);
1129
+ suppressVersion(result.latestVersion, 'build_failed');
1130
+ return;
1131
+ }
1132
+
1133
+ // 6. Restart via launchctl
1134
+ updateRestartInProgress = true;
1135
+ log('restarting via launchctl...');
1136
+ const uid = process.getuid ? process.getuid() : 501;
1137
+ try {
1138
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, {
1139
+ timeout: 10_000,
1140
+ stdio: 'pipe',
1141
+ });
1142
+ } catch {
1143
+ log('WARNING: launchctl kickstart failed');
1144
+ suppressVersion(result.latestVersion, 'restart_failed');
1145
+ updateRestartInProgress = false;
1146
+ return;
1147
+ }
1148
+
1149
+ // 7. Wait for healthy restart
1150
+ let healthy = false;
1151
+ const restartStart = Date.now();
1152
+ while (Date.now() - restartStart < HEALTH_TIMEOUT_MS) {
1153
+ await sleep(2000);
1154
+ try {
1155
+ const resp = await fetch(`http://localhost:${port}/api/health`, {
1156
+ signal: AbortSignal.timeout(3000),
1157
+ });
1158
+ if (resp.ok) {
1159
+ const data = await resp.json();
1160
+ if (data.version === result.latestVersion) {
1161
+ healthy = true;
1162
+ break;
1163
+ }
1164
+ }
1165
+ } catch {
1166
+ /* retry */
1167
+ }
1168
+ }
1169
+
1170
+ if (healthy) {
1171
+ log(`update successful: now running v${result.latestVersion}`);
1172
+ recordSuccessfulUpdate(result.latestVersion, result.currentVersion);
1173
+ // New server will spawn its own guard — exit this one
1174
+ process.exit(0);
1175
+ } else {
1176
+ log(`WARNING: server unhealthy after update to ${result.latestVersion}`);
1177
+ suppressVersion(result.latestVersion, 'unhealthy_after_restart');
1178
+ updateRestartInProgress = false;
1179
+ }
1180
+ } catch (err) {
1181
+ log(`update check error: ${err.message || err}`);
1182
+ } finally {
1183
+ updateInProgress = false;
1184
+ }
1185
+ }
1186
+
1187
+ function guardRollback(savedHead) {
1188
+ if (!savedHead) return;
1189
+ log('rolling back...');
1190
+ try {
1191
+ execSync(`git reset --hard ${savedHead}`, {
1192
+ cwd: PKG_ROOT,
1193
+ stdio: ['ignore', 'pipe', 'ignore'],
1194
+ timeout: 10_000,
1195
+ });
1196
+ execSync('pnpm install --frozen-lockfile', {
1197
+ cwd: PKG_ROOT,
1198
+ stdio: ['ignore', 'pipe', 'pipe'],
1199
+ timeout: INSTALL_TIMEOUT_MS,
1200
+ });
1201
+ execSync('pnpm build', {
1202
+ cwd: PKG_ROOT,
1203
+ stdio: ['ignore', 'pipe', 'pipe'],
1204
+ timeout: BUILD_TIMEOUT_MS,
1205
+ });
1206
+ } catch (rollbackErr) {
1207
+ log(`WARNING: rollback failed: ${rollbackErr.message}`);
1208
+ }
1209
+ }
1210
+
1211
+ // Parent process health monitor
1212
+ const parentCheckInterval = setInterval(() => {
1213
+ if (!isParentAlive() && !updateRestartInProgress) {
1214
+ log('parent process died, exiting guard');
1215
+ clearInterval(parentCheckInterval);
1216
+ process.exit(0);
1217
+ }
1218
+ }, 5_000);
1219
+
1220
+ // Schedule update checks
1221
+ setTimeout(() => void runUpdateCheck(), UPDATE_FIRST_CHECK_MS);
1222
+ setInterval(() => void runUpdateCheck(), UPDATE_CHECK_INTERVAL_MS);
1223
+
1224
+ log(`started (parent PID: ${parentPid}, port: ${port})`);
1225
+ }
1226
+
1227
+ function sleep(ms) {
1228
+ return new Promise((r) => setTimeout(r, ms));
1229
+ }
1230
+
684
1231
  // ── help ────────────────────────────────────────────────────────────
685
1232
 
686
1233
  function showHelp() {
@@ -699,22 +1246,33 @@ Commands:
699
1246
  logs Tail server logs
700
1247
  setup Quick setup (API key + build, ~60 seconds)
701
1248
  setup --push Add/reconfigure push notifications
1249
+ update Check for updates and install if available
1250
+ update check Check for updates without installing
702
1251
  version Show version number
703
1252
  help Show this help message
704
1253
 
705
1254
  Start options:
706
- -d, --daemon Run in background (detach from terminal)
707
- --no-tunnel Don't start a Cloudflare Tunnel
1255
+ -d, --daemon Run in background (detach from terminal)
1256
+ --no-tunnel Don't start a Cloudflare Tunnel
1257
+ -p, --port <num> Port to listen on (overrides PORT env)
1258
+
1259
+ Auto-update:
1260
+ When running as a LaunchAgent, Shooter automatically checks for updates
1261
+ every 2 hours. Updates are pulled from origin/release, built, and the
1262
+ server is restarted via launchctl. Terminal sessions survive restarts.
708
1263
 
709
1264
  Examples:
710
- shooter Start the server + tunnel (foreground)
711
- shooter start -d Start in background (daemon mode)
1265
+ shooter Start the server + tunnel (foreground)
1266
+ shooter start -d Start in background (daemon mode)
712
1267
  shooter start --no-tunnel Start without Cloudflare Tunnel
713
- shooter status Check status and tunnel URL
714
- shooter autostart on Enable autostart on login
715
- shooter logs Follow server logs
716
- shooter setup Quick setup (~60s, push deferred)
717
- shooter setup --push Add iOS/Android push notifications
1268
+ shooter start --port 3000 Start on port 3000
1269
+ shooter status Check status and tunnel URL
1270
+ shooter autostart on Enable autostart on login
1271
+ shooter logs Follow server logs
1272
+ shooter setup Quick setup (~60s, push deferred)
1273
+ shooter setup --push Add iOS/Android push notifications
1274
+ shooter update Check and install updates
1275
+ shooter update check Check for updates only
718
1276
  `.trim()
719
1277
  );
720
1278
  }