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 +115 -49
- package/package.json +4 -3
- package/src/cli.js +107 -202
- package/src/clip.js +18 -0
- package/src/data.js +106 -105
- package/src/diff.js +103 -0
- package/src/fit.js +41 -0
- 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/lint.js +443 -18
- package/src/profile-screens.js +248 -0
- package/src/reject.js +89 -18
- package/src/screens.js +57 -176
- package/src/style.js +16 -8
- package/src/work-data.js +152 -0
package/src/data.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { experience, cases, work, mentorship } from './work-data.js';
|
|
2
|
+
|
|
1
3
|
export const profile = {
|
|
2
4
|
name: 'Yonas Alem',
|
|
3
5
|
title: 'Senior Software Engineer',
|
|
@@ -15,31 +17,96 @@ export const profile = {
|
|
|
15
17
|
resume: 'https://yonasalem.vercel.app/assets/resume.pdf',
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
export const
|
|
19
|
-
'npx jobless —
|
|
20
|
-
|
|
20
|
+
export const hireSubjects = {
|
|
21
|
+
recruiter: 'npx jobless — recruiter intro for a role',
|
|
22
|
+
manager: 'npx jobless — hiring manager, skip the form',
|
|
23
|
+
default: 'npx jobless — Yonas Alem',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const mailtoFor = ({ report, to } = {}) => {
|
|
27
|
+
const subject = hireSubjects[to] || hireSubjects.default;
|
|
28
|
+
let body = 'I ran npx jobless. I want to talk about a role.\n';
|
|
29
|
+
if (report?.letter) {
|
|
30
|
+
body += `\nI linted a posting.\nRESULT: ${report.letter.result} (${report.score}/100)\n`;
|
|
31
|
+
body += report.letter.reasons.map((reason) => `- ${reason}`).join('\n');
|
|
32
|
+
body += '\n';
|
|
33
|
+
}
|
|
34
|
+
if (report?.fit?.matched?.length) {
|
|
35
|
+
body += `\nOverlap: ${report.fit.matched.join(', ')}\n`;
|
|
36
|
+
}
|
|
37
|
+
return `mailto:${profile.email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const hireMailto = mailtoFor();
|
|
41
|
+
|
|
42
|
+
export const vcard = () =>
|
|
43
|
+
[
|
|
44
|
+
'BEGIN:VCARD',
|
|
45
|
+
'VERSION:3.0',
|
|
46
|
+
`FN:${profile.name}`,
|
|
47
|
+
'N:Alem;Yonas;;;',
|
|
48
|
+
`TITLE:${profile.title}`,
|
|
49
|
+
`EMAIL;TYPE=INTERNET,PREF:${profile.email}`,
|
|
50
|
+
`TEL;TYPE=CELL:${profile.phone.replace(/\s+/g, '')}`,
|
|
51
|
+
`ADR;TYPE=WORK:;;${profile.location};;;;`,
|
|
52
|
+
`URL:${profile.website}`,
|
|
53
|
+
`URL:${profile.github}`,
|
|
54
|
+
`URL:${profile.linkedin}`,
|
|
55
|
+
`NOTE:${signature.lines.at(-1)}`,
|
|
56
|
+
'END:VCARD',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
21
59
|
|
|
22
60
|
export const stats = [
|
|
23
61
|
{ value: '5+', label: 'years in production' },
|
|
24
|
-
{ value: '35%', label: 'infra
|
|
25
|
-
{ value: '~30%', label: '
|
|
26
|
-
{ value: '3B+', label: 'ETB
|
|
62
|
+
{ value: '~35%', label: 'infra cut (Mereb)' },
|
|
63
|
+
{ value: '~30%', label: 'perf gain (Mereb)' },
|
|
64
|
+
{ value: '3B+', label: 'ETB on rails I integrated' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export const claims = [
|
|
68
|
+
{
|
|
69
|
+
value: '~35% / ~$100K a year',
|
|
70
|
+
label: 'infra cost',
|
|
71
|
+
who: 'Senior Software Engineer, Mereb Technologies',
|
|
72
|
+
method:
|
|
73
|
+
'Database, caching, and resource optimization on a production gaming platform. An engineering estimate, not a finance-signed audit.',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
value: '~30%',
|
|
77
|
+
label: 'platform performance',
|
|
78
|
+
who: 'Senior Software Engineer, Mereb Technologies',
|
|
79
|
+
method: 'Indexing, read/write separation, caching, pagination, transactional operations, redesigned status-checking.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
value: '2 hours → under 30 minutes',
|
|
83
|
+
label: 'reports on ~1M-row datasets',
|
|
84
|
+
who: 'Senior Software Engineer, Mereb Technologies',
|
|
85
|
+
method: 'Moved aggregation off ad-hoc application paths onto the database.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
value: '3B+ ETB / 100K+ downloads',
|
|
89
|
+
label: 'payment volume on products I integrated',
|
|
90
|
+
who: 'Frontend Developer, Eaglelion — client payment workflows, not the bank and not the ledger',
|
|
91
|
+
method:
|
|
92
|
+
'Mastercard, Visa, Telebirr, and bank APIs: states, callbacks, failures, retries, verification, SOAP→REST. The products handled the volume. I built the client rails.',
|
|
93
|
+
},
|
|
27
94
|
];
|
|
28
95
|
|
|
29
96
|
export const signature = {
|
|
30
97
|
title: 'The deploy that killed 17 cron jobs',
|
|
31
98
|
lines: [
|
|
32
|
-
'
|
|
33
|
-
'Traced it through BullMQ, ioredis,
|
|
34
|
-
'Then added explicit shutdown handling across 17 cron processes.',
|
|
99
|
+
'After every deploy, recurring Amazon sync jobs died quietly. Dashboards looked fine until the data was a day old.',
|
|
100
|
+
'Traced it through BullMQ job state, ioredis connections, Node process shutdown, and the Railway scheduler.',
|
|
101
|
+
'Then added explicit shutdown handling across 17 cron processes so a deploy could not leave work stranded.',
|
|
35
102
|
'I debug silent production failures. That is the job.',
|
|
36
103
|
],
|
|
37
104
|
};
|
|
38
105
|
|
|
39
106
|
export const about = [
|
|
40
|
-
'I
|
|
41
|
-
'
|
|
42
|
-
'
|
|
107
|
+
'I am a senior full-stack engineer in Addis Ababa. I work in Node.js, PostgreSQL, and React, usually on systems where a silent failure costs real money.',
|
|
108
|
+
'The numbers have owners. Infra and performance: Mereb, as senior engineer on a real-money gaming platform. Payment volume: Eaglelion, as the frontend engineer who integrated the client rails — not the bank.',
|
|
109
|
+
'Production code lives in private employer repos. The case studies in `work` are the public record. I will walk through the systems on a call. Remote from Ethiopia, EAT (UTC+3).',
|
|
43
110
|
];
|
|
44
111
|
|
|
45
112
|
export const lookingFor = {
|
|
@@ -65,121 +132,55 @@ export const lookingFor = {
|
|
|
65
132
|
|
|
66
133
|
export const rants = [
|
|
67
134
|
{
|
|
68
|
-
title: '
|
|
69
|
-
body:
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
title: 'I applied for 30 minutes. The bot needed 400ms',
|
|
73
|
-
body: 'The modern application is unpaid data entry with extra steps. Upload the PDF. Then type the PDF into twelve boxes the parser already filled wrong. Then salary in USD, GBP, and emotional damage. Then a culture quiz written by someone who has never been in the culture. Then "why this company?" as if Greenhouse is going to light a candle and read it. Submit. Rejected in 400ms. Sometimes the no arrives before the confirmation. Five years in production, $100K pulled out of infra, payment rails that moved billions — filtered out because I did not write "synergy" enough times. Cool product. Would not ship it.',
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
title: 'They booked the call so they had something to cancel',
|
|
77
|
-
body: 'Favorite genre of fiction: the recruiter calendar invite. "Loved your profile. Thursday 10am?" I clear the morning. I make tea. I put on a shirt with buttons. 9:58am: "we\'ve decided to move forward with other candidates." Which other candidates. The ones you have not scheduled yet? Or they just never show, and the Google Calendar event is the last proof a human existed. If ghosting is the process, skip the theater. Do not put a working engineer on your Greenhouse stage so the pipeline looks busy. I debug silent failures for a living. Yours is not that silent.',
|
|
135
|
+
title: 'Distributed hiring',
|
|
136
|
+
body: 'The interview invite and rejection arrived together. Finally, a company doing parallel processing.',
|
|
78
137
|
},
|
|
79
138
|
{
|
|
80
|
-
title: '
|
|
81
|
-
body:
|
|
139
|
+
title: 'Disaster recovery',
|
|
140
|
+
body: 'They asked how I handle rejection. Apparently: semantic versioning.',
|
|
82
141
|
},
|
|
83
|
-
];
|
|
84
|
-
|
|
85
|
-
export const experience = [
|
|
86
142
|
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
dates: 'Dec 2025 – Present',
|
|
90
|
-
place: 'Remote',
|
|
91
|
-
points: [
|
|
92
|
-
'Amazon seller analytics: marketplace analytics, FBA, finance, PPC, and inventory.',
|
|
93
|
-
'Traced recurring sync jobs that silently died after every deploy; added explicit shutdown handling across 17 cron processes.',
|
|
94
|
-
'Integrated Amazon SP-API and Ads auth end to end: OAuth, LWA, encrypted credentials.',
|
|
95
|
-
'Extended the platform to UK and EU marketplaces with VAT-aware margins.',
|
|
96
|
-
],
|
|
97
|
-
stack: ['Node.js', 'PostgreSQL', 'BullMQ', 'Redis', 'React', 'Amazon SP-API'],
|
|
143
|
+
title: 'Single source of truth',
|
|
144
|
+
body: 'Upload your resume. Now type it again. Your first task is keeping two databases in sync. Unpaid.',
|
|
98
145
|
},
|
|
99
146
|
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
dates: 'Apr 2022 – Nov 2025',
|
|
103
|
-
place: 'Addis Ababa',
|
|
104
|
-
points: [
|
|
105
|
-
'Owned a real-money gaming platform: loyalty, payouts, fraud, reporting, infra.',
|
|
106
|
-
'Improved platform performance ~30% with indexing, caching, and read/write separation.',
|
|
107
|
-
'Cut infrastructure cost ~35%, about $100K a year.',
|
|
108
|
-
'Dropped report generation on ~1M-row datasets from 2 hours to under 30 minutes.',
|
|
109
|
-
'Mentored 3 junior engineers.',
|
|
110
|
-
],
|
|
111
|
-
stack: ['PostgreSQL', 'gRPC', 'PHP', 'React', 'AWS', 'Datadog'],
|
|
147
|
+
title: 'Technical assessment',
|
|
148
|
+
body: 'The take-home was their Q3 roadmap. I submitted a quote.',
|
|
112
149
|
},
|
|
113
150
|
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
dates: 'Feb 2023 – Aug 2023',
|
|
117
|
-
place: 'Remote',
|
|
118
|
-
points: [
|
|
119
|
-
'Migrated a legacy PHP API to Node.js and tRPC without breaking the React UI.',
|
|
120
|
-
'Cut report generation time ~40% by moving it onto Google Cloud Storage.',
|
|
121
|
-
'Rebuilt validation in Zod and reached ~85% test coverage on migrated code.',
|
|
122
|
-
],
|
|
123
|
-
stack: ['Node.js', 'tRPC', 'Prisma', 'React', 'Zod', 'GCS'],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
role: 'Frontend Developer',
|
|
127
|
-
company: 'Eaglelion Systems Technology',
|
|
128
|
-
dates: 'Feb 2021 – Apr 2022',
|
|
129
|
-
place: 'Addis Ababa',
|
|
130
|
-
points: [
|
|
131
|
-
'Integrated Mastercard, Visa, Telebirr, and bank APIs into products with 100K+ downloads.',
|
|
132
|
-
'Payment workflows across 3B+ ETB in transaction volume: callbacks, retries, reconciliation.',
|
|
133
|
-
'Raised GTmetrix from 45 to 97 and cut page load ~25%.',
|
|
134
|
-
],
|
|
135
|
-
stack: ['React', 'Next.js', 'Tailwind', 'Payment APIs'],
|
|
151
|
+
title: 'Dependency audit',
|
|
152
|
+
body: 'Zero dependencies. Still dependent on someone replying.',
|
|
136
153
|
},
|
|
137
154
|
];
|
|
138
155
|
|
|
139
|
-
export const
|
|
140
|
-
{
|
|
141
|
-
title: 'Amazon seller analytics platform',
|
|
142
|
-
company: 'TPK Synergy Limited',
|
|
143
|
-
blurb: 'Production reliability, Amazon integrations, and the data path behind the dashboard.',
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
title: 'Real-money gaming platform',
|
|
147
|
-
company: 'Mereb Technologies',
|
|
148
|
-
blurb: 'Loyalty, payouts, fraud, reporting, and the infrastructure underneath it.',
|
|
149
|
-
},
|
|
156
|
+
export const skills = [
|
|
150
157
|
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
name: 'Dangerous at',
|
|
159
|
+
note: 'I will own this in production',
|
|
160
|
+
items: ['Node.js', 'PostgreSQL', 'TypeScript / JavaScript', 'Redis / BullMQ', 'React / Next.js', 'AWS'],
|
|
154
161
|
},
|
|
155
162
|
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
name: 'Used in production',
|
|
164
|
+
note: 'I have shipped it. I am not the expert in the room.',
|
|
165
|
+
items: ['PHP', 'gRPC', 'Prisma', 'tRPC', 'Docker', 'Datadog', 'SQL', 'Tailwind CSS'],
|
|
159
166
|
},
|
|
160
167
|
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
name: 'Not the job',
|
|
169
|
+
note: 'I will say so in the first screen',
|
|
170
|
+
items: ['Rust', 'native iOS / Android', 'Kubernetes as a daily driver'],
|
|
164
171
|
},
|
|
165
172
|
];
|
|
166
173
|
|
|
167
|
-
export const skills = [
|
|
168
|
-
{ name: 'Languages', items: ['TypeScript', 'JavaScript', 'Go', 'Python', 'PHP', 'SQL'] },
|
|
169
|
-
{ name: 'Backend', items: ['Node.js', 'Express', 'FastAPI', 'Prisma', 'gRPC', 'tRPC', 'REST', 'BullMQ'] },
|
|
170
|
-
{ name: 'Data', items: ['PostgreSQL', 'MySQL', 'MongoDB', 'Redis'] },
|
|
171
|
-
{ name: 'Frontend', items: ['React', 'Next.js', 'Tailwind CSS', 'Material UI'] },
|
|
172
|
-
{ name: 'Cloud', items: ['AWS', 'Docker', 'Kubernetes', 'Nginx', 'GitHub Actions', 'Datadog'] },
|
|
173
|
-
{ name: 'Integrations', items: ['Amazon SP-API', 'Mastercard', 'Visa', 'Telebirr', 'OAuth 2.0', 'OpenAI'] },
|
|
174
|
-
];
|
|
175
|
-
|
|
176
174
|
export const quotes = [
|
|
177
|
-
{ name: 'Dan Warner', role: 'AppLand Inc', text: 'Yonas did a great job! I would work with him again on a larger project.' },
|
|
178
|
-
{ name: 'Muly Oved', role: '', text: 'Excellent freelancer, dedicated, hard worker, was joy to work with.' },
|
|
179
175
|
{
|
|
180
176
|
name: 'Mekidem Getaneh',
|
|
181
|
-
role: '',
|
|
182
|
-
text: 'He goes above and beyond for solving a problem and to find optimal solutions.',
|
|
177
|
+
role: 'Engineering colleague · ~1 year',
|
|
178
|
+
text: 'I have worked with Yonas for almost a year. He goes above and beyond for solving a problem and to find optimal solutions.',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'Kalvin',
|
|
182
|
+
role: 'Client · data analytics frontend',
|
|
183
|
+
text: 'Exceptional expertise and creativity, with attention to detail, responsiveness to feedback, and proactive communication.',
|
|
183
184
|
},
|
|
184
185
|
];
|
|
185
186
|
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const diffLines = (before, after) => {
|
|
2
|
+
const a = String(before).split('\n');
|
|
3
|
+
const b = String(after).split('\n');
|
|
4
|
+
const n = a.length;
|
|
5
|
+
const m = b.length;
|
|
6
|
+
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
|
|
7
|
+
|
|
8
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
9
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
10
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ops = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
let j = 0;
|
|
17
|
+
while (i < n && j < m) {
|
|
18
|
+
if (a[i] === b[j]) {
|
|
19
|
+
ops.push({ type: 'eq', line: a[i] });
|
|
20
|
+
i += 1;
|
|
21
|
+
j += 1;
|
|
22
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
23
|
+
ops.push({ type: 'del', line: a[i] });
|
|
24
|
+
i += 1;
|
|
25
|
+
} else {
|
|
26
|
+
ops.push({ type: 'add', line: b[j] });
|
|
27
|
+
j += 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
while (i < n) {
|
|
31
|
+
ops.push({ type: 'del', line: a[i] });
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
while (j < m) {
|
|
35
|
+
ops.push({ type: 'add', line: b[j] });
|
|
36
|
+
j += 1;
|
|
37
|
+
}
|
|
38
|
+
return ops;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const unifiedDiff = (before, after, path = 'posting.txt') => {
|
|
42
|
+
const ops = diffLines(before, after);
|
|
43
|
+
if (!ops.some((op) => op.type !== 'eq')) return '';
|
|
44
|
+
|
|
45
|
+
const lines = [`--- a/${path}`, `+++ b/${path}`];
|
|
46
|
+
let oldLine = 1;
|
|
47
|
+
let newLine = 1;
|
|
48
|
+
let idx = 0;
|
|
49
|
+
|
|
50
|
+
while (idx < ops.length) {
|
|
51
|
+
while (idx < ops.length && ops[idx].type === 'eq') {
|
|
52
|
+
oldLine += 1;
|
|
53
|
+
newLine += 1;
|
|
54
|
+
idx += 1;
|
|
55
|
+
}
|
|
56
|
+
if (idx >= ops.length) break;
|
|
57
|
+
|
|
58
|
+
let start = idx;
|
|
59
|
+
let cursor = idx;
|
|
60
|
+
let trailingEq = 0;
|
|
61
|
+
while (cursor < ops.length) {
|
|
62
|
+
if (ops[cursor].type === 'eq') {
|
|
63
|
+
trailingEq += 1;
|
|
64
|
+
if (trailingEq > 2) break;
|
|
65
|
+
} else {
|
|
66
|
+
trailingEq = 0;
|
|
67
|
+
}
|
|
68
|
+
cursor += 1;
|
|
69
|
+
}
|
|
70
|
+
if (trailingEq > 2) cursor -= trailingEq - 2;
|
|
71
|
+
|
|
72
|
+
const hunk = ops.slice(Math.max(0, start - 2), cursor);
|
|
73
|
+
const pre = Math.min(2, start);
|
|
74
|
+
const hunkOldStart = oldLine - pre;
|
|
75
|
+
const hunkNewStart = newLine - pre;
|
|
76
|
+
let oldCount = 0;
|
|
77
|
+
let newCount = 0;
|
|
78
|
+
const body = [];
|
|
79
|
+
for (const op of hunk) {
|
|
80
|
+
if (op.type === 'eq') {
|
|
81
|
+
body.push(` ${op.line}`);
|
|
82
|
+
oldCount += 1;
|
|
83
|
+
newCount += 1;
|
|
84
|
+
} else if (op.type === 'del') {
|
|
85
|
+
body.push(`-${op.line}`);
|
|
86
|
+
oldCount += 1;
|
|
87
|
+
} else {
|
|
88
|
+
body.push(`+${op.line}`);
|
|
89
|
+
newCount += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lines.push(`@@ -${Math.max(hunkOldStart, 1)},${oldCount} +${Math.max(hunkNewStart, 1)},${newCount} @@`);
|
|
93
|
+
lines.push(...body);
|
|
94
|
+
|
|
95
|
+
for (const op of ops.slice(start, cursor)) {
|
|
96
|
+
if (op.type === 'eq' || op.type === 'del') oldLine += 1;
|
|
97
|
+
if (op.type === 'eq' || op.type === 'add') newLine += 1;
|
|
98
|
+
}
|
|
99
|
+
idx = cursor;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
};
|
package/src/fit.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const FIT_SIGNALS = [
|
|
2
|
+
{ id: 'node', label: 'Node.js', have: true, core: true, test: /node\.?js|\bjavascript\b|\btypescript\b|\bexpress\b/i },
|
|
3
|
+
{ id: 'postgres', label: 'PostgreSQL', have: true, core: true, test: /\bpostgres(ql)?\b|\bprisma\b/i },
|
|
4
|
+
{ id: 'aws', label: 'AWS', have: true, core: true, test: /\baws\b|amazon web services/i },
|
|
5
|
+
{ id: 'react', label: 'React', have: true, core: true, test: /\breact\b|\bnext\.js\b/i },
|
|
6
|
+
{ id: 'remote', label: 'Remote', have: true, core: false, test: /\bremote\b/i },
|
|
7
|
+
{ id: 'senior', label: 'Senior', have: true, core: false, test: /\b(senior|staff|principal)\b/i },
|
|
8
|
+
{ id: 'payments', label: 'Payments (client rails)', have: true, core: true, test: /\b(payment|fintech|mastercard|visa)\b/i },
|
|
9
|
+
{ id: 'production', label: 'Production', have: true, core: true, test: /\b(production|on-call|incident|uptime)\b/i },
|
|
10
|
+
{ id: 'redis', label: 'Redis / queues', have: true, core: true, test: /\b(redis|bullmq|queue)\b/i },
|
|
11
|
+
{ id: 'docker', label: 'Docker', have: true, core: false, test: /\bdocker\b/i },
|
|
12
|
+
{ id: 'php', label: 'PHP', have: true, core: false, test: /\bphp\b/i },
|
|
13
|
+
{ id: 'go', label: 'Go', have: false, core: false, test: /\bgolang\b|\bgo developer\b|\bgo engineer\b/i },
|
|
14
|
+
{ id: 'kubernetes', label: 'Kubernetes', have: false, core: false, test: /\bkubernetes\b|\bk8s\b/i },
|
|
15
|
+
{ id: 'rust', label: 'Rust', have: false, core: false, test: /\brust\b/i },
|
|
16
|
+
{ id: 'mobile', label: 'iOS/Android native', have: false, core: false, test: /\b(swift|kotlin|android native|ios native)\b/i },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const fitAgainst = (text) => {
|
|
20
|
+
const hay = String(text || '');
|
|
21
|
+
const asked = FIT_SIGNALS.filter((signal) => signal.test.test(hay));
|
|
22
|
+
const matched = asked.filter((signal) => signal.have);
|
|
23
|
+
const missed = asked.filter((signal) => !signal.have);
|
|
24
|
+
const extra = FIT_SIGNALS.filter((signal) => signal.core && signal.have && !asked.includes(signal)).slice(0, 3);
|
|
25
|
+
const denom = matched.length + missed.length;
|
|
26
|
+
const score = denom === 0 ? null : Math.round((matched.length / denom) * 100);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
score,
|
|
30
|
+
matched: matched.map((signal) => signal.label),
|
|
31
|
+
missed: missed.map((signal) => signal.label),
|
|
32
|
+
extra: extra.map((signal) => signal.label),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const tweetFromReport = (report) => {
|
|
37
|
+
if (report && typeof report.score === 'number') {
|
|
38
|
+
return `this posting scored ${report.score}/100\nnpx jobless lint`;
|
|
39
|
+
}
|
|
40
|
+
return 'Got invited to interview and rejected at the same time.\nFiled it as a race condition.\nShipped the reproduction: npx jobless';
|
|
41
|
+
};
|
package/src/hiring.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { detectIdentity } from './identity.js';
|
|
2
|
+
import { copyToClipboard } from './clip.js';
|
|
3
|
+
import { blank, bold, dim, green, print, red, sleep, wrap } from './style.js';
|
|
4
|
+
|
|
5
|
+
const scenarios = [
|
|
6
|
+
{
|
|
7
|
+
accepted: "Congratulations! We'd love to interview you.",
|
|
8
|
+
rejected: "Unfortunately, we're moving forward with others.",
|
|
9
|
+
detail: 'Interview scheduled successfully.\nCandidate rejected successfully.',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
accepted: 'Your profile is exactly what we are looking for.',
|
|
13
|
+
rejected: 'Your profile does not match what we are looking for.',
|
|
14
|
+
detail: 'Requirements unchanged.\nReality updated successfully.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
accepted: 'Please choose a time to meet the team.',
|
|
18
|
+
rejected: 'We will not be meeting at this time.',
|
|
19
|
+
detail: 'Calendar invite delivered.\nClosure marked as spam.',
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const demoPosting = `DemoCorp is hiring a Senior Software Engineer.
|
|
24
|
+
Competitive salary. We are a family looking for a rockstar.
|
|
25
|
+
Six interview rounds. Unpaid take-home: build our next product.
|
|
26
|
+
Remote, but must be in the office three days a week.
|
|
27
|
+
Node.js, PostgreSQL, React. Nights and weekends expected.`;
|
|
28
|
+
|
|
29
|
+
export const hiringResult = ({ seed = 0, personalize = false } = {}) => {
|
|
30
|
+
const numeric = Number(seed);
|
|
31
|
+
if (!Number.isSafeInteger(numeric)) throw new Error('--seed needs a whole, safe integer');
|
|
32
|
+
const scenario = scenarios[((numeric % scenarios.length) + scenarios.length) % scenarios.length];
|
|
33
|
+
const greeting = personalize ? `Candidate: ${detectIdentity({ personalize: true }).name}\n\n` : '';
|
|
34
|
+
return `${greeting}✔ ${scenario.accepted}\n✔ ${scenario.rejected}\n\n` +
|
|
35
|
+
'ERR_HIRING_RACE_CONDITION\nApplication status: yesn\'t\n\n' +
|
|
36
|
+
`${scenario.detail}\nAll systems operational.`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const shareText = (result) => `${result}\n\nReproduce the hiring process: npx jobless`;
|
|
40
|
+
|
|
41
|
+
export const printShare = (text, { copy = false } = {}) => {
|
|
42
|
+
print(text);
|
|
43
|
+
if (copy) {
|
|
44
|
+
blank();
|
|
45
|
+
print(copyToClipboard(text) ? 'Copied. Send it to someone waiting for a reply.' : 'Clipboard unavailable. Copy the text above.');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const printHiring = async ({ seed = 0, personalize = false, animate = false } = {}) => {
|
|
50
|
+
const result = hiringResult({ seed, personalize });
|
|
51
|
+
blank();
|
|
52
|
+
for (const line of wrap('jobless / hiring simulator / satire')) print(dim(` ${line}`));
|
|
53
|
+
blank();
|
|
54
|
+
for (const line of result.split('\n')) {
|
|
55
|
+
const color = line.startsWith('✔') ? green : line.startsWith('ERR_') ? red : bold;
|
|
56
|
+
for (const row of wrap(line)) print(` ${color(row)}`);
|
|
57
|
+
if (animate && line.startsWith('✔')) await sleep(300);
|
|
58
|
+
}
|
|
59
|
+
blank();
|
|
60
|
+
for (const line of wrap("I couldn't fix their hiring pipeline. So I shipped my own.")) print(` ${line}`);
|
|
61
|
+
for (const line of wrap('Built by Yonas Alem. Meet the engineer: npx jobless hire')) print(dim(` ${line}`));
|
|
62
|
+
return result;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const printPlayHints = () => {
|
|
66
|
+
blank();
|
|
67
|
+
for (const line of ['[r] replay [d] demo [l] lint a posting', '[c] copy result [h] meet the author [q] quit']) {
|
|
68
|
+
for (const row of wrap(line)) print(` ${dim(row)}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
package/src/identity.js
CHANGED
|
@@ -40,7 +40,11 @@ const companyFromDomain = (domain) => {
|
|
|
40
40
|
.join(' ');
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
export const detectIdentity = () => {
|
|
43
|
+
export const detectIdentity = ({ personalize = false } = {}) => {
|
|
44
|
+
if (!personalize) {
|
|
45
|
+
return { name: 'Candidate', email: 'candidate@example.invalid', username: 'candidate',
|
|
46
|
+
domain: 'example.invalid', company: 'your team', cwd: '', host: '' };
|
|
47
|
+
}
|
|
44
48
|
const username = os.userInfo()?.username || process.env.USER || process.env.USERNAME || 'candidate';
|
|
45
49
|
const email = git('user.email') || `${username}@local`;
|
|
46
50
|
const name = git('user.name') || titleCase(username);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { lintResult } from './lint.js';
|
|
3
|
+
import { letterText } from './reject.js';
|
|
4
|
+
import { demoPosting, hiringResult, printHiring, printPlayHints, printShare, shareText } from './hiring.js';
|
|
5
|
+
import { blank, clear, dim, print, showCursor } from './style.js';
|
|
6
|
+
import { printHeader, printLintReport, printMenu } from './screens.js';
|
|
7
|
+
|
|
8
|
+
const readPaste = async (lines) => {
|
|
9
|
+
print(dim(' Paste a posting. An empty line finishes it.'));
|
|
10
|
+
const posting = [];
|
|
11
|
+
while (true) {
|
|
12
|
+
const { value, done } = await lines.next();
|
|
13
|
+
if (done || value === '') break;
|
|
14
|
+
posting.push(value);
|
|
15
|
+
}
|
|
16
|
+
return posting.join('\n');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const interactive = async ({ trap, seed = 0, personalize, resolveCommand, runCommand, showResume }) => {
|
|
20
|
+
// Keep one input iterator so pasted lines and EOF are not lost between prompts.
|
|
21
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
22
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
23
|
+
let lastReport = null;
|
|
24
|
+
let lastResult = '';
|
|
25
|
+
let nextSeed = Number(seed);
|
|
26
|
+
try {
|
|
27
|
+
clear();
|
|
28
|
+
if (trap) {
|
|
29
|
+
await printHiring({ seed: nextSeed, personalize, animate: true });
|
|
30
|
+
lastResult = shareText(hiringResult({ seed: nextSeed }));
|
|
31
|
+
printPlayHints();
|
|
32
|
+
} else showResume();
|
|
33
|
+
|
|
34
|
+
while (true) {
|
|
35
|
+
process.stdout.write(dim(' jobless> '));
|
|
36
|
+
const { value, done } = await lines.next();
|
|
37
|
+
if (done) break;
|
|
38
|
+
const command = resolveCommand(value);
|
|
39
|
+
if (command === 'quit') {
|
|
40
|
+
blank();
|
|
41
|
+
print(dim(' still shipping. still waiting for a reply.'));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (command === 'replay') {
|
|
45
|
+
lastReport = null;
|
|
46
|
+
nextSeed = (nextSeed % 3) + 1;
|
|
47
|
+
clear();
|
|
48
|
+
await printHiring({ seed: nextSeed, personalize, animate: true });
|
|
49
|
+
lastResult = shareText(hiringResult({ seed: nextSeed }));
|
|
50
|
+
printPlayHints();
|
|
51
|
+
} else if (command === 'copy' || command === 'share') {
|
|
52
|
+
if (lastResult) printShare(lastResult, { copy: command === 'copy' });
|
|
53
|
+
else print(dim(' Try r for a hiring result or d for the posting demo.'));
|
|
54
|
+
} else if (command === 'lint' || command === 'demo') {
|
|
55
|
+
const text = command === 'demo' ? demoPosting : await readPaste(lines);
|
|
56
|
+
clear();
|
|
57
|
+
lastReport = lintResult(text, command === 'demo' ? 'demo' : 'paste');
|
|
58
|
+
printLintReport(lastReport);
|
|
59
|
+
lastResult = lastReport.letter ? `${letterText(lastReport.letter)}\n\nLint the posting: npx jobless lint` :
|
|
60
|
+
`Posting passed the satire rules: ${lastReport.score}/100\nnpx jobless lint`;
|
|
61
|
+
printPlayHints();
|
|
62
|
+
} else if (command) {
|
|
63
|
+
clear();
|
|
64
|
+
if (!['help', 'why', 'tweet', 'rant'].includes(command)) printHeader();
|
|
65
|
+
runCommand(command, lastReport);
|
|
66
|
+
printMenu();
|
|
67
|
+
} else print(dim(' Try r, d, l, c, h, help, or q.'));
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
rl.close();
|
|
71
|
+
showCursor();
|
|
72
|
+
}
|
|
73
|
+
};
|