@allocator-one/harness-cli 1.16.0 → 1.17.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 +115 -0
- package/dist/{chunk-FQWV5MIC.js → chunk-5THYIKLW.js} +26 -4
- package/dist/chunk-A4WZAC7L.js +563 -0
- package/dist/chunk-BHBEIT5I.js +1348 -0
- package/dist/{chunk-GVTZKAXC.js → chunk-CEEQW6ZV.js} +299 -1592
- package/dist/chunk-W7BVOQQG.js +1448 -0
- package/dist/chunk-WSDFLMO6.js +717 -0
- package/dist/cli.js +1652 -1146
- package/dist/infraone-A3KDADRM.js +249 -0
- package/dist/infraone-V3IKKVBG.js +593 -0
- package/dist/infraone.js +372 -0
- package/dist/{login-page-KGEJFFXR.js → login-page-MFHNVTUV.js} +1 -1
- package/dist/{server-F6YRKNC2.js → server-TRXBTJSA.js} +11 -9
- package/package.json +4 -3
- package/skills/harness-code/SKILL.md +37 -0
- package/spec/openapi.json +3411 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
Client,
|
|
4
|
+
EXPERT_ASSIGNMENTS_PATH,
|
|
5
|
+
EXPERT_LAPSED,
|
|
6
|
+
HttpError,
|
|
7
|
+
SESSION_PATH,
|
|
8
|
+
TransportError,
|
|
9
|
+
clean,
|
|
10
|
+
defaultCredentialsPath,
|
|
11
|
+
deleteCredentials,
|
|
12
|
+
expertSummary,
|
|
13
|
+
isExpertToken,
|
|
14
|
+
isLoginCredential,
|
|
15
|
+
orgCredentialsPath,
|
|
16
|
+
readCredentials,
|
|
17
|
+
validOrgId,
|
|
18
|
+
validateBaseUrl,
|
|
19
|
+
writeCredentials
|
|
20
|
+
} from "./chunk-BHBEIT5I.js";
|
|
21
|
+
import {
|
|
22
|
+
invokedName,
|
|
23
|
+
named
|
|
24
|
+
} from "./chunk-5THYIKLW.js";
|
|
25
|
+
|
|
26
|
+
// src/core/auth.ts
|
|
27
|
+
import { createHash, randomBytes } from "crypto";
|
|
28
|
+
import { execFile } from "child_process";
|
|
29
|
+
import { accessSync, constants as fsConstants } from "fs";
|
|
30
|
+
import { createServer } from "http";
|
|
31
|
+
import { delimiter, join } from "path";
|
|
32
|
+
import { createInterface } from "readline";
|
|
33
|
+
var DEFAULT_TIMEOUT_MS = 18e4;
|
|
34
|
+
var RESPONSE_DRAIN_GRACE_MS = 100;
|
|
35
|
+
var RESPONSE_FLUSH_TIMEOUT_MS = 2e3;
|
|
36
|
+
var MAX_PAGE_RESPONSES = 4;
|
|
37
|
+
var loginPageImport;
|
|
38
|
+
function loadLoginPage() {
|
|
39
|
+
loginPageImport ??= import("./login-page-MFHNVTUV.js");
|
|
40
|
+
return loginPageImport;
|
|
41
|
+
}
|
|
42
|
+
var NO_SESSION = { kind: null, expires_at: null, scopes: null, user: null, org: null };
|
|
43
|
+
function generateState() {
|
|
44
|
+
return randomBytes(24).toString("base64url");
|
|
45
|
+
}
|
|
46
|
+
function generateVerifier() {
|
|
47
|
+
return randomBytes(32).toString("base64url");
|
|
48
|
+
}
|
|
49
|
+
function challengeFor(verifier) {
|
|
50
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
51
|
+
}
|
|
52
|
+
async function login(baseUrl, options = {}) {
|
|
53
|
+
const validated = validateBaseUrl(baseUrl);
|
|
54
|
+
if (!validated.ok) return { ok: false, message: validated.message };
|
|
55
|
+
if (options.org !== void 0 && !validOrgId(options.org)) {
|
|
56
|
+
return { ok: false, message: "org must be an organization UUID" };
|
|
57
|
+
}
|
|
58
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
59
|
+
if (options.noBrowser === true || !browserAvailable(options)) {
|
|
60
|
+
return manualLogin(base, options);
|
|
61
|
+
}
|
|
62
|
+
return loopbackLogin(base, options);
|
|
63
|
+
}
|
|
64
|
+
function browserAvailable(options) {
|
|
65
|
+
if ("openBrowser" in options) return typeof options.openBrowser === "function";
|
|
66
|
+
return browserOpener() !== null && displayAvailable(options.env ?? process.env);
|
|
67
|
+
}
|
|
68
|
+
function findExecutable(name, pathVar = process.env["PATH"]) {
|
|
69
|
+
for (const dir of (pathVar ?? "").split(delimiter)) {
|
|
70
|
+
if (dir === "") continue;
|
|
71
|
+
const candidate = join(dir, name);
|
|
72
|
+
try {
|
|
73
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
74
|
+
return candidate;
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function browserOpener() {
|
|
81
|
+
const name = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
|
|
82
|
+
if (name === null) return null;
|
|
83
|
+
return findExecutable(name);
|
|
84
|
+
}
|
|
85
|
+
function displayAvailable(env) {
|
|
86
|
+
const display = env["DISPLAY"];
|
|
87
|
+
const remote = isSet(env["SSH_CONNECTION"]) || isSet(env["SSH_TTY"]);
|
|
88
|
+
const hasDisplay = isSet(display) || isSet(env["WAYLAND_DISPLAY"]);
|
|
89
|
+
const forwarded = typeof display === "string" && display.startsWith("localhost:");
|
|
90
|
+
if (process.platform === "darwin") return !remote;
|
|
91
|
+
if (process.platform === "linux") return hasDisplay && (!remote || forwarded);
|
|
92
|
+
return !remote;
|
|
93
|
+
}
|
|
94
|
+
function isSet(value) {
|
|
95
|
+
return value !== void 0 && value !== "";
|
|
96
|
+
}
|
|
97
|
+
async function loopbackLogin(baseUrl, options) {
|
|
98
|
+
const callback = await loopbackCode((query) => `${baseUrl}/cli/login?${query.toString()}`, options);
|
|
99
|
+
if (!callback.ok) return callback;
|
|
100
|
+
try {
|
|
101
|
+
const token = await exchangeCode(baseUrl, callback.code, callback.verifier, options);
|
|
102
|
+
if (!token.ok) {
|
|
103
|
+
await callback.respond(
|
|
104
|
+
400,
|
|
105
|
+
"Harness could not exchange the browser authorization. Return to the terminal for details.",
|
|
106
|
+
"error"
|
|
107
|
+
);
|
|
108
|
+
return token;
|
|
109
|
+
}
|
|
110
|
+
const result = await verifyAndStore(baseUrl, token.token, options, { minted: true, kind: token.kind });
|
|
111
|
+
if (result.ok) {
|
|
112
|
+
await callback.respond(200, "You can close this browser and return to the terminal.", "success");
|
|
113
|
+
} else {
|
|
114
|
+
await callback.respond(
|
|
115
|
+
400,
|
|
116
|
+
"Harness could not verify and store this login. Return to the terminal for details.",
|
|
117
|
+
"error"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await callback.respond(500, "Harness could not finish the login. Return to the terminal for details.", "error");
|
|
123
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
124
|
+
return { ok: false, message: `could not finish the browser login: ${detail}` };
|
|
125
|
+
} finally {
|
|
126
|
+
callback.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function loopbackCode(pageUrl, options) {
|
|
130
|
+
const notify = options.notify ?? defaultNotify;
|
|
131
|
+
const state = generateState();
|
|
132
|
+
const verifier = generateVerifier();
|
|
133
|
+
const challenge = challengeFor(verifier);
|
|
134
|
+
const server = createServer();
|
|
135
|
+
const port = await new Promise((resolve) => {
|
|
136
|
+
server.once("error", () => resolve(null));
|
|
137
|
+
server.listen(0, "127.0.0.1", () => {
|
|
138
|
+
const address = server.address();
|
|
139
|
+
resolve(typeof address === "object" && address !== null ? address.port : null);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
if (port === null) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
message: "could not open a loopback port for the browser handoff"
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const close = () => {
|
|
149
|
+
server.close();
|
|
150
|
+
const forceClose = setTimeout(
|
|
151
|
+
() => server.closeAllConnections(),
|
|
152
|
+
RESPONSE_FLUSH_TIMEOUT_MS + RESPONSE_DRAIN_GRACE_MS
|
|
153
|
+
);
|
|
154
|
+
forceClose.unref();
|
|
155
|
+
};
|
|
156
|
+
let handedOff = false;
|
|
157
|
+
try {
|
|
158
|
+
const query = new URLSearchParams({ port: String(port), state, challenge });
|
|
159
|
+
const url = pageUrl(query);
|
|
160
|
+
const callbackPromise = awaitCallback(server, state, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
161
|
+
void loadLoginPage().catch(() => {
|
|
162
|
+
loginPageImport = void 0;
|
|
163
|
+
});
|
|
164
|
+
const openBrowser = options.openBrowser ?? defaultOpenBrowser;
|
|
165
|
+
try {
|
|
166
|
+
openBrowser(url);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
169
|
+
return { ok: false, message: `could not open a browser: ${detail}` };
|
|
170
|
+
}
|
|
171
|
+
if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
|
|
172
|
+
notify(`Opening ${url}`);
|
|
173
|
+
notify("Waiting for the browser authorization (Ctrl-C to abort)\u2026");
|
|
174
|
+
const callback = await callbackPromise;
|
|
175
|
+
if (!callback.ok) return callback;
|
|
176
|
+
handedOff = true;
|
|
177
|
+
return { ok: true, code: callback.code, verifier, respond: callback.respond, close };
|
|
178
|
+
} finally {
|
|
179
|
+
if (!handedOff) close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function awaitCallback(server, state, timeoutMs) {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
let phase = "waiting";
|
|
185
|
+
let finalResponse = null;
|
|
186
|
+
let callbackPageResponses = 0;
|
|
187
|
+
const rootPageResponses = /* @__PURE__ */ new Map();
|
|
188
|
+
const acceptedResponders = [];
|
|
189
|
+
const inFlightResponses = /* @__PURE__ */ new Set();
|
|
190
|
+
const trackResponse = (responsePromise) => {
|
|
191
|
+
inFlightResponses.add(responsePromise);
|
|
192
|
+
void responsePromise.finally(() => inFlightResponses.delete(responsePromise));
|
|
193
|
+
return responsePromise;
|
|
194
|
+
};
|
|
195
|
+
const drainTrackedResponses = async () => {
|
|
196
|
+
await new Promise((settle) => setTimeout(settle, RESPONSE_DRAIN_GRACE_MS));
|
|
197
|
+
const deadline = Date.now() + RESPONSE_FLUSH_TIMEOUT_MS;
|
|
198
|
+
while (inFlightResponses.size > 0 && Date.now() < deadline) {
|
|
199
|
+
const remaining = deadline - Date.now();
|
|
200
|
+
await Promise.race([
|
|
201
|
+
Promise.allSettled([...inFlightResponses]),
|
|
202
|
+
new Promise((settle) => setTimeout(settle, remaining))
|
|
203
|
+
]);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
if (phase !== "waiting") return;
|
|
208
|
+
const timeoutResponse = {
|
|
209
|
+
status: 410,
|
|
210
|
+
message: `This login attempt has ended \u2014 run ${invokedName()} login again if needed.`,
|
|
211
|
+
pageState: "error"
|
|
212
|
+
};
|
|
213
|
+
phase = "terminal";
|
|
214
|
+
finalResponse = timeoutResponse;
|
|
215
|
+
resolve({
|
|
216
|
+
ok: false,
|
|
217
|
+
message: "timed out waiting for the browser authorization"
|
|
218
|
+
});
|
|
219
|
+
void drainTrackedResponses();
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
server.once("close", () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
if (phase !== "waiting") return;
|
|
224
|
+
phase = "terminal";
|
|
225
|
+
resolve({ ok: false, message: "the browser login listener closed before authorization completed" });
|
|
226
|
+
});
|
|
227
|
+
server.on("request", (request, response) => {
|
|
228
|
+
const respond = callbackResponder(response);
|
|
229
|
+
const respondWithPage = (status, message, pageState) => {
|
|
230
|
+
if (callbackPageResponses >= MAX_PAGE_RESPONSES) {
|
|
231
|
+
respondEmpty(response, 429);
|
|
232
|
+
return Promise.resolve();
|
|
233
|
+
}
|
|
234
|
+
callbackPageResponses += 1;
|
|
235
|
+
return respond(status, message, pageState);
|
|
236
|
+
};
|
|
237
|
+
const respondAtRoot = (status, message, pageState) => {
|
|
238
|
+
const rootPhase = phase === "terminal" ? "terminal" : "waiting";
|
|
239
|
+
const responses = rootPageResponses.get(rootPhase) ?? 0;
|
|
240
|
+
if (responses >= MAX_PAGE_RESPONSES) {
|
|
241
|
+
respondEmpty(response, 429);
|
|
242
|
+
return Promise.resolve();
|
|
243
|
+
}
|
|
244
|
+
rootPageResponses.set(rootPhase, responses + 1);
|
|
245
|
+
return respond(status, message, pageState);
|
|
246
|
+
};
|
|
247
|
+
let url;
|
|
248
|
+
try {
|
|
249
|
+
url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
250
|
+
} catch {
|
|
251
|
+
respondEmpty(response, 404);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
255
|
+
if (path !== "/callback") {
|
|
256
|
+
if (path === "/") {
|
|
257
|
+
const rootResponse = phase === "terminal" && finalResponse ? finalResponse : {
|
|
258
|
+
status: 200,
|
|
259
|
+
message: "Waiting for the Harness browser authorization to finish.",
|
|
260
|
+
pageState: "waiting"
|
|
261
|
+
};
|
|
262
|
+
void trackResponse(
|
|
263
|
+
respondAtRoot(rootResponse.status, rootResponse.message, rootResponse.pageState)
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
respondEmpty(response, path === "/favicon.ico" ? 204 : 404);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const matchingState = url.searchParams.get("state") === state;
|
|
271
|
+
if (!matchingState) {
|
|
272
|
+
respondEmpty(response, 400);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (phase === "terminal") {
|
|
276
|
+
if (!url.searchParams.has("code") && !url.searchParams.has("error")) {
|
|
277
|
+
respondEmpty(response, 400);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const result = finalResponse ?? {
|
|
281
|
+
status: 410,
|
|
282
|
+
message: `This login attempt has ended \u2014 run ${invokedName()} login again if needed.`,
|
|
283
|
+
pageState: "error"
|
|
284
|
+
};
|
|
285
|
+
void trackResponse(respondWithPage(result.status, result.message, result.pageState));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (phase === "processing") {
|
|
289
|
+
if (url.searchParams.has("error")) {
|
|
290
|
+
respondEmpty(response, 409);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const duplicateCode = url.searchParams.get("code");
|
|
294
|
+
if (duplicateCode === null || duplicateCode === "") {
|
|
295
|
+
respondEmpty(response, 400);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (acceptedResponders.length >= MAX_PAGE_RESPONSES) {
|
|
299
|
+
respondEmpty(response, 429);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
acceptedResponders.push(respondWithPage);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (url.searchParams.has("error")) {
|
|
306
|
+
const denialResponse = {
|
|
307
|
+
status: 400,
|
|
308
|
+
message: "Browser authorization was denied. Return to the terminal to try again.",
|
|
309
|
+
pageState: "error"
|
|
310
|
+
};
|
|
311
|
+
phase = "terminal";
|
|
312
|
+
finalResponse = denialResponse;
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
void (async () => {
|
|
315
|
+
await trackResponse(respondWithPage(denialResponse.status, denialResponse.message, denialResponse.pageState));
|
|
316
|
+
await drainTrackedResponses();
|
|
317
|
+
resolve({ ok: false, message: "browser authorization was denied" });
|
|
318
|
+
})();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const code = url.searchParams.get("code");
|
|
322
|
+
if (code !== null && code !== "") {
|
|
323
|
+
phase = "processing";
|
|
324
|
+
acceptedResponders.push(respondWithPage);
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
resolve({
|
|
327
|
+
ok: true,
|
|
328
|
+
code,
|
|
329
|
+
respond: async (status, message, pageState) => {
|
|
330
|
+
finalResponse = { status, message, pageState };
|
|
331
|
+
phase = "terminal";
|
|
332
|
+
const responders = acceptedResponders.splice(0);
|
|
333
|
+
await Promise.all(
|
|
334
|
+
responders.map((acceptedRespond) => trackResponse(acceptedRespond(status, message, pageState)))
|
|
335
|
+
);
|
|
336
|
+
await drainTrackedResponses();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
respondEmpty(response, 400);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
function callbackResponder(response) {
|
|
346
|
+
return async (status, message, state) => {
|
|
347
|
+
if (response.writableEnded || response.destroyed) return;
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
let flushTimer;
|
|
350
|
+
const armFlushTimeout = () => {
|
|
351
|
+
if (flushTimer !== void 0) clearTimeout(flushTimer);
|
|
352
|
+
flushTimer = setTimeout(() => {
|
|
353
|
+
response.destroy();
|
|
354
|
+
settle();
|
|
355
|
+
}, RESPONSE_FLUSH_TIMEOUT_MS);
|
|
356
|
+
flushTimer.unref();
|
|
357
|
+
};
|
|
358
|
+
const settle = () => {
|
|
359
|
+
if (flushTimer !== void 0) clearTimeout(flushTimer);
|
|
360
|
+
response.off("finish", settle);
|
|
361
|
+
response.off("close", settle);
|
|
362
|
+
response.off("error", settle);
|
|
363
|
+
response.off("drain", armFlushTimeout);
|
|
364
|
+
resolve();
|
|
365
|
+
};
|
|
366
|
+
response.once("finish", settle);
|
|
367
|
+
response.once("close", settle);
|
|
368
|
+
response.once("error", settle);
|
|
369
|
+
response.on("drain", armFlushTimeout);
|
|
370
|
+
void loadLoginPage().then(({ loginPage }) => {
|
|
371
|
+
if (response.writableEnded || response.destroyed) {
|
|
372
|
+
settle();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const html = loginPage(message, state);
|
|
377
|
+
response.writeHead(status, {
|
|
378
|
+
"cache-control": "no-store",
|
|
379
|
+
connection: "close",
|
|
380
|
+
"content-length": String(Buffer.byteLength(html)),
|
|
381
|
+
"content-type": "text/html; charset=utf-8",
|
|
382
|
+
"x-content-type-options": "nosniff"
|
|
383
|
+
});
|
|
384
|
+
armFlushTimeout();
|
|
385
|
+
response.end(html);
|
|
386
|
+
} catch {
|
|
387
|
+
response.destroy();
|
|
388
|
+
settle();
|
|
389
|
+
}
|
|
390
|
+
}).catch(() => {
|
|
391
|
+
response.destroy();
|
|
392
|
+
settle();
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function respondEmpty(response, status) {
|
|
398
|
+
response.once("error", () => void 0);
|
|
399
|
+
try {
|
|
400
|
+
response.writeHead(status, {
|
|
401
|
+
"cache-control": "no-store",
|
|
402
|
+
connection: "close",
|
|
403
|
+
"x-content-type-options": "nosniff"
|
|
404
|
+
});
|
|
405
|
+
response.end();
|
|
406
|
+
} catch {
|
|
407
|
+
response.destroy();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function exchangeCode(baseUrl, code, verifier, options) {
|
|
411
|
+
try {
|
|
412
|
+
const body = await bareClient(baseUrl, "", options).post("/api/v1/cli/token-exchange", { code, code_verifier: verifier });
|
|
413
|
+
const token = body.data?.token;
|
|
414
|
+
const kind = typeof body.data?.kind === "string" ? body.data.kind : null;
|
|
415
|
+
if (typeof token === "string" && token !== "") return { ok: true, token, kind };
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
message: "the login-code exchange returned no token \u2014 aborting"
|
|
419
|
+
};
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error instanceof HttpError) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
message: `the login-code exchange failed: ${error.message}`
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (error instanceof TransportError) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
message: `the login-code exchange failed: cannot reach the API (${error.message})`
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function manualLogin(baseUrl, options) {
|
|
437
|
+
const notify = options.notify ?? defaultNotify;
|
|
438
|
+
const prompt = options.prompt ?? defaultPrompt;
|
|
439
|
+
if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
|
|
440
|
+
notify(
|
|
441
|
+
`Open ${baseUrl}/cli/login in a browser, authorize, and paste the token below (an outside expert pastes the pxc_ credential minted in their expert view instead).`
|
|
442
|
+
);
|
|
443
|
+
const token = (await prompt()).trim();
|
|
444
|
+
if (token === "") return { ok: false, message: "no token entered" };
|
|
445
|
+
return verifyAndStore(baseUrl, token, options, { minted: false, kind: null });
|
|
446
|
+
}
|
|
447
|
+
async function verifyAndStore(baseUrl, token, options, received) {
|
|
448
|
+
const defaultPath = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
|
|
449
|
+
const path = options.org === void 0 ? defaultPath : orgCredentialsPath(defaultPath, baseUrl, options.org);
|
|
450
|
+
const client = bareClient(baseUrl, token, options);
|
|
451
|
+
const expert = isExpertToken(token);
|
|
452
|
+
let kind = received.kind;
|
|
453
|
+
try {
|
|
454
|
+
if (expert) {
|
|
455
|
+
if (options.org !== void 0) return { ok: false, message: "cannot verify an outside expert credential against a repo org \u2014 nothing was stored" };
|
|
456
|
+
await client.get(EXPERT_ASSIGNMENTS_PATH);
|
|
457
|
+
} else {
|
|
458
|
+
const described = isLoginCredential(token) ? await describeSession(client) : null;
|
|
459
|
+
if (described !== null) kind = described.session.kind ?? kind;
|
|
460
|
+
if (options.org !== void 0 && (typeof described?.session.org?.["id"] !== "string" || described.session.org["id"].toLowerCase() !== options.org.toLowerCase())) {
|
|
461
|
+
const revoked = received.minted && await revokeSession(client, token);
|
|
462
|
+
return { ok: false, message: `the received token does not verify organization ${options.org} \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : "") };
|
|
463
|
+
}
|
|
464
|
+
if (described === null) await client.get("/api/v1/teams");
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error instanceof HttpError) {
|
|
468
|
+
const lapsed = expert && error.status === 401 ? ` \u2014 ${EXPERT_LAPSED}` : "";
|
|
469
|
+
const revoked = received.minted && await revokeSession(client, token);
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
message: `the received token failed verification (${error.message}, HTTP ${error.status}) \u2014 nothing was stored${revoked ? ", and the token was revoked" : ""}${lapsed}`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (error instanceof TransportError) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
message: `could not verify the token: cannot reach the API (${error.message}) \u2014 nothing was stored`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
const stored = readCredentials(path);
|
|
484
|
+
const superseded = stored !== null && stored.token !== token ? stored : null;
|
|
485
|
+
try {
|
|
486
|
+
writeCredentials(path, baseUrl, token);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (received.minted) await revokeSession(client, token);
|
|
489
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
message: `could not store credentials at ${path}: ${detail}` + (expert ? " (the pxc_ credential is still valid: revoke it in your expert view)" : received.minted ? "" : " (the pasted token was not revoked and stays valid)")
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
let supersededNote = "";
|
|
496
|
+
if (superseded !== null) {
|
|
497
|
+
await revokeSession(bareClient(superseded.url, superseded.token, options), superseded.token);
|
|
498
|
+
if (isExpertToken(superseded.token)) {
|
|
499
|
+
supersededNote = "The pxc_ credential stored before stays valid until it expires: revoke it in your expert view.\n";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const name = invokedName();
|
|
503
|
+
return {
|
|
504
|
+
ok: true,
|
|
505
|
+
json: { url: baseUrl, credentials: path, kind },
|
|
506
|
+
human: (expert ? `Stored an outside expert's pxc_ credential for ${baseUrl} at ${path} (it lasts at most 30 days; revoke it in your expert view \u2014 ${name} logout only forgets it). Commands now act as the expert: only assignments, conditions and gate work until you log in again.
|
|
507
|
+
` : `Logged in to ${baseUrl} \u2014 login token stored at ${path} (valid ~30 days; ${name} logout revokes it).
|
|
508
|
+
`) + kindNote(kind) + supersededNote
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function kindNote(kind) {
|
|
512
|
+
if (kind === "operator-cli") {
|
|
513
|
+
return "This credential is Operator-only (operator-cli): Operator commands work; issue, plan and gate commands are refused.\n";
|
|
514
|
+
}
|
|
515
|
+
if (kind === "harness-cli") return "This credential has full access (harness-cli).\n";
|
|
516
|
+
return "";
|
|
517
|
+
}
|
|
518
|
+
async function describeSession(client) {
|
|
519
|
+
try {
|
|
520
|
+
const { envelope, apiVersion } = await client.getVersioned(SESSION_PATH);
|
|
521
|
+
return { session: sessionOf(envelope.data), apiVersion };
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (error instanceof HttpError && (error.status === 404 || error.status === 405)) return null;
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function sessionOf(data) {
|
|
528
|
+
if (!isRecord(data)) return NO_SESSION;
|
|
529
|
+
const text = (value) => typeof value === "string" ? value : null;
|
|
530
|
+
return {
|
|
531
|
+
kind: text(data["kind"]),
|
|
532
|
+
expires_at: text(data["expires_at"]),
|
|
533
|
+
scopes: Array.isArray(data["scopes"]) ? data["scopes"].filter((scope) => typeof scope === "string") : null,
|
|
534
|
+
user: isRecord(data["user"]) ? data["user"] : null,
|
|
535
|
+
org: isRecord(data["org"]) ? data["org"] : null
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async function revokeSession(client, token) {
|
|
539
|
+
if (isExpertToken(token)) return false;
|
|
540
|
+
return client.delete("/api/v1/session").then(() => true).catch(() => false);
|
|
541
|
+
}
|
|
542
|
+
async function logout(options = {}) {
|
|
543
|
+
const path = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
|
|
544
|
+
const stored = readCredentials(path);
|
|
545
|
+
if (stored === null) {
|
|
546
|
+
const envToken = (options.env ?? process.env)["HARNESS_API_TOKEN"] ?? "";
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
message: "No stored login to remove. A HARNESS_API_TOKEN from the environment is yours to manage \u2014 " + (isExpertToken(envToken.trim()) ? "unset it, or revoke the pxc_ credential in your expert view." : "unset it, or disable the token in the admin UI.")
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const expert = isExpertToken(stored.token);
|
|
553
|
+
const revoked = await revokeSession(bareClient(stored.url, stored.token, options), stored.token);
|
|
554
|
+
const note = revoked ? "" : " (server-side revoke failed \u2014 the token still expires on its own)";
|
|
555
|
+
try {
|
|
556
|
+
deleteCredentials(path);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
message: `could not remove ${path} (${detail}) \u2014 delete it manually.` + (revoked ? " The token itself was revoked." : "")
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
ok: true,
|
|
566
|
+
json: { logged_out: true, revoked },
|
|
567
|
+
human: expert ? `Forgot the outside expert's pxc_ credential for ${stored.url}. It stays valid until it expires: revoke it in your expert view to end it now.
|
|
568
|
+
` : `Logged out of ${stored.url}${note}.
|
|
569
|
+
`
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
async function whoami(config, credentialSource) {
|
|
573
|
+
const client = new Client(config);
|
|
574
|
+
const expert = isExpertToken(config.token);
|
|
575
|
+
let probe;
|
|
576
|
+
let session = null;
|
|
577
|
+
let serverVersion;
|
|
578
|
+
try {
|
|
579
|
+
const described2 = isLoginCredential(config.token) ? await describeSession(client) : null;
|
|
580
|
+
if (described2 !== null) {
|
|
581
|
+
session = described2.session;
|
|
582
|
+
serverVersion = described2.apiVersion;
|
|
583
|
+
} else {
|
|
584
|
+
const answered = await client.getVersioned(expert ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats");
|
|
585
|
+
probe = answered.envelope.data;
|
|
586
|
+
serverVersion = answered.apiVersion;
|
|
587
|
+
}
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (error instanceof HttpError) {
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
message: expert ? `signed in to ${config.baseUrl} with an outside expert's pxc_ credential (${credentialSource}), but it was refused: ${error.message} (HTTP ${error.status})${error.status === 401 ? ` \u2014 ${EXPERT_LAPSED}` : ""}` : `signed in to ${config.baseUrl} with a ${credentialSource} credential, but it failed verification: ${error.message} (HTTP ${error.status})`
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
let seat = null;
|
|
598
|
+
if (expert) {
|
|
599
|
+
seat = expertSeat(probe);
|
|
600
|
+
if (seat === null) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
message: `signed in to ${config.baseUrl} with an outside expert's pxc_ credential, but the assignments response carried no expert`
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
serverVersion ??= "unknown";
|
|
608
|
+
const hostSourceLabels = {
|
|
609
|
+
env: "HARNESS_API_URL",
|
|
610
|
+
"repo-config": ".harness-cli/config.json",
|
|
611
|
+
"stored-login": "stored login",
|
|
612
|
+
default: "workspace default"
|
|
613
|
+
};
|
|
614
|
+
const via = config.hostSource !== void 0 ? ` (via ${hostSourceLabels[config.hostSource]})` : "";
|
|
615
|
+
const expertLines = seat === null ? "" : `Expert: ${expertSummary(seat)}
|
|
616
|
+
Reaches: its own assignments and the gates it owns on them (assignments, conditions, gate)
|
|
617
|
+
`;
|
|
618
|
+
const described = session ?? NO_SESSION;
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
json: {
|
|
622
|
+
url: config.baseUrl,
|
|
623
|
+
source: credentialSource,
|
|
624
|
+
verified: true,
|
|
625
|
+
server_version: serverVersion,
|
|
626
|
+
host_source: config.hostSource,
|
|
627
|
+
kind: described.kind,
|
|
628
|
+
expires_at: described.expires_at,
|
|
629
|
+
scopes: described.scopes,
|
|
630
|
+
user: described.user,
|
|
631
|
+
org: described.org,
|
|
632
|
+
expert: seat
|
|
633
|
+
},
|
|
634
|
+
human: `Host: ${config.baseUrl}${via}
|
|
635
|
+
Credential: ${credentialSource}${seat === null ? "" : ", an outside expert's pxc_ credential"}${kindLabel(described.kind)}
|
|
636
|
+
` + sessionLines(described) + expertLines + `Verified: ok (server API ${serverVersion})
|
|
637
|
+
`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function kindLabel(kind) {
|
|
641
|
+
if (kind === null) return "";
|
|
642
|
+
if (kind === "harness-cli") return ", harness-cli (full access)";
|
|
643
|
+
if (kind === "operator-cli") return ", operator-cli (Operator only)";
|
|
644
|
+
return `, ${clean(kind)}`;
|
|
645
|
+
}
|
|
646
|
+
function sessionLines(session) {
|
|
647
|
+
let lines = "";
|
|
648
|
+
const user = session.user;
|
|
649
|
+
if (user !== null) {
|
|
650
|
+
const name = clean(user["name"]);
|
|
651
|
+
const email = clean(user["email"]);
|
|
652
|
+
lines += `User: ${name === "" ? email : email === "" ? name : `${name} <${email}>`}
|
|
653
|
+
`;
|
|
654
|
+
}
|
|
655
|
+
const org = session.org;
|
|
656
|
+
if (org !== null) {
|
|
657
|
+
const name = clean(org["name"]);
|
|
658
|
+
const slug = clean(org["slug"]);
|
|
659
|
+
lines += `Org: ${slug === "" || slug === name ? name : `${name} (${slug})`}
|
|
660
|
+
`;
|
|
661
|
+
}
|
|
662
|
+
if (session.expires_at !== null) lines += `Expires: ${clean(session.expires_at)}
|
|
663
|
+
`;
|
|
664
|
+
if (session.kind === "operator-cli") {
|
|
665
|
+
lines += "Reaches: Operator commands only; issue, plan and gate commands are refused\n";
|
|
666
|
+
}
|
|
667
|
+
return lines;
|
|
668
|
+
}
|
|
669
|
+
function expertSeat(data) {
|
|
670
|
+
if (!isRecord(data) || !isRecord(data["expert"])) return null;
|
|
671
|
+
const plan = isRecord(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
|
|
672
|
+
const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord).length : 0;
|
|
673
|
+
return { id: data["expert"]["id"], role: data["expert"]["role"] ?? null, plan, assigned_steps: steps };
|
|
674
|
+
}
|
|
675
|
+
function isRecord(value) {
|
|
676
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
677
|
+
}
|
|
678
|
+
function bareClient(baseUrl, token, options) {
|
|
679
|
+
const config = {
|
|
680
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
681
|
+
token,
|
|
682
|
+
actor: null,
|
|
683
|
+
team: null,
|
|
684
|
+
cacheDir: ""
|
|
685
|
+
};
|
|
686
|
+
if (options.fetchImpl !== void 0) config.fetchImpl = options.fetchImpl;
|
|
687
|
+
return new Client(config);
|
|
688
|
+
}
|
|
689
|
+
function defaultNotify(line) {
|
|
690
|
+
process.stderr.write(named(line + "\n"));
|
|
691
|
+
}
|
|
692
|
+
function defaultPrompt() {
|
|
693
|
+
const readline = createInterface({
|
|
694
|
+
input: process.stdin,
|
|
695
|
+
output: process.stderr
|
|
696
|
+
});
|
|
697
|
+
return new Promise((resolve) => {
|
|
698
|
+
readline.question("Token: ", (answer) => {
|
|
699
|
+
readline.close();
|
|
700
|
+
resolve(answer);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
function defaultOpenBrowser(url) {
|
|
705
|
+
const opener = browserOpener();
|
|
706
|
+
if (opener !== null) {
|
|
707
|
+
execFile(opener, [url], () => void 0);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export {
|
|
712
|
+
login,
|
|
713
|
+
browserAvailable,
|
|
714
|
+
loopbackCode,
|
|
715
|
+
logout,
|
|
716
|
+
whoami
|
|
717
|
+
};
|