From 35b66e5ad185014826bba824d7d6aa69f2061b8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 17:57:57 +0100 Subject: [PATCH] feat: add onboarding wizard --- CHANGELOG.md | 2 + docs/skills-config.md | 4 +- docs/skills.md | 2 +- package.json | 1 + pnpm-lock.yaml | 25 ++ src/agents/skills-install.ts | 2 + src/agents/skills.ts | 7 +- src/cli/program.ts | 25 ++ src/commands/onboard.ts | 716 +++++++++++++++++++++++++++++++++++ src/config/config.ts | 9 +- src/daemon/constants.ts | 3 + src/daemon/launchd.ts | 282 ++++++++++++++ src/daemon/program-args.ts | 93 +++++ src/daemon/schtasks.ts | 233 ++++++++++++ src/daemon/service.ts | 106 ++++++ src/daemon/systemd.ts | 256 +++++++++++++ 16 files changed, 1759 insertions(+), 7 deletions(-) create mode 100644 src/commands/onboard.ts create mode 100644 src/daemon/constants.ts create mode 100644 src/daemon/launchd.ts create mode 100644 src/daemon/program-args.ts create mode 100644 src/daemon/schtasks.ts create mode 100644 src/daemon/service.ts create mode 100644 src/daemon/systemd.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee9094b0..348db8252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - Tests: add a Z.AI live test gate for smoke validation when keys are present. - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. +- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. +- Skills: allow `bun` as a node manager for skill installs. ### Fixes - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b diff --git a/docs/skills-config.md b/docs/skills-config.md index 657c14fb4..dc2404c19 100644 --- a/docs/skills-config.md +++ b/docs/skills-config.md @@ -20,7 +20,7 @@ All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.jso }, install: { preferBrew: true, - nodeManager: "npm" // npm | pnpm | yarn + nodeManager: "npm" // npm | pnpm | yarn | bun }, entries: { "nano-banana-pro": { @@ -43,7 +43,7 @@ All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.jso bundled skills in the list are eligible (managed/workspace skills unaffected). - `load.extraDirs`: additional skill directories to scan (lowest precedence). - `install.preferBrew`: prefer brew installers when available (default: true). -- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm). +- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm). - `entries.`: per-skill overrides. Per-skill fields: diff --git a/docs/skills.md b/docs/skills.md index 48783665e..515e0f56c 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -77,7 +77,7 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install": Notes: - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). -- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn). +- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn/bun). If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config or blocked by `skills.allowBundled` for bundled skills). diff --git a/package.json b/package.json index 13a57461d..a06cde53e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "packageManager": "pnpm@10.23.0", "dependencies": { "@grammyjs/transformer-throttler": "^1.2.1", + "@clack/prompts": "^0.11.0", "@homebridge/ciao": "^1.3.4", "@mariozechner/pi-agent-core": "^0.30.2", "@mariozechner/pi-ai": "^0.30.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5894da5e1..bb83906b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: .: dependencies: + '@clack/prompts': + specifier: ^0.11.0 + version: 0.11.0 '@grammyjs/transformer-throttler': specifier: ^1.2.1 version: 1.2.1(grammy@1.38.4) @@ -314,6 +317,12 @@ packages: '@cacheable/utils@2.3.3': resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@discordjs/builders@1.13.1': resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} engines: {node: '>=16.11.0'} @@ -2631,6 +2640,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -3109,6 +3121,17 @@ snapshots: hashery: 1.3.0 keyv: 5.5.5 + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@discordjs/builders@1.13.1': dependencies: '@discordjs/formatters': 0.6.2 @@ -5503,6 +5526,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index c41a04644..df241b36a 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -50,6 +50,8 @@ function buildNodeInstallCommand( return ["pnpm", "add", "-g", packageName]; case "yarn": return ["yarn", "global", "add", packageName]; + case "bun": + return ["bun", "add", "-g", packageName]; default: return ["npm", "install", "-g", packageName]; } diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 8ad19586b..16d1f60b4 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -37,7 +37,7 @@ export type ClawdisSkillMetadata = { export type SkillsInstallPreferences = { preferBrew: boolean; - nodeManager: "npm" | "pnpm" | "yarn"; + nodeManager: "npm" | "pnpm" | "yarn" | "bun"; }; type ParsedSkillFrontmatter = Record; @@ -179,7 +179,10 @@ export function resolveSkillsInstallPreferences( typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : ""; const manager = managerRaw.toLowerCase(); const nodeManager = - manager === "pnpm" || manager === "yarn" || manager === "npm" + manager === "pnpm" || + manager === "yarn" || + manager === "bun" || + manager === "npm" ? (manager as SkillsInstallPreferences["nodeManager"]) : "npm"; return { preferBrew, nodeManager }; diff --git a/src/cli/program.ts b/src/cli/program.ts index 8109104d0..40b231311 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; import { healthCommand } from "../commands/health.js"; +import { onboardCommand } from "../commands/onboard.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; @@ -105,8 +106,16 @@ export function buildProgram() { "--workspace ", "Agent workspace directory (default: ~/clawd; stored as agent.workspace)", ) + .option("--wizard", "Run the interactive onboarding wizard", false) .action(async (opts) => { try { + if (opts.wizard) { + await onboardCommand( + { workspace: opts.workspace as string | undefined }, + defaultRuntime, + ); + return; + } await setupCommand( { workspace: opts.workspace as string | undefined }, defaultRuntime, @@ -117,6 +126,22 @@ export function buildProgram() { } }); + program + .command("onboard") + .description("Interactive wizard to set up the gateway, workspace, and skills") + .option("--workspace ", "Agent workspace directory (default: ~/clawd)") + .action(async (opts) => { + try { + await onboardCommand( + { workspace: opts.workspace as string | undefined }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("login") .description("Link your personal WhatsApp via QR (web provider)") diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts new file mode 100644 index 000000000..4b938a5d2 --- /dev/null +++ b/src/commands/onboard.ts @@ -0,0 +1,716 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + cancel, + confirm, + intro, + isCancel, + note, + outro, + select, + spinner, + text, + multiselect, +} from "@clack/prompts"; +import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; +import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; + +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; +import { resolveClawdisAgentDir } from "../agents/agent-paths.js"; +import { installSkill } from "../agents/skills-install.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import type { ClawdisConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDIS, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; +import { resolveSessionTranscriptsDir } from "../config/sessions.js"; +import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import { assertSupportedRuntime } from "../infra/runtime-guard.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { CONFIG_DIR, resolveUserPath, sleep } from "../utils.js"; +import { healthCommand } from "./health.js"; + +type OnboardMode = "local" | "remote"; +type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip"; +type GatewayAuthChoice = "off" | "token" | "password"; +type ResetScope = "config" | "config+creds+sessions" | "full"; + +type OnboardOptions = { + mode?: OnboardMode; + workspace?: string; + nonInteractive?: boolean; +}; + +function guardCancel(value: T, runtime: RuntimeEnv): T { + if (isCancel(value)) { + cancel("Setup cancelled."); + runtime.exit(0); + } + return value; +} + +function summarizeExistingConfig(config: ClawdisConfig): string { + const rows: string[] = []; + if (config.agent?.workspace) + rows.push(`workspace: ${config.agent.workspace}`); + if (config.agent?.model) rows.push(`model: ${config.agent.model}`); + if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); + if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`); + if (config.skills?.install?.nodeManager) { + rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`); + } + return rows.length ? rows.join("\n") : "No key settings detected."; +} + +function randomToken(): string { + return crypto.randomBytes(24).toString("hex"); +} + +async function openUrl(url: string): Promise { + const platform = process.platform; + const command = + platform === "darwin" + ? ["open", url] + : platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + try { + await runCommandWithTimeout(command, { timeoutMs: 5_000 }); + } catch { + // ignore; we still print the URL for manual open + } +} + +async function ensureWorkspaceAndSessions( + workspaceDir: string, + runtime: RuntimeEnv, +) { + const ws = await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: true, + }); + runtime.log(`Workspace OK: ${ws.dir}`); + const sessionsDir = resolveSessionTranscriptsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + runtime.log(`Sessions OK: ${sessionsDir}`); +} + +async function writeOAuthCredentials( + provider: "anthropic", + creds: OAuthCredentials, +): Promise { + const dir = path.join(CONFIG_DIR, "credentials"); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const filePath = path.join(dir, "oauth.json"); + let storage: Record = {}; + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + if (parsed && typeof parsed === "object") storage = parsed; + } catch { + // ignore + } + storage[provider] = creds; + await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8"); + await fs.chmod(filePath, 0o600); +} + +async function setAnthropicApiKey(key: string) { + const agentDir = resolveClawdisAgentDir(); + const authStorage = discoverAuthStorage(agentDir); + authStorage.set("anthropic", { type: "api_key", key }); +} + +function applyMinimaxConfig(cfg: ClawdisConfig): ClawdisConfig { + const allowed = new Set(cfg.agent?.allowedModels ?? []); + allowed.add("anthropic/claude-opus-4-5"); + allowed.add("lmstudio/minimax-m2.1-gs32"); + + const aliases = { ...(cfg.agent?.modelAliases ?? {}) }; + if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; + if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; + + const providers = { ...(cfg.models?.providers ?? {}) }; + if (!providers.lmstudio) { + providers.lmstudio = { + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "lmstudio", + api: "openai-responses", + models: [ + { + id: "minimax-m2.1-gs32", + name: "MiniMax M2.1 GS32", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 196608, + maxTokens: 8192, + }, + ], + }; + } + + return { + ...cfg, + agent: { + ...cfg.agent, + model: "Minimax", + allowedModels: Array.from(allowed), + modelAliases: aliases, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +function upsertSkillEntry( + cfg: ClawdisConfig, + skillKey: string, + patch: { apiKey?: string }, +): ClawdisConfig { + const entries = { ...(cfg.skills?.entries ?? {}) }; + const existing = (entries[skillKey] as { apiKey?: string } | undefined) ?? {}; + entries[skillKey] = { ...existing, ...patch }; + return { + ...cfg, + skills: { + ...cfg.skills, + entries, + }, + }; +} + +function resolveNodeManagerOptions(): Array<{ value: "npm" | "pnpm" | "bun"; label: string }> { + return [ + { value: "npm", label: "npm" }, + { value: "pnpm", label: "pnpm" }, + { value: "bun", label: "bun" }, + ]; +} + +async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise { + if (!pathname) return; + try { + await fs.access(pathname); + } catch { + return; + } + try { + await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 }); + runtime.log(`Moved to Trash: ${pathname}`); + } catch { + runtime.log(`Failed to move to Trash (manual delete): ${pathname}`); + } +} + +async function handleReset( + scope: ResetScope, + workspaceDir: string, + runtime: RuntimeEnv, +) { + await moveToTrash(CONFIG_PATH_CLAWDIS, runtime); + if (scope === "config") return; + await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime); + await moveToTrash(resolveSessionTranscriptsDir(), runtime); + if (scope === "full") { + await moveToTrash(workspaceDir, runtime); + } +} + +async function setupSkills( + cfg: ClawdisConfig, + workspaceDir: string, + runtime: RuntimeEnv, +): Promise { + const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); + const eligible = report.skills.filter((s) => s.eligible); + const missing = report.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ); + const blocked = report.skills.filter((s) => s.blockedByAllowlist); + + note( + [ + `Eligible: ${eligible.length}`, + `Missing requirements: ${missing.length}`, + `Blocked by allowlist: ${blocked.length}`, + ].join("\n"), + "Skills status", + ); + + const shouldConfigure = guardCancel( + await confirm({ + message: "Configure skills now? (recommended)", + initialValue: true, + }), + runtime, + ); + if (!shouldConfigure) return cfg; + + const nodeManager = guardCancel( + await select({ + message: "Preferred node manager for skill installs", + options: resolveNodeManagerOptions(), + }), + runtime, + ); + + let next: ClawdisConfig = { + ...cfg, + skills: { + ...cfg.skills, + install: { + ...cfg.skills?.install, + nodeManager, + }, + }, + }; + + const installable = missing.filter( + (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, + ); + if (installable.length > 0) { + const toInstall = guardCancel( + await multiselect({ + message: "Install missing skill dependencies", + options: installable.map((skill) => ({ + value: skill.name, + label: `${skill.emoji ?? "🧩"} ${skill.name}`, + hint: skill.install[0]?.label ?? "install", + })), + }), + runtime, + ); + + for (const name of toInstall as string[]) { + const target = installable.find((s) => s.name === name); + if (!target || target.install.length === 0) continue; + const installId = target.install[0]?.id; + if (!installId) continue; + const spin = spinner(); + spin.start(`Installing ${name}…`); + const result = await installSkill({ + workspaceDir, + skillName: target.name, + installId, + config: next, + }); + spin.stop(result.ok ? `Installed ${name}` : `Install failed: ${name}`); + if (!result.ok && result.stderr) { + runtime.log(result.stderr.trim()); + } + } + } + + for (const skill of missing) { + if (!skill.primaryEnv || skill.missing.env.length === 0) continue; + const wantsKey = guardCancel( + await confirm({ + message: `Set ${skill.primaryEnv} for ${skill.name}?`, + initialValue: false, + }), + runtime, + ); + if (!wantsKey) continue; + const apiKey = guardCancel( + await text({ + message: `Enter ${skill.primaryEnv}`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); + } + + return next; +} + +export async function onboardCommand( + opts: OnboardOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + assertSupportedRuntime(runtime); + + if (opts.nonInteractive) { + runtime.error("Non-interactive mode is not implemented yet."); + runtime.exit(1); + return; + } + + intro("Clawdis onboarding"); + + const snapshot = await readConfigFileSnapshot(); + let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; + + if (snapshot.exists) { + const title = snapshot.valid ? "Existing config detected" : "Invalid config"; + note(summarizeExistingConfig(baseConfig), title); + if (!snapshot.valid && snapshot.issues.length > 0) { + note( + snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`).join("\n"), + "Config issues", + ); + } + + const action = guardCancel( + await select({ + message: "Config handling", + options: [ + { value: "keep", label: "Use existing values" }, + { value: "modify", label: "Update values" }, + { value: "reset", label: "Reset" }, + ], + }), + runtime, + ); + + if (action === "reset") { + const workspaceDefault = + baseConfig.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const resetScope = guardCancel( + await select({ + message: "Reset scope", + options: [ + { value: "config", label: "Config only" }, + { value: "config+creds+sessions", label: "Config + creds + sessions" }, + { value: "full", label: "Full reset (config + creds + sessions + workspace)" }, + ], + }), + runtime, + ) as ResetScope; + await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); + baseConfig = {}; + } else if (action === "keep" && !snapshot.valid) { + baseConfig = {}; + } + } + + const mode = + opts.mode ?? + (guardCancel( + await select({ + message: "Where will the Gateway run?", + options: [ + { value: "local", label: "Local (this machine)" }, + { value: "remote", label: "Remote (info-only)" }, + ], + }), + runtime, + ) as OnboardMode); + + if (mode === "remote") { + note( + [ + "Run on the gateway host:", + "- clawdis setup", + "- clawdis gateway-daemon --port 18789", + "- OAuth creds: ~/.clawdis/credentials/oauth.json", + "- Workspace: ~/clawd", + ].join("\n"), + "Remote setup", + ); + outro("Done. Local config unchanged."); + return; + } + + const workspaceInput = + opts.workspace ?? + (guardCancel( + await text({ + message: "Workspace directory", + initialValue: + baseConfig.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR, + }), + runtime, + ) as string); + + const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_AGENT_WORKSPACE_DIR); + + let nextConfig: ClawdisConfig = { + ...baseConfig, + agent: { + ...baseConfig.agent, + workspace: workspaceDir, + }, + gateway: { + ...baseConfig.gateway, + mode: "local", + }, + }; + + const authChoice = guardCancel( + await select({ + message: "Model/auth choice", + options: [ + { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "apiKey", label: "Anthropic API key" }, + { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, + { value: "skip", label: "Skip for now" }, + ], + }), + runtime, + ) as AuthChoice; + + if (authChoice === "oauth") { + note( + "Browser will open. Paste the code shown after login (code#state).", + "Anthropic OAuth", + ); + const spin = spinner(); + spin.start("Waiting for authorization…"); + let oauthCreds: OAuthCredentials | null = null; + try { + oauthCreds = await loginAnthropic( + async (url) => { + await openUrl(url); + runtime.log(`Open: ${url}`); + }, + async () => { + const code = guardCancel( + await text({ + message: "Paste authorization code (code#state)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + return String(code); + }, + ); + spin.stop("OAuth complete"); + await writeOAuthCredentials("anthropic", oauthCreds); + } catch (err) { + spin.stop("OAuth failed"); + runtime.error(String(err)); + } + } else if (authChoice === "apiKey") { + const key = guardCancel( + await text({ + message: "Enter Anthropic API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + await setAnthropicApiKey(String(key).trim()); + } else if (authChoice === "minimax") { + nextConfig = applyMinimaxConfig(nextConfig); + } + + const portRaw = guardCancel( + await text({ + message: "Gateway port", + initialValue: "18789", + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + runtime, + ); + const port = Number.parseInt(String(portRaw), 10); + + let bind = guardCancel( + await select({ + message: "Gateway bind", + options: [ + { value: "loopback", label: "Loopback (127.0.0.1)" }, + { value: "lan", label: "LAN" }, + { value: "tailnet", label: "Tailnet" }, + { value: "auto", label: "Auto" }, + ], + }), + runtime, + ); + + let authMode = guardCancel( + await select({ + message: "Gateway auth", + options: [ + { value: "off", label: "Off (loopback only)" }, + { value: "token", label: "Token" }, + { value: "password", label: "Password" }, + ], + }), + runtime, + ) as GatewayAuthChoice; + + const tailscaleMode = guardCancel( + await select({ + message: "Tailscale exposure", + options: [ + { value: "off", label: "Off" }, + { value: "serve", label: "Serve" }, + { value: "funnel", label: "Funnel" }, + ], + }), + runtime, + ) as "off" | "serve" | "funnel"; + + let tailscaleResetOnExit = false; + if (tailscaleMode !== "off") { + tailscaleResetOnExit = guardCancel( + await confirm({ + message: "Reset Tailscale serve/funnel on exit?", + initialValue: false, + }), + runtime, + ); + } + + if (tailscaleMode !== "off" && bind !== "loopback") { + note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note"); + bind = "loopback"; + } + + if (authMode === "off" && bind !== "loopback") { + note("Non-loopback bind requires auth. Switching to token auth.", "Note"); + authMode = "token"; + } + + if (tailscaleMode === "funnel" && authMode !== "password") { + note("Tailscale funnel requires password auth.", "Note"); + authMode = "password"; + } + + let gatewayToken: string | undefined; + if (authMode === "token") { + const tokenInput = guardCancel( + await text({ + message: "Gateway token (blank to generate)", + initialValue: randomToken(), + }), + runtime, + ); + gatewayToken = String(tokenInput).trim() || randomToken(); + } + + if (authMode === "password") { + const password = guardCancel( + await text({ + message: "Gateway password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "password", + password: String(password).trim(), + }, + }, + }; + } else if (authMode === "token") { + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { ...nextConfig.gateway?.auth, mode: "token" }, + }, + }; + } + + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + bind: String(bind), + tailscale: { + ...nextConfig.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); + await ensureWorkspaceAndSessions(workspaceDir, runtime); + + nextConfig = await setupSkills(nextConfig, workspaceDir, runtime); + await writeConfigFile(nextConfig); + + const installDaemon = guardCancel( + await confirm({ + message: "Install Gateway daemon (recommended)", + initialValue: true, + }), + runtime, + ); + + if (installDaemon) { + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + if (loaded) { + const action = guardCancel( + await select({ + message: "Gateway service already installed", + options: [ + { value: "restart", label: "Restart" }, + { value: "reinstall", label: "Reinstall" }, + { value: "skip", label: "Skip" }, + ], + }), + runtime, + ); + if (action === "restart") { + await service.restart({ stdout: process.stdout }); + } else if (action === "reinstall") { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } + } + + if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) { + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDIS_GATEWAY_TOKEN: gatewayToken, + CLAWDIS_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } + } + + await sleep(1500); + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(`Health check failed: ${String(err)}`); + } + + note( + [ + "Add nodes for extra features:", + "- macOS app (system + notifications)", + "- iOS app (camera/canvas)", + "- Android app (camera/canvas)", + ].join("\n"), + "Optional apps", + ); + + outro("Onboarding complete."); +} diff --git a/src/config/config.ts b/src/config/config.ts index 689cfaef9..214f9e868 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -328,7 +328,7 @@ export type SkillsLoadConfig = { export type SkillsInstallConfig = { preferBrew?: boolean; - nodeManager?: "npm" | "pnpm" | "yarn"; + nodeManager?: "npm" | "pnpm" | "yarn" | "bun"; }; export type SkillsConfig = { @@ -945,7 +945,12 @@ const ClawdisSchema = z.object({ .object({ preferBrew: z.boolean().optional(), nodeManager: z - .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")]) + .union([ + z.literal("npm"), + z.literal("pnpm"), + z.literal("yarn"), + z.literal("bun"), + ]) .optional(), }) .optional(), diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts new file mode 100644 index 000000000..0ce264413 --- /dev/null +++ b/src/daemon/constants.ts @@ -0,0 +1,3 @@ +export const GATEWAY_LAUNCH_AGENT_LABEL = "com.steipete.clawdis.gateway"; +export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdis-gateway"; +export const GATEWAY_WINDOWS_TASK_NAME = "Clawdis Gateway"; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts new file mode 100644 index 000000000..173dde899 --- /dev/null +++ b/src/daemon/launchd.ts @@ -0,0 +1,282 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { GATEWAY_LAUNCH_AGENT_LABEL } from "./constants.js"; + +const execFileAsync = promisify(execFile); + +function resolveHomeDir(env: Record): string { + const home = env.HOME?.trim() || env.USERPROFILE?.trim(); + if (!home) throw new Error("Missing HOME"); + return home; +} + +export function resolveLaunchAgentPlistPath( + env: Record, +): string { + const home = resolveHomeDir(env); + return path.join( + home, + "Library", + "LaunchAgents", + `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`, + ); +} + +export function resolveGatewayLogPaths(env: Record): { + logDir: string; + stdoutPath: string; + stderrPath: string; +} { + const home = resolveHomeDir(env); + const logDir = path.join(home, ".clawdis", "logs"); + return { + logDir, + stdoutPath: path.join(logDir, "gateway.log"), + stderrPath: path.join(logDir, "gateway.err.log"), + }; +} + +function plistEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function plistUnescape(value: string): string { + return value + .replaceAll("'", "'") + .replaceAll(""", '"') + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} + +function renderEnvDict( + env: Record | undefined, +): string { + if (!env) return ""; + const entries = Object.entries(env).filter( + ([, value]) => typeof value === "string" && value.trim(), + ); + if (entries.length === 0) return ""; + const items = entries + .map( + ([key, value]) => ` + ${plistEscape(key)} + ${plistEscape(value?.trim() ?? "")}`, + ) + .join(""); + return ` + EnvironmentVariables + ${items} + `; +} + +export async function readLaunchAgentProgramArguments( + env: Record, +): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { + const plistPath = resolveLaunchAgentPlistPath(env); + try { + const plist = await fs.readFile(plistPath, "utf8"); + const programMatch = plist.match( + /ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/i, + ); + if (!programMatch) return null; + const args = Array.from( + programMatch[1].matchAll(/([\s\S]*?)<\/string>/gi), + ).map((match) => plistUnescape(match[1] ?? "").trim()); + const workingDirMatch = plist.match( + /WorkingDirectory<\/key>\s*([\s\S]*?)<\/string>/i, + ); + const workingDirectory = workingDirMatch + ? plistUnescape(workingDirMatch[1] ?? "").trim() + : ""; + return { + programArguments: args.filter(Boolean), + ...(workingDirectory ? { workingDirectory } : {}), + }; + } catch { + return null; + } +} + +export function buildLaunchAgentPlist({ + label = GATEWAY_LAUNCH_AGENT_LABEL, + programArguments, + workingDirectory, + stdoutPath, + stderrPath, + environment, +}: { + label?: string; + programArguments: string[]; + workingDirectory?: string; + stdoutPath: string; + stderrPath: string; + environment?: Record; +}): string { + const argsXml = programArguments + .map((arg) => `\n ${plistEscape(arg)}`) + .join(""); + const workingDirXml = workingDirectory + ? ` + WorkingDirectory + ${plistEscape(workingDirectory)}` + : ""; + const envXml = renderEnvDict(environment); + return ` + + + + Label + ${plistEscape(label)} + RunAtLoad + + KeepAlive + + ProgramArguments + ${argsXml} + + ${workingDirXml} + StandardOutPath + ${plistEscape(stdoutPath)} + StandardErrorPath + ${plistEscape(stderrPath)}${envXml} + + +`; +} + +async function execLaunchctl( + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await execFileAsync("launchctl", args, { + encoding: "utf8", + }); + 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, + }; + } +} + +function resolveGuiDomain(): string { + if (typeof process.getuid !== "function") return "gui/501"; + return `gui/${process.getuid()}`; +} + +export async function isLaunchAgentLoaded(): Promise { + const domain = resolveGuiDomain(); + const label = GATEWAY_LAUNCH_AGENT_LABEL; + const res = await execLaunchctl(["print", `${domain}/${label}`]); + return res.code === 0; +} + +export async function uninstallLaunchAgent({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + const domain = resolveGuiDomain(); + const plistPath = resolveLaunchAgentPlistPath(env); + await execLaunchctl(["bootout", domain, plistPath]); + await execLaunchctl(["unload", plistPath]); + + try { + await fs.access(plistPath); + } catch { + stdout.write(`LaunchAgent not found at ${plistPath}\n`); + return; + } + + const home = resolveHomeDir(env); + const trashDir = path.join(home, ".Trash"); + const dest = path.join(trashDir, `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`); + try { + await fs.mkdir(trashDir, { recursive: true }); + await fs.rename(plistPath, dest); + stdout.write(`Moved LaunchAgent to Trash: ${dest}\n`); + } catch { + stdout.write(`LaunchAgent remains at ${plistPath} (could not move)\n`); + } +} + +export async function installLaunchAgent({ + env, + stdout, + programArguments, + workingDirectory, + environment, +}: { + env: Record; + stdout: NodeJS.WritableStream; + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}): Promise<{ plistPath: string }> { + const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + await fs.mkdir(logDir, { recursive: true }); + + const plistPath = resolveLaunchAgentPlistPath(env); + await fs.mkdir(path.dirname(plistPath), { recursive: true }); + + const plist = buildLaunchAgentPlist({ + programArguments, + workingDirectory, + stdoutPath, + stderrPath, + environment, + }); + await fs.writeFile(plistPath, plist, "utf8"); + + const domain = resolveGuiDomain(); + await execLaunchctl(["bootout", domain, plistPath]); + await execLaunchctl(["unload", plistPath]); + const boot = await execLaunchctl(["bootstrap", domain, plistPath]); + if (boot.code !== 0) { + throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim()); + } + await execLaunchctl(["enable", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]); + await execLaunchctl(["kickstart", "-k", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]); + + stdout.write(`Installed LaunchAgent: ${plistPath}\n`); + stdout.write(`Logs: ${stdoutPath}\n`); + return { plistPath }; +} + +export async function restartLaunchAgent({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + const domain = resolveGuiDomain(); + const label = GATEWAY_LAUNCH_AGENT_LABEL; + const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + if (res.code !== 0) { + throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim()); + } + stdout.write(`Restarted LaunchAgent: ${domain}/${label}\n`); +} diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts new file mode 100644 index 000000000..875601ab1 --- /dev/null +++ b/src/daemon/program-args.ts @@ -0,0 +1,93 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type GatewayProgramArgs = { + programArguments: string[]; + workingDirectory?: string; +}; + +function isNodeRuntime(execPath: string): boolean { + const base = path.basename(execPath).toLowerCase(); + return base === "node" || base === "node.exe"; +} + +async function resolveCliEntrypointPathForService(): Promise { + const argv1 = process.argv[1]; + if (!argv1) throw new Error("Unable to resolve CLI entrypoint path"); + + const normalized = path.resolve(argv1); + const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized); + if (looksLikeDist) { + await fs.access(normalized); + return normalized; + } + + const distCandidates = [ + path.resolve(path.dirname(normalized), "..", "dist", "index.js"), + path.resolve(path.dirname(normalized), "..", "dist", "index.mjs"), + path.resolve(path.dirname(normalized), "dist", "index.js"), + path.resolve(path.dirname(normalized), "dist", "index.mjs"), + ]; + + for (const candidate of distCandidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + // keep going + } + } + + throw new Error( + `Cannot find built CLI at ${distCandidates.join(" or ")}. Run "pnpm build" first, or use dev mode.`, + ); +} + +function resolveRepoRootForDev(): string { + const argv1 = process.argv[1]; + if (!argv1) throw new Error("Unable to resolve repo root"); + const normalized = path.resolve(argv1); + const parts = normalized.split(path.sep); + const srcIndex = parts.lastIndexOf("src"); + if (srcIndex === -1) { + throw new Error("Dev mode requires running from repo (src/index.ts)"); + } + return parts.slice(0, srcIndex).join(path.sep); +} + +async function resolveTsxCliPath(repoRoot: string): Promise { + const candidate = path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"); + await fs.access(candidate); + return candidate; +} + +export async function resolveGatewayProgramArguments(params: { + port: number; + dev?: boolean; +}): Promise { + const gatewayArgs = ["gateway-daemon", "--port", String(params.port)]; + const nodePath = process.execPath; + + if (!params.dev) { + try { + const cliEntrypointPath = await resolveCliEntrypointPathForService(); + return { + programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs], + }; + } catch (error) { + if (!isNodeRuntime(nodePath)) { + return { programArguments: [nodePath, ...gatewayArgs] }; + } + throw error; + } + } + + const repoRoot = resolveRepoRootForDev(); + const tsxCliPath = await resolveTsxCliPath(repoRoot); + const devCliPath = path.join(repoRoot, "src", "index.ts"); + await fs.access(devCliPath); + return { + programArguments: [nodePath, tsxCliPath, devCliPath, ...gatewayArgs], + workingDirectory: repoRoot, + }; +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts new file mode 100644 index 000000000..a19454dbc --- /dev/null +++ b/src/daemon/schtasks.ts @@ -0,0 +1,233 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { GATEWAY_WINDOWS_TASK_NAME } from "./constants.js"; + +const execFileAsync = promisify(execFile); + +function resolveHomeDir(env: Record): string { + const home = env.USERPROFILE?.trim() || env.HOME?.trim(); + if (!home) throw new Error("Missing HOME"); + return home; +} + +function resolveTaskScriptPath(env: Record): string { + const home = resolveHomeDir(env); + return path.join(home, ".clawdis", "gateway.cmd"); +} + +function quoteCmdArg(value: string): string { + if (!/[ \t"]/g.test(value)) return value; + return `"${value.replace(/"/g, '\\"')}"`; +} + +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, +): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { + const scriptPath = resolveTaskScriptPath(env); + try { + const content = await fs.readFile(scriptPath, "utf8"); + let workingDirectory = ""; + let commandLine = ""; + 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 ")) 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 } : {}), + }; + } catch { + return null; + } +} + +function buildTaskScript({ + programArguments, + workingDirectory, + environment, +}: { + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}): string { + const lines: string[] = ["@echo off"]; + 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, +}: { + env: Record; + stdout: NodeJS.WritableStream; + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}): Promise<{ scriptPath: string }> { + await assertSchtasksAvailable(); + const scriptPath = resolveTaskScriptPath(env); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + const script = buildTaskScript({ + programArguments, + workingDirectory, + environment, + }); + await fs.writeFile(scriptPath, script, "utf8"); + + const quotedScript = quoteCmdArg(scriptPath); + const create = await execSchtasks([ + "/Create", + "/F", + "/SC", + "ONLOGON", + "/RL", + "LIMITED", + "/TN", + GATEWAY_WINDOWS_TASK_NAME, + "/TR", + quotedScript, + ]); + if (create.code !== 0) { + throw new Error(`schtasks create failed: ${create.stderr || create.stdout}`.trim()); + } + + await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + stdout.write(`Installed Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); + stdout.write(`Task script: ${scriptPath}\n`); + return { scriptPath }; +} + +export async function uninstallScheduledTask({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + await assertSchtasksAvailable(); + await execSchtasks(["/Delete", "/F", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + + const scriptPath = resolveTaskScriptPath(env); + try { + await fs.unlink(scriptPath); + stdout.write(`Removed task script: ${scriptPath}\n`); + } catch { + stdout.write(`Task script not found at ${scriptPath}\n`); + } +} + +export async function restartScheduledTask({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + await assertSchtasksAvailable(); + await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + const res = await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + if (res.code !== 0) { + throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim()); + } + stdout.write(`Restarted Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); +} + +export async function isScheduledTaskInstalled(): Promise { + await assertSchtasksAvailable(); + const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + return res.code === 0; +} diff --git a/src/daemon/service.ts b/src/daemon/service.ts new file mode 100644 index 000000000..2ca0eb567 --- /dev/null +++ b/src/daemon/service.ts @@ -0,0 +1,106 @@ +import { + installLaunchAgent, + isLaunchAgentLoaded, + readLaunchAgentProgramArguments, + restartLaunchAgent, + uninstallLaunchAgent, +} from "./launchd.js"; +import { + installScheduledTask, + isScheduledTaskInstalled, + readScheduledTaskCommand, + restartScheduledTask, + uninstallScheduledTask, +} from "./schtasks.js"; +import { + installSystemdService, + isSystemdServiceEnabled, + readSystemdServiceExecStart, + restartSystemdService, + uninstallSystemdService, +} from "./systemd.js"; + +export type GatewayServiceInstallArgs = { + env: Record; + stdout: NodeJS.WritableStream; + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}; + +export type GatewayService = { + label: string; + loadedText: string; + notLoadedText: string; + install: (args: GatewayServiceInstallArgs) => Promise; + uninstall: (args: { + env: Record; + stdout: NodeJS.WritableStream; + }) => Promise; + restart: (args: { stdout: NodeJS.WritableStream }) => Promise; + isLoaded: (args: { env: Record }) => Promise; + readCommand: ( + env: Record, + ) => Promise<{ programArguments: string[]; workingDirectory?: string } | null>; +}; + +export function resolveGatewayService(): GatewayService { + if (process.platform === "darwin") { + return { + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + install: async (args) => { + await installLaunchAgent(args); + }, + uninstall: async (args) => { + await uninstallLaunchAgent(args); + }, + restart: async (args) => { + await restartLaunchAgent(args); + }, + isLoaded: async () => isLaunchAgentLoaded(), + readCommand: readLaunchAgentProgramArguments, + }; + } + + if (process.platform === "linux") { + return { + label: "systemd", + loadedText: "enabled", + notLoadedText: "disabled", + install: async (args) => { + await installSystemdService(args); + }, + uninstall: async (args) => { + await uninstallSystemdService(args); + }, + restart: async (args) => { + await restartSystemdService(args); + }, + isLoaded: async () => isSystemdServiceEnabled(), + readCommand: readSystemdServiceExecStart, + }; + } + + if (process.platform === "win32") { + return { + label: "Scheduled Task", + loadedText: "registered", + notLoadedText: "missing", + install: async (args) => { + await installScheduledTask(args); + }, + uninstall: async (args) => { + await uninstallScheduledTask(args); + }, + restart: async (args) => { + await restartScheduledTask(args); + }, + isLoaded: async () => isScheduledTaskInstalled(), + readCommand: readScheduledTaskCommand, + }; + } + + throw new Error(`Gateway service install not supported on ${process.platform}`); +} diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts new file mode 100644 index 000000000..e70871f02 --- /dev/null +++ b/src/daemon/systemd.ts @@ -0,0 +1,256 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { GATEWAY_SYSTEMD_SERVICE_NAME } from "./constants.js"; + +const execFileAsync = promisify(execFile); + +function resolveHomeDir(env: Record): string { + const home = env.HOME?.trim() || env.USERPROFILE?.trim(); + if (!home) throw new Error("Missing HOME"); + return home; +} + +function resolveSystemdUnitPath(env: Record): string { + const home = resolveHomeDir(env); + return path.join( + home, + ".config", + "systemd", + "user", + `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, + ); +} + +function systemdEscapeArg(value: string): string { + if (!/[\s"\\]/.test(value)) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function renderEnvLines( + env: Record | undefined, +): string[] { + if (!env) return []; + const entries = Object.entries(env).filter( + ([, value]) => typeof value === "string" && value.trim(), + ); + if (entries.length === 0) return []; + return entries.map( + ([key, value]) => + `Environment=${systemdEscapeArg(`${key}=${value?.trim() ?? ""}`)}`, + ); +} + +function buildSystemdUnit({ + programArguments, + workingDirectory, + environment, +}: { + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}): string { + const execStart = programArguments.map(systemdEscapeArg).join(" "); + const workingDirLine = workingDirectory + ? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}` + : null; + const envLines = renderEnvLines(environment); + return [ + "[Unit]", + "Description=Clawdis Gateway", + "", + "[Service]", + `ExecStart=${execStart}`, + "Restart=always", + workingDirLine, + ...envLines, + "", + "[Install]", + "WantedBy=default.target", + "", + ] + .filter((line) => line !== null) + .join("\n"); +} + +function parseSystemdExecStart(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 readSystemdServiceExecStart( + env: Record, +): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { + const unitPath = resolveSystemdUnitPath(env); + try { + const content = await fs.readFile(unitPath, "utf8"); + let execStart = ""; + let workingDirectory = ""; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("ExecStart=")) { + execStart = line.slice("ExecStart=".length).trim(); + } else if (line.startsWith("WorkingDirectory=")) { + workingDirectory = line.slice("WorkingDirectory=".length).trim(); + } + } + if (!execStart) return null; + const programArguments = parseSystemdExecStart(execStart); + return { + programArguments, + ...(workingDirectory ? { workingDirectory } : {}), + }; + } catch { + return null; + } +} + +async function execSystemctl( + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await execFileAsync("systemctl", args, { + encoding: "utf8", + }); + 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 assertSystemdAvailable() { + const res = await execSystemctl(["--user", "status"]); + if (res.code === 0) return; + const detail = res.stderr || res.stdout; + if (detail.toLowerCase().includes("not found")) { + throw new Error("systemctl not available; systemd user services are required on Linux."); + } + throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); +} + +export async function installSystemdService({ + env, + stdout, + programArguments, + workingDirectory, + environment, +}: { + env: Record; + stdout: NodeJS.WritableStream; + programArguments: string[]; + workingDirectory?: string; + environment?: Record; +}): Promise<{ unitPath: string }> { + await assertSystemdAvailable(); + + const unitPath = resolveSystemdUnitPath(env); + await fs.mkdir(path.dirname(unitPath), { recursive: true }); + const unit = buildSystemdUnit({ programArguments, workingDirectory, environment }); + await fs.writeFile(unitPath, unit, "utf8"); + + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + const reload = await execSystemctl(["--user", "daemon-reload"]); + if (reload.code !== 0) { + throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`.trim()); + } + + const enable = await execSystemctl(["--user", "enable", unitName]); + if (enable.code !== 0) { + throw new Error(`systemctl enable failed: ${enable.stderr || enable.stdout}`.trim()); + } + + const restart = await execSystemctl(["--user", "restart", unitName]); + if (restart.code !== 0) { + throw new Error(`systemctl restart failed: ${restart.stderr || restart.stdout}`.trim()); + } + + stdout.write(`Installed systemd service: ${unitPath}\n`); + return { unitPath }; +} + +export async function uninstallSystemdService({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + await assertSystemdAvailable(); + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + await execSystemctl(["--user", "disable", "--now", unitName]); + + const unitPath = resolveSystemdUnitPath(env); + try { + await fs.unlink(unitPath); + stdout.write(`Removed systemd service: ${unitPath}\n`); + } catch { + stdout.write(`Systemd service not found at ${unitPath}\n`); + } +} + +export async function restartSystemdService({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + await assertSystemdAvailable(); + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + const res = await execSystemctl(["--user", "restart", unitName]); + if (res.code !== 0) { + throw new Error(`systemctl restart failed: ${res.stderr || res.stdout}`.trim()); + } + stdout.write(`Restarted systemd service: ${unitName}\n`); +} + +export async function isSystemdServiceEnabled(): Promise { + await assertSystemdAvailable(); + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + const res = await execSystemctl(["--user", "is-enabled", unitName]); + return res.code === 0; +}