sdocs-dev 1.15.0 → 1.19.2

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() {},
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
@@ -44,6 +44,7 @@ async function finishUrl(opts, content, local, defaults) {
44
44
  try {
45
45
  const url = await buildShortUrl(content, {
46
46
  url: opts.url, mode: opts.mode, theme: opts.theme, section: opts.section,
47
+ source: opts.sourceFlag, placement: opts.placementFlag,
47
48
  });
48
49
  return { url, contentPresent: !!content };
49
50
  } catch (e) {
@@ -89,6 +90,36 @@ async function prepareCodewalkUrl(opts) {
89
90
  return finishUrl(opts, content, null, defaults);
90
91
  }
91
92
 
93
+ function isMarkdownPath(file) {
94
+ return /\.(?:md|markdown|mdown|mkd)$/i.test(String(file || ''));
95
+ }
96
+
97
+ function frontMatterLineOffset(content) {
98
+ const match = String(content || '').match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
99
+ return match ? (match[0].match(/\n/g) || []).length : 0;
100
+ }
101
+
102
+ // A regular Markdown file uses the same line annotation syntax as a source
103
+ // file. `docwalk: true` tells the reader to resolve those source lines against
104
+ // rendered prose and rich blocks instead of offering them to the code viewer.
105
+ function applyDocumentWalkthrough(meta, file, annotations, lineOffset) {
106
+ if (!isMarkdownPath(file) || !Array.isArray(annotations) || !annotations.length) {
107
+ return false;
108
+ }
109
+ const offset = Math.max(0, parseInt(lineOffset, 10) || 0);
110
+ meta.docwalk = true;
111
+ meta.annotations = annotations.flatMap(({ file: _file, ...rest }) => {
112
+ const sourceLine = parseInt(rest.line, 10);
113
+ const parsedEnd = parseInt(rest.endLine, 10);
114
+ const sourceEnd = parsedEnd >= sourceLine ? parsedEnd : sourceLine;
115
+ if (!(sourceLine >= 1) || sourceEnd <= offset) return [];
116
+ const line = Math.max(1, sourceLine - offset);
117
+ const endLine = Math.max(line, sourceEnd - offset);
118
+ return [{ ...rest, line, endLine }];
119
+ });
120
+ return true;
121
+ }
122
+
92
123
  async function prepareUrl(opts) {
93
124
  // Annotations render as a walkthrough: a tabbed tour for 2+ files, a single-
94
125
  // tab stepper for one. A plain `sdoc app.py` with no annotations stays the
@@ -101,6 +132,7 @@ async function prepareUrl(opts) {
101
132
  }
102
133
 
103
134
  let content = await readContent(opts.file);
135
+ const documentLineOffset = frontMatterLineOffset(content);
104
136
  const defaults = loadDefaultStyles();
105
137
  if (content && defaults) {
106
138
  content = applyDefaultStyles(content);
@@ -115,7 +147,10 @@ async function prepareUrl(opts) {
115
147
  const parsed = SDocYaml.parseFrontMatter(content);
116
148
  let changed = false;
117
149
  if (!parsed.meta.file) { parsed.meta.file = path.basename(opts.file); changed = true; }
118
- if (opts.annotations && opts.annotations.length) {
150
+ if (applyDocumentWalkthrough(
151
+ parsed.meta, opts.file, opts.annotations, documentLineOffset)) {
152
+ changed = true;
153
+ } else if (opts.annotations && opts.annotations.length) {
119
154
  // A single file needs no per-annotation `file` binding — drop it so the
120
155
  // serialized shape stays {line, endLine, text}. (Multi-file keeps it, in
121
156
  // prepareCodewalkUrl.)
@@ -152,7 +187,7 @@ async function prepareUrl(opts) {
152
187
  // The non-blocking, share-by-URL case is `sdoc share <file>`.
153
188
  async function openCommand(opts) {
154
189
  const { url } = await prepareUrl(opts);
155
- openBrowser(url);
190
+ if (!opts.noOpenFlag) openBrowser(url);
156
191
  console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
157
192
  await postCommandHooks();
158
193
  }
@@ -238,6 +273,7 @@ function newCommand(opts) {
238
273
  // sdoc slides list -> built-in template registry
239
274
  // sdoc slides custom-shapes -> raw-shape reference
240
275
  // sdoc slides icons [query] -> Lucide icon name listing
276
+ // sdoc slides verify <file> -> handled by slides-verify in the router
241
277
  function slidesCommand(opts) {
242
278
  const helpText = require('./help-text');
243
279
  const sub = opts.file;
@@ -255,9 +291,11 @@ function presentCommand(opts) {
255
291
  }
256
292
 
257
293
  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');
294
+ // The registry lives inside the published CLI package and is symlinked
295
+ // into public/ for the browser. Reaching through ../../public works from
296
+ // a repository checkout but fails after npm or URL installation because
297
+ // the server-side public/ directory is not part of the CLI tarball.
298
+ const SDocSlideStdlib = require('../shared/sdocs-slide-stdlib.js');
261
299
  const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
262
300
  const slots = SDocSlideStdlib.slots || {};
263
301
  console.log('Built-in slide templates');
@@ -329,6 +367,9 @@ function printIconList(query) {
329
367
  }
330
368
 
331
369
  module.exports = {
370
+ applyDocumentWalkthrough,
371
+ frontMatterLineOffset,
372
+ isMarkdownPath,
332
373
  prepareUrl,
333
374
  openCommand,
334
375
  shareCommand,