@actuate-media/cli 0.10.0 → 0.11.1

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.
@@ -0,0 +1,181 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { Command } from 'commander'
5
+ import chalk from 'chalk'
6
+ import { logger } from '../utils/logger.js'
7
+
8
+ /**
9
+ * `actuate mcp:init` — retrofit the Actuate MCP server config onto an
10
+ * existing project.
11
+ *
12
+ * New scaffolds (`npm create actuate-cms`) emit `.cursor/mcp.json` and
13
+ * `.vscode/mcp.json` automatically; projects created before that feature (or
14
+ * set up by hand) have no easy path. This command writes both editor configs,
15
+ * merging into existing files so other MCP servers are preserved, and ensures
16
+ * both paths are git-ignored (a real `act_sk_...` key pasted into them must
17
+ * never be committed).
18
+ */
19
+
20
+ interface McpInitOptions {
21
+ url: string
22
+ key: string
23
+ force?: boolean
24
+ }
25
+
26
+ const PLACEHOLDER_KEY = 'act_sk_REPLACE_WITH_YOUR_KEY'
27
+
28
+ /** Cursor / Claude Desktop shape: top-level `mcpServers`. */
29
+ function cursorEntry(opts: McpInitOptions): Record<string, unknown> {
30
+ return {
31
+ command: 'npx',
32
+ args: ['-y', '@actuate-media/mcp-server'],
33
+ env: { ACTUATE_BASE_URL: opts.url, ACTUATE_API_KEY: opts.key },
34
+ }
35
+ }
36
+
37
+ /** VS Code shape: top-level `servers`, each entry declares `type: "stdio"`. */
38
+ function vscodeEntry(opts: McpInitOptions): Record<string, unknown> {
39
+ return { type: 'stdio', ...cursorEntry(opts) }
40
+ }
41
+
42
+ interface MergeTarget {
43
+ /** Path relative to the project root, also used in log lines. */
44
+ relPath: string
45
+ /** Top-level key the editor expects (`mcpServers` vs `servers`). */
46
+ rootKey: 'mcpServers' | 'servers'
47
+ entry: Record<string, unknown>
48
+ }
49
+
50
+ type MergeResult = 'created' | 'updated' | 'skipped' | 'error'
51
+
52
+ /**
53
+ * Create or merge one editor config. Existing files keep every other server
54
+ * entry; an existing `actuate` entry is only replaced with `--force`.
55
+ */
56
+ async function mergeConfigFile(
57
+ root: string,
58
+ target: MergeTarget,
59
+ force: boolean,
60
+ ): Promise<MergeResult> {
61
+ const filePath = path.join(root, target.relPath)
62
+
63
+ let config: Record<string, unknown> = {}
64
+ let existed = false
65
+ if (existsSync(filePath)) {
66
+ existed = true
67
+ const raw = await readFile(filePath, 'utf8')
68
+ try {
69
+ const parsed = JSON.parse(raw) as unknown
70
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
71
+ config = parsed as Record<string, unknown>
72
+ } else if (!force) {
73
+ logger.error(`${target.relPath} is not a JSON object — fix it or re-run with --force.`)
74
+ return 'error'
75
+ }
76
+ } catch {
77
+ if (!force) {
78
+ logger.error(`${target.relPath} contains invalid JSON — fix it or re-run with --force.`)
79
+ return 'error'
80
+ }
81
+ config = {}
82
+ }
83
+ }
84
+
85
+ const serversRaw = config[target.rootKey]
86
+ const servers =
87
+ serversRaw && typeof serversRaw === 'object' && !Array.isArray(serversRaw)
88
+ ? (serversRaw as Record<string, unknown>)
89
+ : {}
90
+
91
+ if (servers.actuate && !force) {
92
+ logger.warn(`${target.relPath} already has an "actuate" server — use --force to replace it.`)
93
+ return 'skipped'
94
+ }
95
+
96
+ config[target.rootKey] = { ...servers, actuate: target.entry }
97
+
98
+ await mkdir(path.dirname(filePath), { recursive: true })
99
+ await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8')
100
+ return existed ? 'updated' : 'created'
101
+ }
102
+
103
+ /**
104
+ * Make sure both MCP config paths are git-ignored. Appends only the missing
105
+ * entries; creates `.gitignore` if the project somehow has none.
106
+ */
107
+ async function ensureGitignored(root: string, relPaths: string[]): Promise<string[]> {
108
+ const gitignorePath = path.join(root, '.gitignore')
109
+ const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, 'utf8') : ''
110
+ const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()))
111
+ const missing = relPaths.filter((p) => !lines.has(p))
112
+ if (missing.length === 0) return []
113
+
114
+ const block =
115
+ (existing && !existing.endsWith('\n') ? '\n' : '') +
116
+ '\n# MCP configs hold a real API key once configured — never commit them\n' +
117
+ missing.join('\n') +
118
+ '\n'
119
+ await writeFile(gitignorePath, existing + block, 'utf8')
120
+ return missing
121
+ }
122
+
123
+ export function registerMcpCommand(program: Command): void {
124
+ program
125
+ .command('mcp:init')
126
+ .description('Write .cursor/mcp.json + .vscode/mcp.json wiring the Actuate MCP server')
127
+ .option('--url <baseUrl>', 'Base URL of the running CMS', 'http://localhost:3000')
128
+ .option('--key <apiKey>', 'Actuate API key (act_sk_...)', PLACEHOLDER_KEY)
129
+ .option('--force', 'Replace an existing "actuate" server entry (or unparseable config)')
130
+ .action(async (opts: McpInitOptions) => {
131
+ const root = process.cwd()
132
+
133
+ if (opts.key !== PLACEHOLDER_KEY && !opts.key.startsWith('act_sk_')) {
134
+ logger.warn(
135
+ 'The provided --key does not look like an Actuate API key (expected act_sk_ prefix).',
136
+ )
137
+ }
138
+
139
+ const targets: MergeTarget[] = [
140
+ { relPath: '.cursor/mcp.json', rootKey: 'mcpServers', entry: cursorEntry(opts) },
141
+ { relPath: '.vscode/mcp.json', rootKey: 'servers', entry: vscodeEntry(opts) },
142
+ ]
143
+
144
+ let failed = false
145
+ for (const target of targets) {
146
+ const result = await mergeConfigFile(root, target, Boolean(opts.force))
147
+ if (result === 'created') logger.success(`Created ${target.relPath}`)
148
+ else if (result === 'updated') logger.success(`Updated ${target.relPath}`)
149
+ else if (result === 'error') failed = true
150
+ }
151
+
152
+ const ignored = await ensureGitignored(
153
+ root,
154
+ targets.map((t) => t.relPath),
155
+ )
156
+ if (ignored.length > 0) {
157
+ logger.success(`Added to .gitignore: ${ignored.join(', ')}`)
158
+ }
159
+
160
+ console.log()
161
+ console.log(chalk.bold('Next steps'))
162
+ if (opts.key === PLACEHOLDER_KEY) {
163
+ console.log(
164
+ ` 1. Mint an API key in ${chalk.cyan('Admin → Settings → API Keys')} (act_sk_...)`,
165
+ )
166
+ console.log(
167
+ ` 2. Replace ${chalk.cyan(PLACEHOLDER_KEY)} in both config files with the real key`,
168
+ )
169
+ console.log(' 3. Restart Cursor / VS Code so the MCP server is picked up')
170
+ } else {
171
+ console.log(' 1. Restart Cursor / VS Code so the MCP server is picked up')
172
+ }
173
+ if (opts.url === 'http://localhost:3000') {
174
+ console.log(
175
+ chalk.dim(' Tip: pass --url https://your-site.com to target a deployed CMS instead.'),
176
+ )
177
+ }
178
+
179
+ if (failed) process.exitCode = 1
180
+ })
181
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  registerVerifyCommand,
19
19
  } from './commands/doctor.js'
20
20
  import { registerVercelBlobLinkCommand } from './commands/vercel-blob-link.js'
21
+ import { registerMcpCommand } from './commands/mcp.js'
21
22
 
22
23
  const program = new Command()
23
24
 
@@ -42,5 +43,6 @@ registerDoctorCommand(program)
42
43
  registerDeployCheckCommand(program)
43
44
  registerVerifyCommand(program)
44
45
  registerVercelBlobLinkCommand(program)
46
+ registerMcpCommand(program)
45
47
 
46
48
  program.parse()
@@ -0,0 +1,52 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+
4
+ /** Parse a minimal `.env` file (KEY=VALUE, # comments, optional quotes). */
5
+ export function parseDotenv(content: string): Record<string, string> {
6
+ const out: Record<string, string> = {}
7
+ for (const line of content.split(/\r?\n/)) {
8
+ const trimmed = line.trim()
9
+ if (!trimmed || trimmed.startsWith('#')) continue
10
+ const eq = trimmed.indexOf('=')
11
+ if (eq <= 0) continue
12
+ const key = trimmed.slice(0, eq).trim()
13
+ let value = trimmed.slice(eq + 1).trim()
14
+ if (
15
+ (value.startsWith('"') && value.endsWith('"')) ||
16
+ (value.startsWith("'") && value.endsWith("'"))
17
+ ) {
18
+ value = value.slice(1, -1)
19
+ }
20
+ out[key] = value
21
+ }
22
+ return out
23
+ }
24
+
25
+ /**
26
+ * Merge project `.env` files into a copy of `process.env`.
27
+ *
28
+ * Matches the common Next.js local-dev order: `.env` first, then `.env.local`
29
+ * overrides. Never overwrites keys already set in the parent environment
30
+ * (CI secrets, inline exports).
31
+ */
32
+ export async function loadProjectEnv(
33
+ cwd: string,
34
+ baseEnv: Record<string, string | undefined> = process.env,
35
+ ): Promise<Record<string, string | undefined>> {
36
+ const merged: Record<string, string | undefined> = { ...baseEnv }
37
+
38
+ for (const file of ['.env', '.env.local'] as const) {
39
+ try {
40
+ const content = await readFile(resolve(cwd, file), 'utf-8')
41
+ for (const [key, value] of Object.entries(parseDotenv(content))) {
42
+ if (merged[key] === undefined || merged[key] === '') {
43
+ merged[key] = value
44
+ }
45
+ }
46
+ } catch {
47
+ // Missing env file is normal.
48
+ }
49
+ }
50
+
51
+ return merged
52
+ }