@olwiba/dx 0.0.24 → 0.0.27

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/cli.js CHANGED
@@ -2353,12 +2353,146 @@ var init_generate_assets = __esm({
2353
2353
  }
2354
2354
  });
2355
2355
 
2356
+ // src/env-check.ts
2357
+ var env_check_exports = {};
2358
+ __export(env_check_exports, {
2359
+ checkEnv: () => checkEnv,
2360
+ formatEnvReport: () => formatEnvReport,
2361
+ parseEnv: () => parseEnv
2362
+ });
2363
+ function parseEnv(source) {
2364
+ const keys = /* @__PURE__ */ new Map();
2365
+ const duplicates = [];
2366
+ const malformed = [];
2367
+ source.split(/\r?\n/).forEach((raw, index) => {
2368
+ const line = raw.trim();
2369
+ if (line === "" || line.startsWith("#")) return;
2370
+ const withoutExport = line.replace(/^export\s+/, "");
2371
+ const eq = withoutExport.indexOf("=");
2372
+ if (eq <= 0) {
2373
+ malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });
2374
+ return;
2375
+ }
2376
+ const key = withoutExport.slice(0, eq).trim();
2377
+ const value = withoutExport.slice(eq + 1).trim();
2378
+ if (keys.has(key)) duplicates.push(key);
2379
+ keys.set(key, value !== "" && value !== '""' && value !== "''");
2380
+ });
2381
+ return { keys, duplicates, malformed };
2382
+ }
2383
+ function findRenameCandidate(unknownKey, knownKeys) {
2384
+ const normalise = (key) => key.replace(/[^A-Z0-9]/gi, "").toUpperCase();
2385
+ const target = normalise(unknownKey);
2386
+ return knownKeys.find((known) => {
2387
+ const candidate = normalise(known);
2388
+ if (candidate === target) return true;
2389
+ return candidate.length >= 6 && (target.endsWith(candidate) || target.startsWith(candidate));
2390
+ });
2391
+ }
2392
+ function checkEnv({
2393
+ example,
2394
+ actual,
2395
+ /** Keys allowed to be absent, e.g. optional credentials. */
2396
+ optional = []
2397
+ }) {
2398
+ const exampleEnv = parseEnv(example);
2399
+ const actualEnv = parseEnv(actual);
2400
+ const optionalSet = new Set(optional);
2401
+ const exampleKeys = [...exampleEnv.keys.keys()];
2402
+ const findings = [];
2403
+ let okCount = 0;
2404
+ for (const key of exampleKeys) {
2405
+ if (!actualEnv.keys.has(key)) {
2406
+ if (!optionalSet.has(key)) findings.push({ kind: "missing", key });
2407
+ continue;
2408
+ }
2409
+ const hasValue = actualEnv.keys.get(key) === true;
2410
+ const exampleHasValue = exampleEnv.keys.get(key) === true;
2411
+ if (!hasValue && exampleHasValue && !optionalSet.has(key)) {
2412
+ findings.push({ kind: "empty", key });
2413
+ continue;
2414
+ }
2415
+ okCount += 1;
2416
+ }
2417
+ for (const key of actualEnv.keys.keys()) {
2418
+ if (exampleEnv.keys.has(key)) continue;
2419
+ const looksLike = findRenameCandidate(key, exampleKeys);
2420
+ findings.push(looksLike ? { kind: "renamed", key, looksLike } : { kind: "unknown", key });
2421
+ }
2422
+ for (const key of actualEnv.duplicates) findings.push({ kind: "duplicate", key });
2423
+ for (const line of actualEnv.malformed) findings.push({ kind: "malformed", ...line });
2424
+ return {
2425
+ findings,
2426
+ okCount,
2427
+ exampleCount: exampleEnv.keys.size,
2428
+ actualCount: actualEnv.keys.size
2429
+ };
2430
+ }
2431
+ function formatEnvReport(result) {
2432
+ const lines = [];
2433
+ lines.push(
2434
+ ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`
2435
+ );
2436
+ if (result.findings.length === 0) {
2437
+ lines.push("\nPASS \u2014 environment matches the example.");
2438
+ return lines.join("\n");
2439
+ }
2440
+ for (const kind of ORDER) {
2441
+ const group = result.findings.filter((finding) => finding.kind === kind);
2442
+ if (group.length === 0) continue;
2443
+ lines.push(`
2444
+ ${LABELS[kind]}:`);
2445
+ for (const finding of group) {
2446
+ if (finding.kind === "renamed") {
2447
+ lines.push(` \u2717 ${finding.key} \u2192 did you mean ${finding.looksLike}?`);
2448
+ } else if (finding.kind === "malformed") {
2449
+ lines.push(` \u2717 line ${finding.line}: ${finding.text}\u2026`);
2450
+ } else {
2451
+ lines.push(` \u2717 ${finding.key}`);
2452
+ }
2453
+ }
2454
+ }
2455
+ const renamed = result.findings.filter((finding) => finding.kind === "renamed").length;
2456
+ if (renamed > 0) {
2457
+ lines.push(
2458
+ `
2459
+ ${renamed} key(s) look renamed. The setting they were meant to carry is unset,
2460
+ which usually means a default is in force that nobody chose.`
2461
+ );
2462
+ }
2463
+ lines.push(`
2464
+ FAIL \u2014 ${result.findings.length} finding(s).`);
2465
+ return lines.join("\n");
2466
+ }
2467
+ var ORDER, LABELS;
2468
+ var init_env_check = __esm({
2469
+ "src/env-check.ts"() {
2470
+ ORDER = [
2471
+ "renamed",
2472
+ "missing",
2473
+ "empty",
2474
+ "malformed",
2475
+ "duplicate",
2476
+ "unknown"
2477
+ ];
2478
+ LABELS = {
2479
+ renamed: "Renamed \u2014 set under a name nothing reads",
2480
+ missing: "Missing",
2481
+ empty: "Present but empty",
2482
+ malformed: "Malformed line",
2483
+ duplicate: "Set more than once",
2484
+ unknown: "Unknown \u2014 not in the example"
2485
+ };
2486
+ }
2487
+ });
2488
+
2356
2489
  // src/skills.ts
2357
2490
  function isSafeSkillSlug(slug) {
2358
2491
  return slug !== "." && slug !== ".." && /^[A-Za-z0-9._-]+$/.test(slug);
2359
2492
  }
2360
2493
 
2361
2494
  // src/cli.ts
2495
+ var BREAK = "\n";
2362
2496
  var DEFAULT_SOURCE = "https://olwiba.com/skills/manifest.json";
2363
2497
  var [command, subcommand] = process.argv.slice(2);
2364
2498
  if (command === "skills" && subcommand === "install") {
@@ -2376,6 +2510,8 @@ if (command === "skills" && subcommand === "install") {
2376
2510
  await runAsciiGif();
2377
2511
  } else if (command === "generate-assets") {
2378
2512
  await runGenerateAssets();
2513
+ } else if (command === "env-check" || command === "env") {
2514
+ process.exitCode = await runEnvCheck();
2379
2515
  } else {
2380
2516
  process.stdout.write(
2381
2517
  "Usage:\n dx skills install [--source <url>] [--target claude|amp] [--all] [--name a,b,c]\n dx worktree cleanup [repo-name-or-path] [--repos-root <path>] [--remote <name>] [--dry-run] [--force] [--no-fetch]\n dx ascii-gif --text <text> --out <file.gif>\n dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>] [--og-component <svg-or-image-path>]\n"
@@ -2597,3 +2733,47 @@ function parseNumberFlag(value) {
2597
2733
  const parsed = Number(value);
2598
2734
  return Number.isFinite(parsed) ? parsed : void 0;
2599
2735
  }
2736
+ async function runEnvCheck() {
2737
+ const { readFileSync: readFileSync3, existsSync: existsSync2 } = await import('fs');
2738
+ const { checkEnv: checkEnv2, formatEnvReport: formatEnvReport2 } = await Promise.resolve().then(() => (init_env_check(), env_check_exports));
2739
+ const flags = parseFlags(process.argv.slice(3));
2740
+ const examplePath = flags.example ?? ".env.example";
2741
+ if (!existsSync2(examplePath)) {
2742
+ process.stderr.write(
2743
+ `No example file at ${examplePath}.${BREAK}It is the schema this compares against; pass --example to point elsewhere.` + BREAK
2744
+ );
2745
+ return 1;
2746
+ }
2747
+ const example = readFileSync3(examplePath, "utf8");
2748
+ let actual;
2749
+ if (flags.file) {
2750
+ if (!existsSync2(flags.file)) {
2751
+ process.stderr.write(`No environment file at ${flags.file}.${BREAK}`);
2752
+ return 1;
2753
+ }
2754
+ actual = readFileSync3(flags.file, "utf8");
2755
+ } else {
2756
+ if (stdin.isTTY) {
2757
+ process.stdout.write(
2758
+ `Paste the environment below, then press ${process.platform === "win32" ? "Ctrl+Z and Enter" : "Ctrl+D"}.${BREAK}Nothing is stored or transmitted.` + BREAK + BREAK
2759
+ );
2760
+ }
2761
+ actual = await readAllStdin();
2762
+ if (actual.trim() === "") {
2763
+ process.stderr.write("Nothing to check. Pass --file <path> or paste an environment." + BREAK);
2764
+ return 1;
2765
+ }
2766
+ }
2767
+ const result = checkEnv2({
2768
+ example,
2769
+ actual,
2770
+ optional: flags.optional ? flags.optional.split(",").map((key) => key.trim()).filter(Boolean) : []
2771
+ });
2772
+ process.stdout.write(`${formatEnvReport2(result)}${BREAK}`);
2773
+ return result.findings.length > 0 ? 1 : 0;
2774
+ }
2775
+ async function readAllStdin() {
2776
+ const chunks = [];
2777
+ for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
2778
+ return Buffer.concat(chunks).toString("utf8");
2779
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Compares a real environment against the example that documents it.
3
+ *
4
+ * Pure string comparison, deliberately. The input is a file full of live
5
+ * credentials, so it is never sent anywhere, never written to disk, and never
6
+ * echoed back — the report names keys and says nothing about values.
7
+ *
8
+ * `.env.example` is the schema. Every repository already has one, which is what
9
+ * makes this usable outside the repositories that have a typed schema, and
10
+ * those are exactly the ones where environments have been rotting unnoticed.
11
+ */
12
+ type EnvFinding = {
13
+ kind: 'missing';
14
+ key: string;
15
+ } | {
16
+ kind: 'unknown';
17
+ key: string;
18
+ } | {
19
+ kind: 'renamed';
20
+ key: string;
21
+ looksLike: string;
22
+ } | {
23
+ kind: 'empty';
24
+ key: string;
25
+ } | {
26
+ kind: 'malformed';
27
+ line: number;
28
+ text: string;
29
+ } | {
30
+ kind: 'duplicate';
31
+ key: string;
32
+ };
33
+ interface EnvCheckResult {
34
+ findings: EnvFinding[];
35
+ /** Keys present in both, non-empty. Counted, never listed. */
36
+ okCount: number;
37
+ exampleCount: number;
38
+ actualCount: number;
39
+ }
40
+ interface ParsedEnv {
41
+ /** Key to whether it has a non-empty value. Values themselves are discarded. */
42
+ keys: Map<string, boolean>;
43
+ duplicates: string[];
44
+ malformed: Array<{
45
+ line: number;
46
+ text: string;
47
+ }>;
48
+ }
49
+ /**
50
+ * Reads keys and discards values immediately.
51
+ *
52
+ * The value never leaves this function, so nothing downstream can leak one by
53
+ * accident — including a future change to the report format.
54
+ */
55
+ declare function parseEnv(source: string): ParsedEnv;
56
+ declare function checkEnv({ example, actual,
57
+ /** Keys allowed to be absent, e.g. optional credentials. */
58
+ optional, }: {
59
+ example: string;
60
+ actual: string;
61
+ optional?: string[];
62
+ }): EnvCheckResult;
63
+ /** Formats a report. Contains key names and counts only, never a value. */
64
+ declare function formatEnvReport(result: EnvCheckResult): string;
65
+
66
+ export { type EnvCheckResult, type EnvFinding, checkEnv, formatEnvReport, parseEnv };
@@ -0,0 +1,125 @@
1
+ // src/env-check.ts
2
+ function parseEnv(source) {
3
+ const keys = /* @__PURE__ */ new Map();
4
+ const duplicates = [];
5
+ const malformed = [];
6
+ source.split(/\r?\n/).forEach((raw, index) => {
7
+ const line = raw.trim();
8
+ if (line === "" || line.startsWith("#")) return;
9
+ const withoutExport = line.replace(/^export\s+/, "");
10
+ const eq = withoutExport.indexOf("=");
11
+ if (eq <= 0) {
12
+ malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });
13
+ return;
14
+ }
15
+ const key = withoutExport.slice(0, eq).trim();
16
+ const value = withoutExport.slice(eq + 1).trim();
17
+ if (keys.has(key)) duplicates.push(key);
18
+ keys.set(key, value !== "" && value !== '""' && value !== "''");
19
+ });
20
+ return { keys, duplicates, malformed };
21
+ }
22
+ function findRenameCandidate(unknownKey, knownKeys) {
23
+ const normalise = (key) => key.replace(/[^A-Z0-9]/gi, "").toUpperCase();
24
+ const target = normalise(unknownKey);
25
+ return knownKeys.find((known) => {
26
+ const candidate = normalise(known);
27
+ if (candidate === target) return true;
28
+ return candidate.length >= 6 && (target.endsWith(candidate) || target.startsWith(candidate));
29
+ });
30
+ }
31
+ function checkEnv({
32
+ example,
33
+ actual,
34
+ /** Keys allowed to be absent, e.g. optional credentials. */
35
+ optional = []
36
+ }) {
37
+ const exampleEnv = parseEnv(example);
38
+ const actualEnv = parseEnv(actual);
39
+ const optionalSet = new Set(optional);
40
+ const exampleKeys = [...exampleEnv.keys.keys()];
41
+ const findings = [];
42
+ let okCount = 0;
43
+ for (const key of exampleKeys) {
44
+ if (!actualEnv.keys.has(key)) {
45
+ if (!optionalSet.has(key)) findings.push({ kind: "missing", key });
46
+ continue;
47
+ }
48
+ const hasValue = actualEnv.keys.get(key) === true;
49
+ const exampleHasValue = exampleEnv.keys.get(key) === true;
50
+ if (!hasValue && exampleHasValue && !optionalSet.has(key)) {
51
+ findings.push({ kind: "empty", key });
52
+ continue;
53
+ }
54
+ okCount += 1;
55
+ }
56
+ for (const key of actualEnv.keys.keys()) {
57
+ if (exampleEnv.keys.has(key)) continue;
58
+ const looksLike = findRenameCandidate(key, exampleKeys);
59
+ findings.push(looksLike ? { kind: "renamed", key, looksLike } : { kind: "unknown", key });
60
+ }
61
+ for (const key of actualEnv.duplicates) findings.push({ kind: "duplicate", key });
62
+ for (const line of actualEnv.malformed) findings.push({ kind: "malformed", ...line });
63
+ return {
64
+ findings,
65
+ okCount,
66
+ exampleCount: exampleEnv.keys.size,
67
+ actualCount: actualEnv.keys.size
68
+ };
69
+ }
70
+ var ORDER = [
71
+ "renamed",
72
+ "missing",
73
+ "empty",
74
+ "malformed",
75
+ "duplicate",
76
+ "unknown"
77
+ ];
78
+ var LABELS = {
79
+ renamed: "Renamed \u2014 set under a name nothing reads",
80
+ missing: "Missing",
81
+ empty: "Present but empty",
82
+ malformed: "Malformed line",
83
+ duplicate: "Set more than once",
84
+ unknown: "Unknown \u2014 not in the example"
85
+ };
86
+ function formatEnvReport(result) {
87
+ const lines = [];
88
+ lines.push(
89
+ ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`
90
+ );
91
+ if (result.findings.length === 0) {
92
+ lines.push("\nPASS \u2014 environment matches the example.");
93
+ return lines.join("\n");
94
+ }
95
+ for (const kind of ORDER) {
96
+ const group = result.findings.filter((finding) => finding.kind === kind);
97
+ if (group.length === 0) continue;
98
+ lines.push(`
99
+ ${LABELS[kind]}:`);
100
+ for (const finding of group) {
101
+ if (finding.kind === "renamed") {
102
+ lines.push(` \u2717 ${finding.key} \u2192 did you mean ${finding.looksLike}?`);
103
+ } else if (finding.kind === "malformed") {
104
+ lines.push(` \u2717 line ${finding.line}: ${finding.text}\u2026`);
105
+ } else {
106
+ lines.push(` \u2717 ${finding.key}`);
107
+ }
108
+ }
109
+ }
110
+ const renamed = result.findings.filter((finding) => finding.kind === "renamed").length;
111
+ if (renamed > 0) {
112
+ lines.push(
113
+ `
114
+ ${renamed} key(s) look renamed. The setting they were meant to carry is unset,
115
+ which usually means a default is in force that nobody chose.`
116
+ );
117
+ }
118
+ lines.push(`
119
+ FAIL \u2014 ${result.findings.length} finding(s).`);
120
+ return lines.join("\n");
121
+ }
122
+
123
+ export { checkEnv, formatEnvReport, parseEnv };
124
+ //# sourceMappingURL=env-check.js.map
125
+ //# sourceMappingURL=env-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/env-check.ts"],"names":[],"mappings":"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","file":"env-check.js","sourcesContent":["/**\n * Compares a real environment against the example that documents it.\n *\n * Pure string comparison, deliberately. The input is a file full of live\n * credentials, so it is never sent anywhere, never written to disk, and never\n * echoed back — the report names keys and says nothing about values.\n *\n * `.env.example` is the schema. Every repository already has one, which is what\n * makes this usable outside the repositories that have a typed schema, and\n * those are exactly the ones where environments have been rotting unnoticed.\n */\n\nexport type EnvFinding =\n | { kind: 'missing'; key: string }\n | { kind: 'unknown'; key: string }\n | { kind: 'renamed'; key: string; looksLike: string }\n | { kind: 'empty'; key: string }\n | { kind: 'malformed'; line: number; text: string }\n | { kind: 'duplicate'; key: string };\n\nexport interface EnvCheckResult {\n findings: EnvFinding[];\n /** Keys present in both, non-empty. Counted, never listed. */\n okCount: number;\n exampleCount: number;\n actualCount: number;\n}\n\ninterface ParsedEnv {\n /** Key to whether it has a non-empty value. Values themselves are discarded. */\n keys: Map<string, boolean>;\n duplicates: string[];\n malformed: Array<{ line: number; text: string }>;\n}\n\n/**\n * Reads keys and discards values immediately.\n *\n * The value never leaves this function, so nothing downstream can leak one by\n * accident — including a future change to the report format.\n */\nexport function parseEnv(source: string): ParsedEnv {\n const keys = new Map<string, boolean>();\n const duplicates: string[] = [];\n const malformed: Array<{ line: number; text: string }> = [];\n\n source.split(/\\r?\\n/).forEach((raw, index) => {\n const line = raw.trim();\n if (line === '' || line.startsWith('#')) return;\n\n // `export FOO=bar` is valid in a shell-sourced file.\n const withoutExport = line.replace(/^export\\s+/, '');\n const eq = withoutExport.indexOf('=');\n\n if (eq <= 0) {\n // Reported by line number without the text where it could hold a value:\n // a line missing its `=` may still be a pasted secret.\n malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });\n return;\n }\n\n const key = withoutExport.slice(0, eq).trim();\n const value = withoutExport.slice(eq + 1).trim();\n\n if (keys.has(key)) duplicates.push(key);\n // A later assignment wins in most loaders, so the last one decides.\n keys.set(key, value !== '' && value !== '\"\"' && value !== \"''\");\n });\n\n return { keys, duplicates, malformed };\n}\n\n/**\n * Whether an unrecognised key looks like a renamed version of a known one.\n *\n * This is the finding that matters. A stale key reads as configured — someone\n * scanning the file sees the name they expect in all but a prefix and stops\n * looking — so reporting it as a generic unknown would not tell anyone that the\n * setting it was meant to carry is unset.\n */\nfunction findRenameCandidate(unknownKey: string, knownKeys: string[]): string | undefined {\n const normalise = (key: string) => key.replace(/[^A-Z0-9]/gi, '').toUpperCase();\n const target = normalise(unknownKey);\n\n return knownKeys.find((known) => {\n const candidate = normalise(known);\n if (candidate === target) return true;\n // A prefix or suffix on an otherwise identical name: VITE_FOO against FOO.\n return (\n candidate.length >= 6 &&\n (target.endsWith(candidate) || target.startsWith(candidate))\n );\n });\n}\n\nexport function checkEnv({\n example,\n actual,\n /** Keys allowed to be absent, e.g. optional credentials. */\n optional = [],\n}: {\n example: string;\n actual: string;\n optional?: string[];\n}): EnvCheckResult {\n const exampleEnv = parseEnv(example);\n const actualEnv = parseEnv(actual);\n const optionalSet = new Set(optional);\n\n const exampleKeys = [...exampleEnv.keys.keys()];\n const findings: EnvFinding[] = [];\n let okCount = 0;\n\n for (const key of exampleKeys) {\n if (!actualEnv.keys.has(key)) {\n if (!optionalSet.has(key)) findings.push({ kind: 'missing', key });\n continue;\n }\n\n const hasValue = actualEnv.keys.get(key) === true;\n const exampleHasValue = exampleEnv.keys.get(key) === true;\n\n // Blank where the example shows a value: the example is demonstrating that\n // something belongs there. Blank in both is a deliberate opt-out.\n if (!hasValue && exampleHasValue && !optionalSet.has(key)) {\n findings.push({ kind: 'empty', key });\n continue;\n }\n\n okCount += 1;\n }\n\n for (const key of actualEnv.keys.keys()) {\n if (exampleEnv.keys.has(key)) continue;\n\n const looksLike = findRenameCandidate(key, exampleKeys);\n findings.push(looksLike ? { kind: 'renamed', key, looksLike } : { kind: 'unknown', key });\n }\n\n for (const key of actualEnv.duplicates) findings.push({ kind: 'duplicate', key });\n for (const line of actualEnv.malformed) findings.push({ kind: 'malformed', ...line });\n\n return {\n findings,\n okCount,\n exampleCount: exampleEnv.keys.size,\n actualCount: actualEnv.keys.size,\n };\n}\n\nconst ORDER: EnvFinding['kind'][] = [\n 'renamed',\n 'missing',\n 'empty',\n 'malformed',\n 'duplicate',\n 'unknown',\n];\n\nconst LABELS: Record<EnvFinding['kind'], string> = {\n renamed: 'Renamed — set under a name nothing reads',\n missing: 'Missing',\n empty: 'Present but empty',\n malformed: 'Malformed line',\n duplicate: 'Set more than once',\n unknown: 'Unknown — not in the example',\n};\n\n/** Formats a report. Contains key names and counts only, never a value. */\nexport function formatEnvReport(result: EnvCheckResult): string {\n const lines: string[] = [];\n lines.push(\n ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`,\n );\n\n if (result.findings.length === 0) {\n lines.push('\\nPASS — environment matches the example.');\n return lines.join('\\n');\n }\n\n for (const kind of ORDER) {\n const group = result.findings.filter((finding) => finding.kind === kind);\n if (group.length === 0) continue;\n\n lines.push(`\\n${LABELS[kind]}:`);\n for (const finding of group) {\n if (finding.kind === 'renamed') {\n lines.push(` ✗ ${finding.key} → did you mean ${finding.looksLike}?`);\n } else if (finding.kind === 'malformed') {\n lines.push(` ✗ line ${finding.line}: ${finding.text}…`);\n } else {\n lines.push(` ✗ ${finding.key}`);\n }\n }\n }\n\n // Renames are called out because they are the ones that read as configured.\n const renamed = result.findings.filter((finding) => finding.kind === 'renamed').length;\n if (renamed > 0) {\n lines.push(\n `\\n${renamed} key(s) look renamed. The setting they were meant to carry is unset,` +\n '\\nwhich usually means a default is in force that nobody chose.',\n );\n }\n\n lines.push(`\\nFAIL — ${result.findings.length} finding(s).`);\n return lines.join('\\n');\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/dx",
3
- "version": "0.0.24",
3
+ "version": "0.0.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -39,6 +39,10 @@
39
39
  "./generate-assets": {
40
40
  "import": "./dist/generate-assets.js",
41
41
  "types": "./dist/generate-assets.d.ts"
42
+ },
43
+ "./env-check": {
44
+ "import": "./dist/env-check.js",
45
+ "types": "./dist/env-check.d.ts"
42
46
  }
43
47
  },
44
48
  "files": [