@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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Read the user's existing ngrok authtoken from ngrok's own config file.
3
+ *
4
+ * If they've already run `ngrok config add-authtoken <token>` at some point,
5
+ * the token is sitting in ngrok.yml — no reason to make them paste it again.
6
+ *
7
+ * Supported locations (in order):
8
+ * - macOS (v3): ~/Library/Application Support/ngrok/ngrok.yml
9
+ * - Linux/Win (v3): ~/.config/ngrok/ngrok.yml (or $LOCALAPPDATA/ngrok/)
10
+ * - Legacy (v2): ~/.ngrok2/ngrok.yml
11
+ *
12
+ * Supported formats:
13
+ * v2: `authtoken: <token>` (top-level)
14
+ * v3: `agent:\n authtoken: <token>` (nested under agent)
15
+ *
16
+ * We don't add a YAML dependency for this — a single-line regex over the file
17
+ * is sufficient since ngrok.yml has exactly one `authtoken:` key.
18
+ */
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ /** Default locations to probe, in priority order. */
23
+ function candidatePaths() {
24
+ const home = os.homedir();
25
+ const paths = [];
26
+ if (process.platform === "darwin") {
27
+ paths.push(path.join(home, "Library", "Application Support", "ngrok", "ngrok.yml"));
28
+ }
29
+ // Linux + common cross-platform location
30
+ paths.push(path.join(home, ".config", "ngrok", "ngrok.yml"));
31
+ if (process.platform === "win32" && process.env.LOCALAPPDATA) {
32
+ paths.push(path.join(process.env.LOCALAPPDATA, "ngrok", "ngrok.yml"));
33
+ }
34
+ // Legacy v2 location
35
+ paths.push(path.join(home, ".ngrok2", "ngrok.yml"));
36
+ return paths;
37
+ }
38
+ /**
39
+ * Probe known ngrok config paths and return the first authtoken found.
40
+ * Returns null if no ngrok config file exists or none contain an authtoken.
41
+ */
42
+ export function findNgrokAuthtoken() {
43
+ for (const p of candidatePaths()) {
44
+ if (!existsSync(p))
45
+ continue;
46
+ let raw;
47
+ try {
48
+ raw = readFileSync(p, "utf8");
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ const token = parseAuthtoken(raw);
54
+ if (token)
55
+ return { authtoken: token, source: p };
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Extract the authtoken value from a ngrok.yml file's contents.
61
+ *
62
+ * Handles both v2 (top-level `authtoken:`) and v3 (`agent: authtoken:` nested
63
+ * with 2-space indent). Strips optional surrounding quotes. Skips commented
64
+ * lines.
65
+ */
66
+ export function parseAuthtoken(yaml) {
67
+ for (const line of yaml.split(/\r?\n/)) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed || trimmed.startsWith("#"))
70
+ continue;
71
+ // Match `authtoken: value` (optionally quoted, any indentation)
72
+ const m = trimmed.match(/^authtoken:\s*['"]?([^'"\s#]+)['"]?\s*(#.*)?$/);
73
+ if (m && m[1])
74
+ return m[1];
75
+ }
76
+ return null;
77
+ }
78
+ //# sourceMappingURL=ngrokConfig.js.map
@@ -0,0 +1,94 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { nanoid } from "nanoid";
6
+ import QRCode from "qrcode";
7
+ /**
8
+ * Where persisted state lives. We migrated from `~/.config/claude-watcher/`
9
+ * to `~/.config/fleetwatch/` when the project was renamed — `loadOrInitConfig`
10
+ * silently picks up the old location if the new one doesn't exist yet.
11
+ */
12
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "fleetwatch");
13
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
14
+ const LEGACY_CONFIG_FILE = path.join(os.homedir(), ".config", "claude-watcher", "config.json");
15
+ export async function loadOrInitConfig() {
16
+ try {
17
+ // Prefer the new location; transparently fall back to the legacy one
18
+ // from when this project was called claude-watcher. Once we successfully
19
+ // save back, future loads come from the new path only.
20
+ let raw;
21
+ try {
22
+ raw = await fs.readFile(CONFIG_FILE, "utf8");
23
+ }
24
+ catch {
25
+ if (!existsSync(LEGACY_CONFIG_FILE))
26
+ throw new Error("no config yet");
27
+ raw = await fs.readFile(LEGACY_CONFIG_FILE, "utf8");
28
+ }
29
+ const parsed = JSON.parse(raw);
30
+ const result = {
31
+ token: parsed.token ?? nanoid(32),
32
+ jwtSecret: parsed.jwtSecret ?? nanoid(48),
33
+ hostLabel: parsed.hostLabel ?? os.hostname(),
34
+ ngrokAuthtoken: parsed.ngrokAuthtoken,
35
+ ngrokDisabled: parsed.ngrokDisabled,
36
+ preferences: {
37
+ includeCowork: parsed.preferences?.includeCowork ?? false,
38
+ showLibrarySessions: parsed.preferences?.showLibrarySessions ?? false,
39
+ },
40
+ };
41
+ // Persist on first read after migration / when we auto-generated fields.
42
+ if (!parsed.jwtSecret || !existsSync(CONFIG_FILE))
43
+ await saveConfig(result);
44
+ return result;
45
+ }
46
+ catch {
47
+ const fresh = {
48
+ token: nanoid(32),
49
+ jwtSecret: nanoid(48),
50
+ hostLabel: os.hostname(),
51
+ preferences: { includeCowork: false, showLibrarySessions: false },
52
+ };
53
+ await saveConfig(fresh);
54
+ return fresh;
55
+ }
56
+ }
57
+ export async function saveConfig(cfg) {
58
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
59
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf8");
60
+ }
61
+ export async function rotateToken(cfg) {
62
+ const next = { ...cfg, token: nanoid(32) };
63
+ await saveConfig(next);
64
+ return next;
65
+ }
66
+ export async function buildPairingPayload(host, port, token, overrideUrl) {
67
+ const url = overrideUrl
68
+ ? `${overrideUrl}/?token=${encodeURIComponent(token)}`
69
+ : `http://${host}:${port}/?token=${encodeURIComponent(token)}`;
70
+ const qrSvg = await QRCode.toString(url, { type: "svg", margin: 1, width: 320, color: { dark: "#3D3929", light: "#FAF9F5" } });
71
+ const qrAscii = await QRCode.toString(url, { type: "terminal", small: true });
72
+ return { url, qrSvg, qrAscii };
73
+ }
74
+ export function pickLanIp() {
75
+ const ifaces = os.networkInterfaces();
76
+ let candidate = null;
77
+ for (const [name, addrs] of Object.entries(ifaces)) {
78
+ if (!addrs)
79
+ continue;
80
+ for (const addr of addrs) {
81
+ if (addr.family !== "IPv4" || addr.internal)
82
+ continue;
83
+ // Prefer en0/en1 on macOS, but pick any non-internal IPv4.
84
+ if (name.startsWith("en") || name.startsWith("wlan") || name.startsWith("eth")) {
85
+ if (addr.address.startsWith("192.168.") || addr.address.startsWith("10.") || addr.address.startsWith("172.")) {
86
+ return addr.address;
87
+ }
88
+ }
89
+ candidate = candidate ?? addr.address;
90
+ }
91
+ }
92
+ return candidate;
93
+ }
94
+ //# sourceMappingURL=pairing.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Claude Code stores sessions under ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
3
+ * where <encoded-cwd> is the absolute path with '/' replaced by '-' (leading
4
+ * '/' becomes a leading '-').
5
+ *
6
+ * Decoding back is ambiguous because real directory names may contain '-',
7
+ * so we use a heuristic: walk the candidate path checking which prefix
8
+ * actually exists on disk, and reconstruct.
9
+ *
10
+ * For display purposes we don't need full fidelity — the last two segments
11
+ * are enough.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ const cache = new Map();
15
+ export function decodeProjectDir(encoded) {
16
+ const cached = cache.get(encoded);
17
+ if (cached)
18
+ return cached;
19
+ // Strategy: assume the typical case is /Users/name/... — try filesystem-resolution first.
20
+ const guessed = `/${encoded.replace(/^-/, "").split("-").join("/")}`;
21
+ let resolved = guessed;
22
+ if (!existsSync(guessed)) {
23
+ // Try resolving with a smarter walk: prefer the deepest prefix that exists.
24
+ resolved = bestExistingPath(encoded) ?? guessed;
25
+ }
26
+ const segments = resolved.split("/").filter(Boolean);
27
+ const label = segments.length >= 2
28
+ ? `${segments[segments.length - 2]} / ${segments[segments.length - 1]}`
29
+ : segments[segments.length - 1] ?? encoded;
30
+ const result = { projectPath: resolved, projectLabel: label };
31
+ cache.set(encoded, result);
32
+ return result;
33
+ }
34
+ function bestExistingPath(encoded) {
35
+ const parts = encoded.replace(/^-/, "").split("-");
36
+ if (parts.length === 0)
37
+ return null;
38
+ // Greedy: walk parts left-to-right; at each step try joining the next
39
+ // chunk with either "-" or "/". Prefer "/" when the candidate exists.
40
+ let acc = `/${parts[0]}`;
41
+ for (let i = 1; i < parts.length; i++) {
42
+ const withSlash = `${acc}/${parts[i]}`;
43
+ const withDash = `${acc}-${parts[i]}`;
44
+ if (existsSync(withSlash)) {
45
+ acc = withSlash;
46
+ }
47
+ else if (existsSync(withDash)) {
48
+ acc = withDash;
49
+ }
50
+ else {
51
+ // Neither prefix exists. Default to slash (typical case).
52
+ acc = withSlash;
53
+ }
54
+ }
55
+ return acc;
56
+ }
57
+ //# sourceMappingURL=projectPath.js.map
@@ -0,0 +1,85 @@
1
+ import { discover } from "./discovery.js";
2
+ /**
3
+ * Implementers focus on three things:
4
+ * 1. `info` — provider metadata
5
+ * 2. `onStart` — open files / DBs / connections, seed the registry
6
+ * 3. `backfillSession` — fetch events for a session the user just opened
7
+ *
8
+ * Everything else (state tracking, log prefixing, idempotent stop) is handled
9
+ * here so subclasses don't reinvent it.
10
+ */
11
+ export class BaseProvider {
12
+ registry;
13
+ _rawLog;
14
+ _state = "idle";
15
+ constructor(opts) {
16
+ this.registry = opts.registry;
17
+ this._rawLog = opts.onLog;
18
+ }
19
+ /** Provider id (mirrors info.id) — satisfies Provider interface. */
20
+ get id() {
21
+ return this.info.id;
22
+ }
23
+ /** Current lifecycle phase. */
24
+ get state() {
25
+ return this._state;
26
+ }
27
+ /**
28
+ * Public lifecycle entry — handles state bookkeeping and error trapping
29
+ * so ProviderManager doesn't need to know which phase a provider is in.
30
+ */
31
+ async start() {
32
+ if (this._state === "running" || this._state === "starting")
33
+ return;
34
+ this._state = "starting";
35
+ try {
36
+ await this.onStart();
37
+ // onStart may set state to "skipped" itself (via skipStartup()); if it
38
+ // didn't, we assume it ran successfully.
39
+ if (this._state === "starting")
40
+ this._state = "running";
41
+ }
42
+ catch (err) {
43
+ this._state = "stopped";
44
+ this.log(`failed to start: ${err.message}`);
45
+ throw err;
46
+ }
47
+ }
48
+ async stop() {
49
+ if (this._state === "stopped" || this._state === "idle" || this._state === "skipped")
50
+ return;
51
+ this._state = "stopping";
52
+ try {
53
+ await this.onStop();
54
+ }
55
+ finally {
56
+ this._state = "stopped";
57
+ }
58
+ }
59
+ // ─── helpers available to subclasses ───────────────────────────────────
60
+ /**
61
+ * Mark startup as deliberately skipped (e.g. dependency missing on disk).
62
+ * Call from inside onStart() before returning. The provider stays alive but
63
+ * does nothing — useful when a provider is optional.
64
+ */
65
+ skipStartup(reason) {
66
+ this.log(`skipping: ${reason}`);
67
+ this._state = "skipped";
68
+ }
69
+ /** Prefixed logger. Use this instead of console.* so messages are routable. */
70
+ log(msg) {
71
+ this._rawLog?.(`[${this.info.id}] ${msg}`);
72
+ }
73
+ /**
74
+ * Discover a data location using the shared discovery primitive — candidate
75
+ * path list with bounded filesystem search fallback. Logs results with the
76
+ * provider's prefix so users can see where each piece of data was found.
77
+ *
78
+ * Returns the resolved path, or null when nothing matched. The caller
79
+ * decides whether to skipStartup or surface a more specific error.
80
+ */
81
+ async discover(spec) {
82
+ return discover(spec, (msg) => this.log(msg));
83
+ }
84
+ }
85
+ //# sourceMappingURL=base.js.map