sendword 0.8.7

Simple HTTP webhook to command runner sidecar. Frontend for managing hooks, JSON state for config portability, SQLite for execution history and logs.
Documentation
{% extends "base.html" %}

{% block title %}sendword — {% if is_new %}new hook{% else %}edit {{ form_name }}{% endif %}{% endblock %}

{% block crumbs %}
<div class="wf-crumbs">
  <a href="/">HOOKS</a><span class="sep">/</span>
  {% if is_new %}
  <span aria-current="page">NEW</span>
  {% else %}
  <a href="/hooks/{{ slug }}">{{ slug | upper }}</a><span class="sep">/</span>
  <span aria-current="page">EDIT</span>
  {% endif %}
</div>
{% endblock %}

{% block content %}
<form method="post" action="{% if is_new %}/hooks/new{% else %}/hooks/{{ slug }}/edit{% endif %}">

  {# --- Basic Info --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">BASIC INFO</span></div>
    <div class="wf-panel-body">
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="name">Name</label>
        <input type="text" id="name" name="name" value="{{ form_name }}" required placeholder="Deploy App" class="wf-input">
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="slug">Slug</label>
        <input type="text" id="slug" name="slug" value="{{ form_slug }}" {% if not is_new %}readonly{% endif %} required pattern="[a-z0-9]+(-[a-z0-9]+)*" maxlength="64" placeholder="deploy-app" class="wf-input">
        {% if is_new %}
        <span class="wf-field-hint">Lowercase letters, numbers, and hyphens. Used in the webhook URL.</span>
        {% else %}
        <span class="wf-field-hint">Slug cannot be changed after creation.</span>
        {% endif %}
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="description">Description</label>
        <textarea id="description" name="description" rows="2" placeholder="Optional description" class="wf-textarea">{{ form_description }}</textarea>
      </div>
      <label class="wf-check-row">
        <input type="checkbox" name="enabled" value="true" {% if form_enabled %}checked{% endif %} class="wf-switch">
        <span>Enabled</span>
      </label>
    </div>
  </div>

  {# --- Authentication --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">AUTHENTICATION</span></div>
    <div class="wf-panel-body">
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="auth_mode">Auth mode</label>
        <select id="auth_mode" name="auth_mode" class="wf-select" onchange="toggleAuthFields()">
          <option value="none" {% if form_auth_mode == "none" %}selected{% endif %}>None (public)</option>
          <option value="bearer" {% if form_auth_mode == "bearer" %}selected{% endif %}>Bearer token</option>
          <option value="hmac" {% if form_auth_mode == "hmac" %}selected{% endif %}>HMAC signature</option>
        </select>
        <span class="wf-field-hint">How callers authenticate when triggering this hook.</span>
      </div>

      <div id="bearer-fields">
        <div class="wf-field" style="margin-bottom: 16px;">
          <label class="wf-label" for="auth_token">Bearer token</label>
          <input type="text" id="auth_token" name="auth_token" value="{{ form_auth_token }}" placeholder="${HOOK_TOKEN} or literal value" class="wf-input">
          <span class="wf-field-hint">Use ${ENV_VAR} syntax to reference an environment variable.</span>
        </div>
      </div>

      <div id="hmac-fields">
        <div class="wf-field" style="margin-bottom: 16px;">
          <label class="wf-label" for="auth_header">Signature header</label>
          <input type="text" id="auth_header" name="auth_header" value="{{ form_auth_header }}" placeholder="X-Hub-Signature-256" class="wf-input">
          <span class="wf-field-hint">HTTP header containing the HMAC signature.</span>
        </div>
        <div class="wf-field" style="margin-bottom: 16px;">
          <label class="wf-label" for="auth_algorithm">Algorithm</label>
          <select id="auth_algorithm" name="auth_algorithm" class="wf-select">
            <option value="sha256" {% if form_auth_algorithm == "sha256" %}selected{% endif %}>SHA-256</option>
          </select>
        </div>
        <div class="wf-field">
          <label class="wf-label" for="auth_secret">Shared secret</label>
          <input type="text" id="auth_secret" name="auth_secret" value="{{ form_auth_secret }}" placeholder="${WEBHOOK_SECRET} or literal value" class="wf-input">
          <span class="wf-field-hint">Use ${ENV_VAR} syntax to reference an environment variable.</span>
        </div>
      </div>
    </div>
  </div>

  {# --- Executor --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">EXECUTOR</span></div>
    <div class="wf-panel-body">
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="executor_type">Executor type</label>
        <select id="executor_type" name="executor_type" class="wf-select" onchange="updateExecutorField()">
          <option value="shell" {% if form_executor_type == "shell" %}selected{% endif %}>Shell command</option>
          <option value="script" {% if form_executor_type == "script" %}selected{% endif %}>Executable script</option>
          <option value="javascript" {% if form_executor_type == "javascript" %}selected{% endif %}>JavaScript script</option>
          <option value="python" {% if form_executor_type == "python" %}selected{% endif %}>Python script</option>
          <option value="http" {% if form_executor_type == "http" %}selected{% endif %}>HTTP request</option>
        </select>
        <span class="wf-field-hint">Choose how sendword should run this hook.</span>
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" id="command_label" for="command">Shell command</label>
        <input type="text" id="command" name="command" value="{{ form_command }}" required placeholder="make deploy" class="wf-input">
        <span class="wf-field-hint" id="command_hint">Shell commands may use payload interpolation like {{ "{{ action }}" }}.</span>
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="cwd">Working directory</label>
        <input type="text" id="cwd" name="cwd" value="{{ form_cwd }}" placeholder="/opt/app (optional)" class="wf-input">
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="timeout">Timeout</label>
        <input type="text" id="timeout" name="timeout" value="{{ form_timeout }}" placeholder="30s" class="wf-input">
        <span class="wf-field-hint">Duration with unit, e.g. 30s, 2m, 1h. Leave blank for default.</span>
      </div>
      <div class="wf-field">
        <label class="wf-label" for="env_text">Environment variables</label>
        <textarea id="env_text" name="env_text" rows="4" placeholder="KEY=VALUE" class="wf-textarea" spellcheck="false">{{ form_env_text }}</textarea>
        <span class="wf-field-hint">One variable per line, in KEY=VALUE format.</span>
      </div>
    </div>
  </div>

  {# --- Payload Schema --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">PAYLOAD SCHEMA</span></div>
    <div class="wf-panel-body">
      <div class="wf-field">
        <label class="wf-label" for="payload_text">Field definitions</label>
        <textarea id="payload_text" name="payload_text" rows="4" placeholder="action:string:required&#10;tag:string&#10;count:number:required" class="wf-textarea" spellcheck="false">{{ form_payload_text }}</textarea>
        <span class="wf-field-hint">One field per line: name:type or name:type:required. Types: string, number, boolean, object, array.</span>
      </div>
    </div>
  </div>

  {# --- Trigger Rules --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">TRIGGER RULES</span></div>
    <div class="wf-panel-body">
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="trigger_filters_text">Payload filters</label>
        <textarea id="trigger_filters_text" name="trigger_filters_text" rows="3" placeholder="action:equals:deploy&#10;env:contains:prod" class="wf-textarea" spellcheck="false">{{ form_trigger_filters_text }}</textarea>
        <span class="wf-field-hint">One filter per line: field:operator:value. Operators: equals, not_equals, contains, regex, gt, lt, gte, lte, exists.</span>
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="trigger_windows_text">Time windows (UTC)</label>
        <textarea id="trigger_windows_text" name="trigger_windows_text" rows="2" placeholder="Mon,Tue,Wed,Thu,Fri:09:00-17:00" class="wf-textarea" spellcheck="false">{{ form_trigger_windows_text }}</textarea>
        <span class="wf-field-hint">One window per line: Days:HH:MM-HH:MM. Leave blank to allow at any time.</span>
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="trigger_cooldown">Cooldown</label>
        <input type="text" id="trigger_cooldown" name="trigger_cooldown" value="{{ form_trigger_cooldown }}" placeholder="5m" class="wf-input">
        <span class="wf-field-hint">Duration with unit, e.g. 30s, 5m, 1h.</span>
      </div>
      <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
        <div class="wf-field">
          <label class="wf-label" for="trigger_rate_max">Rate limit max</label>
          <input type="number" id="trigger_rate_max" name="trigger_rate_max" value="{{ form_trigger_rate_max }}" min="1" placeholder="10" class="wf-input">
        </div>
        <div class="wf-field">
          <label class="wf-label" for="trigger_rate_window">Rate limit window</label>
          <input type="text" id="trigger_rate_window" name="trigger_rate_window" value="{{ form_trigger_rate_window }}" placeholder="1h" class="wf-input">
        </div>
      </div>
    </div>
  </div>

  {# --- Retry --- #}
  <div class="wf-panel" style="margin-bottom: 24px;">
    <div class="wf-panel-head"><span class="wf-panel-title">RETRY</span></div>
    <div class="wf-panel-body">
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="retry_count">Retry count</label>
        <input type="number" id="retry_count" name="retry_count" value="{{ form_retry_count }}" min="0" placeholder="0" class="wf-input">
        <span class="wf-field-hint">0 = no retries.</span>
      </div>
      <div class="wf-field" style="margin-bottom: 16px;">
        <label class="wf-label" for="retry_backoff">Backoff strategy</label>
        <select id="retry_backoff" name="retry_backoff" class="wf-select">
          <option value="none" {% if form_retry_backoff == "none" %}selected{% endif %}>None</option>
          <option value="linear" {% if form_retry_backoff == "linear" %}selected{% endif %}>Linear</option>
          <option value="exponential" {% if form_retry_backoff == "exponential" %}selected{% endif %}>Exponential</option>
        </select>
      </div>
      <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
        <div class="wf-field">
          <label class="wf-label" for="retry_initial_delay">Initial delay</label>
          <input type="text" id="retry_initial_delay" name="retry_initial_delay" value="{{ form_retry_initial_delay }}" placeholder="1s" class="wf-input">
        </div>
        <div class="wf-field">
          <label class="wf-label" for="retry_max_delay">Max delay</label>
          <input type="text" id="retry_max_delay" name="retry_max_delay" value="{{ form_retry_max_delay }}" placeholder="60s" class="wf-input">
        </div>
      </div>
    </div>
  </div>

  {# --- Actions --- #}
  <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px;">
    <a class="wf-btn" href="{% if is_new %}/{% else %}/hooks/{{ slug }}{% endif %}">CANCEL</a>
    <button type="submit" class="wf-btn primary">{% if is_new %}CREATE HOOK{% else %}SAVE CHANGES{% endif %}</button>
  </div>
</form>

<script>
function toggleAuthFields() {
  var mode = document.getElementById('auth_mode').value;
  document.getElementById('bearer-fields').style.display = mode === 'bearer' ? '' : 'none';
  document.getElementById('hmac-fields').style.display = mode === 'hmac' ? '' : 'none';
}
function updateExecutorField() {
  var type = document.getElementById('executor_type').value;
  var command = document.getElementById('command');
  var label = document.getElementById('command_label');
  var hint = document.getElementById('command_hint');
  var copy = {
    shell: {
      label: 'Shell command',
      placeholder: 'make deploy',
      hint: 'Shell commands may use payload interpolation like {{ "{{ action }}" }}.'
    },
    script: {
      label: 'Script path',
      placeholder: 'data/scripts/deploy.sh',
      hint: 'Executable scripts are run directly and need a shebang plus executable permissions.'
    },
    javascript: {
      label: 'JavaScript path',
      placeholder: 'data/scripts/deploy.js',
      hint: 'JavaScript scripts run with node and can read payload fields from process.env.'
    },
    python: {
      label: 'Python path',
      placeholder: 'data/scripts/deploy.py',
      hint: 'Python scripts run with python3, then python, and can read payload fields from os.environ.'
    },
    http: {
      label: 'HTTP URL',
      placeholder: 'https://example.com/webhook',
      hint: 'HTTP executors are view-only in this form and cannot be saved here.'
    }
  };
  var selected = copy[type] || copy.shell;
  label.textContent = selected.label;
  command.placeholder = selected.placeholder;
  hint.textContent = selected.hint;
}
toggleAuthFields();
updateExecutorField();
</script>
{% endblock %}