@flowfuse/nr-assistant 0.14.0 → 0.14.1-11df41b-202605121045.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/CHANGELOG.md CHANGED
@@ -1,3 +1,5 @@
1
+ # CHANGELOG
2
+
1
3
  ## [0.14.0](https://github.com/FlowFuse/nr-assistant/compare/v0.13.1...v0.14.0) (2026-05-07)
2
4
 
3
5
  - feat(actions): add automation/open-palette-manager action (#314) @andypalmi
@@ -10,7 +12,7 @@
10
12
  - chore(deps): bump hono from 4.12.14 to 4.12.18 (#308) @app/dependabot
11
13
  - build(deps): bump protobufjs from 7.5.3 to 7.5.5 (#272) @app/dependabot
12
14
 
13
- ## 0.13.0
15
+ ## [0.13.0](https://github.com/FlowFuse/nr-assistant/compare/v0.12.0...v0.13.0) (2026-05-06)
14
16
 
15
17
  - ci: Use new project-automation workflow (#303)
16
18
  - ci: Replace PAT with GitHub Application token in `Projects automations` workflow (#300)
@@ -53,7 +55,7 @@
53
55
  - Update lint scripts to include all subdirectories (#255) @Steve-Mcl
54
56
  - Use git ref_name for version instead of npm info (#252) @allthedoll
55
57
 
56
- ## 0.12.0
58
+ ## [0.12.0](https://github.com/FlowFuse/nr-assistant/compare/v0.11.0...v0.12.0) (2026-04-08)
57
59
 
58
60
  - Bump actions/create-github-app-token from 2.2.1 to 3.0.0 (#191)
59
61
  - Bump actions/setup-node from 6.2.0 to 6.3.0 (#177)
@@ -85,7 +87,7 @@
85
87
  - Bump hono from 4.12.3 to 4.12.5 (#166) @app/dependabot
86
88
  - Bump minimatch from 3.1.2 to 3.1.5 (#161) @app/dependabot
87
89
 
88
- ## 0.11.0
90
+ ## [0.11.0](https://github.com/FlowFuse/nr-assistant/compare/v0.10.2...v0.11.0) (2026-02-26)
89
91
 
90
92
  - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#149)
91
93
  - Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml (#148)
@@ -98,7 +100,7 @@
98
100
  - Bump hono from 4.11.7 to 4.12.0 (#152) @app/dependabot
99
101
  - Bump qs from 6.14.1 to 6.14.2 (#146) @app/dependabot
100
102
 
101
- ## 0.10.2
103
+ ## [0.10.2](https://github.com/FlowFuse/nr-assistant/compare/v0.10.1...v0.10.2) (2026-02-12)
102
104
 
103
105
  - Bump JS-DevTools/npm-publish from 4.1.4 to 4.1.5 (#136)
104
106
  - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#141)
@@ -107,13 +109,13 @@
107
109
  - Bump @modelcontextprotocol/sdk from 1.25.3 to 1.26.0 (#138) @app/dependabot
108
110
  - Update public catalouge on release (#144) @hardillb
109
111
 
110
- ## 0.10.1
112
+ ## [0.10.1](https://github.com/FlowFuse/nr-assistant/compare/v0.10.0...v0.10.1) (2026-01-30)
111
113
 
112
114
  - Bump hono from 4.11.5 to 4.11.7 (#129) @app/dependabot
113
115
  - Improve discoverability of supported features (#131) @Steve-Mcl
114
116
  - Replace hard coded event mapping with dynamic registrations (#132) @Steve-Mcl
115
117
 
116
- ## 0.10.0
118
+ ## [0.10.0](https://github.com/FlowFuse/nr-assistant/compare/v0.9.0...v0.10.0) (2026-01-27)
117
119
 
118
120
  - Bump JS-DevTools/npm-publish from 4.1.3 to 4.1.4 (#110)
119
121
  - Bump actions/checkout from 6.0.1 to 6.0.2 (#124)
@@ -122,7 +124,7 @@
122
124
  - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#121)
123
125
  - Add selection handling: `view:selection-changed` notifier and `get-selection` handler (#125) @cstns
124
126
 
125
- ## 0.9.0
127
+ ## [0.9.0](https://github.com/FlowFuse/nr-assistant/compare/v0.8.0...v0.9.0) (2026-01-24)
126
128
 
127
129
  - Update dependencies (#119) @Steve-Mcl
128
130
  - Bump actions/setup-node from 6.1.0 to 6.2.0 (#113)
@@ -131,7 +133,7 @@
131
133
  - Expose installed packages to flowfuse expert (#114) @cstns
132
134
  - ci: Enable SAST (#109) @ppawlowski
133
135
 
134
- ## 0.8.0
136
+ ## [0.8.0](https://github.com/FlowFuse/nr-assistant/compare/v0.7.0...v0.8.0) (2026-01-14)
135
137
 
136
138
  - Bump JS-DevTools/npm-publish from 4.1.1 to 4.1.3 (#105)
137
139
  - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#96)
@@ -143,7 +145,7 @@
143
145
  - Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml from 0.44.0 to 0.45.0 (#98) @app/dependabot
144
146
  - Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml from 0.44.0 to 0.45.0 (#97) @app/dependabot
145
147
 
146
- ## 0.7.0
148
+ ## [0.7.0](https://github.com/FlowFuse/nr-assistant/compare/v0.6.0...v0.7.0) (2025-12-09)
147
149
 
148
150
  - Allow the assistant to be installed in standalone Node-RED instances (#89) @knolleary
149
151
 
@@ -161,33 +163,33 @@
161
163
  - Bump body-parser from 2.2.0 to 2.2.1 (#88) @app/dependabot
162
164
  - Bump js-yaml from 4.1.0 to 4.1.1 (#86) @app/dependabot
163
165
 
164
- ## 0.6.0
166
+ ## [0.6.0](https://github.com/FlowFuse/nr-assistant/compare/v0.5.0...v0.6.0) (2025-09-05)
165
167
  - Fix relative script source path (#77) @Steve-Mcl
166
168
  - Add inline completions feature (#75) @Steve-Mcl
167
169
 
168
- ## 0.5.0
170
+ ## [0.5.0](https://github.com/FlowFuse/nr-assistant/compare/v0.4.0...v0.5.0) (2025-08-28)
169
171
  - Bump actions/checkout from 4.2.2 to 5.0.0 (#69)
170
172
  - Bump flowfuse/github-actions-workflows from 0.40.0 to 0.42.0 (#68)
171
173
  - Add tables codelens feature (#72) @Steve-Mcl
172
174
 
173
- ## 0.4.0
175
+ ## [0.4.0](https://github.com/FlowFuse/nr-assistant/compare/v0.3.0...v0.4.0) (2025-07-30)
174
176
  - update package for 0.4.0 release
175
177
  - Bump flowfuse/github-actions-workflows from 0.39.0 to 0.40.0 (#60)
176
178
  - Update imports (#64) @Steve-Mcl
177
179
  - Implement node suggestions (#62) @Steve-Mcl
178
180
  - Add copy to clipboard and generate comment node to explain dialog (#61) @Steve-Mcl
179
181
 
180
- ## 0.3.0
182
+ ## [0.3.0](https://github.com/FlowFuse/nr-assistant/compare/v0.2.1...v0.3.0) (2025-06-27)
181
183
  - Change assistant button to menu for exposing new Flows Explainer by @Steve-Mcl in #53
182
184
  - Add menu shortcuts for menu items by @Steve-Mcl in #54
183
185
  - Show flow explanation in dialog by @Steve-Mcl in #52
184
186
  - Add codelens for CSS and DB2 ui-template by @Steve-Mcl in #56
185
187
 
186
- ## 0.2.1
188
+ ## [0.2.1](https://github.com/FlowFuse/nr-assistant/compare/v0.2.0...v0.2.1) (2025-06-12)
187
189
  - Improve README with visuals of what it does by @Steve-Mcl in #49
188
190
  - V0.2.1 by @Steve-Mcl in #50
189
191
 
190
- ## 0.2.0
192
+ ## [0.2.0](https://github.com/FlowFuse/nr-assistant/compare/v0.1.3...v0.2.0) (2025-06-11)
191
193
 
192
194
  - Bump flowfuse/github-actions-workflows from 0.19.0 to 0.28.0 by @dependabot in #32
193
195
  - Bump flowfuse/github-actions-workflows from 0.28.0 to 0.29.0 by @dependabot in #33
@@ -202,17 +204,17 @@
202
204
  - Add initial MCP support by @Steve-Mcl in #44
203
205
  - V0.2.0 by @Steve-Mcl in #47
204
206
 
205
- ## 0.1.3
207
+ ## [0.1.3](https://github.com/FlowFuse/nr-assistant/compare/v0.1.2...v0.1.3) (2024-07-17)
206
208
 
207
209
  - Fix icon on device agent by @Steve-Mcl in #29
208
210
  - bump for 0.1.3 by @Steve-Mcl in #30
209
211
 
210
- ## 0.1.2
212
+ ## [0.1.2](https://github.com/FlowFuse/nr-assistant/compare/v0.1.1...v0.1.2) (2024-07-16)
211
213
 
212
214
  - Fix height of new icon on NR3.x by @Steve-Mcl in #26
213
215
  - bump for 0.1.2 by @Steve-Mcl in #27
214
216
 
215
- ## 0.1.1
217
+ ## [0.1.1](https://github.com/FlowFuse/nr-assistant/compare/v0.1.0...v0.1.1) (2024-07-16)
216
218
 
217
219
  - ci: Add build and publish nightly package workflow by @ppawlowski in #7
218
220
  - Bump tibdex/github-app-token from 1 to 2 by @dependabot in #11
@@ -223,7 +225,7 @@
223
225
  - Add comma to settings.js by @kazuhitoyokoi in #22
224
226
  - Improved messaging for error responses by @Steve-Mcl in #24
225
227
 
226
- ## 0.1.0
228
+ ## 0.1.0 (2024-07-03)
227
229
 
228
230
  - add automations by @Steve-Mcl in #1
229
231
  - Bump JS-DevTools/npm-publish from 2 to 3 by @dependabot in #5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.14.0",
3
+ "version": "0.14.1-11df41b-202605121045.0",
4
4
  "description": "FlowFuse Node-RED Expert plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -25,6 +25,14 @@ const GET_NODE_TYPES = 'automation/get-node-types'
25
25
  const GET_PALETTE = 'automation/get-palette'
26
26
  const LIST_CONFIG_NODES = 'automation/list-config-nodes'
27
27
  const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
28
+ const MANAGE_GROUPS = 'automation/manage-groups'
29
+
30
+ const ERROR_CODES = Object.freeze({
31
+ GROUP_OPERATION_REQUIRED: 'GROUP_OPERATION_REQUIRED',
32
+ FORBIDDEN_PROPERTY: 'FORBIDDEN_PROPERTY'
33
+ })
34
+
35
+ const LINK_NODE_TYPES = ['link in', 'link out', 'link call']
28
36
 
29
37
  /**
30
38
  * @typedef {SELECT_NODES
@@ -50,7 +58,8 @@ const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
50
58
  * |GET_NODE_TYPES
51
59
  * |GET_PALETTE
52
60
  * |LIST_CONFIG_NODES
53
- * |OPEN_PALETTE_MANAGER} ExpertAutomationsActionsEnum
61
+ * |OPEN_PALETTE_MANAGER
62
+ * |MANAGE_GROUPS} ExpertAutomationsActionsEnum
54
63
  */
55
64
 
56
65
  export class ExpertAutomations extends ExpertActionsInterface {
@@ -187,6 +196,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
187
196
  tabId: {
188
197
  type: 'string',
189
198
  description: 'Optional parameter to only get nodes on a specific workspace. Exclude this parameter to get node from all workspaces.'
199
+ },
200
+ full: {
201
+ type: 'boolean',
202
+ description: 'When true, returns the raw node objects instead of condensed summaries.'
190
203
  }
191
204
  }
192
205
  }
@@ -268,6 +281,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
268
281
  type: 'array',
269
282
  items: { type: 'string' },
270
283
  description: 'IDs of nodes to remove from the canvas'
284
+ },
285
+ reconnectWires: {
286
+ type: 'boolean',
287
+ description: 'If true, reconnects wires around removed nodes (pass-through). Default: false'
271
288
  }
272
289
  },
273
290
  required: ['ids']
@@ -377,6 +394,61 @@ export class ExpertAutomations extends ExpertActionsInterface {
377
394
  }
378
395
  }
379
396
  }
397
+ },
398
+ [MANAGE_GROUPS]: {
399
+ params: {
400
+ type: 'object',
401
+ properties: {
402
+ operations: {
403
+ type: 'array',
404
+ items: {
405
+ type: 'object',
406
+ properties: {
407
+ op: {
408
+ type: 'string',
409
+ enum: ['create', 'update', 'manage-members', 'delete'],
410
+ description: 'Operation to perform'
411
+ },
412
+ id: {
413
+ type: 'string',
414
+ description: 'Group ID (required for update, manage-members, and delete)'
415
+ },
416
+ nodeIds: {
417
+ type: 'array',
418
+ items: { type: 'string' },
419
+ description: 'Node IDs: required for create and manage-members'
420
+ },
421
+ name: {
422
+ type: 'string',
423
+ description: 'Group display name (optional for create and update)'
424
+ },
425
+ mode: {
426
+ type: 'string',
427
+ enum: ['add', 'remove'],
428
+ description: 'manage-members only — "add" moves nodes into the group, "remove" takes them out'
429
+ },
430
+ style: {
431
+ type: 'object',
432
+ description: 'Visual style: stroke/border-color, fill, color, stroke-opacity, fill-opacity, label, label-position',
433
+ properties: {
434
+ stroke: { type: 'string' },
435
+ 'border-color': { type: 'string' },
436
+ fill: { type: 'string' },
437
+ color: { type: 'string' },
438
+ 'stroke-opacity': { type: 'number' },
439
+ 'fill-opacity': { type: 'number' },
440
+ label: { type: 'boolean' },
441
+ 'label-position': { type: 'string', enum: ['nw', 'n', 'ne', 'sw', 's', 'se'] }
442
+ }
443
+ }
444
+ },
445
+ required: ['op']
446
+ },
447
+ description: 'Array of group operations to execute sequentially'
448
+ }
449
+ },
450
+ required: ['operations']
451
+ }
380
452
  }
381
453
  })
382
454
 
@@ -821,7 +893,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
821
893
  * @param {string} id - workspace ID to show
822
894
  */
823
895
  showWorkspace (id) {
824
- if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
896
+ this._assertWorkspaceExists(id)
825
897
  this.RED.workspaces.show(id)
826
898
  }
827
899
 
@@ -831,9 +903,24 @@ export class ExpertAutomations extends ExpertActionsInterface {
831
903
  * @returns {boolean} true if the workspace exists, false otherwise
832
904
  */
833
905
  hasWorkspace (id) {
834
- const ws = this.RED.nodes.workspace(id)
835
- if (ws) return true
836
- return false
906
+ return !!this.RED.nodes.workspace(id)
907
+ }
908
+
909
+ /**
910
+ * Throw if the workspace does not exist.
911
+ * @param {string} id - workspace ID
912
+ */
913
+ _assertWorkspaceExists (id) {
914
+ if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
915
+ }
916
+
917
+ /**
918
+ * Throw if the workspace tab is locked. No-op when tabId is falsy (e.g. config nodes).
919
+ * @param {string|null|undefined} tabId - workspace tab ID
920
+ */
921
+ _assertWorkspaceNotLocked (tabId) {
922
+ if (!tabId) return
923
+ if (this.RED.workspaces.isLocked(tabId)) throw new Error(`Workspace ${tabId} is locked`)
837
924
  }
838
925
 
839
926
  closeSearch () { this.RED.search.hide() }
@@ -918,9 +1005,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
918
1005
  // Validate all target tabs exist and are not locked
919
1006
  const uniqueZs = [...new Set(prepared.map(n => n.z).filter(Boolean))]
920
1007
  for (const z of uniqueZs) {
921
- if (!this.hasWorkspace(z)) throw new Error(`Workspace tab ${z} not found`)
922
- const ws = this.RED.nodes.workspace(z)
923
- if (ws.locked) throw new Error(`Workspace tab ${z} is locked`)
1008
+ this._assertWorkspaceExists(z)
1009
+ this._assertWorkspaceNotLocked(z)
924
1010
  }
925
1011
  // Pre-import: reject if any node ID already exists on the canvas
926
1012
  if (!generateIds) {
@@ -953,9 +1039,13 @@ export class ExpertAutomations extends ExpertActionsInterface {
953
1039
 
954
1040
  /**
955
1041
  * Remove one or more nodes from the live NR4 canvas by ID.
1042
+ * Delegates to Node-RED's core:delete-selection action to ensure all internal data
1043
+ * structures (including group membership arrays) are properly cleaned up.
956
1044
  * @param {string[]} ids - node IDs to remove
1045
+ * @param {object} [options]
1046
+ * @param {boolean} [options.reconnectWires=false] - reconnect wires around removed nodes
957
1047
  */
958
- removeNodes (ids) {
1048
+ removeNodes (ids, { reconnectWires = false } = {}) {
959
1049
  // Resolve all nodes once and check for missing
960
1050
  const nodes = ids.map(id => {
961
1051
  const node = this.RED.nodes.node(id)
@@ -964,19 +1054,15 @@ export class ExpertAutomations extends ExpertActionsInterface {
964
1054
  })
965
1055
  // Check if any node's workspace is locked
966
1056
  for (const node of nodes) {
967
- if (node.z && this.RED.workspaces.isLocked(node.z)) {
968
- throw new Error(`Cannot remove node ${node.id} — workspace ${node.z} is locked`)
969
- }
1057
+ this._assertWorkspaceNotLocked(node.z)
970
1058
  }
971
- const allRemovedLinks = []
972
- for (const node of nodes) {
973
- const removed = this.RED.nodes.remove(node.id)
974
- allRemovedLinks.push(...(removed.links || []))
1059
+ this.RED.view.select({ nodes })
1060
+ this._verifySelection(nodes)
1061
+ if (reconnectWires) {
1062
+ this.RED.actions.invoke('core:delete-selection-and-reconnect')
1063
+ } else {
1064
+ this.RED.actions.invoke('core:delete-selection')
975
1065
  }
976
- this.RED.history.push({ t: 'delete', nodes, links: allRemovedLinks, dirty: this.RED.nodes.dirty() })
977
- this.RED.nodes.dirty(true)
978
- this.RED.view.updateActive()
979
- this.RED.view.redraw()
980
1066
  }
981
1067
 
982
1068
  /**
@@ -998,9 +1084,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
998
1084
  throw new Error('Source and target nodes must be on the same tab')
999
1085
  }
1000
1086
  // Check workspace is not locked
1001
- if (sourceNode.z && this.RED.workspaces.isLocked(sourceNode.z)) {
1002
- throw new Error(`Cannot modify wires — workspace ${sourceNode.z} is locked`)
1003
- }
1087
+ this._assertWorkspaceNotLocked(sourceNode.z)
1004
1088
  // Validate output port exists on source
1005
1089
  const port = output ?? 0
1006
1090
  if (port >= (sourceNode.outputs || 0)) {
@@ -1069,12 +1153,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
1069
1153
  throw new Error(`Source node ${source} is a link call in dynamic mode and cannot have static links`)
1070
1154
  }
1071
1155
  // Check workspace locks for both nodes
1072
- if (sourceNode.z && this.RED.workspaces.isLocked(sourceNode.z)) {
1073
- throw new Error(`Cannot modify links — workspace ${sourceNode.z} is locked`)
1074
- }
1075
- if (targetNode.z && targetNode.z !== sourceNode.z && this.RED.workspaces.isLocked(targetNode.z)) {
1076
- throw new Error(`Cannot modify links — workspace ${targetNode.z} is locked`)
1077
- }
1156
+ this._assertWorkspaceNotLocked(sourceNode.z)
1157
+ if (targetNode.z !== sourceNode.z) this._assertWorkspaceNotLocked(targetNode.z)
1078
1158
  const isBidirectional = sourceNode.type === 'link out'
1079
1159
  const sourceLinks = sourceNode.links || []
1080
1160
  const targetLinks = targetNode.links || []
@@ -1197,6 +1277,30 @@ export class ExpertAutomations extends ExpertActionsInterface {
1197
1277
  break
1198
1278
 
1199
1279
  case UPDATE_NODE: {
1280
+ if (this.RED.nodes.group(params.id)) {
1281
+ result.error = `Node ${params.id} is a group — group nodes cannot be updated via this action`
1282
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1283
+ result.success = false
1284
+ break
1285
+ }
1286
+ if (params.properties && 'wires' in params.properties) {
1287
+ result.error = `Node ${params.id}: "wires" cannot be set directly — wire connections must be managed via a dedicated action`
1288
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1289
+ result.success = false
1290
+ break
1291
+ }
1292
+ if (params.properties && 'links' in params.properties && LINK_NODE_TYPES.includes(this.RED.nodes.node(params.id)?.type)) {
1293
+ result.error = `Node ${params.id}: "links" cannot be set directly — link connections must be managed via a dedicated action`
1294
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1295
+ result.success = false
1296
+ break
1297
+ }
1298
+ if (params.properties && 'g' in params.properties) {
1299
+ result.error = `Node ${params.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
1300
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1301
+ result.success = false
1302
+ break
1303
+ }
1200
1304
  await this.updateNode(params.id, params.properties, params.patches)
1201
1305
  const updatedNode = this.RED.nodes.node(params.id)
1202
1306
  result.data = this._summarizeNode(updatedNode)
@@ -1216,15 +1320,18 @@ export class ExpertAutomations extends ExpertActionsInterface {
1216
1320
  case GET_FLOW:
1217
1321
  result.flows = this.getFlow()
1218
1322
  if (result.flows && Array.isArray(result.flows) && result.flows.length > 0) {
1219
- if (params.type) {
1323
+ if (params && params.type) {
1220
1324
  // filter by type if specified (e.g. "tab", "subflow", or any node type)
1221
1325
  result.flows = result.flows.filter(f => f.type === params.type)
1222
1326
  }
1223
- if (params.tabId) {
1327
+ if (params && params.tabId) {
1224
1328
  // filter by parent tab ID if specified (for nodes/config nodes)
1225
1329
  result.flows = result.flows.filter(f => f.z === params.tabId)
1226
1330
  }
1227
1331
  }
1332
+ if (!params || !params.full) {
1333
+ result.flows = (result.flows || []).map(f => this._summarizeFlowItem(f))
1334
+ }
1228
1335
  result.success = true
1229
1336
  break
1230
1337
 
@@ -1265,6 +1372,34 @@ export class ExpertAutomations extends ExpertActionsInterface {
1265
1372
  break
1266
1373
 
1267
1374
  case ADD_NODES: {
1375
+ const groupNodes = (params.nodes || []).filter(n => n.type === 'group')
1376
+ if (groupNodes.length > 0) {
1377
+ result.error = `Nodes [${groupNodes.map(n => n.id).join(', ')}] are group nodes — group nodes cannot be added via this action`
1378
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1379
+ result.success = false
1380
+ break
1381
+ }
1382
+ const wiresNode = (params.nodes || []).find(n => n.wires !== undefined)
1383
+ if (wiresNode) {
1384
+ result.error = `Node ${wiresNode.id}: "wires" cannot be set directly — wire connections must be managed via a dedicated action`
1385
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1386
+ result.success = false
1387
+ break
1388
+ }
1389
+ const linksNode = (params.nodes || []).find(n => n.links !== undefined && LINK_NODE_TYPES.includes(n.type))
1390
+ if (linksNode) {
1391
+ result.error = `Node ${linksNode.id}: "links" cannot be set directly — link connections must be managed via a dedicated action`
1392
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1393
+ result.success = false
1394
+ break
1395
+ }
1396
+ const gNode = (params.nodes || []).find(n => n.g !== undefined)
1397
+ if (gNode) {
1398
+ result.error = `Node ${gNode.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
1399
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1400
+ result.success = false
1401
+ break
1402
+ }
1268
1403
  this.addNodes(params.nodes, { generateIds: params.generateIds ?? false })
1269
1404
  const addedNodes = params.nodes.map(n => this.RED.nodes.node(n.id)).filter(Boolean)
1270
1405
  if (this.RED.editor?.validateNode) {
@@ -1276,10 +1411,25 @@ export class ExpertAutomations extends ExpertActionsInterface {
1276
1411
  }
1277
1412
  break
1278
1413
 
1279
- case REMOVE_NODES:
1280
- this.removeNodes(params.ids)
1414
+ case REMOVE_NODES: {
1415
+ const groupIds = (params.ids || []).filter(id => !!this.RED.nodes.group(id))
1416
+ if (groupIds.length > 0) {
1417
+ result.error = `IDs [${groupIds.join(', ')}] are group nodes — group nodes cannot be removed via this action`
1418
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1419
+ result.success = false
1420
+ break
1421
+ }
1422
+ const groupedNodes = (params.ids || []).map(id => this.RED.nodes.node(id)).filter(n => n && n.g)
1423
+ if (groupedNodes.length > 0) {
1424
+ result.error = `Node ${groupedNodes[0].id}: cannot be removed while it is a member of a group — group membership must be managed via a dedicated action first`
1425
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1426
+ result.success = false
1427
+ break
1428
+ }
1429
+ this.removeNodes(params.ids, { reconnectWires: params.reconnectWires ?? false })
1281
1430
  result.data = { removed: params.ids }
1282
1431
  result.success = true
1432
+ }
1283
1433
  break
1284
1434
 
1285
1435
  case SET_WIRES:
@@ -1362,6 +1512,57 @@ export class ExpertAutomations extends ExpertActionsInterface {
1362
1512
  })
1363
1513
  result.success = true
1364
1514
  break
1515
+ case MANAGE_GROUPS: {
1516
+ const operations = params.operations
1517
+ if (!Array.isArray(operations) || operations.length === 0) {
1518
+ throw new Error('operations array is required and must not be empty')
1519
+ }
1520
+ const results = []
1521
+ for (const entry of operations) {
1522
+ const op = entry.op
1523
+ if (!op) throw new Error('op is required for each manage-groups operation')
1524
+ switch (op) {
1525
+ case 'create': {
1526
+ const group = this.createGroup(entry.nodeIds, entry.name, { style: entry.style, env: entry.env, id: entry.id })
1527
+ results.push({ op: 'create', ...this._summarizeGroup(group) })
1528
+ break
1529
+ }
1530
+ case 'update': {
1531
+ if (!entry.id) throw new Error('id is required for update')
1532
+ const group = this.updateGroup(entry.id, {
1533
+ name: entry.name,
1534
+ style: entry.style,
1535
+ env: entry.env
1536
+ })
1537
+ results.push({ op: 'update', ...this._summarizeGroup(group) })
1538
+ break
1539
+ }
1540
+ case 'manage-members': {
1541
+ if (!entry.id) throw new Error('id is required for manage-members')
1542
+ if (!entry.nodeIds || entry.nodeIds.length === 0) throw new Error('nodeIds is required for manage-members')
1543
+ const mode = entry.mode
1544
+ if (mode !== 'add' && mode !== 'remove') throw new Error('mode must be "add" or "remove"')
1545
+ const group = this.updateGroup(entry.id, {
1546
+ nodeIds: entry.nodeIds,
1547
+ remove: mode === 'remove'
1548
+ })
1549
+ results.push({ op: 'manage-members', mode, ...this._summarizeGroup(group) })
1550
+ break
1551
+ }
1552
+ case 'delete': {
1553
+ if (!entry.id) throw new Error('id is required for delete')
1554
+ this.deleteGroup(entry.id)
1555
+ results.push({ op: 'delete', deleted: entry.id })
1556
+ break
1557
+ }
1558
+ default:
1559
+ throw new Error(`Unknown manage-groups op: ${op}`)
1560
+ }
1561
+ }
1562
+ result.data = results
1563
+ result.success = true
1564
+ break
1565
+ }
1365
1566
  default:
1366
1567
  result.handled = false
1367
1568
  result.success = false
@@ -1411,6 +1612,185 @@ export class ExpertAutomations extends ExpertActionsInterface {
1411
1612
  }
1412
1613
  }
1413
1614
 
1615
+ /**
1616
+ * Create a group containing the specified nodes.
1617
+ * Uses Node-RED's core:group-selection action after selecting the target nodes.
1618
+ * @param {string[]} nodeIds - IDs of nodes to group
1619
+ * @param {string} [name] - optional group display name
1620
+ * @param {object} [opts]
1621
+ * @param {object} [opts.style] - visual style overrides
1622
+ * @param {Array} [opts.env] - environment variables for nodes inside the group
1623
+ * @param {string} [opts.id] - explicit group ID; auto-generated if omitted
1624
+ * @returns {object} the created group node
1625
+ */
1626
+ createGroup (nodeIds, name, { style, env, id } = {}) {
1627
+ if (!nodeIds || nodeIds.length === 0) {
1628
+ throw new Error('nodeIds is required and must not be empty')
1629
+ }
1630
+ const nodes = nodeIds.map(id => {
1631
+ const n = this.RED.nodes.node(id)
1632
+ if (!n) throw new Error(`Node ${id} not found`)
1633
+ return n
1634
+ })
1635
+ const tabs = new Set(nodes.map(n => n.z).filter(Boolean))
1636
+ if (tabs.size > 1) {
1637
+ throw new Error('All nodes must be on the same tab to form a group')
1638
+ }
1639
+ const tabId = tabs.size === 1 ? [...tabs][0] : null
1640
+ this._assertWorkspaceNotLocked(tabId)
1641
+
1642
+ // Snapshot existing group IDs so we can identify the newly created one
1643
+ const existingGroupIds = new Set()
1644
+ this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
1645
+
1646
+ this.RED.view.select({ nodes })
1647
+ this._verifySelection(nodes)
1648
+ this.RED.actions.invoke('core:group-selection')
1649
+
1650
+ const newGroups = []
1651
+ this.RED.nodes.eachGroup(g => {
1652
+ if (!existingGroupIds.has(g.id)) newGroups.push(g)
1653
+ })
1654
+
1655
+ if (newGroups.length === 0) {
1656
+ throw new Error('Group creation failed: core:group-selection did not create a new group')
1657
+ }
1658
+
1659
+ const newGroup = newGroups[0]
1660
+ const missingIds = nodeIds.filter(nid => !newGroup.nodes?.some(n => (n.id || n) === nid))
1661
+ if (missingIds.length > 0) {
1662
+ throw new Error(`Group created but missing nodes: ${missingIds.join(', ')}`)
1663
+ }
1664
+
1665
+ // Rename to the requested ID if one was provided and differs from the auto-generated one
1666
+ if (id !== undefined && id !== newGroup.id) {
1667
+ if (this.RED.nodes.node(id) || this.RED.nodes.group(id)) {
1668
+ throw new Error(`Group ID ${id} is already in use`)
1669
+ }
1670
+ const oldId = newGroup.id
1671
+ this.RED.nodes.remove(oldId)
1672
+ newGroup.id = id
1673
+ ;(newGroup.nodes || []).forEach(n => {
1674
+ const nodeRef = typeof n === 'object' ? n : this.RED.nodes.node(n)
1675
+ if (nodeRef && nodeRef.g === oldId) nodeRef.g = id
1676
+ })
1677
+ this.RED.nodes.add(newGroup)
1678
+ }
1679
+
1680
+ const changes = {}
1681
+ if (name) {
1682
+ changes.name = newGroup.name
1683
+ newGroup.name = name
1684
+ }
1685
+ if (style) {
1686
+ changes.style = { ...newGroup.style }
1687
+ newGroup.style = Object.assign({}, newGroup.style || {}, style)
1688
+ }
1689
+ if (env !== undefined) {
1690
+ changes.env = newGroup.env
1691
+ newGroup.env = env
1692
+ }
1693
+ if (Object.keys(changes).length > 0) {
1694
+ newGroup.changed = true
1695
+ newGroup.dirty = true
1696
+ this.RED.history.push({ t: 'edit', node: newGroup, changes, changed: false, dirty: this.RED.nodes.dirty() })
1697
+ }
1698
+
1699
+ this.RED.nodes.dirty(true)
1700
+ this.RED.view.redraw()
1701
+ return newGroup
1702
+ }
1703
+
1704
+ /**
1705
+ * Update an existing group: rename, add/remove nodes, change style.
1706
+ * @param {string} id - group ID
1707
+ * @param {object} updates - { name?, addNodeIds?, removeNodeIds?, style? }
1708
+ * @returns {object} the updated group node
1709
+ */
1710
+ updateGroup (id, { name, nodeIds, remove, style, env } = {}) {
1711
+ let group = this.RED.nodes.group(id)
1712
+ if (!group) throw new Error(`Group ${id} not found`)
1713
+ this._assertWorkspaceNotLocked(group.z)
1714
+
1715
+ if (nodeIds && nodeIds.length > 0) {
1716
+ if (remove) {
1717
+ const nodes = nodeIds.map(nodeId => {
1718
+ const node = this.RED.nodes.node(nodeId)
1719
+ if (!node) throw new Error(`Node ${nodeId} not found`)
1720
+ if (node.g !== group.id) throw new Error(`Node ${nodeId} is not in group ${id}`)
1721
+ return node
1722
+ })
1723
+ this.RED.view.select({ nodes })
1724
+ this._verifySelection(nodes)
1725
+ this.RED.actions.invoke('core:remove-selection-from-group')
1726
+ } else {
1727
+ const existingGroupIds = new Set()
1728
+ this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
1729
+
1730
+ const newNodes = nodeIds.map(nodeId => {
1731
+ const node = this.RED.nodes.node(nodeId)
1732
+ if (!node) throw new Error(`Node ${nodeId} not found`)
1733
+ if (node.z !== group.z) throw new Error(`Node ${nodeId} is on tab ${node.z} but group is on tab ${group.z}`)
1734
+ return node
1735
+ })
1736
+ this.RED.view.select({ nodes: [group, ...newNodes] })
1737
+ this._verifySelection([group, ...newNodes])
1738
+ this.RED.actions.invoke('core:merge-selection-to-group')
1739
+
1740
+ const newGroups = []
1741
+ this.RED.nodes.eachGroup(g => {
1742
+ if (!existingGroupIds.has(g.id)) newGroups.push(g)
1743
+ })
1744
+ if (newGroups.length === 0) {
1745
+ throw new Error('Add nodes failed: core:merge-selection-to-group did not create a new group')
1746
+ }
1747
+ group = newGroups[0]
1748
+ }
1749
+ }
1750
+
1751
+ const changes = {}
1752
+
1753
+ if (name !== undefined) {
1754
+ changes.name = group.name
1755
+ group.name = name
1756
+ }
1757
+
1758
+ if (style) {
1759
+ changes.style = { ...group.style }
1760
+ group.style = Object.assign({}, group.style || {}, style)
1761
+ }
1762
+
1763
+ if (env !== undefined) {
1764
+ changes.env = group.env
1765
+ group.env = env
1766
+ }
1767
+
1768
+ if (Object.keys(changes).length > 0) {
1769
+ group.changed = true
1770
+ group.dirty = true
1771
+ this.RED.history.push({ t: 'edit', node: group, changes, changed: false, dirty: this.RED.nodes.dirty() })
1772
+ }
1773
+
1774
+ this.RED.nodes.dirty(true)
1775
+ this.RED.view.redraw()
1776
+ return group
1777
+ }
1778
+
1779
+ /**
1780
+ * Delete a group (ungroup its nodes).
1781
+ * @param {string} id - group ID
1782
+ */
1783
+ deleteGroup (id) {
1784
+ const group = this.RED.nodes.group(id)
1785
+ if (!group) throw new Error(`Group ${id} not found`)
1786
+ this._assertWorkspaceNotLocked(group.z)
1787
+ this.RED.view.select({ nodes: [group] })
1788
+ this._verifySelection([group])
1789
+ this.RED.actions.invoke('core:ungroup-selection')
1790
+ this.RED.nodes.dirty(true)
1791
+ this.RED.view.redraw()
1792
+ }
1793
+
1414
1794
  _summarizeNode (node) {
1415
1795
  if (!node) return null
1416
1796
  const s = { id: node.id }
@@ -1420,7 +1800,65 @@ export class ExpertAutomations extends ExpertActionsInterface {
1420
1800
  if (node.x !== undefined) s.x = node.x
1421
1801
  if (node.y !== undefined) s.y = node.y
1422
1802
  if (node.z !== undefined) s.z = node.z
1803
+ if (node.wires !== undefined) s.wires = node.wires
1804
+ if (node.links !== undefined) s.links = node.links
1423
1805
  if (node.valid !== undefined) s.valid = node.valid
1424
1806
  return s
1425
1807
  }
1808
+
1809
+ /**
1810
+ * Defensive check: verify the view selection contains exactly the expected nodes.
1811
+ * Guards against editor states where RED.view.select() is silently ignored,
1812
+ * which would cause core actions to act on unintended nodes.
1813
+ * @param {object[]} expectedNodes
1814
+ */
1815
+ _verifySelection (expectedNodes) {
1816
+ const selection = this.RED.view.selection()
1817
+ const selectedNodes = selection?.nodes || []
1818
+ const expectedIds = expectedNodes.map(n => n.id)
1819
+ const selectedIds = selectedNodes.map(n => n.id)
1820
+ const expectedSet = new Set(expectedIds)
1821
+ const selectedSet = new Set(selectedIds)
1822
+ if (selectedSet.size !== expectedSet.size || ![...expectedSet].every(id => selectedSet.has(id))) {
1823
+ throw new Error(
1824
+ `Selection mismatch: expected [${expectedIds.join(', ')}] but selection is [${selectedIds.join(', ')}] — editor may be in an unexpected state`
1825
+ )
1826
+ }
1827
+ }
1828
+
1829
+ _summarizeSubflow (subflow) {
1830
+ if (!subflow) return null
1831
+ return {
1832
+ id: subflow.id,
1833
+ type: subflow.type,
1834
+ name: subflow.name,
1835
+ info: subflow.info,
1836
+ inputs: Array.isArray(subflow.in) ? subflow.in.length : 0,
1837
+ outputs: Array.isArray(subflow.out) ? subflow.out.length : 0
1838
+ }
1839
+ }
1840
+
1841
+ _summarizeFlowItem (item) {
1842
+ if (!item) return null
1843
+ if (item.type === 'tab') {
1844
+ const ws = this.RED.nodes.workspace(item.id)
1845
+ return this._summarizeWorkspace(ws)
1846
+ }
1847
+ if (item.type === 'subflow') return this._summarizeSubflow(item)
1848
+ if (item.type === 'group') return this._summarizeGroup(item)
1849
+ return this._summarizeNode(item)
1850
+ }
1851
+
1852
+ _summarizeGroup (group) {
1853
+ if (!group) return null
1854
+ const s = { id: group.id, type: 'group' }
1855
+ if (group.name !== undefined) s.name = group.name
1856
+ if (group.z !== undefined) s.z = group.z
1857
+ if (Array.isArray(group.nodes)) {
1858
+ s.nodes = group.nodes.filter(Boolean).map(n => (typeof n === 'string' ? n : n.id))
1859
+ }
1860
+ if (group.style !== undefined) s.style = group.style
1861
+ if (Array.isArray(group.env) && group.env.length > 0) s.env = group.env
1862
+ return s
1863
+ }
1426
1864
  }