jobless 1.0.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/LICENSE +21 -0
- package/README.md +56 -0
- package/bin/jobless.js +8 -0
- package/package.json +42 -0
- package/src/cli.js +159 -0
- package/src/data.js +182 -0
- package/src/screens.js +257 -0
- package/src/style.js +145 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yonas Alem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# jobless
|
|
2
|
+
|
|
3
|
+
A terminal resume for a broken job market.
|
|
4
|
+
|
|
5
|
+
LinkedIn turned into social media. Applications take 30 minutes and get rejected by a bot. Recruiters schedule interviews, then vanish. This package is the profile I actually want people to read.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx jobless
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx jobless # interactive
|
|
15
|
+
npx jobless who # who I am
|
|
16
|
+
npx jobless looking # roles I want
|
|
17
|
+
npx jobless rant # the market, unfiltered
|
|
18
|
+
npx jobless work # proof of work
|
|
19
|
+
npx jobless skills # the stack
|
|
20
|
+
npx jobless hire # links + email
|
|
21
|
+
npx jobless --json # machine readable
|
|
22
|
+
npx jobless --no-anim # skip the ATS joke
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Yonas Alem
|
|
26
|
+
|
|
27
|
+
Senior Software Engineer in Addis Ababa. Open to remote senior roles.
|
|
28
|
+
|
|
29
|
+
- Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
|
|
30
|
+
- GitHub: [github.com/Yonas21](https://github.com/Yonas21)
|
|
31
|
+
- LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
|
|
32
|
+
- Email: yonalem21@gmail.com
|
|
33
|
+
|
|
34
|
+
Zero dependencies. Node 18+.
|
|
35
|
+
|
|
36
|
+
## Publish
|
|
37
|
+
|
|
38
|
+
npm now requires 2FA or a granular token. Browser login alone is not enough.
|
|
39
|
+
|
|
40
|
+
1. Enable 2FA on your npm account: [npmjs.com/settings](https://www.npmjs.com/) → Account → Two-Factor Authentication.
|
|
41
|
+
2. Publish with the authenticator code:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm publish --access public --otp=123456
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or create a **Granular Access Token** with Read and Write + **Bypass 2FA**, then:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm config set //registry.npmjs.org/:_authToken=npm_YOUR_TOKEN
|
|
51
|
+
npm publish --access public
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then tell people:
|
|
55
|
+
|
|
56
|
+
> I was so done with the job market that I published a resume to npm. Run `npx jobless`.
|
package/bin/jobless.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jobless",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A terminal resume for a broken job market. npx jobless",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"npx",
|
|
8
|
+
"resume",
|
|
9
|
+
"portfolio",
|
|
10
|
+
"terminal",
|
|
11
|
+
"job-search",
|
|
12
|
+
"yonas-alem"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Yonas Alem",
|
|
17
|
+
"email": "yonalem21@gmail.com",
|
|
18
|
+
"url": "https://yonasalem.vercel.app"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://yonasalem.vercel.app",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"email": "yonalem21@gmail.com"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"jobless": "bin/jobless.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"start": "node bin/jobless.js",
|
|
37
|
+
"who": "node bin/jobless.js who",
|
|
38
|
+
"work": "node bin/jobless.js work",
|
|
39
|
+
"rant": "node bin/jobless.js rant",
|
|
40
|
+
"hire": "node bin/jobless.js hire"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { menu } from './data.js';
|
|
7
|
+
import {
|
|
8
|
+
blank,
|
|
9
|
+
clear,
|
|
10
|
+
dim,
|
|
11
|
+
print,
|
|
12
|
+
showCursor,
|
|
13
|
+
} from './style.js';
|
|
14
|
+
import {
|
|
15
|
+
printBoot,
|
|
16
|
+
printHeader,
|
|
17
|
+
printHelp,
|
|
18
|
+
printHire,
|
|
19
|
+
printLandingTeaser,
|
|
20
|
+
printLooking,
|
|
21
|
+
printMenu,
|
|
22
|
+
printRant,
|
|
23
|
+
printSkills,
|
|
24
|
+
printWho,
|
|
25
|
+
printWork,
|
|
26
|
+
toJson,
|
|
27
|
+
} from './screens.js';
|
|
28
|
+
|
|
29
|
+
const pkg = JSON.parse(
|
|
30
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const COMMANDS = {
|
|
34
|
+
who: printWho,
|
|
35
|
+
about: printWho,
|
|
36
|
+
looking: printLooking,
|
|
37
|
+
jobs: printLooking,
|
|
38
|
+
rant: printRant,
|
|
39
|
+
market: printRant,
|
|
40
|
+
work: printWork,
|
|
41
|
+
experience: printWork,
|
|
42
|
+
skills: printSkills,
|
|
43
|
+
hire: printHire,
|
|
44
|
+
links: printHire,
|
|
45
|
+
contact: printHire,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ask = (rl, prompt) =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
rl.question(prompt, resolve);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const resolveCommand = (input) => {
|
|
54
|
+
const value = String(input).trim().toLowerCase();
|
|
55
|
+
if (!value) return null;
|
|
56
|
+
if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
|
|
57
|
+
const fromMenu = menu.find((item) => item.key === value || item.command === value);
|
|
58
|
+
if (fromMenu) return fromMenu.command;
|
|
59
|
+
if (COMMANDS[value]) return value;
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const runCommand = (command) => {
|
|
64
|
+
const render = COMMANDS[command];
|
|
65
|
+
if (!render) return false;
|
|
66
|
+
render();
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const interactive = async ({ animate }) => {
|
|
71
|
+
if (process.stdout.isTTY) clear();
|
|
72
|
+
printHeader();
|
|
73
|
+
if (animate) {
|
|
74
|
+
blank();
|
|
75
|
+
await printBoot();
|
|
76
|
+
}
|
|
77
|
+
printLandingTeaser();
|
|
78
|
+
printMenu();
|
|
79
|
+
|
|
80
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
81
|
+
try {
|
|
82
|
+
while (true) {
|
|
83
|
+
const answer = await ask(rl, dim(' jobless> '));
|
|
84
|
+
const command = resolveCommand(answer);
|
|
85
|
+
|
|
86
|
+
if (!command) {
|
|
87
|
+
print(` ${dim('unknown. try 1–6 or q.')}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (command === 'quit') {
|
|
91
|
+
blank();
|
|
92
|
+
print(dim(' still jobless. still shipping.'));
|
|
93
|
+
blank();
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (process.stdout.isTTY) clear();
|
|
98
|
+
printHeader();
|
|
99
|
+
runCommand(command);
|
|
100
|
+
printMenu();
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
rl.close();
|
|
104
|
+
showCursor();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const parse = (argv) => {
|
|
109
|
+
const flags = new Set(argv.filter((arg) => arg.startsWith('-')));
|
|
110
|
+
const rest = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.toLowerCase());
|
|
111
|
+
return { flags, rest };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const run = async (argv = []) => {
|
|
115
|
+
const { flags, rest } = parse(argv);
|
|
116
|
+
|
|
117
|
+
if (flags.has('-h') || flags.has('--help')) {
|
|
118
|
+
printHelp();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (flags.has('-v') || flags.has('--version')) {
|
|
123
|
+
print(pkg.version);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (flags.has('--json')) {
|
|
128
|
+
print(toJson());
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const animate = process.stdout.isTTY && !flags.has('--no-anim');
|
|
133
|
+
const command = rest[0] ? resolveCommand(rest[0]) : null;
|
|
134
|
+
|
|
135
|
+
if (command && command !== 'quit') {
|
|
136
|
+
printHeader();
|
|
137
|
+
runCommand(command);
|
|
138
|
+
blank();
|
|
139
|
+
print(dim(' more: npx jobless or npx jobless --help'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!process.stdout.isTTY) {
|
|
144
|
+
printHeader();
|
|
145
|
+
printWho();
|
|
146
|
+
printLooking();
|
|
147
|
+
printRant();
|
|
148
|
+
printHire({ copy: false });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
process.on('SIGINT', () => {
|
|
153
|
+
showCursor();
|
|
154
|
+
process.stdout.write('\n');
|
|
155
|
+
process.exit(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await interactive({ animate });
|
|
159
|
+
};
|
package/src/data.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export const profile = {
|
|
2
|
+
name: 'Yonas Alem',
|
|
3
|
+
title: 'Senior Software Engineer',
|
|
4
|
+
location: 'Addis Ababa, Ethiopia',
|
|
5
|
+
availability: 'Open to remote senior roles',
|
|
6
|
+
currently: 'TPK Synergy Limited',
|
|
7
|
+
study: 'BSc Software Engineering, Addis Ababa University',
|
|
8
|
+
email: 'yonalem21@gmail.com',
|
|
9
|
+
phone: '+251 936 972 697',
|
|
10
|
+
website: 'https://yonasalem.vercel.app',
|
|
11
|
+
github: 'https://github.com/Yonas21',
|
|
12
|
+
linkedin: 'https://www.linkedin.com/in/yonasalem21/',
|
|
13
|
+
instagram: 'https://www.instagram.com/its_yon_21/',
|
|
14
|
+
resume: 'https://yonasalem.vercel.app/assets/resume.pdf',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const stats = [
|
|
18
|
+
{ value: '5+', label: 'years in production' },
|
|
19
|
+
{ value: '35%', label: 'infra cost cut' },
|
|
20
|
+
{ value: '~30%', label: 'performance gain' },
|
|
21
|
+
{ value: '3B+', label: 'ETB payment volume' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const about = [
|
|
25
|
+
'I build and fix production systems across payments, gaming, ecommerce, advertising, and government services — from PostgreSQL and backend services through React, AWS, and the last mile of production debugging.',
|
|
26
|
+
'I have cut infrastructure spend by about 35% (roughly $100K a year), improved platform performance around 30%, and integrated payment rails that have handled more than 3 billion ETB in transaction volume.',
|
|
27
|
+
'Day to day I work across the stack: schema design, APIs, React frontends, AWS infrastructure, and the kind of debugging that starts with a silent failure and ends with a safer system.',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const lookingFor = {
|
|
31
|
+
roles: [
|
|
32
|
+
'Senior Software Engineer',
|
|
33
|
+
'Senior Full-Stack Engineer',
|
|
34
|
+
'Backend-leaning full-stack (Node.js, PostgreSQL, AWS)',
|
|
35
|
+
],
|
|
36
|
+
setup: ['Remote-first', 'Serious hybrid if the team is actually good'],
|
|
37
|
+
wants: [
|
|
38
|
+
'Production ownership. Real incidents, real systems, real users.',
|
|
39
|
+
'Teams that read PRs and ship, not teams that post carousels about shipping.',
|
|
40
|
+
'Work in payments, marketplace, SaaS, data-heavy backends, or infra that has to stay up.',
|
|
41
|
+
'Managers who can say no to a 7-round interview for a role they already understand.',
|
|
42
|
+
],
|
|
43
|
+
no: [
|
|
44
|
+
'Unpaid take-homes that look like a sprint of free consulting.',
|
|
45
|
+
'"Entry-level" postings that require 5 years, 12 frameworks, and a public blog.',
|
|
46
|
+
'Culture surveys that take longer than the actual interview.',
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const rants = [
|
|
51
|
+
{
|
|
52
|
+
title: 'LinkedIn is not a job market anymore',
|
|
53
|
+
body: "LinkedIn used to be where you found work. Now it is Instagram with a resume attached. Everyone is a thought leader. Everyone is humbled to announce. Nobody is hiring. The feed is bragging, engagement bait, and 10-slide carousels on 'how senior engineers think' from people who have never been on-call. I did not come here to like your humble-brag. I came here because I can actually build the thing.",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: '30 minutes to apply. 0.4 seconds to get rejected',
|
|
57
|
+
body: 'I spend half an hour on an application. Upload the PDF. Then type the same PDF into 12 text boxes. Then salary in three currencies. Then a culture quiz. Then "why this company?" as if the ATS is going to read it. I hit submit. The rejection arrives so fast it had to be a machine. Sometimes the rejection beats the confirmation email. Five years of production work, $100K of infra savings, payment rails that moved billions — discarded because the keyword density was wrong.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: 'Recruiters who schedule, then vanish',
|
|
61
|
+
body: 'A recruiter books a call. I clear the calendar. I prepare. I show up. They cancel at T-minus two minutes, or reject me after the invite is already on the calendar, or ghost so hard the Google Calendar event is the last human artifact. If you are going to disappear, skip the scheduling theater. Do not waste a working engineer\'s Thursday morning just to keep your Greenhouse pipeline looking alive.',
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const experience = [
|
|
66
|
+
{
|
|
67
|
+
role: 'Senior Full Stack Engineer',
|
|
68
|
+
company: 'TPK Synergy Limited',
|
|
69
|
+
dates: 'Dec 2025 – Present',
|
|
70
|
+
place: 'Remote',
|
|
71
|
+
points: [
|
|
72
|
+
'Amazon seller analytics: marketplace analytics, FBA, finance, PPC, and inventory.',
|
|
73
|
+
'Traced recurring sync jobs that silently died after every deploy; added explicit shutdown handling across 17 cron processes.',
|
|
74
|
+
'Integrated Amazon SP-API and Ads auth end to end: OAuth, LWA, encrypted credentials.',
|
|
75
|
+
'Extended the platform to UK and EU marketplaces with VAT-aware margins.',
|
|
76
|
+
],
|
|
77
|
+
stack: ['Node.js', 'PostgreSQL', 'BullMQ', 'Redis', 'React', 'Amazon SP-API'],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
role: 'Senior Software Engineer',
|
|
81
|
+
company: 'Mereb Technologies',
|
|
82
|
+
dates: 'Apr 2022 – Nov 2025',
|
|
83
|
+
place: 'Addis Ababa',
|
|
84
|
+
points: [
|
|
85
|
+
'Owned a real-money gaming platform: loyalty, payouts, fraud, reporting, infra.',
|
|
86
|
+
'Improved platform performance ~30% with indexing, caching, and read/write separation.',
|
|
87
|
+
'Cut infrastructure cost ~35%, about $100K a year.',
|
|
88
|
+
'Dropped report generation on ~1M-row datasets from 2 hours to under 30 minutes.',
|
|
89
|
+
'Mentored 3 junior engineers.',
|
|
90
|
+
],
|
|
91
|
+
stack: ['PostgreSQL', 'gRPC', 'PHP', 'React', 'AWS', 'Datadog'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
role: 'Full Stack Developer (Contract)',
|
|
95
|
+
company: 'Affiliate.com',
|
|
96
|
+
dates: 'Feb 2023 – Aug 2023',
|
|
97
|
+
place: 'Remote',
|
|
98
|
+
points: [
|
|
99
|
+
'Migrated a legacy PHP API to Node.js and tRPC without breaking the React UI.',
|
|
100
|
+
'Cut report generation time ~40% by moving it onto Google Cloud Storage.',
|
|
101
|
+
'Rebuilt validation in Zod and reached ~85% test coverage on migrated code.',
|
|
102
|
+
],
|
|
103
|
+
stack: ['Node.js', 'tRPC', 'Prisma', 'React', 'Zod', 'GCS'],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
role: 'Frontend Developer',
|
|
107
|
+
company: 'Eaglelion Systems Technology',
|
|
108
|
+
dates: 'Feb 2021 – Apr 2022',
|
|
109
|
+
place: 'Addis Ababa',
|
|
110
|
+
points: [
|
|
111
|
+
'Integrated Mastercard, Visa, Telebirr, and bank APIs into products with 100K+ downloads.',
|
|
112
|
+
'Payment workflows across 3B+ ETB in transaction volume: callbacks, retries, reconciliation.',
|
|
113
|
+
'Raised GTmetrix from 45 to 97 and cut page load ~25%.',
|
|
114
|
+
],
|
|
115
|
+
stack: ['React', 'Next.js', 'Tailwind', 'Payment APIs'],
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export const work = [
|
|
120
|
+
{
|
|
121
|
+
title: 'Amazon seller analytics platform',
|
|
122
|
+
company: 'TPK Synergy Limited',
|
|
123
|
+
blurb: 'Production reliability, Amazon integrations, and the data path behind the dashboard.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
title: 'Real-money gaming platform',
|
|
127
|
+
company: 'Mereb Technologies',
|
|
128
|
+
blurb: 'Loyalty, payouts, fraud, reporting, and the infrastructure underneath it.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
title: 'Government constituent platform',
|
|
132
|
+
company: 'Autobridge Systems',
|
|
133
|
+
blurb: 'Complaints, document approvals, access control, encryption, 10x faster email ingest.',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
title: 'Upplai',
|
|
137
|
+
company: 'AI resume platform',
|
|
138
|
+
blurb: 'Resume generation and ATS feedback with OpenAI and Gemini, plus per-resume cost tracking.',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
title: 'Nedaj payments',
|
|
142
|
+
company: 'Eaglelion',
|
|
143
|
+
blurb: 'Fuel-purchase payments for 100K+ downloads: Mastercard, Visa, Telebirr, bank APIs.',
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
export const skills = [
|
|
148
|
+
{ name: 'Languages', items: ['TypeScript', 'JavaScript', 'Go', 'Python', 'PHP', 'SQL'] },
|
|
149
|
+
{ name: 'Backend', items: ['Node.js', 'Express', 'FastAPI', 'Prisma', 'gRPC', 'tRPC', 'REST', 'BullMQ'] },
|
|
150
|
+
{ name: 'Data', items: ['PostgreSQL', 'MySQL', 'MongoDB', 'Redis'] },
|
|
151
|
+
{ name: 'Frontend', items: ['React', 'Next.js', 'Tailwind CSS', 'Material UI'] },
|
|
152
|
+
{ name: 'Cloud', items: ['AWS', 'Docker', 'Kubernetes', 'Nginx', 'GitHub Actions', 'Datadog'] },
|
|
153
|
+
{ name: 'Integrations', items: ['Amazon SP-API', 'Mastercard', 'Visa', 'Telebirr', 'OAuth 2.0', 'OpenAI'] },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
export const quotes = [
|
|
157
|
+
{ name: 'Dan Warner', role: 'AppLand Inc', text: 'Yonas did a great job! I would work with him again on a larger project.' },
|
|
158
|
+
{ name: 'Muly Oved', role: '', text: 'Excellent freelancer, dedicated, hard worker, was joy to work with.' },
|
|
159
|
+
{
|
|
160
|
+
name: 'Mekidem Getaneh',
|
|
161
|
+
role: '',
|
|
162
|
+
text: 'He goes above and beyond for solving a problem and to find optimal solutions.',
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
export const menu = [
|
|
167
|
+
{ key: '1', command: 'who', label: 'who I am' },
|
|
168
|
+
{ key: '2', command: 'looking', label: 'roles I want' },
|
|
169
|
+
{ key: '3', command: 'rant', label: 'the market, unfiltered' },
|
|
170
|
+
{ key: '4', command: 'work', label: 'proof of work' },
|
|
171
|
+
{ key: '5', command: 'skills', label: 'the stack' },
|
|
172
|
+
{ key: '6', command: 'hire', label: 'hire me / links' },
|
|
173
|
+
{ key: 'q', command: 'quit', label: 'leave the void' },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
export const bootLines = [
|
|
177
|
+
'parsing resume.pdf',
|
|
178
|
+
'pasting the same resume into 12 identical boxes',
|
|
179
|
+
'answering "why this company?" for a parser',
|
|
180
|
+
'waiting for a human',
|
|
181
|
+
'human not found',
|
|
182
|
+
];
|
package/src/screens.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
about,
|
|
5
|
+
bootLines,
|
|
6
|
+
experience,
|
|
7
|
+
lookingFor,
|
|
8
|
+
menu,
|
|
9
|
+
profile,
|
|
10
|
+
quotes,
|
|
11
|
+
rants,
|
|
12
|
+
skills,
|
|
13
|
+
stats,
|
|
14
|
+
work,
|
|
15
|
+
} from './data.js';
|
|
16
|
+
import {
|
|
17
|
+
banner,
|
|
18
|
+
blank,
|
|
19
|
+
bold,
|
|
20
|
+
box,
|
|
21
|
+
bullet,
|
|
22
|
+
cyan,
|
|
23
|
+
dim,
|
|
24
|
+
green,
|
|
25
|
+
hideCursor,
|
|
26
|
+
inner,
|
|
27
|
+
kv,
|
|
28
|
+
link,
|
|
29
|
+
magenta,
|
|
30
|
+
pad,
|
|
31
|
+
print,
|
|
32
|
+
red,
|
|
33
|
+
section,
|
|
34
|
+
showCursor,
|
|
35
|
+
sleep,
|
|
36
|
+
wrap,
|
|
37
|
+
yellow,
|
|
38
|
+
} from './style.js';
|
|
39
|
+
|
|
40
|
+
const copyToClipboard = (text) => {
|
|
41
|
+
try {
|
|
42
|
+
if (process.platform === 'darwin') {
|
|
43
|
+
execFileSync('pbcopy', { input: text });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (process.platform === 'win32') {
|
|
47
|
+
execFileSync('clip', { input: text, windowsHide: true });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
execFileSync('xclip', ['-selection', 'clipboard'], { input: text });
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const printHeader = () => {
|
|
58
|
+
print(banner());
|
|
59
|
+
blank();
|
|
60
|
+
print(` ${bold(cyan(profile.name))}`);
|
|
61
|
+
print(` ${profile.title}`);
|
|
62
|
+
print(` ${dim(`${profile.location} · ${profile.availability}`)}`);
|
|
63
|
+
blank();
|
|
64
|
+
const statLine = (items) =>
|
|
65
|
+
items.map((item) => `${bold(green(item.value))} ${dim(item.label)}`).join(dim(' '));
|
|
66
|
+
print(` ${statLine(stats.slice(0, 2))}`);
|
|
67
|
+
print(` ${statLine(stats.slice(2))}`);
|
|
68
|
+
blank();
|
|
69
|
+
print(
|
|
70
|
+
` ${link(profile.email, `mailto:${profile.email}`)}${dim(' · ')}${link(profile.website, profile.website)}${dim(' · ')}${link(profile.github.replace('https://', ''), profile.github)}`,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const printBoot = async () => {
|
|
75
|
+
hideCursor();
|
|
76
|
+
try {
|
|
77
|
+
print(dim(' simulating the modern application process...'));
|
|
78
|
+
blank();
|
|
79
|
+
for (const [index, line] of bootLines.entries()) {
|
|
80
|
+
print(` ${dim(`[${String(index + 1)}/5]`)} ${line}`);
|
|
81
|
+
await sleep(180);
|
|
82
|
+
}
|
|
83
|
+
blank();
|
|
84
|
+
print(` ${bold(red('RESULT'))} ${bold('REJECTED')}`);
|
|
85
|
+
print(` ${dim('reason')} keyword density insufficient`);
|
|
86
|
+
print(` ${dim('elapsed')} 0.41s`);
|
|
87
|
+
print(` ${dim('note')} this part is a joke. the rest is not.`);
|
|
88
|
+
} finally {
|
|
89
|
+
showCursor();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const printWho = () => {
|
|
94
|
+
blank();
|
|
95
|
+
section('WHO');
|
|
96
|
+
blank();
|
|
97
|
+
for (const paragraph of about) {
|
|
98
|
+
bullet(paragraph);
|
|
99
|
+
blank();
|
|
100
|
+
}
|
|
101
|
+
kv('Now', profile.currently);
|
|
102
|
+
kv('Study', profile.study);
|
|
103
|
+
kv('Site', link(profile.website, profile.website));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const printLooking = () => {
|
|
107
|
+
blank();
|
|
108
|
+
section('WHAT I WANT');
|
|
109
|
+
blank();
|
|
110
|
+
print(` ${dim('roles')}`);
|
|
111
|
+
for (const role of lookingFor.roles) bullet(role, green);
|
|
112
|
+
blank();
|
|
113
|
+
print(` ${dim('setup')}`);
|
|
114
|
+
for (const item of lookingFor.setup) bullet(item);
|
|
115
|
+
blank();
|
|
116
|
+
print(` ${dim('yes')}`);
|
|
117
|
+
for (const item of lookingFor.wants) bullet(item);
|
|
118
|
+
blank();
|
|
119
|
+
print(` ${dim('hard no')}`);
|
|
120
|
+
for (const item of lookingFor.no) bullet(item, yellow);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const printRant = () => {
|
|
124
|
+
blank();
|
|
125
|
+
section('THE MARKET');
|
|
126
|
+
blank();
|
|
127
|
+
for (const line of wrap(
|
|
128
|
+
'LinkedIn became social media. Applications became rituals. Recruiters became calendar ghosts.',
|
|
129
|
+
)) {
|
|
130
|
+
print(` ${dim(line)}`);
|
|
131
|
+
}
|
|
132
|
+
for (const rant of rants) {
|
|
133
|
+
blank();
|
|
134
|
+
print(box([bold(yellow(rant.title)), '', ...wrap(rant.body, inner())]));
|
|
135
|
+
}
|
|
136
|
+
blank();
|
|
137
|
+
print(` ${dim('If you are still reading, you are already more serious than the feed.')}`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const printWork = () => {
|
|
141
|
+
blank();
|
|
142
|
+
section('PROOF');
|
|
143
|
+
blank();
|
|
144
|
+
for (const job of experience) {
|
|
145
|
+
print(` ${bold(job.role)}`);
|
|
146
|
+
print(` ${cyan(job.company)} ${dim(`${job.dates} · ${job.place}`)}`);
|
|
147
|
+
blank();
|
|
148
|
+
for (const point of job.points) bullet(point);
|
|
149
|
+
print(` ${dim(job.stack.join(' · '))}`);
|
|
150
|
+
blank();
|
|
151
|
+
}
|
|
152
|
+
print(` ${dim('selected work')}`);
|
|
153
|
+
blank();
|
|
154
|
+
for (const item of work) {
|
|
155
|
+
print(` ${bold(item.title)} ${dim(item.company)}`);
|
|
156
|
+
for (const line of wrap(item.blurb)) print(` ${line}`);
|
|
157
|
+
blank();
|
|
158
|
+
}
|
|
159
|
+
print(` ${dim('people who were not bots')}`);
|
|
160
|
+
blank();
|
|
161
|
+
for (const quote of quotes) {
|
|
162
|
+
print(` ${magenta(`"${quote.text}"`)}`);
|
|
163
|
+
print(` ${dim(`— ${quote.name}${quote.role ? `, ${quote.role}` : ''}`)}`);
|
|
164
|
+
blank();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const printSkills = () => {
|
|
169
|
+
blank();
|
|
170
|
+
section('STACK');
|
|
171
|
+
blank();
|
|
172
|
+
for (const group of skills) {
|
|
173
|
+
print(` ${bold(group.name)}`);
|
|
174
|
+
print(` ${group.items.join(dim(' · '))}`);
|
|
175
|
+
blank();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const printHire = ({ copy = true } = {}) => {
|
|
180
|
+
blank();
|
|
181
|
+
section('HIRE ME');
|
|
182
|
+
blank();
|
|
183
|
+
print(` ${dim('Skip the 47-field form. This is the whole application.')}`);
|
|
184
|
+
blank();
|
|
185
|
+
kv('Email', link(profile.email, `mailto:${profile.email}`));
|
|
186
|
+
kv('Phone', link(profile.phone, `tel:${profile.phone.replace(/\s+/g, '')}`));
|
|
187
|
+
kv('Site', link(profile.website, profile.website));
|
|
188
|
+
kv('GitHub', link(profile.github, profile.github));
|
|
189
|
+
kv('LinkedIn', link(profile.linkedin, profile.linkedin));
|
|
190
|
+
kv('Resume', link(profile.resume, profile.resume));
|
|
191
|
+
blank();
|
|
192
|
+
if (copy && process.stdout.isTTY && copyToClipboard(profile.email)) {
|
|
193
|
+
print(` ${green('copied')} ${profile.email} ${dim('to clipboard')}`);
|
|
194
|
+
}
|
|
195
|
+
blank();
|
|
196
|
+
print(` ${dim('If you got this far, you already did more than most ATS pipelines.')}`);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const printMenu = () => {
|
|
200
|
+
blank();
|
|
201
|
+
print(
|
|
202
|
+
box(
|
|
203
|
+
menu.map((item) => `${bold(cyan(pad(item.key, 3)))} ${item.label}`),
|
|
204
|
+
{ title: 'open a door' },
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
blank();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const printHelp = () => {
|
|
211
|
+
print(banner());
|
|
212
|
+
blank();
|
|
213
|
+
print(` ${bold('npx jobless')} interactive`);
|
|
214
|
+
print(` ${bold('npx jobless who')} bio`);
|
|
215
|
+
print(` ${bold('npx jobless looking')} roles`);
|
|
216
|
+
print(` ${bold('npx jobless rant')} the market`);
|
|
217
|
+
print(` ${bold('npx jobless work')} experience`);
|
|
218
|
+
print(` ${bold('npx jobless skills')} stack`);
|
|
219
|
+
print(` ${bold('npx jobless hire')} links + email`);
|
|
220
|
+
print(` ${bold('npx jobless --json')} machine readable`);
|
|
221
|
+
print(` ${bold('npx jobless --no-anim')} skip the ATS joke`);
|
|
222
|
+
blank();
|
|
223
|
+
print(` ${dim('Built because LinkedIn stopped being useful.')}`);
|
|
224
|
+
print(` ${dim(profile.website)}`);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const printLandingTeaser = () => {
|
|
228
|
+
blank();
|
|
229
|
+
print(` ${yellow('LinkedIn is a social network now. This is the actual profile.')}`);
|
|
230
|
+
print(` ${dim('Applications take 30 minutes. Rejections take milliseconds.')}`);
|
|
231
|
+
print(` ${dim('Recruiters book the call, then ghost the calendar.')}`);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const toJson = () =>
|
|
235
|
+
JSON.stringify(
|
|
236
|
+
{
|
|
237
|
+
name: profile.name,
|
|
238
|
+
title: profile.title,
|
|
239
|
+
location: profile.location,
|
|
240
|
+
availability: profile.availability,
|
|
241
|
+
contact: {
|
|
242
|
+
email: profile.email,
|
|
243
|
+
phone: profile.phone,
|
|
244
|
+
website: profile.website,
|
|
245
|
+
github: profile.github,
|
|
246
|
+
linkedin: profile.linkedin,
|
|
247
|
+
resume: profile.resume,
|
|
248
|
+
},
|
|
249
|
+
lookingFor,
|
|
250
|
+
rants,
|
|
251
|
+
stats,
|
|
252
|
+
experience,
|
|
253
|
+
skills,
|
|
254
|
+
},
|
|
255
|
+
null,
|
|
256
|
+
2,
|
|
257
|
+
);
|
package/src/style.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { stdout } from 'node:process';
|
|
2
|
+
|
|
3
|
+
const CSI = '\x1b[';
|
|
4
|
+
const RESET = `${CSI}0m`;
|
|
5
|
+
const OSC = '\x1b]8;;';
|
|
6
|
+
const BEL = '\x07';
|
|
7
|
+
|
|
8
|
+
const hasColor = () => {
|
|
9
|
+
if (process.env.NO_COLOR) return false;
|
|
10
|
+
if (process.env.FORCE_COLOR === '0') return false;
|
|
11
|
+
if (process.env.FORCE_COLOR) return true;
|
|
12
|
+
return Boolean(stdout.isTTY);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const paint =
|
|
16
|
+
(codes) =>
|
|
17
|
+
(value) => {
|
|
18
|
+
if (!hasColor()) return String(value);
|
|
19
|
+
return `${CSI}${codes}m${value}${RESET}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const bold = paint('1');
|
|
23
|
+
export const dim = paint('2');
|
|
24
|
+
export const red = paint('31');
|
|
25
|
+
export const green = paint('32');
|
|
26
|
+
export const yellow = paint('33');
|
|
27
|
+
export const magenta = paint('35');
|
|
28
|
+
export const cyan = paint('36');
|
|
29
|
+
export const white = paint('37');
|
|
30
|
+
export const brightRed = paint('91');
|
|
31
|
+
|
|
32
|
+
export const width = () => {
|
|
33
|
+
const columns = stdout.columns || 80;
|
|
34
|
+
return Math.min(Math.max(columns, 56), 86);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const inner = () => width() - 4;
|
|
38
|
+
|
|
39
|
+
export const wrap = (text, max = width() - 8) => {
|
|
40
|
+
const paragraphs = String(text).split('\n');
|
|
41
|
+
const lines = [];
|
|
42
|
+
|
|
43
|
+
for (const paragraph of paragraphs) {
|
|
44
|
+
if (paragraph.trim() === '') {
|
|
45
|
+
lines.push('');
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const words = paragraph.split(/\s+/);
|
|
50
|
+
let line = '';
|
|
51
|
+
|
|
52
|
+
for (const word of words) {
|
|
53
|
+
if (!line) {
|
|
54
|
+
line = word;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (line.length + 1 + word.length > max) {
|
|
58
|
+
lines.push(line);
|
|
59
|
+
line = word;
|
|
60
|
+
} else {
|
|
61
|
+
line += ` ${word}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (line) lines.push(line);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const link = (label, url) => {
|
|
72
|
+
if (!stdout.isTTY || process.env.NO_HYPERLINK) return label;
|
|
73
|
+
return `${OSC}${url}${BEL}${label}${OSC}${BEL}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
|
|
78
|
+
export const hideCursor = () => {
|
|
79
|
+
if (stdout.isTTY) stdout.write(`${CSI}?25l`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const showCursor = () => {
|
|
83
|
+
if (stdout.isTTY) stdout.write(`${CSI}?25h`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const clear = () => {
|
|
87
|
+
if (stdout.isTTY) stdout.write(`${CSI}2J${CSI}H`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const visibleLength = (text) =>
|
|
91
|
+
String(text)
|
|
92
|
+
.replace(/\x1b\]8;;[^\x07]*\x07/g, '')
|
|
93
|
+
.replace(/\x1b\]8;;\x07/g, '')
|
|
94
|
+
.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
95
|
+
|
|
96
|
+
export const pad = (text, size = inner(), align = 'left') => {
|
|
97
|
+
const length = visibleLength(text);
|
|
98
|
+
const space = Math.max(size - length, 0);
|
|
99
|
+
if (align === 'center') {
|
|
100
|
+
const left = Math.floor(space / 2);
|
|
101
|
+
return `${' '.repeat(left)}${text}${' '.repeat(space - left)}`;
|
|
102
|
+
}
|
|
103
|
+
if (align === 'right') return `${' '.repeat(space)}${text}`;
|
|
104
|
+
return `${text}${' '.repeat(space)}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const box = (lines, { title } = {}) => {
|
|
108
|
+
const max = inner();
|
|
109
|
+
const topInner = title ? `─ ${title} ${'─'.repeat(Math.max(max - title.length - 3, 0))}` : '─'.repeat(max);
|
|
110
|
+
const top = dim(`┌${topInner}┐`);
|
|
111
|
+
const bottom = dim(`└${'─'.repeat(max)}┘`);
|
|
112
|
+
const body = lines.map((line) => `${dim('│')} ${pad(line, max)} ${dim('│')}`);
|
|
113
|
+
return [top, ...body, bottom].join('\n');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const banner = () => {
|
|
117
|
+
const max = inner();
|
|
118
|
+
return box(
|
|
119
|
+
[
|
|
120
|
+
bold(brightRed(pad('JOBLESS', max, 'center'))),
|
|
121
|
+
dim(pad('a terminal resume for a broken job market', max, 'center')),
|
|
122
|
+
],
|
|
123
|
+
{ title: 'npx jobless' },
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const print = (text = '') => {
|
|
128
|
+
stdout.write(`${text}\n`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const blank = () => print('');
|
|
132
|
+
|
|
133
|
+
export const section = (title) => {
|
|
134
|
+
print(bold(cyan(title)));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const bullet = (text, color = white) => {
|
|
138
|
+
for (const [index, line] of wrap(text).entries()) {
|
|
139
|
+
print(index === 0 ? ` ${dim('•')} ${color(line)}` : ` ${color(line)}`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const kv = (key, value) => {
|
|
144
|
+
print(` ${dim(pad(`${key}`, 12))}${value}`);
|
|
145
|
+
};
|