hngr 0.1.0 → 0.2.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.
@@ -0,0 +1,13 @@
1
+ import { t as commandCatalog } from "./catalog-jU0-wzh3.mjs";
2
+ // src/commands/commands.ts
3
+ function run(ctx) {
4
+ const catalog = commandCatalog();
5
+ if (ctx.io.stdout.isTTY && !ctx.flags.json) {
6
+ const width = Math.max(...catalog.map((entry) => entry.name.length));
7
+ for (const entry of catalog) ctx.io.stdout.write(`${entry.name.padEnd(width)} ${entry.summary}\n`);
8
+ return 0;
9
+ }
10
+ ctx.writePayload(catalog);
11
+ return 0;
12
+ }
13
+ export { run };
@@ -0,0 +1,459 @@
1
+ import { chmod, lstat, mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ // src/errors.ts
6
+ var HngrError = class extends Error {
7
+ body;
8
+ exitCode;
9
+ constructor(body, exitCode = 1) {
10
+ super(body.title);
11
+ this.name = "HngrError";
12
+ this.body = body;
13
+ this.exitCode = exitCode;
14
+ }
15
+ };
16
+ /** Interactive prompt cancelled via Ctrl+C / Esc; maps to exit code 130. */
17
+ var CancelledError = class extends Error {
18
+ constructor(message = "cancelled") {
19
+ super(message);
20
+ this.name = "CancelledError";
21
+ }
22
+ };
23
+ function bodyOf(err) {
24
+ if (err instanceof HngrError) return {
25
+ body: err.body,
26
+ exitCode: err.exitCode
27
+ };
28
+ return {
29
+ body: {
30
+ type: "io",
31
+ title: err instanceof Error ? err.message : String(err)
32
+ },
33
+ exitCode: 1
34
+ };
35
+ }
36
+ /**
37
+ * Renders an error per the output contract: human text on a TTY (unless
38
+ * --json), otherwise a single-line JSON object on stderr. Returns the exit
39
+ * code. Never prints tokens or passwords; bodies must be pre-redacted.
40
+ */
41
+ function renderError(io, options, err) {
42
+ if (err instanceof CancelledError) {
43
+ if (io.stderr.isTTY && !options.json) io.stderr.write(`${options.colors.dim(err.message)}\n`);
44
+ else io.stderr.write(`${JSON.stringify({ error: {
45
+ type: "usage",
46
+ title: err.message
47
+ } })}\n`);
48
+ return 130;
49
+ }
50
+ const { body, exitCode } = bodyOf(err);
51
+ if (io.stderr.isTTY && !options.json) {
52
+ io.stderr.write(`${options.colors.red(`error: ${body.title}`)}\n`);
53
+ if (body.detail) io.stderr.write(`${options.colors.dim(body.detail)}\n`);
54
+ } else io.stderr.write(`${JSON.stringify({ error: body })}\n`);
55
+ return exitCode;
56
+ }
57
+ // src/atomic.ts
58
+ /**
59
+ * Writes a file through a temp file plus rename, so readers see either the old
60
+ * content or the complete new content.
61
+ */
62
+ async function writeFileAtomically(path, content, options = {}) {
63
+ const dir = dirname(path);
64
+ const temp = resolve(dir, `.${basename(path)}.${randomUUID()}.tmp`);
65
+ try {
66
+ const existed = await stat(dir).then((stats) => stats.isDirectory(), () => false);
67
+ if (options.dirMode === void 0) await mkdir(dir, { recursive: true });
68
+ else {
69
+ await mkdir(dir, {
70
+ recursive: true,
71
+ mode: options.dirMode
72
+ });
73
+ if (!existed) await chmod(dir, options.dirMode);
74
+ }
75
+ await writeFile(temp, content, { mode: options.mode ?? 420 });
76
+ await rename(temp, path);
77
+ } catch (err) {
78
+ await unlink(temp).catch(() => {});
79
+ if (err instanceof HngrError) throw err;
80
+ throw new HngrError({
81
+ type: "io",
82
+ title: `Could not write ${options.label ?? path}`,
83
+ detail: err instanceof Error ? err.message : String(err)
84
+ });
85
+ }
86
+ }
87
+ /**
88
+ * Writes a directory through "temp → rename": the destination is either
89
+ * absent or complete, never half-written. With `force`, the old directory is
90
+ * moved aside before the rename so a failed swap puts it back.
91
+ */
92
+ async function writeDirectoryAtomically(dest, writeTemp, options) {
93
+ const parent = dirname(dest);
94
+ await mkdir(parent, { recursive: true });
95
+ const temp = resolve(parent, `.hngr-${basename(dest)}-${randomUUID()}.tmp`);
96
+ try {
97
+ await writeTemp(temp);
98
+ const exists = await lstat(dest).then(() => true, () => false);
99
+ if (exists && !options.force) throw options.existsError();
100
+ if (!exists) {
101
+ await rename(temp, dest);
102
+ return;
103
+ }
104
+ const backup = resolve(parent, `.hngr-${basename(dest)}-${randomUUID()}.bak`);
105
+ await rename(dest, backup);
106
+ try {
107
+ await rename(temp, dest);
108
+ } catch (err) {
109
+ await rename(backup, dest).catch(() => {});
110
+ throw err;
111
+ }
112
+ await rm(backup, {
113
+ recursive: true,
114
+ force: true
115
+ });
116
+ } catch (err) {
117
+ await rm(temp, {
118
+ recursive: true,
119
+ force: true
120
+ }).catch(() => {});
121
+ if (err instanceof HngrError) throw err;
122
+ throw new HngrError({
123
+ type: "io",
124
+ title: `Could not install into ${options.label ?? dest}`,
125
+ detail: err instanceof Error ? err.message : String(err)
126
+ });
127
+ }
128
+ }
129
+ // src/config.ts
130
+ /** `$XDG_CONFIG_HOME/hngr/config.json`, falling back to `~/.config/hngr/config.json`. */
131
+ function configFilePath(env) {
132
+ const xdg = env.XDG_CONFIG_HOME;
133
+ const home = env.HOME ?? homedir();
134
+ const base = xdg && xdg.length > 0 ? xdg : home ? join(home, ".config") : void 0;
135
+ return base ? join(base, "hngr", "config.json") : void 0;
136
+ }
137
+ /** Reads the stored config; a missing file is not an error, invalid JSON is. */
138
+ async function readStoredConfig(env) {
139
+ const path = configFilePath(env);
140
+ if (!path) return;
141
+ let raw;
142
+ try {
143
+ raw = await readFile(path, "utf8");
144
+ } catch (err) {
145
+ if (err.code === "ENOENT") return;
146
+ throw new HngrError({
147
+ type: "config",
148
+ title: `Could not read ${path}`,
149
+ detail: err instanceof Error ? err.message : String(err)
150
+ });
151
+ }
152
+ try {
153
+ const parsed = JSON.parse(raw);
154
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected a JSON object");
155
+ return parsed;
156
+ } catch (err) {
157
+ throw new HngrError({
158
+ type: "config",
159
+ title: `Invalid config file ${path}`,
160
+ detail: err instanceof Error ? err.message : String(err)
161
+ });
162
+ }
163
+ }
164
+ /**
165
+ * The instance the CLI talks to when `--server`, `HANGAR_URL` and the config
166
+ * file are all absent. Point a deployment elsewhere with any of those.
167
+ */
168
+ const DEFAULT_SERVER_URL = "http://10.199.100.24:26434";
169
+ const INVALID_SERVER_DETAIL = "Pass --server <url>, set HANGAR_URL, or run `hngr auth login` to store one in the config file.";
170
+ /** Parses and normalizes a server URL (no trailing slash); invalid input is a config error. */
171
+ function normalizeServerUrl(server) {
172
+ let url;
173
+ try {
174
+ url = new URL(server);
175
+ } catch {
176
+ throw new HngrError({
177
+ type: "config",
178
+ title: `Invalid server URL: ${redactUrl(server)}`,
179
+ detail: INVALID_SERVER_DETAIL
180
+ });
181
+ }
182
+ return url.toString().replace(/\/+$/, "");
183
+ }
184
+ /**
185
+ * Atomically writes the config file: a `0600` temp file renamed into place
186
+ * inside a `0700` directory. Returns the config path. Never logs the content.
187
+ */
188
+ async function writeStoredConfig(env, config) {
189
+ const path = configFilePath(env);
190
+ if (!path) throw new HngrError({
191
+ type: "config",
192
+ title: "No config location available",
193
+ detail: "Set HOME or XDG_CONFIG_HOME so hngr can store credentials."
194
+ });
195
+ await writeFileAtomically(path, `${JSON.stringify(config, null, 2)}\n`, {
196
+ mode: 384,
197
+ dirMode: 448
198
+ });
199
+ return path;
200
+ }
201
+ /** Deletes the stored config file; a missing file is not an error. */
202
+ async function deleteStoredConfig(env) {
203
+ const path = configFilePath(env);
204
+ if (!path) return;
205
+ try {
206
+ await unlink(path);
207
+ } catch (err) {
208
+ if (err.code !== "ENOENT") throw new HngrError({
209
+ type: "io",
210
+ title: `Could not delete ${path}`,
211
+ detail: err instanceof Error ? err.message : String(err)
212
+ });
213
+ }
214
+ }
215
+ /**
216
+ * Resolves the server and credentials for one call: `--server` beats
217
+ * `HANGAR_URL` beats the config file beats the built-in default; `HANGAR_TOKEN`
218
+ * beats a stored token.
219
+ */
220
+ async function resolveConnection(flags, env) {
221
+ const config = await readStoredConfig(env);
222
+ const server = flags.server ?? env.HANGAR_URL ?? config?.server ?? "http://10.199.100.24:26434";
223
+ const token = env.HANGAR_TOKEN ?? config?.token;
224
+ return {
225
+ server: normalizeServerUrl(server),
226
+ token
227
+ };
228
+ }
229
+ /** Strips any embedded credentials so a URL is safe to print. */
230
+ function redactUrl(raw) {
231
+ try {
232
+ const url = new URL(raw);
233
+ url.username = "";
234
+ url.password = "";
235
+ return url.toString();
236
+ } catch {
237
+ return raw;
238
+ }
239
+ }
240
+ /** Removes any credentials embedded in `server` from free-form error text. */
241
+ function scrubCredentials(text, server) {
242
+ try {
243
+ const url = new URL(server);
244
+ for (const secret of [url.username, url.password]) if (secret) text = text.split(secret).join("<redacted>");
245
+ } catch {}
246
+ return text;
247
+ }
248
+ // ../client/src/problem.ts
249
+ /** A typed transport failure. `detail` is developer text, never the primary message. */
250
+ var ApiError = class ApiError extends Error {
251
+ status;
252
+ type;
253
+ title;
254
+ detail;
255
+ requestId;
256
+ retryAfter;
257
+ problem;
258
+ constructor(init) {
259
+ super(init.title ?? (init.status === 0 ? "网络连接失败" : "请求失败"));
260
+ this.name = "ApiError";
261
+ this.status = init.status;
262
+ this.type = init.type;
263
+ this.title = init.title;
264
+ this.detail = init.detail;
265
+ this.requestId = init.requestId;
266
+ this.retryAfter = init.retryAfter;
267
+ this.problem = init.problem;
268
+ }
269
+ static network() {
270
+ return new ApiError({
271
+ status: 0,
272
+ type: "network"
273
+ });
274
+ }
275
+ };
276
+ /** Narrows a decoded body to a problem document; anything else maps to about:blank. */
277
+ function asProblemDetails(body) {
278
+ if (body === null || typeof body !== "object") return;
279
+ return body;
280
+ }
281
+ /** Parses the `Retry-After` delta-seconds header; absent or invalid means no hint. */
282
+ function parseRetryAfter(header) {
283
+ if (header === null) return;
284
+ const value = Number.parseInt(header, 10);
285
+ return Number.isFinite(value) ? value : void 0;
286
+ }
287
+ /** Maps a decoded problem+json body onto an `ApiError`. */
288
+ function apiErrorFromProblem(init) {
289
+ const problem = asProblemDetails(init.body);
290
+ return new ApiError({
291
+ status: init.status,
292
+ type: problem?.type ?? "about:blank",
293
+ title: problem?.title,
294
+ detail: problem?.detail,
295
+ requestId: init.requestId,
296
+ retryAfter: parseRetryAfter(init.retryAfter ?? null),
297
+ problem
298
+ });
299
+ }
300
+ /** Maps a failed HTTP response onto an `ApiError`; non-JSON bodies degrade to status only. */
301
+ async function apiErrorFromResponse(response, requestId) {
302
+ let body;
303
+ if ((response.headers.get("Content-Type") ?? "").includes("json")) try {
304
+ body = await response.json();
305
+ } catch {
306
+ body = void 0;
307
+ }
308
+ return apiErrorFromProblem({
309
+ status: response.status,
310
+ body,
311
+ retryAfter: response.headers.get("Retry-After"),
312
+ requestId
313
+ });
314
+ }
315
+ // ../client/src/query.ts
316
+ /**
317
+ * Serializes query parameters with `URLSearchParams` semantics (spaces become
318
+ * `+`), dropping `undefined`, `null` and empty-string values. Returns the empty
319
+ * string when nothing survives, so callers can skip the `?` separator.
320
+ */
321
+ function serializeQueryParams(query = {}) {
322
+ const params = new URLSearchParams();
323
+ for (const [key, value] of Object.entries(query)) {
324
+ if (value === void 0 || value === null || value === "") continue;
325
+ params.set(key, String(value));
326
+ }
327
+ return params.toString();
328
+ }
329
+ // src/redact.ts
330
+ const REDACTED = "<redacted>";
331
+ /**
332
+ * One redaction rule for every human-visible representation of a request:
333
+ * a key is sensitive when it contains password, token, secret, authorization
334
+ * or cookie, case-insensitively.
335
+ */
336
+ const SENSITIVE_KEY = /password|token|secret|authorization|cookie/i;
337
+ function isSensitiveKey(key) {
338
+ return SENSITIVE_KEY.test(key);
339
+ }
340
+ /** Recursively replaces sensitive object values with `<redacted>`. */
341
+ function redactSensitive(value) {
342
+ if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
343
+ if (value === null || typeof value !== "object") return value;
344
+ const redacted = {};
345
+ for (const [key, inner] of Object.entries(value)) redacted[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(inner);
346
+ return redacted;
347
+ }
348
+ /** Replaces the value of every sensitive query parameter. */
349
+ function redactQuery(query) {
350
+ const redacted = {};
351
+ for (const [key, value] of Object.entries(query)) redacted[key] = isSensitiveKey(key) ? REDACTED : value;
352
+ return redacted;
353
+ }
354
+ /**
355
+ * A URL safe to print: embedded credentials are removed and sensitive query
356
+ * values are redacted, matching the object redaction rule.
357
+ */
358
+ function redactUrlForDisplay(raw) {
359
+ let url;
360
+ try {
361
+ url = new URL(raw);
362
+ } catch {
363
+ return raw;
364
+ }
365
+ url.username = "";
366
+ url.password = "";
367
+ for (const key of [...url.searchParams.keys()]) if (isSensitiveKey(key)) url.searchParams.set(key, REDACTED);
368
+ return url.toString();
369
+ }
370
+ // src/http.ts
371
+ const AUTH_REMEDIATION = "Run `hngr auth login` or set HANGAR_TOKEN.";
372
+ /** Builds the absolute request URL; the redacted form is safe for dry-run and errors. */
373
+ function buildRequestUrl(connection, path, query) {
374
+ const qs = serializeQueryParams(query);
375
+ const url = `${connection.server}${path}${qs ? `?${qs}` : ""}`;
376
+ return {
377
+ url,
378
+ redactedUrl: redactUrlForDisplay(url)
379
+ };
380
+ }
381
+ /** Maps a failed response or transport error onto the CLI error contract. */
382
+ function toHngrError(err) {
383
+ if (err.status === 401) return new HngrError({
384
+ type: err.type === "about:blank" ? "auth" : err.type,
385
+ title: err.title ?? "Unauthenticated",
386
+ status: err.status,
387
+ requestId: err.requestId,
388
+ detail: err.detail ? `${err.detail} ${AUTH_REMEDIATION}` : AUTH_REMEDIATION
389
+ }, 4);
390
+ let detail = err.detail;
391
+ if (err.retryAfter !== void 0) {
392
+ const hint = `retry after ${err.retryAfter}s`;
393
+ detail = detail ? `${detail} (${hint})` : hint;
394
+ }
395
+ return new HngrError({
396
+ type: err.type,
397
+ title: err.title ?? `Request failed with status ${err.status}`,
398
+ status: err.status,
399
+ requestId: err.requestId,
400
+ detail
401
+ });
402
+ }
403
+ /**
404
+ * Sends one request and returns the raw Response. A Bearer token goes out
405
+ * when the connection has one; non-2xx responses and transport errors become
406
+ * HngrErrors through the same mapping as everything else.
407
+ */
408
+ async function apiRequest(connection, options) {
409
+ const { url } = buildRequestUrl(connection, options.path, options.query);
410
+ const headers = {
411
+ Accept: "application/json",
412
+ ...options.headers
413
+ };
414
+ if (connection.token) headers.Authorization = `Bearer ${connection.token}`;
415
+ const init = {
416
+ method: options.method,
417
+ headers,
418
+ signal: options.signal
419
+ };
420
+ if (options.rawBody !== void 0) init.body = options.rawBody;
421
+ else if (options.body !== void 0) {
422
+ if (!("Content-Type" in headers)) headers["Content-Type"] = "application/json";
423
+ init.body = JSON.stringify(options.body);
424
+ }
425
+ let response;
426
+ try {
427
+ response = await fetch(url, init);
428
+ } catch (err) {
429
+ const origin = redactUrl(connection.server);
430
+ const cause = err instanceof Error ? err.message : String(err);
431
+ throw new HngrError({
432
+ type: "network",
433
+ title: `Could not reach the Hangar server at ${origin}`,
434
+ detail: scrubCredentials(cause, connection.server)
435
+ });
436
+ }
437
+ if (!response.ok) throw toHngrError(await apiErrorFromResponse(response, response.headers.get("X-Request-ID") ?? void 0));
438
+ return response;
439
+ }
440
+ /** GETs a JSON resource; anonymous reads work when no token is configured. */
441
+ async function apiGet(connection, path, query, options) {
442
+ return (await apiRequest(connection, {
443
+ method: "GET",
444
+ path,
445
+ query,
446
+ signal: options?.signal
447
+ })).json();
448
+ }
449
+ /** Sends a non-GET JSON request and returns the raw Response (status, headers). */
450
+ async function apiFetch(connection, options) {
451
+ return apiRequest(connection, {
452
+ method: options.method,
453
+ path: options.path,
454
+ body: options.body,
455
+ headers: options.headers,
456
+ signal: options.signal
457
+ });
458
+ }
459
+ export { renderError as C, HngrError as S, scrubCredentials as _, toHngrError as a, writeFileAtomically as b, redactUrlForDisplay as c, configFilePath as d, deleteStoredConfig as f, resolveConnection as g, redactUrl as h, buildRequestUrl as i, apiErrorFromResponse as l, readStoredConfig as m, apiGet as n, redactQuery as o, normalizeServerUrl as p, apiRequest as r, redactSensitive as s, apiFetch as t, DEFAULT_SERVER_URL as u, writeStoredConfig as v, CancelledError as x, writeDirectoryAtomically as y };