Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> · <mode>` 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.
Expand Down
4 changes: 4 additions & 0 deletions app/banner/art.ts

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions app/banner/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> } {
return { isTTY, columns, write: vi.fn() } as unknown as NodeJS.WriteStream & {
write: ReturnType<typeof vi.fn>;
};
}

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();
});
});
30 changes: 30 additions & 0 deletions app/banner/index.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
5 changes: 5 additions & 0 deletions app/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -92,6 +94,7 @@ async function loadEntryPoint({
setDefaultResultOrder,
getDnsMode,
runConfigMigrateCommandIfRequested,
renderBanner,
logInfo,
logWarn,
storeInit,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
155 changes: 155 additions & 0 deletions scripts/gen-banner.mjs
Original file line number Diff line number Diff line change
@@ -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:-`, {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
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}`, {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
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}`);
Loading