@seekrit/cli 0.28.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.
Files changed (2) hide show
  1. package/dist/index.js +234 -43
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -208,6 +208,130 @@ function parseBranchTtl(input) {
208
208
  return value * multiplier;
209
209
  }
210
210
  //#endregion
211
+ //#region ../../packages/core/src/dotenv.ts
212
+ /**
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.
292
+ */
293
+ function parseDotenv(content) {
294
+ const out = {};
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();
308
+ if (!key) continue;
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;
324
+ }
325
+ }
326
+ return out;
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
+ }
334
+ //#endregion
211
335
  //#region ../../packages/core/src/ids.ts
212
336
  const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
213
337
  /**
@@ -371,6 +495,104 @@ function interpolateSecrets(values) {
371
495
  unresolved: [...unresolved].sort()
372
496
  };
373
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
+ }
374
596
  z.enum([
375
597
  "postgres",
376
598
  "mysql",
@@ -2497,7 +2719,7 @@ function isCliSessionToken(value) {
2497
2719
  }
2498
2720
  //#endregion
2499
2721
  //#region package.json
2500
- var version = "0.28.0";
2722
+ var version = "0.30.0";
2501
2723
  //#endregion
2502
2724
  //#region ../../packages/api-client/src/index.ts
2503
2725
  var SeekritApiError = class extends Error {
@@ -4591,45 +4813,7 @@ function registerBranchCommands(program) {
4591
4813
  });
4592
4814
  }
4593
4815
  //#endregion
4594
- //#region src/dotenv.ts
4595
- /**
4596
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
4597
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
4598
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
4599
- * not supported — keep those in seekrit itself.
4600
- */
4601
- function parseDotenv(content) {
4602
- const out = {};
4603
- for (const raw of content.split(/\r?\n/)) {
4604
- let line = raw.trim();
4605
- if (!line || line.startsWith("#")) continue;
4606
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
4607
- const eq = line.indexOf("=");
4608
- if (eq === -1) continue;
4609
- const key = line.slice(0, eq).trim();
4610
- if (!key) continue;
4611
- let value = line.slice(eq + 1).trim();
4612
- const quote = value[0];
4613
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
4614
- value = value.slice(1, -1);
4615
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
4616
- } else {
4617
- const comment = value.indexOf(" #");
4618
- if (comment !== -1) value = value.slice(0, comment).trim();
4619
- }
4620
- out[key] = value;
4621
- }
4622
- return out;
4623
- }
4624
- //#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
- await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {