residoo 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,180 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * Claude Code session transcripts.
10
+ *
11
+ * One JSONL file per session, one JSON object per line, under
12
+ * ~/.claude/projects/<project-slug>/<session-id>.jsonl. This is the only
13
+ * source shipped in v1 — it's the one path we could verify actually exists
14
+ * and actually holds real transcript content, rather than a guessed path
15
+ * for a tool we didn't have installed to check against. See CONTRIBUTING.md
16
+ * for how to add a source for another tool.
17
+ */
18
+ const ROOT = path.join(os.homedir(), ".claude", "projects");
19
+
20
+ // Bounds for readLines(), see the docstring below for why both exist.
21
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — generous headroom over the
22
+ // largest real transcript this
23
+ // tool has been tested against (818MB).
24
+ const READ_TIMEOUT_MS = 60_000;
25
+
26
+ function id() { return "claude-code"; }
27
+ function label() { return "Claude Code"; }
28
+
29
+ function available() {
30
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
31
+ }
32
+
33
+ /**
34
+ * Dirent.isDirectory()/isFile() reflect the entry ITSELF (lstat semantics) —
35
+ * for a symlink they both return false, even when the link resolves to a
36
+ * real directory or file. A dotfiles manager or a project relocated onto a
37
+ * symlink would then be silently excluded from scanning with no indication
38
+ * anything was skipped. statSync (unlike lstatSync) follows the link, so
39
+ * only symlinks fall through to it — the common case stays lstat-only and
40
+ * cheap.
41
+ *
42
+ * The tradeoff, stated plainly rather than left implicit: following symlinks
43
+ * here means residoo will read whatever a `*.jsonl`-named symlink under
44
+ * ~/.claude/projects points at, not only files Claude Code itself wrote.
45
+ * Before this, lstat semantics accidentally sandboxed every scan to real
46
+ * transcript files; that sandbox is intentionally traded away for the
47
+ * relocated-project case. Do not place a symlink to a sensitive file inside
48
+ * ~/.claude/projects. See SECURITY.md.
49
+ */
50
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
51
+ if (checkFn(dirent)) return true;
52
+ if (!dirent.isSymbolicLink()) return false;
53
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
54
+ }
55
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
56
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
57
+
58
+ /**
59
+ * Yield { file, mtimeMs, sizeBytes, broken } for every session transcript
60
+ * found — including ones that could not actually be resolved.
61
+ *
62
+ * `broken: true` (mtimeMs/sizeBytes absent) marks a `.jsonl`-named entry, or
63
+ * a project directory, that looked like it should be scannable but wasn't —
64
+ * chiefly a dangling symlink (a real, plausible case: dotfiles-sync tools
65
+ * and moved home directories both produce these). The earlier version of
66
+ * this fix followed valid symlinks correctly but let a BROKEN one fall
67
+ * through its own try/catch and `continue` silently, inside this generator,
68
+ * before the caller ever saw it — reintroducing, for exactly the entries
69
+ * most likely to need it, the same silent exclusion this whole feature was
70
+ * built to end. Every entry this function decides not to scan is now
71
+ * reported, one way or another.
72
+ */
73
+ function* files() {
74
+ let projectDirs;
75
+ try { projectDirs = fs.readdirSync(ROOT, { withFileTypes: true }); }
76
+ catch { return; }
77
+
78
+ for (const proj of projectDirs) {
79
+ const dir = path.join(ROOT, proj.name);
80
+ if (!proj.isDirectory()) {
81
+ const resolved = isDirFollowingSymlink(dir, proj);
82
+ // Only a symlink that fails to resolve is a reportable failure — a
83
+ // stray non-directory entry that was never meant to be a project
84
+ // folder in the first place is silently out of scope, same as before.
85
+ if (!resolved) {
86
+ if (proj.isSymbolicLink()) yield { file: dir, broken: true };
87
+ continue;
88
+ }
89
+ }
90
+ let entries;
91
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
92
+ catch { yield { file: dir, broken: true }; continue; }
93
+
94
+ for (const e of entries) {
95
+ if (!e.name.endsWith(".jsonl")) continue;
96
+ const file = path.join(dir, e.name);
97
+ if (!e.isFile()) {
98
+ const resolved = isFileFollowingSymlink(file, e);
99
+ if (!resolved) {
100
+ if (e.isSymbolicLink()) yield { file, broken: true };
101
+ continue;
102
+ }
103
+ }
104
+ let stat;
105
+ try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
106
+ yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Read one transcript as an array of raw text lines.
113
+ *
114
+ * Streams line-by-line via readline/promises rather than `readFileSync +
115
+ * split` — verified this matters, not a theoretical concern: a real 818MB
116
+ * session on a real machine this tool was tested against threw
117
+ * `ERR_STRING_TOO_LONG` under the whole-file-as-one-string approach (V8's
118
+ * ~512M-character single-string limit). To be precise about what this fixes
119
+ * and what it doesn't: individual JSONL lines stay far under that ceiling,
120
+ * so line-by-line reading removes the crash — it does NOT reduce peak
121
+ * memory, since every line is still collected into one array before
122
+ * returning. A true bounded-memory version would match patterns against
123
+ * each line as it arrives instead of collecting first; not done here
124
+ * because no transcript observed so far makes that the binding constraint.
125
+ *
126
+ * Re-stats the file immediately before opening it — yes, files() already
127
+ * stat'd it once. That's deliberate, not an oversight: re-checking right
128
+ * before open narrows the TOCTOU window between "we decided this looks
129
+ * like a readable file" and "we actually opened it," which matters more
130
+ * here than saving one syscall, given the entry may be a symlink whose
131
+ * target isn't guaranteed stable between the two points.
132
+ *
133
+ * Returns { lines, status, bytesRead }. `status` is "complete", "partial"
134
+ * (some lines WERE read before a failure — scan them, don't discard real
135
+ * content just because the file didn't finish cleanly), "too-large", or
136
+ * "failed" (nothing could be read at all — e.g. deleted between the files()
137
+ * walk and this call, plausible mid-scan if Claude Code is actively
138
+ * writing). Callers should scan `lines` whenever present, and treat
139
+ * anything other than "complete" as worth surfacing to the user rather
140
+ * than silently folding into a clean report.
141
+ */
142
+ async function readLines(file) {
143
+ let stat;
144
+ try { stat = fs.statSync(file); }
145
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
146
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
147
+
148
+ const lines = [];
149
+ let bytesRead = 0;
150
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
151
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
152
+
153
+ // No natural timeout exists anywhere in Node's stream/readline stack. A
154
+ // symlink whose target changes between the stat above and this open
155
+ // (e.g. retargeted onto a FIFO with no writer) can make the underlying
156
+ // open() block forever with no 'error', 'line', or 'close' ever firing —
157
+ // destroying the stream is what actually unblocks that. Without this, one
158
+ // hostile or merely unlucky file hangs the entire CLI, no way out.
159
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
160
+
161
+ try {
162
+ for await (const line of rl) {
163
+ lines.push(line);
164
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
165
+ }
166
+ return { lines, status: "complete", bytesRead };
167
+ } catch {
168
+ // Whatever WAS read before the failure is real content and may contain
169
+ // a real secret — discarding it because the file didn't finish cleanly
170
+ // would be a silent false negative, which is worse than an honest
171
+ // "partial" label.
172
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
173
+ } finally {
174
+ clearTimeout(timer);
175
+ rl.close();
176
+ stream.destroy();
177
+ }
178
+ }
179
+
180
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Registry of transcript sources. Each source is a small adapter exposing
5
+ * { id, label, available, files, readLines } — see claude-code.js for the
6
+ * reference implementation and CONTRIBUTING.md for how to add one.
7
+ *
8
+ * Deliberately NOT included here: guessed paths for Cursor, GitHub Copilot,
9
+ * or Windsurf. Their local history formats are real but weren't verified
10
+ * against an actual installation while building this — shipping a scanner
11
+ * that silently checks the wrong path and reports "all clear" is worse than
12
+ * not supporting the tool at all. PRs adding a verified adapter are the
13
+ * fastest way to get a tool covered.
14
+ */
15
+ const claudeCode = require("./claude-code");
16
+
17
+ const ALL_SOURCES = [claudeCode];
18
+
19
+ function availableSources() {
20
+ return ALL_SOURCES.filter((s) => s.available());
21
+ }
22
+
23
+ module.exports = { ALL_SOURCES, availableSources };