sdocs-dev 1.6.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/safe.js ADDED
@@ -0,0 +1,200 @@
1
+ // `sdoc safe` — verify the SDocs host is serving the bytes GitHub
2
+ // published for its claimed commit.
3
+ //
4
+ // 1. Ask the host (`/trust/manifest`) what commit it is running.
5
+ // 2. Fetch the authoritative fingerprint list for that commit from
6
+ // raw.githubusercontent.com/.../trust-manifests/<sha>.json.
7
+ // 3. Download each file from the host, SHA-256 it, compare.
8
+ //
9
+ // Bytes come from the host. Fingerprints come from GitHub. The host
10
+ // cannot produce a match it did not already publish to GitHub.
11
+ //
12
+ // Server-side request handling still cannot be verified by hashing.
13
+ // `--audit` prints GitHub links to the files an auditor needs to read.
14
+
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const crypto = require('crypto');
18
+
19
+ const { DEFAULT_URL } = require('./constants');
20
+
21
+ // Server-side files an auditor needs to read to review what `sdoc safe`
22
+ // cannot prove by hashing. Kept small on purpose.
23
+ const AUDIT_SOURCE_FILES = [
24
+ 'server.js',
25
+ 'short-links/db.js',
26
+ 'short-links/rate-limit.js',
27
+ 'analytics/db.js',
28
+ 'analytics/query.js',
29
+ ];
30
+
31
+ const TRUST_RAW_BASE = 'https://raw.githubusercontent.com/espressoplease/SDocs/trust-manifests';
32
+
33
+ function fetchJson(url) {
34
+ return new Promise((resolve, reject) => {
35
+ const u = new URL(url);
36
+ const mod = u.protocol === 'https:' ? https : http;
37
+ mod.get(u, { timeout: 8000 }, (res) => {
38
+ if (res.statusCode < 200 || res.statusCode >= 300) {
39
+ reject(new Error('HTTP ' + res.statusCode + ' for ' + url));
40
+ res.resume();
41
+ return;
42
+ }
43
+ let body = '';
44
+ res.on('data', (c) => { body += c; });
45
+ res.on('end', () => {
46
+ try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('invalid JSON from ' + url)); }
47
+ });
48
+ }).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
49
+ });
50
+ }
51
+
52
+ function fetchBuffer(url) {
53
+ return new Promise((resolve, reject) => {
54
+ const u = new URL(url);
55
+ const mod = u.protocol === 'https:' ? https : http;
56
+ mod.get(u, { timeout: 15000 }, (res) => {
57
+ if (res.statusCode < 200 || res.statusCode >= 300) {
58
+ reject(new Error('HTTP ' + res.statusCode));
59
+ res.resume();
60
+ return;
61
+ }
62
+ const chunks = [];
63
+ res.on('data', (c) => { chunks.push(c); });
64
+ res.on('end', () => { resolve(Buffer.concat(chunks)); });
65
+ }).on('error', reject).on('timeout', function () { this.destroy(new Error('timeout')); });
66
+ });
67
+ }
68
+
69
+ async function runSafe(opts) {
70
+ const base = (opts.url || process.env.SDOCS_URL || DEFAULT_URL).replace(/\/$/, '');
71
+ const jsonOut = !!opts.jsonFlag;
72
+ const audit = !!opts.auditFlag;
73
+ const rawBase = (opts.rawBase || process.env.SDOCS_TRUST_RAW || TRUST_RAW_BASE).replace(/\/$/, '');
74
+
75
+ let serverReport;
76
+ try {
77
+ serverReport = await fetchJson(base + '/trust/manifest');
78
+ } catch (e) {
79
+ if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'server_fetch_failed', message: e.message })); }
80
+ else { console.error('sdoc safe: could not fetch ' + base + '/trust/manifest - ' + e.message); }
81
+ process.exit(2);
82
+ }
83
+ const commit = serverReport.commit;
84
+ if (!commit || commit === 'unknown') {
85
+ if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'no_commit_reported' })); }
86
+ else { console.error('sdoc safe: host did not report a commit.'); }
87
+ process.exit(2);
88
+ }
89
+
90
+ const manifestUrl = rawBase + '/' + commit + '.json';
91
+ let manifest;
92
+ try {
93
+ manifest = await fetchJson(manifestUrl);
94
+ } catch (e) {
95
+ const pending = /HTTP 404/.test(e.message);
96
+ if (jsonOut) {
97
+ console.log(JSON.stringify({
98
+ ok: false,
99
+ error: pending ? 'manifest_not_yet_published' : 'manifest_fetch_failed',
100
+ host: base, commit, manifestUrl, message: e.message,
101
+ }));
102
+ } else if (pending) {
103
+ console.error('sdoc safe: no fingerprint list published on GitHub for commit ' + commit.slice(0, 7) + ' yet.');
104
+ console.error(' (publish-manifest.yml runs on push to main; give it a minute.)');
105
+ console.error(' looked for: ' + manifestUrl);
106
+ } else {
107
+ console.error('sdoc safe: could not fetch ' + manifestUrl + ' - ' + e.message);
108
+ }
109
+ process.exit(pending ? 2 : 3);
110
+ }
111
+
112
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
113
+ if (jsonOut) { console.log(JSON.stringify({ ok: false, error: 'manifest_has_no_files', manifestUrl })); }
114
+ else { console.error('sdoc safe: GitHub manifest at ' + manifestUrl + ' has no files array.'); }
115
+ process.exit(3);
116
+ }
117
+
118
+ const results = [];
119
+ let ok = 0, fail = 0;
120
+ for (const file of manifest.files) {
121
+ const fileUrl = base + '/public' + file.path;
122
+ try {
123
+ const buf = await fetchBuffer(fileUrl);
124
+ const got = crypto.createHash('sha256').update(buf).digest('hex');
125
+ const match = got === file.sha256;
126
+ results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, got, match });
127
+ if (match) ok++; else fail++;
128
+ } catch (e) {
129
+ results.push({ path: file.path, bytes: file.bytes, expected: file.sha256, error: e.message, match: false });
130
+ fail++;
131
+ }
132
+ }
133
+
134
+ const repo = manifest.repo || 'https://github.com/espressoplease/SDocs';
135
+ const auditLinks = audit ? AUDIT_SOURCE_FILES.map(f => ({
136
+ file: f,
137
+ url: repo + '/blob/' + commit + '/' + f,
138
+ })) : null;
139
+
140
+ if (jsonOut) {
141
+ console.log(JSON.stringify({
142
+ ok: fail === 0,
143
+ host: base,
144
+ commit,
145
+ builtAt: manifest.builtAt,
146
+ manifestUrl,
147
+ totals: { ok, fail, total: results.length },
148
+ files: results,
149
+ audit: auditLinks,
150
+ unverified: {
151
+ 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.',
152
+ files: AUDIT_SOURCE_FILES,
153
+ },
154
+ }, null, 2));
155
+ } else {
156
+ console.log('');
157
+ console.log(' sdoc safe - verifying ' + base);
158
+ console.log(' commit ' + commit);
159
+ console.log(' built at ' + (manifest.builtAt || '?'));
160
+ console.log(' tree ' + repo + '/tree/' + commit);
161
+ console.log(' list ' + manifestUrl);
162
+ console.log('');
163
+ for (const r of results) {
164
+ const glyph = r.match ? '✓' : '✗';
165
+ const line = ' ' + glyph + ' ' + r.path.padEnd(32) + ' ' + (r.match ? 'match' : (r.error || 'MISMATCH'));
166
+ console.log(line);
167
+ }
168
+ console.log('');
169
+ if (fail === 0) {
170
+ console.log(' ✓ ' + ok + ' / ' + results.length + ' files match the list GitHub published for this commit.');
171
+ console.log(' Bytes came from this host; fingerprints came from GitHub.');
172
+ } else {
173
+ console.log(' ✗ ' + fail + ' / ' + results.length + ' files FAILED to match GitHub\'s list for this commit.');
174
+ console.log(' The host is serving different bytes than GitHub published for ' + commit.slice(0, 7) + '.');
175
+ }
176
+ console.log('');
177
+ console.log(' What this does not prove:');
178
+ console.log(' Server-side request handling cannot be verified by hashing alone.');
179
+ console.log(' The only way to audit it is to read the source. Start here:');
180
+ console.log('');
181
+ for (const f of AUDIT_SOURCE_FILES) {
182
+ console.log(' ' + repo + '/blob/' + commit + '/' + f);
183
+ }
184
+ console.log('');
185
+ if (!audit) {
186
+ console.log(' Re-run with --audit for machine-readable audit pointers, or --json for full output.');
187
+ console.log('');
188
+ }
189
+ }
190
+
191
+ process.exit(fail === 0 ? 0 : 1);
192
+ }
193
+
194
+ module.exports = {
195
+ AUDIT_SOURCE_FILES,
196
+ TRUST_RAW_BASE,
197
+ fetchJson,
198
+ fetchBuffer,
199
+ runSafe,
200
+ };
package/lib/setup.js ADDED
@@ -0,0 +1,332 @@
1
+ // `sdoc setup`, `sdoc refresh`, `sdoc auto-update`, and the implicit
2
+ // post-command refresh that keeps agent files in sync as new sdoc
3
+ // versions ship.
4
+ //
5
+ // runSetup: first-run interactive flow. Detects agent configs, writes
6
+ // the block into the ones the user agrees to.
7
+ // runRefresh: unconditional refresh of every agent file that already
8
+ // has a recognised block.
9
+ // runAutoUpdateSubcommand: flips state.autoInstallUpdates.
10
+ // maybeAutoRefresh: called after every successful command. Quiet, only
11
+ // touches files whose existing block we already manage.
12
+
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const readline = require('readline');
16
+
17
+ const {
18
+ AGENT_BLOCK_VERSION,
19
+ AGENT_BLOCK_BODY,
20
+ compareVersions,
21
+ readSetupState,
22
+ writeSetupState,
23
+ implicitConsentState,
24
+ } = require('./agent-block');
25
+
26
+ const { upgradeCommand } = require('./update-check');
27
+
28
+ const {
29
+ detectAgents,
30
+ fileHasBlock,
31
+ writeBookendedBlock,
32
+ refreshAllAgentFiles,
33
+ printRefreshSummary,
34
+ } = require('./agent-files');
35
+
36
+ const { VERSION, AGENT_CHANGES_URL } = require('./constants');
37
+
38
+ function ask(question) {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
+ return new Promise(resolve => {
41
+ rl.question(question, a => { rl.close(); resolve(a.trim().toLowerCase()); });
42
+ });
43
+ }
44
+
45
+ async function askAutoInstallConsent() {
46
+ console.log('\nAuto-install sdoc updates when available?');
47
+ console.log('');
48
+ console.log('This runs `' + upgradeCommand() + '` on your behalf when a new');
49
+ console.log('version ships. The output includes a source-diff link so you');
50
+ console.log('(or your agent) can verify what was installed.');
51
+ console.log('');
52
+ console.log('Recommended if you mostly use sdoc through coding agents.');
53
+ console.log('');
54
+ console.log('Change any time with `sdoc auto-update on` / `sdoc auto-update off`.\n');
55
+ const a = await ask('Enable? [Y/n] ');
56
+ return !a || a === 'y' || a === 'yes';
57
+ }
58
+
59
+ async function askAutoRefreshConsent() {
60
+ console.log('\nKeep this block updated on future sdoc upgrades?');
61
+ console.log('');
62
+ console.log('When sdoc adds a feature we sometimes update this section so');
63
+ console.log('your agent learns about it. Each time the block changes we');
64
+ console.log(`print a notice with a link to ${AGENT_CHANGES_URL}`);
65
+ console.log('showing the exact delta - the new wording, and why it changed.');
66
+ console.log('');
67
+ console.log('Re-run `sdoc setup` any time to change this.\n');
68
+ const a = await ask('Enable? [Y/n] ');
69
+ return !a || a === 'y' || a === 'yes';
70
+ }
71
+
72
+ async function runSetup({ force = false, yes = false } = {}) {
73
+ if (!force) {
74
+ if (!process.stdout.isTTY || !process.stdin.isTTY) return;
75
+ if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
76
+ if (readSetupState()) return;
77
+ }
78
+
79
+ // ── --yes (non-interactive) path ───────────────────────────
80
+ // Pulled out of the detection branch so this is the SINGLE place that
81
+ // handles every --yes case: fresh install, old block (upgrade), legacy
82
+ // open-marker (migration), already-current (no-op), no agents at all.
83
+ // Idempotent by design - an agent or user can re-paste the install prompt
84
+ // any number of times and the result is a current block in every detected
85
+ // config, or a clean "nothing to do".
86
+ if (yes) {
87
+ // Step 1: refresh any existing outdated / legacy blocks. This is what
88
+ // closes the gap where re-running setup --yes used to silently no-op
89
+ // on a stale install.
90
+ const refreshResults = refreshAllAgentFiles();
91
+ const refreshedFiles = refreshResults.filter(r => r.changed).map(r => r.path);
92
+ if (refreshResults.some(r => r.changed)) printRefreshSummary(refreshResults);
93
+
94
+ // Step 2: any agent whose config dir exists but doesn't yet have a
95
+ // block gets one written. Re-detect after refresh because the refresh
96
+ // step may have flipped some files from "needs block" to "has block".
97
+ const stillMissing = detectAgents().filter(t => !fileHasBlock(t.filePath));
98
+ const writtenTo = [];
99
+ for (const t of stillMissing) {
100
+ try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
101
+ catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
102
+ }
103
+
104
+ const affected = [...new Set([...writtenTo, ...refreshedFiles])];
105
+
106
+ if (affected.length === 0) {
107
+ const anyAgentDir = detectAgents().length > 0;
108
+ writeSetupState({
109
+ setupCompleted: new Date().toISOString(),
110
+ writtenTo: [], declined: !anyAgentDir,
111
+ autoRefreshAgentFiles: anyAgentDir,
112
+ autoInstallUpdates: false,
113
+ lastRunVersion: VERSION,
114
+ });
115
+ if (anyAgentDir) {
116
+ console.log('All SDocs agent blocks already at current version. Nothing to do.');
117
+ } else {
118
+ console.log('No coding-agent configs detected. Nothing to write.');
119
+ console.log('Re-run `sdoc setup` (interactive) if you want to include opencode.');
120
+ }
121
+ return;
122
+ }
123
+
124
+ writeSetupState({
125
+ setupCompleted: new Date().toISOString(),
126
+ writtenTo: affected, declined: false,
127
+ autoRefreshAgentFiles: true,
128
+ autoInstallUpdates: false,
129
+ lastRunVersion: VERSION,
130
+ });
131
+ const n = affected.length;
132
+ const verb = writtenTo.length && refreshedFiles.length
133
+ ? 'Wrote/refreshed'
134
+ : (writtenTo.length ? 'Wrote' : 'Refreshed');
135
+ console.log(`\nDone. ${verb} SDocs block in ${n} ${n === 1 ? 'file' : 'files'}.`);
136
+ return;
137
+ }
138
+
139
+ const detected = detectAgents().filter(t => !fileHasBlock(t.filePath));
140
+
141
+ if (detected.length === 0) {
142
+ const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
143
+ if (opencodeAlreadyDone) {
144
+ writeSetupState({
145
+ setupCompleted: new Date().toISOString(),
146
+ writtenTo: [], declined: false,
147
+ autoRefreshAgentFiles: true, autoInstallUpdates: false,
148
+ lastRunVersion: VERSION,
149
+ });
150
+ console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
151
+ return;
152
+ }
153
+ console.log('\n✨─────── SDocs setup ───────✨');
154
+ console.log('First run only - wire SDocs into your CLI coding agents.\n');
155
+ console.log('No coding-agent configs detected.');
156
+ const a = await ask('Do you use opencode? [y/N] ');
157
+ const writtenTo = [];
158
+ let autoRefresh = false;
159
+ let autoInstall = false;
160
+ if (a === 'y' || a === 'yes') {
161
+ const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
162
+ try { writeBookendedBlock(target); writtenTo.push(target); console.log(`✓ Wrote SDocs section to ${target}`); }
163
+ catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
164
+ autoRefresh = await askAutoRefreshConsent();
165
+ autoInstall = await askAutoInstallConsent();
166
+ console.log('Done. Run `sdoc setup` any time to revisit.');
167
+ } else {
168
+ console.log('Skipped. Run `sdoc setup` any time to revisit.');
169
+ }
170
+ writeSetupState({
171
+ setupCompleted: new Date().toISOString(),
172
+ writtenTo, declined: writtenTo.length === 0,
173
+ autoRefreshAgentFiles: autoRefresh,
174
+ autoInstallUpdates: autoInstall,
175
+ lastRunVersion: VERSION,
176
+ });
177
+ return;
178
+ }
179
+
180
+ console.log('\n✨─────── SDocs setup ───────✨');
181
+ console.log('First run only - wire SDocs into your CLI coding agents.\n');
182
+ console.log('Detected: ' + detected.map(t => t.name).join(', '));
183
+ console.log('\nWill append a short SDocs section to:');
184
+ for (const t of detected) console.log(' ' + t.filePath);
185
+ console.log('\nThese files are loaded into every conversation across all your');
186
+ console.log('projects, so SDocs becomes available no matter where you\'re working.');
187
+ console.log('');
188
+ console.log('You can ask your agent things like:');
189
+ console.log(' "write up the plan and sdoc it to me"');
190
+ console.log(' "explain async/await to me in a sdoc"');
191
+ console.log(' "draft the release notes as a sdoc I can share"');
192
+ console.log('');
193
+ console.log('This is the best way to work with SDocs');
194
+ const RULE = '═'.repeat(36);
195
+ console.log(`\n═══════════ Block to add ═══════════`);
196
+ console.log(AGENT_BLOCK_BODY.trim());
197
+ console.log(RULE);
198
+
199
+ const a = await ask('\nAdd to all? [Y/n/skip] ');
200
+ const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
201
+ if (skipped) {
202
+ writeSetupState({
203
+ setupCompleted: new Date().toISOString(),
204
+ writtenTo: [], declined: true,
205
+ autoRefreshAgentFiles: false, autoInstallUpdates: false,
206
+ lastRunVersion: VERSION,
207
+ });
208
+ console.log('Skipped. Run `sdoc setup` any time to revisit.');
209
+ return;
210
+ }
211
+
212
+ const writtenTo = [];
213
+ for (const t of detected) {
214
+ try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
215
+ catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
216
+ }
217
+
218
+ const autoRefresh = writtenTo.length > 0 ? await askAutoRefreshConsent() : false;
219
+ const autoInstall = writtenTo.length > 0 ? await askAutoInstallConsent() : false;
220
+
221
+ writeSetupState({
222
+ setupCompleted: new Date().toISOString(),
223
+ writtenTo, declined: false,
224
+ autoRefreshAgentFiles: autoRefresh,
225
+ autoInstallUpdates: autoInstall,
226
+ lastRunVersion: VERSION,
227
+ });
228
+ console.log('\nDone. Run `sdoc setup` any time to revisit.');
229
+ }
230
+
231
+ // Auto-refresh existing agent files when the binary version is newer than the
232
+ // version that last ran. No prompt: the user already consented during setup.
233
+ // Bails on downgrades (block version > shipped version), errors, or partial
234
+ // failures (lastRunVersion only advances when every changed file succeeded).
235
+ async function maybeAutoRefresh() {
236
+ if (process.env.SDOCS_NO_REFRESH) return;
237
+ let state = readSetupState();
238
+
239
+ // Implicit-consent migration for users who have a recognised SDocs block in
240
+ // an agent file but no `~/.sdocs/setup.json`. This is the pre-1.5.0 install
241
+ // path. `refreshContent` only signals `changed` for a block whose exact
242
+ // shape we wrote (legacy JoshInLisbon terminator, or our bookend markers);
243
+ // anything else is left untouched, so a user who deleted the block or
244
+ // hand-edited it doesn't get state silently created.
245
+ if (!state) {
246
+ const results = refreshAllAgentFiles();
247
+ const next = implicitConsentState(results, VERSION);
248
+ if (!next) return;
249
+ printRefreshSummary(results);
250
+ writeSetupState(next);
251
+ return;
252
+ }
253
+
254
+ if (!state.autoRefreshAgentFiles) return;
255
+ if (compareVersions(VERSION, state.lastRunVersion) <= 0) return;
256
+
257
+ const results = refreshAllAgentFiles();
258
+ const anyChanged = results.some(r => r.changed);
259
+ if (anyChanged) printRefreshSummary(results);
260
+
261
+ const anyError = results.some(r => r.error);
262
+ if (!anyError) {
263
+ writeSetupState({ ...state, lastRunVersion: VERSION });
264
+ }
265
+ }
266
+
267
+ // `sdoc refresh` — unconditional agent-block refresh. Useful for users whose
268
+ // setup.json was never written (pre-1.5.0 installs) or has been deleted, and
269
+ // for agents that want to trigger the migration explicitly without going
270
+ // through the interactive setup flow.
271
+ async function runRefresh() {
272
+ const existing = readSetupState();
273
+ const results = refreshAllAgentFiles();
274
+ const changed = results.filter(r => r.changed);
275
+ const errors = results.filter(r => r.error);
276
+ const current = results.filter(r => r.reason === 'current');
277
+ const blocksPresent = changed.length + current.length;
278
+
279
+ printRefreshSummary(results);
280
+
281
+ if (changed.length === 0 && errors.length === 0) {
282
+ if (blocksPresent === 0) {
283
+ console.log('No SDocs blocks found in any agent file. Run `sdoc setup` to add one.');
284
+ return;
285
+ }
286
+ console.log(`All SDocs agent blocks already at v${AGENT_BLOCK_VERSION}.`);
287
+ }
288
+
289
+ if (errors.length > 0) return;
290
+
291
+ if (blocksPresent === 0 && !existing) return;
292
+
293
+ writeSetupState({
294
+ setupCompleted: existing?.setupCompleted || new Date().toISOString(),
295
+ writtenTo: [...changed, ...current].map(r => r.path),
296
+ declined: false,
297
+ autoRefreshAgentFiles: existing ? existing.autoRefreshAgentFiles !== false : true,
298
+ autoInstallUpdates: existing?.autoInstallUpdates ?? false,
299
+ lastRunVersion: VERSION,
300
+ });
301
+ }
302
+
303
+ // `sdoc auto-update on|off|status` — flips state.autoInstallUpdates.
304
+ function runAutoUpdateSubcommand(arg) {
305
+ let state = readSetupState();
306
+ if (!state) {
307
+ console.log('Run `sdoc setup` first to configure auto-update.');
308
+ return;
309
+ }
310
+ if (arg === 'on') {
311
+ writeSetupState({ ...state, autoInstallUpdates: true });
312
+ console.log('✓ Auto-install of sdoc updates: on');
313
+ return;
314
+ }
315
+ if (arg === 'off') {
316
+ writeSetupState({ ...state, autoInstallUpdates: false });
317
+ console.log('✓ Auto-install of sdoc updates: off');
318
+ return;
319
+ }
320
+ console.log(`Auto-install of sdoc updates: ${state.autoInstallUpdates ? 'on' : 'off'}`);
321
+ console.log('Use `sdoc auto-update on` or `sdoc auto-update off` to change.');
322
+ }
323
+
324
+ module.exports = {
325
+ ask,
326
+ askAutoInstallConsent,
327
+ askAutoRefreshConsent,
328
+ runSetup,
329
+ runRefresh,
330
+ runAutoUpdateSubcommand,
331
+ maybeAutoRefresh,
332
+ };
@@ -0,0 +1,105 @@
1
+ // Encrypted short links: /s/<id>#k=<key>.
2
+ //
3
+ // The CLI compresses + encrypts the document with a freshly generated
4
+ // AES-256-GCM key, POSTs the ciphertext to /api/short, then assembles a
5
+ // URL whose key lives in the fragment (which browsers don't send to
6
+ // servers). The key never leaves this process. Trade-offs vs the
7
+ // default `#md=` form are documented in HELP.
8
+
9
+ const https = require('https');
10
+ const http = require('http');
11
+ const zlib = require('zlib');
12
+ const crypto = require('crypto');
13
+
14
+ const SDocYaml = require('../shared/sdocs-yaml.js');
15
+ const SDocStyles = require('../shared/sdocs-styles.js');
16
+ const { slugify } = require('../shared/sdocs-slugify.js');
17
+
18
+ const { toBase64Url } = require('./url');
19
+ const { DEFAULT_URL } = require('./constants');
20
+
21
+ // The blob format (nonce(12) + ciphertext + tag(16)) matches the browser.
22
+ function compressAndEncrypt(content) {
23
+ const compressed = zlib.brotliCompressSync(Buffer.from(content, 'utf-8'), {
24
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
25
+ });
26
+ const keyBytes = crypto.randomBytes(32);
27
+ const nonce = crypto.randomBytes(12);
28
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, nonce);
29
+ const ct = Buffer.concat([cipher.update(compressed), cipher.final()]);
30
+ const tag = cipher.getAuthTag();
31
+ const blob = Buffer.concat([nonce, ct, tag]);
32
+ return { keyBytes, cipherB64url: toBase64Url(blob) };
33
+ }
34
+
35
+ function uploadShortLink(ciphertextB64, baseUrl) {
36
+ return new Promise((resolve, reject) => {
37
+ const u = new URL('/api/short', baseUrl);
38
+ const isHttps = u.protocol === 'https:';
39
+ const mod = isHttps ? https : http;
40
+ const payload = JSON.stringify({ ciphertext: ciphertextB64 });
41
+ const req = mod.request({
42
+ method: 'POST',
43
+ protocol: u.protocol,
44
+ hostname: u.hostname,
45
+ port: u.port || (isHttps ? 443 : 80),
46
+ path: u.pathname,
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'Content-Length': Buffer.byteLength(payload),
50
+ },
51
+ timeout: 10000,
52
+ }, (res) => {
53
+ let body = '';
54
+ res.on('data', (chunk) => { body += chunk; });
55
+ res.on('end', () => {
56
+ let json;
57
+ try { json = JSON.parse(body); } catch (_) { json = null; }
58
+ if (res.statusCode >= 200 && res.statusCode < 300 && json && json.id) {
59
+ resolve(json.id);
60
+ } else {
61
+ const err = (json && json.error) || ('http_' + res.statusCode);
62
+ reject(new Error(err));
63
+ }
64
+ });
65
+ });
66
+ req.on('error', reject);
67
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
68
+ req.write(payload);
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ async function buildShortUrl(content, opts) {
74
+ if (!content) throw new Error('short link requires file content');
75
+
76
+ // Mirror the hash-build's default-stripping so the encrypted payload is
77
+ // identical to what the browser would encode.
78
+ const parsed = SDocYaml.parseFrontMatter(content);
79
+ if (parsed.meta && parsed.meta.styles) {
80
+ const stripped = SDocStyles.stripStyleDefaults(parsed.meta.styles);
81
+ if (Object.keys(stripped).length > 0) parsed.meta.styles = stripped;
82
+ else delete parsed.meta.styles;
83
+ content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
84
+ }
85
+
86
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
87
+ const { keyBytes, cipherB64url } = compressAndEncrypt(content);
88
+ const id = await uploadShortLink(cipherB64url, baseUrl);
89
+ const keyB64 = toBase64Url(keyBytes);
90
+
91
+ const params = new URLSearchParams();
92
+ params.set('k', keyB64);
93
+ const mode = opts.mode;
94
+ if (mode && mode !== 'read') params.set('mode', mode);
95
+ if (opts.theme) params.set('theme', opts.theme);
96
+ if (opts.section) params.set('sec', slugify(opts.section));
97
+
98
+ return `${baseUrl}/s/${id}#${params.toString()}`;
99
+ }
100
+
101
+ module.exports = {
102
+ compressAndEncrypt,
103
+ uploadShortLink,
104
+ buildShortUrl,
105
+ };