@zenera/cli 1.1.18 → 1.1.19

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/banner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { dim, note, styled } from "./term.js";
1
+ import { appearance, dim, note, styled } from "./term.js";
2
2
  // ---------------------------------------------------------------------------
3
3
  // The banner
4
4
  //
@@ -65,7 +65,10 @@ function big(word) {
65
65
  /** A wordmark rather than a sentence, so the letters are set apart. */
66
66
  const spaced = (s) => [...s.toUpperCase()].join(' ');
67
67
  const RESET = '\u001b[0m';
68
- const HEAD = { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' };
68
+ const HEADS = {
69
+ dark: { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' },
70
+ light: { face: '\u001b[1;38;5;235m', shade: '\u001b[0;38;5;245m' },
71
+ };
69
72
  /** One per sub-brand, so two of them are told apart before the word is read. */
70
73
  const ACCENTS = {
71
74
  orange: { face: '\u001b[1;38;5;208m', shade: '\u001b[0;38;5;130m' },
@@ -95,17 +98,18 @@ export function bannerLines(text, columns = process.stderr.columns || 80) {
95
98
  // inside a styled string cannot be trimmed away later.
96
99
  const accent = big(text.accent).map((row) => row.trimEnd());
97
100
  const tone = ACCENTS[text.hue ?? 'orange'];
101
+ const ink = HEADS[appearance()];
98
102
  const margin = ' '.repeat(INDENT);
99
103
  const width = INDENT + head[0].length + GAP + Math.max(...accent.map((row) => row.length));
100
104
  if (width > columns) {
101
105
  return [
102
- `${margin}${flat(text.head.toUpperCase(), HEAD)} ${flat(text.accent.toUpperCase(), tone)}`,
106
+ `${margin}${flat(text.head.toUpperCase(), ink)} ${flat(text.accent.toUpperCase(), tone)}`,
103
107
  `${margin}${dim(spaced(text.subtitle))}`,
104
108
  ];
105
109
  }
106
110
  const lines = [];
107
111
  for (let r = 0; r < HEIGHT; r++) {
108
- lines.push(`${margin}${paint(head[r], HEAD)}${' '.repeat(GAP)}${paint(accent[r], tone)}`);
112
+ lines.push(`${margin}${paint(head[r], ink)}${' '.repeat(GAP)}${paint(accent[r], tone)}`);
109
113
  }
110
114
  // Rules on both sides, centred under the art: the subtitle is a caption,
111
115
  // and without them a short line of spaced capitals floats.
package/dist/term.d.ts CHANGED
@@ -20,7 +20,21 @@ export declare class CliError extends Error {
20
20
  export declare const usageError: (m: string, hint?: string) => CliError;
21
21
  export declare const invalidError: (m: string, hint?: string) => CliError;
22
22
  export declare const credentialError: (m: string, hint?: string) => CliError;
23
+ export type Appearance = 'dark' | 'light';
24
+ /** One grey per background. The only 256-colour indices in the CLI. */
25
+ export declare const CHROME: Record<Appearance, number>;
26
+ /** What the terminal looks like, as far as can be known without asking it. */
27
+ export declare function appearance(): Appearance;
23
28
  export declare const bold: (s: string) => string;
29
+ /**
30
+ * One step quieter, which is half of everything this tool prints: paths,
31
+ * counts, hints, the second column of every table.
32
+ *
33
+ * A stated grey rather than SGR 2. `dim` is not a colour, it is a request, and
34
+ * how far it is honoured is the terminal's own business — iTerm2 takes a step,
35
+ * Apple Terminal goes most of the way to the background and takes the quiet
36
+ * half of the output with it.
37
+ */
24
38
  export declare const dim: (s: string) => string;
25
39
  export declare const red: (s: string) => string;
26
40
  export declare const green: (s: string) => string;
package/dist/term.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { createInterface } from 'node:readline/promises';
2
3
  import { styleText } from 'node:util';
3
4
  // ---------------------------------------------------------------------------
@@ -36,11 +37,75 @@ export class CliError extends Error {
36
37
  export const usageError = (m, hint) => new CliError(m, EXIT.usage, hint);
37
38
  export const invalidError = (m, hint) => new CliError(m, EXIT.invalid, hint);
38
39
  export const credentialError = (m, hint) => new CliError(m, EXIT.credentials, hint);
40
+ /** One grey per background. The only 256-colour indices in the CLI. */
41
+ export const CHROME = { dark: 245, light: 242 };
42
+ let sniffed;
43
+ /** What the terminal looks like, as far as can be known without asking it. */
44
+ export function appearance() {
45
+ sniffed ??= fromEnv() ?? fromColorFgBg() ?? fromAppleTerminal() ?? 'dark';
46
+ return sniffed;
47
+ }
48
+ function fromEnv() {
49
+ const v = process.env['ZENERA_THEME']?.trim().toLowerCase();
50
+ return v === 'dark' || v === 'light' ? v : undefined;
51
+ }
52
+ /**
53
+ * `COLORFGBG` is `fg;bg` or `fg;<something>;bg`; the background is the last
54
+ * field. Anything non-numeric (notably `default`) tells us nothing.
55
+ */
56
+ function fromColorFgBg() {
57
+ const parts = process.env['COLORFGBG']?.split(';');
58
+ const bg = parts?.[parts.length - 1];
59
+ if (bg === undefined || !/^\d+$/.test(bg)) {
60
+ return undefined;
61
+ }
62
+ const n = Number(bg);
63
+ return n === 7 || n >= 9 ? 'light' : 'dark';
64
+ }
65
+ /** The shipped Apple Terminal profiles that are paper rather than ink. */
66
+ const APPLE_LIGHT = new Set(['basic', 'man page', 'novel', 'silver aerogel']);
67
+ /**
68
+ * Ask `defaults` which profile Terminal.app opens with. A subprocess during
69
+ * startup is a real cost, so it is paid last, once per process, and only on
70
+ * the one terminal that leaves us nothing else to go on. A custom profile is
71
+ * not in the table and falls through to the guess.
72
+ */
73
+ function fromAppleTerminal() {
74
+ if (process.env['TERM_PROGRAM'] !== 'Apple_Terminal') {
75
+ return undefined;
76
+ }
77
+ try {
78
+ const name = execFileSync('defaults', ['read', 'com.apple.Terminal', 'Default Window Settings'], { encoding: 'utf8', timeout: 300, stdio: ['ignore', 'pipe', 'ignore'] })
79
+ .trim()
80
+ .toLowerCase();
81
+ return APPLE_LIGHT.has(name) ? 'light' : undefined;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
39
87
  // ---------------------------------------------------------------------------
40
88
  // Styling
41
89
  // ---------------------------------------------------------------------------
42
90
  export const bold = (s) => styleText('bold', s);
43
- export const dim = (s) => styleText('dim', s);
91
+ /**
92
+ * One step quieter, which is half of everything this tool prints: paths,
93
+ * counts, hints, the second column of every table.
94
+ *
95
+ * A stated grey rather than SGR 2. `dim` is not a colour, it is a request, and
96
+ * how far it is honoured is the terminal's own business — iTerm2 takes a step,
97
+ * Apple Terminal goes most of the way to the background and takes the quiet
98
+ * half of the output with it.
99
+ */
100
+ export const dim = (s) => {
101
+ if (!styled()) {
102
+ return s;
103
+ }
104
+ const open = `\u001b[38;5;${CHROME[appearance()]}m`;
105
+ // A colour nested inside closes with `39m`, which would drop the grey for
106
+ // whatever follows it.
107
+ return `${open}${s.replaceAll('\u001b[39m', `\u001b[39m${open}`)}\u001b[39m`;
108
+ };
44
109
  export const red = (s) => styleText('red', s);
45
110
  export const green = (s) => styleText('green', s);
46
111
  export const yellow = (s) => styleText('yellow', s);