@xynogen/pix-pretty 1.7.17 → 1.7.18

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": "@xynogen/pix-pretty",
3
- "version": "1.7.17",
3
+ "version": "1.7.18",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -160,6 +160,44 @@ describe("showOverlay — sudo mode", () => {
160
160
  expect(result.password).toBeUndefined();
161
161
  });
162
162
 
163
+ test("wrong password retries inside the same overlay", async () => {
164
+ let component: Wired | undefined;
165
+ let overlayCount = 0;
166
+ const attempts: string[] = [];
167
+ const ui: OverlayUI = {
168
+ custom: <T>(cb: Parameters<OverlayUI["custom"]>[0]): Promise<T | undefined> => {
169
+ overlayCount += 1;
170
+ return new Promise((resolve) => {
171
+ component = cb({ requestRender: () => {} }, theme, undefined, (value) =>
172
+ resolve(value as T),
173
+ );
174
+ });
175
+ },
176
+ };
177
+
178
+ const pending = showOverlay(ui, {
179
+ mode: "sudo",
180
+ title: "ROOT",
181
+ timeoutMs: 0,
182
+ maxPasswordAttempts: 3,
183
+ validatePassword: async (password) => {
184
+ attempts.push(password);
185
+ return password === "correct";
186
+ },
187
+ });
188
+ component?.handleInput(ENTER);
189
+ component?.handleInput("wrong");
190
+ component?.handleInput(ENTER);
191
+ await new Promise((resolve) => setTimeout(resolve, 0));
192
+ expect(component?.render(80).join("\n")).toContain("Incorrect password — attempt 1 of 3");
193
+ component?.handleInput("correct");
194
+ component?.handleInput(ENTER);
195
+
196
+ expect(await pending).toEqual({ action: "approved", password: "correct" });
197
+ expect(attempts).toEqual(["wrong", "correct"]);
198
+ expect(overlayCount).toBe(1);
199
+ });
200
+
163
201
  test("password is masked in render (● not plaintext)", async () => {
164
202
  let pwFrame: string[] = [];
165
203
  await showOverlay(
@@ -25,6 +25,8 @@ export interface OverlayResult {
25
25
  action: OverlayAction;
26
26
  /** Only present when action === "approved" and mode === "sudo". */
27
27
  password?: string;
28
+ /** True when password validation exhausted every allowed attempt. */
29
+ passwordAttemptsExhausted?: boolean;
28
30
  }
29
31
 
30
32
  export interface OverlayChoice {
@@ -64,6 +66,10 @@ export interface SudoConfig extends BaseConfig {
64
66
  mode: "sudo";
65
67
  /** Label for the password input hint. Default "Sudo password:" */
66
68
  passwordLabel?: string;
69
+ /** Validate an entered password without closing the overlay. */
70
+ validatePassword?: (password: string) => Promise<boolean>;
71
+ /** Number of validation attempts before closing as exhausted. Default 3. */
72
+ maxPasswordAttempts?: number;
67
73
  }
68
74
 
69
75
  export type OverlayConfig = ConfirmConfig | SudoConfig;
@@ -128,9 +134,20 @@ function buildLines(opts: {
128
134
  selectList: SelectList;
129
135
  maskedInput: MaskedInput;
130
136
  countdownLine: string | undefined;
137
+ passwordStatus: string | undefined;
131
138
  width: number;
132
139
  }): string[] {
133
- const { theme, accent, config, stage, selectList, maskedInput, countdownLine, width } = opts;
140
+ const {
141
+ theme,
142
+ accent,
143
+ config,
144
+ stage,
145
+ selectList,
146
+ maskedInput,
147
+ countdownLine,
148
+ passwordStatus,
149
+ width,
150
+ } = opts;
134
151
  const inner = width - 4; // CHROME = 2 border + 2 padding
135
152
  const lines: string[] = [];
136
153
 
@@ -160,6 +177,7 @@ function buildLines(opts: {
160
177
  } else {
161
178
  const label = config.mode === "sudo" ? (config.passwordLabel ?? "Sudo password:") : "Password:";
162
179
  lines.push(theme.fg("muted", label));
180
+ if (passwordStatus) lines.push(theme.fg("error", passwordStatus));
163
181
  const inputLines = maskedInput.render(inner);
164
182
  for (const l of inputLines) lines.push(l);
165
183
  lines.push("");
@@ -211,6 +229,9 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
211
229
  type Stage = "select" | "password";
212
230
  let stage: Stage = "select";
213
231
  let countdownLine: string | undefined;
232
+ let passwordStatus: string | undefined;
233
+ let passwordAttempts = 0;
234
+ let validatingPassword = false;
214
235
 
215
236
  // Dead-man's-switch timer: counts down only while untouched. The
216
237
  // first keypress cancels it (user is present → let them decide). If
@@ -271,8 +292,36 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
271
292
  };
272
293
  selectList.onCancel = () => finish({ action: "denied" });
273
294
 
274
- maskedInput.onSubmit = (pw) => finish({ action: "approved", password: pw });
275
- maskedInput.onEscape = () => finish({ action: "denied" });
295
+ maskedInput.onSubmit = async (pw) => {
296
+ if (config.mode !== "sudo" || !config.validatePassword) {
297
+ finish({ action: "approved", password: pw });
298
+ return;
299
+ }
300
+ if (!pw.trim() || validatingPassword) return;
301
+
302
+ validatingPassword = true;
303
+ passwordStatus = "Checking password…";
304
+ tui.requestRender();
305
+ const valid = await config.validatePassword(pw);
306
+ validatingPassword = false;
307
+ if (valid) {
308
+ finish({ action: "approved", password: pw });
309
+ return;
310
+ }
311
+
312
+ passwordAttempts += 1;
313
+ const maxAttempts = config.maxPasswordAttempts ?? 3;
314
+ if (passwordAttempts >= maxAttempts) {
315
+ finish({ action: "approved", password: pw, passwordAttemptsExhausted: true });
316
+ return;
317
+ }
318
+ maskedInput.setValue("");
319
+ passwordStatus = `Incorrect password — attempt ${passwordAttempts} of ${maxAttempts}`;
320
+ tui.requestRender();
321
+ };
322
+ maskedInput.onEscape = () => {
323
+ if (!validatingPassword) finish({ action: "denied" });
324
+ };
276
325
 
277
326
  // ── component interface ──────────────────────────────────────────
278
327
  return {
@@ -286,6 +335,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
286
335
  selectList,
287
336
  maskedInput,
288
337
  countdownLine,
338
+ passwordStatus,
289
339
  width: mw,
290
340
  });
291
341
  return frameLines({
@@ -298,6 +348,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
298
348
  invalidate: () => {},
299
349
  handleInput: (data) => {
300
350
  cancelTimer(); // user is present — stop the auto-deny countdown
351
+ if (validatingPassword) return;
301
352
  if (stage === "select") selectList.handleInput(data);
302
353
  else maskedInput.handleInput(data);
303
354
  tui.requestRender();