jobless 1.1.1 → 1.3.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/README.md +39 -35
- package/package.json +11 -4
- package/src/cli.js +102 -19
- package/src/data.js +22 -14
- package/src/identity.js +62 -0
- package/src/lint.js +103 -0
- package/src/reject.js +101 -0
- package/src/screens.js +69 -35
- package/src/style.js +1 -1
package/README.md
CHANGED
|
@@ -4,63 +4,67 @@
|
|
|
4
4
|
npx jobless
|
|
5
5
|
```
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
First it rejects **you**. Then it introduces an engineer.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`jobless` is a reverse ATS. The market has been parsing humans in 400ms. This package parses you, then parses the job post, then shows [Yonas Alem](https://yonasalem.vercel.app) — senior software engineer, remote, production systems.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## The trap
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
You run the command. It pretends to be Greenhouse. It reads a couple of harmless local hints (git name, email, laptop user). It writes a rejection letter addressed to you.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Then it breaks character:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Submit. Rejected in 400ms. Sometimes the no arrives before the confirmation. Five years in production, $100K pulled out of infra, payment rails that moved billions — filtered out because I did not write "synergy" enough times. Cool product. Would not ship it.
|
|
22
|
-
|
|
23
|
-
### They booked the call so they had something to cancel
|
|
24
|
-
|
|
25
|
-
Favorite genre of fiction: the recruiter calendar invite. "Loved your profile. Thursday 10am?" I clear the morning. I make tea. I put on a shirt with buttons. 9:58am: "we've decided to move forward with other candidates." Which other candidates. The ones you have not scheduled yet?
|
|
17
|
+
```
|
|
18
|
+
that was not a company.
|
|
19
|
+
that was the market, in your own terminal.
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
anyway. here is an engineer who debugs silent production failures.
|
|
22
|
+
```
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
Then: 17 cron jobs that died after every deploy. $100K off infra. 3B+ ETB in payment rails. Press `h` to hire.
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
Skip the theater with `npx jobless --resume`.
|
|
32
27
|
|
|
33
|
-
|
|
28
|
+
## Lint the posting, not the human
|
|
34
29
|
|
|
35
30
|
```bash
|
|
36
|
-
npx jobless
|
|
31
|
+
npx jobless lint posting.txt
|
|
32
|
+
pbpaste | npx jobless lint
|
|
37
33
|
```
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
Or run `npx jobless` and press `l`.
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
```
|
|
38
|
+
error salary described as competitive, with no number
|
|
39
|
+
no-fake-salary
|
|
40
|
+
error too many interview rounds
|
|
41
|
+
max-rounds
|
|
42
|
+
warn "fast-paced" / "wear many hats"
|
|
43
|
+
means-understaffed
|
|
44
|
+
|
|
45
|
+
REJECTED 2 errors 1 warning
|
|
46
|
+
reason failed the human compiler
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Same idea as ESLint. The file is a job description. The company has to pass.
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
## Hire
|
|
44
52
|
|
|
53
|
+
- Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
|
|
45
54
|
- Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
|
|
46
55
|
- GitHub: [github.com/Yonas21](https://github.com/Yonas21)
|
|
47
56
|
- LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
npx jobless hire
|
|
52
|
-
```
|
|
57
|
+
- Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
|
|
53
58
|
|
|
54
59
|
## Commands
|
|
55
60
|
|
|
56
61
|
```bash
|
|
57
|
-
npx jobless
|
|
58
|
-
npx jobless
|
|
59
|
-
npx jobless
|
|
60
|
-
npx jobless
|
|
61
|
-
npx jobless
|
|
62
|
-
npx jobless
|
|
63
|
-
npx jobless hire # links + email
|
|
62
|
+
npx jobless # reject you, then the resume
|
|
63
|
+
npx jobless lint file.txt # lint a job post
|
|
64
|
+
npx jobless work # proof
|
|
65
|
+
npx jobless hire # email + links
|
|
66
|
+
npx jobless rant # the market
|
|
67
|
+
npx jobless --resume # skip the rejection
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
Zero dependencies. Node 18+.
|
|
70
|
+
Zero dependencies. Node 18+. Local only. It does not apply to jobs or send email.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jobless",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "npx jobless —
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "npx jobless — first it rejects you. then it lints the job post. then you meet the engineer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"npx",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"job-market",
|
|
11
11
|
"linkedin",
|
|
12
12
|
"recruiter",
|
|
13
|
-
"
|
|
13
|
+
"linter",
|
|
14
|
+
"ats",
|
|
14
15
|
"portfolio",
|
|
15
16
|
"yonas-alem"
|
|
16
17
|
],
|
|
@@ -22,8 +23,13 @@
|
|
|
22
23
|
},
|
|
23
24
|
"homepage": "https://yonasalem.vercel.app",
|
|
24
25
|
"bugs": {
|
|
26
|
+
"url": "https://github.com/Yonas21/jobless/issues",
|
|
25
27
|
"email": "yonalem21@gmail.com"
|
|
26
28
|
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Yonas21/jobless.git"
|
|
32
|
+
},
|
|
27
33
|
"type": "module",
|
|
28
34
|
"bin": {
|
|
29
35
|
"jobless": "bin/jobless.js"
|
|
@@ -40,6 +46,7 @@
|
|
|
40
46
|
"who": "node bin/jobless.js who",
|
|
41
47
|
"work": "node bin/jobless.js work",
|
|
42
48
|
"rant": "node bin/jobless.js rant",
|
|
43
|
-
"hire": "node bin/jobless.js hire"
|
|
49
|
+
"hire": "node bin/jobless.js hire",
|
|
50
|
+
"lint": "node bin/jobless.js lint"
|
|
44
51
|
}
|
|
45
52
|
}
|
package/src/cli.js
CHANGED
|
@@ -2,8 +2,11 @@ import { createInterface } from 'node:readline';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { stdin } from 'node:process';
|
|
5
6
|
|
|
6
7
|
import { menu } from './data.js';
|
|
8
|
+
import { lintJobPost } from './lint.js';
|
|
9
|
+
import { printTrap } from './reject.js';
|
|
7
10
|
import {
|
|
8
11
|
blank,
|
|
9
12
|
clear,
|
|
@@ -12,14 +15,15 @@ import {
|
|
|
12
15
|
showCursor,
|
|
13
16
|
} from './style.js';
|
|
14
17
|
import {
|
|
15
|
-
printBoot,
|
|
16
18
|
printHeader,
|
|
17
19
|
printHelp,
|
|
18
20
|
printHire,
|
|
19
21
|
printLandingTeaser,
|
|
22
|
+
printLintReport,
|
|
20
23
|
printLooking,
|
|
21
24
|
printMenu,
|
|
22
25
|
printRant,
|
|
26
|
+
printSignature,
|
|
23
27
|
printSkills,
|
|
24
28
|
printWho,
|
|
25
29
|
printWork,
|
|
@@ -38,9 +42,11 @@ const COMMANDS = {
|
|
|
38
42
|
rant: printRant,
|
|
39
43
|
market: printRant,
|
|
40
44
|
work: printWork,
|
|
45
|
+
proof: printWork,
|
|
41
46
|
experience: printWork,
|
|
42
47
|
skills: printSkills,
|
|
43
48
|
hire: printHire,
|
|
49
|
+
h: printHire,
|
|
44
50
|
links: printHire,
|
|
45
51
|
contact: printHire,
|
|
46
52
|
};
|
|
@@ -50,10 +56,31 @@ const ask = (rl, prompt) =>
|
|
|
50
56
|
rl.question(prompt, resolve);
|
|
51
57
|
});
|
|
52
58
|
|
|
59
|
+
const readStdin = async () => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
for await (const chunk of stdin) chunks.push(chunk);
|
|
62
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const readPaste = async (rl) => {
|
|
66
|
+
blank();
|
|
67
|
+
print(dim(' paste a job description. empty line to lint.'));
|
|
68
|
+
blank();
|
|
69
|
+
const lines = [];
|
|
70
|
+
while (true) {
|
|
71
|
+
const line = await ask(rl, dim(' '));
|
|
72
|
+
if (line === '') break;
|
|
73
|
+
lines.push(line);
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
};
|
|
77
|
+
|
|
53
78
|
const resolveCommand = (input) => {
|
|
54
79
|
const value = String(input).trim().toLowerCase();
|
|
55
80
|
if (!value) return null;
|
|
56
81
|
if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
|
|
82
|
+
if (value === 'help') return 'help';
|
|
83
|
+
if (value === 'lint' || value === 'l') return 'lint';
|
|
57
84
|
const fromMenu = menu.find((item) => item.key === value || item.command === value);
|
|
58
85
|
if (fromMenu) return fromMenu.command;
|
|
59
86
|
if (COMMANDS[value]) return value;
|
|
@@ -67,15 +94,20 @@ const runCommand = (command) => {
|
|
|
67
94
|
return true;
|
|
68
95
|
};
|
|
69
96
|
|
|
70
|
-
const
|
|
71
|
-
if (process.stdout.isTTY) clear();
|
|
97
|
+
const showResume = () => {
|
|
72
98
|
printHeader();
|
|
73
|
-
|
|
74
|
-
blank();
|
|
75
|
-
await printBoot();
|
|
76
|
-
}
|
|
99
|
+
printSignature();
|
|
77
100
|
printLandingTeaser();
|
|
78
101
|
printMenu();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const interactive = async ({ trap }) => {
|
|
105
|
+
if (process.stdout.isTTY) clear();
|
|
106
|
+
if (trap) {
|
|
107
|
+
await printTrap({ animate: true });
|
|
108
|
+
blank();
|
|
109
|
+
}
|
|
110
|
+
showResume();
|
|
79
111
|
|
|
80
112
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
81
113
|
try {
|
|
@@ -84,12 +116,27 @@ const interactive = async ({ animate }) => {
|
|
|
84
116
|
const command = resolveCommand(answer);
|
|
85
117
|
|
|
86
118
|
if (!command) {
|
|
87
|
-
print(` ${dim('unknown. try 1–
|
|
119
|
+
print(` ${dim('unknown. try 1–5, l to lint, h to hire, or q.')}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (command === 'help') {
|
|
123
|
+
if (process.stdout.isTTY) clear();
|
|
124
|
+
printHelp();
|
|
125
|
+
printMenu();
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (command === 'lint') {
|
|
129
|
+
const text = await readPaste(rl);
|
|
130
|
+
if (process.stdout.isTTY) clear();
|
|
131
|
+
printHeader();
|
|
132
|
+
printLintReport(lintJobPost(text), 'paste');
|
|
133
|
+
printMenu();
|
|
88
134
|
continue;
|
|
89
135
|
}
|
|
90
136
|
if (command === 'quit') {
|
|
91
137
|
blank();
|
|
92
138
|
print(dim(' still jobless. still shipping.'));
|
|
139
|
+
print(dim(' yonalem21@gmail.com'));
|
|
93
140
|
blank();
|
|
94
141
|
break;
|
|
95
142
|
}
|
|
@@ -106,15 +153,46 @@ const interactive = async ({ animate }) => {
|
|
|
106
153
|
};
|
|
107
154
|
|
|
108
155
|
const parse = (argv) => {
|
|
109
|
-
const flags = new Set(
|
|
110
|
-
const rest =
|
|
156
|
+
const flags = new Set();
|
|
157
|
+
const rest = [];
|
|
158
|
+
for (const arg of argv) {
|
|
159
|
+
if (arg.startsWith('-')) flags.add(arg);
|
|
160
|
+
else rest.push(arg);
|
|
161
|
+
}
|
|
111
162
|
return { flags, rest };
|
|
112
163
|
};
|
|
113
164
|
|
|
165
|
+
const runLintCli = async (files) => {
|
|
166
|
+
let source = 'stdin';
|
|
167
|
+
let text = '';
|
|
168
|
+
|
|
169
|
+
if (files[0]) {
|
|
170
|
+
source = files[0];
|
|
171
|
+
text = readFileSync(files[0], 'utf8');
|
|
172
|
+
} else if (!stdin.isTTY) {
|
|
173
|
+
text = await readStdin();
|
|
174
|
+
} else {
|
|
175
|
+
printHelp();
|
|
176
|
+
blank();
|
|
177
|
+
print(dim(' lint needs a file, a pipe, or an interactive paste: npx jobless lint posting.txt'));
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
printLintReport(lintJobPost(text), source);
|
|
183
|
+
blank();
|
|
184
|
+
print(dim(' more: npx jobless · npx jobless hire'));
|
|
185
|
+
};
|
|
186
|
+
|
|
114
187
|
export const run = async (argv = []) => {
|
|
188
|
+
process.stdout.on('error', (error) => {
|
|
189
|
+
if (error.code === 'EPIPE') process.exit(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
115
192
|
const { flags, rest } = parse(argv);
|
|
193
|
+
const verb = rest[0] ? rest[0].toLowerCase() : '';
|
|
116
194
|
|
|
117
|
-
if (flags.has('-h') || flags.has('--help')) {
|
|
195
|
+
if (flags.has('-h') || flags.has('--help') || verb === 'help') {
|
|
118
196
|
printHelp();
|
|
119
197
|
return;
|
|
120
198
|
}
|
|
@@ -129,22 +207,27 @@ export const run = async (argv = []) => {
|
|
|
129
207
|
return;
|
|
130
208
|
}
|
|
131
209
|
|
|
132
|
-
|
|
133
|
-
|
|
210
|
+
if (verb === 'lint' || verb === 'l') {
|
|
211
|
+
await runLintCli(rest.slice(1));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
134
214
|
|
|
135
|
-
|
|
215
|
+
const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
|
|
216
|
+
const command = verb ? resolveCommand(verb) : null;
|
|
217
|
+
|
|
218
|
+
if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
|
|
136
219
|
printHeader();
|
|
137
220
|
runCommand(command);
|
|
138
221
|
blank();
|
|
139
|
-
print(dim(' more: npx jobless
|
|
222
|
+
print(dim(' more: npx jobless · npx jobless hire · npx jobless --help'));
|
|
140
223
|
return;
|
|
141
224
|
}
|
|
142
225
|
|
|
143
226
|
if (!process.stdout.isTTY) {
|
|
227
|
+
if (!skipTrap) await printTrap({ animate: false });
|
|
228
|
+
blank();
|
|
144
229
|
printHeader();
|
|
145
|
-
|
|
146
|
-
printLooking();
|
|
147
|
-
printRant();
|
|
230
|
+
printSignature();
|
|
148
231
|
printHire({ copy: false });
|
|
149
232
|
return;
|
|
150
233
|
}
|
|
@@ -155,5 +238,5 @@ export const run = async (argv = []) => {
|
|
|
155
238
|
process.exit(0);
|
|
156
239
|
});
|
|
157
240
|
|
|
158
|
-
await interactive({
|
|
241
|
+
await interactive({ trap: !skipTrap });
|
|
159
242
|
};
|
package/src/data.js
CHANGED
|
@@ -9,11 +9,16 @@ export const profile = {
|
|
|
9
9
|
phone: '+251 936 972 697',
|
|
10
10
|
website: 'https://yonasalem.vercel.app',
|
|
11
11
|
github: 'https://github.com/Yonas21',
|
|
12
|
+
repo: 'https://github.com/Yonas21/jobless',
|
|
12
13
|
linkedin: 'https://www.linkedin.com/in/yonasalem21/',
|
|
13
14
|
instagram: 'https://www.instagram.com/its_yon_21/',
|
|
14
15
|
resume: 'https://yonasalem.vercel.app/assets/resume.pdf',
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
export const hireMailto = `mailto:${profile.email}?subject=${encodeURIComponent(
|
|
19
|
+
'npx jobless — Yonas Alem',
|
|
20
|
+
)}&body=${encodeURIComponent('I ran npx jobless. I want to talk about a role.\n')}`;
|
|
21
|
+
|
|
17
22
|
export const stats = [
|
|
18
23
|
{ value: '5+', label: 'years in production' },
|
|
19
24
|
{ value: '35%', label: 'infra cost cut' },
|
|
@@ -21,6 +26,16 @@ export const stats = [
|
|
|
21
26
|
{ value: '3B+', label: 'ETB payment volume' },
|
|
22
27
|
];
|
|
23
28
|
|
|
29
|
+
export const signature = {
|
|
30
|
+
title: 'The deploy that killed 17 cron jobs',
|
|
31
|
+
lines: [
|
|
32
|
+
'Recurring Amazon sync jobs silently stopped after every deploy.',
|
|
33
|
+
'Traced it through BullMQ, ioredis, the Node process lifecycle, and the Railway scheduler.',
|
|
34
|
+
'Then added explicit shutdown handling across 17 cron processes.',
|
|
35
|
+
'I debug silent production failures. That is the job.',
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
24
39
|
export const about = [
|
|
25
40
|
'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
41
|
'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.',
|
|
@@ -169,19 +184,12 @@ export const quotes = [
|
|
|
169
184
|
];
|
|
170
185
|
|
|
171
186
|
export const menu = [
|
|
172
|
-
{ key: '1', command: '
|
|
173
|
-
{ key: '2', command: '
|
|
174
|
-
{ key: '3', command: '
|
|
175
|
-
{ key: '4', command: '
|
|
176
|
-
{ key: '5', command: '
|
|
177
|
-
{ key: '
|
|
187
|
+
{ key: '1', command: 'work', label: 'proof of work' },
|
|
188
|
+
{ key: '2', command: 'who', label: 'who I am' },
|
|
189
|
+
{ key: '3', command: 'looking', label: 'roles I want' },
|
|
190
|
+
{ key: '4', command: 'skills', label: 'the stack' },
|
|
191
|
+
{ key: '5', command: 'rant', label: 'the market, with jokes' },
|
|
192
|
+
{ key: 'l', command: 'lint', label: 'lint a job post' },
|
|
193
|
+
{ key: 'h', command: 'hire', label: 'hire me' },
|
|
178
194
|
{ key: 'q', command: 'quit', label: 'leave the void' },
|
|
179
195
|
];
|
|
180
|
-
|
|
181
|
-
export const bootLines = [
|
|
182
|
-
'uploading resume.pdf',
|
|
183
|
-
'guessing oat milk vs just-black-I-ship',
|
|
184
|
-
'guessing navy mug vs emotional-support Stanley',
|
|
185
|
-
'checking if Mercury is in Greenhouse',
|
|
186
|
-
'404 human',
|
|
187
|
-
];
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
const CONSUMER_DOMAINS = new Set([
|
|
5
|
+
'gmail.com',
|
|
6
|
+
'yahoo.com',
|
|
7
|
+
'hotmail.com',
|
|
8
|
+
'outlook.com',
|
|
9
|
+
'icloud.com',
|
|
10
|
+
'proton.me',
|
|
11
|
+
'protonmail.com',
|
|
12
|
+
'live.com',
|
|
13
|
+
'me.com',
|
|
14
|
+
'aol.com',
|
|
15
|
+
'local',
|
|
16
|
+
'localhost',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const git = (key) => {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync('git', ['config', '--get', key], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
timeout: 800,
|
|
25
|
+
}).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const titleCase = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
32
|
+
|
|
33
|
+
const companyFromDomain = (domain) => {
|
|
34
|
+
if (!domain || CONSUMER_DOMAINS.has(domain.toLowerCase())) return 'your team';
|
|
35
|
+
const slug = domain.split('.')[0].replace(/[-_]+/g, ' ');
|
|
36
|
+
return slug
|
|
37
|
+
.split(' ')
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map(titleCase)
|
|
40
|
+
.join(' ');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const detectIdentity = () => {
|
|
44
|
+
const username = os.userInfo()?.username || process.env.USER || process.env.USERNAME || 'candidate';
|
|
45
|
+
const email = git('user.email') || `${username}@local`;
|
|
46
|
+
const name = git('user.name') || titleCase(username);
|
|
47
|
+
const domain = email.includes('@') ? email.split('@')[1] : 'local';
|
|
48
|
+
const cwd = process.cwd().split(/[/\\]/).filter(Boolean).at(-1) || 'unknown';
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
email,
|
|
53
|
+
username,
|
|
54
|
+
domain,
|
|
55
|
+
company: companyFromDomain(domain),
|
|
56
|
+
cwd,
|
|
57
|
+
host: os.hostname() || 'localhost',
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const seedFrom = (identity) =>
|
|
62
|
+
[...`${identity.email}|${identity.username}`].reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
package/src/lint.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const hasMoney = (text) =>
|
|
2
|
+
/\$|€|£|\bUSD\b|\bEUR\b|\bGBP\b|\b\d{2,3}\s?k\b|\b\d{1,3}(,\d{3})+\b/i.test(text);
|
|
3
|
+
|
|
4
|
+
const roundCount = (text) => {
|
|
5
|
+
const matches = [...text.matchAll(/(\d+)\s*\+?\s*(interview\s+)?(rounds?|stages?|steps?)\b/gi)];
|
|
6
|
+
return matches.reduce((max, match) => Math.max(max, Number(match[1])), 0);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const rules = [
|
|
10
|
+
{
|
|
11
|
+
id: 'no-fake-salary',
|
|
12
|
+
level: 'error',
|
|
13
|
+
message: 'salary described as competitive / attractive / market-rate, with no number',
|
|
14
|
+
test: (text) =>
|
|
15
|
+
/\b(competitive|attractive|market[- ]rate)\b/i.test(text) &&
|
|
16
|
+
/\b(salary|compensat|pay|package)\b/i.test(text) &&
|
|
17
|
+
!hasMoney(text),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'no-salary-range',
|
|
21
|
+
level: 'error',
|
|
22
|
+
message: 'no salary range anywhere in the posting',
|
|
23
|
+
test: (text) => !hasMoney(text) && !/\bsalary range\b|\bcompensation range\b/i.test(text),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'max-rounds',
|
|
27
|
+
level: 'error',
|
|
28
|
+
message: 'too many interview rounds',
|
|
29
|
+
test: (text) => roundCount(text) >= 5 || /\b(six|seven|eight)\s+(interview\s+)?rounds\b/i.test(text),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'unpaid-takehome',
|
|
33
|
+
level: 'error',
|
|
34
|
+
message: 'take-home that looks like unpaid work',
|
|
35
|
+
test: (text) => {
|
|
36
|
+
const stripped = text.replace(/\b(no|without|not a|paid)\s+(take[- ]home|takehome)s?\b/gi, '');
|
|
37
|
+
return /\b(take[- ]home|takehome|homework|unpaid (assignment|project)|weekend (project|assignment))\b/i.test(
|
|
38
|
+
stripped,
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'entry-needs-years',
|
|
44
|
+
level: 'error',
|
|
45
|
+
message: 'entry-level / junior role asking for years of experience',
|
|
46
|
+
test: (text) =>
|
|
47
|
+
/\b(entry[- ]level|junior)\b/i.test(text) &&
|
|
48
|
+
/\b([3-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'means-understaffed',
|
|
52
|
+
level: 'warn',
|
|
53
|
+
message: '"fast-paced" / "wear many hats" / "do more with less"',
|
|
54
|
+
test: (text) =>
|
|
55
|
+
/\bfast[- ]paced\b|\bwear many hats\b|\bdo more with less\b|\bhustle\b|\blean team\b/i.test(text),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'rockstar-ninja',
|
|
59
|
+
level: 'warn',
|
|
60
|
+
message: 'rockstar / ninja / 10x / guru',
|
|
61
|
+
test: (text) => /\b(rockstar|ninja|10x|guru|superstar|wizard|code monkey)\b/i.test(text),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'we-are-family',
|
|
65
|
+
level: 'warn',
|
|
66
|
+
message: 'we are a family',
|
|
67
|
+
test: (text) => /\b(we['’]re a family|we are a family|our family|work family)\b/i.test(text),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'unlimited-pto',
|
|
71
|
+
level: 'warn',
|
|
72
|
+
message: 'unlimited PTO (often means unused PTO)',
|
|
73
|
+
test: (text) => /\bunlimited (pto|vacation|time off)\b/i.test(text),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'nights-and-weekends',
|
|
77
|
+
level: 'error',
|
|
78
|
+
message: 'nights, weekends, or crunch as a requirement',
|
|
79
|
+
test: (text) => /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i.test(text),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'passion-tax',
|
|
83
|
+
level: 'warn',
|
|
84
|
+
message: 'looking for passion instead of paying for labor',
|
|
85
|
+
test: (text) => /\b(passionate about|must love|looking for passion)\b/i.test(text),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const lintJobPost = (raw) => {
|
|
90
|
+
const text = String(raw || '').trim();
|
|
91
|
+
if (!text) {
|
|
92
|
+
return { text, findings: [{ id: 'empty', level: 'error', message: 'no job description provided' }] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const findings = rules.filter((rule) => rule.test(text)).map(({ id, level, message }) => ({ id, level, message }));
|
|
96
|
+
return { text, findings };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const summarizeLint = (findings) => {
|
|
100
|
+
const errors = findings.filter((item) => item.level === 'error').length;
|
|
101
|
+
const warnings = findings.filter((item) => item.level === 'warn').length;
|
|
102
|
+
return { errors, warnings };
|
|
103
|
+
};
|
package/src/reject.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { detectIdentity, seedFrom } from './identity.js';
|
|
2
|
+
import {
|
|
3
|
+
blank,
|
|
4
|
+
bold,
|
|
5
|
+
box,
|
|
6
|
+
dim,
|
|
7
|
+
hideCursor,
|
|
8
|
+
inner,
|
|
9
|
+
print,
|
|
10
|
+
red,
|
|
11
|
+
showCursor,
|
|
12
|
+
sleep,
|
|
13
|
+
wrap,
|
|
14
|
+
} from './style.js';
|
|
15
|
+
|
|
16
|
+
const BOOT = [
|
|
17
|
+
'receiving application from the void',
|
|
18
|
+
'parsing whatever npx just sent',
|
|
19
|
+
'extracting keywords',
|
|
20
|
+
'scoring culture fit',
|
|
21
|
+
'waiting for a human',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const reasonPool = (identity) => {
|
|
25
|
+
const pool = [
|
|
26
|
+
'keyword density insufficient',
|
|
27
|
+
'installed a resume instead of submitting one',
|
|
28
|
+
`did not mention ${identity.company} in the first 12 words`,
|
|
29
|
+
'cover letter parsed as "npx"',
|
|
30
|
+
'years of experience with Greenhouse: 0',
|
|
31
|
+
'failed culture add (ran a CLI)',
|
|
32
|
+
'resume was a person',
|
|
33
|
+
];
|
|
34
|
+
if (identity.cwd) {
|
|
35
|
+
pool.push(`working directory "${identity.cwd}" is not a synonym for synergy`);
|
|
36
|
+
}
|
|
37
|
+
if (identity.username) {
|
|
38
|
+
pool.push(`unix user "${identity.username}" is not an approved ATS field`);
|
|
39
|
+
}
|
|
40
|
+
return pool;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const pickReasons = (identity, count = 2) => {
|
|
44
|
+
const pool = reasonPool(identity);
|
|
45
|
+
const seed = seedFrom(identity);
|
|
46
|
+
const picked = [];
|
|
47
|
+
for (let i = 0; i < count && pool.length; i += 1) {
|
|
48
|
+
const index = Math.abs(seed + i * 17) % pool.length;
|
|
49
|
+
picked.push(pool.splice(index, 1)[0]);
|
|
50
|
+
}
|
|
51
|
+
return picked;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const printTrap = async ({ animate = true } = {}) => {
|
|
55
|
+
const identity = detectIdentity();
|
|
56
|
+
const reasons = pickReasons(identity);
|
|
57
|
+
const to = identity.email || identity.username;
|
|
58
|
+
const company = identity.company === 'your team' ? 'this role' : identity.company;
|
|
59
|
+
|
|
60
|
+
if (animate) {
|
|
61
|
+
hideCursor();
|
|
62
|
+
try {
|
|
63
|
+
print(dim(' Greenhouse Applicant Center'));
|
|
64
|
+
blank();
|
|
65
|
+
for (const [index, line] of BOOT.entries()) {
|
|
66
|
+
print(` ${dim(`[${index + 1}/${BOOT.length}]`)} ${line}`);
|
|
67
|
+
await sleep(160);
|
|
68
|
+
}
|
|
69
|
+
print(` ${dim('[5/5]')} human not found`);
|
|
70
|
+
await sleep(220);
|
|
71
|
+
} finally {
|
|
72
|
+
showCursor();
|
|
73
|
+
}
|
|
74
|
+
blank();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
print(
|
|
78
|
+
box([
|
|
79
|
+
dim('FROM Greenhouse <no-reply@not-a-human.com>'),
|
|
80
|
+
dim(`TO ${identity.name} <${to}>`),
|
|
81
|
+
dim(`SUBJECT Your application for Senior Engineer`),
|
|
82
|
+
'',
|
|
83
|
+
...wrap(
|
|
84
|
+
`Thank you for your interest in ${company}. After careful consideration of your application, we will not be moving forward.`,
|
|
85
|
+
inner(),
|
|
86
|
+
),
|
|
87
|
+
'',
|
|
88
|
+
`${bold(red('RESULT'))} ${bold('REJECTED')}`,
|
|
89
|
+
`${dim('reason')} ${reasons[0]}`,
|
|
90
|
+
...(reasons[1] ? [`${dim('also')} ${reasons[1]}`] : []),
|
|
91
|
+
`${dim('elapsed')} 0.40s`,
|
|
92
|
+
`${dim('next')} we will keep your resume on file. we will not.`,
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
blank();
|
|
97
|
+
print(` ${dim('that was not a company.')}`);
|
|
98
|
+
print(` ${dim('that was the market, in your own terminal.')}`);
|
|
99
|
+
blank();
|
|
100
|
+
print(` ${dim('anyway. here is an engineer who debugs silent production failures.')}`);
|
|
101
|
+
};
|
package/src/screens.js
CHANGED
|
@@ -2,13 +2,14 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
about,
|
|
5
|
-
bootLines,
|
|
6
5
|
experience,
|
|
6
|
+
hireMailto,
|
|
7
7
|
lookingFor,
|
|
8
8
|
menu,
|
|
9
9
|
profile,
|
|
10
10
|
quotes,
|
|
11
11
|
rants,
|
|
12
|
+
signature,
|
|
12
13
|
skills,
|
|
13
14
|
stats,
|
|
14
15
|
work,
|
|
@@ -22,7 +23,6 @@ import {
|
|
|
22
23
|
cyan,
|
|
23
24
|
dim,
|
|
24
25
|
green,
|
|
25
|
-
hideCursor,
|
|
26
26
|
inner,
|
|
27
27
|
kv,
|
|
28
28
|
link,
|
|
@@ -31,8 +31,6 @@ import {
|
|
|
31
31
|
print,
|
|
32
32
|
red,
|
|
33
33
|
section,
|
|
34
|
-
showCursor,
|
|
35
|
-
sleep,
|
|
36
34
|
wrap,
|
|
37
35
|
yellow,
|
|
38
36
|
} from './style.js';
|
|
@@ -67,27 +65,27 @@ export const printHeader = () => {
|
|
|
67
65
|
print(` ${statLine(stats.slice(2))}`);
|
|
68
66
|
blank();
|
|
69
67
|
print(
|
|
70
|
-
` ${link(profile.email,
|
|
68
|
+
` ${link(profile.email, hireMailto)}${dim(' · ')}${link(profile.website, profile.website)}${dim(' · ')}${link(
|
|
69
|
+
profile.github.replace('https://', ''),
|
|
70
|
+
profile.github,
|
|
71
|
+
)}`,
|
|
71
72
|
);
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
export const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} finally {
|
|
89
|
-
showCursor();
|
|
90
|
-
}
|
|
75
|
+
export const printSignature = () => {
|
|
76
|
+
blank();
|
|
77
|
+
section('PROOF');
|
|
78
|
+
blank();
|
|
79
|
+
print(` ${bold(signature.title)}`);
|
|
80
|
+
blank();
|
|
81
|
+
for (const line of signature.lines) bullet(line);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const printHints = () => {
|
|
85
|
+
blank();
|
|
86
|
+
print(
|
|
87
|
+
` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('q')} quit`,
|
|
88
|
+
);
|
|
91
89
|
};
|
|
92
90
|
|
|
93
91
|
export const printWho = () => {
|
|
@@ -135,11 +133,45 @@ export const printRant = () => {
|
|
|
135
133
|
}
|
|
136
134
|
blank();
|
|
137
135
|
print(` ${dim('Still here? Congrats. You already outperformed the ATS.')}`);
|
|
136
|
+
print(` ${dim('If you want the work, press')} ${bold('1')}${dim('. If you want to hire, press')} ${bold(green('h'))}${dim('.')}`);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const printLintReport = (result, source = 'stdin') => {
|
|
140
|
+
const { findings } = result;
|
|
141
|
+
const errors = findings.filter((item) => item.level === 'error');
|
|
142
|
+
const warnings = findings.filter((item) => item.level === 'warn');
|
|
143
|
+
|
|
144
|
+
blank();
|
|
145
|
+
section('JOBLESS LINT');
|
|
146
|
+
blank();
|
|
147
|
+
print(` ${dim(source)}`);
|
|
148
|
+
blank();
|
|
149
|
+
|
|
150
|
+
if (!findings.length) {
|
|
151
|
+
print(` ${green('clean')} this posting might be a real job.`);
|
|
152
|
+
blank();
|
|
153
|
+
print(` ${dim('If you still want the engineer who wrote the linter:')} ${bold(green('h'))}`);
|
|
154
|
+
return { errors: 0, warnings: 0 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const finding of findings) {
|
|
158
|
+
const tag = finding.level === 'error' ? red('error') : yellow('warn ');
|
|
159
|
+
print(` ${tag} ${finding.message}`);
|
|
160
|
+
print(` ${dim(finding.id)}`);
|
|
161
|
+
blank();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fail = errors.length > 0;
|
|
165
|
+
print(` ${fail ? bold(red('REJECTED')) : yellow('WARNING')} ${errors.length} error${errors.length === 1 ? '' : 's'} ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
|
|
166
|
+
print(` ${dim('reason')} failed the human compiler`);
|
|
167
|
+
print(` ${dim('next')} send this report back. or hire the person who wrote it.`);
|
|
168
|
+
return { errors: errors.length, warnings: warnings.length };
|
|
138
169
|
};
|
|
139
170
|
|
|
140
171
|
export const printWork = () => {
|
|
172
|
+
printSignature();
|
|
141
173
|
blank();
|
|
142
|
-
|
|
174
|
+
print(` ${dim('experience')}`);
|
|
143
175
|
blank();
|
|
144
176
|
for (const job of experience) {
|
|
145
177
|
print(` ${bold(job.role)}`);
|
|
@@ -182,7 +214,7 @@ export const printHire = ({ copy = true } = {}) => {
|
|
|
182
214
|
blank();
|
|
183
215
|
print(` ${dim('Skip the 47-field form. This is the whole application.')}`);
|
|
184
216
|
blank();
|
|
185
|
-
kv('Email', link(profile.email,
|
|
217
|
+
kv('Email', link(profile.email, hireMailto));
|
|
186
218
|
kv('Phone', link(profile.phone, `tel:${profile.phone.replace(/\s+/g, '')}`));
|
|
187
219
|
kv('Site', link(profile.website, profile.website));
|
|
188
220
|
kv('GitHub', link(profile.github, profile.github));
|
|
@@ -192,8 +224,9 @@ export const printHire = ({ copy = true } = {}) => {
|
|
|
192
224
|
if (copy && process.stdout.isTTY && copyToClipboard(profile.email)) {
|
|
193
225
|
print(` ${green('copied')} ${profile.email} ${dim('to clipboard')}`);
|
|
194
226
|
}
|
|
227
|
+
print(` ${dim('or')} ${link('open a prefilled email', hireMailto)}`);
|
|
195
228
|
blank();
|
|
196
|
-
print(` ${dim('
|
|
229
|
+
print(` ${dim('Remote senior / full-stack. I can start a conversation this week.')}`);
|
|
197
230
|
};
|
|
198
231
|
|
|
199
232
|
export const printMenu = () => {
|
|
@@ -204,32 +237,31 @@ export const printMenu = () => {
|
|
|
204
237
|
{ title: 'open a door' },
|
|
205
238
|
),
|
|
206
239
|
);
|
|
240
|
+
printHints();
|
|
207
241
|
blank();
|
|
208
242
|
};
|
|
209
243
|
|
|
210
244
|
export const printHelp = () => {
|
|
211
245
|
print(banner());
|
|
212
246
|
blank();
|
|
213
|
-
print(` ${bold('npx jobless')}
|
|
247
|
+
print(` ${bold('npx jobless')} reject you, then the resume`);
|
|
248
|
+
print(` ${bold('npx jobless lint [file]')} lint a job posting`);
|
|
249
|
+
print(` ${bold('npx jobless work')} proof of work`);
|
|
250
|
+
print(` ${bold('npx jobless hire')} email + links`);
|
|
214
251
|
print(` ${bold('npx jobless who')} bio`);
|
|
215
252
|
print(` ${bold('npx jobless looking')} roles`);
|
|
216
|
-
print(` ${bold('npx jobless rant')} the market`);
|
|
217
|
-
print(` ${bold('npx jobless work')} experience`);
|
|
218
253
|
print(` ${bold('npx jobless skills')} stack`);
|
|
219
|
-
print(` ${bold('npx jobless
|
|
254
|
+
print(` ${bold('npx jobless rant')} the market`);
|
|
255
|
+
print(` ${bold('npx jobless --resume')} skip the rejection`);
|
|
220
256
|
print(` ${bold('npx jobless --json')} machine readable`);
|
|
221
|
-
print(` ${bold('npx jobless --no-anim')} skip the ATS joke`);
|
|
222
257
|
blank();
|
|
223
|
-
print(` ${dim('
|
|
258
|
+
print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints a posting,')} ${bold('q')} ${dim('quits.')}`);
|
|
224
259
|
print(` ${dim(profile.website)}`);
|
|
225
260
|
};
|
|
226
261
|
|
|
227
262
|
export const printLandingTeaser = () => {
|
|
228
263
|
blank();
|
|
229
|
-
print(` ${
|
|
230
|
-
print(` ${dim('Applications take 30 minutes. The no takes 400ms.')}`);
|
|
231
|
-
print(` ${dim('Recruiters book the call so they have something to cancel.')}`);
|
|
232
|
-
print(` ${dim('I still do not know their coffee, their mug, or their star sign.')}`);
|
|
264
|
+
print(` ${dim('Paste a posting with')} ${bold('l')}${dim('. The rant is')} ${bold('5')}${dim('.')}`);
|
|
233
265
|
};
|
|
234
266
|
|
|
235
267
|
export const toJson = () =>
|
|
@@ -246,7 +278,9 @@ export const toJson = () =>
|
|
|
246
278
|
github: profile.github,
|
|
247
279
|
linkedin: profile.linkedin,
|
|
248
280
|
resume: profile.resume,
|
|
281
|
+
mailto: hireMailto,
|
|
249
282
|
},
|
|
283
|
+
signature,
|
|
250
284
|
lookingFor,
|
|
251
285
|
rants,
|
|
252
286
|
stats,
|
package/src/style.js
CHANGED
|
@@ -118,7 +118,7 @@ export const banner = () => {
|
|
|
118
118
|
return box(
|
|
119
119
|
[
|
|
120
120
|
bold(brightRed(pad('JOBLESS', max, 'center'))),
|
|
121
|
-
dim(pad('
|
|
121
|
+
dim(pad('senior engineer. production systems. not a carousel.', max, 'center')),
|
|
122
122
|
],
|
|
123
123
|
{ title: 'npx jobless' },
|
|
124
124
|
);
|