@fugood/bricks-ctor 2.25.0-beta.50 → 2.25.0-beta.51
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/compile/__tests__/index.test.js +22 -1
- package/compile/__tests__/util.test.js +20 -0
- package/compile/index.ts +5 -2
- package/compile/util.ts +8 -3
- package/package.json +3 -3
- package/tools/__tests__/_mcp-config.test.ts +67 -0
- package/tools/_mcp-config.ts +42 -0
- package/tools/postinstall.ts +17 -37
|
@@ -14,7 +14,7 @@ import os from 'node:os'
|
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
|
|
16
16
|
import { sh } from '../../tools/_shell'
|
|
17
|
-
import { checkConfig, compile } from '../index'
|
|
17
|
+
import { checkConfig, compile, compileTestCase } from '../index'
|
|
18
18
|
|
|
19
19
|
const SUBSPACE_ID = 'SUBSPACE_00000000-0000-0000-0000-000000000001'
|
|
20
20
|
const CANVAS_ID = 'CANVAS_00000000-0000-0000-0000-000000000001'
|
|
@@ -362,4 +362,25 @@ describe('compile linked-module subspace', () => {
|
|
|
362
362
|
expect(first.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
|
|
363
363
|
expect(second.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
|
|
364
364
|
})
|
|
365
|
+
|
|
366
|
+
test('compileTestCase resolves a jump_to getter to its id (survives serialization)', () => {
|
|
367
|
+
// Regression: jump_cond[].jump_to may be a getter (() => TestCase) for dynamic case ids — the
|
|
368
|
+
// project generator emits `jump_to: () => caseVar`. compileTestCase passed it through verbatim
|
|
369
|
+
// (unlike the `run` array, which resolves getters via compileRunElement), so JSON.stringify of
|
|
370
|
+
// the compiled config dropped the function and the conditional jump silently vanished.
|
|
371
|
+
const compiled = compileTestCase({
|
|
372
|
+
__typename: 'TestCase',
|
|
373
|
+
id: 'TEST_CASE_src',
|
|
374
|
+
name: 'src',
|
|
375
|
+
run: [],
|
|
376
|
+
jump_cond: [
|
|
377
|
+
{ type: 'status', status: 'finished', jump_to: () => ({ id: 'TEST_CASE_target' }) },
|
|
378
|
+
{ type: 'status', status: 'failed', jump_to: 'TEST_CASE_literal' },
|
|
379
|
+
],
|
|
380
|
+
})
|
|
381
|
+
// The compiled config is serialized to disk, so the value must survive JSON round-tripping.
|
|
382
|
+
const serialized = JSON.parse(JSON.stringify(compiled))
|
|
383
|
+
expect(serialized.jump_cond[0].jump_to).toBe('TEST_CASE_target') // getter resolved to its id
|
|
384
|
+
expect(serialized.jump_cond[1].jump_to).toBe('TEST_CASE_literal') // string passes through
|
|
385
|
+
})
|
|
365
386
|
})
|
|
@@ -65,6 +65,26 @@ describe('validateConfig', () => {
|
|
|
65
65
|
expect(() => validateConfig(baseConfig())).not.toThrow()
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
// Regression: the overlap checks tested `config.inputs[id]` (the OBJECT_SET path) for
|
|
69
|
+
// truthiness instead of asking whether `id` is an input key. An empty path ('' is
|
|
70
|
+
// OBJECT_SET's default "set whole object") is a legitimate input value, so an input with
|
|
71
|
+
// an empty path that also targets output/error/outputs slipped the guard and compiled into
|
|
72
|
+
// a node double-wired as both input and output.
|
|
73
|
+
test('throws when output collides with an input whose path is empty', () => {
|
|
74
|
+
const config = baseConfig({ inputs: { a: '' }, output: 'a' })
|
|
75
|
+
expect(() => validateConfig(config)).toThrow(/key: output/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('throws when error collides with an input whose path is empty', () => {
|
|
79
|
+
const config = baseConfig({ inputs: { a: '' }, error: 'a' })
|
|
80
|
+
expect(() => validateConfig(config)).toThrow(/key: error/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('throws when an outputs entry references an input whose path is empty', () => {
|
|
84
|
+
const config = baseConfig({ inputs: { a: '' }, outputs: { x: ['a'] } })
|
|
85
|
+
expect(() => validateConfig(config)).toThrow(/key: outputs/)
|
|
86
|
+
})
|
|
87
|
+
|
|
68
88
|
test('throws when error and output target the same id', () => {
|
|
69
89
|
const config = baseConfig({ output: 'shared', error: 'shared' })
|
|
70
90
|
expect(() => validateConfig(config)).toThrow(/key: error\/output/)
|
package/compile/index.ts
CHANGED
|
@@ -584,7 +584,7 @@ function compileRunArray(run: unknown[]): unknown[] {
|
|
|
584
584
|
/**
|
|
585
585
|
* Compile typed TestCase to raw format (strips __typename)
|
|
586
586
|
*/
|
|
587
|
-
const compileTestCase = (testCase: TestCase) => ({
|
|
587
|
+
export const compileTestCase = (testCase: TestCase) => ({
|
|
588
588
|
id: testCase.id,
|
|
589
589
|
name: testCase.name,
|
|
590
590
|
hide_short_ref: testCase.hideShortRef,
|
|
@@ -606,7 +606,10 @@ const compileTestCase = (testCase: TestCase) => ({
|
|
|
606
606
|
variable: cond.variable,
|
|
607
607
|
operator: cond.operator,
|
|
608
608
|
value: cond.value,
|
|
609
|
-
jump_to
|
|
609
|
+
// `jump_to` may be a getter (() => TestCase) for dynamic case ids (the project generator
|
|
610
|
+
// emits this form). Resolve it to its id like the `run` array does — otherwise the function
|
|
611
|
+
// is JSON-serialized to nothing and the conditional jump silently vanishes from the config.
|
|
612
|
+
jump_to: compileRunElement(cond.jump_to),
|
|
610
613
|
}
|
|
611
614
|
}),
|
|
612
615
|
})
|
package/compile/util.ts
CHANGED
|
@@ -18,13 +18,18 @@ const errorMsg = 'Not allow duplicate set property id between inputs / outputs /
|
|
|
18
18
|
export const validateConfig = (config: ScriptConfig) => {
|
|
19
19
|
// Skip input/output overlap validation in manual mode (allows same data node in both)
|
|
20
20
|
if (config.trigger_mode === 'manual') return
|
|
21
|
-
|
|
21
|
+
// `inputs` is keyed by data-node id with the OBJECT_SET path as the *value*. The overlap
|
|
22
|
+
// check is "is this id also an input?" — a key-existence question — so test key presence,
|
|
23
|
+
// not the path's truthiness. An empty path ('' = OBJECT_SET's default "set whole object")
|
|
24
|
+
// is a legitimate input value that would otherwise slip the overlap guard.
|
|
25
|
+
const isInput = (id: string) => Object.prototype.hasOwnProperty.call(config.inputs, id)
|
|
26
|
+
if (config.error && isInput(config.error)) {
|
|
22
27
|
throw new Error(`${errorMsg}. key: error`)
|
|
23
28
|
}
|
|
24
|
-
if (config.output && config.
|
|
29
|
+
if (config.output && isInput(config.output)) {
|
|
25
30
|
throw new Error(`${errorMsg}. key: output`)
|
|
26
31
|
}
|
|
27
|
-
if (Object.values(config.outputs).some((value) => value.some(
|
|
32
|
+
if (Object.values(config.outputs).some((value) => value.some(isInput))) {
|
|
28
33
|
throw new Error(`${errorMsg}. key: outputs`)
|
|
29
34
|
}
|
|
30
35
|
// The same data-node id reused across the output-side targets (output / error / outputs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fugood/bricks-ctor",
|
|
3
|
-
"version": "2.25.0-beta.
|
|
3
|
+
"version": "2.25.0-beta.51",
|
|
4
4
|
"main": "index.ts",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc --noEmit",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@babel/parser": "7.28.5",
|
|
12
12
|
"@babel/traverse": "7.28.5",
|
|
13
13
|
"@babel/types": "7.28.5",
|
|
14
|
-
"@fugood/bricks-cli": "^2.25.0-beta.
|
|
14
|
+
"@fugood/bricks-cli": "^2.25.0-beta.51",
|
|
15
15
|
"@huggingface/gguf": "^0.3.2",
|
|
16
16
|
"@iarna/toml": "^3.0.0",
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"oxfmt": "^0.36.0"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "9a6ddecabc4a2e11fc6ae9a256de730cd3d744ca"
|
|
33
33
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtemp, readFile, writeFile, rm } from 'fs/promises'
|
|
2
|
+
import { tmpdir } from 'os'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import { handleMcpConfigOverride } from '../_mcp-config'
|
|
5
|
+
|
|
6
|
+
// bricks-project's tsconfig has no @types/jest, so declare the globals this test uses
|
|
7
|
+
// (mirrors tools/__tests__/_cli-error.test.ts).
|
|
8
|
+
declare const describe: (name: string, fn: () => void) => void
|
|
9
|
+
declare const it: (name: string, fn: () => void | Promise<void>) => void
|
|
10
|
+
declare const expect: (actual: unknown) => {
|
|
11
|
+
toBe: (expected: unknown) => void
|
|
12
|
+
toEqual: (expected: unknown) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const projectMcpServer = { command: 'bun', args: ['mcp-server.ts'] }
|
|
16
|
+
|
|
17
|
+
const withTempMcp = async (contents: string | null, fn: (p: string) => Promise<void>) => {
|
|
18
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'bf-mcp-'))
|
|
19
|
+
const mcpPath = path.join(dir, '.mcp.json')
|
|
20
|
+
if (contents !== null) await writeFile(mcpPath, contents)
|
|
21
|
+
try {
|
|
22
|
+
await fn(mcpPath)
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(dir, { recursive: true, force: true })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('handleMcpConfigOverride', () => {
|
|
29
|
+
it('injects bricks-ctor while preserving the user other servers', async () => {
|
|
30
|
+
await withTempMcp(
|
|
31
|
+
JSON.stringify({ mcpServers: { smartbear: { command: 'npx' } } }),
|
|
32
|
+
async (p) => {
|
|
33
|
+
await handleMcpConfigOverride(p, projectMcpServer)
|
|
34
|
+
const result = JSON.parse(await readFile(p, 'utf-8'))
|
|
35
|
+
expect(result.mcpServers.smartbear).toEqual({ command: 'npx' })
|
|
36
|
+
expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not clobber a malformed .mcp.json (regression: user servers were wiped)', async () => {
|
|
42
|
+
// trailing comma -> JSON.parse throws; the whole file used to be overwritten with the
|
|
43
|
+
// default, silently deleting every other configured server.
|
|
44
|
+
const malformed = '{ "mcpServers": { "smartbear": { "command": "npx" }, } }'
|
|
45
|
+
await withTempMcp(malformed, async (p) => {
|
|
46
|
+
await handleMcpConfigOverride(p, projectMcpServer)
|
|
47
|
+
expect(await readFile(p, 'utf-8')).toBe(malformed)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('backfills mcpServers when missing without dropping other top-level keys', async () => {
|
|
52
|
+
await withTempMcp(JSON.stringify({ otherKey: 'keep-me' }), async (p) => {
|
|
53
|
+
await handleMcpConfigOverride(p, projectMcpServer)
|
|
54
|
+
const result = JSON.parse(await readFile(p, 'utf-8'))
|
|
55
|
+
expect(result.otherKey).toBe('keep-me')
|
|
56
|
+
expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('writes a default config when no .mcp.json exists', async () => {
|
|
61
|
+
await withTempMcp(null, async (p) => {
|
|
62
|
+
await handleMcpConfigOverride(p, projectMcpServer)
|
|
63
|
+
const result = JSON.parse(await readFile(p, 'utf-8'))
|
|
64
|
+
expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from 'fs/promises'
|
|
2
|
+
|
|
3
|
+
const exists = async (f: string) => {
|
|
4
|
+
try {
|
|
5
|
+
await stat(f)
|
|
6
|
+
return true
|
|
7
|
+
} catch {
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Refresh the `bricks-ctor` entry in a project's `.mcp.json` while preserving every other MCP
|
|
13
|
+
// server the user configured. A malformed `.mcp.json` is left untouched (with a warning)
|
|
14
|
+
// rather than overwritten with the default — clobbering it would silently delete the user's
|
|
15
|
+
// other server entries. This mirrors `setupClaudeAutoMode`'s handling of a malformed
|
|
16
|
+
// `settings.local.json` in postinstall.ts.
|
|
17
|
+
export const handleMcpConfigOverride = async (mcpConfigPath: string, projectMcpServer: object) => {
|
|
18
|
+
let mcpConfig: { mcpServers: Record<string, unknown> }
|
|
19
|
+
if (await exists(mcpConfigPath)) {
|
|
20
|
+
let parsed: unknown
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(await readFile(mcpConfigPath, 'utf-8'))
|
|
23
|
+
} catch {
|
|
24
|
+
console.warn(`Skipping .mcp.json update; ${mcpConfigPath} is not valid JSON`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
mcpConfig =
|
|
28
|
+
parsed && typeof parsed === 'object'
|
|
29
|
+
? (parsed as { mcpServers: Record<string, unknown> })
|
|
30
|
+
: { mcpServers: {} }
|
|
31
|
+
if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== 'object') {
|
|
32
|
+
mcpConfig.mcpServers = {}
|
|
33
|
+
}
|
|
34
|
+
mcpConfig.mcpServers['bricks-ctor'] = projectMcpServer
|
|
35
|
+
delete mcpConfig.mcpServers['bricks-project']
|
|
36
|
+
} else {
|
|
37
|
+
mcpConfig = { mcpServers: { 'bricks-ctor': projectMcpServer } }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await writeFile(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`)
|
|
41
|
+
console.log(`Updated ${mcpConfigPath}`)
|
|
42
|
+
}
|
package/tools/postinstall.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from 'fs/promises'
|
|
13
13
|
import * as path from 'path'
|
|
14
14
|
import TOML from '@iarna/toml'
|
|
15
|
+
import { handleMcpConfigOverride } from './_mcp-config'
|
|
15
16
|
|
|
16
17
|
const cwd = process.cwd()
|
|
17
18
|
const projectSkillsDir = path.join(cwd, '.bricks', 'skills')
|
|
@@ -80,41 +81,13 @@ type CodexMcpConfig = {
|
|
|
80
81
|
mcp_servers: Record<string, typeof codexProjectMcpServer | typeof projectMcpServer>
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
// Claude Code and AGENTS.md projects both use the shared project .mcp.json file.
|
|
84
|
-
const defaultMcpConfig = {
|
|
85
|
-
mcpServers: {
|
|
86
|
-
'bricks-ctor': projectMcpServer,
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const handleMcpConfigOverride = async (mcpConfigPath: string) => {
|
|
91
|
-
let mcpConfig: { mcpServers: Record<string, typeof projectMcpServer> } | null = null
|
|
92
|
-
if (await exists(mcpConfigPath)) {
|
|
93
|
-
const configStr = await readFile(mcpConfigPath, 'utf-8')
|
|
94
|
-
try {
|
|
95
|
-
mcpConfig = JSON.parse(configStr)
|
|
96
|
-
if (!mcpConfig?.mcpServers) throw new Error('mcpServers is not defined')
|
|
97
|
-
mcpConfig.mcpServers['bricks-ctor'] = projectMcpServer
|
|
98
|
-
delete mcpConfig.mcpServers['bricks-project']
|
|
99
|
-
} catch {
|
|
100
|
-
mcpConfig = defaultMcpConfig
|
|
101
|
-
}
|
|
102
|
-
} else {
|
|
103
|
-
mcpConfig = defaultMcpConfig
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
await writeFile(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`)
|
|
107
|
-
|
|
108
|
-
console.log(`Updated ${mcpConfigPath}`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
84
|
const hasClaudeCode = await exists(`${cwd}/CLAUDE.md`)
|
|
112
85
|
const hasAgentsMd = await exists(`${cwd}/AGENTS.md`)
|
|
113
86
|
|
|
114
87
|
if (hasClaudeCode || hasAgentsMd) {
|
|
115
88
|
// Keep the workspace-level JSON MCP config aligned for tools that read .mcp.json.
|
|
116
89
|
const mcpConfigPath = `${cwd}/.mcp.json`
|
|
117
|
-
await handleMcpConfigOverride(mcpConfigPath)
|
|
90
|
+
await handleMcpConfigOverride(mcpConfigPath, projectMcpServer)
|
|
118
91
|
}
|
|
119
92
|
|
|
120
93
|
const copyMissingSkills = async (sourceDir: string, targetDir: string) => {
|
|
@@ -282,18 +255,25 @@ if (hasAgentsMd) {
|
|
|
282
255
|
}
|
|
283
256
|
|
|
284
257
|
const handleCodexMcpConfigOverride = async (mcpConfigPath: string) => {
|
|
285
|
-
let mcpConfig: CodexMcpConfig
|
|
258
|
+
let mcpConfig: CodexMcpConfig
|
|
286
259
|
if (await exists(mcpConfigPath)) {
|
|
287
|
-
|
|
260
|
+
let parsed: unknown
|
|
288
261
|
try {
|
|
289
|
-
|
|
290
|
-
if (!parsed?.mcp_servers) throw new Error('mcp_servers is not defined')
|
|
291
|
-
mcpConfig = { mcp_servers: parsed.mcp_servers }
|
|
292
|
-
mcpConfig.mcp_servers['bricks-ctor'] = codexProjectMcpServer
|
|
293
|
-
delete mcpConfig.mcp_servers['bricks-project']
|
|
262
|
+
parsed = TOML.parse(await readFile(mcpConfigPath, 'utf-8'))
|
|
294
263
|
} catch {
|
|
295
|
-
|
|
264
|
+
// A malformed config is left untouched (with a warning) rather than overwritten with
|
|
265
|
+
// the default — clobbering it would silently delete the user's other server entries.
|
|
266
|
+
// Mirrors handleMcpConfigOverride's handling of a malformed .mcp.json.
|
|
267
|
+
console.warn(`Skipping .codex/config.toml update; ${mcpConfigPath} is not valid TOML`)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
mcpConfig =
|
|
271
|
+
parsed && typeof parsed === 'object' ? (parsed as CodexMcpConfig) : { mcp_servers: {} }
|
|
272
|
+
if (!mcpConfig.mcp_servers || typeof mcpConfig.mcp_servers !== 'object') {
|
|
273
|
+
mcpConfig.mcp_servers = {}
|
|
296
274
|
}
|
|
275
|
+
mcpConfig.mcp_servers['bricks-ctor'] = codexProjectMcpServer
|
|
276
|
+
delete mcpConfig.mcp_servers['bricks-project']
|
|
297
277
|
} else {
|
|
298
278
|
mcpConfig = defaultCodexMcpConfig
|
|
299
279
|
}
|