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,292 @@
1
+ // Self-host sub-wizard: spawns wrangler to deploy a worker + pages project to
2
+ // the user's own Cloudflare account, captures the resulting URLs, and writes
3
+ // them into Config.remote.
4
+ //
5
+ // Best-effort: if wrangler isn't installed, or any spawn fails, surface a
6
+ // clear message + bail back to the wizard. We never partially mutate config.
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { promises as fs } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { randomBytes } from "node:crypto";
14
+ import type { Config } from "../config.js";
15
+ import { select } from "./prompt.js";
16
+ import { t } from "./i18n-cli.js";
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+ // data-dir / package root: src/cli/setup-self-host.ts → ../../ = repo root
21
+ // (or, when installed via npm, the package install dir).
22
+ const PKG_ROOT = path.resolve(__dirname, "..", "..");
23
+ const WORKER_DIR = path.join(PKG_ROOT, "worker");
24
+ // Self-host uses a stripped wrangler config (no routes / custom_domain) so
25
+ // the user's deploy doesn't accidentally hijack relay.f1telemetrystationpro.org from the
26
+ // author's hosted instance when they happen to share a CF account.
27
+ const WORKER_SELFHOST_CONFIG = path.join(WORKER_DIR, "wrangler-selfhost.toml");
28
+ const PHONE_DIST = path.join(PKG_ROOT, "dist", "web-phone");
29
+
30
+ function suggestName(prefix: string): string {
31
+ const rand = randomBytes(3).toString("hex"); // 6 hex chars, low collision risk
32
+ return `${prefix}-${rand}`;
33
+ }
34
+
35
+ /** Resolve wrangler's bin entry from worker/'s package.json — same trick that
36
+ * bin/miki.mjs uses for tsx. Avoids npx entirely (no PATH issues, no shell,
37
+ * no Node 24 DEP0190 deprecation noise), and works regardless of which
38
+ * directory the user ran `miki setup` from. */
39
+ let _wranglerBinCache: string | null | undefined;
40
+ function findWranglerBin(): string | null {
41
+ if (_wranglerBinCache !== undefined) return _wranglerBinCache;
42
+ try {
43
+ // require.resolve relative to WORKER_DIR's package.json — finds wrangler
44
+ // even when miki setup is invoked from the user's home dir.
45
+ const req = createRequire(path.join(WORKER_DIR, "package.json"));
46
+ const pkgPath = req.resolve("wrangler/package.json");
47
+ const pkg = req(pkgPath);
48
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.wrangler;
49
+ if (!binRel) { _wranglerBinCache = null; return null; }
50
+ _wranglerBinCache = path.join(path.dirname(pkgPath), binRel);
51
+ return _wranglerBinCache;
52
+ } catch {
53
+ _wranglerBinCache = null;
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function checkWranglerAvailable(): boolean {
59
+ return findWranglerBin() !== null;
60
+ }
61
+
62
+ /** Spawn wrangler with the given args, inherit stdio so user sees prompts.
63
+ * Optional env merges into process.env (used for CLOUDFLARE_ACCOUNT_ID). */
64
+ function runWrangler(args: string[], cwd: string, env?: Record<string, string>): Promise<number> {
65
+ const bin = findWranglerBin();
66
+ if (!bin) return Promise.resolve(1);
67
+ return new Promise((resolve) => {
68
+ const child = spawn(process.execPath, [bin, ...args], {
69
+ cwd, stdio: "inherit",
70
+ env: env ? { ...process.env, ...env } : process.env,
71
+ });
72
+ child.on("exit", (code) => resolve(code ?? 1));
73
+ child.on("error", () => resolve(1));
74
+ });
75
+ }
76
+
77
+ /** Spawn wrangler + capture both stdout AND stderr (wrangler prints account
78
+ * list to stderr on the "multiple accounts" error). */
79
+ function runWranglerCaptured(
80
+ args: string[],
81
+ cwd: string,
82
+ env?: Record<string, string>,
83
+ ): Promise<{ code: number; out: string; err: string }> {
84
+ const bin = findWranglerBin();
85
+ if (!bin) return Promise.resolve({ code: 1, out: "", err: "wrangler_missing" });
86
+ return new Promise((resolve) => {
87
+ let out = "";
88
+ let err = "";
89
+ const child = spawn(process.execPath, [bin, ...args], {
90
+ cwd, stdio: ["inherit", "pipe", "pipe"],
91
+ env: env ? { ...process.env, ...env } : process.env,
92
+ });
93
+ child.stdout?.on("data", (d: Buffer) => { out += d.toString(); process.stdout.write(d); });
94
+ child.stderr?.on("data", (d: Buffer) => { err += d.toString(); process.stderr.write(d); });
95
+ child.on("exit", (code) => resolve({ code: code ?? 1, out, err }));
96
+ child.on("error", () => resolve({ code: 1, out, err }));
97
+ });
98
+ }
99
+
100
+ /** Parse "More than one account" error from wrangler stderr. Returns the
101
+ * account list as [{label, id}] or null if the error message doesn't match. */
102
+ function parseMultiAccountError(stderr: string): Array<{ label: string; id: string }> | null {
103
+ if (!/More than one account available/i.test(stderr)) return null;
104
+ // Lines look like: `\`Mike25326799@gmail.com's Account\`: \`f566818c…\``
105
+ const matches = Array.from(stderr.matchAll(/`([^`]+)`:\s*`([a-f0-9]{32})`/gi));
106
+ if (matches.length < 2) return null;
107
+ return matches.map((m) => ({ label: m[1]!, id: m[2]! }));
108
+ }
109
+
110
+ async function pickAccountId(accounts: Array<{ label: string; id: string }>): Promise<string> {
111
+ console.log("");
112
+ console.log(t("selfhost.step2.multiacc"));
113
+ console.log("");
114
+ return await select<string>({
115
+ message: t("selfhost.step2.pickacc"),
116
+ choices: accounts.map((a) => ({ name: `${a.label} (${a.id.slice(0, 8)}…)`, value: a.id })),
117
+ });
118
+ }
119
+
120
+ /** Extract a deployed URL from wrangler's stdout. Wrangler 3 / 4 output varies:
121
+ * "Uploaded miki-relay-abc123 (1.75 sec)"
122
+ * "Deployed miki-relay-abc123 triggers (2.90 sec)"
123
+ * sometimes the workers.dev URL is on a separate line, sometimes only
124
+ * in the dashboard. Pages always prints "https://<hash>.<name>.pages.dev"
125
+ * and "https://<branch>.<name>.pages.dev".
126
+ * Returns the synthesized URL if parse fails — we can construct it from
127
+ * `<name>.<accountId>.workers.dev` patterns. */
128
+ function parseUrl(out: string, kind: "worker" | "pages"): string | null {
129
+ if (kind === "worker") {
130
+ const m = out.match(/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev/i);
131
+ return m ? m[0] : null;
132
+ }
133
+ const branch = out.match(/https:\/\/[a-z0-9-]+\.pages\.dev/i);
134
+ return branch ? branch[0] : null;
135
+ }
136
+
137
+ /** Wrangler 3 doesn't always print the workers.dev URL after deploy (only
138
+ * shows custom domain or nothing). Parse the worker name from the "Uploaded
139
+ * <name>" line so we can synthesize the URL from <name>.<account>.workers.dev
140
+ * — but we need the account's workers.dev subdomain, which we get via
141
+ * `wrangler subdomain` or fall back to a sensible default with the user's
142
+ * Cloudflare username. For v1 we just construct <name>.workers.dev with the
143
+ * account_id; user can correct manually if wrong. */
144
+ function parseWorkerName(out: string): string | null {
145
+ const m = out.match(/(?:Uploaded|Deployed)\s+([a-z0-9_-]+)\s/i);
146
+ return m ? m[1]! : null;
147
+ }
148
+
149
+ export async function runSelfHostWizard(cfg: Config): Promise<Config> {
150
+ // Suppress Node's DEP0190 / DeprecationWarning noise from npx/wrangler
151
+ // internals — end users panic when they see "DeprecationWarning" mid-flow.
152
+ // (We only do this for the duration of the wizard; revert at end.)
153
+ const prevNoDep = (process as any).noDeprecation;
154
+ (process as any).noDeprecation = true;
155
+
156
+ console.log("");
157
+ console.log("─".repeat(64));
158
+ console.log("📦 " + t("selfhost.intro.1"));
159
+ console.log("");
160
+ console.log(" " + t("selfhost.intro.2"));
161
+ console.log(" " + t("selfhost.intro.3"));
162
+ console.log("");
163
+ console.log(" " + t("selfhost.intro.4"));
164
+ console.log("─".repeat(64));
165
+ console.log("");
166
+
167
+ if (!checkWranglerAvailable()) {
168
+ console.error(t("selfhost.wrangler.missing"));
169
+ console.error(t("selfhost.wrangler.install"));
170
+ console.error(t("selfhost.wrangler.retry"));
171
+ throw new Error("wrangler_missing");
172
+ }
173
+
174
+ // Sanity: worker source + phone bundle must be present.
175
+ try { await fs.access(path.join(WORKER_DIR, "wrangler.toml")); }
176
+ catch {
177
+ console.error(`✗ Worker source not found in ${WORKER_DIR}`);
178
+ console.error(" This npm package wasn't shipped with worker/ — please file an issue.");
179
+ throw new Error("worker_source_missing");
180
+ }
181
+ try { await fs.access(path.join(PHONE_DIST, "index.html")); }
182
+ catch {
183
+ console.error(`✗ Phone bundle not found in ${PHONE_DIST}`);
184
+ console.error(" Run `pnpm build:phone` (or reinstall the npm package).");
185
+ throw new Error("phone_bundle_missing");
186
+ }
187
+
188
+ // Auto-generate unique names. End users don't care about CF's URL scheme;
189
+ // we just need names that won't collide on their own account. The random
190
+ // suffix gives 16M collision space.
191
+ const workerName = suggestName("miki-relay");
192
+ const pagesName = suggestName("miki");
193
+
194
+ // 1/3: wrangler login (interactive — opens browser).
195
+ console.log(t("selfhost.step1"));
196
+ console.log(t("selfhost.step1.browser"));
197
+ console.log("");
198
+ const loginCode = await runWrangler(["login"], WORKER_DIR);
199
+ if (loginCode !== 0) {
200
+ console.error("");
201
+ console.error("✗ Cloudflare login failed (exit " + loginCode + ")。");
202
+ throw new Error("wrangler_login_failed");
203
+ }
204
+ console.log(t("selfhost.step1.ok"));
205
+ console.log("");
206
+
207
+ // 2/3: Worker deploy. Use the self-host config (no routes/custom_domain)
208
+ // so we never hijack the author's hosted relay.f1telemetrystationpro.org.
209
+ console.log(t("selfhost.step2"));
210
+ console.log("");
211
+ const deployArgs = ["deploy", "--config", WORKER_SELFHOST_CONFIG, "--name", workerName];
212
+ let accountEnv: Record<string, string> | undefined;
213
+ let wOut = await runWranglerCaptured(deployArgs, WORKER_DIR);
214
+ if (wOut.code !== 0) {
215
+ const multi = parseMultiAccountError(wOut.err);
216
+ if (multi) {
217
+ const accountId = await pickAccountId(multi);
218
+ accountEnv = { CLOUDFLARE_ACCOUNT_ID: accountId };
219
+ console.log("");
220
+ console.log(`${t("selfhost.step2.retry")} ${accountId.slice(0, 8)}…`);
221
+ console.log("");
222
+ wOut = await runWranglerCaptured(deployArgs, WORKER_DIR, accountEnv);
223
+ }
224
+ }
225
+ if (wOut.code !== 0) {
226
+ console.error("");
227
+ console.error(t("selfhost.step2.fail"));
228
+ throw new Error("worker_deploy_failed");
229
+ }
230
+ let workerUrl = parseUrl(wOut.out, "worker");
231
+ if (!workerUrl) {
232
+ // Older wrangler may not print the workers.dev URL post-deploy. Ask the
233
+ // user to paste it from the CF dashboard rather than fail outright.
234
+ const deployedName = parseWorkerName(wOut.out) ?? workerName;
235
+ console.log("");
236
+ console.log(`✓ Relay deployed as '${deployedName}', ${t("selfhost.step2.urlfail")}`);
237
+ console.log(" https://dash.cloudflare.com → Workers & Pages → " + deployedName);
238
+ console.log(" → Settings → Triggers");
239
+ console.log("");
240
+ const { input } = await import("./prompt.js");
241
+ const pasted = await input({
242
+ message: t("selfhost.step2.urlinput"),
243
+ validate: (v) => /^https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev\/?$/i.test(v) || "請貼完整的 https://...workers.dev URL",
244
+ });
245
+ workerUrl = pasted.replace(/\/$/, "");
246
+ }
247
+ console.log("");
248
+ console.log(`${t("selfhost.step2.ok")} ${workerUrl}`);
249
+ console.log("");
250
+
251
+ // 3/3: Pages deploy. Reuse the chosen account.
252
+ console.log(t("selfhost.step3"));
253
+ console.log("");
254
+ await runWrangler(["pages", "project", "create", pagesName, "--production-branch=main"], WORKER_DIR, accountEnv);
255
+ const pOut = await runWranglerCaptured(
256
+ ["pages", "deploy", PHONE_DIST, "--project-name", pagesName, "--branch=main", "--commit-dirty=true"],
257
+ WORKER_DIR,
258
+ accountEnv,
259
+ );
260
+ if (pOut.code !== 0) {
261
+ console.error("");
262
+ console.error(t("selfhost.step3.fail"));
263
+ throw new Error("pages_deploy_failed");
264
+ }
265
+ const pagesUrl = parseUrl(pOut.out, "pages") ?? `https://${pagesName}.pages.dev`;
266
+ console.log("");
267
+ console.log(`${t("selfhost.step3.ok")} ${pagesUrl}`);
268
+ console.log("");
269
+
270
+ // Worker URL needs ws:// prefix for our config.
271
+ const wsUrl = workerUrl.replace(/^https:/, "wss:");
272
+
273
+ console.log("─".repeat(64));
274
+ console.log(t("selfhost.done.title"));
275
+ console.log("");
276
+ console.log(" Relay: " + wsUrl);
277
+ console.log(" Phone app: " + pagesUrl);
278
+ console.log("─".repeat(64));
279
+ console.log("");
280
+
281
+ // Restore prior deprecation setting before we hand back to the caller.
282
+ (process as any).noDeprecation = prevNoDep;
283
+
284
+ return {
285
+ ...cfg,
286
+ remote: {
287
+ ...(cfg.remote ?? {}),
288
+ worker_url: wsUrl,
289
+ phone_pwa_url: pagesUrl.endsWith("/") ? pagesUrl : pagesUrl + "/",
290
+ },
291
+ };
292
+ }
@@ -0,0 +1,130 @@
1
+ // First-run setup wizard. Invoked once on a fresh install — when
2
+ // `config.remote` is missing — to pick how phones reach the daemon.
3
+ //
4
+ // 1. Hosted (default, recommended) — use relay.f1telemetrystationpro.org. Zero setup.
5
+ // 2. Self-host — auto wrangler deploy to user's CF.
6
+ // 3. Local-only — no phone access; mark remote = null.
7
+ //
8
+ // In non-TTY contexts (CI, headless start), the wizard silently picks
9
+ // hosted-defaults so `miki start` never blocks. Mutation is pure: returns
10
+ // the new Config; caller writes it.
11
+
12
+ import path from "node:path";
13
+ import { promises as fs } from "node:fs";
14
+ import type { Config, Locale } from "../config.js";
15
+ import { HUB_HOME } from "../data-dir.js";
16
+ import { runSelfHostWizard } from "./setup-self-host.js";
17
+ import { select } from "./prompt.js";
18
+ import { setLocale, t, LOCALE_CHOICES } from "./i18n-cli.js";
19
+
20
+ const HOSTED_RELAY_URL = "wss://relay.f1telemetrystationpro.org";
21
+ const HOSTED_PHONE_PWA_URL = "https://miki-moni.pages.dev/";
22
+
23
+ /** Sentinel file written when the user explicitly picks "local-only" so we
24
+ * don't re-ask on every `miki start`. They can delete this file or run
25
+ * `miki setup` to bring up the wizard again. */
26
+ const WIZARD_LOCAL_ONLY_MARKER = path.join(HUB_HOME, "wizard-local-only");
27
+
28
+ async function localOnlyChosen(): Promise<boolean> {
29
+ try { await fs.access(WIZARD_LOCAL_ONLY_MARKER); return true; } catch { return false; }
30
+ }
31
+
32
+ async function markLocalOnly(): Promise<void> {
33
+ try { await fs.mkdir(HUB_HOME, { recursive: true }); } catch { /* ignore */ }
34
+ await fs.writeFile(WIZARD_LOCAL_ONLY_MARKER, new Date().toISOString() + "\n");
35
+ }
36
+
37
+ type Choice = "hosted" | "self-host" | "local-only";
38
+
39
+ export interface SetupWizardOpts {
40
+ /** Skip the prompt and use the given choice (testing, --setup flag). */
41
+ forceChoice?: Choice;
42
+ }
43
+
44
+ export async function runSetupWizard(cfg: Config, opts: SetupWizardOpts = {}): Promise<Config> {
45
+ // Step 0: pick UI language (English / Traditional / Simplified Chinese)
46
+ // BEFORE anything else, so subsequent prompts speak the user's language.
47
+ // Respects cfg.locale if already set (e.g. re-running `miki setup`).
48
+ let cfgWithLocale = cfg;
49
+ if (!opts.forceChoice) {
50
+ const existing = cfg.locale;
51
+ if (existing) setLocale(existing);
52
+ else {
53
+ const lang = await pickLocale();
54
+ setLocale(lang);
55
+ cfgWithLocale = { ...cfg, locale: lang };
56
+ }
57
+ }
58
+
59
+ const choice = opts.forceChoice ?? await pickChoice();
60
+ switch (choice) {
61
+ case "hosted":
62
+ return applyHosted(cfgWithLocale);
63
+ case "self-host":
64
+ return await runSelfHostWizard(cfgWithLocale);
65
+ case "local-only":
66
+ await markLocalOnly();
67
+ return applyLocalOnly(cfgWithLocale);
68
+ }
69
+ }
70
+
71
+ /** Non-TTY (e.g. systemd, CI, redirected stdin) → skip wizard so
72
+ * `miki start` never hangs waiting for input. */
73
+ export async function shouldRunWizard(cfg: Config): Promise<boolean> {
74
+ if (cfg.remote?.worker_url) return false;
75
+ if (await localOnlyChosen()) return false;
76
+ if (!process.stdin.isTTY) return false;
77
+ return true;
78
+ }
79
+
80
+ async function pickLocale(): Promise<Locale> {
81
+ // Language picker shown in English-only since user hasn't told us yet.
82
+ console.log("");
83
+ console.log("✨ Welcome to miki-moni! / 歡迎 / 欢迎");
84
+ console.log("");
85
+ return await select<Locale>({
86
+ message: "Language / 語言 / 语言:",
87
+ choices: LOCALE_CHOICES.map((c) => ({ name: c.name, value: c.value })),
88
+ default: "en",
89
+ });
90
+ }
91
+
92
+ async function pickChoice(): Promise<Choice> {
93
+ console.log("");
94
+ console.log(t("wizard.welcome"));
95
+ console.log("");
96
+ return await select<Choice>({
97
+ message: t("wizard.pick.relay"),
98
+ choices: [
99
+ { name: t("wizard.choice.hosted"), value: "hosted", description: t("wizard.choice.hosted.desc") },
100
+ { name: t("wizard.choice.selfhost"), value: "self-host", description: t("wizard.choice.selfhost.desc") },
101
+ { name: t("wizard.choice.local"), value: "local-only", description: t("wizard.choice.local.desc") },
102
+ ],
103
+ default: "hosted",
104
+ });
105
+ }
106
+
107
+ function applyHosted(cfg: Config): Config {
108
+ return {
109
+ ...cfg,
110
+ remote: {
111
+ ...(cfg.remote ?? {}),
112
+ worker_url: HOSTED_RELAY_URL,
113
+ phone_pwa_url: HOSTED_PHONE_PWA_URL,
114
+ },
115
+ };
116
+ }
117
+
118
+ function applyLocalOnly(cfg: Config): Config {
119
+ // We can't actually set `remote: null` in the typed Config — remote is
120
+ // optional. Instead unset it and leave a sentinel field elsewhere if
121
+ // needed. For now: just delete remote entirely; subsequent starts will
122
+ // re-trigger the wizard. Mark with an internal flag stored separately.
123
+ const next: Config = { ...cfg };
124
+ delete (next as any).remote;
125
+ // Suppress wizard re-trigger by writing a sentinel into device.created_at-ish
126
+ // metadata. Simpler: write a marker file beside config.
127
+ return next;
128
+ }
129
+
130
+ export { HOSTED_RELAY_URL, HOSTED_PHONE_PWA_URL };