Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/webcmd-weaver.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 109 additions & 2 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,109 @@
*/

import { mkdirSync, writeFileSync, existsSync } from 'node:fs';

// ── Weaver, the webcmd mascot ──────────────────────────────────────────────
//
// Deliberately duplicated from src/mascot.ts: this script must run with no
// build step and no imports from the source tree, so it cannot reuse the
// module. src/mascot.test.ts asserts the two copies stay identical.

const MASCOT_FRAME_HEIGHT = 9;
const BLANK = ' '.repeat(18);
const THREAD = ' ││ ';
const LEGS_TOP = [' ▓▓ ▓▓ ', ' ▓▓ ▓▓ '];
const LEGS_BOTTOM = [' ▓▓ ▓▓ ▓▓ ▓▓ ', ' ▓▓ ▓▓ ▓▓ ▓▓ '];
const HEAD = ' ██████████ ';
const EYES_OPEN = ' ▓▓██ ● ● ██▓▓ ';
const EYES_SHUT = ' ▓▓██ ─ ─ ██▓▓ ';
const MOUTH = ' ▓▓██ ▾ ██▓▓ ';
const BELLY = ' ██████████ ';

function weaverSprite(blink, stance) {
return [LEGS_TOP[stance], HEAD, blink ? EYES_SHUT : EYES_OPEN, MOUTH, BELLY, LEGS_BOTTOM[stance]];
}

function frame(sprite, dropped) {
const rows = [...Array.from({ length: dropped }, () => THREAD), ...sprite];
while (rows.length < MASCOT_FRAME_HEIGHT) rows.push(BLANK);
return rows;
}

export const WEAVER_FRAMES = [
frame(weaverSprite(false, 1), 1),
frame(weaverSprite(false, 1), 2),
frame(weaverSprite(false, 0), 3),
frame(weaverSprite(false, 1), 3),
frame(weaverSprite(false, 0), 3),
frame(weaverSprite(true, 0), 3),
frame(weaverSprite(false, 0), 3),
];

const ACCENT = '\u001b[38;2;86;197;255m';
const SHADE = '\u001b[38;2;0;107;154m';
const WHITE = '\u001b[97m';
const RESET = '\u001b[0m';
const GLYPH_COLOR = { '█': ACCENT, '│': ACCENT, '▓': SHADE, '▾': ACCENT, '●': WHITE, '─': WHITE };

function paintRow(row) {
let out = '';
let run = '';
let runColor;
const flush = () => {
if (run === '') return;
out += runColor === undefined ? run : `${runColor}${run}${RESET}`;
run = '';
};
for (const glyph of row) {
const color = GLYPH_COLOR[glyph];
if (color !== runColor) {
flush();
runColor = color;
}
run += glyph;
}
flush();
return out;
}

/** Accent text, unless the user asked for no color. */
function accent(text) {
return process.env.NO_COLOR ? text : `${ACCENT}${text}${RESET}`;
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Greet the install with Weaver.
*
* Animates only on an interactive terminal — npm sometimes pipes lifecycle
* script output, and in-place redraw needs a real cursor. Everywhere else the
* final frame is printed once, so the mascot still shows up without leaving
* escape sequences in a log. CI is already excluded by the caller.
*/
async function playWeaver() {
if (process.env.WEBCMD_NO_MASCOT) return;

const color = !process.env.NO_COLOR;
const paint = (rows) => rows.map(color ? paintRow : (row) => row).join('\n');

if (!process.stdout.isTTY) {
process.stdout.write(`${paint(WEAVER_FRAMES[WEAVER_FRAMES.length - 1])}\n`);
return;
}

process.stdout.write('\u001b[?25l'); // Hide cursor
try {
for (let i = 0; i < WEAVER_FRAMES.length; i++) {
if (i > 0) process.stdout.write(`\u001b[${MASCOT_FRAME_HEIGHT}A\u001b[J`);
process.stdout.write(`${paint(WEAVER_FRAMES[i])}\n`);
if (i < WEAVER_FRAMES.length - 1) await sleep(90);
}
} finally {
process.stdout.write('\u001b[?25h'); // Show cursor
}
}

import { join } from 'node:path';
import { homedir } from 'node:os';

Expand Down Expand Up @@ -73,7 +176,7 @@ function ensureDir(dir) {

// ── Main ───────────────────────────────────────────────────────────────────

function main() {
async function main() {
// Skip in CI environments
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
return;
Expand All @@ -85,6 +188,10 @@ function main() {
return;
}

await playWeaver();
console.log(` ${accent('Webcmd installed.')} Weaver crawls a site once so your agents never have to rediscover it.`);
console.log('');

const shell = detectShell();
if (!shell) {
// Cannot determine shell; silently skip
Expand Down Expand Up @@ -154,4 +261,4 @@ function main() {

}

main();
main().catch(() => {});
10 changes: 10 additions & 0 deletions src/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,13 @@ export const ENV_PREFIX = 'WEBCMD';
export const DAEMON_HEADER_NAME = 'X-Webcmd';
export const EXTENSION_PACKAGE_NAME = 'webcmd-extension';
export const EXTENSION_ARTIFACT_PREFIX = 'webcmd-extension';

/**
* Brand accent. Single source of truth shared by the help banner and the
* mascot; matches `colors.light` in docs/docs.json and the stroke color in
* docs/webcmd*.svg.
*/
export const ACCENT_RGB = { r: 0x56, g: 0xc5, b: 0xff } as const;

/** Deeper brand blue, matching `colors.primary`/`colors.dark` in docs/docs.json. */
export const SHADE_RGB = { r: 0x00, g: 0x6b, b: 0x9a } as const;
29 changes: 21 additions & 8 deletions src/command-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CLI_COMMAND } from './brand.js';
import { ACCENT_RGB, CLI_COMMAND } from './brand.js';
import { MASCOT_WIDTH, WEAVER, renderRows } from './mascot.js';
import { JSON_FORMAT_ALIAS_HELP, OUTPUT_FORMAT_HELP, OUTPUT_FORMATS } from './command-surface.js';
import type { Arg } from './registry.js';

Expand Down Expand Up @@ -80,8 +81,8 @@ export interface FormatRootHelpOptions {
columns?: number;
}

/** Banner from ascii.md — WEB (white) + CMD (#56C5FF) when color is on. */
export const ROOT_HELP_BANNER_PARTS = [
/** Wordmark rows — WEB (white) + CMD (#56C5FF) when color is on. */
const WORDMARK_PARTS = [
['ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ', 'ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ'],
['██╗ ██╗███████╗██████╗ ', '██████╗███╗ ███╗██████╗'],
['██║ ██║██╔════╝██╔══██╗', '██╔════╝████╗ ████║██╔══██╗'],
Expand All @@ -91,8 +92,22 @@ export const ROOT_HELP_BANNER_PARTS = [
[' ╚══╝╚══╝ ╚══════╝╚═════╝ ', '╚═════╝╚═╝ ╚═╝╚═════╝'],
] as const;

/**
* Weaver stands to the left of the wordmark. The banner's first row is a
* Hangul-filler spacer that is already ~60 display columns wide, so the mascot
* column is empty there and the sprite starts on row 1 — that keeps the spacer
* row exactly as wide as it was before the mascot existed.
*/
const MASCOT_COLUMN: readonly string[] = ['', ...WEAVER.map((row) => `${row} `)];

const MASCOT_PAD = ' '.repeat(MASCOT_WIDTH + 2);

/** Banner rows as `[mascot, web, cmd]` so each column can be painted separately. */
export const ROOT_HELP_BANNER_PARTS: readonly (readonly [string, string, string])[] =
WORDMARK_PARTS.map(([web, cmd], row) => [MASCOT_COLUMN[row] ?? MASCOT_PAD, web, cmd] as const);

export const ROOT_HELP_BANNER = ROOT_HELP_BANNER_PARTS
.map(([web, cmd]) => `${web}${cmd}`)
.map(([mascot, web, cmd]) => `${mascot}${web}${cmd}`)
.join('\n');

interface RootHelpCommandSection {
Expand All @@ -109,9 +124,6 @@ const ROOT_HELP_COMMAND_SECTIONS: readonly RootHelpCommandSection[] = [
{ title: 'COMPLETION', order: ['completion'] },
];

/** Brand accent from docs theme (`colors.light`). */
const ACCENT_RGB = { r: 0x56, g: 0xc5, b: 0xff } as const;

const ANSI = {
reset: '\u001b[0m',
bold: '\u001b[1m',
Expand All @@ -123,7 +135,8 @@ const ANSI = {
function formatRootHelpBanner(color: boolean): string {
if (!color) return ROOT_HELP_BANNER;
return ROOT_HELP_BANNER_PARTS
.map(([web, cmd]) => `${paint(web, true, ANSI.white)}${paint(cmd, true, ANSI.accent)}`)
.map(([mascot, web, cmd]) =>
`${renderRows([mascot], { color: true })}${paint(web, true, ANSI.white)}${paint(cmd, true, ANSI.accent)}`)
.join('\n');
}

Expand Down
17 changes: 15 additions & 2 deletions src/hosted/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { constants, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { access, realpath, stat } from 'node:fs/promises';
import { isAbsolute } from 'node:path';
import { CLI_COMMAND, ENV_PREFIX } from '../brand.js';
import { CLI_COMMAND, ENV_PREFIX, PRODUCT_DISPLAY_NAME } from '../brand.js';
import { animateWeaver } from '../mascot.js';
import { ArgumentError, getErrorMessage, toEnvelope } from '../errors.js';
import { formatErrorEnvelope } from '../output.js';
import { writeToStream } from '../stream-write.js';
Expand Down Expand Up @@ -104,7 +105,13 @@ export async function runHostedSetup(io: SetupIo = {}): Promise<number> {
}

const interactive = canPrompt(io);
await write('Webcmd setup\n');
// Weaver greets first-run setup. Gated on the output actually being a
// terminal rather than on `interactive`: an injected `question` makes
// prompting possible without the stream being able to move a cursor.
if (canAnimate(io)) {
await animateWeaver(write, { animate: true, color: !process.env.NO_COLOR });
}
await write(`${PRODUCT_DISPLAY_NAME} setup\n`);

if ((parsed.chromeProfile || parsed.importChromeCookies !== undefined || parsed.syncToChrome) && parsed.browser && parsed.browser.kind !== 'chrome') {
throw new ArgumentError(
Expand Down Expand Up @@ -152,6 +159,12 @@ export async function runHostedSetup(io: SetupIo = {}): Promise<number> {
}
}

/** Whether the output stream can render an in-place animation. */
function canAnimate(io: SetupIo): boolean {
if (io.isTTY !== undefined) return io.isTTY;
return process.stdin.isTTY === true && process.stdout.isTTY === true;
}

function canPrompt(io: SetupIo): boolean {
if (io.isTTY !== undefined) return io.isTTY;
if (io.question) return true;
Expand Down
Loading
Loading