brignano 1.0.0 → 1.1.0
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +1 -1
- package/demo.gif +0 -0
- package/dist/banner.js +57 -0
- package/dist/card.js +76 -118
- package/dist/cardBox.js +44 -0
- package/dist/data.js +34 -0
- package/dist/env.js +48 -0
- package/dist/live.js +40 -0
- package/dist/menu.js +73 -0
- package/dist/qrcode.js +8 -0
- package/dist/reveal.js +19 -0
- package/dist/vcard.js +40 -0
- package/package.json +18 -5
- package/readme.md +53 -38
- package/src/banner.ts +66 -0
- package/src/card.ts +81 -116
- package/src/cardBox.ts +64 -0
- package/src/data.ts +38 -0
- package/src/env.ts +69 -0
- package/src/live.ts +58 -0
- package/src/menu.ts +84 -0
- package/src/qrcode.ts +9 -0
- package/src/reveal.ts +25 -0
- package/src/types/qrcode-terminal.d.ts +13 -0
- package/src/vcard.ts +41 -0
- package/vhs/demo.tape +40 -0
package/readme.md
CHANGED
|
@@ -1,66 +1,81 @@
|
|
|
1
|
-
# npx_brignano <a href="#"><img src="https://img.shields.io/badge/-npx%20brignano-white?style=flat-square&logo=npm&logoColor=grey" align="right" alt="npm Badge"></img></a>
|
|
2
|
-
|
|
3
|
-
An interactive shell experience that renders my business card in your terminal.
|
|
4
|
-
|
|
5
|
-
👇 just hit
|
|
6
|
-
```bash
|
|
7
|
-
npx brignano
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
### DEMO
|
|
11
|
-
|
|
12
1
|
# npx-brignano <a href="#"><img src="https://img.shields.io/badge/-npx%20brignano-white?style=flat-square&logo=npm&logoColor=grey" align="right" alt="npm Badge"></img></a>
|
|
13
2
|
|
|
14
|
-
An interactive terminal business card for Anthony Brignano. Run it with `npx` to
|
|
3
|
+
An interactive terminal business card for Anthony Brignano. Run it with `npx` to get a gradient ASCII-art intro, a styled card with **live GitHub stats**, and quick actions — copy my email, open my socials, save my contact, or grab a scannable QR code.
|
|
15
4
|
|
|
16
|
-
Quick start
|
|
5
|
+
## Quick start
|
|
17
6
|
|
|
18
7
|
```bash
|
|
19
8
|
npx brignano
|
|
20
9
|
```
|
|
21
10
|
|
|
22
|
-
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **Gradient ASCII banner** (`figlet` + `gradient-string`) with a smooth line-by-line reveal animation.
|
|
16
|
+
- **Styled business card** (`boxen` + `chalk`) with name, role, and links.
|
|
17
|
+
- **Live GitHub stats** — follower/repo counts and my latest repo, fetched fresh with a strict ~1.5s timeout. If the network is slow or offline, the card renders instantly and just omits the live line. It never hangs.
|
|
18
|
+
- **Interactive menu** (`inquirer`) with quick actions:
|
|
19
|
+
- Copy my email to your clipboard
|
|
20
|
+
- Send me an email (`mailto:`)
|
|
21
|
+
- Open my GitHub / LinkedIn
|
|
22
|
+
- View my resume
|
|
23
|
+
- Save my contact as a `.vcf` (opens in Contacts)
|
|
24
|
+
- Show a scannable contact **QR code**
|
|
25
|
+
- Quit
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
The animation, network fetch, and prompt are all skipped automatically when output isn't a TTY (piped, CI, or `--no-prompt`), so it stays fast and never blocks scripts.
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
## CLI
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
- Presents a small interactive menu (via `inquirer`) with actions:
|
|
30
|
-
- Send an email (opens your mail client with `mailto:`)
|
|
31
|
-
- Download the resume (fetches a resume HTML and opens it locally)
|
|
32
|
-
- Quit
|
|
31
|
+
Run `npx brignano --help` to see this list.
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
| Flag | Description |
|
|
34
|
+
|------|-------------|
|
|
35
|
+
| `-h, --help` | Show help and exit. |
|
|
36
|
+
| `-v, --view` | Open my resume in your default browser (`https://brignano.io/resume`). |
|
|
37
|
+
| `-e, --email` | Open your mail client with a new message to `hi@brignano.io`. |
|
|
38
|
+
| `--qr` | Print a scannable QR code to my website and exit. |
|
|
39
|
+
| `--vcard` / `--save-contact` | Save my contact card (`.vcf`) and open it. |
|
|
40
|
+
| `--no-animation` | Render the full card instantly, skipping the reveal animation. |
|
|
41
|
+
| `--no-prompt` | Show the card only and exit (non-interactive). |
|
|
42
|
+
| `-d, --download` | Deprecated alias for `--view`. |
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
- `package.json` — project metadata and `bin` entry used by `npx`.
|
|
44
|
+
### Examples
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
```bash
|
|
47
|
+
npx brignano # full interactive experience
|
|
48
|
+
npx brignano --view # jump straight to my resume
|
|
49
|
+
npx brignano --qr # print a QR code to my site
|
|
50
|
+
npx brignano --vcard # save my contact card
|
|
51
|
+
npx brignano --no-prompt # card only (useful in CI/scripts)
|
|
52
|
+
```
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
- Or clone and run locally:
|
|
54
|
+
## Local development
|
|
43
55
|
|
|
44
56
|
```bash
|
|
45
|
-
git clone https://github.com/brignano/
|
|
57
|
+
git clone https://github.com/brignano/npx-brignano.git
|
|
46
58
|
cd npx-brignano
|
|
47
59
|
npm install
|
|
48
|
-
|
|
60
|
+
npm run build
|
|
61
|
+
node dist/card.js
|
|
49
62
|
```
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
Source lives in `src/` (split into small modules — `card.ts` is the entry/`bin` target) and compiles to `dist/` via `tsc`.
|
|
65
|
+
|
|
66
|
+
## Prerequisites
|
|
52
67
|
|
|
53
|
-
- Node.js (
|
|
68
|
+
- Node.js **>=20** (`npx` ships with npm).
|
|
54
69
|
|
|
55
|
-
Notes
|
|
70
|
+
## Notes
|
|
56
71
|
|
|
57
|
-
-
|
|
58
|
-
-
|
|
72
|
+
- Links printed in the card are cmd/ctrl+clickable in supported terminals.
|
|
73
|
+
- The `--download` flag is deprecated and now opens the resume in your browser instead of writing a local file.
|
|
59
74
|
|
|
60
|
-
Credits
|
|
75
|
+
## Credits
|
|
61
76
|
|
|
62
|
-
|
|
77
|
+
Inspired by [anmol098/npx_card](https://github.com/anmol098/npx_card).
|
|
63
78
|
|
|
64
|
-
License
|
|
79
|
+
## License
|
|
65
80
|
|
|
66
|
-
See `package.json` for license information.
|
|
81
|
+
See `package.json` for license information.
|
package/src/banner.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import figlet from 'figlet';
|
|
2
|
+
import gradient from 'gradient-string';
|
|
3
|
+
import { sleep } from './reveal.js';
|
|
4
|
+
|
|
5
|
+
// Brand palette — matches brignano.io's electric indigo/violet accent
|
|
6
|
+
// (violet-700 → violet-500 → violet-400). Reads well on a dark terminal.
|
|
7
|
+
const BRAND = ['#6d28d9', '#8b5cf6', '#a78bfa'];
|
|
8
|
+
|
|
9
|
+
// A longer ramp with a bright crest (violet-300) used for the shimmer sweep.
|
|
10
|
+
const SHIMMER = [
|
|
11
|
+
'#5b21b6',
|
|
12
|
+
'#6d28d9',
|
|
13
|
+
'#7c3aed',
|
|
14
|
+
'#8b5cf6',
|
|
15
|
+
'#a78bfa',
|
|
16
|
+
'#c4b5fd',
|
|
17
|
+
'#a78bfa',
|
|
18
|
+
'#8b5cf6',
|
|
19
|
+
'#7c3aed',
|
|
20
|
+
'#6d28d9',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function bannerArt(text: string): string {
|
|
24
|
+
try {
|
|
25
|
+
return figlet.textSync(text, { font: 'ANSI Shadow' });
|
|
26
|
+
} catch {
|
|
27
|
+
return text.toUpperCase();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function paint(colors: string[], art: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return gradient(colors).multiline(art);
|
|
34
|
+
} catch {
|
|
35
|
+
return art;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Static gradient banner (used for the non-animated path).
|
|
40
|
+
export function renderBanner(text = 'brignano'): string {
|
|
41
|
+
return paint(BRAND, bannerArt(text));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Animated splash: a violet highlight sweeps across the letters, then settles on
|
|
45
|
+
// the static brand gradient. Redraws in place via ANSI cursor moves, so it must
|
|
46
|
+
// only be called on a TTY (the caller gates this behind `shouldAnimate`).
|
|
47
|
+
export async function animateBanner(
|
|
48
|
+
text = 'brignano',
|
|
49
|
+
frames = 14,
|
|
50
|
+
frameMs = 70,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const art = bannerArt(text);
|
|
53
|
+
const lineCount = art.split('\n').length;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < frames; i++) {
|
|
56
|
+
const offset = i % SHIMMER.length;
|
|
57
|
+
const rotated = SHIMMER.slice(offset).concat(SHIMMER.slice(0, offset));
|
|
58
|
+
if (i > 0) process.stdout.write(`\u001b[${lineCount}A`);
|
|
59
|
+
process.stdout.write(`\r${paint(rotated, art)}\n`);
|
|
60
|
+
await sleep(frameMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Settle on the canonical brand gradient.
|
|
64
|
+
process.stdout.write(`\u001b[${lineCount}A`);
|
|
65
|
+
process.stdout.write(`${renderBanner(text)}\n`);
|
|
66
|
+
}
|
package/src/card.ts
CHANGED
|
@@ -1,133 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import boxen from 'boxen';
|
|
4
3
|
import chalk from 'chalk';
|
|
5
4
|
import inquirer from 'inquirer';
|
|
6
|
-
import
|
|
5
|
+
import { animateBanner, renderBanner } from './banner.js';
|
|
6
|
+
import { renderCard } from './cardBox.js';
|
|
7
|
+
import { funFacts, pick, profile, taglines } from './data.js';
|
|
8
|
+
import {
|
|
9
|
+
clearConsole,
|
|
10
|
+
isInteractive,
|
|
11
|
+
parseFlags,
|
|
12
|
+
shouldAnimate,
|
|
13
|
+
shouldFetchLive,
|
|
14
|
+
} from './env.js';
|
|
15
|
+
import { fetchLive } from './live.js';
|
|
16
|
+
import { buildChoices, sendEmail, viewResume } from './menu.js';
|
|
17
|
+
import { renderQr } from './qrcode.js';
|
|
18
|
+
import { reveal, sleep } from './reveal.js';
|
|
19
|
+
import { writeAndOpenVCard } from './vcard.js';
|
|
7
20
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
function printHelp(): void {
|
|
22
|
+
console.log('\nUsage: npx brignano [options]\n');
|
|
23
|
+
console.log('Options:');
|
|
24
|
+
console.log(' -h, --help Show this help');
|
|
25
|
+
console.log(' -v, --view Open my resume in your browser');
|
|
26
|
+
console.log(' -e, --email Open your mail client to email me');
|
|
27
|
+
console.log(' --qr Print a scannable QR code to my website');
|
|
28
|
+
console.log(' --vcard Save my contact card (.vcf) and open it');
|
|
29
|
+
console.log(' --no-animation Render instantly, skip the reveal animation');
|
|
30
|
+
console.log(' --no-prompt Show the card only (non-interactive)');
|
|
31
|
+
console.log(' -d, --download Deprecated alias for --view\n');
|
|
15
32
|
}
|
|
16
33
|
|
|
17
|
-
|
|
34
|
+
async function main(): Promise<void> {
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
const flags = parseFlags(argv);
|
|
18
37
|
|
|
19
|
-
|
|
38
|
+
// Direct-action flags short-circuit the full experience.
|
|
39
|
+
if (flags.help) return printHelp();
|
|
40
|
+
if (flags.view || flags.download) {
|
|
41
|
+
if (flags.download) {
|
|
42
|
+
console.warn('Note: --download is deprecated; opening the resume instead.');
|
|
43
|
+
}
|
|
44
|
+
return viewResume();
|
|
45
|
+
}
|
|
46
|
+
if (flags.email) return sendEmail();
|
|
47
|
+
if (flags.qr) {
|
|
48
|
+
console.log(await renderQr(profile.website));
|
|
49
|
+
console.log(chalk.gray(` ${profile.website}\n`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (flags.vcard) return writeAndOpenVCard();
|
|
20
53
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
type: 'list',
|
|
24
|
-
name: 'action',
|
|
25
|
-
message: 'What you want to do?',
|
|
26
|
-
choices: [
|
|
27
|
-
{
|
|
28
|
-
name: `Send me an ${chalk.green.bold('Email')}.`,
|
|
29
|
-
value: () => sendEmail(),
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: `View my ${chalk.magentaBright.bold('Resume')}.`,
|
|
33
|
-
value: () => viewResume(),
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: 'Just quit.',
|
|
37
|
-
value: () => {
|
|
38
|
-
console.log('Have a good day!\n');
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
],
|
|
42
|
-
},
|
|
43
|
-
];
|
|
54
|
+
const animate = shouldAnimate(flags);
|
|
55
|
+
const interactive = isInteractive(flags);
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log('\nDone, see you soon at inbox.\n');
|
|
49
|
-
})
|
|
50
|
-
.catch((err: any) => {
|
|
51
|
-
console.error('Failed to open mail client:', err && err.message ? err.message : err);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
57
|
+
// Kick off the live fetch early (don't await yet) so its timeout budget
|
|
58
|
+
// overlaps the banner render. Skipped entirely when non-interactive/CI.
|
|
59
|
+
const livePromise = shouldFetchLive(flags) ? fetchLive() : Promise.resolve(null);
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
const resumeUrl = 'https://brignano.io/resume';
|
|
57
|
-
open(resumeUrl)
|
|
58
|
-
.then(() => {
|
|
59
|
-
console.log(`\nOpened resume: ${resumeUrl}\n`);
|
|
60
|
-
})
|
|
61
|
-
.catch((err: any) => {
|
|
62
|
-
console.error('Failed to open resume URL:', err && err.message ? err.message : err);
|
|
63
|
-
});
|
|
64
|
-
}
|
|
61
|
+
clearConsole();
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
// Banner first: an animated violet shimmer when we can animate, otherwise the
|
|
64
|
+
// static gradient.
|
|
65
|
+
if (animate) {
|
|
66
|
+
await animateBanner();
|
|
67
|
+
await sleep(120);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(renderBanner());
|
|
70
|
+
}
|
|
74
71
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
labelCard: chalk.white.bold(' Card:'),
|
|
80
|
-
};
|
|
72
|
+
// Card, enriched with whatever the (bounded) live fetch returned.
|
|
73
|
+
const live = await livePromise;
|
|
74
|
+
const tagline = pick(taglines);
|
|
75
|
+
await reveal(renderCard({ live, tagline }), 30, animate);
|
|
81
76
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`${data.name}`,
|
|
85
|
-
'',
|
|
86
|
-
`${data.labelWork} ${data.work}`,
|
|
87
|
-
'',
|
|
88
|
-
`${data.labelGitHub} ${data.github}`,
|
|
89
|
-
`${data.labelLinkedIn} ${data.linkedin}`,
|
|
90
|
-
`${data.labelWeb} ${data.web}`,
|
|
91
|
-
'',
|
|
92
|
-
`${data.labelCard} ${data.npx}`,
|
|
93
|
-
'',
|
|
94
|
-
`${chalk.italic("My inbox is always open. Whether you have a")}`,
|
|
95
|
-
`${chalk.italic("question or just want to say hi, I will try")}`,
|
|
96
|
-
`${chalk.italic("my best to get back to you! :)")}`,
|
|
97
|
-
].join('\n'),
|
|
98
|
-
{
|
|
99
|
-
margin: 1,
|
|
100
|
-
float: 'center' as const,
|
|
101
|
-
padding: 1,
|
|
102
|
-
borderStyle: 'single',
|
|
103
|
-
borderColor: 'green',
|
|
104
|
-
}
|
|
105
|
-
);
|
|
77
|
+
console.log(`Tip: ${chalk.cyanBright.bold('cmd/ctrl + click')} the links above.`);
|
|
78
|
+
console.log(chalk.gray(` ${pick(funFacts)}\n`));
|
|
106
79
|
|
|
107
|
-
|
|
108
|
-
const tip = [`Tip: Try ${chalk.cyanBright.bold('cmd/ctrl + click')} on the links above`, ''].join('\n');
|
|
109
|
-
console.log(tip);
|
|
80
|
+
if (!interactive) return;
|
|
110
81
|
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
} else if (argv.includes('--view') || argv.includes('-v')) {
|
|
122
|
-
viewResume();
|
|
123
|
-
} else if (argv.includes('--download') || argv.includes('-d')) {
|
|
124
|
-
console.warn('Warning: `--download` is deprecated and now opens the resume in your browser.');
|
|
125
|
-
viewResume();
|
|
126
|
-
} else if (argv.includes('--email') || argv.includes('-e')) {
|
|
127
|
-
sendEmail();
|
|
128
|
-
} else if (argv.includes('--no-prompt') || argv.includes('--quiet')) {
|
|
129
|
-
console.log('\nExiting in non-interactive mode (--no-prompt).');
|
|
130
|
-
} else {
|
|
131
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
-
prompt(questions as any).then((answer: any) => answer.action());
|
|
82
|
+
const prompt = inquirer.createPromptModule();
|
|
83
|
+
const { action } = await prompt([
|
|
84
|
+
{
|
|
85
|
+
type: 'list',
|
|
86
|
+
name: 'action',
|
|
87
|
+
message: 'What would you like to do?',
|
|
88
|
+
choices: buildChoices(),
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
await action();
|
|
133
92
|
}
|
|
93
|
+
|
|
94
|
+
main().catch((err: unknown) => {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
console.error(`Something went wrong: ${message}`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
});
|
package/src/cardBox.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import boxen from 'boxen';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { profile } from './data.js';
|
|
4
|
+
import type { LiveData } from './live.js';
|
|
5
|
+
|
|
6
|
+
// Right-aligns labels to a common width so the values line up cleanly.
|
|
7
|
+
const LABEL_WIDTH = 10;
|
|
8
|
+
function label(text: string): string {
|
|
9
|
+
return chalk.white.bold(text.padStart(LABEL_WIDTH) + ':');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CardOptions {
|
|
13
|
+
live?: LiveData | null;
|
|
14
|
+
tagline: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Builds the boxen business card. `live` is optional — when absent the live
|
|
18
|
+
// line is simply omitted, so the card looks intentional with or without network.
|
|
19
|
+
export function renderCard({ live, tagline }: CardOptions): string {
|
|
20
|
+
// Violet accent matches brignano.io (violet-400 #a78bfa on dark).
|
|
21
|
+
const accent = chalk.hex('#a78bfa');
|
|
22
|
+
const lines = [
|
|
23
|
+
accent.bold(profile.fullName) + ' ' + chalk.gray(profile.handle),
|
|
24
|
+
'',
|
|
25
|
+
`${label('Work')} ${profile.title} ${chalk.gray('at')} ${chalk
|
|
26
|
+
.hex('#3A5A78')
|
|
27
|
+
.bold(profile.org)}`,
|
|
28
|
+
'',
|
|
29
|
+
`${label('GitHub')} ${chalk.gray('https://github.com/')}${chalk.green(
|
|
30
|
+
profile.username,
|
|
31
|
+
)}`,
|
|
32
|
+
`${label('LinkedIn')} ${chalk.gray('https://linkedin.com/in/')}${chalk.blue(
|
|
33
|
+
profile.username,
|
|
34
|
+
)}`,
|
|
35
|
+
`${label('Website')} ${chalk.cyan(profile.website)}`,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
if (live) {
|
|
39
|
+
lines.push('');
|
|
40
|
+
const parts = [
|
|
41
|
+
`${chalk.yellow('★')} ${chalk.bold(live.followers)} followers`,
|
|
42
|
+
`${chalk.bold(live.publicRepos)} repos`,
|
|
43
|
+
];
|
|
44
|
+
if (live.latestRepo) {
|
|
45
|
+
parts.push(`latest: ${chalk.green(live.latestRepo.name)}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push(`${label('Live')} ${chalk.gray(parts.join(' · '))}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lines.push(
|
|
51
|
+
'',
|
|
52
|
+
`${label('Card')} ${chalk.red('npx')} ${chalk.white('brignano')}`,
|
|
53
|
+
'',
|
|
54
|
+
chalk.italic(tagline),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return boxen(lines.join('\n'), {
|
|
58
|
+
margin: 1,
|
|
59
|
+
float: 'center',
|
|
60
|
+
padding: 1,
|
|
61
|
+
borderStyle: 'round',
|
|
62
|
+
borderColor: 'gray',
|
|
63
|
+
});
|
|
64
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Single source of truth for everything the card, vCard, QR and live modules need.
|
|
2
|
+
// Plain (uncolored) values live here; chalk styling is applied at render time.
|
|
3
|
+
|
|
4
|
+
export const profile = {
|
|
5
|
+
firstName: 'Anthony',
|
|
6
|
+
lastName: 'Brignano',
|
|
7
|
+
fullName: 'Anthony Brignano',
|
|
8
|
+
handle: '@brignano',
|
|
9
|
+
username: 'brignano',
|
|
10
|
+
title: 'Senior Staff Software Engineer',
|
|
11
|
+
org: 'The Hartford',
|
|
12
|
+
email: 'hi@brignano.io',
|
|
13
|
+
website: 'https://brignano.io',
|
|
14
|
+
resume: 'https://brignano.io/resume',
|
|
15
|
+
github: 'https://github.com/brignano',
|
|
16
|
+
linkedin: 'https://linkedin.com/in/brignano',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
// Rotating taglines — one is picked per run so repeat visitors see variety.
|
|
20
|
+
export const taglines: string[] = [
|
|
21
|
+
'My inbox is always open. Ask a question or just say hi — I read everything.',
|
|
22
|
+
'Building reliable systems, mentoring engineers, and shipping things that last.',
|
|
23
|
+
"If it's worth automating, it's worth a clean abstraction. Let's build.",
|
|
24
|
+
'Self-hoster, spec-driven, and unreasonably fond of a tidy terminal.',
|
|
25
|
+
'Good engineering is mostly good taste applied consistently. Say hello.',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Small easter-egg facts, shown occasionally below the card.
|
|
29
|
+
export const funFacts: string[] = [
|
|
30
|
+
'This card is open source — `npx brignano --help` shows every trick.',
|
|
31
|
+
'Tip: scan the QR with your phone to save my contact instantly.',
|
|
32
|
+
'The live stats above are pulled fresh from GitHub (with a strict timeout).',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Deterministic-enough pick without Math.random (kept simple + dependency-free).
|
|
36
|
+
export function pick<T>(items: readonly T[], seed = Date.now()): T {
|
|
37
|
+
return items[Math.floor(seed / 1000) % items.length];
|
|
38
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Environment + capability detection. Keeps the render logic free of scattered
|
|
2
|
+
// `process.stdout.isTTY` checks and guarantees CI / piped output never hangs.
|
|
3
|
+
|
|
4
|
+
export interface Flags {
|
|
5
|
+
help: boolean;
|
|
6
|
+
view: boolean;
|
|
7
|
+
download: boolean;
|
|
8
|
+
email: boolean;
|
|
9
|
+
qr: boolean;
|
|
10
|
+
vcard: boolean;
|
|
11
|
+
noPrompt: boolean;
|
|
12
|
+
noAnimation: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasFlag(argv: string[], names: string[]): boolean {
|
|
16
|
+
return names.some((n) => argv.includes(n));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseFlags(argv: string[]): Flags {
|
|
20
|
+
return {
|
|
21
|
+
help: hasFlag(argv, ['--help', '-h']),
|
|
22
|
+
view: hasFlag(argv, ['--view', '-v']),
|
|
23
|
+
download: hasFlag(argv, ['--download', '-d']),
|
|
24
|
+
email: hasFlag(argv, ['--email', '-e']),
|
|
25
|
+
qr: hasFlag(argv, ['--qr']),
|
|
26
|
+
vcard: hasFlag(argv, ['--vcard', '--save-contact']),
|
|
27
|
+
noPrompt: hasFlag(argv, ['--no-prompt', '--quiet']),
|
|
28
|
+
noAnimation: hasFlag(argv, ['--no-animation']),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isCI = (): boolean => Boolean(process.env.CI);
|
|
33
|
+
|
|
34
|
+
// True only when we can safely show an interactive inquirer prompt.
|
|
35
|
+
export function isInteractive(flags: Flags): boolean {
|
|
36
|
+
return Boolean(
|
|
37
|
+
process.stdout.isTTY && process.stdin.isTTY && !isCI() && !flags.noPrompt,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// True only when the reveal animation should play.
|
|
42
|
+
export function shouldAnimate(flags: Flags): boolean {
|
|
43
|
+
return Boolean(
|
|
44
|
+
process.stdout.isTTY &&
|
|
45
|
+
!isCI() &&
|
|
46
|
+
!flags.noPrompt &&
|
|
47
|
+
!flags.noAnimation &&
|
|
48
|
+
!process.env.NO_COLOR,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True only when we should spend time on a live network fetch.
|
|
53
|
+
export function shouldFetchLive(flags: Flags): boolean {
|
|
54
|
+
return isInteractive(flags);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearConsole(): void {
|
|
58
|
+
const out = process.stdout as NodeJS.WriteStream & { clear?: () => void };
|
|
59
|
+
if (typeof out.clear === 'function') {
|
|
60
|
+
try {
|
|
61
|
+
out.clear();
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (process.stdout.isTTY) {
|
|
67
|
+
process.stdout.write('\u001b[2J\u001b[0f');
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { profile } from './data.js';
|
|
2
|
+
|
|
3
|
+
export interface LiveData {
|
|
4
|
+
followers: number;
|
|
5
|
+
publicRepos: number;
|
|
6
|
+
latestRepo?: { name: string; url: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const API = 'https://api.github.com';
|
|
10
|
+
const HEADERS = {
|
|
11
|
+
'User-Agent': 'npx-brignano',
|
|
12
|
+
Accept: 'application/vnd.github+json',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Fetches live GitHub stats with a hard timeout. ANY failure (timeout, offline,
|
|
16
|
+
// non-200, rate-limit, parse error) resolves to null — this never throws and
|
|
17
|
+
// never blocks the card for longer than `timeoutMs`.
|
|
18
|
+
export async function fetchLive(timeoutMs = 1500): Promise<LiveData | null> {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const [userRes, reposRes] = await Promise.all([
|
|
23
|
+
fetch(`${API}/users/${profile.username}`, {
|
|
24
|
+
headers: HEADERS,
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
}),
|
|
27
|
+
fetch(
|
|
28
|
+
`${API}/users/${profile.username}/repos?sort=updated&per_page=1`,
|
|
29
|
+
{ headers: HEADERS, signal: controller.signal },
|
|
30
|
+
),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (!userRes.ok || !reposRes.ok) return null;
|
|
34
|
+
|
|
35
|
+
const user = (await userRes.json()) as {
|
|
36
|
+
followers?: number;
|
|
37
|
+
public_repos?: number;
|
|
38
|
+
};
|
|
39
|
+
const repos = (await reposRes.json()) as Array<{
|
|
40
|
+
name?: string;
|
|
41
|
+
html_url?: string;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
const latest = Array.isArray(repos) ? repos[0] : undefined;
|
|
45
|
+
return {
|
|
46
|
+
followers: user.followers ?? 0,
|
|
47
|
+
publicRepos: user.public_repos ?? 0,
|
|
48
|
+
latestRepo:
|
|
49
|
+
latest?.name && latest?.html_url
|
|
50
|
+
? { name: latest.name, url: latest.html_url }
|
|
51
|
+
: undefined,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|