@beast01/tcurl 1.0.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,190 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { URL } from 'node:url';
4
+ import { Buffer } from 'node:buffer';
5
+ const MAX_REDIRECTS = 10;
6
+ /** Build the fully-resolved URL including enabled query params. */
7
+ export function buildUrl(config) {
8
+ const url = new URL(config.url);
9
+ for (const q of config.query) {
10
+ if (q.enabled && q.key)
11
+ url.searchParams.append(q.key, q.value);
12
+ }
13
+ return url;
14
+ }
15
+ /** Compute the outgoing headers, folding in auth and body content type. */
16
+ export function buildHeaders(config) {
17
+ const headers = {};
18
+ for (const h of config.headers) {
19
+ if (h.enabled && h.key)
20
+ headers[h.key] = h.value;
21
+ }
22
+ const hasHeader = (name) => Object.keys(headers).some((k) => k.toLowerCase() === name.toLowerCase());
23
+ // Auth
24
+ if (config.auth.type === 'bearer' && config.auth.token) {
25
+ if (!hasHeader('authorization'))
26
+ headers['Authorization'] = `Bearer ${config.auth.token}`;
27
+ }
28
+ else if (config.auth.type === 'basic') {
29
+ if (!hasHeader('authorization')) {
30
+ const raw = `${config.auth.token}:${config.auth.password}`;
31
+ headers['Authorization'] = `Basic ${Buffer.from(raw).toString('base64')}`;
32
+ }
33
+ }
34
+ // Default content types by body mode
35
+ if (config.bodyMode === 'json' && !hasHeader('content-type')) {
36
+ headers['Content-Type'] = 'application/json';
37
+ }
38
+ else if (config.bodyMode === 'form' && !hasHeader('content-type')) {
39
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
40
+ }
41
+ if (!hasHeader('accept'))
42
+ headers['Accept'] = '*/*';
43
+ if (!hasHeader('user-agent'))
44
+ headers['User-Agent'] = 'tcurl/1.0';
45
+ return headers;
46
+ }
47
+ /** Serialize the request body according to its mode. */
48
+ export function buildBody(config) {
49
+ const methodHasBody = !['GET', 'HEAD'].includes(config.method);
50
+ if (!methodHasBody || config.bodyMode === 'none')
51
+ return undefined;
52
+ if (config.bodyMode === 'form') {
53
+ // body holds `key=value` lines; encode them.
54
+ const params = new URLSearchParams();
55
+ for (const line of config.body.split('\n')) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed)
58
+ continue;
59
+ const idx = trimmed.indexOf('=');
60
+ if (idx === -1)
61
+ params.append(trimmed, '');
62
+ else
63
+ params.append(trimmed.slice(0, idx), trimmed.slice(idx + 1));
64
+ }
65
+ return Buffer.from(params.toString());
66
+ }
67
+ if (!config.body)
68
+ return undefined;
69
+ return Buffer.from(config.body);
70
+ }
71
+ function familyFromContentType(ct) {
72
+ const lower = ct.toLowerCase();
73
+ if (lower.includes('json'))
74
+ return 'json';
75
+ if (lower.includes('html'))
76
+ return 'html';
77
+ if (lower.includes('xml'))
78
+ return 'xml';
79
+ if (lower.startsWith('image/'))
80
+ return 'image';
81
+ if (lower.startsWith('text/'))
82
+ return 'text';
83
+ return 'text';
84
+ }
85
+ function doRequest(opts, redirectsLeft) {
86
+ return new Promise((resolve, reject) => {
87
+ const isHttps = opts.url.protocol === 'https:';
88
+ const transport = isHttps ? https : http;
89
+ const reqOptions = {
90
+ method: opts.method,
91
+ hostname: opts.url.hostname,
92
+ port: opts.url.port || (isHttps ? 443 : 80),
93
+ path: opts.url.pathname + opts.url.search,
94
+ headers: opts.headers,
95
+ rejectUnauthorized: opts.rejectUnauthorized,
96
+ signal: opts.signal,
97
+ };
98
+ const req = transport.request(reqOptions, (res) => {
99
+ const status = res.statusCode ?? 0;
100
+ // Handle redirects manually so we can honor the follow setting.
101
+ if (opts.followRedirects &&
102
+ redirectsLeft > 0 &&
103
+ [301, 302, 303, 307, 308].includes(status) &&
104
+ res.headers.location) {
105
+ res.resume(); // drain
106
+ const nextUrl = new URL(res.headers.location, opts.url);
107
+ // 303, or 301/302 on POST, downgrade to GET per browser behavior.
108
+ let nextMethod = opts.method;
109
+ let nextBody = opts.body;
110
+ if (status === 303 || ((status === 301 || status === 302) && opts.method === 'POST')) {
111
+ nextMethod = 'GET';
112
+ nextBody = undefined;
113
+ }
114
+ doRequest({ ...opts, url: nextUrl, method: nextMethod, body: nextBody }, redirectsLeft - 1).then(resolve, reject);
115
+ return;
116
+ }
117
+ const chunks = [];
118
+ res.on('data', (c) => chunks.push(c));
119
+ res.on('end', () => {
120
+ const bodyBuf = Buffer.concat(chunks);
121
+ const headers = {};
122
+ for (const [k, v] of Object.entries(res.headers)) {
123
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
124
+ }
125
+ const ct = headers['content-type'] ?? '';
126
+ resolve({
127
+ ok: status >= 200 && status < 400,
128
+ status,
129
+ statusText: res.statusMessage ?? '',
130
+ headers,
131
+ body: bodyBuf.toString('utf8'),
132
+ contentType: familyFromContentType(ct),
133
+ sizeBytes: bodyBuf.length,
134
+ finalUrl: opts.url.toString(),
135
+ });
136
+ });
137
+ });
138
+ req.on('error', (err) => reject(err));
139
+ if (opts.body)
140
+ req.write(opts.body);
141
+ req.end();
142
+ });
143
+ }
144
+ /** Execute a request config and return a normalized result (never throws). */
145
+ export async function executeRequest(config) {
146
+ const start = Date.now();
147
+ const controller = new AbortController();
148
+ let timer;
149
+ if (config.timeoutMs > 0) {
150
+ timer = setTimeout(() => controller.abort(), config.timeoutMs);
151
+ }
152
+ try {
153
+ const url = buildUrl(config);
154
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
155
+ throw new Error(`Unsupported protocol: ${url.protocol}`);
156
+ }
157
+ const result = await doRequest({
158
+ url,
159
+ method: config.method,
160
+ headers: buildHeaders(config),
161
+ body: buildBody(config),
162
+ timeoutMs: config.timeoutMs,
163
+ rejectUnauthorized: config.rejectUnauthorized,
164
+ followRedirects: config.followRedirects,
165
+ signal: controller.signal,
166
+ }, MAX_REDIRECTS);
167
+ return { ...result, durationMs: Date.now() - start };
168
+ }
169
+ catch (err) {
170
+ const aborted = err?.name === 'AbortError' || controller.signal.aborted;
171
+ return {
172
+ ok: false,
173
+ status: 0,
174
+ statusText: '',
175
+ headers: {},
176
+ body: '',
177
+ contentType: 'text',
178
+ durationMs: Date.now() - start,
179
+ sizeBytes: 0,
180
+ finalUrl: config.url,
181
+ error: aborted
182
+ ? `Request timed out after ${config.timeoutMs}ms`
183
+ : String(err?.message ?? err),
184
+ };
185
+ }
186
+ finally {
187
+ if (timer)
188
+ clearTimeout(timer);
189
+ }
190
+ }
@@ -0,0 +1,40 @@
1
+ import { buildUrl, buildHeaders, buildBody } from '../http/client.js';
2
+ function shellQuote(value) {
3
+ // Wrap in single quotes, escaping embedded single quotes.
4
+ return `'${value.replace(/'/g, `'\\''`)}'`;
5
+ }
6
+ /** Render a request config as an equivalent `curl` command line. */
7
+ export function toCurl(config) {
8
+ const parts = ['curl'];
9
+ if (config.method !== 'GET')
10
+ parts.push('-X', config.method);
11
+ let url;
12
+ try {
13
+ url = buildUrl(config).toString();
14
+ }
15
+ catch {
16
+ url = config.url;
17
+ }
18
+ parts.push(shellQuote(url));
19
+ const headers = buildHeaders(config);
20
+ for (const [k, v] of Object.entries(headers)) {
21
+ parts.push('-H', shellQuote(`${k}: ${v}`));
22
+ }
23
+ const body = buildBody(config);
24
+ if (body && body.length > 0) {
25
+ parts.push('--data', shellQuote(body.toString('utf8')));
26
+ }
27
+ if (!config.followRedirects) {
28
+ /* curl does not follow by default; nothing to add */
29
+ }
30
+ else {
31
+ parts.push('-L');
32
+ }
33
+ if (!config.rejectUnauthorized)
34
+ parts.push('-k');
35
+ if (config.timeoutMs > 0) {
36
+ parts.push('--max-time', String(Math.ceil(config.timeoutMs / 1000)));
37
+ }
38
+ // Join with line continuations for readability.
39
+ return parts.join(' ');
40
+ }
@@ -0,0 +1,41 @@
1
+ /** Pretty-print a body based on its detected content family. */
2
+ export function prettyBody(body, family) {
3
+ if (family === 'json') {
4
+ try {
5
+ return JSON.stringify(JSON.parse(body), null, 2);
6
+ }
7
+ catch {
8
+ return body;
9
+ }
10
+ }
11
+ return body;
12
+ }
13
+ export function formatBytes(bytes) {
14
+ if (bytes < 1024)
15
+ return `${bytes} B`;
16
+ if (bytes < 1024 * 1024)
17
+ return `${(bytes / 1024).toFixed(1)} KB`;
18
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
19
+ }
20
+ export function formatDuration(ms) {
21
+ if (ms < 1000)
22
+ return `${ms} ms`;
23
+ return `${(ms / 1000).toFixed(2)} s`;
24
+ }
25
+ /** Split a string into wrapped lines of at most `width` columns. */
26
+ export function wrapLines(text, width) {
27
+ const out = [];
28
+ for (const rawLine of text.split('\n')) {
29
+ if (rawLine.length <= width) {
30
+ out.push(rawLine);
31
+ continue;
32
+ }
33
+ let line = rawLine;
34
+ while (line.length > width) {
35
+ out.push(line.slice(0, width));
36
+ line = line.slice(width);
37
+ }
38
+ out.push(line);
39
+ }
40
+ return out;
41
+ }
package/dist/lib/id.js ADDED
@@ -0,0 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export function newId() {
3
+ return randomUUID();
4
+ }
package/dist/lib/kv.js ADDED
@@ -0,0 +1,54 @@
1
+ /** Render header pairs as `Key: Value` lines for text editing. */
2
+ export function headersToText(headers) {
3
+ return headers
4
+ .filter((h) => h.enabled)
5
+ .map((h) => `${h.key}: ${h.value}`)
6
+ .join('\n');
7
+ }
8
+ export function textToHeaders(text) {
9
+ const out = [];
10
+ for (const line of text.split('\n')) {
11
+ const trimmed = line.trim();
12
+ if (!trimmed)
13
+ continue;
14
+ const idx = trimmed.indexOf(':');
15
+ if (idx === -1) {
16
+ out.push({ key: trimmed, value: '', enabled: true });
17
+ }
18
+ else {
19
+ out.push({
20
+ key: trimmed.slice(0, idx).trim(),
21
+ value: trimmed.slice(idx + 1).trim(),
22
+ enabled: true,
23
+ });
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ /** Render query pairs as `key=value` lines for text editing. */
29
+ export function queryToText(query) {
30
+ return query
31
+ .filter((q) => q.enabled)
32
+ .map((q) => `${q.key}=${q.value}`)
33
+ .join('\n');
34
+ }
35
+ export function textToQuery(text) {
36
+ const out = [];
37
+ for (const line of text.split('\n')) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed)
40
+ continue;
41
+ const idx = trimmed.indexOf('=');
42
+ if (idx === -1) {
43
+ out.push({ key: trimmed, value: '', enabled: true });
44
+ }
45
+ else {
46
+ out.push({
47
+ key: trimmed.slice(0, idx).trim(),
48
+ value: trimmed.slice(idx + 1).trim(),
49
+ enabled: true,
50
+ });
51
+ }
52
+ }
53
+ return out;
54
+ }
@@ -0,0 +1,78 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import envPaths from 'env-paths';
4
+ import { newId } from './lib/id.js';
5
+ const paths = envPaths('tcurl', { suffix: '' });
6
+ const STORE_DIR = paths.config;
7
+ const STORE_FILE = path.join(STORE_DIR, 'store.json');
8
+ const STORE_VERSION = 1;
9
+ function emptyStore() {
10
+ return { version: STORE_VERSION, requests: [], theme: 'gruvbox' };
11
+ }
12
+ export function defaultRequest(partial = {}) {
13
+ const now = Date.now();
14
+ return {
15
+ id: newId(),
16
+ name: 'Untitled request',
17
+ method: 'GET',
18
+ url: '',
19
+ headers: [],
20
+ query: [],
21
+ bodyMode: 'none',
22
+ body: '',
23
+ auth: { type: 'none', token: '', password: '' },
24
+ timeoutMs: 30000,
25
+ followRedirects: true,
26
+ rejectUnauthorized: true,
27
+ createdAt: now,
28
+ updatedAt: now,
29
+ ...partial,
30
+ };
31
+ }
32
+ /** Migrate/normalize a stored request so older files stay loadable. */
33
+ function normalizeRequest(raw) {
34
+ const base = defaultRequest();
35
+ return {
36
+ ...base,
37
+ ...raw,
38
+ auth: { ...base.auth, ...(raw?.auth ?? {}) },
39
+ headers: Array.isArray(raw?.headers) ? raw.headers : [],
40
+ query: Array.isArray(raw?.query) ? raw.query : [],
41
+ };
42
+ }
43
+ export async function loadStore() {
44
+ try {
45
+ const text = await fs.readFile(STORE_FILE, 'utf8');
46
+ const parsed = JSON.parse(text);
47
+ return {
48
+ version: STORE_VERSION,
49
+ theme: typeof parsed.theme === 'string' ? parsed.theme : 'gruvbox',
50
+ requests: Array.isArray(parsed.requests)
51
+ ? parsed.requests.map(normalizeRequest)
52
+ : [],
53
+ };
54
+ }
55
+ catch (err) {
56
+ if (err?.code === 'ENOENT')
57
+ return emptyStore();
58
+ // Corrupt file: back it up and start fresh rather than crashing.
59
+ try {
60
+ await fs.rename(STORE_FILE, `${STORE_FILE}.corrupt-${Date.now()}`);
61
+ }
62
+ catch {
63
+ /* ignore */
64
+ }
65
+ return emptyStore();
66
+ }
67
+ }
68
+ export async function saveStore(store) {
69
+ await fs.mkdir(STORE_DIR, { recursive: true });
70
+ const tmp = `${STORE_FILE}.tmp-${process.pid}`;
71
+ const data = JSON.stringify(store, null, 2);
72
+ // Write atomically so a crash mid-write can't corrupt the store.
73
+ await fs.writeFile(tmp, data, 'utf8');
74
+ await fs.rename(tmp, STORE_FILE);
75
+ }
76
+ export function storeLocation() {
77
+ return STORE_FILE;
78
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,73 @@
1
+ /** Gruvbox dark — https://github.com/morhetz/gruvbox */
2
+ export const gruvbox = {
3
+ name: 'gruvbox',
4
+ bg: '#282828',
5
+ fg: '#ebdbb2',
6
+ fgDim: '#a89984',
7
+ accent: '#fabd2f', // yellow
8
+ accentAlt: '#83a598', // blue
9
+ success: '#b8bb26', // green
10
+ warning: '#fe8019', // orange
11
+ error: '#fb4934', // red
12
+ info: '#8ec07c', // aqua
13
+ muted: '#665c54',
14
+ selectionBg: '#3c3836',
15
+ border: '#504945',
16
+ borderActive: '#fabd2f',
17
+ };
18
+ /** Ember — a warm, glowing dark palette. */
19
+ export const ember = {
20
+ name: 'ember',
21
+ bg: '#1b1206',
22
+ fg: '#f5e6c8',
23
+ fgDim: '#c9a36a',
24
+ accent: '#ff9e3b', // ember orange
25
+ accentAlt: '#ffcf70', // amber
26
+ success: '#c2d94c', // lime
27
+ warning: '#ffb454', // amber-orange
28
+ error: '#f07178', // coral
29
+ info: '#e6a15c', // tan
30
+ muted: '#6b4f2a',
31
+ selectionBg: '#2a1c0a',
32
+ border: '#4d3616',
33
+ borderActive: '#ff9e3b',
34
+ };
35
+ export const themes = {
36
+ gruvbox,
37
+ ember,
38
+ };
39
+ export function getTheme(name) {
40
+ return themes[name] ?? gruvbox;
41
+ }
42
+ /** A stable color per HTTP method for at-a-glance scanning. */
43
+ export function methodColor(method, theme) {
44
+ switch (method) {
45
+ case 'GET':
46
+ return theme.success;
47
+ case 'POST':
48
+ return theme.accent;
49
+ case 'PUT':
50
+ return theme.accentAlt;
51
+ case 'PATCH':
52
+ return theme.info;
53
+ case 'DELETE':
54
+ return theme.error;
55
+ case 'HEAD':
56
+ case 'OPTIONS':
57
+ return theme.fgDim;
58
+ default:
59
+ return theme.fg;
60
+ }
61
+ }
62
+ /** Color for an HTTP status code by class. */
63
+ export function statusColor(status, theme) {
64
+ if (status >= 200 && status < 300)
65
+ return theme.success;
66
+ if (status >= 300 && status < 400)
67
+ return theme.accentAlt;
68
+ if (status >= 400 && status < 500)
69
+ return theme.warning;
70
+ if (status >= 500)
71
+ return theme.error;
72
+ return theme.fgDim;
73
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ export const HTTP_METHODS = [
2
+ 'GET',
3
+ 'POST',
4
+ 'PUT',
5
+ 'PATCH',
6
+ 'DELETE',
7
+ 'HEAD',
8
+ 'OPTIONS',
9
+ ];
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@beast01/tcurl",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "An interactive terminal UI (TUI) for building, sending, and saving HTTP requests — curl, but comfortable.",
8
+ "type": "module",
9
+ "bin": {
10
+ "tcurl": "dist/cli.js"
11
+ },
12
+ "main": "dist/cli.js",
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "dev": "tsc -p tsconfig.json --watch",
24
+ "start": "node dist/cli.js",
25
+ "prepublishOnly": "npm run build",
26
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
27
+ },
28
+ "keywords": [
29
+ "curl",
30
+ "tui",
31
+ "terminal",
32
+ "http",
33
+ "rest",
34
+ "client",
35
+ "api",
36
+ "ink",
37
+ "cli",
38
+ "postman",
39
+ "httpie",
40
+ "gruvbox"
41
+ ],
42
+ "author": "",
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "env-paths": "^3.0.0",
46
+ "ink": "^5.0.1",
47
+ "react": "^18.3.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.20.0",
51
+ "@types/react": "^18.3.12",
52
+ "typescript": "^5.6.3"
53
+ }
54
+ }