jobless 1.3.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,61 +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, 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"
43
- means-understaffed
39
+ L3 Six interview rounds.
40
+
41
+ RESULT REJECTED
42
+ 28/100
43
+ FIT overlap Senior · Remote
44
+ ```
45
+
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.
47
+
48
+ ### GitHub Action
44
49
 
45
- REJECTED 2 errors 1 warning
46
- reason failed the human compiler
50
+ Copy [`examples/github-action.yml`](examples/github-action.yml):
51
+
52
+ ```yaml
53
+ - run: npx --yes jobless lint --format=github jobs/
47
54
  ```
48
55
 
49
- Same idea as ESLint. The file is a job description. The company has to pass.
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
+ ```
50
65
 
51
66
  ## Hire
52
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
+
53
70
  - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
54
71
  - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
55
72
  - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
56
73
  - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
57
74
  - Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
58
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
+
59
78
  ## Commands
60
79
 
61
80
  ```bash
62
81
  npx jobless # reject you, then the resume
63
- npx jobless lint file.txt # lint a job post
64
- npx jobless work # proof
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)
85
+ npx jobless lint --json # findings + rejection letter
86
+ npx jobless work # three case studies
65
87
  npx jobless hire # email + links
66
88
  npx jobless rant # the market
67
89
  npx jobless --resume # skip the rejection
68
90
  ```
69
91
 
92
+ Post the command, not the punchline: `npx jobless`
93
+
70
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.3.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 { lintJobPost } 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(lintJobPost(text), 'paste');
329
+ lastReport = lintResult(text, 'paste');
330
+ printLintReport(lastReport);
133
331
  printMenu();
134
332
  continue;
135
333
  }
@@ -152,44 +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) => {
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
-
187
353
  export const run = async (argv = []) => {
188
354
  process.stdout.on('error', (error) => {
189
355
  if (error.code === 'EPIPE') process.exit(0);
190
356
  });
191
357
 
192
- const { flags, rest } = parse(argv);
358
+ const { flags, opts, rest } = parse(argv);
359
+ applyColorFlags(flags, opts);
360
+
193
361
  const verb = rest[0] ? rest[0].toLowerCase() : '';
194
362
 
195
363
  if (flags.has('-h') || flags.has('--help') || verb === 'help') {
@@ -202,20 +370,66 @@ export const run = async (argv = []) => {
202
370
  return;
203
371
  }
204
372
 
205
- if (flags.has('--json')) {
206
- print(toJson());
373
+ if (verb === 'rules' || flags.has('--rules')) {
374
+ printRules();
207
375
  return;
208
376
  }
209
377
 
210
378
  if (verb === 'lint' || verb === 'l') {
211
- await runLintCli(rest.slice(1));
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'));
420
+ return;
421
+ }
422
+
423
+ if (flags.has('--json')) {
424
+ print(toJson());
212
425
  return;
213
426
  }
214
427
 
215
428
  const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
429
+ const seed = opts.seed;
216
430
  const command = verb ? resolveCommand(verb) : null;
217
431
 
218
- if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
432
+ if (command && command !== 'quit' && command !== 'help' && command !== 'lint' && command !== 'copy') {
219
433
  printHeader();
220
434
  runCommand(command);
221
435
  blank();
@@ -224,11 +438,11 @@ export const run = async (argv = []) => {
224
438
  }
225
439
 
226
440
  if (!process.stdout.isTTY) {
227
- if (!skipTrap) await printTrap({ animate: false });
441
+ if (!skipTrap) await printTrap({ animate: false, seed });
228
442
  blank();
229
443
  printHeader();
230
444
  printSignature();
231
- printHire({ copy: false });
445
+ printHire({ copy: false, report: lastReport });
232
446
  return;
233
447
  }
234
448
 
@@ -238,5 +452,5 @@ export const run = async (argv = []) => {
238
452
  process.exit(0);
239
453
  });
240
454
 
241
- await interactive({ trap: !skipTrap });
455
+ await interactive({ trap: !skipTrap, seed });
242
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
+ };