dotenvx-ui 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.
- package/README.md +83 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1295 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// src/core/scanner.ts
|
|
7
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
8
|
+
import { join, relative, basename, dirname } from "path";
|
|
9
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
10
|
+
"node_modules",
|
|
11
|
+
".git",
|
|
12
|
+
"dist",
|
|
13
|
+
".next",
|
|
14
|
+
".turbo",
|
|
15
|
+
"build",
|
|
16
|
+
".cache"
|
|
17
|
+
]);
|
|
18
|
+
function detectRoot(cwd) {
|
|
19
|
+
let dir = cwd;
|
|
20
|
+
while (true) {
|
|
21
|
+
try {
|
|
22
|
+
statSync(join(dir, ".git"));
|
|
23
|
+
return dir;
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir) break;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
return cwd;
|
|
31
|
+
}
|
|
32
|
+
function scanForEnvFiles(root) {
|
|
33
|
+
const results = [];
|
|
34
|
+
function walk(dir) {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(dir);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
43
|
+
const full = join(dir, entry);
|
|
44
|
+
try {
|
|
45
|
+
const stat = statSync(full);
|
|
46
|
+
if (stat.isDirectory()) {
|
|
47
|
+
walk(full);
|
|
48
|
+
} else if (isEnvFile(entry)) {
|
|
49
|
+
results.push(full);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
walk(root);
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
function isEnvFile(name) {
|
|
59
|
+
if (name === ".env.keys") return false;
|
|
60
|
+
return name === ".env" || name.startsWith(".env.");
|
|
61
|
+
}
|
|
62
|
+
function parseEnvironmentFromFilename(filename) {
|
|
63
|
+
const name = basename(filename);
|
|
64
|
+
if (name === ".env") return "default";
|
|
65
|
+
const suffix = name.slice(".env.".length);
|
|
66
|
+
return suffix || "default";
|
|
67
|
+
}
|
|
68
|
+
function scan(cwd) {
|
|
69
|
+
const root = detectRoot(cwd);
|
|
70
|
+
const paths = scanForEnvFiles(root);
|
|
71
|
+
return paths.map((filePath) => {
|
|
72
|
+
const rel = relative(root, filePath);
|
|
73
|
+
const pkg = relative(root, dirname(filePath)) || ".";
|
|
74
|
+
const environment = parseEnvironmentFromFilename(basename(filePath));
|
|
75
|
+
let content = "";
|
|
76
|
+
try {
|
|
77
|
+
content = readFileSync(filePath, "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
const hasPublicKey = content.includes("DOTENV_PUBLIC_KEY=");
|
|
81
|
+
const encrypted = /encrypted:/.test(content);
|
|
82
|
+
return {
|
|
83
|
+
path: filePath,
|
|
84
|
+
relativePath: rel,
|
|
85
|
+
package: pkg,
|
|
86
|
+
environment,
|
|
87
|
+
encrypted,
|
|
88
|
+
hasPublicKey,
|
|
89
|
+
keys: []
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/tui/App.tsx
|
|
95
|
+
import { useState as useState3 } from "react";
|
|
96
|
+
import { Box as Box7, Text as Text7, useApp, useInput as useInput6, useStdin as useStdin6 } from "ink";
|
|
97
|
+
import clipboard from "clipboardy";
|
|
98
|
+
|
|
99
|
+
// src/tui/FileList.tsx
|
|
100
|
+
import { Box, Text, useInput, useStdin } from "ink";
|
|
101
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
102
|
+
function FileList({ files, selectedIndex, focused, onSelect }) {
|
|
103
|
+
const { isRawModeSupported } = useStdin();
|
|
104
|
+
useInput((_, key) => {
|
|
105
|
+
if (!focused) return;
|
|
106
|
+
if (key.upArrow) onSelect(Math.max(0, selectedIndex - 1));
|
|
107
|
+
if (key.downArrow) onSelect(Math.min(files.length - 1, selectedIndex + 1));
|
|
108
|
+
}, { isActive: isRawModeSupported });
|
|
109
|
+
const byPkg = Map.groupBy(files, (f) => f.package);
|
|
110
|
+
return /* @__PURE__ */ jsx(Box, { flexDirection: "column", width: 24, borderStyle: "single", borderRight: true, borderTop: false, borderBottom: false, borderLeft: false, children: Array.from(byPkg.entries()).map(([pkg, pkgFiles]) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
111
|
+
/* @__PURE__ */ jsxs(Text, { bold: true, dimColor: true, children: [
|
|
112
|
+
" ",
|
|
113
|
+
pkg
|
|
114
|
+
] }),
|
|
115
|
+
pkgFiles.map((f) => {
|
|
116
|
+
const idx = files.indexOf(f);
|
|
117
|
+
const selected = idx === selectedIndex;
|
|
118
|
+
return /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxs(
|
|
119
|
+
Text,
|
|
120
|
+
{
|
|
121
|
+
backgroundColor: selected && focused ? "blue" : void 0,
|
|
122
|
+
color: selected && focused ? "white" : selected ? "cyan" : void 0,
|
|
123
|
+
children: [
|
|
124
|
+
f.encrypted ? "\u{1F512} " : " ",
|
|
125
|
+
f.environment
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
) }, f.path);
|
|
129
|
+
})
|
|
130
|
+
] }, pkg)) });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/tui/KeyTable.tsx
|
|
134
|
+
import { Box as Box2, Text as Text2, useInput as useInput2, useStdin as useStdin2 } from "ink";
|
|
135
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
136
|
+
var SECRET_PATTERN = /secret|password|token|key|private|api_?key/i;
|
|
137
|
+
function maskValue(k, revealed) {
|
|
138
|
+
if (revealed.has(k.key)) return revealed.get(k.key);
|
|
139
|
+
if (k.encrypted) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
140
|
+
if (SECRET_PATTERN.test(k.key)) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
141
|
+
return k.value.length > 48 ? k.value.slice(0, 48) + "\u2026" : k.value;
|
|
142
|
+
}
|
|
143
|
+
function KeyTable({ file, keys, selectedIndex, focused, revealed, onSelect }) {
|
|
144
|
+
const { isRawModeSupported } = useStdin2();
|
|
145
|
+
useInput2((_, key) => {
|
|
146
|
+
if (!focused) return;
|
|
147
|
+
if (key.upArrow) onSelect(Math.max(0, selectedIndex - 1));
|
|
148
|
+
if (key.downArrow) onSelect(Math.min(keys.length - 1, selectedIndex + 1));
|
|
149
|
+
}, { isActive: isRawModeSupported });
|
|
150
|
+
const encBadge = file.encrypted ? /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " encrypted" }) : /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " plain" });
|
|
151
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, children: [
|
|
152
|
+
/* @__PURE__ */ jsxs2(Box2, { paddingX: 1, children: [
|
|
153
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: file.relativePath }),
|
|
154
|
+
encBadge,
|
|
155
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
156
|
+
" ",
|
|
157
|
+
keys.length,
|
|
158
|
+
" key",
|
|
159
|
+
keys.length === 1 ? "" : "s"
|
|
160
|
+
] })
|
|
161
|
+
] }),
|
|
162
|
+
keys.length === 0 ? /* @__PURE__ */ jsx2(Box2, { paddingX: 2, marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
163
|
+
"No keys found. Press ",
|
|
164
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "a" }),
|
|
165
|
+
" to add one."
|
|
166
|
+
] }) }) : keys.map((k, idx) => {
|
|
167
|
+
const selected = idx === selectedIndex;
|
|
168
|
+
const value = maskValue(k, revealed);
|
|
169
|
+
const lockIcon = k.encrypted && !revealed.has(k.key) ? " \u{1F512}" : "";
|
|
170
|
+
return /* @__PURE__ */ jsx2(Box2, { paddingX: 1, children: /* @__PURE__ */ jsxs2(
|
|
171
|
+
Text2,
|
|
172
|
+
{
|
|
173
|
+
backgroundColor: selected && focused ? "blue" : void 0,
|
|
174
|
+
color: selected && focused ? "white" : selected ? "cyan" : void 0,
|
|
175
|
+
children: [
|
|
176
|
+
k.key.padEnd(24),
|
|
177
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: !selected, children: value }),
|
|
178
|
+
lockIcon
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
) }, k.key);
|
|
182
|
+
})
|
|
183
|
+
] });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/tui/StatusBar.tsx
|
|
187
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
188
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
189
|
+
var FILE_HINTS = "\u2191\u2193 navigate tab switch panel ? help q quit";
|
|
190
|
+
var KEY_HINTS = "\u2191\u2193 navigate tab switch enter edit y copy r reveal a add D del d diff ? help q quit";
|
|
191
|
+
function StatusBar({ message, focus }) {
|
|
192
|
+
return /* @__PURE__ */ jsx3(Box3, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: message ?? (focus === "files" ? FILE_HINTS : KEY_HINTS) }) });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/tui/DiffView.tsx
|
|
196
|
+
import { useState } from "react";
|
|
197
|
+
import { Box as Box4, Text as Text4, useInput as useInput3, useStdin as useStdin3 } from "ink";
|
|
198
|
+
|
|
199
|
+
// src/core/parser/io.ts
|
|
200
|
+
import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
|
|
201
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
202
|
+
import { randomBytes } from "crypto";
|
|
203
|
+
|
|
204
|
+
// src/core/parser/values.ts
|
|
205
|
+
function isEncryptedValue(value) {
|
|
206
|
+
return value.startsWith("encrypted:");
|
|
207
|
+
}
|
|
208
|
+
function parseValue(rawValue, allLines, nextLineIdx) {
|
|
209
|
+
const trimmed = rawValue.trim();
|
|
210
|
+
if (trimmed.startsWith('"')) {
|
|
211
|
+
const inner = trimmed.slice(1);
|
|
212
|
+
const closeIdx = findClosingQuote(inner);
|
|
213
|
+
if (closeIdx !== -1) {
|
|
214
|
+
return { value: unescape(inner.slice(0, closeIdx)), extraLines: [] };
|
|
215
|
+
}
|
|
216
|
+
const valueLines = [inner];
|
|
217
|
+
let idx = nextLineIdx;
|
|
218
|
+
while (idx < allLines.length) {
|
|
219
|
+
const continuation = allLines[idx];
|
|
220
|
+
const close = findClosingQuote(continuation);
|
|
221
|
+
if (close !== -1) {
|
|
222
|
+
valueLines.push(continuation.slice(0, close));
|
|
223
|
+
return {
|
|
224
|
+
value: valueLines.join("\n"),
|
|
225
|
+
extraLines: allLines.slice(nextLineIdx, idx + 1)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
valueLines.push(continuation);
|
|
229
|
+
idx++;
|
|
230
|
+
}
|
|
231
|
+
return { value: valueLines.join("\n"), extraLines: allLines.slice(nextLineIdx, idx) };
|
|
232
|
+
}
|
|
233
|
+
if (trimmed.startsWith("'")) {
|
|
234
|
+
const inner = trimmed.slice(1);
|
|
235
|
+
const closeIdx = inner.indexOf("'");
|
|
236
|
+
return {
|
|
237
|
+
value: closeIdx !== -1 ? inner.slice(0, closeIdx) : inner,
|
|
238
|
+
extraLines: []
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const commentIdx = trimmed.indexOf(" #");
|
|
242
|
+
const bare = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
|
|
243
|
+
return { value: bare, extraLines: [] };
|
|
244
|
+
}
|
|
245
|
+
function serializeKeyValue(key, value) {
|
|
246
|
+
if (value.includes("\n")) {
|
|
247
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
|
|
248
|
+
return `${key}="${escaped}"`;
|
|
249
|
+
}
|
|
250
|
+
if (value === "" || /[\s#"'`]/.test(value)) {
|
|
251
|
+
return `${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
252
|
+
}
|
|
253
|
+
return `${key}=${value}`;
|
|
254
|
+
}
|
|
255
|
+
function findClosingQuote(s) {
|
|
256
|
+
for (let i = 0; i < s.length; i++) {
|
|
257
|
+
if (s[i] === "\\") {
|
|
258
|
+
i++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (s[i] === '"') return i;
|
|
262
|
+
}
|
|
263
|
+
return -1;
|
|
264
|
+
}
|
|
265
|
+
function unescape(s) {
|
|
266
|
+
return s.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r/g, "\r").replace(/\\\\/g, "\\").replace(/\\"/g, '"');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/core/parser/io.ts
|
|
270
|
+
function readEnvFile(filePath) {
|
|
271
|
+
const content = readFileSync2(filePath, "utf8");
|
|
272
|
+
return parse(content).filter((e) => e.type === "key").map((e) => ({
|
|
273
|
+
key: e.key,
|
|
274
|
+
value: e.value,
|
|
275
|
+
encrypted: isEncryptedValue(e.value),
|
|
276
|
+
comment: extractLeadingComment(e.lines)
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
function writeEnvFile(filePath, keys) {
|
|
280
|
+
const content = readFileSync2(filePath, "utf8");
|
|
281
|
+
const entries = parse(content);
|
|
282
|
+
const updates = new Map(keys.map((k) => [k.key, k]));
|
|
283
|
+
const outLines = [];
|
|
284
|
+
const written = /* @__PURE__ */ new Set();
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (entry.type === "raw") {
|
|
287
|
+
outLines.push(entry.text);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const update = updates.get(entry.key);
|
|
291
|
+
if (!update) continue;
|
|
292
|
+
written.add(entry.key);
|
|
293
|
+
const leadingComments = getLeadingCommentLines(entry.lines);
|
|
294
|
+
outLines.push(...leadingComments);
|
|
295
|
+
if (update.value === entry.value) {
|
|
296
|
+
const keyLines = entry.lines.filter((l) => !l.trimStart().startsWith("#"));
|
|
297
|
+
outLines.push(...keyLines);
|
|
298
|
+
} else {
|
|
299
|
+
outLines.push(serializeKeyValue(entry.key, update.value));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const k of keys) {
|
|
303
|
+
if (!written.has(k.key)) {
|
|
304
|
+
if (k.comment) outLines.push(`# ${k.comment}`);
|
|
305
|
+
outLines.push(serializeKeyValue(k.key, k.value));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const output = outLines.join("\n") + (content.endsWith("\n") ? "\n" : "");
|
|
309
|
+
atomicWrite(filePath, output);
|
|
310
|
+
}
|
|
311
|
+
function addKey(filePath, key, value) {
|
|
312
|
+
const keys = readEnvFile(filePath);
|
|
313
|
+
if (keys.some((k) => k.key === key)) {
|
|
314
|
+
throw new Error(`Key "${key}" already exists in ${filePath}`);
|
|
315
|
+
}
|
|
316
|
+
keys.push({ key, value, encrypted: isEncryptedValue(value) });
|
|
317
|
+
writeEnvFile(filePath, keys);
|
|
318
|
+
}
|
|
319
|
+
function updateKey(filePath, key, value) {
|
|
320
|
+
const keys = readEnvFile(filePath);
|
|
321
|
+
const idx = keys.findIndex((k) => k.key === key);
|
|
322
|
+
if (idx === -1) throw new Error(`Key "${key}" not found in ${filePath}`);
|
|
323
|
+
keys[idx] = { ...keys[idx], key, value, encrypted: isEncryptedValue(value) };
|
|
324
|
+
writeEnvFile(filePath, keys);
|
|
325
|
+
}
|
|
326
|
+
function removeKey(filePath, key) {
|
|
327
|
+
const keys = readEnvFile(filePath).filter((k) => k.key !== key);
|
|
328
|
+
writeEnvFile(filePath, keys);
|
|
329
|
+
}
|
|
330
|
+
function parse(content) {
|
|
331
|
+
const entries = [];
|
|
332
|
+
const lines = content.split("\n");
|
|
333
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
334
|
+
let i = 0;
|
|
335
|
+
let pendingComments = [];
|
|
336
|
+
while (i < lines.length) {
|
|
337
|
+
const line = lines[i];
|
|
338
|
+
if (line.trim() === "") {
|
|
339
|
+
for (const c of pendingComments) entries.push({ type: "raw", text: c });
|
|
340
|
+
pendingComments = [];
|
|
341
|
+
entries.push({ type: "raw", text: line });
|
|
342
|
+
i++;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (line.trimStart().startsWith("#")) {
|
|
346
|
+
pendingComments.push(line);
|
|
347
|
+
i++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const eqIdx = line.indexOf("=");
|
|
351
|
+
if (eqIdx === -1) {
|
|
352
|
+
for (const c of pendingComments) entries.push({ type: "raw", text: c });
|
|
353
|
+
pendingComments = [];
|
|
354
|
+
entries.push({ type: "raw", text: line });
|
|
355
|
+
i++;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const key = line.slice(0, eqIdx).trim();
|
|
359
|
+
const rawValue = line.slice(eqIdx + 1);
|
|
360
|
+
const { value, extraLines } = parseValue(rawValue, lines, i + 1);
|
|
361
|
+
entries.push({ type: "key", key, value, lines: [...pendingComments, line, ...extraLines] });
|
|
362
|
+
pendingComments = [];
|
|
363
|
+
i += 1 + extraLines.length;
|
|
364
|
+
}
|
|
365
|
+
for (const c of pendingComments) entries.push({ type: "raw", text: c });
|
|
366
|
+
return entries;
|
|
367
|
+
}
|
|
368
|
+
function getLeadingCommentLines(lines) {
|
|
369
|
+
const result = [];
|
|
370
|
+
for (const l of lines) {
|
|
371
|
+
if (l.trimStart().startsWith("#")) result.push(l);
|
|
372
|
+
else break;
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
function extractLeadingComment(lines) {
|
|
377
|
+
const comments = getLeadingCommentLines(lines).map(
|
|
378
|
+
(l) => l.trimStart().slice(1).trim()
|
|
379
|
+
);
|
|
380
|
+
return comments.length > 0 ? comments.join("\n") : void 0;
|
|
381
|
+
}
|
|
382
|
+
function atomicWrite(filePath, content) {
|
|
383
|
+
const tmp = join2(
|
|
384
|
+
dirname2(filePath),
|
|
385
|
+
`.dotenvx-ui-tmp-${randomBytes(6).toString("hex")}`
|
|
386
|
+
);
|
|
387
|
+
try {
|
|
388
|
+
writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
389
|
+
renameSync(tmp, filePath);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
try {
|
|
392
|
+
writeFileSync(tmp, "");
|
|
393
|
+
} catch {
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`Failed to write ${filePath}: ${err.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/core/dotenvx.ts
|
|
400
|
+
import { createRequire } from "module";
|
|
401
|
+
import { existsSync, readFileSync as readFileSync3 } from "fs";
|
|
402
|
+
import { join as join3, dirname as dirname3 } from "path";
|
|
403
|
+
var dotenvx = createRequire(import.meta.url)("@dotenvx/dotenvx");
|
|
404
|
+
function decryptValue(encryptedValue, envFilePath) {
|
|
405
|
+
if (!isEncryptedValue(encryptedValue)) return encryptedValue;
|
|
406
|
+
const keyName = findKeyForValue(encryptedValue, envFilePath);
|
|
407
|
+
if (!keyName) return null;
|
|
408
|
+
const keysFile = findKeysFile(envFilePath);
|
|
409
|
+
try {
|
|
410
|
+
const result = dotenvx.get(keyName, {
|
|
411
|
+
path: envFilePath,
|
|
412
|
+
...keysFile ? { envKeysFile: keysFile } : {},
|
|
413
|
+
logLevel: "error"
|
|
414
|
+
});
|
|
415
|
+
return result ?? null;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
var DOTENVX_INTERNAL_KEYS = /* @__PURE__ */ new Set(["DOTENV_PUBLIC_KEY", "DOTENV_PRIVATE_KEY"]);
|
|
421
|
+
function encryptFile(envFilePath) {
|
|
422
|
+
const keys = readEnvFile(envFilePath);
|
|
423
|
+
for (const k of keys) {
|
|
424
|
+
if (!isEncryptedValue(k.value) && !DOTENVX_INTERNAL_KEYS.has(k.key)) {
|
|
425
|
+
dotenvx.set(k.key, k.value, {
|
|
426
|
+
path: envFilePath,
|
|
427
|
+
encrypt: true,
|
|
428
|
+
logLevel: "error"
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function encryptKey(envFilePath, keyName, plainValue) {
|
|
434
|
+
dotenvx.set(keyName, plainValue, {
|
|
435
|
+
path: envFilePath,
|
|
436
|
+
encrypt: true,
|
|
437
|
+
logLevel: "error"
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function decryptFile(envFilePath) {
|
|
441
|
+
const keys = readEnvFile(envFilePath);
|
|
442
|
+
for (const k of keys) {
|
|
443
|
+
if (isEncryptedValue(k.value)) {
|
|
444
|
+
const plain = decryptValue(k.value, envFilePath);
|
|
445
|
+
if (plain !== null) updateKey(envFilePath, k.key, plain);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function findKeysFile(envFilePath) {
|
|
450
|
+
let dir = dirname3(envFilePath);
|
|
451
|
+
while (true) {
|
|
452
|
+
const candidate = join3(dir, ".env.keys");
|
|
453
|
+
if (existsSync(candidate)) return candidate;
|
|
454
|
+
const parent = dirname3(dir);
|
|
455
|
+
if (parent === dir) break;
|
|
456
|
+
dir = parent;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function findKeyForValue(encryptedValue, envFilePath) {
|
|
461
|
+
let raw;
|
|
462
|
+
try {
|
|
463
|
+
raw = readFileSync3(envFilePath, "utf8");
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
for (const line of raw.split("\n")) {
|
|
468
|
+
const eqIdx = line.indexOf("=");
|
|
469
|
+
if (eqIdx === -1) continue;
|
|
470
|
+
const lineValue = line.slice(eqIdx + 1).trim();
|
|
471
|
+
if (lineValue === encryptedValue) return line.slice(0, eqIdx).trim();
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/tui/DiffView.tsx
|
|
477
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
478
|
+
var COL_VAL = 26;
|
|
479
|
+
function buildDisplayMap(keys, filePath) {
|
|
480
|
+
const out = /* @__PURE__ */ new Map();
|
|
481
|
+
for (const k of keys) {
|
|
482
|
+
if (k.encrypted) {
|
|
483
|
+
const plain = decryptValue(k.value, filePath);
|
|
484
|
+
out.set(k.key, plain !== null ? plain : "\u{1F512}");
|
|
485
|
+
} else {
|
|
486
|
+
out.set(k.key, k.value);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
function buildRows(leftMap, rightMap) {
|
|
492
|
+
const allKeys = Array.from(/* @__PURE__ */ new Set([...leftMap.keys(), ...rightMap.keys()]));
|
|
493
|
+
return allKeys.map((key) => {
|
|
494
|
+
const l = leftMap.get(key) ?? null;
|
|
495
|
+
const r = rightMap.get(key) ?? null;
|
|
496
|
+
let status;
|
|
497
|
+
if (l === null) status = "right-only";
|
|
498
|
+
else if (r === null) status = "left-only";
|
|
499
|
+
else if (l === r) status = "same";
|
|
500
|
+
else status = "diff";
|
|
501
|
+
return {
|
|
502
|
+
key,
|
|
503
|
+
leftDisplay: l !== null ? trunc(l, COL_VAL) : "",
|
|
504
|
+
rightDisplay: r !== null ? trunc(r, COL_VAL) : "",
|
|
505
|
+
status
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function trunc(s, max) {
|
|
510
|
+
const first = s.split("\n")[0];
|
|
511
|
+
return first.length > max ? first.slice(0, max - 1) + "\u2026" : first;
|
|
512
|
+
}
|
|
513
|
+
function safeRead(file) {
|
|
514
|
+
try {
|
|
515
|
+
return readEnvFile(file.path);
|
|
516
|
+
} catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function DiffView({ left, files, onClose }) {
|
|
521
|
+
const { isRawModeSupported } = useStdin3();
|
|
522
|
+
const others = files.filter((f) => f.path !== left.path);
|
|
523
|
+
const [pickerIndex, setPickerIndex] = useState(0);
|
|
524
|
+
const rightFile = others[pickerIndex] ?? null;
|
|
525
|
+
const leftKeys = safeRead(left);
|
|
526
|
+
const rightKeys = rightFile ? safeRead(rightFile) : [];
|
|
527
|
+
const leftMap = buildDisplayMap(leftKeys, left.path);
|
|
528
|
+
const rightMap = rightFile ? buildDisplayMap(rightKeys, rightFile.path) : /* @__PURE__ */ new Map();
|
|
529
|
+
const rows = rightFile ? buildRows(leftMap, rightMap) : [];
|
|
530
|
+
useInput3((input, key) => {
|
|
531
|
+
if (key.escape || input === "q") {
|
|
532
|
+
onClose();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (key.upArrow) setPickerIndex((i) => Math.max(0, i - 1));
|
|
536
|
+
if (key.downArrow) setPickerIndex((i) => Math.min(others.length - 1, i + 1));
|
|
537
|
+
}, { isActive: isRawModeSupported });
|
|
538
|
+
const leftName = trunc(left.relativePath, COL_VAL);
|
|
539
|
+
const rightName = rightFile ? trunc(rightFile.relativePath, COL_VAL) : "\u2014";
|
|
540
|
+
return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
|
|
541
|
+
/* @__PURE__ */ jsxs3(Box4, { paddingX: 1, children: [
|
|
542
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "dotenvx-ui " }),
|
|
543
|
+
/* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
|
|
544
|
+
"diff ",
|
|
545
|
+
left.relativePath,
|
|
546
|
+
" \u2194 ",
|
|
547
|
+
rightFile?.relativePath ?? "\u2014"
|
|
548
|
+
] })
|
|
549
|
+
] }),
|
|
550
|
+
/* @__PURE__ */ jsxs3(
|
|
551
|
+
Box4,
|
|
552
|
+
{
|
|
553
|
+
flexDirection: "column",
|
|
554
|
+
paddingX: 1,
|
|
555
|
+
borderStyle: "single",
|
|
556
|
+
borderBottom: true,
|
|
557
|
+
borderTop: false,
|
|
558
|
+
borderLeft: false,
|
|
559
|
+
borderRight: false,
|
|
560
|
+
children: [
|
|
561
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, dimColor: true, children: "compare with" }),
|
|
562
|
+
others.map((f, i) => {
|
|
563
|
+
const selected = i === pickerIndex;
|
|
564
|
+
return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs3(
|
|
565
|
+
Text4,
|
|
566
|
+
{
|
|
567
|
+
backgroundColor: selected ? "blue" : void 0,
|
|
568
|
+
color: selected ? "white" : void 0,
|
|
569
|
+
children: [
|
|
570
|
+
selected ? "\u25B6 " : " ",
|
|
571
|
+
f.relativePath
|
|
572
|
+
]
|
|
573
|
+
}
|
|
574
|
+
) }, f.path);
|
|
575
|
+
}),
|
|
576
|
+
others.length === 0 && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " no other files" })
|
|
577
|
+
]
|
|
578
|
+
}
|
|
579
|
+
),
|
|
580
|
+
/* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: [
|
|
581
|
+
/* @__PURE__ */ jsxs3(Box4, { borderStyle: "single", borderBottom: true, borderTop: false, borderLeft: false, borderRight: false, children: [
|
|
582
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "KEY".padEnd(22) }),
|
|
583
|
+
/* @__PURE__ */ jsxs3(Text4, { bold: true, children: [
|
|
584
|
+
" ",
|
|
585
|
+
leftName.padEnd(COL_VAL + 2)
|
|
586
|
+
] }),
|
|
587
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, children: rightName })
|
|
588
|
+
] }),
|
|
589
|
+
rows.map((row) => /* @__PURE__ */ jsx4(DiffRow, { row }, row.key)),
|
|
590
|
+
rows.length === 0 && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Select a file to compare." }) })
|
|
591
|
+
] }),
|
|
592
|
+
/* @__PURE__ */ jsxs3(Box4, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, children: [
|
|
593
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 pick file esc close " }),
|
|
594
|
+
/* @__PURE__ */ jsx4(Text4, { color: "green", children: "\u25CF same" })
|
|
595
|
+
] })
|
|
596
|
+
] });
|
|
597
|
+
}
|
|
598
|
+
function DiffRow({ row }) {
|
|
599
|
+
const { key, leftDisplay, rightDisplay, status } = row;
|
|
600
|
+
const color = status === "same" ? "green" : void 0;
|
|
601
|
+
return /* @__PURE__ */ jsxs3(Box4, { children: [
|
|
602
|
+
/* @__PURE__ */ jsx4(Text4, { color, children: key.padEnd(22) }),
|
|
603
|
+
/* @__PURE__ */ jsxs3(Text4, { color, children: [
|
|
604
|
+
" ",
|
|
605
|
+
(leftDisplay || "\u2014").padEnd(COL_VAL + 2)
|
|
606
|
+
] }),
|
|
607
|
+
/* @__PURE__ */ jsx4(Text4, { color, children: rightDisplay || "\u2014" })
|
|
608
|
+
] });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/tui/HelpOverlay.tsx
|
|
612
|
+
import { Box as Box5, Text as Text5, useInput as useInput4, useStdin as useStdin4 } from "ink";
|
|
613
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
614
|
+
var SECTIONS = [
|
|
615
|
+
{
|
|
616
|
+
title: "Navigation",
|
|
617
|
+
rows: [
|
|
618
|
+
["\u2191 \u2193", "Move up / down"],
|
|
619
|
+
["Tab", "Switch between file list and key table"],
|
|
620
|
+
["q / Esc", "Quit"]
|
|
621
|
+
]
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
title: "Key actions",
|
|
625
|
+
rows: [
|
|
626
|
+
["Enter", "Edit selected key"],
|
|
627
|
+
["a", "Add new key"],
|
|
628
|
+
["D", "Delete selected key (confirmation required)"],
|
|
629
|
+
["y", "Copy value to clipboard"],
|
|
630
|
+
["r", "Reveal / hide selected key value"],
|
|
631
|
+
["R", "Reveal / hide all key values"]
|
|
632
|
+
]
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
title: "File actions",
|
|
636
|
+
rows: [
|
|
637
|
+
["e", "Encrypt / decrypt entire file"],
|
|
638
|
+
["d", "Open diff view"]
|
|
639
|
+
]
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
title: "Diff view",
|
|
643
|
+
rows: [
|
|
644
|
+
["\u2191 \u2193", "Pick file to compare"],
|
|
645
|
+
["Esc / q", "Close diff view"]
|
|
646
|
+
]
|
|
647
|
+
}
|
|
648
|
+
];
|
|
649
|
+
var KEY_WIDTH = 10;
|
|
650
|
+
function HelpOverlay({ onClose }) {
|
|
651
|
+
const { isRawModeSupported } = useStdin4();
|
|
652
|
+
useInput4((input, key) => {
|
|
653
|
+
if (input === "?" || input === "q" || key.escape) onClose();
|
|
654
|
+
}, { isActive: isRawModeSupported });
|
|
655
|
+
return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
656
|
+
/* @__PURE__ */ jsxs4(Box5, { marginBottom: 1, children: [
|
|
657
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "dotenvx-ui " }),
|
|
658
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "keyboard shortcuts" })
|
|
659
|
+
] }),
|
|
660
|
+
SECTIONS.map((section) => /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginBottom: 1, children: [
|
|
661
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: section.title }),
|
|
662
|
+
section.rows.map(([key, desc]) => /* @__PURE__ */ jsxs4(Box5, { children: [
|
|
663
|
+
/* @__PURE__ */ jsx5(Text5, { color: "cyan", children: key.padEnd(KEY_WIDTH) }),
|
|
664
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: desc })
|
|
665
|
+
] }, key))
|
|
666
|
+
] }, section.title)),
|
|
667
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "? / q / esc close help" }) })
|
|
668
|
+
] });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/tui/InlineForm.tsx
|
|
672
|
+
import { useState as useState2 } from "react";
|
|
673
|
+
import { Box as Box6, Text as Text6, useInput as useInput5, useStdin as useStdin5 } from "ink";
|
|
674
|
+
import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
675
|
+
function InlineForm({ label, initialValue = "", onSubmit, onCancel }) {
|
|
676
|
+
const { isRawModeSupported } = useStdin5();
|
|
677
|
+
const [value, setValue] = useState2(initialValue);
|
|
678
|
+
const [cursor, setCursor] = useState2(initialValue.length);
|
|
679
|
+
useInput5((input, key) => {
|
|
680
|
+
if (key.escape) {
|
|
681
|
+
onCancel();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (key.return) {
|
|
685
|
+
onSubmit(value);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (key.leftArrow) {
|
|
689
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (key.rightArrow) {
|
|
693
|
+
setCursor((c) => Math.min(value.length, c + 1));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (key.ctrl && input === "a" || key.home) {
|
|
697
|
+
setCursor(0);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (key.ctrl && input === "e" || key.end) {
|
|
701
|
+
setCursor(value.length);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (key.ctrl && input === "k") {
|
|
705
|
+
setValue((v) => v.slice(0, cursor));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (key.ctrl && input === "u") {
|
|
709
|
+
setValue((v) => v.slice(cursor));
|
|
710
|
+
setCursor(0);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (key.backspace) {
|
|
714
|
+
if (cursor === 0) return;
|
|
715
|
+
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
716
|
+
setCursor((c) => c - 1);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (key.delete) {
|
|
720
|
+
setValue((v) => v.slice(0, cursor) + v.slice(cursor + 1));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (input && !key.ctrl && !key.meta) {
|
|
724
|
+
setValue((v) => v.slice(0, cursor) + input + v.slice(cursor));
|
|
725
|
+
setCursor((c) => c + input.length);
|
|
726
|
+
}
|
|
727
|
+
}, { isActive: isRawModeSupported });
|
|
728
|
+
const before = value.slice(0, cursor);
|
|
729
|
+
const at = value[cursor] ?? " ";
|
|
730
|
+
const after = value.slice(cursor + 1);
|
|
731
|
+
return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, paddingTop: 0, children: /* @__PURE__ */ jsxs5(Box6, { children: [
|
|
732
|
+
/* @__PURE__ */ jsxs5(Text6, { bold: true, color: "cyan", children: [
|
|
733
|
+
label,
|
|
734
|
+
" "
|
|
735
|
+
] }),
|
|
736
|
+
/* @__PURE__ */ jsx6(Text6, { children: before }),
|
|
737
|
+
/* @__PURE__ */ jsx6(Text6, { inverse: true, children: at }),
|
|
738
|
+
/* @__PURE__ */ jsx6(Text6, { children: after }),
|
|
739
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " \u21B5 confirm esc cancel" })
|
|
740
|
+
] }) });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/tui/App.tsx
|
|
744
|
+
import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
745
|
+
function App({ files }) {
|
|
746
|
+
const { exit } = useApp();
|
|
747
|
+
const { isRawModeSupported } = useStdin6();
|
|
748
|
+
const [fileIndex, setFileIndex] = useState3(0);
|
|
749
|
+
const [keyIndex, setKeyIndex] = useState3(0);
|
|
750
|
+
const [focus, setFocus] = useState3("files");
|
|
751
|
+
const [revealed, setRevealed] = useState3(/* @__PURE__ */ new Map());
|
|
752
|
+
const [mode, setMode] = useState3({ type: "normal" });
|
|
753
|
+
const [statusMsg, setStatusMsg] = useState3();
|
|
754
|
+
const [keys, setKeys] = useState3(() => loadKeys(files[0]));
|
|
755
|
+
const selectedFile = files[fileIndex];
|
|
756
|
+
function loadKeys(file) {
|
|
757
|
+
try {
|
|
758
|
+
return readEnvFile(file.path);
|
|
759
|
+
} catch {
|
|
760
|
+
return [];
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function refreshKeys() {
|
|
764
|
+
setKeys(loadKeys(selectedFile));
|
|
765
|
+
}
|
|
766
|
+
function selectFile(idx) {
|
|
767
|
+
setFileIndex(idx);
|
|
768
|
+
setKeyIndex(0);
|
|
769
|
+
setRevealed(/* @__PURE__ */ new Map());
|
|
770
|
+
setKeys(loadKeys(files[idx]));
|
|
771
|
+
}
|
|
772
|
+
function flash(msg, ms = 1500) {
|
|
773
|
+
setStatusMsg(msg);
|
|
774
|
+
setTimeout(() => setStatusMsg(void 0), ms);
|
|
775
|
+
}
|
|
776
|
+
useInput6((input, key) => {
|
|
777
|
+
if (mode.type !== "normal") return;
|
|
778
|
+
if (input === "q" || key.escape) {
|
|
779
|
+
exit();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (input === "?") {
|
|
783
|
+
setMode({ type: "help" });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (key.tab) {
|
|
787
|
+
setFocus((f) => f === "files" ? "keys" : "files");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (focus !== "keys") return;
|
|
791
|
+
const k = keys[keyIndex];
|
|
792
|
+
if (input === "r") {
|
|
793
|
+
if (!k) return;
|
|
794
|
+
if (revealed.has(k.key)) {
|
|
795
|
+
setRevealed((prev) => {
|
|
796
|
+
const next = new Map(prev);
|
|
797
|
+
next.delete(k.key);
|
|
798
|
+
return next;
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (k.encrypted) {
|
|
803
|
+
const plain = decryptValue(k.value, selectedFile.path);
|
|
804
|
+
if (plain === null) {
|
|
805
|
+
flash("\u{1F512} Private key not found in environment");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
setRevealed((prev) => new Map(prev).set(k.key, plain));
|
|
809
|
+
} else {
|
|
810
|
+
setRevealed((prev) => new Map(prev).set(k.key, k.value));
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (input === "R") {
|
|
815
|
+
if (revealed.size > 0) {
|
|
816
|
+
setRevealed(/* @__PURE__ */ new Map());
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const next = /* @__PURE__ */ new Map();
|
|
820
|
+
for (const entry of keys) {
|
|
821
|
+
if (entry.encrypted) {
|
|
822
|
+
const plain = decryptValue(entry.value, selectedFile.path);
|
|
823
|
+
if (plain === null) {
|
|
824
|
+
flash("\u{1F512} Private key not found \u2014 cannot reveal all");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
next.set(entry.key, plain);
|
|
828
|
+
} else {
|
|
829
|
+
next.set(entry.key, entry.value);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
setRevealed(next);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (input === "y") {
|
|
836
|
+
if (!k) return;
|
|
837
|
+
const value = k.encrypted ? decryptValue(k.value, selectedFile.path) : k.value;
|
|
838
|
+
if (value === null) {
|
|
839
|
+
flash("\u{1F512} Private key not found \u2014 cannot copy");
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
clipboard.writeSync(value);
|
|
843
|
+
flash(`Copied ${k.key}`);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (key.return) {
|
|
847
|
+
if (!k) return;
|
|
848
|
+
if (k.encrypted) {
|
|
849
|
+
const plain = decryptValue(k.value, selectedFile.path);
|
|
850
|
+
if (plain === null) {
|
|
851
|
+
flash("\u{1F512} Private key not found \u2014 cannot edit");
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
setMode({ type: "edit", key: { ...k, value: plain } });
|
|
855
|
+
} else {
|
|
856
|
+
setMode({ type: "edit", key: k });
|
|
857
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (input === "a") {
|
|
861
|
+
setMode({ type: "add-key" });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (input === "D") {
|
|
865
|
+
if (!k) return;
|
|
866
|
+
setMode({ type: "confirm-delete", key: k });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (input === "d") {
|
|
870
|
+
setMode({ type: "diff" });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (input === "e") {
|
|
874
|
+
setMode({ type: "confirm-encrypt" });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
}, { isActive: isRawModeSupported && mode.type === "normal" });
|
|
878
|
+
if (mode.type === "edit") {
|
|
879
|
+
const editing = mode.key;
|
|
880
|
+
return /* @__PURE__ */ jsx7(
|
|
881
|
+
Layout,
|
|
882
|
+
{
|
|
883
|
+
files,
|
|
884
|
+
fileIndex,
|
|
885
|
+
keys,
|
|
886
|
+
keyIndex,
|
|
887
|
+
focus,
|
|
888
|
+
revealed,
|
|
889
|
+
onSelectFile: selectFile,
|
|
890
|
+
onSelectKey: setKeyIndex,
|
|
891
|
+
statusMsg,
|
|
892
|
+
focus2: focus,
|
|
893
|
+
extra: /* @__PURE__ */ jsx7(
|
|
894
|
+
InlineForm,
|
|
895
|
+
{
|
|
896
|
+
label: `Edit ${editing.key}`,
|
|
897
|
+
initialValue: editing.value,
|
|
898
|
+
onSubmit: (val) => {
|
|
899
|
+
if (editing.encrypted) {
|
|
900
|
+
encryptKey(selectedFile.path, editing.key, val);
|
|
901
|
+
} else {
|
|
902
|
+
updateKey(selectedFile.path, editing.key, val);
|
|
903
|
+
}
|
|
904
|
+
setRevealed((prev) => {
|
|
905
|
+
const next = new Map(prev);
|
|
906
|
+
next.delete(editing.key);
|
|
907
|
+
return next;
|
|
908
|
+
});
|
|
909
|
+
refreshKeys();
|
|
910
|
+
setMode({ type: "normal" });
|
|
911
|
+
flash(`Saved ${editing.key}`);
|
|
912
|
+
},
|
|
913
|
+
onCancel: () => setMode({ type: "normal" })
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (mode.type === "add-key") {
|
|
920
|
+
return /* @__PURE__ */ jsx7(
|
|
921
|
+
Layout,
|
|
922
|
+
{
|
|
923
|
+
files,
|
|
924
|
+
fileIndex,
|
|
925
|
+
keys,
|
|
926
|
+
keyIndex,
|
|
927
|
+
focus,
|
|
928
|
+
revealed,
|
|
929
|
+
onSelectFile: selectFile,
|
|
930
|
+
onSelectKey: setKeyIndex,
|
|
931
|
+
statusMsg,
|
|
932
|
+
focus2: focus,
|
|
933
|
+
extra: /* @__PURE__ */ jsx7(
|
|
934
|
+
InlineForm,
|
|
935
|
+
{
|
|
936
|
+
label: "New key name",
|
|
937
|
+
onSubmit: (keyName) => {
|
|
938
|
+
if (!keyName.trim()) {
|
|
939
|
+
setMode({ type: "normal" });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
setMode({ type: "add-value", keyName: keyName.trim() });
|
|
943
|
+
},
|
|
944
|
+
onCancel: () => setMode({ type: "normal" })
|
|
945
|
+
},
|
|
946
|
+
"add-key"
|
|
947
|
+
)
|
|
948
|
+
}
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
if (mode.type === "add-value") {
|
|
952
|
+
const { keyName } = mode;
|
|
953
|
+
return /* @__PURE__ */ jsx7(
|
|
954
|
+
Layout,
|
|
955
|
+
{
|
|
956
|
+
files,
|
|
957
|
+
fileIndex,
|
|
958
|
+
keys,
|
|
959
|
+
keyIndex,
|
|
960
|
+
focus,
|
|
961
|
+
revealed,
|
|
962
|
+
onSelectFile: selectFile,
|
|
963
|
+
onSelectKey: setKeyIndex,
|
|
964
|
+
statusMsg,
|
|
965
|
+
focus2: focus,
|
|
966
|
+
extra: /* @__PURE__ */ jsx7(
|
|
967
|
+
InlineForm,
|
|
968
|
+
{
|
|
969
|
+
label: `Value for ${keyName}`,
|
|
970
|
+
onSubmit: (val) => {
|
|
971
|
+
if (selectedFile.encrypted) {
|
|
972
|
+
setMode({ type: "confirm-add-encrypt", keyName, value: val });
|
|
973
|
+
} else {
|
|
974
|
+
addKey(selectedFile.path, keyName, val);
|
|
975
|
+
refreshKeys();
|
|
976
|
+
setKeyIndex(keys.length);
|
|
977
|
+
setMode({ type: "normal" });
|
|
978
|
+
flash(`Added ${keyName}`);
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
onCancel: () => setMode({ type: "normal" })
|
|
982
|
+
},
|
|
983
|
+
"add-value"
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
if (mode.type === "confirm-add-encrypt") {
|
|
989
|
+
const { keyName, value: newVal } = mode;
|
|
990
|
+
const commit = (encrypt) => {
|
|
991
|
+
addKey(selectedFile.path, keyName, newVal);
|
|
992
|
+
if (encrypt) encryptKey(selectedFile.path, keyName, newVal);
|
|
993
|
+
refreshKeys();
|
|
994
|
+
setKeyIndex(keys.length);
|
|
995
|
+
setMode({ type: "normal" });
|
|
996
|
+
flash(`Added ${keyName}${encrypt ? " (encrypted)" : ""}`);
|
|
997
|
+
};
|
|
998
|
+
return /* @__PURE__ */ jsx7(
|
|
999
|
+
Layout,
|
|
1000
|
+
{
|
|
1001
|
+
files,
|
|
1002
|
+
fileIndex,
|
|
1003
|
+
keys,
|
|
1004
|
+
keyIndex,
|
|
1005
|
+
focus,
|
|
1006
|
+
revealed,
|
|
1007
|
+
onSelectFile: selectFile,
|
|
1008
|
+
onSelectKey: setKeyIndex,
|
|
1009
|
+
statusMsg,
|
|
1010
|
+
focus2: focus,
|
|
1011
|
+
extra: /* @__PURE__ */ jsx7(ConfirmAddEncrypt, { keyName, onEncrypt: () => commit(true), onPlain: () => commit(false), onCancel: () => setMode({ type: "normal" }) })
|
|
1012
|
+
}
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
if (mode.type === "confirm-delete") {
|
|
1016
|
+
const { key: k } = mode;
|
|
1017
|
+
return /* @__PURE__ */ jsx7(
|
|
1018
|
+
Layout,
|
|
1019
|
+
{
|
|
1020
|
+
files,
|
|
1021
|
+
fileIndex,
|
|
1022
|
+
keys,
|
|
1023
|
+
keyIndex,
|
|
1024
|
+
focus,
|
|
1025
|
+
revealed,
|
|
1026
|
+
onSelectFile: selectFile,
|
|
1027
|
+
onSelectKey: setKeyIndex,
|
|
1028
|
+
statusMsg,
|
|
1029
|
+
focus2: focus,
|
|
1030
|
+
extra: /* @__PURE__ */ jsx7(
|
|
1031
|
+
ConfirmDelete,
|
|
1032
|
+
{
|
|
1033
|
+
keyName: k.key,
|
|
1034
|
+
onConfirm: () => {
|
|
1035
|
+
removeKey(selectedFile.path, k.key);
|
|
1036
|
+
refreshKeys();
|
|
1037
|
+
setKeyIndex(Math.max(0, keyIndex - 1));
|
|
1038
|
+
setMode({ type: "normal" });
|
|
1039
|
+
flash(`Deleted ${k.key}`);
|
|
1040
|
+
},
|
|
1041
|
+
onCancel: () => setMode({ type: "normal" })
|
|
1042
|
+
}
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
if (mode.type === "confirm-encrypt") {
|
|
1048
|
+
const isEncrypted = selectedFile.encrypted;
|
|
1049
|
+
return /* @__PURE__ */ jsx7(
|
|
1050
|
+
Layout,
|
|
1051
|
+
{
|
|
1052
|
+
files,
|
|
1053
|
+
fileIndex,
|
|
1054
|
+
keys,
|
|
1055
|
+
keyIndex,
|
|
1056
|
+
focus,
|
|
1057
|
+
revealed,
|
|
1058
|
+
onSelectFile: selectFile,
|
|
1059
|
+
onSelectKey: setKeyIndex,
|
|
1060
|
+
statusMsg,
|
|
1061
|
+
focus2: focus,
|
|
1062
|
+
extra: /* @__PURE__ */ jsx7(
|
|
1063
|
+
ConfirmEncrypt,
|
|
1064
|
+
{
|
|
1065
|
+
decrypt: isEncrypted,
|
|
1066
|
+
fileName: selectedFile.relativePath,
|
|
1067
|
+
onConfirm: () => {
|
|
1068
|
+
try {
|
|
1069
|
+
if (isEncrypted) {
|
|
1070
|
+
decryptFile(selectedFile.path);
|
|
1071
|
+
flash(`Decrypted ${selectedFile.relativePath}`);
|
|
1072
|
+
} else {
|
|
1073
|
+
encryptFile(selectedFile.path);
|
|
1074
|
+
flash(`Encrypted ${selectedFile.relativePath}`);
|
|
1075
|
+
}
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
flash(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1078
|
+
}
|
|
1079
|
+
refreshKeys();
|
|
1080
|
+
setRevealed(/* @__PURE__ */ new Map());
|
|
1081
|
+
setMode({ type: "normal" });
|
|
1082
|
+
},
|
|
1083
|
+
onCancel: () => setMode({ type: "normal" })
|
|
1084
|
+
}
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
if (mode.type === "help") {
|
|
1090
|
+
return /* @__PURE__ */ jsx7(HelpOverlay, { onClose: () => setMode({ type: "normal" }) });
|
|
1091
|
+
}
|
|
1092
|
+
if (mode.type === "diff") {
|
|
1093
|
+
return /* @__PURE__ */ jsx7(
|
|
1094
|
+
DiffView,
|
|
1095
|
+
{
|
|
1096
|
+
left: selectedFile,
|
|
1097
|
+
files,
|
|
1098
|
+
onClose: () => setMode({ type: "normal" })
|
|
1099
|
+
}
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return /* @__PURE__ */ jsx7(
|
|
1103
|
+
Layout,
|
|
1104
|
+
{
|
|
1105
|
+
files,
|
|
1106
|
+
fileIndex,
|
|
1107
|
+
keys,
|
|
1108
|
+
keyIndex,
|
|
1109
|
+
focus,
|
|
1110
|
+
revealed,
|
|
1111
|
+
onSelectFile: selectFile,
|
|
1112
|
+
onSelectKey: setKeyIndex,
|
|
1113
|
+
statusMsg,
|
|
1114
|
+
focus2: focus
|
|
1115
|
+
}
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
function Layout({
|
|
1119
|
+
files,
|
|
1120
|
+
fileIndex,
|
|
1121
|
+
keys,
|
|
1122
|
+
keyIndex,
|
|
1123
|
+
focus,
|
|
1124
|
+
revealed,
|
|
1125
|
+
onSelectFile,
|
|
1126
|
+
onSelectKey,
|
|
1127
|
+
statusMsg,
|
|
1128
|
+
extra
|
|
1129
|
+
}) {
|
|
1130
|
+
const selectedFile = files[fileIndex];
|
|
1131
|
+
const encCount = files.filter((f) => f.encrypted).length;
|
|
1132
|
+
return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", height: "100%", children: [
|
|
1133
|
+
/* @__PURE__ */ jsxs6(Box7, { paddingX: 1, children: [
|
|
1134
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: "dotenvx-ui" }),
|
|
1135
|
+
/* @__PURE__ */ jsxs6(Text7, { dimColor: true, children: [
|
|
1136
|
+
" ",
|
|
1137
|
+
selectedFile.relativePath,
|
|
1138
|
+
" \xB7 ",
|
|
1139
|
+
files.length,
|
|
1140
|
+
" files \xB7 ",
|
|
1141
|
+
encCount,
|
|
1142
|
+
" enc"
|
|
1143
|
+
] })
|
|
1144
|
+
] }),
|
|
1145
|
+
/* @__PURE__ */ jsxs6(Box7, { flexGrow: 1, children: [
|
|
1146
|
+
/* @__PURE__ */ jsx7(FileList, { files, selectedIndex: fileIndex, focused: focus === "files", onSelect: onSelectFile }),
|
|
1147
|
+
/* @__PURE__ */ jsx7(
|
|
1148
|
+
KeyTable,
|
|
1149
|
+
{
|
|
1150
|
+
file: selectedFile,
|
|
1151
|
+
keys,
|
|
1152
|
+
selectedIndex: keyIndex,
|
|
1153
|
+
focused: focus === "keys",
|
|
1154
|
+
revealed,
|
|
1155
|
+
onSelect: onSelectKey
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
] }),
|
|
1159
|
+
extra,
|
|
1160
|
+
/* @__PURE__ */ jsx7(StatusBar, { focus, message: statusMsg })
|
|
1161
|
+
] });
|
|
1162
|
+
}
|
|
1163
|
+
function ConfirmDelete({ keyName, onConfirm, onCancel }) {
|
|
1164
|
+
const { isRawModeSupported } = useStdin6();
|
|
1165
|
+
useInput6((input) => {
|
|
1166
|
+
if (input === "y" || input === "Y") onConfirm();
|
|
1167
|
+
else onCancel();
|
|
1168
|
+
}, { isActive: isRawModeSupported });
|
|
1169
|
+
return /* @__PURE__ */ jsxs6(Box7, { paddingX: 1, children: [
|
|
1170
|
+
/* @__PURE__ */ jsxs6(Text7, { color: "red", children: [
|
|
1171
|
+
"Delete ",
|
|
1172
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, children: keyName }),
|
|
1173
|
+
"? "
|
|
1174
|
+
] }),
|
|
1175
|
+
/* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "y confirm any other key cancel" })
|
|
1176
|
+
] });
|
|
1177
|
+
}
|
|
1178
|
+
function ConfirmEncrypt({ decrypt, fileName, onConfirm, onCancel }) {
|
|
1179
|
+
const { isRawModeSupported } = useStdin6();
|
|
1180
|
+
useInput6((input) => {
|
|
1181
|
+
if (input === "y" || input === "Y") onConfirm();
|
|
1182
|
+
else onCancel();
|
|
1183
|
+
}, { isActive: isRawModeSupported });
|
|
1184
|
+
const action = decrypt ? "Decrypt" : "Encrypt";
|
|
1185
|
+
const color = decrypt ? "yellow" : "green";
|
|
1186
|
+
return /* @__PURE__ */ jsxs6(Box7, { paddingX: 1, children: [
|
|
1187
|
+
/* @__PURE__ */ jsxs6(Text7, { color, children: [
|
|
1188
|
+
action,
|
|
1189
|
+
" ",
|
|
1190
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, children: fileName }),
|
|
1191
|
+
"? "
|
|
1192
|
+
] }),
|
|
1193
|
+
/* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "y confirm any other key cancel" })
|
|
1194
|
+
] });
|
|
1195
|
+
}
|
|
1196
|
+
function ConfirmAddEncrypt({ keyName, onEncrypt, onPlain, onCancel }) {
|
|
1197
|
+
const { isRawModeSupported } = useStdin6();
|
|
1198
|
+
useInput6((input, key) => {
|
|
1199
|
+
if (key.escape) {
|
|
1200
|
+
onCancel();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (input === "y" || input === "Y") {
|
|
1204
|
+
onEncrypt();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (input === "n" || input === "N" || key.return) {
|
|
1208
|
+
onPlain();
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
}, { isActive: isRawModeSupported });
|
|
1212
|
+
return /* @__PURE__ */ jsxs6(Box7, { paddingX: 1, children: [
|
|
1213
|
+
/* @__PURE__ */ jsxs6(Text7, { children: [
|
|
1214
|
+
"Encrypt ",
|
|
1215
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, children: keyName }),
|
|
1216
|
+
"? "
|
|
1217
|
+
] }),
|
|
1218
|
+
/* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "y encrypt n plain esc cancel" })
|
|
1219
|
+
] });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/tui/ErrorBoundary.tsx
|
|
1223
|
+
import React4 from "react";
|
|
1224
|
+
import { Box as Box8, Text as Text8 } from "ink";
|
|
1225
|
+
import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1226
|
+
var ErrorBoundary = class extends React4.Component {
|
|
1227
|
+
state = { error: null };
|
|
1228
|
+
static getDerivedStateFromError(error) {
|
|
1229
|
+
return { error };
|
|
1230
|
+
}
|
|
1231
|
+
render() {
|
|
1232
|
+
if (this.state.error) {
|
|
1233
|
+
return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
1234
|
+
/* @__PURE__ */ jsx8(Text8, { bold: true, color: "red", children: "dotenvx-ui crashed" }),
|
|
1235
|
+
/* @__PURE__ */ jsx8(Text8, { children: this.state.error.message }),
|
|
1236
|
+
/* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Please report this at https://github.com/alxbrla/dotenvx-ui/issues" }) })
|
|
1237
|
+
] });
|
|
1238
|
+
}
|
|
1239
|
+
return this.props.children;
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
// src/cli.tsx
|
|
1244
|
+
import { createRequire as createRequire2 } from "module";
|
|
1245
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
1246
|
+
var { version } = createRequire2(import.meta.url)("../package.json");
|
|
1247
|
+
var HELP = `
|
|
1248
|
+
dotenvx-ui \u2014 terminal and web UI for dotenvx environment files
|
|
1249
|
+
|
|
1250
|
+
Usage:
|
|
1251
|
+
dotenvx-ui Launch TUI
|
|
1252
|
+
dotenvx-ui ui Launch web UI in browser
|
|
1253
|
+
|
|
1254
|
+
Options:
|
|
1255
|
+
-v, --version Print version
|
|
1256
|
+
-h, --help Show this help
|
|
1257
|
+
`;
|
|
1258
|
+
var commands = {
|
|
1259
|
+
"--version": () => {
|
|
1260
|
+
console.log(version);
|
|
1261
|
+
process.exit(0);
|
|
1262
|
+
},
|
|
1263
|
+
"-v": () => {
|
|
1264
|
+
console.log(version);
|
|
1265
|
+
process.exit(0);
|
|
1266
|
+
},
|
|
1267
|
+
"--help": () => {
|
|
1268
|
+
console.log(HELP);
|
|
1269
|
+
process.exit(0);
|
|
1270
|
+
},
|
|
1271
|
+
"-h": () => {
|
|
1272
|
+
console.log(HELP);
|
|
1273
|
+
process.exit(0);
|
|
1274
|
+
},
|
|
1275
|
+
"ui": runWebUI
|
|
1276
|
+
};
|
|
1277
|
+
var [, , command] = process.argv;
|
|
1278
|
+
if (command !== void 0 && !(command in commands)) {
|
|
1279
|
+
console.error(`Unknown command: ${command}
|
|
1280
|
+
Run dotenvx-ui --help for usage.`);
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
commands[command ?? ""]?.() ?? runTUI();
|
|
1284
|
+
function runTUI() {
|
|
1285
|
+
const files = scan(process.cwd());
|
|
1286
|
+
if (files.length === 0) {
|
|
1287
|
+
console.error("No .env files found in this directory.");
|
|
1288
|
+
process.exit(1);
|
|
1289
|
+
}
|
|
1290
|
+
render(/* @__PURE__ */ jsx9(ErrorBoundary, { children: /* @__PURE__ */ jsx9(App, { files }) }));
|
|
1291
|
+
}
|
|
1292
|
+
function runWebUI() {
|
|
1293
|
+
console.log("Web UI \u2014 coming soon");
|
|
1294
|
+
process.exit(0);
|
|
1295
|
+
}
|