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.
@@ -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
- const pasted = (await ctx.io.stdin.readAll()).trim();
108
- if (!pasted) throw new HngrError({
109
- type: "usage",
110
- title: "No token on stdin",
111
- detail: "Pipe an access token into `hngr auth login --token`."
112
- });
113
- const principal = asStoredPrincipal(await apiGet({
114
- server,
115
- token: pasted
116
- }, "/api/v1/me"));
117
- if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
118
- const prefix = tokenPrefixOf(pasted);
119
- const matched = await lookupTokenMetadata(server, pasted, prefix);
120
- const path = await writeStoredConfig(ctx.env, {
121
- server,
122
- token: pasted,
123
- ...matched ? {
124
- token_id: matched.id,
125
- token_name: matched.name
126
- } : {},
127
- token_prefix: prefix,
128
- principal,
129
- created_at: (/* @__PURE__ */ new Date()).toISOString()
130
- });
131
- summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
132
- return 0;
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();