deadman-fm 0.1.4 → 0.1.5

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.
@@ -12,6 +12,10 @@ interface CreateOptions {
12
12
  genre: string;
13
13
  music: string;
14
14
  watch?: boolean;
15
+ /** On-chain station ID — required to ping the heartbeat contract. */
16
+ stationId?: number;
17
+ /** Hex private key (0x-prefixed) for the station creator wallet. */
18
+ privateKey?: string;
15
19
  }
16
20
  export declare function createStation(opts: CreateOptions): Promise<void>;
17
21
  export {};
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
15
15
  import { mkdirSync, writeFileSync } from "node:fs";
16
16
  import Hypercore from "hypercore";
17
17
  import Hyperswarm from "hyperswarm";
18
+ import { pingHeartbeat } from "../node/heartbeat.js";
18
19
  const AUDIO_EXTS = new Set([
19
20
  ".mp3",
20
21
  ".wav",
@@ -88,6 +89,14 @@ export async function createStation(opts) {
88
89
  console.log(` Feed key: ${core.key.toString("hex")}`);
89
90
  console.log(` ${totalSegments} segments on the network`);
90
91
  console.log(`\n Press Ctrl+C to stop seeding`);
92
+ // Ping the on-chain heartbeat to reset the 7-day dead man's switch
93
+ if (opts.stationId !== undefined) {
94
+ await pingHeartbeat(opts.stationId, totalSegments, opts.privateKey);
95
+ }
96
+ else {
97
+ console.warn("\n ⚠️ Heartbeat NOT pinged — no stationId provided.");
98
+ console.warn(" Pass --station-id <ID> to auto-ping after syncing.");
99
+ }
91
100
  // Watch mode
92
101
  if (opts.watch) {
93
102
  console.log(` 👁 Watching ${musicPath} for new files...`);
@@ -106,6 +115,10 @@ export async function createStation(opts) {
106
115
  meta.totalSegments = totalSegments;
107
116
  writeFileSync(join(stationDir, "station.json"), JSON.stringify(meta, null, 2));
108
117
  console.log(` → ${segments} segments (total: ${totalSegments})`);
118
+ // Re-ping heartbeat whenever new content is synced
119
+ if (opts.stationId !== undefined) {
120
+ await pingHeartbeat(opts.stationId, totalSegments, opts.privateKey);
121
+ }
109
122
  });
110
123
  }
111
124
  // Keep alive
@@ -110,6 +110,44 @@ function loadLocalStations() {
110
110
  }
111
111
  return results;
112
112
  }
113
+ async function fetchMyStationsFromChain(walletAddress) {
114
+ const { createPublicClient, http, parseAbiItem } = await import("viem");
115
+ const { base } = await import("viem/chains");
116
+ const REGISTRY = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
117
+ const DEPLOY_BLOCK = 43856547n;
118
+ const client = createPublicClient({
119
+ chain: base,
120
+ transport: http("https://mainnet.base.org"),
121
+ });
122
+ const event = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
123
+ const CHUNK = 9500n;
124
+ const currentBlock = await client.getBlockNumber();
125
+ const allLogs = [];
126
+ let from = DEPLOY_BLOCK;
127
+ while (from <= currentBlock) {
128
+ const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
129
+ const chunk = await client.getLogs({
130
+ address: REGISTRY,
131
+ event,
132
+ fromBlock: from,
133
+ toBlock: to,
134
+ });
135
+ allLogs.push(...chunk);
136
+ from = to + 1n;
137
+ }
138
+ return allLogs
139
+ .filter((log) => log.args.creator?.toLowerCase() === walletAddress.toLowerCase())
140
+ .map((log) => ({
141
+ id: log.args.stationId?.toString() ?? "0",
142
+ name: log.args.name ?? "Unnamed",
143
+ genre: log.args.genre
144
+ ? Buffer.from(log.args.genre.replace(/^0x/, ""), "hex")
145
+ .toString("utf8")
146
+ .replace(/\0/g, "")
147
+ : "Other",
148
+ creator: log.args.creator ?? "0x0",
149
+ }));
150
+ }
113
151
  // ---------------------------------------------------------------------------
114
152
  // Keychain helpers
115
153
  // ---------------------------------------------------------------------------
@@ -206,6 +244,16 @@ function checkRelay() {
206
244
  return false;
207
245
  }
208
246
  }
247
+ function checkDaemon() {
248
+ try {
249
+ const out = execSync("curl -sf http://localhost:9999/health --max-time 2 2>/dev/null", { encoding: "utf8" }).trim();
250
+ const data = JSON.parse(out);
251
+ return { up: data.ok, stations: data.stations, block: data.block };
252
+ }
253
+ catch {
254
+ return { up: false };
255
+ }
256
+ }
209
257
  function checkLocalLink() {
210
258
  try {
211
259
  const out = execSync("pgrep -f 'deadman' 2>/dev/null || true", {
@@ -251,12 +299,12 @@ export async function interactive() {
251
299
  {
252
300
  value: "create",
253
301
  label: "Start Mining",
254
- hint: "point to your music, start earning $FM",
302
+ hint: "sync music to your station, start earning $FM",
255
303
  },
256
304
  {
257
- value: "sync",
258
- label: "Sync Music",
259
- hint: "add or update tracks on your station",
305
+ value: "node",
306
+ label: "Run Node",
307
+ hint: "serve the heartbeat API, earn $FM",
260
308
  },
261
309
  {
262
310
  value: "connect",
@@ -267,7 +315,7 @@ export async function interactive() {
267
315
  {
268
316
  value: "status",
269
317
  label: "Check status",
270
- hint: "relay, speaker, wallet",
318
+ hint: "node, speaker, wallet",
271
319
  },
272
320
  { value: "exit", label: "Exit" },
273
321
  ],
@@ -280,8 +328,8 @@ export async function interactive() {
280
328
  case "create":
281
329
  await handleCreate();
282
330
  break;
283
- case "sync":
284
- await handleSync();
331
+ case "node":
332
+ await handleNode();
285
333
  break;
286
334
  case "connect":
287
335
  await handleConnect();
@@ -299,9 +347,50 @@ export async function interactive() {
299
347
  // Option 1 — Create a station
300
348
  // ---------------------------------------------------------------------------
301
349
  async function handleCreate() {
302
- const stations = loadLocalStations();
303
- if (stations.length === 0) {
304
- p.log.warn("You don't own a station yet.");
350
+ // First check: do we have a saved wallet?
351
+ let wallet = loadSavedWallet();
352
+ if (!wallet) {
353
+ const rawWallet = await p.text({
354
+ message: "Your wallet address (the one that created your stations)",
355
+ placeholder: "0x...",
356
+ validate: (val) => {
357
+ if (!val?.match(/^0x[a-fA-F0-9]{40}$/))
358
+ return "Must be a valid Ethereum address";
359
+ },
360
+ });
361
+ guardCancel(rawWallet);
362
+ wallet = rawWallet.trim();
363
+ // Save for next time
364
+ const configDir = join(homedir(), ".deadman-fm");
365
+ const configPath = join(configDir, "config.json");
366
+ try {
367
+ const { mkdirSync, writeFileSync } = await import("node:fs");
368
+ mkdirSync(configDir, { recursive: true });
369
+ const existing = existsSync(configPath)
370
+ ? JSON.parse(readFileSync(configPath, "utf8"))
371
+ : {};
372
+ existing.walletAddress = wallet;
373
+ writeFileSync(configPath, JSON.stringify(existing, null, 2));
374
+ }
375
+ catch {
376
+ /* non-critical */
377
+ }
378
+ }
379
+ // Check chain for stations owned by this wallet
380
+ const chainSpinner = p.spinner();
381
+ chainSpinner.start("Checking Base chain for your stations...");
382
+ let chainStations = [];
383
+ try {
384
+ chainStations = await fetchMyStationsFromChain(wallet);
385
+ }
386
+ catch (err) {
387
+ chainSpinner.stop("Chain query failed");
388
+ p.log.error(`Could not reach Base chain: ${err instanceof Error ? err.message : String(err)}`);
389
+ return;
390
+ }
391
+ chainSpinner.stop(`Found ${chainStations.length} station${chainStations.length === 1 ? "" : "s"} on chain`);
392
+ if (chainStations.length === 0) {
393
+ p.log.warn("No stations found for this wallet.");
305
394
  p.log.info("Win one in the daily emoji auction at deadman.fm");
306
395
  const openAuction = await p.confirm({
307
396
  message: "Open the auction page?",
@@ -324,50 +413,36 @@ async function handleCreate() {
324
413
  }
325
414
  return;
326
415
  }
327
- // Has stations — go straight to sync
328
- p.log.success(`You own ${stations.length} station${stations.length === 1 ? "" : "s"}. Let's sync music.`);
329
- await handleSync();
330
- }
331
- // ---------------------------------------------------------------------------
332
- // Option 2 — Sync music (standalone entry point)
333
- // ---------------------------------------------------------------------------
334
- async function handleSync() {
335
- const stations = loadLocalStations();
336
- if (stations.length === 0) {
337
- p.log.warn("You don't own a station yet.");
338
- p.log.info("Win one in the daily auction at deadman.fm");
339
- return;
340
- }
416
+ // Has stations on chain pick one and sync
341
417
  let stationName;
342
- let stationId = null;
343
- if (stations.length === 1) {
344
- stationName = stations[0].meta.name;
345
- stationId = stations[0].meta.stationId ?? null;
346
- p.log.info(`Using station: ${stationName}`);
418
+ let stationId;
419
+ let genre;
420
+ if (chainStations.length === 1) {
421
+ stationName = chainStations[0].name;
422
+ stationId = parseInt(chainStations[0].id, 10);
423
+ genre = chainStations[0].genre;
424
+ p.log.info(`Using station: ${stationName} (#${stationId})`);
347
425
  }
348
426
  else {
349
427
  const selected = await p.select({
350
428
  message: "Which station?",
351
- options: stations.map(({ meta }) => ({
352
- value: meta.name,
353
- label: meta.name,
354
- hint: meta.stationId !== undefined
355
- ? `#${meta.stationId} · ${meta.genre}`
356
- : meta.genre,
429
+ options: chainStations.map((s) => ({
430
+ value: s.id,
431
+ label: s.name,
432
+ hint: `#${s.id} · ${s.genre}`,
357
433
  })),
358
434
  });
359
435
  guardCancel(selected);
360
- const found = stations.find((s) => s.meta.name === selected);
361
- stationName = found?.meta.name ?? selected;
362
- stationId = found?.meta.stationId ?? null;
436
+ const found = chainStations.find((s) => s.id === selected);
437
+ stationName = found.name;
438
+ stationId = parseInt(found.id, 10);
439
+ genre = found.genre;
363
440
  }
364
- await handleSyncForStation(stationName, stationId);
441
+ // Go straight to sync with the selected chain station
442
+ await handleSyncForStation(stationName, stationId, genre);
365
443
  }
366
- // ---------------------------------------------------------------------------
367
- // Shared sync logic
368
- // ---------------------------------------------------------------------------
369
- async function handleSyncForStation(stationName, stationId) {
370
- const idLabel = stationId !== null ? `#${stationId}` : `"${stationName}"`;
444
+ async function handleSyncForStation(stationName, stationId, genre) {
445
+ const idLabel = `#${stationId}`;
371
446
  const rawPath = await p.text({
372
447
  message: "Path to music folder",
373
448
  placeholder: "~/Music",
@@ -418,10 +493,6 @@ async function handleSyncForStation(stationName, stationId) {
418
493
  const syncSpinner = p.spinner();
419
494
  syncSpinner.start(`Syncing ${tracks.length} tracks to ${idLabel}...`);
420
495
  const { createStation } = await import("./create.js");
421
- const stations = loadLocalStations();
422
- const found = stations.find((s) => s.meta.name === stationName ||
423
- (stationId !== null && s.meta.stationId === stationId));
424
- const genre = found?.meta.genre ?? "Electronic";
425
496
  const originalLog = console.log;
426
497
  let totalSegments = 0;
427
498
  let currentTrackIdx = 0;
@@ -463,7 +534,34 @@ async function handleSyncForStation(stationName, stationId) {
463
534
  await new Promise(() => { });
464
535
  }
465
536
  // ---------------------------------------------------------------------------
466
- // Option 3 — Connect to speaker
537
+ // Option 3 — Run Node (daemon with heartbeat API)
538
+ // ---------------------------------------------------------------------------
539
+ async function handleNode() {
540
+ p.note("Your machine becomes a deadman.fm node.\nIt indexes the chain, serves the heartbeat API,\nand shares content with the P2P network.\n\nMiners who run nodes earn bonus $FM.", "Run Node");
541
+ const portInput = await p.text({
542
+ message: "API port",
543
+ placeholder: "9999",
544
+ initialValue: "9999",
545
+ validate: (val) => {
546
+ const n = parseInt(val ?? "", 10);
547
+ if (isNaN(n) || n < 1024 || n > 65535)
548
+ return "Port must be 1024-65535";
549
+ },
550
+ });
551
+ guardCancel(portInput);
552
+ const servePublic = await p.confirm({
553
+ message: "Serve to the public network? (peers can query your node)",
554
+ initialValue: false,
555
+ });
556
+ guardCancel(servePublic);
557
+ const port = parseInt(portInput, 10);
558
+ const bind = servePublic ? "0.0.0.0" : "127.0.0.1";
559
+ p.log.info(`Starting node on ${bind}:${port}...`);
560
+ const { startDaemon } = await import("../node/daemon.js");
561
+ await startDaemon({ port, public: servePublic });
562
+ }
563
+ // ---------------------------------------------------------------------------
564
+ // Option 4 — Connect to speaker
467
565
  // ---------------------------------------------------------------------------
468
566
  async function handleConnect() {
469
567
  p.note('This links your Mac to your OpenHome speaker over WebSocket.\nOnce connected, say "deadman radio" on your speaker to control stations.', "Connect speaker");
@@ -573,12 +671,16 @@ async function handleList() {
573
671
  async function handleStatus() {
574
672
  const statusSpinner = p.spinner();
575
673
  statusSpinner.start("Checking status...");
674
+ const daemon = checkDaemon();
576
675
  const relayUp = checkRelay();
577
676
  const linkUp = checkLocalLink();
578
677
  const stations = loadLocalStations();
579
678
  const wallet = loadSavedWallet();
580
679
  const apiKey = await loadApiKey();
581
680
  statusSpinner.stop("Status");
681
+ const daemonLine = daemon.up
682
+ ? `Node daemon (:9999) running (${daemon.stations ?? 0} stations, block ${daemon.block ?? 0})`
683
+ : "Node daemon (:9999) not running — run 'Run Node' to start";
582
684
  const relayLine = `Relay (localhost:4040) ${relayUp ? "running" : "not running"}`;
583
685
  const linkLine = `Speaker bridge ${linkUp ? "connected" : "not running"}`;
584
686
  let stationLines;
@@ -598,5 +700,5 @@ async function handleStatus() {
598
700
  ? `Wallet ${wallet.slice(0, 6)}…${wallet.slice(-4)}`
599
701
  : "Wallet not saved";
600
702
  const apiLine = `OpenHome API key ${apiKey ? "saved" : "not saved"}`;
601
- p.note([relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
703
+ p.note([daemonLine, relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
602
704
  }
package/dist/index.js CHANGED
@@ -47,6 +47,18 @@ program
47
47
  rl.close();
48
48
  await startLocalLink(key.trim());
49
49
  });
50
+ program
51
+ .command("daemon")
52
+ .description("Run the deadman.fm node daemon (API + content serving)")
53
+ .option("--port <port>", "HTTP port", "9999")
54
+ .option("--public", "Bind to 0.0.0.0 (serve peers)")
55
+ .action(async (opts) => {
56
+ const { startDaemon } = await import("./node/daemon.js");
57
+ await startDaemon({
58
+ port: parseInt(opts.port, 10),
59
+ public: opts.public ?? false,
60
+ });
61
+ });
50
62
  // No arguments = interactive mode
51
63
  if (process.argv.length <= 2) {
52
64
  interactive();
@@ -0,0 +1,29 @@
1
+ /**
2
+ * deadman.fm — Cross-platform Node Daemon
3
+ *
4
+ * The actual protocol participant. YOUR MACHINE is the network.
5
+ *
6
+ * Replaces background.py with a single Node.js process that:
7
+ * - Seeds content via Hypercore/Hyperswarm (P2P)
8
+ * - Indexes chain events (stations, heartbeats)
9
+ * - Serves the heartbeat API (decentralized, miner-hosted)
10
+ * - Serves segments to peers and the OpenHome speaker
11
+ * - Handles key exchange (on-chain verification)
12
+ *
13
+ * Cross-platform: Mac, Windows, Linux — anywhere Node.js runs.
14
+ * Zero Python dependency. Zero relay dependency.
15
+ *
16
+ * Usage:
17
+ * import { startDaemon } from "./node/daemon.js";
18
+ * await startDaemon({ port: 9999, stationId: "0" });
19
+ *
20
+ * Or via CLI:
21
+ * deadman-fm --daemon --port 9999
22
+ */
23
+ interface DaemonOptions {
24
+ port?: number;
25
+ public?: boolean;
26
+ stationId?: string;
27
+ }
28
+ export declare function startDaemon(opts?: DaemonOptions): Promise<void>;
29
+ export {};
@@ -0,0 +1,648 @@
1
+ /**
2
+ * deadman.fm — Cross-platform Node Daemon
3
+ *
4
+ * The actual protocol participant. YOUR MACHINE is the network.
5
+ *
6
+ * Replaces background.py with a single Node.js process that:
7
+ * - Seeds content via Hypercore/Hyperswarm (P2P)
8
+ * - Indexes chain events (stations, heartbeats)
9
+ * - Serves the heartbeat API (decentralized, miner-hosted)
10
+ * - Serves segments to peers and the OpenHome speaker
11
+ * - Handles key exchange (on-chain verification)
12
+ *
13
+ * Cross-platform: Mac, Windows, Linux — anywhere Node.js runs.
14
+ * Zero Python dependency. Zero relay dependency.
15
+ *
16
+ * Usage:
17
+ * import { startDaemon } from "./node/daemon.js";
18
+ * await startDaemon({ port: 9999, stationId: "0" });
19
+ *
20
+ * Or via CLI:
21
+ * deadman-fm --daemon --port 9999
22
+ */
23
+ import { createServer } from "node:http";
24
+ import { join } from "node:path";
25
+ import { homedir, platform } from "node:os";
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, } from "node:fs";
27
+ import { createPublicClient, http, parseAbiItem } from "viem";
28
+ import { base } from "viem/chains";
29
+ // ─── Config ──────────────────────────────────────────────────────────────────
30
+ const BASE_RPC = "https://mainnet.base.org";
31
+ const REGISTRY_ADDRESS = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
32
+ const HEARTBEAT_ADDRESS = "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
33
+ const DEPLOY_BLOCK = 43856547n;
34
+ const BLOCKS_PER_SEGMENT = 3;
35
+ // Cross-platform data directory
36
+ const DATA_DIR = join(homedir(), ".deadman-fm");
37
+ const KEYS_DIR = join(DATA_DIR, "keys");
38
+ const SEGMENTS_DIR = join(DATA_DIR, "segments");
39
+ const CACHE_DIR = join(DATA_DIR, "cache");
40
+ // Ensure directories exist
41
+ for (const dir of [DATA_DIR, KEYS_DIR, SEGMENTS_DIR, CACHE_DIR]) {
42
+ mkdirSync(dir, { recursive: true });
43
+ }
44
+ // ─── Chain Client ────────────────────────────────────────────────────────────
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ let client;
47
+ function getClient() {
48
+ if (!client) {
49
+ client = createPublicClient({
50
+ chain: base,
51
+ transport: http(BASE_RPC),
52
+ });
53
+ }
54
+ return client;
55
+ }
56
+ // ─── Station Discovery (from chain) ─────────────────────────────────────────
57
+ const stationCreatedEvent = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
58
+ async function discoverStations() {
59
+ const c = getClient();
60
+ const currentBlock = await c.getBlockNumber();
61
+ const CHUNK = 9500n;
62
+ const stations = [];
63
+ let from = DEPLOY_BLOCK;
64
+ while (from <= currentBlock) {
65
+ const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
66
+ try {
67
+ const logs = await c.getLogs({
68
+ address: REGISTRY_ADDRESS,
69
+ event: stationCreatedEvent,
70
+ fromBlock: from,
71
+ toBlock: to,
72
+ });
73
+ for (const log of logs) {
74
+ const args = log.args;
75
+ const genre = args.genre
76
+ ? Buffer.from(args.genre.replace(/^0x/, ""), "hex")
77
+ .toString("utf8")
78
+ .replace(/\0/g, "")
79
+ : "Other";
80
+ stations.push({
81
+ id: args.stationId?.toString() ?? "0",
82
+ creator: args.creator ?? "0x0",
83
+ name: args.name ?? "Unnamed",
84
+ genre,
85
+ blockNumber: Number(log.blockNumber),
86
+ });
87
+ }
88
+ }
89
+ catch {
90
+ // RPC error on chunk — skip and continue
91
+ }
92
+ from = to + 1n;
93
+ }
94
+ return stations;
95
+ }
96
+ // ─── Heartbeat Reads (from chain) ────────────────────────────────────────────
97
+ const heartbeatABI = [
98
+ {
99
+ name: "isAlive",
100
+ type: "function",
101
+ stateMutability: "view",
102
+ inputs: [{ name: "stationId", type: "uint256" }],
103
+ outputs: [{ name: "", type: "bool" }],
104
+ },
105
+ {
106
+ name: "isDead",
107
+ type: "function",
108
+ stateMutability: "view",
109
+ inputs: [{ name: "stationId", type: "uint256" }],
110
+ outputs: [{ name: "", type: "bool" }],
111
+ },
112
+ {
113
+ name: "daysRemaining",
114
+ type: "function",
115
+ stateMutability: "view",
116
+ inputs: [{ name: "stationId", type: "uint256" }],
117
+ outputs: [{ name: "", type: "uint256" }],
118
+ },
119
+ {
120
+ name: "daysUntilDeath",
121
+ type: "function",
122
+ stateMutability: "view",
123
+ inputs: [{ name: "stationId", type: "uint256" }],
124
+ outputs: [{ name: "", type: "uint256" }],
125
+ },
126
+ {
127
+ name: "lastHeartbeat",
128
+ type: "function",
129
+ stateMutability: "view",
130
+ inputs: [{ name: "stationId", type: "uint256" }],
131
+ outputs: [{ name: "", type: "uint256" }],
132
+ },
133
+ {
134
+ name: "emissionScale",
135
+ type: "function",
136
+ stateMutability: "view",
137
+ inputs: [{ name: "stationId", type: "uint256" }],
138
+ outputs: [{ name: "", type: "uint256" }],
139
+ },
140
+ {
141
+ name: "contentSegments",
142
+ type: "function",
143
+ stateMutability: "view",
144
+ inputs: [{ name: "stationId", type: "uint256" }],
145
+ outputs: [{ name: "", type: "uint256" }],
146
+ },
147
+ ];
148
+ async function fetchHeartbeat(stationId) {
149
+ const c = getClient();
150
+ const sid = BigInt(stationId);
151
+ try {
152
+ const [alive, dead, daysLeft, daysUntilDeath, lastPing, scale, segments] = await Promise.all([
153
+ c.readContract({
154
+ address: HEARTBEAT_ADDRESS,
155
+ abi: heartbeatABI,
156
+ functionName: "isAlive",
157
+ args: [sid],
158
+ }),
159
+ c.readContract({
160
+ address: HEARTBEAT_ADDRESS,
161
+ abi: heartbeatABI,
162
+ functionName: "isDead",
163
+ args: [sid],
164
+ }),
165
+ c.readContract({
166
+ address: HEARTBEAT_ADDRESS,
167
+ abi: heartbeatABI,
168
+ functionName: "daysRemaining",
169
+ args: [sid],
170
+ }),
171
+ c.readContract({
172
+ address: HEARTBEAT_ADDRESS,
173
+ abi: heartbeatABI,
174
+ functionName: "daysUntilDeath",
175
+ args: [sid],
176
+ }),
177
+ c.readContract({
178
+ address: HEARTBEAT_ADDRESS,
179
+ abi: heartbeatABI,
180
+ functionName: "lastHeartbeat",
181
+ args: [sid],
182
+ }),
183
+ c.readContract({
184
+ address: HEARTBEAT_ADDRESS,
185
+ abi: heartbeatABI,
186
+ functionName: "emissionScale",
187
+ args: [sid],
188
+ }),
189
+ c.readContract({
190
+ address: HEARTBEAT_ADDRESS,
191
+ abi: heartbeatABI,
192
+ functionName: "contentSegments",
193
+ args: [sid],
194
+ }),
195
+ ]);
196
+ return {
197
+ stationId,
198
+ alive: alive,
199
+ dead: dead,
200
+ daysLeft: Number(daysLeft),
201
+ daysUntilDeath: Number(daysUntilDeath),
202
+ lastPing: Number(lastPing),
203
+ emissionScale: Number(scale),
204
+ contentSegments: Number(segments),
205
+ };
206
+ }
207
+ catch {
208
+ return {
209
+ stationId,
210
+ alive: false,
211
+ dead: false,
212
+ daysLeft: 0,
213
+ daysUntilDeath: 0,
214
+ lastPing: 0,
215
+ emissionScale: 0,
216
+ contentSegments: 0,
217
+ };
218
+ }
219
+ }
220
+ // ─── Key Persistence (cross-platform) ────────────────────────────────────────
221
+ function saveStationKey(stationId, key) {
222
+ writeFileSync(join(KEYS_DIR, `${stationId}.key`), key, { mode: 0o600 });
223
+ }
224
+ function loadStationKey(stationId) {
225
+ const p = join(KEYS_DIR, `${stationId}.key`);
226
+ if (existsSync(p))
227
+ return readFileSync(p, "utf8").trim();
228
+ return null;
229
+ }
230
+ // ─── On-chain Access Verification ────────────────────────────────────────────
231
+ const accessCache = new Map();
232
+ const ACCESS_CACHE_TTL = 300_000; // 5 minutes
233
+ async function verifyHasAccess(stationId, walletAddress) {
234
+ const cacheKey = `${stationId}:${walletAddress.toLowerCase()}`;
235
+ const cached = accessCache.get(cacheKey);
236
+ if (cached && Date.now() - cached.timestamp < ACCESS_CACHE_TTL) {
237
+ return cached.result;
238
+ }
239
+ const c = getClient();
240
+ try {
241
+ // Get station's bonding curve address
242
+ const curveAddr = await c.readContract({
243
+ address: REGISTRY_ADDRESS,
244
+ abi: [
245
+ {
246
+ name: "getStationCurve",
247
+ type: "function",
248
+ stateMutability: "view",
249
+ inputs: [{ name: "stationId", type: "uint256" }],
250
+ outputs: [{ name: "", type: "address" }],
251
+ },
252
+ ],
253
+ functionName: "getStationCurve",
254
+ args: [BigInt(stationId)],
255
+ });
256
+ if (!curveAddr ||
257
+ curveAddr === "0x0000000000000000000000000000000000000000") {
258
+ accessCache.set(cacheKey, { result: false, timestamp: Date.now() });
259
+ return false;
260
+ }
261
+ // Check hasAccess on the bonding curve
262
+ const hasAccess = await c.readContract({
263
+ address: curveAddr,
264
+ abi: [
265
+ {
266
+ name: "hasAccess",
267
+ type: "function",
268
+ stateMutability: "view",
269
+ inputs: [{ name: "addr", type: "address" }],
270
+ outputs: [{ name: "", type: "bool" }],
271
+ },
272
+ ],
273
+ functionName: "hasAccess",
274
+ args: [walletAddress],
275
+ });
276
+ accessCache.set(cacheKey, {
277
+ result: hasAccess,
278
+ timestamp: Date.now(),
279
+ });
280
+ return hasAccess;
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ }
286
+ // ─── Segment Serving ─────────────────────────────────────────────────────────
287
+ function getLocalStationSegments(stationId) {
288
+ const stationsDir = join(DATA_DIR, "stations");
289
+ if (!existsSync(stationsDir))
290
+ return [];
291
+ // Find station feed directory
292
+ for (const slug of readdirSync(stationsDir)) {
293
+ const metaPath = join(stationsDir, slug, "station.json");
294
+ if (!existsSync(metaPath))
295
+ continue;
296
+ try {
297
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
298
+ if (String(meta.stationId) === stationId || meta.name === stationId) {
299
+ const segDir = join(stationsDir, slug, "segments");
300
+ if (existsSync(segDir)) {
301
+ return readdirSync(segDir).filter((f) => f.endsWith(".ts"));
302
+ }
303
+ }
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ }
309
+ return [];
310
+ }
311
+ function getSegmentData(stationId, seq) {
312
+ const stationsDir = join(DATA_DIR, "stations");
313
+ if (!existsSync(stationsDir))
314
+ return null;
315
+ for (const slug of readdirSync(stationsDir)) {
316
+ const metaPath = join(stationsDir, slug, "station.json");
317
+ if (!existsSync(metaPath))
318
+ continue;
319
+ try {
320
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
321
+ if (String(meta.stationId) === stationId || meta.name === stationId) {
322
+ const segPath = join(stationsDir, slug, "segments", `${seq}.ts`);
323
+ if (existsSync(segPath))
324
+ return readFileSync(segPath);
325
+ }
326
+ }
327
+ catch {
328
+ continue;
329
+ }
330
+ }
331
+ // Also check the P2P cache directory
332
+ const cachePath = join(SEGMENTS_DIR, stationId, `${seq}.ts`);
333
+ if (existsSync(cachePath))
334
+ return readFileSync(cachePath);
335
+ return null;
336
+ }
337
+ // ─── HTTP API Server ─────────────────────────────────────────────────────────
338
+ function sendJSON(res, status, data) {
339
+ const body = JSON.stringify(data);
340
+ res.writeHead(status, {
341
+ "Content-Type": "application/json",
342
+ "Access-Control-Allow-Origin": "*",
343
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
344
+ "Access-Control-Allow-Headers": "Content-Type",
345
+ });
346
+ res.end(body);
347
+ }
348
+ function readBody(req) {
349
+ return new Promise((resolve, reject) => {
350
+ const chunks = [];
351
+ req.on("data", (chunk) => chunks.push(chunk));
352
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
353
+ req.on("error", reject);
354
+ });
355
+ }
356
+ function createAPIServer(state) {
357
+ return createServer(async (req, res) => {
358
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
359
+ const path = url.pathname;
360
+ // CORS preflight
361
+ if (req.method === "OPTIONS") {
362
+ res.writeHead(204, {
363
+ "Access-Control-Allow-Origin": "*",
364
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
365
+ "Access-Control-Allow-Headers": "Content-Type",
366
+ });
367
+ res.end();
368
+ return;
369
+ }
370
+ // ── GET /health ──────────────────────────────────────────────────
371
+ if (path === "/health" && req.method === "GET") {
372
+ sendJSON(res, 200, {
373
+ ok: true,
374
+ platform: platform(),
375
+ block: state.currentBlock,
376
+ stations: state.stations.length,
377
+ heartbeats: state.heartbeats.size,
378
+ peers: state.peerCount,
379
+ uptime: process.uptime(),
380
+ });
381
+ return;
382
+ }
383
+ // ── GET /stations ────────────────────────────────────────────────
384
+ if (path === "/stations" && req.method === "GET") {
385
+ sendJSON(res, 200, state.stations);
386
+ return;
387
+ }
388
+ // ── GET /heartbeat/:stationId ────────────────────────────────────
389
+ // The decentralized heartbeat API — miners serve this
390
+ const heartbeatMatch = path.match(/^\/heartbeat\/(\d+)$/);
391
+ if (heartbeatMatch && req.method === "GET") {
392
+ const stationId = heartbeatMatch[1];
393
+ // Check cache first
394
+ const cached = state.heartbeats.get(stationId);
395
+ if (cached && Date.now() - state.lastHeartbeatPoll < 30_000) {
396
+ sendJSON(res, 200, cached);
397
+ return;
398
+ }
399
+ // Fresh read from chain
400
+ const hb = await fetchHeartbeat(stationId);
401
+ state.heartbeats.set(stationId, hb);
402
+ sendJSON(res, 200, hb);
403
+ return;
404
+ }
405
+ // ── GET /heartbeat — all known stations ──────────────────────────
406
+ if (path === "/heartbeat" && req.method === "GET") {
407
+ const all = [];
408
+ for (const [, hb] of state.heartbeats) {
409
+ all.push(hb);
410
+ }
411
+ sendJSON(res, 200, all);
412
+ return;
413
+ }
414
+ // ── GET /segments/:stationId/:seq.ts ─────────────────────────────
415
+ const segMatch = path.match(/^\/segments\/([^/]+)\/(\d+)\.ts$/);
416
+ if (segMatch && req.method === "GET") {
417
+ const data = getSegmentData(segMatch[1], parseInt(segMatch[2], 10));
418
+ if (data) {
419
+ res.writeHead(200, {
420
+ "Content-Type": "video/mp2t",
421
+ "Content-Length": data.length.toString(),
422
+ "Access-Control-Allow-Origin": "*",
423
+ });
424
+ res.end(data);
425
+ return;
426
+ }
427
+ sendJSON(res, 404, { error: "segment not found" });
428
+ return;
429
+ }
430
+ // ── POST /key-exchange ───────────────────────────────────────────
431
+ if (path === "/key-exchange" && req.method === "POST") {
432
+ try {
433
+ const body = JSON.parse(await readBody(req));
434
+ const { stationId, walletAddress } = body;
435
+ if (!stationId || !walletAddress) {
436
+ sendJSON(res, 400, { error: "missing stationId or walletAddress" });
437
+ return;
438
+ }
439
+ const key = loadStationKey(stationId);
440
+ if (!key) {
441
+ sendJSON(res, 404, { error: "no key for this station" });
442
+ return;
443
+ }
444
+ const hasAccess = await verifyHasAccess(stationId, walletAddress);
445
+ if (!hasAccess) {
446
+ sendJSON(res, 403, {
447
+ error: "no position held — buy on the bonding curve first",
448
+ });
449
+ return;
450
+ }
451
+ sendJSON(res, 200, { stationKey: key, stationId });
452
+ console.log(`[daemon] shared key for station ${stationId} with ${walletAddress.slice(0, 10)}...`);
453
+ }
454
+ catch {
455
+ sendJSON(res, 400, { error: "invalid request body" });
456
+ }
457
+ return;
458
+ }
459
+ // ── GET /register/lookup?room_code=XXXX ──────────────────────
460
+ if (path === "/register/lookup" && req.method === "GET") {
461
+ const roomCode = url.searchParams.get("room_code");
462
+ if (!roomCode) {
463
+ sendJSON(res, 400, { error: "missing room_code query parameter" });
464
+ return;
465
+ }
466
+ const registryFile = join(CACHE_DIR, "device-registry.json");
467
+ if (!existsSync(registryFile)) {
468
+ sendJSON(res, 404, { error: "no devices registered yet" });
469
+ return;
470
+ }
471
+ let registry = {};
472
+ try {
473
+ registry = JSON.parse(readFileSync(registryFile, "utf8"));
474
+ }
475
+ catch {
476
+ sendJSON(res, 500, { error: "failed to read device registry" });
477
+ return;
478
+ }
479
+ const entry = Object.values(registry).find((d) => d.room_code === roomCode.toUpperCase());
480
+ if (!entry) {
481
+ sendJSON(res, 404, { error: "room code not found" });
482
+ return;
483
+ }
484
+ sendJSON(res, 200, entry);
485
+ return;
486
+ }
487
+ // ── POST /register — speaker device registration ───────────────
488
+ if (path === "/register" && req.method === "POST") {
489
+ try {
490
+ const body = JSON.parse(await readBody(req));
491
+ const { device_id } = body;
492
+ if (!device_id) {
493
+ sendJSON(res, 400, { error: "missing device_id" });
494
+ return;
495
+ }
496
+ // Load or create device registry
497
+ const registryFile = join(CACHE_DIR, "device-registry.json");
498
+ let registry = {};
499
+ if (existsSync(registryFile)) {
500
+ try {
501
+ registry = JSON.parse(readFileSync(registryFile, "utf8"));
502
+ }
503
+ catch {
504
+ registry = {};
505
+ }
506
+ }
507
+ // Existing device — return known wallet
508
+ if (registry[device_id]) {
509
+ sendJSON(res, 200, {
510
+ address: registry[device_id].address,
511
+ room_code: registry[device_id].room_code,
512
+ is_new: false,
513
+ });
514
+ return;
515
+ }
516
+ // New device — generate room code, placeholder address
517
+ // TODO: Replace with CDP wallet creation via Coinbase SDK
518
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
519
+ const roomCode = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
520
+ // Placeholder: deterministic address from device_id hash
521
+ // In production: call Coinbase CDP API to create a real wallet
522
+ const { createHash } = await import("node:crypto");
523
+ const hash = createHash("sha256").update(device_id).digest("hex");
524
+ const placeholderAddress = `0x${hash.slice(0, 40)}`;
525
+ registry[device_id] = {
526
+ address: placeholderAddress,
527
+ room_code: roomCode,
528
+ created_at: new Date().toISOString(),
529
+ };
530
+ writeFileSync(registryFile, JSON.stringify(registry, null, 2));
531
+ console.log(`[daemon] registered device ${device_id} → ${placeholderAddress} (code: ${roomCode})`);
532
+ sendJSON(res, 200, {
533
+ address: placeholderAddress,
534
+ room_code: roomCode,
535
+ is_new: true,
536
+ });
537
+ }
538
+ catch {
539
+ sendJSON(res, 400, { error: "invalid request body" });
540
+ }
541
+ return;
542
+ }
543
+ // ── 404 ──────────────────────────────────────────────────────────
544
+ sendJSON(res, 404, {
545
+ error: "not found",
546
+ routes: [
547
+ "GET /health",
548
+ "GET /stations",
549
+ "GET /heartbeat/:stationId",
550
+ "GET /heartbeat",
551
+ "GET /segments/:stationId/:seq.ts",
552
+ "GET /register/lookup?room_code=XXXX",
553
+ "POST /key-exchange",
554
+ "POST /register",
555
+ ],
556
+ });
557
+ });
558
+ }
559
+ // ─── Background Pollers ──────────────────────────────────────────────────────
560
+ async function pollStations(state) {
561
+ try {
562
+ state.stations = await discoverStations();
563
+ state.lastStationPoll = Date.now();
564
+ console.log(`[daemon] indexed ${state.stations.length} stations from chain`);
565
+ }
566
+ catch (e) {
567
+ console.error(`[daemon] station poll failed: ${e}`);
568
+ }
569
+ }
570
+ async function pollHeartbeats(state) {
571
+ try {
572
+ const promises = state.stations.map(async (s) => {
573
+ const hb = await fetchHeartbeat(s.id);
574
+ state.heartbeats.set(s.id, hb);
575
+ });
576
+ await Promise.all(promises);
577
+ state.lastHeartbeatPoll = Date.now();
578
+ const alive = [...state.heartbeats.values()].filter((h) => h.alive).length;
579
+ console.log(`[daemon] heartbeats: ${alive}/${state.heartbeats.size} alive`);
580
+ }
581
+ catch (e) {
582
+ console.error(`[daemon] heartbeat poll failed: ${e}`);
583
+ }
584
+ }
585
+ async function pollBlock(state) {
586
+ try {
587
+ const c = getClient();
588
+ state.currentBlock = Number(await c.getBlockNumber());
589
+ }
590
+ catch {
591
+ // silent — block polling is best-effort
592
+ }
593
+ }
594
+ // ─── Main Entry ──────────────────────────────────────────────────────────────
595
+ export async function startDaemon(opts = {}) {
596
+ const port = opts.port ?? 9999;
597
+ const bindAddr = opts.public ? "0.0.0.0" : "127.0.0.1";
598
+ const state = {
599
+ stations: [],
600
+ currentBlock: 0,
601
+ lastStationPoll: 0,
602
+ lastHeartbeatPoll: 0,
603
+ heartbeats: new Map(),
604
+ peerCount: 0,
605
+ };
606
+ console.log(`[daemon] deadman.fm node starting on ${platform()}`);
607
+ console.log(`[daemon] data: ${DATA_DIR}`);
608
+ // Initial chain reads
609
+ await pollBlock(state);
610
+ console.log(`[daemon] Base block: ${state.currentBlock}`);
611
+ await pollStations(state);
612
+ await pollHeartbeats(state);
613
+ // Start HTTP API server
614
+ const server = createAPIServer(state);
615
+ server.listen(port, bindAddr, () => {
616
+ console.log(`[daemon] API server: http://${bindAddr}:${port}`);
617
+ console.log(`[daemon] heartbeat API: http://${bindAddr}:${port}/heartbeat/:stationId`);
618
+ console.log(`[daemon] ${state.stations.length} stations indexed, ${state.heartbeats.size} heartbeats cached`);
619
+ });
620
+ // Background polling intervals
621
+ // Block: every 6 seconds
622
+ setInterval(() => pollBlock(state), 6_000);
623
+ // Stations: every 60 seconds
624
+ setInterval(() => pollStations(state), 60_000);
625
+ // Heartbeats: every 30 seconds
626
+ setInterval(() => pollHeartbeats(state), 30_000);
627
+ // Write state file for Local Link / OpenHome ability interop
628
+ setInterval(() => {
629
+ const stateFile = join(DATA_DIR, "daemon-state.json");
630
+ const out = {
631
+ block: state.currentBlock,
632
+ stations: state.stations,
633
+ heartbeats: Object.fromEntries(state.heartbeats),
634
+ peers: state.peerCount,
635
+ port,
636
+ platform: platform(),
637
+ updatedAt: new Date().toISOString(),
638
+ };
639
+ try {
640
+ writeFileSync(stateFile, JSON.stringify(out, null, 2));
641
+ }
642
+ catch {
643
+ // non-critical
644
+ }
645
+ }, 5_000);
646
+ // Keep alive
647
+ await new Promise(() => { });
648
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * deadman.fm — Heartbeat ping utility
3
+ *
4
+ * Calls StationHeartbeat.ping(stationId, segments) on Base to reset
5
+ * the 7-day dead man's switch after content is synced.
6
+ *
7
+ * Only the station creator can call ping(). If no private key is
8
+ * configured, a warning is logged and the call is skipped gracefully.
9
+ */
10
+ export declare const HEARTBEAT_ADDRESS: "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
11
+ export declare const HEARTBEAT_ABI: readonly [{
12
+ readonly name: "ping";
13
+ readonly type: "function";
14
+ readonly stateMutability: "nonpayable";
15
+ readonly inputs: readonly [{
16
+ readonly type: "uint256";
17
+ readonly name: "stationId";
18
+ }, {
19
+ readonly type: "uint256";
20
+ readonly name: "segments";
21
+ }];
22
+ readonly outputs: readonly [];
23
+ }, {
24
+ readonly name: "HeartbeatPinged";
25
+ readonly type: "event";
26
+ readonly inputs: readonly [{
27
+ readonly type: "uint256";
28
+ readonly name: "stationId";
29
+ readonly indexed: true;
30
+ }, {
31
+ readonly type: "address";
32
+ readonly name: "creator";
33
+ readonly indexed: true;
34
+ }, {
35
+ readonly type: "uint256";
36
+ readonly name: "segments";
37
+ }, {
38
+ readonly type: "uint256";
39
+ readonly name: "timestamp";
40
+ }];
41
+ }];
42
+ /**
43
+ * Ping the StationHeartbeat contract to reset the dead man's switch.
44
+ *
45
+ * @param stationId On-chain station ID (uint256)
46
+ * @param totalSegments Total segment count to report
47
+ * @param privateKey Optional hex private key (0x-prefixed). If omitted,
48
+ * the call is skipped and a warning is printed.
49
+ * @returns Transaction hash, or null if skipped.
50
+ */
51
+ export declare function pingHeartbeat(stationId: number, totalSegments: number, privateKey?: string): Promise<string | null>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * deadman.fm — Heartbeat ping utility
3
+ *
4
+ * Calls StationHeartbeat.ping(stationId, segments) on Base to reset
5
+ * the 7-day dead man's switch after content is synced.
6
+ *
7
+ * Only the station creator can call ping(). If no private key is
8
+ * configured, a warning is logged and the call is skipped gracefully.
9
+ */
10
+ import { createWalletClient, createPublicClient, http, parseAbi, } from "viem";
11
+ import { base } from "viem/chains";
12
+ import { privateKeyToAccount } from "viem/accounts";
13
+ // ── Contract constants ────────────────────────────────────────────────────────
14
+ export const HEARTBEAT_ADDRESS = "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
15
+ export const HEARTBEAT_ABI = parseAbi([
16
+ "function ping(uint256 stationId, uint256 segments) external",
17
+ "event HeartbeatPinged(uint256 indexed stationId, address indexed creator, uint256 segments, uint256 timestamp)",
18
+ ]);
19
+ // ── Main export ───────────────────────────────────────────────────────────────
20
+ /**
21
+ * Ping the StationHeartbeat contract to reset the dead man's switch.
22
+ *
23
+ * @param stationId On-chain station ID (uint256)
24
+ * @param totalSegments Total segment count to report
25
+ * @param privateKey Optional hex private key (0x-prefixed). If omitted,
26
+ * the call is skipped and a warning is printed.
27
+ * @returns Transaction hash, or null if skipped.
28
+ */
29
+ export async function pingHeartbeat(stationId, totalSegments, privateKey) {
30
+ if (!privateKey) {
31
+ console.warn("\n ⚠️ Heartbeat NOT pinged — no wallet configured.");
32
+ console.warn(" The 7-day dead man's switch is ticking. Ping manually:");
33
+ console.warn(` deadman-fm heartbeat --station ${stationId} --segments ${totalSegments} --key <PRIVATE_KEY>`);
34
+ return null;
35
+ }
36
+ // Normalise key: viem expects a 0x-prefixed 32-byte hex string
37
+ const keyHex = privateKey.startsWith("0x")
38
+ ? privateKey
39
+ : `0x${privateKey}`;
40
+ let account;
41
+ try {
42
+ account = privateKeyToAccount(keyHex);
43
+ }
44
+ catch (err) {
45
+ console.error(" ❌ Heartbeat: invalid private key —", err.message);
46
+ return null;
47
+ }
48
+ const walletClient = createWalletClient({
49
+ account,
50
+ chain: base,
51
+ transport: http(),
52
+ });
53
+ const publicClient = createPublicClient({
54
+ chain: base,
55
+ transport: http(),
56
+ });
57
+ console.log(`\n 💓 Pinging heartbeat for station #${stationId} (${totalSegments} segments)...`);
58
+ try {
59
+ const hash = await walletClient.writeContract({
60
+ address: HEARTBEAT_ADDRESS,
61
+ abi: HEARTBEAT_ABI,
62
+ functionName: "ping",
63
+ args: [BigInt(stationId), BigInt(totalSegments)],
64
+ });
65
+ // Wait for 1 confirmation so the user gets a definitive success signal
66
+ await publicClient.waitForTransactionReceipt({ hash, confirmations: 1 });
67
+ console.log(` ✅ Heartbeat pinged — tx: ${hash}`);
68
+ return hash;
69
+ }
70
+ catch (err) {
71
+ console.error(" ❌ Heartbeat ping failed —", err.message);
72
+ console.warn(" The 7-day dead man's switch is still ticking. Retry with:");
73
+ console.warn(` deadman-fm heartbeat --station ${stationId} --segments ${totalSegments} --key <PRIVATE_KEY>`);
74
+ return null;
75
+ }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deadman-fm",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "💀.fm — Create and seed decentralized radio stations",
5
5
  "type": "module",
6
6
  "files": [