terminalhire 0.42.17 → 0.42.19

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 CHANGED
@@ -310,6 +310,13 @@ node test/zero-egress.test.js
310
310
  uses that one answer — so reads and writes can no longer disagree about which
311
311
  environment you are on.
312
312
 
313
+ The credential store follows that answer. Pointed at `dev.terminalhire.com` by any
314
+ spelling (`thdev`, the variable, an MCP server entry), the CLI reads and writes
315
+ `~/.terminalhire-dev` unless `TERMINALHIRE_DIR` names a store (`src/state-dir-pin.ts`,
316
+ called first thing in `bin/jpi-dispatch.js`, before any subcommand loads). Production and loopback keep
317
+ `~/.terminalhire`. Before this, only `thdev` switched stores, and every other
318
+ spelling presented the production session to dev.
319
+
313
320
  The override is an **allowlist**, not a free-form URL, because this same base decides
314
321
  where your push token is presented. Only these are accepted:
315
322
 
@@ -91,10 +91,14 @@ var init_state_dir = __esm({
91
91
  var claims_exports = {};
92
92
  __export(claims_exports, {
93
93
  CLAIM_STATES: () => CLAIM_STATES,
94
+ CLAIM_TURN_LABEL_WIDTH: () => CLAIM_TURN_LABEL_WIDTH,
94
95
  PUSHED_CLAIM_FIELDS: () => PUSHED_CLAIM_FIELDS,
95
96
  acceptedPRRate: () => acceptedPRRate,
97
+ claimTurn: () => claimTurn,
98
+ claimTurnLabel: () => claimTurnLabel,
96
99
  countAwaitingFounderApproval: () => countAwaitingFounderApproval,
97
100
  findClaim: () => findClaim,
101
+ formatAcceptedPRRate: () => formatAcceptedPRRate,
98
102
  listClaims: () => listClaims,
99
103
  nextPolledState: () => nextPolledState,
100
104
  readClaims: () => readClaims,
@@ -149,6 +153,12 @@ function withClaimsLock(fn) {
149
153
  rmSync2(LOCK_DIR, { recursive: true, force: true });
150
154
  }
151
155
  }
156
+ function claimTurn(state) {
157
+ return CLAIM_TURN_BY_STATE[state] ?? "you";
158
+ }
159
+ function claimTurnLabel(state) {
160
+ return CLAIM_TURN_LABEL[claimTurn(state)];
161
+ }
152
162
  function toPushedClaim(claim) {
153
163
  return {
154
164
  kind: claim.kind,
@@ -303,9 +313,18 @@ function countAwaitingFounderApproval(claims = readClaims()) {
303
313
  function acceptedPRRate(claims = readClaims()) {
304
314
  const total = claims.length;
305
315
  const merged = claims.filter((c) => c.state === "merged").length;
306
- return { merged, total, rate: total === 0 ? 0 : merged / total };
316
+ const decided = claims.filter((c) => DECIDED_STATES.has(c.state)).length;
317
+ return { merged, decided, inFlight: total - decided, total, rate: decided === 0 ? 0 : merged / decided };
318
+ }
319
+ function formatAcceptedPRRate(rate) {
320
+ const inFlight = rate.inFlight > 0 ? ` \xB7 ${rate.inFlight} in flight` : "";
321
+ if (rate.decided === 0) {
322
+ return `Accepted-PR rate: no claims decided yet${inFlight}`;
323
+ }
324
+ const pct = Math.round(rate.rate * 100);
325
+ return `Accepted-PR rate: ${rate.merged}/${rate.decided} decided claims merged (${pct}%)${inFlight}`;
307
326
  }
308
- var TERMINALHIRE_DIR3, CLAIMS_FILE, LOCK_DIR, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, CLAIM_STATES, PUSHED_CLAIM_FIELDS, TERMINAL_STATES, POLL_TRANSITIONS, WHITESPACE_CONTROLS, CONTROL_CHARS;
327
+ var TERMINALHIRE_DIR3, CLAIMS_FILE, LOCK_DIR, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, CLAIM_STATES, CLAIM_TURN_BY_STATE, CLAIM_TURN_LABEL, CLAIM_TURN_LABEL_WIDTH, PUSHED_CLAIM_FIELDS, TERMINAL_STATES, POLL_TRANSITIONS, WHITESPACE_CONTROLS, CONTROL_CHARS, DECIDED_STATES;
309
328
  var init_claims = __esm({
310
329
  "src/claims.ts"() {
311
330
  "use strict";
@@ -332,6 +351,24 @@ var init_claims = __esm({
332
351
  "abandoned"
333
352
  // released, or PR closed unmerged
334
353
  ]);
354
+ CLAIM_TURN_BY_STATE = Object.freeze({
355
+ claimed: "you",
356
+ working: "you",
357
+ "in-review": "you",
358
+ ready: "you",
359
+ submitted: "poster",
360
+ merged: "merged",
361
+ abandoned: "closed"
362
+ });
363
+ CLAIM_TURN_LABEL = Object.freeze({
364
+ you: "waiting on you",
365
+ poster: "waiting on poster",
366
+ merged: "merged",
367
+ closed: "closed"
368
+ });
369
+ CLAIM_TURN_LABEL_WIDTH = Math.max(
370
+ ...Object.values(CLAIM_TURN_LABEL).map((label) => label.length)
371
+ );
335
372
  PUSHED_CLAIM_FIELDS = [
336
373
  "kind",
337
374
  "repoFullName",
@@ -363,6 +400,7 @@ var init_claims = __esm({
363
400
  };
364
401
  WHITESPACE_CONTROLS = /[\t\n\v\f\r]+/g;
365
402
  CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
403
+ DECIDED_STATES = /* @__PURE__ */ new Set(["merged", "abandoned"]);
366
404
  }
367
405
  });
368
406
 
@@ -182,7 +182,7 @@ async function syncFounderVerdicts({
182
182
  const applied = [];
183
183
  for (const t of plan) {
184
184
  try {
185
- claimsModule.updateClaim(t.id, { state: t.to });
185
+ claimsModule.updateClaim(t.id, { state: t.to, posterVerdict: t.verdict });
186
186
  applied.push(t);
187
187
  const line = buildVerdictNotice(t);
188
188
  if (line) log(line);
@@ -10550,6 +10550,187 @@ var init_state_dir = __esm({
10550
10550
  }
10551
10551
  });
10552
10552
 
10553
+ // src/api-base.ts
10554
+ var api_base_exports = {};
10555
+ __export(api_base_exports, {
10556
+ ApiBaseError: () => ApiBaseError,
10557
+ DEV_API_BASE: () => DEV_API_BASE,
10558
+ DEV_STATE_DIR_NAME: () => DEV_STATE_DIR_NAME,
10559
+ PROD_API_BASE: () => PROD_API_BASE,
10560
+ __resetDevMarkerLatchForTests: () => __resetDevMarkerLatchForTests,
10561
+ formatDevMarker: () => formatDevMarker,
10562
+ isLoopbackOrigin: () => isLoopbackOrigin,
10563
+ isNonProdApiBase: () => isNonProdApiBase,
10564
+ pinToDevApiBase: () => pinToDevApiBase,
10565
+ printDevMarkerIfNeeded: () => printDevMarkerIfNeeded,
10566
+ resolveApiBase: () => resolveApiBase,
10567
+ resolveOAuthBase: () => resolveOAuthBase,
10568
+ warnSharedCredentialsIfNonProd: () => warnSharedCredentialsIfNonProd
10569
+ });
10570
+ import { homedir as homedir2 } from "os";
10571
+ import { join as join3 } from "path";
10572
+ function sanitizeOverrideForError(raw) {
10573
+ try {
10574
+ const url = new URL(raw);
10575
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
10576
+ return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
10577
+ }
10578
+ if (url.username !== "" || url.password !== "") {
10579
+ return `${url.protocol}//***@${url.host}`;
10580
+ }
10581
+ return url.origin;
10582
+ } catch {
10583
+ return "(unparseable override)";
10584
+ }
10585
+ }
10586
+ function isLoopbackOrigin(origin) {
10587
+ try {
10588
+ const host = new URL(origin).hostname;
10589
+ return host === "localhost" || host === "127.0.0.1";
10590
+ } catch {
10591
+ return false;
10592
+ }
10593
+ }
10594
+ function localApiAllowed(env) {
10595
+ return env[ALLOW_LOCAL_API_KEY] === "1";
10596
+ }
10597
+ function pinToDevApiBase(env = process.env) {
10598
+ env["TERMINALHIRE_API_URL"] = DEV_API_BASE;
10599
+ env["TERMINALHIRE_DIR"] = env["TERMINALHIRE_DIR"] || join3(homedir2(), DEV_STATE_DIR_NAME);
10600
+ return DEV_API_BASE;
10601
+ }
10602
+ function resolveApiBase(env = process.env) {
10603
+ for (const key of ENV_KEYS) {
10604
+ const raw = env[key];
10605
+ if (typeof raw !== "string") continue;
10606
+ const trimmed = raw.trim();
10607
+ if (trimmed === "") continue;
10608
+ const normalized = normalizeOverride(trimmed);
10609
+ if (normalized === null) {
10610
+ throw new ApiBaseError(
10611
+ `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
10612
+ );
10613
+ }
10614
+ if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
10615
+ throw new ApiBaseError(
10616
+ `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.`
10617
+ );
10618
+ }
10619
+ return normalized;
10620
+ }
10621
+ return PROD_API_BASE;
10622
+ }
10623
+ function resolveOAuthBase(env = process.env) {
10624
+ const base = resolveApiBase(env);
10625
+ if (OAUTH_ALLOWED_ORIGINS.includes(base)) return base;
10626
+ if (env[ALLOW_LOCAL_OAUTH_KEY] === "1") return base;
10627
+ throw new ApiBaseError(
10628
+ `terminalhire: the API base is ${base}, which is not a trusted origin for a browser sign-in. Point the CLI at ${DEV_API_BASE} for an end-to-end login, or set ${ALLOW_LOCAL_OAUTH_KEY}=1 (with ${ALLOW_LOCAL_API_KEY}=1) if you are running the web app locally on purpose. Refusing to open production sign-in while the API is local.`
10629
+ );
10630
+ }
10631
+ function resolveApiBaseOrProd(env = process.env) {
10632
+ try {
10633
+ return resolveApiBase(env);
10634
+ } catch {
10635
+ return PROD_API_BASE;
10636
+ }
10637
+ }
10638
+ function normalizeOverride(raw) {
10639
+ let url;
10640
+ try {
10641
+ url = new URL(raw);
10642
+ } catch {
10643
+ return null;
10644
+ }
10645
+ if (url.username !== "" || url.password !== "") return null;
10646
+ const expectedProtocol = ALLOWED_HOSTS[url.hostname];
10647
+ if (expectedProtocol === void 0) return null;
10648
+ if (url.protocol !== expectedProtocol) return null;
10649
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
10650
+ return null;
10651
+ }
10652
+ const rewrite = CANONICAL_REWRITES[url.hostname];
10653
+ if (rewrite !== void 0) return rewrite;
10654
+ return url.origin;
10655
+ }
10656
+ function isNonProdApiBase(base = resolveApiBaseOrProd()) {
10657
+ return base !== PROD_API_BASE;
10658
+ }
10659
+ function formatDevMarker(base = resolveApiBaseOrProd()) {
10660
+ if (!isNonProdApiBase(base)) return null;
10661
+ let host = base;
10662
+ try {
10663
+ host = new URL(base).host;
10664
+ } catch {
10665
+ }
10666
+ return `[dev \u2192 ${host}]`;
10667
+ }
10668
+ function printDevMarkerIfNeeded(stream = process.stderr) {
10669
+ if (markerPrinted) return;
10670
+ const marker = formatDevMarker();
10671
+ if (marker === null) return;
10672
+ markerPrinted = true;
10673
+ try {
10674
+ stream.write(`${marker}
10675
+ `);
10676
+ } catch {
10677
+ }
10678
+ }
10679
+ function __resetDevMarkerLatchForTests() {
10680
+ markerPrinted = false;
10681
+ }
10682
+ function warnSharedCredentialsIfNonProd(base, stream = process.stderr) {
10683
+ if (!isNonProdApiBase(base)) return;
10684
+ if (usingSeparateStateDir()) return;
10685
+ try {
10686
+ stream.write(
10687
+ "terminalhire: non-prod API base \u2014 using the same local session/push credentials as prod; do not mix environments casually.\n"
10688
+ );
10689
+ } catch {
10690
+ }
10691
+ }
10692
+ function usingSeparateStateDir(env = process.env) {
10693
+ const dir = env["TERMINALHIRE_DIR"];
10694
+ if (dir === void 0 || dir === "") return false;
10695
+ return dir.endsWith(DEV_STATE_DIR_NAME);
10696
+ }
10697
+ var PROD_API_BASE, DEV_API_BASE, DEV_STATE_DIR_NAME, ApiBaseError, ALLOWED_HOSTS, OAUTH_ALLOWED_ORIGINS, ALLOW_LOCAL_OAUTH_KEY, ALLOW_LOCAL_API_KEY, ALLOWED_DESCRIPTION, CANONICAL_REWRITES, ENV_KEYS, markerPrinted;
10698
+ var init_api_base = __esm({
10699
+ "src/api-base.ts"() {
10700
+ "use strict";
10701
+ PROD_API_BASE = "https://terminalhire.com";
10702
+ DEV_API_BASE = "https://dev.terminalhire.com";
10703
+ DEV_STATE_DIR_NAME = ".terminalhire-dev";
10704
+ ApiBaseError = class extends Error {
10705
+ constructor(message) {
10706
+ super(message);
10707
+ this.name = "ApiBaseError";
10708
+ }
10709
+ };
10710
+ ALLOWED_HOSTS = {
10711
+ "terminalhire.com": "https:",
10712
+ "www.terminalhire.com": "https:",
10713
+ "dev.terminalhire.com": "https:",
10714
+ localhost: "http:",
10715
+ "127.0.0.1": "http:"
10716
+ };
10717
+ OAUTH_ALLOWED_ORIGINS = [PROD_API_BASE, DEV_API_BASE];
10718
+ ALLOW_LOCAL_OAUTH_KEY = "TERMINALHIRE_ALLOW_LOCAL_OAUTH";
10719
+ ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
10720
+ ALLOWED_DESCRIPTION = [
10721
+ PROD_API_BASE,
10722
+ DEV_API_BASE,
10723
+ `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
10724
+ `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
10725
+ ].join(", ");
10726
+ CANONICAL_REWRITES = {
10727
+ "www.terminalhire.com": PROD_API_BASE
10728
+ };
10729
+ ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
10730
+ markerPrinted = false;
10731
+ }
10732
+ });
10733
+
10553
10734
  // bin/founder-pin.js
10554
10735
  function isPinnedFounderBounty(j) {
10555
10736
  return j?.bounty?.bountySource === "founder" && j?.bounty?.claimable === true;
@@ -11254,10 +11435,14 @@ var init_repo_experience = __esm({
11254
11435
  var claims_exports = {};
11255
11436
  __export(claims_exports, {
11256
11437
  CLAIM_STATES: () => CLAIM_STATES,
11438
+ CLAIM_TURN_LABEL_WIDTH: () => CLAIM_TURN_LABEL_WIDTH,
11257
11439
  PUSHED_CLAIM_FIELDS: () => PUSHED_CLAIM_FIELDS,
11258
11440
  acceptedPRRate: () => acceptedPRRate,
11441
+ claimTurn: () => claimTurn,
11442
+ claimTurnLabel: () => claimTurnLabel,
11259
11443
  countAwaitingFounderApproval: () => countAwaitingFounderApproval,
11260
11444
  findClaim: () => findClaim,
11445
+ formatAcceptedPRRate: () => formatAcceptedPRRate,
11261
11446
  listClaims: () => listClaims,
11262
11447
  nextPolledState: () => nextPolledState,
11263
11448
  readClaims: () => readClaims,
@@ -11312,6 +11497,12 @@ function withClaimsLock(fn) {
11312
11497
  rmSync2(LOCK_DIR, { recursive: true, force: true });
11313
11498
  }
11314
11499
  }
11500
+ function claimTurn(state) {
11501
+ return CLAIM_TURN_BY_STATE[state] ?? "you";
11502
+ }
11503
+ function claimTurnLabel(state) {
11504
+ return CLAIM_TURN_LABEL[claimTurn(state)];
11505
+ }
11315
11506
  function toPushedClaim(claim) {
11316
11507
  return {
11317
11508
  kind: claim.kind,
@@ -11466,9 +11657,18 @@ function countAwaitingFounderApproval(claims = readClaims()) {
11466
11657
  function acceptedPRRate(claims = readClaims()) {
11467
11658
  const total = claims.length;
11468
11659
  const merged = claims.filter((c) => c.state === "merged").length;
11469
- return { merged, total, rate: total === 0 ? 0 : merged / total };
11660
+ const decided = claims.filter((c) => DECIDED_STATES.has(c.state)).length;
11661
+ return { merged, decided, inFlight: total - decided, total, rate: decided === 0 ? 0 : merged / decided };
11662
+ }
11663
+ function formatAcceptedPRRate(rate) {
11664
+ const inFlight = rate.inFlight > 0 ? ` \xB7 ${rate.inFlight} in flight` : "";
11665
+ if (rate.decided === 0) {
11666
+ return `Accepted-PR rate: no claims decided yet${inFlight}`;
11667
+ }
11668
+ const pct = Math.round(rate.rate * 100);
11669
+ return `Accepted-PR rate: ${rate.merged}/${rate.decided} decided claims merged (${pct}%)${inFlight}`;
11470
11670
  }
11471
- var TERMINALHIRE_DIR5, CLAIMS_FILE, LOCK_DIR, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, CLAIM_STATES, PUSHED_CLAIM_FIELDS, TERMINAL_STATES, POLL_TRANSITIONS, WHITESPACE_CONTROLS2, CONTROL_CHARS2;
11671
+ var TERMINALHIRE_DIR5, CLAIMS_FILE, LOCK_DIR, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, CLAIM_STATES, CLAIM_TURN_BY_STATE, CLAIM_TURN_LABEL, CLAIM_TURN_LABEL_WIDTH, PUSHED_CLAIM_FIELDS, TERMINAL_STATES, POLL_TRANSITIONS, WHITESPACE_CONTROLS2, CONTROL_CHARS2, DECIDED_STATES;
11472
11672
  var init_claims = __esm({
11473
11673
  "src/claims.ts"() {
11474
11674
  "use strict";
@@ -11495,6 +11695,24 @@ var init_claims = __esm({
11495
11695
  "abandoned"
11496
11696
  // released, or PR closed unmerged
11497
11697
  ]);
11698
+ CLAIM_TURN_BY_STATE = Object.freeze({
11699
+ claimed: "you",
11700
+ working: "you",
11701
+ "in-review": "you",
11702
+ ready: "you",
11703
+ submitted: "poster",
11704
+ merged: "merged",
11705
+ abandoned: "closed"
11706
+ });
11707
+ CLAIM_TURN_LABEL = Object.freeze({
11708
+ you: "waiting on you",
11709
+ poster: "waiting on poster",
11710
+ merged: "merged",
11711
+ closed: "closed"
11712
+ });
11713
+ CLAIM_TURN_LABEL_WIDTH = Math.max(
11714
+ ...Object.values(CLAIM_TURN_LABEL).map((label) => label.length)
11715
+ );
11498
11716
  PUSHED_CLAIM_FIELDS = [
11499
11717
  "kind",
11500
11718
  "repoFullName",
@@ -11526,6 +11744,7 @@ var init_claims = __esm({
11526
11744
  };
11527
11745
  WHITESPACE_CONTROLS2 = /[\t\n\v\f\r]+/g;
11528
11746
  CONTROL_CHARS2 = /[\x00-\x1f\x7f-\x9f]/g;
11747
+ DECIDED_STATES = /* @__PURE__ */ new Set(["merged", "abandoned"]);
11529
11748
  }
11530
11749
  });
11531
11750
 
@@ -11747,12 +11966,16 @@ var init_founder_paid_badge = __esm({
11747
11966
  var web_session_exports = {};
11748
11967
  __export(web_session_exports, {
11749
11968
  clearWebSessionFile: () => clearWebSessionFile,
11969
+ hostLabel: () => hostLabel,
11970
+ missingSessionLines: () => missingSessionLines,
11750
11971
  readWebSessionCookie: () => readWebSessionCookie,
11751
11972
  readWebSessionFile: () => readWebSessionFile,
11752
11973
  readWebSessionRecord: () => readWebSessionRecord,
11974
+ refusedSessionLines: () => refusedSessionLines,
11753
11975
  webSessionCookieForHost: () => webSessionCookieForHost,
11754
11976
  webSessionFilePath: () => webSessionFilePath,
11755
11977
  webSessionForHost: () => webSessionForHost,
11978
+ withSessionDeps: () => withSessionDeps,
11756
11979
  writeWebSessionFile: () => writeWebSessionFile
11757
11980
  });
11758
11981
  import { chmodSync, existsSync as existsSync6, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
@@ -11792,6 +12015,7 @@ function readWebSessionFile() {
11792
12015
  return readWebSessionRecord()?.token ?? null;
11793
12016
  }
11794
12017
  function webSessionForHost(apiBase) {
12018
+ assertBase(apiBase, "webSessionForHost");
11795
12019
  const record = readWebSessionRecord();
11796
12020
  if (!record) return { cookie: null, mismatch: null };
11797
12021
  if (record.host !== null && record.host !== apiBase) {
@@ -11805,11 +12029,50 @@ function webSessionCookieForHost(apiBase) {
11805
12029
  const env = process.env["TERMINALHIRE_WEB_SESSION"];
11806
12030
  return { cookie: typeof env === "string" && env.length > 0 ? env : null, mismatch: null };
11807
12031
  }
11808
- function readWebSessionCookie() {
11809
- const fromFile = readWebSessionFile();
11810
- if (fromFile) return fromFile;
11811
- const env = process.env["TERMINALHIRE_WEB_SESSION"];
11812
- return typeof env === "string" && env.length > 0 ? env : null;
12032
+ function assertBase(apiBase, fn) {
12033
+ if (typeof apiBase !== "string" || apiBase.length === 0) {
12034
+ throw new TypeError(
12035
+ `${fn}(apiBase) requires the destination base as a non-empty string; received ${apiBase === "" ? "''" : String(apiBase)}. This is a wiring bug in the calling command, not a developer misconfiguration: pass the same resolveApiBase() value the request is sent to, so the session's host affinity can be checked (TERM-991).`
12036
+ );
12037
+ }
12038
+ }
12039
+ function hostLabel(base) {
12040
+ return String(base ?? "").replace(/^https?:\/\//, "");
12041
+ }
12042
+ function missingSessionLines(mismatch) {
12043
+ if (!mismatch) {
12044
+ return [
12045
+ "No linked web session found on this machine.",
12046
+ "Run `terminalhire link` to connect this terminal to your account, then re-run."
12047
+ ];
12048
+ }
12049
+ const linked = hostLabel(mismatch.linkedHost);
12050
+ const current = hostLabel(mismatch.currentHost);
12051
+ const reach = mismatch.linkedHost === PROD_API_BASE ? `unset TERMINALHIRE_API_URL to reach ${linked}` : isLoopbackOrigin(mismatch.linkedHost) ? `set TERMINALHIRE_ALLOW_LOCAL_API=1 TERMINALHIRE_API_URL=${mismatch.linkedHost} to reach ${linked}` : `set TERMINALHIRE_API_URL=${mismatch.linkedHost} to reach ${linked}`;
12052
+ const link = isLoopbackOrigin(mismatch.currentHost) ? "`TERMINALHIRE_ALLOW_LOCAL_OAUTH=1 terminalhire link`" : "`terminalhire link`";
12053
+ return [
12054
+ `This terminal is on ${current}, but your linked session belongs to ${linked}. Nothing was sent.`,
12055
+ `Either ${reach}, or run ${link} to link this terminal to ${current} instead.`
12056
+ ];
12057
+ }
12058
+ function withSessionDeps(defaults, overrides) {
12059
+ const merged = { ...defaults, ...overrides };
12060
+ if (overrides?.sessionCookie && !overrides.sessionMismatch) merged.sessionMismatch = () => null;
12061
+ return merged;
12062
+ }
12063
+ function refusedSessionLines(apiBase) {
12064
+ return [
12065
+ `${hostLabel(apiBase)} refused your linked session.`,
12066
+ "Run `terminalhire link` to link this terminal again, then re-run."
12067
+ ];
12068
+ }
12069
+ function readWebSessionCookie(apiBase) {
12070
+ if (typeof apiBase !== "string" || apiBase.length === 0) {
12071
+ throw new TypeError(
12072
+ `readWebSessionCookie(apiBase) requires the destination base as a non-empty string; received ${apiBase === "" ? "''" : String(apiBase)}. This is a wiring bug in the calling command, not a developer misconfiguration: pass the same resolveApiBase() value the request is sent to, so the session's host affinity can be checked (TERM-991).`
12073
+ );
12074
+ }
12075
+ return webSessionCookieForHost(apiBase).cookie;
11813
12076
  }
11814
12077
  function writeWebSessionFile(token, host) {
11815
12078
  ensureStateDirForSecret(terminalhireDir());
@@ -11830,6 +12093,7 @@ function clearWebSessionFile() {
11830
12093
  var init_web_session = __esm({
11831
12094
  "src/web-session.ts"() {
11832
12095
  "use strict";
12096
+ init_api_base();
11833
12097
  init_state_dir();
11834
12098
  }
11835
12099
  });
@@ -11938,15 +12202,27 @@ async function sendDecline(bountyId, reason) {
11938
12202
  console.error("Not one of the answers \u2014 nothing sent.");
11939
12203
  return false;
11940
12204
  }
11941
- const { readWebSessionCookie: readWebSessionCookie2 } = await Promise.resolve().then(() => (init_web_session(), web_session_exports));
11942
- const cookie = readWebSessionCookie2();
12205
+ let apiUrl;
12206
+ try {
12207
+ const { resolveApiBase: resolveApiBase2 } = await Promise.resolve().then(() => (init_api_base(), api_base_exports));
12208
+ apiUrl = resolveApiBase2();
12209
+ } catch (err) {
12210
+ console.log(`
12211
+ ${err instanceof Error ? err.message : String(err)}`);
12212
+ return false;
12213
+ }
12214
+ const { missingSessionLines: missingSessionLines2, webSessionCookieForHost: webSessionCookieForHost2 } = await Promise.resolve().then(() => (init_web_session(), web_session_exports));
12215
+ const { cookie, mismatch } = webSessionCookieForHost2(apiUrl);
11943
12216
  if (!cookie) {
11944
- console.log("\nRun `terminalhire link` first \u2014 a poster needs to know the answer is real.");
12217
+ if (mismatch) console.log(`
12218
+ ${missingSessionLines2(mismatch).join("\n")}`);
12219
+ else
12220
+ console.log("\nRun `terminalhire link` first \u2014 a poster needs to know the answer is real.");
11945
12221
  return false;
11946
12222
  }
11947
12223
  if (!await ensureDeclineConsent(bountyId, reason)) return false;
11948
12224
  try {
11949
- const res = await fetch(`${API_URL}/api/posting/decline`, {
12225
+ const res = await fetch(`${apiUrl}/api/posting/decline`, {
11950
12226
  method: "POST",
11951
12227
  headers: {
11952
12228
  "content-type": "application/json",
@@ -11975,11 +12251,10 @@ async function runDeclinePrompt(bountyId, { ask = prompt } = {}) {
11975
12251
  if (!reason) return false;
11976
12252
  return sendDecline(bountyId, reason);
11977
12253
  }
11978
- var API_URL, GH_SESSION_COOKIE, DECLINE_CHOICES;
12254
+ var GH_SESSION_COOKIE, DECLINE_CHOICES;
11979
12255
  var init_jpi_decline = __esm({
11980
12256
  "bin/jpi-decline.js"() {
11981
12257
  "use strict";
11982
- API_URL = process.env["TERMINALHIRE_API_URL"] || "https://terminalhire.com";
11983
12258
  GH_SESSION_COOKIE = "__jpi_gh_session";
11984
12259
  DECLINE_CHOICES = [
11985
12260
  { key: "1", reason: "price_low", label: "the price is too low for the work" },
@@ -12211,106 +12486,13 @@ function linkTitle(title, url) {
12211
12486
  return href ? `${safeTitle} (${href})` : safeTitle;
12212
12487
  }
12213
12488
 
12214
- // src/api-base.ts
12215
- import { homedir as homedir2 } from "os";
12216
- import { join as join3 } from "path";
12217
- var PROD_API_BASE = "https://terminalhire.com";
12218
- var DEV_API_BASE = "https://dev.terminalhire.com";
12219
- var ApiBaseError = class extends Error {
12220
- constructor(message) {
12221
- super(message);
12222
- this.name = "ApiBaseError";
12223
- }
12224
- };
12225
- var ALLOWED_HOSTS = {
12226
- "terminalhire.com": "https:",
12227
- "www.terminalhire.com": "https:",
12228
- "dev.terminalhire.com": "https:",
12229
- localhost: "http:",
12230
- "127.0.0.1": "http:"
12231
- };
12232
- var ALLOW_LOCAL_API_KEY = "TERMINALHIRE_ALLOW_LOCAL_API";
12233
- var ALLOWED_DESCRIPTION = [
12234
- PROD_API_BASE,
12235
- DEV_API_BASE,
12236
- `http://localhost:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`,
12237
- `http://127.0.0.1:<port> (requires ${ALLOW_LOCAL_API_KEY}=1)`
12238
- ].join(", ");
12239
- var CANONICAL_REWRITES = {
12240
- "www.terminalhire.com": PROD_API_BASE
12241
- };
12242
- var ENV_KEYS = ["TERMINALHIRE_API_URL", "JPI_API_URL"];
12243
- function sanitizeOverrideForError(raw) {
12244
- try {
12245
- const url = new URL(raw);
12246
- if (url.protocol !== "http:" && url.protocol !== "https:") {
12247
- return `(disallowed scheme: ${url.protocol.slice(0, -1)})`;
12248
- }
12249
- if (url.username !== "" || url.password !== "") {
12250
- return `${url.protocol}//***@${url.host}`;
12251
- }
12252
- return url.origin;
12253
- } catch {
12254
- return "(unparseable override)";
12255
- }
12256
- }
12257
- function isLoopbackOrigin(origin) {
12258
- try {
12259
- const host = new URL(origin).hostname;
12260
- return host === "localhost" || host === "127.0.0.1";
12261
- } catch {
12262
- return false;
12263
- }
12264
- }
12265
- function localApiAllowed(env) {
12266
- return env[ALLOW_LOCAL_API_KEY] === "1";
12267
- }
12268
- function resolveApiBase(env = process.env) {
12269
- for (const key of ENV_KEYS) {
12270
- const raw = env[key];
12271
- if (typeof raw !== "string") continue;
12272
- const trimmed = raw.trim();
12273
- if (trimmed === "") continue;
12274
- const normalized = normalizeOverride(trimmed);
12275
- if (normalized === null) {
12276
- throw new ApiBaseError(
12277
- `terminalhire: ${key}=${sanitizeOverrideForError(trimmed)} is not an allowed API host (allowed: ${ALLOWED_DESCRIPTION}). Refusing to continue so we do not silently hit production.`
12278
- );
12279
- }
12280
- if (isLoopbackOrigin(normalized) && !localApiAllowed(env)) {
12281
- throw new ApiBaseError(
12282
- `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.`
12283
- );
12284
- }
12285
- return normalized;
12286
- }
12287
- return PROD_API_BASE;
12288
- }
12289
- function normalizeOverride(raw) {
12290
- let url;
12291
- try {
12292
- url = new URL(raw);
12293
- } catch {
12294
- return null;
12295
- }
12296
- if (url.username !== "" || url.password !== "") return null;
12297
- const expectedProtocol = ALLOWED_HOSTS[url.hostname];
12298
- if (expectedProtocol === void 0) return null;
12299
- if (url.protocol !== expectedProtocol) return null;
12300
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.port !== "") {
12301
- return null;
12302
- }
12303
- const rewrite = CANONICAL_REWRITES[url.hostname];
12304
- if (rewrite !== void 0) return rewrite;
12305
- return url.origin;
12306
- }
12307
-
12308
12489
  // bin/jpi-bounties.js
12490
+ init_api_base();
12309
12491
  init_founder_pin();
12310
12492
  var TERMINALHIRE_DIR7 = process.env.TERMINALHIRE_DIR || join13(homedir10(), ".terminalhire");
12311
12493
  var INDEX_CACHE_FILE2 = join13(TERMINALHIRE_DIR7, "index-cache.json");
12312
12494
  var INDEX_TTL_MS = 15 * 60 * 1e3;
12313
- var API_URL2 = resolveApiBase();
12495
+ var API_URL = resolveApiBase();
12314
12496
  var RANK_MODE = process.env["TERMINALHIRE_BOUNTY_RANK"] ?? "winnability";
12315
12497
  var CONTINUITY_RANK_DISABLED = process.env["TERMINALHIRE_NO_CONTINUITY_RANK"] === "1";
12316
12498
  var DEFAULT_LIMIT = 15;
@@ -12331,7 +12513,7 @@ function readIndexCache() {
12331
12513
  }
12332
12514
  async function fetchIndex(quiet = false) {
12333
12515
  const { index, reason, source, cachedAt } = await revalidateIndex({
12334
- apiUrl: API_URL2,
12516
+ apiUrl: API_URL,
12335
12517
  fetchImpl: (url, init) => fetch(url, init),
12336
12518
  readEntry: readCacheEntry,
12337
12519
  writeEntry: updateIndexCache
@@ -12482,7 +12664,7 @@ function classifyEmptyStatus({ preFilterCount, postFilterCount, priced }) {
12482
12664
  return "empty";
12483
12665
  }
12484
12666
  async function getBounties({ quiet = false, offline = false, priced = PRICED_ONLY } = {}) {
12485
- if (!quiet) console.log(`Fetching bounty index from ${API_URL2}/api/index...`);
12667
+ if (!quiet) console.log(`Fetching bounty index from ${API_URL}/api/index...`);
12486
12668
  const index = offline ? readIndexCache() : await fetchIndex(quiet);
12487
12669
  if (offline && !index) return { status: "no-cache" };
12488
12670
  let bounties = (index.jobs ?? []).filter((j) => j.source === "bounty");