hngr 0.1.1 → 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/README.md CHANGED
@@ -13,14 +13,19 @@ hngr --version
13
13
  ## Authenticate
14
14
 
15
15
  ```sh
16
- # Interactive: username and password, then an access token is written to
16
+ # Interactive: username and password against http://10.199.100.24:26434 (the
17
+ # built-in default), then an access token is written to
17
18
  # ~/.config/hngr/config.json (0600).
19
+ hngr auth login
20
+
21
+ # A different deployment. The built-in default is plain http on a private
22
+ # network; any other host needs https:// (or an explicit --insecure).
18
23
  hngr auth login --server https://hangar.example.com
19
24
 
20
25
  # CI or containers: read an existing access token from stdin.
21
26
  printf '%s' "$HANGAR_TOKEN_VALUE" | hngr auth login --token --server https://hangar.example.com
22
27
 
23
- # Or skip the config file entirely.
28
+ # Or skip the config file entirely. HANGAR_URL overrides the built-in default.
24
29
  export HANGAR_URL=https://hangar.example.com
25
30
  export HANGAR_TOKEN=hng_...
26
31
  hngr auth status
@@ -1,6 +1,7 @@
1
- import { _ as writeStoredConfig, d as deleteStoredConfig, f as normalizeServerUrl, h as resolveConnection, m as redactUrl, n as apiGet, p as readStoredConfig, t as apiFetch, u as configFilePath, x as HngrError } from "./http-DpXNrkSL.mjs";
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. */
@@ -38,8 +39,8 @@ function canPrompt(ctx) {
38
39
  return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts !== void 0;
39
40
  }
40
41
  /**
41
- * Enforces https everywhere; http only for localhost, 127.0.0.1 and [::1],
42
- * or with an explicit --insecure.
42
+ * Enforces https everywhere; http only for localhost, 127.0.0.1, [::1], the
43
+ * built-in default, or with an explicit --insecure.
43
44
  */
44
45
  function assertServerUrlAllowed(raw, insecure) {
45
46
  const server = normalizeServerUrl(raw);
@@ -50,7 +51,7 @@ function assertServerUrlAllowed(raw, insecure) {
50
51
  "localhost",
51
52
  "127.0.0.1",
52
53
  "[::1]"
53
- ].includes(url.hostname) || insecure) return server;
54
+ ].includes(url.hostname) || insecure || server === "http://10.199.100.24:26434") return server;
54
55
  throw new HngrError({
55
56
  type: "config",
56
57
  title: `Refusing insecure server URL ${redactUrl(server)}`,
@@ -92,14 +93,7 @@ async function revokePreviousToken(ctx, stored) {
92
93
  async function login(ctx, argv) {
93
94
  if (ctx.env.HANGAR_TOKEN) throw envTokenRefusal();
94
95
  const stored = await readStoredConfig(ctx.env);
95
- let rawServer = ctx.flags.server ?? ctx.env.HANGAR_URL ?? stored?.server;
96
- if (!rawServer && canPrompt(ctx)) rawServer = await ctx.io.prompts?.text("Server URL");
97
- if (!rawServer) throw new HngrError({
98
- type: "config",
99
- title: "No Hangar server configured",
100
- detail: "Pass --server <url>, set HANGAR_URL, or run `hngr auth login` interactively to store one."
101
- });
102
- const server = assertServerUrlAllowed(rawServer, ctx.flags.insecure);
96
+ const server = assertServerUrlAllowed(ctx.flags.server ?? ctx.env.HANGAR_URL ?? stored?.server ?? "http://10.199.100.24:26434", ctx.flags.insecure);
103
97
  if (stored?.token && !ctx.flags.force) try {
104
98
  const me = asStoredPrincipal(await apiGet({
105
99
  server,
@@ -110,37 +104,42 @@ async function login(ctx, argv) {
110
104
  } catch (err) {
111
105
  if (err instanceof HngrError && err.body.type === "api") throw err;
112
106
  }
113
- if (argv.token === true) {
114
- const pasted = (await ctx.io.stdin.readAll()).trim();
115
- if (!pasted) throw new HngrError({
116
- type: "usage",
117
- title: "No token on stdin",
118
- detail: "Pipe an access token into `hngr auth login --token`."
119
- });
120
- const principal = asStoredPrincipal(await apiGet({
121
- server,
122
- token: pasted
123
- }, "/api/v1/me"));
124
- if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
125
- const prefix = tokenPrefixOf(pasted);
126
- const matched = await lookupTokenMetadata(server, pasted, prefix);
127
- const path = await writeStoredConfig(ctx.env, {
128
- server,
129
- token: pasted,
130
- ...matched ? {
131
- token_id: matched.id,
132
- token_name: matched.name
133
- } : {},
134
- token_prefix: prefix,
135
- principal,
136
- created_at: (/* @__PURE__ */ new Date()).toISOString()
137
- });
138
- summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
139
- return 0;
140
- }
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) {
141
140
  if (!canPrompt(ctx)) throw new HngrError({
142
141
  type: "auth",
143
- title: "Logging in needs a terminal",
142
+ title: "Logging in with a password needs a terminal",
144
143
  detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
145
144
  }, 4);
146
145
  const prompter = ctx.io.prompts;
@@ -182,6 +181,143 @@ async function login(ctx, argv) {
182
181
  summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
183
182
  return 0;
184
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
+ }
185
321
  /** Reads the session cookie off the login response; missing cookie is an api error. */
186
322
  function sessionCookieOf(response) {
187
323
  const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();