pleumrouter 0.5.5 → 0.5.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/src/login.mjs CHANGED
@@ -3,23 +3,50 @@ import { randomUUID } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
4
  import { ROOT, V1, readConfig, writeConfig, mergeLoginConfig } from "./config.mjs";
5
5
  import { t } from "./i18n.mjs";
6
+ import { sanitizeTerminalText } from "./ansi.mjs";
7
+
8
+ // `start`는 Windows cmd.exe의 built-in이라 shell:true가 필요하다. rundll32는 같은
9
+ // 기본 브라우저 핸드오프를 제공하는 실제 실행 파일이므로 URL을 argv로 안전하게 넘긴다.
10
+ export function browserCommand(url, platform = process.platform) {
11
+ if (platform === "darwin") return { command: "open", args: [url] };
12
+ if (platform === "win32") {
13
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
14
+ }
15
+ return { command: "xdg-open", args: [url] };
16
+ }
6
17
 
7
18
  function openBrowser(url) {
8
- const cmd =
9
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
10
- spawn(cmd, [url], {
19
+ const { command, args } = browserCommand(url);
20
+ spawn(command, args, {
11
21
  stdio: "ignore",
12
22
  detached: true,
13
- shell: process.platform === "win32",
23
+ shell: false,
14
24
  }).unref();
15
25
  }
16
26
 
27
+ function saveTokens({ access_token, refresh_token, expires_in }) {
28
+ const existing = readConfig() || {};
29
+ const { key: _legacy, ...rest } = existing;
30
+ const token_expires_at =
31
+ typeof expires_in === "number"
32
+ ? Date.now() + expires_in * 1000
33
+ : undefined;
34
+ writeConfig(
35
+ mergeLoginConfig(rest, {
36
+ access_token,
37
+ refresh_token,
38
+ token_expires_at,
39
+ base_url: ROOT,
40
+ }),
41
+ );
42
+ }
43
+
17
44
  // 브라우저 핸드오프: 로컬 콜백 서버를 열고 대시보드 /cli-auth로 보낸다.
18
- // 프론트가 로그인 확인 후 키를 발급해 127.0.0.1:PORT/cb 로 POST 한다(URL에 키 미노출).
45
+ // 프론트가 로그인 확인 후 JWT를 발급해 127.0.0.1:PORT/cb 로 POST 한다.
19
46
  export async function login() {
20
47
  const state = randomUUID();
21
48
 
22
- const key = await new Promise((resolve, reject) => {
49
+ const tokens = await new Promise((resolve, reject) => {
23
50
  const server = createServer((req, res) => {
24
51
  // 로컬 콜백만 받으므로 CORS는 전부 허용(브라우저→127.0.0.1 cross-origin POST).
25
52
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -43,12 +70,22 @@ export async function login() {
43
70
  } catch {
44
71
  // 잘못된 본문 — 아래 검증에서 걸러짐
45
72
  }
46
- const ok = payload.state === state && typeof payload.key === "string" && payload.key;
73
+ const ok =
74
+ payload.state === state &&
75
+ typeof payload.access_token === "string" &&
76
+ payload.access_token &&
77
+ typeof payload.refresh_token === "string" &&
78
+ payload.refresh_token;
47
79
  res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" });
48
80
  res.end(JSON.stringify({ ok: Boolean(ok) }));
49
81
  server.close();
50
- if (ok) resolve(payload.key);
51
- else reject(new Error(t("State mismatch or missing key")));
82
+ if (ok) {
83
+ resolve({
84
+ access_token: payload.access_token,
85
+ refresh_token: payload.refresh_token,
86
+ expires_in: payload.expires_in,
87
+ });
88
+ } else reject(new Error(t("State mismatch or missing key")));
52
89
  });
53
90
  });
54
91
 
@@ -56,7 +93,7 @@ export async function login() {
56
93
  server.listen(0, "127.0.0.1", () => {
57
94
  const { port } = server.address();
58
95
  const authUrl = `${ROOT}/cli-auth?port=${port}&state=${state}`;
59
- console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${authUrl}\n`);
96
+ console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${sanitizeTerminalText(authUrl)}\n`);
60
97
  openBrowser(authUrl);
61
98
  });
62
99
 
@@ -66,12 +103,12 @@ export async function login() {
66
103
  }, 120_000).unref();
67
104
  });
68
105
 
69
- writeConfig(mergeLoginConfig(readConfig(), { key, base_url: ROOT }));
70
- console.log(`✓ ${t("Logged in. Saved the key to ~/.pleum/config.json.")}`);
106
+ saveTokens(tokens);
107
+ console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
71
108
  }
72
109
 
73
110
  // device-code 로그인 — 브라우저 없는 환경(SSH·CI).
74
- // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 키 수신.
111
+ // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 JWT 수신.
75
112
  export async function loginDevice() {
76
113
  const res = await fetch(`${V1}/auth/device/code`, { method: "POST" });
77
114
  if (!res.ok) {
@@ -81,8 +118,8 @@ export async function loginDevice() {
81
118
  const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
82
119
 
83
120
  console.log(
84
- `\n ${t("Code")}: \x1b[1;32m${user_code}\x1b[0m\n` +
85
- ` URL: ${verification_url}\n\n` +
121
+ `\n ${t("Code")}: \x1b[1;32m${sanitizeTerminalText(user_code)}\x1b[0m\n` +
122
+ ` URL: ${sanitizeTerminalText(verification_url)}\n\n` +
86
123
  ` ${t("Log in at the URL above, then enter the code (within {min} min).", { min: expires_in / 60 })}\n`
87
124
  );
88
125
 
@@ -98,12 +135,16 @@ export async function loginDevice() {
98
135
  if (!poll.ok) {
99
136
  const e = await poll.json().catch(() => ({}));
100
137
  if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }
101
- console.error(t("Error:"), e.detail ?? poll.status);
138
+ console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
139
+ process.exit(1);
140
+ }
141
+ const data = await poll.json();
142
+ if (!data.access_token || !data.refresh_token) {
143
+ console.error(t("Error:"), "missing tokens");
102
144
  process.exit(1);
103
145
  }
104
- const { key } = await poll.json();
105
- writeConfig(mergeLoginConfig(readConfig(), { key, base_url: ROOT }));
106
- console.log(`✓ ${t("Logged in. Saved the key to ~/.pleum/config.json.")}`);
146
+ saveTokens(data);
147
+ console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
107
148
  return;
108
149
  }
109
150
  console.error(t("Timed out: the code has expired."));
package/src/markdown.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
3
3
  // 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
4
4
 
5
- import { style, displayWidth, terminalWidth } from "./ansi.mjs";
5
+ import { style, displayWidth, terminalWidth, sanitizeTerminalText } from "./ansi.mjs";
6
6
 
7
7
  // ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
8
8
  export function detectFence(line) {
@@ -13,7 +13,7 @@ export function detectFence(line) {
13
13
  // 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
14
14
  // 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
15
15
  export function renderInline(text, styler = style) {
16
- return text
16
+ return sanitizeTerminalText(text)
17
17
  .replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
18
18
  .replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
19
19
  .replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
@@ -21,6 +21,7 @@ export function renderInline(text, styler = style) {
21
21
 
22
22
  // 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
23
23
  export function renderLine(line, state = { inFence: false }, styler = style) {
24
+ line = sanitizeTerminalText(line);
24
25
  const fence = detectFence(line);
25
26
  if (fence) {
26
27
  return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
@@ -67,6 +68,8 @@ export function createMarkdownStream({
67
68
 
68
69
  return {
69
70
  push(delta) {
71
+ delta = sanitizeTerminalText(delta);
72
+ if (!delta) return;
70
73
  if (!interactive) return void write(delta);
71
74
  buf += delta;
72
75
  const lines = buf.split("\n");
package/src/menu.mjs CHANGED
@@ -11,12 +11,12 @@
11
11
  import { readFileSync } from "node:fs";
12
12
  import { spawn } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
- import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
14
+ import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
15
15
  import { keyFeeder, select } from "./interactive.mjs";
16
16
  import { readConfig, writeConfig } from "./config.mjs";
17
17
  import { RECIPES, MANUAL } from "./recipes.mjs";
18
18
  import { onPath, launch } from "./launch.mjs";
19
- import { fetchModels, matchModels, listModels } from "./models.mjs";
19
+ import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
20
20
  import { run } from "./run.mjs";
21
21
  import { login } from "./login.mjs";
22
22
  import { top } from "./top.mjs";
@@ -115,12 +115,12 @@ async function pickModelFor(item) {
115
115
  try {
116
116
  models = await fetchModels();
117
117
  } catch (e) {
118
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
118
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
119
119
  return undefined;
120
120
  }
121
121
  const m = await select(models, {
122
122
  message: `${item.title} — ${t("select a model")}`,
123
- formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
123
+ formatItem: (x) => formatModelLabel(x),
124
124
  matches: matchModels,
125
125
  limit: 12,
126
126
  });
package/src/models.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { V1 } from "./config.mjs";
2
- import { style, padDisplay, divider } from "./ansi.mjs";
2
+ import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
3
3
  import { t } from "./i18n.mjs";
4
4
 
5
5
  // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
@@ -15,16 +15,28 @@ export function matchModels(models, query) {
15
15
  const q = (query || "").toLowerCase();
16
16
  if (!q) return models;
17
17
  return models.filter(
18
- (m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
18
+ (m) => String(m.id ?? "").toLowerCase().includes(q) || String(m.display_name ?? "").toLowerCase().includes(q),
19
19
  );
20
20
  }
21
21
 
22
+ // Model registry data is remote input. Keep this boundary before any style/render operation so
23
+ // formatting codes emitted by the CLI itself are the only ANSI sequences written to a terminal.
24
+ export function safeModelText(value, fallback = "") {
25
+ return sanitizeTerminalText(value ?? fallback);
26
+ }
27
+
28
+ export function formatModelLabel(model, styler = style) {
29
+ const id = safeModelText(model?.id);
30
+ const displayName = safeModelText(model?.display_name);
31
+ return `${id}${displayName ? ` ${styler.dim(displayName)}` : ""}`;
32
+ }
33
+
22
34
  // 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
23
35
  export function fmtCtx(n) {
24
36
  if (!n) return "—";
25
37
  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
26
38
  if (n >= 1000) return `${Math.round(n / 1000)}K`;
27
- return String(n);
39
+ return safeModelText(n);
28
40
  }
29
41
 
30
42
  // pricing(_krw)는 토큰당 단가 문자열. 1M 토큰당으로 환산해 표시(KRW 우선, 없으면 USD).
@@ -49,9 +61,9 @@ export function formatTable(models, styler = style) {
49
61
  const rows = models.map((m) => {
50
62
  const [pin, pout] = fmtPrice(m);
51
63
  return [
52
- m.id,
53
- m.provider || "",
54
- m.modality || "text",
64
+ safeModelText(m.id),
65
+ safeModelText(m.provider || ""),
66
+ safeModelText(m.modality || "text"),
55
67
  fmtCtx(m.context_length || m.context_window),
56
68
  pin,
57
69
  pout,
@@ -119,14 +131,16 @@ const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
119
131
  export function renderModelInfo(m, styler = style) {
120
132
  const [pin, pout] = fmtPrice(m);
121
133
  const lines = [];
122
- lines.push(`${styler.bold(styler.plum(m.id))}${m.display_name ? ` ${styler.dim(m.display_name)}` : ""}`);
134
+ const id = safeModelText(m.id);
135
+ const displayName = safeModelText(m.display_name);
136
+ lines.push(`${styler.bold(styler.plum(id))}${displayName ? ` ${styler.dim(displayName)}` : ""}`);
123
137
  lines.push(divider(undefined, styler));
124
138
  const facts = [
125
- ["maker", m.maker || "—"],
126
- ["modality", m.modality || "text"],
139
+ ["maker", safeModelText(m.maker || "—")],
140
+ ["modality", safeModelText(m.modality || "text")],
127
141
  ["context", fmtCtx(m.context_length || m.context_window)],
128
142
  ["max output", fmtCtx(m.max_output_tokens || m.max_output)],
129
- [t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent ?? 0 }))}`],
143
+ [t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: safeModelText(m.effective_markup_percent, 0) }))}`],
130
144
  ];
131
145
  if (m.supports_thinking) {
132
146
  facts.push([
@@ -160,19 +174,19 @@ export function renderModelInfo(m, styler = style) {
160
174
  const [i1m, o1m] = fmtPrice(p);
161
175
  lines.push(
162
176
  [
163
- styler.plum(padDisplay(p.provider, 12)),
177
+ styler.plum(padDisplay(safeModelText(p.provider), 12)),
164
178
  padDisplay(i1m, 10),
165
179
  padDisplay(o1m, 10),
166
180
  padDisplay(fmtCtx(p.context_window), 5),
167
181
  padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
168
182
  padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
169
- p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
183
+ p.uptime_percent != null ? `${safeModelText(p.uptime_percent)}%` : "—",
170
184
  ].join(" "),
171
185
  );
172
186
  }
173
187
  }
174
188
 
175
- const sibs = (m.siblings || []).map((s) => s.id);
189
+ const sibs = (m.siblings || []).map((s) => safeModelText(s.id));
176
190
  if (sibs.length) {
177
191
  lines.push("");
178
192
  lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
package/src/recipes.mjs CHANGED
@@ -169,15 +169,33 @@ export const RECIPES = {
169
169
 
170
170
  // Codex는 Chat Completions가 제거돼 Responses API(wire_api="responses")만 지원.
171
171
  // 사용자의 ~/.codex/config.toml을 건드리지 않도록 격리 CODEX_HOME에 이 파일을 쓴다.
172
+ // TOML basic string encoder. 모델명과 base_url은 CLI/env에서 오므로, 따옴표·개행이
173
+ // config의 새 키/테이블로 해석되지 않도록 모든 제어문자를 이스케이프한다.
174
+ export function tomlBasicString(value) {
175
+ const escapes = {
176
+ "\b": "\\b",
177
+ "\t": "\\t",
178
+ "\n": "\\n",
179
+ "\f": "\\f",
180
+ "\r": "\\r",
181
+ '"': '\\"',
182
+ "\\": "\\\\",
183
+ };
184
+ const escaped = String(value).replace(/[\u0000-\u001f\u007f"\\\\]/g, (ch) =>
185
+ escapes[ch] ?? `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`,
186
+ );
187
+ return `"${escaped}"`;
188
+ }
189
+
172
190
  export function codexToml(model) {
173
191
  const m = model || "gpt-5.4-mini";
174
192
  return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
175
- model = "${m}"
193
+ model = ${tomlBasicString(m)}
176
194
  model_provider = "pleum"
177
195
 
178
196
  [model_providers.pleum]
179
197
  name = "PleumRouter"
180
- base_url = "${V1}"
198
+ base_url = ${tomlBasicString(V1)}
181
199
  env_key = "PLEUM_API_KEY"
182
200
  wire_api = "responses"
183
201
  `;