mirror of
https://github.com/openprose/prose.git
synced 2026-09-19 05:55:05 +08:00
e4892c039d
Moves `@openprose/prose-cli` from `cli/` to `tools/cli/` and seeds a `tools/` convention at the repo root for shippable tooling published as standalone artifacts. Package internals are unchanged — only paths outside the package that hard-code the `cli/` segment were updated. ## What changed - `git mv cli tools/cli` — package files render as 100%-similarity renames, so `git log --follow` is preserved. - `.github/workflows/cli-publish.yml`, `.github/workflows/cli-release-check.yml`, and `.github/workflows/cli-real-harness-smoke.yml`: every literal `cli/` path (`paths:`, `working-directory:`, `cache-dependency-path:`, artifact `path:`, and inline `node`/`bash` invocations) now points at `tools/cli/`. `cli-skills-smoke.yml` had no `cli/` references and is untouched. - `tools/cli/package.json`: `repository.directory` and `homepage` flip to `tools/cli` so the npm registry "Repository" link resolves. - `README.md`: the badge nav, the prose-layout paragraph, and the repo layout table now point at `tools/cli/`, plus a new `tools/` row introducing the convention. - `RELEASE.md`: cross-references to `cli/RELEASE.md` updated. - `tools/cli/README.md`, `tools/cli/RELEASE.md`, `tools/cli/POST_RELEASE_PLAYTEST.md`: public installer URL updated to `https://raw.githubusercontent.com/openprose/prose/main/tools/cli/install.sh`.
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
linkSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
const cliDir = resolve(testDir, "..", "..");
|
|
const installScript = join(cliDir, "install.sh");
|
|
const buildScript = join(cliDir, "scripts", "build-release-tarball.mjs");
|
|
const packageVersion = (JSON.parse(readFileSync(join(cliDir, "package.json"), "utf8")) as { version: string }).version;
|
|
|
|
function run(command: string, args: string[], options: Parameters<typeof spawnSync>[2] = {}) {
|
|
return spawnSync(command, args, {
|
|
encoding: "utf8",
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function sha256(path: string) {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
function commandPath(command: string) {
|
|
return execFileSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }).trim();
|
|
}
|
|
|
|
describe("install.sh", () => {
|
|
it("passes shell syntax checks", () => {
|
|
const result = run("bash", ["-n", installScript]);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("prints planned actions in dry-run mode without creating files", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-dry-run-"));
|
|
|
|
try {
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_DRY_RUN: "1",
|
|
PROSE_VERSION: "9.9.9",
|
|
PROSE_OS: "linux",
|
|
PROSE_ARCH: "x64",
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: "https://example.invalid/releases",
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("Would download: https://example.invalid/releases/prose-9.9.9-linux-x64.tar.gz");
|
|
expect(result.stderr).toContain(`Would write shim: ${join(temp, "bin", "prose")}`);
|
|
expect(existsSync(join(temp, "bin", "prose"))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects unsafe release labels before building download paths", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-label-"));
|
|
|
|
try {
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_DRY_RUN: "1",
|
|
PROSE_VERSION: "9.9.9;touch-pwned",
|
|
PROSE_OS: "linux",
|
|
PROSE_ARCH: "x64",
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: "https://example.invalid/releases",
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("PROSE_VERSION may only contain letters, numbers, dots, underscores, and dashes");
|
|
expect(existsSync(join(temp, "bin", "prose"))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("requires Node.js before installing the shim", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-node-"));
|
|
const pathDir = join(temp, "path");
|
|
|
|
try {
|
|
mkdirSync(pathDir, { recursive: true });
|
|
for (const command of ["dirname", "find", "mkdir", "mktemp", "rm", "sed", "sh", "tar", "uname"]) {
|
|
symlinkSync(commandPath(command), join(pathDir, command));
|
|
}
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
PATH: pathDir,
|
|
HOME: temp,
|
|
PROSE_VERSION: "9.9.9",
|
|
PROSE_OS: "linux",
|
|
PROSE_ARCH: "x64",
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: "file:///tmp/no-release",
|
|
PROSE_SKIP_SHA256: "1",
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("required command not found: node");
|
|
expect(existsSync(join(temp, "bin", "prose"))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("installs a local tarball and creates a runnable shim", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-test-"));
|
|
const version = "9.9.9";
|
|
const targetOs = "linux";
|
|
const targetArch = "x64";
|
|
const packageName = `prose-${version}-${targetOs}-${targetArch}`;
|
|
const releaseDir = join(temp, "release");
|
|
const packageRoot = join(temp, "stage", packageName);
|
|
const distDir = join(packageRoot, "dist");
|
|
const tarballPath = join(releaseDir, `${packageName}.tar.gz`);
|
|
|
|
try {
|
|
mkdirSync(distDir, { recursive: true });
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(
|
|
join(distDir, "index.js"),
|
|
[
|
|
"#!/usr/bin/env node",
|
|
"console.log(['prose-test'].concat(process.argv.slice(2)).join(' '));",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(join(distDir, "index.js"), 0o755);
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", join(temp, "stage"), packageName]);
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_VERSION: version,
|
|
PROSE_OS: targetOs,
|
|
PROSE_ARCH: targetArch,
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: `file://${releaseDir}`,
|
|
PROSE_SHA256: sha256(tarballPath),
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain(`Installed prose ${version}`);
|
|
|
|
const shimPath = join(temp, "bin", "prose");
|
|
expect(existsSync(shimPath)).toBe(true);
|
|
const output = execFileSync(shimPath, ["hello", "world"], { encoding: "utf8" });
|
|
expect(output).toBe("prose-test hello world\n");
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects archives with symlinks that escape the package root", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-unsafe-link-"));
|
|
const version = "9.9.9";
|
|
const targetOs = "linux";
|
|
const targetArch = "x64";
|
|
const packageName = `prose-${version}-${targetOs}-${targetArch}`;
|
|
const releaseDir = join(temp, "release");
|
|
const packageRoot = join(temp, "stage", packageName);
|
|
const distDir = join(packageRoot, "dist");
|
|
const tarballPath = join(releaseDir, `${packageName}.tar.gz`);
|
|
|
|
try {
|
|
mkdirSync(distDir, { recursive: true });
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(join(distDir, "index.js"), "console.log('unsafe');\n");
|
|
symlinkSync("/tmp", join(packageRoot, "escape"));
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", join(temp, "stage"), packageName]);
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_VERSION: version,
|
|
PROSE_OS: targetOs,
|
|
PROSE_ARCH: targetArch,
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: `file://${releaseDir}`,
|
|
PROSE_SHA256: sha256(tarballPath),
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("archive contains an unsafe symlink");
|
|
expect(existsSync(join(temp, "install", packageName))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects checksum mismatches before installing", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-bad-checksum-"));
|
|
const version = "9.9.9";
|
|
const targetOs = "linux";
|
|
const targetArch = "x64";
|
|
const packageName = `prose-${version}-${targetOs}-${targetArch}`;
|
|
const releaseDir = join(temp, "release");
|
|
const packageRoot = join(temp, "stage", packageName);
|
|
const distDir = join(packageRoot, "dist");
|
|
const tarballPath = join(releaseDir, `${packageName}.tar.gz`);
|
|
|
|
try {
|
|
mkdirSync(distDir, { recursive: true });
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(join(distDir, "index.js"), "console.log('checksum');\n");
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", join(temp, "stage"), packageName]);
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_VERSION: version,
|
|
PROSE_OS: targetOs,
|
|
PROSE_ARCH: targetArch,
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: `file://${releaseDir}`,
|
|
PROSE_SHA256: "0".repeat(64),
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("archive checksum mismatch");
|
|
expect(existsSync(join(temp, "install", packageName))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects archives with the wrong root directory", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-wrong-root-"));
|
|
const version = "9.9.9";
|
|
const targetOs = "linux";
|
|
const targetArch = "x64";
|
|
const packageName = `prose-${version}-${targetOs}-${targetArch}`;
|
|
const wrongRoot = "not-prose";
|
|
const releaseDir = join(temp, "release");
|
|
const packageRoot = join(temp, "stage", wrongRoot);
|
|
const distDir = join(packageRoot, "dist");
|
|
const tarballPath = join(releaseDir, `${packageName}.tar.gz`);
|
|
|
|
try {
|
|
mkdirSync(distDir, { recursive: true });
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(join(distDir, "index.js"), "console.log('wrong-root');\n");
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", join(temp, "stage"), wrongRoot]);
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_VERSION: version,
|
|
PROSE_OS: targetOs,
|
|
PROSE_ARCH: targetArch,
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: `file://${releaseDir}`,
|
|
PROSE_SHA256: sha256(tarballPath),
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(`archive root must be ${packageName}/`);
|
|
expect(existsSync(join(temp, "install", packageName))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects archives with hardlinked files", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-install-hardlink-"));
|
|
const version = "9.9.9";
|
|
const targetOs = "linux";
|
|
const targetArch = "x64";
|
|
const packageName = `prose-${version}-${targetOs}-${targetArch}`;
|
|
const releaseDir = join(temp, "release");
|
|
const packageRoot = join(temp, "stage", packageName);
|
|
const distDir = join(packageRoot, "dist");
|
|
const tarballPath = join(releaseDir, `${packageName}.tar.gz`);
|
|
const indexPath = join(distDir, "index.js");
|
|
|
|
try {
|
|
mkdirSync(distDir, { recursive: true });
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(indexPath, "console.log('hardlink');\n");
|
|
linkSync(indexPath, join(packageRoot, "linked-index.js"));
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", join(temp, "stage"), packageName]);
|
|
|
|
const result = run("sh", [installScript], {
|
|
env: {
|
|
...process.env,
|
|
PROSE_VERSION: version,
|
|
PROSE_OS: targetOs,
|
|
PROSE_ARCH: targetArch,
|
|
PROSE_INSTALL_DIR: join(temp, "install"),
|
|
PROSE_BIN_DIR: join(temp, "bin"),
|
|
PROSE_BASE_URL: `file://${releaseDir}`,
|
|
PROSE_SHA256: sha256(tarballPath),
|
|
},
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("hardlink");
|
|
expect(existsSync(join(temp, "install", packageName))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("build-release-tarball.mjs", () => {
|
|
it("reports the archive it would build in dry-run mode", () => {
|
|
const temp = mkdtempSync(join(tmpdir(), "prose-build-dry-run-"));
|
|
|
|
try {
|
|
const result = run(process.execPath, [
|
|
buildScript,
|
|
"--os",
|
|
"linux",
|
|
"--arch",
|
|
"x64",
|
|
"--out-dir",
|
|
temp,
|
|
"--skip-build",
|
|
"--dry-run",
|
|
]);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`Package name: prose-${packageVersion}-linux-x64`);
|
|
expect(result.stdout).toContain(`Archive path: ${join(temp, `prose-${packageVersion}-linux-x64.tar.gz`)}`);
|
|
expect(result.stdout).toContain(`Checksum path: ${join(temp, `prose-${packageVersion}-linux-x64.tar.gz.sha256`)}`);
|
|
expect(result.stdout).toContain("Production npm target: linux/x64");
|
|
expect(existsSync(join(temp, `prose-${packageVersion}-linux-x64.tar.gz`))).toBe(false);
|
|
} finally {
|
|
rmSync(temp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|