jobless 1.5.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 +103 -49
- package/package.json +2 -2
- package/src/cli.js +60 -361
- package/src/data.js +12 -158
- package/src/fit.js +1 -1
- package/src/hiring.js +70 -0
- package/src/identity.js +5 -1
- package/src/interactive.js +73 -0
- package/src/lint-cli.js +128 -0
- package/src/lint-screens.js +114 -0
- package/src/profile-screens.js +248 -0
- package/src/screens.js +20 -337
- package/src/style.js +16 -8
- package/src/work-data.js +152 -0
package/README.md
CHANGED
|
@@ -1,94 +1,148 @@
|
|
|
1
1
|
# jobless
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/jobless)
|
|
4
|
+
[](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.
|
|
5
10
|
|
|
6
11
|
```bash
|
|
7
12
|
npx jobless
|
|
8
13
|
```
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
```text
|
|
16
|
+
✔ Congratulations! We'd love to interview you.
|
|
17
|
+
✔ Unfortunately, we're moving forward with others.
|
|
18
|
+
|
|
19
|
+
ERR_HIRING_RACE_CONDITION
|
|
20
|
+
Application status: yesn't
|
|
11
21
|
|
|
12
|
-
|
|
22
|
+
Interview scheduled successfully.
|
|
23
|
+
Candidate rejected successfully.
|
|
24
|
+
All systems operational.
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
I couldn't fix their hiring pipeline. So I shipped my own.
|
|
27
|
+
```
|
|
15
28
|
|
|
16
|
-
|
|
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.
|
|
17
31
|
|
|
18
|
-
|
|
32
|
+
## Try it. Break it. Send it to a friend.
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
In the interactive prompt:
|
|
21
35
|
|
|
22
|
-
|
|
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
|
|
50
|
+
```
|
|
51
|
+
|
|
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
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx jobless lint --demo
|
|
59
|
+
```
|
|
60
|
+
|
|
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.
|
|
23
63
|
|
|
24
64
|
```bash
|
|
25
65
|
npx jobless lint posting.txt
|
|
26
66
|
npx jobless lint jobs/
|
|
27
67
|
pbpaste | npx jobless lint
|
|
28
|
-
npx jobless lint --fix posting.txt
|
|
29
|
-
npx jobless lint --format=github jobs/
|
|
30
68
|
npx jobless lint --tweet posting.txt
|
|
69
|
+
npx jobless lint --copy posting.txt
|
|
70
|
+
npx jobless lint --json posting.txt
|
|
31
71
|
npx jobless lint --rules
|
|
32
72
|
```
|
|
33
73
|
|
|
34
|
-
|
|
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.
|
|
35
76
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
max-rounds
|
|
39
|
-
L3 Six interview rounds.
|
|
77
|
+
Errors exit with status `1`, including the intentionally bad demo. Warnings
|
|
78
|
+
alone exit with `0`, unless you set `--max-warnings=0`.
|
|
40
79
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
```bash
|
|
81
|
+
npx jobless lint --fix posting.txt # proposed unified diff only
|
|
82
|
+
npx jobless lint --write posting.txt # explicitly overwrite the file
|
|
44
83
|
```
|
|
45
84
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
49
88
|
|
|
50
|
-
|
|
89
|
+
For job postings in your own repository, see
|
|
90
|
+
[the example GitHub Action](examples/github-action.yml):
|
|
51
91
|
|
|
52
92
|
```yaml
|
|
53
93
|
- run: npx --yes jobless lint --format=github jobs/
|
|
54
94
|
```
|
|
55
95
|
|
|
56
|
-
|
|
96
|
+
## There is an engineer behind this
|
|
57
97
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
61
101
|
|
|
62
102
|
```bash
|
|
63
|
-
npx jobless
|
|
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
|
|
64
113
|
```
|
|
65
114
|
|
|
66
|
-
|
|
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/)
|
|
67
123
|
|
|
68
|
-
|
|
124
|
+
## Local, with boundaries
|
|
69
125
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- GitHub: [github.com/Yonas21](https://github.com/Yonas21)
|
|
73
|
-
- LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
|
|
74
|
-
- Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
|
|
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.
|
|
75
128
|
|
|
76
|
-
|
|
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.
|
|
77
136
|
|
|
78
|
-
|
|
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.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
79
142
|
|
|
80
143
|
```bash
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
87
|
-
npx jobless hire # email + links
|
|
88
|
-
npx jobless rant # the market
|
|
89
|
-
npx jobless --resume # skip the rejection
|
|
144
|
+
npm test
|
|
145
|
+
node bin/jobless.js
|
|
90
146
|
```
|
|
91
147
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
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
|
-
"description": "
|
|
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",
|
package/src/cli.js
CHANGED
|
@@ -1,124 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { stdin } from 'node:process';
|
|
6
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
7
2
|
import { menu } from './data.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
blank,
|
|
13
|
-
clear,
|
|
14
|
-
dim,
|
|
15
|
-
green,
|
|
16
|
-
print,
|
|
17
|
-
showCursor,
|
|
18
|
-
} 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';
|
|
19
7
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
printHeader,
|
|
24
|
-
printHelp,
|
|
25
|
-
printHire,
|
|
26
|
-
printLandingTeaser,
|
|
27
|
-
printLintReport,
|
|
28
|
-
printLooking,
|
|
29
|
-
printMenu,
|
|
30
|
-
printProof,
|
|
31
|
-
printRant,
|
|
32
|
-
printRules,
|
|
33
|
-
printSignature,
|
|
34
|
-
printSkills,
|
|
35
|
-
printTweet,
|
|
36
|
-
printVcf,
|
|
37
|
-
printWho,
|
|
38
|
-
printWhy,
|
|
39
|
-
printWork,
|
|
40
|
-
toJson,
|
|
8
|
+
printCard, printHeader, printHelp, printHire, printLandingTeaser,
|
|
9
|
+
printLooking, printMenu, printProof, printRant, printRules, printSignature,
|
|
10
|
+
printSkills, printTweet, printVcf, printWho, printWhy, printWork, toJson,
|
|
41
11
|
} from './screens.js';
|
|
42
12
|
|
|
43
|
-
const pkg = JSON.parse(
|
|
44
|
-
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
let lastReport = null;
|
|
48
|
-
let lastTo = '';
|
|
49
|
-
|
|
50
|
-
const COMMANDS = {
|
|
51
|
-
who: printWho,
|
|
52
|
-
about: printWho,
|
|
53
|
-
looking: printLooking,
|
|
54
|
-
jobs: printLooking,
|
|
55
|
-
rant: printRant,
|
|
56
|
-
market: printRant,
|
|
57
|
-
work: printWork,
|
|
58
|
-
proof: printProof,
|
|
59
|
-
experience: printWork,
|
|
60
|
-
skills: printSkills,
|
|
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,
|
|
69
|
-
};
|
|
70
|
-
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
71
14
|
const VALUE_FLAGS = new Set(['--max-warnings', '--seed', '--off', '--format', '--to']);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const readStdin = async () => {
|
|
79
|
-
const chunks = [];
|
|
80
|
-
for await (const chunk of stdin) chunks.push(chunk);
|
|
81
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
15
|
+
const COMMANDS = {
|
|
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,
|
|
82
20
|
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
print(dim(' paste a job description. empty line to lint.'));
|
|
87
|
-
blank();
|
|
88
|
-
const lines = [];
|
|
89
|
-
while (true) {
|
|
90
|
-
const line = await ask(rl, dim(' '));
|
|
91
|
-
if (line === '') break;
|
|
92
|
-
lines.push(line);
|
|
93
|
-
}
|
|
94
|
-
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',
|
|
95
24
|
};
|
|
96
|
-
|
|
97
25
|
const resolveCommand = (input) => {
|
|
98
26
|
const value = String(input).trim().toLowerCase();
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
if (value
|
|
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';
|
|
109
|
-
const fromMenu = menu.find((item) => item.key === value || item.command === value);
|
|
110
|
-
if (fromMenu) return fromMenu.command;
|
|
111
|
-
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;
|
|
112
31
|
return null;
|
|
113
32
|
};
|
|
114
33
|
|
|
115
|
-
const runCommand = (command) => {
|
|
116
|
-
const render = COMMANDS[command];
|
|
117
|
-
if (!render) return false;
|
|
118
|
-
render();
|
|
119
|
-
return true;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
34
|
const showResume = () => {
|
|
123
35
|
printHeader();
|
|
124
36
|
printSignature();
|
|
@@ -173,284 +85,71 @@ const applyColorFlags = (flags, opts) => {
|
|
|
173
85
|
if (flags.has('--no-color') || opts.color === 'never') process.env.NO_COLOR = '1';
|
|
174
86
|
};
|
|
175
87
|
|
|
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 }) => {
|
|
293
|
-
if (process.stdout.isTTY) clear();
|
|
294
|
-
if (trap) {
|
|
295
|
-
await printTrap({ animate: true, seed });
|
|
296
|
-
blank();
|
|
297
|
-
}
|
|
298
|
-
showResume();
|
|
299
|
-
|
|
300
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
301
|
-
try {
|
|
302
|
-
while (true) {
|
|
303
|
-
const answer = await ask(rl, dim(' jobless> '));
|
|
304
|
-
const command = resolveCommand(answer);
|
|
305
|
-
|
|
306
|
-
if (!command) {
|
|
307
|
-
print(` ${dim('unknown. try 1–5, l to lint, c to copy, h to hire, or q.')}`);
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
if (command === 'help') {
|
|
311
|
-
if (process.stdout.isTTY) clear();
|
|
312
|
-
printHelp();
|
|
313
|
-
printMenu();
|
|
314
|
-
continue;
|
|
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
|
-
}
|
|
325
|
-
if (command === 'lint') {
|
|
326
|
-
const text = await readPaste(rl);
|
|
327
|
-
if (process.stdout.isTTY) clear();
|
|
328
|
-
printHeader();
|
|
329
|
-
lastReport = lintResult(text, 'paste');
|
|
330
|
-
printLintReport(lastReport);
|
|
331
|
-
printMenu();
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
if (command === 'quit') {
|
|
335
|
-
blank();
|
|
336
|
-
print(dim(' still jobless. still shipping.'));
|
|
337
|
-
print(dim(' yonalem21@gmail.com'));
|
|
338
|
-
blank();
|
|
339
|
-
break;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (process.stdout.isTTY) clear();
|
|
343
|
-
printHeader();
|
|
344
|
-
runCommand(command);
|
|
345
|
-
printMenu();
|
|
346
|
-
}
|
|
347
|
-
} finally {
|
|
348
|
-
rl.close();
|
|
349
|
-
showCursor();
|
|
350
|
-
}
|
|
351
|
-
};
|
|
352
|
-
|
|
353
88
|
export const run = async (argv = []) => {
|
|
354
89
|
process.stdout.on('error', (error) => {
|
|
355
90
|
if (error.code === 'EPIPE') process.exit(0);
|
|
356
91
|
});
|
|
357
|
-
|
|
358
92
|
const { flags, opts, rest } = parse(argv);
|
|
359
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
|
+
};
|
|
360
102
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
printHelp();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (flags.has('-v') || flags.has('--version')) {
|
|
369
|
-
print(pkg.version);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (verb === 'rules' || flags.has('--rules')) {
|
|
374
|
-
printRules();
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
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();
|
|
377
106
|
|
|
378
|
-
if (
|
|
107
|
+
if (command === 'lint' || command === 'demo') {
|
|
379
108
|
const maxRaw = opts['max-warnings'];
|
|
380
109
|
const maxWarnings = maxRaw == null || maxRaw === '' ? null : Number(maxRaw);
|
|
381
110
|
await runLintCli(rest.slice(1), {
|
|
382
|
-
format:
|
|
111
|
+
format: opts.format || (flags.has('--json') ? 'json' : 'pretty'),
|
|
383
112
|
fix: flags.has('--fix') || flags.has('--diff') || flags.has('--write'),
|
|
384
|
-
write: flags.has('--write'),
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
off: String(opts.off || '')
|
|
389
|
-
.split(',')
|
|
390
|
-
.map((id) => id.trim())
|
|
391
|
-
.filter(Boolean),
|
|
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),
|
|
392
117
|
maxWarnings: Number.isFinite(maxWarnings) ? maxWarnings : null,
|
|
393
118
|
});
|
|
394
119
|
return;
|
|
395
120
|
}
|
|
121
|
+
if (flags.has('--json')) return print(toJson());
|
|
122
|
+
if (verb && !command) throw new Error(`Unknown command: ${verb}. Try npx jobless --help.`);
|
|
396
123
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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());
|
|
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') });
|
|
425
130
|
return;
|
|
426
131
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const command = verb ? resolveCommand(verb) : null;
|
|
431
|
-
|
|
432
|
-
if (command && command !== 'quit' && command !== 'help' && command !== 'lint' && command !== 'copy') {
|
|
433
|
-
printHeader();
|
|
132
|
+
if (command === 'quit') return;
|
|
133
|
+
if (command && command !== 'replay') {
|
|
134
|
+
if (!['why', 'tweet', 'rant'].includes(command)) printHeader();
|
|
434
135
|
runCommand(command);
|
|
435
|
-
blank();
|
|
436
|
-
print(dim(' more: npx jobless · npx jobless hire · npx jobless --help'));
|
|
437
136
|
return;
|
|
438
137
|
}
|
|
439
|
-
|
|
440
|
-
if (!
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
147
|
return;
|
|
447
148
|
}
|
|
448
|
-
|
|
449
|
-
process.on('SIGINT', () => {
|
|
149
|
+
process.once('SIGINT', () => {
|
|
450
150
|
showCursor();
|
|
451
151
|
process.stdout.write('\n');
|
|
452
152
|
process.exit(0);
|
|
453
153
|
});
|
|
454
|
-
|
|
455
|
-
await interactive({ trap: !skipTrap, seed });
|
|
154
|
+
await interactive({ trap: !skipTrap, seed, personalize, resolveCommand, runCommand, showResume });
|
|
456
155
|
};
|