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.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- 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 };
|