sdocs-dev 1.4.2 → 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/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. The marker comment on line 1 is used to detect
39
- // whether the block has already been written to a file (idempotent re-runs).
40
- const AGENT_BLOCK = `<!-- sdocs-agent-block -->
41
- ## SDocs
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 nothing hits a server.
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\` open styled in browser (great for easy reading).
51
- - \`sdoc share file.md\` copy shareable URL to clipboard.
52
- - \`sdoc schema\` how to adjust all stylable properties (fonts, colors, spacing).
53
- - \`sdoc charts\` chart types, JSON format, and styling. Charts render via Chart.js from \`\`\`chart code blocks.
54
- - \`sdoc --help\` full usage.
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
- Source: https://github.com/JoshInLisbon/SDocs
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
- const AGENT_BLOCK_MARKER = '<!-- sdocs-agent-block -->';
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
- async function promptUpdateIfAvailable() {
83
- if (!process.stdout.isTTY || !process.stdin.isTTY) return;
84
- if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
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 (!process.stdout.isTTY || process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
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 append AGENT_BLOCK to each. Tracked in ~/.sdocs/setup.json
128
- // so we never prompt twice. Manually re-runnable via `sdoc setup`.
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
- try { return JSON.parse(fs.readFileSync(SETUP_CACHE, 'utf-8')); }
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
- fs.writeFileSync(SETUP_CACHE, JSON.stringify(state, null, 2));
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 { return fs.readFileSync(filePath, 'utf-8').includes(AGENT_BLOCK_MARKER); }
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
- function appendBlockTo(filePath) {
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 exists = fs.existsSync(filePath);
157
- const prefix = exists && fs.readFileSync(filePath, 'utf-8').endsWith('\n') ? '\n' : (exists ? '\n\n' : '');
158
- fs.appendFileSync(filePath, prefix + AGENT_BLOCK);
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({ setupCompleted: new Date().toISOString(), writtenTo: [], declined: false });
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 { appendBlockTo(target); writtenTo.push(target); console.log(`\u2713 Wrote SDocs section to ${target}`); }
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({ setupCompleted: new Date().toISOString(), writtenTo, declined: writtenTo.length === 0 });
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(previewBody);
211
- console.log(RULE + '\n');
550
+ console.log(AGENT_BLOCK_BODY.trim());
551
+ console.log(RULE);
212
552
 
213
- const a = await ask('Add to all? [Y/n/skip] ');
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({ setupCompleted: new Date().toISOString(), writtenTo: [], declined: true });
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 { appendBlockTo(t.filePath); writtenTo.push(t.filePath); console.log(`\u2713 ${t.name}: ${t.filePath}`); }
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
- writeSetupState({ setupCompleted: new Date().toISOString(), writtenTo, declined: false });
227
- console.log('Done. Run `sdoc setup` any time to revisit.');
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') { mode = 'write'; continue; }
694
- if (arg === '--style') { mode = 'style'; continue; }
695
- if (arg === '--raw') { mode = 'raw'; continue; }
696
- if (arg === '--read') { mode = 'read'; continue; }
697
- if (arg === '--light') { theme = 'light'; continue; }
698
- if (arg === '--dark') { theme = 'dark'; continue; }
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 raw`);
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
- return fs.readFileSync(resolved, 'utf-8');
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') execSync(`open "${url}"`);
886
- else if (platform === 'win32') execSync(`start "" "${url}"`);
887
- else execSync(`xdg-open "${url}"`);
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
- const url = buildUrl(content, {
951
- url: opts.url,
952
- mode: opts.mode,
953
- theme: opts.theme,
954
- defaultStyles: !content ? defaults : null,
955
- section: opts.section,
956
- local: local,
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
- console.log(`\u2713 Link for ${name} copied to clipboard`);
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 promptUpdateIfAvailable();
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 promptUpdateIfAvailable();
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
  };