sdocs-dev 1.4.1 → 1.6.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/README.md +3 -3
- package/bin/sdocs-dev.js +1192 -66
- package/bin/sdocs-postinstall.js +27 -0
- package/package.json +7 -4
- package/public/sdocs-yaml.js +109 -9
package/bin/sdocs-dev.js
CHANGED
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const zlib = require('zlib');
|
|
15
|
-
const { execSync } = require('child_process');
|
|
15
|
+
const { execSync, execFileSync } = require('child_process');
|
|
16
16
|
const SDocYaml = require('../public/sdocs-yaml.js');
|
|
17
17
|
const SDocStyles = require('../public/sdocs-styles.js');
|
|
18
18
|
|
|
19
19
|
const https = require('https');
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const crypto = require('crypto');
|
|
20
22
|
const os = require('os');
|
|
21
23
|
const readline = require('readline');
|
|
22
24
|
|
|
@@ -35,26 +37,49 @@ const ONE_DAY = 86400000;
|
|
|
35
37
|
// ── Agent integration block ──────────────────────────────
|
|
36
38
|
// IMPORTANT: keep this in sync with the per-agent setup snippets in
|
|
37
39
|
// public/sdoc.md (the "Set up your agent" section). If you reword one,
|
|
38
|
-
// reword the other.
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// reword the other.
|
|
41
|
+
//
|
|
42
|
+
// Release checklist when AGENT_BLOCK_BODY changes:
|
|
43
|
+
// 1. Bump AGENT_BLOCK_VERSION below.
|
|
44
|
+
// 2. Set AGENT_BLOCK_REASON to a one-line summary of what changed.
|
|
45
|
+
// 3. Prepend a new section to public/agent-changes.md.
|
|
46
|
+
// 4. Reword public/sdoc.md per-agent snippets to match.
|
|
47
|
+
|
|
48
|
+
const AGENT_BLOCK_VERSION = 4;
|
|
49
|
+
const AGENT_BLOCK_REASON = 'Add `sdoc diagrams` reference for the new Mermaid render path; tell agents to read it before writing ```mermaid blocks; mention `.mmd` / `.mermaid` standalone files.';
|
|
50
|
+
|
|
51
|
+
const AGENT_BLOCK_BODY = `## SDocs
|
|
42
52
|
|
|
43
53
|
SDocs (sdocs.dev) renders markdown with clean styling you can adjust.
|
|
44
|
-
Content lives in the URL hash
|
|
54
|
+
Content lives in the URL hash - nothing hits a server.
|
|
45
55
|
|
|
46
56
|
Use it (or offer it) when the user wants to read, share, or export a \`.md\` file.
|
|
57
|
+
Also handy for copying specific code - SDocs code blocks have copy buttons.
|
|
47
58
|
|
|
48
59
|
The \`sdoc\` CLI is installed globally:
|
|
49
|
-
- \`sdoc file.md\`
|
|
50
|
-
- \`sdoc share file.md\`
|
|
51
|
-
- \`sdoc schema\`
|
|
52
|
-
- \`sdoc charts\`
|
|
53
|
-
- \`sdoc
|
|
60
|
+
- \`sdoc file.md\` - open styled in browser (great for easy reading).
|
|
61
|
+
- \`sdoc share file.md\` - copy shareable URL to clipboard.
|
|
62
|
+
- \`sdoc schema\` - how to adjust all stylable properties (fonts, colors, spacing).
|
|
63
|
+
- \`sdoc charts\` - read this before writing a \`\`\`chart block. The JSON shape is specific and easy to get wrong from memory.
|
|
64
|
+
- \`sdoc diagrams\` - read this before writing a \`\`\`mermaid block. Covers supported diagram types and the security model. Standalone \`.mmd\` / \`.mermaid\` files also work: \`sdoc graph.mmd\`.
|
|
65
|
+
- \`sdoc --help\` - full usage.
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
When helping the user themselves, prefer \`sdoc file.md\` over \`sdoc share file.md\`. Share is for sending a link to someone else.
|
|
68
|
+
|
|
69
|
+
Source: https://github.com/espressoplease/SDocs
|
|
56
70
|
`;
|
|
57
|
-
|
|
71
|
+
|
|
72
|
+
const AGENT_BLOCK_START_PREFIX = '<!-- sdocs-agent-block:start v=';
|
|
73
|
+
const AGENT_BLOCK_START_RE = /<!-- sdocs-agent-block:start v=(\d+) -->/;
|
|
74
|
+
const AGENT_BLOCK_END_MARKER = '<!-- sdocs-agent-block:end -->';
|
|
75
|
+
const AGENT_BLOCK_LEGACY_OPEN = '<!-- sdocs-agent-block -->';
|
|
76
|
+
|
|
77
|
+
const AGENT_CHANGES_URL = 'https://sdocs.dev/agent-changes';
|
|
78
|
+
const GITHUB_REPO_URL = 'https://github.com/espressoplease/SDocs';
|
|
79
|
+
|
|
80
|
+
function formatAgentBlock(version, body) {
|
|
81
|
+
return `${AGENT_BLOCK_START_PREFIX}${version} -->\n${body}${AGENT_BLOCK_END_MARKER}\n`;
|
|
82
|
+
}
|
|
58
83
|
|
|
59
84
|
const AGENT_TARGETS = [
|
|
60
85
|
{ name: 'Claude Code', dir: '.claude', file: 'CLAUDE.md' },
|
|
@@ -63,6 +88,82 @@ const AGENT_TARGETS = [
|
|
|
63
88
|
{ name: 'opencode', dir: path.join('.config', 'opencode'), file: 'AGENTS.md' },
|
|
64
89
|
];
|
|
65
90
|
|
|
91
|
+
// Find a current bookended block. Returns { start, end, version, body } | null.
|
|
92
|
+
// Bails on ambiguity (multiple start markers).
|
|
93
|
+
function findBookendedBlock(content) {
|
|
94
|
+
const startMatch = AGENT_BLOCK_START_RE.exec(content);
|
|
95
|
+
if (!startMatch) return null;
|
|
96
|
+
const startIdx = startMatch.index;
|
|
97
|
+
const startLineEnd = content.indexOf('\n', startIdx);
|
|
98
|
+
if (startLineEnd < 0) return null;
|
|
99
|
+
const endIdx = content.indexOf(AGENT_BLOCK_END_MARKER, startLineEnd);
|
|
100
|
+
if (endIdx < 0) return null;
|
|
101
|
+
const endMarkerEnd = endIdx + AGENT_BLOCK_END_MARKER.length;
|
|
102
|
+
const trailingNewline = content[endMarkerEnd] === '\n' ? 1 : 0;
|
|
103
|
+
const second = content.indexOf(AGENT_BLOCK_START_PREFIX, endMarkerEnd);
|
|
104
|
+
if (second >= 0) return null;
|
|
105
|
+
return {
|
|
106
|
+
start: startIdx,
|
|
107
|
+
end: endMarkerEnd + trailingNewline,
|
|
108
|
+
version: parseInt(startMatch[1], 10),
|
|
109
|
+
body: content.slice(startLineEnd + 1, endIdx),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Find a legacy open-only block (1.4.x format). Returns { start, end, version } | null.
|
|
114
|
+
// Only matches bodies whose terminator is the JoshInLisbon URL line, which is the
|
|
115
|
+
// known shape of v1 (1.4.0/1.4.1) and v2 (1.4.2). Hand-edited bodies return null.
|
|
116
|
+
function findLegacyBlock(content) {
|
|
117
|
+
const idx = content.indexOf(AGENT_BLOCK_LEGACY_OPEN);
|
|
118
|
+
if (idx < 0) return null;
|
|
119
|
+
const second = content.indexOf(AGENT_BLOCK_LEGACY_OPEN, idx + AGENT_BLOCK_LEGACY_OPEN.length);
|
|
120
|
+
if (second >= 0) return null;
|
|
121
|
+
const terminator = 'Source: https://github.com/JoshInLisbon/SDocs\n';
|
|
122
|
+
const termIdx = content.indexOf(terminator, idx);
|
|
123
|
+
if (termIdx < 0) return null;
|
|
124
|
+
const blockEnd = termIdx + terminator.length;
|
|
125
|
+
const region = content.slice(idx, blockEnd);
|
|
126
|
+
// Heuristic to recover from-version: v2 added the copy-code line, v1 didn't.
|
|
127
|
+
const version = region.includes('Also handy for copying specific code') ? 2 : 1;
|
|
128
|
+
return { start: idx, end: blockEnd, version };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Pure: takes content, returns refresh result.
|
|
132
|
+
// { changed: false, reason: 'absent'|'current'|'newer'|'hand_edited' }
|
|
133
|
+
// { changed: true, content, fromVersion, toVersion }
|
|
134
|
+
function refreshContent(content) {
|
|
135
|
+
const bookended = findBookendedBlock(content);
|
|
136
|
+
if (bookended) {
|
|
137
|
+
if (bookended.version === AGENT_BLOCK_VERSION) {
|
|
138
|
+
return { changed: false, reason: 'current' };
|
|
139
|
+
}
|
|
140
|
+
if (bookended.version > AGENT_BLOCK_VERSION) {
|
|
141
|
+
return { changed: false, reason: 'newer' };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
changed: true,
|
|
145
|
+
content: content.slice(0, bookended.start)
|
|
146
|
+
+ formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
|
|
147
|
+
+ content.slice(bookended.end),
|
|
148
|
+
fromVersion: bookended.version,
|
|
149
|
+
toVersion: AGENT_BLOCK_VERSION,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const legacy = findLegacyBlock(content);
|
|
153
|
+
if (!legacy) {
|
|
154
|
+
// No block, or unrecognised legacy body. Either is "leave it alone."
|
|
155
|
+
return { changed: false, reason: content.includes(AGENT_BLOCK_LEGACY_OPEN) ? 'hand_edited' : 'absent' };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
changed: true,
|
|
159
|
+
content: content.slice(0, legacy.start)
|
|
160
|
+
+ formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
|
|
161
|
+
+ content.slice(legacy.end),
|
|
162
|
+
fromVersion: legacy.version,
|
|
163
|
+
toVersion: AGENT_BLOCK_VERSION,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
66
167
|
function isNewer(latest, current) {
|
|
67
168
|
const a = latest.split('.').map(Number);
|
|
68
169
|
const b = current.split('.').map(Number);
|
|
@@ -78,13 +179,49 @@ function readCachedLatest() {
|
|
|
78
179
|
catch (_) { return null; }
|
|
79
180
|
}
|
|
80
181
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
182
|
+
// Self-upgrade: runs npm i -g, then re-execs the same command into the new binary.
|
|
183
|
+
// On any failure, falls through (so the user's actual command still runs).
|
|
184
|
+
function autoInstallAndReexec(latest) {
|
|
185
|
+
console.log(`\nUpdating sdoc ${VERSION} \u2192 ${latest}...`);
|
|
186
|
+
try {
|
|
187
|
+
execSync('npm i -g sdocs-dev@latest', { stdio: 'pipe' });
|
|
188
|
+
} catch (e) {
|
|
189
|
+
console.error(`! sdoc auto-update to ${latest} failed: ${(e.stderr || e.message || '').toString().trim().split('\n')[0]}`);
|
|
190
|
+
console.error(` Run \`npm i -g sdocs-dev@latest\` manually to upgrade.`);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
console.log(`\u2713 sdoc updated ${VERSION} \u2192 ${latest}`);
|
|
194
|
+
console.log(` Diff: ${GITHUB_REPO_URL}/compare/v${VERSION}...v${latest}`);
|
|
195
|
+
// Re-exec into the new binary so the user's command runs with the new code.
|
|
196
|
+
const { spawnSync } = require('child_process');
|
|
197
|
+
const r = spawnSync(process.argv0, process.argv.slice(1), { stdio: 'inherit' });
|
|
198
|
+
process.exit(r.status == null ? 0 : r.status);
|
|
199
|
+
}
|
|
84
200
|
|
|
201
|
+
// Single entry point for "there's a newer version on npm" handling.
|
|
202
|
+
// Behaviour depends on context:
|
|
203
|
+
// - autoInstallUpdates=true: silent self-upgrade + re-exec.
|
|
204
|
+
// - interactive TTY: Y/n prompt as today.
|
|
205
|
+
// - non-TTY (agent shell): one-line hint to stdout.
|
|
206
|
+
async function maybeUpdateBinary() {
|
|
207
|
+
if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
|
|
85
208
|
const latest = readCachedLatest();
|
|
86
209
|
if (!latest || !isNewer(latest, VERSION)) return;
|
|
87
210
|
|
|
211
|
+
const state = readSetupState();
|
|
212
|
+
const autoInstall = state && state.autoInstallUpdates === true;
|
|
213
|
+
|
|
214
|
+
if (autoInstall) {
|
|
215
|
+
autoInstallAndReexec(latest);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
|
|
220
|
+
if (!isInteractive) {
|
|
221
|
+
console.log(`Update available: ${VERSION} \u2192 ${latest}. Run \`npm i -g sdocs-dev@latest\` to upgrade.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
88
225
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
89
226
|
const answer = await new Promise(resolve => {
|
|
90
227
|
rl.question(`\nUpdate available: ${VERSION} \u2192 ${latest}. Install now? [Y/n] `, a => {
|
|
@@ -102,8 +239,10 @@ async function promptUpdateIfAvailable() {
|
|
|
102
239
|
}
|
|
103
240
|
}
|
|
104
241
|
|
|
242
|
+
// Daily refresh of the cached `latest` version from npm. Not gated on TTY:
|
|
243
|
+
// agents populate the cache too, so the update hint reaches them on next run.
|
|
105
244
|
function refreshUpdateCache() {
|
|
106
|
-
if (
|
|
245
|
+
if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
|
|
107
246
|
try {
|
|
108
247
|
if (Date.now() - fs.statSync(UPDATE_CACHE).mtimeMs < ONE_DAY) return;
|
|
109
248
|
} catch (_) {}
|
|
@@ -123,21 +262,66 @@ function refreshUpdateCache() {
|
|
|
123
262
|
|
|
124
263
|
// ── Agent setup ──────────────────────────────────────────
|
|
125
264
|
// On first interactive run, detect which coding-agent config dirs exist
|
|
126
|
-
// and offer to
|
|
127
|
-
// so we never prompt twice. Manually re-runnable
|
|
265
|
+
// and offer to write the SDocs section into each. Tracked in
|
|
266
|
+
// ~/.sdocs/setup.json so we never prompt twice. Manually re-runnable
|
|
267
|
+
// via `sdoc setup`. Auto-refresh on later upgrades is gated on the
|
|
268
|
+
// user's consent during setup.
|
|
269
|
+
|
|
270
|
+
const SETUP_SCHEMA_VERSION = 1;
|
|
271
|
+
|
|
272
|
+
// Pre-1.5.0 setup.json had no `schemaVersion`. Existing users wrote the block
|
|
273
|
+
// (so they want it kept current) but were never asked about auto-install.
|
|
274
|
+
function migrateSetupState(raw) {
|
|
275
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
276
|
+
if (raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
|
|
277
|
+
if (raw.schemaVersion && raw.schemaVersion > SETUP_SCHEMA_VERSION) {
|
|
278
|
+
// From a future sdoc; treat as unknown and let the user re-consent.
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (!raw.setupCompleted) return null;
|
|
282
|
+
return {
|
|
283
|
+
schemaVersion: SETUP_SCHEMA_VERSION,
|
|
284
|
+
setupCompleted: raw.setupCompleted,
|
|
285
|
+
writtenTo: raw.writtenTo || [],
|
|
286
|
+
declined: !!raw.declined,
|
|
287
|
+
autoRefreshAgentFiles: !raw.declined,
|
|
288
|
+
autoInstallUpdates: false,
|
|
289
|
+
lastRunVersion: null,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
128
292
|
|
|
129
293
|
function readSetupState() {
|
|
130
|
-
|
|
294
|
+
let raw;
|
|
295
|
+
try { raw = JSON.parse(fs.readFileSync(SETUP_CACHE, 'utf-8')); }
|
|
131
296
|
catch (_) { return null; }
|
|
297
|
+
if (raw && raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
|
|
298
|
+
const migrated = migrateSetupState(raw);
|
|
299
|
+
if (migrated) {
|
|
300
|
+
writeSetupState(migrated);
|
|
301
|
+
return migrated;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
132
304
|
}
|
|
133
305
|
|
|
134
306
|
function writeSetupState(state) {
|
|
135
307
|
try {
|
|
136
308
|
fs.mkdirSync(path.dirname(SETUP_CACHE), { recursive: true });
|
|
137
|
-
|
|
309
|
+
const payload = { schemaVersion: SETUP_SCHEMA_VERSION, ...state };
|
|
310
|
+
payload.schemaVersion = SETUP_SCHEMA_VERSION;
|
|
311
|
+
fs.writeFileSync(SETUP_CACHE, JSON.stringify(payload, null, 2));
|
|
138
312
|
} catch (_) {}
|
|
139
313
|
}
|
|
140
314
|
|
|
315
|
+
function compareVersions(a, b) {
|
|
316
|
+
const A = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
|
|
317
|
+
const B = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
|
|
318
|
+
for (let i = 0; i < 3; i++) {
|
|
319
|
+
if ((A[i] || 0) > (B[i] || 0)) return 1;
|
|
320
|
+
if ((A[i] || 0) < (B[i] || 0)) return -1;
|
|
321
|
+
}
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
141
325
|
function detectAgents() {
|
|
142
326
|
const home = os.homedir();
|
|
143
327
|
return AGENT_TARGETS
|
|
@@ -146,15 +330,122 @@ function detectAgents() {
|
|
|
146
330
|
}
|
|
147
331
|
|
|
148
332
|
function fileHasBlock(filePath) {
|
|
149
|
-
try {
|
|
333
|
+
try {
|
|
334
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
335
|
+
return findBookendedBlock(content) !== null
|
|
336
|
+
|| content.includes(AGENT_BLOCK_LEGACY_OPEN);
|
|
337
|
+
} catch (_) { return false; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isSymlink(filePath) {
|
|
341
|
+
try { return fs.lstatSync(filePath).isSymbolicLink(); }
|
|
150
342
|
catch (_) { return false; }
|
|
151
343
|
}
|
|
152
344
|
|
|
153
|
-
|
|
345
|
+
// Atomic write: tmp file in the SAME directory (so rename can't hit EXDEV),
|
|
346
|
+
// then rename. Cleans up the tmp on any error.
|
|
347
|
+
function atomicWrite(filePath, content) {
|
|
348
|
+
const dir = path.dirname(filePath);
|
|
349
|
+
const base = path.basename(filePath);
|
|
350
|
+
const tmp = path.join(dir, `.${base}.sdocs.tmp.${process.pid}.${Date.now()}`);
|
|
351
|
+
fs.writeFileSync(tmp, content);
|
|
352
|
+
try { fs.renameSync(tmp, filePath); }
|
|
353
|
+
catch (e) {
|
|
354
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
355
|
+
throw e;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function backupFile(filePath) {
|
|
360
|
+
try {
|
|
361
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
362
|
+
fs.copyFileSync(filePath, `${filePath}.sdocs.bak.${stamp}`);
|
|
363
|
+
} catch (_) {}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Best-effort exclusive lock. Returns a release function or null on contention.
|
|
367
|
+
// Stale locks (>60s) are reaped.
|
|
368
|
+
function acquireLock(filePath) {
|
|
369
|
+
const lockPath = `${filePath}.sdocs.lock`;
|
|
370
|
+
try {
|
|
371
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
372
|
+
try { fs.writeSync(fd, String(process.pid)); } catch (_) {}
|
|
373
|
+
fs.closeSync(fd);
|
|
374
|
+
return () => { try { fs.unlinkSync(lockPath); } catch (_) {} };
|
|
375
|
+
} catch (e) {
|
|
376
|
+
if (e.code !== 'EEXIST') return null;
|
|
377
|
+
try {
|
|
378
|
+
const age = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
379
|
+
if (age > 60000) {
|
|
380
|
+
fs.unlinkSync(lockPath);
|
|
381
|
+
return acquireLock(filePath);
|
|
382
|
+
}
|
|
383
|
+
} catch (_) {}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function writeBookendedBlock(filePath) {
|
|
154
389
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
390
|
+
const block = formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY);
|
|
391
|
+
if (!fs.existsSync(filePath)) {
|
|
392
|
+
atomicWrite(filePath, block);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const existing = fs.readFileSync(filePath, 'utf-8');
|
|
396
|
+
const prefix = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
397
|
+
atomicWrite(filePath, existing + prefix + block);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Refresh a single agent file. Returns { path, name?, changed, fromVersion?, toVersion?, reason?, error? }.
|
|
401
|
+
function refreshAgentFile(filePath, opts = {}) {
|
|
402
|
+
if (!fs.existsSync(filePath)) return { path: filePath, changed: false, reason: 'absent' };
|
|
403
|
+
if (isSymlink(filePath) && !opts.followSymlinks) return { path: filePath, changed: false, reason: 'symlink' };
|
|
404
|
+
|
|
405
|
+
const release = acquireLock(filePath);
|
|
406
|
+
if (!release) return { path: filePath, changed: false, reason: 'locked' };
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
410
|
+
const result = refreshContent(content);
|
|
411
|
+
if (!result.changed) return { path: filePath, changed: false, reason: result.reason };
|
|
412
|
+
backupFile(filePath);
|
|
413
|
+
atomicWrite(filePath, result.content);
|
|
414
|
+
return {
|
|
415
|
+
path: filePath, changed: true,
|
|
416
|
+
fromVersion: result.fromVersion, toVersion: result.toVersion,
|
|
417
|
+
};
|
|
418
|
+
} catch (e) {
|
|
419
|
+
return { path: filePath, changed: false, error: e.message };
|
|
420
|
+
} finally {
|
|
421
|
+
release();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function refreshAllAgentFiles(opts = {}) {
|
|
426
|
+
const home = os.homedir();
|
|
427
|
+
return AGENT_TARGETS.map(t => {
|
|
428
|
+
const filePath = path.join(home, t.dir, t.file);
|
|
429
|
+
return { name: t.name, ...refreshAgentFile(filePath, opts) };
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function printRefreshSummary(results) {
|
|
434
|
+
const changed = results.filter(r => r.changed);
|
|
435
|
+
if (changed.length > 0) {
|
|
436
|
+
const n = changed.length;
|
|
437
|
+
console.log(`✓ SDocs agent block updated to v${AGENT_BLOCK_VERSION} in ${n} ${n === 1 ? 'file' : 'files'}`);
|
|
438
|
+
console.log(` Changes: ${AGENT_CHANGES_URL}#v${AGENT_BLOCK_VERSION}`);
|
|
439
|
+
}
|
|
440
|
+
for (const r of results.filter(r => r.error)) {
|
|
441
|
+
console.log(`! ${r.path}: ${r.error}`);
|
|
442
|
+
}
|
|
443
|
+
for (const r of results.filter(r => r.reason === 'symlink')) {
|
|
444
|
+
console.log(`! ${r.path}: symlink, skipped (run \`sdoc setup --follow-symlinks\` to follow)`);
|
|
445
|
+
}
|
|
446
|
+
for (const r of results.filter(r => r.reason === 'hand_edited')) {
|
|
447
|
+
console.log(`! ${r.path}: local edits detected, run \`sdoc setup\` to refresh manually`);
|
|
448
|
+
}
|
|
158
449
|
}
|
|
159
450
|
|
|
160
451
|
function ask(question) {
|
|
@@ -164,6 +455,33 @@ function ask(question) {
|
|
|
164
455
|
});
|
|
165
456
|
}
|
|
166
457
|
|
|
458
|
+
async function askAutoInstallConsent() {
|
|
459
|
+
console.log('\nAuto-install sdoc updates when available?');
|
|
460
|
+
console.log('');
|
|
461
|
+
console.log('This runs `npm i -g sdocs-dev@latest` on your behalf when a new');
|
|
462
|
+
console.log('version ships. The output includes a source-diff link so you');
|
|
463
|
+
console.log('(or your agent) can verify what was installed.');
|
|
464
|
+
console.log('');
|
|
465
|
+
console.log('Recommended if you mostly use sdoc through coding agents.');
|
|
466
|
+
console.log('');
|
|
467
|
+
console.log('Change any time with `sdoc auto-update on` / `sdoc auto-update off`.\n');
|
|
468
|
+
const a = await ask('Enable? [Y/n] ');
|
|
469
|
+
return !a || a === 'y' || a === 'yes';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function askAutoRefreshConsent() {
|
|
473
|
+
console.log('\nKeep this block updated on future sdoc upgrades?');
|
|
474
|
+
console.log('');
|
|
475
|
+
console.log('When sdoc adds a feature we sometimes update this section so');
|
|
476
|
+
console.log('your agent learns about it. Each time the block changes we');
|
|
477
|
+
console.log(`print a notice with a link to ${AGENT_CHANGES_URL}`);
|
|
478
|
+
console.log('showing the exact delta - the new wording, and why it changed.');
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log('Re-run `sdoc setup` any time to change this.\n');
|
|
481
|
+
const a = await ask('Enable? [Y/n] ');
|
|
482
|
+
return !a || a === 'y' || a === 'yes';
|
|
483
|
+
}
|
|
484
|
+
|
|
167
485
|
async function runSetup({ force = false } = {}) {
|
|
168
486
|
if (!force) {
|
|
169
487
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return;
|
|
@@ -177,53 +495,133 @@ async function runSetup({ force = false } = {}) {
|
|
|
177
495
|
// Fallback: ask about opencode if nothing detected and not already set up
|
|
178
496
|
const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
|
|
179
497
|
if (opencodeAlreadyDone) {
|
|
180
|
-
writeSetupState({
|
|
498
|
+
writeSetupState({
|
|
499
|
+
setupCompleted: new Date().toISOString(),
|
|
500
|
+
writtenTo: [], declined: false,
|
|
501
|
+
autoRefreshAgentFiles: true, autoInstallUpdates: false,
|
|
502
|
+
lastRunVersion: VERSION,
|
|
503
|
+
});
|
|
181
504
|
console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
|
|
182
505
|
return;
|
|
183
506
|
}
|
|
184
507
|
console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
|
|
185
|
-
console.log('First run only
|
|
508
|
+
console.log('First run only - wire SDocs into your CLI coding agents.\n');
|
|
186
509
|
console.log('No coding-agent configs detected.');
|
|
187
510
|
const a = await ask('Do you use opencode? [y/N] ');
|
|
188
511
|
const writtenTo = [];
|
|
512
|
+
let autoRefresh = false;
|
|
513
|
+
let autoInstall = false;
|
|
189
514
|
if (a === 'y' || a === 'yes') {
|
|
190
515
|
const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
|
|
191
|
-
try {
|
|
516
|
+
try { writeBookendedBlock(target); writtenTo.push(target); console.log(`\u2713 Wrote SDocs section to ${target}`); }
|
|
192
517
|
catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
|
|
518
|
+
autoRefresh = await askAutoRefreshConsent();
|
|
519
|
+
autoInstall = await askAutoInstallConsent();
|
|
193
520
|
console.log('Done. Run `sdoc setup` any time to revisit.');
|
|
194
521
|
} else {
|
|
195
522
|
console.log('Skipped. Run `sdoc setup` any time to revisit.');
|
|
196
523
|
}
|
|
197
|
-
writeSetupState({
|
|
524
|
+
writeSetupState({
|
|
525
|
+
setupCompleted: new Date().toISOString(),
|
|
526
|
+
writtenTo, declined: writtenTo.length === 0,
|
|
527
|
+
autoRefreshAgentFiles: autoRefresh,
|
|
528
|
+
autoInstallUpdates: autoInstall,
|
|
529
|
+
lastRunVersion: VERSION,
|
|
530
|
+
});
|
|
198
531
|
return;
|
|
199
532
|
}
|
|
200
533
|
|
|
201
534
|
console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
|
|
202
|
-
console.log('First run only
|
|
535
|
+
console.log('First run only - wire SDocs into your CLI coding agents.\n');
|
|
203
536
|
console.log('Detected: ' + detected.map(t => t.name).join(', '));
|
|
204
537
|
console.log('\nWill append a short SDocs section to:');
|
|
205
538
|
for (const t of detected) console.log(' ' + t.filePath);
|
|
539
|
+
console.log('\nThese files are loaded into every conversation across all your');
|
|
540
|
+
console.log('projects, so SDocs becomes available no matter where you\'re working.');
|
|
541
|
+
console.log('');
|
|
542
|
+
console.log('You can ask your agent things like:');
|
|
543
|
+
console.log(' "write up the plan and sdoc it to me"');
|
|
544
|
+
console.log(' "explain async/await to me in a sdoc"');
|
|
545
|
+
console.log(' "draft the release notes as a sdoc I can share"');
|
|
546
|
+
console.log('');
|
|
547
|
+
console.log('This is the best way to work with SDocs');
|
|
206
548
|
const RULE = '\u2550'.repeat(36);
|
|
207
|
-
const previewBody = AGENT_BLOCK.replace(AGENT_BLOCK_MARKER + '\n', '').trim();
|
|
208
549
|
console.log(`\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Block to add \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550`);
|
|
209
|
-
console.log(
|
|
210
|
-
console.log(RULE
|
|
550
|
+
console.log(AGENT_BLOCK_BODY.trim());
|
|
551
|
+
console.log(RULE);
|
|
211
552
|
|
|
212
|
-
const a = await ask('
|
|
553
|
+
const a = await ask('\nAdd to all? [Y/n/skip] ');
|
|
213
554
|
const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
|
|
214
555
|
if (skipped) {
|
|
215
|
-
writeSetupState({
|
|
556
|
+
writeSetupState({
|
|
557
|
+
setupCompleted: new Date().toISOString(),
|
|
558
|
+
writtenTo: [], declined: true,
|
|
559
|
+
autoRefreshAgentFiles: false, autoInstallUpdates: false,
|
|
560
|
+
lastRunVersion: VERSION,
|
|
561
|
+
});
|
|
216
562
|
console.log('Skipped. Run `sdoc setup` any time to revisit.');
|
|
217
563
|
return;
|
|
218
564
|
}
|
|
219
565
|
|
|
220
566
|
const writtenTo = [];
|
|
221
567
|
for (const t of detected) {
|
|
222
|
-
try {
|
|
568
|
+
try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`\u2713 ${t.name}: ${t.filePath}`); }
|
|
223
569
|
catch (e) { console.error(`\u2717 ${t.name}: ${e.message}`); }
|
|
224
570
|
}
|
|
225
|
-
|
|
226
|
-
|
|
571
|
+
|
|
572
|
+
const autoRefresh = writtenTo.length > 0 ? await askAutoRefreshConsent() : false;
|
|
573
|
+
const autoInstall = writtenTo.length > 0 ? await askAutoInstallConsent() : false;
|
|
574
|
+
|
|
575
|
+
writeSetupState({
|
|
576
|
+
setupCompleted: new Date().toISOString(),
|
|
577
|
+
writtenTo, declined: false,
|
|
578
|
+
autoRefreshAgentFiles: autoRefresh,
|
|
579
|
+
autoInstallUpdates: autoInstall,
|
|
580
|
+
lastRunVersion: VERSION,
|
|
581
|
+
});
|
|
582
|
+
console.log('\nDone. Run `sdoc setup` any time to revisit.');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Auto-refresh existing agent files when the binary version is newer than the
|
|
586
|
+
// version that last ran. No prompt: the user already consented during setup.
|
|
587
|
+
// Bails on downgrades (block version > shipped version), errors, or partial
|
|
588
|
+
// failures (lastRunVersion only advances when every changed file succeeded).
|
|
589
|
+
async function maybeAutoRefresh() {
|
|
590
|
+
if (process.env.SDOCS_NO_REFRESH) return;
|
|
591
|
+
const state = readSetupState();
|
|
592
|
+
if (!state) return;
|
|
593
|
+
if (!state.autoRefreshAgentFiles) return;
|
|
594
|
+
if (compareVersions(VERSION, state.lastRunVersion) <= 0) return;
|
|
595
|
+
|
|
596
|
+
const results = refreshAllAgentFiles();
|
|
597
|
+
const anyChanged = results.some(r => r.changed);
|
|
598
|
+
if (anyChanged) printRefreshSummary(results);
|
|
599
|
+
|
|
600
|
+
const anyError = results.some(r => r.error);
|
|
601
|
+
if (!anyError) {
|
|
602
|
+
writeSetupState({ ...state, lastRunVersion: VERSION });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// `sdoc auto-update on|off|status` — flips state.autoInstallUpdates.
|
|
607
|
+
function runAutoUpdateSubcommand(arg) {
|
|
608
|
+
let state = readSetupState();
|
|
609
|
+
if (!state) {
|
|
610
|
+
console.log('Run `sdoc setup` first to configure auto-update.');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (arg === 'on') {
|
|
614
|
+
writeSetupState({ ...state, autoInstallUpdates: true });
|
|
615
|
+
console.log('✓ Auto-install of sdoc updates: on');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (arg === 'off') {
|
|
619
|
+
writeSetupState({ ...state, autoInstallUpdates: false });
|
|
620
|
+
console.log('✓ Auto-install of sdoc updates: off');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
console.log(`Auto-install of sdoc updates: ${state.autoInstallUpdates ? 'on' : 'off'}`);
|
|
624
|
+
console.log('Use `sdoc auto-update on` or `sdoc auto-update off` to change.');
|
|
227
625
|
}
|
|
228
626
|
|
|
229
627
|
// ── Help ───────────────────────────────────────────────────
|
|
@@ -237,14 +635,22 @@ USAGE
|
|
|
237
635
|
sdoc <file> --write Open in write mode
|
|
238
636
|
sdoc <file> --style Open with style panel
|
|
239
637
|
sdoc <file> --raw Open raw markdown source
|
|
638
|
+
sdoc <file> --comment Open in comment mode (review/annotate)
|
|
240
639
|
sdoc new New blank document (write mode)
|
|
241
640
|
sdoc share <file> Copy shareable link to clipboard
|
|
242
641
|
sdoc share <file> --section "X" Link with section anchor
|
|
642
|
+
sdoc share <file> --short Encrypted /s/<id> short link (see SHORT LINKS)
|
|
243
643
|
sdoc schema Print the full styles schema
|
|
244
644
|
sdoc charts Chart types, options, and styling guide
|
|
645
|
+
sdoc diagrams Mermaid diagrams reference (\`\`\`mermaid blocks)
|
|
646
|
+
sdoc comments Comment-format reference (for agents)
|
|
245
647
|
sdoc defaults Show ~/.sdocs/styles.yaml
|
|
246
648
|
sdoc defaults --reset Remove default styles
|
|
247
649
|
sdoc setup Wire SDocs into your coding agents
|
|
650
|
+
sdoc auto-update [on|off] Toggle auto-install of sdoc updates
|
|
651
|
+
sdoc safe Verify the SDocs server is running the published code
|
|
652
|
+
sdoc safe --json Same, machine-readable (for agents)
|
|
653
|
+
sdoc safe --audit Same, plus GitHub links to server-side source files
|
|
248
654
|
sdoc help Show this help
|
|
249
655
|
cat file.md | sdoc Pipe markdown from stdin
|
|
250
656
|
cat file.md | sdoc share Pipe to clipboard link
|
|
@@ -254,13 +660,21 @@ MODE FLAGS
|
|
|
254
660
|
--write Opens the contentEditable writer
|
|
255
661
|
--style Styled preview with style panel visible
|
|
256
662
|
--raw Shows raw markdown source
|
|
663
|
+
--comment Comment mode: gutter buttons appear on each block; cards
|
|
664
|
+
render under blocks that already have comments. Useful both
|
|
665
|
+
for human review and for opening files an agent has annotated.
|
|
257
666
|
|
|
258
667
|
OPTIONS
|
|
259
668
|
--section <heading> Scroll to heading section on load
|
|
260
669
|
--light Open in light theme
|
|
261
670
|
--dark Open in dark theme
|
|
262
671
|
--url <base> Custom base URL (default: https://sdocs.dev)
|
|
263
|
-
--mode <m> Alias for --read / --write / --style / --raw
|
|
672
|
+
--mode <m> Alias for --read / --write / --style / --raw / --comment
|
|
673
|
+
--short Use the encrypted /s/<id> short-URL form (share
|
|
674
|
+
subcommand only). See SHORT LINKS below.
|
|
675
|
+
--json Machine-readable output (safe subcommand only).
|
|
676
|
+
--audit Also print GitHub links to server-side source
|
|
677
|
+
files (safe subcommand only).
|
|
264
678
|
|
|
265
679
|
ENVIRONMENT
|
|
266
680
|
SDOCS_URL Fallback base URL if --url is not passed.
|
|
@@ -279,6 +693,64 @@ FILE INFO CARD
|
|
|
279
693
|
the generated link. If someone opens your shared URL, only
|
|
280
694
|
\`file\` is visible.
|
|
281
695
|
|
|
696
|
+
SHORT LINKS (sdoc share --short)
|
|
697
|
+
By default, \`sdoc share <file>\` encodes the document into the URL hash:
|
|
698
|
+
\`https://sdocs.dev/#md=<base64url>\`. The whole document lives in the
|
|
699
|
+
hash, which the browser does not send to any server.
|
|
700
|
+
|
|
701
|
+
\`--short\` produces a shorter, encrypted form: \`https://sdocs.dev/s/<id>#k=<key>\`.
|
|
702
|
+
|
|
703
|
+
How it works:
|
|
704
|
+
1. The CLI brotli-compresses the content, generates a 256-bit AES-GCM
|
|
705
|
+
key + 96-bit nonce locally, and encrypts the compressed bytes.
|
|
706
|
+
2. The CLI POSTs the ciphertext (nonce + ct + auth tag, base64url) to
|
|
707
|
+
/api/short. The server stores it under a random short id and
|
|
708
|
+
returns the id. The key NEVER leaves the CLI.
|
|
709
|
+
3. The CLI assembles \`https://sdocs.dev/s/<id>#k=<key>\` and copies
|
|
710
|
+
it to the clipboard. The key lives in the URL fragment, which the
|
|
711
|
+
browser does not send to the server on page load.
|
|
712
|
+
4. Whoever opens the link: the browser fetches the ciphertext from
|
|
713
|
+
/api/short/<id>, reads \`#k=\` from window.location.hash, decrypts
|
|
714
|
+
in JavaScript, and renders.
|
|
715
|
+
|
|
716
|
+
What the server can see:
|
|
717
|
+
- That a ciphertext blob was uploaded under id <id>.
|
|
718
|
+
- When it was fetched and from which IP (standard server logs).
|
|
719
|
+
What the server cannot see:
|
|
720
|
+
- The plaintext document. It does not have the key.
|
|
721
|
+
- The key. The key never leaves the URL fragment.
|
|
722
|
+
|
|
723
|
+
Trade-offs vs the default \`#md=\` hash form:
|
|
724
|
+
- + Shorter URL.
|
|
725
|
+
- + Survives URL-length limits (some chat apps truncate at ~2k chars).
|
|
726
|
+
- - Requires the server to remain reachable (the default form does not).
|
|
727
|
+
- - Stored ciphertext can be deleted by the operator at any time.
|
|
728
|
+
- - Server logs reveal access patterns even though contents are encrypted.
|
|
729
|
+
|
|
730
|
+
If you don't trust an SDocs operator with even those metadata, use the
|
|
731
|
+
default \`sdoc share <file>\` (no --short) which never contacts the
|
|
732
|
+
server at all.
|
|
733
|
+
|
|
734
|
+
VERIFYING THE SERVER (sdoc safe)
|
|
735
|
+
\`sdoc safe\` asks https://sdocs.dev what commit it is running, pulls the
|
|
736
|
+
authoritative fingerprint list for that commit from GitHub (published by the
|
|
737
|
+
publish-manifest workflow on every push to main), downloads every frontend
|
|
738
|
+
file from the host, hashes each one with SHA-256, and compares. Bytes come
|
|
739
|
+
from the host; fingerprints come from GitHub. The host cannot produce a
|
|
740
|
+
match it did not already publish to GitHub.
|
|
741
|
+
|
|
742
|
+
It does not prove anything about server-side code (that runs on a machine
|
|
743
|
+
we control). \`sdoc safe --audit\` prints GitHub links to the server files
|
|
744
|
+
an agent or human would need to read to audit the rest.
|
|
745
|
+
|
|
746
|
+
\`sdoc safe --json\` returns structured output for scripting.
|
|
747
|
+
|
|
748
|
+
MATH
|
|
749
|
+
Inline $...$ and display $$...$$ are rendered as LaTeX via KaTeX.
|
|
750
|
+
Inline: The energy is $E = mc^2$.
|
|
751
|
+
Display: $$\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}$$
|
|
752
|
+
Supported commands: https://katex.org/docs/supported.html
|
|
753
|
+
|
|
282
754
|
STYLED MARKDOWN FORMAT
|
|
283
755
|
SDocs extends standard .md files with an optional YAML
|
|
284
756
|
front matter block (the same standard used by Jekyll, Hugo, Obsidian).
|
|
@@ -299,10 +771,242 @@ STYLED MARKDOWN FORMAT
|
|
|
299
771
|
Colors work in both themes automatically — dark mode versions
|
|
300
772
|
are generated by inverting lightness. Use \`dark:\` to override.
|
|
301
773
|
|
|
774
|
+
COMMENTS
|
|
775
|
+
SDocs files can carry reviewer comments in their YAML front matter
|
|
776
|
+
under a \`comments:\` key. Comments do not modify the body — they're
|
|
777
|
+
resolved at render time by index lookup with a text-based fallback.
|
|
778
|
+
A typical use:
|
|
779
|
+
1. an agent generates a draft .md file
|
|
780
|
+
2. a human reads it via \`sdoc <file> --comment\`, leaves comments
|
|
781
|
+
3. the user copies the .md back to the agent (with comments)
|
|
782
|
+
4. the agent processes the comments and regenerates
|
|
783
|
+
|
|
784
|
+
Or the inverse: an agent writes comments into the front matter to
|
|
785
|
+
flag uncertainty, and runs \`sdoc <file> --comment\` to surface them
|
|
786
|
+
for the human.
|
|
787
|
+
|
|
788
|
+
Run \`sdoc comments\` for the full format reference and authoring guide.
|
|
302
789
|
Run \`sdoc schema\` for the complete list of style properties.
|
|
303
790
|
Run \`sdoc charts\` for chart types, options, and styling.
|
|
304
791
|
`;
|
|
305
792
|
|
|
793
|
+
const COMMENTS_HELP = `
|
|
794
|
+
SDocs — Comments
|
|
795
|
+
================
|
|
796
|
+
Reviewer comments are stored in YAML front matter under \`comments:\`.
|
|
797
|
+
The body is never modified — anchoring happens at render time.
|
|
798
|
+
This makes the format safe for round-tripping through agents and
|
|
799
|
+
markdown tooling that doesn't understand SDocs-specific markers.
|
|
800
|
+
|
|
801
|
+
WHEN TO USE THIS
|
|
802
|
+
Two flows benefit from comments:
|
|
803
|
+
|
|
804
|
+
1. Human reviewing agent output. The agent generates a .md file,
|
|
805
|
+
the human runs \`sdoc <file> --comment\`, leaves notes, and pastes
|
|
806
|
+
the file (with its YAML) back to the agent. The agent reads
|
|
807
|
+
\`comments:\` and acts on each entry.
|
|
808
|
+
|
|
809
|
+
2. Agent flagging uncertainty for a human. The agent writes one or
|
|
810
|
+
more comments into the front matter, then opens the file with
|
|
811
|
+
\`sdoc <file> --comment\` so the user sees the annotations rendered
|
|
812
|
+
beside the relevant blocks.
|
|
813
|
+
|
|
814
|
+
OPENING IN COMMENT MODE
|
|
815
|
+
sdoc <file> --comment Open in comment mode (or --mode comment)
|
|
816
|
+
|
|
817
|
+
Comment mode shows a gutter "+" button beside every top-level block
|
|
818
|
+
for adding new comments, and renders existing comments as yellow
|
|
819
|
+
sidecar cards beneath their anchored blocks.
|
|
820
|
+
|
|
821
|
+
TWO INPUT FORMATS
|
|
822
|
+
SDocs accepts comments in two interchangeable formats. Both render
|
|
823
|
+
identically in comment mode. Pick whichever is more natural for the
|
|
824
|
+
context:
|
|
825
|
+
|
|
826
|
+
1. Markdown footnote format (RECOMMENDED FOR AGENTS).
|
|
827
|
+
Standard markdown footnote syntax. The agent edits the body,
|
|
828
|
+
adding [^cN] markers where the comment anchors. No counting of
|
|
829
|
+
element indices required — anchoring is positional, computed
|
|
830
|
+
from the marker's position in the body.
|
|
831
|
+
|
|
832
|
+
2. YAML front-matter format.
|
|
833
|
+
The canonical on-disk store. Used by the SDocs UI and round-trip
|
|
834
|
+
export. Comments live as a structured list under \`comments:\`.
|
|
835
|
+
|
|
836
|
+
At load time, SDocs parses both: footnote markers are lifted out of
|
|
837
|
+
the body and merged with the YAML list. On save (round-trip export),
|
|
838
|
+
comments are normalised to YAML.
|
|
839
|
+
|
|
840
|
+
AUTHORING VIA MARKDOWN FOOTNOTES
|
|
841
|
+
Recommended path for agents that produce text. No tag:n counting,
|
|
842
|
+
no block_text, just standard markdown. Two patterns:
|
|
843
|
+
|
|
844
|
+
Inline (anchor a specific phrase):
|
|
845
|
+
Wrap the phrase in [phrase][^cN] and add the definition at the
|
|
846
|
+
end of the document.
|
|
847
|
+
|
|
848
|
+
The migration was [implemented in three weeks][^c1] this quarter.
|
|
849
|
+
|
|
850
|
+
[^c1]: agent - actually slipped to five weeks
|
|
851
|
+
|
|
852
|
+
Block (anchor an entire paragraph or heading):
|
|
853
|
+
Place a lone [^cN] at the end of the block (after the closing
|
|
854
|
+
period) and add the definition at the end.
|
|
855
|
+
|
|
856
|
+
The reliability picture was equally encouraging.[^c2]
|
|
857
|
+
|
|
858
|
+
[^c2]: agent - need to specify what "incident-free" means
|
|
859
|
+
|
|
860
|
+
Definitions support optional author and a [resolved] marker:
|
|
861
|
+
[^c3]: priya [resolved] - already addressed
|
|
862
|
+
[^c4]: agent - check Q2 numbers (block p:5)
|
|
863
|
+
|
|
864
|
+
Only footnote ids matching the cN pattern (c1, c2, ...) are treated
|
|
865
|
+
as comments. Other footnote ids (e.g. [^citation1]) keep standard
|
|
866
|
+
footnote semantics.
|
|
867
|
+
|
|
868
|
+
This format renders sensibly in any markdown viewer — refs as
|
|
869
|
+
superscripts, definitions at the bottom — so the file is useful
|
|
870
|
+
outside SDocs too.
|
|
871
|
+
|
|
872
|
+
COMMENT KINDS
|
|
873
|
+
block Anchored to an entire block element (paragraph, heading,
|
|
874
|
+
list, code block, table, blockquote).
|
|
875
|
+
inline Anchored to a specific text span within a block.
|
|
876
|
+
|
|
877
|
+
THE BLOCK ID SCHEME
|
|
878
|
+
Both kinds carry a \`block\` field of the form "tag:n":
|
|
879
|
+
- tag is the lowercased HTML element name (p, h1, h2, h3, h4,
|
|
880
|
+
ul, ol, pre, blockquote, table, plus "chart" for chart blocks).
|
|
881
|
+
- n is the 0-indexed position of that element among siblings of
|
|
882
|
+
the same tag, in render order across the entire document.
|
|
883
|
+
|
|
884
|
+
Examples:
|
|
885
|
+
"h2:0" First <h2> in the document.
|
|
886
|
+
"p:3" Fourth <p> in render order (ignores headings/lists).
|
|
887
|
+
"ul:0" First unordered list.
|
|
888
|
+
"pre:1" Second code block.
|
|
889
|
+
|
|
890
|
+
Per-tag-type indexing is more resilient to reordering than a single
|
|
891
|
+
global ordinal, but indices still drift if blocks of the same type
|
|
892
|
+
are inserted upstream. See "Survival hints" below.
|
|
893
|
+
|
|
894
|
+
SCHEMA — A FULLY-POPULATED EXAMPLE
|
|
895
|
+
---
|
|
896
|
+
title: "Q2 Roadmap (Draft)"
|
|
897
|
+
# Comments: block "tag:n" = nth (0-indexed) <tag> in render order.
|
|
898
|
+
# block kind may carry block_text (first ~60 chars) as a survival hint when the index drifts.
|
|
899
|
+
# inline kind anchors via quote (+ optional prefix/suffix). resolved: true marks addressed.
|
|
900
|
+
comments:
|
|
901
|
+
- id: c1
|
|
902
|
+
kind: block
|
|
903
|
+
block: "h2:0"
|
|
904
|
+
block_text: "Context"
|
|
905
|
+
author: priya
|
|
906
|
+
color: "#ffbb00"
|
|
907
|
+
at: "2026-04-22T09:14:00Z"
|
|
908
|
+
text: "rename this to 'Where Q1 left us' — sharper"
|
|
909
|
+
- id: c2
|
|
910
|
+
kind: inline
|
|
911
|
+
quote: "shipped on time"
|
|
912
|
+
prefix: "every committed feature "
|
|
913
|
+
suffix: " and within budget"
|
|
914
|
+
block: "p:0"
|
|
915
|
+
author: priya
|
|
916
|
+
color: "#ffbb00"
|
|
917
|
+
at: "2026-04-22T09:15:00Z"
|
|
918
|
+
text: "auth migration slipped 2 weeks — please correct"
|
|
919
|
+
- id: c3
|
|
920
|
+
kind: block
|
|
921
|
+
block: "p:5"
|
|
922
|
+
block_text: "Cost discipline becomes more visible in Q2"
|
|
923
|
+
author: priya
|
|
924
|
+
color: "#ffbb00"
|
|
925
|
+
at: "2026-04-22T09:24:00Z"
|
|
926
|
+
text: "align the $180k figure with finance before publishing"
|
|
927
|
+
resolved: true
|
|
928
|
+
---
|
|
929
|
+
|
|
930
|
+
# Q2 Roadmap (Draft)
|
|
931
|
+
## Context
|
|
932
|
+
Q1 closed strong: every committed feature shipped on time and within budget...
|
|
933
|
+
|
|
934
|
+
FIELDS
|
|
935
|
+
Required for both kinds:
|
|
936
|
+
id Stable identifier. Convention: c1, c2, c3...
|
|
937
|
+
kind "block" or "inline"
|
|
938
|
+
text The reviewer's note (the comment body).
|
|
939
|
+
|
|
940
|
+
Required for inline:
|
|
941
|
+
quote The exact text span in the rendered body to highlight.
|
|
942
|
+
|
|
943
|
+
Optional but recommended:
|
|
944
|
+
block The "tag:n" anchor. Used as a fast lookup. Optional
|
|
945
|
+
for inline (the quote alone is enough), required for
|
|
946
|
+
block (it's the only anchor).
|
|
947
|
+
block_text For block kind only. The first ~60 characters of
|
|
948
|
+
the block's plain text at the time of writing.
|
|
949
|
+
Survival hint: when "tag:n" no longer matches (the
|
|
950
|
+
document was edited and indices drifted), readers
|
|
951
|
+
fall back to scanning for a block whose start
|
|
952
|
+
matches block_text.
|
|
953
|
+
prefix For inline kind. Up to 60 chars of the rendered
|
|
954
|
+
text immediately before the quote, used to
|
|
955
|
+
disambiguate when the quote appears multiple times.
|
|
956
|
+
suffix Same as prefix but for the text immediately after.
|
|
957
|
+
resolved true if the comment has been addressed. Preserved
|
|
958
|
+
for audit; readers should skip resolved comments
|
|
959
|
+
when generating action lists.
|
|
960
|
+
author Display name on the rendered card. Default: "user".
|
|
961
|
+
color Card tint, hex (#rrggbb). Default: "#ffbb00" (yellow).
|
|
962
|
+
at ISO 8601 timestamp. Default: now (browser side).
|
|
963
|
+
|
|
964
|
+
ID GENERATION
|
|
965
|
+
Use c1, c2, c3... in chronological order. To pick the next id, take
|
|
966
|
+
the highest cN currently in the file and add 1. Don't reuse ids of
|
|
967
|
+
deleted comments — gaps are fine. Non-cN ids are tolerated but lose
|
|
968
|
+
the auto-increment guarantee.
|
|
969
|
+
|
|
970
|
+
ANCHOR RESOLUTION (HOW READERS RECOVER FROM DRIFT)
|
|
971
|
+
When a tool (the SDocs renderer or another agent) loads the file,
|
|
972
|
+
each comment is resolved in this order:
|
|
973
|
+
|
|
974
|
+
Block kind:
|
|
975
|
+
1. Try \`block: "tag:n"\` exactly.
|
|
976
|
+
2. If found, optionally verify the resolved block's leading text
|
|
977
|
+
matches \`block_text\`. If not, fall through.
|
|
978
|
+
3. Search the document for any block whose first ~60 chars start
|
|
979
|
+
with \`block_text\`.
|
|
980
|
+
4. Give up — comment is orphaned.
|
|
981
|
+
|
|
982
|
+
Inline kind:
|
|
983
|
+
1. Find the block via \`block: "tag:n"\`.
|
|
984
|
+
2. Inside that block, find \`prefix + quote + suffix\`.
|
|
985
|
+
3. Fall back to \`prefix + quote + suffix\` anywhere in the body.
|
|
986
|
+
4. Fall back to \`quote\` alone, anywhere in the body.
|
|
987
|
+
5. Give up — comment is orphaned.
|
|
988
|
+
|
|
989
|
+
AUTHORING TIPS FOR AGENTS
|
|
990
|
+
- Prefer the markdown-footnote authoring path (above). It avoids
|
|
991
|
+
the index-counting work the YAML path requires and is the most
|
|
992
|
+
reliable way for an LLM to write a comment that anchors correctly.
|
|
993
|
+
- If you do author in YAML directly:
|
|
994
|
+
- Compute "tag:n" by counting same-tag elements in render order.
|
|
995
|
+
Headings, paragraphs, lists each have their own counters.
|
|
996
|
+
- Counting errors are common. The fallback tiers (block_text
|
|
997
|
+
for block kind, prefix/suffix or quote-only search for inline)
|
|
998
|
+
will rescue an off-by-one index — but only if you populate them.
|
|
999
|
+
- For block comments, ALWAYS populate block_text (first ~60 chars
|
|
1000
|
+
of the block's plain text).
|
|
1001
|
+
- For inline comments, ensure the comment is uniquely resolvable:
|
|
1002
|
+
either pick a long unique quote, or populate prefix/suffix.
|
|
1003
|
+
- To mark a comment addressed without losing audit trail, set
|
|
1004
|
+
\`resolved: true\` (YAML) or add \`[resolved]\` after the author
|
|
1005
|
+
name in the footnote definition.
|
|
1006
|
+
- When acting on comments, skip those marked resolved — they
|
|
1007
|
+
describe past work, not pending requests.
|
|
1008
|
+
`;
|
|
1009
|
+
|
|
306
1010
|
const SCHEMA = `
|
|
307
1011
|
SDocs — Styles Schema
|
|
308
1012
|
=====================
|
|
@@ -630,6 +1334,87 @@ MIXED CHART EXAMPLE (dual y-axis)
|
|
|
630
1334
|
\`\`\`
|
|
631
1335
|
`;
|
|
632
1336
|
|
|
1337
|
+
const DIAGRAMS_HELP = `
|
|
1338
|
+
SDocs — Diagrams
|
|
1339
|
+
================
|
|
1340
|
+
Render Mermaid diagrams in markdown using \`\`\`mermaid code blocks.
|
|
1341
|
+
Mermaid is loaded lazily from CDN only when a diagram is present.
|
|
1342
|
+
|
|
1343
|
+
BASIC SYNTAX
|
|
1344
|
+
\`\`\`mermaid
|
|
1345
|
+
graph TD
|
|
1346
|
+
A[Start] --> B{Decision}
|
|
1347
|
+
B -- yes --> C[Do this]
|
|
1348
|
+
B -- no --> D[Do that]
|
|
1349
|
+
\`\`\`
|
|
1350
|
+
|
|
1351
|
+
STANDALONE .mmd FILES
|
|
1352
|
+
\`sdoc graph.mmd\` works like \`sdoc file.md\` - the CLI wraps the
|
|
1353
|
+
contents in a \`\`\`mermaid fence before opening. Same for share:
|
|
1354
|
+
\`sdoc share graph.mmd\`. \`.mermaid\` files work the same way.
|
|
1355
|
+
|
|
1356
|
+
SUPPORTED DIAGRAM TYPES
|
|
1357
|
+
flowchart / graph flowchart TD, LR, etc.
|
|
1358
|
+
sequenceDiagram interaction sequences
|
|
1359
|
+
classDiagram UML-style class relationships
|
|
1360
|
+
stateDiagram-v2 state machines
|
|
1361
|
+
erDiagram entity-relationship
|
|
1362
|
+
gantt timelines
|
|
1363
|
+
pie proportional breakdown
|
|
1364
|
+
journey user-journey diagrams
|
|
1365
|
+
gitGraph git history visualisation
|
|
1366
|
+
mindmap mind maps
|
|
1367
|
+
timeline chronological events
|
|
1368
|
+
quadrantChart 2x2 matrix
|
|
1369
|
+
sankey-beta flow diagrams
|
|
1370
|
+
See https://mermaid.js.org for the full syntax reference.
|
|
1371
|
+
|
|
1372
|
+
THEMING
|
|
1373
|
+
Diagrams inherit colors from the SDocs blocks cascade:
|
|
1374
|
+
|
|
1375
|
+
\`\`\`yaml
|
|
1376
|
+
styles:
|
|
1377
|
+
blocks:
|
|
1378
|
+
background: "#f4f1ed" # diagram wrapper bg
|
|
1379
|
+
color: "#6b6560" # node text / lines
|
|
1380
|
+
\`\`\`
|
|
1381
|
+
|
|
1382
|
+
In dark mode the inverted block colors apply automatically.
|
|
1383
|
+
For finer-grained control, set Mermaid theme variables in the
|
|
1384
|
+
diagram source itself, but note that \`%%{init:...}%%\` directives
|
|
1385
|
+
are stripped by SDocs as a security measure (they can otherwise
|
|
1386
|
+
override sanitisation settings at parse time).
|
|
1387
|
+
|
|
1388
|
+
LIMITS
|
|
1389
|
+
- Per-diagram source cap: 64 KB.
|
|
1390
|
+
- Per-document diagram cap: 50 (excess rendered as plain code).
|
|
1391
|
+
- Per-render timeout: 5 seconds (large or pathological graphs error out).
|
|
1392
|
+
|
|
1393
|
+
SECURITY
|
|
1394
|
+
Mermaid runs with \`securityLevel: 'strict'\` and \`htmlLabels: true\`.
|
|
1395
|
+
htmlLabels lets long node labels wrap inside a \`<foreignObject>\`,
|
|
1396
|
+
which is otherwise a script-injection vector; SDocs makes that safe
|
|
1397
|
+
by post-sanitising the SVG before render. \`<script>\`, \`<iframe>\`,
|
|
1398
|
+
\`<form>\`, \`<input>\`, \`<use>\`, animation tags, \`on*\` event handlers
|
|
1399
|
+
and \`javascript:\` URLs are stripped (inside foreignObject and out).
|
|
1400
|
+
Source caps and a render timeout cover the DoS surface. Treat diagram
|
|
1401
|
+
source as untrusted - it travels in the URL hash with the rest of
|
|
1402
|
+
the document.
|
|
1403
|
+
|
|
1404
|
+
EXAMPLE
|
|
1405
|
+
\`\`\`mermaid
|
|
1406
|
+
sequenceDiagram
|
|
1407
|
+
participant U as User
|
|
1408
|
+
participant S as SDocs
|
|
1409
|
+
participant C as CDN
|
|
1410
|
+
U->>S: open page with diagram
|
|
1411
|
+
S->>C: load mermaid.min.js (lazy, first time only)
|
|
1412
|
+
C-->>S: script
|
|
1413
|
+
S->>S: render() → SVG
|
|
1414
|
+
S->>U: paint diagram
|
|
1415
|
+
\`\`\`
|
|
1416
|
+
`;
|
|
1417
|
+
|
|
633
1418
|
// ── Compression (brotli + base64url) ─────────────────
|
|
634
1419
|
|
|
635
1420
|
function toBase64Url(buf) {
|
|
@@ -663,13 +1448,289 @@ function decompressFromBase64Url(b64url) {
|
|
|
663
1448
|
}
|
|
664
1449
|
}
|
|
665
1450
|
|
|
1451
|
+
// ── Short-link encrypt + upload (AES-GCM, client-held key) ─
|
|
1452
|
+
|
|
1453
|
+
// Compress with brotli, then encrypt with AES-256-GCM. Returns
|
|
1454
|
+
// { keyBytes, cipherB64url } where keyBytes never leaves this process.
|
|
1455
|
+
// The blob format (nonce(12) + ciphertext + tag(16)) matches the browser.
|
|
1456
|
+
function compressAndEncrypt(content) {
|
|
1457
|
+
const compressed = zlib.brotliCompressSync(Buffer.from(content, 'utf-8'), {
|
|
1458
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
|
|
1459
|
+
});
|
|
1460
|
+
const keyBytes = crypto.randomBytes(32);
|
|
1461
|
+
const nonce = crypto.randomBytes(12);
|
|
1462
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, nonce);
|
|
1463
|
+
const ct = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
1464
|
+
const tag = cipher.getAuthTag();
|
|
1465
|
+
const blob = Buffer.concat([nonce, ct, tag]);
|
|
1466
|
+
return { keyBytes, cipherB64url: toBase64Url(blob) };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function uploadShortLink(ciphertextB64, baseUrl) {
|
|
1470
|
+
return new Promise((resolve, reject) => {
|
|
1471
|
+
const u = new URL('/api/short', baseUrl);
|
|
1472
|
+
const isHttps = u.protocol === 'https:';
|
|
1473
|
+
const mod = isHttps ? https : http;
|
|
1474
|
+
const payload = JSON.stringify({ ciphertext: ciphertextB64 });
|
|
1475
|
+
const req = mod.request({
|
|
1476
|
+
method: 'POST',
|
|
1477
|
+
protocol: u.protocol,
|
|
1478
|
+
hostname: u.hostname,
|
|
1479
|
+
port: u.port || (isHttps ? 443 : 80),
|
|
1480
|
+
path: u.pathname,
|
|
1481
|
+
headers: {
|
|
1482
|
+
'Content-Type': 'application/json',
|
|
1483
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
1484
|
+
},
|
|
1485
|
+
timeout: 10000,
|
|
1486
|
+
}, (res) => {
|
|
1487
|
+
let body = '';
|
|
1488
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
1489
|
+
res.on('end', () => {
|
|
1490
|
+
let json;
|
|
1491
|
+
try { json = JSON.parse(body); } catch (_) { json = null; }
|
|
1492
|
+
if (res.statusCode >= 200 && res.statusCode < 300 && json && json.id) {
|
|
1493
|
+
resolve(json.id);
|
|
1494
|
+
} else {
|
|
1495
|
+
const err = (json && json.error) || ('http_' + res.statusCode);
|
|
1496
|
+
reject(new Error(err));
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
req.on('error', reject);
|
|
1501
|
+
req.on('timeout', () => { req.destroy(new Error('timeout')); });
|
|
1502
|
+
req.write(payload);
|
|
1503
|
+
req.end();
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function buildShortUrl(content, opts) {
|
|
1508
|
+
if (!content) throw new Error('short link requires file content');
|
|
1509
|
+
|
|
1510
|
+
// Mirror the hash-build's default-stripping so the encrypted payload is
|
|
1511
|
+
// identical to what the browser would encode.
|
|
1512
|
+
const parsed = SDocYaml.parseFrontMatter(content);
|
|
1513
|
+
if (parsed.meta && parsed.meta.styles) {
|
|
1514
|
+
const stripped = SDocStyles.stripStyleDefaults(parsed.meta.styles);
|
|
1515
|
+
if (Object.keys(stripped).length > 0) parsed.meta.styles = stripped;
|
|
1516
|
+
else delete parsed.meta.styles;
|
|
1517
|
+
content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
|
|
1521
|
+
const { keyBytes, cipherB64url } = compressAndEncrypt(content);
|
|
1522
|
+
const id = await uploadShortLink(cipherB64url, baseUrl);
|
|
1523
|
+
const keyB64 = toBase64Url(keyBytes);
|
|
1524
|
+
|
|
1525
|
+
const params = new URLSearchParams();
|
|
1526
|
+
params.set('k', keyB64);
|
|
1527
|
+
const mode = opts.mode;
|
|
1528
|
+
if (mode && mode !== 'read') params.set('mode', mode);
|
|
1529
|
+
if (opts.theme) params.set('theme', opts.theme);
|
|
1530
|
+
if (opts.section) params.set('sec', slugify(opts.section));
|
|
1531
|
+
|
|
1532
|
+
return `${baseUrl}/s/${id}#${params.toString()}`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
666
1535
|
// ── Slugify (shared module) ───────────────────────────────
|
|
667
1536
|
|
|
668
1537
|
var slugify = require('../public/sdocs-slugify').slugify;
|
|
669
1538
|
|
|
1539
|
+
// ── sdoc safe: verify frontend hashes + point agents at the server source ──
|
|
1540
|
+
//
|
|
1541
|
+
// 1. Asks the SDocs host what commit it is running (/trust/manifest, .commit).
|
|
1542
|
+
// 2. Fetches the authoritative fingerprint list for that commit from GitHub
|
|
1543
|
+
// (raw.githubusercontent.com/.../trust-manifests/<sha>.json), published on
|
|
1544
|
+
// every push to main by .github/workflows/publish-manifest.yml.
|
|
1545
|
+
// 3. Downloads each file from the host, hashes it with SHA-256, compares to
|
|
1546
|
+
// GitHub's list.
|
|
1547
|
+
// Bytes come from the host. Fingerprints come from GitHub. The host cannot
|
|
1548
|
+
// produce a match it did not already publish to GitHub.
|
|
1549
|
+
//
|
|
1550
|
+
// Server-side code (request handling, storage) still cannot be verified by
|
|
1551
|
+
// hashing. With --audit, the command prints direct GitHub links to the files
|
|
1552
|
+
// an agent should read to review that part.
|
|
1553
|
+
|
|
1554
|
+
// Server-side files that a curious human or agent needs to read to audit
|
|
1555
|
+
// what `sdoc safe` cannot prove by hashing. Kept small on purpose: these
|
|
1556
|
+
// are the only files that touch server-side request handling.
|
|
1557
|
+
const AUDIT_SOURCE_FILES = [
|
|
1558
|
+
'server.js',
|
|
1559
|
+
'short-links/db.js',
|
|
1560
|
+
'short-links/rate-limit.js',
|
|
1561
|
+
'analytics/db.js',
|
|
1562
|
+
'analytics/query.js',
|
|
1563
|
+
];
|
|
1564
|
+
|
|
1565
|
+
const TRUST_RAW_BASE = 'https://raw.githubusercontent.com/espressoplease/SDocs/trust-manifests';
|
|
1566
|
+
|
|
1567
|
+
function fetchJson(url) {
|
|
1568
|
+
return new Promise((resolve, reject) => {
|
|
1569
|
+
const u = new URL(url);
|
|
1570
|
+
const mod = u.protocol === 'https:' ? https : http;
|
|
1571
|
+
mod.get(u, { timeout: 8000 }, (res) => {
|
|
1572
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1573
|
+
reject(new Error('HTTP ' + res.statusCode + ' for ' + url));
|
|
1574
|
+
res.resume();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
let body = '';
|
|
1578
|
+
res.on('data', (c) => { body += c; });
|
|
1579
|
+
res.on('end', () => {
|
|
1580
|
+
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('invalid JSON from ' + url)); }
|
|
1581
|
+
});
|
|
1582
|
+
}).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function fetchBuffer(url) {
|
|
1587
|
+
return new Promise((resolve, reject) => {
|
|
1588
|
+
const u = new URL(url);
|
|
1589
|
+
const mod = u.protocol === 'https:' ? https : http;
|
|
1590
|
+
mod.get(u, { timeout: 15000 }, (res) => {
|
|
1591
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1592
|
+
reject(new Error('HTTP ' + res.statusCode));
|
|
1593
|
+
res.resume();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const chunks = [];
|
|
1597
|
+
res.on('data', (c) => { chunks.push(c); });
|
|
1598
|
+
res.on('end', () => { resolve(Buffer.concat(chunks)); });
|
|
1599
|
+
}).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function runSafe(opts) {
|
|
1604
|
+
const base = (opts.url || process.env.SDOCS_URL || DEFAULT_URL).replace(/\/$/, '');
|
|
1605
|
+
const jsonOut = !!opts.jsonFlag;
|
|
1606
|
+
const audit = !!opts.auditFlag;
|
|
1607
|
+
const rawBase = (opts.rawBase || process.env.SDOCS_TRUST_RAW || TRUST_RAW_BASE).replace(/\/$/, '');
|
|
1608
|
+
|
|
1609
|
+
// Step 1: learn the commit the host reports.
|
|
1610
|
+
let serverReport;
|
|
1611
|
+
try {
|
|
1612
|
+
serverReport = await fetchJson(base + '/trust/manifest');
|
|
1613
|
+
} catch (e) {
|
|
1614
|
+
if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'server_fetch_failed', message: e.message })); }
|
|
1615
|
+
else { console.error('sdoc safe: could not fetch ' + base + '/trust/manifest - ' + e.message); }
|
|
1616
|
+
process.exit(2);
|
|
1617
|
+
}
|
|
1618
|
+
const commit = serverReport.commit;
|
|
1619
|
+
if (!commit || commit === 'unknown') {
|
|
1620
|
+
if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'no_commit_reported' })); }
|
|
1621
|
+
else { console.error('sdoc safe: host did not report a commit.'); }
|
|
1622
|
+
process.exit(2);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// Step 2: pull the authoritative fingerprint list from GitHub for that commit.
|
|
1626
|
+
const manifestUrl = rawBase + '/' + commit + '.json';
|
|
1627
|
+
let manifest;
|
|
1628
|
+
try {
|
|
1629
|
+
manifest = await fetchJson(manifestUrl);
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
const pending = /HTTP 404/.test(e.message);
|
|
1632
|
+
if (jsonOut) {
|
|
1633
|
+
console.log(JSON.stringify({
|
|
1634
|
+
ok: false,
|
|
1635
|
+
error: pending ? 'manifest_not_yet_published' : 'manifest_fetch_failed',
|
|
1636
|
+
host: base, commit, manifestUrl, message: e.message,
|
|
1637
|
+
}));
|
|
1638
|
+
} else if (pending) {
|
|
1639
|
+
console.error('sdoc safe: no fingerprint list published on GitHub for commit ' + commit.slice(0, 7) + ' yet.');
|
|
1640
|
+
console.error(' (publish-manifest.yml runs on push to main; give it a minute.)');
|
|
1641
|
+
console.error(' looked for: ' + manifestUrl);
|
|
1642
|
+
} else {
|
|
1643
|
+
console.error('sdoc safe: could not fetch ' + manifestUrl + ' - ' + e.message);
|
|
1644
|
+
}
|
|
1645
|
+
process.exit(pending ? 2 : 3);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
|
1649
|
+
if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'manifest_has_no_files', manifestUrl })); }
|
|
1650
|
+
else { console.error('sdoc safe: GitHub manifest at ' + manifestUrl + ' has no files array.'); }
|
|
1651
|
+
process.exit(3);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Step 3: hash files from the host, compare to GitHub's list.
|
|
1655
|
+
const results = [];
|
|
1656
|
+
let ok = 0, fail = 0;
|
|
1657
|
+
for (const file of manifest.files) {
|
|
1658
|
+
const fileUrl = base + '/public' + file.path;
|
|
1659
|
+
try {
|
|
1660
|
+
const buf = await fetchBuffer(fileUrl);
|
|
1661
|
+
const got = crypto.createHash('sha256').update(buf).digest('hex');
|
|
1662
|
+
const match = got === file.sha256;
|
|
1663
|
+
results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, got, match });
|
|
1664
|
+
if (match) ok++; else fail++;
|
|
1665
|
+
} catch (e) {
|
|
1666
|
+
results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, error: e.message, match: false });
|
|
1667
|
+
fail++;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const repo = manifest.repo || 'https://github.com/espressoplease/SDocs';
|
|
1672
|
+
const auditLinks = audit ? AUDIT_SOURCE_FILES.map(f => ({
|
|
1673
|
+
file: f,
|
|
1674
|
+
url: repo + '/blob/' + commit + '/' + f,
|
|
1675
|
+
})) : null;
|
|
1676
|
+
|
|
1677
|
+
if (jsonOut) {
|
|
1678
|
+
console.log(JSON.stringify({
|
|
1679
|
+
ok: fail === 0,
|
|
1680
|
+
host: base,
|
|
1681
|
+
commit,
|
|
1682
|
+
builtAt: manifest.builtAt,
|
|
1683
|
+
manifestUrl,
|
|
1684
|
+
totals: { ok, fail, total: results.length },
|
|
1685
|
+
files: results,
|
|
1686
|
+
audit: auditLinks,
|
|
1687
|
+
unverified: {
|
|
1688
|
+
note: 'Server-side code (request handling, storage) cannot be verified by hashing. Read the source files listed under audit to review what a malicious operator could theoretically modify.',
|
|
1689
|
+
files: AUDIT_SOURCE_FILES,
|
|
1690
|
+
},
|
|
1691
|
+
}, null, 2));
|
|
1692
|
+
} else {
|
|
1693
|
+
console.log('');
|
|
1694
|
+
console.log(' sdoc safe - verifying ' + base);
|
|
1695
|
+
console.log(' commit ' + commit);
|
|
1696
|
+
console.log(' built at ' + (manifest.builtAt || '?'));
|
|
1697
|
+
console.log(' tree ' + repo + '/tree/' + commit);
|
|
1698
|
+
console.log(' list ' + manifestUrl);
|
|
1699
|
+
console.log('');
|
|
1700
|
+
for (const r of results) {
|
|
1701
|
+
const glyph = r.match ? '\u2713' : '\u2717';
|
|
1702
|
+
const line = ' ' + glyph + ' ' + r.path.padEnd(32) + ' ' + (r.match ? 'match' : (r.error || 'MISMATCH'));
|
|
1703
|
+
console.log(line);
|
|
1704
|
+
}
|
|
1705
|
+
console.log('');
|
|
1706
|
+
if (fail === 0) {
|
|
1707
|
+
console.log(' \u2713 ' + ok + ' / ' + results.length + ' files match the list GitHub published for this commit.');
|
|
1708
|
+
console.log(' Bytes came from this host; fingerprints came from GitHub.');
|
|
1709
|
+
} else {
|
|
1710
|
+
console.log(' \u2717 ' + fail + ' / ' + results.length + ' files FAILED to match GitHub\'s list for this commit.');
|
|
1711
|
+
console.log(' The host is serving different bytes than GitHub published for ' + commit.slice(0, 7) + '.');
|
|
1712
|
+
}
|
|
1713
|
+
console.log('');
|
|
1714
|
+
console.log(' What this does not prove:');
|
|
1715
|
+
console.log(' Server-side request handling cannot be verified by hashing alone.');
|
|
1716
|
+
console.log(' The only way to audit it is to read the source. Start here:');
|
|
1717
|
+
console.log('');
|
|
1718
|
+
for (const f of AUDIT_SOURCE_FILES) {
|
|
1719
|
+
console.log(' ' + repo + '/blob/' + commit + '/' + f);
|
|
1720
|
+
}
|
|
1721
|
+
console.log('');
|
|
1722
|
+
if (!audit) {
|
|
1723
|
+
console.log(' Re-run with --audit for machine-readable audit pointers, or --json for full output.');
|
|
1724
|
+
console.log('');
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
670
1731
|
// ── Parse args ────────────────────────────────────────────
|
|
671
1732
|
|
|
672
|
-
const SUBCOMMANDS = new Set(['new', 'share', 'schema', 'defaults', 'help', 'charts', 'setup']);
|
|
1733
|
+
const SUBCOMMANDS = new Set(['new', 'share', 'schema', 'defaults', 'help', 'charts', 'diagrams', 'comments', 'setup', 'safe', 'auto-update']);
|
|
673
1734
|
|
|
674
1735
|
function parseArgs(argv) {
|
|
675
1736
|
const args = argv || process.argv.slice(2);
|
|
@@ -680,6 +1741,9 @@ function parseArgs(argv) {
|
|
|
680
1741
|
let section = null;
|
|
681
1742
|
let theme = null;
|
|
682
1743
|
let resetFlag = false;
|
|
1744
|
+
let shortFlag = false;
|
|
1745
|
+
let jsonFlag = false;
|
|
1746
|
+
let auditFlag = false;
|
|
683
1747
|
|
|
684
1748
|
for (let i = 0; i < args.length; i++) {
|
|
685
1749
|
const arg = args[i];
|
|
@@ -689,18 +1753,19 @@ function parseArgs(argv) {
|
|
|
689
1753
|
if (arg === '--schema') { subcommand = 'schema'; continue; }
|
|
690
1754
|
|
|
691
1755
|
// Mode shorthand flags
|
|
692
|
-
if (arg === '--write')
|
|
693
|
-
if (arg === '--style')
|
|
694
|
-
if (arg === '--raw')
|
|
695
|
-
if (arg === '--read')
|
|
696
|
-
if (arg === '--
|
|
697
|
-
if (arg === '--
|
|
1756
|
+
if (arg === '--write') { mode = 'write'; continue; }
|
|
1757
|
+
if (arg === '--style') { mode = 'style'; continue; }
|
|
1758
|
+
if (arg === '--raw') { mode = 'raw'; continue; }
|
|
1759
|
+
if (arg === '--read') { mode = 'read'; continue; }
|
|
1760
|
+
if (arg === '--comment') { mode = 'comment'; continue; }
|
|
1761
|
+
if (arg === '--light') { theme = 'light'; continue; }
|
|
1762
|
+
if (arg === '--dark') { theme = 'dark'; continue; }
|
|
698
1763
|
|
|
699
1764
|
// Long-form --mode
|
|
700
1765
|
if (arg === '--mode' || arg === '-m') {
|
|
701
1766
|
mode = args[++i];
|
|
702
|
-
if (!['read', 'write', 'style', 'raw'].includes(mode)) {
|
|
703
|
-
console.error(`sdoc: unknown mode "${mode}" — use read, write, style, or
|
|
1767
|
+
if (!['read', 'write', 'style', 'raw', 'comment'].includes(mode)) {
|
|
1768
|
+
console.error(`sdoc: unknown mode "${mode}" — use read, write, style, raw, or comment`);
|
|
704
1769
|
process.exit(1);
|
|
705
1770
|
}
|
|
706
1771
|
continue;
|
|
@@ -715,6 +1780,15 @@ function parseArgs(argv) {
|
|
|
715
1780
|
// --reset flag (for defaults subcommand)
|
|
716
1781
|
if (arg === '--reset') { resetFlag = true; continue; }
|
|
717
1782
|
|
|
1783
|
+
// --short flag (share subcommand only): encrypt + upload, return /s/... URL
|
|
1784
|
+
if (arg === '--short') { shortFlag = true; continue; }
|
|
1785
|
+
|
|
1786
|
+
// --json flag (safe subcommand): machine-readable output
|
|
1787
|
+
if (arg === '--json') { jsonFlag = true; continue; }
|
|
1788
|
+
|
|
1789
|
+
// --audit flag (safe subcommand): also print server-side source audit links
|
|
1790
|
+
if (arg === '--audit') { auditFlag = true; continue; }
|
|
1791
|
+
|
|
718
1792
|
// Positional: check for subcommand first, then file
|
|
719
1793
|
if (!subcommand && SUBCOMMANDS.has(arg)) {
|
|
720
1794
|
subcommand = arg;
|
|
@@ -724,7 +1798,7 @@ function parseArgs(argv) {
|
|
|
724
1798
|
if (!file) { file = arg; continue; }
|
|
725
1799
|
}
|
|
726
1800
|
|
|
727
|
-
return { file, mode, url, subcommand, section, theme, resetFlag };
|
|
1801
|
+
return { file, mode, url, subcommand, section, theme, resetFlag, shortFlag, jsonFlag, auditFlag };
|
|
728
1802
|
}
|
|
729
1803
|
|
|
730
1804
|
// ── Build URL ─────────────────────────────────────────────
|
|
@@ -859,7 +1933,13 @@ async function readContent(file) {
|
|
|
859
1933
|
console.error(`sdoc: file not found: ${file}`);
|
|
860
1934
|
process.exit(1);
|
|
861
1935
|
}
|
|
862
|
-
|
|
1936
|
+
var raw = fs.readFileSync(resolved, 'utf-8');
|
|
1937
|
+
// .mmd / .mermaid files (standalone Mermaid sources) are wrapped in a
|
|
1938
|
+
// fenced block so the renderer picks them up. No special CLI path needed.
|
|
1939
|
+
if (/\.(mmd|mermaid)$/i.test(file)) {
|
|
1940
|
+
raw = '```mermaid\n' + raw.replace(/\s+$/, '') + '\n```\n';
|
|
1941
|
+
}
|
|
1942
|
+
return raw;
|
|
863
1943
|
}
|
|
864
1944
|
|
|
865
1945
|
// Check if stdin has data (piped input)
|
|
@@ -879,11 +1959,10 @@ async function readContent(file) {
|
|
|
879
1959
|
// ── Open browser ───────────────────────────────────────────
|
|
880
1960
|
|
|
881
1961
|
function openBrowser(url) {
|
|
882
|
-
const platform = process.platform;
|
|
883
1962
|
try {
|
|
884
|
-
if (platform === 'darwin')
|
|
885
|
-
else if (platform === 'win32')
|
|
886
|
-
else
|
|
1963
|
+
if (process.platform === 'darwin') execFileSync('open', [url]);
|
|
1964
|
+
else if (process.platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
|
|
1965
|
+
else execFileSync('xdg-open', [url]);
|
|
887
1966
|
} catch {
|
|
888
1967
|
console.log(`Open in browser: ${url}`);
|
|
889
1968
|
}
|
|
@@ -899,7 +1978,15 @@ if (require.main === module) {
|
|
|
899
1978
|
if (opts.subcommand === 'help') { console.log(HELP); process.exit(0); }
|
|
900
1979
|
if (opts.subcommand === 'schema') { console.log(SCHEMA); process.exit(0); }
|
|
901
1980
|
if (opts.subcommand === 'charts') { console.log(CHARTS_HELP); process.exit(0); }
|
|
1981
|
+
if (opts.subcommand === 'diagrams') { console.log(DIAGRAMS_HELP); process.exit(0); }
|
|
1982
|
+
if (opts.subcommand === 'comments') { console.log(COMMENTS_HELP); process.exit(0); }
|
|
902
1983
|
if (opts.subcommand === 'setup') { await runSetup({ force: true }); process.exit(0); }
|
|
1984
|
+
if (opts.subcommand === 'auto-update') {
|
|
1985
|
+
// Sub-arg lives in opts.file (positional). Accept on/off/empty.
|
|
1986
|
+
runAutoUpdateSubcommand((opts.file || '').toLowerCase());
|
|
1987
|
+
process.exit(0);
|
|
1988
|
+
}
|
|
1989
|
+
if (opts.subcommand === 'safe') { await runSafe(opts); return; }
|
|
903
1990
|
if (opts.subcommand === 'defaults') {
|
|
904
1991
|
if (opts.resetFlag) resetDefaults();
|
|
905
1992
|
else showDefaults();
|
|
@@ -946,14 +2033,37 @@ if (require.main === module) {
|
|
|
946
2033
|
}
|
|
947
2034
|
}
|
|
948
2035
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
2036
|
+
let url;
|
|
2037
|
+
if (opts.shortFlag) {
|
|
2038
|
+
if (opts.subcommand !== 'share') {
|
|
2039
|
+
console.error('sdoc: --short is only valid with the `share` subcommand');
|
|
2040
|
+
process.exit(1);
|
|
2041
|
+
}
|
|
2042
|
+
if (!content) {
|
|
2043
|
+
console.error('sdoc: --short needs content (a file path or piped stdin)');
|
|
2044
|
+
process.exit(1);
|
|
2045
|
+
}
|
|
2046
|
+
try {
|
|
2047
|
+
url = await buildShortUrl(content, {
|
|
2048
|
+
url: opts.url,
|
|
2049
|
+
mode: opts.mode,
|
|
2050
|
+
theme: opts.theme,
|
|
2051
|
+
section: opts.section,
|
|
2052
|
+
});
|
|
2053
|
+
} catch (e) {
|
|
2054
|
+
console.error('sdoc: could not create short link -', e.message);
|
|
2055
|
+
process.exit(1);
|
|
2056
|
+
}
|
|
2057
|
+
} else {
|
|
2058
|
+
url = buildUrl(content, {
|
|
2059
|
+
url: opts.url,
|
|
2060
|
+
mode: opts.mode,
|
|
2061
|
+
theme: opts.theme,
|
|
2062
|
+
defaultStyles: !content ? defaults : null,
|
|
2063
|
+
section: opts.section,
|
|
2064
|
+
local: local,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
957
2067
|
|
|
958
2068
|
// Share: copy to clipboard
|
|
959
2069
|
if (opts.subcommand === 'share') {
|
|
@@ -963,13 +2073,16 @@ if (require.main === module) {
|
|
|
963
2073
|
: 'xsel --clipboard --input';
|
|
964
2074
|
execSync(clip, { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
965
2075
|
const name = opts.file ? path.basename(opts.file) : 'stdin';
|
|
966
|
-
|
|
2076
|
+
const label = opts.shortFlag ? 'Short link' : 'Link';
|
|
2077
|
+
console.log(`\u2713 ${label} for ${name} copied to clipboard`);
|
|
2078
|
+
if (opts.shortFlag) console.log(` ${url}`);
|
|
967
2079
|
} catch (_) {
|
|
968
2080
|
process.stdout.write(url + '\n');
|
|
969
2081
|
}
|
|
970
2082
|
refreshUpdateCache();
|
|
971
|
-
await
|
|
2083
|
+
await maybeUpdateBinary();
|
|
972
2084
|
await runSetup();
|
|
2085
|
+
await maybeAutoRefresh();
|
|
973
2086
|
return;
|
|
974
2087
|
}
|
|
975
2088
|
|
|
@@ -977,8 +2090,9 @@ if (require.main === module) {
|
|
|
977
2090
|
openBrowser(url);
|
|
978
2091
|
console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
|
|
979
2092
|
refreshUpdateCache();
|
|
980
|
-
await
|
|
2093
|
+
await maybeUpdateBinary();
|
|
981
2094
|
await runSetup();
|
|
2095
|
+
await maybeAutoRefresh();
|
|
982
2096
|
})().catch(e => {
|
|
983
2097
|
console.error('sdoc:', e.message);
|
|
984
2098
|
process.exit(1);
|
|
@@ -998,4 +2112,16 @@ module.exports = {
|
|
|
998
2112
|
slugify,
|
|
999
2113
|
compressToBase64Url,
|
|
1000
2114
|
decompressFromBase64Url,
|
|
2115
|
+
compressAndEncrypt,
|
|
2116
|
+
uploadShortLink,
|
|
2117
|
+
buildShortUrl,
|
|
2118
|
+
// Agent block (pure functions for tests)
|
|
2119
|
+
AGENT_BLOCK_VERSION,
|
|
2120
|
+
AGENT_BLOCK_BODY,
|
|
2121
|
+
formatAgentBlock,
|
|
2122
|
+
findBookendedBlock,
|
|
2123
|
+
findLegacyBlock,
|
|
2124
|
+
refreshContent,
|
|
2125
|
+
compareVersions,
|
|
2126
|
+
migrateSetupState,
|
|
1001
2127
|
};
|