From 33bd611a0f2679192087a5df498bf3962ed32a10 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:07:59 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20colored=20startup=20ba?= =?UTF-8?q?nner=20from=20the=20whale=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🎨 render the master logo (drydock.png) as a truecolor half-block banner on an interactive terminal, followed by a `drydock v · ` identity line - 🔧 art is baked at build time by scripts/gen-banner.mjs (deterministic), so startup decodes no image — app/banner/art.ts is generated - 🔒 written to stderr; auto-suppressed when not a TTY or NO_COLOR is set, keeping logs and piped output clean - ✅ 100% coverage: app/banner/index.test.ts (11 cases) plus entrypoint assertions that renderBanner runs for both controller and agent modes --- CHANGELOG.md | 4 + app/banner/art.ts | 4 + app/banner/index.test.ts | 118 +++++++++++++++++++++++++++++ app/banner/index.ts | 30 ++++++++ app/index.test.ts | 5 ++ app/index.ts | 2 + scripts/gen-banner.mjs | 155 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+) create mode 100644 app/banner/art.ts create mode 100644 app/banner/index.test.ts create mode 100644 app/banner/index.ts create mode 100644 scripts/gen-banner.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2807d7..16bebb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ scheme restriction) live in `UPGRADE-NOTES.md` and are auto-appended to every 1.4.6+ / 1.5.x release's notes by `scripts/append-upgrade-notes.mjs` (wired into `release-cut.yml`). Update that file — not this comment — when the notes change. --> +### Added + +- **Colored startup banner.** When drydock starts on an interactive terminal it now renders the whale logo as a compact truecolor half-block banner followed by a `drydock v · ` identity line. The art is baked from the master logo (`drydock.png`) at build time by `scripts/gen-banner.mjs`, so startup decodes no image. The banner is written to stderr and suppressed automatically when stdout/stderr is not a TTY or `NO_COLOR` is set, so logs and piped output stay clean. + ### Changed - **Refreshed the drydock whale logo across the app, website, demo, and docs.** A new master render replaces the brand mark everywhere — the in-app logo and favicons, the website/demo favicons, PWA icons, and OpenGraph cards, and the README/docs logos (including the dark-mode variant). All brand assets are now regenerated from a single master (`drydock.png`) via `scripts/regenerate-brand-assets.sh`. Filenames are unchanged, so the Home Assistant `entity_picture` URL contract is preserved. diff --git a/app/banner/art.ts b/app/banner/art.ts new file mode 100644 index 00000000..cb200ddc --- /dev/null +++ b/app/banner/art.ts @@ -0,0 +1,4 @@ +// AUTO-GENERATED by scripts/gen-banner.mjs — do not edit manually. +export const BANNER_ART = + '\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;101;201;252m▄\x1b[0m\x1b[38;2;100;204;254m▄\x1b[38;2;114;207;253;48;2;94;204;255m▀\x1b[38;2;112;208;254;48;2;92;204;255m▀\x1b[38;2;114;210;254;48;2;93;205;255m▀\x1b[38;2;120;212;253;48;2;98;207;255m▀\x1b[0m\x1b[38;2;115;212;253m▄\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;100;195;246m▄\x1b[38;2;96;197;249;48;2;88;196;252m▀\x1b[38;2;87;199;254;48;2;85;200;255m▀\x1b[38;2;87;201;255;48;2;87;201;255m▀\x1b[38;2;87;202;255;48;2;87;202;255m▀\x1b[38;2;88;203;255;48;2;88;203;255m▀\x1b[38;2;89;204;255;48;2;89;203;255m▀\x1b[38;2;90;205;255;48;2;89;204;255m▀\x1b[38;2;103;209;255;48;2;97;207;255m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;140;212;251m▄\x1b[0m\x1b[38;2;123;209;253m▄\x1b[38;2;122;206;251;48;2;104;204;255m▀\x1b[38;2;81;190;249;48;2;87;200;255m▀\x1b[38;2;81;196;254;48;2;87;202;255m▀\x1b[38;2;90;202;255;48;2;93;205;255m▀\x1b[38;2;93;204;255;48;2;96;206;255m▀\x1b[38;2;94;205;255;48;2;97;207;255m▀\x1b[38;2;94;205;255;48;2;98;207;255m▀\x1b[38;2;95;206;255;48;2;99;208;255m▀\x1b[38;2;97;207;255;48;2;101;209;255m▀\x1b[38;2;107;211;255;48;2;105;210;255m▀\x1b[38;2;136;220;255;48;2;108;211;255m▀\x1b[38;2;152;225;255;48;2;110;212;255m▀\x1b[38;2;153;226;255;48;2;114;213;255m▀\x1b[38;2;154;226;254;48;2;118;214;255m▀\x1b[38;2;157;227;254;48;2;123;216;255m▀\x1b[38;2;158;227;254;48;2;127;218;255m▀\x1b[38;2;160;227;254;48;2;132;220;255m▀\x1b[0m\x1b[38;2;138;221;255m▄\x1b[0m\x1b[38;2;144;223;255m▄\x1b[0m\x1b[38;2;149;224;255m▄\x1b[0m\x1b[38;2;154;225;254m▄\x1b[0m\x1b[38;2;157;226;254m▄\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;134;207;246m▄\x1b[38;2;147;212;248;48;2;97;195;249m▀\x1b[38;2;117;203;250;48;2;77;191;252m▀\x1b[38;2;93;197;253;48;2;76;192;254m▀\x1b[38;2;82;196;255;48;2;79;196;255m▀\x1b[38;2;81;197;255;48;2;82;198;255m▀\x1b[38;2;83;199;255;48;2;86;201;255m▀\x1b[38;2;87;202;255;48;2;90;204;255m▀\x1b[38;2;89;203;255;48;2;94;206;255m▀\x1b[38;2;92;205;255;48;2;99;208;255m▀\x1b[38;2;96;207;255;48;2;103;211;255m▀\x1b[38;2;99;209;255;48;2;108;213;255m▀\x1b[38;2;101;210;255;48;2;113;215;255m▀\x1b[38;2;103;211;255;48;2;115;216;255m▀\x1b[38;2;105;212;255;48;2;117;217;255m▀\x1b[38;2;106;212;255;48;2;118;217;255m▀\x1b[38;2;107;213;255;48;2;118;217;255m▀\x1b[38;2;108;213;255;48;2;116;216;255m▀\x1b[38;2;108;213;255;48;2;116;216;255m▀\x1b[38;2;109;213;255;48;2;116;216;255m▀\x1b[38;2;110;213;255;48;2;116;216;255m▀\x1b[38;2;111;213;255;48;2;116;216;255m▀\x1b[38;2;113;214;255;48;2;117;216;255m▀\x1b[38;2;115;214;255;48;2;118;217;255m▀\x1b[38;2;119;215;255;48;2;119;217;255m▀\x1b[38;2;125;217;255;48;2;121;217;255m▀\x1b[38;2;132;219;255;48;2;122;217;255m▀\x1b[38;2;141;222;255;48;2;124;218;255m▀\x1b[38;2;149;224;255;48;2;128;219;255m▀\x1b[38;2;156;225;254;48;2;135;220;255m▀\x1b[0m\x1b[38;2;145;223;255m▄\x1b[0m\x1b[38;2;154;225;254m▄\x1b[0m\x1b[38;2;158;226;254m▄\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;98;201;253m▄\x1b[0m\x1b[38;2;116;209;255m▄\x1b[38;2;125;212;254;48;2;134;218;255m▀\x1b[38;2;141;220;255;48;2;105;209;255m▀\x1b[38;2;128;215;254;48;2;110;210;254m▀\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;137;205;242m▄\x1b[38;2;132;204;244;48;2;89;189;244m▀\x1b[38;2;87;191;247;48;2;71;185;248m▀\x1b[38;2;73;188;250;48;2;73;188;251m▀\x1b[38;2;74;191;253;48;2;75;192;254m▀\x1b[38;2;77;194;255;48;2;78;194;255m▀\x1b[38;2;80;197;255;48;2;81;197;255m▀\x1b[38;2;84;199;255;48;2;84;200;255m▀\x1b[38;2;87;202;255;48;2;88;202;255m▀\x1b[38;2;92;204;255;48;2;93;205;255m▀\x1b[38;2;97;207;255;48;2;99;208;255m▀\x1b[38;2;103;210;255;48;2;108;212;255m▀\x1b[38;2;111;214;255;48;2;120;216;255m▀\x1b[38;2;122;217;255;48;2;146;224;255m▀\x1b[38;2;136;222;255;48;2;179;234;255m▀\x1b[38;2;152;227;255;48;2;205;242;255m▀\x1b[38;2;164;230;255;48;2;212;244;255m▀\x1b[38;2;170;232;255;48;2;206;242;255m▀\x1b[38;2;167;231;255;48;2;194;239;255m▀\x1b[38;2;160;229;255;48;2;184;236;255m▀\x1b[38;2;149;226;255;48;2;174;233;255m▀\x1b[38;2;138;223;255;48;2;162;230;255m▀\x1b[38;2;128;220;255;48;2;150;226;255m▀\x1b[38;2;123;219;255;48;2;140;224;255m▀\x1b[38;2;121;218;255;48;2;134;222;255m▀\x1b[38;2;120;218;255;48;2;131;221;255m▀\x1b[38;2;121;218;255;48;2;129;221;255m▀\x1b[38;2;122;218;255;48;2;128;220;255m▀\x1b[38;2;123;218;255;48;2;127;220;255m▀\x1b[38;2;124;219;255;48;2;127;220;255m▀\x1b[38;2;127;220;255;48;2;128;221;255m▀\x1b[38;2;131;221;255;48;2;131;221;255m▀\x1b[38;2;138;222;255;48;2;135;222;255m▀\x1b[38;2;149;225;255;48;2;143;224;255m▀\x1b[38;2;162;229;255;48;2;162;229;255m▀\x1b[38;2;171;231;255;48;2;186;236;255m▀\x1b[0m\x1b[38;2;177;233;255m▄\x1b[0m\x1b[38;2;107;206;254m▄\x1b[38;2;96;199;252;48;2;89;198;254m▀\x1b[38;2;104;205;254;48;2;111;210;255m▀\x1b[38;2;129;216;255;48;2;99;207;255m▀\x1b[38;2;99;206;255;48;2;90;203;255m▀\x1b[38;2;93;204;255;48;2;90;203;255m▀\x1b[38;2;106;208;254;48;2;103;207;254m▀\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;131;201;241m▄\x1b[38;2;104;193;242;48;2;79;185;243m▀\x1b[38;2;71;184;245;48;2;70;184;246m▀\x1b[38;2;72;187;249;48;2;74;188;249m▀\x1b[38;2;75;190;252;48;2;76;191;252m▀\x1b[38;2;76;193;254;48;2;77;193;254m▀\x1b[38;2;78;195;255;48;2;79;195;255m▀\x1b[38;2;81;198;255;48;2;80;197;255m▀\x1b[38;2;84;200;255;48;2;83;199;255m▀\x1b[38;2;88;202;255;48;2;88;203;255m▀\x1b[38;2;93;205;255;48;2;94;206;255m▀\x1b[38;2;99;208;255;48;2;99;208;255m▀\x1b[38;2;107;211;255;48;2;103;210;255m▀\x1b[38;2;118;216;255;48;2;104;211;255m▀\x1b[38;2;137;221;255;48;2;106;211;255m▀\x1b[38;2;153;227;255;48;2;107;212;255m▀\x1b[38;2;154;227;255;48;2;106;212;255m▀\x1b[38;2;146;225;255;48;2;104;211;255m▀\x1b[38;2;135;222;255;48;2;102;211;255m▀\x1b[38;2;126;220;255;48;2;101;211;255m▀\x1b[38;2;120;218;255;48;2;100;210;255m▀\x1b[38;2;117;217;255;48;2;99;210;255m▀\x1b[38;2;116;217;255;48;2;99;210;255m▀\x1b[38;2;116;216;255;48;2;99;210;255m▀\x1b[38;2;116;216;255;48;2;99;210;255m▀\x1b[38;2;116;216;255;48;2;99;210;255m▀\x1b[38;2;117;217;255;48;2;100;210;255m▀\x1b[38;2;118;217;255;48;2;100;210;255m▀\x1b[38;2;119;217;255;48;2;100;210;255m▀\x1b[38;2;120;218;255;48;2;101;210;255m▀\x1b[38;2;122;218;255;48;2;101;210;255m▀\x1b[38;2;126;219;255;48;2;101;210;255m▀\x1b[38;2;133;221;255;48;2;102;210;255m▀\x1b[38;2;141;223;255;48;2;103;211;255m▀\x1b[38;2;145;224;255;48;2;105;211;255m▀\x1b[38;2;143;224;255;48;2;103;210;255m▀\x1b[38;2;159;228;255;48;2;102;210;255m▀\x1b[38;2;162;229;255;48;2;100;209;255m▀\x1b[38;2;111;211;255;48;2;104;210;255m▀\x1b[38;2;103;207;255;48;2;112;212;255m▀\x1b[38;2;97;206;255;48;2;93;204;255m▀\x1b[38;2;90;204;255;48;2;87;202;255m▀\x1b[38;2;89;203;255;48;2;86;201;255m▀\x1b[38;2;89;202;255;48;2;88;202;255m▀\x1b[0m\x1b[38;2;102;206;254m▀\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[38;2;107;194;241;48;2;89;187;241m▀\x1b[38;2;69;182;243;48;2;68;182;244m▀\x1b[38;2;73;186;247;48;2;75;187;248m▀\x1b[38;2;77;190;250;48;2;85;193;251m▀\x1b[38;2;83;194;253;48;2;100;201;253m▀\x1b[38;2;80;195;254;48;2;86;197;255m▀\x1b[38;2;79;195;255;48;2;79;195;255m▀\x1b[38;2;80;197;255;48;2;83;197;255m▀\x1b[38;2;86;200;255;48;2;100;195;247m▀\x1b[38;2;92;200;253;48;2;164;210;241m▀\x1b[38;2;106;201;250;48;2;206;225;242m▀\x1b[38;2;114;202;249;48;2;218;232;244m▀\x1b[38;2;109;203;251;48;2;213;232;246m▀\x1b[38;2;102;206;254;48;2;155;212;245m▀\x1b[38;2;100;208;255;48;2;104;203;251m▀\x1b[38;2;94;207;255;48;2;96;206;255m▀\x1b[38;2;92;206;255;48;2;88;204;255m▀\x1b[38;2;91;206;255;48;2;87;203;255m▀\x1b[38;2;91;206;255;48;2;86;203;255m▀\x1b[38;2;90;206;255;48;2;85;203;255m▀\x1b[38;2;90;206;255;48;2;85;203;255m▀\x1b[38;2;90;206;255;48;2;85;203;255m▀\x1b[38;2;90;206;255;48;2;85;203;255m▀\x1b[38;2;90;205;255;48;2;85;203;255m▀\x1b[38;2;90;206;255;48;2;86;203;255m▀\x1b[38;2;90;206;255;48;2;86;203;255m▀\x1b[38;2;91;205;255;48;2;86;203;255m▀\x1b[38;2;91;205;255;48;2;86;202;255m▀\x1b[38;2;91;205;255;48;2;86;203;255m▀\x1b[38;2;91;205;255;48;2;86;202;255m▀\x1b[38;2;90;205;255;48;2;85;202;255m▀\x1b[38;2;90;205;255;48;2;85;202;255m▀\x1b[38;2;90;205;255;48;2;85;202;255m▀\x1b[38;2;90;205;255;48;2;85;202;255m▀\x1b[38;2;89;204;255;48;2;85;202;255m▀\x1b[38;2;89;204;255;48;2;85;202;255m▀\x1b[38;2;89;204;255;48;2;85;202;255m▀\x1b[38;2;90;204;255;48;2;89;203;255m▀\x1b[38;2;99;208;255;48;2;100;208;255m▀\x1b[38;2;97;206;255;48;2;95;205;255m▀\x1b[38;2;87;201;255;48;2;90;203;255m▀\x1b[38;2;86;202;255;48;2;91;204;255m▀\x1b[38;2;86;201;255;48;2;92;203;255m▀\x1b[38;2;90;202;255;48;2;94;204;255m▀\x1b[0m\x1b[38;2;110;209;254m▄\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[38;2;130;200;240;48;2;119;196;239m▀\x1b[38;2;78;183;241;48;2;72;181;241m▀\x1b[38;2;70;183;245;48;2;71;183;245m▀\x1b[38;2;75;187;248;48;2;74;187;248m▀\x1b[38;2;81;191;251;48;2;76;189;250m▀\x1b[38;2;87;195;253;48;2;77;191;252m▀\x1b[38;2;80;194;255;48;2;76;192;254m▀\x1b[38;2;80;195;255;48;2;80;193;253m▀\x1b[38;2;95;193;247;48;2;136;197;237m▀\x1b[38;2;181;213;237;48;2;222;229;237m▀\x1b[38;2;228;233;240;48;2;229;232;236m▀\x1b[38;2;233;236;241;48;2;222;224;228m▀\x1b[38;2;235;238;242;48;2;228;229;233m▀\x1b[38;2;241;243;246;48;2;220;223;227m▀\x1b[38;2;233;238;244;48;2;234;237;241m▀\x1b[38;2;158;210;242;48;2;213;227;239m▀\x1b[38;2;98;203;253;48;2;112;200;246m▀\x1b[38;2;87;203;255;48;2;87;202;255m▀\x1b[38;2;83;201;255;48;2;80;200;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;79;199;255m▀\x1b[38;2;82;201;255;48;2;79;199;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;79;199;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;80;199;255m▀\x1b[38;2;82;201;255;48;2;79;199;255m▀\x1b[38;2;82;201;255;48;2;79;199;255m▀\x1b[38;2;82;200;255;48;2;79;198;255m▀\x1b[38;2;82;200;255;48;2;78;198;255m▀\x1b[38;2;82;200;255;48;2;78;198;255m▀\x1b[38;2;82;200;255;48;2;78;198;255m▀\x1b[38;2;81;200;255;48;2;77;197;255m▀\x1b[38;2;81;199;255;48;2;77;197;255m▀\x1b[38;2;81;199;255;48;2;77;197;255m▀\x1b[38;2;82;200;255;48;2;80;198;255m▀\x1b[38;2;88;202;255;48;2;84;200;255m▀\x1b[38;2;96;205;255;48;2;88;199;254m▀\x1b[38;2;85;199;255;48;2;65;182;248m▀\x1b[38;2;81;197;255;48;2;71;189;253m▀\x1b[38;2;84;200;255;48;2;79;196;255m▀\x1b[38;2;87;201;255;48;2;83;198;255m▀\x1b[38;2;89;202;255;48;2;84;199;255m▀\x1b[38;2;103;206;255;48;2;96;203;255m▀\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[38;2;112;193;239;48;2;105;191;240m▀\x1b[38;2;69;180;241;48;2;66;179;241m▀\x1b[38;2;71;183;245;48;2;71;183;245m▀\x1b[38;2;73;186;248;48;2;72;186;248m▀\x1b[38;2;74;188;250;48;2;73;188;250m▀\x1b[38;2;75;190;252;48;2;74;189;252m▀\x1b[38;2;75;191;254;48;2;74;190;253m▀\x1b[38;2;82;190;249;48;2;80;189;249m▀\x1b[38;2;165;205;234;48;2;161;203;233m▀\x1b[38;2;226;230;237;48;2;225;229;237m▀\x1b[38;2;229;231;235;48;2;228;230;235m▀\x1b[38;2;172;175;182;48;2;169;172;180m▀\x1b[38;2;143;146;155;48;2;133;137;147m▀\x1b[38;2;198;200;206;48;2;193;196;203m▀\x1b[38;2;235;237;241;48;2;233;235;240m▀\x1b[38;2;225;231;239;48;2;221;229;238m▀\x1b[38;2;126;200;243;48;2;120;197;242m▀\x1b[38;2;86;200;255;48;2;84;199;255m▀\x1b[38;2;77;198;255;48;2;76;197;255m▀\x1b[38;2;78;198;255;48;2;76;197;255m▀\x1b[38;2;77;198;255;48;2;76;197;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;198;255;48;2;75;196;255m▀\x1b[38;2;77;197;255;48;2;75;196;255m▀\x1b[38;2;76;197;255;48;2;74;195;255m▀\x1b[38;2;76;197;255;48;2;73;195;255m▀\x1b[38;2;76;196;255;48;2;73;194;255m▀\x1b[38;2;75;196;255;48;2;72;194;255m▀\x1b[38;2;75;196;255;48;2;71;194;255m▀\x1b[38;2;74;195;255;48;2;71;193;255m▀\x1b[38;2;73;195;255;48;2;70;192;255m▀\x1b[38;2;73;194;255;48;2;70;192;255m▀\x1b[38;2;74;195;255;48;2;69;191;255m▀\x1b[38;2;75;195;255;48;2;73;192;255m▀\x1b[38;2;81;197;255;48;2;91;198;255m▀\x1b[0m\x1b[38;2;88;196;253m▀\x1b[0m\x1b[38;2;62;168;234m▀\x1b[0m\x1b[38;2;60;174;242m▀\x1b[38;2;66;185;252;48;2;64;173;240m▀\x1b[38;2;75;193;255;48;2;66;181;247m▀\x1b[38;2;79;196;255;48;2;72;188;252m▀\x1b[38;2;90;200;255;48;2;86;195;253m▀\x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;99;190;241m▄\x1b[38;2;89;187;241;48;2;79;184;242m▀\x1b[38;2;68;180;242;48;2;80;186;245m▀\x1b[38;2;72;184;246;48;2;75;186;247m▀\x1b[38;2;72;186;249;48;2;72;186;249m▀\x1b[38;2;72;187;250;48;2;71;187;251m▀\x1b[38;2;73;189;252;48;2;72;188;252m▀\x1b[38;2;73;189;253;48;2;72;189;253m▀\x1b[38;2;76;190;252;48;2;73;189;253m▀\x1b[38;2;126;192;234;48;2;86;187;245m▀\x1b[38;2;213;223;235;48;2;153;198;230m▀\x1b[38;2;223;227;234;48;2;210;222;234m▀\x1b[38;2;204;208;214;48;2;223;229;237m▀\x1b[38;2;221;224;229;48;2;224;230;237m▀\x1b[38;2;213;216;223;48;2;222;228;237m▀\x1b[38;2;229;233;238;48;2;194;215;233m▀\x1b[38;2;189;215;235;48;2;111;191;239m▀\x1b[38;2;96;194;247;48;2;83;197;254m▀\x1b[38;2;80;198;255;48;2;75;196;255m▀\x1b[38;2;75;196;255;48;2;74;195;255m▀\x1b[38;2;75;196;255;48;2;73;195;255m▀\x1b[38;2;74;196;255;48;2;73;195;255m▀\x1b[38;2;74;195;255;48;2;73;195;255m▀\x1b[38;2;74;195;255;48;2;73;195;255m▀\x1b[38;2;73;195;255;48;2;72;195;255m▀\x1b[38;2;74;195;255;48;2;72;194;255m▀\x1b[38;2;73;195;255;48;2;71;194;255m▀\x1b[38;2;73;195;255;48;2;71;194;255m▀\x1b[38;2;72;194;255;48;2;70;193;255m▀\x1b[38;2;71;194;255;48;2;69;192;255m▀\x1b[38;2;71;193;255;48;2;68;191;255m▀\x1b[38;2;70;193;255;48;2;67;190;255m▀\x1b[38;2;69;192;255;48;2;65;189;255m▀\x1b[38;2;68;191;255;48;2;63;188;255m▀\x1b[38;2;67;190;255;48;2;64;188;255m▀\x1b[38;2;66;190;255;48;2;67;189;255m▀\x1b[38;2;66;189;255;48;2;71;189;254m▀\x1b[38;2;72;191;255;48;2;80;190;252m▀\x1b[38;2;85;194;254;48;2;98;195;250m▀\x1b[0m\x1b[38;2;105;201;252m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;90;186;241m▀\x1b[38;2;84;186;244;48;2;78;175;235m▀\x1b[38;2;82;188;247;48;2;76;180;241m▀\x1b[38;2;78;187;248;48;2;76;184;246m▀\x1b[38;2;73;186;250;48;2;71;185;249m▀\x1b[38;2;71;186;251;48;2;69;185;251m▀\x1b[38;2;70;187;252;48;2;69;186;252m▀\x1b[38;2;70;188;253;48;2;68;186;253m▀\x1b[38;2;70;188;253;48;2;68;187;254m▀\x1b[38;2;73;190;254;48;2;68;187;254m▀\x1b[38;2;86;187;246;48;2;70;189;255m▀\x1b[38;2;122;189;234;48;2;75;191;254m▀\x1b[38;2;154;198;230;48;2;80;190;251m▀\x1b[38;2;159;199;230;48;2;80;190;250m▀\x1b[38;2;137;193;232;48;2;77;191;252m▀\x1b[38;2;94;186;241;48;2;75;193;255m▀\x1b[38;2;78;193;253;48;2;72;193;255m▀\x1b[38;2;75;195;255;48;2;71;193;255m▀\x1b[38;2;72;194;255;48;2;71;193;255m▀\x1b[38;2;72;194;255;48;2;71;193;255m▀\x1b[38;2;72;194;255;48;2;71;193;255m▀\x1b[38;2;72;195;255;48;2;74;195;255m▀\x1b[38;2;73;195;255;48;2;84;199;255m▀\x1b[38;2;74;196;255;48;2;95;203;255m▀\x1b[38;2;76;196;255;48;2;98;205;255m▀\x1b[38;2;76;196;255;48;2;97;205;254m▀\x1b[38;2;73;194;255;48;2;94;204;255m▀\x1b[38;2;70;193;255;48;2;93;203;255m▀\x1b[38;2;67;191;255;48;2;85;199;255m▀\x1b[38;2;64;189;255;48;2;72;192;255m▀\x1b[38;2;63;188;255;48;2;70;190;255m▀\x1b[38;2;62;187;255;48;2;70;189;255m▀\x1b[38;2;63;188;255;48;2;66;186;253m▀\x1b[38;2;67;188;255;48;2;64;182;251m▀\x1b[38;2;68;187;254;48;2;70;181;247m▀\x1b[38;2;69;185;252;48;2;85;185;245m▀\x1b[0m\x1b[38;2;80;186;249m▀\x1b[0m\x1b[38;2;99;192;246m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[38;2;47;139;205;48;2;32;119;189m▀\x1b[38;2;63;163;227;48;2;38;124;193m▀\x1b[38;2;68;181;247;48;2;60;174;241m▀\x1b[38;2;66;183;250;48;2;62;180;249m▀\x1b[38;2;65;184;252;48;2;61;181;252m▀\x1b[38;2;65;184;253;48;2;60;182;253m▀\x1b[38;2;65;185;254;48;2;61;183;254m▀\x1b[38;2;65;186;255;48;2;61;183;255m▀\x1b[38;2;65;186;255;48;2;61;184;255m▀\x1b[38;2;65;187;255;48;2;61;185;255m▀\x1b[38;2;67;189;255;48;2;62;186;255m▀\x1b[38;2;68;190;255;48;2;62;186;255m▀\x1b[38;2;68;190;255;48;2;63;187;255m▀\x1b[38;2;67;190;255;48;2;62;187;255m▀\x1b[38;2;67;190;255;48;2;62;187;255m▀\x1b[38;2;67;190;255;48;2;62;187;255m▀\x1b[38;2;67;190;255;48;2;61;187;255m▀\x1b[38;2;67;190;255;48;2;59;185;255m▀\x1b[38;2;69;191;255;48;2;66;188;255m▀\x1b[38;2;81;197;255;48;2;83;196;255m▀\x1b[38;2;93;203;255;48;2;100;205;255m▀\x1b[38;2;97;206;255;48;2;103;207;255m▀\x1b[38;2;100;208;255;48;2;108;210;255m▀\x1b[38;2;102;209;255;48;2;120;213;255m▀\x1b[38;2;100;208;255;48;2;119;213;255m▀\x1b[38;2;97;208;255;48;2;106;209;255m▀\x1b[38;2;95;206;255;48;2;103;207;255m▀\x1b[38;2;90;202;255;48;2;100;206;255m▀\x1b[38;2;74;192;254;48;2;94;201;254m▀\x1b[38;2;63;179;248;48;2;83;184;243m▀\x1b[0m\x1b[38;2;66;177;244m▀\x1b[0m\x1b[38;2;78;181;243m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;83;174;232m▀\x1b[38;2;66;168;231;48;2;86;180;236m▀\x1b[38;2;62;174;241;48;2;72;175;238m▀\x1b[38;2;60;177;246;48;2;66;175;241m▀\x1b[38;2;57;178;250;48;2;63;177;245m▀\x1b[38;2;55;179;253;48;2;60;178;249m▀\x1b[38;2;55;179;253;48;2;58;178;251m▀\x1b[38;2;55;180;254;48;2;56;179;253m▀\x1b[38;2;56;181;255;48;2;56;179;253m▀\x1b[38;2;56;182;255;48;2;56;180;254m▀\x1b[38;2;56;182;255;48;2;56;181;255m▀\x1b[38;2;56;183;255;48;2;56;181;255m▀\x1b[38;2;56;183;255;48;2;57;181;255m▀\x1b[38;2;56;183;255;48;2;57;182;255m▀\x1b[38;2;55;183;255;48;2;59;182;254m▀\x1b[38;2;55;182;255;48;2;61;183;254m▀\x1b[38;2;54;182;255;48;2;61;183;253m▀\x1b[38;2;54;182;255;48;2;60;180;252m▀\x1b[38;2;59;182;254;48;2;56;174;247m▀\x1b[38;2;66;184;253;48;2;54;166;239m▀\x1b[38;2;73;188;254;48;2;58;166;236m▀\x1b[38;2;81;193;254;48;2;62;170;239m▀\x1b[38;2;85;196;254;48;2;66;176;244m▀\x1b[38;2;87;197;254;48;2;67;179;246m▀\x1b[38;2;87;197;254;48;2;68;180;246m▀\x1b[38;2;84;195;253;48;2;71;180;245m▀\x1b[38;2;82;192;252;48;2;76;181;243m▀\x1b[0m\x1b[38;2;83;191;251m▀\x1b[0m\x1b[38;2;92;194;250m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\n\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m\x1b[38;2;74;175;238m▀\x1b[0m\x1b[38;2;69;175;241m▀\x1b[0m\x1b[38;2;66;175;242m▀\x1b[0m\x1b[38;2;64;176;245m▀\x1b[0m\x1b[38;2;63;176;246m▀\x1b[0m\x1b[38;2;63;177;248m▀\x1b[38;2;62;178;249;48;2;77;177;242m▀\x1b[0m\x1b[38;2;62;178;249m▀\x1b[0m\x1b[38;2;62;178;249m▀\x1b[0m\x1b[38;2;62;177;249m▀\x1b[0m\x1b[38;2;62;177;248m▀\x1b[0m\x1b[38;2;64;177;248m▀\x1b[0m\x1b[38;2;66;176;247m▀\x1b[0m\x1b[38;2;68;174;244m▀\x1b[0m\x1b[38;2;68;171;240m▀\x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m \x1b[0m'; +export const BANNER_WIDTH = 50; diff --git a/app/banner/index.test.ts b/app/banner/index.test.ts new file mode 100644 index 00000000..970cecd3 --- /dev/null +++ b/app/banner/index.test.ts @@ -0,0 +1,118 @@ +import { renderBanner } from './index.js'; + +vi.mock('../configuration/index.js', () => ({ + getVersion: () => '1.6.0-test', + getDnsMode: () => 'ipv4first', + ddEnvVars: {}, +})); + +function makeStream( + isTTY: boolean, + columns?: number, +): NodeJS.WriteStream & { write: ReturnType } { + return { isTTY, columns, write: vi.fn() } as unknown as NodeJS.WriteStream & { + write: ReturnType; + }; +} + +describe('renderBanner', () => { + test('writes art with centering padding when TTY and columns > BANNER_WIDTH', () => { + const stream = makeStream(true, 200); + renderBanner({ mode: 'controller', stream, env: {} }); + + expect(stream.write).toHaveBeenCalledOnce(); + const output = stream.write.mock.calls[0][0] as string; + // Should contain the version and mode + expect(output).toContain('drydock v1.6.0-test · controller'); + // Should have centering padding (200 - 50) / 2 = 75 spaces + const identityLine = output.split('\n').at(-2) ?? ''; + expect(identityLine.startsWith(' ')).toBe(true); + }); + + test('writes art without padding when columns equals BANNER_WIDTH', () => { + const stream = makeStream(true, 50); + renderBanner({ mode: 'agent', stream, env: {} }); + + expect(stream.write).toHaveBeenCalledOnce(); + const output = stream.write.mock.calls[0][0] as string; + expect(output).toContain('drydock v1.6.0-test · agent'); + // No centering: columns not > BANNER_WIDTH + const identityLine = output.split('\n').at(-2) ?? ''; + expect(identityLine.startsWith('\x1b')).toBe(true); + }); + + test('writes art without padding when columns is undefined', () => { + const stream = makeStream(true, undefined); + renderBanner({ mode: 'controller', stream, env: {} }); + + expect(stream.write).toHaveBeenCalledOnce(); + const output = stream.write.mock.calls[0][0] as string; + expect(output).toContain('drydock v1.6.0-test · controller'); + }); + + test('does not write when stream is not a TTY', () => { + const stream = makeStream(false, 200); + renderBanner({ mode: 'controller', stream, env: {} }); + expect(stream.write).not.toHaveBeenCalled(); + }); + + test('does not write when NO_COLOR is set to a non-empty string', () => { + const stream = makeStream(true, 200); + renderBanner({ mode: 'controller', stream, env: { NO_COLOR: '1' } }); + expect(stream.write).not.toHaveBeenCalled(); + }); + + test('does not write when NO_COLOR is empty string (env var present but blank)', () => { + // NO_COLOR='' means set but blank — should still render + const stream = makeStream(true, 200); + renderBanner({ mode: 'controller', stream, env: { NO_COLOR: '' } }); + expect(stream.write).toHaveBeenCalledOnce(); + }); + + test('identity line contains version and mode for agent', () => { + const stream = makeStream(true, 50); + renderBanner({ mode: 'agent', stream, env: {} }); + + const output = stream.write.mock.calls[0][0] as string; + expect(output).toContain('drydock v1.6.0-test · agent'); + }); + + test('identity line contains version and mode for controller', () => { + const stream = makeStream(true, 50); + renderBanner({ mode: 'controller', stream, env: {} }); + + const output = stream.write.mock.calls[0][0] as string; + expect(output).toContain('drydock v1.6.0-test · controller'); + }); + + test('output ends with a trailing newline', () => { + const stream = makeStream(true, 50); + renderBanner({ mode: 'controller', stream, env: {} }); + + const output = stream.write.mock.calls[0][0] as string; + expect(output.endsWith('\n')).toBe(true); + }); + + test('falls back to process.stderr when stream is omitted', () => { + // process.stderr is not a TTY in the test environment — this exercises + // the `stream ?? process.stderr` branch without producing output. + const originalIsTTY = process.stderr.isTTY; + Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: false }); + try { + // Should not throw; renderBanner should be a no-op (not a TTY) + renderBanner({ mode: 'controller', env: {} }); + } finally { + Object.defineProperty(process.stderr, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + } + }); + + test('falls back to process.env when env is omitted', () => { + const stream = makeStream(false, 50); + // process.env is the real env; stream is non-TTY so write is never called. + renderBanner({ mode: 'controller', stream }); + expect(stream.write).not.toHaveBeenCalled(); + }); +}); diff --git a/app/banner/index.ts b/app/banner/index.ts new file mode 100644 index 00000000..5349cce2 --- /dev/null +++ b/app/banner/index.ts @@ -0,0 +1,30 @@ +import { getVersion } from '../configuration/index.js'; +import { BANNER_ART, BANNER_WIDTH } from './art.js'; + +export function renderBanner( + options: { mode: string; stream?: NodeJS.WriteStream; env?: NodeJS.ProcessEnv } = { + mode: 'controller', + }, +): void { + const stream = options.stream ?? process.stderr; + const env = options.env ?? process.env; + + if (!stream.isTTY || (env.NO_COLOR !== undefined && env.NO_COLOR !== '')) { + return; + } + + const version = getVersion(); + const pad = + typeof stream.columns === 'number' && stream.columns > BANNER_WIDTH + ? ' '.repeat(Math.floor((stream.columns - BANNER_WIDTH) / 2)) + : ''; + + const paddedArt = BANNER_ART.split('\n') + .map((line) => `${pad}${line}`) + .join('\n'); + + const identity = `\x1b[1mdrydock v${version} · ${options.mode}\x1b[0m`; + const paddedIdentity = `${pad}${identity}`; + + stream.write(`${paddedArt}\n${paddedIdentity}\n`); +} diff --git a/app/index.test.ts b/app/index.test.ts index ce7d7708..a3665480 100644 --- a/app/index.test.ts +++ b/app/index.test.ts @@ -41,6 +41,7 @@ async function loadEntryPoint({ const setDefaultResultOrder = vi.fn(); const getDnsMode = vi.fn(() => 'ipv4first'); const runConfigMigrateCommandIfRequested = vi.fn(() => migrateExitCode); + const renderBanner = vi.fn(); const logInfo = vi.fn(); const logWarn = vi.fn(); const storeInit = vi.fn(async () => undefined); @@ -59,6 +60,7 @@ async function loadEntryPoint({ vi.doMock('node:dns', () => ({ default: { setDefaultResultOrder }, })); + vi.doMock('./banner/index.js', () => ({ renderBanner })); vi.doMock('./configuration/index.js', () => ({ getDnsMode })); vi.doMock('./configuration/migrate-cli.js', () => ({ runConfigMigrateCommandIfRequested })); vi.doMock('./log/index.js', () => ({ @@ -92,6 +94,7 @@ async function loadEntryPoint({ setDefaultResultOrder, getDnsMode, runConfigMigrateCommandIfRequested, + renderBanner, logInfo, logWarn, storeInit, @@ -169,6 +172,7 @@ describe('entrypoint', () => { await harness.imported; + expect(harness.renderBanner).toHaveBeenCalledWith({ mode: 'controller' }); expect(harness.logInfo).toHaveBeenCalledWith('drydock is starting'); expect(harness.storeInit).toHaveBeenCalledWith({ memory: false }); expect(harness.prometheusInit).toHaveBeenCalledOnce(); @@ -199,6 +203,7 @@ describe('entrypoint', () => { await harness.imported; + expect(harness.renderBanner).toHaveBeenCalledWith({ mode: 'agent' }); expect(harness.storeInit).toHaveBeenCalledWith({ memory: true }); expect(harness.prometheusInit).not.toHaveBeenCalled(); expect(harness.registryInit).toHaveBeenCalledWith({ agent: true }); diff --git a/app/index.ts b/app/index.ts index 1dc0424f..755e26da 100644 --- a/app/index.ts +++ b/app/index.ts @@ -2,6 +2,7 @@ import dns from 'node:dns'; import * as agentServer from './agent/api/index.js'; import * as agentManager from './agent/index.js'; import * as api from './api/index.js'; +import { renderBanner } from './banner/index.js'; import { getDnsMode } from './configuration/index.js'; import { runConfigMigrateCommandIfRequested } from './configuration/migrate-cli.js'; import log from './log/index.js'; @@ -29,6 +30,7 @@ if (commandExitCode !== null) { const runningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0; const runAsRootEnabled = process.env.DD_RUN_AS_ROOT === 'true'; const insecureRootAcknowledged = process.env.DD_ALLOW_INSECURE_ROOT === 'true'; + renderBanner({ mode: isAgent ? 'agent' : 'controller' }); log.info('drydock is starting'); if (runningAsRoot && runAsRootEnabled && !insecureRootAcknowledged) { diff --git a/scripts/gen-banner.mjs b/scripts/gen-banner.mjs new file mode 100644 index 00000000..f9e61248 --- /dev/null +++ b/scripts/gen-banner.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * gen-banner.mjs — Build-time generator for the startup banner art. + * + * Reads drydock.png from the repo root, samples it via ImageMagick 7, + * and writes app/banner/art.ts with a baked 24-bit truecolor half-block + * string. Run manually like scripts/regenerate-brand-assets.sh. + * + * node scripts/gen-banner.mjs + */ + +import { execSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const PNG = resolve(ROOT, 'drydock.png'); +const OUT = resolve(ROOT, 'app/banner/art.ts'); + +const TARGET_WIDTH = 50; + +// ESC character as a string constant so biome doesn't flag a control-char regex literal. +const ESC = String.fromCharCode(27); +const RESET = `${ESC}[0m`; + +// --------------------------------------------------------------------------- +// 1. Dump pixels via ImageMagick +// --------------------------------------------------------------------------- +const raw = execSync(`magick ${PNG} -resize ${TARGET_WIDTH}x -depth 8 txt:-`, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, +}); + +// Parse header: "# ImageMagick pixel enumeration: W,H,..." +const headerMatch = raw.match(/^# ImageMagick pixel enumeration: (\d+),(\d+)/m); +if (!headerMatch) { + throw new Error('Could not parse ImageMagick header'); +} +const imgW = Number.parseInt(headerMatch[1], 10); +const imgH = Number.parseInt(headerMatch[2], 10); + +// Build 2-D array of {r,g,b,a} +const pixels = Array.from({ length: imgH }, () => + Array.from({ length: imgW }, () => ({ r: 0, g: 0, b: 0, a: 0 })), +); + +for (const line of raw.split('\n')) { + // "x,y: (r,g,b,a) #..." + const m = line.match(/^(\d+),(\d+):\s*\((\d+),(\d+),(\d+),(\d+)\)/); + if (!m) continue; + const x = Number.parseInt(m[1], 10); + const y = Number.parseInt(m[2], 10); + if (x < imgW && y < imgH) { + pixels[y][x] = { + r: Number.parseInt(m[3], 10), + g: Number.parseInt(m[4], 10), + b: Number.parseInt(m[5], 10), + a: Number.parseInt(m[6], 10), + }; + } +} + +// --------------------------------------------------------------------------- +// 2. Render half-block rows +// --------------------------------------------------------------------------- +const charRows = []; +const numCharRows = Math.ceil(imgH / 2); + +for (let row = 0; row < numCharRows; row++) { + const topY = row * 2; + const botY = row * 2 + 1; + let line = ''; + + for (let x = 0; x < imgW; x++) { + const top = pixels[topY]?.[x] ?? { r: 0, g: 0, b: 0, a: 0 }; + const bot = pixels[botY]?.[x] ?? { r: 0, g: 0, b: 0, a: 0 }; + const topOpaque = top.a >= 128; + const botOpaque = bot.a >= 128; + + if (!topOpaque && !botOpaque) { + // Both transparent: plain space with reset + line += `${RESET} `; + } else if (topOpaque && !botOpaque) { + // Top only: upper-half block in fg + line += `${RESET}${ESC}[38;2;${top.r};${top.g};${top.b}m▀`; + } else if (!topOpaque && botOpaque) { + // Bottom only: lower-half block in fg + line += `${RESET}${ESC}[38;2;${bot.r};${bot.g};${bot.b}m▄`; + } else { + // Both opaque: upper-half block, fg=top, bg=bot + line += `${ESC}[38;2;${top.r};${top.g};${top.b};48;2;${bot.r};${bot.g};${bot.b}m▀`; + } + } + + line += RESET; + charRows.push(line); +} + +// --------------------------------------------------------------------------- +// 3. Trim fully-blank leading/trailing lines +// "Blank" = line contains only spaces and reset sequences +// --------------------------------------------------------------------------- +// Build ANSI-strip regex dynamically so biome doesn't flag a control-char literal. +const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); + +function isBlankLine(line) { + return line.replace(ANSI_RE, '').trim() === ''; +} + +let start = 0; +let end = charRows.length - 1; +while (start <= end && isBlankLine(charRows[start])) start++; +while (end >= start && isBlankLine(charRows[end])) end--; +const trimmed = charRows.slice(start, end + 1); + +// --------------------------------------------------------------------------- +// 4. Write art.ts +// --------------------------------------------------------------------------- +// Escape the art string for embedding in a TS single-quoted string literal. +// ESC chars become \x1b escape sequences; newlines become \n. +const artJoined = trimmed.join('\n'); + +// Build the escape regex dynamically to avoid biome flagging control-char literals. +const ESC_RE = new RegExp(ESC, 'g'); +const artEscaped = artJoined + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(ESC_RE, '\\x1b') + .replace(/\n/g, '\\n'); + +const bannerWidth = imgW; + +const tsContent = `// AUTO-GENERATED by scripts/gen-banner.mjs — do not edit manually. +export const BANNER_ART = '${artEscaped}'; +export const BANNER_WIDTH = ${bannerWidth}; +`; + +writeFileSync(OUT, tsContent, 'utf8'); + +// --------------------------------------------------------------------------- +// 5. Lint/format the generated file +// --------------------------------------------------------------------------- +try { + execSync(`npx @biomejs/biome check --write ${OUT}`, { + cwd: ROOT, + encoding: 'utf8', + stdio: 'pipe', + }); +} catch { + // biome may exit non-zero if it had to fix things; that's fine. +} + +console.log(`Banner generated: ${trimmed.length} lines, ${bannerWidth} cells wide -> ${OUT}`);