sdocs-dev 1.15.0 → 1.18.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.
@@ -0,0 +1,380 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { execFileSync, spawnSync } = require('child_process');
6
+
7
+ const KEYCHAIN_SERVICE = 'org.smalldocs.cloud';
8
+ const KEYCHAIN_RECORD_VERSION = 2;
9
+ const KEYCHAIN_EXPECT = [
10
+ 'log_user 0',
11
+ 'set timeout 15',
12
+ 'set secret [gets stdin]',
13
+ 'spawn security add-generic-password -U -s "' + KEYCHAIN_SERVICE + '" -a "$env(SDOCS_KEYCHAIN_ACCOUNT)" -w',
14
+ 'expect -re {password.*item.*:}',
15
+ 'send -- "$secret\\r"',
16
+ 'expect -re {retype.*item.*:}',
17
+ 'send -- "$secret\\r"',
18
+ 'expect eof',
19
+ 'catch wait result',
20
+ 'exit [lindex $result 3]',
21
+ ].join('\n');
22
+ const DPAPI_PROTECT = [
23
+ '$ErrorActionPreference = "Stop"',
24
+ 'Add-Type -AssemblyName System.Security',
25
+ '$plain = [Console]::In.ReadToEnd()',
26
+ '$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
27
+ '$cipher = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
28
+ '[Console]::Out.Write([Convert]::ToBase64String($cipher))',
29
+ ].join('; ');
30
+ const DPAPI_UNPROTECT = [
31
+ '$ErrorActionPreference = "Stop"',
32
+ 'Add-Type -AssemblyName System.Security',
33
+ '$encoded = [Console]::In.ReadToEnd()',
34
+ '$cipher = [Convert]::FromBase64String($encoded)',
35
+ '$plain = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)',
36
+ '[Console]::Out.Write([Text.Encoding]::UTF8.GetString($plain))',
37
+ ].join('; ');
38
+
39
+ function cloudDir() {
40
+ return path.join(process.env.SDOCS_HOME || path.join(os.homedir(), '.sdocs'), 'cloud');
41
+ }
42
+
43
+ function credentialFile() { return path.join(cloudDir(), 'credentials.json'); }
44
+ function dpapiCredentialFile(origin) {
45
+ if (!origin) return path.join(cloudDir(), 'credentials.dpapi');
46
+ const id = crypto.createHash('sha256').update(origin).digest('hex');
47
+ return path.join(cloudDir(), 'credentials-' + id + '.dpapi');
48
+ }
49
+ function encryptedCredentialFile(origin, generation) {
50
+ const id = crypto.createHash('sha256').update(origin).digest('hex');
51
+ return path.join(cloudDir(), 'credentials-' + id +
52
+ (generation ? '-' + generation : '') + '.enc');
53
+ }
54
+
55
+ function atomicWriteRaw(file, value) {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
57
+ try { fs.chmodSync(path.dirname(file), 0o700); } catch (_) {}
58
+ const temporary = file + '.tmp-' + process.pid + '-' + crypto.randomBytes(4).toString('hex');
59
+ fs.writeFileSync(temporary, value, { mode: 0o600 });
60
+ try { fs.chmodSync(temporary, 0o600); } catch (_) {}
61
+ fs.renameSync(temporary, file);
62
+ }
63
+
64
+ function atomicWrite(file, value) {
65
+ atomicWriteRaw(file, JSON.stringify(value, null, 2) + '\n');
66
+ }
67
+
68
+ function readJson(file) {
69
+ try {
70
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
71
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
72
+ } catch (_) { return {}; }
73
+ }
74
+
75
+ function readFileStore() { return readJson(credentialFile()); }
76
+
77
+ function keychainAccount(origin) {
78
+ return Buffer.from(origin).toString('base64url');
79
+ }
80
+
81
+ function keychainReadAccount(account) {
82
+ try {
83
+ return execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE,
84
+ '-a', account, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
85
+ } catch (error) {
86
+ if (error && Number(error.status) === 44) return null;
87
+ throw new Error('Could not read the SmallDocs Cloud credential key from macOS Keychain.');
88
+ }
89
+ }
90
+
91
+ function keychainWriteAccount(account, value, execute) {
92
+ const run = execute || spawnSync;
93
+ const result = run('/usr/bin/expect', ['-c', KEYCHAIN_EXPECT], {
94
+ input: value + '\n',
95
+ encoding: 'utf8',
96
+ env: Object.assign({}, process.env, { SDOCS_KEYCHAIN_ACCOUNT: account }),
97
+ stdio: ['pipe', 'ignore', 'ignore'],
98
+ });
99
+ if (result.error) throw result.error;
100
+ if (result.status !== 0) {
101
+ throw new Error('Could not save the Cloud credential key to macOS Keychain.');
102
+ }
103
+ }
104
+
105
+ function keychainDeleteAccount(account) {
106
+ try {
107
+ execFileSync('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE,
108
+ '-a', account], { stdio: 'ignore' });
109
+ } catch (_) {}
110
+ }
111
+
112
+ function keychainOperations(operations) {
113
+ if (operations && typeof operations === 'object') return operations;
114
+ if (typeof operations === 'function') {
115
+ return {
116
+ read() { return null; },
117
+ write(account, value) { keychainWriteAccount(account, value, operations); },
118
+ remove: keychainDeleteAccount,
119
+ };
120
+ }
121
+ return {
122
+ read: keychainReadAccount,
123
+ write(account, value) { keychainWriteAccount(account, value); },
124
+ remove: keychainDeleteAccount,
125
+ };
126
+ }
127
+
128
+ function parseKeychainRecord(raw) {
129
+ if (!raw) return null;
130
+ try {
131
+ const value = JSON.parse(raw);
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
133
+ if (value.v === KEYCHAIN_RECORD_VERSION && typeof value.key === 'string') {
134
+ const key = Buffer.from(value.key, 'base64url');
135
+ const generation = value.file == null ? null : String(value.file);
136
+ if (key.length !== 32 || key.toString('base64url') !== value.key ||
137
+ (generation != null && !/^[a-f0-9]{16}$/.test(generation))) return null;
138
+ return { type: 'key', key, generation };
139
+ }
140
+ return { type: 'legacy', credential: value };
141
+ } catch (_) { return null; }
142
+ }
143
+
144
+ function encryptCredential(credential, key) {
145
+ const iv = crypto.randomBytes(12);
146
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
147
+ const ciphertext = Buffer.concat([
148
+ cipher.update(JSON.stringify(credential), 'utf8'),
149
+ cipher.final(),
150
+ ]);
151
+ return JSON.stringify({
152
+ v: 1,
153
+ iv: iv.toString('base64url'),
154
+ tag: cipher.getAuthTag().toString('base64url'),
155
+ ciphertext: ciphertext.toString('base64url'),
156
+ }) + '\n';
157
+ }
158
+
159
+ function decryptCredential(value, key) {
160
+ const envelope = JSON.parse(value);
161
+ if (!envelope || envelope.v !== 1 || typeof envelope.iv !== 'string' ||
162
+ typeof envelope.tag !== 'string' || typeof envelope.ciphertext !== 'string') return null;
163
+ const iv = Buffer.from(envelope.iv, 'base64url');
164
+ const tag = Buffer.from(envelope.tag, 'base64url');
165
+ if (iv.length !== 12 || tag.length !== 16) return null;
166
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
167
+ decipher.setAuthTag(tag);
168
+ const plain = Buffer.concat([
169
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64url')),
170
+ decipher.final(),
171
+ ]).toString('utf8');
172
+ const credential = JSON.parse(plain);
173
+ return credential && typeof credential === 'object' && !Array.isArray(credential) ? credential : null;
174
+ }
175
+
176
+ function keychainLoad(origin, operations) {
177
+ const ops = keychainOperations(operations);
178
+ const record = parseKeychainRecord(ops.read(keychainAccount(origin)));
179
+ if (!record) return null;
180
+ if (record.type === 'legacy') return record.credential;
181
+ try {
182
+ return decryptCredential(fs.readFileSync(
183
+ encryptedCredentialFile(origin, record.generation), 'utf8'), record.key);
184
+ } catch (_) { return null; }
185
+ }
186
+
187
+ function keychainSave(origin, credential, operations) {
188
+ const ops = keychainOperations(operations);
189
+ const account = keychainAccount(origin);
190
+ const current = parseKeychainRecord(ops.read(account));
191
+ const key = current && current.type === 'key' ? current.key : crypto.randomBytes(32);
192
+ const generation = current && current.type === 'key'
193
+ ? current.generation : crypto.randomBytes(8).toString('hex');
194
+ const file = encryptedCredentialFile(origin, generation);
195
+ atomicWriteRaw(file, encryptCredential(credential, key));
196
+ if (!current || current.type !== 'key') {
197
+ const record = JSON.stringify({ v: KEYCHAIN_RECORD_VERSION,
198
+ key: key.toString('base64url'), file: generation });
199
+ try { ops.write(account, record); }
200
+ catch (error) {
201
+ try { fs.unlinkSync(file); } catch (_) {}
202
+ throw error;
203
+ }
204
+ }
205
+ }
206
+
207
+ function keychainDelete(origin, operations) {
208
+ const ops = keychainOperations(operations);
209
+ const record = parseKeychainRecord(ops.read(keychainAccount(origin)));
210
+ ops.remove(keychainAccount(origin));
211
+ const file = encryptedCredentialFile(origin,
212
+ record && record.type === 'key' ? record.generation : null);
213
+ try { fs.unlinkSync(file); } catch (_) {}
214
+ }
215
+
216
+ function removeFileCredential(origin) {
217
+ const values = readFileStore();
218
+ delete values[origin];
219
+ if (Object.keys(values).length) atomicWrite(credentialFile(), values);
220
+ else {
221
+ try { fs.unlinkSync(credentialFile()); }
222
+ catch (error) { if (!error || error.code !== 'ENOENT') throw error; }
223
+ }
224
+ }
225
+
226
+ function retryFileCredentialCleanup(origin) {
227
+ try { removeFileCredential(origin); } catch (_) {}
228
+ }
229
+
230
+ function macLoad(origin, operations) {
231
+ const credential = keychainLoad(origin, operations);
232
+ if (credential) {
233
+ if (readFileStore()[origin]) retryFileCredentialCleanup(origin);
234
+ return credential;
235
+ }
236
+ const legacy = readFileStore()[origin] || null;
237
+ if (!legacy) return null;
238
+ keychainSave(origin, legacy, operations);
239
+ retryFileCredentialCleanup(origin);
240
+ return legacy;
241
+ }
242
+
243
+ function macSave(origin, credential, operations) {
244
+ keychainSave(origin, credential, operations);
245
+ retryFileCredentialCleanup(origin);
246
+ }
247
+
248
+ function macRemove(origin, operations) {
249
+ keychainDelete(origin, operations);
250
+ removeFileCredential(origin);
251
+ }
252
+
253
+ function runDpapi(script, input, execute) {
254
+ const run = execute || spawnSync;
255
+ const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive',
256
+ '-Command', script], {
257
+ input,
258
+ encoding: 'utf8',
259
+ windowsHide: true,
260
+ stdio: ['pipe', 'pipe', 'ignore'],
261
+ });
262
+ if (result.error) throw result.error;
263
+ if (result.status !== 0 || typeof result.stdout !== 'string' || !result.stdout.trim()) {
264
+ throw new Error('Windows could not protect the SmallDocs Cloud credential.');
265
+ }
266
+ return result.stdout.trim();
267
+ }
268
+
269
+ function validBase64(value) {
270
+ if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
271
+ try {
272
+ const decoded = Buffer.from(value, 'base64');
273
+ return decoded.length > 0 && decoded.toString('base64') === value;
274
+ } catch (_) { return false; }
275
+ }
276
+
277
+ function readDpapiCredential(origin, execute) {
278
+ const file = dpapiCredentialFile(origin);
279
+ if (!fs.existsSync(file)) return null;
280
+ try {
281
+ const plain = runDpapi(DPAPI_UNPROTECT, fs.readFileSync(file, 'utf8').trim(), execute);
282
+ const value = JSON.parse(plain);
283
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid store');
284
+ return value;
285
+ } catch (_) {
286
+ throw new Error('Could not read the Windows-protected SmallDocs Cloud credential.');
287
+ }
288
+ }
289
+
290
+ function writeDpapiCredential(origin, credential, execute) {
291
+ const encrypted = runDpapi(DPAPI_PROTECT, JSON.stringify(credential), execute);
292
+ if (!validBase64(encrypted)) {
293
+ throw new Error('Windows returned an invalid protected SmallDocs Cloud credential.');
294
+ }
295
+ atomicWriteRaw(dpapiCredentialFile(origin), encrypted + '\n');
296
+ }
297
+
298
+ function windowsLoad(origin, execute) {
299
+ const protectedCredential = readDpapiCredential(origin, execute);
300
+ if (protectedCredential) {
301
+ if (readFileStore()[origin]) retryFileCredentialCleanup(origin);
302
+ return protectedCredential;
303
+ }
304
+ const legacy = readFileStore()[origin] || null;
305
+ if (!legacy) return null;
306
+ writeDpapiCredential(origin, legacy, execute);
307
+ retryFileCredentialCleanup(origin);
308
+ return legacy;
309
+ }
310
+
311
+ function windowsSave(origin, credential, execute) {
312
+ writeDpapiCredential(origin, credential, execute);
313
+ retryFileCredentialCleanup(origin);
314
+ }
315
+
316
+ function windowsRemove(origin) {
317
+ try { fs.unlinkSync(dpapiCredentialFile(origin)); }
318
+ catch (error) { if (!error || error.code !== 'ENOENT') throw error; }
319
+ removeFileCredential(origin);
320
+ }
321
+
322
+ function forceFileCredentials() {
323
+ return process.env.SDOCS_CLOUD_FILE_CREDENTIALS === '1';
324
+ }
325
+
326
+ function useKeychain() {
327
+ return process.platform === 'darwin' && !forceFileCredentials();
328
+ }
329
+
330
+ function useDpapi() {
331
+ return process.platform === 'win32' && !forceFileCredentials();
332
+ }
333
+
334
+ function load(origin) {
335
+ if (useKeychain()) return macLoad(origin);
336
+ if (useDpapi()) return windowsLoad(origin);
337
+ return readFileStore()[origin] || null;
338
+ }
339
+
340
+ function save(origin, credential) {
341
+ if (useKeychain()) return macSave(origin, credential);
342
+ if (useDpapi()) return windowsSave(origin, credential);
343
+ const values = readFileStore();
344
+ values[origin] = credential;
345
+ atomicWrite(credentialFile(), values);
346
+ }
347
+
348
+ function remove(origin) {
349
+ if (useKeychain()) return macRemove(origin);
350
+ if (useDpapi()) return windowsRemove(origin);
351
+ const values = readFileStore();
352
+ delete values[origin];
353
+ atomicWrite(credentialFile(), values);
354
+ }
355
+
356
+ module.exports = {
357
+ cloudDir,
358
+ credentialFile,
359
+ dpapiCredentialFile,
360
+ encryptedCredentialFile,
361
+ atomicWrite,
362
+ load,
363
+ save,
364
+ remove,
365
+ useKeychain,
366
+ useDpapi,
367
+ keychainLoad,
368
+ keychainSave,
369
+ keychainDelete,
370
+ macLoad,
371
+ macSave,
372
+ macRemove,
373
+ readDpapiCredential,
374
+ writeDpapiCredential,
375
+ windowsLoad,
376
+ windowsSave,
377
+ windowsRemove,
378
+ DPAPI_PROTECT,
379
+ DPAPI_UNPROTECT,
380
+ };
package/lib/commands.js CHANGED
@@ -89,6 +89,36 @@ async function prepareCodewalkUrl(opts) {
89
89
  return finishUrl(opts, content, null, defaults);
90
90
  }
91
91
 
92
+ function isMarkdownPath(file) {
93
+ return /\.(?:md|markdown|mdown|mkd)$/i.test(String(file || ''));
94
+ }
95
+
96
+ function frontMatterLineOffset(content) {
97
+ const match = String(content || '').match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
98
+ return match ? (match[0].match(/\n/g) || []).length : 0;
99
+ }
100
+
101
+ // A regular Markdown file uses the same line annotation syntax as a source
102
+ // file. `docwalk: true` tells the reader to resolve those source lines against
103
+ // rendered prose and rich blocks instead of offering them to the code viewer.
104
+ function applyDocumentWalkthrough(meta, file, annotations, lineOffset) {
105
+ if (!isMarkdownPath(file) || !Array.isArray(annotations) || !annotations.length) {
106
+ return false;
107
+ }
108
+ const offset = Math.max(0, parseInt(lineOffset, 10) || 0);
109
+ meta.docwalk = true;
110
+ meta.annotations = annotations.flatMap(({ file: _file, ...rest }) => {
111
+ const sourceLine = parseInt(rest.line, 10);
112
+ const parsedEnd = parseInt(rest.endLine, 10);
113
+ const sourceEnd = parsedEnd >= sourceLine ? parsedEnd : sourceLine;
114
+ if (!(sourceLine >= 1) || sourceEnd <= offset) return [];
115
+ const line = Math.max(1, sourceLine - offset);
116
+ const endLine = Math.max(line, sourceEnd - offset);
117
+ return [{ ...rest, line, endLine }];
118
+ });
119
+ return true;
120
+ }
121
+
92
122
  async function prepareUrl(opts) {
93
123
  // Annotations render as a walkthrough: a tabbed tour for 2+ files, a single-
94
124
  // tab stepper for one. A plain `sdoc app.py` with no annotations stays the
@@ -101,6 +131,7 @@ async function prepareUrl(opts) {
101
131
  }
102
132
 
103
133
  let content = await readContent(opts.file);
134
+ const documentLineOffset = frontMatterLineOffset(content);
104
135
  const defaults = loadDefaultStyles();
105
136
  if (content && defaults) {
106
137
  content = applyDefaultStyles(content);
@@ -115,7 +146,10 @@ async function prepareUrl(opts) {
115
146
  const parsed = SDocYaml.parseFrontMatter(content);
116
147
  let changed = false;
117
148
  if (!parsed.meta.file) { parsed.meta.file = path.basename(opts.file); changed = true; }
118
- if (opts.annotations && opts.annotations.length) {
149
+ if (applyDocumentWalkthrough(
150
+ parsed.meta, opts.file, opts.annotations, documentLineOffset)) {
151
+ changed = true;
152
+ } else if (opts.annotations && opts.annotations.length) {
119
153
  // A single file needs no per-annotation `file` binding — drop it so the
120
154
  // serialized shape stays {line, endLine, text}. (Multi-file keeps it, in
121
155
  // prepareCodewalkUrl.)
@@ -238,6 +272,7 @@ function newCommand(opts) {
238
272
  // sdoc slides list -> built-in template registry
239
273
  // sdoc slides custom-shapes -> raw-shape reference
240
274
  // sdoc slides icons [query] -> Lucide icon name listing
275
+ // sdoc slides verify <file> -> handled by slides-verify in the router
241
276
  function slidesCommand(opts) {
242
277
  const helpText = require('./help-text');
243
278
  const sub = opts.file;
@@ -255,9 +290,11 @@ function presentCommand(opts) {
255
290
  }
256
291
 
257
292
  function printSlideStdlib() {
258
- // Require lazily so the browser-side slide stdlib (which uses window
259
- // globals) is only loaded when this command actually runs.
260
- const SDocSlideStdlib = require('../../public/sdocs-slide-stdlib.js');
293
+ // The registry lives inside the published CLI package and is symlinked
294
+ // into public/ for the browser. Reaching through ../../public works from
295
+ // a repository checkout but fails after npm or URL installation because
296
+ // the server-side public/ directory is not part of the CLI tarball.
297
+ const SDocSlideStdlib = require('../shared/sdocs-slide-stdlib.js');
261
298
  const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
262
299
  const slots = SDocSlideStdlib.slots || {};
263
300
  console.log('Built-in slide templates');
@@ -329,6 +366,9 @@ function printIconList(query) {
329
366
  }
330
367
 
331
368
  module.exports = {
369
+ applyDocumentWalkthrough,
370
+ frontMatterLineOffset,
371
+ isMarkdownPath,
332
372
  prepareUrl,
333
373
  openCommand,
334
374
  shareCommand,