apilive 1.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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +74 -0
- package/README.md +206 -0
- package/SECURITY.md +62 -0
- package/SUPPORT.md +33 -0
- package/TERMS.md +66 -0
- package/bin/apilive.js +20 -0
- package/docs/GUIDE.md +167 -0
- package/package.json +62 -0
- package/public/app.js +1026 -0
- package/public/favicon.svg +1 -0
- package/public/index.html +244 -0
- package/public/style.css +554 -0
- package/src/check.js +204 -0
- package/src/cli.js +427 -0
- package/src/demo.js +40 -0
- package/src/env.js +134 -0
- package/src/markdown.js +133 -0
- package/src/providers.js +400 -0
- package/src/server.js +293 -0
- package/src/term.js +28 -0
- package/src/update.js +129 -0
- package/src/util.js +13 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Local web UI server. Zero dependencies — just node:http.
|
|
2
|
+
//
|
|
3
|
+
// Security model:
|
|
4
|
+
// • Binds to 127.0.0.1 only — never reachable from your network.
|
|
5
|
+
// • Rejects any request whose Host header isn't 127.0.0.1/localhost on our
|
|
6
|
+
// port (blocks DNS-rebinding attacks from malicious websites).
|
|
7
|
+
// • Every /api call needs a random per-session token that is only embedded in
|
|
8
|
+
// the page this server renders — other sites can't read it (same-origin).
|
|
9
|
+
// • Raw keys stay in this process's memory. The browser only ever receives
|
|
10
|
+
// masked keys and opaque refs. Nothing is written to disk or logged.
|
|
11
|
+
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { describeProviders, providers } from "./providers.js";
|
|
19
|
+
import { checkKey, DEFAULT_TIMEOUT_MS } from "./check.js";
|
|
20
|
+
import { extractKeys, scanEnvironment } from "./env.js";
|
|
21
|
+
import { mask } from "./util.js";
|
|
22
|
+
import { DEMO_KEYS, demoFetch } from "./demo.js";
|
|
23
|
+
import { renderMarkdown } from "./markdown.js";
|
|
24
|
+
import { checkForUpdate, installKind, updateCommand, RELEASES_URL } from "./update.js";
|
|
25
|
+
|
|
26
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const PUBLIC_DIR = path.join(ROOT, "public");
|
|
28
|
+
|
|
29
|
+
// Bundled documents, served (rendered) inside the app so they work offline.
|
|
30
|
+
export const DOCS = {
|
|
31
|
+
guide: { title: "Guide", file: "docs/GUIDE.md" },
|
|
32
|
+
changelog: { title: "Changelog", file: "CHANGELOG.md" },
|
|
33
|
+
privacy: { title: "Privacy Policy", file: "PRIVACY.md" },
|
|
34
|
+
terms: { title: "Terms of Use", file: "TERMS.md" },
|
|
35
|
+
security: { title: "Security", file: "SECURITY.md" },
|
|
36
|
+
support: { title: "Support", file: "SUPPORT.md" },
|
|
37
|
+
license: { title: "License", file: "LICENSE" },
|
|
38
|
+
};
|
|
39
|
+
const STATIC = {
|
|
40
|
+
"/app.js": "text/javascript; charset=utf-8",
|
|
41
|
+
"/style.css": "text/css; charset=utf-8",
|
|
42
|
+
"/favicon.svg": "image/svg+xml",
|
|
43
|
+
};
|
|
44
|
+
export const MAX_KEYS = 100; // built for your own keys, not bulk testing
|
|
45
|
+
const MAX_BODY = 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
const SECURITY_HEADERS = {
|
|
48
|
+
"Content-Security-Policy":
|
|
49
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; " +
|
|
50
|
+
"font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
51
|
+
"X-Content-Type-Options": "nosniff",
|
|
52
|
+
"X-Frame-Options": "DENY",
|
|
53
|
+
"Referrer-Policy": "no-referrer",
|
|
54
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
55
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
56
|
+
"Cache-Control": "no-store",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createApp({ version, cwd = process.cwd(), includeEnv = true, files = null, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl, demo = false, updateCheck = true } = {}) {
|
|
60
|
+
if (demo) fetchImpl = demoFetch;
|
|
61
|
+
// Kick off the (cached, opt-out) update check without blocking startup.
|
|
62
|
+
const updatePromise = checkForUpdate(version, { disabled: !updateCheck || demo }).catch(() => null);
|
|
63
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
64
|
+
const keys = new Map(); // ref -> entry (with raw key)
|
|
65
|
+
let allowedHosts = new Set();
|
|
66
|
+
|
|
67
|
+
const scan = demo
|
|
68
|
+
? { keys: DEMO_KEYS, scanned: [{ file: ".env" }, { file: ".env.local" }] }
|
|
69
|
+
: scanEnvironment({ cwd, files, includeEnv });
|
|
70
|
+
for (const k of scan.keys.slice(0, MAX_KEYS)) add(k, "env");
|
|
71
|
+
|
|
72
|
+
const indexHtml = fs
|
|
73
|
+
.readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf8")
|
|
74
|
+
.replace("__APILIVE_TOKEN__", token);
|
|
75
|
+
|
|
76
|
+
function add(entry, origin) {
|
|
77
|
+
for (const e of keys.values()) {
|
|
78
|
+
if (e.key === entry.key && e.provider === entry.provider) return { entry: e, duplicate: true };
|
|
79
|
+
}
|
|
80
|
+
const ref = crypto.randomBytes(6).toString("hex");
|
|
81
|
+
const e = { ...entry, ref, origin, sources: entry.sources || [] };
|
|
82
|
+
keys.set(ref, e);
|
|
83
|
+
return { entry: e, duplicate: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const view = (e) => ({
|
|
87
|
+
ref: e.ref,
|
|
88
|
+
provider: e.provider,
|
|
89
|
+
masked: mask(e.key),
|
|
90
|
+
name: e.name || null,
|
|
91
|
+
sources: e.sources,
|
|
92
|
+
origin: e.origin,
|
|
93
|
+
confident: e.confident,
|
|
94
|
+
candidates: e.candidates || [],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function send(res, status, body, type = "application/json; charset=utf-8") {
|
|
98
|
+
const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
|
|
99
|
+
res.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": type });
|
|
100
|
+
res.end(payload);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJson(req) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let size = 0;
|
|
106
|
+
const chunks = [];
|
|
107
|
+
req.on("data", (c) => {
|
|
108
|
+
size += c.length;
|
|
109
|
+
if (size > MAX_BODY) {
|
|
110
|
+
reject(Object.assign(new Error("Request too large"), { status: 413 }));
|
|
111
|
+
req.destroy();
|
|
112
|
+
} else chunks.push(c);
|
|
113
|
+
});
|
|
114
|
+
req.on("end", () => {
|
|
115
|
+
try {
|
|
116
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {});
|
|
117
|
+
} catch {
|
|
118
|
+
reject(Object.assign(new Error("Invalid JSON"), { status: 400 }));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tokenOk(req) {
|
|
126
|
+
const given = Buffer.from(String(req.headers["x-apilive-token"] || ""));
|
|
127
|
+
const want = Buffer.from(token);
|
|
128
|
+
return given.length === want.length && crypto.timingSafeEqual(given, want);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handle(req, res) {
|
|
132
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
133
|
+
if (!allowedHosts.has(host)) return send(res, 421, { error: "Invalid host" });
|
|
134
|
+
|
|
135
|
+
const url = new URL(req.url, `http://${host}`);
|
|
136
|
+
const p = url.pathname;
|
|
137
|
+
|
|
138
|
+
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
139
|
+
return send(res, 200, indexHtml, "text/html; charset=utf-8");
|
|
140
|
+
}
|
|
141
|
+
if (req.method === "GET" && STATIC[p]) {
|
|
142
|
+
const file = path.join(PUBLIC_DIR, p.slice(1));
|
|
143
|
+
return send(res, 200, fs.readFileSync(file), STATIC[p]);
|
|
144
|
+
}
|
|
145
|
+
if (!p.startsWith("/api/")) return send(res, 404, { error: "Not found" });
|
|
146
|
+
|
|
147
|
+
const origin = req.headers.origin;
|
|
148
|
+
if (origin && !allowedHosts.has(origin.replace(/^https?:\/\//, "").toLowerCase())) {
|
|
149
|
+
return send(res, 403, { error: "Cross-origin request blocked" });
|
|
150
|
+
}
|
|
151
|
+
if (!tokenOk(req)) return send(res, 403, { error: "Missing or invalid session token" });
|
|
152
|
+
if (req.method !== "GET" && req.method !== "DELETE" && !String(req.headers["content-type"] || "").startsWith("application/json")) {
|
|
153
|
+
return send(res, 415, { error: "Expected application/json" });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (req.method === "GET" && p === "/api/state") {
|
|
157
|
+
return send(res, 200, {
|
|
158
|
+
version,
|
|
159
|
+
cwd: demo ? "my-app" : path.basename(cwd),
|
|
160
|
+
demo,
|
|
161
|
+
providers: describeProviders(),
|
|
162
|
+
scanned: scan.scanned.filter((s) => !s.error).map((s) => path.relative(cwd, s.file) || s.file),
|
|
163
|
+
envIncluded: includeEnv,
|
|
164
|
+
maxKeys: MAX_KEYS,
|
|
165
|
+
docs: Object.entries(DOCS).map(([slug, d]) => ({ slug, title: d.title })),
|
|
166
|
+
keys: [...keys.values()].map(view),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (req.method === "GET" && p === "/api/update") {
|
|
171
|
+
const u = (await updatePromise) || { current: version, updateAvailable: false, disabled: true };
|
|
172
|
+
const kind = u.updateAvailable ? installKind() : null;
|
|
173
|
+
return send(res, 200, { ...u, command: kind ? updateCommand(kind) : null, releasesUrl: RELEASES_URL });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const doc = p.match(/^\/api\/docs\/([a-z]+)$/);
|
|
177
|
+
if (req.method === "GET" && doc) {
|
|
178
|
+
const meta = DOCS[doc[1]];
|
|
179
|
+
if (!meta) return send(res, 404, { error: "Unknown document" });
|
|
180
|
+
const text = fs.readFileSync(path.join(ROOT, meta.file), "utf8");
|
|
181
|
+
const rendered = meta.file === "LICENSE" ? { html: `<pre class="license">${text.replace(/&/g, "&").replace(/</g, "<")}</pre>`, toc: [] } : renderMarkdown(text);
|
|
182
|
+
return send(res, 200, { slug: doc[1], title: meta.title, ...rendered });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (req.method === "POST" && p === "/api/keys") {
|
|
186
|
+
const body = await readJson(req);
|
|
187
|
+
const forced = body.provider && providers[body.provider] ? body.provider : null;
|
|
188
|
+
const found = extractKeys(String(body.text || "").slice(0, MAX_BODY), { forced });
|
|
189
|
+
const added = [];
|
|
190
|
+
let duplicates = 0;
|
|
191
|
+
let limited = 0;
|
|
192
|
+
for (const f of found) {
|
|
193
|
+
if (keys.size >= MAX_KEYS) {
|
|
194
|
+
limited++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const r = add(f, "manual");
|
|
198
|
+
if (r.duplicate) duplicates++;
|
|
199
|
+
else added.push(view(r.entry));
|
|
200
|
+
}
|
|
201
|
+
return send(res, 200, { added, duplicates, limited, maxKeys: MAX_KEYS });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const m = p.match(/^\/api\/keys\/([a-f0-9]{12})$/);
|
|
205
|
+
if (m) {
|
|
206
|
+
const entry = keys.get(m[1]);
|
|
207
|
+
if (!entry) return send(res, 404, { error: "Unknown key" });
|
|
208
|
+
if (req.method === "DELETE") {
|
|
209
|
+
keys.delete(m[1]);
|
|
210
|
+
return send(res, 200, { ok: true });
|
|
211
|
+
}
|
|
212
|
+
if (req.method === "PATCH") {
|
|
213
|
+
const body = await readJson(req);
|
|
214
|
+
if (!providers[body.provider]) return send(res, 400, { error: "Unknown provider" });
|
|
215
|
+
entry.provider = body.provider;
|
|
216
|
+
entry.confident = true;
|
|
217
|
+
return send(res, 200, view(entry));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (req.method === "POST" && p === "/api/check") {
|
|
222
|
+
const body = await readJson(req);
|
|
223
|
+
const entry = keys.get(String(body.ref || ""));
|
|
224
|
+
if (!entry) return send(res, 404, { error: "Unknown key" });
|
|
225
|
+
if (!entry.provider) return send(res, 400, { error: "Choose a provider for this key first" });
|
|
226
|
+
const result = await checkKey(entry.provider, entry.key, { timeoutMs, fetchImpl });
|
|
227
|
+
return send(res, 200, { ref: entry.ref, ...result });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return send(res, 404, { error: "Not found" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const server = http.createServer((req, res) => {
|
|
234
|
+
handle(req, res).catch((e) => {
|
|
235
|
+
if (!res.headersSent) send(res, e.status || 500, { error: e.status ? e.message : "Internal error" });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
server,
|
|
241
|
+
token,
|
|
242
|
+
keys,
|
|
243
|
+
scan,
|
|
244
|
+
listen(port) {
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
const onError = (e) => {
|
|
247
|
+
server.off("listening", onListening);
|
|
248
|
+
reject(e);
|
|
249
|
+
};
|
|
250
|
+
const onListening = () => {
|
|
251
|
+
server.off("error", onError);
|
|
252
|
+
const actual = server.address().port;
|
|
253
|
+
allowedHosts = new Set([`127.0.0.1:${actual}`, `localhost:${actual}`]);
|
|
254
|
+
resolve(actual);
|
|
255
|
+
};
|
|
256
|
+
server.once("error", onError);
|
|
257
|
+
server.once("listening", onListening);
|
|
258
|
+
server.listen(port, "127.0.0.1");
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
close: () =>
|
|
262
|
+
new Promise((r) => {
|
|
263
|
+
server.close(() => r());
|
|
264
|
+
server.closeAllConnections?.();
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function listenWithFallback(app, port, attempts = 20) {
|
|
270
|
+
for (let i = 0; i < attempts; i++) {
|
|
271
|
+
try {
|
|
272
|
+
return await app.listen(port + i);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (e.code !== "EADDRINUSE" || i === attempts - 1) throw e;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function openBrowser(url) {
|
|
280
|
+
const [cmd, args] =
|
|
281
|
+
process.platform === "win32"
|
|
282
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
283
|
+
: process.platform === "darwin"
|
|
284
|
+
? ["open", [url]]
|
|
285
|
+
: ["xdg-open", [url]];
|
|
286
|
+
try {
|
|
287
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
288
|
+
child.on("error", () => {});
|
|
289
|
+
child.unref();
|
|
290
|
+
} catch {
|
|
291
|
+
// No browser available (SSH, container…) — the URL is printed anyway.
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/term.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Tiny ANSI helper that respects NO_COLOR / FORCE_COLOR / non-TTY output.
|
|
2
|
+
|
|
3
|
+
const enabled =
|
|
4
|
+
process.env.FORCE_COLOR ? process.env.FORCE_COLOR !== "0" : !process.env.NO_COLOR && process.stdout.isTTY;
|
|
5
|
+
|
|
6
|
+
const wrap = (open, close) => (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
7
|
+
|
|
8
|
+
export const c = {
|
|
9
|
+
bold: wrap(1, 22),
|
|
10
|
+
dim: wrap(2, 22),
|
|
11
|
+
red: wrap(31, 39),
|
|
12
|
+
green: wrap(32, 39),
|
|
13
|
+
yellow: wrap(33, 39),
|
|
14
|
+
blue: wrap(34, 39),
|
|
15
|
+
magenta: wrap(35, 39),
|
|
16
|
+
cyan: wrap(36, 39),
|
|
17
|
+
gray: wrap(90, 39),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const unicode = process.platform !== "win32" || Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM);
|
|
21
|
+
|
|
22
|
+
export const sym = unicode
|
|
23
|
+
? { live: "✓", invalid: "✗", error: "!", limited: "~", no_credit: "$", dot: "●", arrow: "→", sep: "·" }
|
|
24
|
+
: { live: "+", invalid: "x", error: "!", limited: "~", no_credit: "$", dot: "*", arrow: "->", sep: "|" };
|
|
25
|
+
|
|
26
|
+
// Visible width, ignoring ANSI escapes.
|
|
27
|
+
export const width = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
28
|
+
export const pad = (s, n) => s + " ".repeat(Math.max(0, n - width(s)));
|
package/src/update.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Update checks and self-update.
|
|
2
|
+
//
|
|
3
|
+
// Privacy: the only request is GET https://registry.npmjs.org/apilive/latest.
|
|
4
|
+
// It sends no keys, no usage data and no identifiers. At most once a day.
|
|
5
|
+
// Disable with --no-update-check, NO_UPDATE_NOTIFIER=1, APILIVE_NO_UPDATE_CHECK=1
|
|
6
|
+
// or DO_NOT_TRACK=1. It is skipped automatically in CI.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const PACKAGE = "apilive";
|
|
15
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
16
|
+
const INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
export const RELEASES_URL = "https://github.com/parthksingh1/apilive/releases";
|
|
18
|
+
|
|
19
|
+
export function configDir() {
|
|
20
|
+
if (process.env.APILIVE_CONFIG_DIR) return process.env.APILIVE_CONFIG_DIR;
|
|
21
|
+
if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "apilive");
|
|
22
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Preferences", "apilive");
|
|
23
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "apilive");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const stateFile = () => path.join(configDir(), "state.json");
|
|
27
|
+
|
|
28
|
+
export function readState() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(stateFile(), "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeState(patch) {
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
39
|
+
fs.writeFileSync(stateFile(), JSON.stringify({ ...readState(), ...patch }, null, 2));
|
|
40
|
+
} catch {
|
|
41
|
+
// Read-only home, sandbox… never fatal.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function updateCheckDisabled(flagDisabled = false) {
|
|
46
|
+
const env = process.env;
|
|
47
|
+
return Boolean(
|
|
48
|
+
flagDisabled ||
|
|
49
|
+
env.NO_UPDATE_NOTIFIER ||
|
|
50
|
+
env.APILIVE_NO_UPDATE_CHECK ||
|
|
51
|
+
env.DO_NOT_TRACK === "1" ||
|
|
52
|
+
env.CI ||
|
|
53
|
+
env.NODE_ENV === "test" ||
|
|
54
|
+
readState().updateCheck === false,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compareVersions(a, b) {
|
|
59
|
+
const parse = (v) => String(v).replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
60
|
+
const [x, y] = [parse(a), parse(b)];
|
|
61
|
+
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0) ? 1 : -1;
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns { current, latest, updateAvailable, checkedAt, disabled }.
|
|
66
|
+
export async function checkForUpdate(current, { disabled = false, force = false, fetchImpl = globalThis.fetch } = {}) {
|
|
67
|
+
const base = { current, latest: null, updateAvailable: false, checkedAt: null, disabled: false };
|
|
68
|
+
if (updateCheckDisabled(disabled)) return { ...base, disabled: true };
|
|
69
|
+
|
|
70
|
+
const state = readState();
|
|
71
|
+
const fresh = state.lastUpdateCheck && Date.now() - state.lastUpdateCheck < INTERVAL_MS;
|
|
72
|
+
let latest = state.latestVersion || null;
|
|
73
|
+
let checkedAt = state.lastUpdateCheck || null;
|
|
74
|
+
let registryStatus = null;
|
|
75
|
+
|
|
76
|
+
if (force || !fresh || !latest) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetchImpl(REGISTRY_URL, {
|
|
79
|
+
headers: { Accept: "application/json", "User-Agent": "apilive-update-check" },
|
|
80
|
+
signal: AbortSignal.timeout(2500),
|
|
81
|
+
});
|
|
82
|
+
registryStatus = res.status;
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
if (typeof body.version === "string") {
|
|
86
|
+
latest = body.version;
|
|
87
|
+
checkedAt = Date.now();
|
|
88
|
+
writeState({ latestVersion: latest, lastUpdateCheck: checkedAt });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Offline or blocked — silently skip.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ...base, latest, checkedAt, registryStatus, updateAvailable: Boolean(latest && compareVersions(latest, current) > 0) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// How was apilive launched? Decides how to update it.
|
|
99
|
+
export function installKind() {
|
|
100
|
+
const here = path.dirname(fileURLToPath(import.meta.url)).toLowerCase();
|
|
101
|
+
if (here.includes(`${path.sep}_npx${path.sep}`) || process.env.npm_command === "exec") return "npx";
|
|
102
|
+
try {
|
|
103
|
+
const r = spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: process.platform === "win32", timeout: 8000 });
|
|
104
|
+
const globalRoot = r.stdout?.trim().toLowerCase();
|
|
105
|
+
if (globalRoot && here.startsWith(globalRoot)) return "global";
|
|
106
|
+
} catch {}
|
|
107
|
+
if (here.includes(`${path.sep}node_modules${path.sep}`)) return "local";
|
|
108
|
+
return "source";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function updateCommand(kind = installKind()) {
|
|
112
|
+
return {
|
|
113
|
+
npx: "npx apilive@latest",
|
|
114
|
+
global: "npm install -g apilive@latest",
|
|
115
|
+
local: "npm install apilive@latest",
|
|
116
|
+
source: "git pull",
|
|
117
|
+
}[kind];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function runGlobalUpdate() {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const child = spawn("npm", ["install", "-g", `${PACKAGE}@latest`], {
|
|
123
|
+
stdio: "inherit",
|
|
124
|
+
shell: process.platform === "win32",
|
|
125
|
+
});
|
|
126
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
127
|
+
child.on("error", () => resolve(1));
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Small shared helpers.
|
|
2
|
+
|
|
3
|
+
// "sk-proj-abc…wxyz" — enough to recognise a key, never enough to use it.
|
|
4
|
+
export function mask(key) {
|
|
5
|
+
const k = String(key ?? "");
|
|
6
|
+
if (k.length <= 12) return k.slice(0, 2) + "…" + (k.length > 6 ? k.slice(-2) : "");
|
|
7
|
+
const head = Math.min(8, Math.floor(k.length / 5));
|
|
8
|
+
return `${k.slice(0, head)}…${k.slice(-4)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function plural(n, word, pluralWord = word + "s") {
|
|
12
|
+
return `${n} ${n === 1 ? word : pluralWord}`;
|
|
13
|
+
}
|