summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 17:07:31 +0900
committerAdam Malczewski <[email protected]>2026-05-22 17:07:31 +0900
commit288b21cec98421fda57028a0c8c9d835cfbb14b0 (patch)
tree9ce3ec38acdcf7be96e28f1e0d5deccf6b46c917
parent45a4890031192f4e7409443f98e824dad17ba175 (diff)
downloaddispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.tar.gz
dispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.zip
feat: add/remove keys from UI, backend URL setting, user service, Docker fix
- Add POST /models/add-key and POST /models/remove-key API endpoints - Add 'Add New Key' modal (page-level) with provider selection - Add remove button per key in Model Status view - Add configurable backend URL setting in Settings panel with localStorage persistence - Convert systemd service from system to user service (systemctl --user) - Fix Docker entrypoint to chown all nested node_modules dirs - Update dispatch.toml credential paths to use -pro/-max naming - Make API port configurable via PORT env var (default 3000, prod 18390)
-rw-r--r--dispatch.toml4
-rw-r--r--docker/entrypoint.dev.sh6
-rw-r--r--packages/api/src/routes/models.ts124
-rw-r--r--packages/frontend/src/App.svelte80
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte49
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte46
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte4
-rw-r--r--packages/frontend/src/lib/config.ts36
-rw-r--r--packaging/PKGBUILD8
-rw-r--r--packaging/dispatch-api.service20
-rw-r--r--packaging/dispatch.install4
11 files changed, 342 insertions, 39 deletions
diff --git a/dispatch.toml b/dispatch.toml
index 4332462..72ff9bb 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -8,13 +8,13 @@
id = "claude-pro"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-credentials_file = "/home/tradam/.claude/.credentials-1.json"
+credentials_file = "/home/tradam/.claude/.credentials-pro.json"
[[keys]]
id = "claude-max"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-credentials_file = "/home/tradam/.claude/.credentials-2.json"
+credentials_file = "/home/tradam/.claude/.credentials-max.json"
[[keys]]
id = "opencode-1"
diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh
index bbde09a..dd0d423 100644
--- a/docker/entrypoint.dev.sh
+++ b/docker/entrypoint.dev.sh
@@ -35,10 +35,8 @@ if [ -d "$USER_HOME/.claude" ]; then
chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true
fi
-# Ensure node_modules is writable (created as root during build)
-if [ -d /app/node_modules ]; then
- chown -R "$HOST_UID:$HOST_GID" /app/node_modules
-fi
+# Ensure all node_modules are writable (created as root during build)
+find /app -name node_modules -type d -maxdepth 3 -exec chown -R "$HOST_UID:$HOST_GID" {} + 2>/dev/null || true
# Install/update dependencies as the target user (skip with SKIP_INSTALL=1)
if [ "${SKIP_INSTALL:-}" != "1" ]; then
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 1daf37e..d6b82c2 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -1,3 +1,5 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
import type { ModelRegistry } from "@dispatch/core";
import {
ANTHROPIC_MODELS_FALLBACK,
@@ -396,6 +398,128 @@ modelsRoutes.get("/credentials-status", (c) => {
return c.json({ credentials: status });
});
+// ─── Add key to dispatch.toml ─────────────────────────────────
+
+const VALID_PROVIDERS = ["anthropic", "opencode-go", "github-copilot"] as const;
+type SupportedProvider = (typeof VALID_PROVIDERS)[number];
+
+const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = {
+ anthropic: "https://api.anthropic.com/v1",
+ "opencode-go": "https://opencode.ai/zen/go/v1",
+ "github-copilot": "https://api.githubcopilot.com",
+};
+
+modelsRoutes.post("/add-key", async (c) => {
+ const body = await c.req.json<{ id?: unknown; provider?: unknown }>();
+
+ // Validate id
+ if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) {
+ return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400);
+ }
+ const id = body.id.trim();
+
+ // Validate provider
+ if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) {
+ return c.json(
+ { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` },
+ 400,
+ );
+ }
+ const provider = body.provider as SupportedProvider;
+ const base_url = PROVIDER_BASE_URLS[provider];
+
+ // Read current dispatch.toml
+ const tomlPath = `${process.cwd()}/dispatch.toml`;
+ let tomlContent: string;
+ try {
+ tomlContent = readFileSync(tomlPath, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ // Check for duplicate key id
+ const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m");
+ if (idPattern.test(tomlContent)) {
+ return c.json({ error: `key with id "${id}" already exists` }, 409);
+ }
+
+ // Build the new [[keys]] block
+ let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`;
+ if (provider === "anthropic") {
+ const credPath = `${homedir()}/.claude/.credentials-${id}.json`;
+ newBlock += `\ncredentials_file = "${credPath}"`;
+ }
+ newBlock += "\n";
+
+ // Insert before the # ─── Permissions section if it exists, otherwise at end
+ const permissionsMarker = /\n# [─\-]+ Permissions/;
+ let newContent: string;
+ const permMatch = permissionsMarker.exec(tomlContent);
+ if (permMatch) {
+ const insertAt = permMatch.index;
+ newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt);
+ } else {
+ newContent = tomlContent + newBlock;
+ }
+
+ try {
+ writeFileSync(tomlPath, newContent, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ const key: { id: string; provider: string; base_url: string; credentials_file?: string } = {
+ id,
+ provider,
+ base_url,
+ };
+ if (provider === "anthropic") {
+ key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`;
+ }
+
+ return c.json({ success: true, key });
+});
+
+// ─── Remove key from dispatch.toml ────────────────────────────
+
+modelsRoutes.post("/remove-key", async (c) => {
+ const body = await c.req.json<{ id?: unknown }>();
+
+ if (typeof body.id !== "string" || !body.id.trim()) {
+ return c.json({ error: "id is required" }, 400);
+ }
+ const id = body.id.trim();
+
+ const tomlPath = `${process.cwd()}/dispatch.toml`;
+ let tomlContent: string;
+ try {
+ tomlContent = readFileSync(tomlPath, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ // Match the [[keys]] block containing this id and remove it.
+ // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF.
+ const blockPattern = new RegExp(
+ `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`,
+ "s",
+ );
+ const match = blockPattern.exec(tomlContent);
+ if (!match) {
+ return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404);
+ }
+
+ const newContent = tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length);
+
+ try {
+ writeFileSync(tomlPath, newContent, "utf-8");
+ } catch (err) {
+ return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
+ }
+
+ return c.json({ success: true });
+});
+
// ─── Shared wake function ─────────────────────────────────────
async function wakeAllClaudeAccounts(): Promise<
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 33415bd..d96cb63 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -22,6 +22,40 @@ let modelsData = $state<{ keys: KeyInfo[] }>({
let sidebarOpen = $state(true);
+// Add Key modal state (rendered at page level to escape sidebar transform)
+let showAddKeyModal = $state(false);
+let addKeyProvider = $state("anthropic");
+let addKeyId = $state("");
+let addKeyError = $state<string | null>(null);
+let addKeySaving = $state(false);
+
+async function addNewKey(): Promise<void> {
+ if (!addKeyId.trim()) return;
+ addKeySaving = true;
+ addKeyError = null;
+ try {
+ const res = await fetch(`${config.apiBase}/models/add-key`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }),
+ });
+ const data = (await res.json()) as { success?: boolean; error?: string };
+ if (!res.ok || !data.success) {
+ addKeyError = data.error ?? "Failed to add key";
+ } else {
+ showAddKeyModal = false;
+ addKeyId = "";
+ addKeyProvider = "anthropic";
+ await new Promise((r) => setTimeout(r, 500));
+ window.location.reload();
+ }
+ } catch (e) {
+ addKeyError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ addKeySaving = false;
+ }
+}
+
async function fetchModels() {
try {
const res = await fetch(`${config.apiBase}/models`);
@@ -112,6 +146,7 @@ onMount(() => {
}}
onAgentChange={(agent) => tabStore.setAgent(agent)}
onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
+ onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
/>
</div>
</div>
@@ -144,3 +179,48 @@ onMount(() => {
<div class="fixed top-4 right-4 z-50">
<HotReloadIndicator active={tabStore.configReloaded} />
</div>
+
+<!-- Add New Key Modal (page-level to escape sidebar transform) -->
+{#if showAddKeyModal}
+ <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
+ <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
+ <h3 class="text-sm font-semibold">Add New Key</h3>
+ <div class="flex flex-col gap-1">
+ <label class="text-xs text-base-content/60">Provider</label>
+ <select class="select select-bordered select-sm w-full" bind:value={addKeyProvider}>
+ <option value="anthropic">Anthropic</option>
+ <option value="opencode-go">OpenCode</option>
+ <option value="github-copilot">GitHub Copilot</option>
+ </select>
+ </div>
+ <div class="flex flex-col gap-1">
+ <label class="text-xs text-base-content/60">Key ID</label>
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="e.g. claude-max, copilot-2"
+ bind:value={addKeyId}
+ />
+ <p class="text-xs text-base-content/40">Unique identifier for this key</p>
+ </div>
+ {#if addKeyError}
+ <p class="text-xs text-error">{addKeyError}</p>
+ {/if}
+ <div class="flex justify-end gap-2">
+ <button type="button" class="btn btn-sm btn-ghost"
+ onclick={() => { showAddKeyModal = false; }}>
+ Cancel
+ </button>
+ <button type="button" class="btn btn-sm btn-primary"
+ disabled={!addKeyId.trim() || addKeySaving}
+ onclick={addNewKey}>
+ {#if addKeySaving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Add Key
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
index 1270fcc..d6ff0f5 100644
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ b/packages/frontend/src/lib/components/ModelStatus.svelte
@@ -20,10 +20,12 @@ const {
keys = [],
currentModel,
apiBase = "",
+ onAddKey = () => {},
}: {
keys?: KeyInfo[];
currentModel?: string;
apiBase?: string;
+ onAddKey?: () => void;
} = $props();
const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
@@ -44,6 +46,9 @@ let showKeyModal = $state<string | null>(null); // keyId or null
let keyModalValue = $state("");
let keyModalError = $state<string | null>(null);
let keyModalSaving = $state(false);
+let removingKey = $state<string | null>(null);
+
+
async function loadCredentialStatus(): Promise<void> {
try {
@@ -129,6 +134,27 @@ async function saveApiKey(): Promise<void> {
}
}
+async function removeKey(keyId: string): Promise<void> {
+ if (!confirm(`Remove key "${keyId}" from config?`)) return;
+ removingKey = keyId;
+ try {
+ const res = await fetch(`${apiBase}/models/remove-key`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: keyId }),
+ });
+ const data = (await res.json()) as { success?: boolean; error?: string };
+ if (res.ok && data.success) {
+ await new Promise((r) => setTimeout(r, 500));
+ window.location.reload();
+ }
+ } catch {
+ // ignore
+ } finally {
+ removingKey = null;
+ }
+}
+
$effect(() => {
void loadCredentialStatus();
void loadApiKeyStatus();
@@ -209,8 +235,21 @@ function truncate(str: string | null, max: number): string {
>
{key.status}
</span>
- <span class="text-xs font-mono">{key.id}</span>
+ <span class="text-xs font-mono flex-1">{key.id}</span>
<span class="text-xs text-base-content/40">{key.provider}</span>
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-square text-base-content/30 hover:text-error"
+ disabled={removingKey === key.id}
+ onclick={() => removeKey(key.id)}
+ title="Remove key"
+ >
+ {#if removingKey === key.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
</div>
{#if key.status === "exhausted"}
<div class="pl-2 flex flex-col gap-0.5">
@@ -270,6 +309,13 @@ function truncate(str: string | null, max: number): string {
</div>
{/if}
{/if}
+ <button
+ type="button"
+ class="btn btn-sm btn-primary btn-outline w-full mt-2"
+ onclick={onAddKey}
+ >
+ + Add New Key
+ </button>
<!-- Import Key Modal -->
{#if showKeyModal}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
@@ -308,4 +354,5 @@ function truncate(str: string | null, max: number): string {
</div>
</div>
{/if}
+
</div>
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 79574d2..c19fe45 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { config } from "../config.js";
import { appSettings } from "../settings.svelte.js";
import type { KeyInfo } from "../types.js";
@@ -15,6 +16,24 @@ let titleModelId = $state<string | null>(null);
let availableModels = $state<string[]>([]);
let loadingModels = $state(false);
let autoExpandThinking = $state(appSettings.autoExpandThinking);
+let backendUrl = $state(config.apiBase);
+let backendUrlSaved = $state(false);
+
+function saveBackendUrl(): void {
+ const trimmed = backendUrl.trim().replace(/\/+$/, "");
+ if (!trimmed) return;
+ config.setApiBase(trimmed);
+ backendUrl = trimmed;
+ backendUrlSaved = true;
+ setTimeout(() => { backendUrlSaved = false; }, 2000);
+}
+
+function resetBackendUrl(): void {
+ config.setApiBase(config.defaultApiBase);
+ backendUrl = config.defaultApiBase;
+ backendUrlSaved = true;
+ setTimeout(() => { backendUrlSaved = false; }, 2000);
+}
async function loadSettings(): Promise<void> {
try {
@@ -143,5 +162,32 @@ $effect(() => {
/>
<span class="text-xs text-base-content/70">Auto-expand thinking</span>
</label>
+
+ <div class="divider my-0"></div>
+
+ <p class="text-xs text-base-content/70">Backend URL</p>
+ <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p>
+ <div class="flex gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-sm flex-1"
+ bind:value={backendUrl}
+ placeholder={config.defaultApiBase}
+ />
+ <button type="button" class="btn btn-sm btn-primary" onclick={saveBackendUrl}>
+ Save
+ </button>
+ </div>
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-outline w-full"
+ disabled={config.apiBase === config.defaultApiBase}
+ onclick={resetBackendUrl}
+ >
+ Reset to default
+ </button>
+ {#if backendUrlSaved}
+ <p class="text-xs text-success">Saved. Reload the page to apply.</p>
+ {/if}
</div>
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 041679a..e89e351 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -25,6 +25,7 @@ const {
onReasoningChange,
onAgentChange = (_agent: any) => {},
onWorkingDirectoryChange = (_dir: string | null) => {},
+ onAddKey = () => {},
}: {
keys?: KeyInfo[];
tasks?: TaskItem[];
@@ -40,6 +41,7 @@ const {
onReasoningChange: (effort: string) => void;
onAgentChange?: (agent: any) => void;
onWorkingDirectoryChange?: (dir: string | null) => void;
+ onAddKey?: () => void;
} = $props();
interface Panel {
@@ -129,7 +131,7 @@ function contentClass(selected: string): string {
{:else if panel.selected === "Claude Reset"}
<ClaudeReset {apiBase} />
{:else if panel.selected === "Model Status"}
- <ModelStatus {keys} {apiBase} />
+ <ModelStatus {keys} {apiBase} {onAddKey} />
{:else if panel.selected === "Tasks"}
<TaskListPanel {tasks} />
{:else if panel.selected === "Config"}
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts
index c22746c..c8f4d9f 100644
--- a/packages/frontend/src/lib/config.ts
+++ b/packages/frontend/src/lib/config.ts
@@ -1,6 +1,34 @@
-const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+const STORAGE_KEY = "dispatch-api-url";
+const DEFAULT_API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+
+function loadApiBase(): string {
+ if (typeof localStorage !== "undefined") {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) return saved;
+ }
+ return DEFAULT_API_BASE;
+}
+
+let _apiBase = loadApiBase();
export const config = {
- apiBase: API_BASE,
- wsUrl: `${API_BASE.replace(/^http/, "ws")}/ws`,
-} as const;
+ get apiBase() {
+ return _apiBase;
+ },
+ get wsUrl() {
+ return `${_apiBase.replace(/^http/, "ws")}/ws`;
+ },
+ get defaultApiBase() {
+ return DEFAULT_API_BASE;
+ },
+ setApiBase(url: string) {
+ _apiBase = url;
+ if (typeof localStorage !== "undefined") {
+ if (url === DEFAULT_API_BASE) {
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, url);
+ }
+ }
+ },
+};
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD
index 57e7e28..de49d4e 100644
--- a/packaging/PKGBUILD
+++ b/packaging/PKGBUILD
@@ -91,10 +91,10 @@ package() {
install -Dm644 dispatch.toml "${optdir}/dispatch.toml"
fi
- # --- systemd service ---
+ # --- systemd user service ---
install -Dm644 \
"${srcdir}/dispatch-api.service" \
- "${pkgdir}/usr/lib/systemd/system/dispatch-api.service"
+ "${pkgdir}/usr/lib/systemd/user/dispatch-api.service"
# --- Environment config (preserved across upgrades via backup=()) ---
install -Dm644 \
@@ -119,10 +119,6 @@ package() {
"${srcdir}/dispatch.svg" \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/dispatch.svg"
- # --- sysusers: create the 'dispatch' system user ---
- install -Dm644 /dev/null "${pkgdir}/usr/lib/sysusers.d/dispatch.conf"
- echo 'u dispatch - "Dispatch API" /var/lib/dispatch' > "${pkgdir}/usr/lib/sysusers.d/dispatch.conf"
-
# --- License ---
if [ -f "${_projectdir}/LICENSE" ]; then
install -Dm644 "${_projectdir}/LICENSE" \
diff --git a/packaging/dispatch-api.service b/packaging/dispatch-api.service
index ccbb727..120d460 100644
--- a/packaging/dispatch-api.service
+++ b/packaging/dispatch-api.service
@@ -1,12 +1,8 @@
[Unit]
Description=Dispatch API Backend
-After=network-online.target
-Wants=network-online.target
[Service]
Type=simple
-User=dispatch
-Group=dispatch
WorkingDirectory=/opt/dispatch
ExecStart=/usr/bin/bun packages/api/src/index.ts
EnvironmentFile=-/etc/dispatch/dispatch-api.conf
@@ -15,19 +11,5 @@ RestartSec=5
StandardOutput=journal
StandardError=journal
-# Data directory: systemd creates /var/lib/dispatch owned by dispatch:dispatch
-StateDirectory=dispatch
-
-# Tell the app to store its SQLite DB under /var/lib/dispatch/
-Environment=XDG_DATA_HOME=/var/lib
-
-# Security hardening
-ProtectSystem=strict
-ProtectHome=true
-NoNewPrivileges=true
-PrivateTmp=true
-PrivateDevices=true
-RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
-
[Install]
-WantedBy=multi-user.target
+WantedBy=default.target
diff --git a/packaging/dispatch.install b/packaging/dispatch.install
index e6db694..7d1439f 100644
--- a/packaging/dispatch.install
+++ b/packaging/dispatch.install
@@ -3,7 +3,7 @@ post_install() {
echo "==> Dispatch has been installed."
echo " Edit /etc/dispatch/dispatch-api.conf to configure the API."
echo " Then enable and start the service:"
- echo " systemctl enable --now dispatch-api"
+ echo " systemctl --user enable --now dispatch-api"
echo ""
}
@@ -11,6 +11,6 @@ post_upgrade() {
echo ""
echo "==> Dispatch has been upgraded."
echo " You may need to restart the service:"
- echo " systemctl restart dispatch-api"
+ echo " systemctl --user restart dispatch-api"
echo ""
}