James Read 1 lună în urmă
părinte
comite
b2096d4a8d
37 a modificat fișierele cu 1432 adăugiri și 490 ștergeri
  1. 0 132
      .github/dependabot.yml
  2. 31 0
      config.yaml
  3. 23 9
      docs/modules/ROOT/pages/action_customization/icons.adoc
  4. 1 0
      docs/modules/ROOT/pages/args/types.adoc
  5. 5 5
      docs/modules/ROOT/pages/config.adoc
  6. 3 5
      docs/modules/ROOT/pages/reference/reference_themes_for_developers.adoc
  7. 137 130
      frontend/package-lock.json
  8. 4 4
      frontend/package.json
  9. 72 10
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  10. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  11. 28 20
      frontend/resources/vue/ActionButton.vue
  12. 73 0
      frontend/resources/vue/components/ActionIconGlyph.vue
  13. 38 0
      frontend/resources/vue/components/actionIconGlyphHelpers.mjs
  14. 20 0
      frontend/resources/vue/components/actionIconGlyphHelpers.test.mjs
  15. 13 0
      frontend/resources/vue/router.js
  16. 25 8
      frontend/resources/vue/views/ActionDetailsView.vue
  17. 219 0
      frontend/resources/vue/views/ActionExecConditionsView.vue
  18. 6 3
      frontend/resources/vue/views/ArgumentForm.vue
  19. 3 2
      frontend/resources/vue/views/ExecutionView.vue
  20. 4 3
      frontend/resources/vue/views/LogsListView.vue
  21. 4 4
      integration-tests/package-lock.json
  22. 1 1
      integration-tests/package.json
  23. 13 0
      proto/olivetin/api/v1/olivetin.proto
  24. 221 105
      service/gen/olivetin/api/v1/olivetin.pb.go
  25. 11 11
      service/go.mod
  26. 20 0
      service/go.sum
  27. 27 0
      service/internal/api/apiActionExecTriggers.go
  28. 2 0
      service/internal/api/apiActions.go
  29. 18 0
      service/internal/api/api_test.go
  30. 4 1
      service/internal/config/config.go
  31. 28 0
      service/internal/config/sanitize.go
  32. 54 1
      service/internal/config/sanitize_test.go
  33. 1 0
      service/internal/executor/arguments.go
  34. 32 0
      service/internal/executor/arguments_test.go
  35. 77 25
      service/internal/executor/executor.go
  36. 206 4
      service/internal/executor/executor_test.go
  37. 7 6
      service/internal/onfileindir/fileindir.go

+ 0 - 132
.github/dependabot.yml

@@ -1,132 +0,0 @@
-version: 2
-updates:
-  # npm updates for frontend - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for frontend - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for integration-tests - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for integration-tests - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for service - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for service - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for lang - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for lang - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Docker updates - targeting "next" branch
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Docker updates - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-

+ 31 - 0
config.yaml

@@ -14,6 +14,10 @@ logLevel: "INFO"
 #
 # Docs: https://docs.olivetin.app/action_execution/create_your_first.html
 actions:
+  # Every action can still be run on demand from the web UI or API. The keys
+  # below are optional *additional* triggers (see each action and
+  # https://docs.olivetin.app/action_execution/ ).
+  #
   # This is the most simple action, it just runs the command and flashes the
   # button to indicate status.
   #
@@ -23,6 +27,8 @@ actions:
     shell: ping -c 3 1.1.1.1
     icon: ping
     popupOnStart: execution-dialog-stdout-only
+    # https://docs.olivetin.app/action_execution/onstartup.html
+    execOnStartup: true
 
   # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
   # the command output.
@@ -30,6 +36,10 @@ actions:
     icon: disk
     shell: df -h /media
     popupOnStart: execution-dialog-stdout-only
+    # https://docs.olivetin.app/action_execution/onfilechanged.html
+    # Create the directory first, e.g. mkdir -p /tmp/olivetin-demo-file-changed
+    execOnFileChangedInDir:
+      - /tmp/olivetin-demo-file-changed
 
   # This uses `popupOnStart: execution-dialog` to show a dialog with more
   # information about the command that was run.
@@ -37,6 +47,10 @@ actions:
     shell: dmesg | tail
     icon: logs
     popupOnStart: execution-dialog
+    # https://docs.olivetin.app/action_execution/oncron.html — second example;
+    # the "date" action uses @hourly elsewhere in this file.
+    execOnCron:
+      - "0 3 * * 0"
 
   # This uses `popupOnStart: execution-button` to display a mini button that
   # links to the logs.
@@ -51,6 +65,8 @@ actions:
     maxRate:
       - limit: 3
         duration: 1m
+    execOnCron:
+      - "@hourly"
 
   # You are not limited to operating system commands, and of course you can run
   # your own scripts. Here `maxConcurrent` stops the script running multiple
@@ -63,6 +79,8 @@ actions:
     timeout: 10
     icon: backup
     popupOnStart: execution-dialog
+    # https://docs.olivetin.app/action_execution/oncalendar.html
+    execOnCalendarFile: examples/demo-olivetin-calendar.yaml
 
   # When you want to prompt users for input, that is when you should use
   # `arguments` - this presents a popup dialog and asks for argument values.
@@ -74,6 +92,11 @@ actions:
     icon: ping
     timeout: 100
     popupOnStart: execution-dialog-stdout-only
+    # https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
+    # with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
+    execOnWebhook:
+      - matchHeaders:
+          X-OliveTin-Demo: ping-host
     arguments:
       - name: host
         title: Host
@@ -149,6 +172,10 @@ actions:
     icon: ssh
     shell: olivetin-setup-easy-ssh
     popupOnStart: execution-dialog
+    # Second webhook example: POST /webhooks?demo=setup-ssh
+    execOnWebhook:
+      - matchQuery:
+          demo: setup-ssh
 
   # Here's how to use SSH with the "easy" config, to restart a service on
   # another server.
@@ -215,6 +242,10 @@ actions:
   - title: Ping All Servers
     shell: "echo 'Ping all servers'"
     icon: ping
+    # https://docs.olivetin.app/action_execution/onfilecreated.html
+    # mkdir -p /tmp/olivetin-demo-file-created
+    execOnFileCreatedInDir:
+      - /tmp/olivetin-demo-file-created
 
   - title: Start {{ .CurrentEntity.Names }}
     icon: box

+ 23 - 9
docs/modules/ROOT/pages/action_customization/icons.adoc

@@ -2,7 +2,7 @@
 = Icons
 
 You can specify any HTML for an icon. It's a popular choice to use Unicode
-icons because they are extremely fast to load and there are a lot of them, 
+icons because they are extremely fast to load and there are a lot of them,
 but OliveTin also support Iconify, and simple PNG, JPG, WEBP and similar images.
 
 .Examples of icons in OliveTin
@@ -39,14 +39,30 @@ And you should get something that looks like this;
 
 image::../action-button-iconify.png[]
 
+== Default Icon (bundled HugeIcon)
+
+OliveTin used to use a default emoji smiley face as the default icon for actions, but that was a bit too "emoji" and not everyone liked it. Now, OliveTin uses a simple "command line" icon from the HugeIcons set as the default icon for actions. This is a simple and neutral icon that should work well for most actions.
+
+If you need to reset a default icon for some reason, this is how you can do it;
+
+.`config.yaml`
+----
+actions:
+  - title: Action with the bundled CLI HugeIcon
+    icon: hugeicons:CommandLineIcon
+    shell: echo hello
+----
+
+If you want to use other icons from the HugeIcons set, you need to use the Iconify method described above, not with the "hugeicons:" prefix - that only works for the default icon.
+
 == Unicode icons ("emoji")
 
-Using simple emoji (unicode) icons from your browser's font is extremely fast, and can look good on some platforms. However, the icons are platform specific, which mean's they'll look different between browsers and between operating systems. 
+Using simple emoji (unicode) icons from your browser's font is extremely fast, and can look good on some platforms. However, the icons are platform specific, which mean's they'll look different between browsers and between operating systems.
 
 There are great sites like link:https://symbl.cc/en/emoji/[symbl.cc - a list of
-"Emoji" in unicode]. 
+"Emoji" in unicode].
 
-For example, if you find "link:https://symbl.cc/en/1F60E/[Smiling face with sunglasses]" you can click 
+For example, if you find "link:https://symbl.cc/en/1F60E/[Smiling face with sunglasses]" you can click
 on it to see it's "HTML-code". In OliveTin, you'd setup the icon like this;
 
 ----
@@ -56,16 +72,16 @@ actions:
     shell: echo "You are awesome"
 ----
 
-=== Unicode alises 
+=== Unicode aliases
 
 OliveTin has hard-coded aliases for a few commonly used icons, so you don't have to type out the full unicode codes. A list of those hard coded icons is;
 
 .Alias'd unicode reference table
 [%header]
 |===
-| Alias                       | Rendered as                       
+| Alias                       | Rendered as
 
-| `poop`                      | 💩 
+| `poop`                      | 💩
 | `smile`                     | 😀
 |	`ping`                      | 📡
 |	`backup`                    | 💾
@@ -140,5 +156,3 @@ examples;
       shell: echo "I like purple"
 ----
 ////
-
-

+ 1 - 0
docs/modules/ROOT/pages/args/types.adoc

@@ -10,6 +10,7 @@ A full list of argument types are below;
 | (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults.
 | ascii                       | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, but no spaces or punctuation
 | ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. 
+| shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
 | ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`. 
 | unicode_identifier          | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc.
 | email                       | xref:args/input.adoc[Textbox]           | An email address.

+ 5 - 5
docs/modules/ROOT/pages/config.adoc

@@ -2,11 +2,11 @@
 = Configuration
 
 OliveTin is controlled by a `config.yaml` file. On startup, it looks for this
-file in the following locations; 
+file in the following locations;
 
 1. The value specified by the `--configdir` argument, which defaults to the current working directory (`./`)
 2. `/config/` - Mostly used for containers
-3. `/etc/OliveTin/` - this is the recommended directory on Linux for your `config.yaml`. 
+3. `/etc/OliveTin/` - this is the recommended directory on Linux for your `config.yaml`.
 
 The most simple `config.yaml` would be something like this;
 
@@ -18,9 +18,9 @@ actions:
     shell: echo 'Hello World!'
 ----
 
-The configuration does not really get more complicated than that. You can of course add more actions, and customize more, but the syntax otherwise extremely simple. 
+The configuration does not really get more complicated than that. You can of course add more actions, and customize more, but the syntax is otherwise extremely simple.
 
-For building up from here, look at the following resources; 
+For building up from here, look at the following resources;
 
 * See the xref:action_examples/intro.adoc[action examples] section for extra examples of what OliveTin could be configured to do.
 
@@ -54,7 +54,7 @@ All configuration options are covered in the solution sections
 | `showNavigateOnStartIcons` | Show (or hide) the small icons on action buttons that indicate popup/argument/background behavior on start. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
 | `sectionNavigationStyle` | The style of the section navigation. `sidebar`, `topbar` | `sidebar` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
 | `defaultPopupOnStart` | The default popup to show on start. | `none` | Live reloadable | xref:action_customization/popuponstart.adoc[Popup On Start].
-| `defaultIconForActions` | The default icon to use for actions. | `smile` | Requires Restart | -
+| `defaultIconForActions` | The default icon string for actions (Unicode aliases such as `smile`, `hugeicons:NeutralIcon`, HTML, Iconify snippets, images, etc.). See xref:action_customization/icons.adoc[Icons]. | `hugeicons:CommandLineIcon` | Requires Restart | -
 | `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | -
 | `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | -
 | `enableCustomJs` | Enable custom JavaScript. | `false` | Live Reloadable, but refreshing the web browser is required. | xref:advanced_configuration/webui.adoc[Custom JS].

+ 3 - 5
docs/modules/ROOT/pages/reference/reference_themes_for_developers.adoc

@@ -18,7 +18,7 @@ Note that OliveTin will load `/theme.css` depending on `themeName:` in your conf
 
 == Understanding theme URLs
 
-When you create a theme, OliveTin will serve your theme's CSS at `/theme.css` and any other assets at `/custom-webui/themes/mytheme/`. This might be a little strange at first, as your theme.css file wil be in the `/custom-webui/themes/mytheme/` directory, but OliveTin will still serve it at `/theme.css`. Let's explain why this happens;
+When you create a theme, OliveTin will serve your theme's CSS at `/theme.css` and any other assets at `/custom-webui/themes/mytheme/`. This might be a little strange at first, as your theme.css file will be in the `/custom-webui/themes/mytheme/` directory, but OliveTin will still serve it at `/theme.css`. Let's explain why this happens;
 
 OliveTin wants to make it easy for your reverse proxy, cache server, or browser, to cache as much content as possible. This means that if OliveTin had to inject a new CSS file into the HTML every time you changed your theme, then your reverse proxy, cache server, or browser would have to re-download the HTML every time you changed your theme. This is not ideal. 
 
@@ -26,7 +26,7 @@ It is possible that OliveTin's initial webUiSettings.json (that is loaded to set
 
 To make things fast, OliveTin will copy the content of your `/custom-webui/themes/mytheme/theme.css` file into memory when it starts, and then requests for `/theme.css` will load this file. 
 
-What this means for you, is that to get to files like `backgrond.png` from your CSS, you must write your CSS to point to the file in the `/custom-webui/themes/mytheme/` directory;
+What this means for you, is that to get to files like `background.png` from your CSS, you must write your CSS to point to the file in the `/custom-webui/themes/mytheme/` directory;
 
 .Correct example
 ```
@@ -48,6 +48,4 @@ The OliveTin themes page is here; https://olivetin.app/themes
 
 When you are done with your theme, fork https://github.com/OliveTin/themes on GitHub and create a new page under the "content" directory for your new theme. Commit that to GitHub and then raise a pull request.
 
-If you meed more help, please jump on our discord server! 
-
-
+If you need more help, please jump on our Discord server! 

+ 137 - 130
frontend/package-lock.json

@@ -20,15 +20,15 @@
 				"iconify-icon": "^3.0.2",
 				"picocrank": "^1.15.0",
 				"standard": "^17.1.2",
-				"unplugin-vue-components": "^32.0.0",
-				"vite": "^8.0.13",
+				"unplugin-vue-components": "^32.1.0",
+				"vite": "^8.0.14",
 				"vue": "^3.5.34",
 				"vue-i18n": "^11.4.4",
 				"vue-router": "^5.0.7"
 			},
 			"devDependencies": {
 				"process": "^0.11.10",
-				"stylelint": "^17.11.1",
+				"stylelint": "^17.12.0",
 				"stylelint-config-standard": "^40.0.0"
 			}
 		},
@@ -164,16 +164,16 @@
 			"peer": true
 		},
 		"node_modules/@cacheable/memory": {
-			"version": "2.0.7",
-			"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
-			"integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
+			"version": "2.0.9",
+			"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
+			"integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@cacheable/utils": "^2.3.3",
-				"@keyv/bigmap": "^1.3.0",
-				"hookified": "^1.14.0",
-				"keyv": "^5.5.5"
+				"@cacheable/utils": "^2.4.1",
+				"@keyv/bigmap": "^1.3.1",
+				"hookified": "^1.15.1",
+				"keyv": "^5.6.0"
 			}
 		},
 		"node_modules/@cacheable/memory/node_modules/@keyv/bigmap": {
@@ -204,13 +204,13 @@
 			}
 		},
 		"node_modules/@cacheable/utils": {
-			"version": "2.3.4",
-			"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz",
-			"integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==",
+			"version": "2.4.1",
+			"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz",
+			"integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"hashery": "^1.3.0",
+				"hashery": "^1.5.1",
 				"keyv": "^5.6.0"
 			}
 		},
@@ -1157,18 +1157,18 @@
 			}
 		},
 		"node_modules/@oxc-project/types": {
-			"version": "0.130.0",
-			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
-			"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
+			"version": "0.132.0",
+			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+			"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
 			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/Boshen"
 			}
 		},
 		"node_modules/@rolldown/binding-android-arm64": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
-			"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
+			"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
 			"cpu": [
 				"arm64"
 			],
@@ -1182,9 +1182,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-arm64": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
-			"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
+			"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1198,9 +1198,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-x64": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
-			"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
+			"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
 			"cpu": [
 				"x64"
 			],
@@ -1214,9 +1214,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-freebsd-x64": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
-			"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
+			"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
 			"cpu": [
 				"x64"
 			],
@@ -1230,9 +1230,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
-			"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
+			"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
 			"cpu": [
 				"arm"
 			],
@@ -1246,9 +1246,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-gnu": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
-			"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
+			"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
 			"cpu": [
 				"arm64"
 			],
@@ -1262,9 +1262,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-musl": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
-			"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
+			"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1278,9 +1278,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-ppc64-gnu": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
-			"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
+			"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1294,9 +1294,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-s390x-gnu": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
-			"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
+			"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
 			"cpu": [
 				"s390x"
 			],
@@ -1310,9 +1310,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-gnu": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
-			"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
+			"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1326,9 +1326,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-musl": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
-			"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
+			"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
 			"cpu": [
 				"x64"
 			],
@@ -1342,9 +1342,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-openharmony-arm64": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
-			"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
+			"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1358,9 +1358,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-wasm32-wasi": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
-			"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
+			"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
 			"cpu": [
 				"wasm32"
 			],
@@ -1376,9 +1376,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-win32-arm64-msvc": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
-			"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
+			"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
 			"cpu": [
 				"arm64"
 			],
@@ -1392,9 +1392,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-win32-x64-msvc": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
-			"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
+			"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1990,17 +1990,17 @@
 			}
 		},
 		"node_modules/cacheable": {
-			"version": "2.3.2",
-			"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
-			"integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
+			"version": "2.3.5",
+			"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz",
+			"integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@cacheable/memory": "^2.0.7",
-				"@cacheable/utils": "^2.3.3",
+				"@cacheable/memory": "^2.0.8",
+				"@cacheable/utils": "^2.4.1",
 				"hookified": "^1.15.0",
-				"keyv": "^5.5.5",
-				"qified": "^0.6.0"
+				"keyv": "^5.6.0",
+				"qified": "^0.10.1"
 			}
 		},
 		"node_modules/cacheable/node_modules/keyv": {
@@ -3672,13 +3672,13 @@
 			}
 		},
 		"node_modules/hashery": {
-			"version": "1.4.0",
-			"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz",
-			"integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+			"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"hookified": "^1.14.0"
+				"hookified": "^1.15.0"
 			},
 			"engines": {
 				"node": ">=20"
@@ -4645,9 +4645,9 @@
 			}
 		},
 		"node_modules/local-pkg": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
-			"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
+			"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
 			"license": "MIT",
 			"dependencies": {
 				"mlly": "^1.7.4",
@@ -4851,9 +4851,9 @@
 			"license": "MIT"
 		},
 		"node_modules/nanoid": {
-			"version": "3.3.11",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
-			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+			"version": "3.3.12",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+			"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
 			"funding": [
 				{
 					"type": "github",
@@ -5304,9 +5304,9 @@
 			}
 		},
 		"node_modules/postcss": {
-			"version": "8.5.14",
-			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
-			"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+			"version": "8.5.15",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+			"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
 			"funding": [
 				{
 					"type": "opencollective",
@@ -5323,7 +5323,7 @@
 			],
 			"license": "MIT",
 			"dependencies": {
-				"nanoid": "^3.3.11",
+				"nanoid": "^3.3.12",
 				"picocolors": "^1.1.1",
 				"source-map-js": "^1.2.1"
 			},
@@ -5416,18 +5416,25 @@
 			}
 		},
 		"node_modules/qified": {
-			"version": "0.6.0",
-			"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
-			"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
+			"version": "0.10.1",
+			"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+			"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"hookified": "^1.14.0"
+				"hookified": "^2.1.1"
 			},
 			"engines": {
 				"node": ">=20"
 			}
 		},
+		"node_modules/qified/node_modules/hookified": {
+			"version": "2.2.0",
+			"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+			"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/quansync": {
 			"version": "0.2.11",
 			"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -5599,12 +5606,12 @@
 			}
 		},
 		"node_modules/rolldown": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
-			"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
+			"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
 			"license": "MIT",
 			"dependencies": {
-				"@oxc-project/types": "=0.130.0",
+				"@oxc-project/types": "=0.132.0",
 				"@rolldown/pluginutils": "^1.0.0"
 			},
 			"bin": {
@@ -5614,21 +5621,21 @@
 				"node": "^20.19.0 || >=22.12.0"
 			},
 			"optionalDependencies": {
-				"@rolldown/binding-android-arm64": "1.0.1",
-				"@rolldown/binding-darwin-arm64": "1.0.1",
-				"@rolldown/binding-darwin-x64": "1.0.1",
-				"@rolldown/binding-freebsd-x64": "1.0.1",
-				"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
-				"@rolldown/binding-linux-arm64-gnu": "1.0.1",
-				"@rolldown/binding-linux-arm64-musl": "1.0.1",
-				"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
-				"@rolldown/binding-linux-s390x-gnu": "1.0.1",
-				"@rolldown/binding-linux-x64-gnu": "1.0.1",
-				"@rolldown/binding-linux-x64-musl": "1.0.1",
-				"@rolldown/binding-openharmony-arm64": "1.0.1",
-				"@rolldown/binding-wasm32-wasi": "1.0.1",
-				"@rolldown/binding-win32-arm64-msvc": "1.0.1",
-				"@rolldown/binding-win32-x64-msvc": "1.0.1"
+				"@rolldown/binding-android-arm64": "1.0.2",
+				"@rolldown/binding-darwin-arm64": "1.0.2",
+				"@rolldown/binding-darwin-x64": "1.0.2",
+				"@rolldown/binding-freebsd-x64": "1.0.2",
+				"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
+				"@rolldown/binding-linux-arm64-gnu": "1.0.2",
+				"@rolldown/binding-linux-arm64-musl": "1.0.2",
+				"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
+				"@rolldown/binding-linux-s390x-gnu": "1.0.2",
+				"@rolldown/binding-linux-x64-gnu": "1.0.2",
+				"@rolldown/binding-linux-x64-musl": "1.0.2",
+				"@rolldown/binding-openharmony-arm64": "1.0.2",
+				"@rolldown/binding-wasm32-wasi": "1.0.2",
+				"@rolldown/binding-win32-arm64-msvc": "1.0.2",
+				"@rolldown/binding-win32-x64-msvc": "1.0.2"
 			}
 		},
 		"node_modules/run-parallel": {
@@ -6130,9 +6137,9 @@
 			}
 		},
 		"node_modules/stylelint": {
-			"version": "17.11.1",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.1.tgz",
-			"integrity": "sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==",
+			"version": "17.12.0",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.12.0.tgz",
+			"integrity": "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==",
 			"dev": true,
 			"funding": [
 				{
@@ -6160,7 +6167,7 @@
 				"debug": "^4.4.3",
 				"fast-glob": "^3.3.3",
 				"fastest-levenshtein": "^1.0.16",
-				"file-entry-cache": "^11.1.2",
+				"file-entry-cache": "^11.1.3",
 				"global-modules": "^2.0.0",
 				"globby": "^16.2.0",
 				"globjoin": "^0.1.4",
@@ -6252,24 +6259,24 @@
 			}
 		},
 		"node_modules/stylelint/node_modules/file-entry-cache": {
-			"version": "11.1.2",
-			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
-			"integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
+			"version": "11.1.3",
+			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz",
+			"integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"flat-cache": "^6.1.20"
+				"flat-cache": "^6.1.22"
 			}
 		},
 		"node_modules/stylelint/node_modules/flat-cache": {
-			"version": "6.1.20",
-			"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz",
-			"integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
+			"version": "6.1.22",
+			"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz",
+			"integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"cacheable": "^2.3.2",
-				"flatted": "^3.3.3",
+				"cacheable": "^2.3.4",
+				"flatted": "^3.4.2",
 				"hookified": "^1.15.0"
 			}
 		},
@@ -6689,18 +6696,18 @@
 			}
 		},
 		"node_modules/unplugin-vue-components": {
-			"version": "32.0.0",
-			"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz",
-			"integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==",
+			"version": "32.1.0",
+			"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
+			"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
 			"license": "MIT",
 			"dependencies": {
 				"chokidar": "^5.0.0",
-				"local-pkg": "^1.1.2",
+				"local-pkg": "^1.2.0",
 				"magic-string": "^0.30.21",
 				"mlly": "^1.8.2",
 				"obug": "^2.1.1",
-				"picomatch": "^4.0.3",
-				"tinyglobby": "^0.2.15",
+				"picomatch": "^4.0.4",
+				"tinyglobby": "^0.2.16",
 				"unplugin": "^3.0.0",
 				"unplugin-utils": "^0.3.1"
 			},
@@ -6769,15 +6776,15 @@
 			}
 		},
 		"node_modules/vite": {
-			"version": "8.0.13",
-			"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
-			"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+			"version": "8.0.14",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+			"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
 			"license": "MIT",
 			"dependencies": {
 				"lightningcss": "^1.32.0",
 				"picomatch": "^4.0.4",
-				"postcss": "^8.5.14",
-				"rolldown": "1.0.1",
+				"postcss": "^8.5.15",
+				"rolldown": "1.0.2",
 				"tinyglobby": "^0.2.16"
 			},
 			"bin": {

+ 4 - 4
frontend/package.json

@@ -6,11 +6,11 @@
 	"source": "index.html",
 	"devDependencies": {
 		"process": "^0.11.10",
-		"stylelint": "^17.11.1",
+		"stylelint": "^17.12.0",
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
-		"test": "echo \"Error: no test specified\" && exit 1"
+		"test": "node --test resources/vue/components/*.test.mjs"
 	},
 	"author": "",
 	"parcelIgnore": [
@@ -33,8 +33,8 @@
 		"iconify-icon": "^3.0.2",
 		"picocrank": "^1.15.0",
 		"standard": "^17.1.2",
-		"unplugin-vue-components": "^32.0.0",
-		"vite": "^8.0.13",
+		"unplugin-vue-components": "^32.1.0",
+		"vite": "^8.0.14",
 		"vue": "^3.5.34",
 		"vue-i18n": "^11.4.4",
 		"vue-router": "^5.0.7"

+ 72 - 10
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.11.0
+// @generated by protoc-gen-es v2.12.0
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 
@@ -60,6 +60,36 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
    * @generated from field: string datetime_rate_limit_expires = 9;
    */
   datetimeRateLimitExpires: string;
+
+  /**
+   * @generated from field: bool exec_on_startup = 10;
+   */
+  execOnStartup: boolean;
+
+  /**
+   * @generated from field: repeated string exec_on_cron = 11;
+   */
+  execOnCron: string[];
+
+  /**
+   * @generated from field: repeated string exec_on_file_created_in_dir = 12;
+   */
+  execOnFileCreatedInDir: string[];
+
+  /**
+   * @generated from field: repeated string exec_on_file_changed_in_dir = 13;
+   */
+  execOnFileChangedInDir: string[];
+
+  /**
+   * @generated from field: string exec_on_calendar_file = 14;
+   */
+  execOnCalendarFile: string;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
+   */
+  execOnWebhooks: ActionWebhookExecHint[];
 };
 
 /**
@@ -68,6 +98,37 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
  */
 export declare const ActionSchema: GenMessage<Action>;
 
+/**
+ * @generated from message olivetin.api.v1.ActionWebhookExecHint
+ */
+export declare type ActionWebhookExecHint = Message<"olivetin.api.v1.ActionWebhookExecHint"> & {
+  /**
+   * @generated from field: string template = 1;
+   */
+  template: string;
+
+  /**
+   * @generated from field: string match_path = 2;
+   */
+  matchPath: string;
+
+  /**
+   * @generated from field: map<string, string> match_headers = 3;
+   */
+  matchHeaders: { [key: string]: string };
+
+  /**
+   * @generated from field: map<string, string> match_query = 4;
+   */
+  matchQuery: { [key: string]: string };
+};
+
+/**
+ * Describes the message olivetin.api.v1.ActionWebhookExecHint.
+ * Use `create(ActionWebhookExecHintSchema)` to create a new message.
+ */
+export declare const ActionWebhookExecHintSchema: GenMessage<ActionWebhookExecHint>;
+
 /**
  * @generated from message olivetin.api.v1.ActionArgument
  */
@@ -188,7 +249,7 @@ export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboard
   /**
    * @generated from field: olivetin.api.v1.Dashboard dashboard = 4;
    */
-  dashboard?: Dashboard;
+  dashboard?: Dashboard | undefined;
 };
 
 /**
@@ -302,7 +363,7 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo
   /**
    * @generated from field: olivetin.api.v1.Action action = 6;
    */
-  action?: Action;
+  action?: Action | undefined;
 
   /**
    * @generated from field: string entity_type = 7;
@@ -412,7 +473,7 @@ export declare type StartActionAndWaitResponse = Message<"olivetin.api.v1.StartA
   /**
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
-  logEntry?: LogEntry;
+  logEntry?: LogEntry | undefined;
 };
 
 /**
@@ -476,7 +537,7 @@ export declare type StartActionByGetAndWaitResponse = Message<"olivetin.api.v1.S
   /**
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
-  logEntry?: LogEntry;
+  logEntry?: LogEntry | undefined;
 };
 
 /**
@@ -825,7 +886,7 @@ export declare type ExecutionStatusResponse = Message<"olivetin.api.v1.Execution
   /**
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
-  logEntry?: LogEntry;
+  logEntry?: LogEntry | undefined;
 };
 
 /**
@@ -1135,7 +1196,7 @@ export declare type EventExecutionFinished = Message<"olivetin.api.v1.EventExecu
   /**
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
-  logEntry?: LogEntry;
+  logEntry?: LogEntry | undefined;
 };
 
 /**
@@ -1151,7 +1212,7 @@ export declare type EventExecutionStarted = Message<"olivetin.api.v1.EventExecut
   /**
    * @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
    */
-  logEntry?: LogEntry;
+  logEntry?: LogEntry | undefined;
 };
 
 /**
@@ -1437,7 +1498,7 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
   /**
    * @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 18;
    */
-  effectivePolicy?: EffectivePolicy;
+  effectivePolicy?: EffectivePolicy | undefined;
 
   /**
    * @generated from field: string banner_message = 19;
@@ -1553,7 +1614,7 @@ export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActio
   /**
    * @generated from field: olivetin.api.v1.Action action = 1;
    */
-  action?: Action;
+  action?: Action | undefined;
 };
 
 /**
@@ -1858,3 +1919,4 @@ export declare const OliveTinApiService: GenService<{
     output: typeof EntitySchema;
   },
 }>;
+

Fișier diff suprimat deoarece este prea mare
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 28 - 20
frontend/resources/vue/ActionButton.vue

@@ -1,5 +1,5 @@
 <template>
-	<div :id="`actionButton-${bindingId}`" role="none" class="action-button">
+	<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
 		<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
 													  :class="combinedClasses" @click="handleClick">
 
@@ -18,7 +18,7 @@
 				</div>
 			</div>
 
-			<span class="icon" v-html="unicodeIcon"></span>
+			<ActionIconGlyph class="icon" :glyph="actionGlyph" />
 			<span class="title" aria-live="polite">{{ displayTitle }}
 			</span>
 			<span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
@@ -33,7 +33,9 @@ import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
 
-import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
+import ActionIconGlyph from './components/ActionIconGlyph.vue'
+
+import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
 
 const router = useRouter()
 const navigateOnStart = ref('')
@@ -56,7 +58,6 @@ const canExec = ref(true)
 const popupOnStart = ref('')
 
 // Display properties
-const unicodeIcon = ref('&#x1f4a9;')
 const displayTitle = ref('')
 
 // State
@@ -77,6 +78,9 @@ const showNavigateOnStartIcons = computed(() => {
 	return window.initResponse?.showNavigateOnStartIcons ?? true
 })
 
+const actionGlyph = computed(() => props.actionData?.icon ?? '')
+const glyph = ref('')
+
 // Combined classes including custom cssClass
 const combinedClasses = computed(() => {
 	const classes = [...buttonClasses.value]
@@ -89,16 +93,6 @@ const combinedClasses = computed(() => {
 // Timestamps
 const updateIterationTimestamp = ref(0)
 
-function getUnicodeIcon(icon) {
-  if (icon === '') {
-	console.log('icon not found	', icon)
-
-	return '&#x1f4a9;'
-  } else {
-	return unescape(icon)
-  }
-}
-
 function constructFromJson(json) {
   updateIterationTimestamp.value = 0
 
@@ -119,8 +113,7 @@ function constructFromJson(json) {
 
   isDisabled.value = !json.canExec
   displayTitle.value = title.value
-  unicodeIcon.value = getUnicodeIcon(json.icon)
-
+  glyph.value = json.icon ?? ''
   // Initialize rate limit from action data (parse datetime string)
   if (json.datetimeRateLimitExpires) {
 	const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
@@ -139,8 +132,6 @@ function updateFromJson(json) {
   // Fields that should not be updated
   // title - as the callback URL relies on it
 
-  unicodeIcon.value = getUnicodeIcon(json.icon)
-
   // Update rate limiting if changed (parse datetime string)
   if (json.datetimeRateLimitExpires) {
 	const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
@@ -191,7 +182,19 @@ function updateRateLimitStatus() {
   }
 }
 
+function openActionDetails() {
+  const id = props.actionData?.bindingId
+  if (!id) {
+	return
+  }
+  router.push(`/action/${id}`)
+}
+
 async function handleClick() {
+  if (popupOnStart.value === 'history') {
+	openActionDetails()
+	return
+  }
   if (props.actionData.arguments && props.actionData.arguments.length > 0) {
 	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
   } else {
@@ -249,8 +252,6 @@ function onLogEntryChanged(logEntry) {
 function onExecutionStarted(logEntry) {
   if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
 	router.push(`/logs/${logEntry.executionTrackingId}`)
-  } else if (popupOnStart.value === 'history') {
-	router.push(`/action/${bindingId.value}`)
   }
 
   isDisabled.value = true
@@ -324,10 +325,17 @@ watch(
   () => props.actionData,
   (newData) => {
 	updateFromJson(newData)
+	if (newData?.icon !== undefined) {
+	  glyph.value = newData.icon ?? ''
+	}
   },
   { deep: true }
 )
 
+defineExpose({
+  glyph
+})
+
 </script>
 
 <style>

+ 73 - 0
frontend/resources/vue/components/ActionIconGlyph.vue

@@ -0,0 +1,73 @@
+<template>
+	<span class="action-icon-glyph">
+		<HugeiconsIcon
+			v-if="hugeiconsModel"
+			:icon="hugeiconsModel"
+			width="1em"
+			height="1em"
+			class="action-icon-glyph-svg"
+		/>
+		<span v-else-if="decodedTextGlyphIsHtml" v-html="decodedTextGlyph"></span>
+		<span v-else v-text="decodedTextGlyph"></span>
+	</span>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { CommandLineIcon } from '@hugeicons/core-free-icons'
+import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
+
+const hugeiconsPrefix = 'hugeicons:'
+
+/** Maps config values like hugeicons:CommandLineIcon to Hugeicons icon definitions. */
+const hugeiconsRegistry = {
+	CommandLineIcon,
+}
+
+const props = defineProps({
+	glyph: {
+		type: String,
+		required: false,
+		default: '',
+	},
+})
+
+const hugeiconsModel = computed(() => {
+	if (!props.glyph) {
+		return CommandLineIcon
+	}
+
+	if (!props.glyph.startsWith(hugeiconsPrefix)) {
+		return null
+	}
+
+	const name = props.glyph.slice(hugeiconsPrefix.length)
+	const iconModel = hugeiconsRegistry[name]
+
+	return iconModel ?? CommandLineIcon
+})
+
+const decodedTextGlyph = computed(() => {
+	if (hugeiconsModel.value) {
+		return ''
+	}
+
+	return decodeHtmlEntities(props.glyph)
+})
+
+const decodedTextGlyphIsHtml = computed(() => glyphLooksLikeHtml(decodedTextGlyph.value))
+</script>
+
+<style scoped>
+.action-icon-glyph {
+	display: inline-flex;
+	vertical-align: middle;
+	align-items: center;
+	justify-content: center;
+}
+
+.action-icon-glyph-svg {
+	vertical-align: middle;
+}
+</style>

+ 38 - 0
frontend/resources/vue/components/actionIconGlyphHelpers.mjs

@@ -0,0 +1,38 @@
+const fallbackNamedHtmlEntities = {
+	amp: '&',
+	apos: "'",
+	darr: '\u2193',
+	gt: '>',
+	laquo: '\u00ab',
+	larr: '\u2190',
+	nbsp: '\u00a0',
+	quot: '"',
+	raquo: '\u00bb',
+	rarr: '\u2192',
+	uarr: '\u2191',
+}
+
+export function decodeHtmlEntities(text) {
+	if (typeof document !== 'undefined') {
+		const textarea = document.createElement('textarea')
+		textarea.innerHTML = text
+
+		return textarea.value
+	}
+
+	return text.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => {
+		const codePoint = Number.parseInt(hex, 16)
+		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+	}).replace(/&#(\d+);?/g, (_, decimal) => {
+		const codePoint = Number.parseInt(decimal, 10)
+		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+	}).replace(/&([a-zA-Z][a-zA-Z0-9]+);?/g, (entity, name) => {
+		return fallbackNamedHtmlEntities[name] ?? entity
+	})
+}
+
+export function glyphLooksLikeHtml(text) {
+	const trimmedText = text.trim()
+
+	return trimmedText.startsWith('<') || /<img\b/i.test(text) || /\/custom-webui\//i.test(text)
+}

+ 20 - 0
frontend/resources/vue/components/actionIconGlyphHelpers.test.mjs

@@ -0,0 +1,20 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
+
+test('decodeHtmlEntities decodes named entity icons as plain glyph text', () => {
+	assert.equal(decodeHtmlEntities('&laquo;'), '\u00ab')
+	assert.equal(decodeHtmlEntities('&rarr;'), '\u2192')
+	assert.equal(decodeHtmlEntities('&laquo; next &rarr;'), '\u00ab next \u2192')
+})
+
+test('decoded named entity icons are not treated as HTML markup', () => {
+	const decodedGlyph = decodeHtmlEntities('&rarr;')
+
+	assert.equal(glyphLooksLikeHtml(decodedGlyph), false)
+})
+
+test('decodeHtmlEntities keeps existing numeric entity icon support', () => {
+	assert.equal(decodeHtmlEntities('&#x1f4a9;'), '\ud83d\udca9')
+	assert.equal(decodeHtmlEntities('&#128190;'), '\ud83d\udcbe')
+})

+ 13 - 0
frontend/resources/vue/router.js

@@ -95,6 +95,19 @@ const routes = [
       ]
     }
   },
+  {
+    path: '/action/:actionId/actionexecconditions',
+    name: 'ActionExecConditions',
+    component: () => import('./views/ActionExecConditionsView.vue'),
+    props: true,
+    meta: {
+      title: 'Execution conditions',
+      breadcrumb: [
+        { name: "Actions", href: "/" },
+        { name: "Execution conditions" },
+      ]
+    }
+  },
   {
     path: '/diagnostics',
     name: 'Diagnostics',

+ 25 - 8
frontend/resources/vue/views/ActionDetailsView.vue

@@ -1,12 +1,22 @@
 <template>
   <Section :title="'Action Details: ' + actionTitle" :padding="false">
       <template #toolbar>
-        <button v-if="action" @click="startAction" title="Start this action" class="button neutral">
-          <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-            <path fill="currentColor" d="M8 6v12l8-6z" />
-          </svg>
-          Start
-        </button>
+        <div class="action-details-toolbar">
+          <button v-if="action" @click="startAction" title="Start this action" class="button neutral">
+            <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+              <path fill="currentColor" d="M8 6v12l8-6z" />
+            </svg>
+            Start
+          </button>
+          <router-link
+            v-if="action"
+            :to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
+            class="button neutral"
+            title="View configured automatic triggers and on-demand execution"
+          >
+            Execution conditions
+          </router-link>
+        </div>
       </template>
 
       <div class = "flex-row padding" v-if="action">
@@ -22,7 +32,7 @@
           </p>
         </div>
         <div style = "align-self: start; text-align: right;">
-          <span class="icon" v-html="action.icon"></span>
+          <ActionIconGlyph class="icon" :glyph="action.icon" />
 
           <div class="filter-container">
             <label class="input-with-icons">
@@ -94,6 +104,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 
 const route = useRoute()
@@ -428,5 +439,11 @@ watch(
 .padding {
   padding: 1rem;
 }
-</style>
 
+.action-details-toolbar {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  align-items: center;
+}
+</style>

+ 219 - 0
frontend/resources/vue/views/ActionExecConditionsView.vue

@@ -0,0 +1,219 @@
+<template>
+  <Section :title="'Execution conditions: ' + actionTitle" :padding="false">
+    <template #toolbar>
+      <router-link :to="{ name: 'ActionDetails', params: { actionId: route.params.actionId } }" class="button neutral">
+        Back to action details
+      </router-link>
+    </template>
+
+    <div v-if="action" class="padding content">
+      <p>
+        These entries mirror the automatic triggers from your OliveTin configuration for this action.
+        You can always run the action manually as well.
+      </p>
+
+      <h3 class="exec-type-heading">
+        On demand
+        <a class="doc-link" :href="execConditionDocs.onDemand" target="_blank" rel="noopener noreferrer">Documentation</a>
+      </h3>
+      <p>
+        Manual execution from the web UI (dashboard or action details), or via the API (for example StartAction),
+        is always available when your user is allowed to execute the action.
+      </p>
+
+      <template v-if="action.execOnStartup">
+        <h3 class="exec-type-heading">
+          <code>execOnStartup</code>
+          <a class="doc-link" :href="execConditionDocs.startup" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <p>Runs once when OliveTin starts.</p>
+      </template>
+
+      <template v-if="nonEmptyList(action.execOnCron)">
+        <h3 class="exec-type-heading">
+          <code>execOnCron</code>
+          <a class="doc-link" :href="execConditionDocs.cron" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <ul>
+          <li v-for="(line, idx) in action.execOnCron" :key="'cron-' + idx"><code>{{ line }}</code></li>
+        </ul>
+      </template>
+
+      <template v-if="nonEmptyList(action.execOnFileCreatedInDir)">
+        <h3 class="exec-type-heading">
+          <code>execOnFileCreatedInDir</code>
+          <a class="doc-link" :href="execConditionDocs.fileCreated" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <ul>
+          <li v-for="(dir, idx) in action.execOnFileCreatedInDir" :key="'created-' + idx"><code>{{ dir }}</code></li>
+        </ul>
+      </template>
+
+      <template v-if="nonEmptyList(action.execOnFileChangedInDir)">
+        <h3 class="exec-type-heading">
+          <code>execOnFileChangedInDir</code>
+          <a class="doc-link" :href="execConditionDocs.fileChanged" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <ul>
+          <li v-for="(dir, idx) in action.execOnFileChangedInDir" :key="'changed-' + idx"><code>{{ dir }}</code></li>
+        </ul>
+      </template>
+
+      <template v-if="action.execOnCalendarFile">
+        <h3 class="exec-type-heading">
+          <code>execOnCalendarFile</code>
+          <a class="doc-link" :href="execConditionDocs.calendar" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <p><code>{{ action.execOnCalendarFile }}</code></p>
+      </template>
+
+      <template v-if="nonEmptyList(action.execOnWebhooks)">
+        <h3 class="exec-type-heading">
+          <code>execOnWebhook</code>
+          <a class="doc-link" :href="execConditionDocs.webhook" target="_blank" rel="noopener noreferrer">Documentation</a>
+        </h3>
+        <ul class="webhook-list">
+          <li v-for="(wh, idx) in action.execOnWebhooks" :key="'wh-' + idx">
+            <span v-if="wh.template">template: <code>{{ wh.template }}</code></span>
+            <span v-if="wh.matchPath"> · matchPath: <code>{{ wh.matchPath }}</code></span>
+            <span v-if="nonEmptyObject(wh.matchHeaders)"> · matchHeaders: <code>{{ wh.matchHeaders }}</code></span>
+            <span v-if="nonEmptyObject(wh.matchQuery)"> · matchQuery: <code>{{ wh.matchQuery }}</code></span>
+            <span v-if="!webhookHasCondition(wh)">Webhook trigger (no conditions in response)</span>
+          </li>
+        </ul>
+      </template>
+
+      <p v-if="!hasConfiguredTriggers" class="muted">
+        This action has no automatic triggers in configuration besides on-demand execution.
+      </p>
+    </div>
+
+    <div v-else-if="!loading" class="padding empty-state">
+      <p>Could not load this action.</p>
+      <router-link :to="{ name: 'Actions' }">Return to index</router-link>
+    </div>
+  </Section>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import Section from 'picocrank/vue/components/Section.vue'
+
+const route = useRoute()
+const action = ref(null)
+const actionTitle = ref('Action')
+const loading = ref(true)
+
+const execConditionDocs = {
+  onDemand: 'https://docs.olivetin.app/action_execution/ondemand.html',
+  startup: 'https://docs.olivetin.app/action_execution/onstartup.html',
+  cron: 'https://docs.olivetin.app/action_execution/oncron.html',
+  fileCreated: 'https://docs.olivetin.app/action_execution/onfilecreated.html',
+  fileChanged: 'https://docs.olivetin.app/action_execution/onfilechanged.html',
+  calendar: 'https://docs.olivetin.app/action_execution/oncalendar.html',
+  webhook: 'https://docs.olivetin.app/action_execution/onwebhook.html',
+}
+
+function nonEmptyList(list) {
+  return Array.isArray(list) && list.length > 0
+}
+
+function nonEmptyObject(object) {
+  return object && Object.keys(object).length > 0
+}
+
+function webhookHasCondition(webhook) {
+  return webhook.template || webhook.matchPath || nonEmptyObject(webhook.matchHeaders) || nonEmptyObject(webhook.matchQuery)
+}
+
+const hasConfiguredTriggers = computed(() => {
+  const a = action.value
+  if (!a) {
+    return false
+  }
+  if (a.execOnStartup) {
+    return true
+  }
+  if (nonEmptyList(a.execOnCron) || nonEmptyList(a.execOnFileCreatedInDir) || nonEmptyList(a.execOnFileChangedInDir)) {
+    return true
+  }
+  if (a.execOnCalendarFile) {
+    return true
+  }
+  if (nonEmptyList(a.execOnWebhooks)) {
+    return true
+  }
+  return false
+})
+
+async function fetchAction() {
+  loading.value = true
+  try {
+    const actionId = route.params.actionId
+    const response = await window.client.getActionBinding({ bindingId: actionId })
+    action.value = response.action
+    actionTitle.value = response.action?.title || 'Action'
+  } catch (err) {
+    console.error('Failed to fetch action:', err)
+    window.showBigError('fetch-action-exec-conditions', 'getting action', err, false)
+    action.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(fetchAction)
+
+watch(
+  () => route.params.actionId,
+  () => {
+    action.value = null
+    actionTitle.value = 'Action'
+    fetchAction()
+  }
+)
+</script>
+
+<style scoped>
+.content h3 {
+  margin-top: 1.25rem;
+  margin-bottom: 0.35rem;
+  font-size: 1rem;
+}
+
+.exec-type-heading {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 0.35rem 0.75rem;
+}
+
+.exec-type-heading .doc-link {
+  font-size: 0.85rem;
+  font-weight: normal;
+}
+
+.content p,
+.content ul {
+  margin: 0.35rem 0 0;
+}
+
+.webhook-list li {
+  margin-bottom: 0.35rem;
+}
+
+.muted {
+  color: var(--text-secondary);
+  margin-top: 1.5rem;
+}
+
+.empty-state {
+  text-align: center;
+  color: var(--text-secondary);
+}
+
+.padding {
+  padding: 1rem;
+}
+</style>

+ 6 - 3
frontend/resources/vue/views/ArgumentForm.vue

@@ -174,7 +174,7 @@ function getInputType(arg) {
     return 'checkbox'
   }
 
-  if (arg.type === 'ascii_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
+  if (arg.type === 'ascii_identifier' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
     return 'text'
   }
 
@@ -392,6 +392,11 @@ async function startAction(actionArgs) {
 async function handleSubmit(event) {
   event.preventDefault()
 
+  if (popupOnStart.value === 'history') {
+    router.push(`/action/${props.bindingId}`)
+    return
+  }
+
   // Set custom validity for required fields
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
@@ -422,8 +427,6 @@ async function handleSubmit(event) {
     const response = await startAction(argvs)
     if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
       router.push(`/logs/${response.executionTrackingId}`)
-    } else if (popupOnStart.value === 'history') {
-      router.push(`/action/${props.bindingId}`)
     } else {
       router.back()
     }

+ 3 - 2
frontend/resources/vue/views/ExecutionView.vue

@@ -25,7 +25,7 @@
 						<ActionStatusDisplay :log-entry="logEntry" id = "execution-dialog-status" />
 					</dd>
 				</dl>
-        <span class="icon" role="img" v-html="icon" style = "align-self: start"></span>
+        <ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
     </div>
 
 		<div v-if="notFound" class="error-message padded-content">
@@ -62,6 +62,7 @@
 
 <script setup>
 	import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
+import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
@@ -151,7 +152,7 @@ async function reset() {
 
 function show(actionButton) {
   if (actionButton) {
-	icon.value = actionButton.domIcon.innerText
+	icon.value = actionButton.glyph ?? ''
   }
 
   canKill.value = true

+ 4 - 3
frontend/resources/vue/views/LogsListView.vue

@@ -47,7 +47,7 @@
             <tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
               <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
               <td>
-                <span class="icon" v-html="log.actionIcon"></span>
+                <ActionIconGlyph class="icon" :glyph="log.actionIcon" />
                 <router-link :to="`/logs/${log.executionTrackingId}`">
                   {{ log.actionTitle }}
                 </router-link>
@@ -93,6 +93,7 @@ import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { useI18n } from 'vue-i18n'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 
 const route = useRoute()
 const router = useRouter()
@@ -126,7 +127,7 @@ watch(() => route.query.date, () => {
 
 const filteredLogs = computed(() => {
   let result = logs.value
-  
+
   // Date filtering is now done server-side, so we only need to filter by search text
   if (searchText.value) {
     const searchLower = searchText.value.toLowerCase()
@@ -134,7 +135,7 @@ const filteredLogs = computed(() => {
       log.actionTitle.toLowerCase().includes(searchLower)
     )
   }
-  
+
   // Sort by timestamp with most recent first
   return [...result].sort((a, b) => {
     const dateA = a.datetimeStarted ? new Date(a.datetimeStarted).getTime() : 0

+ 4 - 4
integration-tests/package-lock.json

@@ -14,7 +14,7 @@
       "devDependencies": {
         "chai": "^6.2.2",
         "eslint": "^10.4.0",
-        "mocha": "^11.7.5",
+        "mocha": "^11.7.6",
         "selenium-webdriver": "^4.44.0"
       }
     },
@@ -1575,9 +1575,9 @@
       }
     },
     "node_modules/mocha": {
-      "version": "11.7.5",
-      "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz",
-      "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==",
+      "version": "11.7.6",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz",
+      "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {

+ 1 - 1
integration-tests/package.json

@@ -13,7 +13,7 @@
   "devDependencies": {
     "chai": "^6.2.2",
     "eslint": "^10.4.0",
-    "mocha": "^11.7.5",
+    "mocha": "^11.7.6",
     "selenium-webdriver": "^4.44.0"
   },
   "dependencies": {

+ 13 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -14,6 +14,19 @@ message Action {
 	int32 order = 7;
 	int32 timeout = 8;
 	string datetime_rate_limit_expires = 9; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
+	bool exec_on_startup = 10;
+	repeated string exec_on_cron = 11;
+	repeated string exec_on_file_created_in_dir = 12;
+	repeated string exec_on_file_changed_in_dir = 13;
+	string exec_on_calendar_file = 14;
+	repeated ActionWebhookExecHint exec_on_webhooks = 15;
+}
+
+message ActionWebhookExecHint {
+	string template = 1;
+	string match_path = 2;
+	map<string, string> match_headers = 3;
+	map<string, string> match_query = 4;
 }
 
 message ActionArgument {

Fișier diff suprimat deoarece este prea mare
+ 221 - 105
service/gen/olivetin/api/v1/olivetin.pb.go


+ 11 - 11
service/go.mod

@@ -1,16 +1,16 @@
 module github.com/OliveTin/OliveTin
 
-go 1.25.7
+go 1.25.10
 
 exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884
 
 require (
-	connectrpc.com/connect v1.19.2
+	connectrpc.com/connect v1.20.0
 	github.com/Masterminds/semver v1.5.0
 	github.com/MicahParks/keyfunc/v3 v3.8.0
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/alexedwards/argon2id v1.0.0
-	github.com/bufbuild/buf v1.69.0
+	github.com/bufbuild/buf v1.70.0
 	github.com/fsnotify/fsnotify v1.10.1
 	github.com/fzipp/gocyclo v0.6.0
 	github.com/go-critic/go-critic v0.14.3
@@ -29,7 +29,7 @@ require (
 	go.akshayshah.org/connectproto v0.6.0
 	golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
 	golang.org/x/oauth2 v0.36.0
-	golang.org/x/sys v0.44.0
+	golang.org/x/sys v0.45.0
 	google.golang.org/protobuf v1.36.11
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -38,7 +38,7 @@ require (
 	buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1 // indirect
 	buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 // indirect
 	buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
-	buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.2-20260507063250-43b0c5a6cd08.1 // indirect
+	buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1 // indirect
 	buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260507063250-43b0c5a6cd08.1 // indirect
 	buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 // indirect
 	buf.build/go/app v0.2.1-0.20260407195847-833f8f978cda // indirect
@@ -57,7 +57,7 @@ require (
 	github.com/PaesslerAG/gval v1.2.4 // indirect
 	github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/bufbuild/protocompile v0.14.2-0.20260429155904-12ef1ef2ce91 // indirect
+	github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132 // indirect
 	github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cli/browser v1.3.0 // indirect
@@ -68,7 +68,7 @@ require (
 	github.com/cristalhq/acmd v0.12.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/docker/cli v29.5.1+incompatible // indirect
+	github.com/docker/cli v29.5.2+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker v28.5.2+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.9.7 // indirect
@@ -148,17 +148,17 @@ require (
 	go.uber.org/zap v1.28.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.4 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/crypto v0.51.0 // indirect
+	golang.org/x/crypto v0.52.0 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a // indirect
 	golang.org/x/mod v0.36.0 // indirect
-	golang.org/x/net v0.54.0 // indirect
+	golang.org/x/net v0.55.0 // indirect
 	golang.org/x/sync v0.20.0 // indirect
 	golang.org/x/term v0.43.0 // indirect
 	golang.org/x/text v0.37.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.45.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
 	google.golang.org/grpc v1.79.3 // indirect
 	mvdan.cc/xurls/v2 v2.6.0 // indirect
 	pluginrpc.com/pluginrpc v0.5.0 // indirect

+ 20 - 0
service/go.sum

@@ -16,6 +16,8 @@ buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-81958296
 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2/go.mod h1:mpsjeEaxOYPIJV2cz4IagLghZufRvx+NPVtInjEeoQ8=
 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.2-20260507063250-43b0c5a6cd08.1 h1:DcwtSdaY9CwXwPSOneDxJ/B0OCAgNPQQaQxAr/pTHvc=
 buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.2-20260507063250-43b0c5a6cd08.1/go.mod h1:WjOwVG7wzFSwEkjCjHVRWEOdGYyON/TQYPabl7N2VGI=
+buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1 h1:f8pa4iy1Bs+hQ16f3jg22rV/StDKIRj1rNNWX5rLwZ8=
+buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1/go.mod h1:7MNigA51XJjPKrLnbcE61BmgW+pAp3mLW61gUqLBBRY=
 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20251202164234-62b14f0b533c.1 h1:PdfIJUbUVKdajMVYuMdvr2Wvo+wmzGnlPEYA4bhFaWI=
 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20251202164234-62b14f0b533c.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40=
 buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260122161138-ab4e39a3c3bc.1 h1:yWmrELGX6l1GphG9kPVcrMQLjWfXGI5bLDxwE+SfbDw=
@@ -64,6 +66,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
 connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
 connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
 connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
+connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
+connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
 connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU=
 connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
 connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA=
@@ -112,6 +116,8 @@ github.com/bufbuild/buf v1.66.1 h1:wqmmU+6uoxB/eYDOmXq2To4qEXvOJN7gR6L9AxrPL1E=
 github.com/bufbuild/buf v1.66.1/go.mod h1:Vd3ELm8IePWaDJaS9FLy94FFOnLrjLi4mDxmXtw9Xio=
 github.com/bufbuild/buf v1.69.0 h1:q1YTnHJISHuoeUdmsuC9u+nb9rV8glM/TOsPNEteEzg=
 github.com/bufbuild/buf v1.69.0/go.mod h1:Q3KRCXSanDCMFs2zL/MqUwUQV0OUqs23P2sy58CW0nc=
+github.com/bufbuild/buf v1.70.0 h1:rGL4TGoy8F1DbQa4BSlMOVBBR7lWblfnKxUdOxmeFns=
+github.com/bufbuild/buf v1.70.0/go.mod h1:5gCCIpDmBzhiSJwqmxmbdN5aRZQYGmFSGnOBE7seP8c=
 github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs=
 github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
 github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 h1:cQYwUyAzyMmYr7AyJU1C6pVCpUrJJBkmx7UunZosxxs=
@@ -126,6 +132,8 @@ github.com/bufbuild/protocompile v0.14.2-0.20260306221011-519528254156 h1:XOfIIn
 github.com/bufbuild/protocompile v0.14.2-0.20260306221011-519528254156/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE=
 github.com/bufbuild/protocompile v0.14.2-0.20260429155904-12ef1ef2ce91 h1:RPIMBLTMx/CRy0NVyb6yJDlGx2Vo84FsU+kAh46zqIA=
 github.com/bufbuild/protocompile v0.14.2-0.20260429155904-12ef1ef2ce91/go.mod h1:DhgqsRznX/F0sGkUYtTQJRP+q8xMReQRQ3qr+n1opWU=
+github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132 h1:f4T4k/41jHHhp2Otl6ZShDedr4wF9b+NdqIfLezx4R4=
+github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132/go.mod h1:jPUiZUFWc8E3Kc2Y4SRlGAdjde4amGkHY0BUACNS43E=
 github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU=
 github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ=
@@ -170,6 +178,8 @@ github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNu
 github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v29.5.1+incompatible h1:NiufLAJoRcPauFoBNYthfuM4REFwM8H2h9xnLABNHGs=
 github.com/docker/cli v29.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
+github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
@@ -531,6 +541,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
@@ -582,6 +594,8 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
 golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
 golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
 golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
 golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
@@ -616,6 +630,8 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
 golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -683,6 +699,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:
 google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
 google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
 google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 h1:WVVw1Nl19li0fMX++FJ3ye1z9+S1N35QODDy5qpnaXw=
+google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
@@ -699,6 +717,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
 google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
 google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=

+ 27 - 0
service/internal/api/apiActionExecTriggers.go

@@ -0,0 +1,27 @@
+package api
+
+import (
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func applyActionExecTriggers(pb *apiv1.Action, cfg *config.Action) {
+	if cfg == nil {
+		return
+	}
+
+	pb.ExecOnStartup = cfg.ExecOnStartup
+	pb.ExecOnCron = append([]string(nil), cfg.ExecOnCron...)
+	pb.ExecOnFileCreatedInDir = append([]string(nil), cfg.ExecOnFileCreatedInDir...)
+	pb.ExecOnFileChangedInDir = append([]string(nil), cfg.ExecOnFileChangedInDir...)
+	pb.ExecOnCalendarFile = cfg.ExecOnCalendarFile
+
+	for _, wh := range cfg.ExecOnWebhook {
+		pb.ExecOnWebhooks = append(pb.ExecOnWebhooks, &apiv1.ActionWebhookExecHint{
+			Template:     wh.Template,
+			MatchPath:    wh.MatchPath,
+			MatchHeaders: wh.MatchHeaders,
+			MatchQuery:   wh.MatchQuery,
+		})
+	}
+}

+ 2 - 0
service/internal/api/apiActions.go

@@ -156,6 +156,8 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 		DatetimeRateLimitExpires: datetimeRateLimitExpires,
 	}
 
+	applyActionExecTriggers(&btn, action)
+
 	for _, cfgArg := range action.Arguments {
 		pbArg := apiv1.ActionArgument{
 			Name:                  cfgArg.Name,

+ 18 - 0
service/internal/api/api_test.go

@@ -52,6 +52,24 @@ func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server,
 	return ts, client
 }
 
+func TestApplyActionExecTriggersIncludesWebhookHeaderAndQueryMatches(t *testing.T) {
+	cfg := &config.Action{
+		ExecOnWebhook: []config.WebhookConfig{
+			{
+				MatchHeaders: map[string]string{"X-GitHub-Event": "push"},
+				MatchQuery:   map[string]string{"source": "github"},
+			},
+		},
+	}
+	pb := &apiv1.Action{}
+
+	applyActionExecTriggers(pb, cfg)
+
+	require.Len(t, pb.ExecOnWebhooks, 1)
+	assert.Equal(t, cfg.ExecOnWebhook[0].MatchHeaders, pb.ExecOnWebhooks[0].MatchHeaders)
+	assert.Equal(t, cfg.ExecOnWebhook[0].MatchQuery, pb.ExecOnWebhooks[0].MatchQuery)
+}
+
 func TestGetActionsAndStart(t *testing.T) {
 	cfg := config.DefaultConfig()
 

+ 4 - 1
service/internal/config/config.go

@@ -4,6 +4,9 @@ import (
 	"fmt"
 )
 
+// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
+const ReservedArgumentNamePrefix = "ot_"
+
 // Action represents the core functionality of OliveTin - commands that show up
 // as buttons in the UI.
 type Action struct {
@@ -286,7 +289,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.Security.HeaderXContentTypeOptions = true
 	config.Security.HeaderXFrameOptions = true
 	config.Security.XFrameOptions = "DENY"
-	config.DefaultIconForActions = "&#x1F600;"
+	config.DefaultIconForActions = "hugeicons:CommandLineIcon"
 	config.DefaultIconForDirectories = "&#128193"
 	config.DefaultIconForBack = "&laquo;"
 	config.ThemeCacheDisabled = false

+ 28 - 0
service/internal/config/sanitize.go

@@ -26,6 +26,34 @@ func (cfg *Config) Sanitize() {
 	}
 
 	cfg.sanitizeDashboardsForInlineActions()
+
+	if err := cfg.validateReservedActionArgumentNames(); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
+
+func (cfg *Config) validateReservedActionArgumentNames() error {
+	for _, action := range cfg.Actions {
+		if err := action.validateReservedArgumentNames(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (action *Action) validateReservedArgumentNames() error {
+	if action == nil {
+		return nil
+	}
+
+	for _, arg := range action.Arguments {
+		if strings.HasPrefix(arg.Name, ReservedArgumentNamePrefix) {
+			return fmt.Errorf("action %q argument %q uses reserved prefix %q", action.Title, arg.Name, ReservedArgumentNamePrefix)
+		}
+	}
+
+	return nil
 }
 
 func (cfg *Config) sanitizeDashboardsForInlineActions() {

+ 54 - 1
service/internal/config/sanitize_test.go

@@ -34,7 +34,7 @@ func TestSanitizeConfig(t *testing.T) {
 
 	assert.NotNil(t, a2, "Found action after adding it")
 	assert.Equal(t, 3, a2.Timeout, "Default timeout is set")
-	assert.Equal(t, "&#x1F600;", a2.Icon, "Default icon is a smiley")
+	assert.Equal(t, "hugeicons:CommandLineIcon", a2.Icon, "Default icon is the neutral CLI glyph")
 	assert.Equal(t, "Carrots", a2.Arguments[0].Title, "Arg title is set to name")
 	assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name")
 }
@@ -92,6 +92,59 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 	}
 }
 
+func TestValidateReservedActionArgumentNames(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Reserved arg",
+		Arguments: []ActionArgument{
+			{Name: "ot_custom", Type: "ascii"},
+		},
+	})
+
+	err := c.validateReservedActionArgumentNames()
+
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `action "Reserved arg" argument "ot_custom" uses reserved prefix "ot_"`)
+}
+
+func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Allowed arg",
+		Arguments: []ActionArgument{
+			{Name: "target", Type: "ascii"},
+		},
+	})
+
+	require.NoError(t, c.validateReservedActionArgumentNames())
+}
+
+func TestValidateReservedActionArgumentNamesChecksInlineActions(t *testing.T) {
+	c := DefaultConfig()
+	c.Dashboards = []*DashboardComponent{
+		{
+			Title: "Dashboard",
+			Contents: []*DashboardComponent{
+				{
+					Title: "Inline reserved arg",
+					InlineAction: &Action{
+						Shell: "echo test",
+						Arguments: []ActionArgument{
+							{Name: "ot_custom", Type: "ascii"},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	c.sanitizeDashboardsForInlineActions()
+	err := c.validateReservedActionArgumentNames()
+
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `action "Inline reserved arg" argument "ot_custom" uses reserved prefix "ot_"`)
+}
+
 func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
 	t.Parallel()
 

+ 1 - 0
service/internal/executor/arguments.go

@@ -21,6 +21,7 @@ var (
 		"unicode_identifier":        `^[\w\-\.\_\d]+$`,
 		"ascii":                     `^[a-zA-Z0-9]+$`,
 		"ascii_identifier":          `^[a-zA-Z0-9\-\._]+$`,
+		"shell_safe_identifier":     `^[a-zA-Z0-9@\.\_\+\-]+$`,
 		"ascii_sentence":            `^[a-zA-Z0-9\-\._, ]+$`,
 	}
 )

+ 32 - 0
service/internal/executor/arguments_test.go

@@ -576,6 +576,38 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) {
 	}
 }
 
+func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) {
+	tests := []struct {
+		name     string
+		value    string
+		hasError bool
+	}{
+		{"Simple username", "alice123", false},
+		{"Email username", "alice@example.com", false},
+		{"Plus addressing", "alice+test@example.com", false},
+		{"Hyphen underscore dot", "alice-test_user.example", false},
+		{"Invalid space", "alice example", true},
+		{"Invalid shell substitution", "$(whoami)", true},
+		{"Invalid backtick", "`whoami`", true},
+		{"Invalid semicolon", "alice;id", true},
+		{"Invalid ampersand", "alice&id", true},
+		{"Invalid pipe", "alice|id", true},
+		{"Invalid quote", "alice'example", true},
+		{"Invalid slash", "alice/example", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier")
+			if tt.hasError {
+				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+			} else {
+				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+			}
+		})
+	}
+}
+
 func TestTypeSafetyCheckAsciiSentence(t *testing.T) {
 	tests := []struct {
 		name     string

+ 77 - 25
service/internal/executor/executor.go

@@ -666,13 +666,15 @@ func stepACLCheck(req *ExecutionRequest) bool {
 
 func stepParseArgs(req *ExecutionRequest) bool {
 	ensureArgumentMap(req)
-	injectSystemArgs(req)
 
 	if !hasBindingAndAction(req) {
 		return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil"))
 	}
 
 	filterToDefinedArgumentsOnly(req)
+	if err := injectSystemArgs(req); err != nil {
+		return fail(req, err)
+	}
 	mangleInvalidArgumentValues(req)
 
 	if hasExec(req) {
@@ -735,7 +737,7 @@ func filterToDefinedArgumentsOnly(req *ExecutionRequest) {
 
 func keepArgument(name string, definedNames map[string]struct{}) bool {
 	_, ok := definedNames[name]
-	return ok || strings.HasPrefix(name, "ot_")
+	return ok
 }
 
 func hasWebhookTag(req *ExecutionRequest) bool {
@@ -747,9 +749,38 @@ func hasWebhookTag(req *ExecutionRequest) bool {
 	return false
 }
 
-func injectSystemArgs(req *ExecutionRequest) {
-	req.Arguments["ot_executionTrackingId"] = req.TrackingID
-	req.Arguments["ot_username"] = req.AuthenticatedUser.Username
+var systemArgumentDefinitions = []config.ActionArgument{
+	{Name: "ot_executionTrackingId", Type: "ascii_identifier", RejectNull: true},
+	{Name: "ot_username", Type: "shell_safe_identifier", RejectNull: true},
+}
+
+func injectSystemArgs(req *ExecutionRequest) error {
+	args, err := validatedSystemArgs(req)
+	if err != nil {
+		return err
+	}
+
+	for name, value := range args {
+		req.Arguments[name] = value
+	}
+
+	return nil
+}
+
+func validatedSystemArgs(req *ExecutionRequest) (map[string]string, error) {
+	values := map[string]string{
+		"ot_executionTrackingId": req.TrackingID,
+		"ot_username":            req.AuthenticatedUser.Username,
+	}
+
+	for i := range systemArgumentDefinitions {
+		arg := &systemArgumentDefinitions[i]
+		if err := ValidateArgument(arg, values[arg.Name], req.Binding.Action); err != nil {
+			return nil, fmt.Errorf("system argument %q failed validation: %w", arg.Name, err)
+		}
+	}
+
+	return values, nil
 }
 
 func hasBindingAndAction(req *ExecutionRequest) bool {
@@ -939,36 +970,20 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
 }
 
 func stepExecAfter(req *ExecutionRequest) bool {
-	if req.Binding.Action.ShellAfterCompleted == "" {
-		return true
-	}
-
 	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor)
 	defer cancel()
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
 
-	args := map[string]string{
-		"output":                 req.logEntry.Output,
-		"exitCode":               fmt.Sprintf("%v", req.logEntry.ExitCode),
-		"ot_executionTrackingId": req.TrackingID,
-		"ot_username":            req.AuthenticatedUser.Username,
-	}
-
-	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
-
+	cmd, args, err := buildShellAfterCommand(ctx, req, &stdout, &stderr)
 	if err != nil {
-		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
-		req.logEntry.Output += msg
-		log.Warn(msg)
+		return fail(req, err)
+	}
+	if cmd == nil {
 		return true
 	}
 
-	cmd := wrapCommandInShell(ctx, finalParsedCommand)
-	cmd.Stdout = &stdout
-	cmd.Stderr = &stderr
-
 	cmd.Env = buildEnv(args)
 
 	runerr := cmd.Start()
@@ -998,6 +1013,43 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	return true
 }
 
+func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
+	if req.Binding.Action.ShellAfterCompleted == "" {
+		return nil, nil, nil
+	}
+
+	args, err := buildShellAfterArgs(req)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
+	if err != nil {
+		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
+		req.logEntry.Output += msg
+		log.Warn(msg)
+		return nil, nil, nil
+	}
+
+	cmd := wrapCommandInShell(ctx, finalParsedCommand)
+	cmd.Stdout = stdout
+	cmd.Stderr = stderr
+
+	return cmd, args, nil
+}
+
+func buildShellAfterArgs(req *ExecutionRequest) (map[string]string, error) {
+	args, err := validatedSystemArgs(req)
+	if err != nil {
+		return nil, err
+	}
+
+	args["output"] = req.logEntry.Output
+	args["exitCode"] = fmt.Sprintf("%v", req.logEntry.ExitCode)
+
+	return args, nil
+}
+
 //gocyclo:ignore
 func stepTrigger(req *ExecutionRequest) bool {
 	if req.Binding.Action.Triggers == nil {

+ 206 - 4
service/internal/executor/executor_test.go

@@ -1,6 +1,7 @@
 package executor
 
 import (
+	"strings"
 	"testing"
 	"time"
 
@@ -37,7 +38,7 @@ func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 
 	req := ExecutionRequest{
-		AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "Mr Tickle"},
+		AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"},
 		Cfg:               cfg,
 		Arguments: map[string]string{
 			"person": "yourself",
@@ -379,7 +380,7 @@ func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 	assert.Empty(t, req.Arguments["extra_undefined"])
 }
 
-func TestFilterToDefinedArgumentsPreservesSystemArgs(t *testing.T) {
+func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) {
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{
 		Title:     "Filter test",
@@ -393,8 +394,209 @@ func TestFilterToDefinedArgumentsPreservesSystemArgs(t *testing.T) {
 
 	filterToDefinedArgumentsOnly(req)
 
-	assert.Equal(t, "track-123", req.Arguments["ot_executionTrackingId"])
-	assert.Equal(t, "webhook", req.Arguments["ot_username"])
+	assert.Empty(t, req.Arguments["ot_executionTrackingId"])
+	assert.Empty(t, req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
+	req.Binding.Action = &config.Action{
+		Title: "Filter then inject",
+		Shell: "echo test",
+		Arguments: []config.ActionArgument{
+			{Name: "name", Type: "ascii"},
+		},
+	}
+	req.Arguments = map[string]string{
+		"name":                   "Alice",
+		"ot_executionTrackingId": "attacker-track",
+		"ot_username":            "mallory",
+		"ot_custom":              "polluted",
+	}
+
+	assert.True(t, stepParseArgs(req))
+	assert.Equal(t, "Alice", req.Arguments["name"])
+	assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"])
+	assert.Equal(t, "alice", req.Arguments["ot_username"])
+	assert.Empty(t, req.Arguments["ot_custom"])
+}
+
+func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:     "No reserved prefix pollution",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+	req.Arguments = map[string]string{
+		"ot_custom": "polluted",
+	}
+
+	assert.True(t, stepParseArgs(req))
+	env := buildEnv(req.Arguments)
+
+	assert.False(t, containsEnvPrefix(env, "OT_CUSTOM="))
+	assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com"))
+	assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456"))
+}
+
+func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) {
+	unsafeTypes := map[string]struct{}{
+		"email":                     {},
+		"password":                  {},
+		"raw_string_multiline":      {},
+		"url":                       {},
+		"very_dangerous_raw_string": {},
+	}
+	seen := map[string]struct{}{}
+
+	for _, arg := range systemArgumentDefinitions {
+		assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix))
+		assert.NotEmpty(t, arg.Type)
+		assert.True(t, arg.RejectNull)
+
+		_, duplicate := seen[arg.Name]
+		assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name)
+		seen[arg.Name] = struct{}{}
+
+		_, unsafe := unsafeTypes[arg.Type]
+		assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type)
+	}
+}
+
+func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+
+	args, err := validatedSystemArgs(req)
+
+	assert.Nil(t, err)
+	assert.Len(t, args, len(systemArgumentDefinitions))
+	for _, arg := range systemArgumentDefinitions {
+		assert.Contains(t, args, arg.Name)
+	}
+}
+
+func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{
+		Output:   "hello",
+		ExitCode: 7,
+	}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, err)
+	assert.Len(t, args, len(systemArgumentDefinitions)+2)
+	assert.Contains(t, args, "output")
+	assert.Contains(t, args, "exitCode")
+	for _, arg := range systemArgumentDefinitions {
+		assert.Contains(t, args, arg.Name)
+	}
+}
+
+func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:     "Email username",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.True(t, stepParseArgs(req))
+	assert.Equal(t, "alice@example.com", req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
+	req.Binding.Action = &config.Action{
+		Title:     "Invalid system arg",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.False(t, stepParseArgs(req))
+	assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`)
+	assert.Empty(t, req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "track/../../bad"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
+	req.Binding.Action = &config.Action{
+		Title:     "Invalid tracking ID",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.False(t, stepParseArgs(req))
+	assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`)
+	assert.Empty(t, req.Arguments["ot_executionTrackingId"])
+}
+
+func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{
+		Output:   "hello",
+		ExitCode: 7,
+	}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:               "Shell after",
+		ShellAfterCompleted: "echo test",
+	}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, err)
+	assert.Equal(t, "alice@example.com", args["ot_username"])
+	assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
+	assert.Equal(t, "hello", args["output"])
+	assert.Equal(t, "7", args["exitCode"])
+}
+
+func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
+	req.Binding.Action = &config.Action{
+		Title:               "Shell after invalid username",
+		ShellAfterCompleted: "echo test",
+	}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, args)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
+}
+
+func containsEnvPrefix(env []string, prefix string) bool {
+	for _, item := range env {
+		if strings.HasPrefix(item, prefix) {
+			return true
+		}
+	}
+
+	return false
 }
 
 func TestTriggerExecutesTriggeredAction(t *testing.T) {

+ 7 - 6
service/internal/onfileindir/fileindir.go

@@ -13,18 +13,19 @@ import (
 
 func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
 	for _, action := range cfg.Actions {
-		for _, dirname := range action.ExecOnFileChangedInDir {
-			// Pass values into anonymous function because of this issue
-			// https://github.com/OliveTin/OliveTin/issues/503
-
+		for _, dirname := range action.ExecOnFileCreatedInDir {
 			go func(act *config.Action, dir string) {
-				filehelper.WatchDirectoryWrite(dir, func(filename string) {
+				filehelper.WatchDirectoryCreate(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
 				})
 			}(action, dirname)
+		}
+		for _, dirname := range action.ExecOnFileChangedInDir {
+			// Pass values into anonymous function because of this issue
+			// https://github.com/OliveTin/OliveTin/issues/503
 
 			go func(act *config.Action, dir string) {
-				filehelper.WatchDirectoryCreate(dir, func(filename string) {
+				filehelper.WatchDirectoryWrite(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
 				})
 			}(action, dirname)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff