about-system 0.10.9 → 0.10.11
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 +3 -1
- package/dist/about-system-cli.js +69 -54
- package/dist/about-system-cli.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +1 -1
- package/dist/system-info-api-D85U-_0W.js +2473 -0
- package/dist/system-info-api-D85U-_0W.js.map +1 -0
- package/dist/system-info-api.d.ts +15 -25
- package/dist/system-info-api.js +4 -5
- package/dist/system-info-api.js.map +1 -1
- package/package.json +1 -1
- package/src/about-system-cli.ts +42 -3
- package/src/cache/cache-config.ts +3 -3
- package/src/info/network.ts +14 -47
- package/src/system-info-api.test.ts +106 -0
- package/src/system-info-api.ts +68 -94
- package/src/systeminfo-types.d.ts +6 -0
- package/src/utils/network.ts +142 -23
- package/src/utils/utils.test.ts +136 -60
- package/dist/system-info-api-NgTm8Gzo.js +0 -2428
- package/dist/system-info-api-NgTm8Gzo.js.map +0 -1
package/src/utils/network.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* @module network
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import http from "http";
|
|
6
7
|
import https from "https";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_IPINFO_TOKEN,
|
|
@@ -25,34 +26,152 @@ export interface IPInfo {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
+
* IP information from ip-api.com (fallback)
|
|
30
|
+
* @interface IPAPIInfo
|
|
31
|
+
*/
|
|
32
|
+
interface IPAPIInfo {
|
|
33
|
+
status?: string;
|
|
34
|
+
query?: string;
|
|
35
|
+
city?: string;
|
|
36
|
+
isp?: string;
|
|
37
|
+
org?: string;
|
|
38
|
+
as?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GETs a URL and parses the body as JSON. Never rejects: resolves `null` on
|
|
43
|
+
* any error, on abort, or once `timeout` ms have passed in total — a hard
|
|
44
|
+
* deadline for the whole request, not a socket-idle timeout.
|
|
45
|
+
*/
|
|
46
|
+
function getJSON(
|
|
47
|
+
url: string,
|
|
48
|
+
timeout: number,
|
|
49
|
+
signal?: AbortSignal
|
|
50
|
+
): Promise<any> {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
if (signal?.aborted) return resolve(null);
|
|
53
|
+
|
|
54
|
+
let settled = false;
|
|
55
|
+
let req: http.ClientRequest | undefined;
|
|
56
|
+
const finish = (value: any) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
signal?.removeEventListener("abort", onAbort);
|
|
61
|
+
resolve(value);
|
|
62
|
+
};
|
|
63
|
+
const onAbort = () => {
|
|
64
|
+
req?.destroy();
|
|
65
|
+
finish(null);
|
|
66
|
+
};
|
|
67
|
+
const timer = setTimeout(onAbort, timeout);
|
|
68
|
+
signal?.addEventListener("abort", onAbort);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const get = url.startsWith("https:") ? https.get : http.get;
|
|
72
|
+
req = get(url, (res) => {
|
|
73
|
+
let data = "";
|
|
74
|
+
res.on("data", (chunk) => (data += chunk));
|
|
75
|
+
res.on("end", () => {
|
|
76
|
+
try {
|
|
77
|
+
finish(JSON.parse(data));
|
|
78
|
+
} catch {
|
|
79
|
+
finish(null);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
res.on("error", () => finish(null));
|
|
83
|
+
});
|
|
84
|
+
req.on("error", () => finish(null));
|
|
85
|
+
} catch {
|
|
86
|
+
finish(null);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetches IP information for this machine from the ipinfo.io lite API
|
|
93
|
+
* @param {string} token - IPInfo.io API token
|
|
94
|
+
* @param {number} timeout - Total request deadline in milliseconds
|
|
95
|
+
* @param {AbortSignal} signal - Abandons the request when aborted
|
|
96
|
+
* @returns {Promise<IPInfo>} IP information object or empty object on error
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchFromIPInfo(
|
|
99
|
+
token: string = DEFAULT_IPINFO_TOKEN,
|
|
100
|
+
timeout: number = DEFAULT_NETWORK_TIMEOUT,
|
|
101
|
+
signal?: AbortSignal
|
|
102
|
+
): Promise<IPInfo> {
|
|
103
|
+
const parsed = await getJSON(
|
|
104
|
+
`https://api.ipinfo.io/lite/me?token=${token}`,
|
|
105
|
+
timeout,
|
|
106
|
+
signal
|
|
107
|
+
);
|
|
108
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
109
|
+
// ipinfo lite returns { ip, asn, as_name, country, ... } — no city.
|
|
110
|
+
const org =
|
|
111
|
+
parsed.org ?? (parsed.asn && parsed.as_name ? `${parsed.asn} ${parsed.as_name}` : undefined);
|
|
112
|
+
return {
|
|
113
|
+
ip: parsed.ip,
|
|
114
|
+
city: parsed.city,
|
|
115
|
+
hostname: parsed.hostname,
|
|
116
|
+
org,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetches IP geolocation information from ip-api.com (fallback). The free
|
|
122
|
+
* tier is plain HTTP only.
|
|
123
|
+
* @param {number} timeout - Total request deadline in milliseconds
|
|
124
|
+
* @param {AbortSignal} signal - Abandons the request when aborted
|
|
125
|
+
* @returns {Promise<IPInfo>} IP information object or empty object on error
|
|
126
|
+
*/
|
|
127
|
+
export async function fetchFromIPAPI(
|
|
128
|
+
timeout: number = DEFAULT_NETWORK_TIMEOUT,
|
|
129
|
+
signal?: AbortSignal
|
|
130
|
+
): Promise<IPInfo> {
|
|
131
|
+
const parsed: IPAPIInfo | null = await getJSON(
|
|
132
|
+
"http://ip-api.com/json/?fields=status,message,query,city,isp,org,as",
|
|
133
|
+
timeout,
|
|
134
|
+
signal
|
|
135
|
+
);
|
|
136
|
+
if (!parsed || parsed.status !== "success") return {};
|
|
137
|
+
return {
|
|
138
|
+
ip: parsed.query,
|
|
139
|
+
city: parsed.city,
|
|
140
|
+
hostname: undefined,
|
|
141
|
+
org: parsed.org || parsed.isp || parsed.as,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetches IP information from ipinfo.io and ip-api.com in parallel and merges
|
|
147
|
+
* them: ipinfo.io wins per field, ip-api.com fills the gaps (ipinfo lite has
|
|
148
|
+
* no city). Both requests share the same deadline, so this never takes longer
|
|
149
|
+
* than `timeout`.
|
|
29
150
|
* @param {string} token - IPInfo.io API token
|
|
30
|
-
* @param {number} timeout -
|
|
151
|
+
* @param {number} timeout - Total deadline in milliseconds
|
|
152
|
+
* @param {AbortSignal} signal - Abandons both requests when aborted
|
|
31
153
|
* @returns {Promise<IPInfo>} IP information object or empty object on error
|
|
32
154
|
*/
|
|
33
155
|
export async function fetchIPInfo(
|
|
34
156
|
token: string = DEFAULT_IPINFO_TOKEN,
|
|
35
|
-
timeout: number = DEFAULT_NETWORK_TIMEOUT
|
|
157
|
+
timeout: number = DEFAULT_NETWORK_TIMEOUT,
|
|
158
|
+
signal?: AbortSignal
|
|
36
159
|
): Promise<IPInfo> {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let data = "";
|
|
42
|
-
res.on("data", (chunk) => (data += chunk));
|
|
43
|
-
res.on("end", () => {
|
|
44
|
-
try {
|
|
45
|
-
resolve(JSON.parse(data));
|
|
46
|
-
} catch {
|
|
47
|
-
resolve({});
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
});
|
|
160
|
+
const [ipinfo, ipapi] = await Promise.all([
|
|
161
|
+
fetchFromIPInfo(token, timeout, signal),
|
|
162
|
+
fetchFromIPAPI(timeout, signal),
|
|
163
|
+
]);
|
|
51
164
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
}
|
|
165
|
+
const merged: IPInfo = {};
|
|
166
|
+
for (const source of [ipapi, ipinfo]) {
|
|
167
|
+
for (const [key, value] of Object.entries(source)) {
|
|
168
|
+
if (value) merged[key as keyof IPInfo] = value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ipinfo answers over IPv6 when the machine has it; the short IPv4 address
|
|
172
|
+
// reads better in a one-line greeting, so keep ip-api's when it has one.
|
|
173
|
+
if (merged.ip?.includes(":") && ipapi.ip && !ipapi.ip.includes(":")) {
|
|
174
|
+
merged.ip = ipapi.ip;
|
|
175
|
+
}
|
|
176
|
+
return merged;
|
|
58
177
|
}
|
package/src/utils/utils.test.ts
CHANGED
|
@@ -22,16 +22,23 @@ vi.mock("https", () => ({
|
|
|
22
22
|
get: httpsGet,
|
|
23
23
|
default: { get: httpsGet },
|
|
24
24
|
}));
|
|
25
|
+
const httpGet = vi.hoisted(() => vi.fn());
|
|
26
|
+
vi.mock("http", () => ({
|
|
27
|
+
get: httpGet,
|
|
28
|
+
default: { get: httpGet },
|
|
29
|
+
}));
|
|
25
30
|
|
|
26
31
|
import { commandExists, execCommand } from "./command";
|
|
27
32
|
import { IS_LINUX, IS_MAC, IS_WINDOWS } from "./platform";
|
|
28
|
-
import { fetchIPInfo } from "./network";
|
|
29
|
-
import { DEFAULT_IPINFO_TOKEN } from "../cache/cache-config";
|
|
33
|
+
import { fetchIPInfo, fetchFromIPInfo, fetchFromIPAPI } from "./network";
|
|
34
|
+
import { DEFAULT_IPINFO_TOKEN, DEFAULT_NETWORK_TIMEOUT } from "../cache/cache-config";
|
|
30
35
|
|
|
31
36
|
afterEach(() => {
|
|
32
37
|
execSync.mockReset();
|
|
33
38
|
httpsGet.mockReset();
|
|
39
|
+
httpGet.mockReset();
|
|
34
40
|
vi.restoreAllMocks();
|
|
41
|
+
vi.useRealTimers();
|
|
35
42
|
});
|
|
36
43
|
|
|
37
44
|
describe("platform flags", () => {
|
|
@@ -118,101 +125,170 @@ describe("commandExists", () => {
|
|
|
118
125
|
});
|
|
119
126
|
});
|
|
120
127
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
type FakeReq = EventEmitter & { destroy: () => void };
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A fake `get` (https or http) whose response a test controls. `behavior`
|
|
132
|
+
* runs on the next microtask; leave it empty to simulate a hung request.
|
|
133
|
+
*/
|
|
134
|
+
function stubGet(
|
|
135
|
+
get: typeof httpsGet,
|
|
136
|
+
behavior: (req: FakeReq, onResponse: (res: EventEmitter) => void) => void = () => {},
|
|
137
|
+
) {
|
|
138
|
+
get.mockImplementation((_url: string, callback: (res: EventEmitter) => void) => {
|
|
139
|
+
const req: FakeReq = Object.assign(new EventEmitter(), { destroy: vi.fn() });
|
|
140
|
+
queueMicrotask(() => behavior(req, callback));
|
|
141
|
+
return req;
|
|
142
|
+
});
|
|
143
|
+
return get;
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
/** Emits a complete response body on the next tick. */
|
|
137
|
-
const respondWith = (body: string) =>
|
|
138
|
-
|
|
147
|
+
const respondWith = (get: typeof httpsGet, body: string) =>
|
|
148
|
+
stubGet(get, (_req, onResponse) => {
|
|
139
149
|
const res = new EventEmitter();
|
|
140
150
|
onResponse(res);
|
|
141
151
|
res.emit("data", body);
|
|
142
152
|
res.emit("end");
|
|
143
153
|
});
|
|
144
154
|
|
|
145
|
-
describe("
|
|
146
|
-
it("parses the ipinfo.io payload", async () => {
|
|
147
|
-
respondWith(JSON.stringify({ ip: "1.2.3.4",
|
|
155
|
+
describe("fetchFromIPInfo", () => {
|
|
156
|
+
it("parses the ipinfo.io lite payload", async () => {
|
|
157
|
+
respondWith(httpsGet, JSON.stringify({ ip: "1.2.3.4", asn: "AS123", as_name: "Test ISP", country: "US" }));
|
|
148
158
|
|
|
149
|
-
await expect(
|
|
159
|
+
await expect(fetchFromIPInfo()).resolves.toEqual({
|
|
150
160
|
ip: "1.2.3.4",
|
|
151
|
-
|
|
161
|
+
org: "AS123 Test ISP",
|
|
152
162
|
});
|
|
153
163
|
});
|
|
154
164
|
|
|
155
|
-
it("
|
|
156
|
-
const get = respondWith("{}");
|
|
157
|
-
await
|
|
165
|
+
it("asks about this machine, not a fixed address", async () => {
|
|
166
|
+
const get = respondWith(httpsGet, "{}");
|
|
167
|
+
await fetchFromIPInfo();
|
|
158
168
|
|
|
159
169
|
expect(get.mock.calls[0][0]).toBe(
|
|
160
|
-
`https://ipinfo.io/
|
|
170
|
+
`https://api.ipinfo.io/lite/me?token=${DEFAULT_IPINFO_TOKEN}`,
|
|
161
171
|
);
|
|
162
172
|
});
|
|
163
173
|
|
|
164
|
-
it("
|
|
165
|
-
|
|
166
|
-
await
|
|
174
|
+
it("resolves empty rather than rejecting on a malformed body", async () => {
|
|
175
|
+
respondWith(httpsGet, "<html>gateway error</html>");
|
|
176
|
+
await expect(fetchFromIPInfo()).resolves.toEqual({});
|
|
177
|
+
});
|
|
167
178
|
|
|
168
|
-
|
|
179
|
+
it("resolves empty rather than rejecting when the request errors", async () => {
|
|
180
|
+
stubGet(httpsGet, (req) => req.emit("error", new Error("ENOTFOUND")));
|
|
181
|
+
await expect(fetchFromIPInfo()).resolves.toEqual({});
|
|
169
182
|
});
|
|
170
183
|
|
|
171
|
-
it("
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
it("gives up after a hard 3 second deadline by default", async () => {
|
|
185
|
+
vi.useFakeTimers();
|
|
186
|
+
stubGet(httpsGet);
|
|
187
|
+
let done = false;
|
|
188
|
+
const pending = fetchFromIPInfo().then((r) => ((done = true), r));
|
|
174
189
|
|
|
175
|
-
|
|
190
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_NETWORK_TIMEOUT - 1);
|
|
191
|
+
expect(done).toBe(false);
|
|
192
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
193
|
+
await expect(pending).resolves.toEqual({});
|
|
194
|
+
expect(DEFAULT_NETWORK_TIMEOUT).toBe(3000);
|
|
195
|
+
expect(httpsGet.mock.results[0].value.destroy).toHaveBeenCalled();
|
|
176
196
|
});
|
|
177
197
|
|
|
178
|
-
it("
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
it("abandons the request when the signal aborts", async () => {
|
|
199
|
+
stubGet(httpsGet);
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const pending = fetchFromIPInfo(DEFAULT_IPINFO_TOKEN, 60_000, controller.signal);
|
|
202
|
+
|
|
203
|
+
controller.abort();
|
|
204
|
+
await expect(pending).resolves.toEqual({});
|
|
205
|
+
expect(httpsGet.mock.results[0].value.destroy).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("fetchFromIPAPI", () => {
|
|
210
|
+
it("parses the ip-api.com payload", async () => {
|
|
211
|
+
respondWith(httpGet, JSON.stringify({
|
|
212
|
+
status: "success",
|
|
213
|
+
query: "1.2.3.4",
|
|
214
|
+
city: "San Francisco",
|
|
215
|
+
isp: "Test ISP",
|
|
216
|
+
org: "AS123 Test Organization",
|
|
217
|
+
as: "AS123 Test AS"
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
await expect(fetchFromIPAPI()).resolves.toEqual({
|
|
221
|
+
ip: "1.2.3.4",
|
|
222
|
+
city: "San Francisco",
|
|
223
|
+
org: "AS123 Test Organization",
|
|
185
224
|
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("uses plain http, which is all ip-api.com's free tier serves", async () => {
|
|
228
|
+
const get = respondWith(httpGet, '{"status":"success"}');
|
|
229
|
+
await fetchFromIPAPI();
|
|
186
230
|
|
|
187
|
-
|
|
231
|
+
expect(get.mock.calls[0][0]).toBe(
|
|
232
|
+
"http://ip-api.com/json/?fields=status,message,query,city,isp,org,as"
|
|
233
|
+
);
|
|
234
|
+
expect(httpsGet).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns empty object on failed status", async () => {
|
|
238
|
+
respondWith(httpGet, JSON.stringify({ status: "fail", message: "private range" }));
|
|
239
|
+
await expect(fetchFromIPAPI()).resolves.toEqual({});
|
|
188
240
|
});
|
|
189
241
|
|
|
190
242
|
it("resolves empty rather than rejecting on a malformed body", async () => {
|
|
191
|
-
respondWith("<html>gateway error</html>");
|
|
192
|
-
await expect(
|
|
243
|
+
respondWith(httpGet, "<html>gateway error</html>");
|
|
244
|
+
await expect(fetchFromIPAPI()).resolves.toEqual({});
|
|
193
245
|
});
|
|
246
|
+
});
|
|
194
247
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
248
|
+
describe("fetchIPInfo", () => {
|
|
249
|
+
it("prefers ipinfo per field and fills gaps from ip-api", async () => {
|
|
250
|
+
respondWith(httpsGet, JSON.stringify({ ip: "1.2.3.4", asn: "AS123", as_name: "Primary ISP" }));
|
|
251
|
+
respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York", org: "AS456 Other" }));
|
|
252
|
+
|
|
253
|
+
await expect(fetchIPInfo()).resolves.toEqual({
|
|
254
|
+
ip: "1.2.3.4",
|
|
255
|
+
city: "New York",
|
|
256
|
+
org: "AS123 Primary ISP",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("prefers an IPv4 address over ipinfo's IPv6 one", async () => {
|
|
261
|
+
respondWith(httpsGet, JSON.stringify({ ip: "2600:1010::1", asn: "AS123", as_name: "Primary ISP" }));
|
|
262
|
+
respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York" }));
|
|
263
|
+
|
|
264
|
+
await expect(fetchIPInfo()).resolves.toMatchObject({ ip: "5.6.7.8" });
|
|
198
265
|
});
|
|
199
266
|
|
|
200
|
-
it("
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
return req;
|
|
267
|
+
it("falls back to ip-api when ipinfo fails", async () => {
|
|
268
|
+
stubGet(httpsGet, (req) => req.emit("error", new Error("ECONNRESET")));
|
|
269
|
+
respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York", org: "AS456 Fallback ISP" }));
|
|
270
|
+
|
|
271
|
+
await expect(fetchIPInfo()).resolves.toEqual({
|
|
272
|
+
ip: "5.6.7.8",
|
|
273
|
+
city: "New York",
|
|
274
|
+
org: "AS456 Fallback ISP",
|
|
210
275
|
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("returns empty when both fail", async () => {
|
|
279
|
+
stubGet(httpsGet, (req) => req.emit("error", new Error("ENOTFOUND")));
|
|
280
|
+
stubGet(httpGet, (req) => req.emit("error", new Error("ENOTFOUND")));
|
|
281
|
+
|
|
282
|
+
await expect(fetchIPInfo()).resolves.toEqual({});
|
|
283
|
+
});
|
|
211
284
|
|
|
212
|
-
|
|
213
|
-
|
|
285
|
+
it("never waits past the shared deadline when both hang", async () => {
|
|
286
|
+
vi.useFakeTimers();
|
|
287
|
+
stubGet(httpsGet);
|
|
288
|
+
stubGet(httpGet);
|
|
289
|
+
const pending = fetchIPInfo();
|
|
214
290
|
|
|
215
|
-
|
|
291
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_NETWORK_TIMEOUT);
|
|
216
292
|
await expect(pending).resolves.toEqual({});
|
|
217
293
|
});
|
|
218
294
|
});
|