agent update!
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "platform-agent",
|
"name": "platform-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
+50
-1
@@ -25,7 +25,7 @@ from rclpy.executors import SingleThreadedExecutor
|
|||||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
|
|
||||||
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped
|
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
||||||
from lifecycle_msgs.srv import GetState
|
from lifecycle_msgs.srv import GetState
|
||||||
from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
|
from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
|
||||||
from nav_msgs.srv import GetMap
|
from nav_msgs.srv import GetMap
|
||||||
@@ -237,6 +237,13 @@ class SubscriberNode(Node):
|
|||||||
"/rviz/routing/rough_goal",
|
"/rviz/routing/rough_goal",
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
|
self._cmd_vel_pub = None
|
||||||
|
if _ROBOT_STACK == "nav2":
|
||||||
|
self._cmd_vel_pub = self.create_publisher(
|
||||||
|
Twist,
|
||||||
|
"/cmd_vel",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
|
||||||
# Short control trajectory / long predicted path
|
# Short control trajectory / long predicted path
|
||||||
self._trajectory = None
|
self._trajectory = None
|
||||||
@@ -1247,6 +1254,17 @@ class SubscriberNode(Node):
|
|||||||
self._nav2_goal_handle = None
|
self._nav2_goal_handle = None
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
def publish_nav2_stop(self, repeat=1, interval=0.1):
|
||||||
|
if self._cmd_vel_pub is None:
|
||||||
|
return {"topic": "/cmd_vel", "published": 0}
|
||||||
|
|
||||||
|
zero_velocity = Twist()
|
||||||
|
for index in range(max(1, repeat)):
|
||||||
|
self._cmd_vel_pub.publish(zero_velocity)
|
||||||
|
if index + 1 < repeat:
|
||||||
|
time.sleep(interval)
|
||||||
|
return {"topic": "/cmd_vel", "published": max(1, repeat)}
|
||||||
|
|
||||||
|
|
||||||
sub_node: Optional[SubscriberNode] = None
|
sub_node: Optional[SubscriberNode] = None
|
||||||
_spin_thread: Optional[threading.Thread] = None
|
_spin_thread: Optional[threading.Thread] = None
|
||||||
@@ -1676,6 +1694,37 @@ def disengage():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/emergency_stop")
|
||||||
|
def emergency_stop():
|
||||||
|
"""Stop motion immediately through the active robot stack.
|
||||||
|
|
||||||
|
This is a software stop. A physical emergency stop still has to be handled
|
||||||
|
by the platform's safety controller.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if _ROBOT_STACK == "nav2":
|
||||||
|
# Stop the controller first, then cancel Nav2 so a pending action
|
||||||
|
# cannot issue another velocity command while cancellation completes.
|
||||||
|
initial_stop = sub_node.publish_nav2_stop()
|
||||||
|
cancel_result = sub_node.cancel_nav2_goal()
|
||||||
|
repeated_stop = sub_node.publish_nav2_stop(repeat=4)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"stack": "nav2",
|
||||||
|
"cancelled": cancel_result.get("ok", False),
|
||||||
|
"cmdVel": {
|
||||||
|
"topic": initial_stop["topic"],
|
||||||
|
"published": initial_stop["published"] + repeated_stop["published"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = disengage()
|
||||||
|
return {**result, "stack": _ROBOT_STACK, "emergency": True}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/reset")
|
@app.post("/reset")
|
||||||
def reset():
|
def reset():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ values = {
|
|||||||
"TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"),
|
"TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"),
|
||||||
"TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"),
|
"TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"),
|
||||||
"AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"),
|
"AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"),
|
||||||
|
"AGENT_DEVICE_ID": first("agentDeviceId", "agent_device_id"),
|
||||||
}
|
}
|
||||||
if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]:
|
if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]:
|
||||||
values["INSTALL_TAILSCALE"] = "true"
|
values["INSTALL_TAILSCALE"] = "true"
|
||||||
@@ -690,6 +691,7 @@ write_agent_config_from_env() {
|
|||||||
CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \
|
CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \
|
||||||
KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \
|
KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \
|
||||||
AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \
|
AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \
|
||||||
|
AGENT_DEVICE_ID="$AGENT_DEVICE_ID" \
|
||||||
bun -e '
|
bun -e '
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = process.env.AGENT_CONFIG_PATH;
|
const path = process.env.AGENT_CONFIG_PATH;
|
||||||
@@ -700,6 +702,10 @@ write_agent_config_from_env() {
|
|||||||
cfg.camera ??= {};
|
cfg.camera ??= {};
|
||||||
cfg.desktop ??= {};
|
cfg.desktop ??= {};
|
||||||
cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || "";
|
cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || "";
|
||||||
|
cfg.updates ??= {};
|
||||||
|
cfg.updates.enabled = cfg.updates.enabled !== false;
|
||||||
|
cfg.updates.checkUrl = cfg.updates.checkUrl || "https://portal-api.autonomystudio.net/api/provision/agent/update-check";
|
||||||
|
cfg.updates.deviceId = process.env.AGENT_DEVICE_ID || cfg.updates.deviceId || "";
|
||||||
cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces";
|
cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces";
|
||||||
cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware";
|
cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware";
|
||||||
cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0";
|
cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0";
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,110 @@
|
|||||||
|
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { arch } from "node:process";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import type { AgentConfig } from "./config.ts";
|
||||||
|
|
||||||
|
type UpdateCheckResponse = {
|
||||||
|
latestVersion?: string;
|
||||||
|
releaseBaseUrl?: string;
|
||||||
|
updateAvailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentUpdateResult = {
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string | null;
|
||||||
|
status: "disabled" | "unconfigured" | "current" | "updated" | "failed";
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function packageRoot(): string {
|
||||||
|
return resolve(import.meta.dir, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCurrentVersion(): Promise<string> {
|
||||||
|
const packageJson = JSON.parse(await readFile(join(packageRoot(), "package.json"), "utf8")) as { version?: unknown };
|
||||||
|
return typeof packageJson.version === "string" && packageJson.version.trim() ? packageJson.version.trim() : "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveArchitecture(): "amd64" | "arm64" | null {
|
||||||
|
if (arch === "x64") return "amd64";
|
||||||
|
if (arch === "arm64") return "arm64";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(command: string[], cwd?: string): Promise<void> {
|
||||||
|
const process = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" });
|
||||||
|
const exitCode = await process.exited;
|
||||||
|
if (exitCode === 0) return;
|
||||||
|
const stderr = (await new Response(process.stderr).text()).trim();
|
||||||
|
throw new Error(stderr || `${basename(command[0])} exited with code ${exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceRelease(releaseBaseUrl: string, version: string): Promise<void> {
|
||||||
|
const architecture = archiveArchitecture();
|
||||||
|
if (!architecture) throw new Error(`unsupported architecture: ${arch}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${releaseBaseUrl}/platform-agent-linux-${architecture}.tar.gz`, {
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`release download failed: HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const stagingRoot = await mkdtemp(join(tmpdir(), "platform-agent-update-"));
|
||||||
|
try {
|
||||||
|
const archivePath = join(stagingRoot, "platform-agent.tar.gz");
|
||||||
|
await writeFile(archivePath, new Uint8Array(await response.arrayBuffer()));
|
||||||
|
await run(["tar", "-xzf", archivePath, "-C", stagingRoot]);
|
||||||
|
|
||||||
|
const releaseRoot = join(stagingRoot, "platform-agent");
|
||||||
|
const releasePackage = JSON.parse(await readFile(join(releaseRoot, "package.json"), "utf8")) as { version?: unknown };
|
||||||
|
if (releasePackage.version !== version) {
|
||||||
|
throw new Error(`release version mismatch: expected ${version}, received ${String(releasePackage.version)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = packageRoot();
|
||||||
|
for (const entry of ["src", "scripts", "package.json", "bun.lock", "tsconfig.json"]) {
|
||||||
|
await cp(join(releaseRoot, entry), join(root, entry), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
await run(["bun", "install", "--frozen-lockfile"], root);
|
||||||
|
} finally {
|
||||||
|
await rm(stagingRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForAgentUpdate(config: AgentConfig): Promise<AgentUpdateResult> {
|
||||||
|
const currentVersion = await readCurrentVersion();
|
||||||
|
if (!config.updates.enabled) return { currentVersion, latestVersion: null, status: "disabled" };
|
||||||
|
if (!config.security.gatewayToken || !config.updates.deviceId || !config.updates.checkUrl) {
|
||||||
|
return { currentVersion, latestVersion: null, status: "unconfigured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.updates.checkUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-agent-gateway-token": config.security.gatewayToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ deviceId: config.updates.deviceId, version: currentVersion }),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`update check failed: HTTP ${response.status}`);
|
||||||
|
const update = await response.json() as UpdateCheckResponse;
|
||||||
|
const latestVersion = typeof update.latestVersion === "string" ? update.latestVersion : null;
|
||||||
|
if (!update.updateAvailable || !latestVersion) {
|
||||||
|
return { currentVersion, latestVersion, status: "current" };
|
||||||
|
}
|
||||||
|
if (!update.releaseBaseUrl) throw new Error("update response does not include a release URL");
|
||||||
|
|
||||||
|
await replaceRelease(update.releaseBaseUrl, latestVersion);
|
||||||
|
return { currentVersion, latestVersion, status: "updated" };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: null,
|
||||||
|
status: "failed",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,14 @@ export function createBridgeApi(config: AgentConfig): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/emergency-stop", async (c) => {
|
||||||
|
try {
|
||||||
|
return c.json(await bridgeJson(config, "/emergency_stop", { method: "POST" }));
|
||||||
|
} catch (error) {
|
||||||
|
return errorJson(error, "Failed to emergency stop robot.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/reset", async (c) => {
|
app.post("/reset", async (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json(await bridgeJson(config, "/reset", { method: "POST" }));
|
return c.json(await bridgeJson(config, "/reset", { method: "POST" }));
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export interface AgentConfig {
|
|||||||
security: {
|
security: {
|
||||||
gatewayToken: string;
|
gatewayToken: string;
|
||||||
};
|
};
|
||||||
|
updates: {
|
||||||
|
enabled: boolean;
|
||||||
|
checkUrl: string;
|
||||||
|
deviceId: string;
|
||||||
|
};
|
||||||
iframe: {
|
iframe: {
|
||||||
title: string;
|
title: string;
|
||||||
allowOrigins: string[];
|
allowOrigins: string[];
|
||||||
@@ -72,6 +77,11 @@ const defaultConfig: AgentConfig = {
|
|||||||
security: {
|
security: {
|
||||||
gatewayToken: "",
|
gatewayToken: "",
|
||||||
},
|
},
|
||||||
|
updates: {
|
||||||
|
enabled: true,
|
||||||
|
checkUrl: "https://portal-api.autonomystudio.net/api/provision/agent/update-check",
|
||||||
|
deviceId: "",
|
||||||
|
},
|
||||||
iframe: {
|
iframe: {
|
||||||
title: "KasmVNC",
|
title: "KasmVNC",
|
||||||
allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"],
|
allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"],
|
||||||
@@ -175,6 +185,10 @@ function mergeConfig(base: AgentConfig, patch: unknown): AgentConfig {
|
|||||||
...base.security,
|
...base.security,
|
||||||
...(isPlainObject(patch.security) ? patch.security : {}),
|
...(isPlainObject(patch.security) ? patch.security : {}),
|
||||||
},
|
},
|
||||||
|
updates: {
|
||||||
|
...base.updates,
|
||||||
|
...(isPlainObject(patch.updates) ? patch.updates : {}),
|
||||||
|
},
|
||||||
iframe: {
|
iframe: {
|
||||||
...base.iframe,
|
...base.iframe,
|
||||||
...(isPlainObject(patch.iframe) ? patch.iframe : {}),
|
...(isPlainObject(patch.iframe) ? patch.iframe : {}),
|
||||||
@@ -218,6 +232,10 @@ export function normalizeConfig(config: AgentConfig): AgentConfig {
|
|||||||
config.server.port = Number(config.server.port);
|
config.server.port = Number(config.server.port);
|
||||||
config.security ??= { gatewayToken: "" };
|
config.security ??= { gatewayToken: "" };
|
||||||
config.security.gatewayToken = String(config.security.gatewayToken ?? "");
|
config.security.gatewayToken = String(config.security.gatewayToken ?? "");
|
||||||
|
config.updates ??= { ...defaultConfig.updates };
|
||||||
|
config.updates.enabled = config.updates.enabled !== false;
|
||||||
|
config.updates.checkUrl = String(config.updates.checkUrl || defaultConfig.updates.checkUrl).replace(/\/+$/, "");
|
||||||
|
config.updates.deviceId = String(config.updates.deviceId ?? "").trim();
|
||||||
config.desktop.provider = "kasmvnc";
|
config.desktop.provider = "kasmvnc";
|
||||||
config.desktop.display = String(config.desktop.display || ":1");
|
config.desktop.display = String(config.desktop.display || ":1");
|
||||||
config.desktop.webPort = Number(config.desktop.webPort);
|
config.desktop.webPort = Number(config.desktop.webPort);
|
||||||
@@ -334,6 +352,8 @@ export async function saveConfigPatch(current: AgentConfig, patch: unknown): Pro
|
|||||||
const normalized = normalizeConfig(merged);
|
const normalized = normalizeConfig(merged);
|
||||||
await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||||
Object.assign(current.server, normalized.server);
|
Object.assign(current.server, normalized.server);
|
||||||
|
Object.assign(current.security, normalized.security);
|
||||||
|
Object.assign(current.updates, normalized.updates);
|
||||||
Object.assign(current.iframe, normalized.iframe);
|
Object.assign(current.iframe, normalized.iframe);
|
||||||
Object.assign(current.desktop, normalized.desktop);
|
Object.assign(current.desktop, normalized.desktop);
|
||||||
Object.assign(current.camera, normalized.camera);
|
Object.assign(current.camera, normalized.camera);
|
||||||
@@ -377,6 +397,10 @@ export function getConfigSummary(config: AgentConfig) {
|
|||||||
pipelineDescConfigured: config.camera.pipelineDesc.length > 0,
|
pipelineDescConfigured: config.camera.pipelineDesc.length > 0,
|
||||||
},
|
},
|
||||||
workspace: config.workspace,
|
workspace: config.workspace,
|
||||||
|
updates: {
|
||||||
|
enabled: config.updates.enabled,
|
||||||
|
deviceIdConfigured: config.updates.deviceId.length > 0,
|
||||||
|
},
|
||||||
ros: config.ros,
|
ros: config.ros,
|
||||||
modules: config.modules,
|
modules: config.modules,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,21 @@ import { createDisplayApi } from "./display.ts";
|
|||||||
import { createKasmVncApi } from "./kasmVnc.ts";
|
import { createKasmVncApi } from "./kasmVnc.ts";
|
||||||
import { createBridgeApi, createViewerControlApi } from "./bridge.ts";
|
import { createBridgeApi, createViewerControlApi } from "./bridge.ts";
|
||||||
import { createVisualizationApi } from "./visualization.ts";
|
import { createVisualizationApi } from "./visualization.ts";
|
||||||
|
import { checkForAgentUpdate } from "./agentUpdate.ts";
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
const updateResult = await checkForAgentUpdate(config);
|
||||||
|
if (updateResult.status === "updated") {
|
||||||
|
console.log(`[agent-update] updated ${updateResult.currentVersion} -> ${updateResult.latestVersion}; restarting service.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (updateResult.status === "failed") {
|
||||||
|
console.warn(`[agent-update] ${updateResult.message}`);
|
||||||
|
}
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const camera = new CameraManager(config);
|
const camera = new CameraManager(config);
|
||||||
|
const agentStartedAt = new Date();
|
||||||
|
const agentStartedAtMs = agentStartedAt.getTime();
|
||||||
const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS";
|
const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS";
|
||||||
const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS";
|
const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS";
|
||||||
|
|
||||||
@@ -327,8 +338,15 @@ app.get("/camera", (c) => {
|
|||||||
|
|
||||||
app.get("/health", (c) => c.json({
|
app.get("/health", (c) => c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
startedAt: agentStartedAt.toISOString(),
|
||||||
|
uptimeSeconds: Math.floor((Date.now() - agentStartedAtMs) / 1000),
|
||||||
sshUser: process.env.AGENT_SSH_USER || process.env.USER || null,
|
sshUser: process.env.AGENT_SSH_USER || process.env.USER || null,
|
||||||
workspaceRoot: config.workspace.root,
|
workspaceRoot: config.workspace.root,
|
||||||
|
agentVersion: updateResult.currentVersion,
|
||||||
|
update: {
|
||||||
|
status: updateResult.status,
|
||||||
|
latestVersion: updateResult.latestVersion,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
app.post("/api/ssh/authorized-keys", async (c) => {
|
app.post("/api/ssh/authorized-keys", async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|||||||
Reference in New Issue
Block a user