accelr8 3.8.2 → 4.0.0-next.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.
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- detectVoiceTools,
4
3
  tzCity,
5
4
  tzToCoords
6
- } from "./chunk-HY6ULYGH.js";
5
+ } from "./chunk-CUUNLVID.js";
6
+ import {
7
+ detectVoiceTools
8
+ } from "./chunk-UMPU6SAD.js";
7
9
  import {
8
10
  DEFAULT_SERVER,
9
11
  VERSION
10
- } from "./chunk-7JSMON7M.js";
12
+ } from "./chunk-5QXGOLQ3.js";
13
+ import {
14
+ accelr8Dir
15
+ } from "./chunk-62O2F73J.js";
11
16
 
12
17
  // src/radio/app.ts
13
18
  import tk from "terminal-kit";
14
19
  import fs from "node:fs";
15
- import os from "node:os";
16
20
  import path from "node:path";
17
21
  import { WebSocket } from "ws";
18
22
 
@@ -5348,12 +5352,18 @@ function renderOverlay(ctx, opts) {
5348
5352
  import { spawn as spawn2, spawnSync } from "node:child_process";
5349
5353
  var SR = 44100;
5350
5354
  var CH = 2;
5351
- function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
5355
+ function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500, stallMs = 4e3) {
5352
5356
  const pos = nowPlaying(sched, serverNowMs);
5353
5357
  if (!pos || !pos.item.url) return { kind: "none" };
5354
- if (pos.item.id !== mem.playingTrackId || !mem.procAlive) return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5358
+ const play = (reason) => ({ kind: "play", track: pos.item, offsetMs: pos.offsetMs, reason });
5359
+ if (pos.item.id !== mem.playingTrackId) return play(mem.playingTrackId ? "track" : "start");
5360
+ if (!mem.procAlive) return play("dead");
5361
+ if (mem.lastDataAtServerMs !== void 0) {
5362
+ const quietFor = serverNowMs - (mem.lastDataAtServerMs || mem.spawnedAtServerMs);
5363
+ if (quietFor > (mem.lastDataAtServerMs ? stallMs : stallMs * 2)) return play("stall");
5364
+ }
5355
5365
  const implied = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
5356
- if (Math.abs(implied - pos.offsetMs) > driftBudgetMs) return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5366
+ if (Math.abs(implied - pos.offsetMs) > driftBudgetMs) return play("drift");
5357
5367
  return { kind: "none" };
5358
5368
  }
5359
5369
  function applyGain(buf, fromGain, target, rampPerSample = 1 / 1400) {
@@ -5370,6 +5380,7 @@ function applyGain(buf, fromGain, target, rampPerSample = 1 / 1400) {
5370
5380
  }
5371
5381
  function decoderArgs(url, offsetMs) {
5372
5382
  const args = ["-hide_banner", "-loglevel", "error", "-re"];
5383
+ if (/^https?:/i.test(url)) args.push("-rw_timeout", "8000000");
5373
5384
  if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
5374
5385
  args.push("-i", url, "-f", "s16le", "-ar", String(SR), "-ac", String(CH), "-");
5375
5386
  return args;
@@ -5408,6 +5419,39 @@ function sinkArgs(ffplayPath) {
5408
5419
  "-"
5409
5420
  ];
5410
5421
  }
5422
+ function coreAudioSinkArgs(ffmpegPath) {
5423
+ return [
5424
+ "-hide_banner",
5425
+ "-loglevel",
5426
+ "error",
5427
+ "-f",
5428
+ "s16le",
5429
+ "-ar",
5430
+ String(SR),
5431
+ ...ffplayStereoFlag(ffmpegPath),
5432
+ "-i",
5433
+ "-",
5434
+ "-f",
5435
+ "audiotoolbox",
5436
+ "-"
5437
+ ];
5438
+ }
5439
+ var coreAudioCache = null;
5440
+ function hasCoreAudioSink(ffmpegPath) {
5441
+ if (coreAudioCache !== null) return coreAudioCache;
5442
+ coreAudioCache = false;
5443
+ if (process.platform !== "darwin") return false;
5444
+ try {
5445
+ const out = spawnSync(ffmpegPath, ["-hide_banner", "-devices"], { encoding: "utf8", timeout: 3e3 });
5446
+ coreAudioCache = /\baudiotoolbox\b/.test((out.stdout || "") + (out.stderr || ""));
5447
+ } catch {
5448
+ }
5449
+ return coreAudioCache;
5450
+ }
5451
+ function pickSink(ffmpegPath, ffplayPath, pref = "auto") {
5452
+ const core = pref === "coreaudio" || pref === "auto" && hasCoreAudioSink(ffmpegPath);
5453
+ return core ? { kind: "coreaudio", bin: ffmpegPath, args: coreAudioSinkArgs(ffmpegPath) } : { kind: "ffplay", bin: ffplayPath, args: sinkArgs(ffplayPath) };
5454
+ }
5411
5455
  var SyncedAudio = class {
5412
5456
  sink = null;
5413
5457
  decoder = null;
@@ -5419,12 +5463,50 @@ var SyncedAudio = class {
5419
5463
  target = 0.7;
5420
5464
  // desired volume (0..1)
5421
5465
  muted = false;
5466
+ sinkKind = "";
5467
+ lastFeedAt = 0;
5468
+ // wall ms of the last write to the sink (music or silence)
5469
+ written = 0;
5470
+ // bytes into the current sink, to keep whole s16 stereo frames
5471
+ keepalive = null;
5422
5472
  start(opts) {
5423
5473
  this.stop();
5424
5474
  this.opts = opts;
5425
5475
  this.startSink();
5426
5476
  this.tick();
5427
5477
  this.timer = setInterval(() => this.tick(), 1e3);
5478
+ this.keepalive = setInterval(() => {
5479
+ const quiet = Date.now() - this.lastFeedAt;
5480
+ if (quiet > 60) this.feed(Buffer.alloc(Math.floor(Math.min(quiet, 250) * SR / 1e3) * CH * 2));
5481
+ }, 40);
5482
+ }
5483
+ /** Every write to the sink goes through here. Pads to a whole 4-byte frame
5484
+ * first, so a decoder killed mid-frame can't shift the stream into noise. */
5485
+ feed(buf) {
5486
+ const sink = this.sink;
5487
+ if (!sink || !sink.stdin || sink.stdin.destroyed) return true;
5488
+ const pad = (4 - this.written % 4) % 4;
5489
+ if (pad && buf.length) {
5490
+ sink.stdin.write(Buffer.alloc(pad));
5491
+ this.written += pad;
5492
+ }
5493
+ this.written += buf.length;
5494
+ this.lastFeedAt = Date.now();
5495
+ return sink.stdin.write(buf);
5496
+ }
5497
+ /** Open a fresh sink, so it binds to whatever output is current NOW (the TV
5498
+ * while screen sharing, the speakers after). The old one is dropped first
5499
+ * so its exit handler doesn't treat this as a crash. */
5500
+ restartSink() {
5501
+ const old = this.sink;
5502
+ this.sink = null;
5503
+ this.startSink();
5504
+ if (old) {
5505
+ try {
5506
+ old.kill("SIGKILL");
5507
+ } catch {
5508
+ }
5509
+ }
5428
5510
  }
5429
5511
  setSchedule(sched) {
5430
5512
  if (this.opts) this.opts.schedule = sched;
@@ -5453,8 +5535,14 @@ var SyncedAudio = class {
5453
5535
  }
5454
5536
  startSink() {
5455
5537
  if (!this.opts) return;
5456
- const sink = spawn2(this.opts.ffplayPath, sinkArgs(this.opts.ffplayPath), { stdio: ["pipe", "ignore", "pipe"] });
5538
+ const env = process.env.ACCELR8_RADIO_SINK;
5539
+ const pref = env === "ffplay" || env === "coreaudio" ? env : this.opts.sink ?? "auto";
5540
+ const pick4 = pickSink(this.opts.ffmpegPath, this.opts.ffplayPath, pref);
5541
+ this.opts.onSink?.(pick4.kind);
5542
+ const sink = spawn2(pick4.bin, pick4.args, { stdio: ["pipe", "ignore", "pipe"] });
5457
5543
  this.sink = sink;
5544
+ this.sinkKind = pick4.kind;
5545
+ this.written = 0;
5458
5546
  sink.stdin?.on("error", () => {
5459
5547
  });
5460
5548
  sink.stdin?.on("drain", () => this.decoder?.stdout?.resume());
@@ -5476,22 +5564,24 @@ var SyncedAudio = class {
5476
5564
  if (!this.opts) return;
5477
5565
  if (!this.sink) this.startSink();
5478
5566
  const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
5479
- if (action.kind === "play") this.spawnDecoder(action.track, action.offsetMs);
5567
+ if (action.kind === "play") {
5568
+ if (action.reason !== "track") this.opts.onRespawn?.(action.reason, action.track, action.offsetMs);
5569
+ if (action.reason === "track" && this.sinkKind === "coreaudio") this.restartSink();
5570
+ this.spawnDecoder(action.track, action.offsetMs);
5571
+ }
5480
5572
  }
5481
5573
  spawnDecoder(track, offsetMs) {
5482
5574
  if (!this.opts) return;
5483
5575
  this.killDecoder();
5484
5576
  const dec = spawn2(this.opts.ffmpegPath, decoderArgs(track.url, offsetMs), { stdio: ["ignore", "pipe", "ignore"] });
5485
5577
  this.decoder = dec;
5486
- this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
5578
+ this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true, lastDataAtServerMs: 0 };
5487
5579
  this.opts.onTrack?.(track);
5488
5580
  dec.stdout?.on("data", (chunk) => {
5489
5581
  if (this.decoder !== dec) return;
5582
+ if (this.opts) this.mem.lastDataAtServerMs = this.opts.serverNow();
5490
5583
  this.gain = applyGain(chunk, this.gain, this.effTarget());
5491
- const sink = this.sink;
5492
- if (!sink || !sink.stdin || sink.stdin.destroyed) return;
5493
- const ok = sink.stdin.write(chunk);
5494
- if (ok === false) dec.stdout?.pause();
5584
+ if (this.feed(chunk) === false) dec.stdout?.pause();
5495
5585
  });
5496
5586
  dec.on("error", () => {
5497
5587
  if (this.decoder === dec) this.mem.procAlive = false;
@@ -5514,6 +5604,10 @@ var SyncedAudio = class {
5514
5604
  clearInterval(this.timer);
5515
5605
  this.timer = null;
5516
5606
  }
5607
+ if (this.keepalive) {
5608
+ clearInterval(this.keepalive);
5609
+ this.keepalive = null;
5610
+ }
5517
5611
  this.killDecoder();
5518
5612
  if (this.sink) {
5519
5613
  try {
@@ -5529,7 +5623,7 @@ var SyncedAudio = class {
5529
5623
  // src/radio/app.ts
5530
5624
  function rlog(msg) {
5531
5625
  try {
5532
- const dir = path.join(os.homedir(), ".accelr8");
5626
+ const dir = accelr8Dir();
5533
5627
  fs.mkdirSync(dir, { recursive: true });
5534
5628
  fs.appendFileSync(path.join(dir, "radio.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
5535
5629
  `);
@@ -5550,6 +5644,7 @@ var frameH = 0;
5550
5644
  var renderTimer = null;
5551
5645
  var running = false;
5552
5646
  var startedAt = Date.now();
5647
+ var VISUAL_EPOCH = Date.UTC(2026, 0, 1);
5553
5648
  var skewMs = 0;
5554
5649
  var serverNow = () => Date.now() + skewMs;
5555
5650
  var W = () => Math.max(20, term.width || process.stdout.columns || 100);
@@ -5595,10 +5690,29 @@ function connectStation(opts) {
5595
5690
  return;
5596
5691
  }
5597
5692
  stationWs = ws;
5693
+ let lastHeard = Date.now();
5694
+ const beat = setInterval(() => {
5695
+ if (ws.readyState !== WebSocket.OPEN) return;
5696
+ if (Date.now() - lastHeard > 25e3) {
5697
+ rlog("station socket silent 25s, reconnecting");
5698
+ ws.terminate();
5699
+ return;
5700
+ }
5701
+ try {
5702
+ ws.ping();
5703
+ } catch {
5704
+ }
5705
+ }, 1e4);
5706
+ ws.on("pong", () => {
5707
+ lastHeard = Date.now();
5708
+ });
5598
5709
  ws.on("open", () => {
5710
+ lastHeard = Date.now();
5599
5711
  ws.send(JSON.stringify({ t: "hello", id: me.id, name: me.name, tz: me.tz, color: me.color }));
5712
+ if (youVoted && skipTrackId) ws.send(JSON.stringify({ t: "voteSkip", trackId: skipTrackId }));
5600
5713
  });
5601
5714
  ws.on("message", (raw) => {
5715
+ lastHeard = Date.now();
5602
5716
  let m;
5603
5717
  try {
5604
5718
  m = JSON.parse(raw.toString());
@@ -5608,6 +5722,7 @@ function connectStation(opts) {
5608
5722
  if (m && m.t === "radioState") applyStation(m);
5609
5723
  });
5610
5724
  ws.on("close", () => {
5725
+ clearInterval(beat);
5611
5726
  if (stationWs === ws) {
5612
5727
  state = { ...state, connected: false };
5613
5728
  scheduleReconnect(opts);
@@ -5615,7 +5730,7 @@ function connectStation(opts) {
5615
5730
  });
5616
5731
  ws.on("error", () => {
5617
5732
  try {
5618
- ws.close();
5733
+ ws.terminate();
5619
5734
  } catch {
5620
5735
  }
5621
5736
  });
@@ -5638,8 +5753,14 @@ function applyStation(m) {
5638
5753
  skipVotes = m.skip.votes ?? 0;
5639
5754
  skipNeeded = m.skip.needed ?? 1;
5640
5755
  if (m.skip.trackId && m.skip.trackId !== skipTrackId) {
5756
+ if (youVoted && skipTrackId) {
5757
+ skipNote = "skipped \u2713";
5758
+ skipNoteUntil = Date.now() + 4e3;
5759
+ }
5641
5760
  skipTrackId = m.skip.trackId;
5642
5761
  youVoted = false;
5762
+ } else if (Array.isArray(m.skip.voters) && Date.now() - lastToggleAt > 2e3) {
5763
+ youVoted = m.skip.voters.includes(me.id);
5643
5764
  }
5644
5765
  }
5645
5766
  }
@@ -5668,7 +5789,14 @@ function startBackground(opts) {
5668
5789
  }
5669
5790
  if (tools.ffmpegPath && tools.ffplayPath) {
5670
5791
  audio = new SyncedAudio();
5671
- audio.start({ ffplayPath: tools.ffplayPath, ffmpegPath: tools.ffmpegPath, schedule: state.schedule, serverNow });
5792
+ audio.start({
5793
+ ffplayPath: tools.ffplayPath,
5794
+ ffmpegPath: tools.ffmpegPath,
5795
+ schedule: state.schedule,
5796
+ serverNow,
5797
+ onRespawn: (reason, track, offsetMs) => rlog(`audio respawn: ${reason} \xB7 ${track.id} @ ${(offsetMs / 1e3).toFixed(1)}s`),
5798
+ onSink: (kind) => rlog(`audio sink: ${kind}`)
5799
+ });
5672
5800
  }
5673
5801
  }
5674
5802
  function makeScreen() {
@@ -5694,9 +5822,18 @@ var skipVotes = 0;
5694
5822
  var skipNeeded = 1;
5695
5823
  var youVoted = false;
5696
5824
  var skipTrackId = "";
5825
+ var skipNote = "";
5826
+ var skipNoteUntil = 0;
5827
+ var lastToggleAt = 0;
5697
5828
  function toggleSkip() {
5829
+ if (!stationWs || stationWs.readyState !== WebSocket.OPEN) {
5830
+ skipNote = "offline, retrying";
5831
+ skipNoteUntil = Date.now() + 3e3;
5832
+ return;
5833
+ }
5698
5834
  youVoted = !youVoted;
5699
- if (stationWs && stationWs.readyState === 1) stationWs.send(JSON.stringify({ t: youVoted ? "voteSkip" : "unvoteSkip" }));
5835
+ lastToggleAt = Date.now();
5836
+ stationWs.send(JSON.stringify({ t: youVoted ? "voteSkip" : "unvoteSkip", trackId: skipTrackId || void 0 }));
5700
5837
  }
5701
5838
  function drawControls() {
5702
5839
  const w = screen.width, y = 1;
@@ -5708,7 +5845,7 @@ function drawControls() {
5708
5845
  const vol = audio ? audio.isMuted ? "MUTED" : `vol ${audio.volume}%` : "no audio";
5709
5846
  put2(1, `\u266A ${vol} [m] mute [+/-] volume [q] quit`);
5710
5847
  const met = skipVotes >= skipNeeded && skipVotes > 0;
5711
- const label = youVoted ? "[s] voted \u2713" : "[s] vote skip";
5848
+ const label = Date.now() < skipNoteUntil ? `[s] ${skipNote}` : youVoted ? "[s] voted \u2713" : "[s] vote skip";
5712
5849
  const right = `${label} skip ${skipVotes}/${skipNeeded} `;
5713
5850
  put2(w - right.length, right, met ? C_GOOD : 231, youVoted);
5714
5851
  }
@@ -5743,7 +5880,7 @@ var renderErrs = 0;
5743
5880
  function renderFrame() {
5744
5881
  if (!running) return;
5745
5882
  try {
5746
- const tMs = Date.now() - startedAt;
5883
+ const tMs = serverNow() - VISUAL_EPOCH;
5747
5884
  if (visuals) {
5748
5885
  lastFrame = visuals.frame(tMs, screen.width, screen.height);
5749
5886
  frameW = screen.width;
@@ -5810,7 +5947,7 @@ function runRadio(opts) {
5810
5947
  const detail = err instanceof Error ? err.stack || err.message : String(err);
5811
5948
  rlog(`CRASH: ${detail}`);
5812
5949
  teardown();
5813
- console.error("\naccelr8 radio crashed:\n" + detail + "\n\nlog: " + path.join(os.homedir(), ".accelr8", "radio.log") + "\n(press any key to close)");
5950
+ console.error("\naccelr8 radio crashed:\n" + detail + "\n\nlog: " + path.join(accelr8Dir(), "radio.log") + "\n(press any key to close)");
5814
5951
  try {
5815
5952
  process.stdin.setRawMode?.(true);
5816
5953
  process.stdin.resume();
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AuthNetworkError,
4
+ approveSignIn,
5
+ approveUrl,
6
+ deviceLogin,
7
+ ensureSignedIn,
8
+ getAccessToken,
9
+ httpBase,
10
+ legacyProofs,
11
+ logout,
12
+ olderServerMessage,
13
+ pendingSignIn,
14
+ refreshCredentials,
15
+ whoami
16
+ } from "./chunk-3OTVRBTJ.js";
17
+ import "./chunk-FVC2UPMV.js";
18
+ import "./chunk-5QXGOLQ3.js";
19
+ import "./chunk-62O2F73J.js";
20
+ export {
21
+ AuthNetworkError,
22
+ approveSignIn,
23
+ approveUrl,
24
+ deviceLogin,
25
+ ensureSignedIn,
26
+ getAccessToken,
27
+ httpBase,
28
+ legacyProofs,
29
+ logout,
30
+ olderServerMessage,
31
+ pendingSignIn,
32
+ refreshCredentials,
33
+ whoami
34
+ };
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DIFFERENT_NETWORK,
4
+ ago,
5
+ confirmPage,
6
+ deniedPage,
7
+ donePage,
8
+ enterCodePage,
9
+ esc,
10
+ platformName
11
+ } from "./chunk-RZ4OBBLX.js";
12
+ export {
13
+ DIFFERENT_NETWORK,
14
+ ago,
15
+ confirmPage,
16
+ deniedPage,
17
+ donePage,
18
+ enterCodePage,
19
+ esc,
20
+ platformName
21
+ };
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AuthLockTimeout,
4
+ credentialsKey,
5
+ deleteCredentials,
6
+ loadCredentials,
7
+ loadLegacyIdentity,
8
+ loadTokens,
9
+ saveCredentials,
10
+ withAuthLock
11
+ } from "./chunk-FVC2UPMV.js";
12
+ import {
13
+ PORT,
14
+ VERSION
15
+ } from "./chunk-5QXGOLQ3.js";
16
+
17
+ // src/authclient.ts
18
+ import os from "node:os";
19
+ import { createHash } from "node:crypto";
20
+ var sha256hex = (s) => createHash("sha256").update(s).digest("hex");
21
+ function legacyProofs(serverUrl, baseDir) {
22
+ const server = credentialsKey(serverUrl);
23
+ const out = [];
24
+ for (const [key, token] of Object.entries(loadTokens(baseDir))) {
25
+ const i = key.indexOf(":");
26
+ if (i <= 0 || !token) continue;
27
+ out.push({ projectId: key.slice(0, i), handle: key.slice(i + 1), proof: sha256hex(sha256hex(token) + "\n" + server) });
28
+ if (out.length >= 20) break;
29
+ }
30
+ return out;
31
+ }
32
+ var REFRESH_EARLY_MS = 6e4;
33
+ function httpBase(serverUrl) {
34
+ const key = credentialsKey(serverUrl);
35
+ return key.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
36
+ }
37
+ function approveUrl(serverUrl) {
38
+ return httpBase(serverUrl) + "/approve";
39
+ }
40
+ var AuthNetworkError = class extends Error {
41
+ };
42
+ async function call(url, init = {}) {
43
+ let res;
44
+ try {
45
+ res = await fetch(url, {
46
+ method: init.method ?? (init.body === void 0 ? "GET" : "POST"),
47
+ headers: {
48
+ ...init.body === void 0 ? {} : { "content-type": "application/json" },
49
+ ...init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}
50
+ },
51
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
52
+ signal: AbortSignal.timeout(15e3)
53
+ });
54
+ } catch (e) {
55
+ throw new AuthNetworkError(`could not reach ${new URL(url).origin}: ${e instanceof Error ? e.message : e}`);
56
+ }
57
+ let data = null;
58
+ try {
59
+ data = await res.json();
60
+ } catch {
61
+ }
62
+ return { status: res.status, data };
63
+ }
64
+ function toCredentials(serverUrl, r, now = Date.now()) {
65
+ return {
66
+ server: credentialsKey(serverUrl),
67
+ user: { id: r.user.id, name: r.user.name, color: r.user.color },
68
+ sessionId: r.sessionId,
69
+ accessToken: r.accessToken,
70
+ accessExpiresAt: now + r.accessExpiresIn * 1e3,
71
+ refreshToken: r.refreshToken,
72
+ refreshExpiresAt: now + r.refreshExpiresIn * 1e3,
73
+ signedInAt: now
74
+ };
75
+ }
76
+ var isTokens = (d) => !!d && typeof d.accessToken === "string" && typeof d.refreshToken === "string" && !!d.user;
77
+ function defaultName() {
78
+ try {
79
+ return os.userInfo().username;
80
+ } catch {
81
+ return process.env.USER || "guest";
82
+ }
83
+ }
84
+ var OLDER_SERVER = /* @__PURE__ */ new Set([404, 405, 426]);
85
+ function olderServerMessage(serverUrl) {
86
+ return [
87
+ `${credentialsKey(serverUrl)} runs an older accelr8 with no sign-in yet, so this version can't use it.`,
88
+ "To try this version, start your own server in another terminal:",
89
+ ` accelr8 serve --host 127.0.0.1`,
90
+ "then open accelr8 on it:",
91
+ ` accelr8 --server ws://127.0.0.1:${PORT}`
92
+ ].join("\n ");
93
+ }
94
+ async function deviceLogin(serverUrl, opts = {}) {
95
+ const print = opts.print ?? ((l) => console.log(l));
96
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
97
+ const base = httpBase(serverUrl);
98
+ const legacy = loadLegacyIdentity(opts.baseDir);
99
+ const startRes = await call(`${base}/auth/device`, {
100
+ body: {
101
+ machine: (opts.machine ?? os.hostname()).slice(0, 64),
102
+ platform: process.platform,
103
+ name: opts.name || legacy.name || defaultName(),
104
+ legacyId: legacy.id,
105
+ legacyName: legacy.name,
106
+ // old ids were public: the old projects come along only with proof
107
+ legacyProof: legacy.id ? legacyProofs(serverUrl, opts.baseDir) : void 0,
108
+ version: VERSION
109
+ }
110
+ });
111
+ if (startRes.status !== 200 || typeof startRes.data?.deviceCode !== "string") {
112
+ if (!startRes.data?.message && OLDER_SERVER.has(startRes.status)) throw new Error(olderServerMessage(serverUrl));
113
+ throw new Error(startRes.data?.message || `the server refused to start a sign-in (HTTP ${startRes.status})`);
114
+ }
115
+ const start = startRes.data;
116
+ print("");
117
+ print(` To sign in, open this page in your browser:`);
118
+ print(` ${approveUrl(serverUrl)}`);
119
+ print(` and type this code:`);
120
+ print(` ${start.userCode}`);
121
+ print("");
122
+ print(` Waiting for you to approve it. The code expires in ${Math.round(start.expiresIn / 60)} minutes.`);
123
+ await opts.onCode?.(start);
124
+ let interval = Math.max(1, start.interval) * 1e3;
125
+ const deadline = Date.now() + start.expiresIn * 1e3;
126
+ for (; ; ) {
127
+ const r = await call(`${base}/auth/device/token`, { body: { deviceCode: start.deviceCode } });
128
+ if (r.status === 200 && isTokens(r.data)) {
129
+ const creds = toCredentials(serverUrl, r.data);
130
+ saveCredentials(creds, opts.baseDir);
131
+ return creds;
132
+ }
133
+ if (r.status === 200 && r.data?.pending) {
134
+ } else if (r.data?.error === "slow_down") interval += 5e3;
135
+ else throw new Error(r.data?.message || `sign-in failed (HTTP ${r.status})`);
136
+ if (Date.now() + interval > deadline) throw new Error("The code expired. Run accelr8 login again for a new one.");
137
+ await sleep(interval);
138
+ }
139
+ }
140
+ async function refreshCredentials(serverUrl, opts = {}) {
141
+ const before = loadCredentials(serverUrl, opts.baseDir);
142
+ try {
143
+ return await withAuthLock(async () => {
144
+ const creds = loadCredentials(serverUrl, opts.baseDir);
145
+ if (!creds) return null;
146
+ if (!opts.force && creds.accessExpiresAt - Date.now() > REFRESH_EARLY_MS) return creds;
147
+ if (opts.force && before && creds.refreshToken !== before.refreshToken) return creds;
148
+ const r = await call(`${httpBase(serverUrl)}/auth/refresh`, { body: { refreshToken: creds.refreshToken } });
149
+ if (r.status === 200 && isTokens(r.data)) {
150
+ const next = { ...toCredentials(serverUrl, r.data), signedInAt: creds.signedInAt };
151
+ saveCredentials(next, opts.baseDir);
152
+ return next;
153
+ }
154
+ if (r.data?.error === "invalid_grant") {
155
+ deleteCredentials(serverUrl, opts.baseDir, (saved) => saved.refreshToken === creds.refreshToken);
156
+ return null;
157
+ }
158
+ throw new AuthNetworkError(`refresh failed (HTTP ${r.status})`);
159
+ }, opts.baseDir, opts.lock);
160
+ } catch (e) {
161
+ if (!(e instanceof AuthLockTimeout)) throw e;
162
+ const now = loadCredentials(serverUrl, opts.baseDir);
163
+ if (now && (!before || now.refreshToken !== before.refreshToken)) return now;
164
+ throw new AuthNetworkError("another accelr8 process is refreshing this sign-in; trying again shortly");
165
+ }
166
+ }
167
+ async function getAccessToken(serverUrl, opts = {}) {
168
+ const creds = loadCredentials(serverUrl, opts.baseDir);
169
+ if (!creds) return null;
170
+ if (!opts.force && creds.accessExpiresAt - Date.now() > REFRESH_EARLY_MS) return { token: creds.accessToken, creds };
171
+ try {
172
+ const next = await refreshCredentials(serverUrl, opts);
173
+ return next ? { token: next.accessToken, creds: next } : null;
174
+ } catch (e) {
175
+ if (e instanceof AuthNetworkError) return { token: creds.accessToken, creds };
176
+ throw e;
177
+ }
178
+ }
179
+ async function logout(serverUrl, opts = {}) {
180
+ const creds = loadCredentials(serverUrl, opts.baseDir);
181
+ if (!creds) return { wasSignedIn: false, revoked: null };
182
+ let revoked = null;
183
+ try {
184
+ const r = await call(`${httpBase(serverUrl)}/auth/logout`, { body: { refreshToken: creds.refreshToken, all: opts.all === true }, bearer: creds.accessToken });
185
+ if (r.status === 200) revoked = Number(r.data?.revoked ?? 0);
186
+ } catch {
187
+ }
188
+ deleteCredentials(serverUrl, opts.baseDir);
189
+ return { wasSignedIn: true, user: creds.user, revoked };
190
+ }
191
+ async function whoami(serverUrl, opts = {}) {
192
+ const local = loadCredentials(serverUrl, opts.baseDir);
193
+ if (!local) return null;
194
+ let tok;
195
+ try {
196
+ tok = await getAccessToken(serverUrl, opts);
197
+ } catch {
198
+ tok = { token: local.accessToken, creds: local };
199
+ }
200
+ if (!tok) return null;
201
+ try {
202
+ let r = await call(`${httpBase(serverUrl)}/auth/me`, { bearer: tok.token });
203
+ if (r.status === 401) {
204
+ const again = await getAccessToken(serverUrl, { ...opts, force: true });
205
+ if (!again) return null;
206
+ tok = again;
207
+ r = await call(`${httpBase(serverUrl)}/auth/me`, { bearer: tok.token });
208
+ }
209
+ if (r.status === 200 && r.data?.user) {
210
+ const user = r.data.user;
211
+ if (user.name !== tok.creds.user.name || user.color !== tok.creds.user.color) {
212
+ saveCredentials({ ...tok.creds, user: { id: user.id, name: user.name, color: user.color } }, opts.baseDir);
213
+ }
214
+ return { server: tok.creds.server, user, machine: r.data.session?.machine, since: r.data.session?.createdAt, verified: true };
215
+ }
216
+ return { server: tok.creds.server, user: tok.creds.user, since: tok.creds.signedInAt, verified: false, note: `server answered HTTP ${r.status}` };
217
+ } catch (e) {
218
+ return { server: tok.creds.server, user: tok.creds.user, since: tok.creds.signedInAt, verified: false, note: e instanceof Error ? e.message : String(e) };
219
+ }
220
+ }
221
+ async function signedInCall(serverUrl, route, body, baseDir) {
222
+ const tok = await getAccessToken(serverUrl, { baseDir });
223
+ if (!tok) throw new Error(`This machine is not signed in to ${credentialsKey(serverUrl)}. Run accelr8 login first.`);
224
+ return call(`${httpBase(serverUrl)}${route}`, { body, bearer: tok.token });
225
+ }
226
+ async function pendingSignIn(serverUrl, userCode, opts = {}) {
227
+ const r = await signedInCall(serverUrl, "/auth/device/pending", { userCode }, opts.baseDir);
228
+ if (r.status !== 200 || typeof r.data?.machine !== "string") throw new Error(r.data?.message || `the server refused (HTTP ${r.status})`);
229
+ return r.data;
230
+ }
231
+ async function approveSignIn(serverUrl, userCode, opts = {}) {
232
+ const r = await signedInCall(serverUrl, "/auth/device/approve", { userCode }, opts.baseDir);
233
+ if (r.status !== 200 || !r.data?.user) throw new Error(r.data?.message || `the server refused (HTTP ${r.status})`);
234
+ return r.data.user;
235
+ }
236
+ async function ensureSignedIn(serverUrl, opts = {}) {
237
+ const have = loadCredentials(serverUrl, opts.baseDir);
238
+ if (have) return have;
239
+ const print = opts.print ?? ((l) => console.log(l));
240
+ print(`You are not signed in to ${credentialsKey(serverUrl)} yet.`);
241
+ try {
242
+ const creds = await deviceLogin(serverUrl, opts);
243
+ print(` Signed in as ${creds.user.name}. Opening accelr8...`);
244
+ return creds;
245
+ } catch (e) {
246
+ print(` ${e instanceof Error ? e.message : e}`);
247
+ return null;
248
+ }
249
+ }
250
+
251
+ export {
252
+ legacyProofs,
253
+ httpBase,
254
+ approveUrl,
255
+ AuthNetworkError,
256
+ olderServerMessage,
257
+ deviceLogin,
258
+ refreshCredentials,
259
+ getAccessToken,
260
+ logout,
261
+ whoami,
262
+ pendingSignIn,
263
+ approveSignIn,
264
+ ensureSignedIn
265
+ };