zigmini 0.8.0-beta.6 → 0.9.0-beta.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/README.ko.md +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -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 +69 -1
- package/docs/manual.md +67 -1
- 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 +169 -7
- 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/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -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 +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
package/src/cli/pair.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
|
|
2
|
+
import { type Language, message } from "./messages.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* `zigmini pair`
|
|
5
|
+
* `zigmini pair` (plan Todo 15): the phone enrollment QR drawn in the terminal with
|
|
5
6
|
* the same vendored qrcode-generator the web settings use. The token stays inside the QR; no text here carries it.
|
|
6
7
|
* The vendored library is loaded on demand so that `src/` keeps importing without `web/` (the mutation runs copy only `src/`).
|
|
7
8
|
*/
|
|
@@ -9,12 +10,12 @@ import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
|
|
|
9
10
|
export const PAIR_CREATED_BY = "cli";
|
|
10
11
|
export const QR_MARGIN = 4;
|
|
11
12
|
export const PAIR_ERRORS = {
|
|
12
|
-
gateway_down: "
|
|
13
|
-
access_url_unknown: "
|
|
14
|
-
bad_response: "
|
|
15
|
-
apk_unavailable: "
|
|
13
|
+
gateway_down: message("ko", "gatewayDown"),
|
|
14
|
+
access_url_unknown: message("ko", "accessGuidance"),
|
|
15
|
+
bad_response: message("ko", "badResponse"),
|
|
16
|
+
apk_unavailable: message("ko", "apkUnavailable"),
|
|
16
17
|
} as const;
|
|
17
|
-
export const APK_INSTALL_HINT = "
|
|
18
|
+
export const APK_INSTALL_HINT = message("ko", "apkPermission");
|
|
18
19
|
export type PairErrorReason = keyof typeof PAIR_ERRORS | `http_${number}`;
|
|
19
20
|
|
|
20
21
|
export class PairError extends Error {
|
|
@@ -32,6 +33,7 @@ export interface Enrollment {
|
|
|
32
33
|
export type FetchLike = (url: string, init: RequestInit) => Promise<{ readonly status: number; json(): Promise<unknown> }>;
|
|
33
34
|
|
|
34
35
|
export interface PairDeps {
|
|
36
|
+
readonly lang?: Language;
|
|
35
37
|
readonly fetch: FetchLike;
|
|
36
38
|
readonly webPort: number;
|
|
37
39
|
readonly now: () => number;
|
|
@@ -78,22 +80,27 @@ export function minutesLeft(expiresAt: number, now: number): number {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/** The terminal text: caption, QR and the address. It never contains the token or the payload. */
|
|
81
|
-
export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number): string {
|
|
82
|
-
return
|
|
83
|
-
+ `접속 주소: ${enrollment.url}\n폰의 지그미니 앱에서 QR을 찍으세요. 만료되면 \`zigmini pair\`로 새 QR을 만듭니다.\n`;
|
|
83
|
+
export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number, lang: Language = "ko"): string {
|
|
84
|
+
return message(lang, "enrollmentText", { minutes: minutesLeft(enrollment.expiresAt, now), qr: qrAnsi(qrcode, enrollment.payload), url: enrollment.url });
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
function failureReason(error: unknown): string {
|
|
87
|
+
function failureReason(error: unknown, lang: Language): string {
|
|
87
88
|
const reason = error instanceof PairError ? error.reason : error instanceof Error ? error.message : String(error);
|
|
88
|
-
|
|
89
|
+
switch (reason) {
|
|
90
|
+
case "gateway_down": return message(lang, "gatewayDown");
|
|
91
|
+
case "access_url_unknown": return message(lang, "accessGuidance");
|
|
92
|
+
case "bad_response": return message(lang, "badResponse");
|
|
93
|
+
case "apk_unavailable": return message(lang, "apkUnavailable");
|
|
94
|
+
default: return message(lang, "gatewayResponse", { reason });
|
|
95
|
+
}
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
export function pairFailureLine(error: unknown): string {
|
|
92
|
-
return
|
|
98
|
+
export function pairFailureLine(error: unknown, lang: Language = "ko"): string {
|
|
99
|
+
return message(lang, "pairFailed", { reason: failureReason(error, lang) });
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
export function apkFailureLine(error: unknown): string {
|
|
96
|
-
return
|
|
102
|
+
export function apkFailureLine(error: unknown, lang: Language = "ko"): string {
|
|
103
|
+
return message(lang, "apkFailed", { reason: failureReason(error, lang) });
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
export interface ApkLink {
|
|
@@ -122,33 +129,33 @@ export async function mintApkLink(fetchFn: FetchLike, webPort: number): Promise<
|
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/** The terminal text for the APK QR: caption, QR, the address without the link token, and the install permission hint. */
|
|
125
|
-
export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number): string {
|
|
126
|
-
return
|
|
127
|
-
+ `접속 주소: ${new URL(link.url).origin}\n폰 카메라로 QR을 찍어 APK를 받으세요. ${APK_INSTALL_HINT}. 만료되면 \`zigmini pair --apk\`로 새 QR을 만듭니다.\n`;
|
|
132
|
+
export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number, lang: Language = "ko"): string {
|
|
133
|
+
return message(lang, "apkText", { version: link.versionName, minutes: minutesLeft(link.expiresAt, now), qr: qrAnsi(qrcode, link.url), url: new URL(link.url).origin, permission: message(lang, "apkPermission") });
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
|
|
136
|
+
export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
|
|
131
137
|
const link = await mintApkLink(fetchFn, webPort);
|
|
132
|
-
return apkLinkText(await loadQrcode(), link, now());
|
|
138
|
+
return apkLinkText(await loadQrcode(), link, now(), lang);
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
async function loadQrcode(): Promise<QRCodeFactory> {
|
|
136
142
|
return (await import("../../web/vendor/qrcode.js")).default;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
/** The QR text for
|
|
140
|
-
export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
|
|
145
|
+
/** The QR text for `zigmini pair`; throws PairError when the gateway cannot give a token. */
|
|
146
|
+
export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
|
|
141
147
|
const enrollment = await mintEnrollment(fetchFn, webPort);
|
|
142
|
-
return enrollmentText(await loadQrcode(), enrollment, now());
|
|
148
|
+
return enrollmentText(await loadQrcode(), enrollment, now(), lang);
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
/** `zigmini pair` draws the enrollment QR; `zigmini pair --apk` draws the APK download QR instead. */
|
|
146
152
|
export async function pairCommand(deps: PairDeps, apk = false): Promise<number> {
|
|
153
|
+
const lang = deps.lang ?? "ko";
|
|
147
154
|
try {
|
|
148
|
-
deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now) : await enrollmentQr(deps.fetch, deps.webPort, deps.now));
|
|
155
|
+
deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now, lang) : await enrollmentQr(deps.fetch, deps.webPort, deps.now, lang));
|
|
149
156
|
return 0;
|
|
150
157
|
} catch (error) {
|
|
151
|
-
deps.err(`${apk ? apkFailureLine(error) : pairFailureLine(error)}\n`);
|
|
158
|
+
deps.err(`${apk ? apkFailureLine(error, lang) : pairFailureLine(error, lang)}\n`);
|
|
152
159
|
return 1;
|
|
153
160
|
}
|
|
154
161
|
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -2,16 +2,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { engine } from "../engine/index.ts";
|
|
5
|
+
import { engine, ENGINE_PACKAGE } from "../engine/index.ts";
|
|
6
6
|
import { installEngine } from "../engine/install.ts";
|
|
7
7
|
import { ensureInstallRules, INSTALL_RULES_FILE } from "../agent-rules.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
|
|
9
9
|
import { type InstallKind, installKind } from "../runtime/install-kind.ts";
|
|
10
10
|
import { agentDir as defaultAgentDir, providerCount } from "../model-accounts.ts";
|
|
11
11
|
import type { Env, RuntimePaths } from "../runtime/paths.ts";
|
|
12
12
|
import { writeVoiceSetting } from "../voice-setting.ts";
|
|
13
13
|
import { parseConfigEnv, startCommand, systemContext, systemDeps } from "./launchd.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { isLanguage, LANGUAGE_QUESTION, type Language, localize, message, resolveLanguage, saveLanguage } from "./messages.ts";
|
|
15
15
|
import { SYSTEM_NETWORK, type Network } from "../enroll.ts";
|
|
16
16
|
import {
|
|
17
17
|
ACCESS_CHOICES, type AccessChoice, type AccessRecord, appPortEnv, isAccessChoice, parseDomain, pingAccess, type PingResult, proxyGuide, readAccess,
|
|
@@ -38,21 +38,24 @@ export interface SetupOptions {
|
|
|
38
38
|
readonly domain?: string;
|
|
39
39
|
readonly notionTokenFile?: string;
|
|
40
40
|
readonly agentDir?: string;
|
|
41
|
+
readonly lang?: Language;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface Platform { readonly platform: string; readonly arch: string; readonly bunVersion: string }
|
|
44
45
|
|
|
45
46
|
export interface SetupDeps {
|
|
47
|
+
readonly lang?: Language;
|
|
46
48
|
readonly platform: Platform;
|
|
47
49
|
readonly env: Env;
|
|
48
50
|
readonly paths: RuntimePaths;
|
|
49
51
|
readonly bunPath: string;
|
|
50
52
|
readonly installEngine: (options: { home: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
|
|
53
|
+
readonly globalOmoVersion: () => Promise<string | null>;
|
|
54
|
+
readonly installGlobalOmo: (version: string) => Promise<void>;
|
|
55
|
+
/** The executable checked or installed above; injected installers may keep the plain omo command. */
|
|
56
|
+
readonly globalOmoCommand?: () => string;
|
|
57
|
+
readonly providerCount: (agent: string) => number;
|
|
51
58
|
readonly start: () => Promise<number>;
|
|
52
|
-
/** The phone enrollment QR text after the services are up (plan Todo 15); rejects when the gateway gives no token. */
|
|
53
|
-
readonly enrollQr: () => Promise<string>;
|
|
54
|
-
/** The app download QR shown before the enrollment QR (plan Todo 20); rejects when the gateway serves no APK. */
|
|
55
|
-
readonly apkQr: () => Promise<string>;
|
|
56
59
|
readonly prompt: (question: string) => Promise<string | null>;
|
|
57
60
|
readonly network: Network;
|
|
58
61
|
readonly tailscale: () => TailscaleRun;
|
|
@@ -67,10 +70,10 @@ export interface SetupDeps {
|
|
|
67
70
|
readonly err: (text: string) => void;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir" } as const;
|
|
73
|
+
const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir", "--lang": "lang" } as const;
|
|
71
74
|
const BOOLEAN_FLAGS = { "--non-interactive": "nonInteractive", "--accept-license": "acceptLicense", "--engine-only": "engineOnly" } as const;
|
|
72
75
|
|
|
73
|
-
export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
|
|
76
|
+
export function parseSetupArgs(argv: readonly string[], lang: Language = "en"): SetupOptions | string {
|
|
74
77
|
const options: Record<string, string | boolean> = { nonInteractive: false, acceptLicense: false, engineOnly: false };
|
|
75
78
|
for (let i = 0; i < argv.length; i++) {
|
|
76
79
|
const flag = argv[i]!;
|
|
@@ -78,18 +81,19 @@ export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
|
|
|
78
81
|
options[BOOLEAN_FLAGS[flag as keyof typeof BOOLEAN_FLAGS]] = true;
|
|
79
82
|
continue;
|
|
80
83
|
}
|
|
81
|
-
if (!(flag in VALUE_FLAGS)) return
|
|
84
|
+
if (!(flag in VALUE_FLAGS)) return message(lang, "unknownOption", { flag });
|
|
82
85
|
const value = argv[i + 1];
|
|
83
|
-
if (value === undefined || value === "" || value.startsWith("--")) return
|
|
84
|
-
if (/[\0\n\r]/.test(value)) return
|
|
86
|
+
if (value === undefined || value === "" || value.startsWith("--")) return message(lang, "missingValue", { flag });
|
|
87
|
+
if (/[\0\n\r]/.test(value)) return message(lang, "badValue", { flag });
|
|
85
88
|
options[VALUE_FLAGS[flag as keyof typeof VALUE_FLAGS]] = value;
|
|
86
89
|
i++;
|
|
87
90
|
}
|
|
88
|
-
if (options.
|
|
89
|
-
if (options.
|
|
90
|
-
if (options.
|
|
91
|
+
if (options.lang !== undefined && !isLanguage(options.lang)) return message(lang, "badLanguage");
|
|
92
|
+
if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return message(lang, "badAccess", { choices: ACCESS_CHOICES.join(", ") });
|
|
93
|
+
if (options.domain !== undefined && options.access !== "domain") return message(lang, "domainTogether");
|
|
94
|
+
if (options.access === "domain" && options.domain === undefined) return message(lang, "domainRequired");
|
|
91
95
|
if (typeof options.domain === "string") {
|
|
92
|
-
const domain = parseDomain(options.domain);
|
|
96
|
+
const domain = parseDomain(options.domain, lang);
|
|
93
97
|
if ("error" in domain) return domain.error;
|
|
94
98
|
}
|
|
95
99
|
return options as unknown as SetupOptions;
|
|
@@ -103,11 +107,11 @@ export function bunAtLeast(version: string, min: readonly [number, number] = MIN
|
|
|
103
107
|
return major > min[0] || (major === min[0] && minor >= min[1]);
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
export function platformProblem(platform: Platform): string | null {
|
|
110
|
+
export function platformProblem(platform: Platform, lang: Language = "en"): string | null {
|
|
107
111
|
if (platform.platform !== "darwin" || platform.arch !== "arm64") {
|
|
108
|
-
return
|
|
112
|
+
return message(lang, "platform", { ...platform });
|
|
109
113
|
}
|
|
110
|
-
if (!bunAtLeast(platform.bunVersion)) return
|
|
114
|
+
if (!bunAtLeast(platform.bunVersion)) return message(lang, "bunVersion", { min: MIN_BUN.join("."), version: platform.bunVersion });
|
|
111
115
|
return null;
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -142,10 +146,61 @@ function quote(text: string): string {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/** Never throws: the engine's pinned launcher path (or the ZGW_OMO_BIN test candidate), whether or not it exists yet. */
|
|
145
|
-
export function loginHint(agent: string, home: string, bunPath: string, env: Env): string {
|
|
149
|
+
export function loginHint(agent: string, home: string, bunPath: string, env: Env, lang: Language = "en"): string {
|
|
146
150
|
const assignments = Object.entries(engine.env(agent)).map(([key, value]) => `${key}=${quote(value)}`).join(" ");
|
|
147
151
|
const bin = env.ZGW_OMO_BIN || join(home, "omo", "current", "node_modules", ".bin", "omo");
|
|
148
|
-
return
|
|
152
|
+
return message(lang, "loginHint", { command: `env ${assignments} ${quote(bunPath)} ${quote(bin)}` });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Login uses stock OMO; the gateway continues to use its separate, patched engine. */
|
|
156
|
+
export function stockOmoVersion(version = engine.pinnedVersion()): string {
|
|
157
|
+
return version.replace(/-zgw(?:\.\d+)?$/, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function loginStep(options: SetupOptions, deps: SetupDeps, agent: string, dedicated: boolean): Promise<void> {
|
|
161
|
+
const lang = deps.lang ?? "en";
|
|
162
|
+
const version = stockOmoVersion();
|
|
163
|
+
let global = true;
|
|
164
|
+
try {
|
|
165
|
+
const current = await deps.globalOmoVersion();
|
|
166
|
+
let install = current === null;
|
|
167
|
+
if (current !== null && current !== version) {
|
|
168
|
+
if (!options.nonInteractive) {
|
|
169
|
+
const answer = await deps.prompt(message(lang, "globalOverwrite", { current, version }));
|
|
170
|
+
install = answer?.trim().toLowerCase() === "y";
|
|
171
|
+
}
|
|
172
|
+
if (!install) deps.out(message(lang, "globalKeep", { current, version }));
|
|
173
|
+
}
|
|
174
|
+
if (install) await deps.installGlobalOmo(version);
|
|
175
|
+
if (current === version || install) deps.out(message(lang, "globalDone", { version }));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
global = false;
|
|
178
|
+
deps.err(message(lang, "globalFailed", { error: error instanceof Error ? error.message : String(error) }));
|
|
179
|
+
}
|
|
180
|
+
let providers = deps.providerCount(agent);
|
|
181
|
+
if (providers >= 1) {
|
|
182
|
+
deps.out(message(lang, "providers", { count: providers }));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const command = quote(deps.globalOmoCommand?.() ?? "omo");
|
|
186
|
+
const hint = global
|
|
187
|
+
? message(lang, "loginHint", { command: dedicated ? `env OMO_CODING_AGENT_DIR=${quote(agent)} ${command}` : command })
|
|
188
|
+
: loginHint(agent, deps.paths.home, deps.bunPath, deps.env, lang);
|
|
189
|
+
deps.out(message(lang, "loginBlock", { hint }));
|
|
190
|
+
if (options.nonInteractive) return;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const answer = await deps.prompt(message(lang, "loginWait"));
|
|
193
|
+
if (answer === null || answer.trim().toLowerCase() === "s") {
|
|
194
|
+
deps.out(message(lang, "loginWarning"));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
providers = deps.providerCount(agent);
|
|
198
|
+
if (providers >= 1) {
|
|
199
|
+
deps.out(message(lang, "providers", { count: providers }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
deps.out(message(lang, "loginPending"));
|
|
203
|
+
}
|
|
149
204
|
}
|
|
150
205
|
|
|
151
206
|
function ensureDir(path: string, mode?: number): void {
|
|
@@ -159,116 +214,134 @@ function writeSecret(path: string, value: string): void {
|
|
|
159
214
|
}
|
|
160
215
|
|
|
161
216
|
async function engineStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
217
|
+
const lang = deps.lang ?? "en";
|
|
162
218
|
const confirmLicense = options.nonInteractive ? undefined : async (summary: string) => {
|
|
163
|
-
deps.out(`${summary}\n`);
|
|
164
|
-
const answer = await deps.prompt(
|
|
219
|
+
deps.out(`${localize(summary, lang)}\n`);
|
|
220
|
+
const answer = await deps.prompt(message(lang, "engineLicense"));
|
|
165
221
|
return answer?.trim().toLowerCase() === "y";
|
|
166
222
|
};
|
|
167
223
|
try {
|
|
168
224
|
const target = await deps.installEngine({ home: deps.paths.home, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
|
|
169
|
-
deps.out(
|
|
225
|
+
deps.out(message(lang, "engineDone", { target }));
|
|
170
226
|
return 0;
|
|
171
227
|
} catch (error) {
|
|
172
|
-
const
|
|
173
|
-
deps.err(
|
|
174
|
-
?
|
|
175
|
-
:
|
|
228
|
+
const detail = localize(error instanceof Error ? error.message : String(error), lang);
|
|
229
|
+
deps.err(detail === "engine_license_declined"
|
|
230
|
+
? message(lang, "engineDeclined")
|
|
231
|
+
: message(lang, "engineFailed", { error: detail }));
|
|
176
232
|
return EXIT_FAILED;
|
|
177
233
|
}
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
async function notionStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
237
|
+
const lang = deps.lang ?? "en";
|
|
181
238
|
const file = deps.paths.notionTokenFile;
|
|
182
239
|
let token: string | null = null;
|
|
183
240
|
if (options.notionTokenFile !== undefined) {
|
|
184
241
|
try {
|
|
185
242
|
token = readFileSync(options.notionTokenFile, "utf8").trim();
|
|
186
243
|
} catch {
|
|
187
|
-
deps.err(
|
|
244
|
+
deps.err(message(lang, "notionFile", { path: options.notionTokenFile }));
|
|
188
245
|
return EXIT_FAILED;
|
|
189
246
|
}
|
|
190
247
|
} else if (!options.nonInteractive && !existsSync(file)) {
|
|
191
|
-
token = (await deps.prompt(
|
|
248
|
+
token = (await deps.prompt(message(lang, "notionPrompt")))?.trim() ?? "";
|
|
192
249
|
}
|
|
193
250
|
if (token) {
|
|
194
251
|
ensureDir(dirname(file), 0o700);
|
|
195
252
|
writeSecret(file, token);
|
|
196
253
|
}
|
|
197
|
-
deps.out(
|
|
254
|
+
deps.out(message(lang, "notionState", { state: message(lang, existsSync(file) ? "tokenOn" : "tokenOff") }));
|
|
198
255
|
return 0;
|
|
199
256
|
}
|
|
200
257
|
|
|
201
258
|
/** The "외부 접속" step: a new choice is resolved and saved; without one, the saved choice is kept (or asked for interactively). */
|
|
202
259
|
async function accessStep(options: SetupOptions, deps: SetupDeps): Promise<AccessRecord | null | number> {
|
|
260
|
+
const lang = deps.lang ?? "en";
|
|
203
261
|
let choice = options.access;
|
|
204
262
|
let domain = options.domain;
|
|
205
263
|
if (choice === undefined) {
|
|
206
264
|
const saved = readAccess(deps.paths.home);
|
|
207
265
|
if (saved !== null && saved !== "invalid") {
|
|
208
|
-
deps.out(
|
|
266
|
+
deps.out(message(lang, "accessSaved", { ...saved }));
|
|
209
267
|
return saved;
|
|
210
268
|
}
|
|
211
269
|
if (options.nonInteractive) return null;
|
|
212
|
-
const answer = (await deps.prompt(
|
|
270
|
+
const answer = (await deps.prompt(message(lang, "accessPrompt", { choices: ACCESS_CHOICES.join("/") })))?.trim() ?? "";
|
|
213
271
|
if (answer === "") {
|
|
214
|
-
deps.out(
|
|
272
|
+
deps.out(message(lang, "accessNone"));
|
|
215
273
|
return null;
|
|
216
274
|
}
|
|
217
275
|
if (!isAccessChoice(answer)) {
|
|
218
|
-
deps.err(
|
|
276
|
+
deps.err(message(lang, "accessInvalid", { choices: ACCESS_CHOICES.join(", "), answer }));
|
|
219
277
|
return EXIT_USAGE;
|
|
220
278
|
}
|
|
221
279
|
choice = answer;
|
|
222
|
-
if (choice === "domain") domain = (await deps.prompt(
|
|
280
|
+
if (choice === "domain") domain = (await deps.prompt(message(lang, "domainPrompt")))?.trim() ?? "";
|
|
223
281
|
}
|
|
224
|
-
const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps);
|
|
282
|
+
const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps, lang);
|
|
225
283
|
if (!resolved.ok) {
|
|
226
|
-
deps.err(
|
|
284
|
+
deps.err(message(lang, "accessFailed", { error: resolved.message }));
|
|
227
285
|
return EXIT_FAILED;
|
|
228
286
|
}
|
|
229
287
|
writeAccess(deps.paths.home, resolved.record);
|
|
230
|
-
deps.out(
|
|
288
|
+
deps.out(message(lang, "accessDone", { ...resolved.record, port: deps.paths.ports.app }));
|
|
231
289
|
return resolved.record;
|
|
232
290
|
}
|
|
233
291
|
|
|
234
292
|
/** A domain is checked only once the services are up; a failure keeps the install and points at the proxy and doctor. */
|
|
235
293
|
async function domainCheckStep(access: AccessRecord | null, deps: SetupDeps): Promise<void> {
|
|
294
|
+
const lang = deps.lang ?? "en";
|
|
236
295
|
if (access?.choice !== "domain") return;
|
|
237
296
|
const result = await deps.ping(access.url);
|
|
238
297
|
if (result.ok) {
|
|
239
|
-
deps.out(
|
|
298
|
+
deps.out(message(lang, "domainOk", { url: access.url }));
|
|
240
299
|
return;
|
|
241
300
|
}
|
|
242
|
-
deps.err(
|
|
301
|
+
deps.err(message(lang, "domainWarning", { url: access.url, detail: result.detail, guide: proxyGuide(access.url, deps.paths.ports.app, lang) }));
|
|
243
302
|
}
|
|
244
303
|
|
|
245
304
|
export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<number> {
|
|
246
|
-
|
|
305
|
+
let lang = options.lang ?? resolveLanguage(deps.paths.home, deps.env);
|
|
306
|
+
if (!options.nonInteractive && options.lang === undefined) {
|
|
307
|
+
for (;;) {
|
|
308
|
+
const answer = await deps.prompt(LANGUAGE_QUESTION);
|
|
309
|
+
if (answer === null || answer.trim() === "") break;
|
|
310
|
+
if (answer.trim() === "1" || answer.trim() === "en") { lang = "en"; break; }
|
|
311
|
+
if (answer.trim() === "2" || answer.trim() === "ko") { lang = "ko"; break; }
|
|
312
|
+
deps.err(message(lang, "chooseLanguage"));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
deps = { ...deps, lang };
|
|
316
|
+
const problem = platformProblem(deps.platform, lang);
|
|
247
317
|
if (problem !== null) {
|
|
248
318
|
deps.err(`${problem}\n`);
|
|
249
319
|
return EXIT_USAGE;
|
|
250
320
|
}
|
|
251
321
|
const { paths } = deps;
|
|
252
322
|
const consent = { home: paths.home, kind: deps.installKind, notice: deps.notice, zigminiVersion: deps.zigminiVersion, now: deps.now };
|
|
253
|
-
if (!await ensureConsent(consent, deps)) {
|
|
254
|
-
deps.err(`${
|
|
323
|
+
if (!await ensureConsent(consent, { ...deps, out: (text) => deps.out(localize(text, lang)), prompt: (question) => deps.prompt(localize(question, lang)) })) {
|
|
324
|
+
deps.err(`${message(lang, "consentDeclined")}\n`);
|
|
255
325
|
return EXIT_DECLINED;
|
|
256
326
|
}
|
|
257
327
|
if (options.engineOnly) {
|
|
258
328
|
ensureDir(paths.omo);
|
|
259
|
-
|
|
329
|
+
const code = await engineStep(options, deps);
|
|
330
|
+
if (code === 0) saveLanguage(paths.home, lang);
|
|
331
|
+
return code;
|
|
260
332
|
}
|
|
261
333
|
|
|
262
334
|
for (const dir of [paths.home, paths.data, paths.logs, paths.omo, paths.sessions]) ensureDir(dir);
|
|
263
335
|
for (const dir of [paths.secrets, paths.run]) ensureDir(dir, 0o700);
|
|
264
|
-
deps.out(
|
|
265
|
-
if (ensureInstallRules(paths.home)) deps.out(
|
|
336
|
+
deps.out(message(lang, "home", { path: paths.home }));
|
|
337
|
+
if (ensureInstallRules(paths.home)) deps.out(message(lang, "rules", { path: join(paths.home, INSTALL_RULES_FILE) }));
|
|
266
338
|
|
|
267
339
|
if (!existsSync(paths.omoTokenFile)) writeSecret(paths.omoTokenFile, deps.randomToken());
|
|
268
340
|
chmodSync(paths.omoTokenFile, 0o600);
|
|
269
341
|
|
|
270
342
|
const engineCode = await engineStep(options, deps);
|
|
271
343
|
if (engineCode !== 0) return engineCode;
|
|
344
|
+
saveLanguage(paths.home, lang);
|
|
272
345
|
|
|
273
346
|
const configFile = join(paths.home, "config.env");
|
|
274
347
|
const text = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
|
|
@@ -276,16 +349,15 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
|
|
|
276
349
|
try {
|
|
277
350
|
config = parseConfigEnv(text);
|
|
278
351
|
} catch (error) {
|
|
279
|
-
deps.err(`${(error as Error).message}\n`);
|
|
352
|
+
deps.err(`${localize((error as Error).message, lang)}\n`);
|
|
280
353
|
return EXIT_FAILED;
|
|
281
354
|
}
|
|
282
355
|
|
|
283
356
|
const dedicated = options.agentDir ?? config.ZGW_OMO_AGENT_DIR;
|
|
284
357
|
const agent = dedicated ? resolve(dedicated) : defaultAgentDir(deps.env);
|
|
285
358
|
if (options.agentDir !== undefined) ensureDir(agent, 0o700);
|
|
286
|
-
deps.out(
|
|
287
|
-
|
|
288
|
-
deps.out(providers > 0 ? `모델 계정: 로그인됨(제공자 ${providers}개)\n` : `${loginHint(agent, paths.home, deps.bunPath, deps.env)}\n`);
|
|
359
|
+
deps.out(message(lang, "agent", { path: agent, kind: message(lang, dedicated ? "dedicated" : "shared") }));
|
|
360
|
+
await loginStep(options, deps, agent, Boolean(dedicated));
|
|
289
361
|
|
|
290
362
|
const notionCode = await notionStep(options, deps);
|
|
291
363
|
if (notionCode !== 0) return notionCode;
|
|
@@ -302,34 +374,26 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
|
|
|
302
374
|
chmodSync(configFile, 0o600);
|
|
303
375
|
|
|
304
376
|
if (!existsSync(paths.voiceSettingFile)) writeVoiceSetting(paths.voiceSettingFile, false);
|
|
305
|
-
deps.out(
|
|
377
|
+
deps.out(message(lang, "voice", { state: message(lang, readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "off" : "on") }));
|
|
306
378
|
|
|
307
379
|
const started = await deps.start();
|
|
308
380
|
if (started !== 0) {
|
|
309
|
-
deps.err(
|
|
381
|
+
deps.err(message(lang, "startFailed", { path: paths.logs }));
|
|
310
382
|
return started;
|
|
311
383
|
}
|
|
312
|
-
deps.out(
|
|
384
|
+
deps.out(message(lang, "setupDone", { port: paths.ports.web }));
|
|
313
385
|
await domainCheckStep(access, deps);
|
|
314
|
-
|
|
386
|
+
phoneStep(access, deps);
|
|
315
387
|
return 0;
|
|
316
388
|
}
|
|
317
389
|
|
|
318
390
|
/**
|
|
319
|
-
*
|
|
320
|
-
* hint, not a failed setup.
|
|
391
|
+
* Terminal font metrics can break QR scanning. Direct the user to the real web settings sections instead.
|
|
321
392
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
deps.out(`${apkFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair --apk\`로 다시 만듭니다.\n`);
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
deps.out(`\n${await deps.enrollQr()}`);
|
|
330
|
-
} catch (error) {
|
|
331
|
-
deps.out(`${pairFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair\`로 다시 만듭니다.\n`);
|
|
332
|
-
}
|
|
393
|
+
function phoneStep(access: AccessRecord | null, deps: SetupDeps): void {
|
|
394
|
+
const lang = deps.lang ?? "en";
|
|
395
|
+
if (access === null) deps.out(`${message(lang, "setupAccessGuidance")}\n`);
|
|
396
|
+
deps.out(message(lang, "webQrGuide", { port: deps.paths.ports.web }));
|
|
333
397
|
}
|
|
334
398
|
|
|
335
399
|
async function* stdinLines(): AsyncGenerator<string> {
|
|
@@ -356,19 +420,40 @@ export function stdinPrompt(): (question: string) => Promise<string | null> {
|
|
|
356
420
|
|
|
357
421
|
export function systemSetupDeps(zigminiVersion: string, env: Env = process.env): SetupDeps {
|
|
358
422
|
const ctx = systemContext(env);
|
|
423
|
+
let globalBin: string | null = null;
|
|
359
424
|
return {
|
|
360
425
|
platform: { platform: process.platform, arch: process.arch, bunVersion: Bun.version },
|
|
361
426
|
env,
|
|
362
427
|
paths: ctx.paths,
|
|
363
428
|
bunPath: ctx.bunPath,
|
|
364
429
|
installEngine: (options) => installEngine(options),
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
430
|
+
globalOmoVersion: async () => {
|
|
431
|
+
const bin = env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin/omo") : Bun.which("omo", { PATH: env.PATH });
|
|
432
|
+
if (!bin || !existsSync(bin)) return null;
|
|
433
|
+
const proc = Bun.spawn([ctx.bunPath, bin, "--version"], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(10_000) });
|
|
434
|
+
const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
435
|
+
const version = /^omo\s+(\S+)/.exec(stdout.trim())?.[1];
|
|
436
|
+
if (code !== 0 || version === undefined) throw new Error(stderr.trim() || "global_omo_version_unknown");
|
|
437
|
+
globalBin = bin;
|
|
438
|
+
return version;
|
|
439
|
+
},
|
|
440
|
+
installGlobalOmo: async (version) => {
|
|
441
|
+
const proc = Bun.spawn([ctx.bunPath, "add", "-g", `${ENGINE_PACKAGE}@${version}`], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(180_000) });
|
|
442
|
+
const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
443
|
+
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `bun_add_exit_${code}`);
|
|
444
|
+
globalBin = join(env.BUN_INSTALL || join(env.HOME || homedir(), ".bun"), "bin/omo");
|
|
445
|
+
},
|
|
446
|
+
globalOmoCommand: () => globalBin === null || Bun.which("omo", { PATH: env.PATH }) === globalBin ? "omo" : globalBin,
|
|
447
|
+
providerCount,
|
|
448
|
+
start: () => {
|
|
449
|
+
const deps = systemDeps();
|
|
450
|
+
const lang = resolveLanguage(ctx.paths.home, env);
|
|
451
|
+
return startCommand({ ...deps, out: (text) => deps.out(localize(text, lang)), err: (text) => deps.err(localize(text, lang)) }, ctx);
|
|
452
|
+
},
|
|
368
453
|
prompt: stdinPrompt(),
|
|
369
454
|
network: SYSTEM_NETWORK,
|
|
370
455
|
tailscale: systemTailscale,
|
|
371
|
-
ping: (url) => pingAccess((target, init) => fetch(target, init), url),
|
|
456
|
+
ping: (url) => pingAccess((target, init) => fetch(target, init), url, undefined, resolveLanguage(ctx.paths.home, env)),
|
|
372
457
|
isDir: (path) => {
|
|
373
458
|
try {
|
|
374
459
|
return statSync(path).isDirectory();
|
|
@@ -387,10 +472,14 @@ export function systemSetupDeps(zigminiVersion: string, env: Env = process.env):
|
|
|
387
472
|
}
|
|
388
473
|
|
|
389
474
|
export async function setupCommand(argv: readonly string[], deps: () => SetupDeps, usage: string): Promise<number> {
|
|
390
|
-
const
|
|
475
|
+
const runtime = deps();
|
|
476
|
+
const at = argv.indexOf("--lang");
|
|
477
|
+
const requested = at < 0 ? undefined : argv[at + 1];
|
|
478
|
+
const lang = isLanguage(requested) ? requested : resolveLanguage(runtime.paths.home, runtime.env);
|
|
479
|
+
const options = parseSetupArgs(argv, lang);
|
|
391
480
|
if (typeof options === "string") {
|
|
392
|
-
process.stderr.write(`${options}\n\n${usage}`);
|
|
481
|
+
process.stderr.write(`${options}\n\n${localize(usage, lang)}`);
|
|
393
482
|
return EXIT_USAGE;
|
|
394
483
|
}
|
|
395
|
-
return await runSetup(options,
|
|
484
|
+
return await runSetup(options, runtime);
|
|
396
485
|
}
|