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 CHANGED
@@ -4,63 +4,67 @@
4
4
  npx jobless
5
5
  ```
6
6
 
7
- A terminal resume for a broken job market. LinkedIn is a content farm with a jobs tab. Applications take 30 minutes. The no takes 400ms. Recruiters book the call so they have something to cancel. I still do not know their coffee, their mug, or their star sign.
7
+ First it rejects **you**. Then it introduces an engineer.
8
8
 
9
- ## The market
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
- ### I do not know their coffee, their mug, or their stars
11
+ ## The trap
12
12
 
13
- The job posting said TypeScript, PostgreSQL, and "excellent communication." The actual exam is telepathy. What is on the recruiter's mind at 10:02am. Oat milk or "just black, I ship." What color is the mug. Company merch that says world-class talent, or the chipped navy one from a 2019 conference that still has a lipstick ghost. What is their star sign. A Virgo who will reject you for a missing Oxford comma. A Gemini who double-booked you with someone named also-Yonas. A Scorpio who already decided during the handshake.
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
- I can trace a silent cron death across 17 processes. I cannot see into their mug. Apparently that was the seniority gap. Next round I will bring a natal chart, a color swatch, and a cortado. Or we could talk about the actual system.
15
+ Then it breaks character:
16
16
 
17
- ### I applied for 30 minutes. The bot needed 400ms
18
-
19
- The modern application is unpaid data entry with extra steps. Upload the PDF. Then type the PDF into twelve boxes the parser already filled wrong. Then salary in USD, GBP, and emotional damage. Then a culture quiz written by someone who has never been in the culture. Then "why this company?" as if Greenhouse is going to light a candle and read it.
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
- If ghosting is the process, skip the theater. I debug silent failures for a living. Yours is not that silent.
21
+ anyway. here is an engineer who debugs silent production failures.
22
+ ```
28
23
 
29
- ### LinkedIn: 40% more thought leadership, 0% more jobs
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
- LinkedIn used to be a jobs board that accidentally had a feed. Now it is a feed that accidentally has jobs. Everyone is humbled. Everyone is announcing. Everyone is a thought leader on a Tuesday. The algorithm loves a carousel called "10 habits of senior engineers" from someone who has never been on-call. It does not love the person who actually kept production up.
26
+ Skip the theater with `npx jobless --resume`.
32
27
 
33
- I did not open this app to like your new chapter. I opened it because I can build the thing you are posting about.
28
+ ## Lint the posting, not the human
34
29
 
35
30
  ```bash
36
- npx jobless rant
31
+ npx jobless lint posting.txt
32
+ pbpaste | npx jobless lint
37
33
  ```
38
34
 
39
- ## Yonas Alem
35
+ Or run `npx jobless` and press `l`.
40
36
 
41
- Senior Software Engineer in Addis Ababa. Open to remote senior roles.
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
- 5+ years in production · 35% infra cost cut · ~30% performance gain · 3B+ ETB payment volume
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
- - Email: yonalem21@gmail.com
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 # interactive
58
- npx jobless rant # the market, with jokes
59
- npx jobless who # who I am
60
- npx jobless looking # roles I want
61
- npx jobless work # proof of work
62
- npx jobless skills # the stack
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.1.1",
4
- "description": "npx jobless — I still do not know the recruiter's coffee, mug color, or star sign. A terminal resume for a broken job market.",
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
- "terminal",
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 interactive = async ({ animate }) => {
71
- if (process.stdout.isTTY) clear();
97
+ const showResume = () => {
72
98
  printHeader();
73
- if (animate) {
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–6 or q.')}`);
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(argv.filter((arg) => arg.startsWith('-')));
110
- const rest = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.toLowerCase());
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
- const animate = process.stdout.isTTY && !flags.has('--no-anim');
133
- const command = rest[0] ? resolveCommand(rest[0]) : null;
210
+ if (verb === 'lint' || verb === 'l') {
211
+ await runLintCli(rest.slice(1));
212
+ return;
213
+ }
134
214
 
135
- if (command && command !== 'quit') {
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 or npx jobless --help'));
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
- printWho();
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({ animate });
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: 'who', label: 'who I am' },
173
- { key: '2', command: 'looking', label: 'roles I want' },
174
- { key: '3', command: 'rant', label: 'the market, with jokes' },
175
- { key: '4', command: 'work', label: 'proof of work' },
176
- { key: '5', command: 'skills', label: 'the stack' },
177
- { key: '6', command: 'hire', label: 'hire me / links' },
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
- ];
@@ -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, `mailto:${profile.email}`)}${dim(' · ')}${link(profile.website, profile.website)}${dim(' · ')}${link(profile.github.replace('https://', ''), profile.github)}`,
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 printBoot = async () => {
75
- hideCursor();
76
- try {
77
- print(dim(' applying to a role that was closed in 2022...'));
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')} guessed white mug. it was navy. also a Virgo.`);
86
- print(` ${dim('elapsed')} 0.40s (personal best)`);
87
- print(` ${dim('note')} the loading bar is the joke. the rejection is documentary.`);
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
- section('PROOF');
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, `mailto:${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('If you got this far, you already did more than most ATS pipelines.')}`);
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')} interactive`);
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 hire')} links + email`);
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('Built because the feed would not shut up.')}`);
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(` ${yellow('LinkedIn is a content farm with a jobs tab. This is the profile.')}`);
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('a terminal resume for a broken job market', max, 'center')),
121
+ dim(pad('senior engineer. production systems. not a carousel.', max, 'center')),
122
122
  ],
123
123
  { title: 'npx jobless' },
124
124
  );