@actuate-media/cli 0.8.0 → 0.11.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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +288 -28
- package/CHANGELOG.md +26 -0
- package/README.md +30 -0
- package/dist/__tests__/form-seed.test.d.ts +2 -0
- package/dist/__tests__/form-seed.test.d.ts.map +1 -0
- package/dist/__tests__/form-seed.test.js +79 -0
- package/dist/__tests__/form-seed.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/__tests__/seed.test.js +73 -0
- package/dist/__tests__/seed.test.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/commands/seed.d.ts +30 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +146 -21
- package/dist/commands/seed.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/form-seed.d.ts +15 -0
- package/dist/utils/form-seed.d.ts.map +1 -0
- package/dist/utils/form-seed.js +97 -0
- package/dist/utils/form-seed.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/form-seed.test.ts +91 -0
- package/src/__tests__/mcp-init.test.ts +125 -0
- package/src/__tests__/seed.test.ts +97 -0
- package/src/commands/mcp.ts +181 -0
- package/src/commands/seed.ts +167 -21
- package/src/index.ts +2 -0
- package/src/utils/form-seed.ts +137 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { Command } from 'commander'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { registerMcpCommand } from '../commands/mcp.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `actuate mcp:init` retrofits MCP editor configs onto existing projects.
|
|
11
|
+
* Pins the merge contract: other configured servers are never clobbered, an
|
|
12
|
+
* existing `actuate` entry needs `--force`, and both config paths end up
|
|
13
|
+
* git-ignored (they hold a real API key once configured).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let projectDir: string
|
|
17
|
+
let originalCwd: string
|
|
18
|
+
|
|
19
|
+
async function run(args: string[] = []): Promise<void> {
|
|
20
|
+
const program = new Command()
|
|
21
|
+
program.exitOverride()
|
|
22
|
+
registerMcpCommand(program)
|
|
23
|
+
await program.parseAsync(['mcp:init', ...args], { from: 'user' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readJson(relPath: string): Promise<any> {
|
|
27
|
+
return JSON.parse(await readFile(path.join(projectDir, relPath), 'utf8'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
originalCwd = process.cwd()
|
|
32
|
+
projectDir = await mkdtemp(path.join(tmpdir(), 'actuate-mcp-init-'))
|
|
33
|
+
process.chdir(projectDir)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
process.chdir(originalCwd)
|
|
38
|
+
process.exitCode = 0
|
|
39
|
+
await rm(projectDir, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('mcp:init', () => {
|
|
43
|
+
it('creates both editor configs with placeholder key and git-ignores them', async () => {
|
|
44
|
+
await writeFile(path.join(projectDir, '.gitignore'), 'node_modules\n', 'utf8')
|
|
45
|
+
await run()
|
|
46
|
+
|
|
47
|
+
const cursor = await readJson('.cursor/mcp.json')
|
|
48
|
+
expect(cursor.mcpServers.actuate.command).toBe('npx')
|
|
49
|
+
expect(cursor.mcpServers.actuate.args).toEqual(['-y', '@actuate-media/mcp-server'])
|
|
50
|
+
expect(cursor.mcpServers.actuate.env.ACTUATE_BASE_URL).toBe('http://localhost:3000')
|
|
51
|
+
expect(cursor.mcpServers.actuate.env.ACTUATE_API_KEY).toBe('act_sk_REPLACE_WITH_YOUR_KEY')
|
|
52
|
+
|
|
53
|
+
const vscode = await readJson('.vscode/mcp.json')
|
|
54
|
+
expect(vscode.servers.actuate.type).toBe('stdio')
|
|
55
|
+
|
|
56
|
+
const gitignore = await readFile(path.join(projectDir, '.gitignore'), 'utf8')
|
|
57
|
+
expect(gitignore).toContain('node_modules')
|
|
58
|
+
expect(gitignore).toContain('.cursor/mcp.json')
|
|
59
|
+
expect(gitignore).toContain('.vscode/mcp.json')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('applies --url and --key to both configs', async () => {
|
|
63
|
+
await run(['--url', 'https://example.com', '--key', 'act_sk_real123'])
|
|
64
|
+
|
|
65
|
+
const cursor = await readJson('.cursor/mcp.json')
|
|
66
|
+
expect(cursor.mcpServers.actuate.env).toEqual({
|
|
67
|
+
ACTUATE_BASE_URL: 'https://example.com',
|
|
68
|
+
ACTUATE_API_KEY: 'act_sk_real123',
|
|
69
|
+
})
|
|
70
|
+
const vscode = await readJson('.vscode/mcp.json')
|
|
71
|
+
expect(vscode.servers.actuate.env.ACTUATE_BASE_URL).toBe('https://example.com')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('merges into an existing config without clobbering other servers', async () => {
|
|
75
|
+
await mkdir(path.join(projectDir, '.cursor'), { recursive: true })
|
|
76
|
+
await writeFile(
|
|
77
|
+
path.join(projectDir, '.cursor', 'mcp.json'),
|
|
78
|
+
JSON.stringify({ mcpServers: { sentry: { command: 'sentry-mcp' } } }),
|
|
79
|
+
'utf8',
|
|
80
|
+
)
|
|
81
|
+
await run()
|
|
82
|
+
|
|
83
|
+
const cursor = await readJson('.cursor/mcp.json')
|
|
84
|
+
expect(cursor.mcpServers.sentry).toEqual({ command: 'sentry-mcp' })
|
|
85
|
+
expect(cursor.mcpServers.actuate).toBeDefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('leaves an existing actuate entry alone unless --force is passed', async () => {
|
|
89
|
+
await mkdir(path.join(projectDir, '.cursor'), { recursive: true })
|
|
90
|
+
const existing = {
|
|
91
|
+
mcpServers: { actuate: { command: 'custom', env: { ACTUATE_BASE_URL: 'https://keep.me' } } },
|
|
92
|
+
}
|
|
93
|
+
await writeFile(path.join(projectDir, '.cursor', 'mcp.json'), JSON.stringify(existing), 'utf8')
|
|
94
|
+
|
|
95
|
+
await run()
|
|
96
|
+
let cursor = await readJson('.cursor/mcp.json')
|
|
97
|
+
expect(cursor.mcpServers.actuate.command).toBe('custom')
|
|
98
|
+
|
|
99
|
+
await run(['--force', '--url', 'https://new.example.com'])
|
|
100
|
+
cursor = await readJson('.cursor/mcp.json')
|
|
101
|
+
expect(cursor.mcpServers.actuate.command).toBe('npx')
|
|
102
|
+
expect(cursor.mcpServers.actuate.env.ACTUATE_BASE_URL).toBe('https://new.example.com')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('fails on invalid JSON without --force, overwrites with --force', async () => {
|
|
106
|
+
await mkdir(path.join(projectDir, '.cursor'), { recursive: true })
|
|
107
|
+
await writeFile(path.join(projectDir, '.cursor', 'mcp.json'), '{ not json', 'utf8')
|
|
108
|
+
|
|
109
|
+
await run()
|
|
110
|
+
expect(process.exitCode).toBe(1)
|
|
111
|
+
process.exitCode = 0
|
|
112
|
+
|
|
113
|
+
await run(['--force'])
|
|
114
|
+
const cursor = await readJson('.cursor/mcp.json')
|
|
115
|
+
expect(cursor.mcpServers.actuate).toBeDefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('does not duplicate gitignore entries on re-runs', async () => {
|
|
119
|
+
await run()
|
|
120
|
+
await run(['--force'])
|
|
121
|
+
const gitignore = await readFile(path.join(projectDir, '.gitignore'), 'utf8')
|
|
122
|
+
const count = gitignore.split('\n').filter((l) => l.trim() === '.cursor/mcp.json').length
|
|
123
|
+
expect(count).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -130,6 +130,103 @@ describe('seed document creation', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
describe('seed document upsert', () => {
|
|
134
|
+
function makeDb(existing: { id: string; status: string; publishedAt: Date | null } | null) {
|
|
135
|
+
const tx = {
|
|
136
|
+
document: {
|
|
137
|
+
create: vi.fn().mockResolvedValue({ id: 'doc-new' }),
|
|
138
|
+
update: vi.fn().mockResolvedValue({ id: existing?.id ?? 'doc-new' }),
|
|
139
|
+
},
|
|
140
|
+
version: { create: vi.fn() },
|
|
141
|
+
}
|
|
142
|
+
const db = {
|
|
143
|
+
document: { findFirst: vi.fn().mockResolvedValue(existing) },
|
|
144
|
+
$transaction: vi.fn(async (fn: any) => fn(tx)),
|
|
145
|
+
}
|
|
146
|
+
return { db, tx }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const doc = {
|
|
150
|
+
collection: 'pages',
|
|
151
|
+
status: 'PUBLISHED',
|
|
152
|
+
data: { title: 'Home v2', slug: 'home' },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('updates an existing document matched by collection + slug', async () => {
|
|
156
|
+
const publishedAt = new Date('2026-01-01T00:00:00Z')
|
|
157
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'PUBLISHED', publishedAt })
|
|
158
|
+
|
|
159
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
160
|
+
|
|
161
|
+
expect(result).toBe('updated')
|
|
162
|
+
expect(db.document.findFirst).toHaveBeenCalledWith({
|
|
163
|
+
where: { collection: 'pages', slug: 'home', deletedAt: null },
|
|
164
|
+
select: { id: true, status: true, publishedAt: true },
|
|
165
|
+
})
|
|
166
|
+
expect(tx.document.create).not.toHaveBeenCalled()
|
|
167
|
+
expect(tx.document.update).toHaveBeenCalledWith({
|
|
168
|
+
where: { id: 'doc-1' },
|
|
169
|
+
data: expect.objectContaining({
|
|
170
|
+
title: 'Home v2',
|
|
171
|
+
status: 'PUBLISHED',
|
|
172
|
+
// Re-publishing an already-published doc keeps the original timestamp.
|
|
173
|
+
publishedAt,
|
|
174
|
+
updatedById: 'admin-1',
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
expect(tx.version.create).toHaveBeenCalledWith({
|
|
178
|
+
data: expect.objectContaining({ documentId: 'doc-1', changeType: 'UPDATE' }),
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('stamps publishedAt on the transition into PUBLISHED', async () => {
|
|
183
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
184
|
+
|
|
185
|
+
await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
186
|
+
|
|
187
|
+
expect(tx.document.update).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
data: expect.objectContaining({ publishedAt: expect.any(Date) }),
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('creates when no document matches', async () => {
|
|
195
|
+
const { db, tx } = makeDb(null)
|
|
196
|
+
|
|
197
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
198
|
+
|
|
199
|
+
expect(result).toBe('created')
|
|
200
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
201
|
+
expect(tx.document.update).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('always creates slugless documents (no upsert key)', async () => {
|
|
205
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
206
|
+
|
|
207
|
+
const result = await createSeedDocument(
|
|
208
|
+
db,
|
|
209
|
+
'admin-1',
|
|
210
|
+
{ collection: 'pages', status: 'DRAFT', data: { title: 'No slug' } },
|
|
211
|
+
{ upsert: true },
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result).toBe('created')
|
|
215
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
216
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not look up existing documents without the upsert flag', async () => {
|
|
220
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
221
|
+
|
|
222
|
+
const result = await createSeedDocument(db, 'admin-1', doc)
|
|
223
|
+
|
|
224
|
+
expect(result).toBe('created')
|
|
225
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
226
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
133
230
|
describe('demo navigations', () => {
|
|
134
231
|
// The scaffold site layout fetches menus by the `main` and `footer` slugs, so
|
|
135
232
|
// the demo seed must provide exactly those — guard against slug drift.
|
|
@@ -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
|
+
}
|