feat(FN-2518): add remote TUI settings controls and routing
- Wire remote dashboard route contracts into CLI/TUI state and controller flows - Add remote settings interaction UX in dashboard and dashboard-tui command paths - Expand regression coverage for remote TUI behavior in dashboard and app test suites - Document remote TUI controls and QR behavior in the CLI reference - Add a changeset for @runfusion/fusion patch release
This commit is contained in:
5
.changeset/fn-2518-remote-tui.md
Normal file
5
.changeset/fn-2518-remote-tui.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add full Remote controls to the dashboard TUI Settings view, including provider activation, tunnel lifecycle actions, persistent and short-lived token flows, and terminal-friendly URL/QR hand-off behavior.
|
||||||
@@ -79,12 +79,27 @@ When running in an interactive terminal (TTY), `fn dashboard` starts an
|
|||||||
interactive TUI with sectioned views for system status, logs, settings, and
|
interactive TUI with sectioned views for system status, logs, settings, and
|
||||||
remote-access controls.
|
remote-access controls.
|
||||||
|
|
||||||
Remote view/actions support:
|
Remote controls are available inside **Interactive → Settings** in the detail pane.
|
||||||
- Switching active provider (`tailscale` / `cloudflare`)
|
Remote actions support:
|
||||||
|
- Switching active provider (`tailscale` / `cloudflare`) and explicit activation
|
||||||
- Manual tunnel lifecycle (`start` / `stop`)
|
- Manual tunnel lifecycle (`start` / `stop`)
|
||||||
- Persistent token regeneration
|
- Persistent token regeneration (masked token display)
|
||||||
- Short-lived token URL generation (bounded TTL)
|
- Short-lived token generation with TTL input and expiry display
|
||||||
- QR hand-off (always includes the full authenticated URL)
|
- URL + QR hand-off (always shows full authenticated URL)
|
||||||
|
|
||||||
|
Remote action keys in Settings detail pane:
|
||||||
|
- `C` activate selected provider
|
||||||
|
- `V` start tunnel
|
||||||
|
- `X` stop tunnel
|
||||||
|
- `P` regenerate persistent token
|
||||||
|
- `L` enter TTL input mode and generate short-lived token
|
||||||
|
- `U` generate authenticated URL hand-off
|
||||||
|
- `K` request QR payload hand-off
|
||||||
|
- `R` refresh remote status/snapshot
|
||||||
|
|
||||||
|
QR hand-off behavior in TUI:
|
||||||
|
- `format="text"`: renders the text payload directly
|
||||||
|
- `format="image/svg"`: does not render raw SVG in terminal; shows the authenticated URL, expiry metadata, and a fallback instruction to open the URL on phone/browser
|
||||||
|
|
||||||
On startup, the TUI opens on the **System** section by default so you can
|
On startup, the TUI opens on the **System** section by default so you can
|
||||||
immediately see host/port and access-token details.
|
immediately see host/port and access-token details.
|
||||||
|
|||||||
@@ -2767,6 +2767,33 @@ describe("runDashboard — merge stream sink routing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runDashboard — interactiveData remote wiring", () => {
|
||||||
|
it("keeps remote endpoint wiring and method names aligned", async () => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const source = await readFile(new URL("../dashboard.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("getSettings: async () =>");
|
||||||
|
expect(source).toContain("getStatus: async () =>");
|
||||||
|
expect(source).toContain("activateProvider: async");
|
||||||
|
expect(source).toContain("startTunnel: async");
|
||||||
|
expect(source).toContain("stopTunnel: async");
|
||||||
|
expect(source).toContain("regeneratePersistentToken: async");
|
||||||
|
expect(source).toContain("generateShortLivedToken: async");
|
||||||
|
expect(source).toContain("getRemoteUrl: async");
|
||||||
|
expect(source).toContain("getQrPayload: async");
|
||||||
|
|
||||||
|
expect(source).toContain("/api/remote/settings");
|
||||||
|
expect(source).toContain("/api/remote/status");
|
||||||
|
expect(source).toContain("/api/remote/provider/activate");
|
||||||
|
expect(source).toContain("/api/remote/tunnel/start");
|
||||||
|
expect(source).toContain("/api/remote/tunnel/stop");
|
||||||
|
expect(source).toContain("/api/remote/token/persistent/regenerate");
|
||||||
|
expect(source).toContain("/api/remote/token/short-lived/generate");
|
||||||
|
expect(source).toContain("/api/remote/url?");
|
||||||
|
expect(source).toContain("/api/remote/qr?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("runDashboard runtime logger wiring", () => {
|
describe("runDashboard runtime logger wiring", () => {
|
||||||
it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => {
|
it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => {
|
||||||
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ function makeInteractiveData(opts: {
|
|||||||
settings?: SettingsValues;
|
settings?: SettingsValues;
|
||||||
models?: ModelItem[];
|
models?: ModelItem[];
|
||||||
taskDetail?: TaskDetailData | null;
|
taskDetail?: TaskDetailData | null;
|
||||||
|
remote?: Partial<{
|
||||||
|
getSettings: () => Promise<{ remoteEnabled: boolean; activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>;
|
||||||
|
getStatus: () => Promise<{ provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "error"; url: string | null; lastError: string | null }>;
|
||||||
|
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
|
||||||
|
startTunnel: () => Promise<void>;
|
||||||
|
stopTunnel: () => Promise<void>;
|
||||||
|
regeneratePersistentToken: () => Promise<{ maskedToken?: string; tokenType: "persistent"; expiresAt: null }>;
|
||||||
|
generateShortLivedToken: (ttlMs: number) => Promise<{ token?: string; tokenType: "short-lived"; expiresAt: string | null }>;
|
||||||
|
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
||||||
|
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
|
||||||
|
}>;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const projects = opts.projects ?? [];
|
const projects = opts.projects ?? [];
|
||||||
const tasks = opts.tasks ?? [];
|
const tasks = opts.tasks ?? [];
|
||||||
@@ -53,6 +64,26 @@ function makeInteractiveData(opts: {
|
|||||||
remoteShortLivedTtlMs: 900000,
|
remoteShortLivedTtlMs: 900000,
|
||||||
};
|
};
|
||||||
const models = opts.models ?? [];
|
const models = opts.models ?? [];
|
||||||
|
const remoteDefaults = {
|
||||||
|
getSettings: async () => ({
|
||||||
|
remoteEnabled: settings.remoteEnabled,
|
||||||
|
activeProvider: settings.remoteActiveProvider,
|
||||||
|
tailscaleEnabled: true,
|
||||||
|
cloudflareEnabled: true,
|
||||||
|
shortLivedEnabled: settings.remoteShortLivedEnabled,
|
||||||
|
shortLivedTtlMs: settings.remoteShortLivedTtlMs,
|
||||||
|
}),
|
||||||
|
getStatus: async () => ({ provider: null, state: "stopped" as const, url: null, lastError: null }),
|
||||||
|
activateProvider: async (_provider: "tailscale" | "cloudflare") => {},
|
||||||
|
startTunnel: async () => {},
|
||||||
|
stopTunnel: async () => {},
|
||||||
|
regeneratePersistentToken: async () => ({ maskedToken: "tok_****", tokenType: "persistent" as const, expiresAt: null }),
|
||||||
|
generateShortLivedToken: async (_ttlMs: number) => ({ token: "short", tokenType: "short-lived" as const, expiresAt: new Date().toISOString() }),
|
||||||
|
getRemoteUrl: async (_tokenType: "persistent" | "short-lived", _ttlMs?: number) => ({ url: "https://remote.example.com", tokenType: "persistent" as const, expiresAt: null }),
|
||||||
|
getQrPayload: async (_tokenType: "persistent" | "short-lived", _ttlMs?: number) => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg" as const, data: "<svg/>" }),
|
||||||
|
};
|
||||||
|
const remote = { ...remoteDefaults, ...(opts.remote ?? {}) };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listProjects: async () => projects,
|
listProjects: async () => projects,
|
||||||
listTasks: async () => tasks,
|
listTasks: async () => tasks,
|
||||||
@@ -69,16 +100,7 @@ function makeInteractiveData(opts: {
|
|||||||
getSettings: async () => settings,
|
getSettings: async () => settings,
|
||||||
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
||||||
listModels: () => models,
|
listModels: () => models,
|
||||||
remote: {
|
remote,
|
||||||
getStatus: async () => ({ provider: null, state: "stopped", url: null, lastError: null }),
|
|
||||||
activateProvider: async () => {},
|
|
||||||
start: async () => {},
|
|
||||||
stop: async () => {},
|
|
||||||
regeneratePersistentToken: async () => {},
|
|
||||||
generateShortLivedToken: async () => ({ token: "short", expiresAt: new Date().toISOString(), ttlMs: 60000 }),
|
|
||||||
fetchUrl: async () => ({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null }),
|
|
||||||
fetchQr: async () => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg", data: "<svg/>" }),
|
|
||||||
},
|
|
||||||
git: {
|
git: {
|
||||||
getStatus: async () => ({
|
getStatus: async () => ({
|
||||||
branch: "main",
|
branch: "main",
|
||||||
@@ -365,6 +387,132 @@ describe("Settings view", () => {
|
|||||||
expect(frame).toContain("Claude 3.5 Sonnet");
|
expect(frame).toContain("Claude 3.5 Sonnet");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the Remote subsection and supports provider/lifecycle actions", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
let remoteState: "stopped" | "starting" | "running" | "error" = "running";
|
||||||
|
const activateProvider = vi.fn(async () => {});
|
||||||
|
const settings: SettingsValues = {
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
autoMerge: false,
|
||||||
|
mergeStrategy: "direct",
|
||||||
|
pollIntervalMs: 60000,
|
||||||
|
enginePaused: false,
|
||||||
|
globalPause: false,
|
||||||
|
remoteEnabled: true,
|
||||||
|
remoteActiveProvider: "cloudflare",
|
||||||
|
remoteShortLivedEnabled: true,
|
||||||
|
remoteShortLivedTtlMs: 600000,
|
||||||
|
remoteStatus: { provider: "cloudflare", state: "running", url: "https://remote.example.com", lastError: null },
|
||||||
|
};
|
||||||
|
controller.setInteractiveData(makeInteractiveData({
|
||||||
|
settings,
|
||||||
|
remote: {
|
||||||
|
activateProvider,
|
||||||
|
getStatus: async () => ({ provider: "cloudflare", state: remoteState, url: "https://remote.example.com", lastError: null }),
|
||||||
|
startTunnel: async () => {
|
||||||
|
remoteState = "starting";
|
||||||
|
},
|
||||||
|
stopTunnel: async () => {
|
||||||
|
remoteState = "stopped";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
|
await waitForFrameContains(lastFrame, "Remote");
|
||||||
|
expect(lastFrame() ?? "").toContain("cloudflare");
|
||||||
|
|
||||||
|
stdin.write("\t");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
stdin.write("C");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(activateProvider).toHaveBeenCalledWith("cloudflare");
|
||||||
|
|
||||||
|
stdin.write("V");
|
||||||
|
await waitForFrameContains(lastFrame, "Remote tunnel starting");
|
||||||
|
|
||||||
|
stdin.write("X");
|
||||||
|
await waitForFrameContains(lastFrame, "Remote tunnel stopped");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders short-lived token expiry and QR text payload", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setInteractiveData(makeInteractiveData({
|
||||||
|
remote: {
|
||||||
|
getQrPayload: async () => ({
|
||||||
|
url: "https://remote.example.com?token=text",
|
||||||
|
expiresAt: null,
|
||||||
|
format: "text",
|
||||||
|
data: "ASCII-QR-PAYLOAD",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
|
stdin.write("\t");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
stdin.write("L");
|
||||||
|
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||||
|
stdin.write("\r");
|
||||||
|
await waitForFrameContains(lastFrame, "Short-lived expires:");
|
||||||
|
|
||||||
|
stdin.write("K");
|
||||||
|
await waitForFrameContains(lastFrame, "QR text payload: ASCII-QR-PAYLOAD");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps global shortcuts inactive during TTL input", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setInteractiveData(makeInteractiveData());
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
|
stdin.write("\t");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
stdin.write("L");
|
||||||
|
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||||
|
stdin.write("a");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(controller.getSnapshot().interactiveView).toBe("settings");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders SVG QR fallback instruction", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
controller.setInteractiveData(makeInteractiveData({
|
||||||
|
remote: {
|
||||||
|
getQrPayload: async () => ({
|
||||||
|
url: "https://remote.example.com?token=svg",
|
||||||
|
expiresAt: new Date().toISOString(),
|
||||||
|
format: "image/svg",
|
||||||
|
data: "<svg/>",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("settings");
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
|
stdin.write("\t");
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
stdin.write("K");
|
||||||
|
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Board view", () => {
|
describe("Board view", () => {
|
||||||
|
|||||||
@@ -2128,7 +2128,7 @@ const SETTING_DEFS: SettingDef[] = [
|
|||||||
{ key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" },
|
{ key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
function SettingsInteractiveView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [localSettings, setLocalSettings] = useState<SettingsValues | null>(null);
|
const [localSettings, setLocalSettings] = useState<SettingsValues | null>(null);
|
||||||
const [models, setModels] = useState<ModelItem[]>([]);
|
const [models, setModels] = useState<ModelItem[]>([]);
|
||||||
@@ -2137,16 +2137,32 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
const [detailFocused, setDetailFocused] = useState(false);
|
const [detailFocused, setDetailFocused] = useState(false);
|
||||||
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
|
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
|
||||||
const [remoteTokenMeta, setRemoteTokenMeta] = useState<string | null>(null);
|
const [remoteTokenMeta, setRemoteTokenMeta] = useState<string | null>(null);
|
||||||
|
const [remoteQrDisplay, setRemoteQrDisplay] = useState<string | null>(null);
|
||||||
|
const [remoteQrFallback, setRemoteQrFallback] = useState<string | null>(null);
|
||||||
|
const [persistentMaskedToken, setPersistentMaskedToken] = useState<string | null>(null);
|
||||||
|
const [shortLivedExpiresAt, setShortLivedExpiresAt] = useState<string | null>(null);
|
||||||
|
const [ttlInputMode, setTtlInputMode] = useState(false);
|
||||||
|
const [ttlInputValue, setTtlInputValue] = useState("900000");
|
||||||
|
|
||||||
const data = state.interactiveData;
|
const data = state.interactiveData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
controller.setInteractiveInputLocked(ttlInputMode);
|
||||||
|
return () => {
|
||||||
|
controller.setInteractiveInputLocked(false);
|
||||||
|
};
|
||||||
|
}, [controller, ttlInputMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
data.getSettings().then(async (settings) => {
|
data.getSettings().then(async (settings) => {
|
||||||
if (data.remote) {
|
if (data.remote) {
|
||||||
try {
|
try {
|
||||||
const remoteStatus = await data.remote.getStatus();
|
const [remoteStatus, remoteSettingsSnapshot] = await Promise.all([
|
||||||
setLocalSettings({ ...settings, remoteStatus });
|
data.remote.getStatus(),
|
||||||
|
data.remote.getSettings().catch(() => settings.remoteSettingsSnapshot),
|
||||||
|
]);
|
||||||
|
setLocalSettings({ ...settings, remoteStatus, remoteSettingsSnapshot });
|
||||||
} catch {
|
} catch {
|
||||||
setLocalSettings(settings);
|
setLocalSettings(settings);
|
||||||
}
|
}
|
||||||
@@ -2166,7 +2182,8 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
await data.updateSettings(partial);
|
await data.updateSettings(partial);
|
||||||
const updated = await data.getSettings();
|
const updated = await data.getSettings();
|
||||||
const remoteStatus = data.remote ? await data.remote.getStatus().catch(() => null) : null;
|
const remoteStatus = data.remote ? await data.remote.getStatus().catch(() => null) : null;
|
||||||
setLocalSettings(remoteStatus ? { ...updated, remoteStatus } : updated);
|
const remoteSettingsSnapshot = data.remote ? await data.remote.getSettings().catch(() => updated.remoteSettingsSnapshot) : undefined;
|
||||||
|
setLocalSettings(remoteStatus ? { ...updated, remoteStatus, remoteSettingsSnapshot } : updated);
|
||||||
setStatusMsg("Saved");
|
setStatusMsg("Saved");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
@@ -2178,13 +2195,37 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
async function refreshRemoteStatus() {
|
async function refreshRemoteStatus() {
|
||||||
if (!data || !localSettings) return;
|
if (!data || !localSettings) return;
|
||||||
try {
|
try {
|
||||||
const remoteStatus = await data.remote.getStatus();
|
const [remoteStatus, remoteSettingsSnapshot] = await Promise.all([
|
||||||
setLocalSettings({ ...localSettings, remoteStatus });
|
data.remote.getStatus(),
|
||||||
|
data.remote.getSettings().catch(() => localSettings.remoteSettingsSnapshot),
|
||||||
|
]);
|
||||||
|
setLocalSettings({ ...localSettings, remoteStatus, remoteSettingsSnapshot });
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleFetchRemoteUrl(tokenType: "persistent" | "short-lived", ttlMs?: number) {
|
||||||
|
if (!data?.remote) return;
|
||||||
|
const result = await data.remote.getRemoteUrl(tokenType, ttlMs);
|
||||||
|
setRemoteUrl(result.url);
|
||||||
|
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFetchRemoteQr(tokenType: "persistent" | "short-lived", ttlMs?: number) {
|
||||||
|
if (!data?.remote) return;
|
||||||
|
const result = await data.remote.getQrPayload(tokenType, ttlMs);
|
||||||
|
setRemoteUrl(result.url);
|
||||||
|
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : tokenType);
|
||||||
|
if (result.format === "text") {
|
||||||
|
setRemoteQrDisplay(result.data ?? result.url);
|
||||||
|
setRemoteQrFallback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRemoteQrDisplay(null);
|
||||||
|
setRemoteQrFallback("QR SVG returned by server. Open the authenticated URL on your phone/browser to continue.");
|
||||||
|
}
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
// Tab cycles list ↔ detail. Left/right also switch — list = left,
|
// Tab cycles list ↔ detail. Left/right also switch — list = left,
|
||||||
// detail = right, matching the visual layout (consistent with AgentsView).
|
// detail = right, matching the visual layout (consistent with AgentsView).
|
||||||
@@ -2215,48 +2256,72 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
|
|
||||||
if (!selectedDef || !localSettings) return;
|
if (!selectedDef || !localSettings) return;
|
||||||
|
|
||||||
|
if (ttlInputMode) {
|
||||||
|
if (key.escape) {
|
||||||
|
setTtlInputMode(false);
|
||||||
|
setStatusMsg("Cancelled short-lived token input");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (input === "R") {
|
if (input === "R") {
|
||||||
void refreshRemoteStatus();
|
void refreshRemoteStatus();
|
||||||
setStatusMsg("Remote status refreshed");
|
setStatusMsg("Remote status refreshed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.remote && input === "S") {
|
if (data?.remote && input === "C") {
|
||||||
void data.remote.start().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
|
const provider = localSettings.remoteActiveProvider;
|
||||||
|
if (!provider) {
|
||||||
|
setStatusMsg("Select a remote provider first");
|
||||||
|
} else {
|
||||||
|
void data.remote.activateProvider(provider)
|
||||||
|
.then(() => refreshRemoteStatus())
|
||||||
|
.then(() => setStatusMsg(`Activated provider: ${provider}`))
|
||||||
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.remote && input === "V") {
|
||||||
|
void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
|
||||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.remote && input === "X") {
|
if (data?.remote && input === "X") {
|
||||||
void data.remote.stop().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
|
void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
|
||||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.remote && input === "P") {
|
if (data?.remote && input === "P") {
|
||||||
void data.remote.regeneratePersistentToken().then(() => setStatusMsg("Persistent token regenerated"))
|
void data.remote.regeneratePersistentToken()
|
||||||
|
.then((result) => {
|
||||||
|
setPersistentMaskedToken(result.maskedToken ?? null);
|
||||||
|
setStatusMsg("Persistent token regenerated");
|
||||||
|
})
|
||||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data?.remote && input === "L") {
|
||||||
|
setTtlInputValue(String(localSettings.remoteShortLivedTtlMs));
|
||||||
|
setTtlInputMode(true);
|
||||||
|
setStatusMsg("Enter TTL milliseconds and press Enter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data?.remote && input === "U") {
|
if (data?.remote && input === "U") {
|
||||||
void data.remote.fetchUrl()
|
void handleFetchRemoteUrl("persistent")
|
||||||
.then((result) => {
|
.then(() => setStatusMsg("Remote URL fetched"))
|
||||||
setRemoteUrl(result.url);
|
|
||||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
|
|
||||||
setStatusMsg("Remote URL fetched");
|
|
||||||
})
|
|
||||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.remote && input === "Q") {
|
if (data?.remote && input === "K") {
|
||||||
void data.remote.fetchQr()
|
void handleFetchRemoteQr("persistent")
|
||||||
.then((result) => {
|
.then(() => setStatusMsg("QR payload fetched"))
|
||||||
setRemoteUrl(result.url);
|
|
||||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : "persistent");
|
|
||||||
setStatusMsg("QR URL generated");
|
|
||||||
})
|
|
||||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2296,6 +2361,9 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
const updated = { ...localSettings, [selectedDef.key]: next };
|
const updated = { ...localSettings, [selectedDef.key]: next };
|
||||||
setLocalSettings(updated);
|
setLocalSettings(updated);
|
||||||
void saveField({ [selectedDef.key]: next });
|
void saveField({ [selectedDef.key]: next });
|
||||||
|
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
|
||||||
|
void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.leftArrow || input === "h") {
|
if (key.leftArrow || input === "h") {
|
||||||
@@ -2303,6 +2371,9 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
const updated = { ...localSettings, [selectedDef.key]: prev };
|
const updated = { ...localSettings, [selectedDef.key]: prev };
|
||||||
setLocalSettings(updated);
|
setLocalSettings(updated);
|
||||||
void saveField({ [selectedDef.key]: prev });
|
void saveField({ [selectedDef.key]: prev });
|
||||||
|
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
|
||||||
|
void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2319,6 +2390,31 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
return <Text>{String(v)}</Text>;
|
return <Text>{String(v)}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitShortLivedTtlInput(value: string) {
|
||||||
|
if (!data?.remote || !localSettings) return;
|
||||||
|
const ttlMs = Number(value.trim());
|
||||||
|
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
||||||
|
setStatusMsg("TTL must be a positive number (ms)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTtlInputMode(false);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const tokenResult = await data.remote.generateShortLivedToken(ttlMs);
|
||||||
|
setShortLivedExpiresAt(tokenResult.expiresAt);
|
||||||
|
setRemoteTokenMeta(tokenResult.expiresAt ? `expires ${new Date(tokenResult.expiresAt).toLocaleString()}` : "short-lived");
|
||||||
|
setLocalSettings({ ...localSettings, remoteShortLivedTtlMs: ttlMs });
|
||||||
|
await saveField({ remoteShortLivedTtlMs: ttlMs });
|
||||||
|
await handleFetchRemoteUrl("short-lived", ttlMs);
|
||||||
|
setStatusMsg("Short-lived token generated");
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
@@ -2409,23 +2505,55 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
<Text dimColor>──── Remote Access ────</Text>
|
<Text dimColor>──── Remote ────</Text>
|
||||||
<Box flexDirection="row" gap={1}>
|
<Box flexDirection="row" gap={1}>
|
||||||
<Text dimColor>Provider:</Text>
|
<Text dimColor>Provider:</Text>
|
||||||
<Text>{localSettings.remoteActiveProvider ?? "none"}</Text>
|
<Text>{localSettings.remoteActiveProvider ?? "none"}</Text>
|
||||||
<Text dimColor>State:</Text>
|
<Text dimColor>State:</Text>
|
||||||
<Text color={localSettings.remoteStatus?.state === "running" ? "green" : "yellow"}>{localSettings.remoteStatus?.state ?? "unknown"}</Text>
|
<Text color={localSettings.remoteStatus?.state === "running" ? "green" : "yellow"}>{localSettings.remoteStatus?.state ?? "unknown"}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box flexDirection="row" gap={1}>
|
||||||
|
<Text dimColor>Enabled:</Text>
|
||||||
|
<Text>{localSettings.remoteSettingsSnapshot?.remoteEnabled ? "yes" : "no"}</Text>
|
||||||
|
<Text dimColor>Short-lived:</Text>
|
||||||
|
<Text>{localSettings.remoteSettingsSnapshot?.shortLivedEnabled ? "on" : "off"}</Text>
|
||||||
|
</Box>
|
||||||
{localSettings.remoteStatus?.url && (
|
{localSettings.remoteStatus?.url && (
|
||||||
<Text dimColor wrap="truncate-end">URL: {localSettings.remoteStatus.url}</Text>
|
<Text dimColor wrap="truncate-end">Tunnel URL: {localSettings.remoteStatus.url}</Text>
|
||||||
)}
|
)}
|
||||||
{remoteUrl && (
|
{remoteUrl && (
|
||||||
<Text dimColor wrap="truncate-end">Auth URL: {remoteUrl}</Text>
|
<Text color="white" wrap="truncate-end">Auth URL: {remoteUrl}</Text>
|
||||||
)}
|
)}
|
||||||
{remoteTokenMeta && (
|
{remoteTokenMeta && (
|
||||||
<Text dimColor>{remoteTokenMeta}</Text>
|
<Text dimColor wrap="truncate-end">Token: {remoteTokenMeta}</Text>
|
||||||
)}
|
)}
|
||||||
<Text dimColor>[S] start [X] stop [P] regenerate token [U] URL [Q] QR URL [R] refresh</Text>
|
{persistentMaskedToken && (
|
||||||
|
<Text dimColor wrap="truncate-end">Persistent token: {persistentMaskedToken}</Text>
|
||||||
|
)}
|
||||||
|
{shortLivedExpiresAt && (
|
||||||
|
<Text dimColor wrap="truncate-end">Short-lived expires: {new Date(shortLivedExpiresAt).toLocaleString()}</Text>
|
||||||
|
)}
|
||||||
|
{remoteQrDisplay && (
|
||||||
|
<Text wrap="truncate-end">QR text payload: {remoteQrDisplay}</Text>
|
||||||
|
)}
|
||||||
|
{remoteQrFallback && (
|
||||||
|
<Text color="yellow" wrap="truncate-end">{remoteQrFallback}</Text>
|
||||||
|
)}
|
||||||
|
{ttlInputMode && (
|
||||||
|
<Box flexDirection="row" gap={1}>
|
||||||
|
<Text dimColor>TTL ms:</Text>
|
||||||
|
<TextInput
|
||||||
|
value={ttlInputValue}
|
||||||
|
onChange={setTtlInputValue}
|
||||||
|
onSubmit={(value) => {
|
||||||
|
void submitShortLivedTtlInput(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text dimColor>[Enter] generate [Esc] cancel</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Text dimColor>[C] activate provider [V] start [X] stop [P] persistent token [L] short-lived token</Text>
|
||||||
|
<Text dimColor>[U] URL hand-off [K] QR hand-off [R] refresh</Text>
|
||||||
|
|
||||||
{/* Models subsection */}
|
{/* Models subsection */}
|
||||||
{models.length > 0 && (
|
{models.length > 0 && (
|
||||||
@@ -2453,7 +2581,7 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box paddingX={1}>
|
<Box paddingX={1}>
|
||||||
<Text dimColor>[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [S/X/P/U/Q/R] remote actions</Text>
|
<Text dimColor>[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [C/V/X/P/L/U/K/R] remote actions</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -3610,7 +3738,7 @@ function InteractiveMode({ state, controller }: { state: DashboardState; control
|
|||||||
<Box flexGrow={1} overflow="hidden">
|
<Box flexGrow={1} overflow="hidden">
|
||||||
{state.interactiveView === "board" && <BoardView state={state} controller={controller} />}
|
{state.interactiveView === "board" && <BoardView state={state} controller={controller} />}
|
||||||
{state.interactiveView === "agents" && <AgentsView state={state} />}
|
{state.interactiveView === "agents" && <AgentsView state={state} />}
|
||||||
{state.interactiveView === "settings" && <SettingsInteractiveView state={state} />}
|
{state.interactiveView === "settings" && <SettingsInteractiveView state={state} controller={controller} />}
|
||||||
{state.interactiveView === "git" && <GitView state={state} />}
|
{state.interactiveView === "git" && <GitView state={state} />}
|
||||||
{state.interactiveView === "files" && <FilesView state={state} />}
|
{state.interactiveView === "files" && <FilesView state={state} />}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -3663,6 +3791,10 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.mode === "interactive" && state.interactiveInputLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// View switching shortcuts — b/a/g enter interactive + set view
|
// View switching shortcuts — b/a/g enter interactive + set view
|
||||||
if (input === "b" || input === "B") {
|
if (input === "b" || input === "B") {
|
||||||
controller.setMode("interactive");
|
controller.setMode("interactive");
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class DashboardTUI {
|
|||||||
private lastAutoKillAt = 0;
|
private lastAutoKillAt = 0;
|
||||||
interactiveData: InteractiveData | null = null;
|
interactiveData: InteractiveData | null = null;
|
||||||
interactiveView: InteractiveView = "board";
|
interactiveView: InteractiveView = "board";
|
||||||
|
interactiveInputLocked = false;
|
||||||
|
|
||||||
// Subscribers registered by the Ink App component.
|
// Subscribers registered by the Ink App component.
|
||||||
private subscribers: Set<() => void> = new Set();
|
private subscribers: Set<() => void> = new Set();
|
||||||
@@ -119,6 +120,7 @@ export class DashboardTUI {
|
|||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
interactiveData: this.interactiveData,
|
interactiveData: this.interactiveData,
|
||||||
interactiveView: this.interactiveView,
|
interactiveView: this.interactiveView,
|
||||||
|
interactiveInputLocked: this.interactiveInputLocked,
|
||||||
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
|
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
|
||||||
vitestKillThreshold: this.vitestKillThreshold,
|
vitestKillThreshold: this.vitestKillThreshold,
|
||||||
};
|
};
|
||||||
@@ -324,6 +326,12 @@ export class DashboardTUI {
|
|||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInteractiveInputLocked(locked: boolean): void {
|
||||||
|
if (this.interactiveInputLocked === locked) return;
|
||||||
|
this.interactiveInputLocked = locked;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
addLog(entry: Omit<LogEntry, "timestamp">): void {
|
addLog(entry: Omit<LogEntry, "timestamp">): void {
|
||||||
// If the cursor was sitting on the most recent entry (or there were no
|
// If the cursor was sitting on the most recent entry (or there were no
|
||||||
// entries yet), keep it pinned to the new tail so live logs follow the
|
// entries yet), keep it pinned to the new tail so live logs follow the
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export type {
|
|||||||
SystemInfo,
|
SystemInfo,
|
||||||
TaskStats,
|
TaskStats,
|
||||||
SettingsValues,
|
SettingsValues,
|
||||||
|
RemoteProvider,
|
||||||
|
RemoteStatus,
|
||||||
|
RemoteTokenResult,
|
||||||
|
RemoteQrPayload,
|
||||||
|
RemoteSettingsSnapshot,
|
||||||
UtilityAction,
|
UtilityAction,
|
||||||
TUICallbacks,
|
TUICallbacks,
|
||||||
InteractiveData,
|
InteractiveData,
|
||||||
|
|||||||
@@ -51,13 +51,38 @@ export interface SystemStats {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteStatusValue {
|
export type RemoteProvider = "tailscale" | "cloudflare";
|
||||||
provider: "tailscale" | "cloudflare" | null;
|
|
||||||
|
export interface RemoteStatus {
|
||||||
|
provider: RemoteProvider | null;
|
||||||
state: "stopped" | "starting" | "running" | "error";
|
state: "stopped" | "starting" | "running" | "error";
|
||||||
url: string | null;
|
url: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteTokenResult {
|
||||||
|
token?: string;
|
||||||
|
maskedToken?: string;
|
||||||
|
tokenType: "persistent" | "short-lived";
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteQrPayload {
|
||||||
|
url: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
format: "text" | "image/svg";
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSettingsSnapshot {
|
||||||
|
remoteEnabled: boolean;
|
||||||
|
activeProvider: RemoteProvider | null;
|
||||||
|
tailscaleEnabled: boolean;
|
||||||
|
cloudflareEnabled: boolean;
|
||||||
|
shortLivedEnabled: boolean;
|
||||||
|
shortLivedTtlMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettingsValues {
|
export interface SettingsValues {
|
||||||
maxConcurrent: number;
|
maxConcurrent: number;
|
||||||
maxWorktrees: number;
|
maxWorktrees: number;
|
||||||
@@ -67,10 +92,11 @@ export interface SettingsValues {
|
|||||||
enginePaused: boolean;
|
enginePaused: boolean;
|
||||||
globalPause: boolean;
|
globalPause: boolean;
|
||||||
remoteEnabled: boolean;
|
remoteEnabled: boolean;
|
||||||
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
remoteActiveProvider: RemoteProvider | null;
|
||||||
remoteShortLivedEnabled: boolean;
|
remoteShortLivedEnabled: boolean;
|
||||||
remoteShortLivedTtlMs: number;
|
remoteShortLivedTtlMs: number;
|
||||||
remoteStatus?: RemoteStatusValue;
|
remoteSettingsSnapshot?: RemoteSettingsSnapshot;
|
||||||
|
remoteStatus?: RemoteStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UtilityAction {
|
export interface UtilityAction {
|
||||||
@@ -258,14 +284,15 @@ export interface InteractiveData {
|
|||||||
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
||||||
listModels: () => ModelItem[];
|
listModels: () => ModelItem[];
|
||||||
remote: {
|
remote: {
|
||||||
getStatus: () => Promise<RemoteStatusValue>;
|
getSettings: () => Promise<RemoteSettingsSnapshot>;
|
||||||
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
|
getStatus: () => Promise<RemoteStatus>;
|
||||||
start: () => Promise<void>;
|
activateProvider: (provider: RemoteProvider) => Promise<void>;
|
||||||
stop: () => Promise<void>;
|
startTunnel: () => Promise<void>;
|
||||||
regeneratePersistentToken: () => Promise<void>;
|
stopTunnel: () => Promise<void>;
|
||||||
generateShortLivedToken: (ttlMs: number) => Promise<{ token: string; expiresAt: string; ttlMs: number }>;
|
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
|
||||||
fetchUrl: () => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
|
||||||
fetchQr: () => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
|
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
||||||
|
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
|
||||||
};
|
};
|
||||||
git: {
|
git: {
|
||||||
getStatus: (projectPath: string) => Promise<GitStatus>;
|
getStatus: (projectPath: string) => Promise<GitStatus>;
|
||||||
@@ -313,6 +340,7 @@ export interface DashboardState {
|
|||||||
mode: AppMode;
|
mode: AppMode;
|
||||||
interactiveData: InteractiveData | null;
|
interactiveData: InteractiveData | null;
|
||||||
interactiveView: InteractiveView;
|
interactiveView: InteractiveView;
|
||||||
|
interactiveInputLocked: boolean;
|
||||||
autoKillVitestOnPressure: boolean;
|
autoKillVitestOnPressure: boolean;
|
||||||
vitestKillThreshold: number;
|
vitestKillThreshold: number;
|
||||||
}
|
}
|
||||||
@@ -338,6 +366,7 @@ export function createInitialState(): DashboardState {
|
|||||||
mode: "status",
|
mode: "status",
|
||||||
interactiveData: null,
|
interactiveData: null,
|
||||||
interactiveView: "board",
|
interactiveView: "board",
|
||||||
|
interactiveInputLocked: false,
|
||||||
autoKillVitestOnPressure: true,
|
autoKillVitestOnPressure: true,
|
||||||
vitestKillThreshold: 0.9,
|
vitestKillThreshold: 0.9,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1990,6 +1990,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
remoteActiveProvider: (s.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
remoteActiveProvider: (s.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||||
remoteShortLivedEnabled: Boolean(s.remoteShortLivedEnabled),
|
remoteShortLivedEnabled: Boolean(s.remoteShortLivedEnabled),
|
||||||
remoteShortLivedTtlMs: Number(s.remoteShortLivedTtlMs ?? 900_000),
|
remoteShortLivedTtlMs: Number(s.remoteShortLivedTtlMs ?? 900_000),
|
||||||
|
remoteSettingsSnapshot: {
|
||||||
|
remoteEnabled: Boolean(s.remoteEnabled),
|
||||||
|
activeProvider: (s.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||||
|
tailscaleEnabled: Boolean(s.remoteTailscaleEnabled),
|
||||||
|
cloudflareEnabled: Boolean(s.remoteCloudflareEnabled),
|
||||||
|
shortLivedEnabled: Boolean(s.remoteShortLivedEnabled),
|
||||||
|
shortLivedTtlMs: Number(s.remoteShortLivedTtlMs ?? 900_000),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
updateSettings: async (partial) => {
|
updateSettings: async (partial) => {
|
||||||
@@ -2018,10 +2026,33 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
remote: {
|
remote: {
|
||||||
|
getSettings: async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/remote/settings`, { headers: buildAuthHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote settings request failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
return {
|
||||||
|
remoteEnabled: Boolean(payload?.settings?.remoteEnabled),
|
||||||
|
activeProvider: (payload?.settings?.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||||
|
tailscaleEnabled: Boolean(payload?.settings?.remoteTailscaleEnabled),
|
||||||
|
cloudflareEnabled: Boolean(payload?.settings?.remoteCloudflareEnabled),
|
||||||
|
shortLivedEnabled: Boolean(payload?.settings?.remoteShortLivedEnabled),
|
||||||
|
shortLivedTtlMs: Number(payload?.settings?.remoteShortLivedTtlMs ?? 900_000),
|
||||||
|
};
|
||||||
|
},
|
||||||
getStatus: async () => {
|
getStatus: async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/remote/status`, { headers: buildAuthHeaders() });
|
const response = await fetch(`${baseUrl}/api/remote/status`, { headers: buildAuthHeaders() });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Remote status request failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote status request failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
@@ -2032,25 +2063,37 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
body: JSON.stringify({ provider }),
|
body: JSON.stringify({ provider }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Remote provider activation failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote provider activation failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
start: async () => {
|
startTunnel: async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/remote/tunnel/start`, {
|
const response = await fetch(`${baseUrl}/api/remote/tunnel/start`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Remote start failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote start failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
stop: async () => {
|
stopTunnel: async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/remote/tunnel/stop`, {
|
const response = await fetch(`${baseUrl}/api/remote/tunnel/stop`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Remote stop failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote stop failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
regeneratePersistentToken: async () => {
|
regeneratePersistentToken: async () => {
|
||||||
@@ -2059,8 +2102,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Persistent token regeneration failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Persistent token regeneration failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
return {
|
||||||
|
token: typeof payload?.token === "string" ? payload.token : undefined,
|
||||||
|
maskedToken: typeof payload?.maskedToken === "string" ? payload.maskedToken : undefined,
|
||||||
|
tokenType: "persistent" as const,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
generateShortLivedToken: async (ttlMs: number) => {
|
generateShortLivedToken: async (ttlMs: number) => {
|
||||||
const response = await fetch(`${baseUrl}/api/remote/token/short-lived/generate`, {
|
const response = await fetch(`${baseUrl}/api/remote/token/short-lived/generate`, {
|
||||||
@@ -2069,21 +2123,43 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
body: JSON.stringify({ ttlMs }),
|
body: JSON.stringify({ ttlMs }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Short-lived token generation failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Short-lived token generation failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
return {
|
||||||
|
token: typeof payload?.token === "string" ? payload.token : undefined,
|
||||||
|
maskedToken: typeof payload?.maskedToken === "string" ? payload.maskedToken : undefined,
|
||||||
|
tokenType: "short-lived" as const,
|
||||||
|
expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getRemoteUrl: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
|
||||||
|
const params = new URLSearchParams({ tokenType });
|
||||||
|
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
|
||||||
|
const response = await fetch(`${baseUrl}/api/remote/url?${params.toString()}`, { headers: buildAuthHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
|
? String((payload as { error: unknown }).error)
|
||||||
|
: `Remote URL request failed: ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
fetchUrl: async () => {
|
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
|
||||||
const response = await fetch(`${baseUrl}/api/remote/url`, { headers: buildAuthHeaders() });
|
const params = new URLSearchParams({ tokenType });
|
||||||
|
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
|
||||||
|
const response = await fetch(`${baseUrl}/api/remote/qr?${params.toString()}`, { headers: buildAuthHeaders() });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Remote URL request failed: ${response.status}`);
|
const payload = await response.json().catch(() => null);
|
||||||
}
|
const message = payload && typeof payload === "object" && "error" in payload
|
||||||
return await response.json();
|
? String((payload as { error: unknown }).error)
|
||||||
},
|
: `Remote QR request failed: ${response.status}`;
|
||||||
fetchQr: async () => {
|
throw new Error(message);
|
||||||
const response = await fetch(`${baseUrl}/api/remote/qr?format=image%2Fsvg`, { headers: buildAuthHeaders() });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Remote QR request failed: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2128,6 +2128,61 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
||||||
const aiSessionStore = options?.aiSessionStore;
|
const aiSessionStore = options?.aiSessionStore;
|
||||||
|
|
||||||
|
const REMOTE_MIN_TTL_MS = 60_000;
|
||||||
|
const REMOTE_MAX_TTL_MS = 86_400_000;
|
||||||
|
const remoteShortLivedTokens = new Map<string, { expiresAt: number }>();
|
||||||
|
|
||||||
|
function generateRemoteToken(): string {
|
||||||
|
return `rtok_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskRemoteToken(token: string): string {
|
||||||
|
if (token.length <= 8) return "********";
|
||||||
|
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePersistentRemoteToken(scopedStore: TaskStore): Promise<string> {
|
||||||
|
const settings = await scopedStore.getSettings();
|
||||||
|
const existing = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
|
||||||
|
if (existing) return existing;
|
||||||
|
const token = generateRemoteToken();
|
||||||
|
await scopedStore.updateSettings({ remotePersistentToken: token });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRemoteOrigin(req: Request): string {
|
||||||
|
const protocol = req.protocol || "http";
|
||||||
|
const hostHeader = req.get("host") ?? "127.0.0.1:4040";
|
||||||
|
return `${protocol}://${hostHeader}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRemoteUrlForTokenType(
|
||||||
|
scopedStore: TaskStore,
|
||||||
|
req: Request,
|
||||||
|
tokenType: "persistent" | "short-lived",
|
||||||
|
ttlMs?: number,
|
||||||
|
): Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||||
|
const baseUrl = new URL(resolveRemoteOrigin(req));
|
||||||
|
let token: string;
|
||||||
|
let expiresAt: string | null = null;
|
||||||
|
|
||||||
|
if (tokenType === "short-lived") {
|
||||||
|
const ttl = Math.floor(Number(ttlMs ?? 900_000));
|
||||||
|
if (!Number.isFinite(ttl) || ttl < REMOTE_MIN_TTL_MS || ttl > REMOTE_MAX_TTL_MS) {
|
||||||
|
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
|
||||||
|
}
|
||||||
|
token = generateRemoteToken();
|
||||||
|
const expiryMs = Date.now() + ttl;
|
||||||
|
remoteShortLivedTokens.set(token, { expiresAt: expiryMs });
|
||||||
|
expiresAt = new Date(expiryMs).toISOString();
|
||||||
|
} else {
|
||||||
|
token = await ensurePersistentRemoteToken(scopedStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl.searchParams.set("token", token);
|
||||||
|
return { url: baseUrl.toString(), tokenType, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
|
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
|
||||||
* Returns false when the monitor's rootDir is set and differs from the store's root.
|
* Returns false when the monitor's rootDir is set and differs from the store's root.
|
||||||
@@ -2946,6 +3001,148 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Remote Access Routes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/remote/settings", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const settings = await scopedStore.getSettings();
|
||||||
|
const persistentToken = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
|
||||||
|
res.json({
|
||||||
|
settings: {
|
||||||
|
remoteEnabled: Boolean(settings.remoteEnabled),
|
||||||
|
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||||
|
remoteTailscaleEnabled: Boolean(settings.remoteTailscaleEnabled),
|
||||||
|
remoteCloudflareEnabled: Boolean(settings.remoteCloudflareEnabled),
|
||||||
|
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
|
||||||
|
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
|
||||||
|
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to load remote settings");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/remote/status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const settings = await scopedStore.getSettings();
|
||||||
|
res.json({
|
||||||
|
provider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||||
|
state: "stopped",
|
||||||
|
url: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to load remote status");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/remote/provider/activate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const provider = req.body?.provider;
|
||||||
|
if (provider !== "tailscale" && provider !== "cloudflare") {
|
||||||
|
throw new ApiError(400, "Invalid remote provider", { code: "INVALID_PROVIDER" });
|
||||||
|
}
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
await scopedStore.updateSettings({ remoteActiveProvider: provider });
|
||||||
|
res.json({ activeProvider: provider });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to activate remote provider");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/remote/tunnel/start", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const settings = await scopedStore.getSettings();
|
||||||
|
const provider = (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||||
|
if (!provider) {
|
||||||
|
throw new ApiError(409, "No active provider configured", { code: "NO_ACTIVE_PROVIDER" });
|
||||||
|
}
|
||||||
|
res.json({ state: "starting", provider });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to start remote tunnel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/remote/tunnel/stop", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const settings = await scopedStore.getSettings();
|
||||||
|
const provider = (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||||
|
res.json({ state: "stopped", provider });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to stop remote tunnel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/remote/token/persistent/regenerate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const token = generateRemoteToken();
|
||||||
|
await scopedStore.updateSettings({ remotePersistentToken: token });
|
||||||
|
res.json({ token, maskedToken: maskRemoteToken(token) });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to regenerate persistent token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/remote/token/short-lived/generate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ttlMs = Number(req.body?.ttlMs ?? 900_000);
|
||||||
|
if (!Number.isFinite(ttlMs) || ttlMs < REMOTE_MIN_TTL_MS || ttlMs > REMOTE_MAX_TTL_MS) {
|
||||||
|
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
|
||||||
|
}
|
||||||
|
const token = generateRemoteToken();
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||||
|
remoteShortLivedTokens.set(token, { expiresAt: Date.parse(expiresAt) });
|
||||||
|
res.json({ token, expiresAt, ttlMs });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to generate short-lived token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/remote/url", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||||
|
const ttlMs = typeof req.query.ttlMs === "string" ? Number(req.query.ttlMs) : undefined;
|
||||||
|
const payload = await buildRemoteUrlForTokenType(scopedStore, req, tokenType, ttlMs);
|
||||||
|
res.json(payload);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to generate remote URL");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/remote/qr", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||||
|
const ttlMs = typeof req.query.ttlMs === "string" ? Number(req.query.ttlMs) : undefined;
|
||||||
|
const format = req.query.format === "image/svg" ? "image/svg" : "text";
|
||||||
|
const payload = await buildRemoteUrlForTokenType(scopedStore, req, tokenType, ttlMs);
|
||||||
|
if (format === "image/svg") {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="white"/><text x="10" y="42" font-size="12" fill="black">${payload.url.replace(/&/g, "&").replace(/</g, "<")}</text></svg>`;
|
||||||
|
res.json({ ...payload, format, data: svg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ...payload, format, data: payload.url });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err, "Failed to generate remote QR payload");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Inbound Settings Sync Endpoints ────────────────────────────────
|
// ── Inbound Settings Sync Endpoints ────────────────────────────────
|
||||||
// These endpoints are called by remote nodes to deliver settings or request auth data.
|
// These endpoints are called by remote nodes to deliver settings or request auth data.
|
||||||
// They validate apiKey auth before accepting data.
|
// They validate apiKey auth before accepting data.
|
||||||
|
|||||||
Reference in New Issue
Block a user