Spaces:
Paused
Paused
| import { execFile } from "node:child_process"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { promisify } from "node:util"; | |
| import type { GatewayServiceRuntime } from "./service-runtime.js"; | |
| import { colorize, isRich, theme } from "../terminal/theme.js"; | |
| import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; | |
| import { resolveGatewayStateDir } from "./paths.js"; | |
| import { parseKeyValueOutput } from "./runtime-parse.js"; | |
| const execFileAsync = promisify(execFile); | |
| const formatLine = (label: string, value: string) => { | |
| const rich = isRich(); | |
| return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; | |
| }; | |
| function resolveTaskName(env: Record<string, string | undefined>): string { | |
| const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); | |
| if (override) { | |
| return override; | |
| } | |
| return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); | |
| } | |
| export function resolveTaskScriptPath(env: Record<string, string | undefined>): string { | |
| const override = env.OPENCLAW_TASK_SCRIPT?.trim(); | |
| if (override) { | |
| return override; | |
| } | |
| const scriptName = env.OPENCLAW_TASK_SCRIPT_NAME?.trim() || "gateway.cmd"; | |
| const stateDir = resolveGatewayStateDir(env); | |
| return path.join(stateDir, scriptName); | |
| } | |
| function quoteCmdArg(value: string): string { | |
| if (!/[ \t"]/g.test(value)) { | |
| return value; | |
| } | |
| return `"${value.replace(/"/g, '\\"')}"`; | |
| } | |
| function resolveTaskUser(env: Record<string, string | undefined>): string | null { | |
| const username = env.USERNAME || env.USER || env.LOGNAME; | |
| if (!username) { | |
| return null; | |
| } | |
| if (username.includes("\\")) { | |
| return username; | |
| } | |
| const domain = env.USERDOMAIN; | |
| if (domain) { | |
| return `${domain}\\${username}`; | |
| } | |
| return username; | |
| } | |
| function parseCommandLine(value: string): string[] { | |
| const args: string[] = []; | |
| let current = ""; | |
| let inQuotes = false; | |
| let escapeNext = false; | |
| for (const char of value) { | |
| if (escapeNext) { | |
| current += char; | |
| escapeNext = false; | |
| continue; | |
| } | |
| if (char === "\\") { | |
| escapeNext = true; | |
| continue; | |
| } | |
| if (char === '"') { | |
| inQuotes = !inQuotes; | |
| continue; | |
| } | |
| if (!inQuotes && /\s/.test(char)) { | |
| if (current) { | |
| args.push(current); | |
| current = ""; | |
| } | |
| continue; | |
| } | |
| current += char; | |
| } | |
| if (current) { | |
| args.push(current); | |
| } | |
| return args; | |
| } | |
| export async function readScheduledTaskCommand(env: Record<string, string | undefined>): Promise<{ | |
| programArguments: string[]; | |
| workingDirectory?: string; | |
| environment?: Record<string, string>; | |
| } | null> { | |
| const scriptPath = resolveTaskScriptPath(env); | |
| try { | |
| const content = await fs.readFile(scriptPath, "utf8"); | |
| let workingDirectory = ""; | |
| let commandLine = ""; | |
| const environment: Record<string, string> = {}; | |
| for (const rawLine of content.split(/\r?\n/)) { | |
| const line = rawLine.trim(); | |
| if (!line) { | |
| continue; | |
| } | |
| if (line.startsWith("@echo")) { | |
| continue; | |
| } | |
| if (line.toLowerCase().startsWith("rem ")) { | |
| continue; | |
| } | |
| if (line.toLowerCase().startsWith("set ")) { | |
| const assignment = line.slice(4).trim(); | |
| const index = assignment.indexOf("="); | |
| if (index > 0) { | |
| const key = assignment.slice(0, index).trim(); | |
| const value = assignment.slice(index + 1).trim(); | |
| if (key) { | |
| environment[key] = value; | |
| } | |
| } | |
| continue; | |
| } | |
| if (line.toLowerCase().startsWith("cd /d ")) { | |
| workingDirectory = line.slice("cd /d ".length).trim().replace(/^"|"$/g, ""); | |
| continue; | |
| } | |
| commandLine = line; | |
| break; | |
| } | |
| if (!commandLine) { | |
| return null; | |
| } | |
| return { | |
| programArguments: parseCommandLine(commandLine), | |
| ...(workingDirectory ? { workingDirectory } : {}), | |
| ...(Object.keys(environment).length > 0 ? { environment } : {}), | |
| }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export type ScheduledTaskInfo = { | |
| status?: string; | |
| lastRunTime?: string; | |
| lastRunResult?: string; | |
| }; | |
| export function parseSchtasksQuery(output: string): ScheduledTaskInfo { | |
| const entries = parseKeyValueOutput(output, ":"); | |
| const info: ScheduledTaskInfo = {}; | |
| const status = entries.status; | |
| if (status) { | |
| info.status = status; | |
| } | |
| const lastRunTime = entries["last run time"]; | |
| if (lastRunTime) { | |
| info.lastRunTime = lastRunTime; | |
| } | |
| const lastRunResult = entries["last run result"]; | |
| if (lastRunResult) { | |
| info.lastRunResult = lastRunResult; | |
| } | |
| return info; | |
| } | |
| function buildTaskScript({ | |
| description, | |
| programArguments, | |
| workingDirectory, | |
| environment, | |
| }: { | |
| description?: string; | |
| programArguments: string[]; | |
| workingDirectory?: string; | |
| environment?: Record<string, string | undefined>; | |
| }): string { | |
| const lines: string[] = ["@echo off"]; | |
| if (description?.trim()) { | |
| lines.push(`rem ${description.trim()}`); | |
| } | |
| if (workingDirectory) { | |
| lines.push(`cd /d ${quoteCmdArg(workingDirectory)}`); | |
| } | |
| if (environment) { | |
| for (const [key, value] of Object.entries(environment)) { | |
| if (!value) { | |
| continue; | |
| } | |
| lines.push(`set ${key}=${value}`); | |
| } | |
| } | |
| const command = programArguments.map(quoteCmdArg).join(" "); | |
| lines.push(command); | |
| return `${lines.join("\r\n")}\r\n`; | |
| } | |
| async function execSchtasks( | |
| args: string[], | |
| ): Promise<{ stdout: string; stderr: string; code: number }> { | |
| try { | |
| const { stdout, stderr } = await execFileAsync("schtasks", args, { | |
| encoding: "utf8", | |
| windowsHide: true, | |
| }); | |
| return { | |
| stdout: String(stdout ?? ""), | |
| stderr: String(stderr ?? ""), | |
| code: 0, | |
| }; | |
| } catch (error) { | |
| const e = error as { | |
| stdout?: unknown; | |
| stderr?: unknown; | |
| code?: unknown; | |
| message?: unknown; | |
| }; | |
| return { | |
| stdout: typeof e.stdout === "string" ? e.stdout : "", | |
| stderr: | |
| typeof e.stderr === "string" ? e.stderr : typeof e.message === "string" ? e.message : "", | |
| code: typeof e.code === "number" ? e.code : 1, | |
| }; | |
| } | |
| } | |
| async function assertSchtasksAvailable() { | |
| const res = await execSchtasks(["/Query"]); | |
| if (res.code === 0) { | |
| return; | |
| } | |
| const detail = res.stderr || res.stdout; | |
| throw new Error(`schtasks unavailable: ${detail || "unknown error"}`.trim()); | |
| } | |
| export async function installScheduledTask({ | |
| env, | |
| stdout, | |
| programArguments, | |
| workingDirectory, | |
| environment, | |
| description, | |
| }: { | |
| env: Record<string, string | undefined>; | |
| stdout: NodeJS.WritableStream; | |
| programArguments: string[]; | |
| workingDirectory?: string; | |
| environment?: Record<string, string | undefined>; | |
| description?: string; | |
| }): Promise<{ scriptPath: string }> { | |
| await assertSchtasksAvailable(); | |
| const scriptPath = resolveTaskScriptPath(env); | |
| await fs.mkdir(path.dirname(scriptPath), { recursive: true }); | |
| const taskDescription = | |
| description ?? | |
| formatGatewayServiceDescription({ | |
| profile: env.OPENCLAW_PROFILE, | |
| version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, | |
| }); | |
| const script = buildTaskScript({ | |
| description: taskDescription, | |
| programArguments, | |
| workingDirectory, | |
| environment, | |
| }); | |
| await fs.writeFile(scriptPath, script, "utf8"); | |
| const taskName = resolveTaskName(env); | |
| const quotedScript = quoteCmdArg(scriptPath); | |
| const baseArgs = [ | |
| "/Create", | |
| "/F", | |
| "/SC", | |
| "ONLOGON", | |
| "/RL", | |
| "LIMITED", | |
| "/TN", | |
| taskName, | |
| "/TR", | |
| quotedScript, | |
| ]; | |
| const taskUser = resolveTaskUser(env); | |
| let create = await execSchtasks( | |
| taskUser ? [...baseArgs, "/RU", taskUser, "/NP", "/IT"] : baseArgs, | |
| ); | |
| if (create.code !== 0 && taskUser) { | |
| create = await execSchtasks(baseArgs); | |
| } | |
| if (create.code !== 0) { | |
| const detail = create.stderr || create.stdout; | |
| const hint = /access is denied/i.test(detail) | |
| ? " Run PowerShell as Administrator or rerun without installing the daemon." | |
| : ""; | |
| throw new Error(`schtasks create failed: ${detail}${hint}`.trim()); | |
| } | |
| await execSchtasks(["/Run", "/TN", taskName]); | |
| // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). | |
| stdout.write("\n"); | |
| stdout.write(`${formatLine("Installed Scheduled Task", taskName)}\n`); | |
| stdout.write(`${formatLine("Task script", scriptPath)}\n`); | |
| return { scriptPath }; | |
| } | |
| export async function uninstallScheduledTask({ | |
| env, | |
| stdout, | |
| }: { | |
| env: Record<string, string | undefined>; | |
| stdout: NodeJS.WritableStream; | |
| }): Promise<void> { | |
| await assertSchtasksAvailable(); | |
| const taskName = resolveTaskName(env); | |
| await execSchtasks(["/Delete", "/F", "/TN", taskName]); | |
| const scriptPath = resolveTaskScriptPath(env); | |
| try { | |
| await fs.unlink(scriptPath); | |
| stdout.write(`${formatLine("Removed task script", scriptPath)}\n`); | |
| } catch { | |
| stdout.write(`Task script not found at ${scriptPath}\n`); | |
| } | |
| } | |
| function isTaskNotRunning(res: { stdout: string; stderr: string; code: number }): boolean { | |
| const detail = `${res.stderr || res.stdout}`.toLowerCase(); | |
| return detail.includes("not running"); | |
| } | |
| export async function stopScheduledTask({ | |
| stdout, | |
| env, | |
| }: { | |
| stdout: NodeJS.WritableStream; | |
| env?: Record<string, string | undefined>; | |
| }): Promise<void> { | |
| await assertSchtasksAvailable(); | |
| const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>)); | |
| const res = await execSchtasks(["/End", "/TN", taskName]); | |
| if (res.code !== 0 && !isTaskNotRunning(res)) { | |
| throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); | |
| } | |
| stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`); | |
| } | |
| export async function restartScheduledTask({ | |
| stdout, | |
| env, | |
| }: { | |
| stdout: NodeJS.WritableStream; | |
| env?: Record<string, string | undefined>; | |
| }): Promise<void> { | |
| await assertSchtasksAvailable(); | |
| const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>)); | |
| await execSchtasks(["/End", "/TN", taskName]); | |
| const res = await execSchtasks(["/Run", "/TN", taskName]); | |
| if (res.code !== 0) { | |
| throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim()); | |
| } | |
| stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`); | |
| } | |
| export async function isScheduledTaskInstalled(args: { | |
| env?: Record<string, string | undefined>; | |
| }): Promise<boolean> { | |
| await assertSchtasksAvailable(); | |
| const taskName = resolveTaskName(args.env ?? (process.env as Record<string, string | undefined>)); | |
| const res = await execSchtasks(["/Query", "/TN", taskName]); | |
| return res.code === 0; | |
| } | |
| export async function readScheduledTaskRuntime( | |
| env: Record<string, string | undefined> = process.env as Record<string, string | undefined>, | |
| ): Promise<GatewayServiceRuntime> { | |
| try { | |
| await assertSchtasksAvailable(); | |
| } catch (err) { | |
| return { | |
| status: "unknown", | |
| detail: String(err), | |
| }; | |
| } | |
| const taskName = resolveTaskName(env); | |
| const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); | |
| if (res.code !== 0) { | |
| const detail = (res.stderr || res.stdout).trim(); | |
| const missing = detail.toLowerCase().includes("cannot find the file"); | |
| return { | |
| status: missing ? "stopped" : "unknown", | |
| detail: detail || undefined, | |
| missingUnit: missing, | |
| }; | |
| } | |
| const parsed = parseSchtasksQuery(res.stdout || ""); | |
| const statusRaw = parsed.status?.toLowerCase(); | |
| const status = statusRaw === "running" ? "running" : statusRaw ? "stopped" : "unknown"; | |
| return { | |
| status, | |
| state: parsed.status, | |
| lastRunTime: parsed.lastRunTime, | |
| lastRunResult: parsed.lastRunResult, | |
| }; | |
| } | |