pleumrouter 0.5.50 → 0.5.53

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.50",
3
+ "version": "0.5.53",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,7 @@ _pleum() {
46
46
  completion) compadd zsh bash;;
47
47
  esac
48
48
  }
49
+ compdef _pleum pleum 2>/dev/null || true
49
50
  _pleum "$@"`;
50
51
  }
51
52
 
package/src/config.mjs CHANGED
@@ -173,7 +173,7 @@ export async function requireAuth() {
173
173
  process.exit(1);
174
174
  }
175
175
 
176
- export async function refreshAccessToken(cfg = readConfig()) {
176
+ export async function refreshAccessToken(cfg = readConfig(), { signal, clearOnReject = true } = {}) {
177
177
  if (!cfg?.refresh_token) return null;
178
178
  const base = (cfg.base_url || ROOT).replace(/\/+$/, "").replace(/\/v1$/, "");
179
179
  try {
@@ -181,9 +181,10 @@ export async function refreshAccessToken(cfg = readConfig()) {
181
181
  method: "POST",
182
182
  headers: { "Content-Type": "application/json" },
183
183
  body: JSON.stringify({ refresh_token: cfg.refresh_token }),
184
+ signal,
184
185
  });
185
186
  if (!res.ok) {
186
- if (res.status === 401 || res.status === 403) {
187
+ if (clearOnReject && (res.status === 401 || res.status === 403)) {
187
188
  writeConfig(clearCredentials(cfg));
188
189
  }
189
190
  return null;
package/src/health.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // `pleum health` — 게이트웨이 연결성 + 로그인 상태 한눈에.
2
2
  // 셀프호스트/스테이징(PLEUM_BASE_URL) 점검·고객지원 트리아지용. 공개 /v1/health 재사용.
3
3
 
4
- import { ROOT, V1, readConfig, isLoggedIn } from "./config.mjs";
4
+ import { ROOT, V1, readConfig, isLoggedIn, refreshAccessToken } from "./config.mjs";
5
+ import { formatKrwCredit } from "./balance.mjs";
5
6
  import { style, divider } from "./ansi.mjs";
6
7
  import { t } from "./i18n.mjs";
7
8
 
@@ -14,7 +15,7 @@ const warn = (s) => `${style.yellow("●")} ${s}`;
14
15
  export const isHealthy = (v) => v === true || v === "ok" || (typeof v === "string" && v.startsWith("ok:"));
15
16
 
16
17
  // health 응답 + 로컬 상태 → 표시 문자열(순수 함수 — fixture 테스트 대상).
17
- export function renderHealth({ root, health, error, ms, keyPrefix }, styler = style) {
18
+ export function renderHealth({ root, health, error, ms, keyPrefix, credit }, styler = style) {
18
19
  const lines = [];
19
20
  lines.push(`${styler.bold(styler.plum("pleum health"))} ${styler.dim(`· ${root}`)}`);
20
21
  lines.push(divider(undefined, styler));
@@ -35,12 +36,45 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
35
36
  }
36
37
  lines.push(
37
38
  keyPrefix
38
- ? ok(`${t("Logged in")} ${styler.dim(`(${keyPrefix === "oauth" ? t("OAuth session") : `${t("key")} ${keyPrefix}…`})`)}`)
39
+ ? ok(`${t("Local credentials found")} ${styler.dim(`(${keyPrefix === "oauth" ? t("OAuth session") : `${t("key")} ${keyPrefix}…`})`)}`)
39
40
  : warn(`${t("Not logged in")} ${styler.dim("— pleum login")}`),
40
41
  );
42
+ if (credit?.personal) {
43
+ lines.push(ok(t("Personal credit: {amount} credits", { amount: formatKrwCredit(credit.personal.balance_krw) })));
44
+ lines.push(styler.dim(t("Personal credit does not indicate workspace availability or model access.")));
45
+ } else if (credit?.error) {
46
+ lines.push(warn(t("Credit check failed: {error}", { error: credit.error })));
47
+ } else {
48
+ lines.push(warn(t("Credit check skipped. Run pleum login.")));
49
+ }
41
50
  return lines.join("\n");
42
51
  }
43
52
 
53
+ export async function fetchHealthCredit(cfg, { fetchFn = fetch, refresh = refreshAccessToken } = {}) {
54
+ if (!isLoggedIn(cfg)) return null;
55
+ const loginRoot = (cfg.base_url || "https://apirouter.pleum.ai").replace(/\/+$/, "").replace(/\/v1$/, "");
56
+ if (loginRoot !== ROOT) return { error: t("Login server differs from the diagnostic server. Run pleum login for this server.") };
57
+ try {
58
+ const get = (token) => fetchFn(`${V1}/credits`, {
59
+ headers: { Authorization: `Bearer ${token}` },
60
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
61
+ });
62
+ let res = await get(cfg.access_token);
63
+ if (res.status === 401) {
64
+ const token = await refresh(cfg, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), clearOnReject: false });
65
+ if (token) res = await get(token);
66
+ }
67
+ if (!res.ok) return { error: `HTTP ${res.status}` };
68
+ const personal = await res.json();
69
+ if (typeof personal.balance_krw !== "number" || !Number.isFinite(personal.balance_krw)) {
70
+ return { error: t("Invalid credit response") };
71
+ }
72
+ return { personal };
73
+ } catch {
74
+ return { error: t("Unable to reach the credit service") };
75
+ }
76
+ }
77
+
44
78
  // 게이트웨이 응답 대기 상한. 없으면 서버·네트워크가 멈췄을 때 끝없이(79초 실측) 기다린다.
45
79
  export const HEALTH_TIMEOUT_MS = 15_000;
46
80
 
@@ -60,7 +94,7 @@ export async function fetchHealth(url, timeoutMs = HEALTH_TIMEOUT_MS) {
60
94
 
61
95
  export async function health({ exitOnError = true } = {}) {
62
96
  const cfg = readConfig();
63
- const keyPrefix = isLoggedIn(cfg)
97
+ const keyPrefix = cfg?.access_token && cfg?.refresh_token
64
98
  ? "oauth"
65
99
  : cfg?.key
66
100
  ? cfg.key.slice(0, 8)
@@ -68,6 +102,7 @@ export async function health({ exitOnError = true } = {}) {
68
102
  const t0 = Date.now();
69
103
  const { data, error } = await fetchHealth(`${V1}/health`);
70
104
  const ms = Date.now() - t0;
71
- console.log(renderHealth({ root: ROOT, health: data, error, ms, keyPrefix }));
105
+ const credit = await fetchHealthCredit(cfg);
106
+ console.log(renderHealth({ root: ROOT, health: data, error, ms, keyPrefix, credit }));
72
107
  if (error && exitOnError) process.exit(1);
73
108
  }
@@ -1,5 +1,14 @@
1
1
  // Diccionario en español — la clave es el texto original en inglés (mantener los marcadores {name} tal cual).
2
2
  export const ES = {
3
+ "Login server differs from the diagnostic server. Run pleum login for this server.": "El servidor de inicio de sesión no coincide con el servidor de diagnóstico. Ejecuta pleum login para este servidor.",
4
+ "Local credentials found": "Credenciales locales encontradas",
5
+ "Personal credit: {amount} credits": "Crédito personal: {amount}",
6
+ "Personal credit does not indicate workspace availability or model access.": "El crédito personal no indica el saldo disponible del espacio de trabajo ni el acceso a modelos.",
7
+ "Credit check failed: {error}": "Error al consultar el crédito: {error}",
8
+ "Credit check skipped. Run pleum login.": "Consulta de crédito omitida. Ejecuta pleum login.",
9
+ "Unable to reach the credit service": "No se puede conectar al servicio de crédito",
10
+ "Invalid credit response": "Respuesta de crédito no válida",
11
+
3
12
  "'{bin}' not found. Install:\n {install}": "'{bin}' no encontrado. Instalar:\n {install}",
4
13
  "(incl. {pct}% markup)": "(incl. {pct}% de margen)",
5
14
  "(no args) interactive launcher menu — keyboard only (TTY only)": "(sin argumentos) menú de lanzador interactivo — solo teclado (solo TTY)",
@@ -1,5 +1,14 @@
1
1
  // 日本語辞書 — キーは英語原文(プレースホルダーは {name} 形式のまま保持)。
2
2
  export const JA = {
3
+ "Login server differs from the diagnostic server. Run pleum login for this server.": "ログインサーバーと診断サーバーが異なります。このサーバーで pleum login を実行してください。",
4
+ "Local credentials found": "ローカル認証情報あり",
5
+ "Personal credit: {amount} credits": "個人クレジット: {amount}",
6
+ "Personal credit does not indicate workspace availability or model access.": "個人クレジットはワークスペースの利用可能額やモデルへのアクセス権を示しません。",
7
+ "Credit check failed: {error}": "クレジット確認失敗: {error}",
8
+ "Credit check skipped. Run pleum login.": "クレジット確認をスキップしました。pleum login を実行してください。",
9
+ "Unable to reach the credit service": "クレジットサービスに接続できません",
10
+ "Invalid credit response": "無効なクレジット応答",
11
+
3
12
  "'{bin}' not found. Install:\n {install}": "'{bin}' が見つかりません。インストール:\n {install}",
4
13
  "(incl. {pct}% markup)": "({pct}% マークアップ込み)",
5
14
  "(no args) interactive launcher menu — keyboard only (TTY only)": "(引数なし) 対話式ランチャーメニュー — キーボード操作のみ (TTYのみ)",
@@ -1,6 +1,15 @@
1
1
  // 한국어 사전 — 키는 영어 원문(placeholders는 {name} 형태로 보존).
2
2
  // 값은 i18n 도입 전 이 CLI가 실제로 출력하던 원문 한국어 그대로다.
3
3
  export const KO = {
4
+ "Login server differs from the diagnostic server. Run pleum login for this server.": "로그인 서버와 진단 서버가 다릅니다. 이 서버에서 pleum login을 실행하세요.",
5
+ "Local credentials found": "로컬 자격증명 있음",
6
+ "Personal credit: {amount} credits": "개인 크레딧: {amount}",
7
+ "Personal credit does not indicate workspace availability or model access.": "개인 크레딧은 워크스페이스 사용 가능액이나 모델 접근 권한을 뜻하지 않습니다.",
8
+ "Credit check failed: {error}": "크레딧 조회 실패: {error}",
9
+ "Credit check skipped. Run pleum login.": "크레딧 조회를 건너뛰었습니다. pleum login을 실행하세요.",
10
+ "Unable to reach the credit service": "크레딧 서비스에 연결할 수 없습니다",
11
+ "Invalid credit response": "잘못된 크레딧 응답",
12
+
4
13
  // config.mjs
5
14
  "Login required. Run this first:\n pleum login": "로그인이 필요합니다. 먼저 실행하세요:\n pleum login",
6
15
 
@@ -1,5 +1,14 @@
1
1
  // 中文词典 — 键为英文原文(占位符保持 {name} 格式)。
2
2
  export const ZH = {
3
+ "Login server differs from the diagnostic server. Run pleum login for this server.": "登录服务器与诊断服务器不同。请在此服务器运行 pleum login。",
4
+ "Local credentials found": "已找到本地凭据",
5
+ "Personal credit: {amount} credits": "个人额度:{amount}",
6
+ "Personal credit does not indicate workspace availability or model access.": "个人额度不代表工作空间可用额度或模型访问权限。",
7
+ "Credit check failed: {error}": "额度查询失败:{error}",
8
+ "Credit check skipped. Run pleum login.": "已跳过额度查询。请运行 pleum login。",
9
+ "Unable to reach the credit service": "无法连接额度服务",
10
+ "Invalid credit response": "额度响应无效",
11
+
3
12
  "'{bin}' not found. Install:\n {install}": "未找到 '{bin}'。安装:\n {install}",
4
13
  "(incl. {pct}% markup)": "(含 {pct}% 加价)",
5
14
  "(no args) interactive launcher menu — keyboard only (TTY only)": "(无参数)交互式启动菜单 — 仅键盘操作(仅限 TTY)",
package/src/login.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
- import { ROOT, SITE_ROOT, V1, readConfig, writeConfig, mergeLoginConfig, clearCredentials, setActiveContext, refreshAccessToken, accessTokenIsStale } from "./config.mjs";
4
+ import { ROOT, SITE_ROOT, V1, readConfig, writeConfig, mergeLoginConfig, clearCredentials, setActiveContext, refreshAccessToken, accessTokenIsStale, readJwtExp } from "./config.mjs";
5
5
  import { t } from "./i18n.mjs";
6
6
  import { sanitizeTerminalText } from "./ansi.mjs";
7
7
 
@@ -30,13 +30,16 @@ function openBrowser(url) {
30
30
  }
31
31
  }
32
32
 
33
- function saveTokens({ access_token, refresh_token, expires_in }) {
33
+ export function saveTokens({ access_token, refresh_token, expires_in }) {
34
34
  const existing = readConfig() || {};
35
35
  const { key: _legacy, ...rest } = existing;
36
+ const exp = readJwtExp(access_token);
36
37
  const token_expires_at =
37
- typeof expires_in === "number"
38
- ? Date.now() + expires_in * 1000
39
- : undefined;
38
+ exp != null
39
+ ? exp * 1000
40
+ : typeof expires_in === "number"
41
+ ? Date.now() + expires_in * 1000
42
+ : undefined;
40
43
  writeConfig(
41
44
  mergeLoginConfig(rest, {
42
45
  access_token,
@@ -151,10 +154,31 @@ export async function login() {
151
154
  console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
152
155
  }
153
156
 
157
+ export function retryAfterSeconds(response) {
158
+ const value = response.headers.get("Retry-After");
159
+ const seconds = value === null ? NaN : Number(value);
160
+ return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : 60;
161
+ }
162
+
154
163
  // device-code 로그인 — 브라우저 없는 환경(SSH·CI).
155
164
  // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 JWT 수신.
156
165
  export async function loginDevice() {
157
- const res = await fetch(`${V1}/auth/device/code`, { method: "POST" });
166
+ const codeDeadline = Date.now() + 120_000;
167
+ let res;
168
+ while (true) {
169
+ try {
170
+ res = await fetch(`${V1}/auth/device/code`, { method: "POST", signal: AbortSignal.timeout(Math.max(1, codeDeadline - Date.now())) });
171
+ } catch (error) {
172
+ if (error.name !== "TimeoutError") throw error;
173
+ console.error(t("Login timed out (2 minutes)"));
174
+ process.exit(1);
175
+ }
176
+ if (res.status !== 429) break;
177
+ const wait = retryAfterSeconds(res) * 1000;
178
+ if (Date.now() + wait >= codeDeadline) break;
179
+ console.error(t("Failed to issue a device code (server responded {status})", { status: res.status }));
180
+ await new Promise((r) => setTimeout(r, wait));
181
+ }
158
182
  if (!res.ok) {
159
183
  console.error(t("Failed to issue a device code (server responded {status})", { status: res.status }));
160
184
  process.exit(1);
@@ -170,19 +194,26 @@ export async function loginDevice() {
170
194
  const expiresIn = Number(expires_in) || 600;
171
195
  const pollInterval = Math.max(1, Number(interval) || 5);
172
196
  const deadline = Date.now() + expiresIn * 1000;
173
- while (Date.now() < deadline) {
174
- await new Promise((r) => setTimeout(r, pollInterval * 1000));
197
+ let nextWait = pollInterval * 1000;
198
+ while (Date.now() + nextWait < deadline) {
199
+ await new Promise((r) => setTimeout(r, nextWait));
200
+ nextWait = pollInterval * 1000;
175
201
  let poll;
176
202
  try {
177
203
  poll = await fetch(`${V1}/auth/device/token`, {
178
204
  method: "POST",
179
205
  headers: { "Content-Type": "application/json" },
180
206
  body: JSON.stringify({ device_code }),
207
+ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
181
208
  });
182
209
  } catch {
183
210
  continue; // Transient network error; retry on next poll tick
184
211
  }
185
212
  if (poll.status === 202) continue; // authorization_pending
213
+ if (poll.status === 429) {
214
+ nextWait = Math.max(pollInterval, retryAfterSeconds(poll)) * 1000;
215
+ continue;
216
+ }
186
217
  if (!poll.ok) {
187
218
  const e = await poll.json().catch(() => ({}));
188
219
  if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }