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.
- package/README.md +80 -35
- package/dist/bin/approved-claims-sync.js +93 -1
- package/dist/bin/cache-store.js +5 -1
- package/dist/bin/claim-push-bg.js +115 -1
- package/dist/bin/directory.js +93 -1
- package/dist/bin/founder-note-sync.js +161 -0
- package/dist/bin/founder-verdict-sync.js +201 -0
- package/dist/bin/jpi-bounties.js +373 -19
- package/dist/bin/jpi-chat-read.js +104 -3
- package/dist/bin/jpi-chat.js +181 -21
- package/dist/bin/jpi-claim.js +221 -21
- package/dist/bin/jpi-config.js +5 -1
- package/dist/bin/jpi-contribute.js +116 -5
- package/dist/bin/jpi-devs.js +116 -3
- package/dist/bin/jpi-dispatch.js +6931 -1079
- package/dist/bin/jpi-hub.js +139 -16
- package/dist/bin/jpi-inbox.js +177 -19
- package/dist/bin/jpi-init.js +96 -4
- package/dist/bin/jpi-intro.js +162 -13
- package/dist/bin/jpi-jobs.js +130 -8
- package/dist/bin/jpi-learn.js +20 -1
- package/dist/bin/jpi-link.js +120 -4
- package/dist/bin/jpi-login.js +186 -30
- package/dist/bin/jpi-mcp-chat.js +140 -9
- package/dist/bin/jpi-mcp.js +230 -24
- package/dist/bin/jpi-post.js +93 -1
- package/dist/bin/jpi-profile.js +45 -6
- package/dist/bin/jpi-project.js +93 -1
- package/dist/bin/jpi-protocol.js +27 -4
- package/dist/bin/jpi-refresh.js +153 -7
- package/dist/bin/jpi-run.js +5383 -12
- package/dist/bin/jpi-save.js +20 -1
- package/dist/bin/jpi-sync.js +151 -6
- package/dist/bin/jpi-trajectory.js +136 -6
- package/dist/bin/jpi-update.js +27 -4
- package/dist/bin/peer-connect-prompt.js +121 -7
- package/dist/bin/proxy/egressProxy.js +117 -0
- package/dist/bin/proxy/proxyEntry.js +46 -0
- package/dist/bin/pulse-prompt.js +93 -1
- package/dist/bin/recall-check.js +278 -0
- package/dist/sandbox/fence-install.sb +129 -0
- package/dist/sandbox/fence-offline.sb +185 -0
- package/dist/src/api-base.js +156 -0
- package/dist/src/chat-client.js +93 -1
- package/dist/src/intro.js +155 -13
- package/dist/src/link.js +108 -3
- package/dist/src/profile.js +19 -0
- package/dist/src/protocol.js +27 -4
- package/dist/src/trajectory.js +130 -5
- 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
|
-
|
|
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 —
|
|
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 #
|
|
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
|
|
88
|
-
|
|
89
|
-
| Skill tags
|
|
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
|
|
92
|
-
| Contact email
|
|
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
|
|
203
|
-
|
|
204
|
-
| Profile accumulated from personal project sessions
|
|
205
|
-
| Closed-vocab skill tags + seniority (encrypted at rest)
|
|
206
|
-
| GitHub token + public profile data (encrypted at rest)
|
|
207
|
-
| Employer-repo sessions: language tags only
|
|
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`
|
|
284
|
-
|
|
285
|
-
| `direct`
|
|
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
|
|
299
|
-
|
|
300
|
-
| `TERMINALHIRE_API_URL`
|
|
301
|
-
| `GITHUB_CLIENT_ID`
|
|
302
|
-
| `GITHUB_CLIENT_SECRET`
|
|
303
|
-
| `GITHUB_DEVICE_CLIENT_ID`
|
|
304
|
-
| `TERMINALHIRE_GITHUB_MOCK` | `0`
|
|
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
|
|
330
|
-
|
|
331
|
-
| Skill tags
|
|
332
|
-
| Seniority
|
|
333
|
-
| Display name
|
|
334
|
-
| Contact email
|
|
335
|
-
| GitHub login / profileUrl | Stored in `profile.github` on `terminalhire login`
|
|
336
|
-
| GitHub topLanguages
|
|
337
|
-
| GitHub token
|
|
338
|
-
| Private repos
|
|
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 =
|
|
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" };
|
package/dist/bin/cache-store.js
CHANGED
|
@@ -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 =
|
|
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) {
|
package/dist/bin/directory.js
CHANGED
|
@@ -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 =
|
|
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
|
+
};
|