chore: harden installer and add smoke ci
This commit is contained in:
34
.github/workflows/install-smoke.yml
vendored
Normal file
34
.github/workflows/install-smoke.yml
vendored
Normal file
@@ -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
|
||||||
@@ -15,6 +15,11 @@
|
|||||||
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak
|
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak
|
||||||
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.
|
- 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
|
### Fixes
|
||||||
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging.
|
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging.
|
||||||
- Doctor: surface plugin diagnostics in the report.
|
- Doctor: surface plugin diagnostics in the report.
|
||||||
|
|||||||
23
scripts/docker/install-sh-nonroot/Dockerfile
Normal file
23
scripts/docker/install-sh-nonroot/Dockerfile
Normal file
@@ -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"]
|
||||||
42
scripts/docker/install-sh-nonroot/run.sh
Normal file
42
scripts/docker/install-sh-nonroot/run.sh
Normal file
@@ -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"
|
||||||
@@ -5,7 +5,7 @@ RUN apt-get update \
|
|||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
git \
|
sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY run.sh /usr/local/bin/clawdbot-install-smoke
|
COPY run.sh /usr/local/bin/clawdbot-install-smoke
|
||||||
|
|||||||
@@ -1,73 +1,12 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
function isBunInstall() {
|
|
||||||
const ua = process.env.npm_config_user_agent ?? "";
|
|
||||||
return ua.includes("bun/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRepoRoot() {
|
function getRepoRoot() {
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
return path.resolve(here, "..");
|
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) {
|
function extractPackageName(key) {
|
||||||
if (key.startsWith("@")) {
|
if (key.startsWith("@")) {
|
||||||
const idx = key.indexOf("@", 1);
|
const idx = key.indexOf("@", 1);
|
||||||
@@ -79,9 +18,180 @@ function extractPackageName(key) {
|
|||||||
return key.slice(0, idx);
|
return key.slice(0, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function stripPrefix(p) {
|
||||||
if (!isBunInstall()) return;
|
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] ?? "<eof>"}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
} else if (marker === "-") {
|
||||||
|
if (lines[cursor] !== text) {
|
||||||
|
throw new Error(
|
||||||
|
`delete mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? "<eof>"}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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();
|
const repoRoot = getRepoRoot();
|
||||||
process.chdir(repoRoot);
|
process.chdir(repoRoot);
|
||||||
|
|
||||||
@@ -95,7 +205,7 @@ function main() {
|
|||||||
if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
|
if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
|
||||||
const pkgName = extractPackageName(String(key));
|
const pkgName = extractPackageName(String(key));
|
||||||
if (!pkgName) continue;
|
if (!pkgName) continue;
|
||||||
applyPatchIfNeeded({
|
applyPatchFile({
|
||||||
targetDir: path.join("node_modules", ...pkgName.split("/")),
|
targetDir: path.join("node_modules", ...pkgName.split("/")),
|
||||||
patchPath: relPatchPath,
|
patchPath: relPatchPath,
|
||||||
});
|
});
|
||||||
@@ -103,8 +213,22 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
main();
|
const skip =
|
||||||
|
process.env.CLAWDBOT_SKIP_POSTINSTALL === "1" ||
|
||||||
|
process.env.VITEST === "true" ||
|
||||||
|
process.env.NODE_ENV === "test";
|
||||||
|
|
||||||
|
if (!skip) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(String(err));
|
console.error(String(err));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
applyPatchFile,
|
||||||
|
applyPatchSet,
|
||||||
|
applyPatchToFile,
|
||||||
|
parsePatch,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,16 +2,38 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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}"
|
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 \
|
docker build \
|
||||||
-t "$IMAGE_NAME" \
|
-t "$SMOKE_IMAGE" \
|
||||||
-f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \
|
-f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \
|
||||||
"$ROOT_DIR/scripts/docker/install-sh-smoke"
|
"$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 \
|
docker run --rm -t \
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
-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"
|
||||||
|
|||||||
117
src/postinstall-patcher.test.ts
Normal file
117
src/postinstall-patcher.test.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user