sdocs-dev 1.6.2 → 1.12.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/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -16
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Hardcoded list of OS paths that auto-delete files. Two categories:
|
|
2
|
+
//
|
|
3
|
+
// ephemeralRoots() - paths a user might legitimately put work in
|
|
4
|
+
// (`/tmp`, `~/.Trash`). Files here get a *rescue copy* taken into
|
|
5
|
+
// ~/.sdocs/library/rescued/ at index time, because the original is
|
|
6
|
+
// going to vanish on the OS's schedule.
|
|
7
|
+
//
|
|
8
|
+
// throwawayRoots() - OS-managed scratch directories like
|
|
9
|
+
// `os.tmpdir()` on macOS (which resolves to a per-user folder under
|
|
10
|
+
// /var/folders/...). Nothing meaningful lives here long enough to
|
|
11
|
+
// index; it is where Playwright sandboxes, build tools, and other
|
|
12
|
+
// transient processes write their working files. Anything under
|
|
13
|
+
// these roots is skipped entirely at index time - no rescue, no
|
|
14
|
+
// library entry. This is what stops test-run pollution from
|
|
15
|
+
// accumulating in the user's library.
|
|
16
|
+
//
|
|
17
|
+
// Both checks realpath() the input so macOS's /private/var/folders/...
|
|
18
|
+
// alias does not slip through.
|
|
19
|
+
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
function homedir() { return os.homedir(); }
|
|
25
|
+
|
|
26
|
+
// Resolve all known forms of a path so the `/private/...` macOS alias
|
|
27
|
+
// is matched the same as the bare form.
|
|
28
|
+
function expandAliases(p) {
|
|
29
|
+
if (!p) return [];
|
|
30
|
+
const out = new Set();
|
|
31
|
+
const resolved = path.resolve(p);
|
|
32
|
+
out.add(resolved);
|
|
33
|
+
try {
|
|
34
|
+
const real = fs.realpathSync(resolved);
|
|
35
|
+
if (real) out.add(real);
|
|
36
|
+
} catch (_) { /* path may not exist; resolved form is enough */ }
|
|
37
|
+
return [...out];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ephemeralRoots() {
|
|
41
|
+
const roots = new Set();
|
|
42
|
+
for (const r of expandAliases(os.tmpdir())) roots.add(r);
|
|
43
|
+
|
|
44
|
+
if (process.platform === 'darwin') {
|
|
45
|
+
roots.add('/tmp');
|
|
46
|
+
roots.add('/private/tmp');
|
|
47
|
+
roots.add('/var/tmp');
|
|
48
|
+
roots.add('/private/var/tmp');
|
|
49
|
+
roots.add(path.join(homedir(), '.Trash'));
|
|
50
|
+
} else if (process.platform === 'linux') {
|
|
51
|
+
roots.add('/tmp');
|
|
52
|
+
roots.add('/var/tmp');
|
|
53
|
+
roots.add('/run');
|
|
54
|
+
roots.add('/dev/shm');
|
|
55
|
+
roots.add(path.join(homedir(), '.cache'));
|
|
56
|
+
} else if (process.platform === 'win32') {
|
|
57
|
+
if (process.env.TEMP) roots.add(path.resolve(process.env.TEMP));
|
|
58
|
+
if (process.env.TMP) roots.add(path.resolve(process.env.TMP));
|
|
59
|
+
roots.add('C:\\Windows\\Temp');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...roots].filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// OS scratch directories - never index files from here, even with
|
|
66
|
+
// rescue. These are where test runners, build tools, and short-lived
|
|
67
|
+
// processes write their working files; the user does not put meaningful
|
|
68
|
+
// long-lived documents under them.
|
|
69
|
+
function throwawayRoots() {
|
|
70
|
+
const roots = new Set();
|
|
71
|
+
for (const r of expandAliases(os.tmpdir())) roots.add(r);
|
|
72
|
+
if (process.platform === 'darwin') {
|
|
73
|
+
// /var/folders/<u>/<gid>/T is per-user macOS tmp; the /private alias
|
|
74
|
+
// is the realpath, the un-prefixed form is the symlink. Cover both.
|
|
75
|
+
roots.add('/var/folders');
|
|
76
|
+
roots.add('/private/var/folders');
|
|
77
|
+
} else if (process.platform === 'linux') {
|
|
78
|
+
roots.add('/run');
|
|
79
|
+
roots.add('/dev/shm');
|
|
80
|
+
} else if (process.platform === 'win32') {
|
|
81
|
+
if (process.env.TEMP) roots.add(path.resolve(process.env.TEMP));
|
|
82
|
+
if (process.env.TMP) roots.add(path.resolve(process.env.TMP));
|
|
83
|
+
roots.add('C:\\Windows\\Temp');
|
|
84
|
+
}
|
|
85
|
+
return [...roots].filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathUnder(absPath, roots) {
|
|
89
|
+
if (!absPath) return false;
|
|
90
|
+
for (const candidate of expandAliases(absPath)) {
|
|
91
|
+
for (const root of roots) {
|
|
92
|
+
if (candidate === root || candidate.startsWith(root + path.sep)) return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isEphemeralPath(absPath) {
|
|
99
|
+
return pathUnder(absPath, ephemeralRoots());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isThrowawayPath(absPath) {
|
|
103
|
+
return pathUnder(absPath, throwawayRoots());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
ephemeralRoots,
|
|
108
|
+
throwawayRoots,
|
|
109
|
+
isEphemeralPath,
|
|
110
|
+
isThrowawayPath,
|
|
111
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Top-level library operations. Brings the modules together:
|
|
2
|
+
// - read a markdown file
|
|
3
|
+
// - extract title, body excerpt, tags, agent metadata
|
|
4
|
+
// - if the file is in an ephemeral location, take a rescue copy
|
|
5
|
+
// - upsert into the index
|
|
6
|
+
//
|
|
7
|
+
// Also handles the YAML front-matter edit when the CLI passes tags.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
const SDocYaml = require('../shared/sdocs-yaml.js');
|
|
14
|
+
const SDocLibTags = require('../shared/sdocs-library-tags.js');
|
|
15
|
+
|
|
16
|
+
const store = require('./library-store');
|
|
17
|
+
const scanner = require('./library-scan');
|
|
18
|
+
const ephemeral = require('./library-ephemeral');
|
|
19
|
+
const paths = require('./library-paths');
|
|
20
|
+
|
|
21
|
+
const MAX_EXCERPT = 400;
|
|
22
|
+
|
|
23
|
+
function deriveTitle(meta, body) {
|
|
24
|
+
if (meta && typeof meta.title === 'string' && meta.title.trim()) return meta.title.trim();
|
|
25
|
+
const m = (body || '').match(/^#\s+(.+?)\s*$/m);
|
|
26
|
+
if (m) return m[1];
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bodyExcerpt(body) {
|
|
31
|
+
if (!body) return '';
|
|
32
|
+
const cleaned = body
|
|
33
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
34
|
+
.replace(/`[^`\n]+`/g, '')
|
|
35
|
+
.replace(/^#{1,6} .*$/gm, '')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim();
|
|
38
|
+
if (cleaned.length <= MAX_EXCERPT) return cleaned;
|
|
39
|
+
return cleaned.slice(0, MAX_EXCERPT) + '...';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readFileSafe(absPath) {
|
|
43
|
+
try { return fs.readFileSync(absPath, 'utf8'); } catch (_) { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function detectGitProject(absPath) {
|
|
47
|
+
let dir = path.dirname(absPath);
|
|
48
|
+
for (let i = 0; i < 30; i++) {
|
|
49
|
+
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
50
|
+
let branch = null;
|
|
51
|
+
try {
|
|
52
|
+
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8').trim();
|
|
53
|
+
const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
54
|
+
if (m) branch = m[1];
|
|
55
|
+
} catch (_) {}
|
|
56
|
+
return { project: path.basename(dir), root: dir, branch };
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir) break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return { project: null, root: null, branch: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ensureRescueCopy(absPath, content) {
|
|
66
|
+
const dest = path.join(paths.rescuedDir(),
|
|
67
|
+
crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 12) + '-' +
|
|
68
|
+
path.basename(absPath));
|
|
69
|
+
store.ensureDir(path.dirname(dest));
|
|
70
|
+
fs.writeFileSync(dest, content);
|
|
71
|
+
return dest;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse, merge tags, and produce a normalised entry from a file's content
|
|
75
|
+
// plus metadata. Pure (no fs writes), so tests can drive it directly.
|
|
76
|
+
function buildEntry({ absPath, content, addTags, stats, rescuedFrom = null }) {
|
|
77
|
+
const parsed = SDocYaml.parseFrontMatter(content || '');
|
|
78
|
+
const meta = parsed.meta || {};
|
|
79
|
+
const body = parsed.body || '';
|
|
80
|
+
|
|
81
|
+
const fmTags = Array.isArray(meta.tags) ? meta.tags : [];
|
|
82
|
+
const tags = SDocLibTags.mergeTags(fmTags, addTags || []);
|
|
83
|
+
|
|
84
|
+
const git = detectGitProject(absPath);
|
|
85
|
+
const sdocsMeta = meta['sdocs-library'] && typeof meta['sdocs-library'] === 'object'
|
|
86
|
+
? meta['sdocs-library'] : {};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: store.idForPath(absPath),
|
|
90
|
+
path: absPath,
|
|
91
|
+
rescued: !!rescuedFrom,
|
|
92
|
+
rescuedFrom,
|
|
93
|
+
title: deriveTitle(meta, body) || path.basename(absPath, path.extname(absPath)),
|
|
94
|
+
bodyExcerpt: bodyExcerpt(body),
|
|
95
|
+
body: body,
|
|
96
|
+
tags: tags,
|
|
97
|
+
mtime: stats ? new Date(stats.mtimeMs).toISOString() : new Date().toISOString(),
|
|
98
|
+
size: stats ? stats.size : (content ? Buffer.byteLength(content) : 0),
|
|
99
|
+
gitProject: git.project,
|
|
100
|
+
gitBranch: git.branch,
|
|
101
|
+
agent: typeof meta.agent === 'string' ? meta.agent : sdocsMeta['agent'] || null,
|
|
102
|
+
sessionId: sdocsMeta['session-id'] || null,
|
|
103
|
+
resumeCmd: sdocsMeta['resume-cmd'] || null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Add tags to a file's `tags:` front matter, write the file back.
|
|
108
|
+
// Returns the new content. If no tags would change the file, returns
|
|
109
|
+
// null (caller can skip the write).
|
|
110
|
+
function injectTagsIntoFile(absPath, addTags) {
|
|
111
|
+
if (!addTags || !addTags.length) return null;
|
|
112
|
+
const raw = readFileSafe(absPath);
|
|
113
|
+
if (raw == null) return null;
|
|
114
|
+
const parsed = SDocYaml.parseFrontMatter(raw);
|
|
115
|
+
const meta = parsed.meta || {};
|
|
116
|
+
const existing = Array.isArray(meta.tags) ? meta.tags : [];
|
|
117
|
+
const merged = SDocLibTags.mergeTags(existing, addTags);
|
|
118
|
+
const same = existing.length === merged.length && existing.every((t, i) => merged[i] === t);
|
|
119
|
+
if (same) return null;
|
|
120
|
+
meta.tags = merged;
|
|
121
|
+
const out = SDocYaml.serializeFrontMatter(meta) + '\n' + (parsed.body || '');
|
|
122
|
+
fs.writeFileSync(absPath, out);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Remove tags from a file's front matter. Returns the new tag list or
|
|
127
|
+
// null if the file was missing / had no tags to remove.
|
|
128
|
+
function removeTagsFromFile(absPath, removeTags) {
|
|
129
|
+
if (!removeTags || !removeTags.length) return null;
|
|
130
|
+
const raw = readFileSafe(absPath);
|
|
131
|
+
if (raw == null) return null;
|
|
132
|
+
const parsed = SDocYaml.parseFrontMatter(raw);
|
|
133
|
+
const meta = parsed.meta || {};
|
|
134
|
+
const existing = Array.isArray(meta.tags) ? meta.tags : [];
|
|
135
|
+
if (!existing.length) return null;
|
|
136
|
+
const drop = new Set(removeTags.map(t => String(t).toLowerCase()));
|
|
137
|
+
const next = existing.filter(t => !drop.has(String(t).toLowerCase()));
|
|
138
|
+
if (next.length === existing.length) return null;
|
|
139
|
+
if (next.length) meta.tags = next; else delete meta.tags;
|
|
140
|
+
const out = SDocYaml.serializeFrontMatter(meta) + '\n' + (parsed.body || '');
|
|
141
|
+
fs.writeFileSync(absPath, out);
|
|
142
|
+
return next;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Per-file opt-out: front matter `sdocs-library: false` skips indexing.
|
|
146
|
+
function isOptedOut(content) {
|
|
147
|
+
if (!content) return false;
|
|
148
|
+
const parsed = SDocYaml.parseFrontMatter(content);
|
|
149
|
+
const meta = parsed.meta || {};
|
|
150
|
+
const v = meta['sdocs-library'];
|
|
151
|
+
return v === false || v === 'false';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Add or update a single file. Returns the upserted entry, or null if
|
|
155
|
+
// the file was skipped.
|
|
156
|
+
function indexFile(absPath, { addTags } = {}) {
|
|
157
|
+
const resolved = path.resolve(absPath);
|
|
158
|
+
if (!fs.existsSync(resolved)) return null;
|
|
159
|
+
|
|
160
|
+
// OS scratch directories (macOS /var/folders, Linux /run, Windows
|
|
161
|
+
// %TEMP%) never make it into the library. Test runners and build
|
|
162
|
+
// tools write here constantly; rescue copies would just accumulate.
|
|
163
|
+
// The escape hatch is for the test suite, which legitimately uses
|
|
164
|
+
// mkdtemp under os.tmpdir() to isolate fixtures and needs indexing
|
|
165
|
+
// to work against those paths.
|
|
166
|
+
if (ephemeral.isThrowawayPath(resolved) && !process.env.SDOCS_ALLOW_THROWAWAY_INDEXING) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (addTags && addTags.length) {
|
|
171
|
+
injectTagsIntoFile(resolved, addTags);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let content = readFileSafe(resolved);
|
|
175
|
+
if (content == null) return null;
|
|
176
|
+
if (isOptedOut(content)) return null;
|
|
177
|
+
|
|
178
|
+
let rescuedFrom = null;
|
|
179
|
+
let entryPath = resolved;
|
|
180
|
+
if (ephemeral.isEphemeralPath(resolved)) {
|
|
181
|
+
const rescued = ensureRescueCopy(resolved, content);
|
|
182
|
+
rescuedFrom = resolved;
|
|
183
|
+
entryPath = rescued;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let stats;
|
|
187
|
+
try { stats = fs.statSync(entryPath); } catch (_) { stats = null; }
|
|
188
|
+
|
|
189
|
+
const entry = buildEntry({
|
|
190
|
+
absPath: entryPath, content, addTags, stats, rescuedFrom,
|
|
191
|
+
});
|
|
192
|
+
return store.upsertEntry(entry);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Drop entries whose underlying file is gone. For rescued entries the
|
|
196
|
+
// rescue copy under ~/.sdocs/library/rescued/ is the source of truth -
|
|
197
|
+
// if that's missing the entry can't be opened, so it's pruned even if
|
|
198
|
+
// rescuedFrom happens to still exist. Returns the count removed.
|
|
199
|
+
function pruneMissing() {
|
|
200
|
+
const idx = store.loadIndex();
|
|
201
|
+
let removed = 0;
|
|
202
|
+
for (const e of idx.entries.slice()) {
|
|
203
|
+
if (!e.path || !fs.existsSync(e.path)) {
|
|
204
|
+
store.removeEntry(e.id);
|
|
205
|
+
removed++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return removed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Bulk scan: walk roots, index each .md found, then prune entries
|
|
212
|
+
// whose files have disappeared since the last scan. Returns counts.
|
|
213
|
+
function scanAndIndex({ roots, excludes, maxFileSize } = {}) {
|
|
214
|
+
const found = scanner.scan({ roots, excludes, maxFileSize });
|
|
215
|
+
let added = 0, updated = 0;
|
|
216
|
+
for (const f of found) {
|
|
217
|
+
const before = store.getEntry(store.idForPath(f.path));
|
|
218
|
+
indexFile(f.path);
|
|
219
|
+
if (before) updated++; else added++;
|
|
220
|
+
}
|
|
221
|
+
const removed = pruneMissing();
|
|
222
|
+
const state = store.loadState();
|
|
223
|
+
state.lastScanAt = Date.now();
|
|
224
|
+
store.saveState(state);
|
|
225
|
+
return { scanned: found.length, added, updated, removed };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function rebuild() {
|
|
229
|
+
store.clearIndex();
|
|
230
|
+
return scanAndIndex();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Tags used by entries whose path is under a given prefix (the project
|
|
234
|
+
// path, typically). For the file-info-card autocomplete and `sdoc
|
|
235
|
+
// library ls --tags`. Both the prefix and each entry path are tested
|
|
236
|
+
// against their realpaths too so a symlinked /var on macOS doesn't
|
|
237
|
+
// hide entries from the tag bag.
|
|
238
|
+
function tagsUnderPrefix(prefix) {
|
|
239
|
+
const root = path.resolve(prefix);
|
|
240
|
+
let rootReal = root;
|
|
241
|
+
try { rootReal = fs.realpathSync(root); } catch (_) {}
|
|
242
|
+
// Special case: the filesystem root ('/' on posix, 'C:\' on win) is
|
|
243
|
+
// already its own separator, so `root + path.sep` becomes '//' which
|
|
244
|
+
// matches nothing. Treat it as "everything".
|
|
245
|
+
const rootIsFsRoot = root === path.sep || /^[A-Za-z]:[\\/]$/.test(root);
|
|
246
|
+
const sep = path.sep;
|
|
247
|
+
function under(p) {
|
|
248
|
+
if (rootIsFsRoot) return true;
|
|
249
|
+
if (!p) return false;
|
|
250
|
+
if (p === root || p.startsWith(root + sep)) return true;
|
|
251
|
+
if (rootReal !== root && (p === rootReal || p.startsWith(rootReal + sep))) return true;
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const counts = {};
|
|
255
|
+
for (const e of store.loadIndex().entries) {
|
|
256
|
+
const p = e.rescued && e.rescuedFrom ? e.rescuedFrom : e.path;
|
|
257
|
+
let pReal = p;
|
|
258
|
+
try { pReal = fs.realpathSync(p); } catch (_) {}
|
|
259
|
+
if (under(p) || under(pReal)) {
|
|
260
|
+
for (const t of e.tags || []) counts[t] = (counts[t] || 0) + 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return Object.entries(counts)
|
|
264
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
265
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
indexFile,
|
|
270
|
+
pruneMissing,
|
|
271
|
+
scanAndIndex,
|
|
272
|
+
rebuild,
|
|
273
|
+
buildEntry,
|
|
274
|
+
injectTagsIntoFile,
|
|
275
|
+
removeTagsFromFile,
|
|
276
|
+
tagsUnderPrefix,
|
|
277
|
+
isOptedOut,
|
|
278
|
+
bodyExcerpt,
|
|
279
|
+
deriveTitle,
|
|
280
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Canonical paths the library subsystem uses. One module so tests can
|
|
2
|
+
// rebind for sandboxing (set SDOCS_HOME to redirect everything into a
|
|
3
|
+
// temp directory).
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
function root() {
|
|
9
|
+
if (process.env.SDOCS_HOME) return process.env.SDOCS_HOME;
|
|
10
|
+
return path.join(os.homedir(), '.sdocs');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
root,
|
|
15
|
+
libraryDir: () => path.join(root(), 'library'),
|
|
16
|
+
rescuedDir: () => path.join(root(), 'library', 'rescued'),
|
|
17
|
+
indexFile: () => path.join(root(), 'library-index.json'),
|
|
18
|
+
stateFile: () => path.join(root(), 'library-state.json'),
|
|
19
|
+
configFile: () => path.join(root(), 'library.yaml'),
|
|
20
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Filesystem walker for the library. Walks one or more roots, returning
|
|
2
|
+
// the absolute paths of .md files that pass the size cap and ignore
|
|
3
|
+
// rules. Pure: takes config and returns files; doesn't touch the index.
|
|
4
|
+
//
|
|
5
|
+
// For v1 we walk everything. Mtime-based shortcuts can come once it
|
|
6
|
+
// proves slow in practice.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
const ephemeral = require('./library-ephemeral');
|
|
13
|
+
const paths = require('./library-paths');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_SIZE = 1 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const DIRNAME_BLOCKLIST = new Set([
|
|
18
|
+
'node_modules',
|
|
19
|
+
'.git',
|
|
20
|
+
'.svn',
|
|
21
|
+
'.hg',
|
|
22
|
+
'dist',
|
|
23
|
+
'build',
|
|
24
|
+
'vendor',
|
|
25
|
+
'.venv',
|
|
26
|
+
'.next',
|
|
27
|
+
'.cache',
|
|
28
|
+
'__pycache__',
|
|
29
|
+
'target',
|
|
30
|
+
'.gradle',
|
|
31
|
+
'.idea',
|
|
32
|
+
'.vscode',
|
|
33
|
+
'.DS_Store',
|
|
34
|
+
'.Trash',
|
|
35
|
+
// Directories whose contents are sensitive even when the user
|
|
36
|
+
// happens to have them outside a hidden-dir ancestor. Belt-and-
|
|
37
|
+
// suspenders for the hidden-dir rule above.
|
|
38
|
+
'.ssh',
|
|
39
|
+
'.aws',
|
|
40
|
+
'.gnupg',
|
|
41
|
+
'.docker',
|
|
42
|
+
'.kube',
|
|
43
|
+
'.gcloud',
|
|
44
|
+
'.azure',
|
|
45
|
+
'.bitwarden',
|
|
46
|
+
'.password-store',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// File basenames that should never make it into the library, regardless
|
|
50
|
+
// of which directory they live in or what extension they carry. Most of
|
|
51
|
+
// these don't have a markdown extension and so the existing extension
|
|
52
|
+
// filter already drops them - but the deny list is the right place for
|
|
53
|
+
// the rule, and is in position for when more extensions get indexed.
|
|
54
|
+
//
|
|
55
|
+
// Crucially, the credentials/secrets patterns require a config-file
|
|
56
|
+
// extension (json/yaml/env/...) - they will NOT match `.md` because
|
|
57
|
+
// markdown is a notes format and "company-secrets.md" or
|
|
58
|
+
// "credentials-handling.md" are legitimate user notes about secrets,
|
|
59
|
+
// not the secrets themselves.
|
|
60
|
+
const DENY_BASENAME_PATTERNS = [
|
|
61
|
+
// SSH private/public keys
|
|
62
|
+
/^id_rsa(\.pub)?$/i,
|
|
63
|
+
/^id_ed25519(\.pub)?$/i,
|
|
64
|
+
/^id_ecdsa(\.pub)?$/i,
|
|
65
|
+
/^id_dsa(\.pub)?$/i,
|
|
66
|
+
/\.ppk$/i,
|
|
67
|
+
// Environment files (.env, .env.local, .env.production, ...)
|
|
68
|
+
/^\.env(\..+)?$/i,
|
|
69
|
+
// Cryptographic material
|
|
70
|
+
/\.(key|pem|p12|pfx|cer|crt|jks|keystore)$/i,
|
|
71
|
+
// PGP / GPG
|
|
72
|
+
/\.(gpg|pgp|asc)$/i,
|
|
73
|
+
// Password databases / wallets
|
|
74
|
+
/\.(kdbx|kdb|agilekeychain|1pif)$/i,
|
|
75
|
+
/^wallet\.dat$/i,
|
|
76
|
+
// Credential files: literal name (no extension - this is how
|
|
77
|
+
// git/aws/gh store them) or with a config / data extension.
|
|
78
|
+
/(^|[._-])credentials$/i,
|
|
79
|
+
/(^|[._-])credentials\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt|sh|properties)$/i,
|
|
80
|
+
// Files literally named secret(s).<config-ext>. Does NOT match
|
|
81
|
+
// `secret.md` or other markdown notes ABOUT secrets.
|
|
82
|
+
/^secrets?\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt|sh|properties)$/i,
|
|
83
|
+
// api_secret.json, api-secret.yaml, apisecret.txt, ...
|
|
84
|
+
/^api[_-]?secret\.(json|yaml|yml|toml|ini|env|conf|cfg|key|txt)$/i,
|
|
85
|
+
// Common single-file secrets in $HOME
|
|
86
|
+
/^\.netrc$/i,
|
|
87
|
+
/^\.pgpass$/i,
|
|
88
|
+
/^\.htpasswd$/i,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
function systemSkipPaths() {
|
|
92
|
+
const home = os.homedir();
|
|
93
|
+
const out = new Set();
|
|
94
|
+
if (process.platform === 'darwin') {
|
|
95
|
+
out.add('/Applications');
|
|
96
|
+
out.add('/System');
|
|
97
|
+
out.add('/Library');
|
|
98
|
+
out.add('/usr');
|
|
99
|
+
out.add('/private');
|
|
100
|
+
out.add(path.join(home, 'Library'));
|
|
101
|
+
} else if (process.platform === 'linux') {
|
|
102
|
+
out.add('/usr');
|
|
103
|
+
out.add('/var');
|
|
104
|
+
out.add('/etc');
|
|
105
|
+
out.add('/proc');
|
|
106
|
+
out.add('/sys');
|
|
107
|
+
out.add('/boot');
|
|
108
|
+
out.add(path.join(home, '.local'));
|
|
109
|
+
out.add(path.join(home, '.config'));
|
|
110
|
+
} else if (process.platform === 'win32') {
|
|
111
|
+
out.add('C:\\Program Files');
|
|
112
|
+
out.add('C:\\Program Files (x86)');
|
|
113
|
+
out.add('C:\\Windows');
|
|
114
|
+
out.add(path.join(home, 'AppData'));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Exported so the agent's file-read and bridge-spawn endpoints can
|
|
120
|
+
// apply the same deny rule on the path they're handed.
|
|
121
|
+
function deniedByPattern(absPath) {
|
|
122
|
+
const base = path.basename(absPath);
|
|
123
|
+
if (DENY_BASENAME_PATTERNS.some(re => re.test(base))) return true;
|
|
124
|
+
const segs = absPath.split(path.sep);
|
|
125
|
+
for (const s of segs) {
|
|
126
|
+
if (DIRNAME_BLOCKLIST.has(s)) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function shouldSkipDir(absDir, base, skipSet, exemptRoots) {
|
|
132
|
+
if (base.startsWith('.') && base !== '.' && base !== '..') return true;
|
|
133
|
+
if (DIRNAME_BLOCKLIST.has(base)) return true;
|
|
134
|
+
if (skipSet.has(absDir)) return true;
|
|
135
|
+
// Skip ephemeral paths during descent unless we're inside a root that
|
|
136
|
+
// the caller explicitly named (in which case they want it scanned).
|
|
137
|
+
if (ephemeral.isEphemeralPath(absDir) && !insideAnyRoot(absDir, exemptRoots)) return true;
|
|
138
|
+
if (absDir === paths.root() || absDir.startsWith(paths.root() + path.sep)) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function insideAnyRoot(absDir, roots) {
|
|
143
|
+
if (!roots || !roots.length) return false;
|
|
144
|
+
for (const r of roots) {
|
|
145
|
+
if (absDir === r || absDir.startsWith(r + path.sep)) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function defaultRoots() {
|
|
151
|
+
return [os.homedir()];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// .sdocsignore: per-directory dotfile that excludes files / directories
|
|
155
|
+
// from indexing. Subset of gitignore syntax:
|
|
156
|
+
// - blank lines and lines starting with # are ignored
|
|
157
|
+
// - trailing / means "directory only"
|
|
158
|
+
// - * matches any run of non-/ characters
|
|
159
|
+
// - ** matches across directory boundaries
|
|
160
|
+
// - patterns without a / match against basename only
|
|
161
|
+
// - patterns with a / match against the path relative to the .sdocsignore
|
|
162
|
+
// Negation (!) and other gitignore niceties are not supported in v1.
|
|
163
|
+
function parseSdocsignore(dir) {
|
|
164
|
+
let raw;
|
|
165
|
+
try { raw = fs.readFileSync(path.join(dir, '.sdocsignore'), 'utf8'); }
|
|
166
|
+
catch (_) { return null; }
|
|
167
|
+
const out = [];
|
|
168
|
+
for (let line of raw.split(/\r?\n/)) {
|
|
169
|
+
line = line.trim();
|
|
170
|
+
if (!line || line.startsWith('#')) continue;
|
|
171
|
+
let dirOnly = false;
|
|
172
|
+
if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1); }
|
|
173
|
+
const hasSlash = line.includes('/');
|
|
174
|
+
const re = new RegExp('^' + line
|
|
175
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
176
|
+
.replace(/\*\*/g, '__GLOBSTAR__')
|
|
177
|
+
.replace(/\*/g, '[^/]*')
|
|
178
|
+
.replace(/\?/g, '[^/]')
|
|
179
|
+
.replace(/__GLOBSTAR__/g, '.*') + '$');
|
|
180
|
+
out.push({ re, dirOnly, anchored: hasSlash, dir });
|
|
181
|
+
}
|
|
182
|
+
return out.length ? out : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function matchesIgnoreStack(stack, absPath, isDir) {
|
|
186
|
+
for (const layer of stack) {
|
|
187
|
+
const rel = path.relative(layer.dir, absPath);
|
|
188
|
+
if (!rel || rel.startsWith('..')) continue; // outside this .sdocsignore's scope
|
|
189
|
+
const base = path.basename(absPath);
|
|
190
|
+
for (const p of layer.patterns) {
|
|
191
|
+
if (p.dirOnly && !isDir) continue;
|
|
192
|
+
const target = p.anchored ? rel : base;
|
|
193
|
+
if (p.re.test(target)) return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Walk synchronously and accumulate matches. Cheap enough for personal
|
|
200
|
+
// libraries; we'll revisit if a user reports a slow scan.
|
|
201
|
+
function scan({ roots, excludes, maxFileSize } = {}) {
|
|
202
|
+
const rootsToWalk = (roots && roots.length ? roots : defaultRoots()).map(p => path.resolve(p));
|
|
203
|
+
const exSet = new Set((excludes || []).map(p => path.resolve(p)));
|
|
204
|
+
for (const sys of systemSkipPaths()) exSet.add(sys);
|
|
205
|
+
const limit = maxFileSize || DEFAULT_MAX_SIZE;
|
|
206
|
+
|
|
207
|
+
const found = [];
|
|
208
|
+
|
|
209
|
+
function walk(dir, ignoreStack) {
|
|
210
|
+
let entries;
|
|
211
|
+
try {
|
|
212
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
213
|
+
} catch (_) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Pick up .sdocsignore in this directory (cumulative with ancestors).
|
|
217
|
+
const here = parseSdocsignore(dir);
|
|
218
|
+
const stack = here ? ignoreStack.concat([{ dir, patterns: here }]) : ignoreStack;
|
|
219
|
+
|
|
220
|
+
for (const ent of entries) {
|
|
221
|
+
const full = path.join(dir, ent.name);
|
|
222
|
+
if (ent.isSymbolicLink()) continue;
|
|
223
|
+
if (matchesIgnoreStack(stack, full, ent.isDirectory())) continue;
|
|
224
|
+
if (ent.isDirectory()) {
|
|
225
|
+
if (!shouldSkipDir(full, ent.name, exSet, rootsToWalk)) walk(full, stack);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!ent.isFile()) continue;
|
|
229
|
+
if (deniedByPattern(full)) continue;
|
|
230
|
+
if (!/\.(md|mdx|markdown)$/i.test(ent.name)) continue;
|
|
231
|
+
let st;
|
|
232
|
+
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
233
|
+
if (st.size > limit) continue;
|
|
234
|
+
found.push({ path: full, mtime: st.mtimeMs, size: st.size });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const r of rootsToWalk) {
|
|
239
|
+
let st;
|
|
240
|
+
try { st = fs.statSync(r); } catch (_) { continue; }
|
|
241
|
+
if (!st.isDirectory()) continue;
|
|
242
|
+
walk(r, []);
|
|
243
|
+
}
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
scan,
|
|
249
|
+
defaultRoots,
|
|
250
|
+
systemSkipPaths,
|
|
251
|
+
DIRNAME_BLOCKLIST,
|
|
252
|
+
DENY_BASENAME_PATTERNS,
|
|
253
|
+
DEFAULT_MAX_SIZE,
|
|
254
|
+
shouldSkipDir,
|
|
255
|
+
deniedByPattern,
|
|
256
|
+
parseSdocsignore,
|
|
257
|
+
matchesIgnoreStack,
|
|
258
|
+
};
|