jobless 1.2.0 → 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,86 +4,67 @@
4
4
  npx jobless
5
5
  ```
6
6
 
7
- A terminal resume for **Yonas Alem**, senior software engineer. Remote. Production systems across payments, gaming, ecommerce, and AWS.
7
+ First it rejects **you**. Then it introduces an engineer.
8
8
 
9
- The joke is optional. The work is the point.
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
- ## What you get in 10 seconds
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
- PROOF
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
- ```bash
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
- ## Hire me
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
- Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
26
+ Skip the theater with `npx jobless --resume`.
55
27
 
56
- ## The market
28
+ ## Lint the posting, not the human
57
29
 
58
- Dessert. Press `5` or run `npx jobless rant`.
59
-
60
- ### I do not know their coffee, their mug, or their stars
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
- Upload the PDF. Type the PDF. Culture quiz. Submit. Rejected in 400ms. Five years in production, filtered out for missing "synergy." Cool product. Would not ship it.
35
+ Or run `npx jobless` and press `l`.
67
36
 
68
- ### They booked the call so they had something to cancel
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
+ ```
69
48
 
70
- "Loved your profile. Thursday 10am?" Shirt with buttons. 9:58am: other candidates. I debug silent failures for a living. Yours is not that silent.
49
+ Same idea as ESLint. The file is a job description. The company has to pass.
71
50
 
72
- ### LinkedIn: 40% more thought leadership, 0% more jobs
51
+ ## Hire
73
52
 
74
- A jobs board that accidentally had a feed. Now a feed that accidentally has jobs.
53
+ - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
54
+ - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
55
+ - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
56
+ - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
57
+ - Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
75
58
 
76
59
  ## Commands
77
60
 
78
61
  ```bash
79
- npx jobless # resume first, rant if you ask
80
- npx jobless work # proof
81
- npx jobless hire # email + links
82
- npx jobless who # bio
83
- npx jobless looking # roles
84
- npx jobless skills # stack
85
- npx jobless rant # the market
86
- npx jobless --joke # ATS rejection intro
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
87
68
  ```
88
69
 
89
- 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.2.0",
4
- "description": "npx jobless — senior software engineer terminal resume. Production systems, not carousels.",
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
  ],
@@ -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 { lintJobPost } 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 interactive = async ({ joke }) => {
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(lintJobPost(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,46 @@ const interactive = async ({ joke }) => {
118
153
  };
119
154
 
120
155
  const parse = (argv) => {
121
- const flags = new Set(argv.filter((arg) => arg.startsWith('-')));
122
- 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
+ }
123
162
  return { flags, rest };
124
163
  };
125
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
+
126
187
  export const run = async (argv = []) => {
188
+ process.stdout.on('error', (error) => {
189
+ if (error.code === 'EPIPE') process.exit(0);
190
+ });
191
+
127
192
  const { flags, rest } = parse(argv);
193
+ const verb = rest[0] ? rest[0].toLowerCase() : '';
128
194
 
129
- if (flags.has('-h') || flags.has('--help') || rest[0] === 'help') {
195
+ if (flags.has('-h') || flags.has('--help') || verb === 'help') {
130
196
  printHelp();
131
197
  return;
132
198
  }
@@ -141,10 +207,15 @@ export const run = async (argv = []) => {
141
207
  return;
142
208
  }
143
209
 
144
- const joke = flags.has('--joke');
145
- const command = rest[0] ? resolveCommand(rest[0]) : null;
210
+ if (verb === 'lint' || verb === 'l') {
211
+ await runLintCli(rest.slice(1));
212
+ return;
213
+ }
146
214
 
147
- if (command && command !== 'quit' && command !== 'help') {
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') {
148
219
  printHeader();
149
220
  runCommand(command);
150
221
  blank();
@@ -153,9 +224,10 @@ export const run = async (argv = []) => {
153
224
  }
154
225
 
155
226
  if (!process.stdout.isTTY) {
227
+ if (!skipTrap) await printTrap({ animate: false });
228
+ blank();
156
229
  printHeader();
157
230
  printSignature();
158
- printWho();
159
231
  printHire({ copy: false });
160
232
  return;
161
233
  }
@@ -166,5 +238,5 @@ export const run = async (argv = []) => {
166
238
  process.exit(0);
167
239
  });
168
240
 
169
- await interactive({ joke });
241
+ await interactive({ trap: !skipTrap });
170
242
  };
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
- ];
@@ -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,7 +2,6 @@ import { execFileSync } from 'node:child_process';
2
2
 
3
3
  import {
4
4
  about,
5
- bootLines,
6
5
  experience,
7
6
  hireMailto,
8
7
  lookingFor,
@@ -24,7 +23,6 @@ import {
24
23
  cyan,
25
24
  dim,
26
25
  green,
27
- hideCursor,
28
26
  inner,
29
27
  kv,
30
28
  link,
@@ -33,8 +31,6 @@ import {
33
31
  print,
34
32
  red,
35
33
  section,
36
- showCursor,
37
- sleep,
38
34
  wrap,
39
35
  yellow,
40
36
  } from './style.js';
@@ -88,29 +84,10 @@ export const printSignature = () => {
88
84
  export const printHints = () => {
89
85
  blank();
90
86
  print(
91
- ` ${bold(green('h'))} hire me ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('5')} rant ${dim('·')} ${bold('q')} quit`,
87
+ ` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('q')} quit`,
92
88
  );
93
89
  };
94
90
 
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
91
  export const printWho = () => {
115
92
  blank();
116
93
  section('WHO');
@@ -159,6 +136,38 @@ export const printRant = () => {
159
136
  print(` ${dim('If you want the work, press')} ${bold('1')}${dim('. If you want to hire, press')} ${bold(green('h'))}${dim('.')}`);
160
137
  };
161
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 };
169
+ };
170
+
162
171
  export const printWork = () => {
163
172
  printSignature();
164
173
  blank();
@@ -235,23 +244,24 @@ export const printMenu = () => {
235
244
  export const printHelp = () => {
236
245
  print(banner());
237
246
  blank();
238
- print(` ${bold('npx jobless')} interactive resume`);
247
+ print(` ${bold('npx jobless')} reject you, then the resume`);
248
+ print(` ${bold('npx jobless lint [file]')} lint a job posting`);
239
249
  print(` ${bold('npx jobless work')} proof of work`);
240
250
  print(` ${bold('npx jobless hire')} email + links`);
241
251
  print(` ${bold('npx jobless who')} bio`);
242
252
  print(` ${bold('npx jobless looking')} roles`);
243
253
  print(` ${bold('npx jobless skills')} stack`);
244
254
  print(` ${bold('npx jobless rant')} the market`);
245
- print(` ${bold('npx jobless --joke')} ATS rejection intro`);
255
+ print(` ${bold('npx jobless --resume')} skip the rejection`);
246
256
  print(` ${bold('npx jobless --json')} machine readable`);
247
257
  blank();
248
- print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('1')} ${dim('is proof,')} ${bold('q')} ${dim('quits.')}`);
258
+ print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints a posting,')} ${bold('q')} ${dim('quits.')}`);
249
259
  print(` ${dim(profile.website)}`);
250
260
  };
251
261
 
252
262
  export const printLandingTeaser = () => {
253
263
  blank();
254
- print(` ${dim('The rant is option')} ${bold('5')}${dim('. The work is already on screen.')}`);
264
+ print(` ${dim('Paste a posting with')} ${bold('l')}${dim('. The rant is')} ${bold('5')}${dim('.')}`);
255
265
  };
256
266
 
257
267
  export const toJson = () =>