hngr 0.2.0 → 0.4.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 +6 -3
- package/dist/{auth-BT8HuCif.mjs → auth-ByGgd_Yn.mjs} +221 -40
- package/dist/{catalog-jU0-wzh3.mjs → catalog-Bv304SXJ.mjs} +1154 -122
- package/dist/{commands-BchmocR_.mjs → commands-CLqLwQHY.mjs} +1 -1
- package/dist/{http-2HWxLIOq.mjs → http-DgHX7Fux.mjs} +13 -7
- package/dist/index.mjs +126 -29
- package/dist/{mcp-DeWHHgQK.mjs → mcp-DWF_ELDU.mjs} +29 -50
- package/dist/{pagination-BbKLKSw3.mjs → pagination-BXyFxGYA.mjs} +1 -1
- package/dist/{publish-D7zaQl9x.mjs → scope-Ch6wZCgE.mjs} +63 -12
- package/dist/{skills-DwoDzsXd.mjs → skills-CxMeJ5PF.mjs} +23 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,9 +47,12 @@ hngr skills add team-a/pdf-tools --agent claude-code
|
|
|
47
47
|
hngr skills add pdf-tools@2026.10.25.1 --agent claude-code
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Non-interactive runs must pass `--agent` (repeatable) or `--all`.
|
|
51
|
-
|
|
52
|
-
`--
|
|
50
|
+
Non-interactive runs must pass `--agent` (repeatable) or `--all`. In an interactive
|
|
51
|
+
terminal, omitting `--global` first asks whether to install for the current project or
|
|
52
|
+
the user; without a terminal it defaults to the project. Use `--global` to skip the
|
|
53
|
+
question, `--copy` to skip the canonical `.agents/skills` directory, and `--dry-run` to
|
|
54
|
+
preview without writing. Re-running an install replaces the existing target; `--force`
|
|
55
|
+
is not needed.
|
|
53
56
|
|
|
54
57
|
## Publish capabilities
|
|
55
58
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C 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 storedAudience, y as writeStoredConfig } from "./http-DgHX7Fux.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. */
|
|
@@ -27,12 +28,33 @@ async function activeToken(ctx) {
|
|
|
27
28
|
source: "config"
|
|
28
29
|
} : void 0;
|
|
29
30
|
}
|
|
30
|
-
async function token(ctx) {
|
|
31
|
+
async function token(ctx, argv) {
|
|
32
|
+
if (argv.audience === "mcp") return mintMCPToken(ctx);
|
|
31
33
|
const active = await activeToken(ctx);
|
|
32
34
|
if (!active) throw notLoggedInError();
|
|
33
35
|
ctx.io.stdout.write(`${active.token}\n`);
|
|
34
36
|
return 0;
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Mints a static MCP token with the active credential and prints it once.
|
|
40
|
+
* The stored config is never rewritten, so the same machine keeps its
|
|
41
|
+
* management login while handing out MCP tokens.
|
|
42
|
+
*
|
|
43
|
+
* The caller's kind is deliberately not checked here: the server owns the
|
|
44
|
+
* owner rule (a Principal can manage its own tokens), so the request must go
|
|
45
|
+
* out and its Problem, if any, reaches the user through the usual mapping.
|
|
46
|
+
*/
|
|
47
|
+
async function mintMCPToken(ctx) {
|
|
48
|
+
const connection = await resolveConnection(ctx.flags, ctx.env);
|
|
49
|
+
if (!connection.token) throw notLoggedInError();
|
|
50
|
+
const minted = await mintAccessToken(connection.server, {
|
|
51
|
+
name: cliMCPTokenName(),
|
|
52
|
+
audience: "mcp",
|
|
53
|
+
headers: { Authorization: `Bearer ${connection.token}` }
|
|
54
|
+
});
|
|
55
|
+
ctx.io.stdout.write(`${minted.token}\n`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
36
58
|
/** Prompts only happen with both stdin and stdout on a TTY and a prompter available. */
|
|
37
59
|
function canPrompt(ctx) {
|
|
38
60
|
return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts !== void 0;
|
|
@@ -103,37 +125,43 @@ async function login(ctx, argv) {
|
|
|
103
125
|
} catch (err) {
|
|
104
126
|
if (err instanceof HngrError && err.body.type === "api") throw err;
|
|
105
127
|
}
|
|
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
|
-
|
|
128
|
+
if (argv.token === true) return loginWithToken(ctx, server, stored);
|
|
129
|
+
if (argv.password === true) return loginWithPassword(ctx, server, stored);
|
|
130
|
+
return loginDevice(ctx, server, stored);
|
|
131
|
+
}
|
|
132
|
+
async function loginWithToken(ctx, server, stored) {
|
|
133
|
+
const pasted = (await ctx.io.stdin.readAll()).trim();
|
|
134
|
+
if (!pasted) throw new HngrError({
|
|
135
|
+
type: "usage",
|
|
136
|
+
title: "No token on stdin",
|
|
137
|
+
detail: "Pipe an access token into `hngr auth login --token`."
|
|
138
|
+
});
|
|
139
|
+
const principal = asStoredPrincipal(await apiGet({
|
|
140
|
+
server,
|
|
141
|
+
token: pasted
|
|
142
|
+
}, "/api/v1/me"));
|
|
143
|
+
if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
|
|
144
|
+
const prefix = tokenPrefixOf(pasted);
|
|
145
|
+
const matched = await lookupTokenMetadata(server, pasted, prefix);
|
|
146
|
+
const path = await writeStoredConfig(ctx.env, {
|
|
147
|
+
server,
|
|
148
|
+
token: pasted,
|
|
149
|
+
...matched ? {
|
|
150
|
+
token_id: matched.id,
|
|
151
|
+
token_name: matched.name
|
|
152
|
+
} : {},
|
|
153
|
+
token_prefix: prefix,
|
|
154
|
+
audience: "management",
|
|
155
|
+
principal,
|
|
156
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
157
|
+
});
|
|
158
|
+
summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
async function loginWithPassword(ctx, server, stored) {
|
|
134
162
|
if (!canPrompt(ctx)) throw new HngrError({
|
|
135
163
|
type: "auth",
|
|
136
|
-
title: "Logging in needs a terminal",
|
|
164
|
+
title: "Logging in with a password needs a terminal",
|
|
137
165
|
detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
|
|
138
166
|
}, 4);
|
|
139
167
|
const prompter = ctx.io.prompts;
|
|
@@ -152,7 +180,11 @@ async function login(ctx, argv) {
|
|
|
152
180
|
}
|
|
153
181
|
}));
|
|
154
182
|
if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
|
|
155
|
-
const minted = await mintAccessToken(server,
|
|
183
|
+
const minted = await mintAccessToken(server, {
|
|
184
|
+
name: cliTokenName(),
|
|
185
|
+
audience: "management",
|
|
186
|
+
headers: { Cookie: cookie }
|
|
187
|
+
});
|
|
156
188
|
const principal = asStoredPrincipal(await apiGet({
|
|
157
189
|
server,
|
|
158
190
|
token: minted.token
|
|
@@ -165,6 +197,7 @@ async function login(ctx, argv) {
|
|
|
165
197
|
token_id: minted.id,
|
|
166
198
|
token_name: minted.name,
|
|
167
199
|
token_prefix: minted.token_prefix,
|
|
200
|
+
audience: "management",
|
|
168
201
|
principal,
|
|
169
202
|
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
170
203
|
});
|
|
@@ -175,6 +208,144 @@ async function login(ctx, argv) {
|
|
|
175
208
|
summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
|
|
176
209
|
return 0;
|
|
177
210
|
}
|
|
211
|
+
const DEVICE_CODE_PATH = "/api/v1/auth/device/code";
|
|
212
|
+
const DEVICE_TOKEN_PATH = "/api/v1/auth/device/token";
|
|
213
|
+
/** Suggested when the device flow cannot finish; the password flow still works. */
|
|
214
|
+
const PASSWORD_REMEDIATION = "Run `hngr auth login --password` to sign in with a username and password instead.";
|
|
215
|
+
/** Validates the create response; a bad shape is an api error, not a crash. */
|
|
216
|
+
function asDeviceCode(body) {
|
|
217
|
+
const value = body;
|
|
218
|
+
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({
|
|
219
|
+
type: "api",
|
|
220
|
+
title: "The server returned an unexpected device authorization payload",
|
|
221
|
+
detail: "Expected {device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval} from POST /api/v1/auth/device/code."
|
|
222
|
+
});
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
/** Validates the poll success response; the token is stored only when complete. */
|
|
226
|
+
function asDeviceToken(body) {
|
|
227
|
+
const value = body;
|
|
228
|
+
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({
|
|
229
|
+
type: "api",
|
|
230
|
+
title: "The server returned an unexpected device token payload",
|
|
231
|
+
detail: "Expected {token, token_id, token_prefix, name, principal} from POST /api/v1/auth/device/token."
|
|
232
|
+
});
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
function deviceDeniedError() {
|
|
236
|
+
return new HngrError({
|
|
237
|
+
type: "auth",
|
|
238
|
+
title: "The login request was denied",
|
|
239
|
+
detail: PASSWORD_REMEDIATION
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function deviceExpiredError() {
|
|
243
|
+
return new HngrError({
|
|
244
|
+
type: "auth",
|
|
245
|
+
title: "The login request expired",
|
|
246
|
+
detail: PASSWORD_REMEDIATION
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function defaultSleep(ms) {
|
|
250
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
251
|
+
}
|
|
252
|
+
/** Starts the platform opener without waiting for it; failures are silent. */
|
|
253
|
+
function openInBrowser(url) {
|
|
254
|
+
const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
255
|
+
try {
|
|
256
|
+
const child = spawn(command, args, {
|
|
257
|
+
detached: true,
|
|
258
|
+
stdio: "ignore"
|
|
259
|
+
});
|
|
260
|
+
child.on("error", () => {});
|
|
261
|
+
child.unref();
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
/** The device name shown to the approver. */
|
|
265
|
+
function deviceName() {
|
|
266
|
+
return hostname().slice(0, 128);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Polls until the request leaves the pending state. `slow_down` extends the
|
|
270
|
+
* interval permanently by five seconds; network failures are retried until the
|
|
271
|
+
* advertised lifetime runs out. Sleeps and the clock are injectable for tests.
|
|
272
|
+
*/
|
|
273
|
+
async function pollDeviceToken(ctx, server, deviceCode, expiresIn, interval) {
|
|
274
|
+
const now = ctx.io.now ?? Date.now;
|
|
275
|
+
const sleep = ctx.io.sleep ?? defaultSleep;
|
|
276
|
+
const deadline = now() + expiresIn * 1e3;
|
|
277
|
+
let waitSeconds = interval;
|
|
278
|
+
for (;;) {
|
|
279
|
+
if (now() >= deadline) throw deviceExpiredError();
|
|
280
|
+
try {
|
|
281
|
+
return asDeviceToken(await (await apiFetch({ server }, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
path: DEVICE_TOKEN_PATH,
|
|
284
|
+
body: { device_code: deviceCode }
|
|
285
|
+
})).json());
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (!(err instanceof HngrError)) throw err;
|
|
288
|
+
const problem = String(err.body.type);
|
|
289
|
+
if (problem.endsWith("/authorization-pending")) {
|
|
290
|
+
await sleep(waitSeconds * 1e3);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (problem.endsWith("/slow-down")) {
|
|
294
|
+
waitSeconds += 5;
|
|
295
|
+
await sleep(waitSeconds * 1e3);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (problem.endsWith("/access-denied")) throw deviceDeniedError();
|
|
299
|
+
if (problem.endsWith("/expired-token")) throw deviceExpiredError();
|
|
300
|
+
if (err.body.type === "network") {
|
|
301
|
+
await sleep(waitSeconds * 1e3);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** The browser-based login: request a code, wait for approval, store the token. */
|
|
309
|
+
async function loginDevice(ctx, server, stored) {
|
|
310
|
+
const device = asDeviceCode(await (await apiFetch({ server }, {
|
|
311
|
+
method: "POST",
|
|
312
|
+
path: DEVICE_CODE_PATH,
|
|
313
|
+
body: {
|
|
314
|
+
device_name: deviceName(),
|
|
315
|
+
token_name: cliTokenName()
|
|
316
|
+
}
|
|
317
|
+
})).json());
|
|
318
|
+
ctx.detail("To approve this login from a browser:");
|
|
319
|
+
ctx.io.stderr.write(`open ${device.verification_uri}\n`);
|
|
320
|
+
ctx.io.stderr.write(`enter the user code: ${device.user_code}\n`);
|
|
321
|
+
if (ctx.io.stderr.isTTY) try {
|
|
322
|
+
await (ctx.io.openBrowser ?? openInBrowser)(device.verification_uri_complete);
|
|
323
|
+
} catch {}
|
|
324
|
+
const minted = await pollDeviceToken(ctx, server, device.device_code, device.expires_in, device.interval);
|
|
325
|
+
if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
|
|
326
|
+
const principal = asStoredPrincipal(await apiGet({
|
|
327
|
+
server,
|
|
328
|
+
token: minted.token
|
|
329
|
+
}, "/api/v1/me"));
|
|
330
|
+
let path;
|
|
331
|
+
try {
|
|
332
|
+
path = await writeStoredConfig(ctx.env, {
|
|
333
|
+
server,
|
|
334
|
+
token: minted.token,
|
|
335
|
+
token_id: minted.token_id,
|
|
336
|
+
token_name: minted.name,
|
|
337
|
+
token_prefix: minted.token_prefix,
|
|
338
|
+
audience: "management",
|
|
339
|
+
principal,
|
|
340
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
341
|
+
});
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (!await revokeToken(server, minted.token, minted.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
178
349
|
/** Reads the session cookie off the login response; missing cookie is an api error. */
|
|
179
350
|
function sessionCookieOf(response) {
|
|
180
351
|
const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();
|
|
@@ -189,6 +360,10 @@ function sessionCookieOf(response) {
|
|
|
189
360
|
function cliTokenName() {
|
|
190
361
|
return `hngr-cli@${hostname()}`.slice(0, 64);
|
|
191
362
|
}
|
|
363
|
+
/** `hngr-mcp@<hostname>`, so MCP tokens are recognisable in the token list. */
|
|
364
|
+
function cliMCPTokenName() {
|
|
365
|
+
return `hngr-mcp@${hostname()}`.slice(0, 64);
|
|
366
|
+
}
|
|
192
367
|
/**
|
|
193
368
|
* Finds the stored token's id so logout can revoke it. A token list that is
|
|
194
369
|
* unavailable, malformed, or ambiguous leaves the id unknown; login itself
|
|
@@ -217,20 +392,24 @@ async function lookupTokenMetadata(server, token, prefix) {
|
|
|
217
392
|
} : { id: match.id };
|
|
218
393
|
}
|
|
219
394
|
/**
|
|
220
|
-
* Mints an access token with the
|
|
221
|
-
*
|
|
222
|
-
*
|
|
395
|
+
* Mints an access token with the caller's credentials and a random
|
|
396
|
+
* Idempotency-Key. The caller passes either a session cookie (login) or a
|
|
397
|
+
* Bearer token (minting for an audience). A replay response carries no token
|
|
398
|
+
* value: delete the replayed id and mint again under a fresh key.
|
|
223
399
|
*/
|
|
224
|
-
async function mintAccessToken(server,
|
|
400
|
+
async function mintAccessToken(server, options) {
|
|
225
401
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
226
402
|
const response = await apiFetch({ server }, {
|
|
227
403
|
method: "POST",
|
|
228
404
|
path: "/api/v1/me/tokens",
|
|
229
405
|
headers: {
|
|
230
|
-
|
|
406
|
+
...options.headers,
|
|
231
407
|
"Idempotency-Key": randomUUID()
|
|
232
408
|
},
|
|
233
|
-
body: {
|
|
409
|
+
body: {
|
|
410
|
+
name: options.name,
|
|
411
|
+
audience: options.audience
|
|
412
|
+
}
|
|
234
413
|
});
|
|
235
414
|
const body = await response.json();
|
|
236
415
|
if (response.status === 201) {
|
|
@@ -254,7 +433,7 @@ async function mintAccessToken(server, cookie, name) {
|
|
|
254
433
|
await apiFetch({ server }, {
|
|
255
434
|
method: "DELETE",
|
|
256
435
|
path: `/api/v1/me/tokens/${encodeURIComponent(body.id)}`,
|
|
257
|
-
headers:
|
|
436
|
+
headers: options.headers
|
|
258
437
|
});
|
|
259
438
|
}
|
|
260
439
|
throw new HngrError({
|
|
@@ -304,6 +483,7 @@ async function status(ctx, argv) {
|
|
|
304
483
|
const config = await readStoredConfig(ctx.env);
|
|
305
484
|
const source = active.source === "env" ? "env:HANGAR_TOKEN" : configFilePath(ctx.env);
|
|
306
485
|
const tokenPrefix = active.source === "config" && config?.token_prefix ? config.token_prefix : tokenPrefixOf(active.token);
|
|
486
|
+
const audience = active.source === "config" ? storedAudience(config) : "management";
|
|
307
487
|
let principal = config?.principal ?? null;
|
|
308
488
|
if (argv.offline !== true) try {
|
|
309
489
|
principal = await apiGet(connection, "/api/v1/me", void 0, { signal: AbortSignal.timeout(5e3) });
|
|
@@ -315,7 +495,8 @@ async function status(ctx, argv) {
|
|
|
315
495
|
source,
|
|
316
496
|
server: connection.server,
|
|
317
497
|
principal,
|
|
318
|
-
token_prefix: tokenPrefix
|
|
498
|
+
token_prefix: tokenPrefix,
|
|
499
|
+
audience
|
|
319
500
|
});
|
|
320
501
|
return 0;
|
|
321
502
|
}
|