groove-dev 0.11.0 → 0.12.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.
Files changed (39) hide show
  1. package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
  2. package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
  3. package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
  4. package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
  5. package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
  6. package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
  7. package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
  8. package/node_modules/@groove-dev/daemon/src/api.js +106 -2
  9. package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
  10. package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
  11. package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
  12. package/node_modules/@groove-dev/daemon/src/index.js +59 -6
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
  17. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
  19. package/node_modules/@groove-dev/gui/src/theme.css +2 -2
  20. package/package.json +1 -1
  21. package/packages/cli/bin/groove.js +32 -0
  22. package/packages/cli/src/commands/audit.js +60 -0
  23. package/packages/cli/src/commands/connect.js +279 -0
  24. package/packages/cli/src/commands/disconnect.js +91 -0
  25. package/packages/cli/src/commands/federation.js +84 -0
  26. package/packages/cli/src/commands/start.js +7 -2
  27. package/packages/cli/src/commands/status.js +4 -1
  28. package/packages/daemon/src/api.js +106 -2
  29. package/packages/daemon/src/audit.js +65 -0
  30. package/packages/daemon/src/federation.js +352 -0
  31. package/packages/daemon/src/firstrun.js +27 -2
  32. package/packages/daemon/src/index.js +59 -6
  33. package/packages/gui/dist/assets/{index-BqZnnVJF.js → index-B49YqEXS.js} +17 -17
  34. package/packages/gui/dist/assets/{index-CPzm9ZE9.css → index-Gfb8Zxy9.css} +1 -1
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/src/App.jsx +24 -1
  37. package/packages/gui/src/components/SpawnPanel.jsx +1 -1
  38. package/packages/gui/src/stores/groove.js +19 -2
  39. package/packages/gui/src/theme.css +2 -2
@@ -1 +1 @@
1
- @import"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap";:root{--bg-base: #24282f;--bg-chrome: #282c34;--bg-surface: #2c313a;--bg-hover: #333842;--bg-active: #3a3f4b;--text-primary: #abb2bf;--text-bright: #e6e6e6;--text-dim: #5c6370;--text-muted: #3e4451;--border: #4b5263;--accent: #33afbc;--green: #4ae168;--amber: #e5c07b;--red: #e06c75;--purple: #c678dd;--blue: #61afef;--font: "JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace}*{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--font);background:var(--bg-base);color:var(--text-primary);font-size:12px;-webkit-font-smoothing:antialiased}#root{width:100vw;height:100vh}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--bg-hover);border-radius:2px}::selection{background:#33afbc40}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}@keyframes neuralFlow{0%{transform:translate(-50%)}to{transform:translate(0)}}.react-flow__edge-path{stroke-width:1!important}.react-flow__edge.animated path{stroke-dasharray:6 4!important;stroke-width:1!important}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}
1
+ @import"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap";:root{--bg-base: #24282f;--bg-chrome: #282c34;--bg-surface: #2c313a;--bg-hover: #333842;--bg-active: #3a3f4b;--text-primary: #abb2bf;--text-bright: #e6e6e6;--text-dim: #7a8394;--text-muted: #5c6370;--border: #4b5263;--accent: #33afbc;--green: #4ae168;--amber: #e5c07b;--red: #e06c75;--purple: #c678dd;--blue: #61afef;--font: "JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace}*{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--font);background:var(--bg-base);color:var(--text-primary);font-size:12px;-webkit-font-smoothing:antialiased}#root{width:100vw;height:100vh}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--bg-hover);border-radius:2px}::selection{background:#33afbc40}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}@keyframes neuralFlow{0%{transform:translate(-50%)}to{transform:translate(0)}}.react-flow__edge-path{stroke-width:1!important}.react-flow__edge.animated path{stroke-dasharray:6 4!important;stroke-width:1!important}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>GROOVE</title>
7
- <script type="module" crossorigin src="/assets/index-BqZnnVJF.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-CPzm9ZE9.css">
7
+ <script type="module" crossorigin src="/assets/index-B49YqEXS.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Gfb8Zxy9.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -25,6 +25,8 @@ export default function App() {
25
25
  const activeTab = useGrooveStore((s) => s.activeTab);
26
26
  const detailPanel = useGrooveStore((s) => s.detailPanel);
27
27
  const statusMessage = useGrooveStore((s) => s.statusMessage);
28
+ const daemonHost = useGrooveStore((s) => s.daemonHost);
29
+ const tunneled = useGrooveStore((s) => s.tunneled);
28
30
  const connect = useGrooveStore((s) => s.connect);
29
31
  const setActiveTab = useGrooveStore((s) => s.setActiveTab);
30
32
  const openDetail = useGrooveStore((s) => s.openDetail);
@@ -41,6 +43,9 @@ export default function App() {
41
43
  <header style={styles.header}>
42
44
  <div style={styles.headerLeft}>
43
45
  <img src="/groove-logo-short.png" alt="GROOVE" style={{ height: 18, marginTop: 3, opacity: 0.85 }} />
46
+ {daemonHost && (
47
+ <span style={styles.hostBadge}>{daemonHost}</span>
48
+ )}
44
49
  </div>
45
50
 
46
51
 
@@ -112,8 +117,19 @@ export default function App() {
112
117
  fontFamily: 'var(--font)',
113
118
  animation: 'pulse 3s infinite',
114
119
  }}>
115
- {connected ? 'connected' : 'offline'}
120
+ {connected ? (tunneled ? 'tunneled' : daemonHost ? 'remote' : 'connected') : 'offline'}
116
121
  </span>
122
+ {connected && tunneled && (
123
+ <span
124
+ title="Connected via SSH tunnel. Run 'groove disconnect' in your terminal to close."
125
+ style={{
126
+ fontSize: 9, color: 'var(--text-dim)', fontFamily: 'var(--font)',
127
+ cursor: 'help', marginLeft: 2,
128
+ }}
129
+ >
130
+ (via ssh)
131
+ </span>
132
+ )}
117
133
  </div>
118
134
 
119
135
  {/* Main row */}
@@ -162,6 +178,13 @@ const styles = {
162
178
  headerLeft: {
163
179
  display: 'flex', alignItems: 'center', gap: 8,
164
180
  },
181
+ hostBadge: {
182
+ fontSize: 9, fontWeight: 600, letterSpacing: 0.5,
183
+ color: 'var(--text-dim)', background: 'var(--bg-active)',
184
+ padding: '2px 6px', borderRadius: 3,
185
+ border: '1px solid var(--border)',
186
+ fontFamily: 'var(--font)',
187
+ },
165
188
  logo: {
166
189
  fontSize: 13, fontWeight: 600, letterSpacing: 1.5,
167
190
  color: 'var(--text-bright)',
@@ -559,7 +559,7 @@ const styles = {
559
559
  fontFamily: 'var(--font)', resize: 'vertical',
560
560
  },
561
561
  hint: {
562
- fontSize: 10, color: 'var(--text-muted)', marginTop: 3,
562
+ fontSize: 10, color: 'var(--text-dim)', marginTop: 3,
563
563
  },
564
564
  wsRow: {
565
565
  display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6,
@@ -11,6 +11,8 @@ export const useGrooveStore = create((set, get) => ({
11
11
  agents: [],
12
12
  connected: false,
13
13
  ws: null,
14
+ daemonHost: null, // bound host IP (null = localhost)
15
+ tunneled: false, // true when accessed via SSH tunnel (port mismatch)
14
16
 
15
17
  // UI state — unified panel model
16
18
  activeTab: 'agents', // 'agents' | 'stats' | 'teams' | 'approvals'
@@ -28,7 +30,22 @@ export const useGrooveStore = create((set, get) => ({
28
30
 
29
31
  const ws = new WebSocket(WS_URL);
30
32
 
31
- ws.onopen = () => set({ connected: true, ws });
33
+ ws.onopen = () => {
34
+ set({ connected: true, ws });
35
+ // Fetch daemon info for instance badge + tunnel detection
36
+ fetch(`${API_BASE}/api/status`).then((r) => r.json()).then((s) => {
37
+ const updates = {};
38
+ if (s.host && s.host !== '127.0.0.1') {
39
+ updates.daemonHost = s.host;
40
+ }
41
+ // Detect tunnel: browser port differs from daemon's actual port
42
+ const browserPort = window.location.port || '80';
43
+ if (String(s.port) !== browserPort) {
44
+ updates.tunneled = true;
45
+ }
46
+ if (Object.keys(updates).length > 0) set(updates);
47
+ }).catch(() => {});
48
+ };
32
49
 
33
50
  ws.onmessage = (event) => {
34
51
  const msg = JSON.parse(event.data);
@@ -113,7 +130,7 @@ export const useGrooveStore = create((set, get) => ({
113
130
  };
114
131
 
115
132
  ws.onclose = () => {
116
- set({ connected: false, ws: null });
133
+ set({ connected: false, ws: null, daemonHost: null, tunneled: false });
117
134
  setTimeout(() => get().connect(), 2000);
118
135
  };
119
136
 
@@ -14,8 +14,8 @@
14
14
  /* Text */
15
15
  --text-primary: #abb2bf;
16
16
  --text-bright: #e6e6e6;
17
- --text-dim: #5c6370;
18
- --text-muted: #3e4451;
17
+ --text-dim: #7a8394;
18
+ --text-muted: #5c6370;
19
19
  --border: #4b5263;
20
20
 
21
21
  /* Status */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Open-source agent orchestration layer for AI coding tools. GUI dashboard, multi-agent coordination, zero cold-start (Journalist), infinite sessions (adaptive context rotation), AI Project Manager, Quick Launch. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -16,6 +16,10 @@ import { teamSave, teamLoad, teamList, teamDelete, teamExport, teamImport } from
16
16
  import { approvals, approve, reject } from '../src/commands/approve.js';
17
17
  import { providers, setKey } from '../src/commands/providers.js';
18
18
  import { configShow, configSet } from '../src/commands/config.js';
19
+ import { connect } from '../src/commands/connect.js';
20
+ import { disconnect } from '../src/commands/disconnect.js';
21
+ import { audit } from '../src/commands/audit.js';
22
+ import { federationPair, federationUnpair, federationList, federationStatus } from '../src/commands/federation.js';
19
23
 
20
24
  program
21
25
  .name('groove')
@@ -26,6 +30,7 @@ program
26
30
  .command('start')
27
31
  .description('Start the GROOVE daemon')
28
32
  .option('-p, --port <port>', 'Port to run on', '31415')
33
+ .option('-H, --host <host>', 'Host/IP to bind to (use "tailscale" for auto-detect)', '127.0.0.1')
29
34
  .action(start);
30
35
 
31
36
  program
@@ -90,6 +95,33 @@ program
90
95
  program.command('providers').description('List available AI providers').action(providers);
91
96
  program.command('set-key <provider> <key>').description('Set API key for a provider').action(setKey);
92
97
 
98
+ // Remote
99
+ program
100
+ .command('connect <target>')
101
+ .description('Connect to a remote GROOVE daemon via SSH tunnel')
102
+ .option('-i, --identity <keyfile>', 'SSH private key file')
103
+ .option('--no-browser', 'Don\'t open browser automatically')
104
+ .action(connect);
105
+
106
+ program
107
+ .command('disconnect')
108
+ .description('Disconnect from remote GROOVE daemon')
109
+ .action(disconnect);
110
+
111
+ // Audit
112
+ program
113
+ .command('audit')
114
+ .description('View audit log of state-changing operations')
115
+ .option('-n, --limit <count>', 'Number of entries to show', '25')
116
+ .action(audit);
117
+
118
+ // Federation
119
+ const federation = program.command('federation').description('Manage daemon-to-daemon federation');
120
+ federation.command('pair <target>').description('Pair with a remote GROOVE daemon (ip or ip:port)').action(federationPair);
121
+ federation.command('unpair <id>').description('Remove a paired peer').action(federationUnpair);
122
+ federation.command('list').description('List paired peers').action(federationList);
123
+ federation.command('status').description('Show federation status').action(federationStatus);
124
+
93
125
  // Config
94
126
  const config = program.command('config').description('View and modify configuration');
95
127
  config.command('show').description('Show current configuration').action(configShow);
@@ -0,0 +1,60 @@
1
+ // GROOVE CLI — audit command
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import chalk from 'chalk';
5
+ import { apiCall } from '../client.js';
6
+
7
+ const ACTION_COLORS = {
8
+ 'agent.spawn': 'green',
9
+ 'agent.kill': 'red',
10
+ 'agent.kill_all': 'red',
11
+ 'agent.rotate': 'yellow',
12
+ 'agent.instruct': 'cyan',
13
+ 'team.save': 'blue',
14
+ 'team.load': 'blue',
15
+ 'team.delete': 'red',
16
+ 'team.import': 'blue',
17
+ 'team.launch': 'green',
18
+ 'config.set': 'yellow',
19
+ 'credential.set': 'yellow',
20
+ 'credential.delete': 'red',
21
+ 'approval.approve': 'green',
22
+ 'approval.reject': 'red',
23
+ };
24
+
25
+ function formatEntry(entry) {
26
+ const time = entry.t ? new Date(entry.t).toLocaleTimeString() : '??:??:??';
27
+ const color = ACTION_COLORS[entry.action] || 'white';
28
+ const action = chalk[color](entry.action.padEnd(20));
29
+
30
+ // Build detail string from remaining fields
31
+ const { t, action: _, ...detail } = entry;
32
+ const detailStr = Object.entries(detail)
33
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
34
+ .join(' ');
35
+
36
+ return ` ${chalk.dim(time)} ${action} ${chalk.dim(detailStr)}`;
37
+ }
38
+
39
+ export async function audit(options) {
40
+ try {
41
+ const limit = parseInt(options.limit, 10) || 25;
42
+ const entries = await apiCall('GET', `/api/audit?limit=${limit}`);
43
+
44
+ console.log('');
45
+ if (entries.length === 0) {
46
+ console.log(chalk.dim(' No audit entries yet.'));
47
+ } else {
48
+ console.log(chalk.bold(` Audit Log`) + chalk.dim(` (${entries.length} entries, newest first)`));
49
+ console.log('');
50
+ for (const entry of entries) {
51
+ console.log(formatEntry(entry));
52
+ }
53
+ }
54
+ console.log('');
55
+ } catch {
56
+ console.log('');
57
+ console.log(chalk.yellow(' Daemon not running.'));
58
+ console.log('');
59
+ }
60
+ }
@@ -0,0 +1,279 @@
1
+ // GROOVE CLI — connect command (SSH tunnel to remote daemon)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execFileSync, spawn } from 'child_process';
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import { createConnection } from 'net';
8
+ import chalk from 'chalk';
9
+
10
+ const REMOTE_PORT = 31415;
11
+ const DEFAULT_LOCAL_PORT = 31416;
12
+ const MAX_PORT_ATTEMPTS = 10;
13
+
14
+ // Allow user@host OR plain hostname (SSH config aliases like "groove-ai")
15
+ const SSH_TARGET_PATTERN = /^([a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+$/;
16
+
17
+ function validateTarget(target) {
18
+ if (!target || typeof target !== 'string') {
19
+ throw new Error('SSH target is required (e.g., user@host or ssh-config-alias)');
20
+ }
21
+ // Block injection characters
22
+ if (/[;|&`$(){}[\]<>!#\n\r\\]/.test(target)) {
23
+ throw new Error('Invalid characters in SSH target');
24
+ }
25
+ if (!SSH_TARGET_PATTERN.test(target)) {
26
+ throw new Error('Invalid SSH target format. Expected: user@hostname or ssh-config-alias');
27
+ }
28
+ if (target.length > 253) {
29
+ throw new Error('SSH target too long');
30
+ }
31
+ }
32
+
33
+ function grooveDir() {
34
+ const dir = resolve(process.cwd(), '.groove');
35
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+
39
+ function pidFile() {
40
+ return resolve(grooveDir(), 'tunnel.pid');
41
+ }
42
+
43
+ function tunnelInfoFile() {
44
+ return resolve(grooveDir(), 'tunnel.json');
45
+ }
46
+
47
+ function isPortInUse(port) {
48
+ return new Promise((resolve) => {
49
+ const conn = createConnection({ host: '127.0.0.1', port });
50
+ conn.setTimeout(3000);
51
+ conn.on('connect', () => { conn.destroy(); resolve(true); });
52
+ conn.on('error', () => resolve(false));
53
+ conn.on('timeout', () => { conn.destroy(); resolve(false); });
54
+ });
55
+ }
56
+
57
+ async function findAvailablePort() {
58
+ for (let port = DEFAULT_LOCAL_PORT; port < DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS; port++) {
59
+ if (!(await isPortInUse(port))) return port;
60
+ }
61
+ throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
62
+ }
63
+
64
+ function preflight(target, keyFile) {
65
+ // SSH in and check if daemon is listening on remote
66
+ // Use curl to health endpoint — works on both Linux and macOS
67
+ const args = [
68
+ ...(keyFile ? ['-i', keyFile] : []),
69
+ '-o', 'ConnectTimeout=10',
70
+ '-o', 'StrictHostKeyChecking=accept-new',
71
+ '-o', 'BatchMode=yes',
72
+ target,
73
+ `curl -sf http://localhost:${REMOTE_PORT}/api/health >/dev/null 2>&1 || echo __GROOVE_NOT_RUNNING__`,
74
+ ];
75
+
76
+ try {
77
+ const result = execFileSync('ssh', args, {
78
+ encoding: 'utf8',
79
+ timeout: 15000,
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ });
82
+
83
+ if (result.includes('__GROOVE_NOT_RUNNING__')) {
84
+ return { running: false };
85
+ }
86
+ return { running: true };
87
+ } catch (err) {
88
+ const stderr = err.stderr?.toString() || '';
89
+ if (stderr.includes('Permission denied')) {
90
+ throw new Error('SSH authentication failed. Check your key or credentials.');
91
+ }
92
+ if (stderr.includes('Connection refused') || stderr.includes('Connection timed out') || stderr.includes('No route to host')) {
93
+ throw new Error(`Cannot reach ${target}. Check the hostname and that SSH is running.`);
94
+ }
95
+ throw new Error(`SSH preflight failed: ${stderr.trim() || err.message}`);
96
+ }
97
+ }
98
+
99
+ function isSshProcess(pid) {
100
+ // Verify the PID belongs to an SSH process (not a random reused PID)
101
+ try {
102
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
103
+ encoding: 'utf8',
104
+ timeout: 3000,
105
+ }).trim();
106
+ return cmd.includes('ssh');
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function existingTunnel() {
113
+ const pf = pidFile();
114
+ if (!existsSync(pf)) return null;
115
+ const pid = parseInt(readFileSync(pf, 'utf8').trim(), 10);
116
+ if (isNaN(pid)) return null;
117
+ // Check if process is still alive AND is an SSH process
118
+ try {
119
+ process.kill(pid, 0);
120
+ if (!isSshProcess(pid)) {
121
+ // PID was reused by a different process — stale tunnel files
122
+ return null;
123
+ }
124
+ // Read tunnel info if available
125
+ const infoPath = tunnelInfoFile();
126
+ if (existsSync(infoPath)) {
127
+ const info = JSON.parse(readFileSync(infoPath, 'utf8'));
128
+ return { pid, ...info };
129
+ }
130
+ return { pid };
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function openBrowser(url) {
137
+ const platform = process.platform;
138
+ try {
139
+ if (platform === 'darwin') {
140
+ execFileSync('open', [url], { stdio: 'ignore' });
141
+ } else if (platform === 'win32') {
142
+ execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
143
+ } else {
144
+ execFileSync('xdg-open', [url], { stdio: 'ignore' });
145
+ }
146
+ } catch {
147
+ // Browser open is best-effort
148
+ }
149
+ }
150
+
151
+ export async function connect(target, options) {
152
+ console.log('');
153
+
154
+ // Validate SSH target
155
+ try {
156
+ validateTarget(target);
157
+ } catch (err) {
158
+ console.log(chalk.red(' Error: ') + err.message);
159
+ console.log('');
160
+ return;
161
+ }
162
+
163
+ // Check for existing tunnel
164
+ const existing = existingTunnel();
165
+ if (existing) {
166
+ console.log(chalk.yellow(' Tunnel already active') + ` (PID ${existing.pid})`);
167
+ if (existing.target) {
168
+ console.log(` Target: ${existing.target}`);
169
+ }
170
+ if (existing.localPort) {
171
+ console.log(` GUI: ${chalk.cyan(`http://localhost:${existing.localPort}`)}`);
172
+ }
173
+ console.log('');
174
+ console.log(` Run ${chalk.bold('groove disconnect')} first to close it.`);
175
+ console.log('');
176
+ return;
177
+ }
178
+
179
+ // Preflight — check daemon is running on remote
180
+ console.log(chalk.dim(' Checking remote daemon...'));
181
+ try {
182
+ const check = preflight(target, options.identity);
183
+ if (!check.running) {
184
+ console.log(chalk.red(' Daemon not running on remote.'));
185
+ console.log(` SSH into ${chalk.bold(target)} and run ${chalk.bold('groove start')} first.`);
186
+ console.log('');
187
+ return;
188
+ }
189
+ } catch (err) {
190
+ console.log(chalk.red(' ' + err.message));
191
+ console.log('');
192
+ return;
193
+ }
194
+
195
+ console.log(chalk.dim(' Remote daemon is running.'));
196
+
197
+ // Find available local port
198
+ let localPort;
199
+ try {
200
+ localPort = await findAvailablePort();
201
+ } catch (err) {
202
+ console.log(chalk.red(' ' + err.message));
203
+ console.log('');
204
+ return;
205
+ }
206
+
207
+ if (localPort !== DEFAULT_LOCAL_PORT) {
208
+ console.log(chalk.yellow(` Port ${DEFAULT_LOCAL_PORT} in use, using ${localPort} instead.`));
209
+ }
210
+
211
+ // Spawn SSH tunnel
212
+ const sshArgs = [
213
+ '-N', // No remote command
214
+ '-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`, // Local bind to 127.0.0.1 only
215
+ '-o', 'ServerAliveInterval=30', // Keepalive every 30s
216
+ '-o', 'ServerAliveCountMax=3', // Die after 3 missed keepalives
217
+ '-o', 'ExitOnForwardFailure=yes', // Fail if port forward fails
218
+ '-o', 'StrictHostKeyChecking=accept-new',
219
+ ...(options.identity ? ['-i', options.identity] : []),
220
+ target,
221
+ ];
222
+
223
+ const tunnel = spawn('ssh', sshArgs, {
224
+ stdio: ['ignore', 'ignore', 'pipe'],
225
+ detached: true,
226
+ });
227
+
228
+ // Capture stderr for early errors
229
+ let stderrBuf = '';
230
+ tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
231
+
232
+ // Wait a moment to catch immediate failures (bad key, connection refused)
233
+ await new Promise((resolve) => setTimeout(resolve, 2000));
234
+
235
+ // Check if tunnel is still alive
236
+ if (tunnel.exitCode !== null) {
237
+ console.log(chalk.red(' Tunnel failed to start.'));
238
+ if (stderrBuf.trim()) {
239
+ console.log(chalk.dim(' ' + stderrBuf.trim()));
240
+ }
241
+ console.log('');
242
+ return;
243
+ }
244
+
245
+ // Verify the tunnel is actually forwarding
246
+ const tunnelUp = await isPortInUse(localPort);
247
+ if (!tunnelUp) {
248
+ console.log(chalk.red(' Tunnel started but port forward not active.'));
249
+ try { process.kill(tunnel.pid); } catch { /* ignore */ }
250
+ console.log('');
251
+ return;
252
+ }
253
+
254
+ // Detach and save PID
255
+ tunnel.unref();
256
+ writeFileSync(pidFile(), String(tunnel.pid), { mode: 0o600 });
257
+ writeFileSync(tunnelInfoFile(), JSON.stringify({
258
+ target,
259
+ localPort,
260
+ remotePort: REMOTE_PORT,
261
+ startedAt: new Date().toISOString(),
262
+ }), { mode: 0o600 });
263
+
264
+ const url = `http://localhost:${localPort}`;
265
+
266
+ console.log('');
267
+ console.log(chalk.green(' Connected!'));
268
+ console.log('');
269
+ console.log(` Target: ${chalk.bold(target)}`);
270
+ console.log(` Tunnel: localhost:${localPort} → ${target}:${REMOTE_PORT}`);
271
+ console.log(` GUI: ${chalk.cyan(url)}`);
272
+ console.log(` PID: ${tunnel.pid}`);
273
+ console.log('');
274
+
275
+ // Open browser (Commander: --no-browser sets options.browser = false)
276
+ if (options.browser !== false) {
277
+ openBrowser(url);
278
+ }
279
+ }