poltertab 1.9.0 → 1.10.0-beta.2
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/bin/pair-flow.js +320 -0
- package/bin/poltertab.js +11 -0
- package/bin/setup.js +196 -13
- package/bin/uploads.js +294 -0
- package/mcp-server/bridge.js +262 -1
- package/mcp-server/config.js +7 -1
- package/mcp-server/index.js +10 -1
- package/mcp-server/pair-host.js +184 -0
- package/mcp-server/upload-paths.js +74 -4
- package/mcp-server/uploads-config.js +91 -0
- package/mcp-server/uploads-log.js +59 -0
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// In-memory pairing state. The pair code NEVER leaves this module except
|
|
4
|
+
// via the pair_code push to one socket. It is never written to config.json,
|
|
5
|
+
// to uploads.log.jsonl, to the console, or returned in get_status / state_update.
|
|
6
|
+
//
|
|
7
|
+
// generateForSocket — push a fresh code to an extension socket.
|
|
8
|
+
// handleCodeRequest — throttled re-push (30 s / socket).
|
|
9
|
+
// submitCode — validate a code from the CLI; returns { ok, extensionId, extWs }
|
|
10
|
+
// or { ok: false, reason, triesLeft? / secsLeft? }.
|
|
11
|
+
// onSocketClose — void the code for a socket when it disconnects.
|
|
12
|
+
|
|
13
|
+
const crypto = require("crypto");
|
|
14
|
+
|
|
15
|
+
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 chars; no I L O 0 1
|
|
16
|
+
const CODE_LENGTH = 4;
|
|
17
|
+
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
const REQUEST_THROTTLE_MS = 30 * 1000; // 30 s per socket
|
|
19
|
+
const LOCKOUT_MAX_WRONG = 5;
|
|
20
|
+
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
|
21
|
+
|
|
22
|
+
function _randomCode() {
|
|
23
|
+
let s = "";
|
|
24
|
+
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
25
|
+
s += ALPHABET[crypto.randomInt(ALPHABET.length)];
|
|
26
|
+
}
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class PairHost {
|
|
31
|
+
// opts.codeTtlMs — injectable TTL for unit tests (defaults to 5 minutes).
|
|
32
|
+
constructor({ codeTtlMs = CODE_TTL_MS } = {}) {
|
|
33
|
+
this._codeTtlMs = codeTtlMs;
|
|
34
|
+
// code → { ws, extensionId, expiresAt, timer }
|
|
35
|
+
this._codes = new Map();
|
|
36
|
+
// ws → code
|
|
37
|
+
this._socketCodes = new Map();
|
|
38
|
+
// ws → timestamp of last pair_code_request
|
|
39
|
+
this._socketLastRequest = new Map();
|
|
40
|
+
// global wrong-guess counter
|
|
41
|
+
this._lockout = { wrongCount: 0, lockedUntil: 0 };
|
|
42
|
+
// codes that recently expired — kept for one TTL window so submitCode can
|
|
43
|
+
// tell the user "expired, try the new one" without counting it as wrong.
|
|
44
|
+
this._recentlyExpired = new Set();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_isLockedOut() {
|
|
48
|
+
return this._lockout.lockedUntil > Date.now();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_uniqueCode() {
|
|
52
|
+
let code;
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
do {
|
|
55
|
+
code = _randomCode();
|
|
56
|
+
} while (this._codes.has(code) && ++attempts < 200);
|
|
57
|
+
return code;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Push a pair_code to this extension socket.
|
|
61
|
+
// Voids any previous code for the same socket first.
|
|
62
|
+
generateForSocket(ws, extensionId) {
|
|
63
|
+
// Void old code for this socket without removing throttle tracking.
|
|
64
|
+
const old = this._socketCodes.get(ws);
|
|
65
|
+
if (old) {
|
|
66
|
+
const entry = this._codes.get(old);
|
|
67
|
+
if (entry) clearTimeout(entry.timer);
|
|
68
|
+
this._codes.delete(old);
|
|
69
|
+
this._socketCodes.delete(ws);
|
|
70
|
+
// Track as recently-expired so a late submitCode returns "expired" not "wrong".
|
|
71
|
+
this._recentlyExpired.add(old);
|
|
72
|
+
setTimeout(() => this._recentlyExpired.delete(old), this._codeTtlMs).unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const code = this._uniqueCode();
|
|
76
|
+
const expiresAt = Date.now() + this._codeTtlMs;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
if (this._codes.get(code)) {
|
|
79
|
+
this._codes.delete(code);
|
|
80
|
+
this._socketCodes.delete(ws);
|
|
81
|
+
// Track as recently-expired so a late submitCode returns "expired" (not
|
|
82
|
+
// "wrong") and does not burn a lockout try.
|
|
83
|
+
this._recentlyExpired.add(code);
|
|
84
|
+
setTimeout(() => this._recentlyExpired.delete(code), this._codeTtlMs).unref();
|
|
85
|
+
// Spec: push a fresh code down the same socket so the popup never shows
|
|
86
|
+
// a dead code. If the socket is already closed, nothing to do.
|
|
87
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
88
|
+
this.generateForSocket(ws, extensionId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}, this._codeTtlMs);
|
|
92
|
+
|
|
93
|
+
this._codes.set(code, { ws, extensionId, expiresAt, timer });
|
|
94
|
+
this._socketCodes.set(ws, code);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
98
|
+
ws.send(JSON.stringify({ type: "pair_code", code, expiresAt }));
|
|
99
|
+
}
|
|
100
|
+
} catch (_) {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Throttled re-request from an extension socket.
|
|
104
|
+
handleCodeRequest(ws, extensionId) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const last = this._socketLastRequest.get(ws) || 0;
|
|
107
|
+
if (now - last < REQUEST_THROTTLE_MS) {
|
|
108
|
+
// Re-send the existing code if one exists; otherwise ignore.
|
|
109
|
+
const existing = this._socketCodes.get(ws);
|
|
110
|
+
if (existing && this._codes.has(existing)) {
|
|
111
|
+
try {
|
|
112
|
+
if (ws.readyState === 1) {
|
|
113
|
+
ws.send(JSON.stringify({ type: "pair_code", code: existing }));
|
|
114
|
+
}
|
|
115
|
+
} catch (_) {}
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this._socketLastRequest.set(ws, now);
|
|
120
|
+
this.generateForSocket(ws, extensionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate a submitted code. Called by bridge.js (from CLI) or by the temp
|
|
124
|
+
// listener (directly from the local readline prompt).
|
|
125
|
+
// Returns { ok: true, extensionId, extWs }
|
|
126
|
+
// or { ok: false, reason: "locked_out"|"wrong"|"expired", secsLeft?, triesLeft? }
|
|
127
|
+
submitCode(rawCode) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (this._isLockedOut()) {
|
|
130
|
+
const secsLeft = Math.ceil((this._lockout.lockedUntil - now) / 1000);
|
|
131
|
+
return { ok: false, reason: "locked_out", secsLeft };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const code = String(rawCode || "").toUpperCase().trim();
|
|
135
|
+
const entry = this._codes.get(code);
|
|
136
|
+
|
|
137
|
+
// Expired code: tell the user to wait for the new one; do NOT count as a
|
|
138
|
+
// wrong guess — the expiry timer already regenerated a fresh code.
|
|
139
|
+
// This handles both the sub-second race (entry still present but past
|
|
140
|
+
// expiresAt) and the post-timer case (entry deleted, code in _recentlyExpired).
|
|
141
|
+
if ((entry && entry.expiresAt < now) || this._recentlyExpired.has(code)) {
|
|
142
|
+
return { ok: false, reason: "expired" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!entry) {
|
|
146
|
+
this._lockout.wrongCount += 1;
|
|
147
|
+
if (this._lockout.wrongCount >= LOCKOUT_MAX_WRONG) {
|
|
148
|
+
this._lockout = { wrongCount: 0, lockedUntil: now + LOCKOUT_DURATION_MS };
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: "locked_out",
|
|
152
|
+
secsLeft: Math.ceil(LOCKOUT_DURATION_MS / 1000),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: "wrong",
|
|
158
|
+
triesLeft: LOCKOUT_MAX_WRONG - this._lockout.wrongCount,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Consume — single-use.
|
|
163
|
+
clearTimeout(entry.timer);
|
|
164
|
+
this._codes.delete(code);
|
|
165
|
+
this._socketCodes.delete(entry.ws);
|
|
166
|
+
this._lockout.wrongCount = 0;
|
|
167
|
+
|
|
168
|
+
return { ok: true, extensionId: entry.extensionId, extWs: entry.ws };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Called on every socket close in the Primary's connection handler.
|
|
172
|
+
onSocketClose(ws) {
|
|
173
|
+
const code = this._socketCodes.get(ws);
|
|
174
|
+
if (code) {
|
|
175
|
+
const entry = this._codes.get(code);
|
|
176
|
+
if (entry) clearTimeout(entry.timer);
|
|
177
|
+
this._codes.delete(code);
|
|
178
|
+
this._socketCodes.delete(ws);
|
|
179
|
+
}
|
|
180
|
+
this._socketLastRequest.delete(ws);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { PairHost };
|
|
@@ -70,9 +70,9 @@ function validateUploadPaths(filePaths, config) {
|
|
|
70
70
|
return {
|
|
71
71
|
ok: false,
|
|
72
72
|
errorText:
|
|
73
|
-
"File uploads are disabled.
|
|
74
|
-
"
|
|
75
|
-
"Example:\n
|
|
73
|
+
"File uploads are disabled. The user can run `poltertab uploads allow <dir>` to permit a " +
|
|
74
|
+
"directory, or open the extension options page to manage allowed folders.\n\n" +
|
|
75
|
+
"Example:\n poltertab uploads allow ~/Documents",
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -139,4 +139,74 @@ function validateUploadPaths(filePaths, config) {
|
|
|
139
139
|
return { ok: true, resolvedPaths };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Validate a single path for addition to the allowlist.
|
|
144
|
+
* Accepts absolute paths and ~/relative paths. Rejects relative paths and
|
|
145
|
+
* paths inside the always-blocked set. Accepts nonexistent directories.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} dir - The path the user wants to allow.
|
|
148
|
+
* @param {string} [homeDir] - Override os.homedir() (tests only).
|
|
149
|
+
* @returns {{ ok: true } | { ok: false, errorText: string }}
|
|
150
|
+
*/
|
|
151
|
+
function validateAllowlistEntry(dir, homeDir = os.homedir()) {
|
|
152
|
+
if (!dir || typeof dir !== "string") {
|
|
153
|
+
return { ok: false, errorText: "Path must be a non-empty string." };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!path.isAbsolute(dir) && !dir.startsWith("~/")) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
errorText: `"${dir}" is a relative path. Use an absolute path or ~/relative form.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const expanded = dir.startsWith("~/")
|
|
164
|
+
? path.join(homeDir, dir.slice(2))
|
|
165
|
+
: dir;
|
|
166
|
+
let resolved = path.resolve(expanded);
|
|
167
|
+
|
|
168
|
+
// For existing paths: realpath so symlinks (e.g. ~/link-to-ssh) are caught
|
|
169
|
+
// by the always-blocked check below; also verify it is a directory.
|
|
170
|
+
try {
|
|
171
|
+
const real = fs.realpathSync(resolved);
|
|
172
|
+
const stat = fs.statSync(real);
|
|
173
|
+
if (!stat.isDirectory()) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
errorText: `"${dir}" is a file, not a directory. Only directories can be added to the allowlist.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
resolved = real;
|
|
180
|
+
} catch (_) {
|
|
181
|
+
// Path does not yet exist — accepted so future-created dirs can be pre-allowed.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const alwaysBlocked = buildAlwaysBlocked(homeDir);
|
|
185
|
+
for (const blocked of alwaysBlocked) {
|
|
186
|
+
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
errorText: `"${dir}" is inside a protected directory (${blocked}) and cannot be added.`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Deny home dotfiles
|
|
195
|
+
const rel = path.relative(homeDir, resolved);
|
|
196
|
+
if (!rel.startsWith("..") && rel.startsWith(".") && !rel.includes(path.sep)) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
errorText: `"${dir}" is a home directory dotfile and cannot be added.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
validateUploadPaths,
|
|
208
|
+
validateAllowlistEntry,
|
|
209
|
+
buildAlwaysBlocked,
|
|
210
|
+
expandAllowlistEntry,
|
|
211
|
+
MAX_BYTES,
|
|
212
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Atomic read-modify-write for the upload_allowed_paths key in config.json.
|
|
4
|
+
// Preserves all other keys in config.json. Atomic write = temp file + rename.
|
|
5
|
+
//
|
|
6
|
+
// Used by bridge.js (WS messages) and bin/uploads.js (CLI).
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { POLTERTAB_HOME } = require("./config.js");
|
|
12
|
+
|
|
13
|
+
const CONFIG_PATH = path.join(POLTERTAB_HOME, "config.json");
|
|
14
|
+
|
|
15
|
+
function _readConfig() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code === "ENOENT") return {};
|
|
20
|
+
throw new Error("config.json is not valid JSON — fix it before editing the allowlist");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _writeConfig(data) {
|
|
25
|
+
fs.mkdirSync(POLTERTAB_HOME, { recursive: true });
|
|
26
|
+
const tmp = CONFIG_PATH + ".tmp." + process.pid;
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
28
|
+
fs.renameSync(tmp, CONFIG_PATH);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getAllowlist() {
|
|
32
|
+
const cfg = _readConfig();
|
|
33
|
+
const v = cfg.upload_allowed_paths;
|
|
34
|
+
return Array.isArray(v) ? v : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Adds dir to the allowlist if not already present. Returns true if added,
|
|
38
|
+
// false if already present. Throws if validation fails (call
|
|
39
|
+
// validateAllowlistEntry first to surface user-facing messages).
|
|
40
|
+
function addAllowlistEntry(dir) {
|
|
41
|
+
const cfg = _readConfig();
|
|
42
|
+
const list = Array.isArray(cfg.upload_allowed_paths) ? cfg.upload_allowed_paths : [];
|
|
43
|
+
if (list.includes(dir)) return false;
|
|
44
|
+
_writeConfig({ ...cfg, upload_allowed_paths: [...list, dir] });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Removes dir from the allowlist. Returns true if removed, false if absent.
|
|
49
|
+
function removeAllowlistEntry(dir) {
|
|
50
|
+
const cfg = _readConfig();
|
|
51
|
+
const list = Array.isArray(cfg.upload_allowed_paths) ? cfg.upload_allowed_paths : [];
|
|
52
|
+
const next = list.filter((e) => e !== dir);
|
|
53
|
+
if (next.length === list.length) return false;
|
|
54
|
+
_writeConfig({ ...cfg, upload_allowed_paths: next });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Adds id to trusted_extension_ids if not already present. Returns true if added.
|
|
59
|
+
function addTrustedExtensionId(id) {
|
|
60
|
+
const cfg = _readConfig();
|
|
61
|
+
const list = Array.isArray(cfg.trusted_extension_ids) ? cfg.trusted_extension_ids : [];
|
|
62
|
+
if (list.includes(id)) return false;
|
|
63
|
+
_writeConfig({ ...cfg, trusted_extension_ids: [...list, id] });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getTrustedExtensionIds() {
|
|
68
|
+
const cfg = _readConfig();
|
|
69
|
+
const v = cfg.trusted_extension_ids;
|
|
70
|
+
return Array.isArray(v) ? v : [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Removes id from trusted_extension_ids. Returns true if removed, false if absent.
|
|
74
|
+
function removeTrustedExtensionId(id) {
|
|
75
|
+
const cfg = _readConfig();
|
|
76
|
+
const list = Array.isArray(cfg.trusted_extension_ids) ? cfg.trusted_extension_ids : [];
|
|
77
|
+
const next = list.filter((e) => e !== id);
|
|
78
|
+
if (next.length === list.length) return false;
|
|
79
|
+
_writeConfig({ ...cfg, trusted_extension_ids: next });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
getAllowlist,
|
|
85
|
+
addAllowlistEntry,
|
|
86
|
+
removeAllowlistEntry,
|
|
87
|
+
addTrustedExtensionId,
|
|
88
|
+
getTrustedExtensionIds,
|
|
89
|
+
removeTrustedExtensionId,
|
|
90
|
+
CONFIG_PATH,
|
|
91
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Append + rotate upload activity log at ~/.poltertab/uploads.log.jsonl.
|
|
4
|
+
// Each entry: { ts, paths, method, outcome, origin, session }.
|
|
5
|
+
// Rotation: drop oldest lines until the file is ≤ LOG_MAX_BYTES.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { POLTERTAB_HOME } = require("./config.js");
|
|
10
|
+
|
|
11
|
+
const LOG_PATH = path.join(POLTERTAB_HOME, "uploads.log.jsonl");
|
|
12
|
+
const LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
13
|
+
|
|
14
|
+
function logUpload({ paths, method, outcome, origin, session }) {
|
|
15
|
+
const line =
|
|
16
|
+
JSON.stringify({
|
|
17
|
+
ts: new Date().toISOString(),
|
|
18
|
+
paths,
|
|
19
|
+
method,
|
|
20
|
+
outcome,
|
|
21
|
+
origin: origin || null,
|
|
22
|
+
session: session || null,
|
|
23
|
+
}) + "\n";
|
|
24
|
+
|
|
25
|
+
fs.mkdirSync(POLTERTAB_HOME, { recursive: true });
|
|
26
|
+
fs.appendFileSync(LOG_PATH, line, "utf8");
|
|
27
|
+
|
|
28
|
+
// Rotate if file exceeds limit
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.statSync(LOG_PATH);
|
|
31
|
+
if (stat.size > LOG_MAX_BYTES) {
|
|
32
|
+
let content = fs.readFileSync(LOG_PATH, "utf8");
|
|
33
|
+
const lines = content.split("\n").filter(Boolean);
|
|
34
|
+
while (lines.length > 1 && Buffer.byteLength(lines.join("\n") + "\n") > LOG_MAX_BYTES) {
|
|
35
|
+
lines.shift();
|
|
36
|
+
}
|
|
37
|
+
fs.writeFileSync(LOG_PATH, lines.join("\n") + "\n", "utf8");
|
|
38
|
+
}
|
|
39
|
+
} catch (_) {
|
|
40
|
+
// Rotation failure is non-fatal
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getRecentLog(n = 20) {
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(LOG_PATH, "utf8");
|
|
47
|
+
const lines = content.split("\n").filter(Boolean);
|
|
48
|
+
return lines
|
|
49
|
+
.slice(-n)
|
|
50
|
+
.map((l) => {
|
|
51
|
+
try { return JSON.parse(l); } catch (_) { return null; }
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
} catch (_) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { logUpload, getRecentLog, LOG_PATH, LOG_MAX_BYTES };
|
package/package.json
CHANGED