miki-moni 0.3.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/README.zh-CN.md +275 -0
  4. package/README.zh-TW.md +275 -0
  5. package/bin/miki.mjs +49 -0
  6. package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
  7. package/dist/web/assets/index--89DkyV1.css +1 -0
  8. package/dist/web/assets/index-CyPlxvOn.js +64 -0
  9. package/dist/web/index.html +20 -0
  10. package/dist/web/pair-info.html +138 -0
  11. package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
  12. package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
  13. package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
  14. package/dist/web-phone/index.html +20 -0
  15. package/hooks/miki-emit.ps1 +56 -0
  16. package/package.json +89 -0
  17. package/shared/i18n.ts +915 -0
  18. package/src/cli/i18n-cli.ts +149 -0
  19. package/src/cli/miki.ts +168 -0
  20. package/src/cli/pair.ts +534 -0
  21. package/src/cli/prompt.ts +6 -0
  22. package/src/cli/pushable-iter.ts +45 -0
  23. package/src/cli/setup-self-host.ts +292 -0
  24. package/src/cli/setup-wizard.ts +130 -0
  25. package/src/cli/wrap.ts +742 -0
  26. package/src/config.ts +121 -0
  27. package/src/crypto.ts +66 -0
  28. package/src/data-dir.ts +31 -0
  29. package/src/ext-registry.ts +47 -0
  30. package/src/hook-handler.ts +86 -0
  31. package/src/index.ts +279 -0
  32. package/src/install-hooks.ts +107 -0
  33. package/src/notifier.ts +21 -0
  34. package/src/pairing.ts +100 -0
  35. package/src/protocol-ext.ts +46 -0
  36. package/src/relay-client.ts +468 -0
  37. package/src/relay-protocol.ts +57 -0
  38. package/src/server.ts +1134 -0
  39. package/src/session-resolver.ts +437 -0
  40. package/src/session-store.ts +131 -0
  41. package/src/types.ts +33 -0
  42. package/src/vscode-bridge.ts +407 -0
  43. package/src/wrap-process.ts +183 -0
  44. package/tools/tray.ps1 +286 -0
  45. package/worker/package.json +24 -0
  46. package/worker/src/daemon-relay.ts +348 -0
  47. package/worker/src/env.ts +11 -0
  48. package/worker/src/handshake.ts +63 -0
  49. package/worker/src/index.ts +81 -0
  50. package/worker/src/pairing-code.ts +39 -0
  51. package/worker/src/pairing-coordinator.ts +145 -0
  52. package/worker/wrangler-selfhost.toml +36 -0
  53. package/worker/wrangler.toml +29 -0
@@ -0,0 +1,534 @@
1
+ import WebSocket from "ws";
2
+ import qrcode from "qrcode-terminal";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { promises as fsp } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createRequire } from "node:module";
9
+ import { createHash } from "node:crypto";
10
+ import { spawn } from "node:child_process";
11
+ import { PORT_FILE } from "../data-dir.js";
12
+ import {
13
+ loadOrInitConfig,
14
+ saveConfig,
15
+ addPairedPeer,
16
+ removePairedPeer,
17
+ findPeerById,
18
+ type Config,
19
+ type PairedPeer,
20
+ } from "../config.js";
21
+ import {
22
+ fromBase64,
23
+ toBase64,
24
+ deriveSharedSecret,
25
+ sign as signMsg,
26
+ } from "../crypto.js";
27
+ import {
28
+ generateNewPairingToken,
29
+ pairingQrPayload,
30
+ computePeerId,
31
+ PAIRING_TOKEN_TTL_MS,
32
+ } from "../pairing.js";
33
+ import { CONFIG_FILE as CONFIG_PATH } from "../data-dir.js";
34
+
35
+ function usage(): never {
36
+ console.error("Usage:");
37
+ console.error(" pnpm pair Show the persistent QR (default).");
38
+ console.error(" Auto-creates the token on first run.");
39
+ console.error(" pnpm pair --rotate Regenerate the persistent token (invalidates the old QR).");
40
+ console.error(" pnpm pair --new One-shot ephemeral token (10 min TTL).");
41
+ console.error(" pnpm pair --list List paired phones.");
42
+ console.error(" pnpm pair --revoke <peer_id> Remove a phone from local config + relay.");
43
+ console.error("");
44
+ console.error("Optional flags for --new and --rotate:");
45
+ console.error(" --worker-url=<wss://...> Override relay URL (otherwise from config).");
46
+ process.exit(1);
47
+ }
48
+
49
+ function parseArgs(argv: string[]): {
50
+ cmd: "show" | "rotate" | "new" | "list" | "revoke";
51
+ workerUrl?: string;
52
+ peerId?: string;
53
+ name?: string;
54
+ } {
55
+ const args = argv.slice(2);
56
+ if (args.includes("--help") || args.includes("-h")) usage();
57
+ if (args.includes("--list")) return { cmd: "list" };
58
+ const revIdx = args.indexOf("--revoke");
59
+ if (revIdx >= 0) {
60
+ const peerId = args[revIdx + 1];
61
+ if (!peerId) usage();
62
+ return { cmd: "revoke", peerId };
63
+ }
64
+ const workerUrl = args.find((a) => a.startsWith("--worker-url="))?.split("=")[1];
65
+ const name = args.find((a) => a.startsWith("--name="))?.split("=")[1];
66
+ if (args.includes("--rotate")) return { cmd: "rotate", workerUrl, name };
67
+ if (args.includes("--new")) return { cmd: "new", workerUrl, name };
68
+ return { cmd: "show", workerUrl, name };
69
+ }
70
+
71
+ async function cmdList(cfg: Config): Promise<void> {
72
+ if (cfg.paired_peers.length === 0) {
73
+ console.log("(no paired peers)");
74
+ return;
75
+ }
76
+ for (const p of cfg.paired_peers) {
77
+ const paired = new Date(p.paired_at).toISOString();
78
+ console.log(
79
+ `${p.peer_id} ${p.peer_name} paired=${paired} last_seen=${
80
+ p.last_seen_at ? new Date(p.last_seen_at).toISOString() : "never"
81
+ }`,
82
+ );
83
+ }
84
+ }
85
+
86
+ async function cmdRevoke(cfg: Config, peerId: string): Promise<void> {
87
+ const peer = findPeerById(cfg, peerId);
88
+ if (!peer) {
89
+ console.error(`No peer with id ${peerId}`);
90
+ process.exit(1);
91
+ }
92
+ // 1. Local config
93
+ const next = removePairedPeer(cfg, peerId);
94
+ await saveConfig(CONFIG_PATH, next);
95
+ console.log(`[ok] Removed ${peerId} from local config`);
96
+
97
+ // 2. Relay-side revoke (best-effort). Requires worker URL + peer's signing
98
+ // pubkey — older configs may lack the latter, in which case the relay
99
+ // still has the phone's signing pubkey but we can't address it.
100
+ if (!peer.peer_sign_pubkey) {
101
+ console.warn(
102
+ `[warn] peer ${peerId} has no peer_sign_pubkey in config (paired before this field was added).\n` +
103
+ ` Local config cleaned; the relay's paired_phones entry will remain until you reset the daemon\n` +
104
+ ` (pnpm pair --reset, not yet implemented) or the phone calls revoke_self.`,
105
+ );
106
+ return;
107
+ }
108
+ const workerUrl = cfg.remote?.worker_url;
109
+ if (!workerUrl) {
110
+ console.warn(`[warn] no remote.worker_url in config — skipping relay revoke`);
111
+ return;
112
+ }
113
+ try {
114
+ await pushRevokeToRelay(workerUrl, cfg, peer.peer_sign_pubkey);
115
+ console.log(`[ok] Relay acknowledged revoke for ${peerId}`);
116
+ } catch (e: unknown) {
117
+ console.warn(`[warn] relay revoke failed: ${(e as Error).message}. Local config is clean; retry later if needed.`);
118
+ }
119
+ }
120
+
121
+ /** Open a daemon WS to relay, complete challenge-response, send revoke_phone, close. */
122
+ async function pushRevokeToRelay(
123
+ workerUrl: string,
124
+ cfg: Config,
125
+ phone_sign_pubkey_b64: string,
126
+ ): Promise<void> {
127
+ const signPriv = fromBase64(cfg.device.signing_privkey);
128
+ const wsUrl = workerUrl.replace(/\/$/, "").includes("/v1/")
129
+ ? workerUrl.replace(/\/$/, "")
130
+ : `${workerUrl.replace(/\/$/, "")}/v1/daemon`;
131
+
132
+ await new Promise<void>((resolve, reject) => {
133
+ const ws = new WebSocket(wsUrl, {
134
+ headers: { "X-Daemon-Pubkey": cfg.device.signing_pubkey },
135
+ });
136
+ const timeout = setTimeout(() => {
137
+ ws.close();
138
+ reject(new Error("revoke timed out (10s)"));
139
+ }, 10_000);
140
+ let ready = false;
141
+ ws.on("message", (raw) => {
142
+ const msg = JSON.parse(raw.toString()) as { type: string; nonce?: string; issued_at_ms?: number };
143
+ if (!ready && msg.type === "challenge") {
144
+ const nonce = fromBase64(msg.nonce!);
145
+ const sigMsg = buildChallengeMessage(nonce, msg.issued_at_ms!);
146
+ ws.send(JSON.stringify({ type: "challenge_response", sig: toBase64(signMsg(sigMsg, signPriv)) }));
147
+ return;
148
+ }
149
+ if (!ready && msg.type === "ready") {
150
+ ready = true;
151
+ ws.send(JSON.stringify({ type: "revoke_phone", phone_pubkey_b64: phone_sign_pubkey_b64 }));
152
+ // Relay processes synchronously and broadcasts phone_revoked back to us;
153
+ // we don't need to wait for that — closing right after send is safe.
154
+ setTimeout(() => { ws.close(); clearTimeout(timeout); resolve(); }, 200);
155
+ }
156
+ });
157
+ ws.on("error", (e) => { clearTimeout(timeout); reject(e); });
158
+ ws.on("close", () => { if (!ready) { clearTimeout(timeout); reject(new Error("ws closed before ready")); } });
159
+ });
160
+ }
161
+
162
+ /** Detached daemon spawn so the user doesn't need a second terminal. If a daemon
163
+ * is already running, the second one fails fast on port/lock conflict — harmless. */
164
+ function spawnDaemonInBackground(): void {
165
+ try {
166
+ const cwd = process.cwd();
167
+ const child = spawn("pnpm", ["start"], {
168
+ cwd,
169
+ detached: true,
170
+ stdio: "ignore",
171
+ windowsHide: true,
172
+ shell: process.platform === "win32", // pnpm.cmd lookup needs shell on Windows
173
+ });
174
+ child.unref();
175
+ console.log(`[ok] Daemon spawned in background (pid ${child.pid}). It will connect to the relay shortly.`);
176
+ console.log(` If it doesn't, open another terminal and run \`pnpm start\` manually.`);
177
+ } catch (e: unknown) {
178
+ console.warn(`[warn] Could not auto-spawn daemon (${(e as Error).message}). Run \`pnpm start\` manually.`);
179
+ }
180
+ }
181
+
182
+ /** Bytes the daemon must sign in response to a challenge:
183
+ * nonce (32B) ++ issued_at_ms (8B big-endian). Matches worker's
184
+ * handshake.ts:buildChallengeMessage exactly. */
185
+ function buildChallengeMessage(nonce: Uint8Array, issuedAtMs: number): Uint8Array {
186
+ const out = new Uint8Array(nonce.length + 8);
187
+ out.set(nonce, 0);
188
+ new DataView(out.buffer, nonce.length, 8).setBigUint64(0, BigInt(issuedAtMs), false);
189
+ return out;
190
+ }
191
+
192
+ async function cmdNew(cfg: Config, workerUrlArg?: string): Promise<void> {
193
+ const workerUrl = workerUrlArg ?? cfg.remote?.worker_url;
194
+ if (!workerUrl) {
195
+ console.error("Missing worker URL.");
196
+ console.error("Either set `remote.worker_url` in ~/.miki-moni/config.json, or pass:");
197
+ console.error(" pnpm pair --new --worker-url=wss://relay.f1telemetrystationpro.org");
198
+ process.exit(1);
199
+ }
200
+
201
+ // Daemon-side Ed25519 (signing) + X25519 (encryption) keys from config.
202
+ const signPriv = fromBase64(cfg.device.signing_privkey);
203
+ const signPub = fromBase64(cfg.device.signing_pubkey);
204
+ const encPriv = fromBase64(cfg.device.privkey);
205
+ const encPub = fromBase64(cfg.device.pubkey);
206
+ const daemonId = createHash("sha256").update(signPub).digest("hex").slice(0, 32);
207
+
208
+ // 16-char Crockford base32 token shown in QR + manual entry.
209
+ const pairingToken = generateNewPairingToken();
210
+ const qrPayload = pairingQrPayload({
211
+ worker_url: workerUrl,
212
+ pairing_token: pairingToken,
213
+ daemon_pubkey: cfg.device.pubkey,
214
+ device_name: cfg.device.name,
215
+ });
216
+
217
+ // Daemon-side WS hits /v1/daemon endpoint. Append path if caller passed bare host.
218
+ const wsUrl = workerUrl.replace(/\/$/, "").includes("/v1/")
219
+ ? workerUrl.replace(/\/$/, "")
220
+ : `${workerUrl.replace(/\/$/, "")}/v1/daemon`;
221
+
222
+ console.log(`\nConnecting to ${wsUrl} ...`);
223
+
224
+ await new Promise<void>((resolve, reject) => {
225
+ const ws = new WebSocket(wsUrl, {
226
+ headers: {
227
+ "X-Daemon-Pubkey": cfg.device.signing_pubkey,
228
+ "X-Daemon-Enc-Pubkey": cfg.device.pubkey, // X25519, for phone ECDH (must be sent so DO stores it)
229
+ "X-Daemon-Id": daemonId,
230
+ },
231
+ });
232
+
233
+ let ready = false;
234
+ let printedQr = false;
235
+
236
+ const timeout = setTimeout(() => {
237
+ ws.close();
238
+ reject(new Error(`Pairing timed out after ${PAIRING_TOKEN_TTL_MS / 60_000} min`));
239
+ }, PAIRING_TOKEN_TTL_MS);
240
+
241
+ ws.on("open", () => { /* wait for challenge */ });
242
+
243
+ ws.on("message", async (raw) => {
244
+ let msg: any;
245
+ try {
246
+ msg = JSON.parse(raw.toString());
247
+ } catch {
248
+ return;
249
+ }
250
+
251
+ // --- Challenge-response handshake ---
252
+ if (!ready && msg.type === "challenge") {
253
+ const nonce = fromBase64(msg.nonce);
254
+ const sigMsg = buildChallengeMessage(nonce, msg.issued_at_ms);
255
+ const sig = signMsg(sigMsg, signPriv);
256
+ ws.send(JSON.stringify({ type: "challenge_response", sig: toBase64(sig) }));
257
+ return;
258
+ }
259
+ if (!ready && msg.type === "ready") {
260
+ ready = true;
261
+ // Register this pairing token with the coordinator so the phone can claim it.
262
+ ws.send(JSON.stringify({ type: "register_pairing", token: pairingToken }));
263
+ if (!printedQr) {
264
+ printedQr = true;
265
+ console.log("Ready. Scan this QR with your phone:\n");
266
+ qrcode.generate(qrPayload, { small: true });
267
+ console.log("\nOr type this 16-char code (case-insensitive, dashes optional):\n");
268
+ const grouped = pairingToken.match(/.{1,4}/g)?.join("-") ?? pairingToken;
269
+ console.log(` ${grouped}\n`);
270
+ console.log(`Pairing URL: ${qrPayload}`);
271
+ console.log(`Token expires in ${PAIRING_TOKEN_TTL_MS / 60_000} min.\n`);
272
+ console.log("Waiting for phone to scan and complete pairing...");
273
+ }
274
+ return;
275
+ }
276
+
277
+ // --- Phone offer arrives via DO ---
278
+ if (ready && msg.type === "pair_offer") {
279
+ const phonePubB64 = msg.phone_pubkey as string;
280
+ const phoneSignPubB64 = msg.phone_sign_pubkey as string | undefined;
281
+ if (typeof phonePubB64 !== "string") {
282
+ console.warn("pair_offer missing phone_pubkey, ignoring");
283
+ return;
284
+ }
285
+ const phonePub = fromBase64(phonePubB64);
286
+ const sharedSecret = deriveSharedSecret(encPriv, phonePub);
287
+ const peer: PairedPeer = {
288
+ peer_id: computePeerId(phonePubB64),
289
+ peer_name: typeof msg.phone_name === "string" ? msg.phone_name : "phone",
290
+ peer_pubkey: phonePubB64,
291
+ peer_sign_pubkey: typeof phoneSignPubB64 === "string" ? phoneSignPubB64 : undefined,
292
+ shared_secret: toBase64(sharedSecret),
293
+ paired_at: Date.now(),
294
+ last_seen_at: null,
295
+ };
296
+
297
+ // Acknowledge to phone (worker DO will route this back).
298
+ ws.send(JSON.stringify({ type: "pair_ack", daemon_id: daemonId }));
299
+
300
+ // Persist.
301
+ const updated = addPairedPeer(
302
+ { ...cfg, remote: { worker_url: workerUrl } },
303
+ peer,
304
+ );
305
+ await saveConfig(CONFIG_PATH, updated);
306
+
307
+ console.log(`\n[ok] Paired ${peer.peer_name} (id=${peer.peer_id})`);
308
+ clearTimeout(timeout);
309
+ ws.close();
310
+ // Fire-and-forget background daemon spawn so the phone sees sessions
311
+ // without the user having to open another terminal.
312
+ spawnDaemonInBackground();
313
+ resolve();
314
+ return;
315
+ }
316
+ });
317
+
318
+ ws.on("close", (code) => {
319
+ if (!ready) {
320
+ clearTimeout(timeout);
321
+ reject(new Error(`WebSocket closed before handshake (code ${code})`));
322
+ }
323
+ // After ready but before pair_offer arrived — caller's timeout handles it.
324
+ });
325
+
326
+ ws.on("error", (err) => {
327
+ clearTimeout(timeout);
328
+ reject(err);
329
+ });
330
+ });
331
+ }
332
+
333
+ // ── Persistent QR commands ──────────────────────────────────────────────────
334
+ //
335
+ // `pnpm pair` (default) just renders the existing persistent QR. First-run
336
+ // creates the token. `pnpm pair --rotate` regenerates it (invalidating any old
337
+ // printed/saved copy). The token lives in config.remote.pair_token and is
338
+ // re-registered with the relay coordinator by RelayClient on every daemon
339
+ // start (with persistent:true), so the QR keeps working across restarts.
340
+
341
+ async function ensurePersistentToken(cfg: Config, workerUrlArg?: string): Promise<Config> {
342
+ const workerUrl = workerUrlArg ?? cfg.remote?.worker_url;
343
+ if (!workerUrl) {
344
+ console.error("Missing worker URL.");
345
+ console.error("Either set `remote.worker_url` in ~/.miki-moni/config.json, or pass:");
346
+ console.error(" pnpm pair --worker-url=wss://relay.f1telemetrystationpro.org");
347
+ process.exit(1);
348
+ }
349
+ if (cfg.remote?.pair_token) return cfg;
350
+ const token = generateNewPairingToken();
351
+ const next: Config = {
352
+ ...cfg,
353
+ remote: { ...(cfg.remote ?? { worker_url: workerUrl }), worker_url: workerUrl, pair_token: token },
354
+ };
355
+ await saveConfig(CONFIG_PATH, next);
356
+ console.log("[ok] Generated a new persistent pair token and saved to config.");
357
+ return next;
358
+ }
359
+
360
+ function printQrAndCode(cfg: Config): void {
361
+ if (!cfg.remote?.pair_token || !cfg.remote.worker_url) {
362
+ console.error("No persistent token in config — this should never happen after ensurePersistentToken().");
363
+ return;
364
+ }
365
+ const qrPayload = pairingQrPayload({
366
+ worker_url: cfg.remote.worker_url,
367
+ pairing_token: cfg.remote.pair_token,
368
+ daemon_pubkey: cfg.device.pubkey,
369
+ device_name: cfg.device.name,
370
+ });
371
+ console.log("\nScan this QR (永久有效,rotate 前都能用):\n");
372
+ qrcode.generate(qrPayload, { small: true });
373
+ console.log("\nOr type this 16-char code (case-insensitive, dashes optional):\n");
374
+ const grouped = cfg.remote.pair_token.match(/.{1,4}/g)?.join("-") ?? cfg.remote.pair_token;
375
+ console.log(` ${grouped}\n`);
376
+ console.log(`Pairing URL: ${qrPayload}`);
377
+ console.log("\nThis token survives daemon restarts and can pair multiple devices.");
378
+ console.log("Rotate when leaked or unused: `pnpm pair --rotate`");
379
+ }
380
+
381
+ async function cmdShow(cfg: Config, workerUrlArg?: string): Promise<void> {
382
+ const next = await ensurePersistentToken(cfg, workerUrlArg);
383
+ printQrAndCode(next);
384
+ if (next.paired_peers.length > 0) {
385
+ console.log(`\nAlready paired (${next.paired_peers.length}):`);
386
+ for (const p of next.paired_peers) {
387
+ const paired = new Date(p.paired_at).toISOString().slice(0, 16).replace("T", " ");
388
+ console.log(` ${p.peer_id} ${p.peer_name} (paired ${paired})`);
389
+ }
390
+ console.log("\nRevoke a device: `pnpm pair --revoke <peer_id>`");
391
+ }
392
+ }
393
+
394
+ // ── Daemon lifecycle helpers (used by cmdRotate to hot-swap the running
395
+ // daemon so the new pair_token gets registered with the relay without
396
+ // a manual restart). Mirrors the spawn dance in wrap.ts:ensureDaemonRunning. ─
397
+
398
+ async function readPortFile(): Promise<number | null> {
399
+ try {
400
+ const raw = await fsp.readFile(PORT_FILE, "utf8");
401
+ const n = parseInt(raw.trim(), 10);
402
+ return Number.isFinite(n) ? n : null;
403
+ } catch { return null; }
404
+ }
405
+
406
+ function pingDaemonHttp(port: number, timeoutMs = 800): Promise<boolean> {
407
+ return new Promise((resolve) => {
408
+ const req = http.get(`http://127.0.0.1:${port}/sessions`, { timeout: timeoutMs }, (res) => {
409
+ res.resume();
410
+ resolve((res.statusCode ?? 0) > 0);
411
+ });
412
+ req.on("error", () => resolve(false));
413
+ req.on("timeout", () => { req.destroy(); resolve(false); });
414
+ });
415
+ }
416
+
417
+ function postAdmin(port: number, route: string): Promise<boolean> {
418
+ return new Promise((resolve) => {
419
+ const req = http.request({
420
+ hostname: "127.0.0.1", port, path: route, method: "POST",
421
+ headers: { "content-length": "0" }, timeout: 2000,
422
+ }, (res) => { res.resume(); resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300); });
423
+ req.on("error", () => resolve(false));
424
+ req.on("timeout", () => { req.destroy(); resolve(false); });
425
+ req.end();
426
+ });
427
+ }
428
+
429
+ async function spawnDetachedDaemon(): Promise<void> {
430
+ // Locate tsx via package.json — same approach as wrap.ts so behaviour stays
431
+ // consistent across pnpm-store layouts. spawning `node <tsx-bin> src/index.ts`
432
+ // avoids Node 24's .cmd-spawn ban and doesn't need npm/pnpm on PATH.
433
+ const here = path.dirname(fileURLToPath(import.meta.url));
434
+ const projectRoot = path.join(here, "..", "..");
435
+ const indexEntry = path.join(here, "..", "index.ts");
436
+ const req = createRequire(import.meta.url);
437
+ const tsxPkgPath = req.resolve("tsx/package.json", { paths: [projectRoot] });
438
+ const tsxPkg = req(tsxPkgPath);
439
+ const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
440
+ if (!tsxBinRel) throw new Error("tsx bin not found");
441
+ const tsxBin = path.join(path.dirname(tsxPkgPath), tsxBinRel);
442
+
443
+ const logPath = path.join(os.homedir(), ".miki-moni", "daemon.log");
444
+ try { await fsp.mkdir(path.dirname(logPath), { recursive: true }); } catch { /* ignore */ }
445
+ const out = await fsp.open(logPath, "a").catch(() => null);
446
+ const stdio: any = out ? ["ignore", out.fd, out.fd] : ["ignore", "ignore", "ignore"];
447
+ const child = spawn(process.execPath, [tsxBin, indexEntry], { detached: true, stdio, windowsHide: true });
448
+ child.unref();
449
+ if (out) out.close().catch(() => { /* ignore */ });
450
+ }
451
+
452
+ async function cmdRotate(cfg: Config, workerUrlArg?: string): Promise<void> {
453
+ const workerUrl = workerUrlArg ?? cfg.remote?.worker_url;
454
+ if (!workerUrl) {
455
+ console.error("Missing worker URL — pass `--worker-url=` or set in config first.");
456
+ process.exit(1);
457
+ }
458
+ const token = generateNewPairingToken();
459
+ const next: Config = {
460
+ ...cfg,
461
+ remote: { ...(cfg.remote ?? { worker_url: workerUrl }), worker_url: workerUrl, pair_token: token },
462
+ };
463
+ await saveConfig(CONFIG_PATH, next);
464
+ console.log("[ok] Rotated persistent pair token. Old QR is now invalid.");
465
+ console.log("[note] Existing paired phones are unaffected — they reconnect via signing key, not token.");
466
+
467
+ // Hot-swap the daemon so the new token gets registered with the relay
468
+ // without the user having to manually Ctrl+C + restart. Previously this
469
+ // was a footgun: the new QR would 404 at /v1/phone because the relay's
470
+ // PairingCoordinator DO still held the stale token.
471
+ const port = await readPortFile();
472
+ if (port && await pingDaemonHttp(port)) {
473
+ process.stdout.write("[…] restarting daemon to register the new token… ");
474
+ const acked = await postAdmin(port, "/admin/restart");
475
+ if (!acked) {
476
+ console.log("FAILED");
477
+ console.error("[warn] /admin/restart did not ack. Manually restart: Ctrl+C in the daemon window then `pnpm start`.");
478
+ } else {
479
+ // Wait for the old daemon's port to free (up to 5s).
480
+ for (let i = 0; i < 25; i++) {
481
+ await new Promise((r) => setTimeout(r, 200));
482
+ if (!(await pingDaemonHttp(port, 300))) break;
483
+ }
484
+ // Spawn a fresh detached daemon — singleton guard in index.ts will no-op
485
+ // if somehow a daemon is already there, so this is safe to fire.
486
+ try { await spawnDetachedDaemon(); } catch (e) {
487
+ console.log("FAILED");
488
+ console.error(`[warn] could not respawn daemon: ${(e as Error).message}`);
489
+ console.error("[warn] start manually: pnpm start (from D:\\code\\cc-hub or wherever miki-moni lives)");
490
+ printQrAndCode(next);
491
+ return;
492
+ }
493
+ // Wait for the new daemon to register the token + come up (up to 10s).
494
+ let ready = false;
495
+ for (let i = 0; i < 50; i++) {
496
+ await new Promise((r) => setTimeout(r, 200));
497
+ const p = await readPortFile();
498
+ if (p && await pingDaemonHttp(p)) { ready = true; break; }
499
+ }
500
+ console.log(ready ? "OK" : "TIMED OUT — daemon may still be coming up, give it a moment");
501
+ }
502
+ } else {
503
+ console.log("[info] No running daemon detected. The new token will be active when you next `pnpm start`.");
504
+ }
505
+
506
+ printQrAndCode(next);
507
+ }
508
+
509
+ async function main(): Promise<void> {
510
+ const args = parseArgs(process.argv);
511
+ const cfg = await loadOrInitConfig(CONFIG_PATH);
512
+ switch (args.cmd) {
513
+ case "list":
514
+ await cmdList(cfg);
515
+ return;
516
+ case "revoke":
517
+ await cmdRevoke(cfg, args.peerId!);
518
+ return;
519
+ case "new":
520
+ await cmdNew(cfg, args.workerUrl);
521
+ return;
522
+ case "show":
523
+ await cmdShow(cfg, args.workerUrl);
524
+ return;
525
+ case "rotate":
526
+ await cmdRotate(cfg, args.workerUrl);
527
+ return;
528
+ }
529
+ }
530
+
531
+ main().catch((err) => {
532
+ console.error(err.message || err);
533
+ process.exit(1);
534
+ });
@@ -0,0 +1,6 @@
1
+ // Single import surface for the wizard's interactive prompts. If we want to
2
+ // swap @inquirer/prompts for something lighter later (clack, readline-sync,
3
+ // hand-rolled readline), the change is one file. Keeps the rest of the
4
+ // wizard library-agnostic.
5
+
6
+ export { select, input, confirm } from "@inquirer/prompts";
@@ -0,0 +1,45 @@
1
+ // Minimal push-able async iterable used as the `prompt` stream into Agent SDK's
2
+ // query(). Each push() makes a new SDKUserMessage available to the consumer
3
+ // (the SDK). end() terminates the iterable. Pull-before-push and push-before-pull
4
+ // are both supported.
5
+
6
+ export class PushableAsyncIterable<T> implements AsyncIterable<T>, AsyncIterator<T> {
7
+ private buffer: T[] = [];
8
+ private pendingResolve: ((v: IteratorResult<T>) => void) | null = null;
9
+ private closed = false;
10
+
11
+ push(value: T): void {
12
+ if (this.closed) return;
13
+ if (this.pendingResolve) {
14
+ const r = this.pendingResolve;
15
+ this.pendingResolve = null;
16
+ r({ value, done: false });
17
+ } else {
18
+ this.buffer.push(value);
19
+ }
20
+ }
21
+
22
+ end(): void {
23
+ if (this.closed) return;
24
+ this.closed = true;
25
+ if (this.pendingResolve) {
26
+ const r = this.pendingResolve;
27
+ this.pendingResolve = null;
28
+ r({ value: undefined as unknown as T, done: true });
29
+ }
30
+ }
31
+
32
+ [Symbol.asyncIterator](): AsyncIterator<T> { return this; }
33
+
34
+ next(): Promise<IteratorResult<T>> {
35
+ if (this.buffer.length > 0) {
36
+ return Promise.resolve({ value: this.buffer.shift()!, done: false });
37
+ }
38
+ if (this.closed) {
39
+ return Promise.resolve({ value: undefined as unknown as T, done: true });
40
+ }
41
+ return new Promise<IteratorResult<T>>((resolve) => {
42
+ this.pendingResolve = resolve;
43
+ });
44
+ }
45
+ }