sdocs-dev 1.14.1 → 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.
package/lib/io.js CHANGED
@@ -5,14 +5,16 @@ const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
6
  const { transcludeCells } = require('./cells-transclude');
7
7
  const { isWrappedFile, wrapForDisplay } = require('./file-wrap');
8
+ const codeLangs = require('./code-langs');
8
9
 
9
10
  const SUBCOMMANDS = new Set([
10
11
  'new', 'share', 'schema', 'defaults', 'help', 'version',
11
- 'charts', 'diagrams', 'videos', 'video', 'cells', 'code', 'comments',
12
+ 'charts', 'diagrams', 'videos', 'video', 'apps', 'app', 'cells', 'code', 'comments',
12
13
  'setup', 'safe', 'auto-update', 'refresh', 'upgrade',
13
14
  'bridge', 'feedback',
14
15
  'slides', 'present',
15
16
  'library',
17
+ 'cloud',
16
18
  'color-analysis',
17
19
  ]);
18
20
 
@@ -54,8 +56,32 @@ function parseArgs(argv) {
54
56
  let yesFlag = false;
55
57
  let dryRunFlag = false;
56
58
  let sheetName = null;
59
+ let projectFlag = null;
60
+ let accountFlag = null;
61
+ let outputPath = null;
62
+ let revisionFlag = null;
63
+ let documentFlag = null;
64
+ let baseRevisionFlag = null;
65
+ let limitFlag = null;
66
+ let noOpenFlag = false;
67
+ let noBindFlag = false;
68
+ let forceFlag = false;
69
+ let everyoneFlag = false;
70
+ let onlyYouFlag = false;
71
+ let sharedWithMeFlag = false;
72
+ let noteText = null;
73
+ const memberFlags = [];
74
+ const documentFlags = [];
75
+ const tagFilters = [];
57
76
  const addTags = [];
58
77
  const annotations = [];
78
+ // Multi-file code walkthrough: every source-code positional is collected
79
+ // into `files`, in command order. `currentFile` is the cursor an annotation
80
+ // binds to, so `file1.py 4:"x" file2.py 13:"y"` ties line 4 to file1 and
81
+ // line 13 to file2. The first code file also fills the single-file `file`
82
+ // slot, so a one-file `sdoc app.py` is unchanged.
83
+ const files = [];
84
+ let currentFile = null;
59
85
 
60
86
  for (let i = 0; i < args.length; i++) {
61
87
  const arg = args[i];
@@ -107,6 +133,26 @@ function parseArgs(argv) {
107
133
  if (arg === '--yes' || arg === '-y') { yesFlag = true; continue; }
108
134
  if (arg === '--dry-run') { dryRunFlag = true; continue; }
109
135
  if (arg === '--sheet') { sheetName = args[++i]; continue; }
136
+ if (arg === '--project') { projectFlag = args[++i]; continue; }
137
+ if (arg === '--account') { accountFlag = args[++i]; continue; }
138
+ if (arg === '--member') { memberFlags.push(args[++i]); continue; }
139
+ if (arg === '--everyone') { everyoneFlag = true; continue; }
140
+ if (arg === '--only-you') { onlyYouFlag = true; continue; }
141
+ if (arg === '--shared-with-me') { sharedWithMeFlag = true; continue; }
142
+ if (arg === '--note') { noteText = args[++i]; continue; }
143
+ if (arg === '--output' || arg === '-o') { outputPath = args[++i]; continue; }
144
+ if (arg === '--revision') { revisionFlag = args[++i]; continue; }
145
+ if (arg === '--document') {
146
+ documentFlag = args[++i];
147
+ documentFlags.push(documentFlag);
148
+ continue;
149
+ }
150
+ if (arg === '--base-revision') { baseRevisionFlag = args[++i]; continue; }
151
+ if (arg === '--limit') { limitFlag = Number(args[++i]); continue; }
152
+ if (arg === '--tag') { tagFilters.push(args[++i]); continue; }
153
+ if (arg === '--no-open') { noOpenFlag = true; continue; }
154
+ if (arg === '--no-bind') { noBindFlag = true; continue; }
155
+ if (arg === '--force') { forceFlag = true; continue; }
110
156
 
111
157
  if (!subcommand && SUBCOMMANDS.has(arg)) {
112
158
  subcommand = arg;
@@ -127,11 +173,24 @@ function parseArgs(argv) {
127
173
  // Strip one layer of surrounding quotes if a shell preserved them.
128
174
  const text = ann[3].replace(/^"([\s\S]*)"$/, '$1').replace(/^'([\s\S]*)'$/, '$1');
129
175
  if (start >= 1 && end >= start && text.trim()) {
130
- annotations.push({ line: start, endLine: end, text });
176
+ // `file` binds the annotation to the most-recently-named code file
177
+ // (null if none yet — resolved to the only/first file downstream).
178
+ annotations.push({ line: start, endLine: end, text, file: currentFile });
131
179
  }
132
180
  continue;
133
181
  }
134
182
 
183
+ // A source-code positional (in the default flow, not under a subcommand):
184
+ // collect it as a walkthrough file and move the annotation cursor onto it.
185
+ // Subcommand sub-args (e.g. `slides icons`) are never code files, so they
186
+ // fall through to the file/extra slots below as before.
187
+ if (!subcommand && codeLangs.isCodeFile(arg)) {
188
+ files.push(arg);
189
+ currentFile = arg;
190
+ if (!file) file = arg;
191
+ continue;
192
+ }
193
+
135
194
  if (!file) { file = arg; continue; }
136
195
  // Second positional is captured as `extra` so `sdoc slides icons heart`
137
196
  // gets {subcommand: 'slides', file: 'icons', extra: 'heart'}.
@@ -144,7 +203,10 @@ function parseArgs(argv) {
144
203
  messageText, connectTimeoutS, idleTimeoutS, reconnectGraceMs,
145
204
  keepOpenFlag, logFile,
146
205
  tagsFlag, helpFlag, yesFlag, dryRunFlag, sheetName,
147
- addTags, annotations,
206
+ projectFlag, accountFlag, outputPath, revisionFlag, documentFlag, baseRevisionFlag,
207
+ limitFlag, noOpenFlag, noBindFlag, forceFlag, tagFilters,
208
+ everyoneFlag, onlyYouFlag, sharedWithMeFlag, noteText, memberFlags, documentFlags,
209
+ addTags, annotations, files,
148
210
  };
149
211
  }
150
212
 
@@ -183,13 +245,45 @@ async function readContent(file) {
183
245
  return null; // no content — just open studio
184
246
  }
185
247
 
186
- function openBrowser(url) {
248
+ // Read N source files into one code-walkthrough body: each unique file wrapped
249
+ // in a ```<lang> <basename> fence, joined in command order. Returns the body
250
+ // plus the de-duplicated basename list (the tab order). Tabs are keyed by
251
+ // basename — a file named twice on the command line is one tab; two DIFFERENT
252
+ // files sharing a basename is an error rather than a silent merge (and keeps
253
+ // the shared front matter to safe basenames, matching the single-file `file:`).
254
+ function readCodewalkContent(files) {
255
+ const parts = [];
256
+ const tabs = [];
257
+ const byBase = Object.create(null);
258
+ for (const f of files) {
259
+ const resolved = path.resolve(f);
260
+ const base = path.basename(f);
261
+ if (byBase[base]) {
262
+ if (byBase[base] !== resolved) {
263
+ console.error(`sdoc: a code walkthrough needs distinct file names — two files named "${base}"`);
264
+ process.exit(1);
265
+ }
266
+ continue; // same file referenced again → one tab
267
+ }
268
+ if (!fs.existsSync(resolved)) {
269
+ console.error(`sdoc: file not found: ${f}`);
270
+ process.exit(1);
271
+ }
272
+ byBase[base] = resolved;
273
+ tabs.push(base);
274
+ const raw = fs.readFileSync(resolved, 'utf-8');
275
+ parts.push(codeLangs.wrapCodeFile(raw, f, base));
276
+ }
277
+ return { body: parts.join('\n'), files: tabs };
278
+ }
279
+
280
+ function openBrowser(url, fallback) {
187
281
  try {
188
282
  if (process.platform === 'darwin') execFileSync('open', [url]);
189
283
  else if (process.platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
190
284
  else execFileSync('xdg-open', [url]);
191
285
  } catch {
192
- console.log(`Open in browser: ${url}`);
286
+ (fallback || console.log)(`Open in browser: ${url}`);
193
287
  }
194
288
  }
195
289
 
@@ -197,5 +291,6 @@ module.exports = {
197
291
  SUBCOMMANDS,
198
292
  parseArgs,
199
293
  readContent,
294
+ readCodewalkContent,
200
295
  openBrowser,
201
296
  };
@@ -30,16 +30,8 @@ function libraryDisable() {
30
30
  function libraryStatus() {
31
31
  const s = store.loadState();
32
32
  const idx = store.loadIndex();
33
- const last = s.lastScanAt ? new Date(s.lastScanAt).toISOString() : 'never';
34
33
  console.log(`library: ${s.enabled === false ? 'disabled' : 'enabled'}`);
35
34
  console.log(`entries: ${idx.entries.length}`);
36
- console.log(`last scan: ${last}`);
37
- }
38
-
39
- function libraryRebuild() {
40
- console.log('library: rebuilding...');
41
- const result = libIndex.rebuild();
42
- console.log(`library: scanned ${result.scanned}, added ${result.added}, updated ${result.updated}`);
43
35
  }
44
36
 
45
37
  // Walk up from a directory looking for `.git/`. Falls back to the start
@@ -114,7 +106,7 @@ function libraryLs(opts) {
114
106
  const tags = libIndex.tagsUnderPrefix(scope);
115
107
  if (!tags.length) {
116
108
  console.log(`no tagged markdown files indexed under ${scope} yet`);
117
- console.log(`(tip: run \`sdoc library rebuild\` if you expected results, or open a file with \`sdoc <file> +tag\` to start tagging)`);
109
+ console.log(`(tip: open a file with \`sdoc <file> +tag\` to add and tag it)`);
118
110
  return;
119
111
  }
120
112
  console.log(`most frequent tags for tagged markdown files under ${scope} (tag - count):`);
@@ -132,7 +124,7 @@ function libraryLs(opts) {
132
124
  const entries = entriesUnderScope(scope);
133
125
  if (!entries.length) {
134
126
  console.log(`library has no markdown indexed under ${scope} yet`);
135
- console.log(`(tip: run \`sdoc library rebuild\` to scan, or open a file with \`sdoc <file>\` to index it)`);
127
+ console.log(`(tip: open a file with \`sdoc <file>\` to add it)`);
136
128
  return;
137
129
  }
138
130
 
@@ -209,8 +201,8 @@ async function libraryOpen() {
209
201
  const { agentUrl } = await libServer.createServer();
210
202
  const pageUrl = `${siteUrl}/library?agent=${encodeURIComponent(agentUrl)}`;
211
203
  console.log(`library: ${pageUrl}`);
212
- console.log(`library: ${idx.entries.length} entries indexed` + (state.enabled === false ? ' (scanning disabled)' : ''));
213
- if (!idx.entries.length) console.log('library: click "rescan" in the UI to walk your home for markdown.');
204
+ console.log(`library: ${idx.entries.length} entries indexed` + (state.enabled === false ? ' (indexing disabled)' : ''));
205
+ if (!idx.entries.length) console.log('library: open a file with `sdoc <file>` to add it.');
214
206
  ensureAutostart();
215
207
  console.log(`library: agent at ${agentUrl} (ctrl-c to stop)`);
216
208
  openBrowser(pageUrl);
@@ -263,7 +255,6 @@ async function libraryCommand(opts) {
263
255
  case 'enable': libraryEnable(); break;
264
256
  case 'disable': libraryDisable(); break;
265
257
  case 'status': libraryStatus(); break;
266
- case 'rebuild': libraryRebuild(); break;
267
258
  case 'autostart': {
268
259
  const action = (opts.extra || '').toLowerCase();
269
260
  if (action === 'enable') autostartEnable();
@@ -278,7 +269,7 @@ async function libraryCommand(opts) {
278
269
  }
279
270
  default:
280
271
  console.error(`sdoc library: unknown subcommand "${sub}"`);
281
- console.error('usage: sdoc library [ls|enable|disable|status|rebuild|autostart|help]');
272
+ console.error('usage: sdoc library [ls|enable|disable|status|autostart|help]');
282
273
  process.exit(1);
283
274
  }
284
275
  }
@@ -300,7 +291,7 @@ function tapOpen(opts) {
300
291
 
301
292
  module.exports = {
302
293
  libraryCommand,
303
- libraryEnable, libraryDisable, libraryStatus, libraryRebuild, libraryOpen,
294
+ libraryEnable, libraryDisable, libraryStatus, libraryOpen,
304
295
  libraryLs, libraryHelp,
305
296
  resolveProjectRoot, resolveLsScope, entriesUnderScope,
306
297
  tapOpen,
@@ -15,18 +15,13 @@ const paths = require('./library-paths');
15
15
  const DEFAULT_MAX_SIZE = 1 * 1024 * 1024;
16
16
 
17
17
  const DIRNAME_BLOCKLIST = new Set([
18
- 'node_modules',
19
18
  '.git',
20
19
  '.svn',
21
20
  '.hg',
22
- 'dist',
23
- 'build',
24
- 'vendor',
25
21
  '.venv',
26
22
  '.next',
27
23
  '.cache',
28
24
  '__pycache__',
29
- 'target',
30
25
  '.gradle',
31
26
  '.idea',
32
27
  '.vscode',
@@ -46,6 +41,19 @@ const DIRNAME_BLOCKLIST = new Set([
46
41
  '.password-store',
47
42
  ]);
48
43
 
44
+ // Directories skipped during scanning traversal for speed, but NOT blocked
45
+ // from path-gating when opening an explicitly indexed file.
46
+ const SCAN_SKIP_DIRNAMES = new Set([
47
+ 'assets',
48
+ 'graphify',
49
+ 'node_modules',
50
+ 'vendor',
51
+ 'target',
52
+ 'dist',
53
+ 'build',
54
+ 'venv',
55
+ ]);
56
+
49
57
  // File basenames that should never make it into the library, regardless
50
58
  // of which directory they live in or what extension they carry. Most of
51
59
  // these don't have a markdown extension and so the existing extension
@@ -129,8 +137,10 @@ function deniedByPattern(absPath) {
129
137
  }
130
138
 
131
139
  function shouldSkipDir(absDir, base, skipSet, exemptRoots) {
132
- if (base.startsWith('.') && base !== '.' && base !== '..') return true;
140
+ // Exempt .sdocs so AI coding agent markdown artifacts are indexed
141
+ if (base.startsWith('.') && base !== '.' && base !== '..' && base !== '.sdocs') return true;
133
142
  if (DIRNAME_BLOCKLIST.has(base)) return true;
143
+ if (SCAN_SKIP_DIRNAMES.has(base)) return true;
134
144
  if (skipSet.has(absDir)) return true;
135
145
  // Skip ephemeral paths during descent unless we're inside a root that
136
146
  // the caller explicitly named (in which case they want it scanned).
@@ -35,9 +35,8 @@ try {
35
35
  // 2. The deny-pattern list (SSH keys, .env, credentials.{json,...}
36
36
  // and anything under .ssh/.aws/.gnupg/...).
37
37
  // 3. Library-membership: the real path must appear in the index.
38
- // The index is what the user has explicitly opened with sdoc or
39
- // placed under a scanned root; arbitrary paths outside that set
40
- // are refused.
38
+ // The index contains files the user explicitly opened with sdoc;
39
+ // arbitrary paths outside that set are refused.
41
40
  //
42
41
  // Returns { ok: true, realPath } on pass, { ok: false, reason, status }
43
42
  // on refusal. Caller picks the HTTP status from `status`.
@@ -295,12 +294,6 @@ function createServer({ port } = {}) {
295
294
  return;
296
295
  }
297
296
 
298
- if (req.method === 'POST' && route === '/api/library/rescan') {
299
- const result = libIndex.scanAndIndex();
300
- sendJson(res, 200, result);
301
- return;
302
- }
303
-
304
297
  // Serve the current contents of a local file. The editor page
305
298
  // uses this to refresh content after the URL-hash snapshot goes
306
299
  // stale (e.g. after the user edited tags then reloaded). Gated
@@ -319,9 +312,8 @@ function createServer({ port } = {}) {
319
312
  }
320
313
 
321
314
  // Re-index a single file. Called by the editor page after a Bridge
322
- // save so the library catches up immediately (instead of waiting
323
- // for the next manual scan). Pure read-then-index; never writes
324
- // the file the path points at.
315
+ // save so the library catches up immediately. Pure read-then-index;
316
+ // never writes the file the path points at.
325
317
  if (req.method === 'POST' && route === '/api/library/reindex') {
326
318
  const body = await readBody(req);
327
319
  const filePath = body && body.path;