terminalhire 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/cache-store.js +52 -2
  3. package/dist/bin/claim-push-bg.js +91 -73
  4. package/dist/bin/directory.js +151 -0
  5. package/dist/bin/job-status-store.js +856 -0
  6. package/dist/bin/jpi-bounties.js +2243 -1078
  7. package/dist/bin/jpi-chat-read.js +294 -146
  8. package/dist/bin/jpi-chat.js +314 -176
  9. package/dist/bin/jpi-claim.js +2820 -1313
  10. package/dist/bin/jpi-config.js +52 -2
  11. package/dist/bin/jpi-contribute.js +1549 -152
  12. package/dist/bin/jpi-devs.js +2211 -1073
  13. package/dist/bin/jpi-dispatch.js +8158 -11886
  14. package/dist/bin/jpi-hub.js +2309 -1132
  15. package/dist/bin/jpi-inbox.js +314 -176
  16. package/dist/bin/jpi-init.js +2103 -919
  17. package/dist/bin/jpi-intro.js +241 -124
  18. package/dist/bin/jpi-jobs.js +2940 -1115
  19. package/dist/bin/jpi-learn.js +235 -112
  20. package/dist/bin/jpi-link.js +63 -13
  21. package/dist/bin/jpi-login.js +2228 -1105
  22. package/dist/bin/jpi-mcp-chat.js +426 -5976
  23. package/dist/bin/jpi-mcp.js +429 -5958
  24. package/dist/bin/jpi-profile.js +235 -112
  25. package/dist/bin/jpi-project.js +2103 -919
  26. package/dist/bin/jpi-protocol.js +128 -17
  27. package/dist/bin/jpi-refresh.js +2339 -1164
  28. package/dist/bin/jpi-repo.js +267 -117
  29. package/dist/bin/jpi-save.js +235 -112
  30. package/dist/bin/jpi-spinner.js +208 -76
  31. package/dist/bin/jpi-sync.js +254 -122
  32. package/dist/bin/jpi-trajectory.js +274 -140
  33. package/dist/bin/jpi-update.js +128 -16
  34. package/dist/bin/jpi.js +56 -5
  35. package/dist/bin/peer-connect-prompt.js +52 -2
  36. package/dist/bin/pulse-prompt.js +53 -10
  37. package/dist/bin/spinner-io.js +181 -0
  38. package/dist/bin/spinner-render.js +1260 -0
  39. package/dist/bin/spinner-seen.js +154 -0
  40. package/dist/bin/spinner.js +197 -61
  41. package/dist/bin/version-nudge.js +57 -3
  42. package/dist/src/chat-client.js +165 -119
  43. package/dist/src/chat-keystore.js +149 -106
  44. package/dist/src/claims.js +83 -7
  45. package/dist/src/config.js +52 -2
  46. package/dist/src/crypto-store.js +65 -70
  47. package/dist/src/github-auth.js +65 -74
  48. package/dist/src/intro.js +237 -120
  49. package/dist/src/link.js +54 -11
  50. package/dist/src/policy-acks.js +112 -0
  51. package/dist/src/profile.js +160 -104
  52. package/dist/src/protocol.js +123 -17
  53. package/dist/src/repo-experience.js +159 -103
  54. package/dist/src/repo-policy.js +60 -10
  55. package/dist/src/reputation/audit.js +0 -1
  56. package/dist/src/reputation/fetch.js +201 -34
  57. package/dist/src/signal.js +94 -33
  58. package/dist/src/state-dir.js +54 -0
  59. package/dist/src/trajectory.js +270 -136
  60. package/dist/src/web-session.js +52 -9
  61. package/install.js +169 -27
  62. package/package.json +1 -1
  63. package/statusline-install.js +153 -20
package/README.md CHANGED
@@ -212,7 +212,7 @@ GitHub (optional enrichment):
212
212
  ## Local profile encryption
213
213
 
214
214
  - Algorithm: **AES-256-GCM** via Node built-in `crypto` (no external deps).
215
- - Key: stored at `~/.terminalhire/key` with `0600` permissions. If `keytar` is installed, the OS keychain is preferred and the key file is not written.
215
+ - Key: stored at `~/.terminalhire/key` with `0600` permissions (a plaintext key file). In a compiled build the OS keychain is **not** used for these stores — the cross-platform plugin ships without `node_modules` and can't reach one. See `docs/adr-002` (keychain promotion is tracked as TERM-9).
216
216
  - Profile file: `~/.terminalhire/profile.enc` — JSON blob `{ iv, tag, ciphertext }` (all hex-encoded).
217
217
  - GitHub token file: `~/.terminalhire/github-token.enc` — same format, same key.
218
218
 
@@ -1,7 +1,57 @@
1
1
  // bin/cache-store.js
2
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
2
+ import { readFileSync, writeFileSync, renameSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
+
6
+ // src/state-dir.ts
7
+ import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync } from "fs";
8
+ var STATE_DIR_MODE = 448;
9
+ var STATE_DIR_OK = "ok";
10
+ var STATE_DIR_SYMLINK = "symlink";
11
+ var STATE_DIR_UNVERIFIED = "unverified";
12
+ var warnedDirs = /* @__PURE__ */ new Set();
13
+ function warnStateDirOnce(dir, message) {
14
+ if (warnedDirs.has(dir)) return;
15
+ warnedDirs.add(dir);
16
+ try {
17
+ process.stderr.write(message);
18
+ } catch {
19
+ }
20
+ }
21
+ function ensureStateDir(dir) {
22
+ mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
23
+ const noFollow = constants.O_NOFOLLOW ?? 0;
24
+ let fd;
25
+ try {
26
+ fd = openSync(dir, constants.O_RDONLY | noFollow);
27
+ } catch (err) {
28
+ if (err?.code === "ELOOP") {
29
+ warnStateDirOnce(
30
+ dir,
31
+ `terminalhire: ${dir} is a symlink \u2014 leaving its permissions alone; the 0700 guarantee on the state directory is NOT enforced.
32
+ `
33
+ );
34
+ return STATE_DIR_SYMLINK;
35
+ }
36
+ return STATE_DIR_UNVERIFIED;
37
+ }
38
+ try {
39
+ const currentMode = fstatSync(fd).mode & 511;
40
+ if ((currentMode & ~STATE_DIR_MODE) !== 0) {
41
+ fchmodSync(fd, currentMode & STATE_DIR_MODE);
42
+ }
43
+ return STATE_DIR_OK;
44
+ } catch {
45
+ return STATE_DIR_UNVERIFIED;
46
+ } finally {
47
+ try {
48
+ closeSync(fd);
49
+ } catch {
50
+ }
51
+ }
52
+ }
53
+
54
+ // bin/cache-store.js
5
55
  var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join(homedir(), ".terminalhire");
6
56
  var INDEX_CACHE_FILE = join(TERMINALHIRE_DIR, "index-cache.json");
7
57
  var SCHEMA_VERSION = 1;
@@ -14,7 +64,7 @@ function readCacheEntry() {
14
64
  }
15
65
  }
16
66
  function updateIndexCache(patch) {
17
- mkdirSync(TERMINALHIRE_DIR, { recursive: true });
67
+ ensureStateDir(TERMINALHIRE_DIR);
18
68
  const existing = readCacheEntry() ?? {};
19
69
  const entry = {
20
70
  ...existing,
@@ -1,50 +1,64 @@
1
- var __create = Object.create;
2
1
  var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __getProtoOf = Object.getPrototypeOf;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
3
  var __esm = (fn, res) => function __init() {
8
4
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
9
5
  };
10
- var __commonJS = (cb, mod) => function __require() {
11
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
12
- };
13
6
  var __export = (target, all) => {
14
7
  for (var name in all)
15
8
  __defProp(target, name, { get: all[name], enumerable: true });
16
9
  };
17
- var __copyProps = (to, from, except, desc) => {
18
- if (from && typeof from === "object" || typeof from === "function") {
19
- for (let key of __getOwnPropNames(from))
20
- if (!__hasOwnProp.call(to, key) && key !== except)
21
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
22
- }
23
- return to;
24
- };
25
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
26
- // If the importer is in node compatibility mode or this is not an ESM
27
- // file that has been converted to a CommonJS file using a Babel-
28
- // compatible transform (i.e. "__esModule" has not been set), then set
29
- // "default" to the CommonJS "module.exports" for node compatibility.
30
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
31
- mod
32
- ));
33
10
 
34
- // ../../node_modules/keytar/lib/keytar.js
35
- var require_keytar = __commonJS({
36
- "../../node_modules/keytar/lib/keytar.js"(exports, module) {
37
- "use strict";
38
- function disabled() {
39
- throw new Error("keytar disabled in this dev checkout (keychain popup guard) \u2014 key-file fallback expected");
11
+ // src/state-dir.ts
12
+ import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync } from "fs";
13
+ function warnStateDirOnce(dir, message) {
14
+ if (warnedDirs.has(dir)) return;
15
+ warnedDirs.add(dir);
16
+ try {
17
+ process.stderr.write(message);
18
+ } catch {
19
+ }
20
+ }
21
+ function ensureStateDir(dir) {
22
+ mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
23
+ const noFollow = constants.O_NOFOLLOW ?? 0;
24
+ let fd;
25
+ try {
26
+ fd = openSync(dir, constants.O_RDONLY | noFollow);
27
+ } catch (err) {
28
+ if (err?.code === "ELOOP") {
29
+ warnStateDirOnce(
30
+ dir,
31
+ `terminalhire: ${dir} is a symlink \u2014 leaving its permissions alone; the 0700 guarantee on the state directory is NOT enforced.
32
+ `
33
+ );
34
+ return STATE_DIR_SYMLINK;
35
+ }
36
+ return STATE_DIR_UNVERIFIED;
37
+ }
38
+ try {
39
+ const currentMode = fstatSync(fd).mode & 511;
40
+ if ((currentMode & ~STATE_DIR_MODE) !== 0) {
41
+ fchmodSync(fd, currentMode & STATE_DIR_MODE);
42
+ }
43
+ return STATE_DIR_OK;
44
+ } catch {
45
+ return STATE_DIR_UNVERIFIED;
46
+ } finally {
47
+ try {
48
+ closeSync(fd);
49
+ } catch {
40
50
  }
41
- module.exports = {
42
- getPassword: disabled,
43
- setPassword: disabled,
44
- deletePassword: disabled,
45
- findPassword: disabled,
46
- findCredentials: disabled
47
- };
51
+ }
52
+ }
53
+ var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs;
54
+ var init_state_dir = __esm({
55
+ "src/state-dir.ts"() {
56
+ "use strict";
57
+ STATE_DIR_MODE = 448;
58
+ STATE_DIR_OK = "ok";
59
+ STATE_DIR_SYMLINK = "symlink";
60
+ STATE_DIR_UNVERIFIED = "unverified";
61
+ warnedDirs = /* @__PURE__ */ new Set();
48
62
  }
49
63
  });
50
64
 
@@ -62,7 +76,15 @@ __export(claims_exports, {
62
76
  toPushedClaim: () => toPushedClaim,
63
77
  updateClaim: () => updateClaim
64
78
  });
65
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync as renameSync2, existsSync as existsSync2, rmSync as rmSync2, statSync } from "fs";
79
+ import {
80
+ readFileSync as readFileSync2,
81
+ writeFileSync as writeFileSync2,
82
+ mkdirSync as mkdirSync2,
83
+ renameSync as renameSync2,
84
+ existsSync as existsSync2,
85
+ rmSync as rmSync2,
86
+ statSync
87
+ } from "fs";
66
88
  import { randomBytes as randomBytes2 } from "crypto";
67
89
  import { join as join2 } from "path";
68
90
  import { homedir as homedir2 } from "os";
@@ -70,7 +92,7 @@ function sleepSync(ms) {
70
92
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
71
93
  }
72
94
  function withClaimsLock(fn) {
73
- mkdirSync2(TERMINALHIRE_DIR2, { recursive: true, mode: 448 });
95
+ ensureStateDir(TERMINALHIRE_DIR2);
74
96
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
75
97
  for (; ; ) {
76
98
  try {
@@ -129,11 +151,15 @@ function readClaims() {
129
151
  }
130
152
  }
131
153
  function writeClaims(claims) {
132
- mkdirSync2(TERMINALHIRE_DIR2, { recursive: true, mode: 448 });
154
+ ensureStateDir(TERMINALHIRE_DIR2);
133
155
  const tmp = `${CLAIMS_FILE}.${process.pid}.${randomBytes2(6).toString("hex")}.tmp`;
134
156
  const payload = { claims };
135
157
  try {
136
- writeFileSync2(tmp, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 384, flag: "wx" });
158
+ writeFileSync2(tmp, JSON.stringify(payload, null, 2), {
159
+ encoding: "utf8",
160
+ mode: 384,
161
+ flag: "wx"
162
+ });
137
163
  renameSync2(tmp, CLAIMS_FILE);
138
164
  } catch (err) {
139
165
  try {
@@ -207,6 +233,7 @@ var TERMINALHIRE_DIR2, CLAIMS_FILE, LOCK_DIR, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK
207
233
  var init_claims = __esm({
208
234
  "src/claims.ts"() {
209
235
  "use strict";
236
+ init_state_dir();
210
237
  TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join2(homedir2(), ".terminalhire");
211
238
  CLAIMS_FILE = join2(TERMINALHIRE_DIR2, "claims.json");
212
239
  LOCK_DIR = `${CLAIMS_FILE}.lock`;
@@ -224,8 +251,22 @@ var init_claims = __esm({
224
251
  ];
225
252
  TERMINAL_STATES = /* @__PURE__ */ new Set(["merged", "abandoned"]);
226
253
  POLL_TRANSITIONS = {
227
- merged: /* @__PURE__ */ new Set(["claimed", "working", "in-review", "ready", "submitted", "abandoned"]),
228
- abandoned: /* @__PURE__ */ new Set(["claimed", "working", "in-review", "ready", "submitted", "merged"]),
254
+ merged: /* @__PURE__ */ new Set([
255
+ "claimed",
256
+ "working",
257
+ "in-review",
258
+ "ready",
259
+ "submitted",
260
+ "abandoned"
261
+ ]),
262
+ abandoned: /* @__PURE__ */ new Set([
263
+ "claimed",
264
+ "working",
265
+ "in-review",
266
+ "ready",
267
+ "submitted",
268
+ "merged"
269
+ ]),
229
270
  submitted: /* @__PURE__ */ new Set(["claimed", "working", "in-review", "ready"])
230
271
  };
231
272
  }
@@ -233,24 +274,14 @@ var init_claims = __esm({
233
274
 
234
275
  // bin/claim-push-bg.js
235
276
  import { createHash } from "crypto";
236
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync3, rmSync as rmSync3 } from "fs";
277
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync as rmSync3 } from "fs";
237
278
  import { join as join3 } from "path";
238
279
  import { homedir as homedir3 } from "os";
239
280
 
240
281
  // src/github-auth.ts
241
- import {
242
- createCipheriv,
243
- createDecipheriv,
244
- randomBytes
245
- } from "crypto";
246
- import {
247
- readFileSync,
248
- writeFileSync,
249
- mkdirSync,
250
- existsSync,
251
- rmSync,
252
- renameSync
253
- } from "fs";
282
+ init_state_dir();
283
+ import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
284
+ import { readFileSync, writeFileSync, existsSync, rmSync, renameSync } from "fs";
254
285
  import { join } from "path";
255
286
  import { homedir } from "os";
256
287
  var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join(homedir(), ".terminalhire");
@@ -259,22 +290,8 @@ var KEY_FILE = join(TERMINALHIRE_DIR, "key");
259
290
  var ALGO = "aes-256-gcm";
260
291
  var KEY_BYTES = 32;
261
292
  var IV_BYTES = 12;
262
- function skipKeychain() {
263
- return process.env.TERMINALHIRE_NO_KEYCHAIN !== void 0 || process.env.CI !== void 0 || process.env.VITEST !== void 0 || process.env.NODE_ENV === "test";
264
- }
265
293
  async function loadKey() {
266
- if (!skipKeychain()) {
267
- try {
268
- const kt = await Promise.resolve().then(() => __toESM(require_keytar(), 1));
269
- const stored = await kt.getPassword("terminalhire", "profile-key");
270
- if (stored) return Buffer.from(stored, "hex");
271
- const key2 = randomBytes(KEY_BYTES);
272
- await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
273
- return key2;
274
- } catch {
275
- }
276
- }
277
- mkdirSync(TERMINALHIRE_DIR, { recursive: true, mode: 448 });
294
+ ensureStateDir(TERMINALHIRE_DIR);
278
295
  if (existsSync(KEY_FILE)) {
279
296
  return Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "hex");
280
297
  }
@@ -300,6 +317,7 @@ function decrypt(blob, key) {
300
317
  }
301
318
 
302
319
  // bin/claim-push-bg.js
320
+ init_state_dir();
303
321
  var TERMINALHIRE_DIR3 = process.env.TERMINALHIRE_DIR || join3(homedir3(), ".terminalhire");
304
322
  var CLAIM_PUSH_AUTO_MARKER = join3(TERMINALHIRE_DIR3, "claim-push-auto.json");
305
323
  var CLAIM_PUSH_TOKEN_FILE = join3(TERMINALHIRE_DIR3, "claim-push-token.enc");
@@ -308,7 +326,7 @@ var CLAIM_SYNC_BASE = "https://terminalhire.com";
308
326
  var AUTO_CONSENT_VERSION = 2;
309
327
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
310
328
  async function writePushTokenEnc(rawToken) {
311
- mkdirSync3(TERMINALHIRE_DIR3, { recursive: true });
329
+ ensureStateDir(TERMINALHIRE_DIR3);
312
330
  const key = await loadKey();
313
331
  const blob = encrypt(rawToken, key);
314
332
  writeFileSync3(CLAIM_PUSH_TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
@@ -337,7 +355,7 @@ function readAutoMarker() {
337
355
  }
338
356
  }
339
357
  function writeAutoMarker(marker) {
340
- mkdirSync3(TERMINALHIRE_DIR3, { recursive: true });
358
+ ensureStateDir(TERMINALHIRE_DIR3);
341
359
  writeFileSync3(CLAIM_PUSH_AUTO_MARKER, JSON.stringify(marker, null, 2) + "\n", "utf8");
342
360
  }
343
361
  function clearAutoMarker() {
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/directory.js
4
+ import { readFileSync, writeFileSync, renameSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+
8
+ // src/state-dir.ts
9
+ import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync } from "fs";
10
+ var STATE_DIR_MODE = 448;
11
+ var STATE_DIR_OK = "ok";
12
+ var STATE_DIR_SYMLINK = "symlink";
13
+ var STATE_DIR_UNVERIFIED = "unverified";
14
+ var warnedDirs = /* @__PURE__ */ new Set();
15
+ function warnStateDirOnce(dir, message) {
16
+ if (warnedDirs.has(dir)) return;
17
+ warnedDirs.add(dir);
18
+ try {
19
+ process.stderr.write(message);
20
+ } catch {
21
+ }
22
+ }
23
+ function ensureStateDir(dir) {
24
+ mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
25
+ const noFollow = constants.O_NOFOLLOW ?? 0;
26
+ let fd;
27
+ try {
28
+ fd = openSync(dir, constants.O_RDONLY | noFollow);
29
+ } catch (err) {
30
+ if (err?.code === "ELOOP") {
31
+ warnStateDirOnce(
32
+ dir,
33
+ `terminalhire: ${dir} is a symlink \u2014 leaving its permissions alone; the 0700 guarantee on the state directory is NOT enforced.
34
+ `
35
+ );
36
+ return STATE_DIR_SYMLINK;
37
+ }
38
+ return STATE_DIR_UNVERIFIED;
39
+ }
40
+ try {
41
+ const currentMode = fstatSync(fd).mode & 511;
42
+ if ((currentMode & ~STATE_DIR_MODE) !== 0) {
43
+ fchmodSync(fd, currentMode & STATE_DIR_MODE);
44
+ }
45
+ return STATE_DIR_OK;
46
+ } catch {
47
+ return STATE_DIR_UNVERIFIED;
48
+ } finally {
49
+ try {
50
+ closeSync(fd);
51
+ } catch {
52
+ }
53
+ }
54
+ }
55
+
56
+ // bin/directory.js
57
+ var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join(homedir(), ".terminalhire");
58
+ var DIRECTORY_CACHE_FILE = join(TERMINALHIRE_DIR, "directory-cache.json");
59
+ var PROJECT_FILE = join(TERMINALHIRE_DIR, "project.json");
60
+ var INDEX_TTL_MS = 15 * 60 * 1e3;
61
+ var API_URL = process.env["TERMINALHIRE_API_URL"] ?? process.env["JPI_API_URL"] ?? "https://terminalhire.com";
62
+ function readDirectoryCache() {
63
+ try {
64
+ const entry = JSON.parse(readFileSync(DIRECTORY_CACHE_FILE, "utf8"));
65
+ if (typeof entry.ts === "number" && Number.isFinite(entry.ts) && Date.now() - entry.ts < INDEX_TTL_MS) {
66
+ return { index: entry.index, ts: entry.ts };
67
+ }
68
+ return null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function writeDirectoryCache(index) {
74
+ ensureStateDir(TERMINALHIRE_DIR);
75
+ writeFileSync(DIRECTORY_CACHE_FILE, JSON.stringify({ ts: Date.now(), index }), "utf8");
76
+ }
77
+ function readProject() {
78
+ try {
79
+ return JSON.parse(readFileSync(PROJECT_FILE, "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ function writeProject(patch) {
85
+ const existing = readProject() ?? {};
86
+ const merged = { ...existing, ...patch };
87
+ if (!merged.createdAt) merged.createdAt = (/* @__PURE__ */ new Date()).toISOString();
88
+ ensureStateDir(TERMINALHIRE_DIR);
89
+ const tmpFile = `${PROJECT_FILE}.tmp`;
90
+ writeFileSync(tmpFile, JSON.stringify(merged, null, 2), "utf8");
91
+ renameSync(tmpFile, PROJECT_FILE);
92
+ return merged;
93
+ }
94
+ function relativeTime(ts) {
95
+ const secs = Math.max(0, Math.round((Date.now() - ts) / 1e3));
96
+ if (secs < 60) return `${secs}s ago`;
97
+ const mins = Math.round(secs / 60);
98
+ return mins < 60 ? `${mins}m ago` : `${Math.round(mins / 60)}h ago`;
99
+ }
100
+ async function fetchDirectory({ quiet = false } = {}) {
101
+ const cached = readDirectoryCache();
102
+ if (cached) {
103
+ if (!quiet) console.log(`\u2713 Using cached directory (updated ${relativeTime(cached.ts)})`);
104
+ return cached.index;
105
+ }
106
+ if (!quiet) console.log(`\u21BB Refreshing builder directory from ${API_URL}/api/directory...`);
107
+ const res = await fetch(`${API_URL}/api/directory`, { signal: AbortSignal.timeout(1e4) });
108
+ if (!res.ok) throw new Error(`/api/directory returned ${res.status}`);
109
+ const index = await res.json();
110
+ writeDirectoryCache(index);
111
+ return index;
112
+ }
113
+ function reportMatched(results, fetchImpl = fetch) {
114
+ try {
115
+ const logins = [
116
+ ...new Set(
117
+ results.map((r) => r?.job?.company).filter((login) => typeof login === "string" && login.length > 0)
118
+ )
119
+ ];
120
+ if (logins.length === 0) return;
121
+ return Promise.resolve(
122
+ fetchImpl(`${API_URL}/api/directory/matched`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ matched: logins }),
126
+ signal: AbortSignal.timeout(3e3)
127
+ })
128
+ ).catch(() => {
129
+ });
130
+ } catch {
131
+ }
132
+ }
133
+ function excludeOwnCard(results, ownLogin) {
134
+ if (!Array.isArray(results)) return results;
135
+ if (typeof ownLogin !== "string" || ownLogin.length === 0) return results;
136
+ const own = ownLogin.toLowerCase();
137
+ return results.filter((r) => {
138
+ const handle = r?.job?.company;
139
+ return typeof handle !== "string" || handle.toLowerCase() !== own;
140
+ });
141
+ }
142
+ export {
143
+ excludeOwnCard,
144
+ fetchDirectory,
145
+ readDirectoryCache,
146
+ readProject,
147
+ relativeTime,
148
+ reportMatched,
149
+ writeDirectoryCache,
150
+ writeProject
151
+ };