terminalhire 0.42.6 → 0.42.8

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.
Files changed (50) hide show
  1. package/README.md +80 -35
  2. package/dist/bin/approved-claims-sync.js +93 -1
  3. package/dist/bin/cache-store.js +5 -1
  4. package/dist/bin/claim-push-bg.js +115 -1
  5. package/dist/bin/directory.js +93 -1
  6. package/dist/bin/founder-note-sync.js +161 -0
  7. package/dist/bin/founder-verdict-sync.js +201 -0
  8. package/dist/bin/jpi-bounties.js +373 -19
  9. package/dist/bin/jpi-chat-read.js +104 -3
  10. package/dist/bin/jpi-chat.js +181 -21
  11. package/dist/bin/jpi-claim.js +221 -21
  12. package/dist/bin/jpi-config.js +5 -1
  13. package/dist/bin/jpi-contribute.js +116 -5
  14. package/dist/bin/jpi-devs.js +116 -3
  15. package/dist/bin/jpi-dispatch.js +6931 -1079
  16. package/dist/bin/jpi-hub.js +139 -16
  17. package/dist/bin/jpi-inbox.js +177 -19
  18. package/dist/bin/jpi-init.js +96 -4
  19. package/dist/bin/jpi-intro.js +162 -13
  20. package/dist/bin/jpi-jobs.js +130 -8
  21. package/dist/bin/jpi-learn.js +20 -1
  22. package/dist/bin/jpi-link.js +120 -4
  23. package/dist/bin/jpi-login.js +186 -30
  24. package/dist/bin/jpi-mcp-chat.js +140 -9
  25. package/dist/bin/jpi-mcp.js +230 -24
  26. package/dist/bin/jpi-post.js +93 -1
  27. package/dist/bin/jpi-profile.js +45 -6
  28. package/dist/bin/jpi-project.js +93 -1
  29. package/dist/bin/jpi-protocol.js +27 -4
  30. package/dist/bin/jpi-refresh.js +153 -7
  31. package/dist/bin/jpi-run.js +5383 -12
  32. package/dist/bin/jpi-save.js +20 -1
  33. package/dist/bin/jpi-sync.js +151 -6
  34. package/dist/bin/jpi-trajectory.js +136 -6
  35. package/dist/bin/jpi-update.js +27 -4
  36. package/dist/bin/peer-connect-prompt.js +121 -7
  37. package/dist/bin/proxy/egressProxy.js +117 -0
  38. package/dist/bin/proxy/proxyEntry.js +46 -0
  39. package/dist/bin/pulse-prompt.js +93 -1
  40. package/dist/bin/recall-check.js +278 -0
  41. package/dist/sandbox/fence-install.sb +129 -0
  42. package/dist/sandbox/fence-offline.sb +185 -0
  43. package/dist/src/api-base.js +156 -0
  44. package/dist/src/chat-client.js +93 -1
  45. package/dist/src/intro.js +155 -13
  46. package/dist/src/link.js +108 -3
  47. package/dist/src/profile.js +19 -0
  48. package/dist/src/protocol.js +27 -4
  49. package/dist/src/trajectory.js +130 -5
  50. package/package.json +7 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # terminalhire — your code is your résumé
2
2
 
3
- Local-first job + bounty matching, and a verifiable **Proof of Work** credential earned from PRs that maintainers who aren't you merged into established repos — checkable on GitHub, impossible to self-report. Your profile never leaves your machine.
3
+ Job + bounty matching in the terminal, and a **Proof of Work** credential earned from PRs that maintainers who aren't you merged into established repos — checkable on GitHub, impossible to self-report. Claim a scoped bounty and get paid when the poster accepts it.
4
4
 
5
5
  **Domain:** [terminalhire.com](https://terminalhire.com)
6
6
 
@@ -57,10 +57,10 @@ terminalhire trajectory # trajectory from your local Claude Code corpus
57
57
  terminalhire trajectory --export # write a derived score + Markdown to ~/.terminalhire/
58
58
  terminalhire trajectory --inward # also show private rework/recovery (never exported)
59
59
 
60
- terminalhire devs # opted-in peers & founders — profile never leaves your machine
60
+ terminalhire devs # opted-in peers & founders — matched on your machine
61
61
  terminalhire intro <login> # request a double-opt-in intro (typed-yes consent)
62
62
  terminalhire inbox # unread chats, connections & pending intros in one place
63
- terminalhire mcp --print-config # local-first match + claim-record MCP config for AI hosts
63
+ terminalhire mcp --print-config # match + claim-record MCP config for AI hosts
64
64
 
65
65
  terminalhire profile --show # inspect your encrypted local profile (incl. GitHub fields)
66
66
  terminalhire profile --edit # set displayName, contactEmail, prefs
@@ -84,18 +84,19 @@ This runs the **GitHub OAuth Device Flow**:
84
84
 
85
85
  ### What GitHub enriches
86
86
 
87
- | Signal | Source |
88
- |---|---|
89
- | Skill tags | Repo languages + repo topics, filtered through `vocabulary.normalize()` |
87
+ | Signal | Source |
88
+ | ------------------ | ------------------------------------------------------------------------------------ |
89
+ | Skill tags | Repo languages + repo topics, filtered through `vocabulary.normalize()` |
90
90
  | Seniority estimate | Account age × (repos + followers) per [documented thresholds](#seniority-thresholds) |
91
- | Display name | `user.name` if not already set in profile |
92
- | Contact email | `user.email` if public and not already set in profile |
91
+ | Display name | `user.name` if not already set in profile |
92
+ | Contact email | `user.email` if public and not already set in profile |
93
93
 
94
94
  ### Public scope guarantee
95
95
 
96
96
  Scope requested: **`read:user`** — public profile + public repos only.
97
97
 
98
98
  **Private-repo scopes are NEVER requested.** This means:
99
+
99
100
  - No employer-IP risk from private repos.
100
101
  - No code, secrets, or private file access.
101
102
  - Only what is already publicly visible on your GitHub profile.
@@ -199,13 +200,13 @@ GitHub (optional enrichment):
199
200
 
200
201
  ## Privacy
201
202
 
202
- | What happens locally | What crosses the wire |
203
- |---|---|
204
- | Profile accumulated from personal project sessions | GET /api/index — anonymous, no dev data |
205
- | Closed-vocab skill tags + seniority (encrypted at rest) | POST /api/lead — ONLY after explicit per-role "yes" consent |
206
- | GitHub token + public profile data (encrypted at rest) | GitHub fields in lead — ONLY after consent AND GitHub is connected |
207
- | Employer-repo sessions: language tags only | Nothing else |
208
- | git email domain + remote host (employer detection only) | |
203
+ | What happens locally | What crosses the wire |
204
+ | -------------------------------------------------------- | ------------------------------------------------------------------ |
205
+ | Profile accumulated from personal project sessions | GET /api/index — anonymous, no dev data |
206
+ | Closed-vocab skill tags + seniority (encrypted at rest) | POST /api/lead — ONLY after explicit per-role "yes" consent |
207
+ | GitHub token + public profile data (encrypted at rest) | GitHub fields in lead — ONLY after consent AND GitHub is connected |
208
+ | Employer-repo sessions: language tags only | Nothing else |
209
+ | git email domain + remote host (employer detection only) | |
209
210
 
210
211
  **Employer-repo exclusion (default on):** if `git config user.email` is a corporate domain or `git remote` points to a non-personal host, only language-level tags (`typescript`, `python`, …) accumulate. Fine-grained framework/infra tags are excluded. The flag `hasEmployerSessions` is recorded locally but never emitted.
211
212
 
@@ -280,9 +281,9 @@ Typing anything other than `y` or `yes` aborts with no network call.
280
281
 
281
282
  ## Apply modes
282
283
 
283
- | `applyMode` | What happens when you select a role |
284
- |---|---|
285
- | `direct` | Opens the employer's public URL in your terminal. No data sent. |
284
+ | `applyMode` | What happens when you select a role |
285
+ | ------------ | ------------------------------------------------------------------------- |
286
+ | `direct` | Opens the employer's public URL in your terminal. No data sent. |
286
287
  | `buyer-lead` | Triggers the named-entity consent flow above. Nothing sent without "yes". |
287
288
 
288
289
  The two modes are never silently conflated.
@@ -295,13 +296,57 @@ node test/zero-egress.test.js
295
296
 
296
297
  ## Environment variables
297
298
 
298
- | Variable | Default | Description |
299
- |---|---|---|
300
- | `TERMINALHIRE_API_URL` | `https://terminalhire.com` | Base URL for `/api/index` and `/api/lead` (also accepts legacy `JPI_API_URL`) |
301
- | `GITHUB_CLIENT_ID` | _(required for real OAuth)_ | GitHub OAuth App client ID (device flow) |
302
- | `GITHUB_CLIENT_SECRET` | _(optional)_ | GitHub OAuth App client secret (public apps omit this) |
303
- | `GITHUB_DEVICE_CLIENT_ID` | _(falls back to `GITHUB_CLIENT_ID`)_ | Device-flow specific client ID if different |
304
- | `TERMINALHIRE_GITHUB_MOCK` | `0` | Set to `1` to skip real OAuth and use the fixture (dev/CI); also accepts legacy `JPI_GITHUB_MOCK` |
299
+ | Variable | Default | Description |
300
+ | -------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
301
+ | `TERMINALHIRE_API_URL` | `https://terminalhire.com` | Base URL for **every** server call — reads, login/OAuth and background syncs alike (also accepts legacy `JPI_API_URL`). **Allowlisted hosts only** — see below |
302
+ | `GITHUB_CLIENT_ID` | _(required for real OAuth)_ | GitHub OAuth App client ID (device flow) |
303
+ | `GITHUB_CLIENT_SECRET` | _(optional)_ | GitHub OAuth App client secret (public apps omit this) |
304
+ | `GITHUB_DEVICE_CLIENT_ID` | _(falls back to `GITHUB_CLIENT_ID`)_ | Device-flow specific client ID if different |
305
+ | `TERMINALHIRE_GITHUB_MOCK` | `0` | Set to `1` to skip real OAuth and use the fixture (dev/CI); also accepts legacy `JPI_GITHUB_MOCK` |
306
+
307
+ ### Pointing the CLI at a non-production server
308
+
309
+ `TERMINALHIRE_API_URL` is resolved once, in `src/api-base.ts`, and every call site
310
+ uses that one answer — so reads and writes can no longer disagree about which
311
+ environment you are on.
312
+
313
+ The override is an **allowlist**, not a free-form URL, because this same base decides
314
+ where your push token is presented. Only these are accepted:
315
+
316
+ | Host | Scheme | Extra flag |
317
+ | -------------------------------------------- | ------- | -------------------------------- |
318
+ | `terminalhire.com`, `www.terminalhire.com`\* | `https` | — |
319
+ | `dev.terminalhire.com` | `https` | — |
320
+ | `localhost`, `127.0.0.1` (any port) | `http` | `TERMINALHIRE_ALLOW_LOCAL_API=1` |
321
+
322
+ \* `www` is rewritten to the apex — its 301 strips the session cookie.
323
+
324
+ Anything else — an unknown host, the wrong scheme, embedded credentials, a string
325
+ that will not parse — makes the command **refuse to run**. It does not quietly fall
326
+ back to production: a typo'd override that resolved to prod is how a developer ends up
327
+ pushing claims at the real database while believing they are in a sandbox. It never
328
+ falls back to the host you named either, so a poisoned shell profile cannot redirect
329
+ you. Loopback without the opt-in is refused for the same reason: the URL alone must
330
+ not be enough to make ambient refresh present the persisted prod session to a local
331
+ listener.
332
+
333
+ **Browser sign-in uses a narrower allowlist.** `login`, `link`, `sync` and the claim
334
+ verification flows accept only `https://terminalhire.com` and
335
+ `https://dev.terminalhire.com`. Loopback OAuth requires both
336
+ `TERMINALHIRE_ALLOW_LOCAL_API=1` and `TERMINALHIRE_ALLOW_LOCAL_OAUTH=1`. Point at
337
+ `https://dev.terminalhire.com` for an end-to-end login against the shared-dev stack.
338
+
339
+ Note that `~/.terminalhire` is keyed per machine, not per origin: the web session,
340
+ push token and consent markers are **shared** with production. The credential-bearing
341
+ commands warn about this on stderr when a non-prod base is active.
342
+
343
+ When a non-production base is active, every command prints one line to stderr:
344
+
345
+ ```
346
+ [dev → dev.terminalhire.com]
347
+ ```
348
+
349
+ No marker means you are talking to production.
305
350
 
306
351
  ## File layout
307
352
 
@@ -326,13 +371,13 @@ apps/cli/
326
371
 
327
372
  ## How data is used: local default vs consented lead
328
373
 
329
- | Data | Default (local-enrichment) | Opt-in (consented lead) |
330
- |---|---|---|
331
- | Skill tags | Stored encrypted locally, used for local matching only | Included in `approvedFields.skillTags` on "yes" |
332
- | Seniority | Stored encrypted locally | Included in `approvedFields.seniorityBand` on "yes" |
333
- | Display name | Stored encrypted locally (pre-filled from GitHub if public) | Included if set and "yes" |
334
- | Contact email | Stored encrypted locally (pre-filled from GitHub public email) | Included if set and "yes" |
335
- | GitHub login / profileUrl | Stored in `profile.github` on `terminalhire login` | Included in `approvedFields.github` ONLY on "yes" |
336
- | GitHub topLanguages | Used locally for tag inference (closed vocab) | Included in `approvedFields.github` ONLY on "yes" |
337
- | GitHub token | Encrypted at `~/.terminalhire/github-token.enc` | Never sent |
338
- | Private repos | Never accessed (scope: read:user) | N/A |
374
+ | Data | Default (local-enrichment) | Opt-in (consented lead) |
375
+ | ------------------------- | -------------------------------------------------------------- | --------------------------------------------------- |
376
+ | Skill tags | Stored encrypted locally, used for local matching only | Included in `approvedFields.skillTags` on "yes" |
377
+ | Seniority | Stored encrypted locally | Included in `approvedFields.seniorityBand` on "yes" |
378
+ | Display name | Stored encrypted locally (pre-filled from GitHub if public) | Included if set and "yes" |
379
+ | Contact email | Stored encrypted locally (pre-filled from GitHub public email) | Included if set and "yes" |
380
+ | GitHub login / profileUrl | Stored in `profile.github` on `terminalhire login` | Included in `approvedFields.github` ONLY on "yes" |
381
+ | GitHub topLanguages | Used locally for tag inference (closed vocab) | Included in `approvedFields.github` ONLY on "yes" |
382
+ | GitHub token | Encrypted at `~/.terminalhire/github-token.enc` | Never sent |
383
+ | Private repos | Never accessed (scope: read:user) | N/A |
@@ -1,5 +1,97 @@
1
+ // src/api-base.ts
2
+ var PROD_API_BASE = "https://terminalhire.com";
3
+ var DEV_API_BASE = "https://dev.terminalhire.com";
4
+ var ApiBaseError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ApiBaseError";
8
+ }
9
+ };
10
+ var ALLOWED_HOSTS = {
11
+ "terminalhire.com": "https:",
12
+ "www.terminalhire.com": "https:",
13
+ "dev.terminalhire.com": "https:",
14
+ localhost: "http:",
15
+ "127.0.0.1": "http:"
16
+ };
17
+ var ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
18
+ var ALLOWED_DESCRIPTION = [
19
+ PROD_API_BASE,
20
+ DEV_API_BASE,
21
+ `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
22
+ `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
23
+ ].join(", ");
24
+ var CANONICAL_REWRITES = {
25
+ "www.terminalhire.com": PROD_API_BASE
26
+ };
27
+ var ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
28
+ function sanitizeOverrideForError(raw) {
29
+ try {
30
+ const url = new URL(raw);
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
32
+ return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
33
+ }
34
+ if (url.username !== "" || url.password !== "") {
35
+ return `${url.protocol}//***@${url.host}`;
36
+ }
37
+ return url.origin;
38
+ } catch {
39
+ return "(unparseable override)";
40
+ }
41
+ }
42
+ function isLoopbackOrigin(origin) {
43
+ try {
44
+ const host = new URL(origin).hostname;
45
+ return host === "localhost" || host === "127.0.0.1";
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+ function localApiAllowed(env) {
51
+ return env[ALLOW_LOCAL_API_KEY] === "1";
52
+ }
53
+ function resolveApiBase(env = process.env) {
54
+ for (const key of ENV_KEYS) {
55
+ const raw = env[key];
56
+ if (typeof raw !== "string") continue;
57
+ const trimmed = raw.trim();
58
+ if (trimmed === "") continue;
59
+ const normalized = normalizeOverride(trimmed);
60
+ if (normalized === null) {
61
+ throw new ApiBaseError(
62
+ `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
63
+ );
64
+ }
65
+ if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
66
+ throw new ApiBaseError(
67
+ `terminalhire: ${key}=${normalized} is a loopback origin. Set ${ALLOW_LOCAL_API_KEY}=1 to talk to a local web app on purpose. Refusing so stored credentials cannot be exfiltrated to localhost by a poisoned override.`
68
+ );
69
+ }
70
+ return normalized;
71
+ }
72
+ return PROD_API_BASE;
73
+ }
74
+ function normalizeOverride(raw) {
75
+ let url;
76
+ try {
77
+ url = new URL(raw);
78
+ } catch {
79
+ return null;
80
+ }
81
+ if (url.username !== "" || url.password !== "") return null;
82
+ const expectedProtocol = ALLOWED_HOSTS[url.hostname];
83
+ if (expectedProtocol === void 0) return null;
84
+ if (url.protocol !== expectedProtocol) return null;
85
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
86
+ return null;
87
+ }
88
+ const rewrite = CANONICAL_REWRITES[url.hostname];
89
+ if (rewrite !== void 0) return rewrite;
90
+ return url.origin;
91
+ }
92
+
1
93
  // bin/approved-claims-sync.js
2
- var CLAIM_SYNC_BASE = "https://terminalhire.com";
94
+ var CLAIM_SYNC_BASE = resolveApiBase();
3
95
  function approvalsSyncGate({ autoMarkerExists, tokenFileExists }) {
4
96
  if (!autoMarkerExists || !tokenFileExists) return { sync: false, reason: "not-opted-in" };
5
97
  return { sync: true, reason: "ok" };
@@ -77,7 +77,11 @@ function updateIndexCache(patch) {
77
77
  renameSync(tmp, INDEX_CACHE_FILE);
78
78
  return entry;
79
79
  }
80
+ function writeIndexCache(index) {
81
+ return updateIndexCache({ index, indexETag: "" });
82
+ }
80
83
  export {
81
84
  readCacheEntry,
82
- updateIndexCache
85
+ updateIndexCache,
86
+ writeIndexCache
83
87
  };
@@ -509,11 +509,125 @@ function decrypt(blob, key) {
509
509
 
510
510
  // bin/claim-push-bg.js
511
511
  init_state_dir();
512
+
513
+ // src/api-base.ts
514
+ var PROD_API_BASE = "https://terminalhire.com";
515
+ var DEV_API_BASE = "https://dev.terminalhire.com";
516
+ var ApiBaseError = class extends Error {
517
+ constructor(message) {
518
+ super(message);
519
+ this.name = "ApiBaseError";
520
+ }
521
+ };
522
+ var ALLOWED_HOSTS = {
523
+ "terminalhire.com": "https:",
524
+ "www.terminalhire.com": "https:",
525
+ "dev.terminalhire.com": "https:",
526
+ localhost: "http:",
527
+ "127.0.0.1": "http:"
528
+ };
529
+ var ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
530
+ var ALLOWED_DESCRIPTION = [
531
+ PROD_API_BASE,
532
+ DEV_API_BASE,
533
+ `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
534
+ `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
535
+ ].join(", ");
536
+ var CANONICAL_REWRITES = {
537
+ "www.terminalhire.com": PROD_API_BASE
538
+ };
539
+ var ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
540
+ function sanitizeOverrideForError(raw) {
541
+ try {
542
+ const url = new URL(raw);
543
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
544
+ return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
545
+ }
546
+ if (url.username !== "" || url.password !== "") {
547
+ return `${url.protocol}//***@${url.host}`;
548
+ }
549
+ return url.origin;
550
+ } catch {
551
+ return "(unparseable override)";
552
+ }
553
+ }
554
+ function isLoopbackOrigin(origin) {
555
+ try {
556
+ const host = new URL(origin).hostname;
557
+ return host === "localhost" || host === "127.0.0.1";
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+ function localApiAllowed(env) {
563
+ return env[ALLOW_LOCAL_API_KEY] === "1";
564
+ }
565
+ function resolveApiBase(env = process.env) {
566
+ for (const key of ENV_KEYS) {
567
+ const raw = env[key];
568
+ if (typeof raw !== "string") continue;
569
+ const trimmed = raw.trim();
570
+ if (trimmed === "") continue;
571
+ const normalized = normalizeOverride(trimmed);
572
+ if (normalized === null) {
573
+ throw new ApiBaseError(
574
+ `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
575
+ );
576
+ }
577
+ if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
578
+ throw new ApiBaseError(
579
+ `terminalhire: ${key}=${normalized} is a loopback origin. Set ${ALLOW_LOCAL_API_KEY}=1 to talk to a local web app on purpose. Refusing so stored credentials cannot be exfiltrated to localhost by a poisoned override.`
580
+ );
581
+ }
582
+ return normalized;
583
+ }
584
+ return PROD_API_BASE;
585
+ }
586
+ function resolveApiBaseOrProd(env = process.env) {
587
+ try {
588
+ return resolveApiBase(env);
589
+ } catch {
590
+ return PROD_API_BASE;
591
+ }
592
+ }
593
+ function normalizeOverride(raw) {
594
+ let url;
595
+ try {
596
+ url = new URL(raw);
597
+ } catch {
598
+ return null;
599
+ }
600
+ if (url.username !== "" || url.password !== "") return null;
601
+ const expectedProtocol = ALLOWED_HOSTS[url.hostname];
602
+ if (expectedProtocol === void 0) return null;
603
+ if (url.protocol !== expectedProtocol) return null;
604
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
605
+ return null;
606
+ }
607
+ const rewrite = CANONICAL_REWRITES[url.hostname];
608
+ if (rewrite !== void 0) return rewrite;
609
+ return url.origin;
610
+ }
611
+ function isNonProdApiBase(base = resolveApiBaseOrProd()) {
612
+ return base !== PROD_API_BASE;
613
+ }
614
+ function warnSharedCredentialsIfNonProd(base, stream = process.stderr) {
615
+ if (!isNonProdApiBase(base)) return;
616
+ try {
617
+ stream.write(
618
+ "terminalhire: non-prod API base \u2014 using the same local session/push credentials as prod; do not mix environments casually.\n"
619
+ );
620
+ } catch {
621
+ }
622
+ }
623
+
624
+ // bin/claim-push-bg.js
512
625
  var TERMINALHIRE_DIR4 = process.env.TERMINALHIRE_DIR || join5(homedir4(), ".terminalhire");
513
626
  var CLAIM_PUSH_AUTO_MARKER = join5(TERMINALHIRE_DIR4, "claim-push-auto.json");
514
627
  var CLAIM_PUSH_TOKEN_FILE = join5(TERMINALHIRE_DIR4, "claim-push-token.enc");
515
628
  var CLAIM_PUSH_MANUAL_MARKER = join5(TERMINALHIRE_DIR4, "claim-push.json");
516
- var CLAIM_SYNC_BASE = "https://terminalhire.com";
629
+ var CLAIM_SYNC_BASE = resolveApiBase();
630
+ warnSharedCredentialsIfNonProd(CLAIM_SYNC_BASE);
517
631
  var AUTO_CONSENT_VERSION = 3;
518
632
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
519
633
  async function writePushTokenEnc(rawToken) {
@@ -53,12 +53,104 @@ function ensureStateDir(dir) {
53
53
  }
54
54
  }
55
55
 
56
+ // src/api-base.ts
57
+ var PROD_API_BASE = "https://terminalhire.com";
58
+ var DEV_API_BASE = "https://dev.terminalhire.com";
59
+ var ApiBaseError = class extends Error {
60
+ constructor(message) {
61
+ super(message);
62
+ this.name = "ApiBaseError";
63
+ }
64
+ };
65
+ var ALLOWED_HOSTS = {
66
+ "terminalhire.com": "https:",
67
+ "www.terminalhire.com": "https:",
68
+ "dev.terminalhire.com": "https:",
69
+ localhost: "http:",
70
+ "127.0.0.1": "http:"
71
+ };
72
+ var ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
73
+ var ALLOWED_DESCRIPTION = [
74
+ PROD_API_BASE,
75
+ DEV_API_BASE,
76
+ `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
77
+ `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
78
+ ].join(", ");
79
+ var CANONICAL_REWRITES = {
80
+ "www.terminalhire.com": PROD_API_BASE
81
+ };
82
+ var ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
83
+ function sanitizeOverrideForError(raw) {
84
+ try {
85
+ const url = new URL(raw);
86
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
87
+ return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
88
+ }
89
+ if (url.username !== "" || url.password !== "") {
90
+ return `${url.protocol}//***@${url.host}`;
91
+ }
92
+ return url.origin;
93
+ } catch {
94
+ return "(unparseable override)";
95
+ }
96
+ }
97
+ function isLoopbackOrigin(origin) {
98
+ try {
99
+ const host = new URL(origin).hostname;
100
+ return host === "localhost" || host === "127.0.0.1";
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+ function localApiAllowed(env) {
106
+ return env[ALLOW_LOCAL_API_KEY] === "1";
107
+ }
108
+ function resolveApiBase(env = process.env) {
109
+ for (const key of ENV_KEYS) {
110
+ const raw = env[key];
111
+ if (typeof raw !== "string") continue;
112
+ const trimmed = raw.trim();
113
+ if (trimmed === "") continue;
114
+ const normalized = normalizeOverride(trimmed);
115
+ if (normalized === null) {
116
+ throw new ApiBaseError(
117
+ `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
118
+ );
119
+ }
120
+ if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
121
+ throw new ApiBaseError(
122
+ `terminalhire: ${key}=${normalized} is a loopback origin. Set ${ALLOW_LOCAL_API_KEY}=1 to talk to a local web app on purpose. Refusing so stored credentials cannot be exfiltrated to localhost by a poisoned override.`
123
+ );
124
+ }
125
+ return normalized;
126
+ }
127
+ return PROD_API_BASE;
128
+ }
129
+ function normalizeOverride(raw) {
130
+ let url;
131
+ try {
132
+ url = new URL(raw);
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (url.username !== "" || url.password !== "") return null;
137
+ const expectedProtocol = ALLOWED_HOSTS[url.hostname];
138
+ if (expectedProtocol === void 0) return null;
139
+ if (url.protocol !== expectedProtocol) return null;
140
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
141
+ return null;
142
+ }
143
+ const rewrite = CANONICAL_REWRITES[url.hostname];
144
+ if (rewrite !== void 0) return rewrite;
145
+ return url.origin;
146
+ }
147
+
56
148
  // bin/directory.js
57
149
  var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join(homedir(), ".terminalhire");
58
150
  var DIRECTORY_CACHE_FILE = join(TERMINALHIRE_DIR, "directory-cache.json");
59
151
  var PROJECT_FILE = join(TERMINALHIRE_DIR, "project.json");
60
152
  var INDEX_TTL_MS = 15 * 60 * 1e3;
61
- var API_URL = process.env["TERMINALHIRE_API_URL"] ?? process.env["JPI_API_URL"] ?? "https://terminalhire.com";
153
+ var API_URL = resolveApiBase();
62
154
  function readDirectoryCache() {
63
155
  try {
64
156
  const entry = JSON.parse(readFileSync(DIRECTORY_CACHE_FILE, "utf8"));
@@ -0,0 +1,161 @@
1
+ // src/api-base.ts
2
+ var PROD_API_BASE = "https://terminalhire.com";
3
+ var DEV_API_BASE = "https://dev.terminalhire.com";
4
+ var ApiBaseError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ApiBaseError";
8
+ }
9
+ };
10
+ var ALLOWED_HOSTS = {
11
+ "terminalhire.com": "https:",
12
+ "www.terminalhire.com": "https:",
13
+ "dev.terminalhire.com": "https:",
14
+ localhost: "http:",
15
+ "127.0.0.1": "http:"
16
+ };
17
+ var ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
18
+ var ALLOWED_DESCRIPTION = [
19
+ PROD_API_BASE,
20
+ DEV_API_BASE,
21
+ `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
22
+ `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
23
+ ].join(", ");
24
+ var CANONICAL_REWRITES = {
25
+ "www.terminalhire.com": PROD_API_BASE
26
+ };
27
+ var ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
28
+ function sanitizeOverrideForError(raw) {
29
+ try {
30
+ const url = new URL(raw);
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
32
+ return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
33
+ }
34
+ if (url.username !== "" || url.password !== "") {
35
+ return `${url.protocol}//***@${url.host}`;
36
+ }
37
+ return url.origin;
38
+ } catch {
39
+ return "(unparseable override)";
40
+ }
41
+ }
42
+ function isLoopbackOrigin(origin) {
43
+ try {
44
+ const host = new URL(origin).hostname;
45
+ return host === "localhost" || host === "127.0.0.1";
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+ function localApiAllowed(env) {
51
+ return env[ALLOW_LOCAL_API_KEY] === "1";
52
+ }
53
+ function resolveApiBase(env = process.env) {
54
+ for (const key of ENV_KEYS) {
55
+ const raw = env[key];
56
+ if (typeof raw !== "string") continue;
57
+ const trimmed = raw.trim();
58
+ if (trimmed === "") continue;
59
+ const normalized = normalizeOverride(trimmed);
60
+ if (normalized === null) {
61
+ throw new ApiBaseError(
62
+ `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
63
+ );
64
+ }
65
+ if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
66
+ throw new ApiBaseError(
67
+ `terminalhire: ${key}=${normalized} is a loopback origin. Set ${ALLOW_LOCAL_API_KEY}=1 to talk to a local web app on purpose. Refusing so stored credentials cannot be exfiltrated to localhost by a poisoned override.`
68
+ );
69
+ }
70
+ return normalized;
71
+ }
72
+ return PROD_API_BASE;
73
+ }
74
+ function normalizeOverride(raw) {
75
+ let url;
76
+ try {
77
+ url = new URL(raw);
78
+ } catch {
79
+ return null;
80
+ }
81
+ if (url.username !== "" || url.password !== "") return null;
82
+ const expectedProtocol = ALLOWED_HOSTS[url.hostname];
83
+ if (expectedProtocol === void 0) return null;
84
+ if (url.protocol !== expectedProtocol) return null;
85
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
86
+ return null;
87
+ }
88
+ const rewrite = CANONICAL_REWRITES[url.hostname];
89
+ if (rewrite !== void 0) return rewrite;
90
+ return url.origin;
91
+ }
92
+
93
+ // bin/founder-note-sync.js
94
+ var CLAIM_SYNC_BASE = resolveApiBase();
95
+ async function fetchFounderNotes(pushToken, fetchImpl = fetch) {
96
+ if (typeof pushToken !== "string" || pushToken.length === 0) return null;
97
+ try {
98
+ const res = await fetchImpl(`${CLAIM_SYNC_BASE}/api/claim/notes`, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ pushToken }),
102
+ signal: AbortSignal.timeout(15e3)
103
+ });
104
+ if (!res?.ok) return null;
105
+ const body = await res.json();
106
+ if (!body || !Array.isArray(body.notes)) return null;
107
+ const notes = body.notes.filter(
108
+ (n) => n && typeof n.claimId === "string" && n.claimId !== "" && typeof n.note === "string" && n.note !== "" && typeof n.at === "string" && (n.kind === "founder_note" || n.kind === "feedback")
109
+ );
110
+ return { notes, latestAt: typeof body.latestAt === "string" ? body.latestAt : null };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ function noteKey(note) {
116
+ return `${note.claimId}@${note.at}`;
117
+ }
118
+ function computeFounderNotes(notes, previous) {
119
+ const keys = Array.isArray(notes) ? notes.map(noteKey) : [];
120
+ const prior = previous && Array.isArray(previous.seen) ? new Set(previous.seen) : /* @__PURE__ */ new Set();
121
+ const seen = keys.filter((k) => prior.has(k));
122
+ return { count: keys.length - seen.length, seen };
123
+ }
124
+ function acknowledgeFounderNotes(notes) {
125
+ const keys = Array.isArray(notes) ? notes.map(noteKey) : [];
126
+ return { count: 0, seen: keys };
127
+ }
128
+ async function syncFounderNotes({
129
+ readAutoMarker,
130
+ readPushTokenEnc,
131
+ readPrevious,
132
+ fetchImpl = fetch,
133
+ timeoutMs = 15e3
134
+ } = {}) {
135
+ try {
136
+ const marker = readAutoMarker();
137
+ const token = await readPushTokenEnc();
138
+ if (!marker || !token) return null;
139
+ const answer = await fetchFounderNotes(
140
+ token,
141
+ (url, init) => fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
142
+ );
143
+ if (!answer) return null;
144
+ return computeFounderNotes(answer.notes, readPrevious());
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ function formatNote(note) {
150
+ const label = note.kind === "feedback" ? "changes requested" : "note";
151
+ return ` ${note.at.slice(0, 16).replace("T", " ")} [${label}] ${note.claimId}
152
+ ${note.note.split("\n").join("\n ")}`;
153
+ }
154
+ export {
155
+ acknowledgeFounderNotes,
156
+ computeFounderNotes,
157
+ fetchFounderNotes,
158
+ formatNote,
159
+ noteKey,
160
+ syncFounderNotes
161
+ };