@flowfuse/nr-assistant 0.14.0 → 0.14.1-5d7a461-202605121018.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-5d7a461-202605121018.0",
4
4
  "description": "FlowFuse Node-RED Expert plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -25,6 +25,12 @@ 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
+ })
28
34
 
29
35
  /**
30
36
  * @typedef {SELECT_NODES
@@ -50,7 +56,8 @@ const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
50
56
  * |GET_NODE_TYPES
51
57
  * |GET_PALETTE
52
58
  * |LIST_CONFIG_NODES
53
- * |OPEN_PALETTE_MANAGER} ExpertAutomationsActionsEnum
59
+ * |OPEN_PALETTE_MANAGER
60
+ * |MANAGE_GROUPS} ExpertAutomationsActionsEnum
54
61
  */
55
62
 
56
63
  export class ExpertAutomations extends ExpertActionsInterface {
@@ -187,6 +194,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
187
194
  tabId: {
188
195
  type: 'string',
189
196
  description: 'Optional parameter to only get nodes on a specific workspace. Exclude this parameter to get node from all workspaces.'
197
+ },
198
+ full: {
199
+ type: 'boolean',
200
+ description: 'When true, returns the raw node objects instead of condensed summaries.'
190
201
  }
191
202
  }
192
203
  }
@@ -268,6 +279,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
268
279
  type: 'array',
269
280
  items: { type: 'string' },
270
281
  description: 'IDs of nodes to remove from the canvas'
282
+ },
283
+ reconnectWires: {
284
+ type: 'boolean',
285
+ description: 'If true, reconnects wires around removed nodes (pass-through). Default: false'
271
286
  }
272
287
  },
273
288
  required: ['ids']
@@ -377,6 +392,61 @@ export class ExpertAutomations extends ExpertActionsInterface {
377
392
  }
378
393
  }
379
394
  }
395
+ },
396
+ [MANAGE_GROUPS]: {
397
+ params: {
398
+ type: 'object',
399
+ properties: {
400
+ operations: {
401
+ type: 'array',
402
+ items: {
403
+ type: 'object',
404
+ properties: {
405
+ op: {
406
+ type: 'string',
407
+ enum: ['create', 'update', 'manage-members', 'delete'],
408
+ description: 'Operation to perform'
409
+ },
410
+ id: {
411
+ type: 'string',
412
+ description: 'Group ID (required for update, manage-members, and delete)'
413
+ },
414
+ nodeIds: {
415
+ type: 'array',
416
+ items: { type: 'string' },
417
+ description: 'Node IDs: required for create and manage-members'
418
+ },
419
+ name: {
420
+ type: 'string',
421
+ description: 'Group display name (optional for create and update)'
422
+ },
423
+ mode: {
424
+ type: 'string',
425
+ enum: ['add', 'remove'],
426
+ description: 'manage-members only — "add" moves nodes into the group, "remove" takes them out'
427
+ },
428
+ style: {
429
+ type: 'object',
430
+ description: 'Visual style: stroke/border-color, fill, color, stroke-opacity, fill-opacity, label, label-position',
431
+ properties: {
432
+ stroke: { type: 'string' },
433
+ 'border-color': { type: 'string' },
434
+ fill: { type: 'string' },
435
+ color: { type: 'string' },
436
+ 'stroke-opacity': { type: 'number' },
437
+ 'fill-opacity': { type: 'number' },
438
+ label: { type: 'boolean' },
439
+ 'label-position': { type: 'string', enum: ['nw', 'n', 'ne', 'sw', 's', 'se'] }
440
+ }
441
+ }
442
+ },
443
+ required: ['op']
444
+ },
445
+ description: 'Array of group operations to execute sequentially'
446
+ }
447
+ },
448
+ required: ['operations']
449
+ }
380
450
  }
381
451
  })
382
452
 
@@ -821,7 +891,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
821
891
  * @param {string} id - workspace ID to show
822
892
  */
823
893
  showWorkspace (id) {
824
- if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
894
+ this._assertWorkspaceExists(id)
825
895
  this.RED.workspaces.show(id)
826
896
  }
827
897
 
@@ -831,9 +901,24 @@ export class ExpertAutomations extends ExpertActionsInterface {
831
901
  * @returns {boolean} true if the workspace exists, false otherwise
832
902
  */
833
903
  hasWorkspace (id) {
834
- const ws = this.RED.nodes.workspace(id)
835
- if (ws) return true
836
- return false
904
+ return !!this.RED.nodes.workspace(id)
905
+ }
906
+
907
+ /**
908
+ * Throw if the workspace does not exist.
909
+ * @param {string} id - workspace ID
910
+ */
911
+ _assertWorkspaceExists (id) {
912
+ if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
913
+ }
914
+
915
+ /**
916
+ * Throw if the workspace tab is locked. No-op when tabId is falsy (e.g. config nodes).
917
+ * @param {string|null|undefined} tabId - workspace tab ID
918
+ */
919
+ _assertWorkspaceNotLocked (tabId) {
920
+ if (!tabId) return
921
+ if (this.RED.workspaces.isLocked(tabId)) throw new Error(`Workspace ${tabId} is locked`)
837
922
  }
838
923
 
839
924
  closeSearch () { this.RED.search.hide() }
@@ -918,9 +1003,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
918
1003
  // Validate all target tabs exist and are not locked
919
1004
  const uniqueZs = [...new Set(prepared.map(n => n.z).filter(Boolean))]
920
1005
  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`)
1006
+ this._assertWorkspaceExists(z)
1007
+ this._assertWorkspaceNotLocked(z)
924
1008
  }
925
1009
  // Pre-import: reject if any node ID already exists on the canvas
926
1010
  if (!generateIds) {
@@ -953,9 +1037,13 @@ export class ExpertAutomations extends ExpertActionsInterface {
953
1037
 
954
1038
  /**
955
1039
  * Remove one or more nodes from the live NR4 canvas by ID.
1040
+ * Delegates to Node-RED's core:delete-selection action to ensure all internal data
1041
+ * structures (including group membership arrays) are properly cleaned up.
956
1042
  * @param {string[]} ids - node IDs to remove
1043
+ * @param {object} [options]
1044
+ * @param {boolean} [options.reconnectWires=false] - reconnect wires around removed nodes
957
1045
  */
958
- removeNodes (ids) {
1046
+ removeNodes (ids, { reconnectWires = false } = {}) {
959
1047
  // Resolve all nodes once and check for missing
960
1048
  const nodes = ids.map(id => {
961
1049
  const node = this.RED.nodes.node(id)
@@ -964,19 +1052,15 @@ export class ExpertAutomations extends ExpertActionsInterface {
964
1052
  })
965
1053
  // Check if any node's workspace is locked
966
1054
  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
- }
1055
+ this._assertWorkspaceNotLocked(node.z)
970
1056
  }
971
- const allRemovedLinks = []
972
- for (const node of nodes) {
973
- const removed = this.RED.nodes.remove(node.id)
974
- allRemovedLinks.push(...(removed.links || []))
1057
+ this.RED.view.select({ nodes })
1058
+ this._verifySelection(nodes)
1059
+ if (reconnectWires) {
1060
+ this.RED.actions.invoke('core:delete-selection-and-reconnect')
1061
+ } else {
1062
+ this.RED.actions.invoke('core:delete-selection')
975
1063
  }
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
1064
  }
981
1065
 
982
1066
  /**
@@ -998,9 +1082,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
998
1082
  throw new Error('Source and target nodes must be on the same tab')
999
1083
  }
1000
1084
  // 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
- }
1085
+ this._assertWorkspaceNotLocked(sourceNode.z)
1004
1086
  // Validate output port exists on source
1005
1087
  const port = output ?? 0
1006
1088
  if (port >= (sourceNode.outputs || 0)) {
@@ -1069,12 +1151,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
1069
1151
  throw new Error(`Source node ${source} is a link call in dynamic mode and cannot have static links`)
1070
1152
  }
1071
1153
  // 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
- }
1154
+ this._assertWorkspaceNotLocked(sourceNode.z)
1155
+ if (targetNode.z !== sourceNode.z) this._assertWorkspaceNotLocked(targetNode.z)
1078
1156
  const isBidirectional = sourceNode.type === 'link out'
1079
1157
  const sourceLinks = sourceNode.links || []
1080
1158
  const targetLinks = targetNode.links || []
@@ -1197,6 +1275,18 @@ export class ExpertAutomations extends ExpertActionsInterface {
1197
1275
  break
1198
1276
 
1199
1277
  case UPDATE_NODE: {
1278
+ if (this.RED.nodes.group(params.id)) {
1279
+ result.error = `Node ${params.id} is a group — group nodes cannot be updated via this action`
1280
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1281
+ result.success = false
1282
+ break
1283
+ }
1284
+ if (params.properties && 'g' in params.properties) {
1285
+ result.error = `Node ${params.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
1286
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1287
+ result.success = false
1288
+ break
1289
+ }
1200
1290
  await this.updateNode(params.id, params.properties, params.patches)
1201
1291
  const updatedNode = this.RED.nodes.node(params.id)
1202
1292
  result.data = this._summarizeNode(updatedNode)
@@ -1216,15 +1306,18 @@ export class ExpertAutomations extends ExpertActionsInterface {
1216
1306
  case GET_FLOW:
1217
1307
  result.flows = this.getFlow()
1218
1308
  if (result.flows && Array.isArray(result.flows) && result.flows.length > 0) {
1219
- if (params.type) {
1309
+ if (params && params.type) {
1220
1310
  // filter by type if specified (e.g. "tab", "subflow", or any node type)
1221
1311
  result.flows = result.flows.filter(f => f.type === params.type)
1222
1312
  }
1223
- if (params.tabId) {
1313
+ if (params && params.tabId) {
1224
1314
  // filter by parent tab ID if specified (for nodes/config nodes)
1225
1315
  result.flows = result.flows.filter(f => f.z === params.tabId)
1226
1316
  }
1227
1317
  }
1318
+ if (!params || !params.full) {
1319
+ result.flows = (result.flows || []).map(f => this._summarizeFlowItem(f))
1320
+ }
1228
1321
  result.success = true
1229
1322
  break
1230
1323
 
@@ -1265,6 +1358,20 @@ export class ExpertAutomations extends ExpertActionsInterface {
1265
1358
  break
1266
1359
 
1267
1360
  case ADD_NODES: {
1361
+ const groupNodes = (params.nodes || []).filter(n => n.type === 'group')
1362
+ if (groupNodes.length > 0) {
1363
+ result.error = `Nodes [${groupNodes.map(n => n.id).join(', ')}] are group nodes — group nodes cannot be added via this action`
1364
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1365
+ result.success = false
1366
+ break
1367
+ }
1368
+ const gNode = (params.nodes || []).find(n => n.g !== undefined)
1369
+ if (gNode) {
1370
+ result.error = `Node ${gNode.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
1371
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1372
+ result.success = false
1373
+ break
1374
+ }
1268
1375
  this.addNodes(params.nodes, { generateIds: params.generateIds ?? false })
1269
1376
  const addedNodes = params.nodes.map(n => this.RED.nodes.node(n.id)).filter(Boolean)
1270
1377
  if (this.RED.editor?.validateNode) {
@@ -1276,10 +1383,25 @@ export class ExpertAutomations extends ExpertActionsInterface {
1276
1383
  }
1277
1384
  break
1278
1385
 
1279
- case REMOVE_NODES:
1280
- this.removeNodes(params.ids)
1386
+ case REMOVE_NODES: {
1387
+ const groupIds = (params.ids || []).filter(id => !!this.RED.nodes.group(id))
1388
+ if (groupIds.length > 0) {
1389
+ result.error = `IDs [${groupIds.join(', ')}] are group nodes — group nodes cannot be removed via this action`
1390
+ result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
1391
+ result.success = false
1392
+ break
1393
+ }
1394
+ const groupedNodes = (params.ids || []).map(id => this.RED.nodes.node(id)).filter(n => n && n.g)
1395
+ if (groupedNodes.length > 0) {
1396
+ 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`
1397
+ result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
1398
+ result.success = false
1399
+ break
1400
+ }
1401
+ this.removeNodes(params.ids, { reconnectWires: params.reconnectWires ?? false })
1281
1402
  result.data = { removed: params.ids }
1282
1403
  result.success = true
1404
+ }
1283
1405
  break
1284
1406
 
1285
1407
  case SET_WIRES:
@@ -1362,6 +1484,57 @@ export class ExpertAutomations extends ExpertActionsInterface {
1362
1484
  })
1363
1485
  result.success = true
1364
1486
  break
1487
+ case MANAGE_GROUPS: {
1488
+ const operations = params.operations
1489
+ if (!Array.isArray(operations) || operations.length === 0) {
1490
+ throw new Error('operations array is required and must not be empty')
1491
+ }
1492
+ const results = []
1493
+ for (const entry of operations) {
1494
+ const op = entry.op
1495
+ if (!op) throw new Error('op is required for each manage-groups operation')
1496
+ switch (op) {
1497
+ case 'create': {
1498
+ const group = this.createGroup(entry.nodeIds, entry.name, { style: entry.style, env: entry.env, id: entry.id })
1499
+ results.push({ op: 'create', ...this._summarizeGroup(group) })
1500
+ break
1501
+ }
1502
+ case 'update': {
1503
+ if (!entry.id) throw new Error('id is required for update')
1504
+ const group = this.updateGroup(entry.id, {
1505
+ name: entry.name,
1506
+ style: entry.style,
1507
+ env: entry.env
1508
+ })
1509
+ results.push({ op: 'update', ...this._summarizeGroup(group) })
1510
+ break
1511
+ }
1512
+ case 'manage-members': {
1513
+ if (!entry.id) throw new Error('id is required for manage-members')
1514
+ if (!entry.nodeIds || entry.nodeIds.length === 0) throw new Error('nodeIds is required for manage-members')
1515
+ const mode = entry.mode
1516
+ if (mode !== 'add' && mode !== 'remove') throw new Error('mode must be "add" or "remove"')
1517
+ const group = this.updateGroup(entry.id, {
1518
+ nodeIds: entry.nodeIds,
1519
+ remove: mode === 'remove'
1520
+ })
1521
+ results.push({ op: 'manage-members', mode, ...this._summarizeGroup(group) })
1522
+ break
1523
+ }
1524
+ case 'delete': {
1525
+ if (!entry.id) throw new Error('id is required for delete')
1526
+ this.deleteGroup(entry.id)
1527
+ results.push({ op: 'delete', deleted: entry.id })
1528
+ break
1529
+ }
1530
+ default:
1531
+ throw new Error(`Unknown manage-groups op: ${op}`)
1532
+ }
1533
+ }
1534
+ result.data = results
1535
+ result.success = true
1536
+ break
1537
+ }
1365
1538
  default:
1366
1539
  result.handled = false
1367
1540
  result.success = false
@@ -1411,6 +1584,185 @@ export class ExpertAutomations extends ExpertActionsInterface {
1411
1584
  }
1412
1585
  }
1413
1586
 
1587
+ /**
1588
+ * Create a group containing the specified nodes.
1589
+ * Uses Node-RED's core:group-selection action after selecting the target nodes.
1590
+ * @param {string[]} nodeIds - IDs of nodes to group
1591
+ * @param {string} [name] - optional group display name
1592
+ * @param {object} [opts]
1593
+ * @param {object} [opts.style] - visual style overrides
1594
+ * @param {Array} [opts.env] - environment variables for nodes inside the group
1595
+ * @param {string} [opts.id] - explicit group ID; auto-generated if omitted
1596
+ * @returns {object} the created group node
1597
+ */
1598
+ createGroup (nodeIds, name, { style, env, id } = {}) {
1599
+ if (!nodeIds || nodeIds.length === 0) {
1600
+ throw new Error('nodeIds is required and must not be empty')
1601
+ }
1602
+ const nodes = nodeIds.map(id => {
1603
+ const n = this.RED.nodes.node(id)
1604
+ if (!n) throw new Error(`Node ${id} not found`)
1605
+ return n
1606
+ })
1607
+ const tabs = new Set(nodes.map(n => n.z).filter(Boolean))
1608
+ if (tabs.size > 1) {
1609
+ throw new Error('All nodes must be on the same tab to form a group')
1610
+ }
1611
+ const tabId = tabs.size === 1 ? [...tabs][0] : null
1612
+ this._assertWorkspaceNotLocked(tabId)
1613
+
1614
+ // Snapshot existing group IDs so we can identify the newly created one
1615
+ const existingGroupIds = new Set()
1616
+ this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
1617
+
1618
+ this.RED.view.select({ nodes })
1619
+ this._verifySelection(nodes)
1620
+ this.RED.actions.invoke('core:group-selection')
1621
+
1622
+ const newGroups = []
1623
+ this.RED.nodes.eachGroup(g => {
1624
+ if (!existingGroupIds.has(g.id)) newGroups.push(g)
1625
+ })
1626
+
1627
+ if (newGroups.length === 0) {
1628
+ throw new Error('Group creation failed: core:group-selection did not create a new group')
1629
+ }
1630
+
1631
+ const newGroup = newGroups[0]
1632
+ const missingIds = nodeIds.filter(nid => !newGroup.nodes?.some(n => (n.id || n) === nid))
1633
+ if (missingIds.length > 0) {
1634
+ throw new Error(`Group created but missing nodes: ${missingIds.join(', ')}`)
1635
+ }
1636
+
1637
+ // Rename to the requested ID if one was provided and differs from the auto-generated one
1638
+ if (id !== undefined && id !== newGroup.id) {
1639
+ if (this.RED.nodes.node(id) || this.RED.nodes.group(id)) {
1640
+ throw new Error(`Group ID ${id} is already in use`)
1641
+ }
1642
+ const oldId = newGroup.id
1643
+ this.RED.nodes.remove(oldId)
1644
+ newGroup.id = id
1645
+ ;(newGroup.nodes || []).forEach(n => {
1646
+ const nodeRef = typeof n === 'object' ? n : this.RED.nodes.node(n)
1647
+ if (nodeRef && nodeRef.g === oldId) nodeRef.g = id
1648
+ })
1649
+ this.RED.nodes.add(newGroup)
1650
+ }
1651
+
1652
+ const changes = {}
1653
+ if (name) {
1654
+ changes.name = newGroup.name
1655
+ newGroup.name = name
1656
+ }
1657
+ if (style) {
1658
+ changes.style = { ...newGroup.style }
1659
+ newGroup.style = Object.assign({}, newGroup.style || {}, style)
1660
+ }
1661
+ if (env !== undefined) {
1662
+ changes.env = newGroup.env
1663
+ newGroup.env = env
1664
+ }
1665
+ if (Object.keys(changes).length > 0) {
1666
+ newGroup.changed = true
1667
+ newGroup.dirty = true
1668
+ this.RED.history.push({ t: 'edit', node: newGroup, changes, changed: false, dirty: this.RED.nodes.dirty() })
1669
+ }
1670
+
1671
+ this.RED.nodes.dirty(true)
1672
+ this.RED.view.redraw()
1673
+ return newGroup
1674
+ }
1675
+
1676
+ /**
1677
+ * Update an existing group: rename, add/remove nodes, change style.
1678
+ * @param {string} id - group ID
1679
+ * @param {object} updates - { name?, addNodeIds?, removeNodeIds?, style? }
1680
+ * @returns {object} the updated group node
1681
+ */
1682
+ updateGroup (id, { name, nodeIds, remove, style, env } = {}) {
1683
+ let group = this.RED.nodes.group(id)
1684
+ if (!group) throw new Error(`Group ${id} not found`)
1685
+ this._assertWorkspaceNotLocked(group.z)
1686
+
1687
+ if (nodeIds && nodeIds.length > 0) {
1688
+ if (remove) {
1689
+ const nodes = nodeIds.map(nodeId => {
1690
+ const node = this.RED.nodes.node(nodeId)
1691
+ if (!node) throw new Error(`Node ${nodeId} not found`)
1692
+ if (node.g !== group.id) throw new Error(`Node ${nodeId} is not in group ${id}`)
1693
+ return node
1694
+ })
1695
+ this.RED.view.select({ nodes })
1696
+ this._verifySelection(nodes)
1697
+ this.RED.actions.invoke('core:remove-selection-from-group')
1698
+ } else {
1699
+ const existingGroupIds = new Set()
1700
+ this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
1701
+
1702
+ const newNodes = nodeIds.map(nodeId => {
1703
+ const node = this.RED.nodes.node(nodeId)
1704
+ if (!node) throw new Error(`Node ${nodeId} not found`)
1705
+ if (node.z !== group.z) throw new Error(`Node ${nodeId} is on tab ${node.z} but group is on tab ${group.z}`)
1706
+ return node
1707
+ })
1708
+ this.RED.view.select({ nodes: [group, ...newNodes] })
1709
+ this._verifySelection([group, ...newNodes])
1710
+ this.RED.actions.invoke('core:merge-selection-to-group')
1711
+
1712
+ const newGroups = []
1713
+ this.RED.nodes.eachGroup(g => {
1714
+ if (!existingGroupIds.has(g.id)) newGroups.push(g)
1715
+ })
1716
+ if (newGroups.length === 0) {
1717
+ throw new Error('Add nodes failed: core:merge-selection-to-group did not create a new group')
1718
+ }
1719
+ group = newGroups[0]
1720
+ }
1721
+ }
1722
+
1723
+ const changes = {}
1724
+
1725
+ if (name !== undefined) {
1726
+ changes.name = group.name
1727
+ group.name = name
1728
+ }
1729
+
1730
+ if (style) {
1731
+ changes.style = { ...group.style }
1732
+ group.style = Object.assign({}, group.style || {}, style)
1733
+ }
1734
+
1735
+ if (env !== undefined) {
1736
+ changes.env = group.env
1737
+ group.env = env
1738
+ }
1739
+
1740
+ if (Object.keys(changes).length > 0) {
1741
+ group.changed = true
1742
+ group.dirty = true
1743
+ this.RED.history.push({ t: 'edit', node: group, changes, changed: false, dirty: this.RED.nodes.dirty() })
1744
+ }
1745
+
1746
+ this.RED.nodes.dirty(true)
1747
+ this.RED.view.redraw()
1748
+ return group
1749
+ }
1750
+
1751
+ /**
1752
+ * Delete a group (ungroup its nodes).
1753
+ * @param {string} id - group ID
1754
+ */
1755
+ deleteGroup (id) {
1756
+ const group = this.RED.nodes.group(id)
1757
+ if (!group) throw new Error(`Group ${id} not found`)
1758
+ this._assertWorkspaceNotLocked(group.z)
1759
+ this.RED.view.select({ nodes: [group] })
1760
+ this._verifySelection([group])
1761
+ this.RED.actions.invoke('core:ungroup-selection')
1762
+ this.RED.nodes.dirty(true)
1763
+ this.RED.view.redraw()
1764
+ }
1765
+
1414
1766
  _summarizeNode (node) {
1415
1767
  if (!node) return null
1416
1768
  const s = { id: node.id }
@@ -1420,7 +1772,65 @@ export class ExpertAutomations extends ExpertActionsInterface {
1420
1772
  if (node.x !== undefined) s.x = node.x
1421
1773
  if (node.y !== undefined) s.y = node.y
1422
1774
  if (node.z !== undefined) s.z = node.z
1775
+ if (node.wires !== undefined) s.wires = node.wires
1776
+ if (node.links !== undefined) s.links = node.links
1423
1777
  if (node.valid !== undefined) s.valid = node.valid
1424
1778
  return s
1425
1779
  }
1780
+
1781
+ /**
1782
+ * Defensive check: verify the view selection contains exactly the expected nodes.
1783
+ * Guards against editor states where RED.view.select() is silently ignored,
1784
+ * which would cause core actions to act on unintended nodes.
1785
+ * @param {object[]} expectedNodes
1786
+ */
1787
+ _verifySelection (expectedNodes) {
1788
+ const selection = this.RED.view.selection()
1789
+ const selectedNodes = selection?.nodes || []
1790
+ const expectedIds = expectedNodes.map(n => n.id)
1791
+ const selectedIds = selectedNodes.map(n => n.id)
1792
+ const expectedSet = new Set(expectedIds)
1793
+ const selectedSet = new Set(selectedIds)
1794
+ if (selectedSet.size !== expectedSet.size || ![...expectedSet].every(id => selectedSet.has(id))) {
1795
+ throw new Error(
1796
+ `Selection mismatch: expected [${expectedIds.join(', ')}] but selection is [${selectedIds.join(', ')}] — editor may be in an unexpected state`
1797
+ )
1798
+ }
1799
+ }
1800
+
1801
+ _summarizeSubflow (subflow) {
1802
+ if (!subflow) return null
1803
+ return {
1804
+ id: subflow.id,
1805
+ type: subflow.type,
1806
+ name: subflow.name,
1807
+ info: subflow.info,
1808
+ inputs: Array.isArray(subflow.in) ? subflow.in.length : 0,
1809
+ outputs: Array.isArray(subflow.out) ? subflow.out.length : 0
1810
+ }
1811
+ }
1812
+
1813
+ _summarizeFlowItem (item) {
1814
+ if (!item) return null
1815
+ if (item.type === 'tab') {
1816
+ const ws = this.RED.nodes.workspace(item.id)
1817
+ return this._summarizeWorkspace(ws)
1818
+ }
1819
+ if (item.type === 'subflow') return this._summarizeSubflow(item)
1820
+ if (item.type === 'group') return this._summarizeGroup(item)
1821
+ return this._summarizeNode(item)
1822
+ }
1823
+
1824
+ _summarizeGroup (group) {
1825
+ if (!group) return null
1826
+ const s = { id: group.id, type: 'group' }
1827
+ if (group.name !== undefined) s.name = group.name
1828
+ if (group.z !== undefined) s.z = group.z
1829
+ if (Array.isArray(group.nodes)) {
1830
+ s.nodes = group.nodes.filter(Boolean).map(n => (typeof n === 'string' ? n : n.id))
1831
+ }
1832
+ if (group.style !== undefined) s.style = group.style
1833
+ if (Array.isArray(group.env) && group.env.length > 0) s.env = group.env
1834
+ return s
1835
+ }
1426
1836
  }