contentbase 0.0.1 → 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.
- package/.mcp.json +9 -0
- package/CLI.md +593 -0
- package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +406 -13
- package/bun.lock +45 -6
- package/dist/cnotes +0 -0
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +14 -4
- package/public/web-demo/index.html +813 -0
- package/scripts/examples/01-collection-setup.ts +46 -0
- package/scripts/examples/02-querying.ts +67 -0
- package/scripts/examples/03-sections.ts +36 -0
- package/scripts/examples/04-relationships.ts +54 -0
- package/scripts/examples/05-document-api.ts +54 -0
- package/scripts/examples/06-extract-sections.ts +55 -0
- package/scripts/examples/07-validation.ts +46 -0
- package/scripts/examples/08-serialization.ts +51 -0
- package/scripts/examples/lib/format.ts +87 -0
- package/scripts/examples/lib/setup.ts +21 -0
- package/scripts/examples/run-all.ts +43 -0
- package/showcases/node_modules/.cache/.repl_history +3 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
- package/src/__tests__/semantic-search.integration.test.ts +284 -0
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +455 -6
- package/src/define-model.ts +104 -7
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +222 -0
- package/src/index.ts +20 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +24 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/test/collection.test.ts +118 -2
- package/test/extract-sections.test.ts +356 -0
- package/test/fixtures/sdlc/MODELS.md +106 -0
- package/test/fixtures/sdlc/SKILL.md +404 -0
- package/test/fixtures/sdlc/index.ts +9 -0
- package/test/fixtures/sdlc/models.ts +8 -6
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
- package/test/fixtures/sdlc/templates/epic.md +23 -0
- package/test/fixtures/sdlc/templates/story.md +19 -0
- package/test/pattern.test.ts +191 -0
- package/test/query-dsl.test.ts +431 -0
- package/test/query.test.ts +29 -0
- package/test/relationships.test.ts +130 -0
- package/test/section.test.ts +65 -4
- package/test/table-of-contents.test.ts +135 -0
|
@@ -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
|
+
})
|