hngr 0.2.0 → 0.3.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/dist/{auth-BT8HuCif.mjs → auth-CgBUQUQn.mjs} +172 -29
- package/dist/{catalog-jU0-wzh3.mjs → catalog-DbGLqQ8a.mjs} +713 -73
- package/dist/{commands-BchmocR_.mjs → commands-BHXSZ04F.mjs} +1 -1
- package/dist/index.mjs +21 -23
- package/dist/{mcp-DeWHHgQK.mjs → mcp-b6bE3ZRt.mjs} +4 -4
- package/dist/{publish-D7zaQl9x.mjs → publish-EUqgdp0G.mjs} +39 -9
- package/dist/{skills-DwoDzsXd.mjs → skills-XDbBCC5f.mjs} +6 -6
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { S as HngrError, d as configFilePath, f as deleteStoredConfig, g as resolveConnection, h as redactUrl, m as readStoredConfig, n as apiGet, p as normalizeServerUrl, t as apiFetch, v as writeStoredConfig } from "./http-2HWxLIOq.mjs";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
// src/commands/auth.ts
|
|
5
6
|
const AUTH_REMEDIATION = "Run `hngr auth login` or set HANGAR_TOKEN.";
|
|
6
7
|
/** The first 8 characters after the `hng_` prefix, for recognising a token. Not a credential. */
|
|
@@ -103,37 +104,42 @@ async function login(ctx, argv) {
|
|
|
103
104
|
} catch (err) {
|
|
104
105
|
if (err instanceof HngrError && err.body.type === "api") throw err;
|
|
105
106
|
}
|
|
106
|
-
if (argv.token === true)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
107
|
+
if (argv.token === true) return loginWithToken(ctx, server, stored);
|
|
108
|
+
if (argv.password === true) return loginWithPassword(ctx, server, stored);
|
|
109
|
+
return loginDevice(ctx, server, stored);
|
|
110
|
+
}
|
|
111
|
+
async function loginWithToken(ctx, server, stored) {
|
|
112
|
+
const pasted = (await ctx.io.stdin.readAll()).trim();
|
|
113
|
+
if (!pasted) throw new HngrError({
|
|
114
|
+
type: "usage",
|
|
115
|
+
title: "No token on stdin",
|
|
116
|
+
detail: "Pipe an access token into `hngr auth login --token`."
|
|
117
|
+
});
|
|
118
|
+
const principal = asStoredPrincipal(await apiGet({
|
|
119
|
+
server,
|
|
120
|
+
token: pasted
|
|
121
|
+
}, "/api/v1/me"));
|
|
122
|
+
if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
|
|
123
|
+
const prefix = tokenPrefixOf(pasted);
|
|
124
|
+
const matched = await lookupTokenMetadata(server, pasted, prefix);
|
|
125
|
+
const path = await writeStoredConfig(ctx.env, {
|
|
126
|
+
server,
|
|
127
|
+
token: pasted,
|
|
128
|
+
...matched ? {
|
|
129
|
+
token_id: matched.id,
|
|
130
|
+
token_name: matched.name
|
|
131
|
+
} : {},
|
|
132
|
+
token_prefix: prefix,
|
|
133
|
+
principal,
|
|
134
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
135
|
+
});
|
|
136
|
+
summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
async function loginWithPassword(ctx, server, stored) {
|
|
134
140
|
if (!canPrompt(ctx)) throw new HngrError({
|
|
135
141
|
type: "auth",
|
|
136
|
-
title: "Logging in needs a terminal",
|
|
142
|
+
title: "Logging in with a password needs a terminal",
|
|
137
143
|
detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
|
|
138
144
|
}, 4);
|
|
139
145
|
const prompter = ctx.io.prompts;
|
|
@@ -175,6 +181,143 @@ async function login(ctx, argv) {
|
|
|
175
181
|
summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
|
|
176
182
|
return 0;
|
|
177
183
|
}
|
|
184
|
+
const DEVICE_CODE_PATH = "/api/v1/auth/device/code";
|
|
185
|
+
const DEVICE_TOKEN_PATH = "/api/v1/auth/device/token";
|
|
186
|
+
/** Suggested when the device flow cannot finish; the password flow still works. */
|
|
187
|
+
const PASSWORD_REMEDIATION = "Run `hngr auth login --password` to sign in with a username and password instead.";
|
|
188
|
+
/** Validates the create response; a bad shape is an api error, not a crash. */
|
|
189
|
+
function asDeviceCode(body) {
|
|
190
|
+
const value = body;
|
|
191
|
+
if (value === null || typeof value !== "object" || typeof value.device_code !== "string" || typeof value.user_code !== "string" || typeof value.verification_uri !== "string" || typeof value.verification_uri_complete !== "string" || typeof value.expires_in !== "number" || typeof value.interval !== "number" || value.interval <= 0) throw new HngrError({
|
|
192
|
+
type: "api",
|
|
193
|
+
title: "The server returned an unexpected device authorization payload",
|
|
194
|
+
detail: "Expected {device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval} from POST /api/v1/auth/device/code."
|
|
195
|
+
});
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
/** Validates the poll success response; the token is stored only when complete. */
|
|
199
|
+
function asDeviceToken(body) {
|
|
200
|
+
const value = body;
|
|
201
|
+
if (value === null || typeof value !== "object" || typeof value.token !== "string" || typeof value.token_id !== "string" || typeof value.token_prefix !== "string" || typeof value.name !== "string") throw new HngrError({
|
|
202
|
+
type: "api",
|
|
203
|
+
title: "The server returned an unexpected device token payload",
|
|
204
|
+
detail: "Expected {token, token_id, token_prefix, name, principal} from POST /api/v1/auth/device/token."
|
|
205
|
+
});
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
function deviceDeniedError() {
|
|
209
|
+
return new HngrError({
|
|
210
|
+
type: "auth",
|
|
211
|
+
title: "The login request was denied",
|
|
212
|
+
detail: PASSWORD_REMEDIATION
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function deviceExpiredError() {
|
|
216
|
+
return new HngrError({
|
|
217
|
+
type: "auth",
|
|
218
|
+
title: "The login request expired",
|
|
219
|
+
detail: PASSWORD_REMEDIATION
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function defaultSleep(ms) {
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
224
|
+
}
|
|
225
|
+
/** Starts the platform opener without waiting for it; failures are silent. */
|
|
226
|
+
function openInBrowser(url) {
|
|
227
|
+
const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
228
|
+
try {
|
|
229
|
+
const child = spawn(command, args, {
|
|
230
|
+
detached: true,
|
|
231
|
+
stdio: "ignore"
|
|
232
|
+
});
|
|
233
|
+
child.on("error", () => {});
|
|
234
|
+
child.unref();
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
/** The device name shown to the approver. */
|
|
238
|
+
function deviceName() {
|
|
239
|
+
return hostname().slice(0, 128);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Polls until the request leaves the pending state. `slow_down` extends the
|
|
243
|
+
* interval permanently by five seconds; network failures are retried until the
|
|
244
|
+
* advertised lifetime runs out. Sleeps and the clock are injectable for tests.
|
|
245
|
+
*/
|
|
246
|
+
async function pollDeviceToken(ctx, server, deviceCode, expiresIn, interval) {
|
|
247
|
+
const now = ctx.io.now ?? Date.now;
|
|
248
|
+
const sleep = ctx.io.sleep ?? defaultSleep;
|
|
249
|
+
const deadline = now() + expiresIn * 1e3;
|
|
250
|
+
let waitSeconds = interval;
|
|
251
|
+
for (;;) {
|
|
252
|
+
if (now() >= deadline) throw deviceExpiredError();
|
|
253
|
+
try {
|
|
254
|
+
return asDeviceToken(await (await apiFetch({ server }, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
path: DEVICE_TOKEN_PATH,
|
|
257
|
+
body: { device_code: deviceCode }
|
|
258
|
+
})).json());
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (!(err instanceof HngrError)) throw err;
|
|
261
|
+
const problem = String(err.body.type);
|
|
262
|
+
if (problem.endsWith("/authorization-pending")) {
|
|
263
|
+
await sleep(waitSeconds * 1e3);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (problem.endsWith("/slow-down")) {
|
|
267
|
+
waitSeconds += 5;
|
|
268
|
+
await sleep(waitSeconds * 1e3);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (problem.endsWith("/access-denied")) throw deviceDeniedError();
|
|
272
|
+
if (problem.endsWith("/expired-token")) throw deviceExpiredError();
|
|
273
|
+
if (err.body.type === "network") {
|
|
274
|
+
await sleep(waitSeconds * 1e3);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** The browser-based login: request a code, wait for approval, store the token. */
|
|
282
|
+
async function loginDevice(ctx, server, stored) {
|
|
283
|
+
const device = asDeviceCode(await (await apiFetch({ server }, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
path: DEVICE_CODE_PATH,
|
|
286
|
+
body: {
|
|
287
|
+
device_name: deviceName(),
|
|
288
|
+
token_name: cliTokenName()
|
|
289
|
+
}
|
|
290
|
+
})).json());
|
|
291
|
+
ctx.detail("To approve this login from a browser:");
|
|
292
|
+
ctx.io.stderr.write(`open ${device.verification_uri}\n`);
|
|
293
|
+
ctx.io.stderr.write(`enter the user code: ${device.user_code}\n`);
|
|
294
|
+
if (ctx.io.stderr.isTTY) try {
|
|
295
|
+
await (ctx.io.openBrowser ?? openInBrowser)(device.verification_uri_complete);
|
|
296
|
+
} catch {}
|
|
297
|
+
const minted = await pollDeviceToken(ctx, server, device.device_code, device.expires_in, device.interval);
|
|
298
|
+
if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
|
|
299
|
+
const principal = asStoredPrincipal(await apiGet({
|
|
300
|
+
server,
|
|
301
|
+
token: minted.token
|
|
302
|
+
}, "/api/v1/me"));
|
|
303
|
+
let path;
|
|
304
|
+
try {
|
|
305
|
+
path = await writeStoredConfig(ctx.env, {
|
|
306
|
+
server,
|
|
307
|
+
token: minted.token,
|
|
308
|
+
token_id: minted.token_id,
|
|
309
|
+
token_name: minted.name,
|
|
310
|
+
token_prefix: minted.token_prefix,
|
|
311
|
+
principal,
|
|
312
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (!await revokeToken(server, minted.token, minted.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
178
321
|
/** Reads the session cookie off the login response; missing cookie is an api error. */
|
|
179
322
|
function sessionCookieOf(response) {
|
|
180
323
|
const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();
|