scripts/autostart.mjs
Rama main · réplica de lectura del repositorio original.
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
access,
mkdir,
readFile,
rename,
rm,
stat,
writeFile
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
exposureOrigins,
lanAgentUrls,
loadConfig,
localAgentUrl,
serverBindHost
} from "../packages/config/dist/index.js";
import {
LINUX_UNIT_NAME,
WINDOWS_TASK_NAME,
buildSystemdUserUnit,
buildWindowsHostCompilePowerShellCommand,
buildWindowsInstallationManifest,
buildWindowsTaskStatusPowerShellCommand,
buildWindowsTaskXml,
defaultConfigPath,
defaultLinuxUnitPath,
defaultWindowsPowerShellPath,
encodePowerShellCommand,
parseAutostartArgs,
parseWindowsInstallationManifest,
parseWindowsTaskStatus,
windowsAutostartPaths
} from "./autostart-core.mjs";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const agentPath = path.join(repoRoot, "apps", "agent", "dist", "index.js");
const windowsHostSourcePath = path.join(scriptDir, "windows-autostart-host.cs");
function run(command, args, { acceptedExitCodes = [0] } = {}) {
const result = spawnSync(command, args, {
encoding: "utf8",
windowsHide: true
});
if (result.error) throw result.error;
if (!acceptedExitCodes.includes(result.status ?? -1)) {
const details = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}${details ? `\n${details}` : ""}`);
}
return result;
}
async function regularFileExists(filePath) {
try {
return (await stat(filePath)).isFile();
} catch {
return false;
}
}
async function requireRegularFile(filePath, label) {
if (!await regularFileExists(filePath)) {
throw new Error(`${label} does not exist or is not a regular file: ${filePath}`);
}
}
async function resolveInputs(options) {
const configPath = options.configPath ?? path.resolve(process.env.PALMTTY_CONFIG ?? defaultConfigPath());
await requireRegularFile(agentPath, "Built PalmTTY Agent");
await requireRegularFile(configPath, "PalmTTY config");
if (options.envFile) await requireRegularFile(options.envFile, "Environment file");
return { configPath, envFile: options.envFile };
}
async function validateLinuxEnvFilePermissions(envFile) {
if (!envFile) return;
const info = await stat(envFile);
if ((info.mode & 0o077) !== 0) {
throw new Error(`Linux environment file must not be group/world accessible (chmod 600): ${envFile}`);
}
}
function currentWindowsSid() {
const result = run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
const match = result.stdout.match(/S-[0-9]+(?:-[0-9]+)+/u);
if (!match) throw new Error("Unable to resolve the current Windows user SID");
return match[0];
}
function warnRollbackFailure(action, error) {
console.warn(
`[PalmTTY] autostart rollback warning (${action}): ${error instanceof Error ? error.message : error}`
);
}
function runRollback(action, command, args, options) {
try {
run(command, args, options);
} catch (error) {
warnRollbackFailure(action, error);
}
}
const WINDOWS_FILE_RETRY_CODES = new Set(["EBUSY", "EACCES", "EPERM"]);
async function renameWithRetry(source, destination, attempts = 20) {
for (let attempt = 1; ; attempt += 1) {
try {
await rename(source, destination);
return;
} catch (error) {
const retryable = process.platform === "win32" &&
WINDOWS_FILE_RETRY_CODES.has(error?.code) &&
attempt < attempts;
if (!retryable) throw error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
async function removeWithRetry(target) {
for (let attempt = 1; ; attempt += 1) {
try { await rm(target, { force: true }); return; }
catch (error) {
if (process.platform !== "win32" || !WINDOWS_FILE_RETRY_CODES.has(error?.code) || attempt >= 20) throw error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
async function moveIfPresent(source, destination) {
if (!await regularFileExists(source)) return false;
await renameWithRetry(source, destination);
return true;
}
async function restoreBackup(backup, target, present) {
await rm(target, { force: true }).catch(() => undefined);
if (present && await regularFileExists(backup)) {
await renameWithRetry(backup, target);
}
}
function compileWindowsHost(powershellPath, sourcePath, outputPath) {
const command = buildWindowsHostCompilePowerShellCommand({
sourcePath,
outputPath
});
run(powershellPath, [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
encodePowerShellCommand(command)
]);
}
function processIsAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function printNetworkStatus(configPath) {
try {
const config = await loadConfig(configPath);
const mode = config.server.exposure.mode;
console.log("network:");
console.log(` exposure: ${mode}`);
console.log(` listen: ${serverBindHost(config)}:${config.server.port}`);
if (mode === "local") {
console.log(` browserUrl: ${localAgentUrl(config)}`);
console.log(" lanAccess: disabled (local exposure)");
return;
}
if (mode === "lan") {
const urls = lanAgentUrls(config);
console.log(` browserUrl: ${localAgentUrl(config)}`);
console.log(" lanAccess: enabled (authenticated, unencrypted private/overlay HTTP)");
console.log(" sourceAddressGate: private/overlay clients only");
if (urls.length === 0) {
console.log(" lanUrls: none detected");
} else {
console.log(" lanUrls:");
for (const url of urls) console.log(` - ${url}`);
}
if (process.platform === "win32") {
console.log(" firewall: keep the Agent port scoped to trusted Windows Private networks");
}
return;
}
if (mode === "reverseProxy") {
console.log(` upstreamUrl: ${localAgentUrl(config)}`);
}
console.log(" browserOrigins:");
for (const origin of exposureOrigins(config)) console.log(` - ${origin}`);
} catch (error) {
console.log(`network: unavailable (${error instanceof Error ? error.message : error})`);
}
}
async function installWindows(inputs) {
const userSid = currentWindowsSid();
const powershellPath = defaultWindowsPowerShellPath();
const paths = windowsAutostartPaths();
const installId = randomUUID();
const tempHost = path.join(paths.directory, `host-${installId}.tmp.exe`);
const tempInstallation = path.join(paths.directory, `installation-${installId}.tmp.json`);
const tempTaskXml = path.join(os.tmpdir(), `palmtty-task-${installId}.xml`);
const backupHost = path.join(paths.directory, `host-${installId}.bak.exe`);
const backupInstallation = path.join(paths.directory, `installation-${installId}.bak.json`);
await requireRegularFile(powershellPath, "Windows PowerShell");
await requireRegularFile(windowsHostSourcePath, "Windows autostart host source");
await mkdir(paths.directory, { recursive: true });
compileWindowsHost(powershellPath, windowsHostSourcePath, tempHost);
await requireRegularFile(tempHost, "Compiled Windows autostart host");
const installation = buildWindowsInstallationManifest({
nodePath: process.execPath,
agentPath,
repoRoot,
configPath: inputs.configPath,
envFile: inputs.envFile
});
await writeFile(tempInstallation, `${JSON.stringify(installation, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600
});
const xml = buildWindowsTaskXml({
userSid,
hostPath: paths.host,
installationPath: paths.installation,
workingDirectory: paths.directory
});
await writeFile(tempTaskXml, `\ufeff${xml}`, { encoding: "utf16le", mode: 0o600 });
let previousHost = false;
let previousInstallation = false;
let replacedFiles = false;
try {
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { acceptedExitCodes: [0, 1] });
previousHost = await moveIfPresent(paths.host, backupHost);
previousInstallation = await moveIfPresent(paths.installation, backupInstallation);
await renameWithRetry(tempHost, paths.host);
await renameWithRetry(tempInstallation, paths.installation);
replacedFiles = true;
run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", tempTaskXml, "/F"]);
await rm(paths.lastError, { force: true });
await rm(paths.runtime, { force: true });
run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
await removeWithRetry(backupHost);
await removeWithRetry(backupInstallation);
} catch (error) {
runRollback("end failed installation", "schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], {
acceptedExitCodes: [0, 1]
});
if (replacedFiles) {
await restoreBackup(backupHost, paths.host, previousHost).catch((rollbackError) => {
warnRollbackFailure("restore Windows host", rollbackError);
});
await restoreBackup(backupInstallation, paths.installation, previousInstallation).catch((rollbackError) => {
warnRollbackFailure("restore installation manifest", rollbackError);
});
if (previousHost && previousInstallation) {
runRollback("restart previous Windows task", "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], {
acceptedExitCodes: [0, 1]
});
} else {
runRollback("remove incomplete Windows task", "schtasks.exe", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {
acceptedExitCodes: [0, 1]
});
}
}
throw error;
} finally {
for (const temporary of [
tempHost,
tempInstallation,
tempTaskXml,
backupHost,
backupInstallation
]) {
await rm(temporary, { force: true }).catch(() => undefined);
}
}
console.log(`[PalmTTY] installed Windows console-free sign-in task: ${WINDOWS_TASK_NAME}`);
console.log(`[PalmTTY] launcher: ${paths.host}`);
console.log(`[PalmTTY] config: ${inputs.configPath}`);
if (inputs.envFile) console.log(`[PalmTTY] environment file: ${inputs.envFile}`);
await printNetworkStatus(inputs.configPath);
}
function queryWindowsTaskStatus() {
const command = buildWindowsTaskStatusPowerShellCommand();
const result = run(defaultWindowsPowerShellPath(), [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
encodePowerShellCommand(command)
]);
return parseWindowsTaskStatus(result.stdout);
}
async function readWindowsRuntime(paths) {
try {
const source = await readFile(paths.runtime, "utf8");
const value = JSON.parse(source);
if (
!value ||
typeof value !== "object" ||
!Number.isInteger(value.hostPid) ||
!Number.isInteger(value.agentPid)
) {
return undefined;
}
return {
hostPid: value.hostPid,
agentPid: value.agentPid,
startedAtUtc: typeof value.startedAtUtc === "string" ? value.startedAtUtc : undefined
};
} catch {
return undefined;
}
}
async function statusWindows() {
const status = queryWindowsTaskStatus();
const paths = windowsAutostartPaths();
console.log("[PalmTTY] autostart status");
console.log("platform: windows");
console.log(`task: ${WINDOWS_TASK_NAME}`);
console.log(`installed: ${status.installed ? "yes" : "no"}`);
if (!status.installed) return;
console.log(`state: ${status.state}`);
console.log(`lastTaskResult: ${status.lastTaskResult}`);
console.log(`launcher: ${await regularFileExists(paths.host) ? "native-gui" : "missing"}`);
console.log(`launcherPath: ${paths.host}`);
if (status.lastRunTime) console.log(`lastRunTime: ${status.lastRunTime}`);
if (status.nextRunTime) console.log(`nextRunTime: ${status.nextRunTime}`);
let installation;
try {
installation = parseWindowsInstallationManifest(
await readFile(paths.installation, "utf8")
);
console.log(`config: ${installation.configPath}`);
} catch (error) {
console.log(`installation: invalid (${error instanceof Error ? error.message : error})`);
}
const runtime = await readWindowsRuntime(paths);
if (runtime) {
console.log(`hostPid: ${runtime.hostPid} (${processIsAlive(runtime.hostPid) ? "alive" : "stale"})`);
console.log(`agentPid: ${runtime.agentPid} (${processIsAlive(runtime.agentPid) ? "alive" : "stale"})`);
if (runtime.startedAtUtc) console.log(`startedAt: ${runtime.startedAtUtc}`);
} else {
console.log("runtime: unavailable");
}
if (await regularFileExists(paths.lastError)) {
const firstLine = (await readFile(paths.lastError, "utf8")).split(/\r?\n/u)[0];
console.log(`lastError: ${firstLine || "present"} (${paths.lastError})`);
}
if (installation) await printNetworkStatus(installation.configPath);
}
function restartWindows() {
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { acceptedExitCodes: [0, 1] });
run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
console.log(`[PalmTTY] restarted Windows sign-in task: ${WINDOWS_TASK_NAME}`);
}
async function uninstallWindows() {
const paths = windowsAutostartPaths();
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { acceptedExitCodes: [0, 1] });
run("schtasks.exe", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { acceptedExitCodes: [0, 1] });
await rm(paths.directory, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 100
});
console.log(`[PalmTTY] removed Windows sign-in task and launcher state: ${paths.directory}`);
}
async function installLinux(inputs) {
await validateLinuxEnvFilePermissions(inputs.envFile);
const unitPath = defaultLinuxUnitPath();
const unit = buildSystemdUserUnit({
nodePath: process.execPath,
agentPath,
repoRoot,
configPath: inputs.configPath,
envFile: inputs.envFile
});
await mkdir(path.dirname(unitPath), { recursive: true });
let previousUnit;
try {
previousUnit = await readFile(unitPath, "utf8");
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
const previousEnabled = run(
"systemctl",
["--user", "is-enabled", LINUX_UNIT_NAME],
{ acceptedExitCodes: [0, 1, 3, 4] }
).stdout.trim() === "enabled";
const previousActive = run(
"systemctl",
["--user", "is-active", LINUX_UNIT_NAME],
{ acceptedExitCodes: [0, 1, 3, 4] }
).stdout.trim() === "active";
const tempPath = `${unitPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, unit, { encoding: "utf8", mode: 0o644 });
await rename(tempPath, unitPath);
try {
run("systemctl", ["--user", "daemon-reload"]);
run("systemctl", ["--user", "enable", LINUX_UNIT_NAME]);
run("systemctl", ["--user", "restart", LINUX_UNIT_NAME]);
} catch (error) {
if (previousUnit === undefined) await rm(unitPath, { force: true });
else await writeFile(unitPath, previousUnit, { encoding: "utf8", mode: 0o644 });
runRollback("daemon-reload", "systemctl", ["--user", "daemon-reload"], { acceptedExitCodes: [0, 1] });
if (previousUnit === undefined || !previousEnabled) {
runRollback("disable restored unit", "systemctl", ["--user", "disable", LINUX_UNIT_NAME], { acceptedExitCodes: [0, 1, 4, 5] });
} else {
runRollback("enable restored unit", "systemctl", ["--user", "enable", LINUX_UNIT_NAME], { acceptedExitCodes: [0, 1] });
}
if (previousUnit !== undefined && previousActive) {
runRollback("restart restored unit", "systemctl", ["--user", "restart", LINUX_UNIT_NAME], { acceptedExitCodes: [0, 1, 5] });
}
throw error;
}
console.log(`[PalmTTY] installed systemd user service: ${unitPath}`);
console.log(`[PalmTTY] config: ${inputs.configPath}`);
if (inputs.envFile) console.log(`[PalmTTY] environment file: ${inputs.envFile}`);
await printNetworkStatus(inputs.configPath);
}
function linuxServiceState(action, acceptedExitCodes) {
const result = run("systemctl", ["--user", action, LINUX_UNIT_NAME], { acceptedExitCodes });
const state = result.stdout.trim();
if (!state && result.stderr.trim()) {
throw new Error(`systemctl --user ${action} could not determine ${LINUX_UNIT_NAME} state: ${result.stderr.trim()}`);
}
return state || "unknown";
}
function statusLinux() {
const enabled = linuxServiceState("is-enabled", [0, 1, 3, 4]);
const active = linuxServiceState("is-active", [0, 1, 3, 4]);
console.log(`[PalmTTY] ${LINUX_UNIT_NAME}: enabled=${enabled}, active=${active}`);
}
function restartLinux() {
run("systemctl", ["--user", "restart", LINUX_UNIT_NAME]);
console.log(`[PalmTTY] restarted systemd user service: ${LINUX_UNIT_NAME}`);
}
async function uninstallLinux() {
run("systemctl", ["--user", "disable", "--now", LINUX_UNIT_NAME], { acceptedExitCodes: [0, 1, 4, 5] });
const unitPath = defaultLinuxUnitPath();
await rm(unitPath, { force: true });
run("systemctl", ["--user", "daemon-reload"]);
console.log(`[PalmTTY] removed systemd user service: ${unitPath}`);
}
async function main() {
const options = parseAutostartArgs(process.argv.slice(2));
if (process.platform !== "win32" && process.platform !== "linux") {
throw new Error(`Autostart is supported on Windows and Linux; current platform is ${process.platform}`);
}
if (options.command === "status") {
if (process.platform === "win32") await statusWindows();
else statusLinux();
return;
}
if (options.command === "restart") {
if (process.platform === "win32") restartWindows();
else restartLinux();
return;
}
if (options.command === "uninstall") {
if (process.platform === "win32") await uninstallWindows();
else await uninstallLinux();
return;
}
await access(repoRoot);
const inputs = await resolveInputs(options);
if (process.platform === "win32") await installWindows(inputs);
else await installLinux(inputs);
}
main().catch((error) => {
console.error(`[PalmTTY] autostart failed: ${error instanceof Error ? error.message : error}`);
process.exitCode = 1;
});