pi-remote-control 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +46 -0
  2. package/docs/adr/0001-package-extension-as-control-shim.md +19 -0
  3. package/docs/adr/0002-use-sqlite-for-daemon-state.md +19 -0
  4. package/docs/adr/0003-use-lock-file-as-process-state.md +19 -0
  5. package/docs/adr/0004-allow-loopback-pair-code-without-token.md +19 -0
  6. package/docs/adr/0005-defer-os-service-installation.md +19 -0
  7. package/docs/adr/0006-use-tui-activated-remote-control-sessions.md +24 -0
  8. package/docs/adr/0007-require-tui-originated-pairing.md +19 -0
  9. package/docs/adr/0008-use-qr-pairing-links.md +21 -0
  10. package/docs/adr/0009-rename-package-to-remote-control.md +19 -0
  11. package/docs/adr/0010-clean-stale-lock-on-status.md +19 -0
  12. package/docs/adr/0011-use-loopback-tui-control.md +19 -0
  13. package/docs/adr/0012-use-paginated-session-transcript-loading.md +37 -0
  14. package/docs/adr/0013-require-manual-reactivation-after-tui-entry.md +31 -0
  15. package/docs/adr/0014-read-transcripts-from-session-files.md +33 -0
  16. package/docs/adr/0015-normalize-transcript-messages-and-stream-events.md +35 -0
  17. package/docs/adr/0016-expose-turn-lifecycle-events.md +31 -0
  18. package/docs/adr/0017-bound-initial-websocket-session-state.md +31 -0
  19. package/docs/adr/0018-reregister-active-tui-session-on-heartbeat-miss.md +33 -0
  20. package/docs/adr/0019-display-only-pairing-qr-and-expiry.md +25 -0
  21. package/docs/adr/0020-expose-session-status-snapshots.md +31 -0
  22. package/docs/adr/0021-support-remote-compact-action.md +31 -0
  23. package/docs/adr/0022-rename-session-status-to-runtime-status.md +27 -0
  24. package/docs/adr/0023-return-remote-compact-results.md +29 -0
  25. package/docs/architecture.md +96 -0
  26. package/docs/data-model.md +284 -0
  27. package/docs/interfaces.md +470 -0
  28. package/package.json +37 -0
  29. package/scripts/http-smoke-test.sh +100 -0
  30. package/src/active-session-registry.ts +205 -0
  31. package/src/auth/pairing.ts +30 -0
  32. package/src/auth/tokens.ts +30 -0
  33. package/src/cli-runner.cjs +15 -0
  34. package/src/cli.ts +254 -0
  35. package/src/config.ts +26 -0
  36. package/src/extension/index.ts +422 -0
  37. package/src/index.ts +16 -0
  38. package/src/lock.ts +26 -0
  39. package/src/pairing-link.ts +15 -0
  40. package/src/paths.ts +21 -0
  41. package/src/persistence/daemon-store.ts +56 -0
  42. package/src/persistence/schema.ts +21 -0
  43. package/src/qr.ts +23 -0
  44. package/src/runtime-status.ts +116 -0
  45. package/src/server/http.ts +529 -0
  46. package/src/session-index.ts +9 -0
  47. package/src/session-transcript.ts +34 -0
  48. package/src/transcript-message.ts +76 -0
  49. package/src/transcript-pagination.ts +68 -0
  50. package/src/transcript-preview.ts +102 -0
  51. package/src/transcript-stream.ts +89 -0
  52. package/src/types.ts +116 -0
  53. package/tests/active-session-registry.test.ts +170 -0
  54. package/tests/auth.test.ts +18 -0
  55. package/tests/cli.test.ts +361 -0
  56. package/tests/config.test.ts +35 -0
  57. package/tests/daemon-store.test.ts +54 -0
  58. package/tests/extension.test.ts +617 -0
  59. package/tests/lock.test.ts +36 -0
  60. package/tests/pairing-link.test.ts +26 -0
  61. package/tests/pairing.test.ts +26 -0
  62. package/tests/paths.test.ts +29 -0
  63. package/tests/qr.test.ts +25 -0
  64. package/tests/schema.test.ts +18 -0
  65. package/tests/server-http.test.ts +932 -0
  66. package/tests/session-index.test.ts +10 -0
  67. package/tests/session-transcript.test.ts +75 -0
  68. package/tests/transcript-pagination.test.ts +54 -0
  69. package/tests/transcript-preview.test.ts +64 -0
  70. package/tests/transcript-stream.test.ts +103 -0
  71. package/tsconfig.json +17 -0
@@ -0,0 +1,361 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, it } from "vitest";
5
+ import { main, readInstalledPiVersion, type CliDependencies } from "../src/cli.js";
6
+
7
+ describe("daemon CLI", () => {
8
+ it("reads the installed Pi package version", async () => {
9
+ await expect(readInstalledPiVersion()).resolves.toMatch(/^\d+\.\d+\.\d+/);
10
+ });
11
+ it("starts the HTTP server with state dir and bind overrides", async () => {
12
+ const lines: string[] = [];
13
+ const calls: unknown[] = [];
14
+ const deps: CliDependencies = {
15
+ getStateDir: () => "/tmp/default-state",
16
+ ensureStateDir: async (stateDir) => {
17
+ calls.push({ ensureStateDir: stateDir });
18
+ },
19
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:17373" }),
20
+ saveConfig: async (stateDir, config) => {
21
+ calls.push({ saveConfig: stateDir, config });
22
+ },
23
+ acquireLock: async (stateDir) => {
24
+ calls.push({ acquireLock: stateDir });
25
+ return { path: `${stateDir}/daemon.lock`, release: async () => calls.push({ releaseLock: true }) };
26
+ },
27
+ startServer: async (options) => {
28
+ calls.push({ startServer: options });
29
+ return { address: "127.0.0.1:9999", close: async () => undefined };
30
+ },
31
+ openStore: () => ({
32
+ close: () => undefined,
33
+ authenticateToken: async () => false,
34
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
35
+ claimPairingCode: async () => undefined,
36
+ }),
37
+ getPiVersion: async () => {
38
+ calls.push({ getPiVersion: true });
39
+ return "0.74.0-test";
40
+ },
41
+ waitForShutdown: async () => undefined,
42
+ removeFile: async (path) => {
43
+ calls.push({ removeFile: path });
44
+ },
45
+ writeLine: (line) => lines.push(line),
46
+ env: { PI_REMOTE_CONTROL_DEV_TOKEN: "test-token" },
47
+ };
48
+
49
+ const code = await main(["start", "--state-dir", "/tmp/state", "--bind", "127.0.0.1:0"], deps);
50
+
51
+ expect(code).toBe(0);
52
+ expect(calls).toEqual([
53
+ { ensureStateDir: "/tmp/state" },
54
+ { acquireLock: "/tmp/state" },
55
+ { saveConfig: "/tmp/state", config: { bindAddress: "127.0.0.1:17373" } },
56
+ { getPiVersion: true },
57
+ {
58
+ startServer: expect.objectContaining({
59
+ stateDir: "/tmp/state",
60
+ config: { bindAddress: "127.0.0.1:0" },
61
+ piVersion: "0.74.0-test",
62
+ }),
63
+ },
64
+ { releaseLock: true },
65
+ ]);
66
+ expect(lines).toContain("pi-remote-control listening on http://127.0.0.1:9999");
67
+ });
68
+
69
+ it("creates config.json on first real start", async () => {
70
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-cli-"));
71
+ try {
72
+ const code = await main(["start", "--state-dir", root], {
73
+ openStore: () => ({
74
+ close: () => undefined,
75
+ authenticateToken: async () => false,
76
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
77
+ claimPairingCode: async () => undefined,
78
+ }),
79
+ startServer: async () => ({ address: "127.0.0.1:9999", close: async () => undefined }),
80
+ waitForShutdown: async () => undefined,
81
+ writeLine: () => undefined,
82
+ });
83
+
84
+ expect(code).toBe(0);
85
+ await expect(readFile(join(root, "config.json"), "utf8")).resolves.toContain("127.0.0.1:17373");
86
+ } finally {
87
+ await rm(root, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it("writes default config when config file is missing", async () => {
92
+ const calls: unknown[] = [];
93
+ const missing = new Error("missing") as Error & { code: string };
94
+ missing.code = "ENOENT";
95
+
96
+ const code = await main(["start"], {
97
+ getStateDir: () => "/tmp/state",
98
+ ensureStateDir: async () => undefined,
99
+ acquireLock: async () => ({ path: "/tmp/state/daemon.lock", release: async () => calls.push({ releaseLock: true }) }),
100
+ loadConfig: async () => {
101
+ throw missing;
102
+ },
103
+ saveConfig: async (stateDir, config) => calls.push({ saveConfig: stateDir, config }),
104
+ openStore: () => ({
105
+ close: () => calls.push({ closeStore: true }),
106
+ authenticateToken: async () => false,
107
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
108
+ claimPairingCode: async () => undefined,
109
+ }),
110
+ startServer: async () => ({ address: "127.0.0.1:9999", close: async () => undefined }),
111
+ removeFile: async () => undefined,
112
+ waitForShutdown: async () => undefined,
113
+ writeLine: () => undefined,
114
+ });
115
+
116
+ expect(code).toBe(0);
117
+ expect(calls).toContainEqual({
118
+ saveConfig: "/tmp/state",
119
+ config: { bindAddress: "127.0.0.1:17373" },
120
+ });
121
+ });
122
+
123
+ it("does not start when daemon lock is already held", async () => {
124
+ const lines: string[] = [];
125
+ const calls: unknown[] = [];
126
+ const code = await main(["start"], {
127
+ getStateDir: () => "/tmp/state",
128
+ ensureStateDir: async () => undefined,
129
+ acquireLock: async (stateDir) => {
130
+ calls.push({ acquireLock: stateDir });
131
+ return undefined;
132
+ },
133
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:0" }),
134
+ startServer: async () => {
135
+ throw new Error("should not start");
136
+ },
137
+ writeLine: (line) => lines.push(line),
138
+ });
139
+
140
+ expect(code).toBe(1);
141
+ expect(calls).toEqual([{ acquireLock: "/tmp/state" }]);
142
+ expect(lines).toEqual(["pi-remote-control is already running"]);
143
+ });
144
+
145
+ it("starts with persistent store authentication and pairing", async () => {
146
+ let startOptions: Parameters<NonNullable<CliDependencies["startServer"]>>[0] | undefined;
147
+ const calls: unknown[] = [];
148
+ const code = await main(["start"], {
149
+ getStateDir: () => "/tmp/state",
150
+ ensureStateDir: async () => undefined,
151
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:0" }),
152
+ saveConfig: async (stateDir, config) => calls.push({ saveConfig: stateDir, config }),
153
+ acquireLock: async (stateDir) => {
154
+ calls.push({ acquireLock: stateDir });
155
+ return { path: `${stateDir}/daemon.lock`, release: async () => calls.push({ releaseLock: true }) };
156
+ },
157
+ openStore: (stateDir) => {
158
+ calls.push({ openStore: stateDir });
159
+ return {
160
+ close: () => calls.push({ closeStore: true }),
161
+ authenticateToken: async (token) => token === "stored-token",
162
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
163
+ claimPairingCode: async () => ({ deviceId: "dev_1", token: "prd_1", daemonName: "pi-remote-control" }),
164
+ };
165
+ },
166
+ startServer: async (options) => {
167
+ startOptions = options;
168
+ return { address: "127.0.0.1:9999", close: async () => undefined };
169
+ },
170
+ removeFile: async () => undefined,
171
+ waitForShutdown: async () => undefined,
172
+ writeLine: () => undefined,
173
+ });
174
+
175
+ expect(code).toBe(0);
176
+ expect(calls).toEqual([
177
+ { acquireLock: "/tmp/state" },
178
+ { saveConfig: "/tmp/state", config: { bindAddress: "127.0.0.1:0" } },
179
+ { openStore: "/tmp/state" },
180
+ { closeStore: true },
181
+ { releaseLock: true },
182
+ ]);
183
+ await expect(startOptions?.authenticateToken?.("stored-token")).resolves.toBe(true);
184
+ await expect(startOptions?.pairService?.createPairingCode?.()).resolves.toEqual({
185
+ pairCode: "123456",
186
+ expiresAt: "2026-05-09T00:01:00.000Z",
187
+ });
188
+ });
189
+
190
+
191
+ it("reports stopped status when no lock file exists", async () => {
192
+ const lines: string[] = [];
193
+ const deps: CliDependencies = {
194
+ getStateDir: () => "/tmp/state",
195
+ readTextFile: async () => {
196
+ const error = new Error("missing") as Error & { code: string };
197
+ error.code = "ENOENT";
198
+ throw error;
199
+ },
200
+ writeLine: (line) => lines.push(line),
201
+ };
202
+
203
+ const code = await main(["status"], deps);
204
+
205
+ expect(code).toBe(1);
206
+ expect(lines).toEqual(["pi-remote-control is stopped"]);
207
+ });
208
+
209
+ it("removes stale lock when status finds a missing process", async () => {
210
+ const lines: string[] = [];
211
+ const calls: unknown[] = [];
212
+ const code = await main(["status", "--state-dir", "/tmp/state"], {
213
+ readTextFile: async () => "1234\n",
214
+ isProcessRunning: () => false,
215
+ removeFile: async (path) => calls.push({ removeFile: path }),
216
+ writeLine: (line) => lines.push(line),
217
+ });
218
+
219
+ expect(code).toBe(1);
220
+ expect(calls).toEqual([{ removeFile: "/tmp/state/daemon.lock" }]);
221
+ expect(lines).toEqual(["pi-remote-control is stopped (stale lock removed, pid 1234)"]);
222
+ });
223
+
224
+ it("reports running status from lock file", async () => {
225
+ const lines: string[] = [];
226
+ const code = await main(["status", "--state-dir", "/tmp/state"], {
227
+ readTextFile: async (path) => {
228
+ expect(path).toBe("/tmp/state/daemon.lock");
229
+ return "1234\n";
230
+ },
231
+ isProcessRunning: (pid) => pid === 1234,
232
+ writeLine: (line) => lines.push(line),
233
+ });
234
+
235
+ expect(code).toBe(0);
236
+ expect(lines).toEqual(["pi-remote-control is running (pid 1234)"]);
237
+ });
238
+
239
+ it("reports stopped when stopping without a lock file", async () => {
240
+ const lines: string[] = [];
241
+ const code = await main(["stop", "--state-dir", "/tmp/state"], {
242
+ readTextFile: async () => {
243
+ const error = new Error("missing") as Error & { code: string };
244
+ error.code = "ENOENT";
245
+ throw error;
246
+ },
247
+ writeLine: (line) => lines.push(line),
248
+ });
249
+
250
+ expect(code).toBe(1);
251
+ expect(lines).toEqual(["pi-remote-control is not running"]);
252
+ });
253
+
254
+ it("removes stale lock when stopping a missing process", async () => {
255
+ const lines: string[] = [];
256
+ const calls: unknown[] = [];
257
+ const code = await main(["stop", "--state-dir", "/tmp/state"], {
258
+ readTextFile: async () => "1234\n",
259
+ sendSignal: () => {
260
+ const error = new Error("missing process") as Error & { code: string };
261
+ error.code = "ESRCH";
262
+ throw error;
263
+ },
264
+ removeFile: async (path) => calls.push({ removeFile: path }),
265
+ writeLine: (line) => lines.push(line),
266
+ });
267
+
268
+ expect(code).toBe(0);
269
+ expect(calls).toEqual([{ removeFile: "/tmp/state/daemon.lock" }]);
270
+ expect(lines).toEqual(["pi-remote-control stale lock removed (pid 1234)"]);
271
+ });
272
+
273
+ it("stops a running daemon from its lock file", async () => {
274
+ const lines: string[] = [];
275
+ const calls: unknown[] = [];
276
+ const code = await main(["stop", "--state-dir", "/tmp/state"], {
277
+ readTextFile: async () => "1234\n",
278
+ sendSignal: (pid, signal) => {
279
+ calls.push({ sendSignal: pid, signal });
280
+ },
281
+ removeFile: async (path) => {
282
+ calls.push({ removeFile: path });
283
+ },
284
+ writeLine: (line) => lines.push(line),
285
+ });
286
+
287
+ expect(code).toBe(0);
288
+ expect(calls).toEqual([
289
+ { sendSignal: 1234, signal: "SIGTERM" },
290
+ { removeFile: "/tmp/state/daemon.lock" },
291
+ ]);
292
+ expect(lines).toEqual(["pi-remote-control stop requested (pid 1234)"]);
293
+ });
294
+
295
+ it("prints a helpful pairing setup message when advertisedBaseUrl is missing", async () => {
296
+ const lines: string[] = [];
297
+ const calls: unknown[] = [];
298
+ const code = await main(["pair", "--state-dir", "/tmp/state"], {
299
+ ensureStateDir: async (stateDir) => calls.push({ ensureStateDir: stateDir }),
300
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:17373" }),
301
+ openStore: () => {
302
+ throw new Error("store should not open without advertisedBaseUrl");
303
+ },
304
+ writeLine: (line) => lines.push(line),
305
+ });
306
+
307
+ expect(code).toBe(1);
308
+ expect(calls).toEqual([{ ensureStateDir: "/tmp/state" }]);
309
+ expect(lines).toEqual([
310
+ "advertisedBaseUrl is required for QR pairing.",
311
+ "Set ~/.pi/remote-control/config.json or PI_REMOTE_CONTROL_ADVERTISED_BASE_URL to an iOS-reachable URL.",
312
+ "Example: https://macbook.tailnet.ts.net:17373",
313
+ ]);
314
+ });
315
+
316
+ it("uses PI_REMOTE_CONTROL_URL as pairing URL fallback", async () => {
317
+ const lines: string[] = [];
318
+ const code = await main(["pair", "--state-dir", "/tmp/state"], {
319
+ env: { PI_REMOTE_CONTROL_URL: "https://macbook.tailnet.ts.net:17373" },
320
+ ensureStateDir: async () => undefined,
321
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:17373" }),
322
+ openStore: () => ({
323
+ close: () => undefined,
324
+ authenticateToken: async () => false,
325
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
326
+ claimPairingCode: async () => undefined,
327
+ }),
328
+ writeLine: (line) => lines.push(line),
329
+ });
330
+
331
+ expect(code).toBe(0);
332
+ expect(lines[0]).toBe("Scan with Pi iOS app:");
333
+ expect(lines[1]).toContain("█");
334
+ expect(lines.at(-1)).toBe("Expires at: 2026-05-09T00:01:00.000Z");
335
+ });
336
+
337
+ it("creates and prints local TUI pairing QR and expiry", async () => {
338
+ const lines: string[] = [];
339
+ const calls: unknown[] = [];
340
+ const code = await main(["pair", "--state-dir", "/tmp/state"], {
341
+ ensureStateDir: async (stateDir) => calls.push({ ensureStateDir: stateDir }),
342
+ loadConfig: async () => ({ bindAddress: "127.0.0.1:17373", advertisedBaseUrl: "https://macbook.tailnet.ts.net:17373" }),
343
+ openStore: (stateDir) => {
344
+ calls.push({ openStore: stateDir });
345
+ return {
346
+ close: () => calls.push({ closeStore: true }),
347
+ authenticateToken: async () => false,
348
+ createPairingCode: async () => ({ pairCode: "123456", expiresAt: "2026-05-09T00:01:00.000Z" }),
349
+ claimPairingCode: async () => undefined,
350
+ };
351
+ },
352
+ writeLine: (line) => lines.push(line),
353
+ });
354
+
355
+ expect(code).toBe(0);
356
+ expect(calls).toEqual([{ ensureStateDir: "/tmp/state" }, { openStore: "/tmp/state" }, { closeStore: true }]);
357
+ expect(lines[0]).toBe("Scan with Pi iOS app:");
358
+ expect(lines[1]).toContain("█");
359
+ expect(lines.slice(2)).toEqual(["Expires at: 2026-05-09T00:01:00.000Z"]);
360
+ });
361
+ });
@@ -0,0 +1,35 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, it } from "vitest";
5
+ import { DEFAULT_BIND_ADDRESS, defaultDaemonConfig, loadDaemonConfig, saveDaemonConfig } from "../src/config.js";
6
+
7
+ describe("daemon config", () => {
8
+ it("has a safe localhost default", () => {
9
+ expect(defaultDaemonConfig()).toEqual({ bindAddress: DEFAULT_BIND_ADDRESS });
10
+ });
11
+
12
+ it("loads defaults when config.json does not exist", async () => {
13
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-config-"));
14
+ try {
15
+ await expect(loadDaemonConfig(root)).resolves.toEqual({ bindAddress: DEFAULT_BIND_ADDRESS });
16
+ } finally {
17
+ await rm(root, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it("round-trips saved config", async () => {
22
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-config-"));
23
+ const config = {
24
+ bindAddress: "100.64.0.1:17373",
25
+ advertisedBaseUrl: "https://macbook.tailnet.ts.net:17373",
26
+ };
27
+
28
+ try {
29
+ await saveDaemonConfig(root, config);
30
+ await expect(loadDaemonConfig(root)).resolves.toEqual(config);
31
+ } finally {
32
+ await rm(root, { recursive: true, force: true });
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,54 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, it } from "vitest";
5
+ import { openDaemonStore } from "../src/persistence/daemon-store.js";
6
+
7
+ describe("daemon store", () => {
8
+ it("rejects duplicate pairing claims", async () => {
9
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-store-"));
10
+ try {
11
+ const store = openDaemonStore(root);
12
+ const pair = await store.createPairingCode(new Date("2026-05-09T00:00:00.000Z"), 60_000);
13
+
14
+ await expect(store.claimPairingCode(pair.pairCode, "iPhone", new Date("2026-05-09T00:00:30.000Z"))).resolves.toBeDefined();
15
+ await expect(store.claimPairingCode(pair.pairCode, "iPad", new Date("2026-05-09T00:00:40.000Z"))).resolves.toBeUndefined();
16
+ store.close();
17
+ } finally {
18
+ await rm(root, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ it("authenticates claimed device tokens", async () => {
23
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-store-"));
24
+ try {
25
+ const store = openDaemonStore(root);
26
+ const pair = await store.createPairingCode(new Date("2026-05-09T00:00:00.000Z"), 60_000);
27
+ const claimed = await store.claimPairingCode(pair.pairCode, "iPhone", new Date("2026-05-09T00:00:30.000Z"));
28
+
29
+ await expect(store.authenticateToken(claimed!.token)).resolves.toBe(true);
30
+ await expect(store.authenticateToken("prd_wrong")).resolves.toBe(false);
31
+ store.close();
32
+ } finally {
33
+ await rm(root, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ it("creates and persists pairing codes", async () => {
38
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-store-"));
39
+ try {
40
+ const store = openDaemonStore(root);
41
+ const pair = await store.createPairingCode(new Date("2026-05-09T00:00:00.000Z"), 60_000);
42
+ store.close();
43
+
44
+ const reopened = openDaemonStore(root);
45
+ const claimed = await reopened.claimPairingCode(pair.pairCode, "iPhone", new Date("2026-05-09T00:00:30.000Z"));
46
+ reopened.close();
47
+
48
+ expect(pair).toMatchObject({ pairCode: expect.stringMatching(/^\d{6}$/), expiresAt: "2026-05-09T00:01:00.000Z" });
49
+ expect(claimed).toMatchObject({ deviceId: expect.stringMatching(/^dev_/), token: expect.stringMatching(/^prd_/), daemonName: "pi-remote-control" });
50
+ } finally {
51
+ await rm(root, { recursive: true, force: true });
52
+ }
53
+ });
54
+ });