terminalhire 0.1.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 +294 -0
- package/dist/bin/jpi-dispatch.js +2264 -0
- package/dist/bin/jpi-jobs.js +1506 -0
- package/dist/bin/jpi-learn.js +815 -0
- package/dist/bin/jpi-login.js +1603 -0
- package/dist/bin/jpi-profile.js +625 -0
- package/dist/bin/jpi.js +106 -0
- package/dist/src/github-auth.js +206 -0
- package/dist/src/profile.js +423 -0
- package/dist/src/signal.js +447 -0
- package/fixtures/github-sample.json +33 -0
- package/install.js +275 -0
- package/package.json +43 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ../../packages/core/src/types.ts
|
|
13
|
+
var init_types = __esm({
|
|
14
|
+
"../../packages/core/src/types.ts"() {
|
|
15
|
+
"use strict";
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ../../packages/core/src/vocabulary.ts
|
|
20
|
+
function normalize(tokens) {
|
|
21
|
+
const result = /* @__PURE__ */ new Set();
|
|
22
|
+
for (const raw of tokens) {
|
|
23
|
+
const lower = raw.toLowerCase().trim();
|
|
24
|
+
if (VOCAB_SET.has(lower)) {
|
|
25
|
+
result.add(lower);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const mapped = SYNONYMS[lower];
|
|
29
|
+
if (mapped && VOCAB_SET.has(mapped)) {
|
|
30
|
+
result.add(mapped);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Array.from(result);
|
|
34
|
+
}
|
|
35
|
+
var VOCABULARY, SYNONYMS, VOCAB_SET;
|
|
36
|
+
var init_vocabulary = __esm({
|
|
37
|
+
"../../packages/core/src/vocabulary.ts"() {
|
|
38
|
+
"use strict";
|
|
39
|
+
VOCABULARY = [
|
|
40
|
+
// Languages
|
|
41
|
+
"typescript",
|
|
42
|
+
"javascript",
|
|
43
|
+
"python",
|
|
44
|
+
"go",
|
|
45
|
+
"rust",
|
|
46
|
+
"java",
|
|
47
|
+
"ruby",
|
|
48
|
+
"elixir",
|
|
49
|
+
"scala",
|
|
50
|
+
"kotlin",
|
|
51
|
+
"swift",
|
|
52
|
+
"cpp",
|
|
53
|
+
"csharp",
|
|
54
|
+
"php",
|
|
55
|
+
"haskell",
|
|
56
|
+
"clojure",
|
|
57
|
+
"r",
|
|
58
|
+
// Frontend frameworks / libs
|
|
59
|
+
"react",
|
|
60
|
+
"nextjs",
|
|
61
|
+
"vue",
|
|
62
|
+
"nuxt",
|
|
63
|
+
"svelte",
|
|
64
|
+
"angular",
|
|
65
|
+
"solidjs",
|
|
66
|
+
"tailwind",
|
|
67
|
+
"css",
|
|
68
|
+
"html",
|
|
69
|
+
"graphql",
|
|
70
|
+
"trpc",
|
|
71
|
+
// Backend frameworks
|
|
72
|
+
"nodejs",
|
|
73
|
+
"express",
|
|
74
|
+
"fastify",
|
|
75
|
+
"nestjs",
|
|
76
|
+
"django",
|
|
77
|
+
"fastapi",
|
|
78
|
+
"flask",
|
|
79
|
+
"rails",
|
|
80
|
+
"spring",
|
|
81
|
+
"actix",
|
|
82
|
+
"gin",
|
|
83
|
+
"phoenix",
|
|
84
|
+
"laravel",
|
|
85
|
+
"dotnet",
|
|
86
|
+
// Infrastructure & DevOps
|
|
87
|
+
"kubernetes",
|
|
88
|
+
"docker",
|
|
89
|
+
"terraform",
|
|
90
|
+
"aws",
|
|
91
|
+
"gcp",
|
|
92
|
+
"azure",
|
|
93
|
+
"ci-cd",
|
|
94
|
+
"github-actions",
|
|
95
|
+
"linux",
|
|
96
|
+
"nginx",
|
|
97
|
+
"pulumi",
|
|
98
|
+
"ansible",
|
|
99
|
+
"prometheus",
|
|
100
|
+
"grafana",
|
|
101
|
+
"datadog",
|
|
102
|
+
"opentelemetry",
|
|
103
|
+
// Data & ML
|
|
104
|
+
"postgresql",
|
|
105
|
+
"mysql",
|
|
106
|
+
"sqlite",
|
|
107
|
+
"mongodb",
|
|
108
|
+
"redis",
|
|
109
|
+
"elasticsearch",
|
|
110
|
+
"kafka",
|
|
111
|
+
"rabbitmq",
|
|
112
|
+
"data-engineering",
|
|
113
|
+
"spark",
|
|
114
|
+
"airflow",
|
|
115
|
+
"dbt",
|
|
116
|
+
"ml",
|
|
117
|
+
"llm",
|
|
118
|
+
"pytorch",
|
|
119
|
+
"tensorflow",
|
|
120
|
+
"pandas",
|
|
121
|
+
"numpy",
|
|
122
|
+
// Domains / capabilities
|
|
123
|
+
"oauth",
|
|
124
|
+
"authentication",
|
|
125
|
+
"security",
|
|
126
|
+
"payments",
|
|
127
|
+
"billing",
|
|
128
|
+
"frontend",
|
|
129
|
+
"backend",
|
|
130
|
+
"devops",
|
|
131
|
+
"mobile",
|
|
132
|
+
"ios",
|
|
133
|
+
"android",
|
|
134
|
+
"api-design",
|
|
135
|
+
"microservices",
|
|
136
|
+
"websockets",
|
|
137
|
+
"testing",
|
|
138
|
+
"accessibility",
|
|
139
|
+
"seo",
|
|
140
|
+
"performance",
|
|
141
|
+
"observability",
|
|
142
|
+
"search",
|
|
143
|
+
"realtime"
|
|
144
|
+
];
|
|
145
|
+
SYNONYMS = {
|
|
146
|
+
// Kubernetes aliases
|
|
147
|
+
"k8s": "kubernetes",
|
|
148
|
+
"kube": "kubernetes",
|
|
149
|
+
// Auth / identity
|
|
150
|
+
"passport": "authentication",
|
|
151
|
+
"oauth2": "oauth",
|
|
152
|
+
"oidc": "oauth",
|
|
153
|
+
"jwt": "authentication",
|
|
154
|
+
"saml": "authentication",
|
|
155
|
+
"auth0": "authentication",
|
|
156
|
+
"clerk": "authentication",
|
|
157
|
+
"nextauth": "authentication",
|
|
158
|
+
// Payments
|
|
159
|
+
"@stripe/stripe-js": "payments",
|
|
160
|
+
"stripe": "payments",
|
|
161
|
+
"braintree": "payments",
|
|
162
|
+
"paddle": "payments",
|
|
163
|
+
"lemonsqueezy": "payments",
|
|
164
|
+
"recurly": "billing",
|
|
165
|
+
"chargebee": "billing",
|
|
166
|
+
// Framework / lib aliases
|
|
167
|
+
"next": "nextjs",
|
|
168
|
+
"next.js": "nextjs",
|
|
169
|
+
"nuxt.js": "nuxt",
|
|
170
|
+
"vue.js": "vue",
|
|
171
|
+
"angular.js": "angular",
|
|
172
|
+
"angularjs": "angular",
|
|
173
|
+
"express.js": "express",
|
|
174
|
+
"expressjs": "express",
|
|
175
|
+
"fastapi": "fastapi",
|
|
176
|
+
"nest": "nestjs",
|
|
177
|
+
"nest.js": "nestjs",
|
|
178
|
+
"sveltekit": "svelte",
|
|
179
|
+
// Language aliases
|
|
180
|
+
"ts": "typescript",
|
|
181
|
+
"js": "javascript",
|
|
182
|
+
"py": "python",
|
|
183
|
+
"golang": "go",
|
|
184
|
+
"c++": "cpp",
|
|
185
|
+
"c#": "csharp",
|
|
186
|
+
".net": "dotnet",
|
|
187
|
+
"asp.net": "dotnet",
|
|
188
|
+
// DB aliases
|
|
189
|
+
"postgres": "postgresql",
|
|
190
|
+
"pg": "postgresql",
|
|
191
|
+
"mongo": "mongodb",
|
|
192
|
+
"elastic": "elasticsearch",
|
|
193
|
+
// Cloud aliases
|
|
194
|
+
"amazon web services": "aws",
|
|
195
|
+
"google cloud": "gcp",
|
|
196
|
+
"google cloud platform": "gcp",
|
|
197
|
+
"microsoft azure": "azure",
|
|
198
|
+
// CI/CD aliases
|
|
199
|
+
"github actions": "github-actions",
|
|
200
|
+
"circle ci": "ci-cd",
|
|
201
|
+
"circleci": "ci-cd",
|
|
202
|
+
"jenkins": "ci-cd",
|
|
203
|
+
"gitlab ci": "ci-cd",
|
|
204
|
+
"travis": "ci-cd",
|
|
205
|
+
// Mobile
|
|
206
|
+
"react native": "mobile",
|
|
207
|
+
"flutter": "mobile",
|
|
208
|
+
"expo": "mobile",
|
|
209
|
+
// AI / ML
|
|
210
|
+
"openai": "llm",
|
|
211
|
+
"anthropic": "llm",
|
|
212
|
+
"langchain": "llm",
|
|
213
|
+
"llamaindex": "llm",
|
|
214
|
+
"hugging face": "ml",
|
|
215
|
+
"huggingface": "ml",
|
|
216
|
+
"scikit-learn": "ml",
|
|
217
|
+
"sklearn": "ml",
|
|
218
|
+
// Data pipeline
|
|
219
|
+
"apache kafka": "kafka",
|
|
220
|
+
"apache spark": "spark",
|
|
221
|
+
"apache airflow": "airflow",
|
|
222
|
+
// Misc
|
|
223
|
+
"tailwindcss": "tailwind",
|
|
224
|
+
"tw": "tailwind",
|
|
225
|
+
"gql": "graphql",
|
|
226
|
+
"ws": "websockets",
|
|
227
|
+
"socket.io": "websockets",
|
|
228
|
+
"jest": "testing",
|
|
229
|
+
"vitest": "testing",
|
|
230
|
+
"playwright": "testing",
|
|
231
|
+
"cypress": "testing"
|
|
232
|
+
};
|
|
233
|
+
VOCAB_SET = new Set(VOCABULARY);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ../../packages/core/src/matcher.ts
|
|
238
|
+
function computeIdf(jobs) {
|
|
239
|
+
const docFreq = /* @__PURE__ */ new Map();
|
|
240
|
+
const N = jobs.length;
|
|
241
|
+
for (const job of jobs) {
|
|
242
|
+
const unique = new Set(job.tags);
|
|
243
|
+
for (const tag of unique) {
|
|
244
|
+
docFreq.set(tag, (docFreq.get(tag) ?? 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const idf = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const [tag, df] of docFreq) {
|
|
249
|
+
idf.set(tag, Math.log((N + 1) / (df + 1)) + 1);
|
|
250
|
+
}
|
|
251
|
+
return idf;
|
|
252
|
+
}
|
|
253
|
+
function inferSeniority(title) {
|
|
254
|
+
for (const [re, level] of SENIORITY_PATTERNS) {
|
|
255
|
+
if (re.test(title)) return level;
|
|
256
|
+
}
|
|
257
|
+
return void 0;
|
|
258
|
+
}
|
|
259
|
+
function seniorityScore(fp, job) {
|
|
260
|
+
if (!fp.seniorityBand) return 1;
|
|
261
|
+
const jobLevel = inferSeniority(job.title);
|
|
262
|
+
if (!jobLevel) return 0.85;
|
|
263
|
+
const wanted = SENIORITY_RANK[fp.seniorityBand] ?? 1;
|
|
264
|
+
const got = SENIORITY_RANK[jobLevel] ?? 1;
|
|
265
|
+
const delta = Math.abs(wanted - got);
|
|
266
|
+
if (delta === 0) return 1;
|
|
267
|
+
if (delta === 1) return 0.5;
|
|
268
|
+
return 0.2;
|
|
269
|
+
}
|
|
270
|
+
function recencyScore(postedAt) {
|
|
271
|
+
if (!postedAt) return 0.75;
|
|
272
|
+
const ageDays = (Date.now() - new Date(postedAt).getTime()) / 864e5;
|
|
273
|
+
if (ageDays < 7) return 1;
|
|
274
|
+
if (ageDays < 30) return 0.9;
|
|
275
|
+
if (ageDays < 90) return 0.75;
|
|
276
|
+
return 0.6;
|
|
277
|
+
}
|
|
278
|
+
function passesFilters(fp, job) {
|
|
279
|
+
const prefs = fp.prefs;
|
|
280
|
+
if (!prefs) return true;
|
|
281
|
+
if (prefs.remoteOnly && !job.remote) return false;
|
|
282
|
+
if (prefs.roleTypes && prefs.roleTypes.length > 0 && !prefs.roleTypes.includes(job.roleType)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if (prefs.compFloorUsd !== void 0) {
|
|
286
|
+
if (job.compMax !== void 0 && job.compMax < prefs.compFloorUsd) return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
function buildReason(matchedTags) {
|
|
291
|
+
if (matchedTags.length === 0) return "No direct skill overlap found.";
|
|
292
|
+
const top = matchedTags.slice(0, 3);
|
|
293
|
+
const rest = matchedTags.length - top.length;
|
|
294
|
+
const listed = top.join(", ");
|
|
295
|
+
if (rest === 0) return `Matched on ${listed}.`;
|
|
296
|
+
return `Matched on ${listed} + ${rest} more skill${rest > 1 ? "s" : ""}.`;
|
|
297
|
+
}
|
|
298
|
+
function match(fp, jobs, limit = 5) {
|
|
299
|
+
const idf = computeIdf(jobs);
|
|
300
|
+
const fpTagSet = new Set(fp.skillTags);
|
|
301
|
+
const maxTagScore = fp.skillTags.reduce((acc, t) => acc + (idf.get(t) ?? 1), 0);
|
|
302
|
+
const candidates = jobs.filter((j) => passesFilters(fp, j));
|
|
303
|
+
const scored = candidates.map((job) => {
|
|
304
|
+
const jobTagSet = new Set(job.tags);
|
|
305
|
+
const matched = [];
|
|
306
|
+
let tagScore2 = 0;
|
|
307
|
+
for (const tag of fpTagSet) {
|
|
308
|
+
if (jobTagSet.has(tag)) {
|
|
309
|
+
const w = idf.get(tag) ?? 1;
|
|
310
|
+
tagScore2 += w;
|
|
311
|
+
matched.push(tag);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const normTagScore = maxTagScore > 0 ? tagScore2 / maxTagScore : 0;
|
|
315
|
+
matched.sort((a, b) => (idf.get(b) ?? 1) - (idf.get(a) ?? 1));
|
|
316
|
+
const sScore = seniorityScore(fp, job);
|
|
317
|
+
const rScore = recencyScore(job.postedAt);
|
|
318
|
+
const score = normTagScore * 0.6 + sScore * 0.25 + rScore * 0.15;
|
|
319
|
+
return {
|
|
320
|
+
job,
|
|
321
|
+
score: Math.round(score * 1e3) / 1e3,
|
|
322
|
+
matchedTags: matched,
|
|
323
|
+
reason: buildReason(matched)
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
327
|
+
}
|
|
328
|
+
function matchOne(fp, job) {
|
|
329
|
+
const results = match(fp, [job], 1);
|
|
330
|
+
return results.length > 0 ? results[0] : null;
|
|
331
|
+
}
|
|
332
|
+
var SENIORITY_RANK, SENIORITY_PATTERNS;
|
|
333
|
+
var init_matcher = __esm({
|
|
334
|
+
"../../packages/core/src/matcher.ts"() {
|
|
335
|
+
"use strict";
|
|
336
|
+
SENIORITY_RANK = {
|
|
337
|
+
junior: 0,
|
|
338
|
+
mid: 1,
|
|
339
|
+
senior: 2,
|
|
340
|
+
staff: 3
|
|
341
|
+
};
|
|
342
|
+
SENIORITY_PATTERNS = [
|
|
343
|
+
[/\bstaff\b|\bprincipal\b|\bdistinguished\b/i, "staff"],
|
|
344
|
+
[/\bsenior\b|\bsr\.?\b/i, "senior"],
|
|
345
|
+
[/\bjunior\b|\bjr\.?\b|\bentry[\s-]?level\b/i, "junior"],
|
|
346
|
+
[/\bmid[\s-]?level\b|\bmid\b/i, "mid"]
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ../../packages/core/src/feeds/greenhouse.ts
|
|
352
|
+
function tokenize(text) {
|
|
353
|
+
return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
|
|
354
|
+
}
|
|
355
|
+
function extractTags(job) {
|
|
356
|
+
const texts = [
|
|
357
|
+
job.title,
|
|
358
|
+
...(job.departments ?? []).map((d) => d.name),
|
|
359
|
+
job.location?.name ?? "",
|
|
360
|
+
...(job.offices ?? []).map((o) => o.name),
|
|
361
|
+
// mine the full HTML description for additional signal when present
|
|
362
|
+
...job.content ? [job.content.replace(/<[^>]*>/g, " ")] : []
|
|
363
|
+
].filter(Boolean);
|
|
364
|
+
const tokens = texts.flatMap(tokenize);
|
|
365
|
+
return normalize(tokens);
|
|
366
|
+
}
|
|
367
|
+
function inferRemote(location) {
|
|
368
|
+
const l = location.toLowerCase();
|
|
369
|
+
return l.includes("remote") || l.includes("anywhere") || l.includes("worldwide");
|
|
370
|
+
}
|
|
371
|
+
async function fetchSlug(slug) {
|
|
372
|
+
const url = `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs?content=true`;
|
|
373
|
+
let res;
|
|
374
|
+
try {
|
|
375
|
+
res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.warn(`[greenhouse] ${slug}: network error \u2014`, err);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
if (!res.ok) {
|
|
381
|
+
console.warn(`[greenhouse] ${slug}: HTTP ${res.status} ${res.statusText}`);
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
let data;
|
|
385
|
+
try {
|
|
386
|
+
data = await res.json();
|
|
387
|
+
} catch (err) {
|
|
388
|
+
console.warn(`[greenhouse] ${slug}: JSON parse error \u2014`, err);
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const jobs = data.jobs ?? [];
|
|
392
|
+
if (jobs.length === 0) {
|
|
393
|
+
console.warn(`[greenhouse] ${slug}: 0 jobs returned (board may be private or slug invalid)`);
|
|
394
|
+
} else {
|
|
395
|
+
console.info(`[greenhouse] ${slug}: ${jobs.length} jobs`);
|
|
396
|
+
}
|
|
397
|
+
return jobs.map((j) => ({
|
|
398
|
+
id: `greenhouse:${j.id}`,
|
|
399
|
+
source: "greenhouse",
|
|
400
|
+
title: j.title,
|
|
401
|
+
company: slug,
|
|
402
|
+
url: j.absolute_url,
|
|
403
|
+
remote: inferRemote(j.location?.name ?? ""),
|
|
404
|
+
location: j.location?.name,
|
|
405
|
+
tags: extractTags(j),
|
|
406
|
+
roleType: "full_time",
|
|
407
|
+
postedAt: j.updated_at,
|
|
408
|
+
applyMode: "direct",
|
|
409
|
+
raw: j
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
var FALLBACK_SLUGS, greenhouse;
|
|
413
|
+
var init_greenhouse = __esm({
|
|
414
|
+
"../../packages/core/src/feeds/greenhouse.ts"() {
|
|
415
|
+
"use strict";
|
|
416
|
+
init_vocabulary();
|
|
417
|
+
FALLBACK_SLUGS = [
|
|
418
|
+
"stripe",
|
|
419
|
+
"linear",
|
|
420
|
+
"vercel",
|
|
421
|
+
"ramp",
|
|
422
|
+
"notion",
|
|
423
|
+
"airbnb",
|
|
424
|
+
"anthropic",
|
|
425
|
+
"figma",
|
|
426
|
+
"discord",
|
|
427
|
+
"brex",
|
|
428
|
+
"mercury",
|
|
429
|
+
"retool",
|
|
430
|
+
"vanta",
|
|
431
|
+
"plaid",
|
|
432
|
+
"gusto",
|
|
433
|
+
"scale",
|
|
434
|
+
"databricks",
|
|
435
|
+
"coinbase",
|
|
436
|
+
"robinhood",
|
|
437
|
+
"doordash"
|
|
438
|
+
];
|
|
439
|
+
greenhouse = {
|
|
440
|
+
source: "greenhouse",
|
|
441
|
+
async fetch(opts) {
|
|
442
|
+
const slugs = opts?.slugs && opts.slugs.length > 0 ? opts.slugs : FALLBACK_SLUGS;
|
|
443
|
+
console.info(`[greenhouse] fetching ${slugs.length} slugs: ${slugs.join(", ")}`);
|
|
444
|
+
const results = await Promise.allSettled(slugs.map(fetchSlug));
|
|
445
|
+
const jobs = [];
|
|
446
|
+
let failures = 0;
|
|
447
|
+
for (const r of results) {
|
|
448
|
+
if (r.status === "fulfilled") {
|
|
449
|
+
jobs.push(...r.value);
|
|
450
|
+
} else {
|
|
451
|
+
failures++;
|
|
452
|
+
console.warn(`[greenhouse] slug fetch rejected:`, r.reason);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
console.info(`[greenhouse] total: ${jobs.length} jobs, ${failures} slug failures`);
|
|
456
|
+
return jobs;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ../../packages/core/src/feeds/ashby.ts
|
|
463
|
+
function tokenize2(text) {
|
|
464
|
+
return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
|
|
465
|
+
}
|
|
466
|
+
function extractTags2(job) {
|
|
467
|
+
const texts = [
|
|
468
|
+
job.title,
|
|
469
|
+
job.teamName ?? "",
|
|
470
|
+
job.locationName ?? "",
|
|
471
|
+
...(job.secondaryLocations ?? []).map((l) => l.locationName ?? "")
|
|
472
|
+
];
|
|
473
|
+
return normalize(texts.flatMap(tokenize2));
|
|
474
|
+
}
|
|
475
|
+
function mapEmploymentType(raw) {
|
|
476
|
+
if (!raw) return "full_time";
|
|
477
|
+
const lower = raw.toLowerCase();
|
|
478
|
+
if (lower.includes("contract") || lower.includes("contractor")) return "contract";
|
|
479
|
+
if (lower.includes("freelance")) return "freelance";
|
|
480
|
+
return "full_time";
|
|
481
|
+
}
|
|
482
|
+
function inferRemote2(job) {
|
|
483
|
+
if (job.isRemote === true) return true;
|
|
484
|
+
const loc = (job.locationName ?? "").toLowerCase();
|
|
485
|
+
return loc.includes("remote") || loc.includes("anywhere");
|
|
486
|
+
}
|
|
487
|
+
async function fetchSlug2(slug) {
|
|
488
|
+
const url = `https://api.ashbyhq.com/posting-api/job-board/${slug}`;
|
|
489
|
+
const res = await fetch(url, {
|
|
490
|
+
headers: { Accept: "application/json" }
|
|
491
|
+
});
|
|
492
|
+
if (!res.ok) {
|
|
493
|
+
throw new Error(`Ashby ${slug}: HTTP ${res.status}`);
|
|
494
|
+
}
|
|
495
|
+
const data = await res.json();
|
|
496
|
+
return (data.jobs ?? []).map((j) => {
|
|
497
|
+
const comp = j.compensation;
|
|
498
|
+
return {
|
|
499
|
+
id: `ashby:${j.id}`,
|
|
500
|
+
source: "ashby",
|
|
501
|
+
title: j.title,
|
|
502
|
+
company: slug,
|
|
503
|
+
url: j.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${j.id}`,
|
|
504
|
+
remote: inferRemote2(j),
|
|
505
|
+
location: j.locationName,
|
|
506
|
+
compMin: comp?.minValue,
|
|
507
|
+
compMax: comp?.maxValue,
|
|
508
|
+
tags: extractTags2(j),
|
|
509
|
+
roleType: mapEmploymentType(j.employmentType),
|
|
510
|
+
postedAt: j.publishedDate,
|
|
511
|
+
applyMode: "direct",
|
|
512
|
+
raw: j
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
var ashby;
|
|
517
|
+
var init_ashby = __esm({
|
|
518
|
+
"../../packages/core/src/feeds/ashby.ts"() {
|
|
519
|
+
"use strict";
|
|
520
|
+
init_vocabulary();
|
|
521
|
+
ashby = {
|
|
522
|
+
source: "ashby",
|
|
523
|
+
async fetch(opts) {
|
|
524
|
+
const slugs = opts?.slugs ?? [];
|
|
525
|
+
const results = await Promise.allSettled(slugs.map(fetchSlug2));
|
|
526
|
+
const jobs = [];
|
|
527
|
+
for (const r of results) {
|
|
528
|
+
if (r.status === "fulfilled") jobs.push(...r.value);
|
|
529
|
+
}
|
|
530
|
+
return jobs;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// ../../packages/core/src/feeds/himalayas.ts
|
|
537
|
+
function tokenize3(text) {
|
|
538
|
+
return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
|
|
539
|
+
}
|
|
540
|
+
function extractTags3(job) {
|
|
541
|
+
const texts = [
|
|
542
|
+
job.title,
|
|
543
|
+
...job.tags ?? []
|
|
544
|
+
];
|
|
545
|
+
return normalize(texts.flatMap(tokenize3));
|
|
546
|
+
}
|
|
547
|
+
function mapJobType(raw) {
|
|
548
|
+
if (!raw) return "full_time";
|
|
549
|
+
const lower = raw.toLowerCase();
|
|
550
|
+
if (lower.includes("contract")) return "contract";
|
|
551
|
+
if (lower.includes("freelance")) return "freelance";
|
|
552
|
+
return "full_time";
|
|
553
|
+
}
|
|
554
|
+
function buildUrl(job) {
|
|
555
|
+
if (job.applicationUrl) return job.applicationUrl;
|
|
556
|
+
if (job.url) return job.url;
|
|
557
|
+
const slug = job.slug ?? job.id ?? "unknown";
|
|
558
|
+
return `https://himalayas.app/jobs/${slug}`;
|
|
559
|
+
}
|
|
560
|
+
function buildId(job) {
|
|
561
|
+
return `himalayas:${job.id ?? job.slug ?? job.title}`;
|
|
562
|
+
}
|
|
563
|
+
var himalayas;
|
|
564
|
+
var init_himalayas = __esm({
|
|
565
|
+
"../../packages/core/src/feeds/himalayas.ts"() {
|
|
566
|
+
"use strict";
|
|
567
|
+
init_vocabulary();
|
|
568
|
+
himalayas = {
|
|
569
|
+
source: "himalayas",
|
|
570
|
+
async fetch(opts) {
|
|
571
|
+
const limit = opts?.limit ?? 100;
|
|
572
|
+
const url = `https://himalayas.app/jobs/api?limit=${limit}`;
|
|
573
|
+
const res = await fetch(url, {
|
|
574
|
+
headers: { Accept: "application/json" }
|
|
575
|
+
});
|
|
576
|
+
if (!res.ok) {
|
|
577
|
+
throw new Error(`Himalayas: HTTP ${res.status}`);
|
|
578
|
+
}
|
|
579
|
+
const data = await res.json();
|
|
580
|
+
return (data.jobs ?? []).map((j) => ({
|
|
581
|
+
id: buildId(j),
|
|
582
|
+
source: "himalayas",
|
|
583
|
+
title: j.title,
|
|
584
|
+
company: j.companyName ?? j.companySlug ?? "unknown",
|
|
585
|
+
url: buildUrl(j),
|
|
586
|
+
// Himalayas is a remote-only board
|
|
587
|
+
remote: true,
|
|
588
|
+
location: (j.locationRestrictions ?? []).join(", ") || "Remote",
|
|
589
|
+
compMin: j.salaryMin,
|
|
590
|
+
compMax: j.salaryMax,
|
|
591
|
+
tags: extractTags3(j),
|
|
592
|
+
roleType: mapJobType(j.jobType),
|
|
593
|
+
postedAt: j.pubDate ?? j.createdAt,
|
|
594
|
+
applyMode: "direct",
|
|
595
|
+
raw: j
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// ../../packages/core/src/feeds/wwr.ts
|
|
603
|
+
function tokenize4(text) {
|
|
604
|
+
return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
|
|
605
|
+
}
|
|
606
|
+
function stripHtml(html) {
|
|
607
|
+
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
608
|
+
}
|
|
609
|
+
function inferRoleType(category) {
|
|
610
|
+
const lower = category.toLowerCase();
|
|
611
|
+
if (lower.includes("contract")) return "contract";
|
|
612
|
+
if (lower.includes("freelance")) return "freelance";
|
|
613
|
+
return "full_time";
|
|
614
|
+
}
|
|
615
|
+
function extractId(link) {
|
|
616
|
+
const match2 = link.match(/\/opening\/([^/\s]+)/);
|
|
617
|
+
return `wwr:${match2?.[1] ?? encodeURIComponent(link)}`;
|
|
618
|
+
}
|
|
619
|
+
function parseRss(xml) {
|
|
620
|
+
const items = [];
|
|
621
|
+
const itemBlocks = xml.match(/<item>([\s\S]*?)<\/item>/g) ?? [];
|
|
622
|
+
for (const block of itemBlocks) {
|
|
623
|
+
const get = (tag) => {
|
|
624
|
+
const cdataMatch = block.match(new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, "i"));
|
|
625
|
+
if (cdataMatch) return cdataMatch[1].trim();
|
|
626
|
+
const plainMatch = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
|
|
627
|
+
return plainMatch?.[1].trim() ?? "";
|
|
628
|
+
};
|
|
629
|
+
const rawTitle = get("title");
|
|
630
|
+
const colonIdx = rawTitle.indexOf(":");
|
|
631
|
+
const company = colonIdx !== -1 ? rawTitle.slice(0, colonIdx).trim() : "Unknown";
|
|
632
|
+
const titleAfterColon = colonIdx !== -1 ? rawTitle.slice(colonIdx + 1).trim() : rawTitle;
|
|
633
|
+
const title = titleAfterColon.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
634
|
+
items.push({
|
|
635
|
+
title,
|
|
636
|
+
link: get("link") || get("guid"),
|
|
637
|
+
pubDate: get("pubDate"),
|
|
638
|
+
category: get("category"),
|
|
639
|
+
description: get("description"),
|
|
640
|
+
company
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return items;
|
|
644
|
+
}
|
|
645
|
+
function extractTags4(item) {
|
|
646
|
+
const text = [item.title, item.category, stripHtml(item.description)].join(" ");
|
|
647
|
+
return normalize(tokenize4(text));
|
|
648
|
+
}
|
|
649
|
+
var WWR_RSS_URL, wwr;
|
|
650
|
+
var init_wwr = __esm({
|
|
651
|
+
"../../packages/core/src/feeds/wwr.ts"() {
|
|
652
|
+
"use strict";
|
|
653
|
+
init_vocabulary();
|
|
654
|
+
WWR_RSS_URL = "https://weworkremotely.com/remote-jobs.rss";
|
|
655
|
+
wwr = {
|
|
656
|
+
source: "wwr",
|
|
657
|
+
async fetch(opts) {
|
|
658
|
+
const limit = opts?.limit ?? 200;
|
|
659
|
+
const res = await fetch(WWR_RSS_URL, {
|
|
660
|
+
headers: { Accept: "application/rss+xml, application/xml, text/xml" }
|
|
661
|
+
});
|
|
662
|
+
if (!res.ok) {
|
|
663
|
+
throw new Error(`WWR RSS: HTTP ${res.status}`);
|
|
664
|
+
}
|
|
665
|
+
const xml = await res.text();
|
|
666
|
+
const items = parseRss(xml).slice(0, limit);
|
|
667
|
+
return items.map((item) => ({
|
|
668
|
+
id: extractId(item.link),
|
|
669
|
+
source: "wwr",
|
|
670
|
+
title: item.title,
|
|
671
|
+
company: item.company,
|
|
672
|
+
url: item.link,
|
|
673
|
+
// WWR is a remote-only board
|
|
674
|
+
remote: true,
|
|
675
|
+
location: "Remote",
|
|
676
|
+
tags: extractTags4(item),
|
|
677
|
+
roleType: inferRoleType(item.category),
|
|
678
|
+
postedAt: item.pubDate ? new Date(item.pubDate).toISOString() : void 0,
|
|
679
|
+
applyMode: "direct",
|
|
680
|
+
raw: item
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// ../../packages/core/src/feeds/hn.ts
|
|
688
|
+
function tokenize5(text) {
|
|
689
|
+
return text.toLowerCase().replace(/[^a-z0-9.\-+#]/g, " ").split(/\s+/).filter(Boolean);
|
|
690
|
+
}
|
|
691
|
+
function stripHtml2(html) {
|
|
692
|
+
return html.replace(/<p>/gi, " ").replace(/<[^>]*>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim();
|
|
693
|
+
}
|
|
694
|
+
function extractUrl(text) {
|
|
695
|
+
const match2 = text.match(/https?:\/\/[^\s<>"']+/);
|
|
696
|
+
return match2?.[0] ?? "";
|
|
697
|
+
}
|
|
698
|
+
function inferRemote3(text) {
|
|
699
|
+
const lower = text.toLowerCase();
|
|
700
|
+
return lower.includes("remote") || lower.includes("anywhere") || lower.includes("distributed");
|
|
701
|
+
}
|
|
702
|
+
function inferRoleType2(text) {
|
|
703
|
+
const lower = text.toLowerCase();
|
|
704
|
+
if (lower.includes("contract") || lower.includes("contractor")) return "contract";
|
|
705
|
+
if (lower.includes("freelance")) return "freelance";
|
|
706
|
+
return "full_time";
|
|
707
|
+
}
|
|
708
|
+
function parseComment(item) {
|
|
709
|
+
if (!item.text || item.text.trim().length < 20) return null;
|
|
710
|
+
const raw = stripHtml2(item.text);
|
|
711
|
+
if (!raw) return null;
|
|
712
|
+
const firstLine = raw.split(/\n/)[0];
|
|
713
|
+
const parts = firstLine.split("|").map((s) => s.trim());
|
|
714
|
+
const company = parts[0] ?? "Unknown";
|
|
715
|
+
const title = parts[1] ?? firstLine.slice(0, 80).trim();
|
|
716
|
+
const location = parts[2] ?? "";
|
|
717
|
+
if (company.toLowerCase().startsWith("note:") || company.toLowerCase().startsWith("ps:") || title.length < 3) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
const url = extractUrl(raw) || `https://news.ycombinator.com/item?id=${item.id}`;
|
|
721
|
+
const tags = extractTags5(raw);
|
|
722
|
+
if (tags.length === 0) return null;
|
|
723
|
+
return {
|
|
724
|
+
id: `hn:${item.id}`,
|
|
725
|
+
source: "hn",
|
|
726
|
+
title: title.slice(0, 120),
|
|
727
|
+
company: company.slice(0, 80),
|
|
728
|
+
url,
|
|
729
|
+
remote: inferRemote3(raw),
|
|
730
|
+
location: location || void 0,
|
|
731
|
+
tags,
|
|
732
|
+
roleType: inferRoleType2(raw),
|
|
733
|
+
postedAt: item.created_at,
|
|
734
|
+
applyMode: "direct",
|
|
735
|
+
raw: item
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function extractTags5(text) {
|
|
739
|
+
return normalize(tokenize5(text));
|
|
740
|
+
}
|
|
741
|
+
var ALGOLIA_SEARCH, ALGOLIA_ITEMS, hn;
|
|
742
|
+
var init_hn = __esm({
|
|
743
|
+
"../../packages/core/src/feeds/hn.ts"() {
|
|
744
|
+
"use strict";
|
|
745
|
+
init_vocabulary();
|
|
746
|
+
ALGOLIA_SEARCH = "https://hn.algolia.com/api/v1/search?query=Ask+HN%3A+Who+is+Hiring%3F&tags=story,ask_hn&hitsPerPage=1";
|
|
747
|
+
ALGOLIA_ITEMS = "https://hn.algolia.com/api/v1/items/";
|
|
748
|
+
hn = {
|
|
749
|
+
source: "hn",
|
|
750
|
+
async fetch(opts) {
|
|
751
|
+
const limit = opts?.limit ?? 150;
|
|
752
|
+
const searchRes = await fetch(ALGOLIA_SEARCH, {
|
|
753
|
+
headers: { Accept: "application/json" }
|
|
754
|
+
});
|
|
755
|
+
if (!searchRes.ok) {
|
|
756
|
+
throw new Error(`HN Algolia search: HTTP ${searchRes.status}`);
|
|
757
|
+
}
|
|
758
|
+
const searchData = await searchRes.json();
|
|
759
|
+
const story = searchData.hits[0];
|
|
760
|
+
if (!story) {
|
|
761
|
+
throw new Error('HN: No "Who is Hiring" story found');
|
|
762
|
+
}
|
|
763
|
+
const itemRes = await fetch(`${ALGOLIA_ITEMS}${story.objectID}`, {
|
|
764
|
+
headers: { Accept: "application/json" }
|
|
765
|
+
});
|
|
766
|
+
if (!itemRes.ok) {
|
|
767
|
+
throw new Error(`HN Algolia item ${story.objectID}: HTTP ${itemRes.status}`);
|
|
768
|
+
}
|
|
769
|
+
const storyItem = await itemRes.json();
|
|
770
|
+
const comments = storyItem.children ?? [];
|
|
771
|
+
const jobs = [];
|
|
772
|
+
for (const comment of comments.slice(0, limit)) {
|
|
773
|
+
const job = parseComment(comment);
|
|
774
|
+
if (job) jobs.push(job);
|
|
775
|
+
}
|
|
776
|
+
return jobs;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// ../../packages/core/src/feeds/index.ts
|
|
783
|
+
async function aggregate(opts) {
|
|
784
|
+
const ghSlugs = opts?.slugs?.["greenhouse"] ?? DEFAULT_GREENHOUSE_SLUGS;
|
|
785
|
+
const ashbySlugs = opts?.slugs?.["ashby"] ?? DEFAULT_ASHBY_SLUGS;
|
|
786
|
+
const limit = opts?.limit ?? 150;
|
|
787
|
+
const settled = await Promise.allSettled([
|
|
788
|
+
greenhouse.fetch({ slugs: ghSlugs, limit }),
|
|
789
|
+
ashby.fetch({ slugs: ashbySlugs, limit }),
|
|
790
|
+
himalayas.fetch({ limit }),
|
|
791
|
+
wwr.fetch({ limit }),
|
|
792
|
+
hn.fetch({ limit })
|
|
793
|
+
]);
|
|
794
|
+
const seen = /* @__PURE__ */ new Set();
|
|
795
|
+
const jobs = [];
|
|
796
|
+
const sourceNames = ["greenhouse", "ashby", "himalayas", "wwr", "hn"];
|
|
797
|
+
for (let i = 0; i < settled.length; i++) {
|
|
798
|
+
const result = settled[i];
|
|
799
|
+
if (result.status === "rejected") {
|
|
800
|
+
console.warn(`[feeds] ${sourceNames[i]} failed:`, result.reason);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
for (const job of result.value) {
|
|
804
|
+
if (!seen.has(job.id)) {
|
|
805
|
+
seen.add(job.id);
|
|
806
|
+
jobs.push(job);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return jobs;
|
|
811
|
+
}
|
|
812
|
+
var FEEDS, DEFAULT_GREENHOUSE_SLUGS, DEFAULT_ASHBY_SLUGS;
|
|
813
|
+
var init_feeds = __esm({
|
|
814
|
+
"../../packages/core/src/feeds/index.ts"() {
|
|
815
|
+
"use strict";
|
|
816
|
+
init_greenhouse();
|
|
817
|
+
init_ashby();
|
|
818
|
+
init_himalayas();
|
|
819
|
+
init_wwr();
|
|
820
|
+
init_hn();
|
|
821
|
+
FEEDS = [greenhouse, ashby, himalayas, wwr, hn];
|
|
822
|
+
DEFAULT_GREENHOUSE_SLUGS = [
|
|
823
|
+
"stripe",
|
|
824
|
+
"linear",
|
|
825
|
+
"vercel",
|
|
826
|
+
"ramp",
|
|
827
|
+
"notion",
|
|
828
|
+
"airbnb",
|
|
829
|
+
"anthropic",
|
|
830
|
+
"figma",
|
|
831
|
+
"discord",
|
|
832
|
+
"brex",
|
|
833
|
+
"mercury",
|
|
834
|
+
"retool",
|
|
835
|
+
"vanta",
|
|
836
|
+
"plaid",
|
|
837
|
+
"gusto",
|
|
838
|
+
"scale",
|
|
839
|
+
"databricks",
|
|
840
|
+
"coinbase",
|
|
841
|
+
"robinhood",
|
|
842
|
+
"doordash"
|
|
843
|
+
];
|
|
844
|
+
DEFAULT_ASHBY_SLUGS = [
|
|
845
|
+
"ramp",
|
|
846
|
+
"notion",
|
|
847
|
+
"linear",
|
|
848
|
+
"vercel",
|
|
849
|
+
"replit",
|
|
850
|
+
"posthog"
|
|
851
|
+
];
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// ../../packages/core/src/coastal.ts
|
|
856
|
+
import { readFileSync } from "fs";
|
|
857
|
+
import { join } from "path";
|
|
858
|
+
import { fileURLToPath } from "url";
|
|
859
|
+
function resolveDataPath() {
|
|
860
|
+
try {
|
|
861
|
+
const dir = fileURLToPath(new URL("../../../data", import.meta.url));
|
|
862
|
+
return join(dir, "coastal-roles.json");
|
|
863
|
+
} catch {
|
|
864
|
+
return join(process.cwd(), "data", "coastal-roles.json");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function loadCoastalRoles() {
|
|
868
|
+
const filePath = resolveDataPath();
|
|
869
|
+
try {
|
|
870
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
871
|
+
const parsed = JSON.parse(raw);
|
|
872
|
+
if (!Array.isArray(parsed)) {
|
|
873
|
+
console.warn("[coastal] coastal-roles.json is not an array \u2014 skipping");
|
|
874
|
+
return [];
|
|
875
|
+
}
|
|
876
|
+
const valid = [];
|
|
877
|
+
for (const entry of parsed) {
|
|
878
|
+
if (typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.applyMode === "buyer-lead" && entry.buyer === "coastal") {
|
|
879
|
+
valid.push(entry);
|
|
880
|
+
} else {
|
|
881
|
+
console.warn("[coastal] Skipping malformed role entry:", entry);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return valid;
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (err.code === "ENOENT") {
|
|
887
|
+
console.warn(`[coastal] data/coastal-roles.json not found at ${filePath} \u2014 no Coastal roles loaded`);
|
|
888
|
+
} else {
|
|
889
|
+
console.warn("[coastal] Failed to load coastal-roles.json:", err);
|
|
890
|
+
}
|
|
891
|
+
return [];
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
var COASTAL_BUYER;
|
|
895
|
+
var init_coastal = __esm({
|
|
896
|
+
"../../packages/core/src/coastal.ts"() {
|
|
897
|
+
"use strict";
|
|
898
|
+
COASTAL_BUYER = {
|
|
899
|
+
id: "coastal",
|
|
900
|
+
legalName: "Coastal Recruiting LLC",
|
|
901
|
+
matchCriteria: {
|
|
902
|
+
roleTypes: ["full_time"]
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// ../../packages/core/src/indexer.ts
|
|
909
|
+
async function buildIndex(opts) {
|
|
910
|
+
const includeCoastal = opts?.includeCoastal ?? true;
|
|
911
|
+
const publicJobs = await aggregate(opts);
|
|
912
|
+
const allJobs = [...publicJobs];
|
|
913
|
+
if (includeCoastal) {
|
|
914
|
+
const coastalJobs = loadCoastalRoles();
|
|
915
|
+
const seen = new Set(publicJobs.map((j) => j.id));
|
|
916
|
+
for (const job of coastalJobs) {
|
|
917
|
+
if (!seen.has(job.id)) {
|
|
918
|
+
seen.add(job.id);
|
|
919
|
+
allJobs.push(job);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const jobs = allJobs.map(({ raw: _raw, ...rest }) => rest);
|
|
924
|
+
return {
|
|
925
|
+
builtAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
926
|
+
jobs
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
var init_indexer = __esm({
|
|
930
|
+
"../../packages/core/src/indexer.ts"() {
|
|
931
|
+
"use strict";
|
|
932
|
+
init_feeds();
|
|
933
|
+
init_coastal();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// ../../packages/core/src/github.ts
|
|
938
|
+
function ghHeaders(token) {
|
|
939
|
+
const headers = {
|
|
940
|
+
Accept: "application/vnd.github+json",
|
|
941
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
942
|
+
};
|
|
943
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
944
|
+
return headers;
|
|
945
|
+
}
|
|
946
|
+
async function ghFetch(path, token) {
|
|
947
|
+
const url = `https://api.github.com${path}`;
|
|
948
|
+
const res = await fetch(url, { headers: ghHeaders(token) });
|
|
949
|
+
if (!res.ok) {
|
|
950
|
+
throw new Error(`GitHub API ${path}: HTTP ${res.status} ${res.statusText}`);
|
|
951
|
+
}
|
|
952
|
+
return res.json();
|
|
953
|
+
}
|
|
954
|
+
async function fetchGitHubProfile(login, token) {
|
|
955
|
+
const user = await ghFetch(`/users/${login}`, token);
|
|
956
|
+
let repos = [];
|
|
957
|
+
try {
|
|
958
|
+
repos = await ghFetch(
|
|
959
|
+
`/users/${login}/repos?sort=pushed&per_page=100`,
|
|
960
|
+
token
|
|
961
|
+
);
|
|
962
|
+
} catch (err) {
|
|
963
|
+
console.warn(`[github] ${login}: repos fetch failed, continuing \u2014`, err);
|
|
964
|
+
}
|
|
965
|
+
const langCount = {};
|
|
966
|
+
for (const repo of repos) {
|
|
967
|
+
if (repo.fork) continue;
|
|
968
|
+
if (repo.language) {
|
|
969
|
+
langCount[repo.language.toLowerCase()] = (langCount[repo.language.toLowerCase()] ?? 0) + 1;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const topLanguages = Object.entries(langCount).sort(([, a], [, b]) => b - a).slice(0, 10).map(([lang]) => lang);
|
|
973
|
+
const topicSet = /* @__PURE__ */ new Set();
|
|
974
|
+
for (const repo of repos) {
|
|
975
|
+
if (repo.fork) continue;
|
|
976
|
+
for (const t of repo.topics ?? []) topicSet.add(t.toLowerCase());
|
|
977
|
+
}
|
|
978
|
+
const topics = Array.from(topicSet).slice(0, 30);
|
|
979
|
+
let recentPRorgs;
|
|
980
|
+
try {
|
|
981
|
+
const q = encodeURIComponent(
|
|
982
|
+
`type:pr is:merged author:${login} sort:updated`
|
|
983
|
+
);
|
|
984
|
+
const result = await ghFetch(
|
|
985
|
+
`/search/issues?q=${q}&per_page=30`,
|
|
986
|
+
token
|
|
987
|
+
);
|
|
988
|
+
const orgs = /* @__PURE__ */ new Set();
|
|
989
|
+
for (const item of result.items ?? []) {
|
|
990
|
+
const orgLogin = item.repository?.owner?.login;
|
|
991
|
+
if (orgLogin && orgLogin !== login) orgs.add(orgLogin);
|
|
992
|
+
}
|
|
993
|
+
if (orgs.size > 0) recentPRorgs = Array.from(orgs);
|
|
994
|
+
} catch {
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
login: user.login,
|
|
998
|
+
name: user.name ?? void 0,
|
|
999
|
+
publicEmail: user.email ?? void 0,
|
|
1000
|
+
avatarUrl: user.avatar_url,
|
|
1001
|
+
accountCreatedAt: user.created_at,
|
|
1002
|
+
publicRepos: user.public_repos,
|
|
1003
|
+
followers: user.followers,
|
|
1004
|
+
topLanguages,
|
|
1005
|
+
topics,
|
|
1006
|
+
recentPRorgs
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function inferSeniority2(p) {
|
|
1010
|
+
const ageMs = Date.now() - new Date(p.accountCreatedAt).getTime();
|
|
1011
|
+
const ageYears = ageMs / (1e3 * 60 * 60 * 24 * 365.25);
|
|
1012
|
+
if (ageYears >= 9 && (p.publicRepos >= 40 || p.followers >= 500)) return "staff";
|
|
1013
|
+
if (ageYears >= 5 && (p.publicRepos >= 20 || p.followers >= 100)) return "senior";
|
|
1014
|
+
if (ageYears >= 2 && p.publicRepos >= 5) return "mid";
|
|
1015
|
+
if (ageYears < 2 || p.publicRepos < 5) return "junior";
|
|
1016
|
+
return void 0;
|
|
1017
|
+
}
|
|
1018
|
+
function githubToFingerprint(p) {
|
|
1019
|
+
const rawTokens = [
|
|
1020
|
+
...p.topLanguages,
|
|
1021
|
+
...p.topics
|
|
1022
|
+
// recentPRorgs intentionally excluded — org names are not skill tags
|
|
1023
|
+
];
|
|
1024
|
+
const skillTags = normalize(rawTokens);
|
|
1025
|
+
const seniorityBand = inferSeniority2(p);
|
|
1026
|
+
return { skillTags, seniorityBand };
|
|
1027
|
+
}
|
|
1028
|
+
var init_github = __esm({
|
|
1029
|
+
"../../packages/core/src/github.ts"() {
|
|
1030
|
+
"use strict";
|
|
1031
|
+
init_vocabulary();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// ../../packages/core/src/index.ts
|
|
1036
|
+
var src_exports = {};
|
|
1037
|
+
__export(src_exports, {
|
|
1038
|
+
COASTAL_BUYER: () => COASTAL_BUYER,
|
|
1039
|
+
DEFAULT_ASHBY_SLUGS: () => DEFAULT_ASHBY_SLUGS,
|
|
1040
|
+
DEFAULT_GREENHOUSE_SLUGS: () => DEFAULT_GREENHOUSE_SLUGS,
|
|
1041
|
+
FEEDS: () => FEEDS,
|
|
1042
|
+
SYNONYMS: () => SYNONYMS,
|
|
1043
|
+
VOCABULARY: () => VOCABULARY,
|
|
1044
|
+
aggregate: () => aggregate,
|
|
1045
|
+
ashby: () => ashby,
|
|
1046
|
+
buildIndex: () => buildIndex,
|
|
1047
|
+
buildReason: () => buildReason,
|
|
1048
|
+
fetchGitHubProfile: () => fetchGitHubProfile,
|
|
1049
|
+
githubToFingerprint: () => githubToFingerprint,
|
|
1050
|
+
greenhouse: () => greenhouse,
|
|
1051
|
+
himalayas: () => himalayas,
|
|
1052
|
+
hn: () => hn,
|
|
1053
|
+
loadCoastalRoles: () => loadCoastalRoles,
|
|
1054
|
+
match: () => match,
|
|
1055
|
+
matchOne: () => matchOne,
|
|
1056
|
+
normalize: () => normalize,
|
|
1057
|
+
wwr: () => wwr
|
|
1058
|
+
});
|
|
1059
|
+
var init_src = __esm({
|
|
1060
|
+
"../../packages/core/src/index.ts"() {
|
|
1061
|
+
"use strict";
|
|
1062
|
+
init_types();
|
|
1063
|
+
init_vocabulary();
|
|
1064
|
+
init_matcher();
|
|
1065
|
+
init_feeds();
|
|
1066
|
+
init_indexer();
|
|
1067
|
+
init_coastal();
|
|
1068
|
+
init_github();
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// src/profile.ts
|
|
1073
|
+
var profile_exports = {};
|
|
1074
|
+
__export(profile_exports, {
|
|
1075
|
+
accumulateGitHubTags: () => accumulateGitHubTags,
|
|
1076
|
+
accumulateSession: () => accumulateSession,
|
|
1077
|
+
accumulateTags: () => accumulateTags,
|
|
1078
|
+
deleteProfile: () => deleteProfile,
|
|
1079
|
+
profileToFingerprint: () => profileToFingerprint,
|
|
1080
|
+
readProfile: () => readProfile,
|
|
1081
|
+
writeProfile: () => writeProfile
|
|
1082
|
+
});
|
|
1083
|
+
import {
|
|
1084
|
+
createCipheriv,
|
|
1085
|
+
createDecipheriv,
|
|
1086
|
+
randomBytes
|
|
1087
|
+
} from "crypto";
|
|
1088
|
+
import {
|
|
1089
|
+
readFileSync as readFileSync2,
|
|
1090
|
+
writeFileSync,
|
|
1091
|
+
mkdirSync,
|
|
1092
|
+
existsSync
|
|
1093
|
+
} from "fs";
|
|
1094
|
+
import { join as join2 } from "path";
|
|
1095
|
+
import { homedir } from "os";
|
|
1096
|
+
async function loadKey() {
|
|
1097
|
+
try {
|
|
1098
|
+
const kt = await import("keytar");
|
|
1099
|
+
const stored = await kt.getPassword("terminalhire", "profile-key");
|
|
1100
|
+
if (stored) {
|
|
1101
|
+
return Buffer.from(stored, "hex");
|
|
1102
|
+
}
|
|
1103
|
+
const key2 = randomBytes(KEY_BYTES);
|
|
1104
|
+
await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
|
|
1105
|
+
return key2;
|
|
1106
|
+
} catch {
|
|
1107
|
+
}
|
|
1108
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
1109
|
+
if (existsSync(KEY_FILE)) {
|
|
1110
|
+
return Buffer.from(readFileSync2(KEY_FILE, "utf8").trim(), "hex");
|
|
1111
|
+
}
|
|
1112
|
+
const key = randomBytes(KEY_BYTES);
|
|
1113
|
+
writeFileSync(KEY_FILE, key.toString("hex"), { mode: 384, encoding: "utf8" });
|
|
1114
|
+
return key;
|
|
1115
|
+
}
|
|
1116
|
+
function encrypt(plaintext, key) {
|
|
1117
|
+
const iv = randomBytes(IV_BYTES);
|
|
1118
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
1119
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
1120
|
+
const tag = cipher.getAuthTag();
|
|
1121
|
+
return {
|
|
1122
|
+
iv: iv.toString("hex"),
|
|
1123
|
+
tag: tag.toString("hex"),
|
|
1124
|
+
ciphertext: ct.toString("hex")
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function decrypt(blob, key) {
|
|
1128
|
+
const decipher = createDecipheriv(
|
|
1129
|
+
ALGO,
|
|
1130
|
+
key,
|
|
1131
|
+
Buffer.from(blob.iv, "hex")
|
|
1132
|
+
);
|
|
1133
|
+
decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
|
|
1134
|
+
const plain = Buffer.concat([
|
|
1135
|
+
decipher.update(Buffer.from(blob.ciphertext, "hex")),
|
|
1136
|
+
decipher.final()
|
|
1137
|
+
]);
|
|
1138
|
+
return plain.toString("utf8");
|
|
1139
|
+
}
|
|
1140
|
+
function blankProfile() {
|
|
1141
|
+
return {
|
|
1142
|
+
version: 3,
|
|
1143
|
+
skillTags: [],
|
|
1144
|
+
tagWeights: {},
|
|
1145
|
+
hasEmployerSessions: false,
|
|
1146
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
function recencyDecay(lastSeen) {
|
|
1150
|
+
const ageMs = Date.now() - new Date(lastSeen).getTime();
|
|
1151
|
+
return Math.pow(0.5, ageMs / DECAY_HALF_LIFE_MS);
|
|
1152
|
+
}
|
|
1153
|
+
function tagScore(w) {
|
|
1154
|
+
return w.count * recencyDecay(w.lastSeen);
|
|
1155
|
+
}
|
|
1156
|
+
function deriveSkillTags(tagWeights) {
|
|
1157
|
+
return Object.entries(tagWeights).filter(([, w]) => w.count >= 1).sort(([, a], [, b]) => tagScore(b) - tagScore(a)).map(([tag]) => tag);
|
|
1158
|
+
}
|
|
1159
|
+
function migrateTagWeights(profile) {
|
|
1160
|
+
if (!profile.tagWeights) {
|
|
1161
|
+
profile.tagWeights = {};
|
|
1162
|
+
}
|
|
1163
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1164
|
+
for (const tag of profile.skillTags) {
|
|
1165
|
+
if (!profile.tagWeights[tag]) {
|
|
1166
|
+
profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
async function readProfile() {
|
|
1171
|
+
if (!existsSync(PROFILE_FILE)) return blankProfile();
|
|
1172
|
+
try {
|
|
1173
|
+
const key = await loadKey();
|
|
1174
|
+
const raw = readFileSync2(PROFILE_FILE, "utf8");
|
|
1175
|
+
const blob = JSON.parse(raw);
|
|
1176
|
+
const plaintext = decrypt(blob, key);
|
|
1177
|
+
const parsed = JSON.parse(plaintext);
|
|
1178
|
+
migrateTagWeights(parsed);
|
|
1179
|
+
return parsed;
|
|
1180
|
+
} catch {
|
|
1181
|
+
return blankProfile();
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async function writeProfile(profile) {
|
|
1185
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
1186
|
+
const key = await loadKey();
|
|
1187
|
+
profile.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1188
|
+
profile.skillTags = deriveSkillTags(profile.tagWeights);
|
|
1189
|
+
const blob = encrypt(JSON.stringify(profile), key);
|
|
1190
|
+
writeFileSync(PROFILE_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
|
|
1191
|
+
}
|
|
1192
|
+
function accumulateSession(profile, tags, isEmployerContext, inferredSeniority) {
|
|
1193
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1194
|
+
let filtered = normalize(tags);
|
|
1195
|
+
if (isEmployerContext) {
|
|
1196
|
+
filtered = filtered.filter((t) => LANGUAGE_TAGS.has(t));
|
|
1197
|
+
profile.hasEmployerSessions = true;
|
|
1198
|
+
}
|
|
1199
|
+
for (const tag of filtered) {
|
|
1200
|
+
const existing = profile.tagWeights[tag];
|
|
1201
|
+
if (existing) {
|
|
1202
|
+
existing.count += 1;
|
|
1203
|
+
existing.sessions += 1;
|
|
1204
|
+
existing.lastSeen = now;
|
|
1205
|
+
} else {
|
|
1206
|
+
profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (inferredSeniority && !isEmployerContext) {
|
|
1210
|
+
profile.seniority = inferredSeniority;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async function accumulateTags(rawTokens, isEmployerContext, inferredSeniority) {
|
|
1214
|
+
const profile = await readProfile();
|
|
1215
|
+
accumulateSession(profile, rawTokens, isEmployerContext, inferredSeniority);
|
|
1216
|
+
await writeProfile(profile);
|
|
1217
|
+
}
|
|
1218
|
+
function accumulateGitHubTags(profile, tags) {
|
|
1219
|
+
accumulateSession(
|
|
1220
|
+
profile,
|
|
1221
|
+
tags,
|
|
1222
|
+
/* isEmployerContext */
|
|
1223
|
+
false
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
async function deleteProfile() {
|
|
1227
|
+
const { rmSync } = await import("fs");
|
|
1228
|
+
try {
|
|
1229
|
+
rmSync(PROFILE_FILE);
|
|
1230
|
+
} catch {
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
rmSync(KEY_FILE);
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
function profileToFingerprint(profile) {
|
|
1238
|
+
const rankedTags = Object.entries(profile.tagWeights).map(([tag, w]) => ({ tag, score: tagScore(w) })).filter(({ score }) => score >= MIN_FINGERPRINT_SCORE).sort((a, b) => b.score - a.score).map(({ tag }) => tag);
|
|
1239
|
+
const skillTags = rankedTags.length > 0 ? rankedTags : profile.skillTags;
|
|
1240
|
+
return {
|
|
1241
|
+
skillTags,
|
|
1242
|
+
seniorityBand: profile.seniority,
|
|
1243
|
+
prefs: {
|
|
1244
|
+
roleTypes: profile.roleTypes,
|
|
1245
|
+
remoteOnly: profile.remoteOnly,
|
|
1246
|
+
compFloorUsd: profile.compFloorUsd
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
var TERMINALHIRE_DIR, PROFILE_FILE, KEY_FILE, ALGO, KEY_BYTES, IV_BYTES, DECAY_HALF_LIFE_MS, LANGUAGE_TAGS, MIN_FINGERPRINT_SCORE;
|
|
1251
|
+
var init_profile = __esm({
|
|
1252
|
+
"src/profile.ts"() {
|
|
1253
|
+
"use strict";
|
|
1254
|
+
init_src();
|
|
1255
|
+
TERMINALHIRE_DIR = join2(homedir(), ".terminalhire");
|
|
1256
|
+
PROFILE_FILE = join2(TERMINALHIRE_DIR, "profile.enc");
|
|
1257
|
+
KEY_FILE = join2(TERMINALHIRE_DIR, "key");
|
|
1258
|
+
ALGO = "aes-256-gcm";
|
|
1259
|
+
KEY_BYTES = 32;
|
|
1260
|
+
IV_BYTES = 12;
|
|
1261
|
+
DECAY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1262
|
+
LANGUAGE_TAGS = /* @__PURE__ */ new Set([
|
|
1263
|
+
"typescript",
|
|
1264
|
+
"javascript",
|
|
1265
|
+
"python",
|
|
1266
|
+
"go",
|
|
1267
|
+
"rust",
|
|
1268
|
+
"java",
|
|
1269
|
+
"ruby",
|
|
1270
|
+
"elixir",
|
|
1271
|
+
"scala",
|
|
1272
|
+
"kotlin",
|
|
1273
|
+
"swift",
|
|
1274
|
+
"cpp",
|
|
1275
|
+
"csharp",
|
|
1276
|
+
"php",
|
|
1277
|
+
"haskell",
|
|
1278
|
+
"clojure",
|
|
1279
|
+
"r"
|
|
1280
|
+
]);
|
|
1281
|
+
MIN_FINGERPRINT_SCORE = 0.05;
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
// bin/jpi-jobs.js
|
|
1286
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
1287
|
+
import { join as join3 } from "path";
|
|
1288
|
+
import { homedir as homedir2 } from "os";
|
|
1289
|
+
import { createInterface } from "readline";
|
|
1290
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1291
|
+
var __dirname = fileURLToPath2(new URL(".", import.meta.url));
|
|
1292
|
+
var TERMINALHIRE_DIR2 = join3(homedir2(), ".terminalhire");
|
|
1293
|
+
var INDEX_CACHE_FILE = join3(TERMINALHIRE_DIR2, "index-cache.json");
|
|
1294
|
+
var INDEX_TTL_MS = 15 * 60 * 1e3;
|
|
1295
|
+
var API_URL = process.env["TERMINALHIRE_API_URL"] ?? process.env["TERMINALHIRE_API_URL"] ?? process.env["JPI_API_URL"] ?? "https://terminalhire.com";
|
|
1296
|
+
var DEFAULT_LIMIT = 10;
|
|
1297
|
+
var args = process.argv.slice(2);
|
|
1298
|
+
var limitArg = args.indexOf("--limit");
|
|
1299
|
+
var LIMIT = limitArg !== -1 ? parseInt(args[limitArg + 1] ?? "10", 10) : DEFAULT_LIMIT;
|
|
1300
|
+
var REMOTE_ONLY = args.includes("--remote-only");
|
|
1301
|
+
var SHOW_ALL = args.includes("--all");
|
|
1302
|
+
function readIndexCache() {
|
|
1303
|
+
try {
|
|
1304
|
+
const raw = readFileSync3(INDEX_CACHE_FILE, "utf8");
|
|
1305
|
+
const entry = JSON.parse(raw);
|
|
1306
|
+
if (Date.now() - entry.ts < INDEX_TTL_MS) return entry.index;
|
|
1307
|
+
return null;
|
|
1308
|
+
} catch {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function writeIndexCache(index) {
|
|
1313
|
+
mkdirSync2(TERMINALHIRE_DIR2, { recursive: true });
|
|
1314
|
+
writeFileSync2(INDEX_CACHE_FILE, JSON.stringify({ ts: Date.now(), index }), "utf8");
|
|
1315
|
+
}
|
|
1316
|
+
async function fetchIndex() {
|
|
1317
|
+
const cached = readIndexCache();
|
|
1318
|
+
if (cached) return cached;
|
|
1319
|
+
const res = await fetch(`${API_URL}/api/index`, {
|
|
1320
|
+
signal: AbortSignal.timeout(1e4)
|
|
1321
|
+
});
|
|
1322
|
+
if (!res.ok) throw new Error(`/api/index returned ${res.status}`);
|
|
1323
|
+
const index = await res.json();
|
|
1324
|
+
writeIndexCache(index);
|
|
1325
|
+
return index;
|
|
1326
|
+
}
|
|
1327
|
+
function prompt(question) {
|
|
1328
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1329
|
+
return new Promise((resolve) => {
|
|
1330
|
+
rl.question(question, (answer) => {
|
|
1331
|
+
rl.close();
|
|
1332
|
+
resolve(answer.trim().toLowerCase());
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
function buildConsentText(job, profile) {
|
|
1337
|
+
const fields = ["skillTags: " + JSON.stringify(profile.skillTags)];
|
|
1338
|
+
if (profile.seniority) fields.push('seniorityBand: "' + profile.seniority + '"');
|
|
1339
|
+
if (profile.displayName) fields.push('displayName: "' + profile.displayName + '"');
|
|
1340
|
+
if (profile.contactEmail) fields.push('contactEmail: "' + profile.contactEmail + '"');
|
|
1341
|
+
if (profile.github) {
|
|
1342
|
+
fields.push(
|
|
1343
|
+
'github.login: "' + profile.github.login + '"',
|
|
1344
|
+
'github.profileUrl: "' + profile.github.profileUrl + '"',
|
|
1345
|
+
"github.topLanguages: " + JSON.stringify(profile.github.topLanguages),
|
|
1346
|
+
"github.publicRepos: " + profile.github.publicRepos
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const githubNote = profile.github ? "\nGitHub fields above are public data only (scope: read:user). No private repos.\n" : "";
|
|
1350
|
+
return `You are about to share the following information with Coastal Recruiting LLC
|
|
1351
|
+
for opportunity: ${job.title} at ${job.company} (${job.id})
|
|
1352
|
+
|
|
1353
|
+
Fields that will be sent:
|
|
1354
|
+
` + fields.map((f) => ` \u2022 ${f}`).join("\n") + "\n" + githubNote + `
|
|
1355
|
+
Nothing else leaves your machine. This action is specific to this role.
|
|
1356
|
+
Coastal Recruiting LLC will use this to evaluate you for the role.
|
|
1357
|
+
You can delete your profile at any time with: terminalhire profile --delete`;
|
|
1358
|
+
}
|
|
1359
|
+
function buildLeadPayload(job, profile, consentText, note) {
|
|
1360
|
+
const approvedFields = {
|
|
1361
|
+
skillTags: profile.skillTags
|
|
1362
|
+
};
|
|
1363
|
+
if (profile.seniority) approvedFields.seniorityBand = profile.seniority;
|
|
1364
|
+
if (profile.displayName) approvedFields.displayName = profile.displayName;
|
|
1365
|
+
if (profile.contactEmail) approvedFields.contactEmail = profile.contactEmail;
|
|
1366
|
+
if (note) approvedFields.note = note;
|
|
1367
|
+
if (profile.github) {
|
|
1368
|
+
approvedFields.github = {
|
|
1369
|
+
login: profile.github.login,
|
|
1370
|
+
profileUrl: profile.github.profileUrl,
|
|
1371
|
+
topLanguages: profile.github.topLanguages,
|
|
1372
|
+
publicRepos: profile.github.publicRepos
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
opportunityId: job.id,
|
|
1377
|
+
buyerId: "coastal",
|
|
1378
|
+
buyerLegalName: "Coastal Recruiting LLC",
|
|
1379
|
+
approvedFields,
|
|
1380
|
+
consentText,
|
|
1381
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
function formatScore(score) {
|
|
1385
|
+
return Math.round(score * 100) + "%";
|
|
1386
|
+
}
|
|
1387
|
+
function formatComp(job) {
|
|
1388
|
+
if (job.compMin && job.compMax) {
|
|
1389
|
+
return `$${Math.round(job.compMin / 1e3)}k\u2013$${Math.round(job.compMax / 1e3)}k`;
|
|
1390
|
+
}
|
|
1391
|
+
if (job.compMin) return `$${Math.round(job.compMin / 1e3)}k+`;
|
|
1392
|
+
return "";
|
|
1393
|
+
}
|
|
1394
|
+
function printResult(i, result) {
|
|
1395
|
+
const { job, score, matchedTags, reason } = result;
|
|
1396
|
+
const comp = formatComp(job);
|
|
1397
|
+
const remote = job.remote ? "remote" : job.location ?? "onsite";
|
|
1398
|
+
const compStr = comp ? ` \xB7 ${comp}` : "";
|
|
1399
|
+
const mode = job.applyMode === "buyer-lead" ? " [COASTAL LEAD]" : "";
|
|
1400
|
+
console.log(`
|
|
1401
|
+
${i + 1}. ${job.title} \u2014 ${job.company}${mode}`);
|
|
1402
|
+
console.log(` ${remote}${compStr} \xB7 ${job.roleType} \xB7 score: ${formatScore(score)}`);
|
|
1403
|
+
console.log(` ${reason}`);
|
|
1404
|
+
console.log(` Tags matched: ${matchedTags.slice(0, 5).join(", ")}`);
|
|
1405
|
+
if (job.applyMode === "direct") {
|
|
1406
|
+
console.log(` Apply: ${job.url}`);
|
|
1407
|
+
} else {
|
|
1408
|
+
console.log(` Apply: via Coastal Recruiting LLC (consent required)`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
async function handleBuyerLead(job, profile) {
|
|
1412
|
+
const consentText = buildConsentText(job, profile);
|
|
1413
|
+
console.log("\n" + "\u2500".repeat(70));
|
|
1414
|
+
console.log(consentText);
|
|
1415
|
+
console.log("\u2500".repeat(70));
|
|
1416
|
+
const answer = await prompt(
|
|
1417
|
+
"\nShare your profile with Coastal Recruiting LLC for this role? [y/N] "
|
|
1418
|
+
);
|
|
1419
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1420
|
+
console.log("Aborted \u2014 nothing was sent.");
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
let note;
|
|
1424
|
+
const noteAnswer = await prompt("Optional note to Coastal (press Enter to skip): ");
|
|
1425
|
+
if (noteAnswer.trim()) note = noteAnswer.trim();
|
|
1426
|
+
const payload = buildLeadPayload(job, profile, consentText, note);
|
|
1427
|
+
console.log("\nSending lead payload...");
|
|
1428
|
+
const res = await fetch(`${API_URL}/api/lead`, {
|
|
1429
|
+
method: "POST",
|
|
1430
|
+
headers: { "Content-Type": "application/json" },
|
|
1431
|
+
body: JSON.stringify(payload),
|
|
1432
|
+
signal: AbortSignal.timeout(1e4)
|
|
1433
|
+
});
|
|
1434
|
+
if (!res.ok) {
|
|
1435
|
+
console.error(`Error: /api/lead returned ${res.status}`);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
console.log("Lead sent. Coastal Recruiting LLC will be in touch if there is a match.");
|
|
1439
|
+
}
|
|
1440
|
+
async function run() {
|
|
1441
|
+
try {
|
|
1442
|
+
const { readProfile: readProfile2, profileToFingerprint: profileToFingerprint2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
|
|
1443
|
+
const { match: match2 } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
1444
|
+
const profile = await readProfile2();
|
|
1445
|
+
if (profile.skillTags.length === 0) {
|
|
1446
|
+
console.log("\u2726 terminalhire jobs: no skill tags in local profile yet.");
|
|
1447
|
+
console.log(" Start a Claude Code session in a personal project to accumulate tags.");
|
|
1448
|
+
console.log(" Or edit your profile: terminalhire profile --edit");
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
console.log(`Fetching job index from ${API_URL}/api/index...`);
|
|
1452
|
+
const index = await fetchIndex();
|
|
1453
|
+
const jobs = index.jobs ?? [];
|
|
1454
|
+
if (jobs.length === 0) {
|
|
1455
|
+
console.log("No jobs in index. Try again later.");
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const fp = profileToFingerprint2(profile);
|
|
1459
|
+
if (REMOTE_ONLY) fp.prefs = { ...fp.prefs, remoteOnly: true };
|
|
1460
|
+
const results = match2(fp, jobs, SHOW_ALL ? jobs.length : LIMIT);
|
|
1461
|
+
try {
|
|
1462
|
+
const cacheRaw = readFileSync3(INDEX_CACHE_FILE, "utf8");
|
|
1463
|
+
const cacheEntry = JSON.parse(cacheRaw);
|
|
1464
|
+
cacheEntry.matchCount = results.length;
|
|
1465
|
+
writeFileSync2(INDEX_CACHE_FILE, JSON.stringify(cacheEntry), "utf8");
|
|
1466
|
+
} catch {
|
|
1467
|
+
}
|
|
1468
|
+
if (results.length === 0) {
|
|
1469
|
+
console.log("No matching roles found for your current profile.");
|
|
1470
|
+
console.log(" Your tags: " + profile.skillTags.join(", "));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
console.log(`
|
|
1474
|
+
\u2726 ${results.length} role${results.length === 1 ? "" : "s"} matching your profile (local match \u2014 no data sent)
|
|
1475
|
+
`);
|
|
1476
|
+
for (let i = 0; i < results.length; i++) {
|
|
1477
|
+
printResult(i, results[i]);
|
|
1478
|
+
}
|
|
1479
|
+
if (!process.stdin.isTTY) {
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
console.log("\n" + "\u2500".repeat(70));
|
|
1483
|
+
const pick = await prompt(
|
|
1484
|
+
`
|
|
1485
|
+
Enter a number to act on a role, or press Enter to exit: `
|
|
1486
|
+
);
|
|
1487
|
+
const idx = parseInt(pick, 10) - 1;
|
|
1488
|
+
if (isNaN(idx) || idx < 0 || idx >= results.length) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const chosen = results[idx];
|
|
1492
|
+
if (chosen.job.applyMode === "direct") {
|
|
1493
|
+
console.log(`
|
|
1494
|
+
Open this URL to apply directly (no data shared):
|
|
1495
|
+
${chosen.job.url}`);
|
|
1496
|
+
} else if (chosen.job.applyMode === "buyer-lead") {
|
|
1497
|
+
await handleBuyerLead(chosen.job, profile);
|
|
1498
|
+
}
|
|
1499
|
+
} catch (err) {
|
|
1500
|
+
console.error("terminalhire jobs error:", err.message ?? err);
|
|
1501
|
+
process.exit(1);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
export {
|
|
1505
|
+
run
|
|
1506
|
+
};
|