Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9a84d7da2e | ||
|
eef0bb13c8 | ||
|
876048f60e | ||
|
4ffb6f82b9 | ||
|
a68b7c6463 | ||
|
73c5da01f9 | ||
|
f225acc752 | ||
|
13dd1dbd44 | ||
|
f5a34df9e5 | ||
|
5e448e8041 | ||
|
7bffb76749 | ||
|
dd89c5f650 | ||
|
ac3298c40e | ||
|
708c652d21 | ||
|
fd5ff20743 | ||
|
87449641f6 | ||
|
731cd207dd | ||
|
a5489de212 | ||
|
c0d4448dc1 | ||
|
0d43e181c8 | ||
|
f60068b01f | ||
|
4d484c5023 | ||
|
547cad7a21 | ||
|
f3aa92cd87 |
1
docs
Symbolic link
1
docs
Symbolic link
@@ -0,0 +1 @@
|
||||
octoprint_tailscale_funnel/octoprint_tailscale_funnel/docs
|
@@ -1,7 +1,6 @@
|
||||
include README.md
|
||||
include LICENSE
|
||||
include requirements.txt
|
||||
include setup.py
|
||||
recursive-include static *
|
||||
recursive-include templates *
|
||||
recursive-include octoprint_tailscale_funnel/static *
|
||||
recursive-include octoprint_tailscale_funnel/templates *
|
||||
recursive-include tests *
|
@@ -2,6 +2,8 @@
|
||||
|
||||
This plugin makes your OctoPrint instance accessible from anywhere via Tailscale Funnel, without needing to configure port forwarding, dynamic DNS, or complex firewall settings.
|
||||
|
||||
Disclaimer: *This plugin was partially vibe-coded*.
|
||||
|
||||
## Features
|
||||
|
||||
* Enable/disable Tailscale Funnel access directly from OctoPrint's settings
|
||||
@@ -9,6 +11,10 @@ This plugin makes your OctoPrint instance accessible from anywhere via Tailscale
|
||||
* Display the public URL for accessing OctoPrint remotely
|
||||
* Configure the port to expose via Funnel
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
* OctoPrint 1.3.0 or higher
|
||||
@@ -24,6 +30,10 @@ This plugin makes your OctoPrint instance accessible from anywhere via Tailscale
|
||||
5. Configure the plugin settings in OctoPrint's settings panel
|
||||
6. Enable Funnel through the plugin interface
|
||||
|
||||
### Latest Release
|
||||
|
||||
Get the latest packaged release (wheel, sdist, zip) from Gitea: [Releases](https://gitea.elpatron.me/elpatron/octo-funnel/releases)
|
||||
|
||||
## Building from Source
|
||||
|
||||
If you want to build the plugin from source, please refer to the [BUILDING.md](BUILDING.md) file for detailed instructions.
|
||||
@@ -75,6 +85,43 @@ sudo tailscale funnel reset
|
||||
|
||||
Enabling Funnel makes your OctoPrint instance accessible from the public internet. Only enable it when needed and disable it when finished. The plugin will show a confirmation dialog before enabling Funnel if the "Confirm Enable" option is checked.
|
||||
|
||||
### Disable Funnel automatically on system boot (systemd)
|
||||
|
||||
If you prefer to make sure the Tailscale Funnel is always off after a system reboot (independent of the plugin), you can add a small systemd unit that disables Funnel shortly after boot:
|
||||
|
||||
1) Create unit file:
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/octoprint-tailscale-funnel-off.service >/dev/null <<'UNIT'
|
||||
[Unit]
|
||||
Description=Disable Tailscale Funnel after boot
|
||||
After=network-online.target tailscaled.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=octoprint
|
||||
ExecStart=/usr/bin/tailscale funnel reset
|
||||
# Optional: also turn off possible serve mappings on common ports
|
||||
ExecStart=/usr/bin/tailscale serve --http=80 off
|
||||
ExecStart=/usr/bin/tailscale serve --https=80 off
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
```
|
||||
|
||||
2) Enable and test:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now octoprint-tailscale-funnel-off.service
|
||||
sudo systemctl status octoprint-tailscale-funnel-off.service
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Adjust `User=octoprint` if your OctoPrint runs under a different user (e.g. `pi`).
|
||||
- If sudo is required for tailscale on your system, add the corresponding sudoers entry as described above.
|
||||
- You can add a short delay by inserting `ExecStartPre=/bin/sleep 5` if Tailscale comes up late during boot.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The plugin exposes the following API endpoints:
|
||||
|
@@ -20,8 +20,15 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
##~~ StartupPlugin mixin
|
||||
|
||||
def on_after_startup(self):
|
||||
self._logger = self._plugin_manager.get_logger("octoprint_tailscale_funnel")
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
# Ensure a valid logger; older OctoPrints don't provide PluginManager.get_logger
|
||||
try:
|
||||
import logging
|
||||
if getattr(self, "_logger", None) is None:
|
||||
self._logger = logging.getLogger("octoprint.plugins.tailscale_funnel")
|
||||
except Exception:
|
||||
pass
|
||||
if self._logger:
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
self.tailscale_interface = TailscaleInterface(self._logger)
|
||||
self.status_monitor = StatusMonitor(self)
|
||||
self.status_monitor.start()
|
||||
@@ -84,7 +91,7 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
|
||||
def get_assets(self):
|
||||
return dict(
|
||||
js=["js/tailscale_funnel.js"],
|
||||
js=["js/tailscale_funnel.js", "js/tailscale_funnel_navbar.js"],
|
||||
css=["css/tailscale_funnel.css"],
|
||||
less=["less/tailscale_funnel.less"]
|
||||
)
|
||||
@@ -93,7 +100,8 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
|
||||
def get_template_configs(self):
|
||||
return [
|
||||
dict(type="settings", custom_bindings=True, template="tailscale_funnel_settings.jinja2")
|
||||
dict(type="settings", custom_bindings=True, template="tailscale_funnel_settings.jinja2"),
|
||||
dict(type="navbar", name="Funnel", custom_bindings=False, template="tailscale_funnel_navbar.jinja2")
|
||||
]
|
||||
|
||||
##~~ BlueprintPlugin mixin
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
@@ -38,4 +38,15 @@
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
color: #8a6d3b;
|
||||
}
|
||||
|
||||
/* Navbar Button Farbkennung */
|
||||
#navbar_plugin_tailscale_funnel > a.tsf-enabled {
|
||||
background-color: #5cb85c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#navbar_plugin_tailscale_funnel > a.tsf-disabled {
|
||||
background-color: #777;
|
||||
color: #fff;
|
||||
}
|
@@ -11,6 +11,13 @@ $(function() {
|
||||
self.tailscaleInstalled = ko.observable(true);
|
||||
self.tailscaleRunning = ko.observable(true);
|
||||
|
||||
// Computed URL used across UI and copy handler
|
||||
self.currentUrl = ko.pureComputed(function() {
|
||||
var url = (typeof self.publicUrl === 'function') ? self.publicUrl() : self.publicUrl;
|
||||
if (!url || url === "Not available") return "";
|
||||
return ("" + url).trim();
|
||||
});
|
||||
|
||||
// Button states
|
||||
self.refreshInProgress = ko.observable(false);
|
||||
self.toggleInProgress = ko.observable(false);
|
||||
@@ -126,38 +133,39 @@ $(function() {
|
||||
});
|
||||
};
|
||||
|
||||
// Copy URL to clipboard
|
||||
self.copyUrlToClipboard = function() {
|
||||
if (self.publicUrl() && self.publicUrl() !== "Not available") {
|
||||
navigator.clipboard.writeText(self.publicUrl()).then(function() {
|
||||
// Copy URL to clipboard (robust, event-safe)
|
||||
self.copyUrlToClipboard = function(_, event) {
|
||||
if (event && event.stopPropagation) event.stopPropagation();
|
||||
var url = self.currentUrl();
|
||||
if (!url) return;
|
||||
(navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(url) : Promise.reject()).then(function() {
|
||||
new PNotify({
|
||||
title: "Copied to Clipboard",
|
||||
text: "Public URL copied to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
}, function() {
|
||||
// Fallback for older browsers
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value = url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
new PNotify({
|
||||
title: "Copied to Clipboard",
|
||||
text: "Public URL copied to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
}, function() {
|
||||
// Fallback for older browsers
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value = self.publicUrl();
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
new PNotify({
|
||||
title: "Copied to Clipboard",
|
||||
text: "Public URL copied to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
new PNotify({
|
||||
title: "Copy Failed",
|
||||
text: "Failed to copy URL to clipboard",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
new PNotify({
|
||||
title: "Copy Failed",
|
||||
text: "Failed to copy URL to clipboard",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle messages from the backend
|
||||
|
@@ -0,0 +1,105 @@
|
||||
$(function() {
|
||||
var lastEnabled = null;
|
||||
|
||||
function refreshNavbarStatus() {
|
||||
var $status = $("#tsf_nav_status");
|
||||
var $openLi = $("#tsf_nav_open_li");
|
||||
var $open = $("#tsf_nav_open");
|
||||
var $toggleText = $("#tsf_nav_toggle_text");
|
||||
var $btn = $("#navbar_plugin_tailscale_funnel > a");
|
||||
if ($status.length === 0) return;
|
||||
$status.text("Checking...");
|
||||
$.ajax({
|
||||
url: PLUGIN_BASEURL + "tailscale_funnel/status",
|
||||
type: "GET",
|
||||
dataType: "json",
|
||||
success: function(resp) {
|
||||
if (resp && resp.status === "success") {
|
||||
var enabled = !!(resp.data && resp.data.funnel_enabled);
|
||||
var url = resp.data && resp.data.public_url ? resp.data.public_url : "";
|
||||
$status.text(enabled ? "Enabled" : "Disabled");
|
||||
$toggleText.text(enabled ? "Disable" : "Enable");
|
||||
$btn.toggleClass('tsf-enabled', enabled).toggleClass('tsf-disabled', !enabled);
|
||||
lastEnabled = enabled;
|
||||
if (enabled && url) {
|
||||
$open.attr("href", url);
|
||||
$openLi.removeClass("hidden");
|
||||
} else {
|
||||
$open.attr("href", "#");
|
||||
$openLi.addClass("hidden");
|
||||
}
|
||||
} else {
|
||||
$status.text("Error");
|
||||
$toggleText.text("Enable");
|
||||
$btn.removeClass('tsf-enabled tsf-disabled');
|
||||
$openLi.addClass("hidden");
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$status.text("Error");
|
||||
$toggleText.text("Enable");
|
||||
$btn.removeClass('tsf-enabled tsf-disabled');
|
||||
$openLi.addClass("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh when dropdown opens, and on click of Refresh
|
||||
$(document).on('show.bs.dropdown', '#navbar_plugin_tailscale_funnel', refreshNavbarStatus);
|
||||
$(document).on('click', '#tsf_nav_refresh', function(e) {
|
||||
e.preventDefault();
|
||||
refreshNavbarStatus();
|
||||
});
|
||||
|
||||
$(document).on('click', '#tsf_nav_toggle', function(e) {
|
||||
e.preventDefault();
|
||||
var enable = !(lastEnabled === true);
|
||||
// Optional simpler confirm: nur beim Aktivieren
|
||||
if (enable) {
|
||||
var c = window.confirm("Enabling Funnel will make your OctoPrint instance accessible from the public internet. Continue?");
|
||||
if (!c) return;
|
||||
}
|
||||
var action = enable ? 'enable' : 'disable';
|
||||
var $status = $("#tsf_nav_status");
|
||||
var $toggleText = $("#tsf_nav_toggle_text");
|
||||
var $btn = $("#navbar_plugin_tailscale_funnel > a");
|
||||
$status.text(enable ? 'Enabling...' : 'Disabling...');
|
||||
$.ajax({
|
||||
url: PLUGIN_BASEURL + 'tailscale_funnel/' + action,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
success: function(resp) {
|
||||
if (resp && resp.status === 'success') {
|
||||
if (enable) {
|
||||
$status.text('Enabled');
|
||||
$toggleText.text('Disable');
|
||||
$btn.addClass('tsf-enabled').removeClass('tsf-disabled');
|
||||
var url = resp.data && resp.data.public_url ? resp.data.public_url : '';
|
||||
if (url) {
|
||||
$("#tsf_nav_open").attr('href', url);
|
||||
$("#tsf_nav_open_li").removeClass('hidden');
|
||||
}
|
||||
lastEnabled = true;
|
||||
} else {
|
||||
$status.text('Disabled');
|
||||
$toggleText.text('Enable');
|
||||
$btn.addClass('tsf-disabled').removeClass('tsf-enabled');
|
||||
$("#tsf_nav_open").attr('href', '#');
|
||||
$("#tsf_nav_open_li").addClass('hidden');
|
||||
lastEnabled = false;
|
||||
}
|
||||
// Finaler Abgleich mit Backendstatus
|
||||
setTimeout(refreshNavbarStatus, 250);
|
||||
} else {
|
||||
$status.text('Error');
|
||||
$btn.removeClass('tsf-enabled tsf-disabled');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$status.text('Error');
|
||||
$btn.removeClass('tsf-enabled tsf-disabled');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -2,6 +2,7 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import json
|
||||
import re
|
||||
|
||||
@@ -201,22 +202,49 @@ class TailscaleInterface:
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
# Try new CLI first, then legacy
|
||||
cmds = [
|
||||
# Newer: turn off serve mapping and remove funnel config
|
||||
"tailscale serve --http={p} off".format(p=port),
|
||||
# Execute multiple disable attempts tolerantly (idempotent)
|
||||
commands = [
|
||||
# Try to disable funnel first (new + legacy)
|
||||
"tailscale funnel reset",
|
||||
# Legacy
|
||||
"tailscale funnel {p} off".format(p=port),
|
||||
# Then disable serve mappings (newer variants)
|
||||
"tailscale serve --http={p} off".format(p=port),
|
||||
"tailscale serve --https={p} off".format(p=port),
|
||||
]
|
||||
result = self._run_first_success(cmds)
|
||||
if result["success"]:
|
||||
# Double-check disabled
|
||||
|
||||
any_succeeded = False
|
||||
for cmd in commands:
|
||||
res = self._run_first_success([cmd])
|
||||
if res and res.get("success"):
|
||||
any_succeeded = True
|
||||
else:
|
||||
# Treat "not enabled"/"no such" errors as harmless for idempotency
|
||||
err = (res or {}).get("error") or ""
|
||||
if isinstance(err, str) and (
|
||||
"not enabled" in err.lower()
|
||||
or "no handlers" in err.lower()
|
||||
or "no such" in err.lower()
|
||||
or "not found" in err.lower()
|
||||
or "already off" in err.lower()
|
||||
):
|
||||
any_succeeded = True
|
||||
|
||||
# Poll for a short time until status reflects disabled
|
||||
for _ in range(6): # ~3.0s total
|
||||
try:
|
||||
return not self.is_funnel_enabled()
|
||||
if not self.is_funnel_enabled():
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
# If status check fails, assume disabled if we attempted commands
|
||||
if any_succeeded:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
|
||||
# Final status check
|
||||
try:
|
||||
return not self.is_funnel_enabled()
|
||||
except Exception:
|
||||
return any_succeeded
|
||||
|
||||
def get_public_url(self):
|
||||
"""
|
||||
|
@@ -0,0 +1,14 @@
|
||||
<li id="navbar_plugin_tailscale_funnel" class="dropdown">
|
||||
<a class="dropdown-toggle" href="#" data-toggle="dropdown" title="Tailscale Funnel">
|
||||
<i class="fas fa-share-square"></i>
|
||||
<span class="visible-lg">Funnel</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="disabled"><a href="#"><strong>Status:</strong> <span id="tsf_nav_status">Checking...</span></a></li>
|
||||
<li id="tsf_nav_open_li" class="hidden"><a id="tsf_nav_open" href="#" target="_blank" rel="noopener"><i class="fas fa-external-link-alt"></i> Open</a></li>
|
||||
<li><a id="tsf_nav_toggle" href="#"><i class="fas fa-toggle-on"></i> <span id="tsf_nav_toggle_text">Enable</span></a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a id="tsf_nav_refresh" href="#"><i class="fas fa-sync"></i> Refresh</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
@@ -34,9 +34,9 @@
|
||||
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
|
||||
Not available
|
||||
</span>
|
||||
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button" data-bind="click: copyUrlToClipboard, enable: publicUrl() && publicUrl() !== 'Not available'">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<a id="tailscale_funnel_open_url_btn" class="btn" target="_blank" rel="noopener" data-bind="attr: { href: currentUrl }, visible: currentUrl()">
|
||||
<i class="fas fa-external-link-alt"></i> Open
|
||||
</a>
|
||||
</div>
|
||||
<span class="help-block">Public URL for accessing your OctoPrint instance</span>
|
||||
</div>
|
||||
|
@@ -1 +1,2 @@
|
||||
# Runtime dependency for installation via OctoPrint's Plugin Manager
|
||||
OctoPrint>=1.3.0
|
@@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel"
|
||||
plugin_name = "OctoPrint-Tailscale-Funnel"
|
||||
|
||||
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
|
||||
plugin_version = "0.1.3"
|
||||
plugin_version = "0.1.6.3"
|
||||
|
||||
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
|
||||
# module
|
||||
@@ -88,7 +88,18 @@ setup_parameters = octoprint_setuptools.create_plugin_setup_parameters(
|
||||
)
|
||||
|
||||
if len(additional_setup_parameters):
|
||||
from octoprint.util import dict_merge
|
||||
try:
|
||||
from octoprint.util import dict_merge
|
||||
except Exception:
|
||||
# Fallback to allow building without the octoprint package installed
|
||||
def dict_merge(a, b):
|
||||
result = dict(a)
|
||||
for k, v in b.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = dict_merge(result[k], v)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
setup_parameters = dict_merge(setup_parameters, additional_setup_parameters)
|
||||
|
||||
setup(**setup_parameters)
|
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "0.1.3"
|
||||
"version": "0.1.6.3"
|
||||
}
|
||||
|
||||
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
setuptools>=68
|
||||
wheel>=0.41
|
||||
build>=1.0
|
||||
packaging>=23
|
||||
# Kompatible verfügbare Version laut Index
|
||||
octoprint_setuptools==1.0.3
|
||||
|
147
scripts/build_plugin.sh
Executable file
147
scripts/build_plugin.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/build_plugin.sh [VERSION]
|
||||
# - Läuft im Projekt-.venv
|
||||
# - Optional: VERSION (z.B. 0.1.6.4). Wenn gesetzt, wird setup.py gepatcht.
|
||||
# - Baut wheel + sdist und erstellt zusätzlich ein "normales" ZIP der sdist-Struktur.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
|
||||
PLUGIN_DIR="$ROOT_DIR/octoprint_tailscale_funnel"
|
||||
DIST_DIR="$PLUGIN_DIR/dist"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
|
||||
VERSION_INPUT="${1:-}"
|
||||
|
||||
echo "Project root: $ROOT_DIR"
|
||||
echo "Plugin dir: $PLUGIN_DIR"
|
||||
echo "Venv dir: $VENV_DIR"
|
||||
|
||||
# Ensure venv
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "Creating venv at $VENV_DIR"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if [[ -f "$ROOT_DIR/requirements.txt" ]]; then
|
||||
"$VENV_DIR/bin/pip" install -q --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install -q -r "$ROOT_DIR/requirements.txt"
|
||||
else
|
||||
"$VENV_DIR/bin/pip" install -q --upgrade pip setuptools wheel build packaging octoprint_setuptools
|
||||
fi
|
||||
|
||||
# Optional: bump version
|
||||
if [[ -n "$VERSION_INPUT" ]]; then
|
||||
echo "Setting version to $VERSION_INPUT"
|
||||
"$VENV_DIR/bin/python" - "$VERSION_INPUT" "$PLUGIN_DIR/setup.py" <<'PY'
|
||||
import sys, re, pathlib
|
||||
ver = sys.argv[1]
|
||||
setup_path = pathlib.Path(sys.argv[2])
|
||||
text = setup_path.read_text()
|
||||
new_text = re.sub(r'^(plugin_version\s*=\s*")([^"]*)(")', lambda m: m.group(1) + ver + m.group(3), text, flags=re.M)
|
||||
setup_path.write_text(new_text)
|
||||
print("Updated version to", ver)
|
||||
PY
|
||||
fi
|
||||
|
||||
# Clean dist
|
||||
mkdir -p "$DIST_DIR"
|
||||
rm -f "$DIST_DIR"/*
|
||||
|
||||
# Build wheel + sdist
|
||||
echo "Building (wheel + sdist)..."
|
||||
"$VENV_DIR/bin/python" -m build --no-isolation "$PLUGIN_DIR"
|
||||
|
||||
# Create additional plain ZIP from sdist content (flat source zip)
|
||||
SDIST_TGZ=$(ls -1 "$DIST_DIR"/*.tar.gz | tail -n1 || true)
|
||||
if [[ -n "$SDIST_TGZ" ]]; then
|
||||
echo "Creating plain ZIP from sdist: $SDIST_TGZ"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
tar -xzf "$SDIST_TGZ" -C "$TMP_DIR"
|
||||
SRC_DIR="$(find "$TMP_DIR" -maxdepth 1 -type d -name 'octoprint_tailscale_funnel-*' | head -n1)"
|
||||
if [[ -n "$SRC_DIR" ]]; then
|
||||
ZIP_NAME="$(basename "$SRC_DIR").zip"
|
||||
(cd "$SRC_DIR" && zip -rq "$DIST_DIR/$ZIP_NAME" .)
|
||||
echo "Created: $DIST_DIR/$ZIP_NAME"
|
||||
else
|
||||
echo "WARN: Could not find extracted sdist directory to zip"
|
||||
fi
|
||||
rm -rf "$TMP_DIR"
|
||||
else
|
||||
echo "WARN: No sdist .tar.gz found, skipping plain ZIP creation"
|
||||
fi
|
||||
|
||||
echo "Artifacts in $DIST_DIR:"
|
||||
ls -lah "$DIST_DIR"
|
||||
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/build_plugin.sh [VERSION]
|
||||
# - Läuft im Projekt-.venv
|
||||
# - Optional: VERSION (z.B. 0.1.6.4). Wenn gesetzt, wird setup.py gepatcht.
|
||||
# - Baut wheel + sdist und erstellt zusätzlich ein "normales" ZIP der sdist-Struktur.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
|
||||
PLUGIN_DIR="$ROOT_DIR/octoprint_tailscale_funnel"
|
||||
DIST_DIR="$PLUGIN_DIR/dist"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
|
||||
VERSION_INPUT="${1:-}"
|
||||
|
||||
echo "Project root: $ROOT_DIR"
|
||||
echo "Plugin dir: $PLUGIN_DIR"
|
||||
echo "Venv dir: $VENV_DIR"
|
||||
|
||||
# Ensure venv
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "Creating venv at $VENV_DIR"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
"$VENV_DIR/bin/pip" install -q --upgrade pip setuptools wheel build packaging octoprint_setuptools
|
||||
|
||||
# Optional: bump version
|
||||
if [[ -n "$VERSION_INPUT" ]]; then
|
||||
echo "Setting version to $VERSION_INPUT"
|
||||
"$VENV_DIR/bin/python" - "$VERSION_INPUT" "$PLUGIN_DIR/setup.py" <<'PY'
|
||||
import sys, re, pathlib
|
||||
ver = sys.argv[1]
|
||||
setup_path = pathlib.Path(sys.argv[2])
|
||||
text = setup_path.read_text()
|
||||
new_text = re.sub(r'^(plugin_version\s*=\s*")([^"]*)(")', lambda m: m.group(1) + ver + m.group(3), text, flags=re.M)
|
||||
setup_path.write_text(new_text)
|
||||
print("Updated version to", ver)
|
||||
PY
|
||||
fi
|
||||
|
||||
# Clean dist
|
||||
mkdir -p "$DIST_DIR"
|
||||
rm -f "$DIST_DIR"/*
|
||||
|
||||
# Build wheel + sdist
|
||||
echo "Building (wheel + sdist)..."
|
||||
"$VENV_DIR/bin/python" -m build --no-isolation "$PLUGIN_DIR"
|
||||
|
||||
# Create additional plain ZIP from sdist content (flat source zip)
|
||||
SDIST_TGZ=$(ls -1 "$DIST_DIR"/*.tar.gz | tail -n1 || true)
|
||||
if [[ -n "$SDIST_TGZ" ]]; then
|
||||
echo "Creating plain ZIP from sdist: $SDIST_TGZ"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
tar -xzf "$SDIST_TGZ" -C "$TMP_DIR"
|
||||
SRC_DIR="$(find "$TMP_DIR" -maxdepth 1 -type d -name 'octoprint_tailscale_funnel-*' | head -n1)"
|
||||
if [[ -n "$SRC_DIR" ]]; then
|
||||
ZIP_NAME="$(basename "$SRC_DIR").zip"
|
||||
(cd "$SRC_DIR" && zip -rq "$DIST_DIR/$ZIP_NAME" .)
|
||||
echo "Created: $DIST_DIR/$ZIP_NAME"
|
||||
else
|
||||
echo "WARN: Could not find extracted sdist directory to zip"
|
||||
fi
|
||||
rm -rf "$TMP_DIR"
|
||||
else
|
||||
echo "WARN: No sdist .tar.gz found, skipping plain ZIP creation"
|
||||
fi
|
||||
|
||||
echo "Artifacts in $DIST_DIR:"
|
||||
ls -lah "$DIST_DIR"
|
||||
|
224
scripts/release_gitea.sh
Executable file
224
scripts/release_gitea.sh
Executable file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/release_gitea.sh <version> [--draft] [--prerelease]
|
||||
# Env: liest automatisch $ROOT_DIR/.env (GITEA_TOKEN, GITEA_BASE, OWNER, REPO)
|
||||
# Falls nicht gesetzt, werden GITEA_BASE/OWNER/REPO aus der Git-Remote-URL abgeleitet.
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <version> [--draft] [--prerelease]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
shift || true
|
||||
DRAFT=false
|
||||
PRERELEASE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--draft) DRAFT=true ;;
|
||||
--prerelease) PRERELEASE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
|
||||
|
||||
# Load .env if present
|
||||
if [[ -f "$ROOT_DIR/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Map alternative token variable name
|
||||
if [[ -z "${GITEA_TOKEN:-}" && -n "${GITEA_API_TOKEN:-}" ]]; then
|
||||
GITEA_TOKEN="$GITEA_API_TOKEN"
|
||||
fi
|
||||
|
||||
# Map API URL to base if provided
|
||||
if [[ -z "${GITEA_BASE:-}" && -n "${GITEA_API_URL:-}" ]]; then
|
||||
# strip trailing /api/... from URL
|
||||
GITEA_BASE="${GITEA_API_URL%%/api/*}"
|
||||
fi
|
||||
|
||||
# Map owner/repo alternative names
|
||||
if [[ -z "${OWNER:-}" && -n "${GITEA_OWNER:-}" ]]; then
|
||||
OWNER="$GITEA_OWNER"
|
||||
fi
|
||||
if [[ -z "${REPO:-}" && -n "${GITEA_REPO:-}" ]]; then
|
||||
REPO="$GITEA_REPO"
|
||||
fi
|
||||
|
||||
# Derive defaults from git remote if not provided
|
||||
if [[ -z "${GITEA_BASE:-}" || -z "${OWNER:-}" || -z "${REPO:-}" ]]; then
|
||||
ORIGIN_URL=$(git -C "$ROOT_DIR" remote get-url origin 2>/dev/null || true)
|
||||
if [[ "$ORIGIN_URL" =~ ^https?://([^/]+)/([^/]+)/([^/]+?)(\.git)?$ ]]; then
|
||||
: "${GITEA_BASE:="https://${BASH_REMATCH[1]}"}"
|
||||
: "${OWNER:=${BASH_REMATCH[2]}}"
|
||||
: "${REPO:=${BASH_REMATCH[3]}}"
|
||||
fi
|
||||
fi
|
||||
|
||||
: "${GITEA_TOKEN:?Set GITEA_TOKEN (in .env oder Umgebung)}"
|
||||
: "${GITEA_BASE:?Set GITEA_BASE (z. B. https://gitea.elpatron.me)}"
|
||||
: "${OWNER:?Set OWNER (z. B. elpatron)}"
|
||||
: "${REPO:?Set REPO (z. B. octo-funnel)}"
|
||||
DIST_DIR="$ROOT_DIR/octoprint_tailscale_funnel/dist"
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
echo "Creating git tag ${TAG} and pushing..."
|
||||
git tag -f "${TAG}"
|
||||
git push -f origin "${TAG}"
|
||||
|
||||
echo "Creating Gitea release ${TAG}..."
|
||||
BODY=$(cat <<EOF
|
||||
Release ${TAG}
|
||||
|
||||
Changes:
|
||||
- Navbar: Statusanzeige, Toggle, Farbkennung
|
||||
- Build-Skript & Quick-Build Docs
|
||||
- Version ${VERSION}
|
||||
EOF
|
||||
)
|
||||
|
||||
CREATE_PAYLOAD=$(jq -n \
|
||||
--arg tag_name "${TAG}" \
|
||||
--arg name "${TAG}" \
|
||||
--arg body "${BODY}" \
|
||||
--argjson draft ${DRAFT} \
|
||||
--argjson prerelease ${PRERELEASE} \
|
||||
'{tag_name:$tag_name, name:$name, body:$body, draft:$draft, prerelease:$prerelease}')
|
||||
|
||||
RELEASE_JSON=$(curl -sS -X POST "${GITEA_BASE}/api/v1/repos/${OWNER}/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "${CREATE_PAYLOAD}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r .upload_url)
|
||||
ID=$(echo "$RELEASE_JSON" | jq -r .id)
|
||||
|
||||
if [[ -z "$ID" || "$ID" == "null" ]]; then
|
||||
echo "Failed to create release: $RELEASE_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release created: ID=$ID"
|
||||
echo "Using: GITEA_BASE=$GITEA_BASE OWNER=$OWNER REPO=$REPO"
|
||||
|
||||
function upload_asset() {
|
||||
local file="$1"
|
||||
local name
|
||||
name=$(basename "$file")
|
||||
echo "Uploading asset: $name"
|
||||
curl -sS -X POST "${GITEA_BASE}/api/v1/repos/${OWNER}/${REPO}/releases/${ID}/assets?name=${name}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H 'Content-Type: application/octet-stream' \
|
||||
--data-binary @"$file" > /dev/null
|
||||
}
|
||||
|
||||
upload_asset "$DIST_DIR/octoprint_tailscale_funnel-${VERSION}-py3-none-any.whl"
|
||||
upload_asset "$DIST_DIR/octoprint_tailscale_funnel-${VERSION}.tar.gz"
|
||||
upload_asset "$DIST_DIR/octoprint_tailscale_funnel-${VERSION}.zip"
|
||||
|
||||
echo "Release ${TAG} created and assets uploaded."
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load .env if present
|
||||
if [ -f "$(dirname "$0")/../.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$(dirname "$0")/../.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
API_URL=${GITEA_API_URL:-"https://gitea.elpatron.me/api/v1"}
|
||||
OWNER=${GITEA_OWNER:-"elpatron"}
|
||||
REPO=${GITEA_REPO:-"octo-funnel"}
|
||||
TOKEN=${GITEA_API_TOKEN:-""}
|
||||
|
||||
TAG=""
|
||||
NAME=""
|
||||
ASSET_PATH=""
|
||||
BODY_FILE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -t <tag> -a <asset-zip> [-n <name>] [-b <body-file>]" >&2
|
||||
}
|
||||
|
||||
while getopts ":t:a:n:b:" opt; do
|
||||
case $opt in
|
||||
t) TAG="$OPTARG" ;;
|
||||
a) ASSET_PATH="$OPTARG" ;;
|
||||
n) NAME="$OPTARG" ;;
|
||||
b) BODY_FILE="$OPTARG" ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TAG" ] || [ -z "$ASSET_PATH" ]; then
|
||||
usage; exit 2
|
||||
fi
|
||||
if [ -z "$NAME" ]; then NAME="$TAG"; fi
|
||||
if [ ! -f "$ASSET_PATH" ]; then
|
||||
echo "Asset not found: $ASSET_PATH" >&2; exit 1
|
||||
fi
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "GITEA_API_TOKEN not set (in .env)." >&2; exit 1
|
||||
fi
|
||||
|
||||
# Derive asset name early for later use
|
||||
ASSET_NAME="$(basename "$ASSET_PATH")"
|
||||
|
||||
BODY="Tailscale Funnel Plugin ${TAG}\n\nAutomated release."
|
||||
if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then
|
||||
BODY=$(cat "$BODY_FILE")
|
||||
fi
|
||||
|
||||
# Try to fetch existing release by tag first
|
||||
get_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" || true)
|
||||
rel_id=$(echo "$get_resp" | jq -r '.id // empty')
|
||||
|
||||
if [ -z "$rel_id" ]; then
|
||||
# Build minimal JSON payload (use existing tag)
|
||||
create_payload=$(jq -n --arg tag "$TAG" --arg name "$NAME" --arg body "$BODY" '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')
|
||||
|
||||
create_resp=$(curl -sS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d "$create_payload" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases" || true)
|
||||
|
||||
# Extract id from create response
|
||||
rel_id=$(echo "$create_resp" | jq -r '.id // empty')
|
||||
fi
|
||||
|
||||
# Fallback: search releases list for matching tag if still empty
|
||||
if [ -z "$rel_id" ]; then
|
||||
list_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases?limit=100")
|
||||
rel_id=$(echo "$list_resp" | jq -r --arg tag "$TAG" '[.[] | select(.tag_name==$tag)][0].id // empty')
|
||||
fi
|
||||
|
||||
if [ -z "$rel_id" ]; then
|
||||
echo "Failed to get create/fetch release id" >&2
|
||||
echo "$create_resp" | head -c 400 >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if asset exists (skip upload if present)
|
||||
assets_json=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets")
|
||||
asset_exists=$(echo "$assets_json" | jq -r --arg name "$ASSET_NAME" 'any(.[]; .name==$name)')
|
||||
|
||||
if [ "$asset_exists" = "true" ]; then
|
||||
echo "Asset already exists, skipping upload: ${ASSET_NAME}"
|
||||
else
|
||||
echo "Uploading asset: ${ASSET_NAME}"
|
||||
upload_resp=$(curl -sS -H "Authorization: token ${TOKEN}" -F attachment=@"${ASSET_PATH}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets?name=${ASSET_NAME}")
|
||||
html_url=$(echo "$upload_resp" | jq -r '.browser_download_url // empty')
|
||||
echo "Asset uploaded: ${html_url}"
|
||||
fi
|
||||
|
||||
echo "Release ${TAG} ready (id=${rel_id})."
|
||||
|
Reference in New Issue
Block a user