@ulpi/cli 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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/auth-PN7TMQHV-2W4ICG64.js +15 -0
- package/dist/chunk-247GVVKK.js +2259 -0
- package/dist/chunk-2CLNOKPA.js +793 -0
- package/dist/chunk-2HEE5OKX.js +79 -0
- package/dist/chunk-2MZER6ND.js +415 -0
- package/dist/chunk-3SBPZRB5.js +772 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-6JCMYYBT.js +1546 -0
- package/dist/chunk-6OCEY7JY.js +422 -0
- package/dist/chunk-74WVVWJ4.js +375 -0
- package/dist/chunk-7AL4DOEJ.js +131 -0
- package/dist/chunk-7LXY5UVC.js +330 -0
- package/dist/chunk-DBMUNBNB.js +3048 -0
- package/dist/chunk-JWUUVXIV.js +13694 -0
- package/dist/chunk-KIKPIH6N.js +4048 -0
- package/dist/chunk-KLEASXUR.js +70 -0
- package/dist/chunk-MIAQVCFW.js +39 -0
- package/dist/chunk-NNUWU6CV.js +1610 -0
- package/dist/chunk-PKD4ASEM.js +115 -0
- package/dist/chunk-Q4HIY43N.js +4230 -0
- package/dist/chunk-QJ5GSMEC.js +146 -0
- package/dist/chunk-SIAQVRKG.js +2163 -0
- package/dist/chunk-SPOI23SB.js +197 -0
- package/dist/chunk-YM2HV4IA.js +505 -0
- package/dist/codemap-RRJIDBQ5.js +636 -0
- package/dist/config-EGAXXCGL.js +127 -0
- package/dist/dist-6G7JC2RA.js +90 -0
- package/dist/dist-7LHZ65GC.js +418 -0
- package/dist/dist-LZKZFPVX.js +140 -0
- package/dist/dist-R5F4MX3I.js +107 -0
- package/dist/dist-R5ZJ4LX5.js +56 -0
- package/dist/dist-RJGCUS3L.js +87 -0
- package/dist/dist-RKOGLK7R.js +151 -0
- package/dist/dist-W7K4WPAF.js +597 -0
- package/dist/export-import-4A5MWLIA.js +53 -0
- package/dist/history-ATTUKOHO.js +934 -0
- package/dist/index.js +2120 -0
- package/dist/init-AY5C2ZAS.js +393 -0
- package/dist/launchd-LF2QMSKZ.js +148 -0
- package/dist/log-TVTUXAYD.js +75 -0
- package/dist/mcp-installer-NQCGKQ23.js +124 -0
- package/dist/memory-J3G24QHS.js +406 -0
- package/dist/ollama-3XCUZMZT-FYKHW4TZ.js +7 -0
- package/dist/openai-E7G2YAHU-UYY4ZWON.js +8 -0
- package/dist/projects-ATHDD3D6.js +271 -0
- package/dist/review-ADUPV3PN.js +152 -0
- package/dist/rules-E427DKYJ.js +134 -0
- package/dist/server-MOYPE4SM-N7SE2AN7.js +18 -0
- package/dist/server-X5P6WH2M-7K2RY34N.js +11 -0
- package/dist/skills/ulpi-generate-guardian/SKILL.md +511 -0
- package/dist/skills/ulpi-generate-guardian/references/framework-rules.md +692 -0
- package/dist/skills/ulpi-generate-guardian/references/language-rules.md +596 -0
- package/dist/skills-CX73O3IV.js +76 -0
- package/dist/status-4DFHDJMN.js +66 -0
- package/dist/templates/biome.yml +24 -0
- package/dist/templates/conventional-commits.yml +18 -0
- package/dist/templates/django.yml +30 -0
- package/dist/templates/docker.yml +30 -0
- package/dist/templates/eslint.yml +13 -0
- package/dist/templates/express.yml +20 -0
- package/dist/templates/fastapi.yml +23 -0
- package/dist/templates/git-flow.yml +26 -0
- package/dist/templates/github-flow.yml +27 -0
- package/dist/templates/go.yml +33 -0
- package/dist/templates/jest.yml +24 -0
- package/dist/templates/laravel.yml +30 -0
- package/dist/templates/monorepo.yml +26 -0
- package/dist/templates/nestjs.yml +21 -0
- package/dist/templates/nextjs.yml +31 -0
- package/dist/templates/nodejs.yml +33 -0
- package/dist/templates/npm.yml +15 -0
- package/dist/templates/php.yml +25 -0
- package/dist/templates/pnpm.yml +15 -0
- package/dist/templates/prettier.yml +23 -0
- package/dist/templates/prisma.yml +21 -0
- package/dist/templates/python.yml +33 -0
- package/dist/templates/quality-of-life.yml +111 -0
- package/dist/templates/ruby.yml +25 -0
- package/dist/templates/rust.yml +34 -0
- package/dist/templates/typescript.yml +14 -0
- package/dist/templates/vitest.yml +24 -0
- package/dist/templates/yarn.yml +15 -0
- package/dist/templates-U7T6MARD.js +156 -0
- package/dist/ui-L7UAWXDY.js +167 -0
- package/dist/ui.html +698 -0
- package/dist/ulpi-RMMCUAGP-JCJ273T6.js +161 -0
- package/dist/uninstall-6SW35IK4.js +25 -0
- package/dist/update-M2B4RLGH.js +61 -0
- package/dist/version-checker-ANCS3IHR.js +10 -0
- package/package.json +92 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveApiKey
|
|
3
|
+
} from "./chunk-7LXY5UVC.js";
|
|
4
|
+
|
|
5
|
+
// ../../packages/codemap-engine/dist/chunk-ZW4DV263.js
|
|
6
|
+
var OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
|
|
7
|
+
var MAX_RETRIES = 3;
|
|
8
|
+
var RETRY_BASE_MS = 1e3;
|
|
9
|
+
var OpenAIEmbedder = class {
|
|
10
|
+
provider = "openai";
|
|
11
|
+
model;
|
|
12
|
+
dimensions;
|
|
13
|
+
apiKey;
|
|
14
|
+
constructor(model = "text-embedding-3-small", dimensions = 1536) {
|
|
15
|
+
this.model = model;
|
|
16
|
+
this.dimensions = dimensions;
|
|
17
|
+
const key = resolveApiKey("openai");
|
|
18
|
+
if (!key) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"OpenAI API key not found. Set it with:\n ulpi config set openai-key <your-key>\nOr switch to Ollama (local, no key needed):\n ulpi codemap config embedding.provider ollama"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
this.apiKey = key;
|
|
24
|
+
}
|
|
25
|
+
async embed(texts) {
|
|
26
|
+
if (texts.length === 0) return [];
|
|
27
|
+
const body = {
|
|
28
|
+
input: texts,
|
|
29
|
+
model: this.model,
|
|
30
|
+
dimensions: this.dimensions
|
|
31
|
+
};
|
|
32
|
+
let lastError = null;
|
|
33
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(OPENAI_EMBEDDINGS_URL, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify(body)
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const errorText = await response.text();
|
|
45
|
+
if (response.status === 401 || response.status === 403) {
|
|
46
|
+
throw new Error(`OpenAI API authentication error (${response.status}): check your OPENAI_API_KEY`);
|
|
47
|
+
}
|
|
48
|
+
if (response.status === 429 || response.status >= 500) {
|
|
49
|
+
lastError = new Error(`OpenAI API error (${response.status}): ${errorText.slice(0, 200)}`);
|
|
50
|
+
const delay = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
51
|
+
await sleep(delay);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`OpenAI API error (${response.status}): ${errorText.slice(0, 200)}`);
|
|
55
|
+
}
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
const sorted = data.data.sort((a, b) => a.index - b.index);
|
|
58
|
+
return sorted.map((item) => item.embedding);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof Error && (err.message.includes("authentication") || err.message.includes("Unknown"))) {
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
64
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
65
|
+
const delay = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
66
|
+
await sleep(delay);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastError ?? new Error("OpenAI embedding failed after retries");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
function sleep(ms) {
|
|
74
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
OpenAIEmbedder
|
|
79
|
+
};
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractSections,
|
|
3
|
+
extractTitle,
|
|
4
|
+
generateSlug,
|
|
5
|
+
getNextVersionNumber,
|
|
6
|
+
parseMarkdownToBlocks,
|
|
7
|
+
savePlan,
|
|
8
|
+
scorePlanQuality
|
|
9
|
+
} from "./chunk-3SBPZRB5.js";
|
|
10
|
+
import {
|
|
11
|
+
API_LOCK_FILE,
|
|
12
|
+
getApiHost,
|
|
13
|
+
getApiPort
|
|
14
|
+
} from "./chunk-7LXY5UVC.js";
|
|
15
|
+
|
|
16
|
+
// ../../packages/review-runtime/dist/index.js
|
|
17
|
+
import { basename } from "path";
|
|
18
|
+
import { randomUUID } from "crypto";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
var ReviewHub = class _ReviewHub {
|
|
21
|
+
sessions = /* @__PURE__ */ new Map();
|
|
22
|
+
awaitWaiters = /* @__PURE__ */ new Map();
|
|
23
|
+
decidedEvictionMs = 5 * 60 * 1e3;
|
|
24
|
+
longPollTimeoutMs = 30 * 1e3;
|
|
25
|
+
/** Maximum number of concurrent sessions to prevent unbounded memory growth. */
|
|
26
|
+
static MAX_SESSIONS = 100;
|
|
27
|
+
/** Sessions older than this TTL are automatically evicted. */
|
|
28
|
+
static SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
29
|
+
// 30 minutes
|
|
30
|
+
/**
|
|
31
|
+
* Register a new review session.
|
|
32
|
+
*/
|
|
33
|
+
async register(payload) {
|
|
34
|
+
this.cleanupExpired();
|
|
35
|
+
if (this.sessions.size >= _ReviewHub.MAX_SESSIONS) {
|
|
36
|
+
const oldest = [...this.sessions.entries()].sort(([, a], [, b]) => a.registeredAt - b.registeredAt)[0];
|
|
37
|
+
if (oldest) {
|
|
38
|
+
this.sessions.delete(oldest[0]);
|
|
39
|
+
this.awaitWaiters.delete(oldest[0]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const sessionId = randomUUID();
|
|
43
|
+
const token = randomUUID();
|
|
44
|
+
const projectSlug = basename(payload.projectPath);
|
|
45
|
+
let resolveDecision;
|
|
46
|
+
const decisionPromise = new Promise((resolve) => {
|
|
47
|
+
resolveDecision = resolve;
|
|
48
|
+
});
|
|
49
|
+
const session = {
|
|
50
|
+
id: sessionId,
|
|
51
|
+
type: payload.type,
|
|
52
|
+
projectPath: payload.projectPath,
|
|
53
|
+
projectSlug,
|
|
54
|
+
title: "",
|
|
55
|
+
status: "pending",
|
|
56
|
+
registeredAt: Date.now(),
|
|
57
|
+
resolveDecision,
|
|
58
|
+
token
|
|
59
|
+
};
|
|
60
|
+
if (payload.type === "plan" && payload.plan) {
|
|
61
|
+
const blocks = parseMarkdownToBlocks(payload.plan);
|
|
62
|
+
const sections = extractSections(blocks);
|
|
63
|
+
const quality = scorePlanQuality(blocks, sections);
|
|
64
|
+
const title = extractTitle(blocks);
|
|
65
|
+
const slug = generateSlug(title);
|
|
66
|
+
const versionNumber = await getNextVersionNumber(slug, payload.projectPath);
|
|
67
|
+
const version = {
|
|
68
|
+
id: randomUUID(),
|
|
69
|
+
versionNumber,
|
|
70
|
+
markdown: payload.plan,
|
|
71
|
+
annotations: [],
|
|
72
|
+
inlineEdits: [],
|
|
73
|
+
instructions: [],
|
|
74
|
+
priorities: [],
|
|
75
|
+
risks: [],
|
|
76
|
+
createdAt: Date.now()
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
await savePlan(payload.plan, version, slug, payload.projectPath);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`[review-hub] Failed to persist plan: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
session.plan = payload.plan;
|
|
84
|
+
session.blocks = blocks;
|
|
85
|
+
session.sections = sections;
|
|
86
|
+
session.quality = quality;
|
|
87
|
+
session.version = version;
|
|
88
|
+
session.slug = slug;
|
|
89
|
+
session.title = title;
|
|
90
|
+
} else if (payload.type === "code") {
|
|
91
|
+
session.diff = payload.diff;
|
|
92
|
+
session.commitMessage = payload.commitMessage;
|
|
93
|
+
session.title = payload.commitMessage || "Code Review";
|
|
94
|
+
}
|
|
95
|
+
this.sessions.set(sessionId, session);
|
|
96
|
+
decisionPromise.then((decision) => {
|
|
97
|
+
const waiters = this.awaitWaiters.get(sessionId) || [];
|
|
98
|
+
for (const resolve of waiters) {
|
|
99
|
+
resolve(decision);
|
|
100
|
+
}
|
|
101
|
+
this.awaitWaiters.delete(sessionId);
|
|
102
|
+
});
|
|
103
|
+
return { session: this.toPublicSession(session), sessionId, token };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get a session by ID.
|
|
107
|
+
*/
|
|
108
|
+
getSession(id) {
|
|
109
|
+
const session = this.sessions.get(id);
|
|
110
|
+
return session ? this.toPublicSession(session) : null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* List all sessions, optionally filtered by type/status.
|
|
114
|
+
*/
|
|
115
|
+
listSessions(opts) {
|
|
116
|
+
let sessions = [...this.sessions.values()];
|
|
117
|
+
if (opts?.type) sessions = sessions.filter((s) => s.type === opts.type);
|
|
118
|
+
if (opts?.status) sessions = sessions.filter((s) => s.status === opts.status);
|
|
119
|
+
sessions.sort((a, b) => a.registeredAt - b.registeredAt);
|
|
120
|
+
return sessions.map((s) => this.toPublicSession(s));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Verify a session token.
|
|
124
|
+
*/
|
|
125
|
+
verifyToken(sessionId, token) {
|
|
126
|
+
const session = this.sessions.get(sessionId);
|
|
127
|
+
if (!session) return false;
|
|
128
|
+
return session.token === token;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Submit a decision for a session.
|
|
132
|
+
* When token is provided, it must match the session token.
|
|
133
|
+
*/
|
|
134
|
+
async submitDecision(sessionId, decision, token) {
|
|
135
|
+
const session = this.sessions.get(sessionId);
|
|
136
|
+
if (!session) return false;
|
|
137
|
+
if (token !== void 0 && session.token !== token) return false;
|
|
138
|
+
if (!isValidDecision(decision)) return false;
|
|
139
|
+
try {
|
|
140
|
+
if (session.type === "plan" && session.version && session.slug) {
|
|
141
|
+
const planDecision = decision;
|
|
142
|
+
const reviewedVersion = {
|
|
143
|
+
...session.version,
|
|
144
|
+
annotations: planDecision.annotations || [],
|
|
145
|
+
inlineEdits: planDecision.inlineEdits || [],
|
|
146
|
+
instructions: planDecision.instructions || [],
|
|
147
|
+
priorities: planDecision.priorities || [],
|
|
148
|
+
risks: planDecision.risks || [],
|
|
149
|
+
decision: {
|
|
150
|
+
behavior: planDecision.behavior,
|
|
151
|
+
message: planDecision.message,
|
|
152
|
+
decidedAt: Date.now()
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
await savePlan(session.plan, reviewedVersion, session.slug, session.projectPath);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
session.status = "decided";
|
|
160
|
+
session.decidedAt = Date.now();
|
|
161
|
+
session.decisionPayload = decision;
|
|
162
|
+
session.resolveDecision(decision);
|
|
163
|
+
const waiters = this.awaitWaiters.get(sessionId);
|
|
164
|
+
if (waiters && waiters.length > 0) {
|
|
165
|
+
for (const waiter of waiters) {
|
|
166
|
+
waiter(decision);
|
|
167
|
+
}
|
|
168
|
+
this.awaitWaiters.delete(sessionId);
|
|
169
|
+
}
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
this.sessions.delete(sessionId);
|
|
172
|
+
this.awaitWaiters.delete(sessionId);
|
|
173
|
+
}, this.decidedEvictionMs);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Long-poll waiting for a decision on a session.
|
|
178
|
+
* Returns the decision or null on timeout.
|
|
179
|
+
*/
|
|
180
|
+
awaitDecision(sessionId, timeoutMs) {
|
|
181
|
+
const session = this.sessions.get(sessionId);
|
|
182
|
+
if (!session) return Promise.resolve(null);
|
|
183
|
+
if (session.status === "decided" && session.decisionPayload) {
|
|
184
|
+
return Promise.resolve(session.decisionPayload);
|
|
185
|
+
}
|
|
186
|
+
const timeout = timeoutMs ?? this.longPollTimeoutMs;
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const waiters = this.awaitWaiters.get(sessionId) || [];
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
const idx = waiters.indexOf(onDecision);
|
|
191
|
+
if (idx !== -1) waiters.splice(idx, 1);
|
|
192
|
+
resolve(null);
|
|
193
|
+
}, timeout);
|
|
194
|
+
function onDecision(decision) {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
resolve(decision);
|
|
197
|
+
}
|
|
198
|
+
waiters.push(onDecision);
|
|
199
|
+
this.awaitWaiters.set(sessionId, waiters);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Save annotations/version for a plan session.
|
|
204
|
+
*/
|
|
205
|
+
async saveVersion(sessionId, data) {
|
|
206
|
+
const session = this.sessions.get(sessionId);
|
|
207
|
+
if (!session || session.type !== "plan" || !session.version || !session.slug) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const updatedVersion = {
|
|
211
|
+
...session.version,
|
|
212
|
+
annotations: data.annotations || [],
|
|
213
|
+
inlineEdits: data.inlineEdits || [],
|
|
214
|
+
instructions: data.instructions || [],
|
|
215
|
+
priorities: data.priorities || [],
|
|
216
|
+
risks: data.risks || []
|
|
217
|
+
};
|
|
218
|
+
await savePlan(session.plan, updatedVersion, session.slug, session.projectPath);
|
|
219
|
+
Object.assign(session.version, updatedVersion);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get the session token (for loopback clients that need to pass it to the UI).
|
|
224
|
+
*/
|
|
225
|
+
getSessionToken(id) {
|
|
226
|
+
const session = this.sessions.get(id);
|
|
227
|
+
return session?.token ?? null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get the raw decision payload for a session (used by await endpoint).
|
|
231
|
+
*/
|
|
232
|
+
getDecisionPayload(id) {
|
|
233
|
+
const session = this.sessions.get(id);
|
|
234
|
+
return session?.decisionPayload ?? null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Cleanup orphaned sessions (pending for over 1 hour).
|
|
238
|
+
*/
|
|
239
|
+
cleanup() {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
for (const [id, session] of this.sessions) {
|
|
242
|
+
if (session.status === "pending" && now - session.registeredAt > 60 * 60 * 1e3) {
|
|
243
|
+
this.sessions.delete(id);
|
|
244
|
+
this.awaitWaiters.delete(id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Remove sessions that have exceeded the TTL, regardless of status.
|
|
250
|
+
*/
|
|
251
|
+
cleanupExpired() {
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
for (const [id, session] of this.sessions) {
|
|
254
|
+
if (now - session.registeredAt > _ReviewHub.SESSION_TTL_MS) {
|
|
255
|
+
this.sessions.delete(id);
|
|
256
|
+
this.awaitWaiters.delete(id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
toPublicSession(session) {
|
|
261
|
+
const { resolveDecision, decisionPayload, token: _token, ...pub } = session;
|
|
262
|
+
if (decisionPayload && !pub.decision) {
|
|
263
|
+
if ("behavior" in decisionPayload) {
|
|
264
|
+
pub.decision = {
|
|
265
|
+
behavior: decisionPayload.behavior,
|
|
266
|
+
message: decisionPayload.message,
|
|
267
|
+
decidedAt: session.decidedAt ?? Date.now()
|
|
268
|
+
};
|
|
269
|
+
} else if ("approved" in decisionPayload) {
|
|
270
|
+
pub.decision = {
|
|
271
|
+
behavior: decisionPayload.approved ? "allow" : "deny",
|
|
272
|
+
message: decisionPayload.message ?? decisionPayload.feedback,
|
|
273
|
+
decidedAt: session.decidedAt ?? Date.now()
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return pub;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
function isValidDecision(d) {
|
|
281
|
+
if (!d || typeof d !== "object") return false;
|
|
282
|
+
if ("behavior" in d) {
|
|
283
|
+
return d.behavior === "allow" || d.behavior === "deny";
|
|
284
|
+
}
|
|
285
|
+
if ("approved" in d) {
|
|
286
|
+
return typeof d.approved === "boolean";
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
var DISCOVERY_TIMEOUT_MS = 2e3;
|
|
291
|
+
var MAX_PORTS_TO_PROBE = 5;
|
|
292
|
+
function isProcessAlive(pid) {
|
|
293
|
+
try {
|
|
294
|
+
process.kill(pid, 0);
|
|
295
|
+
return true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function readLockFile() {
|
|
301
|
+
try {
|
|
302
|
+
const raw = fs.readFileSync(API_LOCK_FILE, "utf-8");
|
|
303
|
+
const data = JSON.parse(raw);
|
|
304
|
+
if (typeof data === "object" && data !== null && typeof data.port === "number" && typeof data.pid === "number") {
|
|
305
|
+
const { port, pid, secret } = data;
|
|
306
|
+
if (isProcessAlive(pid)) {
|
|
307
|
+
return { port, secret };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
fs.unlinkSync(API_LOCK_FILE);
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
async function healthCheck(host, port) {
|
|
319
|
+
try {
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
|
|
322
|
+
const res = await fetch(`http://${host}:${port}/api/health`, {
|
|
323
|
+
signal: controller.signal
|
|
324
|
+
});
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
return res.ok;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function discoverUlpiServer() {
|
|
332
|
+
const host = getApiHost();
|
|
333
|
+
const lockData = readLockFile();
|
|
334
|
+
if (lockData !== null) {
|
|
335
|
+
if (await healthCheck(host, lockData.port)) {
|
|
336
|
+
return lockData;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const configPort = getApiPort();
|
|
340
|
+
const ports = [configPort, ...configPort !== 9800 ? [9800] : []].slice(0, MAX_PORTS_TO_PROBE);
|
|
341
|
+
for (const port of ports) {
|
|
342
|
+
if (port === lockData?.port) continue;
|
|
343
|
+
if (await healthCheck(host, port)) {
|
|
344
|
+
return { port };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
function buildHeaders(secret) {
|
|
350
|
+
const headers = { "Content-Type": "application/json" };
|
|
351
|
+
if (secret) headers["X-Ulpi-Secret"] = secret;
|
|
352
|
+
return headers;
|
|
353
|
+
}
|
|
354
|
+
async function registerWithServer(port, payload, secret) {
|
|
355
|
+
try {
|
|
356
|
+
const host = getApiHost();
|
|
357
|
+
const res = await fetch(`http://${host}:${port}/api/review/hub/register`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: buildHeaders(secret),
|
|
360
|
+
body: JSON.stringify(payload)
|
|
361
|
+
});
|
|
362
|
+
if (!res.ok) return null;
|
|
363
|
+
const data = await res.json();
|
|
364
|
+
return data;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function waitForServerDecision(port, sessionId, timeoutMs = 3e4, maxWaitMs, token, secret) {
|
|
370
|
+
const deadline = Date.now() + (maxWaitMs && maxWaitMs > 0 ? maxWaitMs : 10 * 60 * 1e3);
|
|
371
|
+
const host = getApiHost();
|
|
372
|
+
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
|
|
373
|
+
const secretHeaders = secret ? { "X-Ulpi-Secret": secret } : {};
|
|
374
|
+
while (Date.now() < deadline) {
|
|
375
|
+
try {
|
|
376
|
+
const controller = new AbortController();
|
|
377
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs + 5e3);
|
|
378
|
+
const res = await fetch(
|
|
379
|
+
`http://${host}:${port}/api/review/hub/session/${sessionId}/await${tokenParam}`,
|
|
380
|
+
{ signal: controller.signal, headers: secretHeaders }
|
|
381
|
+
);
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
if (res.status === 204) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (res.ok) {
|
|
387
|
+
const data = await res.json();
|
|
388
|
+
return data.decision;
|
|
389
|
+
}
|
|
390
|
+
if (res.status === 410) {
|
|
391
|
+
try {
|
|
392
|
+
const data = await res.json();
|
|
393
|
+
if (data.decision) return data.decision;
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (res.status >= 500) {
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
} catch {
|
|
404
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export {
|
|
411
|
+
ReviewHub,
|
|
412
|
+
discoverUlpiServer,
|
|
413
|
+
registerWithServer,
|
|
414
|
+
waitForServerDecision
|
|
415
|
+
};
|