@fugood/bricks-ctor 2.24.8 → 2.24.9

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/index.ts CHANGED
@@ -6,7 +6,7 @@ import omit from 'lodash/omit'
6
6
  import { parse as parseAST } from 'acorn'
7
7
  import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
8
8
  import escodegen from 'escodegen'
9
- import { makeId } from '../utils/id'
9
+ import { makeSeededId } from '../utils/id'
10
10
  import { generateCalulationMap } from './util'
11
11
  import { templateActionNameMap } from './action-name-map'
12
12
  import { templateEventPropsMap } from '../utils/event-props'
@@ -181,10 +181,17 @@ const compileEvents = (
181
181
 
182
182
  let handlerKey
183
183
  let handlerTemplateKey
184
- if (handler === 'system' || typeof handler === 'string') {
185
- if (handler.startsWith('SUBSPACE_')) handlerKey = handler
186
- else handlerKey = handler.toUpperCase()
187
- if (handlerKey === 'SYSTEM') handlerTemplateKey = 'SYSTEM'
184
+ if (typeof handler === 'string') {
185
+ // Only the literal 'system' handler is normalized to the SYSTEM template key.
186
+ // SubspaceID (SUBSPACE_*) and ItemBrickID handlers are kept verbatim: the runtime
187
+ // resolves them case-sensitively (see mapEventMapHandlersWithNewId), so uppercasing
188
+ // a mixed-case ItemBrickID would break handler-to-item event wiring.
189
+ if (handler === 'system') {
190
+ handlerKey = 'SYSTEM'
191
+ handlerTemplateKey = 'SYSTEM'
192
+ } else {
193
+ handlerKey = handler
194
+ }
188
195
  } else if (typeof handler === 'function') {
189
196
  let instance = handler()
190
197
  if (instance?.id) {
@@ -482,6 +489,7 @@ const preloadTypes = [
482
489
  'media-resource-audio',
483
490
  'media-resource-file',
484
491
  'lottie-file-uri',
492
+ 'rive-file-uri',
485
493
  'ggml-model-asset',
486
494
  'gguf-model-asset',
487
495
  'binary-asset',
@@ -570,7 +578,7 @@ function compileRunArray(run: unknown[]): unknown[] {
570
578
  /**
571
579
  * Compile typed TestCase to raw format (strips __typename)
572
580
  */
573
- const compileTestCase = (testCase: TestCase) => ({
581
+ export const compileTestCase = (testCase: TestCase) => ({
574
582
  id: testCase.id,
575
583
  name: testCase.name,
576
584
  hide_short_ref: testCase.hideShortRef,
@@ -592,7 +600,10 @@ const compileTestCase = (testCase: TestCase) => ({
592
600
  variable: cond.variable,
593
601
  operator: cond.operator,
594
602
  value: cond.value,
595
- jump_to: cond.jump_to,
603
+ // `jump_to` may be a getter (() => TestCase) for dynamic case ids (the project generator
604
+ // emits this form). Resolve it to its id like the `run` array does — otherwise the function
605
+ // is JSON-serialized to nothing and the conditional jump silently vanishes from the config.
606
+ jump_to: compileRunElement(cond.jump_to),
596
607
  }
597
608
  }),
598
609
  })
@@ -719,7 +730,14 @@ export const compile = async (app: Application) => {
719
730
  // validation (root_canvas_id is required before the conditional
720
731
  // schema fix is published).
721
732
  if (subspace.module?.link) {
722
- const placeholderCanvasId = makeId('canvas')
733
+ // Seed the placeholder id from the (stable) subspace id. `makeId('canvas')` would take
734
+ // the count-fallback branch (a process-global counter that is never reset), so the
735
+ // placeholder id depended on how many prior count-fallback ids had been minted — making
736
+ // it differ between recompiles and breaking compile's byte-stable-output contract
737
+ // (phantom config-change ops). `makeSeededId` keeps no global state, so identical source
738
+ // recompiles to an identical id. (`makeId('canvas', alias)` would instead throw
739
+ // "Duplicate makeId alias" on the second compile in a long-lived process.)
740
+ const placeholderCanvasId = makeSeededId('canvas', `${subspaceId}:module-placeholder`)
723
741
  subspaceMap[subspaceId] = {
724
742
  title: subspace.title,
725
743
  description: subspace.description,
@@ -1412,12 +1430,7 @@ export const compile = async (app: Application) => {
1412
1430
  : null,
1413
1431
  }
1414
1432
 
1415
- Object.assign(
1416
- calc,
1417
- generateCalulationMap(calc.script_config, {
1418
- snapshotMode: process.env.BRICKS_SNAPSHOT_MODE === '1',
1419
- }),
1420
- )
1433
+ Object.assign(calc, generateCalulationMap(calc.script_config, dataCalcId))
1421
1434
  }
1422
1435
  map[dataCalcId] = calc
1423
1436
  return map
package/compile/util.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { makeId } from '../utils/id'
1
+ import { makeSeededId } from '../utils/id'
2
2
 
3
3
  type ScriptConfig = {
4
4
  title?: string
@@ -18,30 +18,54 @@ 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
- if (config.error && config.inputs[config.error]) {
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.inputs[config.output]) {
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((id) => config.inputs[id]))) {
32
+ if (Object.values(config.outputs).some((value) => value.some(isInput))) {
28
33
  throw new Error(`${errorMsg}. key: outputs`)
29
34
  }
35
+ // The same data-node id reused across the output-side targets (output / error / outputs)
36
+ // also collides: generateCalulationMap spreads their node objects last-wins, so the later
37
+ // one silently overwrites the earlier wiring (e.g. error == output drops the error change
38
+ // link). The checks above only compare against `inputs`, so guard the output-side pairs
39
+ // too. (The same id appearing in multiple `outputs` entries is a supported last-wins case
40
+ // and stays allowed.)
41
+ if (config.error && config.output && config.error === config.output) {
42
+ throw new Error(`${errorMsg}. key: error/output`)
43
+ }
44
+ const outputsIds = Object.values(config.outputs).flat()
45
+ if (config.output && outputsIds.includes(config.output)) {
46
+ throw new Error(`${errorMsg}. key: output/outputs`)
47
+ }
48
+ if (config.error && outputsIds.includes(config.error)) {
49
+ throw new Error(`${errorMsg}. key: error/outputs`)
50
+ }
30
51
  }
31
52
 
32
53
  const padding = 15
33
54
  const layerXInterval = 300
34
55
  const layerYInterval = 150
35
56
 
36
- export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMode?: boolean }) => {
57
+ // `calcId` (the owning script calc's stable id) seeds every derived command-node id, so an
58
+ // unchanged calc recompiles to identical ids and editing one calc never shifts another's —
59
+ // keeping the compiled config byte-stable for change detection. See makeSeededId in utils/id.
60
+ export const generateCalulationMap = (config: ScriptConfig, calcId: string) => {
37
61
  validateConfig(config)
38
- const sandboxId = makeId('property_bank_command', opts)
39
- const sandboxErrorId = makeId('property_bank_command', opts)
40
- const sandboxResultId = makeId('property_bank_command', opts)
62
+ const sandboxId = makeSeededId('property_bank_command', `${calcId}:sandbox-run`)
63
+ const sandboxErrorId = makeSeededId('property_bank_command', `${calcId}:sandbox-error`)
64
+ const sandboxResultId = makeSeededId('property_bank_command', `${calcId}:sandbox-result`)
41
65
 
42
66
  const inputs = Object.entries(config.inputs).reduce(
43
67
  (acc, [key, value], index) => {
44
- const commandId = makeId('property_bank_command', opts)
68
+ const commandId = makeSeededId('property_bank_command', `${calcId}:input:${key}`)
45
69
  acc.map[key] = {
46
70
  type: 'data-node',
47
71
  properties: {},
@@ -123,7 +147,7 @@ export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMod
123
147
  let y = 0
124
148
  const outputs = Object.entries(config.outputs).reduce(
125
149
  (acc, [key, pbList], index) => {
126
- const commandId = makeId('property_bank_command', opts)
150
+ const commandId = makeSeededId('property_bank_command', `${calcId}:output:${key}`)
127
151
  acc.commandIdList.push(commandId)
128
152
  acc.map[commandId] = {
129
153
  type: 'command-node-object',
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.24.8",
3
+ "version": "2.24.9",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
7
7
  "build": "bun scripts/build.js"
8
8
  },
9
9
  "dependencies": {
10
- "@fugood/bricks-cli": "^2.24.8",
10
+ "@fugood/bricks-cli": "^2.24.9",
11
11
  "@huggingface/gguf": "^0.3.2",
12
12
  "@iarna/toml": "^3.0.0",
13
13
  "@modelcontextprotocol/sdk": "^1.15.0",
@@ -69,6 +69,8 @@ Useful flags:
69
69
 
70
70
  For ad-hoc CDP inspection against this local preview, connect any CDP client to `localhost:19852` — Chrome DevTools front-end works directly. For an agent-friendly CLI over CDP (screenshot, brick tree/query, input emulation, storage reads, runtime eval, network capture), the `bricks-cli` skill documents the `bricks devtools` command surface — read that skill if it is installed in this workspace. If it is not installed, run `bricks --help` and `bricks devtools --help`; the CLI's own help output is authoritative.
71
71
 
72
+ To inspect Data / Property Bank or storage state, prefer the dedicated `bricks devtools storage` subcommands — `storage data-bank get <S_xxxx>` (saved Data values), `storage system persist|memory`, `storage system get <key>` — over hand-written `runtime eval`. Reach for `runtime eval` only for *live* store internals that aren't persisted to a data bank (e.g. a current transient value: `runtime eval "system.data.property('S_xxxx', '<alias>')"`); don't reverse-engineer the `system.*` globals.
73
+
72
74
  ### Project Automations
73
75
 
74
76
  E2E tests authored in TypeScript inside the project (`AutomationTest` / `TestCase`). Test cases include:
@@ -118,6 +120,25 @@ Once DevTools is on, ask the user how they want to drive the verification — Ch
118
120
 
119
121
  For agent-driven CDP/MCP work against the device (`bricks devtools …` with `-a <ip> --passcode <pc>`, plus bridging the device MCP endpoint into an MCP client), the same `bricks-cli` skill referenced in Path 1 covers the on-device case — read it if installed. If not installed, run `bricks --help` and `bricks devtools --help` for the authoritative command listing.
120
122
 
123
+ ### Running real-device Automations from an agent
124
+
125
+ There is no `bricks devtools automation` subcommand. Use the DevTools runtime helpers exposed inside the app:
126
+
127
+ ```bash
128
+ bricks devtools runtime eval -a <ip> -p 19851 --passcode <pc> "Object.getOwnPropertyNames(automation).sort()" -j
129
+ bricks devtools runtime eval -a <ip> -p 19851 --passcode <pc> "automation.list()" -j
130
+ bricks devtools runtime eval -a <ip> -p 19851 --passcode <pc> "automation.run('<TEST_id>', { updateScreenshot: false })" --await -j
131
+ ```
132
+
133
+ `automation.run()` starts the device-side run and may return `null`; treat that as accepted, not as a pass/fail result. Wait for the run timeout/window, then verify completion through the app's own state and a screenshot. For app state, use live runtime reads such as:
134
+
135
+ ```bash
136
+ bricks devtools runtime eval -a <ip> -p 19851 --passcode <pc> "JSON.stringify({ result: system.data.property('<S_xxxx>', '<resultAlias>')?.value })" -j
137
+ bricks devtools screenshot -a <ip> -p 19851 --passcode <pc> -o /tmp/device-automation.png
138
+ ```
139
+
140
+ In CTOR Desktop sandboxed sessions, keep these as separate simple commands. Avoid multi-line shell scripts, `for` loops, brace expansion, and command substitution around `bricks devtools`; those can stay sandboxed and lose LAN/device access. After `bun update-app` or a device refresh, the DevTools socket may briefly drop, so wait and probe with one screenshot or one `runtime eval` before running the automation.
141
+
121
142
  ### Real-device side-effects warning
122
143
 
123
144
  Real devices fire real peripherals. Payment terminals charge. MQTT broadcasts on shared topics. BLE advertises to bystanders. Use a **staging** device for verification cycles; never iterate on a production-deployed device unless the user explicitly approves.
@@ -0,0 +1,17 @@
1
+ // Extract a human-readable message from a `bricks ... --json` failure payload.
2
+ //
3
+ // On failure the CLI prints `{ "error": { "message": "..." } }` (or the older
4
+ // `{ "error": "..." }`) to stdout/stderr. Earlier call sites built that message
5
+ // inside the same `try` that wrapped `JSON.parse`, so the `throw` was caught by
6
+ // its own `catch` and replaced with the raw JSON blob — the human-readable
7
+ // message never surfaced. Parsing here, outside any throw, avoids that trap.
8
+ export function extractCliErrorMessage(output: string, fallback: string): string {
9
+ try {
10
+ const { error } = JSON.parse(output)
11
+ const message = error?.message ?? error
12
+ if (typeof message === 'string' && message) return message
13
+ } catch {
14
+ // output is not JSON — fall through to the raw output below
15
+ }
16
+ return output || fallback
17
+ }
@@ -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/deploy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, readFile, writeFile } from 'node:fs/promises'
2
2
  import { parseArgs } from 'util'
3
3
  import { sh } from './_shell'
4
+ import { extractCliErrorMessage } from './_cli-error'
4
5
  import { buildCommitArgs } from './_git-author'
5
6
  import { writeLastPushedCommit } from './_last-pushed-commit'
6
7
 
@@ -162,12 +163,7 @@ const result = await sh`${args}`.quiet().nothrow()
162
163
 
163
164
  if (result.exitCode !== 0) {
164
165
  const output = result.stderr.toString() || result.stdout.toString()
165
- try {
166
- const json = JSON.parse(output)
167
- throw new Error(json.error || 'Release failed')
168
- } catch {
169
- throw new Error(output || 'Release failed')
170
- }
166
+ throw new Error(extractCliErrorMessage(output, 'Release failed'))
171
167
  }
172
168
 
173
169
  const output = JSON.parse(result.stdout.toString())
@@ -269,6 +269,28 @@ const fetchHFModelDetails = async (modelId: string): Promise<HFModel> => {
269
269
  // Example: Mixtral-8x22B-v0.1.IQ3_XS-00001-of-00005.gguf
270
270
  const ggufSplitPattern = /-(\d{5})-of-(\d{5})\.gguf$/
271
271
 
272
+ export const buildGGUFSplitFiles = (
273
+ filename: string,
274
+ splitTotal: string,
275
+ siblings: HFSibling[],
276
+ ): HFSibling[] => {
277
+ const siblingByFilename = new Map<string, HFSibling>()
278
+ for (const sibling of siblings) {
279
+ if (!siblingByFilename.has(sibling.rfilename)) siblingByFilename.set(sibling.rfilename, sibling)
280
+ }
281
+
282
+ return Array.from({ length: Number(splitTotal) }, (_, i) => {
283
+ const split = String(i + 1).padStart(5, '0')
284
+ const splitRFilename = filename.replace(ggufSplitPattern, `-${split}-of-${splitTotal}.gguf`)
285
+ const sibling = siblingByFilename.get(splitRFilename)
286
+ return {
287
+ rfilename: splitRFilename,
288
+ size: sibling?.size,
289
+ lfs: sibling?.lfs,
290
+ }
291
+ })
292
+ }
293
+
272
294
  export function register(server: McpServer) {
273
295
  server.tool(
274
296
  'huggingface_search',
@@ -657,19 +679,7 @@ export function register(server: McpServer) {
657
679
 
658
680
  if (isSplit) {
659
681
  const [, , splitTotal] = matched!
660
- const splitFiles = Array.from({ length: Number(splitTotal) }, (_, i) => {
661
- const split = String(i + 1).padStart(5, '0')
662
- const splitRFilename = filename.replace(
663
- ggufSplitPattern,
664
- `-${split}-of-${splitTotal}.gguf`,
665
- )
666
- const sibling = siblings.find((sb) => sb.rfilename === splitRFilename)
667
- return {
668
- rfilename: splitRFilename,
669
- size: sibling?.size,
670
- lfs: sibling?.lfs,
671
- }
672
- })
682
+ const splitFiles = buildGGUFSplitFiles(filename, splitTotal, siblings)
673
683
 
674
684
  const first = splitFiles[0]
675
685
  const rest = splitFiles.slice(1)
@@ -36,11 +36,31 @@ const iconList = Object.entries(glyphmap as Record<string, number>).map(([name,
36
36
  return { name, code, styles }
37
37
  })
38
38
 
39
- const iconFuse = new Fuse(iconList, {
39
+ const fuseOptions = {
40
40
  keys: ['name'],
41
41
  threshold: 0.3,
42
42
  includeScore: true,
43
- })
43
+ }
44
+
45
+ const iconFuse = new Fuse(iconList, fuseOptions)
46
+ const iconFuseByStyle = new Map<IconStyle, Fuse<(typeof iconList)[number]>>()
47
+
48
+ function getStyleFuse(style: IconStyle) {
49
+ let fuse = iconFuseByStyle.get(style)
50
+ if (!fuse) {
51
+ fuse = new Fuse(
52
+ iconList.filter((icon) => icon.styles.includes(style)),
53
+ fuseOptions,
54
+ )
55
+ iconFuseByStyle.set(style, fuse)
56
+ }
57
+ return fuse
58
+ }
59
+
60
+ export function searchIcons(query: string, limit: number, style?: IconStyle) {
61
+ if (style) return getStyleFuse(style).search(query, { limit })
62
+ return iconFuse.search(query, { limit })
63
+ }
44
64
 
45
65
  export function register(server: McpServer) {
46
66
  server.tool(
@@ -54,11 +74,7 @@ export function register(server: McpServer) {
54
74
  .describe('Filter by icon style'),
55
75
  },
56
76
  async ({ query, limit, style }) => {
57
- let results = iconFuse.search(query, { limit: style ? limit * 3 : limit })
58
-
59
- if (style) {
60
- results = results.filter((r) => r.item.styles.includes(style)).slice(0, limit)
61
- }
77
+ const results = searchIcons(query, limit, style)
62
78
 
63
79
  const icons = results.map((r) => ({
64
80
  name: r.item.name,
@@ -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 | null = null
258
+ let mcpConfig: CodexMcpConfig
286
259
  if (await exists(mcpConfigPath)) {
287
- const configStr = await readFile(mcpConfigPath, 'utf-8')
260
+ let parsed: unknown
288
261
  try {
289
- const parsed = TOML.parse(configStr) as Partial<CodexMcpConfig>
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
- mcpConfig = defaultCodexMcpConfig
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
  }
package/tools/pull.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
3
3
  import { join, relative } from 'node:path'
4
4
  import { format } from 'oxfmt'
5
5
  import { sh } from './_shell'
6
+ import { extractCliErrorMessage } from './_cli-error'
6
7
  import { buildCommitArgs } from './_git-author'
7
8
  import { readLastPushedCommit, writeLastPushedCommit } from './_last-pushed-commit'
8
9
 
@@ -68,12 +69,7 @@ const result = await sh`bricks ${command} project-pull ${app.id} --json`.quiet()
68
69
 
69
70
  if (result.exitCode !== 0) {
70
71
  const output = result.stderr.toString() || result.stdout.toString()
71
- try {
72
- const json = JSON.parse(output)
73
- throw new Error(json.error || 'Pull failed')
74
- } catch {
75
- throw new Error(output || 'Pull failed')
76
- }
72
+ throw new Error(extractCliErrorMessage(output, 'Pull failed'))
77
73
  }
78
74
 
79
75
  const { files, lastCommitId: serverLastCommitId } = JSON.parse(result.stdout.toString())
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
2
  import { parseArgs } from 'util'
3
3
  import { sh } from './_shell'
4
+ import { extractCliErrorMessage } from './_cli-error'
4
5
  import { buildCommitArgs } from './_git-author'
5
6
  import { writeLastPushedCommit } from './_last-pushed-commit'
6
7
 
@@ -99,12 +100,7 @@ const result = await sh`${args}`.quiet().nothrow()
99
100
 
100
101
  if (result.exitCode !== 0) {
101
102
  const output = result.stderr.toString() || result.stdout.toString()
102
- try {
103
- const json = JSON.parse(output)
104
- throw new Error(json.error?.message || json.error || 'Update failed')
105
- } catch {
106
- throw new Error(output || 'Update failed')
107
- }
103
+ throw new Error(extractCliErrorMessage(output, 'Update failed'))
108
104
  }
109
105
 
110
106
  const output = JSON.parse(result.stdout.toString())
@@ -22,7 +22,7 @@ export type DataCommandDatetimeDate = DataCommand & {
22
22
  outputs?: Array<DataCalcOutput<'result'> /* target: number */>
23
23
  }
24
24
 
25
- /* Day — Get day (Sunday is 7) */
25
+ /* Day — Get day of week (0-based: Sunday is 0, Saturday is 6) */
26
26
  export type DataCommandDatetimeDay = DataCommand & {
27
27
  __commandName: 'DATETIME_DAY'
28
28
  inputs?: Array<
@@ -64,7 +64,7 @@ export type DataCommandDatetimeMinute = DataCommand & {
64
64
  outputs?: Array<DataCalcOutput<'result'> /* target: number */>
65
65
  }
66
66
 
67
- /* Month — Get month (January is 1) */
67
+ /* Month — Get month (0-based: January is 0, December is 11) */
68
68
  export type DataCommandDatetimeMonth = DataCommand & {
69
69
  __commandName: 'DATETIME_MONTH'
70
70
  inputs?: Array<
package/utils/calc.ts CHANGED
@@ -28,6 +28,10 @@ export const generateDataCalculationMapEditorInfo = (
28
28
  DataCalculationData | DataCommand,
29
29
  Set<DataCalculationData | DataCommand>
30
30
  >()
31
+ const nodeById = new Map<string, DataCalculationData | DataCommand>()
32
+ for (const node of nodes) {
33
+ if ('id' in node) nodeById.set(node.id, node)
34
+ }
31
35
 
32
36
  // Analyze node connections
33
37
  nodes.forEach((node) => {
@@ -48,7 +52,7 @@ export const generateDataCalculationMapEditorInfo = (
48
52
  if (!connectedTo.has(node)) {
49
53
  connectedTo.set(node, new Set())
50
54
  }
51
- const sourceNode = nodes.find((n) => 'id' in n && n.id === conn.id)
55
+ const sourceNode = nodeById.get(conn.id)
52
56
  if (sourceNode) {
53
57
  connectedTo.get(node)!.add(sourceNode)
54
58
  }
package/utils/id.ts CHANGED
@@ -69,63 +69,65 @@ const makeStableUuid = (type: string, alias?: string) => {
69
69
  })
70
70
  }
71
71
 
72
- // Make stable ids by default; explicit snapshotMode: false preserves the random escape hatch.
73
- export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
74
- if (type === 'subspace') {
75
- throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
76
- }
77
-
78
- const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
79
- const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
80
-
81
- let prefix = ''
72
+ const idPrefix = (type: IdType): string => {
82
73
  switch (type) {
83
74
  case 'animation':
84
- prefix = 'ANIMATION_'
85
- break
75
+ return 'ANIMATION_'
86
76
  case 'brick':
87
- prefix = 'BRICK_'
88
- break
77
+ return 'BRICK_'
89
78
  case 'dynamic-brick':
90
- prefix = 'DYNAMIC_BRICK_'
91
- break
79
+ return 'DYNAMIC_BRICK_'
92
80
  case 'canvas':
93
- prefix = 'CANVAS_'
94
- break
81
+ return 'CANVAS_'
95
82
  case 'generator':
96
- prefix = 'GENERATOR_'
97
- break
83
+ return 'GENERATOR_'
98
84
  case 'data':
99
- prefix = 'PROPERTY_BANK_DATA_NODE_'
100
- break
85
+ return 'PROPERTY_BANK_DATA_NODE_'
101
86
  case 'switch':
102
- prefix = 'BRICK_STATE_GROUP_'
103
- break
87
+ return 'BRICK_STATE_GROUP_'
104
88
  case 'property_bank_command':
105
- prefix = 'PROPERTY_BANK_COMMAND_NODE_'
106
- break
89
+ return 'PROPERTY_BANK_COMMAND_NODE_'
107
90
  case 'property_bank_calc':
108
- prefix = 'PROPERTY_BANK_COMMAND_MAP_'
109
- break
91
+ return 'PROPERTY_BANK_COMMAND_MAP_'
110
92
  case 'automation_map':
111
- prefix = 'AUTOMATION_MAP_'
112
- break
93
+ return 'AUTOMATION_MAP_'
113
94
  case 'test':
114
- prefix = 'TEST_'
115
- break
95
+ return 'TEST_'
116
96
  case 'test_case':
117
- prefix = 'TEST_CASE_'
118
- break
97
+ return 'TEST_CASE_'
119
98
  case 'test_var':
120
- prefix = 'TEST_VAR_'
121
- break
99
+ return 'TEST_VAR_'
122
100
  default:
101
+ return ''
102
+ }
103
+ }
104
+
105
+ // Make stable ids by default; explicit snapshotMode: false preserves the random escape hatch.
106
+ export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
107
+ if (type === 'subspace') {
108
+ throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
123
109
  }
124
110
 
111
+ const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
112
+ const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
113
+
125
114
  const useCountFallback = aliasOrOpts === undefined && opts === undefined
126
115
  const id =
127
116
  alias !== undefined || options?.snapshotMode || useCountFallback
128
117
  ? makeStableUuid(type, alias)
129
118
  : uuid()
130
- return `${prefix}${id}`
119
+ return `${idPrefix(type)}${id}`
120
+ }
121
+
122
+ // Deterministic id derived solely from a caller-supplied seed. Unlike makeId it keeps no
123
+ // global state — no incrementing counter, no alias registry — so the same (type, seed)
124
+ // always maps to the same id on every compile and in any process. Compiled artifacts seed
125
+ // their ids this way (e.g. generateCalulationMap's command nodes, seeded by their calc id +
126
+ // structural role) so unchanged source recompiles byte-identically and editing one calc
127
+ // never shifts another's ids. The seed must be unique within a single config.
128
+ export const makeSeededId = (type: IdType, seed: string) => {
129
+ if (type === 'subspace') {
130
+ throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
131
+ }
132
+ return `${idPrefix(type)}${uuid({ random: hashToRandomBytes([readApplicationId(), type, seed]) })}`
131
133
  }