@ohmaseclaro/fleetwatch 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/.env.example +25 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/server/attachmentStore.js +68 -0
- package/dist/server/auth.js +88 -0
- package/dist/server/env.js +43 -0
- package/dist/server/index.js +338 -0
- package/dist/server/jsonl.js +345 -0
- package/dist/server/ngrokConfig.js +78 -0
- package/dist/server/pairing.js +94 -0
- package/dist/server/projectPath.js +57 -0
- package/dist/server/providers/base.js +85 -0
- package/dist/server/providers/cursor.js +396 -0
- package/dist/server/providers/discovery.js +151 -0
- package/dist/server/providers/types.js +45 -0
- package/dist/server/registry.js +275 -0
- package/dist/server/server.js +397 -0
- package/dist/server/tail.js +122 -0
- package/dist/server/tunnel.js +103 -0
- package/dist/server/watcher.js +578 -0
- package/dist/web/assets/index-C2fCiOuu.css +1 -0
- package/dist/web/assets/index-DiilkyQ2.js +239 -0
- package/dist/web/icon-192.svg +6 -0
- package/dist/web/icon-512.svg +6 -0
- package/dist/web/index.html +21 -0
- package/dist/web/manifest.webmanifest +24 -0
- package/package.json +90 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
export async function readEntireFile(file) {
|
|
4
|
+
const stat = await fs.stat(file);
|
|
5
|
+
const content = await fs.readFile(file, "utf8");
|
|
6
|
+
const lines = splitLines(content);
|
|
7
|
+
return {
|
|
8
|
+
state: {
|
|
9
|
+
inode: stat.ino,
|
|
10
|
+
offset: stat.size,
|
|
11
|
+
partial: lines.partial,
|
|
12
|
+
},
|
|
13
|
+
lines: lines.complete,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function readDelta(file, state) {
|
|
17
|
+
let stat;
|
|
18
|
+
try {
|
|
19
|
+
stat = await fs.stat(file);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// File disappeared — treat as no new lines.
|
|
23
|
+
return { newState: state, lines: [], rotated: false };
|
|
24
|
+
}
|
|
25
|
+
const rotated = stat.ino !== state.inode || stat.size < state.offset;
|
|
26
|
+
if (rotated) {
|
|
27
|
+
const content = await fs.readFile(file, "utf8");
|
|
28
|
+
const lines = splitLines((state.partial || "") + content);
|
|
29
|
+
return {
|
|
30
|
+
newState: { inode: stat.ino, offset: stat.size, partial: lines.partial },
|
|
31
|
+
lines: lines.complete,
|
|
32
|
+
rotated: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (stat.size === state.offset) {
|
|
36
|
+
return { newState: state, lines: [], rotated: false };
|
|
37
|
+
}
|
|
38
|
+
const handle = await open(file, "r");
|
|
39
|
+
try {
|
|
40
|
+
const length = stat.size - state.offset;
|
|
41
|
+
const buf = Buffer.alloc(length);
|
|
42
|
+
await handle.read(buf, 0, length, state.offset);
|
|
43
|
+
const chunk = (state.partial || "") + buf.toString("utf8");
|
|
44
|
+
const lines = splitLines(chunk);
|
|
45
|
+
return {
|
|
46
|
+
newState: { inode: state.inode, offset: stat.size, partial: lines.partial },
|
|
47
|
+
lines: lines.complete,
|
|
48
|
+
rotated: false,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await handle.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the first `headCount` lines and last `tailCount` lines of a file
|
|
57
|
+
* without buffering the whole file. Used for metadata-only initial scans.
|
|
58
|
+
*
|
|
59
|
+
* For small files where the total lines fit within head+tail we return all
|
|
60
|
+
* lines in headLines (tailLines will be empty to avoid duplicates).
|
|
61
|
+
*/
|
|
62
|
+
export async function readHeadTail(file, headCount, tailCount) {
|
|
63
|
+
const stat = await fs.stat(file);
|
|
64
|
+
const content = await fs.readFile(file, "utf8");
|
|
65
|
+
const all = content.split(/\r?\n/).filter((l) => l.length > 0);
|
|
66
|
+
const state = { inode: stat.ino, offset: stat.size, partial: "" };
|
|
67
|
+
if (all.length <= headCount + tailCount) {
|
|
68
|
+
return { headLines: all, tailLines: [], state };
|
|
69
|
+
}
|
|
70
|
+
const headLines = all.slice(0, headCount);
|
|
71
|
+
const tailLines = all.slice(all.length - tailCount);
|
|
72
|
+
return { headLines, tailLines, state };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read the last `n` complete lines from a large file WITHOUT reading the whole
|
|
76
|
+
* file into memory. Works by seeking backwards from EOF in 64 KB chunks.
|
|
77
|
+
*
|
|
78
|
+
* Returns the lines in forward (chronological) order plus a TailState pointing
|
|
79
|
+
* at EOF so the caller can resume with readDelta for subsequent changes.
|
|
80
|
+
*/
|
|
81
|
+
export async function readTailLines(file, n) {
|
|
82
|
+
const stat = await fs.stat(file);
|
|
83
|
+
const fileSize = stat.size;
|
|
84
|
+
const state = { inode: stat.ino, offset: fileSize, partial: "" };
|
|
85
|
+
if (fileSize === 0 || n === 0)
|
|
86
|
+
return { lines: [], state };
|
|
87
|
+
const CHUNK = 64 * 1024; // 64 KB — large enough to hold many events
|
|
88
|
+
const handle = await open(file, "r");
|
|
89
|
+
try {
|
|
90
|
+
let pos = fileSize;
|
|
91
|
+
let accumulated = "";
|
|
92
|
+
let complete = [];
|
|
93
|
+
while (pos > 0 && complete.length <= n) {
|
|
94
|
+
const readSize = Math.min(CHUNK, pos);
|
|
95
|
+
pos -= readSize;
|
|
96
|
+
const buf = Buffer.alloc(readSize);
|
|
97
|
+
await handle.read(buf, 0, readSize, pos);
|
|
98
|
+
accumulated = buf.toString("utf8") + accumulated;
|
|
99
|
+
// Count complete lines found so far (split, filter, keep going if not enough)
|
|
100
|
+
const parts = accumulated.split(/\r?\n/);
|
|
101
|
+
// parts[0] might be a partial line — preserve it for the next iteration
|
|
102
|
+
if (pos > 0) {
|
|
103
|
+
accumulated = parts[0]; // keep the head fragment for next chunk
|
|
104
|
+
complete = parts.slice(1).filter((l) => l.length > 0).concat(complete);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Reached beginning of file — include everything
|
|
108
|
+
complete = parts.filter((l) => l.length > 0);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { lines: complete.slice(-n), state };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await handle.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function splitLines(input) {
|
|
118
|
+
const lines = input.split(/\r?\n/);
|
|
119
|
+
const partial = lines.pop() ?? "";
|
|
120
|
+
return { complete: lines.filter((l) => l.length > 0), partial };
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=tail.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages a single ngrok HTTPS tunnel that forwards traffic to the local
|
|
3
|
+
* Fastify server. The tunnel is started once at boot and torn down on exit.
|
|
4
|
+
*
|
|
5
|
+
* Free tier (free.ngrok.com account, no credit card):
|
|
6
|
+
* https://dashboard.ngrok.com/get-started/your-authtoken
|
|
7
|
+
*/
|
|
8
|
+
import ngrok from "@ngrok/ngrok";
|
|
9
|
+
let active = null;
|
|
10
|
+
let lastError = null;
|
|
11
|
+
/**
|
|
12
|
+
* Map raw ngrok error strings to a stable error code + actionable hint.
|
|
13
|
+
* Free-tier errors usually include `ERR_NGROK_<code>`; we sniff for that.
|
|
14
|
+
*/
|
|
15
|
+
function classifyError(raw) {
|
|
16
|
+
const lower = raw.toLowerCase();
|
|
17
|
+
if (lower.includes("err_ngrok_105") || lower.includes("invalid") && lower.includes("token")) {
|
|
18
|
+
return {
|
|
19
|
+
code: "invalid_authtoken",
|
|
20
|
+
raw,
|
|
21
|
+
hint: "ngrok rejected the authtoken. Double-check it at https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (lower.includes("err_ngrok_3200") || lower.includes("limit") || lower.includes("simultaneous")) {
|
|
25
|
+
return {
|
|
26
|
+
code: "limited_tier",
|
|
27
|
+
raw,
|
|
28
|
+
hint: "Free tier limit hit — only one ngrok agent can run at a time. Stop other ngrok instances first.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (lower.includes("enotfound") || lower.includes("econnrefused") || lower.includes("etimedout") || lower.includes("network")) {
|
|
32
|
+
return {
|
|
33
|
+
code: "network",
|
|
34
|
+
raw,
|
|
35
|
+
hint: "Could not reach ngrok servers. Check your internet connection and try again.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
code: "unknown",
|
|
40
|
+
raw,
|
|
41
|
+
hint: "Tunnel start failed. See the raw error for details — and try restarting the daemon.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Start the ngrok tunnel. Returns the public HTTPS URL, or null if it fails.
|
|
46
|
+
* Only one tunnel is kept alive at a time — calling again closes the old one.
|
|
47
|
+
*/
|
|
48
|
+
export async function startTunnel(opts) {
|
|
49
|
+
// Tear down any existing tunnel first.
|
|
50
|
+
if (active) {
|
|
51
|
+
await active.stop().catch(() => { });
|
|
52
|
+
active = null;
|
|
53
|
+
}
|
|
54
|
+
lastError = null;
|
|
55
|
+
try {
|
|
56
|
+
opts.onLog?.("[ngrok] connecting tunnel…");
|
|
57
|
+
const listener = await ngrok.forward({
|
|
58
|
+
addr: opts.port,
|
|
59
|
+
authtoken: opts.authtoken,
|
|
60
|
+
schemes: ["https"],
|
|
61
|
+
// Skip the browser interstitial page on free tier.
|
|
62
|
+
request_header_add: ["ngrok-skip-browser-warning: 1"],
|
|
63
|
+
});
|
|
64
|
+
const url = listener.url();
|
|
65
|
+
if (!url)
|
|
66
|
+
throw new Error("ngrok returned no URL");
|
|
67
|
+
opts.onLog?.(`[ngrok] tunnel active: ${url}`);
|
|
68
|
+
active = {
|
|
69
|
+
url,
|
|
70
|
+
stop: async () => {
|
|
71
|
+
try {
|
|
72
|
+
await listener.close();
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
active = null;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return active;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const raw = err.message;
|
|
82
|
+
lastError = classifyError(raw);
|
|
83
|
+
opts.onLog?.(`[ngrok] ${lastError.code}: ${lastError.hint}`);
|
|
84
|
+
opts.onLog?.(`[ngrok] (raw: ${raw})`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function lastTunnelError() {
|
|
89
|
+
return lastError;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Stop the currently active tunnel (if any). Safe to call when not running.
|
|
93
|
+
*/
|
|
94
|
+
export async function stopTunnel() {
|
|
95
|
+
if (active) {
|
|
96
|
+
await active.stop().catch(() => { });
|
|
97
|
+
active = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function activeTunnel() {
|
|
101
|
+
return active;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=tunnel.js.map
|