@redaktyn/cli 0.0.0-stage → 1.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.
@@ -0,0 +1,1652 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ┌─────────────────────────────────────────────────────────────────┐
4
+ * │ Redaktyn Import CLI · Bulk enrollment from files and vaults │
5
+ * │ Digests are computed here. Values never leave this machine. │
6
+ * └─────────────────────────────────────────────────────────────────┘
7
+ *
8
+ * Enrollment used to mean typing every secret into the dashboard by hand, so
9
+ * coverage was whatever the admin remembered. The values are already sitting
10
+ * somewhere — a dotenv file, Vault, AWS Secrets Manager, Doppler, 1Password —
11
+ * and every one of those can already print JSON. So this reads that, computes
12
+ * the HMAC digests locally, and sends only digests.
13
+ *
14
+ * Input modes:
15
+ * --env <file> dotenv (supports `export `, quotes, comments, multi-line)
16
+ * --json <file> JSON object of secrets
17
+ * --yaml <file> flat `key: value` YAML only — refuses anything nested
18
+ * --stdin JSON on stdin; this is what makes every vault work today
19
+ *
20
+ * Vault recipes (all of these pipe into --stdin):
21
+ * vault kv get -format=json secret/prod | redaktyn-import --stdin
22
+ * aws secretsmanager get-secret-value --secret-id prod \
23
+ * --query SecretString --output text | redaktyn-import --stdin
24
+ * doppler secrets download --no-file --format json | redaktyn-import --stdin
25
+ * op item get prod --format json | redaktyn-import --stdin
26
+ *
27
+ * Nothing is written without --apply. Without it the command prints the plan
28
+ * and exits, because this tool changes what gets blocked for a whole org.
29
+ *
30
+ * Environment:
31
+ * REDAKTYN_SERVER_URL API base URL
32
+ * REDAKTYN_PASSPHRASE org passphrase — used locally for HMAC, never sent
33
+ * REDAKTYN_TOKEN OWNER/ADMIN JWT (an extension token cannot enroll)
34
+ * REDAKTYN_ORG_ID optional; must agree with the JWT when both are set
35
+ *
36
+ * Requires Node.js >= 18.
37
+ */
38
+
39
+ import { readFileSync } from "node:fs";
40
+ import { basename } from "node:path";
41
+ import * as readline from "node:readline";
42
+ import { pathToFileURL } from "node:url";
43
+
44
+ import {
45
+ canonicalize,
46
+ computeHmac,
47
+ deriveKey,
48
+ deriveProjectKey,
49
+ enrollPayload,
50
+ } from "./redaktyn-fingerprint.mjs";
51
+
52
+ /* ═══════════════════════════════════════════════════════════════
53
+ Colors
54
+ ═══════════════════════════════════════════════════════════════ */
55
+ const NO_COLOR = process.argv.includes("--no-color") || !!process.env["NO_COLOR"];
56
+ const c = {
57
+ reset: NO_COLOR ? "" : "\x1b[0m",
58
+ bold: NO_COLOR ? "" : "\x1b[1m",
59
+ dim: NO_COLOR ? "" : "\x1b[2m",
60
+ red: NO_COLOR ? "" : "\x1b[31m",
61
+ green: NO_COLOR ? "" : "\x1b[32m",
62
+ yellow: NO_COLOR ? "" : "\x1b[33m",
63
+ blue: NO_COLOR ? "" : "\x1b[34m",
64
+ cyan: NO_COLOR ? "" : "\x1b[36m",
65
+ gray: NO_COLOR ? "" : "\x1b[90m",
66
+ };
67
+
68
+ /** Usage and configuration errors: message, exit 2, no stack trace. */
69
+ class UsageError extends Error {}
70
+
71
+ /* ═══════════════════════════════════════════════════════════════
72
+ Scan budget — mirrors extension/src/security-config.ts
73
+ ═══════════════════════════════════════════════════════════════ */
74
+
75
+ /**
76
+ * Must equal MAX_SCAN_WINDOWS in extension/src/security-config.ts. A guard test
77
+ * compares the two.
78
+ *
79
+ * This is not a soft performance number. Over this budget the extension returns
80
+ * `too_large` and refuses the scan, which hard-blocks in sensitive contexts —
81
+ * so enrolling enough secrets of enough different lengths does not slow pastes
82
+ * down, it stops them working. An import is the only operation that can cross
83
+ * the line in one step, which is why it reports the ceiling.
84
+ */
85
+ const MAX_SCAN_WINDOWS = 1_500_000;
86
+
87
+ /**
88
+ * Ceiling thresholds, chosen from measurements rather than taste.
89
+ * `bench-scan-latency.bench.test.ts` ("bulk-import-load") records:
90
+ *
91
+ * 100 secrets · 300 window lengths ... ceiling 5,068 chars, 7.7 s at it
92
+ * 200 secrets · 600 window lengths ... ceiling 2,618 chars, 7.4 s at it
93
+ *
94
+ * Those are secrets without a scan prefilter. Everything this command enrols
95
+ * carries one, and fifty such secrets leave a ceiling in the megabytes.
96
+ *
97
+ * A ceiling under 50k means a large file paste is already refused, and under
98
+ * 10k means ordinary ones are. Every org above a few secrets has *some* ceiling,
99
+ * so it is always reported and only escalated when it starts to bite.
100
+ */
101
+ const PASTE_CEILING_WARN_CHARS = 50_000;
102
+ const PASTE_CEILING_ALARM_CHARS = 10_000;
103
+
104
+ /** Must equal MAX_TOTAL_SCAN_CHARS in extension/src/security-config.ts. */
105
+ const MAX_TOTAL_SCAN_CHARS = 5 * 1024 * 1024;
106
+
107
+ /** Must equal SCAN_PREFILTER_WINDOWS_PER_SIGNATURE in shared/scan-budget.ts. */
108
+ const SCAN_PREFILTER_WINDOWS_PER_SIGNATURE = 256;
109
+ /** Must equal SCAN_PREFILTER_SPACE in shared/scan-prefilter.ts. */
110
+ const SCAN_PREFILTER_SPACE = 65_536;
111
+ const SCAN_PREFILTER_PATTERN = /^v1\.[0-9a-f]{12}$/;
112
+
113
+ const hasScanPrefilter = (s) =>
114
+ typeof s.scanPrefilter === "string" && SCAN_PREFILTER_PATTERN.test(s.scanPrefilter);
115
+
116
+ /**
117
+ * Every sweep a scan runs: one per distinct window length per pass (exact,
118
+ * whitespace-collapse, case-fold), and whether the scan prefilter applies.
119
+ *
120
+ * Per pass, never de-duplicated across passes: the three lengths of one secret
121
+ * are usually equal, but the passes run over three different texts, so each
122
+ * costs its own sweep. A length is prefiltered only when every secret at it
123
+ * carries a valid prefilter, because the extension sweeps the whole length in
124
+ * full otherwise. Mirrors scanSweeps() in shared/scan-budget.ts.
125
+ */
126
+ function scanSweeps(secrets, assumeFingerprinted = false) {
127
+ const passes = [new Map(), new Map(), new Map()];
128
+ const add = (pass, windowLen, prefiltered) => {
129
+ if (!(windowLen > 0)) return;
130
+ const seen = passes[pass].get(windowLen);
131
+ passes[pass].set(
132
+ windowLen,
133
+ seen
134
+ ? { windowLen, prefiltered: seen.prefiltered && prefiltered, entries: seen.entries + 1 }
135
+ : { windowLen, prefiltered, entries: 1 }
136
+ );
137
+ };
138
+ for (const s of secrets) {
139
+ const prefiltered = assumeFingerprinted || hasScanPrefilter(s);
140
+ if (s.hmacHex) add(0, s.length, prefiltered);
141
+ if (typeof s.hmacHexStripped === "string") add(1, s.lengthStripped, prefiltered);
142
+ if (typeof s.hmacHexLower === "string") add(2, s.lengthLower, prefiltered);
143
+ }
144
+ return passes.flatMap((p) => [...p.values()]);
145
+ }
146
+
147
+ /**
148
+ * What the extension's admission check charges for a text of `textChars`
149
+ * characters, with no discount for the text's shape. Mirrors
150
+ * projectedScanCost() in shared/scan-budget.ts.
151
+ */
152
+ function projectedScanCost(textChars, sweeps) {
153
+ let cost = 0;
154
+ for (const sweep of sweeps) {
155
+ const examined = Math.max(0, textChars - sweep.windowLen + 1);
156
+ cost += sweep.prefiltered
157
+ ? Math.ceil(examined / SCAN_PREFILTER_WINDOWS_PER_SIGNATURE) +
158
+ Math.ceil((examined * sweep.entries) / SCAN_PREFILTER_SPACE)
159
+ : examined;
160
+ }
161
+ return cost;
162
+ }
163
+
164
+ /** Largest text the extension is guaranteed to scan, capped at its hard limit. */
165
+ function maxScannablePasteCharsFor(sweeps) {
166
+ if (sweeps.length === 0) return Infinity;
167
+ if (projectedScanCost(MAX_TOTAL_SCAN_CHARS, sweeps) <= MAX_SCAN_WINDOWS) return MAX_TOTAL_SCAN_CHARS;
168
+ let lo = 0;
169
+ let hi = MAX_TOTAL_SCAN_CHARS;
170
+ while (lo < hi) {
171
+ const mid = Math.ceil((lo + hi) / 2);
172
+ if (projectedScanCost(mid, sweeps) <= MAX_SCAN_WINDOWS) lo = mid;
173
+ else hi = mid - 1;
174
+ }
175
+ return lo;
176
+ }
177
+
178
+ /**
179
+ * The paste ceiling a set of secrets leaves, and what re-enrolling the ones
180
+ * without a scan prefilter would buy back.
181
+ */
182
+ export function scanBudgetForSecrets(secrets) {
183
+ const sweeps = scanSweeps(secrets);
184
+ const ceiling = maxScannablePasteCharsFor(sweeps);
185
+ return {
186
+ windowLengths: sweeps.length,
187
+ ceiling,
188
+ legacySecrets: secrets.filter((s) => s.hmacHex && !hasScanPrefilter(s)).length,
189
+ ceilingIfReenrolled: maxScannablePasteCharsFor(scanSweeps(secrets, true)),
190
+ atCharacterCap: ceiling === MAX_TOTAL_SCAN_CHARS,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Whether the server stores scan prefilters. A server that does returns the
196
+ * field on every row, null when absent; one deployed before it omits the key.
197
+ * An empty vault cannot say, so it is assumed current and the write path falls
198
+ * back if the field is refused.
199
+ */
200
+ function serverStoresScanPrefilter(existing) {
201
+ return (
202
+ existing.length === 0 ||
203
+ existing.some((s) => s && Object.prototype.hasOwnProperty.call(s, "scanPrefilter"))
204
+ );
205
+ }
206
+
207
+ /** A 400 refusing `scanPrefilter` and nothing else: an older server. */
208
+ function refusesOnlyScanPrefilter(r) {
209
+ const fields = r.data && Array.isArray(r.data.unknownFields) ? r.data.unknownFields : null;
210
+ return (
211
+ r.status === 400 &&
212
+ r.data?.code === "UNKNOWN_FIELD" &&
213
+ fields !== null &&
214
+ fields.length === 1 &&
215
+ fields[0] === "scanPrefilter"
216
+ );
217
+ }
218
+
219
+ /* ═══════════════════════════════════════════════════════════════
220
+ Argument parsing
221
+ ═══════════════════════════════════════════════════════════════ */
222
+ function parseArgs(argv) {
223
+ const out = {
224
+ help: false,
225
+ env: null,
226
+ jsonFile: null,
227
+ yaml: null,
228
+ stdin: false,
229
+ source: null,
230
+ apply: false,
231
+ yes: false,
232
+ minLength: null,
233
+ allowWeak: false,
234
+ only: [],
235
+ exclude: [],
236
+ server: null,
237
+ orgId: null,
238
+ project: null,
239
+ reportJson: false,
240
+ noColor: false,
241
+ };
242
+ const need = (i, flag) => {
243
+ const v = argv[i];
244
+ if (v === undefined || v.startsWith("--")) {
245
+ throw new UsageError(`${flag} needs a value`);
246
+ }
247
+ return v;
248
+ };
249
+ for (let i = 2; i < argv.length; i++) {
250
+ const a = argv[i];
251
+ if (a === "--help" || a === "-h") out.help = true;
252
+ else if (a === "--env") out.env = need(++i, "--env");
253
+ else if (a === "--json") out.jsonFile = need(++i, "--json");
254
+ else if (a === "--yaml") out.yaml = need(++i, "--yaml");
255
+ else if (a === "--stdin") out.stdin = true;
256
+ else if (a === "--source") out.source = need(++i, "--source");
257
+ else if (a === "--apply") out.apply = true;
258
+ else if (a === "--yes" || a === "-y") out.yes = true;
259
+ else if (a === "--min-length") out.minLength = Number(need(++i, "--min-length"));
260
+ else if (a === "--allow-weak") out.allowWeak = true;
261
+ else if (a === "--only") out.only.push(need(++i, "--only"));
262
+ else if (a === "--exclude") out.exclude.push(need(++i, "--exclude"));
263
+ else if (a === "--server") out.server = need(++i, "--server");
264
+ else if (a === "--org-id") out.orgId = need(++i, "--org-id");
265
+ else if (a === "--project") out.project = need(++i, "--project");
266
+ else if (a === "--report-json") out.reportJson = true;
267
+ else if (a === "--no-color") out.noColor = true;
268
+ else throw new UsageError(`Unknown flag: ${a}`);
269
+ }
270
+ if (out.minLength !== null && (!Number.isInteger(out.minLength) || out.minLength < 1)) {
271
+ throw new UsageError("--min-length must be a positive integer");
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /* ═══════════════════════════════════════════════════════════════
277
+ Input parsing — dotenv
278
+ ═══════════════════════════════════════════════════════════════ */
279
+
280
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
281
+
282
+ /**
283
+ * dotenv parser.
284
+ *
285
+ * Scans by index rather than by line because a double-quoted value may span
286
+ * lines, and a PEM private key in a .env file is exactly that shape. Splitting
287
+ * on newlines first would read the first line of the key as the whole value and
288
+ * enroll a digest of a truncated secret, which looks enrolled and matches
289
+ * nothing.
290
+ *
291
+ * @returns Entries in file order (duplicates kept; the caller applies last-wins)
292
+ * plus human-readable problems for lines that were skipped.
293
+ */
294
+ export function parseDotenv(text) {
295
+ const src = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
296
+ const entries = [];
297
+ const problems = [];
298
+ let i = 0;
299
+ let line = 1;
300
+
301
+ const skipToNextLine = () => {
302
+ const nl = src.indexOf("\n", i);
303
+ i = nl === -1 ? src.length : nl + 1;
304
+ };
305
+ const countNewlines = (from, to) => {
306
+ let n = 0;
307
+ for (let k = from; k < to; k++) if (src[k] === "\n") n++;
308
+ return n;
309
+ };
310
+
311
+ while (i < src.length) {
312
+ const ch = src[i];
313
+ if (ch === "\n") {
314
+ line++;
315
+ i++;
316
+ continue;
317
+ }
318
+ if (ch === " " || ch === "\t") {
319
+ i++;
320
+ continue;
321
+ }
322
+ if (ch === "#") {
323
+ skipToNextLine();
324
+ line++;
325
+ continue;
326
+ }
327
+
328
+ const declLine = line;
329
+
330
+ let j = i;
331
+ while (j < src.length && src[j] !== "=" && src[j] !== "\n") j++;
332
+ if (j >= src.length || src[j] === "\n") {
333
+ problems.push({ line: declLine, message: "no '=' on this line — skipped" });
334
+ i = j + 1;
335
+ line++;
336
+ continue;
337
+ }
338
+
339
+ let key = src.slice(i, j).trim();
340
+ if (/^export\s/.test(key)) key = key.replace(/^export\s+/, "").trim();
341
+ i = j + 1;
342
+
343
+ while (i < src.length && (src[i] === " " || src[i] === "\t")) i++;
344
+
345
+ let value = "";
346
+ let usable = true;
347
+ const quote = src[i];
348
+
349
+ if (quote === '"' || quote === "'") {
350
+ const valueStart = ++i;
351
+ const resumeAfterLine = src.indexOf("\n", i);
352
+ let closed = false;
353
+ while (i < src.length) {
354
+ const ch2 = src[i];
355
+ // Escapes are a double-quote feature in dotenv; inside single quotes a
356
+ // backslash is a literal backslash, and a key with a Windows path or a
357
+ // regex in it would otherwise be silently altered.
358
+ if (ch2 === "\\" && quote === '"' && i + 1 < src.length) {
359
+ const next = src[i + 1];
360
+ value += next === "n" ? "\n" : next === "t" ? "\t" : next === "r" ? "\r" : next;
361
+ i += 2;
362
+ continue;
363
+ }
364
+ if (ch2 === quote) {
365
+ i++;
366
+ closed = true;
367
+ break;
368
+ }
369
+ value += ch2;
370
+ i++;
371
+ }
372
+ if (closed) {
373
+ line += countNewlines(valueStart, i);
374
+ skipToNextLine();
375
+ line++;
376
+ } else {
377
+ // An unterminated quote scans to end of file, so continuing from there
378
+ // would drop every key below it without saying so. Resuming on the next
379
+ // line loses only the broken entry, which is the one already reported.
380
+ problems.push({ line: declLine, message: "unterminated quote — skipped this entry" });
381
+ usable = false;
382
+ i = resumeAfterLine === -1 ? src.length : resumeAfterLine + 1;
383
+ line++;
384
+ }
385
+ } else {
386
+ const nl = src.indexOf("\n", i);
387
+ const end = nl === -1 ? src.length : nl;
388
+ let raw = src.slice(i, end);
389
+ // Only whitespace-then-# is a comment. `pass#word` is a password.
390
+ const hash = raw.search(/\s#/);
391
+ if (hash !== -1) raw = raw.slice(0, hash);
392
+ value = raw.trim();
393
+ i = end === src.length ? end : end + 1;
394
+ line++;
395
+ }
396
+
397
+ if (!usable) continue;
398
+ if (!KEY_PATTERN.test(key)) {
399
+ problems.push({ line: declLine, message: `unusable key name ${JSON.stringify(key)} — skipped` });
400
+ continue;
401
+ }
402
+ entries.push({ key, value, line: declLine });
403
+ }
404
+
405
+ return { entries, problems };
406
+ }
407
+
408
+ /* ═══════════════════════════════════════════════════════════════
409
+ Input parsing — JSON (and every vault CLI that speaks it)
410
+ ═══════════════════════════════════════════════════════════════ */
411
+
412
+ /**
413
+ * Keys that mark an envelope rather than a secret. Unwrapping only happens when
414
+ * the document's own keys are a subset of these, so a real `.env` that happens
415
+ * to contain a key called `data` is not mistaken for a Vault response.
416
+ */
417
+ const ENVELOPE_KEYS = new Set([
418
+ "data",
419
+ "metadata",
420
+ "lease_id",
421
+ "lease_duration",
422
+ "lease_renewable",
423
+ "renewable",
424
+ "request_id",
425
+ "warnings",
426
+ "auth",
427
+ "wrap_info",
428
+ "mount_type",
429
+ ]);
430
+
431
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
432
+
433
+ /**
434
+ * Peels vendor envelopes off a parsed JSON document.
435
+ *
436
+ * Vault nests under `data.data`, AWS hands back the payload as a JSON string in
437
+ * `SecretString`, and 1Password uses a `fields` array. Handling these here is
438
+ * what lets `--stdin` work with every vault without a single vendor SDK.
439
+ *
440
+ * @returns `{ shape, doc }` where shape names what was recognised, for the log.
441
+ */
442
+ export function unwrapSecretsDocument(doc) {
443
+ if (!isPlainObject(doc)) {
444
+ throw new UsageError("Expected a JSON object of secrets, got " + describeJson(doc));
445
+ }
446
+
447
+ if (typeof doc["SecretString"] === "string") {
448
+ let inner;
449
+ try {
450
+ inner = JSON.parse(doc["SecretString"]);
451
+ } catch {
452
+ throw new UsageError(
453
+ "SecretString is not JSON. For a single-value secret pass it to redaktyn-fingerprint instead."
454
+ );
455
+ }
456
+ return unwrapSecretsDocument(inner);
457
+ }
458
+
459
+ if (Array.isArray(doc["fields"])) {
460
+ const map = {};
461
+ for (const f of doc["fields"]) {
462
+ if (!isPlainObject(f)) continue;
463
+ const key = f["label"] ?? f["id"];
464
+ if (typeof key !== "string" || typeof f["value"] !== "string") continue;
465
+ map[key] = f["value"];
466
+ }
467
+ return { shape: "1password-item", doc: map };
468
+ }
469
+
470
+ const own = Object.keys(doc);
471
+ const envelope = own.length > 0 && own.every((k) => ENVELOPE_KEYS.has(k));
472
+ if (envelope && isPlainObject(doc["data"])) {
473
+ const inner = doc["data"];
474
+ // Vault KV v2 puts the secrets one level deeper and a metadata block beside
475
+ // them; KV v1 has them right here.
476
+ if (isPlainObject(inner["data"]) && "metadata" in inner) {
477
+ return { shape: "vault-kv2", doc: inner["data"] };
478
+ }
479
+ return { shape: "vault-kv1", doc: inner };
480
+ }
481
+
482
+ return { shape: "flat", doc };
483
+ }
484
+
485
+ function describeJson(v) {
486
+ if (v === null) return "null";
487
+ if (Array.isArray(v)) return "an array";
488
+ return typeof v;
489
+ }
490
+
491
+ /**
492
+ * Flattens an unwrapped document to entries.
493
+ *
494
+ * Non-string scalars are stringified rather than dropped so the preview reports
495
+ * a verdict for them; the filter is where `PORT=8080` gets rejected, and a value
496
+ * that vanished before the filter is a value the admin never got told about.
497
+ */
498
+ export function entriesFromJsonDocument(doc) {
499
+ const entries = [];
500
+ const problems = [];
501
+ for (const [key, raw] of Object.entries(doc)) {
502
+ if (typeof raw === "string") {
503
+ entries.push({ key, value: raw });
504
+ continue;
505
+ }
506
+ if (typeof raw === "number" || typeof raw === "boolean") {
507
+ entries.push({ key, value: String(raw) });
508
+ continue;
509
+ }
510
+ if (isPlainObject(raw)) {
511
+ // Doppler and friends wrap each value in an object.
512
+ const field = ["value", "raw", "computed", "computedValue"].find(
513
+ (f) => typeof raw[f] === "string"
514
+ );
515
+ if (field) {
516
+ entries.push({ key, value: raw[field] });
517
+ continue;
518
+ }
519
+ problems.push({ key, message: "nested object with no value/raw/computed field — skipped" });
520
+ continue;
521
+ }
522
+ problems.push({ key, message: `${describeJson(raw)} is not a secret value — skipped` });
523
+ }
524
+ return { entries, problems };
525
+ }
526
+
527
+ export function parseJsonSecrets(text) {
528
+ let doc;
529
+ try {
530
+ doc = JSON.parse(text);
531
+ } catch (e) {
532
+ throw new UsageError(`Input is not valid JSON: ${e.message}`);
533
+ }
534
+ const { shape, doc: unwrapped } = unwrapSecretsDocument(doc);
535
+ const { entries, problems } = entriesFromJsonDocument(unwrapped);
536
+ return { entries, problems, shape };
537
+ }
538
+
539
+ /* ═══════════════════════════════════════════════════════════════
540
+ Input parsing — flat YAML subset
541
+ ═══════════════════════════════════════════════════════════════ */
542
+
543
+ /**
544
+ * Flat `key: value` YAML only.
545
+ *
546
+ * Real YAML is not parseable with a regex, and the failure mode of pretending
547
+ * otherwise is the worst one available here: a nested document read as flat
548
+ * produces keys mapped to empty or partial values, and enrolls digests of
549
+ * things that are not the secrets. So anything with indentation, a list, or a
550
+ * key with no inline value is refused outright.
551
+ */
552
+ export function parseFlatYaml(text) {
553
+ const entries = [];
554
+ const problems = [];
555
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
556
+
557
+ lines.forEach((raw, idx) => {
558
+ const line = idx + 1;
559
+ if (raw.trim() === "" || raw.trim().startsWith("#")) return;
560
+ if (raw.trim() === "---" || raw.trim() === "...") return;
561
+
562
+ if (/^\s/.test(raw)) {
563
+ throw new UsageError(
564
+ `${line}: this YAML is indented, so it is not flat key/value. ` +
565
+ `Refusing rather than guessing — convert it to JSON and use --json.`
566
+ );
567
+ }
568
+ if (/^-\s/.test(raw.trim()) || raw.trim() === "-") {
569
+ throw new UsageError(`${line}: YAML lists are not supported. Use --json.`);
570
+ }
571
+ if (/^[|>]/.test(raw.trim())) {
572
+ throw new UsageError(`${line}: YAML block scalars are not supported. Use --json.`);
573
+ }
574
+
575
+ const m = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s?(.*)$/.exec(raw);
576
+ if (!m) {
577
+ problems.push({ line, message: "not a `key: value` line — skipped" });
578
+ return;
579
+ }
580
+ const key = m[1];
581
+ let value = m[2] ?? "";
582
+
583
+ if (value.trim() === "" || value.trim() === "|" || value.trim() === ">") {
584
+ throw new UsageError(
585
+ `${line}: "${key}" has no inline value, which in YAML means a nested block or null. ` +
586
+ `Refusing rather than registering an empty fingerprint.`
587
+ );
588
+ }
589
+
590
+ const t = value.trim();
591
+ if ((t.startsWith('"') && t.endsWith('"') && t.length > 1) ||
592
+ (t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
593
+ value = t.slice(1, -1);
594
+ if (t[0] === '"') value = value.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\(.)/g, "$1");
595
+ } else {
596
+ const hash = t.search(/\s#/);
597
+ value = (hash === -1 ? t : t.slice(0, hash)).trim();
598
+ }
599
+
600
+ entries.push({ key, value, line });
601
+ });
602
+
603
+ return { entries, problems };
604
+ }
605
+
606
+ /* ═══════════════════════════════════════════════════════════════
607
+ Junk filter
608
+ ═══════════════════════════════════════════════════════════════ */
609
+
610
+ export const DEFAULT_MIN_LENGTH = 12;
611
+
612
+ /**
613
+ * Values that are configuration or placeholders whatever their length. Enrolling
614
+ * one of these is not a harmless extra: every paste containing the word would be
615
+ * blocked, and zero false positives is the whole product.
616
+ */
617
+ const PLACEHOLDER_VALUES = new Set([
618
+ "changeme",
619
+ "change-me",
620
+ "change_me",
621
+ "replaceme",
622
+ "replace-me",
623
+ "your-secret-here",
624
+ "your_secret_here",
625
+ "yoursecrethere",
626
+ "development",
627
+ "production",
628
+ "staging",
629
+ "localhost",
630
+ "password",
631
+ "passw0rd",
632
+ "secret",
633
+ "topsecret",
634
+ "undefined",
635
+ "none",
636
+ "null",
637
+ "n/a",
638
+ "todo",
639
+ "tbd",
640
+ "example",
641
+ "test",
642
+ "dummy",
643
+ "placeholder",
644
+ ]);
645
+
646
+ function shannonBitsPerChar(s) {
647
+ if (s.length === 0) return 0;
648
+ const counts = new Map();
649
+ for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
650
+ let h = 0;
651
+ for (const n of counts.values()) {
652
+ const p = n / s.length;
653
+ h -= p * Math.log2(p);
654
+ }
655
+ return h;
656
+ }
657
+
658
+ /**
659
+ * URLs are the one case where "looks like config" and "is a secret" overlap: a
660
+ * Slack or Discord webhook is a URL and nothing else, and a Postgres DSN carries
661
+ * its password inline. So a URL is kept when it embeds a credential or a
662
+ * token-shaped path segment, and rejected when it is just an address.
663
+ *
664
+ * @returns null when the value is not a URL at all.
665
+ */
666
+ function urlVerdict(value) {
667
+ if (!/:\/\//.test(value)) return null;
668
+ let u;
669
+ try {
670
+ u = new URL(value);
671
+ } catch {
672
+ return null;
673
+ }
674
+ if (u.password) return { ok: true };
675
+ const parts = [
676
+ ...u.pathname.split("/"),
677
+ ...[...u.searchParams.values()],
678
+ u.hash.replace(/^#/, ""),
679
+ ].filter(Boolean);
680
+ if (parts.some((p) => p.length >= 16 && shannonBitsPerChar(p) >= 2.5)) return { ok: true };
681
+ return { ok: false, reason: "URL with no embedded credential or token" };
682
+ }
683
+
684
+ /**
685
+ * Decides whether a value looks like a secret worth enforcing.
686
+ *
687
+ * Advisory by design: every rejection is printed with its reason and can be
688
+ * overridden with --allow-weak or --min-length. The bias is toward rejecting,
689
+ * because a missed secret can be enrolled tomorrow while a bad enrollment
690
+ * starts blocking legitimate pastes org-wide the moment it syncs.
691
+ *
692
+ * @param value - Canonical form, as digested
693
+ */
694
+ export function junkVerdict(value, { minLength = DEFAULT_MIN_LENGTH } = {}) {
695
+ if (value.length === 0) return { ok: false, reason: "empty value" };
696
+ if (value.length < minLength) {
697
+ return { ok: false, reason: `only ${value.length} chars (minimum ${minLength})` };
698
+ }
699
+ if (/^\d+$/.test(value)) return { ok: false, reason: "all digits — port, id or timestamp" };
700
+ if (/^(true|false|yes|no|on|off|null|nil|none|undefined|enabled|disabled)$/i.test(value)) {
701
+ return { ok: false, reason: "boolean or empty-ish flag" };
702
+ }
703
+ if (/^v?\d+(\.\d+){1,3}([-+][0-9A-Za-z.-]+)?$/.test(value)) {
704
+ return { ok: false, reason: "version number" };
705
+ }
706
+ if (PLACEHOLDER_VALUES.has(value.toLowerCase())) {
707
+ return { ok: false, reason: "placeholder or environment name" };
708
+ }
709
+ if (/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value)) {
710
+ return { ok: false, reason: "email address" };
711
+ }
712
+ if (/^(\/[^\s]*|\.{1,2}\/[^\s]*|~\/[^\s]*|[A-Za-z]:[\\/][^\s]*)$/.test(value)) {
713
+ return { ok: false, reason: "filesystem path" };
714
+ }
715
+ const url = urlVerdict(value);
716
+ if (url && !url.ok) return { ok: false, reason: url.reason };
717
+ if (!url) {
718
+ const distinct = new Set(value).size;
719
+ if (distinct < 5) return { ok: false, reason: `only ${distinct} distinct characters` };
720
+ if (shannonBitsPerChar(value) < 2.0) {
721
+ return { ok: false, reason: "very low character diversity" };
722
+ }
723
+ }
724
+ return { ok: true };
725
+ }
726
+
727
+ /* ═══════════════════════════════════════════════════════════════
728
+ Source selection and key filtering
729
+ ═══════════════════════════════════════════════════════════════ */
730
+
731
+ async function readStdin() {
732
+ if (process.stdin.isTTY) {
733
+ throw new UsageError("--stdin was passed but nothing is piped in.");
734
+ }
735
+ const chunks = [];
736
+ for await (const chunk of process.stdin) chunks.push(chunk);
737
+ return Buffer.concat(chunks).toString("utf8");
738
+ }
739
+
740
+ function readFileOrDie(path, flag) {
741
+ try {
742
+ return readFileSync(path, "utf8");
743
+ } catch (e) {
744
+ throw new UsageError(`${flag} ${path}: ${e.code === "ENOENT" ? "no such file" : e.message}`);
745
+ }
746
+ }
747
+
748
+ /** `import:<source>`; capped so the tag stays inside the server's 80-char limit. */
749
+ export function sourceTag(source) {
750
+ return `import:${source}`;
751
+ }
752
+
753
+ export function normalizeSource(raw) {
754
+ const cleaned = String(raw)
755
+ .replace(/^[./]+/, "")
756
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
757
+ .replace(/^-+|-+$/g, "")
758
+ .slice(0, 60);
759
+ return cleaned || "import";
760
+ }
761
+
762
+ function matchesAny(key, patterns) {
763
+ return patterns.some((p) => {
764
+ if (p.includes("*")) {
765
+ const re = new RegExp("^" + p.split("*").map(escapeRegex).join(".*") + "$", "i");
766
+ return re.test(key);
767
+ }
768
+ return key.toLowerCase() === p.toLowerCase();
769
+ });
770
+ }
771
+
772
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
773
+
774
+ /* ═══════════════════════════════════════════════════════════════
775
+ Diff
776
+ ═══════════════════════════════════════════════════════════════ */
777
+
778
+ /**
779
+ * Classifies each candidate against what the org already enforces.
780
+ *
781
+ * Identity is `label` inside the import's own source tag, so two imports from
782
+ * two vaults never fight over the same row. Three deliberate choices:
783
+ *
784
+ * - A value already enrolled under any label is a SKIP, not a CREATE. POST
785
+ * rejects a duplicate digest with 409, so attempting it would turn a normal
786
+ * situation into a failed run.
787
+ * - A same-label secret that is *not* from this source is a CONFLICT, reported
788
+ * and left alone. Rotating it would silently take over a hand-enrolled secret.
789
+ * - A secret from this source that is no longer in the file is reported and
790
+ * never removed. A vault read can fail or return a subset, and the failure
791
+ * mode of acting on that is org-wide loss of protection.
792
+ */
793
+ export function diffAgainstServer({ candidates, existing, source }) {
794
+ const tag = sourceTag(source);
795
+ const byDigest = new Map();
796
+ for (const s of existing) if (s.hmacHex) byDigest.set(s.hmacHex, s);
797
+
798
+ const owned = existing.filter((s) => Array.isArray(s.tags) && s.tags.includes(tag));
799
+ const ownedByLabel = new Map(owned.map((s) => [s.label, s]));
800
+ const otherByLabel = new Map();
801
+ for (const s of existing) {
802
+ if (owned.includes(s)) continue;
803
+ if (!otherByLabel.has(s.label)) otherByLabel.set(s.label, s);
804
+ }
805
+
806
+ const plan = [];
807
+ const seenLabels = new Set();
808
+
809
+ for (const cand of candidates) {
810
+ const { key, digests } = cand;
811
+ seenLabels.add(key);
812
+
813
+ const mine = ownedByLabel.get(key);
814
+ if (mine) {
815
+ if (mine.hmacHex === digests.digest && variantsMatch(mine, digests)) {
816
+ plan.push({ key, action: "skip", reason: "already registered and unchanged", id: mine.id, length: digests.length });
817
+ } else {
818
+ const clash = byDigest.get(digests.digest);
819
+ if (clash && clash.id !== mine.id) {
820
+ plan.push({
821
+ key,
822
+ action: "conflict",
823
+ reason: `new value is already registered as "${clash.label}"`,
824
+ id: mine.id,
825
+ length: digests.length,
826
+ });
827
+ } else {
828
+ const reason =
829
+ mine.hmacHex === digests.digest ? "value unchanged; adding missing variants" : "value changed";
830
+ plan.push({ key, action: "rotate", reason, id: mine.id, length: digests.length });
831
+ }
832
+ }
833
+ continue;
834
+ }
835
+
836
+ const sameDigest = byDigest.get(digests.digest);
837
+ if (sameDigest) {
838
+ plan.push({
839
+ key,
840
+ action: "skip",
841
+ reason: `already registered as "${sameDigest.label}"`,
842
+ id: sameDigest.id,
843
+ length: digests.length,
844
+ });
845
+ continue;
846
+ }
847
+
848
+ const sameLabel = otherByLabel.get(key);
849
+ if (sameLabel) {
850
+ plan.push({
851
+ key,
852
+ action: "conflict",
853
+ reason: "a secret with this label exists outside this import source",
854
+ id: sameLabel.id,
855
+ length: digests.length,
856
+ });
857
+ continue;
858
+ }
859
+
860
+ plan.push({ key, action: "create", reason: "new", length: digests.length });
861
+ }
862
+
863
+ const missing = owned
864
+ .filter((s) => !seenLabels.has(s.label))
865
+ .map((s) => ({ label: s.label, id: s.id, status: s.status }));
866
+
867
+ return { plan, missing };
868
+ }
869
+
870
+ /**
871
+ * True when the stored bypass variants already equal the ones just computed.
872
+ *
873
+ * Without this, a secret enrolled by an older client that stored only the exact
874
+ * digest would compare equal on `hmacHex` and be skipped forever, so the
875
+ * whitespace and case-fold passes would never start covering it.
876
+ *
877
+ * The scan prefilter is compared for the same reason: a row enrolled before it
878
+ * existed is otherwise skipped forever and keeps costing devices a full sweep.
879
+ * Re-importing is the one way an existing secret can gain it, because this is
880
+ * the one tool that holds the plaintext again.
881
+ */
882
+ function variantsMatch(existing, digests) {
883
+ return (
884
+ existing.hmacHexStripped === digests.hmacHexStripped &&
885
+ existing.hmacHexLower === digests.hmacHexLower &&
886
+ existing.scanPrefilter === digests.scanPrefilter
887
+ );
888
+ }
889
+
890
+ /* ═══════════════════════════════════════════════════════════════
891
+ HTTP
892
+ ═══════════════════════════════════════════════════════════════ */
893
+
894
+ const trimSlashes = (u) => u.replace(/\/+$/, "");
895
+
896
+ async function apiRequest(serverUrl, token, method, path, body) {
897
+ let res;
898
+ try {
899
+ res = await fetch(`${trimSlashes(serverUrl)}${path}`, {
900
+ method,
901
+ headers: {
902
+ Authorization: `Bearer ${token}`,
903
+ ...(body ? { "Content-Type": "application/json" } : {}),
904
+ },
905
+ ...(body ? { body: JSON.stringify(body) } : {}),
906
+ });
907
+ } catch (e) {
908
+ throw new UsageError(`Cannot reach ${trimSlashes(serverUrl)}: ${e.message}`);
909
+ }
910
+ const text = await res.text();
911
+ let data = null;
912
+ if (text) {
913
+ try {
914
+ data = JSON.parse(text);
915
+ } catch {
916
+ data = null;
917
+ }
918
+ }
919
+ return { status: res.status, ok: res.ok, data, text };
920
+ }
921
+
922
+ /**
923
+ * Resolve --project to a row of GET /api/projects: exact id, else a
924
+ * case-insensitive exact name. Refuses an unknown, ambiguous or closed project
925
+ * before anything is hashed, because the server would otherwise file digests
926
+ * under a key no device on that project derives.
927
+ */
928
+ async function resolveProject(serverUrl, token, wanted) {
929
+ const r = await apiRequest(serverUrl, token, "GET", "/api/projects");
930
+ if (!r.ok || !Array.isArray(r.data)) {
931
+ throw new UsageError(`GET /api/projects failed: ${r.status} ${String(r.text).slice(0, 200)}`);
932
+ }
933
+ const lower = wanted.trim().toLowerCase();
934
+ const byId = r.data.filter((p) => p.id === wanted.trim());
935
+ const matches = byId.length ? byId : r.data.filter((p) => String(p.name).toLowerCase() === lower);
936
+ if (matches.length === 0) {
937
+ throw new UsageError(`No project named or numbered "${wanted}". Known: ${r.data.map((p) => p.name).join(", ") || "none"}.`);
938
+ }
939
+ if (matches.length > 1) {
940
+ throw new UsageError(`"${wanted}" matches ${matches.length} projects. Pass the project id instead.`);
941
+ }
942
+ if (matches[0].status === "CLOSED") {
943
+ throw new UsageError(`Project "${matches[0].name}" is closed. Reopen it before enrolling into it.`);
944
+ }
945
+ return matches[0];
946
+ }
947
+
948
+ async function fetchExistingSecrets(serverUrl, token) {
949
+ const r = await apiRequest(serverUrl, token, "GET", "/api/secrets");
950
+ if (r.status === 401) {
951
+ throw new UsageError(
952
+ "Server rejected the token (401). REDAKTYN_TOKEN must be a current OWNER or ADMIN login token."
953
+ );
954
+ }
955
+ if (r.status === 403) {
956
+ throw new UsageError(
957
+ "This token is not allowed to manage secrets (403). Importing needs an OWNER or ADMIN token."
958
+ );
959
+ }
960
+ if (!r.ok) throw new UsageError(`GET /api/secrets failed: ${r.status} ${r.text.slice(0, 200)}`);
961
+ if (!Array.isArray(r.data)) throw new UsageError("GET /api/secrets did not return a list.");
962
+ return r.data;
963
+ }
964
+
965
+ /* ═══════════════════════════════════════════════════════════════
966
+ Token checks
967
+ ═══════════════════════════════════════════════════════════════ */
968
+
969
+ export function decodeJwtPayload(token) {
970
+ try {
971
+ const p = token.split(".")[1];
972
+ if (!p) return null;
973
+ const padded = p + "=".repeat((4 - (p.length % 4)) % 4);
974
+ const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
975
+ return JSON.parse(json);
976
+ } catch {
977
+ return null;
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Refuses an extension token before anything is computed.
983
+ *
984
+ * `redaktyn-scan` runs on a device token, so that is the variable already
985
+ * exported in most shells that have used this CLI. An extension token can read
986
+ * the secret list, which means the whole dry-run would look like it worked and
987
+ * only the first write would fail — after the operator had approved the plan.
988
+ */
989
+ export function tokenRoleProblem(payload) {
990
+ if (!payload) return null;
991
+ if (payload.type === "extension") {
992
+ return (
993
+ "REDAKTYN_TOKEN is an extension device token. Those can sync fingerprints but not register them.\n" +
994
+ " Get an OWNER or ADMIN login token instead:\n" +
995
+ " curl -s -X POST \"$REDAKTYN_SERVER_URL/api/auth/login\" \\\n" +
996
+ " -H 'Content-Type: application/json' \\\n" +
997
+ " -d '{\"email\":\"you@example.com\",\"password\":\"…\"}' | jq -r .token"
998
+ );
999
+ }
1000
+ const role = payload.role;
1001
+ if (typeof role === "string" && !["OWNER", "ADMIN"].includes(role.toUpperCase())) {
1002
+ return `This token has role ${role}. Importing needs OWNER or ADMIN.`;
1003
+ }
1004
+ return null;
1005
+ }
1006
+
1007
+ /* ═══════════════════════════════════════════════════════════════
1008
+ Output
1009
+ ═══════════════════════════════════════════════════════════════ */
1010
+
1011
+ const ACTION_STYLE = {
1012
+ create: { label: "CREATE", color: c.green },
1013
+ rotate: { label: "ROTATE", color: c.yellow },
1014
+ skip: { label: "skip", color: c.gray },
1015
+ conflict: { label: "CONFLICT", color: c.red },
1016
+ rejected: { label: "rejected", color: c.gray },
1017
+ };
1018
+
1019
+ function truncateKey(key, width) {
1020
+ return key.length <= width ? key.padEnd(width) : key.slice(0, width - 1) + "…";
1021
+ }
1022
+
1023
+ /**
1024
+ * Prints the plan.
1025
+ *
1026
+ * Values are never printed — not truncated, not masked, not for rejected keys.
1027
+ * A preview is the thing an operator screenshots or pipes into a CI log, and a
1028
+ * tool whose whole claim is that plaintext stays local cannot be the one to put
1029
+ * plaintext on a terminal.
1030
+ */
1031
+ function printPlan({ rows, missing, source, dryRun, budget, projectName = null }) {
1032
+ const width = Math.min(process.stdout.columns || 80, 100);
1033
+ const keyWidth = 34;
1034
+
1035
+ console.log("");
1036
+ console.log(`${c.bold}Redaktyn import${c.reset} ${c.dim}· source tag ${sourceTag(source)}${projectName ? ` · project ${projectName}` : ""}${c.reset}`);
1037
+ console.log(c.dim + "─".repeat(width) + c.reset);
1038
+ console.log(
1039
+ `${c.dim}${"KEY".padEnd(keyWidth)} ${"LEN".padStart(5)} ${"ACTION".padEnd(9)} WHY${c.reset}`
1040
+ );
1041
+
1042
+ for (const row of rows) {
1043
+ const style = ACTION_STYLE[row.action] ?? { label: row.action, color: "" };
1044
+ console.log(
1045
+ `${truncateKey(row.key, keyWidth)} ${String(row.length ?? "-").padStart(5)} ` +
1046
+ `${style.color}${style.label.padEnd(9)}${c.reset} ${c.dim}${row.reason ?? ""}${c.reset}`
1047
+ );
1048
+ }
1049
+
1050
+ console.log(c.dim + "─".repeat(width) + c.reset);
1051
+
1052
+ const counts = tally(rows);
1053
+ const summary = [
1054
+ counts.create ? `${c.green}${counts.create} to create${c.reset}` : null,
1055
+ counts.rotate ? `${c.yellow}${counts.rotate} to rotate${c.reset}` : null,
1056
+ counts.skip ? `${counts.skip} unchanged` : null,
1057
+ counts.rejected ? `${counts.rejected} filtered out` : null,
1058
+ counts.conflict ? `${c.red}${counts.conflict} conflicting${c.reset}` : null,
1059
+ ].filter(Boolean);
1060
+ console.log(" " + (summary.length ? summary.join(c.dim + " · " + c.reset) : "nothing to do"));
1061
+
1062
+ if (missing.length > 0) {
1063
+ console.log("");
1064
+ console.log(
1065
+ `${c.yellow}${missing.length} secret(s) tagged ${sourceTag(source)} are not in this input.${c.reset}`
1066
+ );
1067
+ console.log(`${c.dim} Reported only — import never removes anything. Retire them in the dashboard if they are gone.${c.reset}`);
1068
+ for (const m of missing.slice(0, 20)) {
1069
+ console.log(`${c.dim} · ${m.label}${c.reset}`);
1070
+ }
1071
+ if (missing.length > 20) console.log(`${c.dim} · … ${missing.length - 20} more${c.reset}`);
1072
+ }
1073
+
1074
+ printBudget(budget);
1075
+
1076
+ if (dryRun) {
1077
+ console.log("");
1078
+ console.log(`${c.bold}Dry run — nothing was sent.${c.reset} ${c.dim}Re-run with --apply to register.${c.reset}`);
1079
+ }
1080
+ console.log("");
1081
+ }
1082
+
1083
+ /**
1084
+ * Reports the paste ceiling this import leaves behind.
1085
+ *
1086
+ * Always printed, because every enrolled secret moves it and an operator
1087
+ * running a bulk import is the only person in a position to trade coverage
1088
+ * against it. Escalated when it reaches sizes people actually paste.
1089
+ */
1090
+ function printBudget(budget) {
1091
+ if (!budget || budget.ceiling === Infinity) return;
1092
+ const pretty = budget.ceiling.toLocaleString("en-US");
1093
+ const legacyNote = (tone) => {
1094
+ if (!(budget.legacySecrets > 0) || !(budget.ceilingIfReenrolled > budget.ceiling)) return;
1095
+ const n = budget.legacySecrets;
1096
+ console.log(
1097
+ `${tone} ${n} enrolled secret${n === 1 ? " has" : "s have"} no scan prefilter (enrolled before 2026-10-07 or by an` +
1098
+ ` older client),${c.reset}`
1099
+ );
1100
+ console.log(
1101
+ `${tone} so every paste is checked against ${n === 1 ? "it" : "them"} the slow way. They are outside this import;` +
1102
+ ` re-enrolling ${n === 1 ? "it" : "them"}${c.reset}`
1103
+ );
1104
+ console.log(
1105
+ `${tone} from the dashboard, or importing ${n === 1 ? "it" : "them"}, raises this to ~` +
1106
+ `${budget.ceilingIfReenrolled.toLocaleString("en-US")} chars.${c.reset}`
1107
+ );
1108
+ };
1109
+
1110
+ if (budget.ceiling < PASTE_CEILING_ALARM_CHARS) {
1111
+ console.log("");
1112
+ console.log(`${c.red}${c.bold}Scan budget: pastes over ~${pretty} characters will be refused${c.reset}`);
1113
+ console.log(
1114
+ ` ${budget.windowLengths} distinct window lengths after this import. Detection slides one pass per`
1115
+ );
1116
+ console.log(
1117
+ ` length, so past that size the extension ${c.bold}refuses to scan${c.reset} rather than hang —`
1118
+ );
1119
+ console.log(` which is a hard block in sensitive contexts. A paste at the ceiling takes up to ~8s.`);
1120
+ legacyNote("");
1121
+ console.log(`${c.dim} Register the high-value secrets only, or split them across workspaces.${c.reset}`);
1122
+ return;
1123
+ }
1124
+
1125
+ if (budget.ceiling < PASTE_CEILING_WARN_CHARS) {
1126
+ console.log("");
1127
+ console.log(
1128
+ `${c.yellow}Scan budget:${c.reset} ${budget.windowLengths} window lengths after this import; ` +
1129
+ `pastes above ~${pretty} chars are refused.`
1130
+ );
1131
+ legacyNote("");
1132
+ return;
1133
+ }
1134
+
1135
+ console.log(
1136
+ budget.atCharacterCap
1137
+ ? `${c.dim} Scan budget: every paste up to the extension's ${pretty}-char hard limit is scanned.${c.reset}`
1138
+ : `${c.dim} Scan budget: ${budget.windowLengths} window lengths, pastes up to ~${pretty} chars.${c.reset}`
1139
+ );
1140
+ legacyNote(c.dim);
1141
+ }
1142
+
1143
+ function tally(rows) {
1144
+ const counts = { create: 0, rotate: 0, skip: 0, conflict: 0, rejected: 0 };
1145
+ for (const r of rows) counts[r.action] = (counts[r.action] ?? 0) + 1;
1146
+ return counts;
1147
+ }
1148
+
1149
+ /* ═══════════════════════════════════════════════════════════════
1150
+ Confirmation
1151
+ ═══════════════════════════════════════════════════════════════ */
1152
+
1153
+ async function confirm(question) {
1154
+ if (!process.stdin.isTTY) {
1155
+ throw new UsageError("--apply needs a terminal to confirm on, or --yes to skip the prompt.");
1156
+ }
1157
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1158
+ try {
1159
+ const answer = await new Promise((resolve) => rl.question(`${question} `, resolve));
1160
+ return /^y(es)?$/i.test(answer.trim());
1161
+ } finally {
1162
+ rl.close();
1163
+ }
1164
+ }
1165
+
1166
+ async function promptHidden(question) {
1167
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1168
+ return new Promise((resolve) => {
1169
+ const onData = (chunk) => {
1170
+ // Repaint the prompt without the typed characters. readline has already
1171
+ // echoed them, so this erases the line rather than trying to prevent it.
1172
+ const s = String(chunk);
1173
+ if (!s.includes("\n") && !s.includes("\r")) {
1174
+ readline.clearLine(process.stdout, 0);
1175
+ readline.cursorTo(process.stdout, 0);
1176
+ process.stdout.write(question + " ");
1177
+ }
1178
+ };
1179
+ process.stdin.on("data", onData);
1180
+ rl.question(`${question} `, (answer) => {
1181
+ process.stdin.off("data", onData);
1182
+ rl.close();
1183
+ process.stdout.write("\n");
1184
+ resolve(answer);
1185
+ });
1186
+ });
1187
+ }
1188
+
1189
+ /* ═══════════════════════════════════════════════════════════════
1190
+ Apply
1191
+ ═══════════════════════════════════════════════════════════════ */
1192
+
1193
+ /**
1194
+ * The exact field set the secrets API accepts, built through the shared
1195
+ * enrollPayload() so this cannot drift into sending only the exact digest and
1196
+ * quietly losing the whitespace and case-fold coverage.
1197
+ *
1198
+ * Named fields rather than a spread of the compute result, which also carries
1199
+ * `canonical` — the plaintext. It would be dropped by enrollPayload anyway, but
1200
+ * a tool that promises values stay local should not pass them into a request
1201
+ * builder at all.
1202
+ */
1203
+ function digestBody(label, d) {
1204
+ return enrollPayload({
1205
+ label,
1206
+ hmacHex: d.digest,
1207
+ length: d.length,
1208
+ hmacHexStripped: d.hmacHexStripped,
1209
+ lengthStripped: d.lengthStripped,
1210
+ hmacHexLower: d.hmacHexLower,
1211
+ lengthLower: d.lengthLower,
1212
+ scanPrefilter: d.scanPrefilter,
1213
+ });
1214
+ }
1215
+
1216
+ async function applyPlan({ rows, byKey, serverUrl, token, source, projectId = null }) {
1217
+ const results = [];
1218
+ const tag = sourceTag(source);
1219
+ let sendPrefilter = true;
1220
+
1221
+ for (const row of rows) {
1222
+ if (row.action !== "create" && row.action !== "rotate") continue;
1223
+ const cand = byKey.get(row.key);
1224
+
1225
+ const send = (withPrefilter) => {
1226
+ const { scanPrefilter, ...rest } = cand.digests;
1227
+ const payload = digestBody(row.key, withPrefilter ? { ...rest, scanPrefilter } : rest);
1228
+ // The digest replacement body is strict and has no `label` — replacing a
1229
+ // fingerprint is deliberately not a rename — so the label is dropped here
1230
+ // rather than sent and rejected as an unrecognised key.
1231
+ const { label: _label, ...digestsOnly } = payload;
1232
+ // The server refuses a digest filed under a project it was not hashed for,
1233
+ // so the project travels with every write.
1234
+ const scope = projectId ? { projectId } : {};
1235
+ return row.action === "create"
1236
+ ? apiRequest(serverUrl, token, "POST", "/api/secrets", { ...payload, ...scope, tags: [tag] })
1237
+ : apiRequest(serverUrl, token, "PUT", `/api/secrets/${row.id}/digest`, { ...digestsOnly, ...scope });
1238
+ };
1239
+
1240
+ let r = await send(sendPrefilter);
1241
+ if (sendPrefilter && cand.digests.scanPrefilter && refusesOnlyScanPrefilter(r)) {
1242
+ // Only reachable on an empty vault, where the plan could not tell the
1243
+ // server's age from its rows. Nothing was written by the refused request.
1244
+ sendPrefilter = false;
1245
+ printOlderServerNotice();
1246
+ r = await send(false);
1247
+ }
1248
+
1249
+ if (r.ok) {
1250
+ results.push({ key: row.key, action: row.action, ok: true });
1251
+ console.log(` ${c.green}✓${c.reset} ${row.action === "create" ? "created" : "rotated"} ${row.key}`);
1252
+ continue;
1253
+ }
1254
+
1255
+ const reason = explainWriteFailure(r);
1256
+ results.push({ key: row.key, action: row.action, ok: false, status: r.status, reason });
1257
+ console.log(` ${c.red}✗${c.reset} ${row.key}: ${reason}`);
1258
+ }
1259
+
1260
+ return results;
1261
+ }
1262
+
1263
+ function printOlderServerNotice() {
1264
+ console.log(
1265
+ `${c.yellow}!${c.reset} This server does not store scan prefilters yet, so secrets are registered without one.`
1266
+ );
1267
+ console.log(
1268
+ `${c.dim} They are protected exactly the same, but every paste checks them the slow way. Re-run this${c.reset}`
1269
+ );
1270
+ console.log(`${c.dim} import once the server is updated; it adds the prefilter to each of them.${c.reset}`);
1271
+ }
1272
+
1273
+ function explainWriteFailure(r) {
1274
+ if (r.status === 409) {
1275
+ return "another secret in this workspace already has this fingerprint";
1276
+ }
1277
+ if (r.status === 403) return "token is not OWNER or ADMIN";
1278
+ if (r.status === 401) return "token expired or invalid";
1279
+ if (r.status === 404) return "secret no longer exists — re-run to re-plan";
1280
+ const msg = r.data && typeof r.data.error === "string" ? r.data.error : r.text.slice(0, 160);
1281
+ return `${r.status} ${msg}`;
1282
+ }
1283
+
1284
+ /* ═══════════════════════════════════════════════════════════════
1285
+ Help
1286
+ ═══════════════════════════════════════════════════════════════ */
1287
+ function printHelp() {
1288
+ console.log(`
1289
+ ${c.bold}redaktyn-import${c.reset} — register fingerprints in bulk from files or any vault CLI
1290
+
1291
+ ${c.bold}Digests are computed on this machine.${c.reset} Values are never sent, never
1292
+ printed, and never written to the report.
1293
+
1294
+ ${c.bold}Input (choose one)${c.reset}
1295
+ --env <file> dotenv file (export prefix, quotes, comments, multi-line)
1296
+ --json <file> JSON object of secrets
1297
+ --yaml <file> flat "key: value" YAML only; refuses anything nested
1298
+ --stdin JSON piped in — this is how vault CLIs are consumed
1299
+
1300
+ ${c.bold}Vault recipes${c.reset}
1301
+ vault kv get -format=json secret/prod | redaktyn-import --stdin
1302
+ doppler secrets download --no-file --format json | redaktyn-import --stdin
1303
+ op item get prod --format json | redaktyn-import --stdin
1304
+ aws secretsmanager get-secret-value --secret-id prod \\
1305
+ --query SecretString --output text | redaktyn-import --stdin
1306
+
1307
+ ${c.bold}Options${c.reset}
1308
+ --source <name> Provenance tag, stored as import:<name>
1309
+ (default: input file name, or "stdin")
1310
+ --apply Actually register. Without it this is a dry run.
1311
+ --yes Skip the confirmation prompt (for CI)
1312
+ --min-length <n> Minimum value length to accept (default ${DEFAULT_MIN_LENGTH})
1313
+ --allow-weak Register values the junk filter rejected
1314
+ --only <key|glob> Only these keys (repeatable)
1315
+ --exclude <key|glob> Skip these keys (repeatable)
1316
+ --server <url> Override REDAKTYN_SERVER_URL
1317
+ --org-id <uuid> Override org id (must match the token)
1318
+ --project <name|id> Enrol into this project (hashed under the project's key and
1319
+ compared only with that project's secrets). Default: the
1320
+ organisation's default project.
1321
+ --report-json Machine-readable plan on stdout, no table
1322
+ --no-color Disable ANSI colors
1323
+
1324
+ ${c.bold}Environment${c.reset}
1325
+ REDAKTYN_SERVER_URL API base URL
1326
+ REDAKTYN_PASSPHRASE org passphrase (local HMAC only, never sent)
1327
+ REDAKTYN_TOKEN OWNER/ADMIN JWT — an extension token cannot register
1328
+ REDAKTYN_ORG_ID optional; must agree with the token
1329
+
1330
+ ${c.bold}Exit codes${c.reset}
1331
+ 0 plan printed, or every write succeeded
1332
+ 1 one or more writes failed
1333
+ 2 usage, auth or parse error
1334
+ `);
1335
+ }
1336
+
1337
+ /* ═══════════════════════════════════════════════════════════════
1338
+ Main
1339
+ ═══════════════════════════════════════════════════════════════ */
1340
+
1341
+ async function loadInput(args) {
1342
+ const modes = [
1343
+ args.env && "env",
1344
+ args.jsonFile && "json",
1345
+ args.yaml && "yaml",
1346
+ args.stdin && "stdin",
1347
+ ].filter(Boolean);
1348
+
1349
+ if (modes.length === 0) {
1350
+ throw new UsageError("Nothing to import. Pass one of --env, --json, --yaml or --stdin (see --help).");
1351
+ }
1352
+ if (modes.length > 1) {
1353
+ throw new UsageError(`Pick one input mode, got: ${modes.map((m) => "--" + m).join(", ")}`);
1354
+ }
1355
+
1356
+ const mode = modes[0];
1357
+ if (mode === "env") {
1358
+ const parsed = parseDotenv(readFileOrDie(args.env, "--env"));
1359
+ return { ...parsed, mode, defaultSource: basename(args.env) };
1360
+ }
1361
+ if (mode === "json") {
1362
+ const parsed = parseJsonSecrets(readFileOrDie(args.jsonFile, "--json"));
1363
+ return { ...parsed, mode, defaultSource: basename(args.jsonFile) };
1364
+ }
1365
+ if (mode === "yaml") {
1366
+ const parsed = parseFlatYaml(readFileOrDie(args.yaml, "--yaml"));
1367
+ return { ...parsed, mode, defaultSource: basename(args.yaml) };
1368
+ }
1369
+ const text = await readStdin();
1370
+ if (text.trim() === "") throw new UsageError("Nothing arrived on stdin.");
1371
+ const parsed = parseJsonSecrets(text);
1372
+ return { ...parsed, mode, defaultSource: "stdin" };
1373
+ }
1374
+
1375
+ async function resolveCredentials(args) {
1376
+ const serverUrl = args.server || process.env["REDAKTYN_SERVER_URL"] || process.env["REDAKTYN_SERVER"];
1377
+ const token = process.env["REDAKTYN_TOKEN"] || process.env["REDAKTYN_JWT"];
1378
+
1379
+ if (!serverUrl) throw new UsageError("Set REDAKTYN_SERVER_URL (or pass --server).");
1380
+ if (!token) {
1381
+ // Named explicitly: REDAKTYN_DEVICE_TOKEN is deliberately not accepted, so
1382
+ // a shell set up for redaktyn-scan does not silently supply a token that
1383
+ // cannot write.
1384
+ throw new UsageError("Set REDAKTYN_TOKEN to an OWNER or ADMIN login token.");
1385
+ }
1386
+
1387
+ const payload = decodeJwtPayload(token);
1388
+ const roleProblem = tokenRoleProblem(payload);
1389
+ if (roleProblem) throw new UsageError(roleProblem);
1390
+
1391
+ const orgId = args.orgId || payload?.orgId || payload?.org_id || process.env["REDAKTYN_ORG_ID"];
1392
+ if (!orgId) {
1393
+ throw new UsageError("Could not determine the org id from the token. Pass --org-id.");
1394
+ }
1395
+ const envOrgId = args.orgId || process.env["REDAKTYN_ORG_ID"];
1396
+ if (envOrgId && payload?.orgId && envOrgId !== payload.orgId) {
1397
+ throw new UsageError("The org id given does not match the token's org id.");
1398
+ }
1399
+
1400
+ let passphrase = process.env["REDAKTYN_PASSPHRASE"];
1401
+ if (!passphrase) {
1402
+ if (!process.stdin.isTTY) {
1403
+ throw new UsageError(
1404
+ "Set REDAKTYN_PASSPHRASE. It is used locally to derive the HMAC key and is never sent."
1405
+ );
1406
+ }
1407
+ passphrase = await promptHidden("Org passphrase:");
1408
+ }
1409
+ if (!passphrase) throw new UsageError("No passphrase given.");
1410
+
1411
+ return { serverUrl, token, orgId, passphrase };
1412
+ }
1413
+
1414
+ async function main() {
1415
+ const args = parseArgs(process.argv);
1416
+ if (args.help) {
1417
+ printHelp();
1418
+ return 0;
1419
+ }
1420
+
1421
+ const input = await loadInput(args);
1422
+ const source = normalizeSource(args.source ?? input.defaultSource);
1423
+
1424
+ // Last-wins on duplicate keys, matching how a shell sources a dotenv file.
1425
+ const deduped = new Map();
1426
+ for (const e of input.entries) deduped.set(e.key, e);
1427
+ const duplicates = input.entries.length - deduped.size;
1428
+
1429
+ let selected = [...deduped.values()];
1430
+ if (args.only.length > 0) selected = selected.filter((e) => matchesAny(e.key, args.only));
1431
+ if (args.exclude.length > 0) selected = selected.filter((e) => !matchesAny(e.key, args.exclude));
1432
+
1433
+ if (selected.length === 0) {
1434
+ throw new UsageError(
1435
+ `No usable keys found in the input (${input.mode}). ` +
1436
+ (input.problems?.length ? `${input.problems.length} line(s) were skipped — see above.` : "")
1437
+ );
1438
+ }
1439
+
1440
+ const { serverUrl, token, orgId, passphrase } = await resolveCredentials(args);
1441
+
1442
+ const minLength = args.minLength ?? DEFAULT_MIN_LENGTH;
1443
+ const orgKey = await deriveKey(passphrase, orgId);
1444
+ const project = args.project ? await resolveProject(serverUrl, token, args.project) : null;
1445
+ // A project with its own key hashes under HKDF(orgKey, projectId); the default
1446
+ // project carries the org key itself. Filing a digest under the wrong one lists
1447
+ // as ACTIVE and matches nothing, which is why the server also checks.
1448
+ const key = project && !project.usesOrgKey ? deriveProjectKey(orgKey, project.id) : orgKey;
1449
+
1450
+ const candidates = [];
1451
+ const rejected = [];
1452
+ for (const entry of selected) {
1453
+ if (entry.key.length > 200) {
1454
+ // The server caps labels at 200 chars, so this would be a 400 during
1455
+ // apply, after the operator had already approved the plan.
1456
+ rejected.push({ key: entry.key, action: "rejected", reason: "key longer than 200 characters" });
1457
+ continue;
1458
+ }
1459
+ const canonical = canonicalize(entry.value);
1460
+ const verdict = junkVerdict(canonical, { minLength });
1461
+ if (!verdict.ok && !args.allowWeak) {
1462
+ rejected.push({ key: entry.key, action: "rejected", reason: verdict.reason, length: canonical.length });
1463
+ continue;
1464
+ }
1465
+ const digests = computeHmac(entry.value, key);
1466
+ candidates.push({
1467
+ key: entry.key,
1468
+ digests,
1469
+ weak: !verdict.ok,
1470
+ weakReason: verdict.reason,
1471
+ });
1472
+ }
1473
+
1474
+ let existing = await fetchExistingSecrets(serverUrl, token);
1475
+ // Compared only with the chosen project's own secrets: the same label in
1476
+ // another project is a different secret under a different key.
1477
+ if (project) existing = existing.filter((s) => s && s.projectId === project.id);
1478
+ // Against a server deployed before the scan prefilter, sending it would 400
1479
+ // every write, and comparing it would re-plan every row as a rotation on
1480
+ // every run. Planned as that server will store it: without.
1481
+ const prefilterSupported = serverStoresScanPrefilter(existing);
1482
+ if (!prefilterSupported) {
1483
+ for (const cand of candidates) delete cand.digests.scanPrefilter;
1484
+ }
1485
+ const { plan, missing } = diffAgainstServer({ candidates, existing, source });
1486
+
1487
+ const byKey = new Map(candidates.map((cd) => [cd.key, cd]));
1488
+ for (const row of plan) {
1489
+ const cand = byKey.get(row.key);
1490
+ if (cand?.weak && (row.action === "create" || row.action === "rotate")) {
1491
+ row.reason = `${row.reason} (weak: ${cand.weakReason}, allowed by --allow-weak)`;
1492
+ }
1493
+ }
1494
+
1495
+ const rows = [...plan, ...rejected].sort(
1496
+ (a, b) => actionRank(a.action) - actionRank(b.action) || a.key.localeCompare(b.key)
1497
+ );
1498
+
1499
+ // Projected budget counts what the org would look like after the writes, since
1500
+ // that is the number that decides whether pastes still get scanned.
1501
+ const projected = projectAfterImport({ existing, plan, byKey });
1502
+ const budget = scanBudgetForSecrets(projected);
1503
+
1504
+ const writes = plan.filter((r) => r.action === "create" || r.action === "rotate");
1505
+
1506
+ if (args.reportJson) {
1507
+ console.log(
1508
+ JSON.stringify(
1509
+ {
1510
+ source,
1511
+ sourceTag: sourceTag(source),
1512
+ mode: input.mode,
1513
+ shape: input.shape ?? null,
1514
+ dryRun: !args.apply,
1515
+ minLength,
1516
+ allowWeak: args.allowWeak,
1517
+ duplicateKeys: duplicates,
1518
+ inputProblems: input.problems ?? [],
1519
+ counts: tally(rows),
1520
+ plan: rows.map((r) => ({
1521
+ key: r.key,
1522
+ action: r.action,
1523
+ reason: r.reason ?? null,
1524
+ length: r.length ?? null,
1525
+ })),
1526
+ missing,
1527
+ scanBudget: {
1528
+ windowLengths: budget.windowLengths,
1529
+ maxPasteChars: budget.ceiling === Infinity ? null : budget.ceiling,
1530
+ warn: budget.ceiling < PASTE_CEILING_WARN_CHARS,
1531
+ atCharacterCap: budget.atCharacterCap,
1532
+ legacySecrets: budget.legacySecrets,
1533
+ maxPasteCharsIfReenrolled:
1534
+ budget.ceilingIfReenrolled === Infinity ? null : budget.ceilingIfReenrolled,
1535
+ serverStoresScanPrefilter: prefilterSupported,
1536
+ },
1537
+ },
1538
+ null,
1539
+ 2
1540
+ )
1541
+ );
1542
+ if (!args.apply) return 0;
1543
+ } else {
1544
+ for (const p of input.problems ?? []) {
1545
+ const where = p.line !== undefined ? `line ${p.line}` : p.key;
1546
+ console.log(`${c.yellow}!${c.reset} ${where}: ${p.message}`);
1547
+ }
1548
+ if (duplicates > 0) {
1549
+ console.log(`${c.dim}${duplicates} duplicate key(s) in the input; the last value of each was used.${c.reset}`);
1550
+ }
1551
+ if (!prefilterSupported) printOlderServerNotice();
1552
+ printPlan({ rows, missing, source, dryRun: !args.apply, budget, projectName: project ? project.name : null });
1553
+ }
1554
+
1555
+ if (!args.apply) return 0;
1556
+
1557
+ if (writes.length === 0) {
1558
+ console.log(`${c.dim}Nothing to write.${c.reset}`);
1559
+ return 0;
1560
+ }
1561
+
1562
+ if (!args.yes) {
1563
+ const counts = tally(plan);
1564
+ const ok = await confirm(
1565
+ `Register ${counts.create} new and rotate ${counts.rotate} existing fingerprint(s) for this org? [y/N]`
1566
+ );
1567
+ if (!ok) {
1568
+ console.log("Aborted.");
1569
+ return 0;
1570
+ }
1571
+ }
1572
+
1573
+ console.log("");
1574
+ const results = await applyPlan({ rows: writes, byKey, serverUrl, token, source, projectId: project ? project.id : null });
1575
+ const failed = results.filter((r) => !r.ok);
1576
+
1577
+ console.log("");
1578
+ console.log(
1579
+ failed.length === 0
1580
+ ? `${c.green}${results.length} fingerprint(s) registered.${c.reset}`
1581
+ : `${c.red}${failed.length} of ${results.length} failed.${c.reset}`
1582
+ );
1583
+ printBudget(budget);
1584
+ console.log("");
1585
+
1586
+ return failed.length === 0 ? 0 : 1;
1587
+ }
1588
+
1589
+ const ACTION_ORDER = ["create", "rotate", "conflict", "skip", "rejected"];
1590
+ const actionRank = (a) => {
1591
+ const i = ACTION_ORDER.indexOf(a);
1592
+ return i === -1 ? ACTION_ORDER.length : i;
1593
+ };
1594
+
1595
+ /**
1596
+ * The secret list as it would be after the plan is applied, for the budget
1597
+ * projection. Rotations replace lengths rather than adding them, which matters:
1598
+ * re-importing the same file must not look like it doubles the scan cost.
1599
+ */
1600
+ function projectAfterImport({ existing, plan, byKey }) {
1601
+ const out = existing.map((s) => ({ ...s }));
1602
+ const byId = new Map(out.map((s) => [s.id, s]));
1603
+
1604
+ for (const row of plan) {
1605
+ const cand = byKey.get(row.key);
1606
+ if (!cand) continue;
1607
+ const d = cand.digests;
1608
+ if (row.action === "create") {
1609
+ out.push({
1610
+ id: `pending:${row.key}`,
1611
+ label: row.key,
1612
+ hmacHex: d.digest,
1613
+ length: d.length,
1614
+ hmacHexStripped: d.hmacHexStripped,
1615
+ lengthStripped: d.lengthStripped,
1616
+ hmacHexLower: d.hmacHexLower,
1617
+ lengthLower: d.lengthLower,
1618
+ scanPrefilter: d.scanPrefilter,
1619
+ tags: [],
1620
+ });
1621
+ continue;
1622
+ }
1623
+ if (row.action === "rotate") {
1624
+ const target = byId.get(row.id);
1625
+ if (!target) continue;
1626
+ target.hmacHex = d.digest;
1627
+ target.length = d.length;
1628
+ target.hmacHexStripped = d.hmacHexStripped;
1629
+ target.lengthStripped = d.lengthStripped;
1630
+ target.hmacHexLower = d.hmacHexLower;
1631
+ target.lengthLower = d.lengthLower;
1632
+ target.scanPrefilter = d.scanPrefilter;
1633
+ }
1634
+ }
1635
+ return out;
1636
+ }
1637
+
1638
+ const invokedDirectly =
1639
+ !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1640
+
1641
+ if (invokedDirectly) {
1642
+ main()
1643
+ .then((code) => process.exit(code))
1644
+ .catch((err) => {
1645
+ if (err instanceof UsageError) {
1646
+ console.error(`${c.red}${err.message}${c.reset}`);
1647
+ process.exit(2);
1648
+ }
1649
+ console.error(`${c.red}Fatal: ${err instanceof Error ? err.message : err}${c.reset}`);
1650
+ process.exit(1);
1651
+ });
1652
+ }