zigmini 0.8.0-beta.5 → 0.8.0-beta.7
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/README.ko.md +78 -19
- package/README.md +79 -19
- package/assets/agent/gateway-request-rules.md +1 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +25 -2
- package/docs/manual.md +25 -2
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +13 -39
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/rotate-main.ts +411 -0
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +20 -1
- package/src/hub.ts +140 -30
- package/src/main.ts +45 -4
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +33 -1
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-check.ts +241 -0
- package/src/store.ts +71 -1
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +29 -4
- package/web/app.js +197 -10
- package/web/decisions.js +3 -0
- package/web/gc.d.ts +2 -0
- package/web/gc.js +11 -0
- package/web/index.html +26 -10
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/style.css +16 -0
- package/web/tooltip.d.ts +0 -6
- package/web/tooltip.js +8 -11
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
- package/web/update.d.ts +1 -1
- package/web/update.js +10 -3
package/src/cli/access.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { isPrivateIPv4, type Network } from "../enroll.ts";
|
|
4
|
+
import { type Language, message, resolveLanguage } from "./messages.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* External access choice (plan Todo 27). Setup and the web 설정 panel pick how the phone reaches the app port:
|
|
@@ -27,47 +28,42 @@ export interface ResolveDeps {
|
|
|
27
28
|
|
|
28
29
|
export type Resolved = { readonly ok: true; readonly record: AccessRecord } | { readonly ok: false; readonly message: string };
|
|
29
30
|
|
|
30
|
-
export const TAILSCALE_GUIDE =
|
|
31
|
-
"Tailscale이 이 맥에 없습니다. 아래 순서로 설치한 뒤 같은 명령을 다시 실행하세요.",
|
|
32
|
-
" 1. 맥: https://tailscale.com/download/mac 에서 Tailscale을 설치하고 로그인합니다.",
|
|
33
|
-
" 2. 폰: Play 스토어에서 Tailscale을 설치하고 맥과 같은 계정으로 로그인합니다.",
|
|
34
|
-
" 3. Tailscale 관리 화면에서 MagicDNS를 켭니다(주소가 기기 이름으로 나옵니다).",
|
|
35
|
-
].join("\n");
|
|
31
|
+
export const TAILSCALE_GUIDE = message("ko", "tailscaleGuide");
|
|
36
32
|
|
|
37
33
|
export function isAccessChoice(value: unknown): value is AccessChoice {
|
|
38
34
|
return (ACCESS_CHOICES as readonly unknown[]).includes(value);
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/** The owner's domain: only `https://host[:port]` with no path, query, fragment or user info (89 security note 3: TLS required). */
|
|
42
|
-
export function parseDomain(text: string): { readonly url: string } | { readonly error: string } {
|
|
38
|
+
export function parseDomain(text: string, lang: Language = "ko"): { readonly url: string } | { readonly error: string } {
|
|
43
39
|
let url: URL;
|
|
44
40
|
try {
|
|
45
41
|
url = new URL(text.trim());
|
|
46
42
|
} catch {
|
|
47
|
-
return { error:
|
|
43
|
+
return { error: message(lang, "domainUrl", { text }) };
|
|
48
44
|
}
|
|
49
|
-
if (url.protocol !== "https:") return { error:
|
|
45
|
+
if (url.protocol !== "https:") return { error: message(lang, "domainTls") };
|
|
50
46
|
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
|
|
51
|
-
return { error:
|
|
47
|
+
return { error: message(lang, "domainFormat") };
|
|
52
48
|
}
|
|
53
49
|
return { url: url.origin };
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
export function parseTailscale(stdout: string, appPort: number): { readonly url: string } | { readonly error: string } {
|
|
52
|
+
export function parseTailscale(stdout: string, appPort: number, lang: Language = "ko"): { readonly url: string } | { readonly error: string } {
|
|
57
53
|
let status: { BackendState?: unknown; Self?: { DNSName?: unknown; TailscaleIPs?: unknown } } | null;
|
|
58
54
|
try {
|
|
59
55
|
status = JSON.parse(stdout) as typeof status;
|
|
60
56
|
} catch {
|
|
61
|
-
return { error:
|
|
57
|
+
return { error: message(lang, "tailscaleJson") };
|
|
62
58
|
}
|
|
63
59
|
if (status?.BackendState !== "Running") {
|
|
64
|
-
return { error:
|
|
60
|
+
return { error: message(lang, "tailscaleState", { state: String(status?.BackendState ?? "?") }) };
|
|
65
61
|
}
|
|
66
62
|
const name = typeof status.Self?.DNSName === "string" ? status.Self.DNSName.replace(/\.$/, "") : "";
|
|
67
63
|
const ips = Array.isArray(status.Self?.TailscaleIPs) ? status.Self.TailscaleIPs : [];
|
|
68
64
|
const ip = ips.find((value): value is string => typeof value === "string" && /^100\.\d+\.\d+\.\d+$/.test(value));
|
|
69
65
|
const host = name !== "" ? name : ip;
|
|
70
|
-
if (host === undefined) return { error:
|
|
66
|
+
if (host === undefined) return { error: message(lang, "tailscaleHost") };
|
|
71
67
|
return { url: `http://${host}:${appPort}` };
|
|
72
68
|
}
|
|
73
69
|
|
|
@@ -77,21 +73,21 @@ export function lanUrl(network: Network, appPort: number): string | null {
|
|
|
77
73
|
return address === undefined ? null : `http://${address}:${appPort}`;
|
|
78
74
|
}
|
|
79
75
|
|
|
80
|
-
export function resolveAccess(choice: AccessChoice, domain: string | undefined, appPort: number, deps: ResolveDeps): Resolved {
|
|
76
|
+
export function resolveAccess(choice: AccessChoice, domain: string | undefined, appPort: number, deps: ResolveDeps, lang: Language = "ko"): Resolved {
|
|
81
77
|
if (choice === "domain") {
|
|
82
|
-
const parsed = parseDomain(domain ?? "");
|
|
78
|
+
const parsed = parseDomain(domain ?? "", lang);
|
|
83
79
|
return "error" in parsed ? { ok: false, message: parsed.error } : { ok: true, record: { choice, url: parsed.url } };
|
|
84
80
|
}
|
|
85
81
|
if (choice === "lan") {
|
|
86
82
|
const url = lanUrl(deps.network, appPort);
|
|
87
83
|
return url === null
|
|
88
|
-
? { ok: false, message:
|
|
84
|
+
? { ok: false, message: message(lang, "lanMissing") }
|
|
89
85
|
: { ok: true, record: { choice, url } };
|
|
90
86
|
}
|
|
91
87
|
const run = deps.tailscale();
|
|
92
|
-
if (run === null) return { ok: false, message:
|
|
93
|
-
if (run.code !== 0) return { ok: false, message:
|
|
94
|
-
const parsed = parseTailscale(run.stdout, appPort);
|
|
88
|
+
if (run === null) return { ok: false, message: message(lang, "tailscaleGuide") };
|
|
89
|
+
if (run.code !== 0) return { ok: false, message: message(lang, "tailscaleFailed", { code: run.code }) };
|
|
90
|
+
const parsed = parseTailscale(run.stdout, appPort, lang);
|
|
95
91
|
return "error" in parsed ? { ok: false, message: parsed.error } : { ok: true, record: { choice, url: parsed.url } };
|
|
96
92
|
}
|
|
97
93
|
|
|
@@ -119,13 +115,13 @@ export function appPortEnv(appPort: number): Record<string, string> {
|
|
|
119
115
|
return { ZGW_LAN_PORT: String(appPort), ZGW_LAN_HOST: "0.0.0.0" };
|
|
120
116
|
}
|
|
121
117
|
|
|
122
|
-
export function proxyGuide(url: string, appPort: number): string {
|
|
118
|
+
export function proxyGuide(url: string, appPort: number, lang: Language = "ko"): string {
|
|
123
119
|
return [
|
|
124
|
-
|
|
120
|
+
message(lang, "proxyIntro"),
|
|
125
121
|
` ${new URL(url).host} {`,
|
|
126
122
|
` \treverse_proxy 127.0.0.1:${appPort}`,
|
|
127
123
|
" }",
|
|
128
|
-
|
|
124
|
+
message(lang, "proxyEnd"),
|
|
129
125
|
].join("\n");
|
|
130
126
|
}
|
|
131
127
|
|
|
@@ -133,12 +129,12 @@ export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
|
133
129
|
export type PingResult = { readonly ok: true } | { readonly ok: false; readonly detail: string };
|
|
134
130
|
|
|
135
131
|
/** `GET <url>/api/enroll/ping` must answer 200 `{ ok: true }` through the proxy and its certificate. */
|
|
136
|
-
export async function pingAccess(fetcher: Fetch, url: string, timeoutMs = PING_TIMEOUT_MS): Promise<PingResult> {
|
|
132
|
+
export async function pingAccess(fetcher: Fetch, url: string, timeoutMs = PING_TIMEOUT_MS, lang: Language = "ko"): Promise<PingResult> {
|
|
137
133
|
try {
|
|
138
134
|
const response = await fetcher(`${url}/api/enroll/ping`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
139
135
|
if (response.status !== 200) return { ok: false, detail: `HTTP ${response.status}` };
|
|
140
136
|
const body = await response.json().catch(() => null) as { ok?: unknown } | null;
|
|
141
|
-
return body?.ok === true ? { ok: true } : { ok: false, detail:
|
|
137
|
+
return body?.ok === true ? { ok: true } : { ok: false, detail: message(lang, "pingBody") };
|
|
142
138
|
} catch (error) {
|
|
143
139
|
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
144
140
|
}
|
|
@@ -146,14 +142,14 @@ export async function pingAccess(fetcher: Fetch, url: string, timeoutMs = PING_T
|
|
|
146
142
|
|
|
147
143
|
export interface AccessItem { readonly id: "access"; readonly label: string; readonly status: "ok" | "bad" | "off"; readonly detail: string }
|
|
148
144
|
|
|
149
|
-
export async function accessItem(record: AccessRecord | null | "invalid", ping: (url: string) => Promise<PingResult
|
|
150
|
-
const label =
|
|
151
|
-
if (record === null) return { id: "access", label, status: "off", detail:
|
|
152
|
-
if (record === "invalid") return { id: "access", label, status: "bad", detail:
|
|
145
|
+
export async function accessItem(record: AccessRecord | null | "invalid", ping: (url: string) => Promise<PingResult>, lang: Language = "ko"): Promise<AccessItem> {
|
|
146
|
+
const label = message(lang, "accessLabel");
|
|
147
|
+
if (record === null) return { id: "access", label, status: "off", detail: message(lang, "accessOff") };
|
|
148
|
+
if (record === "invalid") return { id: "access", label, status: "bad", detail: message(lang, "accessRead", { file: ACCESS_FILE }) };
|
|
153
149
|
const result = await ping(record.url);
|
|
154
150
|
return result.ok
|
|
155
151
|
? { id: "access", label, status: "ok", detail: `${record.choice} ${record.url}` }
|
|
156
|
-
: { id: "access", label, status: "bad", detail:
|
|
152
|
+
: { id: "access", label, status: "bad", detail: message(lang, "accessCheck", { ...record, detail: result.detail }) };
|
|
157
153
|
}
|
|
158
154
|
|
|
159
155
|
/**
|
|
@@ -180,6 +176,7 @@ export interface AccessApiDeps extends ResolveDeps {
|
|
|
180
176
|
* the launchd environment, so that stays with `zigmini setup --access`.
|
|
181
177
|
*/
|
|
182
178
|
export async function handleAccessApi(req: Request, deps: AccessApiDeps): Promise<Response> {
|
|
179
|
+
const lang = resolveLanguage(deps.home, process.env);
|
|
183
180
|
if (req.method === "GET") {
|
|
184
181
|
const saved = readAccess(deps.home);
|
|
185
182
|
return Response.json({ access: saved === "invalid" ? null : saved, invalid: saved === "invalid", appPort: deps.appPort });
|
|
@@ -187,17 +184,17 @@ export async function handleAccessApi(req: Request, deps: AccessApiDeps): Promis
|
|
|
187
184
|
if (req.method !== "POST") return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
188
185
|
if (foreignOrigin(req.headers, deps.webPort)) return Response.json({ error: "forbidden_origin" }, { status: 403 });
|
|
189
186
|
if (deps.appPort <= 0) {
|
|
190
|
-
return Response.json({ error: "app_port_off", message:
|
|
187
|
+
return Response.json({ error: "app_port_off", message: message(lang, "appOff") }, { status: 409 });
|
|
191
188
|
}
|
|
192
189
|
const body = await req.json().catch(() => null) as { choice?: unknown; domain?: unknown } | null;
|
|
193
|
-
if (!isAccessChoice(body?.choice)) return Response.json({ error: "bad_choice", message:
|
|
190
|
+
if (!isAccessChoice(body?.choice)) return Response.json({ error: "bad_choice", message: message(lang, "apiChoice", { choices: ACCESS_CHOICES.join(", ") }) }, { status: 400 });
|
|
194
191
|
const domain = typeof body.domain === "string" ? body.domain : undefined;
|
|
195
|
-
const resolved = resolveAccess(body.choice, domain, deps.appPort, deps);
|
|
192
|
+
const resolved = resolveAccess(body.choice, domain, deps.appPort, deps, lang);
|
|
196
193
|
if (!resolved.ok) return Response.json({ error: "unresolved", message: resolved.message }, { status: 422 });
|
|
197
194
|
if (resolved.record.choice === "domain") {
|
|
198
195
|
const result = await deps.ping(resolved.record.url);
|
|
199
196
|
if (!result.ok) {
|
|
200
|
-
return Response.json({ error: "ping_failed", message:
|
|
197
|
+
return Response.json({ error: "ping_failed", message: message(lang, "apiPing", { url: resolved.record.url, detail: result.detail, guide: proxyGuide(resolved.record.url, deps.appPort, lang) }) }, { status: 502 });
|
|
201
198
|
}
|
|
202
199
|
}
|
|
203
200
|
writeAccess(deps.home, resolved.record);
|
package/src/cli/launchd.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseRegistry, type SlotRecord, type SlotRegistry } from "../runtime/sl
|
|
|
6
6
|
import { engine } from "../engine/index.ts";
|
|
7
7
|
import { bunInstallDir, type GuardResult, guardMode, guardStart } from "../engine/schema-guard.ts";
|
|
8
8
|
import { agentDir } from "../model-accounts.ts";
|
|
9
|
+
import { message, resolveLanguage } from "./messages.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* launchd agents for a host install (plan Todo 9): the plist generator and `zigmini start/stop/restart/status`.
|
|
@@ -27,6 +28,7 @@ export const HEALTH_WAIT_MS = 600_000;
|
|
|
27
28
|
/** Bug 104: a busy gateway answered /api/health after 5.5-6.6 s, past the old 3 s (here) and 5 s (migrate-run) limits. */
|
|
28
29
|
export const HEALTH_REQUEST_MS = 15_000;
|
|
29
30
|
export const PROGRESS_EVERY_MS = 30_000;
|
|
31
|
+
export const DIAGNOSTIC_EVERY_MS = 10_000;
|
|
30
32
|
export const LEGACY_REFUSAL = "이 맥은 옛 실행 방식입니다. `zigmini migrate`를 쓰세요";
|
|
31
33
|
|
|
32
34
|
export interface LaunchdContext {
|
|
@@ -317,29 +319,53 @@ export function healthUrl(ctx: LaunchdContext): string {
|
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
export async function waitReady(deps: LaunchdDeps, ctx: LaunchdContext): Promise<boolean> {
|
|
322
|
+
const lang = resolveLanguage(ctx.paths.home, ctx.env, deps.readText);
|
|
320
323
|
const started = deps.now();
|
|
321
324
|
const deadline = started + HEALTH_WAIT_MS;
|
|
322
325
|
let report = started + PROGRESS_EVERY_MS;
|
|
326
|
+
let diagnose = started + DIAGNOSTIC_EVERY_MS;
|
|
327
|
+
let disconnectedSince: number | null = null;
|
|
323
328
|
for (;;) {
|
|
324
329
|
const health = await deps.health(healthUrl(ctx));
|
|
325
330
|
if (health?.omo === "ready") return true;
|
|
326
331
|
const now = deps.now();
|
|
327
332
|
if (now >= deadline) return false;
|
|
333
|
+
if (health?.omo === "disconnected") disconnectedSince ??= now;
|
|
334
|
+
else disconnectedSince = null;
|
|
335
|
+
if (now >= diagnose) {
|
|
336
|
+
if (health === null) {
|
|
337
|
+
const state = agentState(deps, ctx, agentLabel(ctx.paths.launchdPrefix, "gateway"));
|
|
338
|
+
deps.out(message(lang, "launchState", { state: message(lang, state.pid !== null ? "running" : state.loaded ? "stopped" : "unloaded"), pid: state.pid ?? "-", exit: state.lastExit ?? "?" }));
|
|
339
|
+
deps.out(message(lang, "logLine", { service: "gateway", line: lastLogLine(deps, join(ctx.paths.logs, "gateway.log"), lang) }));
|
|
340
|
+
} else if (disconnectedSince !== null && now - disconnectedSince >= DIAGNOSTIC_EVERY_MS) {
|
|
341
|
+
const bridge = bridgeAgents(deps, ctx)[0];
|
|
342
|
+
const file = `bridge${bridge?.record ? slotSuffix(bridge.record.slot) : ""}.log`;
|
|
343
|
+
deps.out(message(lang, "logLine", { service: "bridge", line: lastLogLine(deps, join(ctx.paths.logs, file), lang) }));
|
|
344
|
+
deps.out(message(lang, "engineRepair"));
|
|
345
|
+
}
|
|
346
|
+
diagnose = now + DIAGNOSTIC_EVERY_MS;
|
|
347
|
+
}
|
|
328
348
|
if (now >= report) {
|
|
329
|
-
deps.out(
|
|
349
|
+
deps.out(message(lang, "readyProgress", { seconds: Math.round((now - started) / 1000), health: health === null ? message(lang, "noHealth") : `HTTP ${health.status}, omo=${health.omo ?? "?"}` }));
|
|
330
350
|
report += PROGRESS_EVERY_MS;
|
|
331
351
|
}
|
|
332
352
|
await deps.sleep(1000);
|
|
333
353
|
}
|
|
334
354
|
}
|
|
335
355
|
|
|
356
|
+
function lastLogLine(deps: LaunchdDeps, path: string, lang: import("./messages.ts").Language): string {
|
|
357
|
+
const text = deps.readText(path)?.trimEnd();
|
|
358
|
+
return text ? text.slice(text.lastIndexOf("\n") + 1) : message(lang, "noLog");
|
|
359
|
+
}
|
|
360
|
+
|
|
336
361
|
async function finishReady(deps: LaunchdDeps, ctx: LaunchdContext): Promise<number> {
|
|
337
|
-
|
|
362
|
+
const lang = resolveLanguage(ctx.paths.home, ctx.env, deps.readText);
|
|
363
|
+
deps.out(message(lang, "readyWait", { seconds: HEALTH_WAIT_MS / 1000, url: healthUrl(ctx) }));
|
|
338
364
|
if (await waitReady(deps, ctx)) {
|
|
339
|
-
deps.out(
|
|
365
|
+
deps.out(message(lang, "readyDone", { port: ctx.paths.ports.web }));
|
|
340
366
|
return 0;
|
|
341
367
|
}
|
|
342
|
-
deps.err(
|
|
368
|
+
deps.err(message(lang, "readyFailed", { seconds: HEALTH_WAIT_MS / 1000, path: ctx.paths.logs }));
|
|
343
369
|
return 1;
|
|
344
370
|
}
|
|
345
371
|
|
package/src/cli/main.ts
CHANGED
|
@@ -3,53 +3,18 @@ import { join } from "node:path";
|
|
|
3
3
|
import { type LaunchdContext, type LaunchdDeps, restartCommand, startCommand, statusCommand, stopCommand, systemContext, systemDeps, UPDATER_COMMAND } from "./launchd.ts";
|
|
4
4
|
import { cliGate, consentCommand, consentSummary, EXIT_DECLINED, loadNotice } from "../consent/consent.ts";
|
|
5
5
|
import { installKind } from "../runtime/install-kind.ts";
|
|
6
|
-
import { LEGACY_HOME } from "../runtime/paths.ts";
|
|
7
6
|
import { doctorCommand, systemDoctorDeps } from "./doctor.ts";
|
|
8
7
|
import { setupCommand, stdinPrompt, systemSetupDeps } from "./setup.ts";
|
|
9
8
|
import { pairCommand } from "./pair.ts";
|
|
9
|
+
import { message, resolveLanguage } from "./messages.ts";
|
|
10
10
|
import { setupHint } from "./setup-hint.ts";
|
|
11
11
|
import { helperCommand, updateCommand } from "../update/cli.ts";
|
|
12
12
|
import { handoffCommand } from "../update/handoff-system.ts";
|
|
13
13
|
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
14
|
+
import { ROTATE_COMMAND, ROTATE_RUN_COMMAND, rotateCommand } from "./rotate-main.ts";
|
|
14
15
|
import { EXIT_USAGE, parseMigrateArgs, parseMigrateRunArgs, runMigrate, runMigrateRun, systemMigrateDeps, systemMigrateRunDeps } from "./migrate.ts";
|
|
15
16
|
|
|
16
|
-
export const HELP =
|
|
17
|
-
|
|
18
|
-
처음 설치했으면 먼저 \`zigmini setup\`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.
|
|
19
|
-
|
|
20
|
-
사용법: zigmini <명령> [옵션]
|
|
21
|
-
|
|
22
|
-
명령:
|
|
23
|
-
setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작
|
|
24
|
-
start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다
|
|
25
|
-
stop 게이트웨이와 브리지를 멈춥니다
|
|
26
|
-
stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다
|
|
27
|
-
restart [gateway|bridge] 다시 시작합니다
|
|
28
|
-
status launchd 상태, health, 권한 동의 상태를 보여 줍니다
|
|
29
|
-
doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)
|
|
30
|
-
pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다
|
|
31
|
-
pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다
|
|
32
|
-
consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)
|
|
33
|
-
update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다
|
|
34
|
-
(--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)
|
|
35
|
-
migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]
|
|
36
|
-
Docker 설치(~/${LEGACY_HOME})를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다
|
|
37
|
-
migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]
|
|
38
|
-
유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다
|
|
39
|
-
|
|
40
|
-
setup 옵션:
|
|
41
|
-
--non-interactive 묻지 않습니다
|
|
42
|
-
--accept-license 엔진 라이선스에 동의합니다
|
|
43
|
-
--agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더
|
|
44
|
-
--notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)
|
|
45
|
-
--access <tailscale|lan|domain>, --domain <https://주소>
|
|
46
|
-
외부 접속 선택
|
|
47
|
-
--engine-only 엔진 설치 단계만 실행합니다
|
|
48
|
-
|
|
49
|
-
옵션:
|
|
50
|
-
--version, -v 설치된 zigmini 버전을 출력합니다
|
|
51
|
-
--help, -h 이 도움말을 출력합니다
|
|
52
|
-
`;
|
|
17
|
+
export const HELP = message("ko", "help");
|
|
53
18
|
|
|
54
19
|
export interface CliResult {
|
|
55
20
|
readonly code: number;
|
|
@@ -86,7 +51,7 @@ export async function runCommand(argv: readonly string[], deps: LaunchdDeps, ctx
|
|
|
86
51
|
if (command === "pair") {
|
|
87
52
|
const apk = rest.length === 1 && rest[0] === "--apk";
|
|
88
53
|
if (rest.length !== 0 && !apk) return usage();
|
|
89
|
-
return await pairCommand({ fetch: (url, init) => fetch(url, init), webPort: ctx.paths.ports.web, now: Date.now, out: deps.out, err: deps.err }, apk);
|
|
54
|
+
return await pairCommand({ lang: resolveLanguage(ctx.paths.home, ctx.env), fetch: (url, init) => fetch(url, init), webPort: ctx.paths.ports.web, now: Date.now, out: deps.out, err: deps.err }, apk);
|
|
90
55
|
}
|
|
91
56
|
return null;
|
|
92
57
|
}
|
|
@@ -127,6 +92,15 @@ if (import.meta.main) {
|
|
|
127
92
|
}
|
|
128
93
|
if (argv[0] === UPDATER_COMMAND) process.exit(await helperCommand(deps, ctx));
|
|
129
94
|
if (argv[0] === HANDOFF_COMMAND) process.exit(await handoffCommand(deps, ctx, argv.slice(1)));
|
|
95
|
+
if (argv[0] === ROTATE_COMMAND || argv[0] === ROTATE_RUN_COMMAND) {
|
|
96
|
+
const refusal = argv.includes("--restart") ? cliGate("restart", ctx.paths.home, kind, notice) : null;
|
|
97
|
+
if (refusal !== null) {
|
|
98
|
+
deps.err(`${refusal}\n`);
|
|
99
|
+
process.exit(EXIT_DECLINED);
|
|
100
|
+
}
|
|
101
|
+
const args = argv[0] === ROTATE_RUN_COMMAND ? [...argv.slice(1), "--foreground"] : argv.slice(1);
|
|
102
|
+
process.exit(await rotateCommand(args, deps, ctx));
|
|
103
|
+
}
|
|
130
104
|
if (argv[0] === "update") {
|
|
131
105
|
process.exit(await updateCommand(argv.slice(1), { webPort: ctx.paths.ports.web, fetch: (url, init) => fetch(url, init), prompt: stdinPrompt(),
|
|
132
106
|
out: deps.out, err: deps.err, sleep: (ms) => Bun.sleep(ms), now: Date.now }));
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type Env, LEGACY_HOME } from "../runtime/paths.ts";
|
|
4
|
+
|
|
5
|
+
export type Language = "en" | "ko";
|
|
6
|
+
/** One plain en/ko line in <install home>/language; independent of service config.env. */
|
|
7
|
+
export const LANGUAGE_FILE = "language";
|
|
8
|
+
export const LANGUAGE_QUESTION = "Language / 언어 [1] English [2] 한국어: ";
|
|
9
|
+
|
|
10
|
+
export const MESSAGES = {
|
|
11
|
+
help: { en: "zigmini - personal AI agent gateway\n\nNew installation: run `zigmini setup` for permission consent, engine installation, configuration and startup.\n\nUsage: zigmini <command> [options]\n\nCommands:\n setup Set up the install home, engine, configuration and launchd services\n start Register and start the gateway and bridge with launchd\n stop [--uninstall] Stop services; --uninstall also removes this package's launchd plists\n restart [gateway|bridge] Restart services\n status Show launchd, health and permission consent status\n doctor [--json] [--fix] Check the installation; --fix restarts only unhealthy launchd agents\n pair [--apk] Show a one-use phone enrollment QR, or an APK download QR (--apk); valid for 10 minutes\n rotate-main [--restart] [--wait <seconds>] [--foreground]\n Replace the main conversation's engine session after its turn ends (default wait: 900s).\n Runs detached by default; results: <home>/logs/rotate-main.log.\n --restart restarts the gateway first; --foreground runs in this terminal.\n consent Show and record full-access permission consent; stop on refusal\n update [version] [--force] Install a version (default: latest), back up the DB and restart; roll back on failure.\n --force allows active turns; the gateway must be running.\n migrate [--from <old home>] [--checkout <repository>] [--accept-license] [--dry-run]\n Copy a Docker installation (~/" + LEGACY_HOME + "); keep the old directory unchanged.\n migrate-run [--from <old home>] [--checkout <repository>] [--access ... --domain ...] [--dry-run]\n Wait for idle, stop, back up, migrate, set up and check; restore the old run on failure.\n\nSetup options:\n --lang <en|ko> Language (saved in <home>/language)\n --non-interactive Do not ask setup questions\n --accept-license Accept the engine license\n --agent-dir <path> Dedicated agent directory instead of shared ~/.omo/agent\n --notion-token-file <path> Notion token file; no token disables the calendar\n --access <tailscale|lan|domain>, --domain <https://address>\n Choose external access\n --engine-only Run only the pinned engine installation step\n\nOptions:\n --version, -v Print the installed zigmini version\n --help, -h Print this help\n", ko: "zigmini - 개인 AI 에이전트 게이트웨이\n\n처음 설치했으면 먼저 `zigmini setup`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.\n\n사용법: zigmini <명령> [옵션]\n\n명령:\n setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작\n start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다\n stop 게이트웨이와 브리지를 멈춥니다\n stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다\n restart [gateway|bridge] 다시 시작합니다\n status launchd 상태, health, 권한 동의 상태를 보여 줍니다\n doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)\n pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다\n pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다\n rotate-main [--restart] [--wait <초>] [--foreground]\n main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).\n 기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다\n (--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)\n consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)\n update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다\n (--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)\n migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]\n Docker 설치(~/" + LEGACY_HOME + ")를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다\n migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]\n 유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다\n\nsetup 옵션:\n --lang <en|ko> Language / 언어 (saved in <home>/language)\n --non-interactive 묻지 않습니다\n --accept-license 엔진 라이선스에 동의합니다\n --agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더\n --notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)\n --access <tailscale|lan|domain>, --domain <https://주소>\n 외부 접속 선택\n --engine-only 엔진 설치 단계만 실행합니다\n\n옵션:\n --version, -v 설치된 zigmini 버전을 출력합니다\n --help, -h 이 도움말을 출력합니다\n" },
|
|
12
|
+
permissionNotice: { en: "zigmini permission notice (notice version 1)\n\n1. The zigmini gateway runs the OMO agent with the full-access permission preset.\n2. The full-access preset allows every permission on every target. The agent does not ask for approval for each tool call.\n3. The agent has file read, write and edit tools, and a shell command (bash) tool.\n4. Shell commands run with this macOS account's permissions, without a sandbox or isolation.\n5. The agent uses the network for model API calls, web search (web_search) and web fetching (webfetch).\n6. The agent uses model accounts logged in through auth.json in the agent directory and consumes their usage allowance.\n7. The default agent directory is ~/.omo/agent. Other OMO instances using that directory share its login information (auth.json) and settings (settings.json).\n8. The gateway and bridge run as this macOS user's LaunchAgents. launchd restarts them after an error exit.\n9. Requests from enrolled phone apps run with the same full-access permissions.\n10. If you do not agree, zigmini installs nothing and does not start the gateway or bridge.", ko: "zigmini 권한 안내 (안내문 버전 1)\n\n1. zigmini 게이트웨이는 OMO 에이전트를 권한 프리셋 full-access로 실행합니다.\n2. full-access 프리셋은 모든 권한, 모든 대상에 '허용' 규칙 하나만 둡니다. 그래서 에이전트는 도구를 쓸 때마다 사용자 승인을 받지 않습니다.\n3. 에이전트에는 파일 읽기(read), 쓰기(write), 고치기(edit) 도구와 셸 명령(bash) 도구가 있습니다.\n4. 셸 명령은 샌드박스 없이 이 macOS 사용자 계정의 권한으로 실행됩니다. 격리는 없습니다.\n5. 에이전트는 네트워크를 씁니다: 모델 API 호출, 웹 검색(web_search), 웹 가져오기(webfetch).\n6. 에이전트는 에이전트 폴더의 auth.json에 로그인한 모델 계정을 쓰고, 그 계정의 사용량을 소비합니다.\n7. 기본 에이전트 폴더는 ~/.omo/agent입니다. 같은 폴더를 쓰는 다른 OMO와 로그인 정보(auth.json)와 설정(settings.json)을 같이 씁니다.\n8. 게이트웨이와 브리지는 이 macOS 사용자의 LaunchAgent로 실행되고, 오류로 끝나면 launchd가 다시 시작합니다.\n9. 등록한 폰 앱이 보낸 요청도 같은 full-access 권한으로 실행됩니다.\n10. 동의하지 않으면 zigmini는 아무것도 설치하지 않고, 게이트웨이와 브리지는 시작하지 않습니다." },
|
|
13
|
+
unknownOption: { en: "Unknown option: {flag}", ko: "알 수 없는 옵션: {flag}" },
|
|
14
|
+
missingValue: { en: "{flag} requires a value", ko: "{flag} 에 값이 필요합니다" },
|
|
15
|
+
badValue: { en: "{flag} cannot contain a newline or NUL", ko: "{flag} 값에 줄바꿈이나 NUL 문자를 쓸 수 없습니다" },
|
|
16
|
+
badLanguage: { en: "--lang must be en or ko", ko: "--lang 은 en 또는 ko입니다" },
|
|
17
|
+
chooseLanguage: { en: "Choose 1 (English) or 2 (한국어).\n", ko: "1(English) 또는 2(한국어)를 고르세요.\n" },
|
|
18
|
+
badAccess: { en: "--access must be one of {choices}", ko: "--access 는 {choices} 중 하나입니다" },
|
|
19
|
+
domainTogether: { en: "Use --domain with --access domain", ko: "--domain 은 --access domain 과 함께 씁니다" },
|
|
20
|
+
domainRequired: { en: "--access domain requires --domain <https://address>", ko: "--access domain 에는 --domain <https://주소> 가 필요합니다" },
|
|
21
|
+
platform: { en: "zigmini requires an Apple Silicon (arm64) Mac. (This device: {platform} {arch})", ko: "zigmini는 Apple Silicon(arm64) 맥에서만 동작합니다. (이 기기: {platform} {arch})" },
|
|
22
|
+
bunVersion: { en: "zigmini requires Bun {min} or newer. (Current: {version}) Run `bun upgrade`.", ko: "zigmini에는 Bun {min} 이상이 필요합니다. (지금: {version}) `bun upgrade`로 올리세요." },
|
|
23
|
+
loginHint: { en: "Run `{command}` in a terminal and type `/login`.", ko: "모델 계정에 로그인하세요: 터미널에서 `{command}`를 실행한 뒤 `/login`을 입력합니다." },
|
|
24
|
+
loginBlock: { en: "\n========================================\n{hint}\n========================================\n\n", ko: "\n========================================\n{hint}\n========================================\n\n" },
|
|
25
|
+
engineLicense: { en: "Type y to accept the engine license: ", ko: "엔진 라이선스에 동의하면 y를 입력하세요: " },
|
|
26
|
+
engineDone: { en: "Engine: {target} installed and smoke-tested.\n", ko: "엔진: {target} 설치와 기본 동작 확인을 마쳤습니다.\n" },
|
|
27
|
+
engineDeclined: { en: "Installation stopped because the engine license was not accepted. Non-interactive setup requires --accept-license.\n", ko: "엔진 라이선스에 동의하지 않아 설치를 멈췄습니다. 비대화 설치는 --accept-license 가 필요합니다.\n" },
|
|
28
|
+
engineFailed: { en: "Engine installation failed: {error}\n", ko: "엔진 설치 실패: {error}\n" },
|
|
29
|
+
engineChanged: { en: "The engine license has changed. Run `zigmini setup` to accept it again", ko: "엔진 라이선스가 바뀌었습니다. `zigmini setup`으로 다시 동의하세요" },
|
|
30
|
+
engineSummary: { en: "Sustainable Use License 1.0: personal, non-commercial or internal business use; free non-commercial distribution. Modified by zigmini. Full text: {path}", ko: "Sustainable Use License 1.0: 개인·비상업 또는 내부 업무용 사용, 무료 비상업 배포. zigmini 수정본입니다. 전문: {path}" },
|
|
31
|
+
notionFile: { en: "Cannot read Notion token file: {path}\n", ko: "Notion 토큰 파일을 읽지 못했습니다: {path}\n" },
|
|
32
|
+
notionPrompt: { en: "Notion token (leave blank to disable the calendar): ", ko: "Notion 토큰(비우면 일정 기능 꺼짐): " },
|
|
33
|
+
notionState: { en: "Notion calendar: {state}\n", ko: "Notion 일정: {state}\n" },
|
|
34
|
+
tokenOn: { en: "on (token present)", ko: "켜짐(토큰 있음)" },
|
|
35
|
+
tokenOff: { en: "off (no token)", ko: "꺼짐(토큰 없음)" },
|
|
36
|
+
accessSaved: { en: "External access: {choice} {url} (change with --access)\n", ko: "외부 접속: {choice} {url} (바꾸려면 --access)\n" },
|
|
37
|
+
accessPrompt: { en: "Choose external access [{choices}, leave blank for this Mac only]: ", ko: "외부 접속을 고르세요 [{choices}, 비우면 맥 안에서만]: " },
|
|
38
|
+
accessNone: { en: "External access: not selected (web access is limited to this Mac).\n", ko: "외부 접속: 고르지 않음(웹은 맥 안에서만 엽니다. 폰은 나중에 `zigmini setup --access`로 엽니다)\n" },
|
|
39
|
+
accessInvalid: { en: "External access must be one of {choices}: {answer}\n", ko: "외부 접속은 {choices} 중 하나입니다: {answer}\n" },
|
|
40
|
+
domainPrompt: { en: "Your domain URL (https://...): ", ko: "자기 도메인 주소(https://...): " },
|
|
41
|
+
accessFailed: { en: "Cannot configure external access.\n{error}\n", ko: "외부 접속을 정하지 못했습니다.\n{error}\n" },
|
|
42
|
+
accessDone: { en: "External access: {choice} {url} (app port {port}; phone QR codes use this address)\n", ko: "외부 접속: {choice} {url} (앱 포트 {port}, 폰 등록 QR이 이 주소를 씁니다)\n" },
|
|
43
|
+
domainOk: { en: "External access checked: {url}/api/enroll/ping responded correctly\n", ko: "외부 접속 확인: {url}/api/enroll/ping 응답 정상\n" },
|
|
44
|
+
domainWarning: { en: "Warning: cannot reach the gateway at {url} ({detail}). The installation was kept.\n{guide}\n", ko: "경고: {url} 에서 게이트웨이에 닿지 못했습니다({detail}). 설치는 그대로 두었습니다.\n{guide}\n" },
|
|
45
|
+
home: { en: "Install home: {path}\n", ko: "홈 폴더: {path}\n" },
|
|
46
|
+
rules: { en: "Install rules file: {path} (empty)\n", ko: "이 설치의 규칙 파일: {path} (비어 있음)\n" },
|
|
47
|
+
agent: { en: "Agent directory: {path}{kind}\n", ko: "에이전트 폴더: {path}{kind}\n" },
|
|
48
|
+
dedicated: { en: " (dedicated)", ko: " (전용)" },
|
|
49
|
+
shared: { en: " (shared)", ko: " (공유)" },
|
|
50
|
+
providers: { en: "Model accounts: logged in ({count} providers)\n", ko: "모델 계정: 로그인됨(제공자 {count}개)\n" },
|
|
51
|
+
voice: { en: "Voice: {state} (change in web or app settings)\n", ko: "음성: {state} (웹·앱 설정에서 바꿉니다)\n" },
|
|
52
|
+
off: { en: "off", ko: "꺼짐" }, on: { en: "on", ko: "켜짐" },
|
|
53
|
+
startFailed: { en: "Service start failed. Check `zigmini status` and logs in {path}.\n", ko: "서비스 시작에 실패했습니다. `zigmini status`와 로그 폴더 {path}를 확인하세요.\n" },
|
|
54
|
+
setupDone: { en: "Setup complete.\n Web: http://127.0.0.1:{port}\n Status: zigmini status\n Stop: zigmini stop\n", ko: "설치를 마쳤습니다.\n 웹: http://127.0.0.1:{port}\n 상태 확인: zigmini status\n 멈추기: zigmini stop\n" },
|
|
55
|
+
setupAccessGuidance: { en: "To use the phone app, first configure external access: `zigmini setup --access <tailscale|lan|domain>`.", ko: "폰 앱을 쓰려면 먼저 외부 접속을 고르세요: `zigmini setup --access <tailscale|lan|domain>`." },
|
|
56
|
+
webQrGuide: { en: "Open http://127.0.0.1:{port} and choose '설정' (Settings). Scan the app download QR under '앱 내려받기' and the device enrollment QR under '기기 등록'.\n", ko: "http://127.0.0.1:{port} 웹 화면에서 '설정'을 여세요. '앱 내려받기'의 앱 내려받기 QR과 '기기 등록'의 폰 등록 QR을 스캔하세요.\n" },
|
|
57
|
+
accessGuidance: { en: "To use the phone app, first run `zigmini setup --access <tailscale|lan|domain>`, then `zigmini pair --apk` and `zigmini pair`.", ko: "폰 앱을 쓰려면 먼저 `zigmini setup --access <tailscale|lan|domain>`, 그 뒤 `zigmini pair --apk`, `zigmini pair`를 실행하세요." },
|
|
58
|
+
globalOverwrite: { en: "Global omo {current} is installed. Replace it with {version}? (y/N): ", ko: "전역 omo {current}이 설치되어 있습니다. {version}으로 덮어쓸까요? (y/N): " },
|
|
59
|
+
globalKeep: { en: "Keeping global omo {current}; requested version is {version}.\n", ko: "전역 omo {current}을 유지합니다. 요청한 버전은 {version}입니다.\n" },
|
|
60
|
+
globalDone: { en: "Global omo {version} is available.\n", ko: "전역 omo {version}을 쓸 수 있습니다.\n" },
|
|
61
|
+
globalFailed: { en: "Global omo installation failed: {error}\nUse the pinned engine command below to log in.\n", ko: "전역 omo 설치 실패: {error}\n아래 고정 엔진 명령으로 로그인하세요.\n" },
|
|
62
|
+
loginWait: { en: "After logging in, press Enter to check again; type s to skip: ", ko: "로그인한 뒤 Enter로 다시 확인하세요. 건너뛰려면 s를 입력하세요: " },
|
|
63
|
+
loginWarning: { en: "Warning: Until you log in, the gateway will not answer chats.\n", ko: "경고: 로그인하기 전까지 게이트웨이가 채팅에 답하지 않습니다.\n" },
|
|
64
|
+
loginPending: { en: "No model account found yet.\n", ko: "아직 로그인한 모델 계정이 없습니다.\n" },
|
|
65
|
+
domainUrl: { en: "Your domain is not a URL: {text}", ko: "자기 도메인 주소가 URL이 아닙니다: {text}" },
|
|
66
|
+
domainTls: { en: "Your domain must use https://. TLS is required to protect app credentials in transit.", ko: "자기 도메인은 https:// 주소만 받습니다. 앱의 인증 값이 평문으로 지나가지 않도록 TLS가 필요합니다." },
|
|
67
|
+
domainFormat: { en: "Use https://<domain>[:port] without a path, query, fragment or user information.", ko: "자기 도메인은 https://<도메인>[:포트] 형식만 받습니다(경로, ?, #, 사용자 정보 없이)." },
|
|
68
|
+
tailscaleJson: { en: "Cannot read the output of tailscale status --json.", ko: "tailscale status --json 출력을 읽지 못했습니다." },
|
|
69
|
+
tailscaleState: { en: "Tailscale is not connected (state: {state}). Log in and connect in the Tailscale app.", ko: "Tailscale이 연결되어 있지 않습니다(상태: {state}). Tailscale 앱에서 로그인하고 연결하세요." },
|
|
70
|
+
tailscaleHost: { en: "Cannot find this Mac's MagicDNS name or 100.x address in Tailscale.", ko: "Tailscale에서 이 맥의 MagicDNS 이름과 100.x 주소를 찾지 못했습니다." },
|
|
71
|
+
tailscaleFailed: { en: "tailscale status failed with exit code {code}. Check that the Tailscale app is running.", ko: "tailscale status 가 종료 코드 {code}로 실패했습니다. Tailscale 앱이 실행 중인지 확인하세요." },
|
|
72
|
+
tailscaleGuide: { en: "Tailscale is not installed on this Mac. Follow these steps, then retry the command.\n 1. Mac: install Tailscale from https://tailscale.com/download/mac and log in.\n 2. Phone: install Tailscale from the Play Store and log in with the same account.\n 3. Enable MagicDNS in the Tailscale admin console to use device names.", ko: "Tailscale이 이 맥에 없습니다. 아래 순서로 설치한 뒤 같은 명령을 다시 실행하세요.\n 1. 맥: https://tailscale.com/download/mac 에서 Tailscale을 설치하고 로그인합니다.\n 2. 폰: Play 스토어에서 Tailscale을 설치하고 맥과 같은 계정으로 로그인합니다.\n 3. Tailscale 관리 화면에서 MagicDNS를 켭니다(주소가 기기 이름으로 나옵니다)." },
|
|
73
|
+
lanMissing: { en: "Cannot find a private IP (10.x, 172.16-31.x, 192.168.x) on the default-route network. Connect to your home Wi-Fi and retry.", ko: "기본 경로 네트워크의 사설 IP(10.x, 172.16-31.x, 192.168.x)를 찾지 못했습니다. 집 Wi-Fi에 연결한 뒤 다시 실행하세요." },
|
|
74
|
+
proxyIntro: { en: "Check that the reverse proxy forwards this address to this Mac's app port. Caddy example (one Caddyfile block; Caddy obtains the certificate):", ko: "리버스 프록시가 아래 주소를 이 맥의 앱 포트로 넘기는지 확인하세요. Caddy 예시(Caddyfile 한 블록, 인증서는 Caddy가 받습니다):" },
|
|
75
|
+
proxyEnd: { en: "Forward router port 443 to the proxy host. After changing the settings, run `zigmini doctor` again.", ko: "공유기에서 443 포트를 프록시가 있는 기기로 넘겨야 합니다. 설정을 바꾼 뒤 `zigmini doctor`로 다시 확인합니다." },
|
|
76
|
+
pingBody: { en: "the response is not from a zigmini gateway", ko: "응답이 zigmini 게이트웨이의 것이 아님" },
|
|
77
|
+
accessLabel: { en: "External access", ko: "외부 접속" },
|
|
78
|
+
accessOff: { en: "not selected (app port off)", ko: "고르지 않음(앱 포트 꺼짐)" },
|
|
79
|
+
accessRead: { en: "Cannot read {file}. Select access again with `zigmini setup --access ...`", ko: "{file}를 읽지 못했습니다. `zigmini setup --access ...`로 다시 고르세요" },
|
|
80
|
+
accessCheck: { en: "{choice} {url} check failed: {detail}", ko: "{choice} {url} 확인 실패: {detail}" },
|
|
81
|
+
appOff: { en: "The app port is off. Run `zigmini setup --access <choice>` in a Mac terminal first.", ko: "앱 포트가 꺼져 있습니다. 맥 터미널에서 `zigmini setup --access <선택>`을 먼저 실행하세요." },
|
|
82
|
+
apiChoice: { en: "Choose one of {choices}.", ko: "선택은 {choices} 중 하나입니다." },
|
|
83
|
+
apiPing: { en: "{url} check failed: {detail}\n{guide}", ko: "{url} 확인 실패: {detail}\n{guide}" },
|
|
84
|
+
gatewayDown: { en: "Cannot connect to the gateway. Run `zigmini start`, then try again.", ko: "게이트웨이에 연결하지 못했습니다. `zigmini start`로 시작한 뒤 다시 하세요." },
|
|
85
|
+
badResponse: { en: "The gateway returned an unexpected response.", ko: "게이트웨이가 알 수 없는 응답을 보냈습니다." },
|
|
86
|
+
apkUnavailable: { en: "No app (APK) is available in this installation.", ko: "이 설치에는 내려받을 앱(APK)이 없습니다." },
|
|
87
|
+
apkPermission: { en: "Allow installation from unknown sources", ko: "출처를 알 수 없는 앱 설치 허가가 필요합니다" },
|
|
88
|
+
enrollmentText: { en: "Phone enrollment QR (one use within {minutes} minutes)\n{qr}Access URL: {url}\nScan with the zigmini phone app. If it expires, run `zigmini pair` for a new QR.\n", ko: "폰 등록 QR ({minutes}분 안에 한 번만 쓸 수 있음)\n{qr}접속 주소: {url}\n폰의 지그미니 앱에서 QR을 찍으세요. 만료되면 `zigmini pair`로 새 QR을 만듭니다.\n" },
|
|
89
|
+
apkText: { en: "App download QR (zigmini {version}, valid for {minutes} minutes)\n{qr}Access URL: {url}\nScan with the phone camera to download the APK. {permission}. If it expires, run `zigmini pair --apk` for a new QR.\n", ko: "앱 내려받기 QR (지그미니 {version}, {minutes}분 동안 쓸 수 있음)\n{qr}접속 주소: {url}\n폰 카메라로 QR을 찍어 APK를 받으세요. {permission}. 만료되면 `zigmini pair --apk`로 새 QR을 만듭니다.\n" },
|
|
90
|
+
gatewayResponse: { en: "gateway response {reason}", ko: "게이트웨이 응답 {reason}" },
|
|
91
|
+
pairFailed: { en: "Cannot create phone enrollment QR: {reason}", ko: "폰 등록 QR을 만들지 못했습니다: {reason}" },
|
|
92
|
+
apkFailed: { en: "Cannot create app download QR: {reason}", ko: "앱 내려받기 QR을 만들지 못했습니다: {reason}" },
|
|
93
|
+
readyProgress: { en: "Still starting ({seconds}s elapsed): {health}\n", ko: "아직 준비 중 ({seconds}초 경과): {health}\n" },
|
|
94
|
+
noHealth: { en: "no health response", ko: "health 응답 없음" },
|
|
95
|
+
readyWait: { en: "Waiting for the gateway (up to {seconds}s): {url}\n", ko: "게이트웨이 준비를 기다리는 중 (최대 {seconds}초): {url}\n" },
|
|
96
|
+
readyDone: { en: "Ready: http://127.0.0.1:{port}\n", ko: "준비됨: http://127.0.0.1:{port}\n" },
|
|
97
|
+
readyFailed: { en: "The gateway was not ready within {seconds}s. Logs: {path}\n", ko: "게이트웨이가 {seconds}초 안에 준비되지 않았습니다. 로그: {path}\n" },
|
|
98
|
+
launchState: { en: "launchd: {state}, pid={pid}, last exit code={exit}\n", ko: "launchd: {state}, pid={pid}, 마지막 종료 코드={exit}\n" },
|
|
99
|
+
running: { en: "running", ko: "실행 중" }, stopped: { en: "stopped", ko: "멈춤" }, unloaded: { en: "not loaded", ko: "등록 안 됨" },
|
|
100
|
+
logLine: { en: "{service} last log line: {line}\n", ko: "{service} 로그 마지막 줄: {line}\n" },
|
|
101
|
+
noLog: { en: "(no log yet)", ko: "(아직 로그 없음)" },
|
|
102
|
+
engineRepair: { en: "OMO remains disconnected. Check the bridge log. To reinstall the engine, run `zigmini setup --engine-only --accept-license`.\n", ko: "OMO 연결이 계속 끊겨 있습니다. 브리지 로그를 확인하세요. 엔진을 다시 설치하려면 `zigmini setup --engine-only --accept-license`를 실행하세요.\n" },
|
|
103
|
+
consentQuestion: { en: "Type '동의' to agree. Any other input or Enter declines: ", ko: "동의하려면 '동의'를 입력하세요. 다른 입력이나 Enter는 동의 안 함입니다: " },
|
|
104
|
+
consentDeclined: { en: "Nothing was installed because you did not agree. The gateway cannot be used.", ko: "동의하지 않아 아무것도 설치하지 않았습니다. 게이트웨이를 쓸 수 없습니다." },
|
|
105
|
+
consentValid: { en: "Permission consent: verified (notice version {version}, {date})\n", ko: "권한 동의: 확인됨(안내문 버전 {version}, {date})\n" },
|
|
106
|
+
consentStale: { en: "The permission notice changed (accepted version {old}, current version {version}). You must agree again.\n", ko: "권한 안내문이 바뀌었습니다(동의한 버전 {old}, 지금 버전 {version}). 다시 동의해야 합니다.\n" },
|
|
107
|
+
consentSaved: { en: "Permission consent recorded: {path}\n", ko: "권한 동의를 기록했습니다: {path}\n" },
|
|
108
|
+
configBad: { en: "config.env line {line} is not in KEY=VALUE format", ko: "config.env {line}번째 줄이 KEY=VALUE 형식이 아닙니다" },
|
|
109
|
+
legacyRefusal: { en: "This Mac uses the old launch method. Run `zigmini migrate`", ko: "이 맥은 옛 실행 방식입니다. `zigmini migrate`를 쓰세요" },
|
|
110
|
+
foreignPlist: { en: " This plist was not created by this package: {path}\n", ko: " 이 패키지가 만든 plist가 아닙니다: {path}\n" },
|
|
111
|
+
protectedFolder: { en: "{package} is inside the macOS protected folder {folder}; launchd cannot run it. Install the package elsewhere.\n", ko: "{package} 는 macOS 보호 폴더({folder}) 안이라 launchd가 실행하지 못합니다. 패키지를 다른 곳에 설치하세요.\n" },
|
|
112
|
+
badPort: { en: "{port} must be an integer from 1 to 65535.\n", ko: "{port} 값이 1~65535 사이의 정수가 아닙니다.\n" },
|
|
113
|
+
serviceFailed: { en: "Cannot start {label}: {error}\n", ko: "{label} 시작 실패: {error}\n" },
|
|
114
|
+
serviceRunning: { en: "Running: {label}\n", ko: "실행 중: {label}\n" },
|
|
115
|
+
serviceBootstrap: { en: "Cannot register {label}: {error}\n", ko: "{label} 등록 실패: {error}\n" },
|
|
116
|
+
serviceRegistered: { en: "Registered: {label} ({path})\n", ko: "등록됨: {label} ({path})\n" },
|
|
117
|
+
schemaRefused: { en: "The bridge was not started. Gateway health ({url}) reports omo=schema_too_new.\n", ko: "브리지를 시작하지 않았습니다. 게이트웨이 health({url})가 omo=schema_too_new를 보입니다.\n" },
|
|
118
|
+
} as const satisfies Record<string, Readonly<Record<Language, string>>>;
|
|
119
|
+
|
|
120
|
+
export type MessageKey = keyof typeof MESSAGES;
|
|
121
|
+
export function message(lang: Language, key: MessageKey, values: Readonly<Record<string, string | number>> = {}): string {
|
|
122
|
+
return MESSAGES[key][lang].replace(/\{(\w+)\}/g, (placeholder, name: string) => String(values[name] ?? placeholder));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isLanguage(value: unknown): value is Language {
|
|
126
|
+
return value === "en" || value === "ko";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveLanguage(home: string, env: Env, read: (path: string) => string | null = (path) => existsSync(path) ? readFileSync(path, "utf8") : null): Language {
|
|
130
|
+
const file = join(home, LANGUAGE_FILE);
|
|
131
|
+
const saved = read(file)?.trim();
|
|
132
|
+
if (isLanguage(saved)) return saved;
|
|
133
|
+
const locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || "";
|
|
134
|
+
return locale.toLowerCase().startsWith("ko") ? "ko" : "en";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Localize output from the unchanged consent and engine-license modules without changing their receipt hashes. */
|
|
138
|
+
export function localize(text: string, lang: Language): string {
|
|
139
|
+
if (lang === "ko") return text;
|
|
140
|
+
for (const entry of Object.values(MESSAGES)) {
|
|
141
|
+
const names: string[] = [];
|
|
142
|
+
const pattern = entry.ko.trim().split(/(\{\w+\})/).map((part) => {
|
|
143
|
+
if (/^\{\w+\}$/.test(part)) { names.push(part.slice(1, -1)); return "([\\s\\S]*?)"; }
|
|
144
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
145
|
+
}).join("");
|
|
146
|
+
const match = new RegExp(`^(\\s*)${pattern}(\\s*)$`).exec(text);
|
|
147
|
+
if (match === null) continue;
|
|
148
|
+
const values: Record<string, string> = {};
|
|
149
|
+
for (const [i, name] of names.entries()) values[name] = match[i + 2] ?? "";
|
|
150
|
+
return (match[1] ?? "") + entry.en.trim().replace(/\{(\w+)\}/g, (placeholder, name: string) => values[name] ?? placeholder) + (match[names.length + 2] ?? "");
|
|
151
|
+
}
|
|
152
|
+
return text.includes("\n") ? text.split("\n").map((line) => localize(line, lang)).join("\n") : text;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Call only after consent and after creating the install home. */
|
|
156
|
+
export function saveLanguage(home: string, lang: Language): void {
|
|
157
|
+
const file = join(home, LANGUAGE_FILE);
|
|
158
|
+
const text = `${lang}\n`;
|
|
159
|
+
if (!existsSync(file) || readFileSync(file, "utf8") !== text) writeFileSync(file, text, { mode: 0o600 });
|
|
160
|
+
}
|