jobless 1.2.0 → 1.4.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 +53 -60
- package/package.json +6 -4
- package/src/cli.js +96 -16
- package/src/data.js +1 -8
- package/src/identity.js +62 -0
- package/src/lint.js +147 -0
- package/src/reject.js +132 -0
- package/src/screens.js +41 -29
package/README.md
CHANGED
|
@@ -4,86 +4,79 @@
|
|
|
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
|
-
|
|
14
|
-
Yonas Alem
|
|
15
|
-
Senior Software Engineer
|
|
16
|
-
Addis Ababa, Ethiopia · Open to remote senior roles
|
|
17
|
-
|
|
18
|
-
5+ years in production 35% infra cost cut
|
|
19
|
-
~30% performance gain 3B+ ETB payment volume
|
|
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.
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
The deploy that killed 17 cron jobs
|
|
23
|
-
• Recurring Amazon sync jobs silently stopped after every deploy.
|
|
24
|
-
• Traced it through BullMQ, ioredis, Node, and Railway.
|
|
25
|
-
• Then added explicit shutdown handling across 17 cron processes.
|
|
15
|
+
Then it breaks character:
|
|
26
16
|
|
|
27
|
-
h hire me · 1 proof · 5 rant · q quit
|
|
28
17
|
```
|
|
18
|
+
that was not a company.
|
|
19
|
+
that was the market, in your own terminal.
|
|
29
20
|
|
|
30
|
-
|
|
31
|
-
npx jobless hire
|
|
32
|
-
npx jobless work
|
|
33
|
-
npx jobless rant
|
|
21
|
+
anyway. here is an engineer who debugs silent production failures.
|
|
34
22
|
```
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
|
|
39
|
-
- Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
|
|
40
|
-
- GitHub: [github.com/Yonas21](https://github.com/Yonas21)
|
|
41
|
-
- LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
|
|
42
|
-
- Resume: [PDF](https://yonasalem.vercel.app/assets/resume.pdf)
|
|
43
|
-
|
|
44
|
-
In the CLI, press **`h`**.
|
|
45
|
-
|
|
46
|
-
## Proof
|
|
47
|
-
|
|
48
|
-
- Cut infrastructure cost ~35% (~$100K / year)
|
|
49
|
-
- Improved platform performance ~30%
|
|
50
|
-
- Payment rails that handled **3B+ ETB**
|
|
51
|
-
- Diagnosed sync jobs that died after every deploy; fixed shutdown across **17 cron processes**
|
|
52
|
-
- Amazon SP-API / Ads, PostgreSQL, Node.js, BullMQ, Redis, React, AWS
|
|
24
|
+
Then: 17 cron jobs that died after every deploy. $100K off infra. 3B+ ETB in payment rails. Press `h` to hire.
|
|
53
25
|
|
|
54
|
-
|
|
26
|
+
Skip the theater with `npx jobless --resume`.
|
|
55
27
|
|
|
56
|
-
##
|
|
28
|
+
## Lint the posting, not the human
|
|
57
29
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
The job posting said TypeScript. The actual exam is telepathy. Oat milk or "just black, I ship." Navy mug or emotional-support Stanley. Virgo, Gemini, or Scorpio. I can trace a silent cron death across 17 processes. I cannot see into their mug.
|
|
63
|
-
|
|
64
|
-
### I applied for 30 minutes. The bot needed 400ms
|
|
30
|
+
```bash
|
|
31
|
+
npx jobless lint posting.txt
|
|
32
|
+
pbpaste | npx jobless lint
|
|
33
|
+
```
|
|
65
34
|
|
|
66
|
-
|
|
35
|
+
Or run `npx jobless` and press `l`.
|
|
67
36
|
|
|
68
|
-
|
|
37
|
+
```
|
|
38
|
+
error salary described as competitive / attractive / market-rate, with no number
|
|
39
|
+
no-fake-salary
|
|
40
|
+
error too many interview rounds
|
|
41
|
+
max-rounds
|
|
42
|
+
warn "fast-paced" / "wear many hats" / "do more with less"
|
|
43
|
+
means-understaffed
|
|
44
|
+
|
|
45
|
+
FROM you <you@laptop>
|
|
46
|
+
TO Hiring <no-reply@not-a-human.com>
|
|
47
|
+
SUBJECT Your opening for this role
|
|
48
|
+
|
|
49
|
+
Thank you for your interest in my labor. After careful consideration of your job description, I will not be moving forward.
|
|
50
|
+
|
|
51
|
+
RESULT REJECTED
|
|
52
|
+
reason salary described as competitive / attractive / market-rate, with no number
|
|
53
|
+
also too many interview rounds
|
|
54
|
+
next we will keep your posting on file. we will not.
|
|
55
|
+
|
|
56
|
+
that was not a recruiter.
|
|
57
|
+
that was you, finally answering.
|
|
58
|
+
```
|
|
69
59
|
|
|
70
|
-
|
|
60
|
+
Same idea as ESLint. The file is a job description. Errors fail the process (`exit 1`). Warnings hold it for review. `npx jobless lint --json` prints the findings and the letter.
|
|
71
61
|
|
|
72
|
-
|
|
62
|
+
## Hire
|
|
73
63
|
|
|
74
|
-
|
|
64
|
+
- Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
|
|
65
|
+
- Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
|
|
66
|
+
- GitHub: [github.com/Yonas21](https://github.com/Yonas21)
|
|
67
|
+
- LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
|
|
68
|
+
- Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
|
|
75
69
|
|
|
76
70
|
## Commands
|
|
77
71
|
|
|
78
72
|
```bash
|
|
79
|
-
npx jobless
|
|
80
|
-
npx jobless
|
|
81
|
-
npx jobless
|
|
82
|
-
npx jobless
|
|
83
|
-
npx jobless
|
|
84
|
-
npx jobless
|
|
85
|
-
npx jobless
|
|
86
|
-
npx jobless --joke # ATS rejection intro
|
|
73
|
+
npx jobless # reject you, then the resume
|
|
74
|
+
npx jobless lint file.txt # lint a job post (exit 1 on errors)
|
|
75
|
+
npx jobless lint --json # findings + rejection letter
|
|
76
|
+
npx jobless work # proof
|
|
77
|
+
npx jobless hire # email + links
|
|
78
|
+
npx jobless rant # the market
|
|
79
|
+
npx jobless --resume # skip the rejection
|
|
87
80
|
```
|
|
88
81
|
|
|
89
|
-
Zero dependencies. Node 18+.
|
|
82
|
+
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.4.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
|
],
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"who": "node bin/jobless.js who",
|
|
46
47
|
"work": "node bin/jobless.js work",
|
|
47
48
|
"rant": "node bin/jobless.js rant",
|
|
48
|
-
"hire": "node bin/jobless.js hire"
|
|
49
|
+
"hire": "node bin/jobless.js hire",
|
|
50
|
+
"lint": "node bin/jobless.js lint"
|
|
49
51
|
}
|
|
50
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 { lintResult } from './lint.js';
|
|
9
|
+
import { printTrap } from './reject.js';
|
|
7
10
|
import {
|
|
8
11
|
blank,
|
|
9
12
|
clear,
|
|
@@ -12,11 +15,11 @@ 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,
|
|
@@ -53,11 +56,31 @@ const ask = (rl, prompt) =>
|
|
|
53
56
|
rl.question(prompt, resolve);
|
|
54
57
|
});
|
|
55
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
|
+
|
|
56
78
|
const resolveCommand = (input) => {
|
|
57
79
|
const value = String(input).trim().toLowerCase();
|
|
58
80
|
if (!value) return null;
|
|
59
81
|
if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
|
|
60
82
|
if (value === 'help') return 'help';
|
|
83
|
+
if (value === 'lint' || value === 'l') return 'lint';
|
|
61
84
|
const fromMenu = menu.find((item) => item.key === value || item.command === value);
|
|
62
85
|
if (fromMenu) return fromMenu.command;
|
|
63
86
|
if (COMMANDS[value]) return value;
|
|
@@ -71,16 +94,20 @@ const runCommand = (command) => {
|
|
|
71
94
|
return true;
|
|
72
95
|
};
|
|
73
96
|
|
|
74
|
-
const
|
|
75
|
-
if (process.stdout.isTTY) clear();
|
|
97
|
+
const showResume = () => {
|
|
76
98
|
printHeader();
|
|
77
99
|
printSignature();
|
|
78
|
-
if (joke) {
|
|
79
|
-
blank();
|
|
80
|
-
await printBoot();
|
|
81
|
-
}
|
|
82
100
|
printLandingTeaser();
|
|
83
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();
|
|
84
111
|
|
|
85
112
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
86
113
|
try {
|
|
@@ -89,7 +116,7 @@ const interactive = async ({ joke }) => {
|
|
|
89
116
|
const command = resolveCommand(answer);
|
|
90
117
|
|
|
91
118
|
if (!command) {
|
|
92
|
-
print(` ${dim('unknown. try 1–5, h to hire, or q.')}`);
|
|
119
|
+
print(` ${dim('unknown. try 1–5, l to lint, h to hire, or q.')}`);
|
|
93
120
|
continue;
|
|
94
121
|
}
|
|
95
122
|
if (command === 'help') {
|
|
@@ -98,6 +125,14 @@ const interactive = async ({ joke }) => {
|
|
|
98
125
|
printMenu();
|
|
99
126
|
continue;
|
|
100
127
|
}
|
|
128
|
+
if (command === 'lint') {
|
|
129
|
+
const text = await readPaste(rl);
|
|
130
|
+
if (process.stdout.isTTY) clear();
|
|
131
|
+
printHeader();
|
|
132
|
+
printLintReport(lintResult(text, 'paste'));
|
|
133
|
+
printMenu();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
101
136
|
if (command === 'quit') {
|
|
102
137
|
blank();
|
|
103
138
|
print(dim(' still jobless. still shipping.'));
|
|
@@ -118,15 +153,54 @@ const interactive = async ({ joke }) => {
|
|
|
118
153
|
};
|
|
119
154
|
|
|
120
155
|
const parse = (argv) => {
|
|
121
|
-
const flags = new Set(
|
|
122
|
-
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
|
+
}
|
|
123
162
|
return { flags, rest };
|
|
124
163
|
};
|
|
125
164
|
|
|
165
|
+
const runLintCli = async (files, { json = false } = {}) => {
|
|
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
|
+
const report = lintResult(text, source);
|
|
183
|
+
if (!report.ok) process.exitCode = 1;
|
|
184
|
+
|
|
185
|
+
if (json) {
|
|
186
|
+
print(JSON.stringify(report, null, 2));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
printLintReport(report);
|
|
191
|
+
blank();
|
|
192
|
+
print(dim(' more: npx jobless · npx jobless hire'));
|
|
193
|
+
};
|
|
194
|
+
|
|
126
195
|
export const run = async (argv = []) => {
|
|
196
|
+
process.stdout.on('error', (error) => {
|
|
197
|
+
if (error.code === 'EPIPE') process.exit(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
127
200
|
const { flags, rest } = parse(argv);
|
|
201
|
+
const verb = rest[0] ? rest[0].toLowerCase() : '';
|
|
128
202
|
|
|
129
|
-
if (flags.has('-h') || flags.has('--help') ||
|
|
203
|
+
if (flags.has('-h') || flags.has('--help') || verb === 'help') {
|
|
130
204
|
printHelp();
|
|
131
205
|
return;
|
|
132
206
|
}
|
|
@@ -136,15 +210,20 @@ export const run = async (argv = []) => {
|
|
|
136
210
|
return;
|
|
137
211
|
}
|
|
138
212
|
|
|
213
|
+
if (verb === 'lint' || verb === 'l') {
|
|
214
|
+
await runLintCli(rest.slice(1), { json: flags.has('--json') });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
139
218
|
if (flags.has('--json')) {
|
|
140
219
|
print(toJson());
|
|
141
220
|
return;
|
|
142
221
|
}
|
|
143
222
|
|
|
144
|
-
const
|
|
145
|
-
const command =
|
|
223
|
+
const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
|
|
224
|
+
const command = verb ? resolveCommand(verb) : null;
|
|
146
225
|
|
|
147
|
-
if (command && command !== 'quit' && command !== 'help') {
|
|
226
|
+
if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
|
|
148
227
|
printHeader();
|
|
149
228
|
runCommand(command);
|
|
150
229
|
blank();
|
|
@@ -153,9 +232,10 @@ export const run = async (argv = []) => {
|
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
if (!process.stdout.isTTY) {
|
|
235
|
+
if (!skipTrap) await printTrap({ animate: false });
|
|
236
|
+
blank();
|
|
156
237
|
printHeader();
|
|
157
238
|
printSignature();
|
|
158
|
-
printWho();
|
|
159
239
|
printHire({ copy: false });
|
|
160
240
|
return;
|
|
161
241
|
}
|
|
@@ -166,5 +246,5 @@ export const run = async (argv = []) => {
|
|
|
166
246
|
process.exit(0);
|
|
167
247
|
});
|
|
168
248
|
|
|
169
|
-
await interactive({
|
|
249
|
+
await interactive({ trap: !skipTrap });
|
|
170
250
|
};
|
package/src/data.js
CHANGED
|
@@ -189,14 +189,7 @@ export const menu = [
|
|
|
189
189
|
{ key: '3', command: 'looking', label: 'roles I want' },
|
|
190
190
|
{ key: '4', command: 'skills', label: 'the stack' },
|
|
191
191
|
{ key: '5', command: 'rant', label: 'the market, with jokes' },
|
|
192
|
+
{ key: 'l', command: 'lint', label: 'lint a job post' },
|
|
192
193
|
{ key: 'h', command: 'hire', label: 'hire me' },
|
|
193
194
|
{ key: 'q', command: 'quit', label: 'leave the void' },
|
|
194
195
|
];
|
|
195
|
-
|
|
196
|
-
export const bootLines = [
|
|
197
|
-
'uploading resume.pdf',
|
|
198
|
-
'guessing oat milk vs just-black-I-ship',
|
|
199
|
-
'guessing navy mug vs emotional-support Stanley',
|
|
200
|
-
'checking if Mercury is in Greenhouse',
|
|
201
|
-
'404 human',
|
|
202
|
-
];
|
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,147 @@
|
|
|
1
|
+
import { detectIdentity } from './identity.js';
|
|
2
|
+
|
|
3
|
+
const hasMoney = (text) =>
|
|
4
|
+
/\$|€|£|\bUSD\b|\bEUR\b|\bGBP\b|\b\d{2,3}\s?k\b|\b\d{1,3}(,\d{3})+\b/i.test(text);
|
|
5
|
+
|
|
6
|
+
const roundCount = (text) => {
|
|
7
|
+
const matches = [...text.matchAll(/(\d+)\s*\+?\s*(interview\s+)?(rounds?|stages?|steps?)\b/gi)];
|
|
8
|
+
return matches.reduce((max, match) => Math.max(max, Number(match[1])), 0);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const rules = [
|
|
12
|
+
{
|
|
13
|
+
id: 'no-fake-salary',
|
|
14
|
+
level: 'error',
|
|
15
|
+
message: 'salary described as competitive / attractive / market-rate, with no number',
|
|
16
|
+
test: (text) =>
|
|
17
|
+
/\b(competitive|attractive|market[- ]rate)\b/i.test(text) &&
|
|
18
|
+
/\b(salary|compensat|pay|package)\b/i.test(text) &&
|
|
19
|
+
!hasMoney(text),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'no-salary-range',
|
|
23
|
+
level: 'error',
|
|
24
|
+
message: 'no salary range anywhere in the posting',
|
|
25
|
+
test: (text) => !hasMoney(text) && !/\bsalary range\b|\bcompensation range\b/i.test(text),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'max-rounds',
|
|
29
|
+
level: 'error',
|
|
30
|
+
message: 'too many interview rounds',
|
|
31
|
+
test: (text) => roundCount(text) >= 5 || /\b(six|seven|eight)\s+(interview\s+)?rounds\b/i.test(text),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'unpaid-takehome',
|
|
35
|
+
level: 'error',
|
|
36
|
+
message: 'take-home that looks like unpaid work',
|
|
37
|
+
test: (text) => {
|
|
38
|
+
const stripped = text.replace(/\b(no|without|not a|paid)\s+(take[- ]home|takehome)s?\b/gi, '');
|
|
39
|
+
return /\b(take[- ]home|takehome|homework|unpaid (assignment|project)|weekend (project|assignment))\b/i.test(
|
|
40
|
+
stripped,
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'entry-needs-years',
|
|
46
|
+
level: 'error',
|
|
47
|
+
message: 'entry-level / junior role asking for years of experience',
|
|
48
|
+
test: (text) =>
|
|
49
|
+
/\b(entry[- ]level|junior)\b/i.test(text) &&
|
|
50
|
+
/\b([3-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'means-understaffed',
|
|
54
|
+
level: 'warn',
|
|
55
|
+
message: '"fast-paced" / "wear many hats" / "do more with less"',
|
|
56
|
+
test: (text) =>
|
|
57
|
+
/\bfast[- ]paced\b|\bwear many hats\b|\bdo more with less\b|\bhustle\b|\blean team\b/i.test(text),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'rockstar-ninja',
|
|
61
|
+
level: 'warn',
|
|
62
|
+
message: 'rockstar / ninja / 10x / guru',
|
|
63
|
+
test: (text) => /\b(rockstar|ninja|10x|guru|superstar|wizard|code monkey)\b/i.test(text),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'we-are-family',
|
|
67
|
+
level: 'warn',
|
|
68
|
+
message: 'we are a family',
|
|
69
|
+
test: (text) => /\b(we['’]re a family|we are a family|our family|work family)\b/i.test(text),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'unlimited-pto',
|
|
73
|
+
level: 'warn',
|
|
74
|
+
message: 'unlimited PTO (often means unused PTO)',
|
|
75
|
+
test: (text) => /\bunlimited (pto|vacation|time off)\b/i.test(text),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'nights-and-weekends',
|
|
79
|
+
level: 'error',
|
|
80
|
+
message: 'nights, weekends, or crunch as a requirement',
|
|
81
|
+
test: (text) => /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i.test(text),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'passion-tax',
|
|
85
|
+
level: 'warn',
|
|
86
|
+
message: 'looking for passion instead of paying for labor',
|
|
87
|
+
test: (text) => /\b(passionate about|must love|looking for passion)\b/i.test(text),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const lintJobPost = (raw) => {
|
|
92
|
+
const text = String(raw || '').trim();
|
|
93
|
+
if (!text) {
|
|
94
|
+
return { text, findings: [{ id: 'empty', level: 'error', message: 'no job description provided' }] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const findings = rules.filter((rule) => rule.test(text)).map(({ id, level, message }) => ({ id, level, message }));
|
|
98
|
+
return { text, findings };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const summarizeLint = (findings) => {
|
|
102
|
+
const errors = findings.filter((item) => item.level === 'error').length;
|
|
103
|
+
const warnings = findings.filter((item) => item.level === 'warn').length;
|
|
104
|
+
return { errors, warnings };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const postingMeta = (text) => {
|
|
108
|
+
const roleMatch = String(text || '').match(
|
|
109
|
+
/\b(?:senior|staff|principal|lead|junior|entry[- ]level)?[ \t]*(?:full[- ]stack|frontend|backend|software|platform)?[ \t]*(?:engineer|developer|designer)\b/i,
|
|
110
|
+
);
|
|
111
|
+
const atMatch = String(text || '').match(
|
|
112
|
+
/\b(?:at|@|join)[ \t]+([A-Z][A-Za-z0-9&.'\-]{1,32}(?:[ \t]+[A-Z][A-Za-z0-9&.'\-]{1,20}){0,2})/,
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
role: roleMatch ? roleMatch[0].replace(/\s+/g, ' ').trim() : 'this role',
|
|
116
|
+
company: atMatch ? atMatch[1].trim() : 'Hiring',
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const lintResult = (raw, source = 'stdin') => {
|
|
121
|
+
const { text, findings } = lintJobPost(raw);
|
|
122
|
+
const { errors, warnings } = summarizeLint(findings);
|
|
123
|
+
const identity = detectIdentity();
|
|
124
|
+
const meta = postingMeta(text);
|
|
125
|
+
const errorReasons = findings.filter((item) => item.level === 'error').map((item) => item.message);
|
|
126
|
+
const warnReasons = findings.filter((item) => item.level === 'warn').map((item) => item.message);
|
|
127
|
+
const reasons = (errorReasons.length ? errorReasons : warnReasons).slice(0, 2);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
source,
|
|
131
|
+
ok: errors === 0,
|
|
132
|
+
errors,
|
|
133
|
+
warnings,
|
|
134
|
+
findings,
|
|
135
|
+
meta,
|
|
136
|
+
letter:
|
|
137
|
+
findings.length === 0
|
|
138
|
+
? null
|
|
139
|
+
: {
|
|
140
|
+
from: `${identity.name} <${identity.email}>`,
|
|
141
|
+
to: `${meta.company} <no-reply@not-a-human.com>`,
|
|
142
|
+
subject: `Your opening for ${meta.role}`,
|
|
143
|
+
result: errors > 0 ? 'REJECTED' : 'HELD FOR REVIEW',
|
|
144
|
+
reasons,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
};
|
package/src/reject.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
yellow,
|
|
15
|
+
} from './style.js';
|
|
16
|
+
|
|
17
|
+
const BOOT = [
|
|
18
|
+
'receiving application from the void',
|
|
19
|
+
'parsing whatever npx just sent',
|
|
20
|
+
'extracting keywords',
|
|
21
|
+
'scoring culture fit',
|
|
22
|
+
'waiting for a human',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const reasonPool = (identity) => {
|
|
26
|
+
const pool = [
|
|
27
|
+
'keyword density insufficient',
|
|
28
|
+
'installed a resume instead of submitting one',
|
|
29
|
+
`did not mention ${identity.company} in the first 12 words`,
|
|
30
|
+
'cover letter parsed as "npx"',
|
|
31
|
+
'years of experience with Greenhouse: 0',
|
|
32
|
+
'failed culture add (ran a CLI)',
|
|
33
|
+
'resume was a person',
|
|
34
|
+
];
|
|
35
|
+
if (identity.cwd) {
|
|
36
|
+
pool.push(`working directory "${identity.cwd}" is not a synonym for synergy`);
|
|
37
|
+
}
|
|
38
|
+
if (identity.username) {
|
|
39
|
+
pool.push(`unix user "${identity.username}" is not an approved ATS field`);
|
|
40
|
+
}
|
|
41
|
+
return pool;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const pickReasons = (identity, count = 2) => {
|
|
45
|
+
const pool = reasonPool(identity);
|
|
46
|
+
const seed = seedFrom(identity);
|
|
47
|
+
const picked = [];
|
|
48
|
+
for (let i = 0; i < count && pool.length; i += 1) {
|
|
49
|
+
const index = Math.abs(seed + i * 17) % pool.length;
|
|
50
|
+
picked.push(pool.splice(index, 1)[0]);
|
|
51
|
+
}
|
|
52
|
+
return picked;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const printTrap = async ({ animate = true } = {}) => {
|
|
56
|
+
const identity = detectIdentity();
|
|
57
|
+
const reasons = pickReasons(identity);
|
|
58
|
+
const to = identity.email || identity.username;
|
|
59
|
+
const company = identity.company === 'your team' ? 'this role' : identity.company;
|
|
60
|
+
|
|
61
|
+
if (animate) {
|
|
62
|
+
hideCursor();
|
|
63
|
+
try {
|
|
64
|
+
print(dim(' Greenhouse Applicant Center'));
|
|
65
|
+
blank();
|
|
66
|
+
for (const [index, line] of BOOT.entries()) {
|
|
67
|
+
print(` ${dim(`[${index + 1}/${BOOT.length}]`)} ${line}`);
|
|
68
|
+
await sleep(160);
|
|
69
|
+
}
|
|
70
|
+
print(` ${dim('[5/5]')} human not found`);
|
|
71
|
+
await sleep(220);
|
|
72
|
+
} finally {
|
|
73
|
+
showCursor();
|
|
74
|
+
}
|
|
75
|
+
blank();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
print(
|
|
79
|
+
box([
|
|
80
|
+
dim('FROM Greenhouse <no-reply@not-a-human.com>'),
|
|
81
|
+
dim(`TO ${identity.name} <${to}>`),
|
|
82
|
+
dim(`SUBJECT Your application for Senior Engineer`),
|
|
83
|
+
'',
|
|
84
|
+
...wrap(
|
|
85
|
+
`Thank you for your interest in ${company}. After careful consideration of your application, we will not be moving forward.`,
|
|
86
|
+
inner(),
|
|
87
|
+
),
|
|
88
|
+
'',
|
|
89
|
+
`${bold(red('RESULT'))} ${bold('REJECTED')}`,
|
|
90
|
+
`${dim('reason')} ${reasons[0]}`,
|
|
91
|
+
...(reasons[1] ? [`${dim('also')} ${reasons[1]}`] : []),
|
|
92
|
+
`${dim('elapsed')} 0.40s`,
|
|
93
|
+
`${dim('next')} we will keep your resume on file. we will not.`,
|
|
94
|
+
]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
blank();
|
|
98
|
+
print(` ${dim('that was not a company.')}`);
|
|
99
|
+
print(` ${dim('that was the market, in your own terminal.')}`);
|
|
100
|
+
blank();
|
|
101
|
+
print(` ${dim('anyway. here is an engineer who debugs silent production failures.')}`);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const printCompanyReject = (letter) => {
|
|
105
|
+
if (!letter) return;
|
|
106
|
+
|
|
107
|
+
blank();
|
|
108
|
+
print(
|
|
109
|
+
box([
|
|
110
|
+
dim(`FROM ${letter.from}`),
|
|
111
|
+
dim(`TO ${letter.to}`),
|
|
112
|
+
dim(`SUBJECT ${letter.subject}`),
|
|
113
|
+
'',
|
|
114
|
+
...wrap(
|
|
115
|
+
letter.result === 'REJECTED'
|
|
116
|
+
? 'Thank you for your interest in my labor. After careful consideration of your job description, I will not be moving forward.'
|
|
117
|
+
: 'Thank you for your interest in my labor. Your posting has been held for review. Fix the warnings and resubmit.',
|
|
118
|
+
inner(),
|
|
119
|
+
),
|
|
120
|
+
'',
|
|
121
|
+
`${bold(red('RESULT'))} ${bold(letter.result === 'REJECTED' ? red(letter.result) : yellow(letter.result))}`,
|
|
122
|
+
`${dim('reason')} ${letter.reasons[0] || 'failed the human compiler'}`,
|
|
123
|
+
...(letter.reasons[1] ? [`${dim('also')} ${letter.reasons[1]}`] : []),
|
|
124
|
+
`${dim('elapsed')} 0.40s`,
|
|
125
|
+
`${dim('next')} we will keep your posting on file. we will not.`,
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
blank();
|
|
130
|
+
print(` ${dim('that was not a recruiter.')}`);
|
|
131
|
+
print(` ${dim('that was you, finally answering.')}`);
|
|
132
|
+
};
|
package/src/screens.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
import { printCompanyReject } from './reject.js';
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
about,
|
|
5
|
-
bootLines,
|
|
6
7
|
experience,
|
|
7
8
|
hireMailto,
|
|
8
9
|
lookingFor,
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
cyan,
|
|
25
26
|
dim,
|
|
26
27
|
green,
|
|
27
|
-
hideCursor,
|
|
28
28
|
inner,
|
|
29
29
|
kv,
|
|
30
30
|
link,
|
|
@@ -33,8 +33,6 @@ import {
|
|
|
33
33
|
print,
|
|
34
34
|
red,
|
|
35
35
|
section,
|
|
36
|
-
showCursor,
|
|
37
|
-
sleep,
|
|
38
36
|
wrap,
|
|
39
37
|
yellow,
|
|
40
38
|
} from './style.js';
|
|
@@ -88,29 +86,10 @@ export const printSignature = () => {
|
|
|
88
86
|
export const printHints = () => {
|
|
89
87
|
blank();
|
|
90
88
|
print(
|
|
91
|
-
` ${bold(green('h'))} hire
|
|
89
|
+
` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('q')} quit`,
|
|
92
90
|
);
|
|
93
91
|
};
|
|
94
92
|
|
|
95
|
-
export const printBoot = async () => {
|
|
96
|
-
hideCursor();
|
|
97
|
-
try {
|
|
98
|
-
print(dim(' applying to a role that was closed in 2022...'));
|
|
99
|
-
blank();
|
|
100
|
-
for (const [index, line] of bootLines.entries()) {
|
|
101
|
-
print(` ${dim(`[${String(index + 1)}/5]`)} ${line}`);
|
|
102
|
-
await sleep(180);
|
|
103
|
-
}
|
|
104
|
-
blank();
|
|
105
|
-
print(` ${bold(red('RESULT'))} ${bold('REJECTED')}`);
|
|
106
|
-
print(` ${dim('reason')} guessed white mug. it was navy. also a Virgo.`);
|
|
107
|
-
print(` ${dim('elapsed')} 0.40s (personal best)`);
|
|
108
|
-
print(` ${dim('note')} the loading bar is the joke. the work is not.`);
|
|
109
|
-
} finally {
|
|
110
|
-
showCursor();
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
|
|
114
93
|
export const printWho = () => {
|
|
115
94
|
blank();
|
|
116
95
|
section('WHO');
|
|
@@ -159,6 +138,37 @@ export const printRant = () => {
|
|
|
159
138
|
print(` ${dim('If you want the work, press')} ${bold('1')}${dim('. If you want to hire, press')} ${bold(green('h'))}${dim('.')}`);
|
|
160
139
|
};
|
|
161
140
|
|
|
141
|
+
export const printLintReport = (report) => {
|
|
142
|
+
const { findings, source, errors, warnings, letter, ok } = report;
|
|
143
|
+
|
|
144
|
+
blank();
|
|
145
|
+
section('JOBLESS LINT');
|
|
146
|
+
blank();
|
|
147
|
+
print(` ${dim(source)}`);
|
|
148
|
+
blank();
|
|
149
|
+
|
|
150
|
+
if (ok && !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 report;
|
|
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
|
+
printCompanyReject(letter);
|
|
165
|
+
blank();
|
|
166
|
+
print(
|
|
167
|
+
` ${errors} error${errors === 1 ? '' : 's'} ${warnings} warning${warnings === 1 ? '' : 's'} ${dim('·')} ${dim('hire the person who wrote the compiler:')} ${bold(green('h'))}`,
|
|
168
|
+
);
|
|
169
|
+
return report;
|
|
170
|
+
};
|
|
171
|
+
|
|
162
172
|
export const printWork = () => {
|
|
163
173
|
printSignature();
|
|
164
174
|
blank();
|
|
@@ -235,23 +245,25 @@ export const printMenu = () => {
|
|
|
235
245
|
export const printHelp = () => {
|
|
236
246
|
print(banner());
|
|
237
247
|
blank();
|
|
238
|
-
print(` ${bold('npx jobless')}
|
|
248
|
+
print(` ${bold('npx jobless')} reject you, then the resume`);
|
|
249
|
+
print(` ${bold('npx jobless lint [file]')} lint a job posting`);
|
|
250
|
+
print(` ${bold('npx jobless lint --json')} findings + rejection letter`);
|
|
239
251
|
print(` ${bold('npx jobless work')} proof of work`);
|
|
240
252
|
print(` ${bold('npx jobless hire')} email + links`);
|
|
241
253
|
print(` ${bold('npx jobless who')} bio`);
|
|
242
254
|
print(` ${bold('npx jobless looking')} roles`);
|
|
243
255
|
print(` ${bold('npx jobless skills')} stack`);
|
|
244
256
|
print(` ${bold('npx jobless rant')} the market`);
|
|
245
|
-
print(` ${bold('npx jobless --
|
|
246
|
-
print(` ${bold('npx jobless --json')} machine readable`);
|
|
257
|
+
print(` ${bold('npx jobless --resume')} skip the rejection`);
|
|
258
|
+
print(` ${bold('npx jobless --json')} machine readable resume`);
|
|
247
259
|
blank();
|
|
248
|
-
print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('
|
|
260
|
+
print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints a posting,')} ${bold('q')} ${dim('quits.')}`);
|
|
249
261
|
print(` ${dim(profile.website)}`);
|
|
250
262
|
};
|
|
251
263
|
|
|
252
264
|
export const printLandingTeaser = () => {
|
|
253
265
|
blank();
|
|
254
|
-
print(` ${dim('
|
|
266
|
+
print(` ${dim('Paste a posting with')} ${bold('l')}${dim('. The rant is')} ${bold('5')}${dim('.')}`);
|
|
255
267
|
};
|
|
256
268
|
|
|
257
269
|
export const toJson = () =>
|