deepspace 0.7.0 → 0.8.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +10306 -5256
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +1 -2
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -1,807 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Feature Installation Script (CommonJS)
4
- *
5
- * Installs DeepSpace features into an app directory. Ships inside the
6
- * `deepspace` npm package at `node_modules/deepspace/scripts/add-feature.cjs`
7
- * and reads features from a sibling `node_modules/deepspace/features/` dir.
8
- * Invoked by the `deepspace add` CLI in src/cli/commands/add.ts.
9
- */
10
-
11
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS (.cjs) module: require is required
12
- const fs = require('fs')
13
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS (.cjs) module: require is required
14
- const path = require('path')
15
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS (.cjs) module: require is required
16
- const { spawnSync } = require('child_process')
17
-
18
- // ---------------------------------------------------------------------------
19
- // Environment-aware features path resolution
20
- // ---------------------------------------------------------------------------
21
-
22
- function findFeaturesDir() {
23
- const candidate = path.resolve(__dirname, '..', 'features')
24
- if (fs.existsSync(candidate)) return candidate
25
- console.error('Error: Could not find features directory at ' + candidate)
26
- process.exit(1)
27
- }
28
-
29
- const FEATURES_DIR = findFeaturesDir()
30
-
31
- // ---------------------------------------------------------------------------
32
- // Resolve target directory
33
- // ---------------------------------------------------------------------------
34
-
35
- function resolveTargetDir(raw) {
36
- if (!raw) return null
37
- if (path.isAbsolute(raw)) return raw
38
- return path.resolve(raw)
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Category labels & ordering
43
- // ---------------------------------------------------------------------------
44
-
45
- const CATEGORY_LABELS = {
46
- assistant: 'AI Features',
47
- data: 'Data Features',
48
- nav: 'Navigation',
49
- layout: 'Layouts',
50
- display: 'Display',
51
- landing: 'Landing Page Sections',
52
- }
53
-
54
- const CATEGORY_ORDER = ['assistant', 'data', 'nav', 'layout', 'display', 'landing']
55
-
56
- // ---------------------------------------------------------------------------
57
- // Core helpers
58
- // ---------------------------------------------------------------------------
59
-
60
- function loadFeatureConfig(featureId) {
61
- const configPath = path.join(FEATURES_DIR, featureId, 'feature.json')
62
- if (!fs.existsSync(configPath)) return null
63
- try {
64
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
65
- } catch (e) {
66
- console.error(`Error reading ${configPath}:`, e.message)
67
- return null
68
- }
69
- }
70
-
71
- function listFeatures() {
72
- const entries = fs.readdirSync(FEATURES_DIR, { withFileTypes: true })
73
- const configs = []
74
- for (const e of entries) {
75
- if (e.isDirectory() && e.name !== 'scripts') {
76
- const config = loadFeatureConfig(e.name)
77
- // Internal features (e2e/QA fixtures) are installable by explicit name
78
- // but hidden from `--list` and the interactive picker.
79
- if (config && !config.internal) configs.push(config)
80
- }
81
- }
82
- return configs
83
- }
84
-
85
- function groupByCategory(features) {
86
- const groups = new Map()
87
- for (const f of features) {
88
- const category = f.category || 'other'
89
- if (!groups.has(category)) groups.set(category, [])
90
- groups.get(category).push(f)
91
- }
92
- return groups
93
- }
94
-
95
- // First feature id in display order — used for a never-stale help example.
96
- function firstFeatureId(groups) {
97
- for (const category of CATEGORY_ORDER) {
98
- const list = groups.get(category)
99
- if (list && list.length > 0) return list[0].id
100
- }
101
- return null
102
- }
103
-
104
- // Small edit distance for "did you mean" suggestions on a typo'd feature id.
105
- function levenshtein(a, b) {
106
- const m = a.length
107
- const n = b.length
108
- const row = Array.from({ length: n + 1 }, (_, i) => i)
109
- for (let i = 1; i <= m; i++) {
110
- let prev = row[0]
111
- row[0] = i
112
- for (let j = 1; j <= n; j++) {
113
- const tmp = row[j]
114
- row[j] = Math.min(
115
- row[j] + 1,
116
- row[j - 1] + 1,
117
- prev + (a[i - 1] === b[j - 1] ? 0 : 1),
118
- )
119
- prev = tmp
120
- }
121
- }
122
- return row[n]
123
- }
124
-
125
- // Closest visible feature id to a typo, or null when nothing is close enough.
126
- function suggestFeature(featureId) {
127
- let best = null
128
- let bestD = Infinity
129
- for (const f of listFeatures()) {
130
- const d = levenshtein(featureId, f.id)
131
- if (d < bestD) {
132
- bestD = d
133
- best = f.id
134
- }
135
- }
136
- const threshold = Math.max(2, Math.floor(featureId.length / 3))
137
- return best && bestD <= threshold ? best : null
138
- }
139
-
140
- // Print a friendly unknown-feature error (with a suggestion) and exit.
141
- function unknownFeature(featureId) {
142
- console.error(`\nError: Unknown feature: ${featureId}`)
143
- const guess = suggestFeature(featureId)
144
- if (guess) console.error(`Did you mean "${guess}"?`)
145
- console.error('Run `deepspace add --list` to see available features')
146
- process.exit(1)
147
- }
148
-
149
- // Detect the target app's package manager from its lockfile (defaults to npm).
150
- function detectPackageManager(targetDir) {
151
- const has = (f) => fs.existsSync(path.join(targetDir, f))
152
- if (has('pnpm-lock.yaml')) return 'pnpm'
153
- if (has('yarn.lock')) return 'yarn'
154
- if (has('bun.lockb') || has('bun.lock')) return 'bun'
155
- return 'npm'
156
- }
157
-
158
- // The nav features are mutually exclusive (competing navigation). Derive which
159
- // are installed from the catalog itself — each nav feature's first installed
160
- // file is its signature — so this never drifts from what a feature ships.
161
- function installedNavFeatures(targetDir) {
162
- return listFeatures()
163
- .filter((f) => f.category === 'nav' && Array.isArray(f.files) && f.files.length > 0)
164
- .filter((f) =>
165
- fs.existsSync(path.join(targetDir, resolveDestPath(f.files[0].dest, f.route))),
166
- )
167
- .map((f) => f.id)
168
- }
169
-
170
- function warnNavConflicts(config, targetDir) {
171
- if (config.category !== 'nav') return
172
- const present = installedNavFeatures(targetDir)
173
- if (present.length > 1) {
174
- console.log(`\n⚠ Multiple navigation features present (${present.join(', ')}).`)
175
- console.log(
176
- ' sidebar, topbar, and tree are mutually exclusive — keep one and remove the',
177
- )
178
- console.log(' others to avoid competing navigation in your layout.')
179
- }
180
- }
181
-
182
- // Always-printed footer so every install ends with a clear "what now".
183
- function printNextSteps(config, deps) {
184
- console.log('\nNext steps:')
185
- let n = 1
186
- if (deps.added.length > 0 && !deps.installRan) {
187
- console.log(` ${n++}. Install new dependencies: ${deps.pm} install`)
188
- console.log(' (or re-run this command with --install to do it for you)')
189
- }
190
- console.log(` ${n++}. Start the dev server: deepspace dev`)
191
- if (config.route && config.route.path) {
192
- console.log(` ${n++}. Open ${config.route.path} once the dev server is up`)
193
- }
194
- }
195
-
196
- function copyFile(src, dest) {
197
- const destDir = path.dirname(dest)
198
- if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true })
199
- fs.copyFileSync(src, dest)
200
- }
201
-
202
- // Copy + rewrite leading `../` imports. Used when a page file is redirected
203
- // into a deeper route group (e.g. src/pages/(app)/ adds one directory level,
204
- // src/pages/(app)/(protected)/ adds two) — every relative import that
205
- // traverses upward needs `levels` extra `../`. Matches static `from '...'`,
206
- // side-effect `import '...'`, and dynamic/type `import('...')` (e.g.
207
- // `import('../hooks/x').T` in a type position, or `await import('../x')`).
208
- // Same-directory (`./...`) and bare-package imports are untouched.
209
- function copyFileWithImportShift(src, dest, levels) {
210
- const destDir = path.dirname(dest)
211
- if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true })
212
- const content = fs.readFileSync(src, 'utf-8')
213
- const prefix = '../'.repeat(levels)
214
- const shifted = content.replace(/(from\s+|import\s+|import\()(['"])(\.\.\/)/g, `$1$2${prefix}$3`)
215
- fs.writeFileSync(dest, shifted)
216
- }
217
-
218
- // ---------------------------------------------------------------------------
219
- // Install files
220
- // ---------------------------------------------------------------------------
221
-
222
- // Page files must land under src/pages/(app)/ so the (app)/_layout.tsx route
223
- // group wraps them in the DeepSpace providers (auth + realtime records) — the
224
- // top level of src/pages/ is reserved for static pages that mount no
225
- // providers. Protected pages go one level deeper, under (app)/(protected)/,
226
- // so the nested layout also wraps them in <AuthGate>. Both are
227
- // folder-name-only route groups — the URL path is unchanged. Only page files
228
- // (src/pages/*) get redirected; schemas, components, etc. keep their declared
229
- // destinations. Page files already placed in the (app) group are left as-is.
230
- function resolveDestPath(fileDest, route) {
231
- const PAGES = 'src/pages/'
232
- const APP = 'src/pages/(app)/'
233
- if (!fileDest.startsWith(PAGES) || fileDest.startsWith(APP)) return fileDest
234
- const rest = fileDest.slice(PAGES.length)
235
- const isProtected = route && route.protected !== false
236
- return isProtected ? `${APP}(protected)/${rest}` : `${APP}${rest}`
237
- }
238
-
239
- function installFeature(config, targetDir) {
240
- let copied = 0
241
- let skipped = 0
242
-
243
- for (const file of config.files) {
244
- const srcPath = path.join(FEATURES_DIR, config.id, file.src)
245
- const resolvedDest = resolveDestPath(file.dest, config.route)
246
- const destPath = path.join(targetDir, resolvedDest)
247
- const wasRedirected = resolvedDest !== file.dest
248
- // Each extra path segment in the resolved dest is one directory level the
249
- // page dropped into (e.g. (app)/ = 1, (app)/(protected)/ = 2), which is
250
- // exactly how many `../` its upward relative imports must gain.
251
- const shiftLevels = resolvedDest.split('/').length - file.dest.split('/').length
252
-
253
- if (!fs.existsSync(srcPath)) {
254
- console.error(` Warning: source not found: ${file.src}`)
255
- continue
256
- }
257
-
258
- if (fs.existsSync(destPath) && !file.overwrite) {
259
- console.log(` Exists (skipped): ${resolvedDest}`)
260
- skipped++
261
- } else {
262
- const replacing = fs.existsSync(destPath)
263
- if (wasRedirected) {
264
- copyFileWithImportShift(srcPath, destPath, shiftLevels)
265
- } else {
266
- copyFile(srcPath, destPath)
267
- }
268
- console.log(` ${replacing ? 'Overwrote' : 'Copied'}: ${resolvedDest}`)
269
- copied++
270
- }
271
- }
272
-
273
- return { copied, skipped }
274
- }
275
-
276
- // ---------------------------------------------------------------------------
277
- // Schema auto-integration (structural parse — no comments needed)
278
- // ---------------------------------------------------------------------------
279
-
280
- function integrateSchema(config, targetDir) {
281
- if (!config.schema) return false
282
-
283
- const schemasPath = path.join(targetDir, 'src', 'schemas.ts')
284
- if (!fs.existsSync(schemasPath)) {
285
- printSchemaInstructions(config.schema)
286
- return false
287
- }
288
-
289
- let content = fs.readFileSync(schemasPath, 'utf-8')
290
- const { exportName, importPath, spreadOperator } = config.schema
291
-
292
- // Already integrated?
293
- const importPattern = new RegExp(`^import\\s+\\{[^}]*\\b${exportName}\\b`, 'm')
294
- if (importPattern.test(content)) {
295
- console.log(` Schema already present: ${exportName}`)
296
- return false
297
- }
298
-
299
- // Strict checks: verify the file has the expected structure
300
- const hasExportConst = content.includes('export const schemas')
301
- const hasArrayOpening = /export const schemas:\s*CollectionSchema\[\]\s*=\s*\[/.test(content)
302
-
303
- if (!hasExportConst || !hasArrayOpening) {
304
- console.log(' Could not auto-integrate schema (schemas.ts has unexpected structure)')
305
- printSchemaInstructions(config.schema)
306
- return false
307
- }
308
-
309
- // 1. Add import before "export const schemas"
310
- const importLine = `import { ${exportName} } from '${importPath}'`
311
- const afterImport = content.replace(
312
- /export const schemas/,
313
- `${importLine}\n\nexport const schemas`,
314
- )
315
-
316
- if (afterImport === content) {
317
- console.log(' Could not insert import line')
318
- printSchemaInstructions(config.schema)
319
- return false
320
- }
321
-
322
- // 2. Insert entry into the schemas array
323
- const schemaEntry = spreadOperator ? `...${exportName}` : exportName
324
- const afterEntry = afterImport.replace(
325
- /export const schemas:\s*CollectionSchema\[\]\s*=\s*\[/,
326
- `export const schemas: CollectionSchema[] = [\n ${schemaEntry},`,
327
- )
328
-
329
- if (afterEntry === afterImport) {
330
- console.log(' Could not insert schema entry')
331
- printSchemaInstructions(config.schema)
332
- return false
333
- }
334
-
335
- fs.writeFileSync(schemasPath, afterEntry)
336
- console.log(` Schema integrated: ${exportName} -> schemas.ts`)
337
- return true
338
- }
339
-
340
- function printSchemaInstructions(schema) {
341
- const { exportName, importPath, spreadOperator } = schema
342
- const entry = spreadOperator ? `...${exportName}` : exportName
343
- console.log('')
344
- console.log(' Add manually to src/schemas.ts:')
345
- console.log(` import { ${exportName} } from '${importPath}'`)
346
- console.log(` // then add ${entry} to the schemas array`)
347
- }
348
-
349
- // ---------------------------------------------------------------------------
350
- // Actions auto-integration (append to src/actions/index.ts registry)
351
- // ---------------------------------------------------------------------------
352
-
353
- function integrateActions(config, targetDir) {
354
- if (!config.actions || config.actions.length === 0) return false
355
-
356
- const actionsPath = path.join(targetDir, 'src', 'actions', 'index.ts')
357
- if (!fs.existsSync(actionsPath)) {
358
- printActionsInstructions(config.actions)
359
- return false
360
- }
361
-
362
- let content = fs.readFileSync(actionsPath, 'utf-8')
363
-
364
- // Strict check: must match the starter's shape so our regex patch is safe.
365
- // Accepts both `ActionHandler` and `ActionHandler<Env>` (typed-env templates).
366
- const hasRegistry =
367
- /export const actions:\s*Record<string,\s*ActionHandler(?:<[^>]*>)?>\s*=\s*\{/.test(content)
368
- if (!hasRegistry) {
369
- console.log(' Could not auto-integrate actions (actions/index.ts has unexpected structure)')
370
- printActionsInstructions(config.actions)
371
- return false
372
- }
373
-
374
- let integrated = 0
375
- for (const action of config.actions) {
376
- const { exportName, registerAs, importPath } = action
377
-
378
- // Already integrated?
379
- const importPattern = new RegExp(`^import\\s+\\{[^}]*\\b${exportName}\\b`, 'm')
380
- if (importPattern.test(content)) {
381
- console.log(` Action already present: ${registerAs}`)
382
- continue
383
- }
384
-
385
- // 1. Add import before the registry export.
386
- const importLine = `import { ${exportName} } from '${importPath}'`
387
- const withImport = content.replace(
388
- /export const actions:/,
389
- `${importLine}\n\nexport const actions:`,
390
- )
391
- if (withImport === content) {
392
- console.log(` Could not insert import for ${exportName}`)
393
- continue
394
- }
395
-
396
- // 2. Insert entry into the registry object. Works for both empty and
397
- // populated registries because we inject right after the opening brace.
398
- const registryEntry = ` '${registerAs}': ${exportName},`
399
- const withEntry = withImport.replace(
400
- /export const actions:\s*Record<string,\s*ActionHandler(?:<[^>]*>)?>\s*=\s*\{/,
401
- (match) => `${match}\n${registryEntry}`,
402
- )
403
- if (withEntry === withImport) {
404
- console.log(` Could not insert registry entry for ${registerAs}`)
405
- continue
406
- }
407
-
408
- content = withEntry
409
- integrated++
410
- console.log(` Action integrated: ${registerAs} (${exportName})`)
411
- }
412
-
413
- if (integrated > 0) {
414
- fs.writeFileSync(actionsPath, content)
415
- }
416
- return integrated > 0
417
- }
418
-
419
- function printActionsInstructions(actions) {
420
- console.log('')
421
- console.log(' Add manually to src/actions/index.ts:')
422
- for (const a of actions) {
423
- console.log(` import { ${a.exportName} } from '${a.importPath}'`)
424
- console.log(` // then add '${a.registerAs}': ${a.exportName} to the actions object`)
425
- }
426
- }
427
-
428
- // ---------------------------------------------------------------------------
429
- // CSS auto-integration (append feature CSS to styles.css)
430
- // ---------------------------------------------------------------------------
431
-
432
- function integrateCss(config, targetDir) {
433
- if (!config.css || config.css.length === 0) return false
434
-
435
- const stylesPath = path.join(targetDir, 'src', 'styles.css')
436
- if (!fs.existsSync(stylesPath)) {
437
- console.log(' Warning: Cannot integrate CSS — src/styles.css not found')
438
- return false
439
- }
440
-
441
- let stylesContent = fs.readFileSync(stylesPath, 'utf-8')
442
- let integrated = 0
443
-
444
- for (const cssFile of config.css) {
445
- const srcPath = path.join(FEATURES_DIR, config.id, cssFile)
446
- if (!fs.existsSync(srcPath)) {
447
- console.error(` Warning: CSS source not found: ${cssFile}`)
448
- continue
449
- }
450
-
451
- const cssContent = fs.readFileSync(srcPath, 'utf-8')
452
-
453
- // Check if already integrated by looking for a unique class/keyframe from the CSS
454
- // Use the first non-comment, non-empty line with a class or keyframe as fingerprint
455
- const fingerprint = cssContent.match(/\.([\w-]+)\s*\{|@keyframes\s+([\w-]+)/)
456
- if (fingerprint) {
457
- const marker = fingerprint[1] || fingerprint[2]
458
- if (stylesContent.includes(marker)) {
459
- console.log(` CSS already present: ${cssFile} (found .${marker})`)
460
- continue
461
- }
462
- }
463
-
464
- stylesContent += '\n' + cssContent
465
- integrated++
466
- console.log(` CSS integrated: ${cssFile} -> styles.css`)
467
- }
468
-
469
- if (integrated > 0) {
470
- fs.writeFileSync(stylesPath, stylesContent)
471
- }
472
-
473
- return integrated > 0
474
- }
475
-
476
- // ---------------------------------------------------------------------------
477
- // Nav auto-wiring into nav.ts
478
- // ---------------------------------------------------------------------------
479
-
480
- const NAV_MARKER = '// ── Features add nav items below this line ──'
481
-
482
- function integrateRoute(config, targetDir) {
483
- if (!config.route) return false
484
-
485
- const navPath = path.join(targetDir, 'src', 'nav.ts')
486
- if (!fs.existsSync(navPath)) {
487
- printRouteInstructions(config.route, config)
488
- return false
489
- }
490
-
491
- let content = fs.readFileSync(navPath, 'utf-8')
492
- const { path: routePath } = config.route
493
- const label = config.name
494
-
495
- // Already wired?
496
- if (content.includes(`'${routePath}'`)) {
497
- console.log(` Nav already present: ${routePath}`)
498
- return false
499
- }
500
-
501
- if (!content.includes(NAV_MARKER)) {
502
- console.log(' Could not auto-wire nav (marker not found in nav.ts)')
503
- printRouteInstructions(config.route, config)
504
- return false
505
- }
506
-
507
- const rolesStr = config.route.protected === false ? '' : ", roles: ['member' as Role]"
508
- const entry = ` { path: '${routePath}', label: '${label}'${rolesStr} },`
509
-
510
- content = content.replace(NAV_MARKER, `${NAV_MARKER}\n${entry}`)
511
- fs.writeFileSync(navPath, content)
512
- console.log(` Nav wired: ${routePath} (${label})`)
513
-
514
- // Route is automatic via generouted — page file in src/pages/ is enough
515
- console.log(` Route: automatic (file-based routing via src/pages/)`)
516
- return true
517
- }
518
-
519
- function printRouteInstructions(route, config) {
520
- const { path: routePath } = route
521
- const label = config?.name ?? routePath
522
- console.log('')
523
- console.log(' Add manually to src/nav.ts:')
524
- console.log(` { path: '${routePath}', label: '${label}' },`)
525
- }
526
-
527
- // ---------------------------------------------------------------------------
528
- // Dependency integration — patches package.json with declared deps
529
- // ---------------------------------------------------------------------------
530
-
531
- /**
532
- * Merge `config.dependencies` and `config.devDependencies` into the
533
- * target app's package.json. Existing entries win — never downgrade or
534
- * overwrite a user's pin. Prints a single line telling them to run
535
- * their package manager when at least one new entry was added.
536
- *
537
- * Why we don't shell out to `npm install` here: the scaffolded app
538
- * may use any of npm / pnpm / bun / yarn. Detecting the right one
539
- * heuristically (lockfile presence, packageManager field) is doable
540
- * but easy to get wrong; deferring to the user keeps the contract
541
- * narrow and respects whatever lockfile setup they have.
542
- */
543
- function integrateDependencies(config, targetDir, options = {}) {
544
- const result = { added: [], installRan: false, pm: detectPackageManager(targetDir) }
545
-
546
- const sections = [
547
- { key: 'dependencies', label: 'dependencies' },
548
- { key: 'devDependencies', label: 'devDependencies' },
549
- ]
550
-
551
- // Skip the work if nothing is declared.
552
- const hasAny = sections.some((s) => config[s.key] && Object.keys(config[s.key]).length > 0)
553
- if (!hasAny) return result
554
-
555
- const pkgPath = path.join(targetDir, 'package.json')
556
- if (!fs.existsSync(pkgPath)) {
557
- console.log(' Could not patch package.json (file not found)')
558
- return result
559
- }
560
-
561
- let pkg
562
- try {
563
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
564
- } catch (err) {
565
- console.log(` Could not patch package.json (parse error: ${err.message})`)
566
- return result
567
- }
568
-
569
- const added = []
570
- const skipped = []
571
-
572
- for (const { key } of sections) {
573
- const declared = config[key]
574
- if (!declared) continue
575
- pkg[key] = pkg[key] || {}
576
- for (const [name, version] of Object.entries(declared)) {
577
- // Skip if already declared in this OR the sibling section — never let a
578
- // package land in both dependencies and devDependencies (e.g. when a
579
- // feature moves a dep between sections across versions). Don't report a
580
- // name we added moments ago (from the sibling section of this same
581
- // config) as "already present" — that would list it as both added+skipped.
582
- if (pkg.dependencies?.[name] || pkg.devDependencies?.[name]) {
583
- if (!added.includes(name)) skipped.push(name)
584
- continue
585
- }
586
- pkg[key][name] = version
587
- added.push(name)
588
- }
589
- }
590
-
591
- if (added.length === 0) {
592
- console.log(' Dependencies already present')
593
- return result
594
- }
595
-
596
- // Re-write with two-space indent (Node's de-facto default; matches
597
- // what most generators / lint tools agree on).
598
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8')
599
- console.log(` Added to package.json: ${added.join(', ')}`)
600
- if (skipped.length > 0) {
601
- console.log(` Already present (kept your version): ${skipped.join(', ')}`)
602
- }
603
- result.added = added
604
-
605
- // Opt-in (--install): run the detected package manager for the user. By
606
- // default we only patch package.json and let the "Next steps" footer tell
607
- // them to install, so we never guess wrong about their PM/lockfile.
608
- if (options.runInstall) {
609
- console.log(`\n Installing dependencies with ${result.pm}...`)
610
- // shell:true on Windows, where npm/pnpm/yarn/bun are .cmd shims that
611
- // spawnSync can't exec directly.
612
- const res = spawnSync(result.pm, ['install'], {
613
- cwd: targetDir,
614
- stdio: 'inherit',
615
- shell: process.platform === 'win32',
616
- })
617
- if (res.error) {
618
- const why = res.error.code === 'ENOENT' ? `${result.pm} not found on PATH` : res.error.message
619
- console.log(` Could not launch ${result.pm} (${why}) — run "${result.pm} install" manually.`)
620
- } else if (res.status === 0) {
621
- result.installRan = true
622
- } else {
623
- console.log(` ${result.pm} install did not complete — run "${result.pm} install" manually.`)
624
- }
625
- }
626
-
627
- return result
628
- }
629
-
630
- // ---------------------------------------------------------------------------
631
- // Post-install instructions (for features that need manual wiring)
632
- // ---------------------------------------------------------------------------
633
-
634
- function printPostInstallInstructions(config) {
635
- const instructions = config.instructions || []
636
-
637
- if (instructions.length > 0) {
638
- console.log('\n--- Manual wiring needed ---\n')
639
- instructions.forEach((inst, i) => {
640
- console.log(`${i + 1}. ${inst}\n`)
641
- })
642
- }
643
- }
644
-
645
- // ---------------------------------------------------------------------------
646
- // Main
647
- // ---------------------------------------------------------------------------
648
-
649
- function main() {
650
- // Pull the `--install` flag out first so it can appear anywhere; the rest of
651
- // main() treats args[0]/args[1] as feature + dir positionally.
652
- const runInstall = process.argv.slice(2).includes('--install')
653
- const args = process.argv.slice(2).filter((a) => a !== '--install')
654
-
655
- // --help
656
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
657
- const features = listFeatures()
658
- const groups = groupByCategory(features)
659
-
660
- console.log('\nAdd a feature to your DeepSpace app\n')
661
- console.log('Usage: deepspace add <feature> [dir]\n')
662
- console.log('Commands:')
663
- console.log(' <feature> [dir] Install a feature (dir defaults to .)')
664
- console.log(' --list, -l List all available features')
665
- console.log(' --info <feature> Show detailed info about a feature')
666
- console.log(' --install Run your package manager after adding deps')
667
- console.log(' --help, -h Show this help')
668
-
669
- for (const category of CATEGORY_ORDER) {
670
- const categoryFeatures = groups.get(category)
671
- if (categoryFeatures && categoryFeatures.length > 0) {
672
- console.log(`\n${CATEGORY_LABELS[category] || category}:`)
673
- for (const f of categoryFeatures) {
674
- console.log(` ${f.id.padEnd(22)} ${f.name}`)
675
- }
676
- }
677
- }
678
-
679
- const example = firstFeatureId(groups) || '<feature>'
680
- console.log('\nExamples:')
681
- console.log(' deepspace add --list')
682
- console.log(` deepspace add ${example}`)
683
- console.log(` deepspace add ${example} ./my-app`)
684
- process.exit(0)
685
- }
686
-
687
- // --list
688
- if (args[0] === '--list' || args[0] === '-l') {
689
- console.log('\nAvailable Features\n')
690
-
691
- const features = listFeatures()
692
- const groups = groupByCategory(features)
693
-
694
- for (const category of CATEGORY_ORDER) {
695
- const categoryFeatures = groups.get(category)
696
- if (categoryFeatures && categoryFeatures.length > 0) {
697
- console.log(`${CATEGORY_LABELS[category] || category}:`)
698
- for (const f of categoryFeatures) {
699
- console.log(` ${f.id.padEnd(22)} ${f.name.padEnd(24)} ${f.description}`)
700
- }
701
- console.log('')
702
- }
703
- }
704
-
705
- console.log('Use: deepspace add <feature>')
706
- process.exit(0)
707
- }
708
-
709
- // --info
710
- if (args[0] === '--info' || args[0] === '-i') {
711
- const featureId = args[1]
712
- if (!featureId) {
713
- console.error('\nError: Please specify a feature ID')
714
- process.exit(1)
715
- }
716
- const config = loadFeatureConfig(featureId)
717
- if (!config) unknownFeature(featureId)
718
-
719
- console.log(`\n${config.name} (${config.id})`)
720
- if (config.category) {
721
- console.log(` Category: ${CATEGORY_LABELS[config.category] || config.category}`)
722
- }
723
- console.log(` ${config.description}\n`)
724
- console.log(` ${config.details}\n`)
725
-
726
- console.log(' Files:')
727
- config.files.forEach((f) =>
728
- console.log(` - ${f.src} -> ${resolveDestPath(f.dest, config.route)}`),
729
- )
730
-
731
- if (config.instructions && config.instructions.length > 0) {
732
- console.log('\n Integration steps:')
733
- config.instructions.forEach((inst, i) => console.log(` ${i + 1}. ${inst}`))
734
- }
735
-
736
- if (config.patterns && config.patterns.length > 0) {
737
- console.log('\n Patterns:')
738
- config.patterns.forEach((p) => console.log(` - ${p}`))
739
- }
740
-
741
- if (config.example) {
742
- console.log('\n Example:')
743
- config.example.split('\n').forEach((line) => console.log(` ${line}`))
744
- }
745
- console.log('')
746
- process.exit(0)
747
- }
748
-
749
- // Install feature
750
- const featureId = args[0]
751
- const rawDir = args[1]
752
- const targetDir = resolveTargetDir(rawDir)
753
-
754
- if (!targetDir) {
755
- console.error('\nError: Please specify an app directory')
756
- console.error('Usage: deepspace add <feature> [dir]')
757
- process.exit(1)
758
- }
759
-
760
- const config = loadFeatureConfig(featureId)
761
- if (!config) unknownFeature(featureId)
762
-
763
- if (!fs.existsSync(targetDir)) {
764
- console.error(`\nError: Target directory not found: ${targetDir}`)
765
- process.exit(1)
766
- }
767
-
768
- console.log(`\nInstalling: ${config.name}`)
769
- console.log(` ${config.description}`)
770
- console.log(` Target: ${targetDir}`)
771
-
772
- const { copied, skipped } = installFeature(config, targetDir)
773
-
774
- console.log(`\nCopied ${copied} file(s)${skipped > 0 ? `, skipped ${skipped} existing` : ''}`)
775
-
776
- // Auto-integrate schema, actions, CSS, and route
777
- integrateSchema(config, targetDir)
778
- integrateActions(config, targetDir)
779
- integrateCss(config, targetDir)
780
- integrateRoute(config, targetDir)
781
-
782
- // Add any new npm dependencies the feature declares to the target's
783
- // package.json. Without this, copied source files import packages
784
- // that don't exist in node_modules and the next `vite dev` blows up
785
- // with a hard-to-diagnose "Failed to resolve import" error. By default we
786
- // only patch package.json (the "Next steps" footer tells the user to
787
- // install) so we never guess wrong about their package manager + lockfile;
788
- // `--install` opts into running it for them.
789
- const deps = integrateDependencies(config, targetDir, { runInstall })
790
-
791
- // Print any remaining manual wiring instructions
792
- printPostInstallInstructions(config)
793
-
794
- if (config.patterns && config.patterns.length > 0) {
795
- console.log('--- Key patterns ---\n')
796
- config.patterns.forEach((p) => console.log(`- ${p}`))
797
- console.log('')
798
- }
799
-
800
- // Warn when installing a nav feature alongside another (they're exclusive).
801
- warnNavConflicts(config, targetDir)
802
-
803
- // Always close with concrete next steps.
804
- printNextSteps(config, deps)
805
- }
806
-
807
- main()