terminalhire 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -0
- package/dist/bin/jpi-dispatch.js +2264 -0
- package/dist/bin/jpi-jobs.js +1506 -0
- package/dist/bin/jpi-learn.js +815 -0
- package/dist/bin/jpi-login.js +1603 -0
- package/dist/bin/jpi-profile.js +625 -0
- package/dist/bin/jpi.js +106 -0
- package/dist/src/github-auth.js +206 -0
- package/dist/src/profile.js +423 -0
- package/dist/src/signal.js +447 -0
- package/fixtures/github-sample.json +33 -0
- package/install.js +275 -0
- package/package.json +43 -0
package/dist/bin/jpi.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/jpi.js
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
var TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
|
|
10
|
+
var INDEX_CACHE_FILE = join(TERMINALHIRE_DIR, "index-cache.json");
|
|
11
|
+
var NUDGE_FILE = join(TERMINALHIRE_DIR, "nudged.json");
|
|
12
|
+
var LEARNED_FILE = join(TERMINALHIRE_DIR, "learned-sessions.json");
|
|
13
|
+
var INDEX_CACHE_TTL_MS = 15 * 60 * 1e3;
|
|
14
|
+
var __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
15
|
+
function readStdinSync() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync("/dev/stdin", "utf8").trim();
|
|
18
|
+
if (!raw) return {};
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function readNudged() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(NUDGE_FILE, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readLearned() {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(LEARNED_FILE, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function markLearned(sessionId) {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
41
|
+
const learned = readLearned();
|
|
42
|
+
learned[sessionId] = Date.now();
|
|
43
|
+
const cutoff = Date.now() - 864e5;
|
|
44
|
+
for (const [k, v] of Object.entries(learned)) {
|
|
45
|
+
if (typeof v === "number" && v < cutoff) delete learned[k];
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(LEARNED_FILE, JSON.stringify(learned), "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function spawnLearnDetached(cwd) {
|
|
52
|
+
try {
|
|
53
|
+
const learnScript = join(__dirname, "jpi-learn.js");
|
|
54
|
+
const child = spawn(process.execPath, [learnScript, "--cwd", cwd], {
|
|
55
|
+
detached: true,
|
|
56
|
+
stdio: "ignore"
|
|
57
|
+
});
|
|
58
|
+
child.unref();
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function markNudged(sessionId) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
65
|
+
const nudged = readNudged();
|
|
66
|
+
nudged[sessionId] = Date.now();
|
|
67
|
+
const cutoff = Date.now() - 864e5;
|
|
68
|
+
for (const [k, v] of Object.entries(nudged)) {
|
|
69
|
+
if (typeof v === "number" && v < cutoff) delete nudged[k];
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(NUDGE_FILE, JSON.stringify(nudged), "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function getCachedMatchCount() {
|
|
76
|
+
try {
|
|
77
|
+
const raw = readFileSync(INDEX_CACHE_FILE, "utf8");
|
|
78
|
+
const entry = JSON.parse(raw);
|
|
79
|
+
if (Date.now() - entry.ts > INDEX_CACHE_TTL_MS) return null;
|
|
80
|
+
return typeof entry.matchCount === "number" ? entry.matchCount : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const input = readStdinSync();
|
|
87
|
+
const sessionId = input?.session_id;
|
|
88
|
+
if (!sessionId) process.exit(0);
|
|
89
|
+
const learned = readLearned();
|
|
90
|
+
if (!learned[sessionId]) {
|
|
91
|
+
const workDir = input?.workspace?.current_dir ?? process.cwd();
|
|
92
|
+
spawnLearnDetached(workDir);
|
|
93
|
+
markLearned(sessionId);
|
|
94
|
+
}
|
|
95
|
+
const nudged = readNudged();
|
|
96
|
+
if (nudged[sessionId]) process.exit(0);
|
|
97
|
+
const matchCount = getCachedMatchCount();
|
|
98
|
+
if (matchCount === null || matchCount === 0) process.exit(0);
|
|
99
|
+
const plural = matchCount === 1 ? "role" : "roles";
|
|
100
|
+
process.stdout.write(`\u2726 ${matchCount} ${plural} match your current work \u2014 run: terminalhire jobs
|
|
101
|
+
`);
|
|
102
|
+
markNudged(sessionId);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
} catch {
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// src/github-auth.ts
|
|
2
|
+
import {
|
|
3
|
+
createCipheriv,
|
|
4
|
+
createDecipheriv,
|
|
5
|
+
randomBytes
|
|
6
|
+
} from "crypto";
|
|
7
|
+
import {
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
rmSync
|
|
13
|
+
} from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
var TERMINALHIRE_DIR = join(homedir(), ".terminalhire");
|
|
17
|
+
var TOKEN_FILE = join(TERMINALHIRE_DIR, "github-token.enc");
|
|
18
|
+
var KEY_FILE = join(TERMINALHIRE_DIR, "key");
|
|
19
|
+
var ALGO = "aes-256-gcm";
|
|
20
|
+
var KEY_BYTES = 32;
|
|
21
|
+
var IV_BYTES = 12;
|
|
22
|
+
var GITHUB_SCOPE = "read:user";
|
|
23
|
+
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
24
|
+
var ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
25
|
+
var DEV_PLACEHOLDER_CLIENT_ID = "Ov23lignE2ZSBe0J3a6B";
|
|
26
|
+
async function loadKey() {
|
|
27
|
+
try {
|
|
28
|
+
const kt = await import("keytar");
|
|
29
|
+
const stored = await kt.getPassword("terminalhire", "profile-key");
|
|
30
|
+
if (stored) return Buffer.from(stored, "hex");
|
|
31
|
+
const key2 = randomBytes(KEY_BYTES);
|
|
32
|
+
await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
|
|
33
|
+
return key2;
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
37
|
+
if (existsSync(KEY_FILE)) {
|
|
38
|
+
return Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "hex");
|
|
39
|
+
}
|
|
40
|
+
const key = randomBytes(KEY_BYTES);
|
|
41
|
+
writeFileSync(KEY_FILE, key.toString("hex"), { mode: 384, encoding: "utf8" });
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
function encrypt(plaintext, key) {
|
|
45
|
+
const iv = randomBytes(IV_BYTES);
|
|
46
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
47
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
48
|
+
const tag = cipher.getAuthTag();
|
|
49
|
+
return { iv: iv.toString("hex"), tag: tag.toString("hex"), ciphertext: ct.toString("hex") };
|
|
50
|
+
}
|
|
51
|
+
function decrypt(blob, key) {
|
|
52
|
+
const decipher = createDecipheriv(ALGO, key, Buffer.from(blob.iv, "hex"));
|
|
53
|
+
decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
|
|
54
|
+
const plain = Buffer.concat([
|
|
55
|
+
decipher.update(Buffer.from(blob.ciphertext, "hex")),
|
|
56
|
+
decipher.final()
|
|
57
|
+
]);
|
|
58
|
+
return plain.toString("utf8");
|
|
59
|
+
}
|
|
60
|
+
async function readGitHubToken() {
|
|
61
|
+
if (!existsSync(TOKEN_FILE)) return void 0;
|
|
62
|
+
try {
|
|
63
|
+
const key = await loadKey();
|
|
64
|
+
const raw = readFileSync(TOKEN_FILE, "utf8");
|
|
65
|
+
const blob = JSON.parse(raw);
|
|
66
|
+
return decrypt(blob, key);
|
|
67
|
+
} catch {
|
|
68
|
+
return void 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function writeGitHubToken(token) {
|
|
72
|
+
mkdirSync(TERMINALHIRE_DIR, { recursive: true });
|
|
73
|
+
const key = await loadKey();
|
|
74
|
+
const blob = encrypt(token, key);
|
|
75
|
+
writeFileSync(TOKEN_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
|
|
76
|
+
}
|
|
77
|
+
async function deleteGitHubToken() {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(TOKEN_FILE);
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function hasGitHubToken() {
|
|
84
|
+
return existsSync(TOKEN_FILE);
|
|
85
|
+
}
|
|
86
|
+
var MOCK_TOKEN = "mock-github-token-jpi-dev";
|
|
87
|
+
var MOCK_LOGIN = "janedev";
|
|
88
|
+
async function runDeviceFlow() {
|
|
89
|
+
if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") {
|
|
90
|
+
console.log("\n[mock] GitHub OAuth skipped (JPI_GITHUB_MOCK=1)");
|
|
91
|
+
console.log(`[mock] Using fixture profile: ${MOCK_LOGIN}`);
|
|
92
|
+
await writeGitHubToken(MOCK_TOKEN);
|
|
93
|
+
return MOCK_LOGIN;
|
|
94
|
+
}
|
|
95
|
+
const clientId = process.env["GITHUB_DEVICE_CLIENT_ID"] ?? process.env["GITHUB_CLIENT_ID"] ?? DEV_PLACEHOLDER_CLIENT_ID;
|
|
96
|
+
if (clientId === "Iv1.PLACEHOLDER_REGISTER_YOUR_APP") {
|
|
97
|
+
console.warn("\nWarning: GITHUB_CLIENT_ID env var looks like a placeholder.");
|
|
98
|
+
console.warn("Remove it to use the baked-in client ID, or set it to your own OAuth App Client ID.\n");
|
|
99
|
+
}
|
|
100
|
+
const deviceRes = await fetch(DEVICE_CODE_URL, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
105
|
+
},
|
|
106
|
+
body: new URLSearchParams({ client_id: clientId, scope: GITHUB_SCOPE }).toString(),
|
|
107
|
+
signal: AbortSignal.timeout(15e3)
|
|
108
|
+
});
|
|
109
|
+
if (!deviceRes.ok) {
|
|
110
|
+
throw new Error(`GitHub device code request failed: HTTP ${deviceRes.status}`);
|
|
111
|
+
}
|
|
112
|
+
const deviceData = await deviceRes.json();
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(" GitHub sign-in (device flow)");
|
|
115
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
116
|
+
console.log(` 1. Open: ${deviceData.verification_uri}`);
|
|
117
|
+
console.log(` 2. Enter code: ${deviceData.user_code}`);
|
|
118
|
+
console.log(' 3. Authorize "jpi" (scope: read:user \u2014 public data only)');
|
|
119
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
120
|
+
console.log(" Waiting for authorization...");
|
|
121
|
+
console.log("");
|
|
122
|
+
let intervalSecs = deviceData.interval ?? 5;
|
|
123
|
+
const expiresAt = Date.now() + (deviceData.expires_in ?? 900) * 1e3;
|
|
124
|
+
const clientSecret = process.env["GITHUB_CLIENT_SECRET"];
|
|
125
|
+
while (Date.now() < expiresAt) {
|
|
126
|
+
await sleep(intervalSecs * 1e3);
|
|
127
|
+
const body = new URLSearchParams({
|
|
128
|
+
client_id: clientId,
|
|
129
|
+
device_code: deviceData.device_code,
|
|
130
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
131
|
+
});
|
|
132
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
133
|
+
const tokenRes = await fetch(ACCESS_TOKEN_URL, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: {
|
|
136
|
+
Accept: "application/json",
|
|
137
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
138
|
+
},
|
|
139
|
+
body: body.toString(),
|
|
140
|
+
signal: AbortSignal.timeout(15e3)
|
|
141
|
+
});
|
|
142
|
+
if (!tokenRes.ok) {
|
|
143
|
+
throw new Error(`GitHub token poll failed: HTTP ${tokenRes.status}`);
|
|
144
|
+
}
|
|
145
|
+
const tokenData = await tokenRes.json();
|
|
146
|
+
if (tokenData.access_token) {
|
|
147
|
+
await writeGitHubToken(tokenData.access_token);
|
|
148
|
+
const login = await fetchAuthedLogin(tokenData.access_token);
|
|
149
|
+
console.log(` Authorized as: ${login}`);
|
|
150
|
+
return login;
|
|
151
|
+
}
|
|
152
|
+
if (tokenData.error === "authorization_pending") {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (tokenData.error === "slow_down") {
|
|
156
|
+
intervalSecs = (tokenData.interval ?? intervalSecs) + 5;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (tokenData.error === "expired_token") {
|
|
160
|
+
throw new Error("GitHub device code expired. Please run `terminalhire login` again.");
|
|
161
|
+
}
|
|
162
|
+
if (tokenData.error === "access_denied") {
|
|
163
|
+
throw new Error("GitHub authorization was denied by the user.");
|
|
164
|
+
}
|
|
165
|
+
throw new Error(
|
|
166
|
+
`GitHub device flow error: ${tokenData.error ?? "unknown"} \u2014 ${tokenData.error_description ?? ""}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
throw new Error("GitHub device code expired before authorization. Please run `terminalhire login` again.");
|
|
170
|
+
}
|
|
171
|
+
async function fetchAuthedLogin(token) {
|
|
172
|
+
if (token === MOCK_TOKEN) return MOCK_LOGIN;
|
|
173
|
+
const res = await fetch("https://api.github.com/user", {
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${token}`,
|
|
176
|
+
Accept: "application/vnd.github+json",
|
|
177
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
178
|
+
},
|
|
179
|
+
signal: AbortSignal.timeout(1e4)
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) throw new Error(`GitHub /user: HTTP ${res.status}`);
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
return data.login;
|
|
184
|
+
}
|
|
185
|
+
async function resolveStoredLogin() {
|
|
186
|
+
if (process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["TERMINALHIRE_GITHUB_MOCK"] === "1" || process.env["JPI_GITHUB_MOCK"] === "1") return MOCK_LOGIN;
|
|
187
|
+
const token = await readGitHubToken();
|
|
188
|
+
if (!token) return void 0;
|
|
189
|
+
try {
|
|
190
|
+
return await fetchAuthedLogin(token);
|
|
191
|
+
} catch {
|
|
192
|
+
return void 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function sleep(ms) {
|
|
196
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
GITHUB_SCOPE,
|
|
200
|
+
deleteGitHubToken,
|
|
201
|
+
hasGitHubToken,
|
|
202
|
+
readGitHubToken,
|
|
203
|
+
resolveStoredLogin,
|
|
204
|
+
runDeviceFlow,
|
|
205
|
+
writeGitHubToken
|
|
206
|
+
};
|