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