@seekrit/cli 0.29.0 → 0.30.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/dist/index.js +223 -32
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -210,34 +210,127 @@ function parseBranchTtl(input) {
|
|
|
210
210
|
//#endregion
|
|
211
211
|
//#region ../../packages/core/src/dotenv.ts
|
|
212
212
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
213
|
+
* `.env` parsing and serialization — the canonical definition of what a `.env`
|
|
214
|
+
* file means to seekrit. `apps/run/src/dotenv.rs` is a faithful port of the
|
|
215
|
+
* parser; the two must be changed together.
|
|
216
|
+
*
|
|
217
|
+
* Supported: `KEY=VALUE`, `#` comments, an optional `export` prefix, and
|
|
218
|
+
* single/double-quoted values. A quoted value may span lines — it runs to its
|
|
219
|
+
* closing quote, wherever that lands — which is what makes a pretty-printed
|
|
220
|
+
* JSON credential (a Google service-account key, say) storable in a `.env`
|
|
221
|
+
* file. Single quotes are literal; double quotes honor `\n \r \t \" \\`.
|
|
222
|
+
* Unquoted values are single-line and drop a trailing ` # comment`.
|
|
223
|
+
*
|
|
224
|
+
* {@link parseDotenv} and {@link dotenvQuote} are inverses: anything the
|
|
225
|
+
* serializer emits parses back to the identical string, including values that
|
|
226
|
+
* contain literal backslash escapes (`"private_key": "…\n…"` in a JSON blob).
|
|
227
|
+
*/
|
|
228
|
+
/** Values that can go on the line bare — no quoting needed to survive a parse. */
|
|
229
|
+
function needsQuoting(value) {
|
|
230
|
+
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Quote one value for a `.env` line so it parses back byte-for-byte.
|
|
234
|
+
*
|
|
235
|
+
* Single quotes are preferred whenever they are safe, because they are literal:
|
|
236
|
+
* a JSON credential full of `\"` and `\n` escapes stays readable and survives
|
|
237
|
+
* the round-trip untouched. Values containing a single quote (or a real
|
|
238
|
+
* newline, which many other `.env` readers can't span) fall back to double
|
|
239
|
+
* quotes with every escape written out.
|
|
240
|
+
*/
|
|
241
|
+
function dotenvQuote(value) {
|
|
242
|
+
if (!needsQuoting(value)) return value;
|
|
243
|
+
if (!value.includes("'") && !/[\n\r]/.test(value)) return `'${value}'`;
|
|
244
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll("\r", "\\r")}"`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Apply double-quote escapes in a single left-to-right pass.
|
|
248
|
+
*
|
|
249
|
+
* A pass per escape (`\n` → newline, then `\\` → `\`, …) is wrong: in `\\n` the
|
|
250
|
+
* first pass matches the trailing `\n` and yields a real newline, corrupting
|
|
251
|
+
* every literal backslash-n a JSON credential is made of. Scanning once means a
|
|
252
|
+
* backslash consumes the character after it and can never be re-read.
|
|
253
|
+
*/
|
|
254
|
+
function unescapeDoubleQuoted(text) {
|
|
255
|
+
let out = "";
|
|
256
|
+
for (let i = 0; i < text.length; i++) {
|
|
257
|
+
if (text[i] !== "\\" || i === text.length - 1) {
|
|
258
|
+
out += text[i];
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const next = text[++i];
|
|
262
|
+
if (next === "n") out += "\n";
|
|
263
|
+
else if (next === "r") out += "\r";
|
|
264
|
+
else if (next === "t") out += " ";
|
|
265
|
+
else if (next === "\"" || next === "\\") out += next;
|
|
266
|
+
else out += `\\${next}`;
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Find the index of the quote that closes a value opened at `start`.
|
|
272
|
+
*
|
|
273
|
+
* Inside double quotes a `\"` is an escaped quote, not the terminator (and a
|
|
274
|
+
* `\\` immediately before the quote *is* a terminator, since the backslash is
|
|
275
|
+
* itself escaped) — so the scan tracks escapes rather than searching for the
|
|
276
|
+
* next bare quote. Single quotes have no escapes: the next one closes. Returns
|
|
277
|
+
* -1 when the value is never closed.
|
|
278
|
+
*/
|
|
279
|
+
function findClosingQuote(content, start, quote) {
|
|
280
|
+
for (let i = start; i < content.length; i++) {
|
|
281
|
+
if (quote === "\"" && content[i] === "\\") {
|
|
282
|
+
i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (content[i] === quote) return i;
|
|
286
|
+
}
|
|
287
|
+
return -1;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Parse `.env` text into variables. Later assignments win, matching the
|
|
291
|
+
* object-assignment semantics every `.env` reader has.
|
|
217
292
|
*/
|
|
218
293
|
function parseDotenv(content) {
|
|
219
294
|
const out = {};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
295
|
+
let cursor = 0;
|
|
296
|
+
while (cursor < content.length) {
|
|
297
|
+
const newline = content.indexOf("\n", cursor);
|
|
298
|
+
const lineEnd = newline === -1 ? content.length : newline;
|
|
299
|
+
const lineStart = cursor;
|
|
300
|
+
const trimmedEnd = lineEnd > lineStart && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
301
|
+
cursor = lineEnd + 1;
|
|
302
|
+
let i = skipSpace(content, lineStart, trimmedEnd);
|
|
303
|
+
if (i === trimmedEnd || content[i] === "#") continue;
|
|
304
|
+
if (content.startsWith("export ", i)) i = skipSpace(content, i + 7, trimmedEnd);
|
|
305
|
+
const eq = content.indexOf("=", i);
|
|
306
|
+
if (eq === -1 || eq >= trimmedEnd) continue;
|
|
307
|
+
const key = content.slice(i, eq).trim();
|
|
227
308
|
if (!key) continue;
|
|
228
|
-
|
|
229
|
-
const quote =
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
309
|
+
const valueStart = skipSpace(content, eq + 1, trimmedEnd);
|
|
310
|
+
const quote = content[valueStart];
|
|
311
|
+
if (valueStart === trimmedEnd || quote !== "\"" && quote !== "'") {
|
|
312
|
+
const rest = content.slice(valueStart, trimmedEnd).trimEnd();
|
|
313
|
+
const comment = rest.indexOf(" #");
|
|
314
|
+
out[key] = comment === -1 ? rest : rest.slice(0, comment).trimEnd();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const close = findClosingQuote(content, valueStart + 1, quote);
|
|
318
|
+
const valueEnd = close === -1 ? content.length : close;
|
|
319
|
+
const value = content.slice(valueStart + 1, valueEnd);
|
|
320
|
+
out[key] = quote === "\"" ? unescapeDoubleQuoted(value) : value;
|
|
321
|
+
if (valueEnd >= cursor) {
|
|
322
|
+
const after = content.indexOf("\n", valueEnd);
|
|
323
|
+
cursor = after === -1 ? content.length : after + 1;
|
|
236
324
|
}
|
|
237
|
-
out[key] = value;
|
|
238
325
|
}
|
|
239
326
|
return out;
|
|
240
327
|
}
|
|
328
|
+
/** First index at or after `from` (and before `end`) that isn't a space or tab. */
|
|
329
|
+
function skipSpace(content, from, end) {
|
|
330
|
+
let i = from;
|
|
331
|
+
while (i < end && (content[i] === " " || content[i] === " ")) i++;
|
|
332
|
+
return i;
|
|
333
|
+
}
|
|
241
334
|
//#endregion
|
|
242
335
|
//#region ../../packages/core/src/ids.ts
|
|
243
336
|
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
@@ -402,6 +495,104 @@ function interpolateSecrets(values) {
|
|
|
402
495
|
unresolved: [...unresolved].sort()
|
|
403
496
|
};
|
|
404
497
|
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region ../../packages/core/src/json-value.ts
|
|
500
|
+
/**
|
|
501
|
+
* Whether a value is worth *offering* a JSON view for: an object or array.
|
|
502
|
+
*
|
|
503
|
+
* Deliberately narrow. A bare `123`, `true`, or `"quoted string"` is legal JSON
|
|
504
|
+
* but is far more likely to be an ordinary secret that happens to look like a
|
|
505
|
+
* literal, and badging those as JSON would be noise on almost every row.
|
|
506
|
+
*/
|
|
507
|
+
function looksLikeJson(value) {
|
|
508
|
+
const trimmed = value.trim();
|
|
509
|
+
return trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
510
|
+
}
|
|
511
|
+
/** Strip one layer of matching surrounding quotes, if the value has them. */
|
|
512
|
+
function unquote(value) {
|
|
513
|
+
const trimmed = value.trim();
|
|
514
|
+
const quote = trimmed[0];
|
|
515
|
+
if (trimmed.length < 2) return null;
|
|
516
|
+
if (quote !== "'" && quote !== "\"" || trimmed.at(-1) !== quote) return null;
|
|
517
|
+
return trimmed.slice(1, -1);
|
|
518
|
+
}
|
|
519
|
+
function isParseable(value) {
|
|
520
|
+
try {
|
|
521
|
+
JSON.parse(value);
|
|
522
|
+
return true;
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* The two ways a copied credential usually arrives broken.
|
|
529
|
+
*
|
|
530
|
+
* Both come from a shell habit rather than a typo: the quotes that made the
|
|
531
|
+
* value survive `.env` or a command line get pasted along with it, or a value
|
|
532
|
+
* that was escaped for a double-quoted context is pasted still escaped.
|
|
533
|
+
*/
|
|
534
|
+
function findRepair(value) {
|
|
535
|
+
const inner = unquote(value);
|
|
536
|
+
if (inner !== null && looksLikeJson(inner) && isParseable(inner)) return {
|
|
537
|
+
label: "remove the quotes",
|
|
538
|
+
reason: "the surrounding quotes were pasted along with the value",
|
|
539
|
+
value: inner
|
|
540
|
+
};
|
|
541
|
+
if (value.includes("\\\"")) {
|
|
542
|
+
const unescaped = value.replaceAll("\\\"", "\"").replaceAll("\\\\", "\\");
|
|
543
|
+
const candidate = unquote(unescaped) ?? unescaped;
|
|
544
|
+
if (looksLikeJson(candidate) && isParseable(candidate)) return {
|
|
545
|
+
label: "unescape the quotes",
|
|
546
|
+
reason: "the value is still escaped for a shell or a double-quoted string",
|
|
547
|
+
value: candidate
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Classify a decrypted value as JSON, nearly-JSON, or ordinary text.
|
|
554
|
+
*
|
|
555
|
+
* Only object- and array-shaped values are considered (see
|
|
556
|
+
* {@link looksLikeJson}), so an ordinary secret is never reported as malformed
|
|
557
|
+
* JSON just because it starts with a brace-free string.
|
|
558
|
+
*/
|
|
559
|
+
function inspectJsonValue(value) {
|
|
560
|
+
if (!looksLikeJson(value)) {
|
|
561
|
+
const repair = findRepair(value);
|
|
562
|
+
return repair ? {
|
|
563
|
+
kind: "malformed",
|
|
564
|
+
error: repair.reason,
|
|
565
|
+
repair
|
|
566
|
+
} : { kind: "text" };
|
|
567
|
+
}
|
|
568
|
+
let data;
|
|
569
|
+
try {
|
|
570
|
+
data = JSON.parse(value);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return {
|
|
573
|
+
kind: "malformed",
|
|
574
|
+
error: err instanceof Error ? err.message : "not valid JSON",
|
|
575
|
+
repair: findRepair(value)
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const pretty = JSON.stringify(data, null, 2);
|
|
579
|
+
return {
|
|
580
|
+
kind: "json",
|
|
581
|
+
pretty,
|
|
582
|
+
minified: JSON.stringify(data),
|
|
583
|
+
isPretty: value.trim() === pretty,
|
|
584
|
+
size: Array.isArray(data) ? data.length : Object.keys(data).length,
|
|
585
|
+
shape: Array.isArray(data) ? "array" : "object"
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Pretty-print a value if it is JSON, otherwise hand it back unchanged — for
|
|
590
|
+
* output paths that want to be helpful without ever altering a non-JSON secret.
|
|
591
|
+
*/
|
|
592
|
+
function prettyJsonOrRaw(value) {
|
|
593
|
+
const inspection = inspectJsonValue(value);
|
|
594
|
+
return inspection.kind === "json" ? inspection.pretty : value;
|
|
595
|
+
}
|
|
405
596
|
z.enum([
|
|
406
597
|
"postgres",
|
|
407
598
|
"mysql",
|
|
@@ -2528,7 +2719,7 @@ function isCliSessionToken(value) {
|
|
|
2528
2719
|
}
|
|
2529
2720
|
//#endregion
|
|
2530
2721
|
//#region package.json
|
|
2531
|
-
var version = "0.
|
|
2722
|
+
var version = "0.30.0";
|
|
2532
2723
|
//#endregion
|
|
2533
2724
|
//#region ../../packages/api-client/src/index.ts
|
|
2534
2725
|
var SeekritApiError = class extends Error {
|
|
@@ -4623,13 +4814,6 @@ function registerBranchCommands(program) {
|
|
|
4623
4814
|
}
|
|
4624
4815
|
//#endregion
|
|
4625
4816
|
//#region src/format.ts
|
|
4626
|
-
function needsQuoting(value) {
|
|
4627
|
-
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
4628
|
-
}
|
|
4629
|
-
function dotenvQuote(value) {
|
|
4630
|
-
if (!needsQuoting(value)) return value;
|
|
4631
|
-
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
|
|
4632
|
-
}
|
|
4633
4817
|
function shellQuote(value) {
|
|
4634
4818
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
4635
4819
|
}
|
|
@@ -6582,20 +6766,27 @@ withTarget(secrets.command("list").alias("ls").description("list secret names (n
|
|
|
6582
6766
|
col("updated", (s) => s.updatedAt)
|
|
6583
6767
|
], `no secrets in ${label}`));
|
|
6584
6768
|
});
|
|
6585
|
-
withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
|
|
6769
|
+
withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one").option("--pretty", "re-indent the value if it is JSON (left alone if it is not)")).action(async (name, options) => {
|
|
6586
6770
|
const ctx = buildContext();
|
|
6587
6771
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
6588
6772
|
let value;
|
|
6589
6773
|
if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId, { raw: options.raw }))[name];
|
|
6590
6774
|
else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
|
|
6591
6775
|
if (value === void 0) fail(`no secret named ${name}`);
|
|
6592
|
-
process.stdout.write(value);
|
|
6776
|
+
process.stdout.write(options.pretty ? prettyJsonOrRaw(value) : value);
|
|
6593
6777
|
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
6594
6778
|
});
|
|
6595
|
-
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')")).action(async (name, value, options) => {
|
|
6779
|
+
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')").option("--file <path>", "read the value from a file — the way to store a JSON credential or a PEM key without fighting shell quoting")).action(async (name, value, options) => {
|
|
6780
|
+
if (options.file !== void 0 && value !== void 0) fail("pass a value or --file, not both");
|
|
6596
6781
|
const ctx = buildContext();
|
|
6597
6782
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
6598
|
-
|
|
6783
|
+
let plaintext;
|
|
6784
|
+
if (options.file !== void 0) {
|
|
6785
|
+
if (!existsSync(options.file)) fail(`no such file: ${options.file}`);
|
|
6786
|
+
plaintext = readFileSync(options.file, "utf8").replace(/\n$/, "");
|
|
6787
|
+
} else if (value === void 0 || value === "-") plaintext = (await readStdin()).replace(/\n$/, "");
|
|
6788
|
+
else plaintext = value;
|
|
6789
|
+
await encryptAndSetSecret(ctx, orgId, envId, name, plaintext);
|
|
6599
6790
|
console.error(`${name} saved`);
|
|
6600
6791
|
});
|
|
6601
6792
|
withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing")).action(async (file, options) => {
|