@fugood/bricks-ctor 2.25.0-beta.55 → 2.25.0-beta.58

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.
@@ -249,6 +249,37 @@ describe('compile event handlers', () => {
249
249
  })
250
250
  })
251
251
 
252
+ describe('compile expanded state', () => {
253
+ test('defaults canvas, bricks, and data collapsed for generated configs', async () => {
254
+ const config = await compile(makeApp([], [makeData(undefined)]))
255
+
256
+ expect(config.subspace_map[SUBSPACE_ID]._expanded).toEqual({
257
+ brick: false,
258
+ generator: true,
259
+ canvas: { [CANVAS_ID]: false },
260
+ property_bank: false,
261
+ property_bank_calc: true,
262
+ })
263
+ })
264
+
265
+ test('preserves explicit source unexpanded state when present', async () => {
266
+ const app = makeApp()
267
+ app.rootSubspace.unexpanded = {
268
+ generator: true,
269
+ dataCalculation: true,
270
+ }
271
+
272
+ const config = await compile(app)
273
+ const expanded = config.subspace_map[SUBSPACE_ID]._expanded
274
+
275
+ expect(expanded.brick).toBe(true)
276
+ expect(expanded.generator).toBe(false)
277
+ expect(expanded.canvas).toBeUndefined()
278
+ expect(expanded.property_bank).toBe(true)
279
+ expect(expanded.property_bank_calc).toBe(false)
280
+ })
281
+ })
282
+
252
283
  describe('compile data remote update', () => {
253
284
  test.each([
254
285
  [undefined, { bank_type: 'none' }],
@@ -335,3 +335,116 @@ describe('generateCalulationMap', () => {
335
335
  expect(result.map[refs[1].id].properties.command).toBe('OBJECT_GET')
336
336
  })
337
337
  })
338
+
339
+ // Direction A: generateCalulationMap also emits a single-node `script_map` the new runtime
340
+ // prefers. These assert that derived graph, independent of the legacy `map` above.
341
+ const scriptNode = (scriptMap) =>
342
+ Object.entries(scriptMap).find(([, node]) => node.type === 'command-node-script')
343
+
344
+ describe('generateScriptMap (script_map)', () => {
345
+ test('empty config produces a single command-node-script node', () => {
346
+ const { script_map } = genMap(baseConfig())
347
+ expect(Object.values(script_map)).toHaveLength(1)
348
+ const [, node] = scriptNode(script_map)
349
+ expect(node.properties.command).toBe('SCRIPT_RUN')
350
+ expect(node.properties.args.code).toBe('return inputs')
351
+ expect(node.properties.args.inputs).toEqual({})
352
+ expect(node.properties.args.outputs).toEqual({})
353
+ expect(node.in).toEqual({})
354
+ expect(node.out).toEqual({})
355
+ })
356
+
357
+ test('wires keyed input ports and input data nodes, preserving order', () => {
358
+ const { script_map } = genMap(baseConfig({ inputs: { a: 'foo.bar', b: 'baz' } }))
359
+ const [scriptId, node] = scriptNode(script_map)
360
+ expect(script_map.a.out.value).toEqual([{ id: scriptId, port: 'a' }])
361
+ expect(script_map.b.out.value).toEqual([{ id: scriptId, port: 'b' }])
362
+ expect(script_map.a.in.change).toBeNull()
363
+ expect(node.in.a).toEqual([{ id: 'a', port: 'value' }])
364
+ expect(node.in.b).toEqual([{ id: 'b', port: 'value' }])
365
+ // args.inputs maps each input port (data-node id) to its inputs-object path, in order.
366
+ expect(node.properties.args.inputs).toEqual({ a: 'foo.bar', b: 'baz' })
367
+ expect(Object.keys(node.properties.args.inputs)).toEqual(['a', 'b'])
368
+ })
369
+
370
+ test('fans out output / error / outputs to distinct ports and target data nodes', () => {
371
+ const { script_map } = genMap(
372
+ baseConfig({ output: 'outNode', error: 'errNode', outputs: { sum: ['pb1', 'pb2'] } }),
373
+ )
374
+ const [scriptId, node] = scriptNode(script_map)
375
+ expect(node.out.output).toEqual([{ id: 'outNode', port: 'change' }])
376
+ expect(node.out.error).toEqual([{ id: 'errNode', port: 'change' }])
377
+ expect(node.out['out:sum']).toEqual([
378
+ { id: 'pb1', port: 'change' },
379
+ { id: 'pb2', port: 'change' },
380
+ ])
381
+ expect(node.properties.args.outputs).toEqual({ 'out:sum': 'sum' })
382
+ expect(script_map.outNode.in.change).toEqual([{ id: scriptId, port: 'output' }])
383
+ expect(script_map.errNode.in.change).toEqual([{ id: scriptId, port: 'error' }])
384
+ expect(script_map.pb1.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
385
+ expect(script_map.pb2.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
386
+ })
387
+
388
+ test('manual mode disables every input edge', () => {
389
+ const { script_map } = genMap(
390
+ baseConfig({ trigger_mode: 'manual', inputs: { a: 'foo', b: 'bar' } }),
391
+ )
392
+ const [, node] = scriptNode(script_map)
393
+ expect(node.in.a[0].disable_trigger_command).toBe(true)
394
+ expect(node.in.b[0].disable_trigger_command).toBe(true)
395
+ })
396
+
397
+ test('auto mode honours per-key disabled_triggers', () => {
398
+ const { script_map } = genMap(
399
+ baseConfig({ inputs: { a: 'foo', b: 'bar' }, disabled_triggers: { a: true, b: false } }),
400
+ )
401
+ const [, node] = scriptNode(script_map)
402
+ expect(node.in.a[0].disable_trigger_command).toBe(true)
403
+ expect(node.in.b[0].disable_trigger_command).toBeUndefined()
404
+ })
405
+
406
+ test('a node reused as input and output keeps both edges (manual mode)', () => {
407
+ const { script_map } = genMap(
408
+ baseConfig({
409
+ trigger_mode: 'manual',
410
+ inputs: { shared: 'foo' },
411
+ outputs: { sum: ['shared'] },
412
+ }),
413
+ )
414
+ const [scriptId] = scriptNode(script_map)
415
+ // Input side (out.value) is preserved alongside the output side (in.change).
416
+ expect(script_map.shared.out.value).toEqual([{ id: scriptId, port: 'shared' }])
417
+ expect(script_map.shared.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
418
+ })
419
+
420
+ test('outputs port names never collide with the reserved output port', () => {
421
+ const { script_map } = genMap(baseConfig({ output: 'outNode', outputs: { output: ['pb1'] } }))
422
+ const [, node] = scriptNode(script_map)
423
+ // The whole-return-value port stays `output`; an outputs path literally named "output"
424
+ // is namespaced to `out:output`.
425
+ expect(node.out.output).toEqual([{ id: 'outNode', port: 'change' }])
426
+ expect(node.out['out:output']).toEqual([{ id: 'pb1', port: 'change' }])
427
+ expect(node.properties.args.outputs).toEqual({ 'out:output': 'output' })
428
+ })
429
+
430
+ test('script node id is byte-stable across recompiles and unique per calc', () => {
431
+ const config = baseConfig({ inputs: { a: 'foo' }, outputs: { out: ['pb1'] } })
432
+ const first = generateCalulationMap(config, CALC_ID)
433
+ const second = generateCalulationMap(config, CALC_ID)
434
+ expect(Object.keys(second.script_map)).toEqual(Object.keys(first.script_map))
435
+ const [idA] = scriptNode(first.script_map)
436
+ const [idB] = scriptNode(
437
+ generateCalulationMap(
438
+ config,
439
+ 'PROPERTY_BANK_COMMAND_MAP_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
440
+ ).script_map,
441
+ )
442
+ expect(idA).not.toBe(idB)
443
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
444
+ expect(idA).toMatch(new RegExp(`^PROPERTY_BANK_COMMAND_NODE_${uuid}$`))
445
+ })
446
+
447
+ test('validateConfig still guards the script_map path (auto mode overlap)', () => {
448
+ expect(() => genMap(baseConfig({ inputs: { a: 'foo' }, error: 'a' }))).toThrow(/key: error/)
449
+ })
450
+ })
package/compile/index.ts CHANGED
@@ -751,6 +751,17 @@ const compileAutomation = (automationMap: AutomationMap) =>
751
751
  }),
752
752
  )
753
753
 
754
+ const buildDefaultExpandedState = (subspace: Subspace) => ({
755
+ brick: false,
756
+ generator: true,
757
+ canvas: (subspace.canvases || []).reduce((acc, canvas) => {
758
+ if (canvas?.id) acc[canvas.id] = false
759
+ return acc
760
+ }, {}),
761
+ property_bank: false,
762
+ property_bank_calc: true,
763
+ })
764
+
754
765
  // Record the minimal compiled-config delta this compile produced to the shared audit
755
766
  // log (`.bricks/edits.jsonl`), so editing files directly and running `bun compile`
756
767
  // leaves the same trail as the MCP source-editing tools. Maintained only in the
@@ -868,7 +879,7 @@ export const compile = async (app: Application) => {
868
879
  property_bank: !subspace.unexpanded.data,
869
880
  property_bank_calc: !subspace.unexpanded.dataCalculation,
870
881
  }
871
- : undefined,
882
+ : buildDefaultExpandedState(subspace),
872
883
  layout: {
873
884
  width: subspace.layout?.width,
874
885
  height: subspace.layout?.height,
package/compile/util.ts CHANGED
@@ -54,6 +54,104 @@ const padding = 15
54
54
  const layerXInterval = 300
55
55
  const layerYInterval = 150
56
56
 
57
+ // Direction A — first-class script node (see local-plan/PLAN-data-calculation-refactor.md).
58
+ //
59
+ // `generateCalulationMap` keeps emitting the legacy OBJECT_SET → SANDBOX_RUN_JAVASCRIPT →
60
+ // SANDBOX_GET_*/OBJECT_GET expansion in `map` (consumed by older runtimes). In parallel,
61
+ // `generateScriptMap` derives an equivalent single-node graph in `script_map` that a newer
62
+ // runtime prefers: one `command-node-script` node that assembles `inputs` from keyed input
63
+ // ports and fans out `error` / `output` / each `outputs` path directly to the target data
64
+ // nodes. Both are byte-stable functions of `script_config`, so codegen (which only reads
65
+ // `script_config`) round-trips losslessly and recompiling unchanged source re-derives an
66
+ // identical `script_map`.
67
+ //
68
+ // Trigger semantics stay edge-level (`disable_trigger_command` on the script node's input
69
+ // edges, same as the legacy OBJECT_SET inputs) so the circular-reference check and the
70
+ // manual-trigger overlay keep working unchanged — only the node count collapses N+3 → 1.
71
+ const generateScriptMap = (config: ScriptConfig, calcId: string) => {
72
+ const scriptId = makeSeededId('property_bank_command', `${calcId}:script`)
73
+ const map: Record<string, any> = {}
74
+
75
+ // Compiled run-time config for the script node. `inputs` maps each input port name (the
76
+ // source data-node id) to its OBJECT_SET path; `outputs` maps each fan-out port name to the
77
+ // OBJECT_GET path it extracts from the return value. The runtime reads these from
78
+ // properties.args, keeping the node `value` reserved purely for incoming input-port values.
79
+ const inputArgs: Record<string, string> = {}
80
+ const outputArgs: Record<string, string> = {}
81
+
82
+ const scriptIn: Record<string, any> = {}
83
+ const scriptOut: Record<string, any> = {}
84
+
85
+ // One in-port per input, keyed by the source data-node id (unique per input, so each input
86
+ // keeps its own `disable_trigger_command` flag — a shared path could not). Manual mode
87
+ // disables every input edge; auto mode honours per-key `disabled_triggers`.
88
+ Object.entries(config.inputs).forEach(([dataNodeId, path]) => {
89
+ inputArgs[dataNodeId] = path
90
+ scriptIn[dataNodeId] = [
91
+ {
92
+ id: dataNodeId,
93
+ port: 'value',
94
+ disable_trigger_command:
95
+ config.trigger_mode === 'manual' || config.disabled_triggers?.[dataNodeId] || undefined,
96
+ },
97
+ ]
98
+ map[dataNodeId] = {
99
+ type: 'data-node',
100
+ properties: {},
101
+ in: { change: null },
102
+ out: { value: [{ id: scriptId, port: dataNodeId }] },
103
+ }
104
+ })
105
+
106
+ // Wire an output target data-node's `change` port back from a script out-port, preserving
107
+ // any `out.value` it already has (a node reused as both input and output — allowed in manual
108
+ // mode). Mirrors the last-wins reuse handling in generateCalulationMap's outputs section.
109
+ const wireOutputTarget = (dataNodeId: string, scriptPort: string) => {
110
+ const existing = map[dataNodeId]
111
+ map[dataNodeId] = {
112
+ type: 'data-node',
113
+ properties: {},
114
+ in: { change: [{ id: scriptId, port: scriptPort }] },
115
+ out: { value: existing?.out?.value ?? null },
116
+ }
117
+ }
118
+
119
+ if (config.error) {
120
+ scriptOut.error = [{ id: config.error, port: 'change' }]
121
+ wireOutputTarget(config.error, 'error')
122
+ }
123
+ if (config.output) {
124
+ scriptOut.output = [{ id: config.output, port: 'change' }]
125
+ wireOutputTarget(config.output, 'output')
126
+ }
127
+ // `out:`-prefixed port names keep `outputs` paths from colliding with the reserved
128
+ // `error` / `output` ports (a path could legitimately be the string "output").
129
+ Object.entries(config.outputs).forEach(([path, pbList]) => {
130
+ const portName = `out:${path}`
131
+ outputArgs[portName] = path
132
+ scriptOut[portName] = pbList.map((pb) => ({ id: pb, port: 'change' }))
133
+ pbList.forEach((pb) => wireOutputTarget(pb, portName))
134
+ })
135
+
136
+ map[scriptId] = {
137
+ type: 'command-node-script',
138
+ title: 'Command: SCRIPT_RUN',
139
+ properties: {
140
+ command: 'SCRIPT_RUN',
141
+ args: {
142
+ code: config.code,
143
+ enable_async: config.enable_async,
144
+ inputs: inputArgs,
145
+ outputs: outputArgs,
146
+ },
147
+ },
148
+ in: scriptIn,
149
+ out: scriptOut,
150
+ }
151
+
152
+ return map
153
+ }
154
+
57
155
  // `calcId` (the owning script calc's stable id) seeds every derived command-node id, so an
58
156
  // unchanged calc recompiles to identical ids and editing one calc never shifts another's —
59
157
  // keeping the compiled config byte-stable for change detection. See makeSeededId in utils/id.
@@ -219,6 +317,8 @@ export const generateCalulationMap = (config: ScriptConfig, calcId: string) => {
219
317
  )
220
318
 
221
319
  return {
320
+ // Newer runtimes prefer this single-node graph; older runtimes fall back to `map` below.
321
+ script_map: generateScriptMap(config, calcId),
222
322
  map: {
223
323
  ...inputs.map,
224
324
  [sandboxId]: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.55",
3
+ "version": "2.25.0-beta.58",
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.53",
14
+ "@fugood/bricks-cli": "^2.25.0-beta.58",
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": "08f3ae4a88db84f682384ecfa94e9d4583371bda"
32
+ "gitHead": "355da5754f1136c10e073460af4727dae94f7e30"
33
33
  }
@@ -0,0 +1,108 @@
1
+ import { execFile as execFileCallback } from 'node:child_process'
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ // bricks-project's tsconfig has no @types/jest, so declare the globals this test uses
8
+ // (mirrors tools/__tests__/_mcp-config.test.ts).
9
+ declare const describe: (name: string, fn: () => void) => void
10
+ declare const it: (name: string, fn: () => void | Promise<void>, timeout?: number) => void
11
+ declare const expect: (actual: unknown) => {
12
+ toBe: (expected: unknown) => void
13
+ }
14
+
15
+ const execFile = promisify(execFileCallback)
16
+
17
+ async function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv) {
18
+ return execFile(cmd, args, {
19
+ cwd,
20
+ env: { ...process.env, ...env },
21
+ maxBuffer: 1024 * 1024,
22
+ })
23
+ }
24
+
25
+ async function initGitProject(projectDir: string) {
26
+ await run('git', ['init', '--initial-branch=master'], projectDir)
27
+ await run('git', ['config', 'user.email', 'test@example.com'], projectDir)
28
+ await run('git', ['config', 'user.name', 'Test User'], projectDir)
29
+ await writeFile(join(projectDir, 'application.json'), JSON.stringify({ id: 'app-1' }))
30
+ await run('git', ['add', '.'], projectDir)
31
+ await run('git', ['commit', '-m', 'init'], projectDir)
32
+ }
33
+
34
+ async function writeFakeBricks(binDir: string) {
35
+ const payload = {
36
+ files: [
37
+ {
38
+ name: 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
39
+ input: 'export const value = 1\n',
40
+ formatable: false,
41
+ },
42
+ ],
43
+ lastCommitId: 'server-commit',
44
+ }
45
+ const bricksPath = join(binDir, 'bricks')
46
+ await writeFile(
47
+ bricksPath,
48
+ `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(payload))})\n`,
49
+ )
50
+ await chmod(bricksPath, 0o755)
51
+ }
52
+
53
+ describe('pull.ts', () => {
54
+ it('creates parent directories before writing pulled project files', async () => {
55
+ const root = await mkdtemp(join(tmpdir(), 'bricks-project-pull-'))
56
+ const projectDir = join(root, 'project')
57
+ const binDir = join(root, 'bin')
58
+
59
+ try {
60
+ await mkdir(projectDir)
61
+ await mkdir(binDir)
62
+ await initGitProject(projectDir)
63
+ await writeFakeBricks(binDir)
64
+
65
+ const pullScript = resolve(__dirname, '../pull.ts')
66
+ await run('bun', [pullScript, '--force'], projectDir, {
67
+ PATH: `${binDir}:${process.env.PATH || ''}`,
68
+ })
69
+
70
+ const pulledFile = join(
71
+ projectDir,
72
+ 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
73
+ )
74
+ expect(await readFile(pulledFile, 'utf8')).toBe('export const value = 1\n')
75
+ } finally {
76
+ await rm(root, { recursive: true, force: true })
77
+ }
78
+ }, 30000)
79
+
80
+ it('merges pulled project files back into the starting branch', async () => {
81
+ const root = await mkdtemp(join(tmpdir(), 'bricks-project-pull-'))
82
+ const projectDir = join(root, 'project')
83
+ const binDir = join(root, 'bin')
84
+
85
+ try {
86
+ await mkdir(projectDir)
87
+ await mkdir(binDir)
88
+ await initGitProject(projectDir)
89
+ await writeFakeBricks(binDir)
90
+
91
+ const pullScript = resolve(__dirname, '../pull.ts')
92
+ await run('bun', [pullScript], projectDir, {
93
+ PATH: `${binDir}:${process.env.PATH || ''}`,
94
+ })
95
+
96
+ const { stdout: branch } = await run('git', ['branch', '--show-current'], projectDir)
97
+ const pulledFile = join(
98
+ projectDir,
99
+ 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
100
+ )
101
+
102
+ expect(branch.trim()).toBe('master')
103
+ expect(await readFile(pulledFile, 'utf8')).toBe('export const value = 1\n')
104
+ } finally {
105
+ await rm(root, { recursive: true, force: true })
106
+ }
107
+ }, 30000)
108
+ })
@@ -56,3 +56,43 @@ export const removeObjectProperty = (object: t.ObjectExpression, key: string) =>
56
56
  })
57
57
  if (index >= 0) object.properties.splice(index, 1)
58
58
  }
59
+
60
+ export const insertImport = (ast: t.File, declaration: t.ImportDeclaration) => {
61
+ const body = ast.program.body
62
+ const lastImportIndex = body.findLastIndex((statement) => t.isImportDeclaration(statement))
63
+ body.splice(lastImportIndex + 1, 0, declaration)
64
+ }
65
+
66
+ export const ensureBricksCtorImport = (
67
+ ast: t.File,
68
+ importKind: 'type' | 'value',
69
+ names: Iterable<string>,
70
+ ) => {
71
+ const missing = new Set(Array.from(names).filter(Boolean))
72
+ if (missing.size === 0) return
73
+
74
+ for (const statement of ast.program.body) {
75
+ if (!t.isImportDeclaration(statement) || statement.source.value !== 'bricks-ctor') continue
76
+ const isTypeImport = statement.importKind === 'type'
77
+ if ((importKind === 'type') !== isTypeImport) continue
78
+
79
+ statement.specifiers.forEach((specifier) => {
80
+ if (!t.isImportSpecifier(specifier)) return
81
+ const imported = specifier.imported
82
+ if (t.isIdentifier(imported)) missing.delete(imported.name)
83
+ if (t.isStringLiteral(imported)) missing.delete(imported.value)
84
+ })
85
+
86
+ missing.forEach((name) => {
87
+ statement.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)))
88
+ })
89
+ return
90
+ }
91
+
92
+ const declaration = t.importDeclaration(
93
+ Array.from(missing).map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
94
+ t.stringLiteral('bricks-ctor'),
95
+ )
96
+ if (importKind === 'type') declaration.importKind = 'type'
97
+ insertImport(ast, declaration)
98
+ }
@@ -10,8 +10,10 @@ import { z } from 'zod'
10
10
 
11
11
  import { verifyProject } from './_verify'
12
12
  import {
13
+ ensureBricksCtorImport,
13
14
  getObjectProperty,
14
15
  getStringProperty,
16
+ insertImport,
15
17
  isRecord,
16
18
  makeObjectKey,
17
19
  oxfmtOptions,
@@ -328,12 +330,6 @@ const relativeImportSource = (fromFile: string, toFile: string) => {
328
330
  return rel.replace(/\.(ts|tsx|js|jsx)$/, '')
329
331
  }
330
332
 
331
- const insertImport = (ast: t.File, declaration: t.ImportDeclaration) => {
332
- const body = ast.program.body
333
- const lastImportIndex = body.findLastIndex((statement) => t.isImportDeclaration(statement))
334
- body.splice(lastImportIndex + 1, 0, declaration)
335
- }
336
-
337
333
  const ensureNamespaceImport = (parsed: ParsedFile, source: string, preferredLocal: string) => {
338
334
  for (const statement of parsed.ast.program.body) {
339
335
  if (!t.isImportDeclaration(statement) || statement.source.value !== source) continue
@@ -352,40 +348,6 @@ const ensureNamespaceImport = (parsed: ParsedFile, source: string, preferredLoca
352
348
  return preferredLocal
353
349
  }
354
350
 
355
- const ensureBricksCtorImport = (
356
- ast: t.File,
357
- importKind: 'type' | 'value',
358
- names: Iterable<string>,
359
- ) => {
360
- const missing = new Set(Array.from(names).filter(Boolean))
361
- if (missing.size === 0) return
362
-
363
- for (const statement of ast.program.body) {
364
- if (!t.isImportDeclaration(statement) || statement.source.value !== 'bricks-ctor') continue
365
- const isTypeImport = statement.importKind === 'type'
366
- if ((importKind === 'type') !== isTypeImport) continue
367
-
368
- statement.specifiers.forEach((specifier) => {
369
- if (!t.isImportSpecifier(specifier)) return
370
- const imported = specifier.imported
371
- if (t.isIdentifier(imported)) missing.delete(imported.name)
372
- if (t.isStringLiteral(imported)) missing.delete(imported.value)
373
- })
374
-
375
- missing.forEach((name) => {
376
- statement.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)))
377
- })
378
- return
379
- }
380
-
381
- const declaration = t.importDeclaration(
382
- Array.from(missing).map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
383
- t.stringLiteral('bricks-ctor'),
384
- )
385
- if (importKind === 'type') declaration.importKind = 'type'
386
- insertImport(ast, declaration)
387
- }
388
-
389
351
  const ensureReadFileImport = (ast: t.File) => {
390
352
  for (const statement of ast.program.body) {
391
353
  if (!t.isImportDeclaration(statement) || statement.source.value !== 'node:fs/promises') {
@@ -11,9 +11,11 @@ import { z } from 'zod'
11
11
 
12
12
  import { verifyProject } from './_verify'
13
13
  import {
14
+ ensureBricksCtorImport,
14
15
  getObjectProperty,
15
16
  getPropertyKeyName,
16
17
  getStringProperty,
18
+ insertImport,
17
19
  isRecord,
18
20
  makeObjectKey,
19
21
  oxfmtOptions,
@@ -576,13 +578,6 @@ const getTopLevelNames = (ast: t.File) => {
576
578
  return names
577
579
  }
578
580
 
579
- const insertImport = (ast: t.File, declaration: t.ImportDeclaration) => {
580
- const lastImportIndex = ast.program.body.findLastIndex((statement) =>
581
- t.isImportDeclaration(statement),
582
- )
583
- ast.program.body.splice(lastImportIndex + 1, 0, declaration)
584
- }
585
-
586
581
  const ensureNamespaceImport = (
587
582
  parsed: ParsedFile,
588
583
  namespace: string,
@@ -611,40 +606,6 @@ const ensureNamespaceImport = (
611
606
  return localName
612
607
  }
613
608
 
614
- const ensureBricksCtorImport = (
615
- ast: t.File,
616
- importKind: 'type' | 'value',
617
- names: Iterable<string>,
618
- ) => {
619
- const missing = new Set(Array.from(names).filter(Boolean))
620
- if (missing.size === 0) return
621
-
622
- for (const statement of ast.program.body) {
623
- if (!t.isImportDeclaration(statement) || statement.source.value !== 'bricks-ctor') continue
624
- const isTypeImport = statement.importKind === 'type'
625
- if ((importKind === 'type') !== isTypeImport) continue
626
-
627
- statement.specifiers.forEach((specifier) => {
628
- if (!t.isImportSpecifier(specifier)) return
629
- const imported = specifier.imported
630
- if (t.isIdentifier(imported)) missing.delete(imported.name)
631
- if (t.isStringLiteral(imported)) missing.delete(imported.value)
632
- })
633
-
634
- missing.forEach((name) => {
635
- statement.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)))
636
- })
637
- return
638
- }
639
-
640
- const declaration = t.importDeclaration(
641
- Array.from(missing).map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
642
- t.stringLiteral('bricks-ctor'),
643
- )
644
- if (importKind === 'type') declaration.importKind = 'type'
645
- insertImport(ast, declaration)
646
- }
647
-
648
609
  const applyPendingImports = (ctx: EditContext) => {
649
610
  ensureBricksCtorImport(ctx.parsed.ast, 'type', ctx.typeImports)
650
611
  ensureBricksCtorImport(ctx.parsed.ast, 'value', ctx.valueImports)
package/tools/pull.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readdir, readFile, unlink, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
- import { join, relative } from 'node:path'
3
+ import { dirname, join, relative } from 'node:path'
4
4
  import { format } from 'oxfmt'
5
5
  import { sh } from './_shell'
6
6
  import { extractCliErrorMessage } from './_cli-error'
@@ -86,7 +86,11 @@ const branchName = isModule
86
86
  ? 'BRICKS_PROJECT_try-pull-module'
87
87
  : 'BRICKS_PROJECT_try-pull-application'
88
88
 
89
+ let landingBranch = ''
89
90
  if (isGitRepo && !force) {
91
+ landingBranch = (await sh`cd ${cwd} && git branch --show-current`.text()).trim()
92
+ if (!landingBranch) throw new Error('Cannot pull from a detached HEAD')
93
+
90
94
  console.log(`Checking commit ${baseCommitId}...`)
91
95
  const found = (await sh`cd ${cwd} && git rev-list -1 ${baseCommitId}`.nothrow().text())
92
96
  .trim()
@@ -100,8 +104,8 @@ if (isGitRepo && !force) {
100
104
 
101
105
  // When the base commit isn't reachable in this clone (server stored a
102
106
  // nanoid, or the commit was pruned), fall back to forking from current
103
- // HEAD. The downstream merge into main collapses both paths into the
104
- // same result, just with different merge bases.
107
+ // HEAD. The downstream merge into the starting branch collapses both paths
108
+ // into the same result, just with different merge bases.
105
109
  if (found) {
106
110
  await sh`cd ${cwd} && git checkout -b ${branchName} ${baseCommitId}`.nothrow()
107
111
  } else {
@@ -143,7 +147,9 @@ await Promise.all(
143
147
  const result = await format(file.name, file.input, oxfmtConfig)
144
148
  content = result.code
145
149
  }
146
- return writeFile(`${cwd}/${file.name}`, content)
150
+ const target = join(cwd, file.name)
151
+ await mkdir(dirname(target), { recursive: true })
152
+ return writeFile(target, content)
147
153
  }),
148
154
  )
149
155
 
@@ -160,11 +166,11 @@ if (isGitRepo) {
160
166
  await sh`cd ${cwd} && git ${commitArgs}`
161
167
  }
162
168
  if (!force) {
163
- // Land the pulled commits on main with a single 3-way merge using
169
+ // Land the pulled commits on the starting branch with a single 3-way merge using
164
170
  // baseCommit as the merge base. The user doesn't have to manage a side
165
- // branch, and conflicts (if any) land in the working tree on main where
171
+ // branch, and conflicts (if any) land in the working tree on the starting branch where
166
172
  // auto-compile surfaces them as typecheck errors to resolve in-place.
167
- await sh`cd ${cwd} && git checkout main`
173
+ await sh`cd ${cwd} && git checkout ${landingBranch}`
168
174
  const mergeResult = await sh`cd ${cwd} && git merge ${branchName} --no-edit`.nothrow()
169
175
  if (mergeResult.exitCode !== 0) {
170
176
  // Conflict markers are in the working tree — commit them so the tree
@@ -174,7 +180,7 @@ if (isGitRepo) {
174
180
  await sh`cd ${cwd} && git add .`
175
181
  const conflictArgs = await buildCommitArgs(
176
182
  cwd,
177
- ['chore(project): merge with conflicts (resolve in main)'],
183
+ [`chore(project): merge with conflicts (resolve in ${landingBranch})`],
178
184
  ['--no-verify'],
179
185
  )
180
186
  await sh`cd ${cwd} && git ${conflictArgs}`
@@ -269,6 +269,7 @@ Default property:
269
269
  y?: number | DataLink
270
270
  width?: number | DataLink
271
271
  height?: number | DataLink
272
+ type?: string | DataLink
272
273
  standbyMode?: 'custom' | 'top' | 'bottom' | 'left' | 'right' | DataLink
273
274
  standbyFrame?: DataLink | {}
274
275
  standbyOpacity?: number | DataLink
@@ -319,6 +320,7 @@ Default property:
319
320
  }
320
321
  show?: string | DataLink
321
322
  pressToOpenDetail?: boolean | DataLink
323
+ pressToBackList?: boolean | DataLink
322
324
  }
323
325
  >
324
326
  | DataLink
@@ -346,6 +348,7 @@ Default property:
346
348
  y?: number | DataLink
347
349
  width?: number | DataLink
348
350
  height?: number | DataLink
351
+ type?: string | DataLink
349
352
  standbyMode?: 'custom' | 'top' | 'bottom' | 'left' | 'right' | DataLink
350
353
  standbyFrame?: DataLink | {}
351
354
  standbyOpacity?: number | DataLink
@@ -395,6 +398,7 @@ Default property:
395
398
  renderOutOfViewport?: boolean | DataLink
396
399
  }
397
400
  show?: string | DataLink
401
+ pressToOpenDetail?: boolean | DataLink
398
402
  pressToBackList?: boolean | DataLink
399
403
  }
400
404
  >