contentbase 0.0.2 → 0.0.4

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.
Files changed (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
@@ -0,0 +1,285 @@
1
+ import { z } from 'zod'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+
7
+ const argsSchema = z.object({
8
+ mode: z.enum(['hybrid', 'keyword', 'vector']).default('hybrid'),
9
+ model: z.string().optional(),
10
+ where: z.string().optional(),
11
+ n: z.number().default(10),
12
+ json: z.boolean().default(false),
13
+ full: z.boolean().default(false),
14
+ bootstrap: z.boolean().default(false),
15
+ contentFolder: z.string().optional(),
16
+ })
17
+
18
+ function hasSearchIndex(rootPath: string): boolean {
19
+ const dbDir = path.join(rootPath, '.contentbase')
20
+ if (!existsSync(dbDir)) return false
21
+ try {
22
+ const files = readdirSync(dbDir) as string[]
23
+ return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ async function getSemanticSearch(container: any, rootPath: string) {
30
+ const { SemanticSearch } = await import('@soederpop/luca/agi')
31
+ if (!container.features.available.includes('semanticSearch')) {
32
+ SemanticSearch.attach(container)
33
+ }
34
+
35
+ const dbPath = path.join(rootPath, '.contentbase/search.sqlite')
36
+ const ss = container.feature('semanticSearch', { dbPath })
37
+ await ss.initDb()
38
+ return ss
39
+ }
40
+
41
+ async function buildIndex(container: any, collection: any) {
42
+ const ss = await getSemanticSearch(container, collection.rootPath)
43
+ const docs = collectDocumentInputs(collection)
44
+
45
+ const toIndex = docs.filter((doc: any) => ss.needsReindex(doc))
46
+ ss.removeStale(docs.map((d: any) => d.pathId))
47
+
48
+ if (toIndex.length === 0) {
49
+ console.error('Index is up to date.')
50
+ return ss
51
+ }
52
+
53
+ console.error(`Indexing ${toIndex.length} document(s)...`)
54
+ const batchSize = 5
55
+ for (let i = 0; i < toIndex.length; i += batchSize) {
56
+ const batch = toIndex.slice(i, i + batchSize)
57
+ await ss.indexDocuments(batch)
58
+ console.error(` ${Math.min(i + batchSize, toIndex.length)}/${toIndex.length}`)
59
+ }
60
+ console.error('Index ready.')
61
+ return ss
62
+ }
63
+
64
+ function collectDocumentInputs(collection: any) {
65
+ const inputs: any[] = []
66
+ for (const pathId of collection.available) {
67
+ const doc = collection.document(pathId)
68
+ const modelDef = collection.findModelDefinition(pathId)
69
+
70
+ const sections: any[] = []
71
+ const lines = (doc.content as string).split('\n')
72
+ let currentHeading: string | null = null
73
+ let currentContent: string[] = []
74
+
75
+ for (const line of lines) {
76
+ const h2Match = line.match(/^## (.+)/)
77
+ if (h2Match) {
78
+ if (currentHeading) {
79
+ sections.push({
80
+ heading: currentHeading,
81
+ headingPath: currentHeading,
82
+ content: currentContent.join('\n').trim(),
83
+ level: 2,
84
+ })
85
+ }
86
+ currentHeading = h2Match[1].trim()
87
+ currentContent = []
88
+ } else if (currentHeading) {
89
+ currentContent.push(line)
90
+ }
91
+ }
92
+ if (currentHeading) {
93
+ sections.push({
94
+ heading: currentHeading,
95
+ headingPath: currentHeading,
96
+ content: currentContent.join('\n').trim(),
97
+ level: 2,
98
+ })
99
+ }
100
+
101
+ inputs.push({
102
+ pathId,
103
+ model: modelDef?.name ?? undefined,
104
+ title: doc.title,
105
+ slug: doc.slug,
106
+ meta: doc.meta,
107
+ content: doc.content,
108
+ sections: sections.length > 0 ? sections : undefined,
109
+ })
110
+ }
111
+ return inputs
112
+ }
113
+
114
+ async function handler(options: z.infer<typeof argsSchema>, { container }: { container: any }) {
115
+ const ui = container.feature('ui')
116
+ const query = container.argv._[1] as string | undefined
117
+
118
+ if (!query) {
119
+ console.error('Usage: cnotes search <query> [options]')
120
+ console.error(' --mode hybrid|keyword|vector (default: hybrid)')
121
+ console.error(' --model Filter by model name')
122
+ console.error(' --where Metadata filter, e.g. "status=approved"')
123
+ console.error(' -n Max results (default: 10)')
124
+ console.error(' --json Output as JSON')
125
+ console.error(' --full Include full document content')
126
+ console.error(' --bootstrap Build index if missing, then search')
127
+ process.exit(1)
128
+ }
129
+
130
+ const collection = await loadCollection({
131
+ contentFolder: options.contentFolder,
132
+ })
133
+
134
+ if (!hasSearchIndex(collection.rootPath) && !options.bootstrap) {
135
+ console.error('No search index found. Run: cnotes embed')
136
+ process.exit(1)
137
+ }
138
+
139
+ let ss: any
140
+ if (options.bootstrap && !hasSearchIndex(collection.rootPath)) {
141
+ ss = await buildIndex(container, collection)
142
+ } else {
143
+ ss = await getSemanticSearch(container, collection.rootPath)
144
+ }
145
+
146
+ // Parse where clause: "key=value,key2=value2"
147
+ let where: Record<string, any> | undefined
148
+ if (options.where) {
149
+ where = {}
150
+ for (const pair of options.where.split(',')) {
151
+ const [key, ...rest] = pair.split('=')
152
+ if (key && rest.length > 0) {
153
+ where[key.trim()] = rest.join('=').trim()
154
+ }
155
+ }
156
+ }
157
+
158
+ const searchOptions = {
159
+ limit: options.n,
160
+ model: options.model,
161
+ where,
162
+ }
163
+
164
+ let results: any[]
165
+ switch (options.mode) {
166
+ case 'keyword':
167
+ results = await ss.search(query, searchOptions)
168
+ break
169
+ case 'vector':
170
+ results = await ss.vectorSearch(query, searchOptions)
171
+ break
172
+ case 'hybrid':
173
+ default:
174
+ results = await ss.hybridSearch(query, searchOptions)
175
+ break
176
+ }
177
+
178
+ if (options.json) {
179
+ if (options.full) {
180
+ for (const r of results) {
181
+ try {
182
+ const doc = collection.document(r.pathId)
183
+ r.content = doc.content
184
+ } catch {}
185
+ }
186
+ }
187
+ console.log(JSON.stringify(results, null, 2))
188
+ return
189
+ }
190
+
191
+ if (results.length === 0) {
192
+ console.log('No results found.')
193
+ return
194
+ }
195
+
196
+ const header = `| # | Score | Document | Match |`
197
+ const separator = `|---|-------|----------|-------|`
198
+ const rows = results.map((r: any, i: number) => {
199
+ const score = `${Math.round(r.score * 100)}%`
200
+ const doc = r.title || r.pathId
201
+ let match = ''
202
+ if (r.snippet) {
203
+ const snippet = r.snippet.replace(/\n/g, ' ').replace(/\|/g, '\\|').substring(0, 80)
204
+ const section = r.matchedSection || ''
205
+ match = section ? `**${section}**: ${snippet}` : snippet
206
+ }
207
+ return `| ${i + 1} | ${score} | ${doc} | ${match} |`
208
+ })
209
+
210
+ const table = [header, separator, ...rows].join('\n')
211
+ ui.markdown(table)
212
+
213
+ if (options.full) {
214
+ for (const r of results) {
215
+ try {
216
+ const doc = collection.document(r.pathId)
217
+ console.log(ui.colors.cyan(`\n--- ${r.pathId} ---\n`))
218
+ console.log(doc.content)
219
+ } catch {}
220
+ }
221
+ }
222
+ }
223
+
224
+ commands.register('search', {
225
+ description: 'Semantic search across collection documents',
226
+ help: `# cnotes search
227
+
228
+ Search documents in the collection using keyword, semantic, or hybrid search modes. Requires a search index — run \`cnotes embed\` first.
229
+
230
+ ## Usage
231
+
232
+ \`\`\`
233
+ cnotes search <query> [options]
234
+ \`\`\`
235
+
236
+ ## Arguments
237
+
238
+ | Argument | Description |
239
+ |----------|-------------|
240
+ | \`query\` | Search query text |
241
+
242
+ ## Options
243
+
244
+ | Option | Default | Description |
245
+ |--------|---------|-------------|
246
+ | \`--mode\` | \`hybrid\` | Search mode: \`hybrid\`, \`keyword\`, or \`vector\` |
247
+ | \`--model\` | | Filter results to a specific model |
248
+ | \`--where\` | | Metadata filter (e.g. \`"status=approved"\`) |
249
+ | \`-n\` | \`10\` | Maximum results to return |
250
+ | \`--json\` | \`false\` | Output as JSON |
251
+ | \`--full\` | \`false\` | Include full document content |
252
+ | \`--bootstrap\` | \`false\` | Build index if missing, then search |
253
+ | \`--contentFolder\` | | Path to content folder |
254
+
255
+ ## Examples
256
+
257
+ \`\`\`bash
258
+ # Hybrid search (default)
259
+ cnotes search "authentication patterns"
260
+
261
+ # Keyword-only search (BM25)
262
+ cnotes search "deploymentConfig" --mode keyword
263
+
264
+ # Semantic vector search
265
+ cnotes search "how do deployments work" --mode vector
266
+
267
+ # Filter by model
268
+ cnotes search "auth" --model Epic
269
+
270
+ # Filter by metadata
271
+ cnotes search "approved plans" --where "status=approved"
272
+
273
+ # Limit results
274
+ cnotes search "auth" -n 20
275
+
276
+ # JSON output
277
+ cnotes search "auth" --json
278
+
279
+ # Build index if missing, then search
280
+ cnotes search "auth" --bootstrap
281
+ \`\`\`
282
+ `,
283
+ argsSchema,
284
+ handler,
285
+ })
@@ -0,0 +1,348 @@
1
+ import { z } from 'zod'
2
+ import path from 'node:path'
3
+ import fs from 'node:fs'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+ import { buildSchemaJSON } from '../../api/helpers.js'
7
+
8
+ const argsSchema = z.object({
9
+ port: z.number().default(8000),
10
+ contentFolder: z.string().optional(),
11
+ modulePath: z.string().optional(),
12
+ endpointsDir: z.string().optional(),
13
+ staticDir: z.string().optional(),
14
+ cors: z.boolean().default(true),
15
+ force: z.boolean().default(false),
16
+ anyPort: z.boolean().default(false),
17
+ open: z.boolean().default(false),
18
+ readOnly: z.boolean().default(false),
19
+ refreshInterval: z.number().optional(),
20
+ watch: z.boolean().default(true),
21
+ search: z.boolean().default(false),
22
+ })
23
+
24
+ async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
25
+ const container = context.container
26
+ const { networking, proc } = container
27
+
28
+ // Resolve content folder from positional arg or option
29
+ const positionalFolder = container.argv._[1] as string | undefined
30
+ const contentFolder = positionalFolder || options.contentFolder || undefined
31
+ const modulePath = options.modulePath || undefined
32
+
33
+ const collection = await loadCollection({ contentFolder, modulePath })
34
+ const modelDefs = collection.modelDefinitions as any[]
35
+
36
+ // Attach collection to container so endpoints can access it
37
+ container._contentbaseCollection = collection
38
+ container._contentbaseReadOnly = options.readOnly
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Port handling
42
+ // ---------------------------------------------------------------------------
43
+ let port = options.port
44
+
45
+ if (options.anyPort) {
46
+ port = await networking.findOpenPort(port + 1)
47
+ }
48
+
49
+ const isPortAvailable = await networking.isPortOpen(port)
50
+ if (!isPortAvailable) {
51
+ if (!options.force) {
52
+ console.error(`Port ${port} is already in use.`)
53
+ console.error(`Use --force to kill the process on this port, or --any-port to find another port.`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const pids = proc.findPidsByPort(port)
58
+ if (!pids.length) {
59
+ console.error(`Port ${port} is in use, but no PID could be discovered for termination.`)
60
+ process.exit(1)
61
+ }
62
+
63
+ for (const pid of pids) {
64
+ proc.kill(pid)
65
+ }
66
+
67
+ let portFreed = false
68
+ for (let i = 0; i < 10; i++) {
69
+ if (await networking.isPortOpen(port)) {
70
+ portFreed = true
71
+ break
72
+ }
73
+ await new Promise((resolve) => setTimeout(resolve, 100))
74
+ }
75
+
76
+ if (!portFreed) {
77
+ console.error(`Failed to free port ${port} after terminating process(es): ${pids.join(', ')}`)
78
+ process.exit(1)
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Static directory
84
+ // ---------------------------------------------------------------------------
85
+ const cwd = process.cwd()
86
+ const staticDir = options.staticDir ? path.resolve(cwd, options.staticDir) : path.resolve(cwd, 'public')
87
+ let resolvedStaticDir: string | undefined
88
+ if (fs.existsSync(staticDir)) {
89
+ resolvedStaticDir = staticDir
90
+ } else if (fs.existsSync(path.resolve(cwd, 'index.html'))) {
91
+ resolvedStaticDir = cwd
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // User endpoints directory
96
+ // ---------------------------------------------------------------------------
97
+ let userEndpointsDir: string | null = null
98
+ if (options.endpointsDir) {
99
+ const dir = path.resolve(cwd, options.endpointsDir)
100
+ if (fs.existsSync(dir)) userEndpointsDir = dir
101
+ } else {
102
+ for (const candidate of ['endpoints', 'src/endpoints']) {
103
+ const dir = path.resolve(cwd, candidate)
104
+ if (fs.existsSync(dir)) {
105
+ userEndpointsDir = dir
106
+ break
107
+ }
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Express server
113
+ // ---------------------------------------------------------------------------
114
+ const expressServer = container.server('express', {
115
+ port,
116
+ cors: options.cors,
117
+ static: resolvedStaticDir,
118
+ historyFallback: false,
119
+ }) as any
120
+
121
+ // Load built-in contentbase endpoints
122
+ const builtinEndpointsDir = path.resolve(import.meta.dir, '../../api/endpoints')
123
+ await expressServer.useEndpoints(builtinEndpointsDir)
124
+
125
+ // Load user endpoints if present
126
+ if (userEndpointsDir) {
127
+ await expressServer.useEndpoints(userEndpointsDir)
128
+ }
129
+
130
+ // Redirect root to /docs/ table of contents when no static index.html exists
131
+ if (!resolvedStaticDir || !fs.existsSync(path.join(resolvedStaticDir, 'index.html'))) {
132
+ expressServer.app.get('/', (_req: any, res: any) => {
133
+ res.redirect('/docs/')
134
+ })
135
+ }
136
+
137
+ // OpenAPI spec
138
+ expressServer.serveOpenAPISpec({
139
+ title: 'Contentbase API',
140
+ version: '1.0.0',
141
+ description: `REST API for ${collection.rootPath}`,
142
+ })
143
+
144
+ await expressServer.start({ port })
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Startup summary
148
+ // ---------------------------------------------------------------------------
149
+ const schema = buildSchemaJSON(collection)
150
+ const modelNames = Object.keys(schema)
151
+
152
+ console.log(`\nContentbase server listening on http://localhost:${port}`)
153
+ console.log(`Collection: ${collection.rootPath}`)
154
+ console.log(`Models: ${modelNames.join(', ') || '(none)'}`)
155
+ console.log(`Documents: ${collection.available.length}`)
156
+ if (options.readOnly) {
157
+ console.log(`Mode: read-only (write endpoints disabled)`)
158
+ }
159
+ console.log(`OpenAPI spec: http://localhost:${port}/openapi.json`)
160
+
161
+ if (resolvedStaticDir) {
162
+ console.log(`Static files: ${resolvedStaticDir}`)
163
+ }
164
+
165
+ if (expressServer._mountedEndpoints?.length) {
166
+ console.log(`\nEndpoints:`)
167
+ for (const ep of expressServer._mountedEndpoints) {
168
+ console.log(` ${ep.methods.map((m: string) => m.toUpperCase()).join(', ').padEnd(20)} ${ep.path}`)
169
+ }
170
+ }
171
+
172
+ console.log()
173
+
174
+ if (options.open) {
175
+ try {
176
+ const opener = container.feature('opener')
177
+ await opener.open(`http://localhost:${port}`)
178
+ } catch (error) {
179
+ console.warn(`Could not open browser automatically: ${(error as Error).message}`)
180
+ }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Search index auto-update
185
+ // ---------------------------------------------------------------------------
186
+ if (options.search) {
187
+ const { existsSync, readdirSync } = await import('node:fs')
188
+ const dbDir = path.join(collection.rootPath, '.contentbase')
189
+ const hasIndex = existsSync(dbDir) && (() => {
190
+ try {
191
+ return (readdirSync(dbDir) as string[]).some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
192
+ } catch { return false }
193
+ })()
194
+
195
+ if (hasIndex) {
196
+ try {
197
+ const { SemanticSearch } = await import('@soederpop/luca/agi')
198
+ if (!container.features.available.includes('semanticSearch')) {
199
+ SemanticSearch.attach(container)
200
+ }
201
+ const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
202
+ const ss = container.feature('semanticSearch', { dbPath }) as any
203
+ await ss.initDb()
204
+
205
+ // Collect document inputs
206
+ const docs: any[] = []
207
+ for (const pathId of collection.available) {
208
+ const doc = collection.document(pathId) as any
209
+ docs.push({
210
+ pathId,
211
+ model: collection.findModelDefinition(pathId)?.name ?? undefined,
212
+ title: doc.title,
213
+ meta: doc.meta,
214
+ content: doc.content,
215
+ })
216
+ }
217
+
218
+ const stale = docs.filter((d: any) => ss.needsReindex(d))
219
+ ss.removeStale(docs.map((d: any) => d.pathId))
220
+
221
+ if (stale.length > 0) {
222
+ console.log(`[search] Updating ${stale.length} stale document(s)...`)
223
+ await ss.indexDocuments(stale)
224
+ console.log(`[search] Index updated.`)
225
+ } else {
226
+ console.log(`[search] Index is up to date.`)
227
+ }
228
+ } catch (error) {
229
+ console.warn(`[search] Auto-index failed: ${(error as Error).message}`)
230
+ }
231
+ } else {
232
+ console.log(`[search] No search index found. Run: cnotes embed`)
233
+ }
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // File watching
238
+ // ---------------------------------------------------------------------------
239
+ if (options.watch !== false) {
240
+ const fileManager = container.feature('fileManager')
241
+ await fileManager.start({ rootPath: collection.rootPath })
242
+ await fileManager.watch()
243
+
244
+ const { debounce } = container.utils.lodash
245
+ const refreshCollection = debounce(async () => {
246
+ try {
247
+ const before = collection.available.length
248
+ await collection.load({ refresh: true })
249
+ const after = collection.available.length
250
+ if (after !== before) {
251
+ console.log(`[watch] Collection refreshed: ${before} → ${after} documents`)
252
+ }
253
+ } catch (error) {
254
+ console.warn(`[watch] Refresh failed: ${(error as Error).message}`)
255
+ }
256
+ }, 500)
257
+
258
+ fileManager.on('file:change', (event: { type: string; path: string }) => {
259
+ if (/\.(md|mdx)$/i.test(event.path)) {
260
+ refreshCollection()
261
+ }
262
+ })
263
+ console.log(`Watching for file changes in ${collection.rootPath}`)
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Collection refresh interval (safety-net fallback)
268
+ // ---------------------------------------------------------------------------
269
+ const defaultInterval = container.isProduction ? 10 * 60 : 60 // seconds
270
+ const intervalSeconds = options.refreshInterval ?? defaultInterval
271
+
272
+ setInterval(async () => {
273
+ try {
274
+ const before = collection.available.length
275
+ await collection.load({ refresh: true })
276
+ const after = collection.available.length
277
+ if (after !== before) {
278
+ console.log(`[refresh] Collection rescanned: ${before} → ${after} documents`)
279
+ }
280
+ } catch (error) {
281
+ console.warn(`[refresh] Failed to rescan collection: ${(error as Error).message}`)
282
+ }
283
+ }, intervalSeconds * 1000)
284
+
285
+ console.log(`Refresh interval: every ${intervalSeconds}s`)
286
+ }
287
+
288
+ commands.register('serve', {
289
+ description: 'Start an HTTP server for the collection with REST API and document serving',
290
+ help: `# cnotes serve
291
+
292
+ Start an HTTP server with REST API endpoints for querying, creating, updating, and deleting documents. Serves static files and auto-generates an OpenAPI spec.
293
+
294
+ ## Usage
295
+
296
+ \`\`\`
297
+ cnotes serve [contentFolder] [options]
298
+ \`\`\`
299
+
300
+ ## Arguments
301
+
302
+ | Argument | Description |
303
+ |----------|-------------|
304
+ | \`contentFolder\` | Path to content folder (positional or via \`--contentFolder\`) |
305
+
306
+ ## Options
307
+
308
+ | Option | Default | Description |
309
+ |--------|---------|-------------|
310
+ | \`--port\` | \`8000\` | Port to listen on |
311
+ | \`--force\` | \`false\` | Kill existing process on the port |
312
+ | \`--anyPort\` | \`false\` | Find next available port if taken |
313
+ | \`--open\` | \`false\` | Open browser after starting |
314
+ | \`--readOnly\` | \`false\` | Disable write endpoints |
315
+ | \`--cors\` | \`true\` | Enable CORS |
316
+ | \`--staticDir\` | \`public/\` | Directory for static file serving |
317
+ | \`--endpointsDir\` | auto | Directory for user endpoint modules |
318
+ | \`--modulePath\` | | Path to collection entry module |
319
+ | \`--refreshInterval\` | \`60\` | Seconds between collection rescans (fallback) |
320
+ | \`--disable-watch\` | \`false\` | Disable file watching for automatic collection refresh |
321
+ | \`--contentFolder\` | | Path to content folder |
322
+
323
+ ## User Endpoints
324
+
325
+ Place endpoint modules in \`endpoints/\` or \`src/endpoints/\` and they'll be auto-mounted. Use \`--endpointsDir\` to override.
326
+
327
+ ## Examples
328
+
329
+ \`\`\`bash
330
+ # Start on default port
331
+ cnotes serve
332
+
333
+ # Serve a specific folder on a custom port
334
+ cnotes serve ./docs --port 3000
335
+
336
+ # Force kill existing server and open browser
337
+ cnotes serve --port 8000 --force --open
338
+
339
+ # Read-only mode for production
340
+ cnotes serve --readOnly --port 8080
341
+
342
+ # Find any available port
343
+ cnotes serve --anyPort
344
+ \`\`\`
345
+ `,
346
+ argsSchema,
347
+ handler,
348
+ })
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+
7
+ const argsSchema = z.object({
8
+ contentFolder: z.string().optional(),
9
+ })
10
+
11
+ async function handler(options: z.infer<typeof argsSchema>) {
12
+ const collection = await loadCollection({
13
+ contentFolder: options.contentFolder,
14
+ })
15
+
16
+ await collection.saveModelSummary()
17
+ console.log(`README.md written to ${collection.rootPath}/README.md`)
18
+
19
+ const toc = collection.tableOfContents({ title: 'Table of Contents' })
20
+ const tocPath = join(collection.rootPath, 'TABLE-OF-CONTENTS.md')
21
+ await writeFile(tocPath, toc, 'utf-8')
22
+ console.log(`TABLE-OF-CONTENTS.md written to ${tocPath}`)
23
+ }
24
+
25
+ commands.register('summary', {
26
+ description: 'Generate README.md and TABLE-OF-CONTENTS.md for the collection',
27
+ help: `# cnotes summary
28
+
29
+ Generate documentation files for the collection. Writes \`README.md\` (model definitions summary) and \`TABLE-OF-CONTENTS.md\` (document listing) to the content root.
30
+
31
+ ## Usage
32
+
33
+ \`\`\`
34
+ cnotes summary [options]
35
+ \`\`\`
36
+
37
+ ## Options
38
+
39
+ | Option | Description |
40
+ |--------|-------------|
41
+ | \`--contentFolder\` | Path to content folder |
42
+
43
+ ## Generated Files
44
+
45
+ - **README.md** — Overview of all registered models with fields, sections, and relationships
46
+ - **TABLE-OF-CONTENTS.md** — Listing of all documents organized by model
47
+
48
+ ## Examples
49
+
50
+ \`\`\`bash
51
+ # Generate summary files
52
+ cnotes summary
53
+
54
+ # Generate for a specific content folder
55
+ cnotes summary --contentFolder ./docs
56
+ \`\`\`
57
+ `,
58
+ argsSchema,
59
+ handler,
60
+ })