jobless 1.4.0 → 1.6.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
@@ -1,82 +1,148 @@
1
1
  # jobless
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/jobless)](https://www.npmjs.com/package/jobless)
4
+ [![npm downloads](https://img.shields.io/npm/dw/jobless)](https://www.npmjs.com/package/jobless)
5
+
6
+ I got invited to interview and rejected at the same time.
7
+ So I turned the hiring process into an npm package.
8
+
9
+ They asked how I handle rejection. Apparently: semantic versioning.
10
+
3
11
  ```bash
4
12
  npx jobless
5
13
  ```
6
14
 
7
- First it rejects **you**. Then it introduces an engineer.
15
+ ```text
16
+ ✔ Congratulations! We'd love to interview you.
17
+ ✔ Unfortunately, we're moving forward with others.
8
18
 
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.
19
+ ERR_HIRING_RACE_CONDITION
20
+ Application status: yesn't
10
21
 
11
- ## The trap
22
+ Interview scheduled successfully.
23
+ Candidate rejected successfully.
24
+ All systems operational.
12
25
 
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.
26
+ I couldn't fix their hiring pipeline. So I shipped my own.
27
+ ```
14
28
 
15
- Then it breaks character:
29
+ A hiring simulator, a reverse ATS, and an actual engineer behind the joke.
30
+ The simulator is satire. The contradictory interview is the origin story.
16
31
 
17
- ```
18
- that was not a company.
19
- that was the market, in your own terminal.
32
+ ## Try it. Break it. Send it to a friend.
33
+
34
+ In the interactive prompt:
20
35
 
21
- anyway. here is an engineer who debugs silent production failures.
36
+ - `r` — try again. Same pipeline, different disappointment.
37
+ - `d` — reject a fictional job posting. No file needed.
38
+ - `l` — paste a real posting; finish with an empty line.
39
+ - `c` — copy the latest result, including the opening joke.
40
+ - `h` — meet the engineer.
41
+ - `q` — leave the hiring pipeline. Finally, a working exit.
42
+
43
+ ```bash
44
+ npx jobless share # a compact, anonymous result
45
+ npx jobless share --copy # explicitly copy it
46
+ npx jobless tweet # the origin story, ready to post
47
+ npx jobless --seed=1 # another hiring incident
48
+ npx jobless --record # anonymous, no animation or prompt
49
+ npx jobless --record --seed=42 --no-color
22
50
  ```
23
51
 
24
- Then: 17 cron jobs that died after every deploy. $100K off infra. 3B+ ETB in payment rails. Press `h` to hire.
52
+ There are three hand-written scenarios. Seeds select a repeatable scenario;
53
+ `r` cycles through them. No AI API, account, or subscription needed.
54
+
55
+ ## Reject the posting for a change
25
56
 
26
- Skip the theater with `npx jobless --resume`.
57
+ ```bash
58
+ npx jobless lint --demo
59
+ ```
27
60
 
28
- ## Lint the posting, not the human
61
+ Meet DemoCorp: competitive salary, six interview rounds, an unpaid take-home,
62
+ and a remote role that requires office attendance. Let it hear back from you.
29
63
 
30
64
  ```bash
31
65
  npx jobless lint posting.txt
66
+ npx jobless lint jobs/
32
67
  pbpaste | npx jobless lint
68
+ npx jobless lint --tweet posting.txt
69
+ npx jobless lint --copy posting.txt
70
+ npx jobless lint --json posting.txt
71
+ npx jobless lint --rules
72
+ ```
73
+
74
+ The score is rule-based satire, **not an objective employer rating**. It flags
75
+ phrases; it cannot judge the full context of a role or hiring process.
76
+
77
+ Errors exit with status `1`, including the intentionally bad demo. Warnings
78
+ alone exit with `0`, unless you set `--max-warnings=0`.
79
+
80
+ ```bash
81
+ npx jobless lint --fix posting.txt # proposed unified diff only
82
+ npx jobless lint --write posting.txt # explicitly overwrite the file
33
83
  ```
34
84
 
35
- Or run `npx jobless` and press `l`.
85
+ Review suggested rewrites: they include placeholder terms, not facts about the role.
86
+ `--demo` cannot be combined with file paths or `--write`.
87
+ Use `--off=rule-id` or `jobless-disable-next-line rule-id` to skip a rule.
88
+
89
+ For job postings in your own repository, see
90
+ [the example GitHub Action](examples/github-action.yml):
36
91
 
92
+ ```yaml
93
+ - run: npx --yes jobless lint --format=github jobs/
37
94
  ```
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.
95
+
96
+ ## There is an engineer behind this
97
+
98
+ I'm [Yonas Alem](https://yonasalem.vercel.app), a senior software engineer in
99
+ Addis Ababa, open to remote roles. I debug silent production failures.
100
+ This one was loud enough to become a package.
101
+
102
+ ```bash
103
+ npx jobless hire # contact details; no automatic clipboard writes
104
+ npx jobless hire --copy # explicitly copy my email
105
+ npx jobless work # three case studies and experience
106
+ npx jobless proof # the deploy that killed 17 cron jobs
107
+ npx jobless card # compact contact card
108
+ npx jobless why # the origin story
109
+ npx jobless rant # five short coping mechanisms
110
+ npx jobless --resume # go straight to the engineer
111
+ npx jobless --json # machine-readable profile
112
+ npx jobless --help # all commands
58
113
  ```
59
114
 
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.
115
+ Infra and performance claims belong to my work at Mereb. Payment volume
116
+ belongs to the products I integrated at Eaglelion, not a ledger I built.
117
+ Production code is private; the case studies explain the work and its limits.
118
+
119
+ - [Email](mailto:yonalem21@gmail.com)
120
+ - [Portfolio](https://yonasalem.vercel.app)
121
+ - [GitHub](https://github.com/Yonas21)
122
+ - [LinkedIn](https://www.linkedin.com/in/yonasalem21/)
123
+
124
+ ## Local, with boundaries
125
+
126
+ Zero runtime dependencies. Node 18+. The CLI does not apply to jobs, send email,
127
+ scrape websites, or send telemetry. Fetching it through `npx` uses npm's network.
61
128
 
62
- ## Hire
129
+ The default simulator and linter use an anonymous candidate identity. They do
130
+ not read your Git identity, username, or working directory for personalization.
131
+ `--personalize` opts into local identity lookup for the simulator's greeting;
132
+ it can read Git name/email and OS account details, but only shows your name.
133
+ `--record`, `share`, and copied simulator results always omit that local identity.
134
+ A pasted posting can still contain personal or company information: inspect
135
+ lint output before sharing it.
63
136
 
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)
137
+ Clipboard writes happen only when requested with `c`, `copy`, or `--copy`.
138
+ They use `pbcopy` on macOS, `clip` on Windows, or `xclip` on Linux. If copying is
139
+ unavailable, share commands leave the text visible for manual copying.
69
140
 
70
- ## Commands
141
+ ## Development
71
142
 
72
143
  ```bash
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
144
+ npm test
145
+ node bin/jobless.js
80
146
  ```
81
147
 
82
- Zero dependencies. Node 18+. Local only. It does not apply to jobs or send email.
148
+ Zero dependencies. Still dependent on someone replying.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "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.",
3
+ "version": "1.6.0",
4
+ "description": "Invited to interview and rejected at the same time. A hiring race condition, reproduced in your terminal.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "npx",
@@ -47,6 +47,7 @@
47
47
  "work": "node bin/jobless.js work",
48
48
  "rant": "node bin/jobless.js rant",
49
49
  "hire": "node bin/jobless.js hire",
50
- "lint": "node bin/jobless.js lint"
50
+ "lint": "node bin/jobless.js lint",
51
+ "test": "node test/lint.test.js"
51
52
  }
52
53
  }
package/src/cli.js CHANGED
@@ -1,99 +1,36 @@
1
- import { createInterface } from 'node:readline';
2
1
  import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { stdin } from 'node:process';
6
-
7
2
  import { menu } from './data.js';
8
- import { lintResult } from './lint.js';
9
- import { printTrap } from './reject.js';
10
- import {
11
- blank,
12
- clear,
13
- dim,
14
- print,
15
- showCursor,
16
- } from './style.js';
3
+ import { runLintCli } from './lint-cli.js';
4
+ import { interactive } from './interactive.js';
5
+ import { hiringResult, printHiring, printShare, shareText } from './hiring.js';
6
+ import { blank, dim, print, showCursor, wrap } from './style.js';
17
7
  import {
18
- printHeader,
19
- printHelp,
20
- printHire,
21
- printLandingTeaser,
22
- printLintReport,
23
- printLooking,
24
- printMenu,
25
- printRant,
26
- printSignature,
27
- printSkills,
28
- printWho,
29
- printWork,
30
- toJson,
8
+ printCard, printHeader, printHelp, printHire, printLandingTeaser,
9
+ printLooking, printMenu, printProof, printRant, printRules, printSignature,
10
+ printSkills, printTweet, printVcf, printWho, printWhy, printWork, toJson,
31
11
  } from './screens.js';
32
12
 
33
- const pkg = JSON.parse(
34
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
35
- );
36
-
13
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
14
+ const VALUE_FLAGS = new Set(['--max-warnings', '--seed', '--off', '--format', '--to']);
37
15
  const COMMANDS = {
38
- who: printWho,
39
- about: printWho,
40
- looking: printLooking,
41
- jobs: printLooking,
42
- rant: printRant,
43
- market: printRant,
44
- work: printWork,
45
- proof: printWork,
46
- experience: printWork,
47
- skills: printSkills,
48
- hire: printHire,
49
- h: printHire,
50
- links: printHire,
51
- contact: printHire,
52
- };
53
-
54
- const ask = (rl, prompt) =>
55
- new Promise((resolve) => {
56
- rl.question(prompt, resolve);
57
- });
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');
16
+ who: printWho, about: printWho, looking: printLooking, jobs: printLooking,
17
+ rant: printRant, market: printRant, work: printWork, experience: printWork,
18
+ proof: printProof, skills: printSkills, card: printCard, why: printWhy,
19
+ help: printHelp,
63
20
  };
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');
21
+ const aliases = {
22
+ q: 'quit', exit: 'quit', l: 'lint', c: 'copy', r: 'replay', d: 'demo',
23
+ h: 'hire', links: 'hire', contact: 'hire', vcard: 'vcf',
76
24
  };
77
-
78
25
  const resolveCommand = (input) => {
79
26
  const value = String(input).trim().toLowerCase();
80
- if (!value) return null;
81
- if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
82
- if (value === 'help') return 'help';
83
- if (value === 'lint' || value === 'l') return 'lint';
84
- const fromMenu = menu.find((item) => item.key === value || item.command === value);
85
- if (fromMenu) return fromMenu.command;
86
- if (COMMANDS[value]) return value;
27
+ if (aliases[value]) return aliases[value];
28
+ const item = menu.find((entry) => entry.key === value);
29
+ if (item) return item.command;
30
+ if (COMMANDS[value] || ['quit', 'lint', 'copy', 'replay', 'demo', 'share', 'hire', 'vcf', 'tweet'].includes(value)) return value;
87
31
  return null;
88
32
  };
89
33
 
90
- const runCommand = (command) => {
91
- const render = COMMANDS[command];
92
- if (!render) return false;
93
- render();
94
- return true;
95
- };
96
-
97
34
  const showResume = () => {
98
35
  printHeader();
99
36
  printSignature();
@@ -101,150 +38,118 @@ const showResume = () => {
101
38
  printMenu();
102
39
  };
103
40
 
104
- const interactive = async ({ trap }) => {
105
- if (process.stdout.isTTY) clear();
106
- if (trap) {
107
- await printTrap({ animate: true });
108
- blank();
109
- }
110
- showResume();
111
-
112
- const rl = createInterface({ input: process.stdin, output: process.stdout });
113
- try {
114
- while (true) {
115
- const answer = await ask(rl, dim(' jobless> '));
116
- const command = resolveCommand(answer);
117
-
118
- if (!command) {
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(lintResult(text, 'paste'));
133
- printMenu();
134
- continue;
135
- }
136
- if (command === 'quit') {
137
- blank();
138
- print(dim(' still jobless. still shipping.'));
139
- print(dim(' yonalem21@gmail.com'));
140
- blank();
141
- break;
142
- }
143
-
144
- if (process.stdout.isTTY) clear();
145
- printHeader();
146
- runCommand(command);
147
- printMenu();
148
- }
149
- } finally {
150
- rl.close();
151
- showCursor();
152
- }
153
- };
154
-
155
41
  const parse = (argv) => {
156
42
  const flags = new Set();
43
+ const opts = {};
157
44
  const rest = [];
158
- for (const arg of argv) {
159
- if (arg.startsWith('-')) flags.add(arg);
160
- else rest.push(arg);
161
- }
162
- return { flags, rest };
163
- };
164
45
 
165
- const runLintCli = async (files, { json = false } = {}) => {
166
- let source = 'stdin';
167
- let text = '';
46
+ const setOpt = (key, value) => {
47
+ opts[key] = opts[key] ? `${opts[key]},${value}` : value;
48
+ };
168
49
 
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;
50
+ for (let i = 0; i < argv.length; i += 1) {
51
+ const arg = argv[i];
52
+ if (arg === '--') {
53
+ rest.push(...argv.slice(i + 1));
54
+ break;
55
+ }
56
+ if (arg.startsWith('--') && arg.includes('=')) {
57
+ const eq = arg.indexOf('=');
58
+ const key = arg.slice(2, eq);
59
+ flags.add(`--${key}`);
60
+ setOpt(key, arg.slice(eq + 1));
61
+ continue;
62
+ }
63
+ if (VALUE_FLAGS.has(arg)) {
64
+ flags.add(arg);
65
+ const next = argv[i + 1];
66
+ if (next && !next.startsWith('-')) {
67
+ setOpt(arg.slice(2), next);
68
+ i += 1;
69
+ } else if (arg === '--max-warnings') {
70
+ setOpt('max-warnings', '0');
71
+ }
72
+ continue;
73
+ }
74
+ if (arg.startsWith('-')) {
75
+ flags.add(arg);
76
+ continue;
77
+ }
78
+ rest.push(arg);
180
79
  }
181
80
 
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
- }
81
+ return { flags, opts, rest };
82
+ };
189
83
 
190
- printLintReport(report);
191
- blank();
192
- print(dim(' more: npx jobless · npx jobless hire'));
84
+ const applyColorFlags = (flags, opts) => {
85
+ if (flags.has('--no-color') || opts.color === 'never') process.env.NO_COLOR = '1';
193
86
  };
194
87
 
195
88
  export const run = async (argv = []) => {
196
89
  process.stdout.on('error', (error) => {
197
90
  if (error.code === 'EPIPE') process.exit(0);
198
91
  });
199
-
200
- const { flags, rest } = parse(argv);
201
- const verb = rest[0] ? rest[0].toLowerCase() : '';
202
-
203
- if (flags.has('-h') || flags.has('--help') || verb === 'help') {
204
- printHelp();
92
+ const { flags, opts, rest } = parse(argv);
93
+ applyColorFlags(flags, opts);
94
+ const verb = rest[0]?.toLowerCase() || '';
95
+ const command = resolveCommand(verb);
96
+ const runCommand = (name, report = null) => {
97
+ if (name === 'hire') printHire({ report, to: opts.to, copy: flags.has('--copy') });
98
+ else if (name === 'vcf') printVcf({ copy: flags.has('--copy') });
99
+ else if (name === 'tweet') printTweet(report);
100
+ else COMMANDS[name]?.();
101
+ };
102
+
103
+ if (flags.has('-h') || flags.has('--help') || command === 'help') return printHelp();
104
+ if (flags.has('-v') || flags.has('--version')) return print(pkg.version);
105
+ if (verb === 'rules' || flags.has('--rules')) return printRules();
106
+
107
+ if (command === 'lint' || command === 'demo') {
108
+ const maxRaw = opts['max-warnings'];
109
+ const maxWarnings = maxRaw == null || maxRaw === '' ? null : Number(maxRaw);
110
+ await runLintCli(rest.slice(1), {
111
+ format: opts.format || (flags.has('--json') ? 'json' : 'pretty'),
112
+ fix: flags.has('--fix') || flags.has('--diff') || flags.has('--write'),
113
+ write: flags.has('--write'), copy: flags.has('--copy'),
114
+ quiet: flags.has('--quiet'), tweet: flags.has('--tweet'),
115
+ demo: command === 'demo' || flags.has('--demo'),
116
+ off: String(opts.off || '').split(',').map((id) => id.trim()).filter(Boolean),
117
+ maxWarnings: Number.isFinite(maxWarnings) ? maxWarnings : null,
118
+ });
205
119
  return;
206
120
  }
207
-
208
- if (flags.has('-v') || flags.has('--version')) {
209
- print(pkg.version);
121
+ if (flags.has('--json')) return print(toJson());
122
+ if (verb && !command) throw new Error(`Unknown command: ${verb}. Try npx jobless --help.`);
123
+
124
+ const record = flags.has('--record');
125
+ const seed = opts.seed ?? 0;
126
+ // Recording and exported results always omit local identity, even with --personalize.
127
+ const personalize = flags.has('--personalize') && !record;
128
+ if (command === 'share' || command === 'copy') {
129
+ printShare(shareText(hiringResult({ seed })), { copy: command === 'copy' || flags.has('--copy') });
210
130
  return;
211
131
  }
212
-
213
- if (verb === 'lint' || verb === 'l') {
214
- await runLintCli(rest.slice(1), { json: flags.has('--json') });
215
- return;
216
- }
217
-
218
- if (flags.has('--json')) {
219
- print(toJson());
220
- return;
221
- }
222
-
223
- const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
224
- const command = verb ? resolveCommand(verb) : null;
225
-
226
- if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
227
- printHeader();
132
+ if (command === 'quit') return;
133
+ if (command && command !== 'replay') {
134
+ if (!['why', 'tweet', 'rant'].includes(command)) printHeader();
228
135
  runCommand(command);
229
- blank();
230
- print(dim(' more: npx jobless · npx jobless hire · npx jobless --help'));
231
136
  return;
232
137
  }
233
-
234
- if (!process.stdout.isTTY) {
235
- if (!skipTrap) await printTrap({ animate: false });
236
- blank();
237
- printHeader();
238
- printSignature();
239
- printHire({ copy: false });
138
+ const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
139
+ if (!skipTrap) hiringResult({ seed }); // Validate before starting an interactive prompt.
140
+ if (record || !process.stdout.isTTY || !process.stdin.isTTY) {
141
+ if (skipTrap) showResume();
142
+ else {
143
+ await printHiring({ seed, personalize });
144
+ blank();
145
+ for (const line of wrap('Next: npx jobless share | npx jobless lint --demo')) print(dim(` ${line}`));
146
+ }
240
147
  return;
241
148
  }
242
-
243
- process.on('SIGINT', () => {
149
+ process.once('SIGINT', () => {
244
150
  showCursor();
245
151
  process.stdout.write('\n');
246
152
  process.exit(0);
247
153
  });
248
-
249
- await interactive({ trap: !skipTrap });
154
+ await interactive({ trap: !skipTrap, seed, personalize, resolveCommand, runCommand, showResume });
250
155
  };
package/src/clip.js ADDED
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ export const copyToClipboard = (text) => {
4
+ try {
5
+ if (process.platform === 'darwin') {
6
+ execFileSync('pbcopy', { input: text });
7
+ return true;
8
+ }
9
+ if (process.platform === 'win32') {
10
+ execFileSync('clip', { input: text, windowsHide: true });
11
+ return true;
12
+ }
13
+ execFileSync('xclip', ['-selection', 'clipboard'], { input: text });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ };