sendword 0.9.0

Simple HTTP webhook to command runner sidecar. Frontend for managing hooks, JSON state for config portability, SQLite for execution history and logs.
Documentation
(function () {
  const executorCopy = {
    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.'
    }
  };

  function find(root, selector) {
    if (!root) return null;
    if (root.matches && root.matches(selector)) return root;
    return root.querySelector ? root.querySelector(selector) : null;
  }

  function eventElement(event) {
    if (event.target && event.target.closest) return event.target;
    return event.target && event.target.parentElement ? event.target.parentElement : null;
  }

  function filterHooks(query) {
    const q = (query || '').toLowerCase();
    document.querySelectorAll('#hook-list tr').forEach(row => {
      const name = row.getAttribute('data-hook-name') || '';
      const slug = row.getAttribute('data-hook-slug') || '';
      row.style.display = name.includes(q) || slug.includes(q) ? '' : 'none';
    });
  }

  function initHookFilter(root) {
    const filter = find(root, '[data-sendword-hook-filter]');
    if (!filter || filter.dataset.sendwordReady === 'true') return;
    filter.dataset.sendwordReady = 'true';
    filter.addEventListener('input', () => filterHooks(filter.value));
    filterHooks(filter.value);
  }

  function switchActivityTab(tab, trigger) {
    document.querySelectorAll('#activity-tabs button').forEach(button => {
      button.classList.remove('is-active');
    });
    trigger.classList.add('is-active');

    const executions = document.getElementById('activity-tab-executions');
    const attempts = document.getElementById('activity-tab-attempts');
    if (!executions || !attempts) return;

    if (tab === 'executions') {
      executions.classList.remove('wf-hidden');
      executions.classList.add('wf-f');
      attempts.classList.add('wf-hidden');
      attempts.classList.remove('wf-f');
    } else {
      executions.classList.add('wf-hidden');
      executions.classList.remove('wf-f');
      attempts.classList.remove('wf-hidden');
      attempts.classList.add('wf-f');
    }
  }

  function initActivityTabs(root) {
    const tabs = find(root, '#activity-tabs');
    if (!tabs || tabs.dataset.sendwordReady === 'true') return;
    tabs.dataset.sendwordReady = 'true';
    tabs.addEventListener('click', event => {
      const target = eventElement(event);
      const trigger = target && target.closest('[data-sendword-activity-tab]');
      if (!trigger) return;
      switchActivityTab(trigger.getAttribute('data-sendword-activity-tab'), trigger);
    });
  }

  function toggleAuthFields() {
    const mode = document.getElementById('auth_mode');
    const bearer = document.getElementById('bearer-fields');
    const hmac = document.getElementById('hmac-fields');
    if (!mode || !bearer || !hmac) return;
    bearer.style.display = mode.value === 'bearer' ? '' : 'none';
    hmac.style.display = mode.value === 'hmac' ? '' : 'none';
  }

  function updateExecutorField() {
    const type = document.getElementById('executor_type');
    const command = document.getElementById('command');
    const label = document.getElementById('command_label');
    const hint = document.getElementById('command_hint');
    if (!type || !command || !label || !hint) return;

    const selected = executorCopy[type.value] || executorCopy.shell;
    label.textContent = selected.label;
    command.placeholder = selected.placeholder;
    hint.textContent = selected.hint;
  }

  function initHookForm(root) {
    if (!find(root, '#auth_mode') && !find(root, '#executor_type')) return;

    const authMode = find(root, '#auth_mode');
    const executorType = find(root, '#executor_type');

    if (authMode && authMode.dataset.sendwordReady !== 'true') {
      authMode.dataset.sendwordReady = 'true';
      authMode.addEventListener('change', toggleAuthFields);
    }

    if (executorType && executorType.dataset.sendwordReady !== 'true') {
      executorType.dataset.sendwordReady = 'true';
      executorType.addEventListener('change', updateExecutorField);
    }

    toggleAuthFields();
    updateExecutorField();
  }

  function initReplayRedirect() {
    document.addEventListener('htmx:afterRequest', event => {
      const target = eventElement(event);
      const replay = target && target.closest('[data-sendword-replay]');
      if (!replay || !event.detail || !event.detail.successful) return;

      try {
        const body = JSON.parse(event.detail.xhr.responseText || '{}');
        if (body.execution_id) {
          window.location.assign('/executions/' + encodeURIComponent(body.execution_id));
        }
      } catch (_) {
        return;
      }
    });
  }

  function handleFormConfirm(event) {
    const target = eventElement(event);
    const form = target && target.closest('[data-sendword-confirm]');
    if (!form) return;

    const message = form.getAttribute('data-sendword-confirm');
    if (message && !window.confirm(message)) {
      event.preventDefault();
      event.stopImmediatePropagation();
    }
  }

  function initFormConfirm() {
    document.addEventListener('submit', handleFormConfirm, true);
  }

  function maybeReloadAfterSettle(event) {
    const target = eventElement(event);
    const trigger = target && target.closest('[data-sendword-reload-after-settle]');
    if (!trigger) return;

    const delay = Number.parseInt(
      trigger.getAttribute('data-sendword-reload-after-settle') || '0',
      10
    );
    window.setTimeout(() => window.location.reload(), Number.isFinite(delay) ? delay : 0);
  }

  function init(root) {
    initHookFilter(root);
    initActivityTabs(root);
    initHookForm(root);
  }

  initReplayRedirect();
  initFormConfirm();
  document.addEventListener('DOMContentLoaded', () => init(document));
  document.addEventListener('htmx:afterSettle', event => {
    maybeReloadAfterSettle(event);
    init(event.target || document);
  });
})();