@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +296 -36
- package/CHANGELOG.md +14 -0
- package/dist/__tests__/load-dotenv.test.d.ts +2 -0
- package/dist/__tests__/load-dotenv.test.d.ts.map +1 -0
- package/dist/__tests__/load-dotenv.test.js +40 -0
- package/dist/__tests__/load-dotenv.test.js.map +1 -0
- package/dist/__tests__/mcp-init.test.d.ts +2 -0
- package/dist/__tests__/mcp-init.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-init.test.js +100 -0
- package/dist/__tests__/mcp-init.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +126 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/load-dotenv.d.ts +11 -0
- package/dist/utils/load-dotenv.d.ts.map +1 -0
- package/dist/utils/load-dotenv.js +47 -0
- package/dist/utils/load-dotenv.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/load-dotenv.test.ts +53 -0
- package/src/__tests__/mcp-init.test.ts +125 -0
- package/src/commands/doctor.ts +4 -1
- package/src/commands/mcp.ts +181 -0
- package/src/index.ts +2 -0
- package/src/utils/load-dotenv.ts +52 -0
|
@@ -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
|
+
}
|