canopycms 0.0.5 → 0.0.7

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/plugin.d.ts +9 -0
  3. package/dist/auth/plugin.d.ts.map +1 -1
  4. package/dist/authorization/groups/loader.d.ts.map +1 -1
  5. package/dist/authorization/groups/loader.js +2 -5
  6. package/dist/authorization/groups/loader.js.map +1 -1
  7. package/dist/authorization/permissions/loader.d.ts.map +1 -1
  8. package/dist/authorization/permissions/loader.js +2 -5
  9. package/dist/authorization/permissions/loader.js.map +1 -1
  10. package/dist/branch-metadata.d.ts +10 -0
  11. package/dist/branch-metadata.d.ts.map +1 -1
  12. package/dist/branch-metadata.js +133 -32
  13. package/dist/branch-metadata.js.map +1 -1
  14. package/dist/branch-workspace.d.ts +11 -0
  15. package/dist/branch-workspace.d.ts.map +1 -1
  16. package/dist/branch-workspace.js +21 -1
  17. package/dist/branch-workspace.js.map +1 -1
  18. package/dist/cli/generate-ai-content.d.ts +1 -0
  19. package/dist/cli/generate-ai-content.d.ts.map +1 -1
  20. package/dist/cli/generate-ai-content.js +3 -3
  21. package/dist/cli/generate-ai-content.js.map +1 -1
  22. package/dist/cli/init.d.ts +2 -0
  23. package/dist/cli/init.d.ts.map +1 -1
  24. package/dist/cli/init.js +32 -20
  25. package/dist/cli/init.js.map +1 -1
  26. package/dist/content-reader.d.ts +0 -2
  27. package/dist/content-reader.d.ts.map +1 -1
  28. package/dist/content-reader.js +17 -13
  29. package/dist/content-reader.js.map +1 -1
  30. package/dist/content-store.d.ts.map +1 -1
  31. package/dist/content-store.js +3 -2
  32. package/dist/content-store.js.map +1 -1
  33. package/dist/context.js +5 -5
  34. package/dist/context.js.map +1 -1
  35. package/dist/paths/branch.js +1 -1
  36. package/dist/paths/branch.js.map +1 -1
  37. package/dist/schema/schema-store.d.ts.map +1 -1
  38. package/dist/schema/schema-store.js +3 -2
  39. package/dist/schema/schema-store.js.map +1 -1
  40. package/dist/task-queue/task-queue.d.ts.map +1 -1
  41. package/dist/task-queue/task-queue.js +8 -14
  42. package/dist/task-queue/task-queue.js.map +1 -1
  43. package/dist/utils/atomic-write.d.ts +15 -0
  44. package/dist/utils/atomic-write.d.ts.map +1 -0
  45. package/dist/utils/atomic-write.js +29 -0
  46. package/dist/utils/atomic-write.js.map +1 -0
  47. package/package.json +71 -78
  48. package/src/cli/init.ts +0 -462
package/src/cli/init.ts DELETED
@@ -1,462 +0,0 @@
1
- #!/usr/bin/env tsx
2
-
3
- import fs from 'node:fs/promises'
4
- import { realpathSync } from 'node:fs'
5
- import path from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
- import * as p from '@clack/prompts'
8
- import {
9
- canopyCmsConfig,
10
- canopyContext,
11
- schemasTemplate,
12
- apiRoute,
13
- editPage,
14
- aiConfig,
15
- aiRoute,
16
- dockerfileCms,
17
- githubWorkflowCms,
18
- } from './templates'
19
- import { operatingStrategy } from '../operating-mode'
20
-
21
- export interface InitOptions {
22
- mode: 'prod-sim' | 'dev'
23
- appDir: string
24
- projectDir: string
25
- force: boolean
26
- nonInteractive: boolean
27
- ai: boolean
28
- }
29
-
30
- interface InitDeployOptions {
31
- cloud: 'aws'
32
- projectDir: string
33
- force: boolean
34
- nonInteractive: boolean
35
- }
36
-
37
- async function fileExists(filePath: string): Promise<boolean> {
38
- try {
39
- await fs.stat(filePath)
40
- return true
41
- } catch {
42
- return false
43
- }
44
- }
45
-
46
- /**
47
- * Write a file, prompting for overwrite confirmation if it already exists.
48
- * Returns true if the file was written, false if skipped.
49
- */
50
- async function writeFile(
51
- filePath: string,
52
- content: string,
53
- options: { force: boolean; nonInteractive: boolean },
54
- ): Promise<boolean> {
55
- const relativePath = path.relative(process.cwd(), filePath)
56
-
57
- if (await fileExists(filePath)) {
58
- if (options.force) {
59
- // --force: overwrite without asking
60
- } else if (options.nonInteractive) {
61
- p.log.warn(`skip: ${relativePath} (already exists)`)
62
- return false
63
- } else {
64
- const overwrite = await p.confirm({
65
- message: `${relativePath} already exists. Overwrite?`,
66
- initialValue: false,
67
- })
68
- if (p.isCancel(overwrite) || !overwrite) {
69
- p.log.warn(`skip: ${relativePath}`)
70
- return false
71
- }
72
- }
73
- }
74
-
75
- await fs.mkdir(path.dirname(filePath), { recursive: true })
76
- await fs.writeFile(filePath, content, 'utf-8')
77
- p.log.success(`created: ${relativePath}`)
78
- return true
79
- }
80
-
81
- /**
82
- * Compute the relative path from a file inside appDir to the project root.
83
- * e.g. appDir="app" depth=1 → "../", appDir="src/app" depth=2 → "../../"
84
- */
85
- function configImportPath(appDir: string, subdirs: number): string {
86
- const appDepth = appDir.split('/').filter(Boolean).length
87
- const totalDepth = appDepth + subdirs
88
- return '../'.repeat(totalDepth) + 'canopycms.config'
89
- }
90
-
91
- /**
92
- * Framework integration: generates the files needed to add CanopyCMS
93
- * editing to a Next.js app. Cloud-agnostic.
94
- */
95
- export async function init(options: InitOptions): Promise<void> {
96
- const { projectDir, mode, appDir, ai, force, nonInteractive } = options
97
- const writeOpts = { force, nonInteractive }
98
-
99
- p.intro('CanopyCMS init')
100
-
101
- // Generate files
102
- await writeFile(
103
- path.join(projectDir, 'canopycms.config.ts'),
104
- await canopyCmsConfig({ mode }),
105
- writeOpts,
106
- )
107
- await writeFile(
108
- path.join(projectDir, appDir, 'lib/canopy.ts'),
109
- await canopyContext({ configImport: configImportPath(appDir, 1) }),
110
- writeOpts,
111
- )
112
- await writeFile(path.join(projectDir, appDir, 'schemas.ts'), await schemasTemplate(), writeOpts)
113
- await writeFile(
114
- path.join(projectDir, appDir, 'api/canopycms/[...canopycms]/route.ts'),
115
- await apiRoute({
116
- canopyImport: '../'.repeat(3) + 'lib/canopy',
117
- }),
118
- writeOpts,
119
- )
120
- await writeFile(
121
- path.join(projectDir, appDir, 'edit/page.tsx'),
122
- await editPage({ configImport: configImportPath(appDir, 1) }),
123
- writeOpts,
124
- )
125
- if (ai) {
126
- await writeFile(path.join(projectDir, appDir, 'ai/config.ts'), await aiConfig(), writeOpts)
127
- await writeFile(
128
- path.join(projectDir, appDir, 'ai/[...path]/route.ts'),
129
- await aiRoute({ configImport: configImportPath(appDir, 2) }),
130
- writeOpts,
131
- )
132
- }
133
-
134
- // Update .gitignore
135
- const gitignorePath = path.join(projectDir, '.gitignore')
136
- if (await fileExists(gitignorePath)) {
137
- const content = await fs.readFile(gitignorePath, 'utf-8')
138
- if (!content.includes('.canopy-prod-sim')) {
139
- await fs.appendFile(gitignorePath, '\n# CanopyCMS\n.canopy-prod-sim/\n.canopy-dev/\n')
140
- p.log.success('updated: .gitignore')
141
- }
142
- }
143
-
144
- p.note(
145
- [
146
- '1. Install dependencies:',
147
- ` npm install canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev`,
148
- '',
149
- '2. Add transpilePackages to next.config.ts:',
150
- " transpilePackages: ['canopycms']",
151
- '',
152
- '3. Customize ' + appDir + '/schemas.ts with your content schema',
153
- '',
154
- '4. Run: npm run dev',
155
- '5. Visit: http://localhost:3000/edit',
156
- ].join('\n'),
157
- 'Next steps',
158
- )
159
-
160
- p.outro('Done!')
161
- }
162
-
163
- /**
164
- * Cloud deployment artifacts: generates AWS-specific files
165
- * (Dockerfile, CI workflow).
166
- */
167
- export async function initDeployAws(options: InitDeployOptions): Promise<void> {
168
- const { projectDir, force, nonInteractive } = options
169
- const writeOpts = { force, nonInteractive }
170
-
171
- p.intro('CanopyCMS init-deploy aws')
172
-
173
- await writeFile(path.join(projectDir, 'Dockerfile.cms'), await dockerfileCms(), writeOpts)
174
- await writeFile(
175
- path.join(projectDir, '.github/workflows/deploy-cms.yml'),
176
- await githubWorkflowCms(),
177
- writeOpts,
178
- )
179
-
180
- // Check if next.config already has CANOPY_BUILD support
181
- const nextConfigPath = path.join(projectDir, 'next.config.ts')
182
- const nextConfigMjsPath = path.join(projectDir, 'next.config.mjs')
183
- const configPath = (await fileExists(nextConfigPath))
184
- ? nextConfigPath
185
- : (await fileExists(nextConfigMjsPath))
186
- ? nextConfigMjsPath
187
- : null
188
-
189
- if (configPath) {
190
- const content = await fs.readFile(configPath, 'utf-8')
191
- if (!content.includes('CANOPY_BUILD')) {
192
- p.note(
193
- [
194
- `Add dual build support to ${path.basename(configPath)}:`,
195
- '',
196
- " output: process.env.CANOPY_BUILD === 'cms' ? 'standalone' : 'export',",
197
- ].join('\n'),
198
- 'Manual step',
199
- )
200
- }
201
- }
202
-
203
- p.note(
204
- 'CDK constructs are available via the canopycms-cdk package.\nSee the deployment plan for CDK stack setup.',
205
- 'AWS deployment',
206
- )
207
-
208
- p.outro('Done!')
209
- }
210
-
211
- /**
212
- * Worker run-once: process pending tasks, sync git, refresh auth cache, then exit.
213
- * Used in prod-sim to trigger worker operations without a persistent daemon.
214
- */
215
- export async function workerRunOnce(options: { projectDir: string }): Promise<void> {
216
- // Dynamic import to avoid loading worker deps when not needed
217
- const { getTaskQueueDir } = await import('../worker/task-queue-config')
218
-
219
- // Determine workspace and mode from config
220
- const cfgPath = path.join(options.projectDir, 'canopycms.config.ts')
221
- let mode: 'prod' | 'prod-sim' = 'prod-sim'
222
- try {
223
- const configContent = await fs.readFile(cfgPath, 'utf-8')
224
- // Match the mode property in the config object, not in comments or strings
225
- if (/^\s*mode:\s*['"]prod['"]\s*[,}]/m.test(configContent)) {
226
- mode = 'prod'
227
- }
228
- } catch {
229
- // Default to prod-sim
230
- }
231
-
232
- const taskDir = getTaskQueueDir({ mode })
233
- if (!taskDir) {
234
- console.log('Worker not needed in dev mode')
235
- return
236
- }
237
-
238
- // For prod-sim without GitHub, just refresh auth cache
239
- const authMode = process.env.CANOPY_AUTH_MODE || 'dev'
240
- const cachePath =
241
- process.env.CANOPY_AUTH_CACHE_PATH ??
242
- path.join(operatingStrategy(mode).getWorkspaceRoot(options.projectDir), '.cache')
243
-
244
- let refreshAuthCache: (() => Promise<void>) | undefined
245
-
246
- if (authMode === 'clerk') {
247
- const clerkSecretKey = process.env.CLERK_SECRET_KEY
248
- if (clerkSecretKey) {
249
- const { refreshClerkCache } = await import('canopycms-auth-clerk/cache-writer')
250
- refreshAuthCache = async () => {
251
- const result = await refreshClerkCache({
252
- secretKey: clerkSecretKey,
253
- cachePath,
254
- })
255
- console.log(` ${result.userCount} users, ${result.groupCount} groups`)
256
- }
257
- }
258
- } else if (authMode === 'dev') {
259
- const { refreshDevCache } = await import('canopycms-auth-dev/cache-writer')
260
- refreshAuthCache = async () => {
261
- const result = await refreshDevCache({ cachePath })
262
- console.log(` ${result.userCount} users, ${result.groupCount} groups`)
263
- }
264
- }
265
-
266
- console.log(`\nCanopyCMS worker run-once (mode: ${mode}, auth: ${authMode})\n`)
267
-
268
- // Refresh auth cache
269
- if (refreshAuthCache) {
270
- console.log('Refreshing auth cache...')
271
- await refreshAuthCache()
272
- console.log('Auth cache refreshed')
273
- }
274
-
275
- // Process task queue (if any pending tasks)
276
- const { dequeueTask, completeTask } = await import('../worker/task-queue')
277
- let taskCount = 0
278
- let task
279
- while ((task = await dequeueTask(taskDir)) !== null) {
280
- console.log(`Processing task: ${task.action} (${task.id})`)
281
- // In prod-sim without GitHub, just mark tasks as completed
282
- // A real worker would execute the GitHub operations
283
- console.warn(` WARNING: Task skipped — GitHub operations require the full worker daemon`)
284
- await completeTask(taskDir, task.id, { skipped: true })
285
- taskCount++
286
- }
287
-
288
- if (taskCount > 0) {
289
- console.log(`Processed ${taskCount} task(s)`)
290
- } else {
291
- console.log('No pending tasks')
292
- }
293
-
294
- console.log('\nDone')
295
- }
296
-
297
- /** Parse CLI flags from argv, returning values and remaining positional args. */
298
- function parseFlags(args: string[]): {
299
- flags: Record<string, string | boolean>
300
- positional: string[]
301
- } {
302
- const flags: Record<string, string | boolean> = {}
303
- const positional: string[] = []
304
-
305
- for (let i = 0; i < args.length; i++) {
306
- const arg = args[i]
307
- if (arg.startsWith('--')) {
308
- const key = arg.slice(2)
309
- // Boolean flags
310
- if (key === 'force' || key === 'non-interactive' || key === 'no-ai') {
311
- flags[key] = true
312
- } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
313
- flags[key] = args[++i]
314
- }
315
- } else {
316
- positional.push(arg)
317
- }
318
- }
319
-
320
- return { flags, positional }
321
- }
322
-
323
- // CLI entrypoint
324
- async function main() {
325
- const args = process.argv.slice(2)
326
- const { flags, positional } = parseFlags(args)
327
- const command = positional[0]
328
-
329
- if (command === 'init') {
330
- const nonInteractive = flags['non-interactive'] === true
331
- const force = flags['force'] === true
332
-
333
- let mode: 'dev' | 'prod-sim'
334
- if (flags['mode'] === 'dev' || flags['mode'] === 'prod-sim') {
335
- mode = flags['mode']
336
- } else if (nonInteractive) {
337
- mode = 'dev'
338
- } else {
339
- const result = await p.select({
340
- message: 'Which operating mode?',
341
- options: [
342
- { value: 'dev' as const, label: 'dev', hint: 'Direct editing in current checkout' },
343
- {
344
- value: 'prod-sim' as const,
345
- label: 'prod-sim',
346
- hint: 'Simulates production with local branch clones',
347
- },
348
- ],
349
- initialValue: 'dev' as const,
350
- })
351
- if (p.isCancel(result)) {
352
- p.cancel('Init cancelled.')
353
- process.exit(0)
354
- }
355
- mode = result
356
- }
357
-
358
- let appDir: string
359
- if (typeof flags['app-dir'] === 'string') {
360
- appDir = flags['app-dir']
361
- } else if (nonInteractive) {
362
- appDir = 'app'
363
- } else {
364
- const result = await p.text({
365
- message: 'App directory?',
366
- placeholder: 'app',
367
- defaultValue: 'app',
368
- })
369
- if (p.isCancel(result)) {
370
- p.cancel('Init cancelled.')
371
- process.exit(0)
372
- }
373
- appDir = result
374
- }
375
-
376
- let ai: boolean
377
- if (flags['no-ai'] === true) {
378
- ai = false
379
- } else if (nonInteractive) {
380
- ai = true
381
- } else {
382
- const result = await p.confirm({
383
- message: 'Include AI content endpoint?',
384
- initialValue: true,
385
- })
386
- if (p.isCancel(result)) {
387
- p.cancel('Init cancelled.')
388
- process.exit(0)
389
- }
390
- ai = result
391
- }
392
-
393
- await init({
394
- mode,
395
- appDir,
396
- ai,
397
- projectDir: process.cwd(),
398
- force,
399
- nonInteractive,
400
- })
401
- } else if (command === 'init-deploy') {
402
- const cloud = positional[1]
403
- if (cloud !== 'aws') {
404
- console.error('Usage: canopycms init-deploy aws')
405
- console.error('Only "aws" is currently supported.')
406
- process.exit(1)
407
- }
408
- await initDeployAws({
409
- cloud: 'aws',
410
- projectDir: process.cwd(),
411
- force: flags['force'] === true,
412
- nonInteractive: flags['non-interactive'] === true,
413
- })
414
- } else if (command === 'worker') {
415
- const subcommand = positional[1]
416
- if (subcommand !== 'run-once') {
417
- console.error('Usage: canopycms worker run-once')
418
- process.exit(1)
419
- }
420
- await workerRunOnce({ projectDir: process.cwd() })
421
- } else if (command === 'generate-ai-content') {
422
- const { generateAIContentCLI } = await import('./generate-ai-content')
423
- await generateAIContentCLI({
424
- projectDir: process.cwd(),
425
- outputDir: typeof flags['output'] === 'string' ? flags['output'] : undefined,
426
- configPath: typeof flags['config'] === 'string' ? flags['config'] : undefined,
427
- })
428
- } else {
429
- console.log('CanopyCMS CLI')
430
- console.log('')
431
- console.log('Commands:')
432
- console.log(' init Add CanopyCMS to a Next.js app')
433
- console.log(' --mode <dev|prod-sim> Operating mode (default: dev)')
434
- console.log(' --app-dir <path> App directory (default: app)')
435
- console.log(' --no-ai Skip AI content endpoint generation')
436
- console.log(' --force Overwrite existing files without asking')
437
- console.log(' --non-interactive Use defaults, no prompts')
438
- console.log('')
439
- console.log(' init-deploy aws Generate AWS deployment artifacts')
440
- console.log(' --force Overwrite existing files without asking')
441
- console.log(' --non-interactive Use defaults, no prompts')
442
- console.log('')
443
- console.log(' worker run-once Process tasks, sync git, refresh auth cache')
444
- console.log(' generate-ai-content Generate static AI-ready content files')
445
- console.log(' --output <dir> Output directory (default: public/ai)')
446
- console.log(' --config <path> Path to AI content config file')
447
- process.exit(0)
448
- }
449
- }
450
-
451
- // Only run when executed directly as a CLI, not when imported in tests.
452
- // Use realpathSync to resolve symlinks — npx creates a symlink in node_modules/.bin/
453
- // that won't match import.meta.url's resolved real path.
454
- const __filename = fileURLToPath(import.meta.url)
455
- const isDirectRun = realpathSync(process.argv[1]) === realpathSync(__filename)
456
-
457
- if (isDirectRun) {
458
- main().catch((err) => {
459
- console.error('Error:', err instanceof Error ? err.message : String(err))
460
- process.exit(1)
461
- })
462
- }