sdocs-dev 1.4.2 → 1.6.1
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 +1191 -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,27 +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.
|
|
47
57
|
Also handy for copying specific code - SDocs code blocks have copy buttons.
|
|
48
58
|
|
|
49
59
|
The \`sdoc\` CLI is installed globally:
|
|
50
|
-
- \`sdoc file.md\`
|
|
51
|
-
- \`sdoc share file.md\`
|
|
52
|
-
- \`sdoc schema\`
|
|
53
|
-
- \`sdoc charts\`
|
|
54
|
-
- \`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.
|
|
55
66
|
|
|
56
|
-
|
|
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
|
|
57
70
|
`;
|
|
58
|
-
|
|
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
|
+
}
|
|
59
83
|
|
|
60
84
|
const AGENT_TARGETS = [
|
|
61
85
|
{ name: 'Claude Code', dir: '.claude', file: 'CLAUDE.md' },
|
|
@@ -64,6 +88,82 @@ const AGENT_TARGETS = [
|
|
|
64
88
|
{ name: 'opencode', dir: path.join('.config', 'opencode'), file: 'AGENTS.md' },
|
|
65
89
|
];
|
|
66
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
|
+
|
|
67
167
|
function isNewer(latest, current) {
|
|
68
168
|
const a = latest.split('.').map(Number);
|
|
69
169
|
const b = current.split('.').map(Number);
|
|
@@ -79,13 +179,49 @@ function readCachedLatest() {
|
|
|
79
179
|
catch (_) { return null; }
|
|
80
180
|
}
|
|
81
181
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
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;
|
|
86
208
|
const latest = readCachedLatest();
|
|
87
209
|
if (!latest || !isNewer(latest, VERSION)) return;
|
|
88
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
|
+
|
|
89
225
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
90
226
|
const answer = await new Promise(resolve => {
|
|
91
227
|
rl.question(`\nUpdate available: ${VERSION} \u2192 ${latest}. Install now? [Y/n] `, a => {
|
|
@@ -103,8 +239,10 @@ async function promptUpdateIfAvailable() {
|
|
|
103
239
|
}
|
|
104
240
|
}
|
|
105
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.
|
|
106
244
|
function refreshUpdateCache() {
|
|
107
|
-
if (
|
|
245
|
+
if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
|
|
108
246
|
try {
|
|
109
247
|
if (Date.now() - fs.statSync(UPDATE_CACHE).mtimeMs < ONE_DAY) return;
|
|
110
248
|
} catch (_) {}
|
|
@@ -124,21 +262,66 @@ function refreshUpdateCache() {
|
|
|
124
262
|
|
|
125
263
|
// ── Agent setup ──────────────────────────────────────────
|
|
126
264
|
// On first interactive run, detect which coding-agent config dirs exist
|
|
127
|
-
// and offer to
|
|
128
|
-
// 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
|
+
}
|
|
129
292
|
|
|
130
293
|
function readSetupState() {
|
|
131
|
-
|
|
294
|
+
let raw;
|
|
295
|
+
try { raw = JSON.parse(fs.readFileSync(SETUP_CACHE, 'utf-8')); }
|
|
132
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;
|
|
133
304
|
}
|
|
134
305
|
|
|
135
306
|
function writeSetupState(state) {
|
|
136
307
|
try {
|
|
137
308
|
fs.mkdirSync(path.dirname(SETUP_CACHE), { recursive: true });
|
|
138
|
-
|
|
309
|
+
const payload = { schemaVersion: SETUP_SCHEMA_VERSION, ...state };
|
|
310
|
+
payload.schemaVersion = SETUP_SCHEMA_VERSION;
|
|
311
|
+
fs.writeFileSync(SETUP_CACHE, JSON.stringify(payload, null, 2));
|
|
139
312
|
} catch (_) {}
|
|
140
313
|
}
|
|
141
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
|
+
|
|
142
325
|
function detectAgents() {
|
|
143
326
|
const home = os.homedir();
|
|
144
327
|
return AGENT_TARGETS
|
|
@@ -147,15 +330,122 @@ function detectAgents() {
|
|
|
147
330
|
}
|
|
148
331
|
|
|
149
332
|
function fileHasBlock(filePath) {
|
|
150
|
-
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(); }
|
|
151
342
|
catch (_) { return false; }
|
|
152
343
|
}
|
|
153
344
|
|
|
154
|
-
|
|
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) {
|
|
155
389
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
}
|
|
159
449
|
}
|
|
160
450
|
|
|
161
451
|
function ask(question) {
|
|
@@ -165,6 +455,33 @@ function ask(question) {
|
|
|
165
455
|
});
|
|
166
456
|
}
|
|
167
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
|
+
|
|
168
485
|
async function runSetup({ force = false } = {}) {
|
|
169
486
|
if (!force) {
|
|
170
487
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return;
|
|
@@ -178,53 +495,133 @@ async function runSetup({ force = false } = {}) {
|
|
|
178
495
|
// Fallback: ask about opencode if nothing detected and not already set up
|
|
179
496
|
const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
|
|
180
497
|
if (opencodeAlreadyDone) {
|
|
181
|
-
writeSetupState({
|
|
498
|
+
writeSetupState({
|
|
499
|
+
setupCompleted: new Date().toISOString(),
|
|
500
|
+
writtenTo: [], declined: false,
|
|
501
|
+
autoRefreshAgentFiles: true, autoInstallUpdates: false,
|
|
502
|
+
lastRunVersion: VERSION,
|
|
503
|
+
});
|
|
182
504
|
console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
|
|
183
505
|
return;
|
|
184
506
|
}
|
|
185
507
|
console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
|
|
186
|
-
console.log('First run only - wire SDocs into your coding agents.\n');
|
|
508
|
+
console.log('First run only - wire SDocs into your CLI coding agents.\n');
|
|
187
509
|
console.log('No coding-agent configs detected.');
|
|
188
510
|
const a = await ask('Do you use opencode? [y/N] ');
|
|
189
511
|
const writtenTo = [];
|
|
512
|
+
let autoRefresh = false;
|
|
513
|
+
let autoInstall = false;
|
|
190
514
|
if (a === 'y' || a === 'yes') {
|
|
191
515
|
const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
|
|
192
|
-
try {
|
|
516
|
+
try { writeBookendedBlock(target); writtenTo.push(target); console.log(`\u2713 Wrote SDocs section to ${target}`); }
|
|
193
517
|
catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
|
|
518
|
+
autoRefresh = await askAutoRefreshConsent();
|
|
519
|
+
autoInstall = await askAutoInstallConsent();
|
|
194
520
|
console.log('Done. Run `sdoc setup` any time to revisit.');
|
|
195
521
|
} else {
|
|
196
522
|
console.log('Skipped. Run `sdoc setup` any time to revisit.');
|
|
197
523
|
}
|
|
198
|
-
writeSetupState({
|
|
524
|
+
writeSetupState({
|
|
525
|
+
setupCompleted: new Date().toISOString(),
|
|
526
|
+
writtenTo, declined: writtenTo.length === 0,
|
|
527
|
+
autoRefreshAgentFiles: autoRefresh,
|
|
528
|
+
autoInstallUpdates: autoInstall,
|
|
529
|
+
lastRunVersion: VERSION,
|
|
530
|
+
});
|
|
199
531
|
return;
|
|
200
532
|
}
|
|
201
533
|
|
|
202
534
|
console.log('\n\u2728\u2500\u2500\u2500\u2500\u2500\u2500\u2500 SDocs setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2728');
|
|
203
|
-
console.log('First run only - wire SDocs into your coding agents.\n');
|
|
535
|
+
console.log('First run only - wire SDocs into your CLI coding agents.\n');
|
|
204
536
|
console.log('Detected: ' + detected.map(t => t.name).join(', '));
|
|
205
537
|
console.log('\nWill append a short SDocs section to:');
|
|
206
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');
|
|
207
548
|
const RULE = '\u2550'.repeat(36);
|
|
208
|
-
const previewBody = AGENT_BLOCK.replace(AGENT_BLOCK_MARKER + '\n', '').trim();
|
|
209
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`);
|
|
210
|
-
console.log(
|
|
211
|
-
console.log(RULE
|
|
550
|
+
console.log(AGENT_BLOCK_BODY.trim());
|
|
551
|
+
console.log(RULE);
|
|
212
552
|
|
|
213
|
-
const a = await ask('
|
|
553
|
+
const a = await ask('\nAdd to all? [Y/n/skip] ');
|
|
214
554
|
const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
|
|
215
555
|
if (skipped) {
|
|
216
|
-
writeSetupState({
|
|
556
|
+
writeSetupState({
|
|
557
|
+
setupCompleted: new Date().toISOString(),
|
|
558
|
+
writtenTo: [], declined: true,
|
|
559
|
+
autoRefreshAgentFiles: false, autoInstallUpdates: false,
|
|
560
|
+
lastRunVersion: VERSION,
|
|
561
|
+
});
|
|
217
562
|
console.log('Skipped. Run `sdoc setup` any time to revisit.');
|
|
218
563
|
return;
|
|
219
564
|
}
|
|
220
565
|
|
|
221
566
|
const writtenTo = [];
|
|
222
567
|
for (const t of detected) {
|
|
223
|
-
try {
|
|
568
|
+
try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`\u2713 ${t.name}: ${t.filePath}`); }
|
|
224
569
|
catch (e) { console.error(`\u2717 ${t.name}: ${e.message}`); }
|
|
225
570
|
}
|
|
226
|
-
|
|
227
|
-
|
|
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.');
|
|
228
625
|
}
|
|
229
626
|
|
|
230
627
|
// ── Help ───────────────────────────────────────────────────
|
|
@@ -238,14 +635,22 @@ USAGE
|
|
|
238
635
|
sdoc <file> --write Open in write mode
|
|
239
636
|
sdoc <file> --style Open with style panel
|
|
240
637
|
sdoc <file> --raw Open raw markdown source
|
|
638
|
+
sdoc <file> --comment Open in comment mode (review/annotate)
|
|
241
639
|
sdoc new New blank document (write mode)
|
|
242
640
|
sdoc share <file> Copy shareable link to clipboard
|
|
243
641
|
sdoc share <file> --section "X" Link with section anchor
|
|
642
|
+
sdoc share <file> --short Encrypted /s/<id> short link (see SHORT LINKS)
|
|
244
643
|
sdoc schema Print the full styles schema
|
|
245
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)
|
|
246
647
|
sdoc defaults Show ~/.sdocs/styles.yaml
|
|
247
648
|
sdoc defaults --reset Remove default styles
|
|
248
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
|
|
249
654
|
sdoc help Show this help
|
|
250
655
|
cat file.md | sdoc Pipe markdown from stdin
|
|
251
656
|
cat file.md | sdoc share Pipe to clipboard link
|
|
@@ -255,13 +660,21 @@ MODE FLAGS
|
|
|
255
660
|
--write Opens the contentEditable writer
|
|
256
661
|
--style Styled preview with style panel visible
|
|
257
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.
|
|
258
666
|
|
|
259
667
|
OPTIONS
|
|
260
668
|
--section <heading> Scroll to heading section on load
|
|
261
669
|
--light Open in light theme
|
|
262
670
|
--dark Open in dark theme
|
|
263
671
|
--url <base> Custom base URL (default: https://sdocs.dev)
|
|
264
|
-
--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).
|
|
265
678
|
|
|
266
679
|
ENVIRONMENT
|
|
267
680
|
SDOCS_URL Fallback base URL if --url is not passed.
|
|
@@ -280,6 +693,64 @@ FILE INFO CARD
|
|
|
280
693
|
the generated link. If someone opens your shared URL, only
|
|
281
694
|
\`file\` is visible.
|
|
282
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
|
+
|
|
283
754
|
STYLED MARKDOWN FORMAT
|
|
284
755
|
SDocs extends standard .md files with an optional YAML
|
|
285
756
|
front matter block (the same standard used by Jekyll, Hugo, Obsidian).
|
|
@@ -300,10 +771,242 @@ STYLED MARKDOWN FORMAT
|
|
|
300
771
|
Colors work in both themes automatically — dark mode versions
|
|
301
772
|
are generated by inverting lightness. Use \`dark:\` to override.
|
|
302
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.
|
|
303
789
|
Run \`sdoc schema\` for the complete list of style properties.
|
|
304
790
|
Run \`sdoc charts\` for chart types, options, and styling.
|
|
305
791
|
`;
|
|
306
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
|
+
|
|
307
1010
|
const SCHEMA = `
|
|
308
1011
|
SDocs — Styles Schema
|
|
309
1012
|
=====================
|
|
@@ -631,6 +1334,87 @@ MIXED CHART EXAMPLE (dual y-axis)
|
|
|
631
1334
|
\`\`\`
|
|
632
1335
|
`;
|
|
633
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
|
+
|
|
634
1418
|
// ── Compression (brotli + base64url) ─────────────────
|
|
635
1419
|
|
|
636
1420
|
function toBase64Url(buf) {
|
|
@@ -664,13 +1448,289 @@ function decompressFromBase64Url(b64url) {
|
|
|
664
1448
|
}
|
|
665
1449
|
}
|
|
666
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
|
+
|
|
667
1535
|
// ── Slugify (shared module) ───────────────────────────────
|
|
668
1536
|
|
|
669
1537
|
var slugify = require('../public/sdocs-slugify').slugify;
|
|
670
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
|
+
|
|
671
1731
|
// ── Parse args ────────────────────────────────────────────
|
|
672
1732
|
|
|
673
|
-
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']);
|
|
674
1734
|
|
|
675
1735
|
function parseArgs(argv) {
|
|
676
1736
|
const args = argv || process.argv.slice(2);
|
|
@@ -681,6 +1741,9 @@ function parseArgs(argv) {
|
|
|
681
1741
|
let section = null;
|
|
682
1742
|
let theme = null;
|
|
683
1743
|
let resetFlag = false;
|
|
1744
|
+
let shortFlag = false;
|
|
1745
|
+
let jsonFlag = false;
|
|
1746
|
+
let auditFlag = false;
|
|
684
1747
|
|
|
685
1748
|
for (let i = 0; i < args.length; i++) {
|
|
686
1749
|
const arg = args[i];
|
|
@@ -690,18 +1753,19 @@ function parseArgs(argv) {
|
|
|
690
1753
|
if (arg === '--schema') { subcommand = 'schema'; continue; }
|
|
691
1754
|
|
|
692
1755
|
// Mode shorthand flags
|
|
693
|
-
if (arg === '--write')
|
|
694
|
-
if (arg === '--style')
|
|
695
|
-
if (arg === '--raw')
|
|
696
|
-
if (arg === '--read')
|
|
697
|
-
if (arg === '--
|
|
698
|
-
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; }
|
|
699
1763
|
|
|
700
1764
|
// Long-form --mode
|
|
701
1765
|
if (arg === '--mode' || arg === '-m') {
|
|
702
1766
|
mode = args[++i];
|
|
703
|
-
if (!['read', 'write', 'style', 'raw'].includes(mode)) {
|
|
704
|
-
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`);
|
|
705
1769
|
process.exit(1);
|
|
706
1770
|
}
|
|
707
1771
|
continue;
|
|
@@ -716,6 +1780,15 @@ function parseArgs(argv) {
|
|
|
716
1780
|
// --reset flag (for defaults subcommand)
|
|
717
1781
|
if (arg === '--reset') { resetFlag = true; continue; }
|
|
718
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
|
+
|
|
719
1792
|
// Positional: check for subcommand first, then file
|
|
720
1793
|
if (!subcommand && SUBCOMMANDS.has(arg)) {
|
|
721
1794
|
subcommand = arg;
|
|
@@ -725,7 +1798,7 @@ function parseArgs(argv) {
|
|
|
725
1798
|
if (!file) { file = arg; continue; }
|
|
726
1799
|
}
|
|
727
1800
|
|
|
728
|
-
return { file, mode, url, subcommand, section, theme, resetFlag };
|
|
1801
|
+
return { file, mode, url, subcommand, section, theme, resetFlag, shortFlag, jsonFlag, auditFlag };
|
|
729
1802
|
}
|
|
730
1803
|
|
|
731
1804
|
// ── Build URL ─────────────────────────────────────────────
|
|
@@ -860,7 +1933,13 @@ async function readContent(file) {
|
|
|
860
1933
|
console.error(`sdoc: file not found: ${file}`);
|
|
861
1934
|
process.exit(1);
|
|
862
1935
|
}
|
|
863
|
-
|
|
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;
|
|
864
1943
|
}
|
|
865
1944
|
|
|
866
1945
|
// Check if stdin has data (piped input)
|
|
@@ -880,11 +1959,10 @@ async function readContent(file) {
|
|
|
880
1959
|
// ── Open browser ───────────────────────────────────────────
|
|
881
1960
|
|
|
882
1961
|
function openBrowser(url) {
|
|
883
|
-
const platform = process.platform;
|
|
884
1962
|
try {
|
|
885
|
-
if (platform === 'darwin')
|
|
886
|
-
else if (platform === 'win32')
|
|
887
|
-
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]);
|
|
888
1966
|
} catch {
|
|
889
1967
|
console.log(`Open in browser: ${url}`);
|
|
890
1968
|
}
|
|
@@ -900,7 +1978,15 @@ if (require.main === module) {
|
|
|
900
1978
|
if (opts.subcommand === 'help') { console.log(HELP); process.exit(0); }
|
|
901
1979
|
if (opts.subcommand === 'schema') { console.log(SCHEMA); process.exit(0); }
|
|
902
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); }
|
|
903
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; }
|
|
904
1990
|
if (opts.subcommand === 'defaults') {
|
|
905
1991
|
if (opts.resetFlag) resetDefaults();
|
|
906
1992
|
else showDefaults();
|
|
@@ -947,14 +2033,37 @@ if (require.main === module) {
|
|
|
947
2033
|
}
|
|
948
2034
|
}
|
|
949
2035
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
+
}
|
|
958
2067
|
|
|
959
2068
|
// Share: copy to clipboard
|
|
960
2069
|
if (opts.subcommand === 'share') {
|
|
@@ -964,13 +2073,16 @@ if (require.main === module) {
|
|
|
964
2073
|
: 'xsel --clipboard --input';
|
|
965
2074
|
execSync(clip, { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
966
2075
|
const name = opts.file ? path.basename(opts.file) : 'stdin';
|
|
967
|
-
|
|
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}`);
|
|
968
2079
|
} catch (_) {
|
|
969
2080
|
process.stdout.write(url + '\n');
|
|
970
2081
|
}
|
|
971
2082
|
refreshUpdateCache();
|
|
972
|
-
await
|
|
2083
|
+
await maybeUpdateBinary();
|
|
973
2084
|
await runSetup();
|
|
2085
|
+
await maybeAutoRefresh();
|
|
974
2086
|
return;
|
|
975
2087
|
}
|
|
976
2088
|
|
|
@@ -978,8 +2090,9 @@ if (require.main === module) {
|
|
|
978
2090
|
openBrowser(url);
|
|
979
2091
|
console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
|
|
980
2092
|
refreshUpdateCache();
|
|
981
|
-
await
|
|
2093
|
+
await maybeUpdateBinary();
|
|
982
2094
|
await runSetup();
|
|
2095
|
+
await maybeAutoRefresh();
|
|
983
2096
|
})().catch(e => {
|
|
984
2097
|
console.error('sdoc:', e.message);
|
|
985
2098
|
process.exit(1);
|
|
@@ -999,4 +2112,16 @@ module.exports = {
|
|
|
999
2112
|
slugify,
|
|
1000
2113
|
compressToBase64Url,
|
|
1001
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,
|
|
1002
2127
|
};
|