diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml new file mode 100644 index 000000000..49c215bf1 --- /dev/null +++ b/.github/workflows/install-smoke.yml @@ -0,0 +1,34 @@ +name: Install Smoke + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + install-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout CLI + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + - name: Enable Corepack + run: corepack enable + + - name: Clone installer site + run: git clone --depth=1 https://github.com/clawdbot/clawd.bot ../clawd.bot + + - name: Install pnpm deps (minimal) + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: Run installer docker tests + env: + CLAWDBOT_INSTALL_URL: file://$GITHUB_WORKSPACE/../clawd.bot/public/install.sh + CLAWDBOT_INSTALL_CLI_URL: file://$GITHUB_WORKSPACE/../clawd.bot/public/install-cli.sh + CLAWDBOT_NO_ONBOARD: "1" + run: pnpm test:install:e2e diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf9bde26..360e19f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ - Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak - Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1. +### Installer +- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. +- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped. +- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. + ### Fixes - Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging. - Doctor: surface plugin diagnostics in the report. diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile new file mode 100644 index 000000000..18a3c82b5 --- /dev/null +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash app \ + && echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app + +USER app +WORKDIR /home/app + +ENV NPM_CONFIG_FUND=false +ENV NPM_CONFIG_AUDIT=false + +COPY run.sh /usr/local/bin/clawdbot-install-nonroot +RUN sudo chmod +x /usr/local/bin/clawdbot-install-nonroot + +ENTRYPOINT ["/usr/local/bin/clawdbot-install-nonroot"] diff --git a/scripts/docker/install-sh-nonroot/run.sh b/scripts/docker/install-sh-nonroot/run.sh new file mode 100644 index 000000000..a04b89c15 --- /dev/null +++ b/scripts/docker/install-sh-nonroot/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}" + +echo "==> Pre-flight: ensure git absent" +if command -v git >/dev/null; then + echo "git is present unexpectedly" >&2 + exit 1 +fi + +echo "==> Run installer (non-root user)" +curl -fsSL "$INSTALL_URL" | bash + +# Ensure PATH picks up user npm prefix +export PATH="$HOME/.npm-global/bin:$PATH" + +echo "==> Verify git installed" +command -v git >/dev/null + +echo "==> Verify clawdbot installed" +LATEST_VERSION="$(npm view clawdbot version)" +CMD_PATH="$(command -v clawdbot || true)" +if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then + CMD_PATH="$HOME/.npm-global/bin/clawdbot" +fi +if [[ -z "$CMD_PATH" ]]; then + echo "clawdbot not on PATH" >&2 + exit 1 +fi +INSTALLED_VERSION="$("$CMD_PATH" --version 2>/dev/null | head -n 1 | tr -d '\r')" + +echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION" +if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then + echo "ERROR: expected clawdbot@$LATEST_VERSION, got @$INSTALLED_VERSION" >&2 + exit 1 +fi + +echo "==> Sanity: CLI runs" +"$CMD_PATH" --help >/dev/null + +echo "OK" diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index 638c77c67..4888e3cd7 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update \ bash \ ca-certificates \ curl \ - git \ + sudo \ && rm -rf /var/lib/apt/lists/* COPY run.sh /usr/local/bin/clawdbot-install-smoke diff --git a/scripts/postinstall.js b/scripts/postinstall.js index f849c02fd..cc504933f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -1,73 +1,12 @@ -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -function isBunInstall() { - const ua = process.env.npm_config_user_agent ?? ""; - return ua.includes("bun/"); -} - function getRepoRoot() { const here = path.dirname(fileURLToPath(import.meta.url)); return path.resolve(here, ".."); } -function run(cmd, args, opts = {}) { - const res = spawnSync(cmd, args, { stdio: "inherit", ...opts }); - if (typeof res.status === "number") return res.status; - return 1; -} - -function applyPatchIfNeeded(opts) { - const patchPath = path.resolve(opts.patchPath); - if (!fs.existsSync(patchPath)) { - throw new Error(`missing patch: ${patchPath}`); - } - - let targetDir = path.resolve(opts.targetDir); - if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { - console.warn(`[postinstall] skip missing target: ${targetDir}`); - return; - } - - // Resolve symlinks to avoid "beyond a symbolic link" errors from git apply - // (bun/pnpm use symlinks in node_modules) - targetDir = fs.realpathSync(targetDir); - - const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"]; - const reverseCheck = [ - ...gitArgsBase, - "--reverse", - "--check", - "--directory", - targetDir, - patchPath, - ]; - const forwardCheck = [ - ...gitArgsBase, - "--check", - "--directory", - targetDir, - patchPath, - ]; - const apply = [...gitArgsBase, "--directory", targetDir, patchPath]; - - // Already applied? - if (run("git", reverseCheck, { stdio: "ignore" }) === 0) { - return; - } - - if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) { - throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`); - } - - const status = run("git", apply); - if (status !== 0) { - throw new Error(`failed applying patch: ${path.basename(patchPath)}`); - } -} - function extractPackageName(key) { if (key.startsWith("@")) { const idx = key.indexOf("@", 1); @@ -79,9 +18,180 @@ function extractPackageName(key) { return key.slice(0, idx); } -function main() { - if (!isBunInstall()) return; +function stripPrefix(p) { + if (p.startsWith("a/") || p.startsWith("b/")) return p.slice(2); + return p; +} +function parseRange(segment) { + // segment: "-12,5" or "+7" + const [startRaw, countRaw] = segment.slice(1).split(","); + const start = Number.parseInt(startRaw, 10); + const count = countRaw ? Number.parseInt(countRaw, 10) : 1; + if (Number.isNaN(start) || Number.isNaN(count)) { + throw new Error(`invalid hunk range: ${segment}`); + } + return { start, count }; +} + +function parsePatch(patchText) { + const lines = patchText.split("\n"); + const files = []; + let i = 0; + + while (i < lines.length) { + if (!lines[i].startsWith("diff --git ")) { + i += 1; + continue; + } + + const file = { oldPath: null, newPath: null, hunks: [] }; + i += 1; + + // Skip index line(s) + while (i < lines.length && lines[i].startsWith("index ")) i += 1; + + if (i < lines.length && lines[i].startsWith("--- ")) { + file.oldPath = stripPrefix(lines[i].slice(4).trim()); + i += 1; + } + if (i < lines.length && lines[i].startsWith("+++ ")) { + file.newPath = stripPrefix(lines[i].slice(4).trim()); + i += 1; + } + + while (i < lines.length && lines[i].startsWith("@@")) { + const header = lines[i]; + const match = /^@@\s+(-\d+(?:,\d+)?)\s+(\+\d+(?:,\d+)?)\s+@@/.exec(header); + if (!match) throw new Error(`invalid hunk header: ${header}`); + const oldRange = parseRange(match[1]); + const newRange = parseRange(match[2]); + i += 1; + + const hunkLines = []; + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("@@") || line.startsWith("diff --git ")) break; + if (line === "") { + i += 1; + continue; + } + if (line.startsWith("\\ No newline at end of file")) { + i += 1; + continue; + } + hunkLines.push(line); + i += 1; + } + + file.hunks.push({ + oldStart: oldRange.start, + oldLines: oldRange.count, + newStart: newRange.start, + newLines: newRange.count, + lines: hunkLines, + }); + } + + if (file.newPath && file.hunks.length > 0) { + files.push(file); + } + } + + return files; +} + +function readFileLines(targetPath) { + if (!fs.existsSync(targetPath)) { + throw new Error(`target file missing: ${targetPath}`); + } + const raw = fs.readFileSync(targetPath, "utf-8"); + const hasTrailingNewline = raw.endsWith("\n"); + const parts = raw.split("\n"); + if (hasTrailingNewline) parts.pop(); + return { lines: parts, hasTrailingNewline }; +} + +function writeFileLines(targetPath, lines, hadTrailingNewline) { + const content = lines.join("\n") + (hadTrailingNewline ? "\n" : ""); + fs.writeFileSync(targetPath, content, "utf-8"); +} + +function applyHunk(lines, hunk, offset) { + let cursor = hunk.oldStart - 1 + offset; + + for (const raw of hunk.lines) { + const marker = raw[0]; + const text = raw.slice(1); + if (marker === " ") { + if (lines[cursor] !== text) { + throw new Error( + `context mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? ""}"`, + ); + } + cursor += 1; + } else if (marker === "-") { + if (lines[cursor] !== text) { + throw new Error( + `delete mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? ""}"`, + ); + } + lines.splice(cursor, 1); + } else if (marker === "+") { + lines.splice(cursor, 0, text); + cursor += 1; + } else { + throw new Error(`unexpected hunk marker: ${marker}`); + } + } + + const delta = hunk.newLines - hunk.oldLines; + return offset + delta; +} + +function applyPatchToFile(targetDir, filePatch) { + if (filePatch.newPath === "/dev/null") { + // deletion not needed for our patches + return; + } + const relPath = stripPrefix(filePatch.newPath ?? filePatch.oldPath ?? ""); + const targetPath = path.join(targetDir, relPath); + const { lines, hasTrailingNewline } = readFileLines(targetPath); + + let offset = 0; + for (const hunk of filePatch.hunks) { + offset = applyHunk(lines, hunk, offset); + } + + writeFileLines(targetPath, lines, hasTrailingNewline); +} + +function applyPatchSet({ patchText, targetDir }) { + let resolvedTarget = path.resolve(targetDir); + if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) { + console.warn(`[postinstall] skip missing target: ${resolvedTarget}`); + return; + } + resolvedTarget = fs.realpathSync(resolvedTarget); + + const files = parsePatch(patchText); + if (files.length === 0) return; + + for (const filePatch of files) { + applyPatchToFile(resolvedTarget, filePatch); + } +} + +function applyPatchFile({ patchPath, targetDir }) { + const absPatchPath = path.resolve(patchPath); + if (!fs.existsSync(absPatchPath)) { + throw new Error(`missing patch: ${absPatchPath}`); + } + const patchText = fs.readFileSync(absPatchPath, "utf-8"); + applyPatchSet({ patchText, targetDir }); +} + +function main() { const repoRoot = getRepoRoot(); process.chdir(repoRoot); @@ -95,7 +205,7 @@ function main() { if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue; const pkgName = extractPackageName(String(key)); if (!pkgName) continue; - applyPatchIfNeeded({ + applyPatchFile({ targetDir: path.join("node_modules", ...pkgName.split("/")), patchPath: relPatchPath, }); @@ -103,8 +213,22 @@ function main() { } try { - main(); + const skip = + process.env.CLAWDBOT_SKIP_POSTINSTALL === "1" || + process.env.VITEST === "true" || + process.env.NODE_ENV === "test"; + + if (!skip) { + main(); + } } catch (err) { console.error(String(err)); process.exit(1); } + +export { + applyPatchFile, + applyPatchSet, + applyPatchToFile, + parsePatch, +}; diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 440fa7015..00bc744de 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -2,16 +2,38 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE_NAME="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}" +SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}" +NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}" INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}" +CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}" -echo "==> Build image: $IMAGE_NAME" +echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE" docker build \ - -t "$IMAGE_NAME" \ + -t "$SMOKE_IMAGE" \ -f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \ "$ROOT_DIR/scripts/docker/install-sh-smoke" -echo "==> Run installer smoke test: $INSTALL_URL" +echo "==> Run installer smoke test (root): $INSTALL_URL" docker run --rm -t \ -e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \ - "$IMAGE_NAME" + -e CLAWDBOT_NO_ONBOARD=1 \ + "$SMOKE_IMAGE" + +echo "==> Build non-root image: $NONROOT_IMAGE" +docker build \ + -t "$NONROOT_IMAGE" \ + -f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \ + "$ROOT_DIR/scripts/docker/install-sh-nonroot" + +echo "==> Run installer non-root test: $INSTALL_URL" +docker run --rm -t \ + -e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \ + -e CLAWDBOT_NO_ONBOARD=1 \ + "$NONROOT_IMAGE" + +echo "==> Run CLI installer non-root test (same image)" +docker run --rm -t \ + -e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \ + -e CLAWDBOT_INSTALL_CLI_URL="$CLI_INSTALL_URL" \ + -e CLAWDBOT_NO_ONBOARD=1 \ + "$NONROOT_IMAGE" bash -lc "curl -fsSL \"$CLI_INSTALL_URL\" | bash -s -- --set-npm-prefix --no-onboard" diff --git a/src/postinstall-patcher.test.ts b/src/postinstall-patcher.test.ts new file mode 100644 index 000000000..44dfe2e01 --- /dev/null +++ b/src/postinstall-patcher.test.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { applyPatchSet } from "../scripts/postinstall.js"; + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-patch-")); +} + +describe("postinstall patcher", () => { + it("applies a simple patch", () => { + const dir = makeTempDir(); + const target = path.join(dir, "lib"); + fs.mkdirSync(target); + + const filePath = path.join(target, "main.js"); + const original = [ + "var QRCode = require('./../vendor/QRCode'),", + " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),", + ' black = "\\033[40m \\033[0m",', + ' white = "\\033[47m \\033[0m",', + " toCell = function (isBlack) {", + ].join("\n") + "\n"; + fs.writeFileSync(filePath, original, "utf-8"); + + const patchText = `diff --git a/lib/main.js b/lib/main.js +index 0000000..1111111 100644 +--- a/lib/main.js ++++ b/lib/main.js +@@ -1,5 +1,5 @@ +-var QRCode = require('./../vendor/QRCode'), +- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'), ++var QRCode = require('./../vendor/QRCode/index.js'), ++ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'), + black = "\\033[40m \\033[0m", + white = "\\033[47m \\033[0m", + toCell = function (isBlack) { +`; + + applyPatchSet({ patchText, targetDir: dir }); + + const updated = fs.readFileSync(filePath, "utf-8"); + expect(updated).toBe( + [ + "var QRCode = require('./../vendor/QRCode/index.js'),", + " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),", + ' black = "\\033[40m \\033[0m",', + ' white = "\\033[47m \\033[0m",', + " toCell = function (isBlack) {", + ].join("\n") + "\n", + ); + + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("handles multiple hunks with offsets", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "file.txt"); + fs.writeFileSync( + filePath, + ["alpha", "beta", "gamma", "delta", "epsilon"].join("\n") + "\n", + "utf-8", + ); + + const patchText = `diff --git a/file.txt b/file.txt +--- a/file.txt ++++ b/file.txt +@@ -1,3 +1,4 @@ + alpha + beta ++beta2 + gamma +@@ -3,3 +4,4 @@ + gamma +-delta ++DELTA + epsilon ++zeta +`; + + applyPatchSet({ patchText, targetDir: dir }); + + const updated = fs.readFileSync(filePath, "utf-8").trim().split("\n"); + expect(updated).toEqual([ + "alpha", + "beta", + "beta2", + "gamma", + "DELTA", + "epsilon", + "zeta", + ]); + + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("throws on context mismatch", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "file.txt"); + fs.writeFileSync(filePath, "hello\nworld\n", "utf-8"); + + const patchText = `diff --git a/file.txt b/file.txt +--- a/file.txt ++++ b/file.txt +@@ -1,2 +1,2 @@ +-hola ++hi + world +`; + + expect(() => applyPatchSet({ patchText, targetDir: dir })).toThrow(); + + fs.rmSync(dir, { recursive: true, force: true }); + }); +});