jobless 1.4.0 → 1.5.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,5 +1,8 @@
1
1
  # jobless
2
2
 
3
+ [![jobless](https://img.shields.io/badge/npx-jobless-b91c1c)](https://www.npmjs.com/package/jobless)
4
+ [![npm](https://img.shields.io/npm/dw/jobless?label=npm%20downloads)](https://www.npmjs.com/package/jobless)
5
+
3
6
  ```bash
4
7
  npx jobless
5
8
  ```
@@ -10,73 +13,82 @@ First it rejects **you**. Then it introduces an engineer.
10
13
 
11
14
  ## The trap
12
15
 
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
-
15
- Then it breaks character:
16
-
17
- ```
18
- that was not a company.
19
- that was the market, in your own terminal.
20
-
21
- anyway. here is an engineer who debugs silent production failures.
22
- ```
16
+ You run the command. It pretends to be Greenhouse, Lever, Ashby, or Workday. It reads a couple of harmless local hints (git name, email, laptop user). It writes a rejection letter addressed to you, with a fake ATS score and a pile of applicants.
23
17
 
24
- Then: 17 cron jobs that died after every deploy. $100K off infra. 3B+ ETB in payment rails. Press `h` to hire.
18
+ Then it breaks character and offers `l` — paste the posting that just rejected you — or `h` to hire.
25
19
 
26
- Skip the theater with `npx jobless --resume`.
20
+ Skip the theater with `npx jobless --resume`. Record it with `--seed=42` and `--no-color`.
27
21
 
28
22
  ## Lint the posting, not the human
29
23
 
30
24
  ```bash
31
25
  npx jobless lint posting.txt
26
+ npx jobless lint jobs/
32
27
  pbpaste | npx jobless lint
28
+ npx jobless lint --fix posting.txt
29
+ npx jobless lint --format=github jobs/
30
+ npx jobless lint --tweet posting.txt
31
+ npx jobless lint --rules
33
32
  ```
34
33
 
35
- Or run `npx jobless` and press `l`.
34
+ Or run `npx jobless` and press `l`. `c` copies the last rejection letter.
36
35
 
37
36
  ```
38
- error salary described as competitive / attractive / market-rate, with no number
39
- no-fake-salary
40
37
  error too many interview rounds
41
38
  max-rounds
42
- warn "fast-paced" / "wear many hats" / "do more with less"
43
- means-understaffed
39
+ L3 Six interview rounds.
44
40
 
45
- FROM you <you@laptop>
46
- TO Hiring <no-reply@not-a-human.com>
47
- SUBJECT Your opening for this role
41
+ RESULT REJECTED
42
+ 28/100
43
+ FIT overlap Senior · Remote
44
+ ```
48
45
 
49
- Thank you for your interest in my labor. After careful consideration of your job description, I will not be moving forward.
46
+ Errors fail the process (`exit 1`). `--fix` prints a unified diff and does not write files unless you pass `--write`. `--format=github` emits `::error` annotations. `jobless-disable-next-line no-fake-salary` skips a line.
50
47
 
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.
48
+ ### GitHub Action
49
+
50
+ Copy [`examples/github-action.yml`](examples/github-action.yml):
55
51
 
56
- that was not a recruiter.
57
- that was you, finally answering.
52
+ ```yaml
53
+ - run: npx --yes jobless lint --format=github jobs/
58
54
  ```
59
55
 
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.
56
+ ### GitHub profile README
57
+
58
+ ```markdown
59
+ [![npx jobless](https://img.shields.io/badge/npx-jobless-b91c1c)](https://www.npmjs.com/package/jobless)
60
+ ```
61
+
62
+ ```bash
63
+ npx jobless
64
+ ```
61
65
 
62
66
  ## Hire
63
67
 
68
+ The numbers have owners. Infra and performance: Mereb. Payment volume: client rails at Eaglelion, not the ledger. `npx jobless work` is three case studies. `npx jobless proof` is the 17-cron write-up. Production code is private; the walkthrough is the interview.
69
+
64
70
  - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
65
71
  - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
66
72
  - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
67
73
  - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
68
74
  - Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
69
75
 
76
+ `npx jobless card` is the one-screen version. `npx jobless proof` is the 17-cron write-up. `npx jobless vcf` is a vCard. `npx jobless why` is twelve lines. `npx jobless hire --to=manager` or `--to=recruiter`. `npx jobless tweet` is 280 characters with no punchline.
77
+
70
78
  ## Commands
71
79
 
72
80
  ```bash
73
81
  npx jobless # reject you, then the resume
74
82
  npx jobless lint file.txt # lint a job post (exit 1 on errors)
83
+ npx jobless lint jobs/ # lint a directory of postings
84
+ npx jobless lint --fix # unified diff (dry-run)
75
85
  npx jobless lint --json # findings + rejection letter
76
- npx jobless work # proof
86
+ npx jobless work # three case studies
77
87
  npx jobless hire # email + links
78
88
  npx jobless rant # the market
79
89
  npx jobless --resume # skip the rejection
80
90
  ```
81
91
 
92
+ Post the command, not the punchline: `npx jobless`
93
+
82
94
  Zero dependencies. Node 18+. Local only. It does not apply to jobs or send email.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobless",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
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",
@@ -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,20 +1,25 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { readFileSync } from 'node:fs';
2
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { stdin } from 'node:process';
6
6
 
7
7
  import { menu } from './data.js';
8
- import { lintResult } from './lint.js';
9
- import { printTrap } from './reject.js';
8
+ import { unifiedDiff } from './diff.js';
9
+ import { fixJobPost, githubAnnotations, lintResult } from './lint.js';
10
+ import { letterText, printTrap } from './reject.js';
10
11
  import {
11
12
  blank,
12
13
  clear,
13
14
  dim,
15
+ green,
14
16
  print,
15
17
  showCursor,
16
18
  } from './style.js';
17
19
  import {
20
+ copyLetter,
21
+ printCard,
22
+ printFix,
18
23
  printHeader,
19
24
  printHelp,
20
25
  printHire,
@@ -22,10 +27,15 @@ import {
22
27
  printLintReport,
23
28
  printLooking,
24
29
  printMenu,
30
+ printProof,
25
31
  printRant,
32
+ printRules,
26
33
  printSignature,
27
34
  printSkills,
35
+ printTweet,
36
+ printVcf,
28
37
  printWho,
38
+ printWhy,
29
39
  printWork,
30
40
  toJson,
31
41
  } from './screens.js';
@@ -34,6 +44,9 @@ const pkg = JSON.parse(
34
44
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
35
45
  );
36
46
 
47
+ let lastReport = null;
48
+ let lastTo = '';
49
+
37
50
  const COMMANDS = {
38
51
  who: printWho,
39
52
  about: printWho,
@@ -42,15 +55,21 @@ const COMMANDS = {
42
55
  rant: printRant,
43
56
  market: printRant,
44
57
  work: printWork,
45
- proof: printWork,
58
+ proof: printProof,
46
59
  experience: printWork,
47
60
  skills: printSkills,
48
- hire: printHire,
49
- h: printHire,
50
- links: printHire,
51
- contact: printHire,
61
+ hire: () => printHire({ report: lastReport, to: lastTo }),
62
+ h: () => printHire({ report: lastReport, to: lastTo }),
63
+ links: () => printHire({ report: lastReport, to: lastTo }),
64
+ contact: () => printHire({ report: lastReport, to: lastTo }),
65
+ card: printCard,
66
+ tweet: () => printTweet(lastReport),
67
+ why: printWhy,
68
+ vcf: printVcf,
52
69
  };
53
70
 
71
+ const VALUE_FLAGS = new Set(['--max-warnings', '--seed', '--off', '--format', '--to']);
72
+
54
73
  const ask = (rl, prompt) =>
55
74
  new Promise((resolve) => {
56
75
  rl.question(prompt, resolve);
@@ -81,6 +100,12 @@ const resolveCommand = (input) => {
81
100
  if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
82
101
  if (value === 'help') return 'help';
83
102
  if (value === 'lint' || value === 'l') return 'lint';
103
+ if (value === 'c' || value === 'copy') return 'copy';
104
+ if (value === 'tweet') return 'tweet';
105
+ if (value === 'card') return 'card';
106
+ if (value === 'proof') return 'proof';
107
+ if (value === 'why') return 'why';
108
+ if (value === 'vcf' || value === 'vcard') return 'vcf';
84
109
  const fromMenu = menu.find((item) => item.key === value || item.command === value);
85
110
  if (fromMenu) return fromMenu.command;
86
111
  if (COMMANDS[value]) return value;
@@ -101,10 +126,173 @@ const showResume = () => {
101
126
  printMenu();
102
127
  };
103
128
 
104
- const interactive = async ({ trap }) => {
129
+ const parse = (argv) => {
130
+ const flags = new Set();
131
+ const opts = {};
132
+ const rest = [];
133
+
134
+ const setOpt = (key, value) => {
135
+ opts[key] = opts[key] ? `${opts[key]},${value}` : value;
136
+ };
137
+
138
+ for (let i = 0; i < argv.length; i += 1) {
139
+ const arg = argv[i];
140
+ if (arg === '--') {
141
+ rest.push(...argv.slice(i + 1));
142
+ break;
143
+ }
144
+ if (arg.startsWith('--') && arg.includes('=')) {
145
+ const eq = arg.indexOf('=');
146
+ const key = arg.slice(2, eq);
147
+ flags.add(`--${key}`);
148
+ setOpt(key, arg.slice(eq + 1));
149
+ continue;
150
+ }
151
+ if (VALUE_FLAGS.has(arg)) {
152
+ flags.add(arg);
153
+ const next = argv[i + 1];
154
+ if (next && !next.startsWith('-')) {
155
+ setOpt(arg.slice(2), next);
156
+ i += 1;
157
+ } else if (arg === '--max-warnings') {
158
+ setOpt('max-warnings', '0');
159
+ }
160
+ continue;
161
+ }
162
+ if (arg.startsWith('-')) {
163
+ flags.add(arg);
164
+ continue;
165
+ }
166
+ rest.push(arg);
167
+ }
168
+
169
+ return { flags, opts, rest };
170
+ };
171
+
172
+ const applyColorFlags = (flags, opts) => {
173
+ if (flags.has('--no-color') || opts.color === 'never') process.env.NO_COLOR = '1';
174
+ };
175
+
176
+ const POSTING_RE = /\.(md|txt|markdown)$/i;
177
+
178
+ const walkPostings = (dir, acc = []) => {
179
+ for (const name of readdirSync(dir).sort()) {
180
+ if (name.startsWith('.')) continue;
181
+ const path = join(dir, name);
182
+ let st;
183
+ try {
184
+ st = statSync(path);
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (st.isDirectory()) walkPostings(path, acc);
189
+ else if (POSTING_RE.test(name)) acc.push(path);
190
+ }
191
+ return acc;
192
+ };
193
+
194
+ const collectPostings = (paths) => {
195
+ const files = [];
196
+ for (const path of paths) {
197
+ const st = statSync(path);
198
+ if (st.isDirectory()) walkPostings(path, files);
199
+ else files.push(path);
200
+ }
201
+ return files;
202
+ };
203
+
204
+ const resolveFormat = (flags, opts) => {
205
+ if (opts.format) return opts.format;
206
+ if (flags.has('--json')) return 'json';
207
+ return 'pretty';
208
+ };
209
+
210
+ const lintOne = (text, source, { off, fix, write }) => {
211
+ const report = lintResult(text, source, { off });
212
+ if (fix || write) {
213
+ const fixed = fixJobPost(text, { off });
214
+ report.fix = {
215
+ applied: fixed.applied,
216
+ text: fixed.text,
217
+ path: source,
218
+ before: text,
219
+ diff: unifiedDiff(text, fixed.text, source === 'stdin' || source === 'paste' ? 'posting.txt' : source),
220
+ };
221
+ if (write && source !== 'stdin' && source !== 'paste' && fixed.applied.length) {
222
+ writeFileSync(source, fixed.text);
223
+ report.fix.wrote = true;
224
+ }
225
+ }
226
+ return report;
227
+ };
228
+
229
+ const runLintCli = async (files, options) => {
230
+ const { format = 'pretty', fix = false, write = false, copy = false, quiet = false, off = [], maxWarnings, tweet = false } = options;
231
+
232
+ const inputs = [];
233
+ if (files[0]) {
234
+ const paths = collectPostings(files);
235
+ if (!paths.length) {
236
+ print(dim(` no .md/.txt postings under ${files.join(', ')}`));
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+ for (const path of paths) {
241
+ inputs.push({ source: path, text: readFileSync(path, 'utf8') });
242
+ }
243
+ } else if (!stdin.isTTY) {
244
+ inputs.push({ source: 'stdin', text: await readStdin() });
245
+ } else {
246
+ printHelp();
247
+ blank();
248
+ print(dim(' lint needs a file, a directory, a pipe, or an interactive paste: npx jobless lint jobs/'));
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+
253
+ const reports = inputs.map((input) => lintOne(input.text, input.source, { off, fix: fix || write, write }));
254
+ lastReport = reports.at(-1);
255
+
256
+ let failed = false;
257
+ for (const report of reports) {
258
+ if (!report.ok) failed = true;
259
+ if (maxWarnings != null && report.warnings > maxWarnings) failed = true;
260
+ }
261
+ if (failed) process.exitCode = 1;
262
+
263
+ if (format === 'json') {
264
+ print(JSON.stringify(reports.length === 1 ? reports[0] : { reports }, null, 2));
265
+ if (copy && lastReport?.letter) copyLetter(lastReport.letter);
266
+ return;
267
+ }
268
+
269
+ if (format === 'github') {
270
+ const body = reports.map((report) => githubAnnotations(report)).filter(Boolean).join('\n');
271
+ if (body) print(body);
272
+ return;
273
+ }
274
+
275
+ for (const [index, report] of reports.entries()) {
276
+ if (index) blank();
277
+ const copied = copy && report.letter ? copyLetter(report.letter) : false;
278
+ printLintReport(report, { quiet, copied });
279
+ if (report.fix?.applied.length) printFix(report.fix);
280
+ if (report.fix?.wrote) print(` ${green('wrote')} ${report.source}`);
281
+ }
282
+
283
+ if (tweet) {
284
+ blank();
285
+ printTweet(lastReport);
286
+ }
287
+
288
+ blank();
289
+ print(dim(' more: npx jobless · npx jobless hire'));
290
+ };
291
+
292
+ const interactive = async ({ trap, seed }) => {
105
293
  if (process.stdout.isTTY) clear();
106
294
  if (trap) {
107
- await printTrap({ animate: true });
295
+ await printTrap({ animate: true, seed });
108
296
  blank();
109
297
  }
110
298
  showResume();
@@ -116,7 +304,7 @@ const interactive = async ({ trap }) => {
116
304
  const command = resolveCommand(answer);
117
305
 
118
306
  if (!command) {
119
- print(` ${dim('unknown. try 1–5, l to lint, h to hire, or q.')}`);
307
+ print(` ${dim('unknown. try 1–5, l to lint, c to copy, h to hire, or q.')}`);
120
308
  continue;
121
309
  }
122
310
  if (command === 'help') {
@@ -125,11 +313,21 @@ const interactive = async ({ trap }) => {
125
313
  printMenu();
126
314
  continue;
127
315
  }
316
+ if (command === 'copy') {
317
+ if (!lastReport?.letter) {
318
+ print(` ${dim('nothing to copy. lint a posting first (l).')}`);
319
+ continue;
320
+ }
321
+ if (copyLetter(lastReport.letter)) print(` ${green('copied')} rejection letter`);
322
+ else print(letterText(lastReport.letter));
323
+ continue;
324
+ }
128
325
  if (command === 'lint') {
129
326
  const text = await readPaste(rl);
130
327
  if (process.stdout.isTTY) clear();
131
328
  printHeader();
132
- printLintReport(lintResult(text, 'paste'));
329
+ lastReport = lintResult(text, 'paste');
330
+ printLintReport(lastReport);
133
331
  printMenu();
134
332
  continue;
135
333
  }
@@ -152,52 +350,14 @@ const interactive = async ({ trap }) => {
152
350
  }
153
351
  };
154
352
 
155
- const parse = (argv) => {
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
- }
162
- return { flags, rest };
163
- };
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
-
195
353
  export const run = async (argv = []) => {
196
354
  process.stdout.on('error', (error) => {
197
355
  if (error.code === 'EPIPE') process.exit(0);
198
356
  });
199
357
 
200
- const { flags, rest } = parse(argv);
358
+ const { flags, opts, rest } = parse(argv);
359
+ applyColorFlags(flags, opts);
360
+
201
361
  const verb = rest[0] ? rest[0].toLowerCase() : '';
202
362
 
203
363
  if (flags.has('-h') || flags.has('--help') || verb === 'help') {
@@ -210,8 +370,53 @@ export const run = async (argv = []) => {
210
370
  return;
211
371
  }
212
372
 
373
+ if (verb === 'rules' || flags.has('--rules')) {
374
+ printRules();
375
+ return;
376
+ }
377
+
213
378
  if (verb === 'lint' || verb === 'l') {
214
- await runLintCli(rest.slice(1), { json: flags.has('--json') });
379
+ const maxRaw = opts['max-warnings'];
380
+ const maxWarnings = maxRaw == null || maxRaw === '' ? null : Number(maxRaw);
381
+ await runLintCli(rest.slice(1), {
382
+ format: resolveFormat(flags, opts),
383
+ fix: flags.has('--fix') || flags.has('--diff') || flags.has('--write'),
384
+ write: flags.has('--write'),
385
+ copy: flags.has('--copy'),
386
+ quiet: flags.has('--quiet'),
387
+ tweet: flags.has('--tweet'),
388
+ off: String(opts.off || '')
389
+ .split(',')
390
+ .map((id) => id.trim())
391
+ .filter(Boolean),
392
+ maxWarnings: Number.isFinite(maxWarnings) ? maxWarnings : null,
393
+ });
394
+ return;
395
+ }
396
+
397
+ if (verb === 'tweet') {
398
+ printTweet(lastReport);
399
+ return;
400
+ }
401
+
402
+ if (verb === 'why') {
403
+ printHeader();
404
+ printWhy();
405
+ return;
406
+ }
407
+
408
+ if (verb === 'vcf' || verb === 'vcard') {
409
+ printHeader();
410
+ printVcf();
411
+ return;
412
+ }
413
+
414
+ lastTo = opts.to || '';
415
+ if ((verb === 'hire' || verb === 'h') && lastTo) {
416
+ printHeader();
417
+ printHire({ report: lastReport, to: lastTo, copy: process.stdout.isTTY });
418
+ blank();
419
+ print(dim(' more: npx jobless · npx jobless --help'));
215
420
  return;
216
421
  }
217
422
 
@@ -221,9 +426,10 @@ export const run = async (argv = []) => {
221
426
  }
222
427
 
223
428
  const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
429
+ const seed = opts.seed;
224
430
  const command = verb ? resolveCommand(verb) : null;
225
431
 
226
- if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
432
+ if (command && command !== 'quit' && command !== 'help' && command !== 'lint' && command !== 'copy') {
227
433
  printHeader();
228
434
  runCommand(command);
229
435
  blank();
@@ -232,11 +438,11 @@ export const run = async (argv = []) => {
232
438
  }
233
439
 
234
440
  if (!process.stdout.isTTY) {
235
- if (!skipTrap) await printTrap({ animate: false });
441
+ if (!skipTrap) await printTrap({ animate: false, seed });
236
442
  blank();
237
443
  printHeader();
238
444
  printSignature();
239
- printHire({ copy: false });
445
+ printHire({ copy: false, report: lastReport });
240
446
  return;
241
447
  }
242
448
 
@@ -246,5 +452,5 @@ export const run = async (argv = []) => {
246
452
  process.exit(0);
247
453
  });
248
454
 
249
- await interactive({ trap: !skipTrap });
455
+ await interactive({ trap: !skipTrap, seed });
250
456
  };
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
+ };