lacspace-http 0.1.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/cli.js ADDED
@@ -0,0 +1,1284 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { stdout, stderr, argv, exit, env } from "process";
5
+ import { readFileSync, writeFileSync } from "fs";
6
+
7
+ // src/request.ts
8
+ function parseHeaderStrings(raw) {
9
+ const out = [];
10
+ for (const h of raw) {
11
+ const colon = h.indexOf(":");
12
+ if (colon === -1) continue;
13
+ out.push([h.slice(0, colon).trim(), h.slice(colon + 1).trim()]);
14
+ }
15
+ return out;
16
+ }
17
+ function hasHeader(headers, name) {
18
+ return headers.some(([k]) => k.toLowerCase() === name.toLowerCase());
19
+ }
20
+ function coerceKvValue(raw, rawJson) {
21
+ if (rawJson) {
22
+ try {
23
+ return JSON.parse(raw);
24
+ } catch {
25
+ return raw;
26
+ }
27
+ }
28
+ return raw;
29
+ }
30
+ function base64(input) {
31
+ return Buffer.from(input, "utf8").toString("base64");
32
+ }
33
+ function assembleRequest(url, o = {}) {
34
+ const headers = parseHeaderStrings(o.headers ?? []);
35
+ let finalUrl = url;
36
+ if (o.query && o.query.length) {
37
+ const qs = [];
38
+ for (const q of o.query) {
39
+ const eq = q.indexOf("=");
40
+ const k = eq === -1 ? q : q.slice(0, eq);
41
+ const v = eq === -1 ? "" : q.slice(eq + 1);
42
+ qs.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
43
+ }
44
+ const joined = qs.join("&");
45
+ finalUrl += (url.includes("?") ? "&" : "?") + joined;
46
+ }
47
+ let body;
48
+ let contentType;
49
+ if (o.json !== void 0) {
50
+ body = o.json;
51
+ contentType = "application/json";
52
+ } else if (o.jsonKv && o.jsonKv.length) {
53
+ const obj = {};
54
+ for (const pair of o.jsonKv) {
55
+ const rawJson = pair.includes(":=");
56
+ const sep = rawJson ? ":=" : "=";
57
+ const idx = pair.indexOf(sep);
58
+ if (idx === -1) continue;
59
+ const k = pair.slice(0, idx);
60
+ const v = pair.slice(idx + sep.length);
61
+ obj[k] = coerceKvValue(v, rawJson);
62
+ }
63
+ body = JSON.stringify(obj);
64
+ contentType = "application/json";
65
+ } else if (o.form && o.form.length) {
66
+ const params = new URLSearchParams();
67
+ for (const f of o.form) {
68
+ const eq = f.indexOf("=");
69
+ params.append(eq === -1 ? f : f.slice(0, eq), eq === -1 ? "" : f.slice(eq + 1));
70
+ }
71
+ body = params.toString();
72
+ contentType = "application/x-www-form-urlencoded";
73
+ } else if (o.data !== void 0) {
74
+ body = o.data;
75
+ }
76
+ if (contentType && !hasHeader(headers, "content-type")) {
77
+ headers.push(["Content-Type", contentType]);
78
+ }
79
+ if (o.bearer && !hasHeader(headers, "authorization")) {
80
+ headers.push(["Authorization", `Bearer ${o.bearer}`]);
81
+ } else if (o.user && !hasHeader(headers, "authorization")) {
82
+ headers.push(["Authorization", `Basic ${base64(o.user)}`]);
83
+ }
84
+ const method = (o.method ?? (body !== void 0 ? "POST" : "GET")).toUpperCase();
85
+ const spec = { method, url: finalUrl, headers };
86
+ if (body !== void 0) spec.body = body;
87
+ return spec;
88
+ }
89
+ var REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
90
+ function headersToObject(h) {
91
+ const out = {};
92
+ if (h && typeof h.forEach === "function") {
93
+ h.forEach((v, k) => {
94
+ out[k.toLowerCase()] = v;
95
+ });
96
+ } else if (h && typeof h === "object") {
97
+ for (const [k, v] of Object.entries(h)) out[k.toLowerCase()] = String(v);
98
+ }
99
+ return out;
100
+ }
101
+ async function readCapped(res, maxSize) {
102
+ const body = res.body;
103
+ if (!body || typeof body.getReader !== "function") {
104
+ const text = await res.text();
105
+ const buf2 = Buffer.from(text, "utf8");
106
+ if (buf2.length > maxSize) {
107
+ return { text: buf2.subarray(0, maxSize).toString("utf8"), size: maxSize, truncated: true };
108
+ }
109
+ return { text, size: buf2.length, truncated: false };
110
+ }
111
+ const reader = body.getReader();
112
+ const chunks = [];
113
+ let size = 0;
114
+ let truncated = false;
115
+ for (; ; ) {
116
+ const { done, value } = await reader.read();
117
+ if (done) break;
118
+ if (value) {
119
+ chunks.push(Buffer.from(value));
120
+ size += value.byteLength;
121
+ if (size > maxSize) {
122
+ truncated = true;
123
+ try {
124
+ await reader.cancel();
125
+ } catch {
126
+ }
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ let buf = Buffer.concat(chunks);
132
+ if (truncated) buf = buf.subarray(0, maxSize);
133
+ return { text: buf.toString("utf8"), size: buf.length, truncated };
134
+ }
135
+ function looksJson(contentType, text) {
136
+ if (contentType && /\bjson\b/i.test(contentType)) return true;
137
+ const t = text.trimStart();
138
+ return t.startsWith("{") || t.startsWith("[");
139
+ }
140
+ function hostOf(url) {
141
+ try {
142
+ return new URL(url).host;
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+ async function sendRequest(spec, opts = {}) {
148
+ const timeoutMs = opts.timeoutMs ?? 3e4;
149
+ const maxSize = opts.maxSize ?? 10 * 1024 * 1024;
150
+ const maxRedirects = opts.maxRedirects ?? 5;
151
+ const follow = opts.followRedirects ?? true;
152
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
153
+ if (typeof doFetch !== "function") {
154
+ throw new Error("global fetch is unavailable \u2014 Node 20+ (or a fetchImpl) is required.");
155
+ }
156
+ const originHost = hostOf(spec.url);
157
+ let currentUrl = spec.url;
158
+ let method = spec.method;
159
+ let body = spec.body;
160
+ const redirectChain = [];
161
+ let crossHost = false;
162
+ const start = Date.now();
163
+ for (let hop = 0; ; hop++) {
164
+ const controller = new AbortController();
165
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
166
+ let res;
167
+ try {
168
+ res = await doFetch(currentUrl, {
169
+ method,
170
+ headers: spec.headers,
171
+ ...body !== void 0 ? { body } : {},
172
+ redirect: "manual",
173
+ signal: controller.signal
174
+ });
175
+ } catch (err) {
176
+ clearTimeout(timer);
177
+ if (err?.name === "AbortError") {
178
+ throw new Error(`Request timed out after ${timeoutMs}ms: ${currentUrl}`);
179
+ }
180
+ throw err;
181
+ }
182
+ clearTimeout(timer);
183
+ const location = res.headers.get?.("location") ?? void 0;
184
+ if (follow && REDIRECT_STATUS.has(res.status) && location && hop < maxRedirects) {
185
+ const nextUrl = new URL(location, currentUrl).toString();
186
+ if (hostOf(nextUrl) !== originHost) crossHost = true;
187
+ redirectChain.push(currentUrl);
188
+ if (res.status === 303 || (res.status === 301 || res.status === 302) && method === "POST") {
189
+ method = "GET";
190
+ body = void 0;
191
+ }
192
+ currentUrl = nextUrl;
193
+ try {
194
+ await res.text();
195
+ } catch {
196
+ }
197
+ continue;
198
+ }
199
+ const headers = headersToObject(res.headers);
200
+ const { text, size, truncated } = await readCapped(res, maxSize);
201
+ const timeMs = Date.now() - start;
202
+ const record = {
203
+ status: res.status,
204
+ statusText: res.statusText || "",
205
+ headers,
206
+ timeMs,
207
+ size,
208
+ body: text,
209
+ url: currentUrl,
210
+ redirected: redirectChain.length > 0,
211
+ redirectChain,
212
+ ok: res.status >= 200 && res.status < 300,
213
+ truncated,
214
+ crossHostRedirect: crossHost
215
+ };
216
+ if (looksJson(headers["content-type"], text)) {
217
+ try {
218
+ record.json = JSON.parse(text);
219
+ } catch {
220
+ }
221
+ }
222
+ return record;
223
+ }
224
+ }
225
+ function shq(s) {
226
+ return `'${s.replace(/'/g, `'\\''`)}'`;
227
+ }
228
+ function toCurl(spec, opts = {}) {
229
+ const parts = ["curl"];
230
+ if (spec.method !== "GET") parts.push("-X", spec.method);
231
+ for (const [k, v] of spec.headers) {
232
+ let value = v;
233
+ if (!opts.showSecrets && k.toLowerCase() === "authorization") {
234
+ const scheme = v.split(/\s+/)[0] ?? "";
235
+ value = /^(Bearer|Basic)$/i.test(scheme) ? `${scheme} ***` : "***";
236
+ }
237
+ parts.push("-H", shq(`${k}: ${value}`));
238
+ }
239
+ if (spec.body !== void 0) parts.push("--data-raw", shq(spec.body));
240
+ if (opts.followRedirects === false) {
241
+ } else {
242
+ parts.push("-L");
243
+ if (opts.maxRedirects !== void 0) parts.push("--max-redirs", String(opts.maxRedirects));
244
+ }
245
+ parts.push(shq(spec.url));
246
+ return parts.join(" ");
247
+ }
248
+
249
+ // src/httpfile.ts
250
+ var METHODS = /* @__PURE__ */ new Set([
251
+ "GET",
252
+ "HEAD",
253
+ "POST",
254
+ "PUT",
255
+ "PATCH",
256
+ "DELETE",
257
+ "OPTIONS",
258
+ "TRACE",
259
+ "CONNECT"
260
+ ]);
261
+ function isSeparator(line) {
262
+ return /^###/.test(line.trim());
263
+ }
264
+ function isComment(line) {
265
+ const t = line.trim();
266
+ return t.startsWith("#") || t.startsWith("//");
267
+ }
268
+ function commentBody(line) {
269
+ const t = line.trim();
270
+ if (t.startsWith("//")) return t.slice(2).trim();
271
+ return t.replace(/^#+/, "").trim();
272
+ }
273
+ function parseRequestLine(line) {
274
+ const parts = line.trim().split(/\s+/);
275
+ let method = "GET";
276
+ let rest = parts;
277
+ const first = (parts[0] ?? "").toUpperCase();
278
+ if (METHODS.has(first)) {
279
+ method = first;
280
+ rest = parts.slice(1);
281
+ }
282
+ if (rest.length > 1 && /^HTTP\/\d/i.test(rest[rest.length - 1])) rest = rest.slice(0, -1);
283
+ const url = rest.join(" ").trim();
284
+ return { method, url };
285
+ }
286
+ function parseHttpFile(source) {
287
+ const rawLines = source.split(/\r?\n/);
288
+ const blocks = [];
289
+ let current = { start: 0, lines: [] };
290
+ rawLines.forEach((line, idx) => {
291
+ if (isSeparator(line)) {
292
+ blocks.push(current);
293
+ current = { start: idx + 1, lines: [] };
294
+ } else {
295
+ current.lines.push(line);
296
+ }
297
+ });
298
+ blocks.push(current);
299
+ const requests = [];
300
+ for (const block of blocks) {
301
+ const req = parseBlock(block.lines, block.start);
302
+ if (req) requests.push(req);
303
+ }
304
+ return requests;
305
+ }
306
+ function parseBlock(lines, startLine) {
307
+ let name;
308
+ const captures = [];
309
+ const assertions = [];
310
+ let i = 0;
311
+ for (; i < lines.length; i++) {
312
+ const line = lines[i];
313
+ if (line.trim() === "") continue;
314
+ if (!isComment(line)) break;
315
+ const body2 = commentBody(line);
316
+ const at = /^@(\w+)\s*(.*)$/.exec(body2);
317
+ if (at) {
318
+ const directive2 = at[1].toLowerCase();
319
+ const arg = at[2].trim();
320
+ if (directive2 === "name") name = arg.replace(/^=?\s*/, "").trim() || void 0;
321
+ else if (directive2 === "capture") {
322
+ const eq = arg.indexOf("=");
323
+ if (eq !== -1) {
324
+ const cname = arg.slice(0, eq).trim();
325
+ const source = arg.slice(eq + 1).trim();
326
+ if (cname && source) captures.push({ name: cname, source });
327
+ }
328
+ } else if (directive2 === "assert") {
329
+ if (arg) assertions.push({ expr: arg });
330
+ }
331
+ }
332
+ }
333
+ for (; i < lines.length; i++) {
334
+ if (lines[i].trim() === "") continue;
335
+ break;
336
+ }
337
+ if (i >= lines.length) return void 0;
338
+ const requestLineIdx = i;
339
+ const { method, url: firstUrl } = parseRequestLine(lines[i]);
340
+ i++;
341
+ let url = firstUrl;
342
+ while (i < lines.length) {
343
+ const raw = lines[i];
344
+ const t = raw.trim();
345
+ if (t !== "" && /^[?&]/.test(t) && /^\s/.test(raw)) {
346
+ url += t;
347
+ i++;
348
+ } else break;
349
+ }
350
+ const headers = [];
351
+ for (; i < lines.length; i++) {
352
+ const line = lines[i];
353
+ if (line.trim() === "") {
354
+ i++;
355
+ break;
356
+ }
357
+ if (isComment(line)) {
358
+ const body2 = commentBody(line);
359
+ const at = /^@(\w+)\s*(.*)$/.exec(body2);
360
+ if (at) {
361
+ const directive2 = at[1].toLowerCase();
362
+ const arg = at[2].trim();
363
+ if (directive2 === "assert" && arg) assertions.push({ expr: arg });
364
+ else if (directive2 === "capture") {
365
+ const eq = arg.indexOf("=");
366
+ if (eq !== -1) captures.push({ name: arg.slice(0, eq).trim(), source: arg.slice(eq + 1).trim() });
367
+ }
368
+ }
369
+ continue;
370
+ }
371
+ const colon = line.indexOf(":");
372
+ if (colon === -1) continue;
373
+ const key = line.slice(0, colon).trim();
374
+ const value = line.slice(colon + 1).trim();
375
+ if (key) headers.push([key, value]);
376
+ }
377
+ const bodyLines = [];
378
+ const directive = /^\s*(?:#+|\/\/)\s*@(name|capture|assert)\b\s*(.*)$/;
379
+ for (const line of lines.slice(i)) {
380
+ const m = directive.exec(line);
381
+ if (m) {
382
+ const kind = m[1].toLowerCase();
383
+ const arg = m[2].trim();
384
+ if (kind === "name") {
385
+ if (arg) name = arg.replace(/^=?\s*/, "").trim();
386
+ } else if (kind === "capture") {
387
+ const eq = arg.indexOf("=");
388
+ if (eq !== -1) {
389
+ const cname = arg.slice(0, eq).trim();
390
+ const source = arg.slice(eq + 1).trim();
391
+ if (cname && source) captures.push({ name: cname, source });
392
+ }
393
+ } else if (kind === "assert" && arg) {
394
+ assertions.push({ expr: arg });
395
+ }
396
+ continue;
397
+ }
398
+ bodyLines.push(line);
399
+ }
400
+ const bodyText = bodyLines.join("\n").replace(/^\n+/, "").replace(/\s+$/, "");
401
+ const body = bodyText.length > 0 ? bodyText : void 0;
402
+ const req = {
403
+ method,
404
+ url,
405
+ headers,
406
+ captures,
407
+ assertions,
408
+ line: startLine + requestLineIdx + 1
409
+ };
410
+ if (name !== void 0) req.name = name;
411
+ if (body !== void 0) req.body = body;
412
+ return req;
413
+ }
414
+
415
+ // src/jsonpath.ts
416
+ var IDENT = /[A-Za-z0-9_$-]/;
417
+ function parseJsonPath(path) {
418
+ const s = path.trim();
419
+ const segs = [];
420
+ let i = 0;
421
+ if (s[i] === "$") i++;
422
+ if (segs.length === 0 && i < s.length && s[i] !== "." && s[i] !== "[") {
423
+ let j = i;
424
+ while (j < s.length && IDENT.test(s[j])) j++;
425
+ if (j === i) throw new Error(`Invalid JSON path: "${path}"`);
426
+ segs.push({ type: "key", key: s.slice(i, j) });
427
+ i = j;
428
+ }
429
+ while (i < s.length) {
430
+ const ch = s[i];
431
+ if (ch === ".") {
432
+ i++;
433
+ let j = i;
434
+ while (j < s.length && IDENT.test(s[j])) j++;
435
+ if (j === i) throw new Error(`Invalid JSON path: "${path}"`);
436
+ segs.push({ type: "key", key: s.slice(i, j) });
437
+ i = j;
438
+ } else if (ch === "[") {
439
+ i++;
440
+ const quote = s[i];
441
+ if (quote === "'" || quote === '"') {
442
+ i++;
443
+ let j = i;
444
+ while (j < s.length && s[j] !== quote) j++;
445
+ if (j >= s.length) throw new Error(`Unterminated quote in JSON path: "${path}"`);
446
+ segs.push({ type: "key", key: s.slice(i, j) });
447
+ i = j + 1;
448
+ } else {
449
+ let j = i;
450
+ while (j < s.length && s[j] !== "]") j++;
451
+ const raw = s.slice(i, j).trim();
452
+ const num = Number(raw);
453
+ if (raw === "" || !Number.isInteger(num)) {
454
+ throw new Error(`Invalid array index "${raw}" in JSON path: "${path}"`);
455
+ }
456
+ segs.push({ type: "index", index: num });
457
+ i = j;
458
+ }
459
+ if (s[i] !== "]") throw new Error(`Expected "]" in JSON path: "${path}"`);
460
+ i++;
461
+ } else {
462
+ throw new Error(`Unexpected "${ch}" in JSON path: "${path}"`);
463
+ }
464
+ }
465
+ return segs;
466
+ }
467
+ function evalPath(root, path) {
468
+ const segs = parseJsonPath(path);
469
+ let cur = root;
470
+ for (const seg of segs) {
471
+ if (cur === null || cur === void 0) return void 0;
472
+ if (seg.type === "key") {
473
+ if (typeof cur !== "object") return void 0;
474
+ cur = cur[seg.key];
475
+ } else {
476
+ if (!Array.isArray(cur)) return void 0;
477
+ const idx = seg.index < 0 ? cur.length + seg.index : seg.index;
478
+ cur = cur[idx];
479
+ }
480
+ }
481
+ return cur;
482
+ }
483
+
484
+ // src/assert.ts
485
+ var BINARY_OPS = ["==", "!=", "<=", ">=", "<", ">", "contains", "matches"];
486
+ var UNARY_OPS = ["exists", "empty"];
487
+ function parseAssertion(expr) {
488
+ const raw = expr.trim();
489
+ const tokens = raw.split(/\s+/);
490
+ const last = tokens[tokens.length - 1] ?? "";
491
+ if (UNARY_OPS.includes(last) && tokens.length === 2) {
492
+ return { lhs: tokens[0], op: last, raw };
493
+ }
494
+ for (let i = 1; i < tokens.length - 1; i++) {
495
+ const t = tokens[i];
496
+ if (BINARY_OPS.includes(t)) {
497
+ const lhs = tokens.slice(0, i).join(" ");
498
+ const rhs = tokens.slice(i + 1).join(" ");
499
+ return { lhs, op: t, rhs, raw };
500
+ }
501
+ }
502
+ throw new Error(`Cannot parse assertion: "${raw}"`);
503
+ }
504
+ function resolveLhs(lhs, rec) {
505
+ const l = lhs.trim();
506
+ if (l === "status") return rec.status;
507
+ if (l === "time" || l === "duration") return rec.timeMs;
508
+ if (l === "size") return rec.size;
509
+ if (l === "body") return rec.body;
510
+ if (l === "url") return rec.url;
511
+ if (l.startsWith("body.")) {
512
+ const path = l.slice(5);
513
+ const root = rec.json !== void 0 ? rec.json : tryParse(rec.body);
514
+ return evalPath(root, path);
515
+ }
516
+ if (l.startsWith("header.")) {
517
+ return rec.headers[l.slice(7).toLowerCase()];
518
+ }
519
+ return void 0;
520
+ }
521
+ function tryParse(text) {
522
+ try {
523
+ return JSON.parse(text);
524
+ } catch {
525
+ return void 0;
526
+ }
527
+ }
528
+ function parseLiteral(raw) {
529
+ const s = raw.trim();
530
+ if (s.startsWith('"') && s.endsWith('"') && s.length >= 2 || s.startsWith("'") && s.endsWith("'") && s.length >= 2) {
531
+ return s.slice(1, -1);
532
+ }
533
+ if (s === "true") return true;
534
+ if (s === "false") return false;
535
+ if (s === "null") return null;
536
+ if (s !== "" && !Number.isNaN(Number(s))) return Number(s);
537
+ return s;
538
+ }
539
+ function isEmpty(v) {
540
+ if (v === void 0 || v === null) return true;
541
+ if (typeof v === "string") return v.length === 0;
542
+ if (Array.isArray(v)) return v.length === 0;
543
+ if (typeof v === "object") return Object.keys(v).length === 0;
544
+ return false;
545
+ }
546
+ function looseEqual(actual, expected) {
547
+ if (typeof expected === "number") return Number(actual) === expected;
548
+ if (typeof expected === "boolean") {
549
+ if (typeof actual === "boolean") return actual === expected;
550
+ return String(actual) === String(expected);
551
+ }
552
+ if (expected === null) return actual === null || actual === void 0;
553
+ return String(actual) === String(expected);
554
+ }
555
+ function evalAssertion(assertion, rec) {
556
+ const actual = resolveLhs(assertion.lhs, rec);
557
+ const expected = assertion.rhs !== void 0 ? parseLiteral(assertion.rhs) : void 0;
558
+ let ok = false;
559
+ switch (assertion.op) {
560
+ case "==":
561
+ ok = looseEqual(actual, expected);
562
+ break;
563
+ case "!=":
564
+ ok = !looseEqual(actual, expected);
565
+ break;
566
+ case "<":
567
+ ok = Number(actual) < Number(expected);
568
+ break;
569
+ case "<=":
570
+ ok = Number(actual) <= Number(expected);
571
+ break;
572
+ case ">":
573
+ ok = Number(actual) > Number(expected);
574
+ break;
575
+ case ">=":
576
+ ok = Number(actual) >= Number(expected);
577
+ break;
578
+ case "contains": {
579
+ if (Array.isArray(actual)) ok = actual.map((x) => String(x)).includes(String(expected));
580
+ else ok = String(actual).toLowerCase().includes(String(expected).toLowerCase());
581
+ break;
582
+ }
583
+ case "matches": {
584
+ try {
585
+ ok = new RegExp(String(expected)).test(String(actual));
586
+ } catch {
587
+ ok = false;
588
+ }
589
+ break;
590
+ }
591
+ case "exists":
592
+ ok = actual !== void 0 && actual !== null;
593
+ break;
594
+ case "empty":
595
+ ok = isEmpty(actual);
596
+ break;
597
+ }
598
+ const shownActual = typeof actual === "object" ? JSON.stringify(actual) : String(actual);
599
+ const message = assertion.rhs !== void 0 ? `${assertion.lhs} ${assertion.op} ${assertion.rhs} (actual: ${shownActual})` : `${assertion.lhs} ${assertion.op} (actual: ${shownActual})`;
600
+ const result = { ok, assertion, actual, message };
601
+ if (expected !== void 0) result.expected = expected;
602
+ return result;
603
+ }
604
+ function captureValue(source, rec) {
605
+ const v = resolveLhs(source, rec);
606
+ if (v === void 0 || v === null) return void 0;
607
+ return typeof v === "object" ? JSON.stringify(v) : String(v);
608
+ }
609
+
610
+ // src/vars.ts
611
+ import { randomUUID } from "crypto";
612
+ function makeScope(...sources) {
613
+ const scope = /* @__PURE__ */ new Map();
614
+ for (const src of sources) {
615
+ if (!src) continue;
616
+ for (const [k, v] of Object.entries(src)) scope.set(k, v);
617
+ }
618
+ return scope;
619
+ }
620
+ function resolveOne(expr, scope) {
621
+ if (scope.has(expr)) return scope.get(expr);
622
+ if (expr.startsWith("$")) {
623
+ const [name, ...rest] = expr.slice(1).split(/\s+/);
624
+ switch (name) {
625
+ case "timestamp":
626
+ return String(Math.floor(Date.now() / 1e3));
627
+ case "isoTimestamp":
628
+ case "datetime":
629
+ return (/* @__PURE__ */ new Date()).toISOString();
630
+ case "guid":
631
+ case "uuid":
632
+ return randomUUID();
633
+ case "randomInt": {
634
+ const min = Number(rest[0] ?? 0);
635
+ const max = Number(rest[1] ?? 100);
636
+ if (Number.isNaN(min) || Number.isNaN(max) || max < min) return void 0;
637
+ return String(min + Math.floor(Math.random() * (max - min + 1)));
638
+ }
639
+ case "processEnv":
640
+ case "env": {
641
+ const key = rest[0];
642
+ return key ? process.env[key] : void 0;
643
+ }
644
+ default:
645
+ return void 0;
646
+ }
647
+ }
648
+ return void 0;
649
+ }
650
+ function resolveVars(text, scope) {
651
+ const missing = [];
652
+ const out = text.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (whole, rawExpr) => {
653
+ const expr = rawExpr.trim();
654
+ const val = resolveOne(expr, scope);
655
+ if (val === void 0) {
656
+ missing.push(expr);
657
+ return whole;
658
+ }
659
+ return val;
660
+ });
661
+ return { text: out, missing };
662
+ }
663
+ function parseEnvJson(json, envName) {
664
+ let doc;
665
+ try {
666
+ doc = JSON.parse(json);
667
+ } catch (err) {
668
+ throw new Error(`Invalid env JSON: ${err.message}`);
669
+ }
670
+ if (typeof doc !== "object" || doc === null) {
671
+ throw new Error("Env JSON must be an object of { envName: { key: value } }.");
672
+ }
673
+ const record = doc;
674
+ const flatten = (block) => {
675
+ const out = {};
676
+ if (typeof block !== "object" || block === null) return out;
677
+ for (const [k, v] of Object.entries(block)) {
678
+ out[k] = typeof v === "string" ? v : JSON.stringify(v);
679
+ }
680
+ return out;
681
+ };
682
+ const shared = flatten(record["$shared"]);
683
+ if (!(envName in record)) {
684
+ const names = Object.keys(record).filter((n) => n !== "$shared");
685
+ throw new Error(`Environment "${envName}" not found. Available: ${names.join(", ") || "(none)"}`);
686
+ }
687
+ return { ...shared, ...flatten(record[envName]) };
688
+ }
689
+ function parseDotenv(text) {
690
+ const out = {};
691
+ for (const line of text.split(/\r?\n/)) {
692
+ const trimmed = line.trim();
693
+ if (!trimmed || trimmed.startsWith("#")) continue;
694
+ const eq = trimmed.indexOf("=");
695
+ if (eq === -1) continue;
696
+ const key = trimmed.slice(0, eq).trim().replace(/^export\s+/, "");
697
+ let val = trimmed.slice(eq + 1).trim();
698
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
699
+ val = val.slice(1, -1);
700
+ }
701
+ if (key) out[key] = val;
702
+ }
703
+ return out;
704
+ }
705
+ function parseKvPairs(pairs) {
706
+ const out = {};
707
+ for (const p of pairs) {
708
+ const eq = p.indexOf("=");
709
+ if (eq === -1) continue;
710
+ out[p.slice(0, eq).trim()] = p.slice(eq + 1);
711
+ }
712
+ return out;
713
+ }
714
+
715
+ // src/runner.ts
716
+ function resolveSpec(req, scope) {
717
+ const missing = [];
718
+ const collect = (r) => {
719
+ missing.push(...r.missing);
720
+ return r.text;
721
+ };
722
+ const url = collect(resolveVars(req.url, scope));
723
+ const headers = req.headers.map(([k, v]) => [
724
+ collect(resolveVars(k, scope)),
725
+ collect(resolveVars(v, scope))
726
+ ]);
727
+ const spec = { method: req.method, url, headers };
728
+ if (req.body !== void 0) spec.body = collect(resolveVars(req.body, scope));
729
+ return { spec, missing };
730
+ }
731
+ async function runHttpFile(source, opts = {}) {
732
+ const requests = parseHttpFile(source);
733
+ const scope = makeScope(opts.env, opts.vars);
734
+ const results = [];
735
+ const selected = opts.name ? requests.filter((r) => r.name === opts.name) : requests;
736
+ const sendOpts = {};
737
+ if (opts.timeoutMs !== void 0) sendOpts.timeoutMs = opts.timeoutMs;
738
+ if (opts.maxSize !== void 0) sendOpts.maxSize = opts.maxSize;
739
+ if (opts.maxRedirects !== void 0) sendOpts.maxRedirects = opts.maxRedirects;
740
+ if (opts.followRedirects !== void 0) sendOpts.followRedirects = opts.followRedirects;
741
+ if (opts.fetchImpl !== void 0) sendOpts.fetchImpl = opts.fetchImpl;
742
+ for (const req of selected) {
743
+ const { spec, missing } = resolveSpec(req, scope);
744
+ const result = {
745
+ method: spec.method,
746
+ url: spec.url,
747
+ line: req.line,
748
+ missingVars: missing,
749
+ assertions: [],
750
+ captured: {},
751
+ ok: false
752
+ };
753
+ if (req.name !== void 0) result.name = req.name;
754
+ try {
755
+ const rec = await sendRequest(spec, sendOpts);
756
+ result.response = rec;
757
+ for (const cap of req.captures) {
758
+ const value = captureValue(cap.source, rec);
759
+ if (value !== void 0) {
760
+ result.captured[cap.name] = value;
761
+ scope.set(cap.name, value);
762
+ }
763
+ }
764
+ let allPass = true;
765
+ for (const a of req.assertions) {
766
+ try {
767
+ const ar = evalAssertion(parseAssertion(a.expr), rec);
768
+ result.assertions.push(ar);
769
+ if (!ar.ok) allPass = false;
770
+ } catch (err) {
771
+ result.assertions.push({
772
+ ok: false,
773
+ assertion: { lhs: a.expr, op: "==", raw: a.expr },
774
+ actual: void 0,
775
+ message: `invalid assertion "${a.expr}": ${err.message}`
776
+ });
777
+ allPass = false;
778
+ }
779
+ }
780
+ result.ok = allPass && missing.length === 0;
781
+ } catch (err) {
782
+ result.error = err.message;
783
+ result.ok = false;
784
+ }
785
+ results.push(result);
786
+ opts.onResult?.(result);
787
+ }
788
+ const passed = results.filter((r) => r.ok).length;
789
+ const failed = results.length - passed;
790
+ return { results, passed, failed, ok: failed === 0 && results.length > 0 };
791
+ }
792
+
793
+ // src/format.ts
794
+ var ANSI = {
795
+ reset: "\x1B[0m",
796
+ dim: "\x1B[2m",
797
+ green: "\x1B[32m",
798
+ red: "\x1B[31m",
799
+ yellow: "\x1B[33m",
800
+ cyan: "\x1B[36m",
801
+ magenta: "\x1B[35m",
802
+ blue: "\x1B[34m"
803
+ };
804
+ function humanSize(bytes) {
805
+ if (bytes < 1024) return `${bytes} B`;
806
+ const units = ["KB", "MB", "GB", "TB"];
807
+ let n = bytes / 1024;
808
+ let i = 0;
809
+ while (n >= 1024 && i < units.length - 1) {
810
+ n /= 1024;
811
+ i++;
812
+ }
813
+ return `${n.toFixed(1)} ${units[i]}`;
814
+ }
815
+ function statusColor(status) {
816
+ if (status >= 200 && status < 300) return "green";
817
+ if (status >= 300 && status < 400) return "cyan";
818
+ if (status >= 400 && status < 500) return "yellow";
819
+ return "red";
820
+ }
821
+ function paint(color, s, enabled) {
822
+ return enabled ? `${ANSI[color]}${s}${ANSI.reset}` : s;
823
+ }
824
+ function prettyJson(value, color = false) {
825
+ const plain = JSON.stringify(value, null, 2);
826
+ if (plain === void 0) return String(value);
827
+ if (!color) return plain;
828
+ return plain.replace(
829
+ /("(?:\\.|[^"\\])*"(\s*:)?)|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
830
+ (match, str, colon, lit) => {
831
+ if (str !== void 0) {
832
+ if (colon) return paint("blue", str.slice(0, str.length - colon.length), true) + colon;
833
+ return paint("green", str, true);
834
+ }
835
+ if (lit !== void 0) return paint("yellow", lit, true);
836
+ return paint("cyan", match, true);
837
+ }
838
+ );
839
+ }
840
+
841
+ // src/cli.ts
842
+ var VERSION = "0.1.0";
843
+ var NO_COLOR = !!env["NO_COLOR"];
844
+ var COLOR = !NO_COLOR;
845
+ var C = {
846
+ reset: "\x1B[0m",
847
+ bold: "\x1B[1m",
848
+ dim: "\x1B[2m",
849
+ green: "\x1B[32m",
850
+ cyan: "\x1B[36m",
851
+ yellow: "\x1B[33m",
852
+ red: "\x1B[31m",
853
+ magenta: "\x1B[35m",
854
+ blue: "\x1B[34m"
855
+ };
856
+ var c = (k, s) => COLOR ? `${C[k]}${s}${C.reset}` : s;
857
+ var log = (s = "") => void stderr.write(s + "\n");
858
+ var METHODS2 = /* @__PURE__ */ new Set([
859
+ "GET",
860
+ "HEAD",
861
+ "POST",
862
+ "PUT",
863
+ "PATCH",
864
+ "DELETE",
865
+ "OPTIONS",
866
+ "TRACE",
867
+ "CONNECT"
868
+ ]);
869
+ function parseSize(raw) {
870
+ const m = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(raw.trim());
871
+ if (!m) return Number(raw);
872
+ const n = Number(m[1]);
873
+ const unit = (m[2] ?? "b").toLowerCase();
874
+ const mult = unit === "kb" ? 1024 : unit === "mb" ? 1024 ** 2 : unit === "gb" ? 1024 ** 3 : 1;
875
+ return Math.round(n * mult);
876
+ }
877
+ function parseArgs(list) {
878
+ const a = {
879
+ positional: [],
880
+ headers: [],
881
+ query: [],
882
+ form: [],
883
+ jsonKv: [],
884
+ jsonOut: false,
885
+ noRedirect: false,
886
+ include: false,
887
+ verbose: false,
888
+ fail: false,
889
+ curl: false,
890
+ showSecrets: false,
891
+ vars: [],
892
+ help: false,
893
+ version: false
894
+ };
895
+ for (let i = 0; i < list.length; i++) {
896
+ const arg = list[i];
897
+ const nextVal = () => list[++i] ?? "";
898
+ switch (arg) {
899
+ case "-H":
900
+ case "--header":
901
+ a.headers.push(nextVal());
902
+ break;
903
+ case "-q":
904
+ case "--query":
905
+ a.query.push(nextVal());
906
+ break;
907
+ case "--form":
908
+ a.form.push(nextVal());
909
+ break;
910
+ case "-j":
911
+ a.jsonKv.push(nextVal());
912
+ break;
913
+ case "-d":
914
+ case "--data":
915
+ a.data = nextVal();
916
+ break;
917
+ case "--json": {
918
+ const peek = list[i + 1];
919
+ if (peek !== void 0 && (peek.startsWith("{") || peek.startsWith("["))) a.json = nextVal();
920
+ else a.jsonOut = true;
921
+ break;
922
+ }
923
+ case "-b":
924
+ case "--bearer":
925
+ a.bearer = nextVal();
926
+ break;
927
+ case "-u":
928
+ case "--user":
929
+ a.user = nextVal();
930
+ break;
931
+ case "--timeout":
932
+ a.timeout = Number(nextVal());
933
+ break;
934
+ case "--max-redirects":
935
+ a.maxRedirects = Number(nextVal());
936
+ break;
937
+ case "--no-redirect":
938
+ a.noRedirect = true;
939
+ break;
940
+ case "--max-size":
941
+ a.maxSize = parseSize(nextVal());
942
+ break;
943
+ case "-o":
944
+ case "--out":
945
+ a.out = nextVal();
946
+ break;
947
+ case "-i":
948
+ case "--include":
949
+ a.include = true;
950
+ break;
951
+ case "-v":
952
+ case "--verbose":
953
+ a.verbose = true;
954
+ break;
955
+ case "--fail":
956
+ a.fail = true;
957
+ break;
958
+ case "--curl":
959
+ a.curl = true;
960
+ break;
961
+ case "--show-secrets":
962
+ a.showSecrets = true;
963
+ break;
964
+ case "--var":
965
+ a.vars.push(nextVal());
966
+ break;
967
+ case "--env":
968
+ a.envName = nextVal();
969
+ break;
970
+ case "--env-file":
971
+ a.envFile = nextVal();
972
+ break;
973
+ case "--name":
974
+ a.name = nextVal();
975
+ break;
976
+ case "-h":
977
+ case "--help":
978
+ a.help = true;
979
+ break;
980
+ case "--version":
981
+ a.version = true;
982
+ break;
983
+ default:
984
+ if (arg === "-V") a.version = true;
985
+ else if (!arg.startsWith("-")) a.positional.push(arg);
986
+ break;
987
+ }
988
+ }
989
+ return a;
990
+ }
991
+ var HELP = `
992
+ ${c("bold", c("magenta", "\u25C6 lacspace-http"))} ${c("dim", "\u2014 a keyless terminal API client + .http file runner")}
993
+
994
+ ${c("bold", "Usage")}
995
+ npx lacspace-http [METHOD] <url> [options] ${c("dim", "# ad-hoc request")}
996
+ npx lacspace-http run <file.http> [options] ${c("dim", "# run a .http/.rest file")}
997
+
998
+ ${c("bold", "Request options")}
999
+ -H, --header "K: V" Add a request header (repeatable)
1000
+ -q, --query k=v Add a query-string param (repeatable)
1001
+ -d, --data <str> Raw request body (@file to read a file, @- for stdin)
1002
+ --json '<obj>' JSON body from a literal object/array + content-type
1003
+ -j k=v JSON body shorthand (k:=v for a raw JSON value)
1004
+ --form k=v urlencoded form field (repeatable)
1005
+ -b, --bearer <token> Authorization: Bearer <token>
1006
+ -u, --user user:pass HTTP Basic auth
1007
+ --timeout <ms> Abort after ms (default 30000)
1008
+ --no-redirect Do not follow redirects
1009
+ --max-redirects N Follow at most N redirects (default 5)
1010
+ --max-size <n> Cap the response body read (e.g. 10mb, default 10MB)
1011
+
1012
+ ${c("bold", "Output options")}
1013
+ -i, --include Show response headers
1014
+ -v, --verbose Show the request line, headers and body too
1015
+ -o, --out <file> Write the response body to a file
1016
+ --curl Print the equivalent curl command (secrets masked)
1017
+ --show-secrets Do not mask credentials in --curl / --verbose
1018
+ --fail Exit non-zero on a non-2xx response
1019
+ --json Emit a machine record { status, headers, timeMs, body }
1020
+
1021
+ ${c("bold", "Runner options (run)")}
1022
+ --env <name> Select an env block from http-client.env.json
1023
+ --env-file <path> Load vars from a .json (needs --env) or .env file
1024
+ --var k=v Set/override a variable (repeatable)
1025
+ --name <req> Run only the request with this # @name
1026
+
1027
+ -h, --help Show this help
1028
+ --version Print the version
1029
+
1030
+ ${c("bold", ".http directives")}
1031
+ ${c("dim", "# @name login name a request (for --name and chaining)")}
1032
+ ${c("dim", "# @capture tok = body.$.token grab a value for later requests")}
1033
+ ${c("dim", "# @assert status == 200 fail the run if the check fails")}
1034
+ ${c("dim", "# @assert body.$.ok == true \xB7 header.content-type contains json \xB7 time < 800")}
1035
+
1036
+ ${c("bold", "Examples")}
1037
+ npx lacspace-http https://httpbin.org/get
1038
+ npx lacspace-http POST https://httpbin.org/post -j name=Ada -j admin:=true
1039
+ npx lacspace-http https://api.example.com/me -b "$TOKEN" -i
1040
+ npx lacspace-http GET https://httpbin.org/get --curl
1041
+ npx lacspace-http run api.http --env dev
1042
+ npx lacspace-http run smoke.http --var host=http://localhost:3000
1043
+ `;
1044
+ function readStdin() {
1045
+ try {
1046
+ return readFileSync(0, "utf8");
1047
+ } catch {
1048
+ return "";
1049
+ }
1050
+ }
1051
+ function resolveDataArg(data) {
1052
+ if (data === "@-") return readStdin();
1053
+ if (data.startsWith("@")) return readFileSync(data.slice(1), "utf8");
1054
+ return data;
1055
+ }
1056
+ function fail(msg, jsonOut) {
1057
+ if (jsonOut) stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
1058
+ else log(c("red", `
1059
+ \u2717 ${msg}
1060
+ `));
1061
+ exit(1);
1062
+ }
1063
+ async function runAdHoc(args) {
1064
+ let method;
1065
+ let url;
1066
+ const [p0, p1] = [args.positional[0], args.positional[1]];
1067
+ if (p0 && METHODS2.has(p0.toUpperCase())) {
1068
+ method = p0.toUpperCase();
1069
+ url = p1;
1070
+ } else {
1071
+ url = p0;
1072
+ }
1073
+ if (!url) fail("a URL is required (see --help)", args.jsonOut);
1074
+ if (!/^https?:\/\//i.test(url)) url = "http://" + url;
1075
+ const asm = {
1076
+ headers: args.headers,
1077
+ query: args.query,
1078
+ form: args.form,
1079
+ jsonKv: args.jsonKv
1080
+ };
1081
+ if (method) asm.method = method;
1082
+ if (args.data !== void 0) asm.data = resolveDataArg(args.data);
1083
+ if (args.json !== void 0) asm.json = args.json;
1084
+ if (args.bearer !== void 0) asm.bearer = args.bearer;
1085
+ if (args.user !== void 0) asm.user = args.user;
1086
+ const spec = assembleRequest(url, asm);
1087
+ if (args.curl) {
1088
+ const curlOpts = {
1089
+ showSecrets: args.showSecrets,
1090
+ followRedirects: !args.noRedirect
1091
+ };
1092
+ if (args.maxRedirects !== void 0) curlOpts.maxRedirects = args.maxRedirects;
1093
+ stdout.write(toCurl(spec, curlOpts) + "\n");
1094
+ return;
1095
+ }
1096
+ if (args.verbose) printRequest(spec, args.showSecrets);
1097
+ const sendOpts = { followRedirects: !args.noRedirect };
1098
+ if (args.timeout !== void 0) sendOpts.timeoutMs = args.timeout;
1099
+ if (args.maxSize !== void 0) sendOpts.maxSize = args.maxSize;
1100
+ if (args.maxRedirects !== void 0) sendOpts.maxRedirects = args.maxRedirects;
1101
+ let rec;
1102
+ try {
1103
+ rec = await sendRequest(spec, sendOpts);
1104
+ } catch (err) {
1105
+ fail(err.message, args.jsonOut);
1106
+ }
1107
+ if (args.jsonOut) {
1108
+ stdout.write(JSON.stringify({
1109
+ status: rec.status,
1110
+ statusText: rec.statusText,
1111
+ headers: rec.headers,
1112
+ timeMs: rec.timeMs,
1113
+ size: rec.size,
1114
+ url: rec.url,
1115
+ redirected: rec.redirected,
1116
+ truncated: rec.truncated,
1117
+ body: rec.body
1118
+ }) + "\n");
1119
+ if (args.fail && !rec.ok) exit(1);
1120
+ return;
1121
+ }
1122
+ printMeta(rec, args.include);
1123
+ if (args.out) {
1124
+ writeFileSync(args.out, rec.body);
1125
+ log(`
1126
+ ${c("green", "\u2713")} wrote ${c("bold", args.out)} ${c("dim", `(${humanSize(rec.size)})`)}
1127
+ `);
1128
+ } else {
1129
+ const ct = rec.headers["content-type"];
1130
+ if (rec.json !== void 0) {
1131
+ stdout.write(prettyJson(rec.json, COLOR && !!stdout.isTTY) + "\n");
1132
+ } else {
1133
+ stdout.write(rec.body + (rec.body.endsWith("\n") ? "" : "\n"));
1134
+ }
1135
+ void ct;
1136
+ }
1137
+ if (args.fail && !rec.ok) exit(1);
1138
+ }
1139
+ function printRequest(spec, showSecrets) {
1140
+ log(`${c("blue", "\u2192")} ${c("bold", spec.method)} ${spec.url}`);
1141
+ for (const [k, v] of spec.headers) {
1142
+ let value = v;
1143
+ if (!showSecrets && k.toLowerCase() === "authorization") {
1144
+ const scheme = v.split(/\s+/)[0] ?? "";
1145
+ value = /^(Bearer|Basic)$/i.test(scheme) ? `${scheme} ***` : "***";
1146
+ }
1147
+ log(` ${c("dim", k + ":")} ${value}`);
1148
+ }
1149
+ if (spec.body !== void 0) log(` ${c("dim", spec.body)}`);
1150
+ log("");
1151
+ }
1152
+ function printMeta(rec, includeHeaders) {
1153
+ const col = statusColor(rec.status);
1154
+ const statusLine = `${rec.status} ${rec.statusText}`.trim();
1155
+ const timeCol = rec.timeMs > 1e3 ? "yellow" : "dim";
1156
+ log(
1157
+ `${paint(col, statusLine, COLOR)} ${c("dim", "\xB7")} ${c(timeCol, rec.timeMs + "ms")} ${c("dim", "\xB7")} ${c("dim", humanSize(rec.size))}` + (rec.redirected ? ` ${c("dim", `\xB7 ${rec.redirectChain.length} redirect(s) \u2192 ${rec.url}`)}` : "") + (rec.truncated ? ` ${c("yellow", "\xB7 body truncated")}` : "") + (rec.crossHostRedirect ? ` ${c("yellow", "\xB7 crossed host")}` : "")
1158
+ );
1159
+ if (includeHeaders) {
1160
+ for (const [k, v] of Object.entries(rec.headers)) log(` ${c("cyan", k)}${c("dim", ":")} ${v}`);
1161
+ }
1162
+ log("");
1163
+ }
1164
+ function loadEnvVars(args) {
1165
+ if (!args.envFile) {
1166
+ if (args.envName) {
1167
+ try {
1168
+ const raw2 = readFileSync("http-client.env.json", "utf8");
1169
+ return parseEnvJson(raw2, args.envName);
1170
+ } catch {
1171
+ }
1172
+ }
1173
+ return {};
1174
+ }
1175
+ const raw = readFileSync(args.envFile, "utf8");
1176
+ if (/\.json$/i.test(args.envFile)) {
1177
+ if (!args.envName) throw new Error("--env <name> is required with a JSON env file.");
1178
+ return parseEnvJson(raw, args.envName);
1179
+ }
1180
+ return parseDotenv(raw);
1181
+ }
1182
+ async function runFile(args) {
1183
+ const file = args.positional[1];
1184
+ if (!file) fail("run needs a file: lacspace-http run <file.http>", args.jsonOut);
1185
+ let source;
1186
+ try {
1187
+ source = readFileSync(file, "utf8");
1188
+ } catch (err) {
1189
+ fail(`cannot read ${file}: ${err.message}`, args.jsonOut);
1190
+ }
1191
+ let envVars;
1192
+ try {
1193
+ envVars = loadEnvVars(args);
1194
+ } catch (err) {
1195
+ fail(err.message, args.jsonOut);
1196
+ }
1197
+ const runOpts = {
1198
+ env: envVars,
1199
+ vars: parseKvPairs(args.vars),
1200
+ followRedirects: !args.noRedirect
1201
+ };
1202
+ if (args.name !== void 0) runOpts.name = args.name;
1203
+ if (args.timeout !== void 0) runOpts.timeoutMs = args.timeout;
1204
+ if (args.maxSize !== void 0) runOpts.maxSize = args.maxSize;
1205
+ if (args.maxRedirects !== void 0) runOpts.maxRedirects = args.maxRedirects;
1206
+ if (!args.jsonOut) {
1207
+ log(`
1208
+ ${c("bold", c("magenta", "\u25C6 lacspace-http run"))} ${c("dim", file)}`);
1209
+ log("");
1210
+ runOpts.onResult = (r) => printRunResult(r);
1211
+ }
1212
+ const result = await runHttpFile(source, runOpts);
1213
+ if (args.jsonOut) {
1214
+ stdout.write(JSON.stringify({
1215
+ ok: result.ok,
1216
+ passed: result.passed,
1217
+ failed: result.failed,
1218
+ results: result.results.map((r) => ({
1219
+ name: r.name,
1220
+ method: r.method,
1221
+ url: r.url,
1222
+ ok: r.ok,
1223
+ status: r.response?.status,
1224
+ timeMs: r.response?.timeMs,
1225
+ error: r.error,
1226
+ missingVars: r.missingVars,
1227
+ captured: r.captured,
1228
+ assertions: r.assertions.map((a) => ({ ok: a.ok, message: a.message }))
1229
+ }))
1230
+ }) + "\n");
1231
+ } else {
1232
+ const summary = `${result.passed} passed` + (result.failed ? `, ${c("red", result.failed + " failed")}` : "") + ` ${c("dim", `\xB7 ${result.results.length} request(s)`)}`;
1233
+ log(`${result.ok ? c("green", "\u2713") : c("red", "\u2717")} ${summary}
1234
+ `);
1235
+ }
1236
+ if (!result.ok) exit(1);
1237
+ }
1238
+ function printRunResult(r) {
1239
+ const label = r.name ? c("bold", r.name) : c("dim", `${r.method} ${r.url}`);
1240
+ if (r.error) {
1241
+ log(` ${c("red", "\u2717")} ${label} ${c("dim", `L${r.line}`)}`);
1242
+ log(` ${c("red", r.error)}`);
1243
+ return;
1244
+ }
1245
+ const st = r.response ? r.response.status : 0;
1246
+ const stCol = statusColor(st);
1247
+ const mark = r.ok ? c("green", "\u2713") : c("red", "\u2717");
1248
+ log(` ${mark} ${label} ${c("dim", "\u2192")} ${paint(stCol, String(st), COLOR)} ${c("dim", `${r.response?.timeMs ?? 0}ms`)}`);
1249
+ for (const k of Object.keys(r.captured)) {
1250
+ log(` ${c("cyan", "captured")} ${k}=${c("dim", truncate(r.captured[k], 60))}`);
1251
+ }
1252
+ for (const a of r.assertions) {
1253
+ const am = a.ok ? c("green", "\u2713") : c("red", "\u2717");
1254
+ log(` ${am} ${c("dim", "assert")} ${a.message}`);
1255
+ }
1256
+ if (r.missingVars.length) {
1257
+ log(` ${c("yellow", "\u26A0 unresolved vars:")} ${r.missingVars.join(", ")}`);
1258
+ }
1259
+ }
1260
+ function truncate(s, n) {
1261
+ return s.length > n ? s.slice(0, n) + "\u2026" : s;
1262
+ }
1263
+ async function main() {
1264
+ const args = parseArgs(argv.slice(2));
1265
+ if (args.version) {
1266
+ stdout.write(VERSION + "\n");
1267
+ return;
1268
+ }
1269
+ if (args.help || args.positional.length === 0) {
1270
+ stdout.write(HELP + "\n");
1271
+ return;
1272
+ }
1273
+ if (args.positional[0] === "run") {
1274
+ await runFile(args);
1275
+ return;
1276
+ }
1277
+ await runAdHoc(args);
1278
+ }
1279
+ main().catch((err) => {
1280
+ log(c("red", `
1281
+ \u2717 ${err instanceof Error ? err.message : String(err)}
1282
+ `));
1283
+ exit(1);
1284
+ });