@reegaviljoen/eldlock 0.1.0

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.
Files changed (91) hide show
  1. package/README.md +285 -0
  2. package/bin/eldlock +11 -0
  3. package/docs/architecture.md +164 -0
  4. package/docs/threat-model.md +47 -0
  5. package/eldlock-cli/README.md +56 -0
  6. package/eldlock-cli/bin/eldlock +3 -0
  7. package/eldlock-cli/package-lock.json +805 -0
  8. package/eldlock-cli/package.json +71 -0
  9. package/eldlock-cli/src/api.ts +250 -0
  10. package/eldlock-cli/src/cli.ts +490 -0
  11. package/eldlock-cli/src/main.ts +10 -0
  12. package/eldlock-cli/src/tui.ts +676 -0
  13. package/eldlock-cli/tsconfig.json +13 -0
  14. package/eldlock-cli/vendor/npm/ansi-regex-6.2.2.tgz +0 -0
  15. package/eldlock-cli/vendor/npm/bun-ffi-structs-0.2.2.tgz +0 -0
  16. package/eldlock-cli/vendor/npm/diff-9.0.0.tgz +0 -0
  17. package/eldlock-cli/vendor/npm/emoji-regex-10.6.0.tgz +0 -0
  18. package/eldlock-cli/vendor/npm/esbuild-0.28.0.tgz +0 -0
  19. package/eldlock-cli/vendor/npm/esbuild-darwin-arm64-0.28.0.tgz +0 -0
  20. package/eldlock-cli/vendor/npm/esbuild-darwin-x64-0.28.0.tgz +0 -0
  21. package/eldlock-cli/vendor/npm/esbuild-linux-arm64-0.28.0.tgz +0 -0
  22. package/eldlock-cli/vendor/npm/esbuild-linux-x64-0.28.0.tgz +0 -0
  23. package/eldlock-cli/vendor/npm/fsevents-2.3.3.tgz +0 -0
  24. package/eldlock-cli/vendor/npm/get-east-asian-width-1.6.0.tgz +0 -0
  25. package/eldlock-cli/vendor/npm/marked-17.0.1.tgz +0 -0
  26. package/eldlock-cli/vendor/npm/opentui-core-0.3.1.tgz +0 -0
  27. package/eldlock-cli/vendor/npm/opentui-core-darwin-arm64-0.3.1.tgz +0 -0
  28. package/eldlock-cli/vendor/npm/opentui-core-darwin-x64-0.3.1.tgz +0 -0
  29. package/eldlock-cli/vendor/npm/opentui-core-linux-arm64-0.3.1.tgz +0 -0
  30. package/eldlock-cli/vendor/npm/opentui-core-linux-x64-0.3.1.tgz +0 -0
  31. package/eldlock-cli/vendor/npm/string-width-7.2.0.tgz +0 -0
  32. package/eldlock-cli/vendor/npm/strip-ansi-7.1.2.tgz +0 -0
  33. package/eldlock-cli/vendor/npm/tsx-4.22.4.tgz +0 -0
  34. package/eldlock-cli/vendor/npm/types-node-22.19.19.tgz +0 -0
  35. package/eldlock-cli/vendor/npm/typescript-5.9.3.tgz +0 -0
  36. package/eldlock-cli/vendor/npm/undici-types-6.21.0.tgz +0 -0
  37. package/eldlock-cli/vendor/npm/web-tree-sitter-0.25.10.tgz +0 -0
  38. package/eldlock-cli/vendor/npm/yoga-layout-3.2.1.tgz +0 -0
  39. package/eldlock-server/cmd/eldlock-server/main.go +132 -0
  40. package/eldlock-server/go.mod +10 -0
  41. package/eldlock-server/go.sum +11 -0
  42. package/eldlock-server/internal/api/README.md +14 -0
  43. package/eldlock-server/internal/api/core.go +126 -0
  44. package/eldlock-server/internal/api/exec.go +97 -0
  45. package/eldlock-server/internal/api/secrets.go +358 -0
  46. package/eldlock-server/internal/api/server.go +72 -0
  47. package/eldlock-server/internal/api/service_test.go +416 -0
  48. package/eldlock-server/internal/api/types.go +48 -0
  49. package/eldlock-server/internal/api/vault.go +69 -0
  50. package/eldlock-server/internal/api/vendor.go +44 -0
  51. package/eldlock-server/internal/libfido2/LICENSE +21 -0
  52. package/eldlock-server/internal/libfido2/README.md +127 -0
  53. package/eldlock-server/internal/libfido2/examples_test.go +614 -0
  54. package/eldlock-server/internal/libfido2/fido2.go +1234 -0
  55. package/eldlock-server/internal/libfido2/fido2_darwin.go +7 -0
  56. package/eldlock-server/internal/libfido2/fido2_other.go +9 -0
  57. package/eldlock-server/internal/libfido2/fido2_test.go +101 -0
  58. package/eldlock-server/internal/libfido2/go.mod +10 -0
  59. package/eldlock-server/internal/libfido2/go.sum +16 -0
  60. package/eldlock-server/internal/libfido2/log.go +87 -0
  61. package/eldlock-server/internal/store/README.md +7 -0
  62. package/eldlock-server/internal/store/store.go +434 -0
  63. package/eldlock-server/internal/store/store_test.go +125 -0
  64. package/eldlock-server/internal/yubikey/README.md +25 -0
  65. package/eldlock-server/internal/yubikey/default_fido2.go +7 -0
  66. package/eldlock-server/internal/yubikey/default_stub.go +7 -0
  67. package/eldlock-server/internal/yubikey/fido2_disabled.go +9 -0
  68. package/eldlock-server/internal/yubikey/fido2_libfido2.go +225 -0
  69. package/eldlock-server/internal/yubikey/fido2_libfido2_test.go +66 -0
  70. package/eldlock-server/internal/yubikey/passkey.go +139 -0
  71. package/eldlock-server/internal/yubikey/passkey_test.go +36 -0
  72. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/LICENSE +21 -0
  73. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/README.md +127 -0
  74. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2.go +1234 -0
  75. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_darwin.go +7 -0
  76. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/fido2_other.go +9 -0
  77. package/eldlock-server/vendor/github.com/keys-pub/go-libfido2/log.go +87 -0
  78. package/eldlock-server/vendor/github.com/pkg/errors/.travis.yml +10 -0
  79. package/eldlock-server/vendor/github.com/pkg/errors/LICENSE +23 -0
  80. package/eldlock-server/vendor/github.com/pkg/errors/Makefile +44 -0
  81. package/eldlock-server/vendor/github.com/pkg/errors/README.md +59 -0
  82. package/eldlock-server/vendor/github.com/pkg/errors/appveyor.yml +32 -0
  83. package/eldlock-server/vendor/github.com/pkg/errors/errors.go +288 -0
  84. package/eldlock-server/vendor/github.com/pkg/errors/go113.go +38 -0
  85. package/eldlock-server/vendor/github.com/pkg/errors/stack.go +177 -0
  86. package/eldlock-server/vendor/modules.txt +7 -0
  87. package/examples/eldlock.toml +17 -0
  88. package/install.sh +66 -0
  89. package/package.json +66 -0
  90. package/scripts/build-production.mjs +177 -0
  91. package/scripts/postinstall-production.mjs +23 -0
@@ -0,0 +1,676 @@
1
+ import process from "node:process";
2
+ import type { CliRenderer, TextRenderable } from "@opentui/core";
3
+ import { request, type EldlockResponse, type SecretType } from "./api.js";
4
+
5
+ type TUIMode = "vault" | "pin" | "switch-vault" | "switch-pin" | "browse" | "add-name" | "add-value" | "add-pin" | "edit-value" | "edit-pin" | "delete-pin" | "show-pin" | "show-value" | "import-path" | "import-pin" | "refresh-pin";
6
+
7
+ type TUIPrompt = {
8
+ label: string;
9
+ value: string;
10
+ hidden: boolean;
11
+ title: string;
12
+ help: string[];
13
+ tone: "neutral" | "danger" | "secret";
14
+ };
15
+
16
+ type TUIState = {
17
+ vault: string;
18
+ secrets: Array<{ name: string; type: SecretType }>;
19
+ selected: number;
20
+ message: string;
21
+ mode: TUIMode;
22
+ prompt?: TUIPrompt;
23
+ pendingName?: string;
24
+ pendingValue?: string;
25
+ pendingImportPath?: string;
26
+ revealedName?: string;
27
+ revealedValue?: string;
28
+ };
29
+
30
+ export async function openTUI(): Promise<void> {
31
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
32
+ throw new Error("TUI requires an interactive terminal");
33
+ }
34
+
35
+ const { createCliRenderer, TextRenderable } = await import("@opentui/core").catch((error: unknown) => {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ throw new Error(`could not load OpenTUI; run eldlock with Bun or a Node runtime that supports node:ffi: ${message}`);
38
+ });
39
+ const renderer = await createCliRenderer({
40
+ screenMode: "alternate-screen",
41
+ consoleMode: "disabled",
42
+ exitOnCtrlC: false,
43
+ clearOnShutdown: true,
44
+ useMouse: false,
45
+ backgroundColor: "#0d1117",
46
+ });
47
+ const screen = new TextRenderable(renderer, {
48
+ id: "eldlock-screen",
49
+ content: "",
50
+ width: "100%",
51
+ height: "100%",
52
+ });
53
+ renderer.root.add(screen);
54
+ const input = createOpenTUIInput(renderer);
55
+ const state: TUIState = {
56
+ vault: "",
57
+ secrets: [],
58
+ selected: 0,
59
+ message: "Enter a vault name to begin",
60
+ mode: "vault",
61
+ prompt: {
62
+ label: "Vault name (blank for default)",
63
+ value: "",
64
+ hidden: false,
65
+ title: "Open Vault",
66
+ help: ["Type the vault name to enter.", "Leave it blank to use the default vault."],
67
+ tone: "neutral",
68
+ },
69
+ };
70
+
71
+ try {
72
+ renderer.start();
73
+ for (;;) {
74
+ renderTUI(state, screen, renderer);
75
+ const key = await input.read();
76
+ try {
77
+ const shouldQuit = await handleTUIKey(state, key);
78
+ if (shouldQuit) {
79
+ return;
80
+ }
81
+ } catch (error) {
82
+ state.mode = "browse";
83
+ state.prompt = undefined;
84
+ state.message = error instanceof Error ? error.message : String(error);
85
+ }
86
+ }
87
+ } finally {
88
+ input.destroy();
89
+ renderer.destroy();
90
+ }
91
+ }
92
+
93
+ async function loadTUISecrets(vault: string, pin: string): Promise<Array<{ name: string; type: SecretType }>> {
94
+ const response = await request({ action: "secret.list", vault, pin });
95
+ if (!response.ok) {
96
+ throw new Error(response.error ?? "request failed");
97
+ }
98
+ return response.secrets ?? [];
99
+ }
100
+
101
+ async function requireOK(response: EldlockResponse): Promise<void> {
102
+ if (!response.ok) {
103
+ throw new Error(response.error ?? "request failed");
104
+ }
105
+ }
106
+
107
+ async function handleTUIKey(state: TUIState, key: string): Promise<boolean> {
108
+ if (key === "\u0003") {
109
+ return true;
110
+ }
111
+ if (state.prompt) {
112
+ await handlePromptKey(state, key);
113
+ return false;
114
+ }
115
+ if (state.mode === "show-value") {
116
+ if (key === "q") {
117
+ return true;
118
+ }
119
+ state.revealedName = undefined;
120
+ state.revealedValue = undefined;
121
+ state.mode = "browse";
122
+ state.message = "Secret hidden";
123
+ return false;
124
+ }
125
+
126
+ switch (key) {
127
+ case "q":
128
+ return true;
129
+ case "\u001b[A":
130
+ case "k":
131
+ moveSelection(state, -1);
132
+ return false;
133
+ case "\u001b[B":
134
+ case "j":
135
+ moveSelection(state, 1);
136
+ return false;
137
+ case "a":
138
+ startPrompt(state, "add-name", "New secret name", false);
139
+ return false;
140
+ case "d":
141
+ requireSelectedSecret(state);
142
+ startPrompt(state, "delete-pin", `PIN to delete ${selectedSecret(state).name}`, true);
143
+ return false;
144
+ case "s":
145
+ requireSelectedSecret(state);
146
+ startPrompt(state, "show-pin", `PIN to show ${selectedSecret(state).name}`, true);
147
+ return false;
148
+ case "e":
149
+ requireSelectedSecret(state);
150
+ startPrompt(state, "edit-value", `New value for ${selectedSecret(state).name}`, true);
151
+ return false;
152
+ case "r":
153
+ startPrompt(state, "refresh-pin", "YubiKey PIN", true);
154
+ return false;
155
+ case "v":
156
+ startPrompt(state, "switch-vault", "Vault name (blank for default)", false);
157
+ state.prompt!.value = state.vault;
158
+ state.message = "Switch vault";
159
+ return false;
160
+ case "i":
161
+ startPrompt(state, "import-path", ".env path", false);
162
+ state.prompt!.value = ".env";
163
+ state.message = "Import env file";
164
+ return false;
165
+ default:
166
+ state.message = "Use a/d/s/e/i/r/v, arrows or j/k, q";
167
+ return false;
168
+ }
169
+ }
170
+
171
+ async function handlePromptKey(state: TUIState, key: string): Promise<void> {
172
+ const prompt = state.prompt;
173
+ if (!prompt) {
174
+ return;
175
+ }
176
+ if (isBracketedPaste(key)) {
177
+ if (promptAllowsPaste(state)) {
178
+ prompt.value += normalizePastedPromptText(extractBracketedPaste(key));
179
+ }
180
+ return;
181
+ }
182
+ if (isEscapeKey(key)) {
183
+ cancelPrompt(state);
184
+ return;
185
+ }
186
+ if (key.length > 1 && promptAllowsPaste(state)) {
187
+ prompt.value += normalizePastedPromptText(key);
188
+ return;
189
+ }
190
+ if (key === "\r" || key === "\n") {
191
+ await submitPrompt(state, prompt.value);
192
+ return;
193
+ }
194
+ if (key === "\u007f" || key === "\b") {
195
+ prompt.value = prompt.value.slice(0, -1);
196
+ return;
197
+ }
198
+ if (key >= " " && key !== "\u007f") {
199
+ prompt.value += key;
200
+ }
201
+ }
202
+
203
+ function promptAllowsPaste(state: TUIState): boolean {
204
+ return !state.mode.endsWith("-pin") && state.mode !== "pin";
205
+ }
206
+
207
+ function cancelPrompt(state: TUIState): void {
208
+ state.prompt = undefined;
209
+ state.mode = "browse";
210
+ state.message = "Cancelled";
211
+ }
212
+
213
+ function isEscapeKey(value: string): boolean {
214
+ return value === "\u001b" || value.startsWith("\u001b");
215
+ }
216
+
217
+ function isBracketedPaste(value: string): boolean {
218
+ return value.startsWith("\u001b[200~") && value.endsWith("\u001b[201~");
219
+ }
220
+
221
+ function extractBracketedPaste(value: string): string {
222
+ return value.slice("\u001b[200~".length, -"\u001b[201~".length);
223
+ }
224
+
225
+ function normalizePastedPromptText(value: string): string {
226
+ return value
227
+ .replace(/\u001b\[200~/g, "")
228
+ .replace(/\u001b\[201~/g, "")
229
+ .replace(/[\r\n]+/g, "")
230
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
231
+ }
232
+
233
+ async function submitPrompt(state: TUIState, value: string): Promise<void> {
234
+ switch (state.mode) {
235
+ case "vault":
236
+ state.vault = value.trim();
237
+ startPrompt(state, "pin", "YubiKey PIN", true);
238
+ state.message = `Unlocking ${displayVaultName(state.vault)}`;
239
+ return;
240
+ case "pin":
241
+ state.secrets = await loadTUISecrets(state.vault, value);
242
+ state.selected = clampSelection(state.selected, state.secrets);
243
+ state.prompt = undefined;
244
+ state.mode = "browse";
245
+ state.message = `Vault ${displayVaultName(state.vault)} unlocked`;
246
+ return;
247
+ case "switch-vault":
248
+ state.vault = value.trim();
249
+ state.secrets = [];
250
+ state.selected = 0;
251
+ startPrompt(state, "switch-pin", "YubiKey PIN", true);
252
+ state.message = `Switching to ${displayVaultName(state.vault)}`;
253
+ return;
254
+ case "switch-pin":
255
+ state.secrets = await loadTUISecrets(state.vault, value);
256
+ state.selected = clampSelection(0, state.secrets);
257
+ state.prompt = undefined;
258
+ state.mode = "browse";
259
+ state.message = `Vault ${displayVaultName(state.vault)} unlocked`;
260
+ return;
261
+ case "add-name":
262
+ if (!value.trim()) {
263
+ throw new Error("secret name is required");
264
+ }
265
+ state.pendingName = value.trim();
266
+ startPrompt(state, "add-value", `Value for ${state.pendingName}`, true);
267
+ return;
268
+ case "add-value":
269
+ if (!value) {
270
+ throw new Error("secret value is required");
271
+ }
272
+ state.pendingValue = value;
273
+ startPrompt(state, "add-pin", "YubiKey PIN", true);
274
+ return;
275
+ case "add-pin":
276
+ await requireOK(await request({ action: "secret.add", vault: state.vault, type: "env", name: state.pendingName, value: state.pendingValue, pin: value }));
277
+ await refreshAfterAction(state, value, "Secret added");
278
+ return;
279
+ case "edit-value":
280
+ if (!value) {
281
+ throw new Error("secret value is required");
282
+ }
283
+ state.pendingName = selectedSecret(state).name;
284
+ state.pendingValue = value;
285
+ startPrompt(state, "edit-pin", "YubiKey PIN", true);
286
+ return;
287
+ case "edit-pin":
288
+ await requireOK(await request({ action: "secret.add", vault: state.vault, type: "env", name: state.pendingName, value: state.pendingValue, pin: value }));
289
+ await refreshAfterAction(state, value, "Secret edited");
290
+ return;
291
+ case "delete-pin":
292
+ await requireOK(await request({ action: "secret.remove", vault: state.vault, name: selectedSecret(state).name, pin: value }));
293
+ await refreshAfterAction(state, value, "Secret deleted");
294
+ return;
295
+ case "show-pin": {
296
+ const secret = selectedSecret(state);
297
+ const response = await request({ action: "secret.read", vault: state.vault, name: secret.name, output: "plain", pin: value });
298
+ await requireOK(response);
299
+ state.prompt = undefined;
300
+ state.mode = "show-value";
301
+ state.revealedName = secret.name;
302
+ state.revealedValue = response.value ?? "";
303
+ state.message = "Press any key to hide this value";
304
+ return;
305
+ }
306
+ case "import-path":
307
+ state.pendingImportPath = value.trim() || ".env";
308
+ startPrompt(state, "import-pin", "YubiKey PIN", true);
309
+ return;
310
+ case "import-pin": {
311
+ const response = await request({
312
+ action: "secret.import_env",
313
+ vault: state.vault,
314
+ source_path: state.pendingImportPath,
315
+ cwd: process.cwd(),
316
+ pin: value,
317
+ });
318
+ await requireOK(response);
319
+ await refreshAfterAction(state, value, response.message ?? `Imported ${response.imported ?? 0} env secrets`);
320
+ return;
321
+ }
322
+ case "refresh-pin":
323
+ await refreshAfterAction(state, value, "Refreshed");
324
+ return;
325
+ default:
326
+ return;
327
+ }
328
+ }
329
+
330
+ async function refreshAfterAction(state: TUIState, pin: string, message: string): Promise<void> {
331
+ state.secrets = await loadTUISecrets(state.vault, pin);
332
+ state.selected = clampSelection(state.selected, state.secrets);
333
+ state.pendingName = undefined;
334
+ state.pendingValue = undefined;
335
+ state.pendingImportPath = undefined;
336
+ state.prompt = undefined;
337
+ state.mode = "browse";
338
+ state.message = message;
339
+ }
340
+
341
+ function renderTUI(state: TUIState, screen: TextRenderable, renderer: CliRenderer): void {
342
+ const width = renderer.terminalWidth || process.stdout.columns || 100;
343
+ const height = renderer.terminalHeight || process.stdout.rows || 30;
344
+ const bodyHeight = Math.max(4, height - 8);
345
+ const lines: string[] = [];
346
+ const nameWidth = Math.max(16, width - 42);
347
+ lines.push(padRight(` Eldlock Vault ${displayVaultName(state.vault)} ${state.secrets.length} secrets`, width));
348
+ lines.push(fillLine(width, "─"));
349
+ lines.push(`${padRight(" ", 3)}${padRight("TYPE", 8)} ${padRight("SECRET", nameWidth)} VALUE`);
350
+
351
+ if (state.secrets.length === 0) {
352
+ lines.push("");
353
+ lines.push(" No secrets in this vault");
354
+ } else {
355
+ const windowStart = selectionWindowStart(state.selected, state.secrets.length, bodyHeight);
356
+ const visible = state.secrets.slice(windowStart, windowStart + bodyHeight);
357
+ for (let offset = 0; offset < visible.length; offset += 1) {
358
+ const index = windowStart + offset;
359
+ const secret = state.secrets[index];
360
+ const selected = index === state.selected;
361
+ const prefix = selected ? "›" : " ";
362
+ const row = `${padRight(prefix, 3)}${padRight(secret.type, 8)} ${padRight(truncate(secret.name, nameWidth), nameWidth)} **********`;
363
+ lines.push(selected ? `${row} selected` : row);
364
+ }
365
+ }
366
+
367
+ while (lines.length < height - 3) {
368
+ lines.push("");
369
+ }
370
+
371
+ lines.push(fillLine(width, "─"));
372
+ lines.push(truncatePrintable(` ${state.message}`, width));
373
+ lines.push(truncatePrintable(" a add d delete s show e edit i import v vault r refresh ↑/↓ or j/k move q quit", width));
374
+
375
+ if (state.mode === "show-value") {
376
+ drawModal(lines, width, height, {
377
+ title: `Showing ${state.revealedName ?? "secret"}`,
378
+ body: [state.revealedValue ?? ""],
379
+ footer: "Press any key to hide",
380
+ tone: "secret",
381
+ });
382
+ } else if (state.prompt) {
383
+ const value = state.prompt.hidden ? "•".repeat(state.prompt.value.length) : state.prompt.value;
384
+ drawModal(lines, width, height, {
385
+ title: state.prompt.title,
386
+ body: [...state.prompt.help, "", `${state.prompt.label}`, `${value}_`],
387
+ footer: "Enter submit Esc cancel",
388
+ tone: state.prompt.tone,
389
+ });
390
+ }
391
+
392
+ screen.content = lines.slice(0, height).map((line) => truncatePrintable(line, width)).join("\n");
393
+ renderer.requestRender();
394
+ }
395
+
396
+ function startPrompt(state: TUIState, mode: TUIMode, label: string, hidden: boolean): void {
397
+ state.mode = mode;
398
+ state.prompt = {
399
+ label,
400
+ value: "",
401
+ hidden,
402
+ ...promptPresentation(mode, state),
403
+ };
404
+ }
405
+
406
+ function selectedSecret(state: TUIState): { name: string; type: SecretType } {
407
+ requireSelectedSecret(state);
408
+ return state.secrets[state.selected];
409
+ }
410
+
411
+ function requireSelectedSecret(state: TUIState): void {
412
+ if (state.secrets.length === 0) {
413
+ throw new Error("No selected secret");
414
+ }
415
+ }
416
+
417
+ function moveSelection(state: TUIState, delta: number): void {
418
+ state.selected = clampSelection(state.selected + delta, state.secrets);
419
+ state.message = state.secrets[state.selected]?.name ?? "No secrets";
420
+ }
421
+
422
+ function clampSelection(selected: number, secrets: Array<{ name: string }>): number {
423
+ if (secrets.length === 0) {
424
+ return 0;
425
+ }
426
+ return Math.max(0, Math.min(selected, secrets.length - 1));
427
+ }
428
+
429
+ type ModalOptions = {
430
+ title: string;
431
+ body: string[];
432
+ footer: string;
433
+ tone: "neutral" | "danger" | "secret";
434
+ };
435
+
436
+ function drawModal(lines: string[], width: number, height: number, modal: ModalOptions): void {
437
+ const modalWidth = Math.min(Math.max(46, Math.floor(width * 0.62)), Math.max(28, width - 4));
438
+ const wrappedBody = modal.body.flatMap((line) => wrapLine(line, modalWidth - 6));
439
+ const modalHeight = Math.min(height - 4, Math.max(8, wrappedBody.length + 5));
440
+ const top = Math.max(1, Math.floor((height - modalHeight) / 2));
441
+ const left = Math.max(0, Math.floor((width - modalWidth) / 2));
442
+ const title = ` ${modal.title} `;
443
+ const borderChar = modal.tone === "danger" ? "!" : "─";
444
+ const topBorder = `┌${centerText(title, modalWidth - 2, borderChar)}┐`;
445
+ const bottomBorder = `└${fillLine(modalWidth - 2, "─")}┘`;
446
+
447
+ const modalLines = [
448
+ topBorder,
449
+ ...wrappedBody.slice(0, modalHeight - 4).map((line) => `│ ${padRight(truncate(line, modalWidth - 4), modalWidth - 4)} │`),
450
+ `│ ${padRight("", modalWidth - 4)} │`,
451
+ `│ ${padRight(truncate(modal.footer, modalWidth - 4), modalWidth - 4)} │`,
452
+ bottomBorder,
453
+ ];
454
+
455
+ for (let row = 0; row < modalLines.length && top + row < height; row += 1) {
456
+ const existing = lines[top + row] ?? "";
457
+ const padded = truncatePrintable(existing, width).split("");
458
+ const modalLine = modalLines[row];
459
+ for (let column = 0; column < modalLine.length && left + column < width; column += 1) {
460
+ padded[left + column] = modalLine[column];
461
+ }
462
+ lines[top + row] = padded.join("");
463
+ }
464
+ }
465
+
466
+ function promptPresentation(mode: TUIMode, state: TUIState): Pick<TUIPrompt, "title" | "help" | "tone"> {
467
+ const selected = state.secrets[state.selected]?.name;
468
+ switch (mode) {
469
+ case "vault":
470
+ return {
471
+ title: "Open Vault",
472
+ help: ["Type the vault name to enter.", "Leave it blank to use the default vault."],
473
+ tone: "neutral",
474
+ };
475
+ case "pin":
476
+ case "switch-pin":
477
+ case "refresh-pin":
478
+ return {
479
+ title: "YubiKey PIN",
480
+ help: [`Unlock ${displayVaultName(state.vault)} with your YubiKey.`],
481
+ tone: "secret",
482
+ };
483
+ case "switch-vault":
484
+ return {
485
+ title: "Switch Vault",
486
+ help: ["Type another vault name to enter.", "Leave it blank to use the default vault."],
487
+ tone: "neutral",
488
+ };
489
+ case "add-name":
490
+ return {
491
+ title: "Add Secret",
492
+ help: ["Choose a name for the new secret.", "The value and PIN confirmation come next."],
493
+ tone: "neutral",
494
+ };
495
+ case "add-value":
496
+ return {
497
+ title: `Value for ${state.pendingName ?? "secret"}`,
498
+ help: ["Paste or type the value.", "It is hidden while you enter it."],
499
+ tone: "secret",
500
+ };
501
+ case "add-pin":
502
+ return {
503
+ title: "Confirm Add",
504
+ help: [`Use your YubiKey PIN to add ${state.pendingName ?? "this secret"}.`],
505
+ tone: "secret",
506
+ };
507
+ case "edit-value":
508
+ return {
509
+ title: `Edit ${selected ?? "secret"}`,
510
+ help: ["Enter the replacement value.", "The selected secret name stays unchanged."],
511
+ tone: "secret",
512
+ };
513
+ case "edit-pin":
514
+ return {
515
+ title: "Confirm Edit",
516
+ help: [`Use your YubiKey PIN to update ${state.pendingName ?? selected ?? "this secret"}.`],
517
+ tone: "secret",
518
+ };
519
+ case "delete-pin":
520
+ return {
521
+ title: `Delete ${selected ?? "secret"}`,
522
+ help: ["This removes the selected secret from the vault.", "Enter your YubiKey PIN to confirm."],
523
+ tone: "danger",
524
+ };
525
+ case "show-pin":
526
+ return {
527
+ title: `Show ${selected ?? "secret"}`,
528
+ help: ["This will reveal the selected value on screen.", "Enter your YubiKey PIN to continue."],
529
+ tone: "secret",
530
+ };
531
+ case "import-path":
532
+ return {
533
+ title: "Import .env",
534
+ help: ["Import env variables into the current vault.", "The daemon reads and parses the file."],
535
+ tone: "neutral",
536
+ };
537
+ case "import-pin":
538
+ return {
539
+ title: "Confirm Import",
540
+ help: [`Use your YubiKey PIN to import ${state.pendingImportPath ?? ".env"}.`],
541
+ tone: "secret",
542
+ };
543
+ default:
544
+ return {
545
+ title: "Eldlock",
546
+ help: [],
547
+ tone: "neutral",
548
+ };
549
+ }
550
+ }
551
+
552
+ function selectionWindowStart(selected: number, total: number, visible: number): number {
553
+ if (total <= visible) {
554
+ return 0;
555
+ }
556
+ const half = Math.floor(visible / 2);
557
+ return Math.max(0, Math.min(selected - half, total - visible));
558
+ }
559
+
560
+ function wrapLine(value: string, width: number): string[] {
561
+ if (!value) {
562
+ return [""];
563
+ }
564
+ const words = value.split(" ");
565
+ const lines: string[] = [];
566
+ let current = "";
567
+ for (const word of words) {
568
+ if (!current) {
569
+ current = word;
570
+ continue;
571
+ }
572
+ if (`${current} ${word}`.length > width) {
573
+ lines.push(current);
574
+ current = word;
575
+ } else {
576
+ current = `${current} ${word}`;
577
+ }
578
+ }
579
+ lines.push(current);
580
+ return lines.flatMap((line) => line.length <= width ? [line] : chunkLine(line, width));
581
+ }
582
+
583
+ function chunkLine(value: string, width: number): string[] {
584
+ const chunks: string[] = [];
585
+ for (let index = 0; index < value.length; index += width) {
586
+ chunks.push(value.slice(index, index + width));
587
+ }
588
+ return chunks;
589
+ }
590
+
591
+ function centerText(value: string, size: number, fill: string): string {
592
+ if (value.length >= size) {
593
+ return truncate(value, size);
594
+ }
595
+ const left = Math.floor((size - value.length) / 2);
596
+ const right = size - value.length - left;
597
+ return fill.repeat(left) + value + fill.repeat(right);
598
+ }
599
+
600
+ function fillLine(width: number, char: string): string {
601
+ return char.repeat(Math.max(0, width));
602
+ }
603
+
604
+ function displayVaultName(vault: string): string {
605
+ return vault.trim() || "default";
606
+ }
607
+
608
+ type OpenTUIInput = {
609
+ read(): Promise<string>;
610
+ destroy(): void;
611
+ };
612
+
613
+ function createOpenTUIInput(renderer: CliRenderer): OpenTUIInput {
614
+ const queue: string[] = [];
615
+ let waiter: ((value: string) => void) | undefined;
616
+
617
+ const push = (value: string): void => {
618
+ if (!value) {
619
+ return;
620
+ }
621
+ if (waiter) {
622
+ const resolve = waiter;
623
+ waiter = undefined;
624
+ resolve(value);
625
+ return;
626
+ }
627
+ queue.push(value);
628
+ };
629
+
630
+ const keyHandler = (key: { sequence?: string; raw?: string }): void => {
631
+ push(key.sequence ?? key.raw ?? "");
632
+ };
633
+ const pasteHandler = (event: { bytes: Uint8Array }): void => {
634
+ push(`\u001b[200~${decodePasteBytes(event.bytes)}\u001b[201~`);
635
+ };
636
+
637
+ renderer.keyInput.on("keypress", keyHandler);
638
+ renderer.keyInput.on("paste", pasteHandler);
639
+
640
+ return {
641
+ read(): Promise<string> {
642
+ const next = queue.shift();
643
+ if (next !== undefined) {
644
+ return Promise.resolve(next);
645
+ }
646
+ return new Promise((resolve) => {
647
+ waiter = resolve;
648
+ });
649
+ },
650
+ destroy(): void {
651
+ renderer.keyInput.off("keypress", keyHandler);
652
+ renderer.keyInput.off("paste", pasteHandler);
653
+ if (waiter) {
654
+ const resolve = waiter;
655
+ waiter = undefined;
656
+ resolve("\u0003");
657
+ }
658
+ },
659
+ };
660
+ }
661
+
662
+ function decodePasteBytes(bytes: Uint8Array): string {
663
+ return Buffer.from(bytes).toString("utf8");
664
+ }
665
+
666
+ function padRight(value: string, size: number): string {
667
+ return value.length >= size ? value : value + " ".repeat(size - value.length);
668
+ }
669
+
670
+ function truncate(value: string, size: number): string {
671
+ return value.length <= size ? value : value.slice(0, size - 1) + ".";
672
+ }
673
+
674
+ function truncatePrintable(value: string, size: number): string {
675
+ return value.length <= size ? value.padEnd(size) : value.slice(0, size);
676
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src/**/*.ts"]
12
+ }
13
+