mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
fix: stage complete installed Codex runtime dependencies
Preserve prepared Git installation assets and consumer lifecycle behavior. Bundle the qualified Codex graph and host artifacts without changing agent defaults. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
71cd0a2621
commit
5fa4abaead
10 files changed
+1209
-19
No files matched your search
@@ -28,6 +28,7 @@ import { resolveCliVersion } from "../version.js";
|
||||
import { systemdServiceName } from "../services/service-manager.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const SERVER_PACKAGE_FILES = (JSON.parse(fs.readFileSync(new URL("../../../server/package.json", import.meta.url), "utf8")) as { files: string[] }).files;
|
||||
|
||||
describe("managed install commands", () => {
|
||||
let root: string;
|
||||
@@ -105,7 +106,7 @@ describe("managed install commands", () => {
|
||||
expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath);
|
||||
});
|
||||
|
||||
const createGitCheckoutRunCommand = (sha: string) =>
|
||||
const createGitCheckoutRunCommand = (sha: string, { bundledServer = false }: { bundledServer?: boolean } = {}) =>
|
||||
vi.fn(async (file: string, args: string[], _options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === "curl" && !args.includes("--output")) return { stdout: JSON.stringify({ sha }), stderr: "" };
|
||||
if (file === "curl") { fs.writeFileSync(args[args.indexOf("--output") + 1], "archive"); return { stdout: "", stderr: "" }; }
|
||||
@@ -114,19 +115,45 @@ describe("managed install commands", () => {
|
||||
const packages = [
|
||||
{ dir: "packages/shared", name: "@paperclipai/shared", packageJson: { name: "@paperclipai/shared", version: "0.3.1" } },
|
||||
{ dir: "packages/db", name: "@paperclipai/db", packageJson: { name: "@paperclipai/db", version: "0.3.1", dependencies: { "@paperclipai/shared": "workspace:*" }, bundleDependencies: ["embedded-postgres"] } },
|
||||
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" } } },
|
||||
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" }, ...(bundledServer ? { bundleDependencies: ["acpx"], files: SERVER_PACKAGE_FILES } : {}) } },
|
||||
];
|
||||
fs.mkdirSync(path.join(checkout, "cli"), { recursive: true });
|
||||
fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" }));
|
||||
fs.writeFileSync(path.join(checkout, "pnpm-lock.yaml"), "source lockfile awaiting refresh\n");
|
||||
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
|
||||
for (const workspacePackage of packages) {
|
||||
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify(workspacePackage.packageJson));
|
||||
}
|
||||
const skill = path.join(checkout, "skills", "paperclip");
|
||||
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, "SKILL.md"), "Skill from this Git checkout\n");
|
||||
fs.writeFileSync(path.join(skill, "scripts", "helper.mjs"), "export const sameCheckout = true;\n");
|
||||
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
|
||||
const stale = path.join(checkout, packageDir, "skills");
|
||||
fs.mkdirSync(stale, { recursive: true });
|
||||
fs.writeFileSync(path.join(stale, "stale.md"), "stale package-local skills\n");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (file === "corepack") {
|
||||
if (args.includes("build")) {
|
||||
const dist = path.join(_options!.cwd as string, "server", "dist");
|
||||
fs.mkdirSync(dist, { recursive: true });
|
||||
fs.writeFileSync(path.join(dist, "index.js"), "export const builtServer = true;\n");
|
||||
}
|
||||
if (args.includes("prepare:ui-dist")) {
|
||||
const ui = path.join(_options!.cwd as string, "server", "ui-dist");
|
||||
fs.mkdirSync(ui, { recursive: true });
|
||||
fs.writeFileSync(path.join(ui, "index.html"), "<html>Built from this Git checkout</html>");
|
||||
}
|
||||
if (args.includes("--resolution-only")) {
|
||||
fs.writeFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "resolved checkout lockfile\n");
|
||||
}
|
||||
if (args.includes("--frozen-lockfile")) {
|
||||
expect(fs.readFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "utf8")).toBe("resolved checkout lockfile\n");
|
||||
}
|
||||
if (args.includes("pack")) {
|
||||
const destination = args[args.indexOf("--pack-destination") + 1];
|
||||
const packageDir = args[args.indexOf("--dir") + 1];
|
||||
@@ -137,14 +164,32 @@ describe("managed install commands", () => {
|
||||
}
|
||||
if (file === "bash") return { stdout: "", stderr: "" };
|
||||
if (file === "npm" && args[0] === "pack") {
|
||||
const packageName = args[1]?.includes("workspace-package-") ? "paperclipai-db" : "paperclipai";
|
||||
const packageName = args[1]?.includes("workspace-package-")
|
||||
? (JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string }).name.replace("@", "").replace("/", "-")
|
||||
: "paperclipai";
|
||||
if (packageName === "paperclipai-server") {
|
||||
expect(fs.readFileSync(path.join(args[1], "ui-dist", "index.html"), "utf8"))
|
||||
.toBe("<html>Built from this Git checkout</html>");
|
||||
expect(fs.readFileSync(path.join(args[1], "skills", "paperclip", "SKILL.md"), "utf8"))
|
||||
.toBe("Skill from this Git checkout\n");
|
||||
expect(fs.readFileSync(path.join(args[1], "dist", "index.js"), "utf8"))
|
||||
.toBe("export const builtServer = true;\n");
|
||||
}
|
||||
fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], `${packageName}-0.3.1.tgz`), "package");
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; const packageRoot = path.join(prefix, "node_modules", "paperclipai"); fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); return { stdout: "", stderr: "" }; }
|
||||
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string; version: string; files?: string[] };
|
||||
fs.mkdirSync(args[2], { recursive: true });
|
||||
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify({ name: "@paperclipai/db", version: "0.3.1" }));
|
||||
if (packageJson.name === "@paperclipai/server") {
|
||||
// Match the real bundler's complete declared-file copy. Missing
|
||||
// runtime assets must fail before packing an incomplete payload.
|
||||
for (const entry of packageJson.files ?? []) {
|
||||
fs.cpSync(path.join(args[1], entry), path.join(args[2], entry), { recursive: true });
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify(packageJson));
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
|
||||
@@ -160,7 +205,17 @@ describe("managed install commands", () => {
|
||||
expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha });
|
||||
expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12)));
|
||||
expect(runCommand.mock.calls.filter(([command, args]) => command === "curl" && args.includes("--output"))).toHaveLength(1);
|
||||
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1);
|
||||
const pnpmInstallCalls = runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install");
|
||||
expect(pnpmInstallCalls.map(([, args]) => args)).toEqual([
|
||||
["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"],
|
||||
["pnpm", "install", "--frozen-lockfile"],
|
||||
]);
|
||||
const checkoutPath = runCommand.mock.calls.find(([command]) => command === "tar")?.[1].at(-1);
|
||||
expect(checkoutPath).toContain(path.join("installs", "git", `.${sha.slice(0, 12)}.tmp-`));
|
||||
for (const [, , options] of pnpmInstallCalls) expect(options?.cwd).toBe(checkoutPath);
|
||||
expect(runCommand.mock.calls.indexOf(pnpmInstallCalls[1])).toBeLessThan(
|
||||
runCommand.mock.calls.findIndex(([command]) => command === "bash"),
|
||||
);
|
||||
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2);
|
||||
expect(runCommand.mock.calls.filter(([command, args]) => command === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))).toHaveLength(1);
|
||||
expect(runCommand.mock.calls.filter(([command, args]) => command === "npm" && args[0] === "pack")).toHaveLength(2);
|
||||
@@ -168,6 +223,367 @@ describe("managed install commands", () => {
|
||||
expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("cleans a failed Git checkout lockfile resolution and preserves the active install", async () => {
|
||||
const previousSha = "1".repeat(40);
|
||||
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
|
||||
const paths = resolveInstallStorePaths();
|
||||
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
|
||||
const previousTarget = fs.readlinkSync(paths.currentPath);
|
||||
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
|
||||
const sha = "2".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha);
|
||||
let failedCheckout: string | undefined;
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === "corepack" && args.includes("--resolution-only")) {
|
||||
failedCheckout = options?.cwd as string;
|
||||
expect(fs.existsSync(paths.lockPath)).toBe(true);
|
||||
fs.writeFileSync(path.join(failedCheckout, "pnpm-lock.yaml"), "partial failed resolution\n");
|
||||
throw new Error("dependency lockfile resolution failed");
|
||||
}
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
|
||||
await expect(installCommand({ ref: sha, yes: true }, { runCommand }))
|
||||
.rejects.toThrow("dependency lockfile resolution failed");
|
||||
|
||||
expect(failedCheckout).toBeDefined();
|
||||
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
|
||||
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
|
||||
expect(runCommand.mock.calls.some(([, args]) => args.includes("--frozen-lockfile"))).toBe(false);
|
||||
expect(runCommand.mock.calls.some(([file]) => file === "bash" || file === "npm")).toBe(false);
|
||||
expect(fs.existsSync(paths.lockPath)).toBe(false);
|
||||
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
|
||||
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
|
||||
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds bundled Git server UI from its checkout before staging the declared files", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
const sha = "3".repeat(40);
|
||||
const runCommand = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
|
||||
.resolves.toMatchObject({ version: "0.3.1", reused: false });
|
||||
const uiCalls = runCommand.mock.calls.filter(([file, args]) => file === "corepack" && args.includes("prepare:ui-dist"));
|
||||
expect(uiCalls).toHaveLength(1);
|
||||
expect(uiCalls[0][1]).toEqual(["pnpm", "--dir", "server", "prepare:ui-dist"]);
|
||||
expect(uiCalls[0][2]?.env).not.toHaveProperty("NODE_ENV");
|
||||
const serverStage = runCommand.mock.calls.findIndex(([file, args]) =>
|
||||
file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server"));
|
||||
expect(serverStage).toBeGreaterThan(runCommand.mock.calls.indexOf(uiCalls[0]));
|
||||
expect(runCommand.mock.calls[serverStage][2]?.cwd).toBe(uiCalls[0][2]?.cwd);
|
||||
});
|
||||
|
||||
it("packs the built Git stage without source-only hooks and preserves consumer install hooks", async () => {
|
||||
const sha = "9".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
let packedFiles: string[] = [];
|
||||
let negativeControlPassed = false;
|
||||
const npmEnv = {
|
||||
...process.env,
|
||||
PATH: ORIGINAL_ENV.PATH,
|
||||
npm_config_cache: path.join(root, "npm-cache"),
|
||||
npm_config_userconfig: path.join(root, ".npmrc"),
|
||||
npm_config_offline: "true",
|
||||
npm_config_ignore_scripts: "false",
|
||||
};
|
||||
fs.writeFileSync(npmEnv.npm_config_userconfig, "");
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server")) {
|
||||
const result = await checkoutCommands(file, args, options);
|
||||
const stagedManifest = path.join(args[2], "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(stagedManifest, "utf8"));
|
||||
// The real bundler preserves scripts but stages only declared runtime files.
|
||||
packageJson.scripts = {
|
||||
prepack: "node ../scripts/source-prepack.cjs",
|
||||
postpack: "node ../scripts/source-postpack.cjs",
|
||||
postinstall: "node dist/consumer-install.cjs",
|
||||
};
|
||||
packageJson.dependencies["@paperclipai/db"] = "0.3.1";
|
||||
fs.writeFileSync(stagedManifest, JSON.stringify(packageJson));
|
||||
fs.writeFileSync(path.join(args[2], "dist", "consumer-install.cjs"), "process.stdout.write('consumer hook retained');\n");
|
||||
for (const hook of ["source-prepack", "source-postpack"]) {
|
||||
fs.writeFileSync(path.join(options!.cwd as string, "scripts", `${hook}.cjs`), "process.stdout.write('source workspace hook');\n");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-")) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string };
|
||||
if (manifest.name === "@paperclipai/server") {
|
||||
const realOptions = { ...options, env: npmEnv, timeout: 20_000 };
|
||||
// Removing the lifecycle boundary must reproduce the actual staged
|
||||
// packaging failure, without any provider, build, or network access.
|
||||
await expect(runCommandWithDiagnostics("npm", [...args.filter((arg) => arg !== "--ignore-scripts"), "--json"], realOptions))
|
||||
.rejects.toThrow("source-prepack.cjs");
|
||||
negativeControlPassed = true;
|
||||
const result = await runCommandWithDiagnostics("npm", [...args, "--json"], realOptions);
|
||||
const packed = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }> }>;
|
||||
packedFiles = packed[0].files.map((entry) => entry.path);
|
||||
expect(fs.existsSync(path.join(args[1], "ui-dist", "index.html"))).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")).scripts.postinstall)
|
||||
.toBe("node dist/consumer-install.cjs");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
|
||||
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
|
||||
.resolves.toMatchObject({ version: "0.3.1", reused: false });
|
||||
expect(negativeControlPassed).toBe(true);
|
||||
expect(packedFiles).toEqual(expect.arrayContaining([
|
||||
"dist/index.js", "dist/consumer-install.cjs", "ui-dist/index.html",
|
||||
"skills/paperclip/SKILL.md", "skills/paperclip/scripts/helper.mjs",
|
||||
]));
|
||||
const consumerInstall = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "install");
|
||||
expect(consumerInstall?.[1]).not.toContain("--ignore-scripts");
|
||||
expect(consumerInstall?.[2]?.env?.npm_config_ignore_scripts).toBeUndefined();
|
||||
const sourcePack = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "pack" && args[1] === "--pack-destination");
|
||||
expect(sourcePack?.[1]).not.toContain("--ignore-scripts");
|
||||
});
|
||||
|
||||
it("installs mixed-version Git packages offline with the dependency's own SDK version and consumer hooks", async () => {
|
||||
const sha = "f".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
const { materializePublishManifest } = await import(new URL("../../../scripts/prepare-bundled-package.mjs", import.meta.url).href);
|
||||
const npmEnv = {
|
||||
...process.env,
|
||||
PATH: ORIGINAL_ENV.PATH,
|
||||
npm_config_cache: path.join(root, "npm-cache"),
|
||||
npm_config_userconfig: path.join(root, ".npmrc"),
|
||||
npm_config_offline: "true",
|
||||
npm_config_ignore_scripts: "false",
|
||||
};
|
||||
fs.writeFileSync(npmEnv.npm_config_userconfig, "");
|
||||
let checkout: string;
|
||||
let consumerInstalled = false;
|
||||
let packedSdkReferences: Record<string, string> = {};
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === "tar") {
|
||||
const result = await checkoutCommands(file, args, options);
|
||||
checkout = args[args.indexOf("-C") + 1];
|
||||
const sdk = path.join(checkout, "packages/plugins/sdk");
|
||||
fs.mkdirSync(path.join(sdk, "dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(sdk, "dist/index.cjs"), "module.exports.ownVersion = '1.0.0';\n");
|
||||
fs.writeFileSync(path.join(sdk, "prepare.cjs"), "require('node:fs').writeFileSync('source-pack-hook.txt', 'source hook ran');\n");
|
||||
fs.writeFileSync(path.join(sdk, "package.json"), JSON.stringify({
|
||||
name: "@paperclipai/plugin-sdk", version: "1.0.0", main: "dist/index.cjs", files: ["dist"],
|
||||
dependencies: { "@paperclipai/shared": "0.3.1" }, scripts: { prepack: "node prepare.cjs" },
|
||||
}));
|
||||
const manifestPath = path.join(checkout, "scripts/release-package-manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.push({ dir: "packages/plugins/sdk", name: "@paperclipai/plugin-sdk" });
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
const serverManifestPath = path.join(checkout, "server/package.json");
|
||||
const serverManifest = JSON.parse(fs.readFileSync(serverManifestPath, "utf8"));
|
||||
serverManifest.dependencies["@paperclipai/plugin-sdk"] = "workspace:*";
|
||||
serverManifest.dependencies["@paperclipai/shared"] = "~0.3.0";
|
||||
serverManifest.optionalDependencies = { "@paperclipai/db": "workspace:~" };
|
||||
serverManifest.peerDependencies = { "@paperclipai/plugin-sdk": "workspace:^" };
|
||||
fs.writeFileSync(serverManifestPath, JSON.stringify(serverManifest));
|
||||
const dbManifestPath = path.join(checkout, "packages/db/package.json");
|
||||
const dbManifest = JSON.parse(fs.readFileSync(dbManifestPath, "utf8"));
|
||||
dbManifest.version = "0.2.0";
|
||||
fs.writeFileSync(dbManifestPath, JSON.stringify(dbManifest));
|
||||
fs.mkdirSync(path.join(checkout, "cli/dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(checkout, "cli/dist/index.js"), "console.log('0.3.1');\n");
|
||||
fs.writeFileSync(path.join(checkout, "cli/package.json"), JSON.stringify({
|
||||
name: "paperclipai", version: "0.3.1", files: ["dist"], dependencies: { "@paperclipai/server": "0.3.1" },
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
|
||||
const result = await checkoutCommands(file, args, options);
|
||||
const stagedManifestPath = path.join(args[2], "package.json");
|
||||
// Use the real release manifest translator, including its owning-package
|
||||
// workspace normalization. The installer must correct the Git-only case.
|
||||
const manifest = materializePublishManifest(JSON.parse(fs.readFileSync(stagedManifestPath, "utf8")));
|
||||
if (manifest.name === "@paperclipai/server") {
|
||||
manifest.scripts = {
|
||||
prepack: "node ../scripts/source-prepack-does-not-exist.cjs",
|
||||
postpack: "node ../scripts/source-postpack-does-not-exist.cjs",
|
||||
postinstall: "node dist/consumer-install.cjs",
|
||||
};
|
||||
fs.writeFileSync(path.join(args[2], "dist/consumer-install.cjs"),
|
||||
"require('node:fs').writeFileSync('consumer-hook.txt', require('@paperclipai/plugin-sdk').ownVersion);\n");
|
||||
}
|
||||
fs.writeFileSync(stagedManifestPath, JSON.stringify(manifest));
|
||||
return result;
|
||||
}
|
||||
if (file === "corepack" && args.includes("pack")) {
|
||||
const source = path.join(checkout, args[args.indexOf("--dir") + 1]);
|
||||
return runCommandWithDiagnostics("npm", ["pack", "--pack-destination", args[args.indexOf("--pack-destination") + 1], "--json"],
|
||||
{ cwd: source, env: npmEnv, timeout: 20_000 });
|
||||
}
|
||||
if (file === "npm" && args[0] === "pack") {
|
||||
if (args[1]?.includes("workspace-package-")) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8"));
|
||||
if (manifest.name === "@paperclipai/server") {
|
||||
packedSdkReferences = Object.fromEntries(["dependencies", "optionalDependencies", "peerDependencies"]
|
||||
.map((section) => [section, manifest[section]["@paperclipai/plugin-sdk"]]));
|
||||
}
|
||||
}
|
||||
return runCommandWithDiagnostics(file, [...args, "--json"], { ...options, env: npmEnv, timeout: 20_000 });
|
||||
}
|
||||
if (file === "npm" && args[0] === "install") {
|
||||
expect(args).not.toContain("--ignore-scripts");
|
||||
expect(args.filter((arg) => arg.endsWith(".tgz"))).toHaveLength(5);
|
||||
expect(args.some((arg) => arg.endsWith("paperclipai-plugin-sdk-1.0.0.tgz"))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(checkout, "packages/plugins/sdk/source-pack-hook.txt"), "utf8")).toBe("source hook ran");
|
||||
const sourceManifest = JSON.parse(fs.readFileSync(path.join(checkout, "server/package.json"), "utf8"));
|
||||
expect(sourceManifest.dependencies["@paperclipai/plugin-sdk"]).toBe("workspace:*");
|
||||
expect(sourceManifest.optionalDependencies["@paperclipai/db"]).toBe("workspace:~");
|
||||
expect(sourceManifest.peerDependencies["@paperclipai/plugin-sdk"]).toBe("workspace:^");
|
||||
let result;
|
||||
try {
|
||||
result = await runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 });
|
||||
} catch (error) {
|
||||
throw new Error(`Offline consumer failed with SDK 1.0.0 archive and packed SDK references ${JSON.stringify(packedSdkReferences)}: ${String(error)}`, { cause: error });
|
||||
}
|
||||
const installedServer = path.join(args[args.indexOf("--prefix") + 1], "node_modules/@paperclipai/server");
|
||||
const installed = JSON.parse(fs.readFileSync(path.join(installedServer, "package.json"), "utf8"));
|
||||
expect(installed.dependencies["@paperclipai/plugin-sdk"]).toBe("1.0.0");
|
||||
expect(installed.dependencies["@paperclipai/shared"]).toBe("~0.3.0");
|
||||
expect(installed.dependencies["@paperclipai/db"]).toBe("0.2.0");
|
||||
expect(installed.optionalDependencies["@paperclipai/db"]).toBe("~0.2.0");
|
||||
expect(installed.peerDependencies["@paperclipai/plugin-sdk"]).toBe("^1.0.0");
|
||||
expect(fs.readFileSync(path.join(installedServer, "consumer-hook.txt"), "utf8")).toBe("1.0.0");
|
||||
consumerInstalled = true;
|
||||
return result;
|
||||
}
|
||||
if (file === process.execPath && args.at(-1) === "--version") {
|
||||
return runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 });
|
||||
}
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
|
||||
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
|
||||
.resolves.toMatchObject({ version: "0.3.1", reused: false });
|
||||
expect(consumerInstalled).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it.each(["source build", "staged pack", "consumer install"])("preserves the active Git install after a failed %s", async (phase) => {
|
||||
const previousSha = "a".repeat(40);
|
||||
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
|
||||
const paths = resolveInstallStorePaths();
|
||||
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
|
||||
const previousTarget = fs.readlinkSync(paths.currentPath);
|
||||
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
|
||||
const sha = "e".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
let failedCheckout: string | undefined;
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === "tar") failedCheckout = args[args.indexOf("-C") + 1];
|
||||
const failedPhase = phase === "source build"
|
||||
? file === "corepack" && args.includes("-r") && args.includes("build")
|
||||
: phase === "staged pack"
|
||||
? file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-")
|
||||
: file === "npm" && args[0] === "install";
|
||||
if (failedPhase) throw new Error(`${phase} failed`);
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
|
||||
await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow(`${phase} failed`);
|
||||
expect(failedCheckout).toBeDefined();
|
||||
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
|
||||
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
|
||||
if (phase === "source build") expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false);
|
||||
if (phase === "staged pack") expect(runCommand.mock.calls.some(([file, args]) => file === "npm" && args[0] === "install")).toBe(false);
|
||||
expect(fs.existsSync(paths.lockPath)).toBe(false);
|
||||
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
|
||||
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
|
||||
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("stages same-checkout runtime skills in the release packages before bundled Git packaging", async () => {
|
||||
const sha = "6".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
let checkedSkills = false;
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
|
||||
const checkout = options!.cwd as string;
|
||||
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
|
||||
const skills = path.join(checkout, packageDir, "skills");
|
||||
expect(fs.readdirSync(skills)).toEqual(["paperclip"]);
|
||||
for (const file of ["SKILL.md", "scripts/helper.mjs"]) {
|
||||
expect(fs.readFileSync(path.join(skills, "paperclip", file), "utf8"))
|
||||
.toBe(fs.readFileSync(path.join(checkout, "skills", "paperclip", file), "utf8"));
|
||||
}
|
||||
}
|
||||
// Other adapters' optional skills keep their established pack behavior.
|
||||
expect(fs.existsSync(path.join(checkout, "packages/adapters/cursor-local/skills"))).toBe(false);
|
||||
checkedSkills = true;
|
||||
}
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
|
||||
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
|
||||
.resolves.toMatchObject({ version: "0.3.1", reused: false });
|
||||
expect(checkedSkills).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans missing Git runtime skills and preserves the active installed payload", async () => {
|
||||
const previousSha = "7".repeat(40);
|
||||
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
|
||||
const paths = resolveInstallStorePaths();
|
||||
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
|
||||
const previousTarget = fs.readlinkSync(paths.currentPath);
|
||||
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
|
||||
const sha = "8".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
let failedCheckout: string | undefined;
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
const result = await checkoutCommands(file, args, options);
|
||||
if (file === "tar") {
|
||||
failedCheckout = args[args.indexOf("-C") + 1];
|
||||
fs.rmSync(path.join(failedCheckout, "skills"), { recursive: true });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
await expect(installCommand({ ref: sha, yes: true }, { runCommand }))
|
||||
.rejects.toThrow(/ENOENT.*skills/);
|
||||
expect(failedCheckout).toBeDefined();
|
||||
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
|
||||
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
|
||||
expect(runCommand.mock.calls.some(([file, args]) => file === "npm" || (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")))).toBe(false);
|
||||
expect(fs.existsSync(paths.lockPath)).toBe(false);
|
||||
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
|
||||
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
|
||||
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("cleans a failed Git UI build and preserves the active installed payload", async () => {
|
||||
const previousSha = "4".repeat(40);
|
||||
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
|
||||
const paths = resolveInstallStorePaths();
|
||||
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
|
||||
const previousTarget = fs.readlinkSync(paths.currentPath);
|
||||
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
|
||||
const sha = "5".repeat(40);
|
||||
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
|
||||
let failedCheckout: string | undefined;
|
||||
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
|
||||
if (file === "corepack" && args.includes("prepare:ui-dist")) {
|
||||
failedCheckout = options?.cwd as string;
|
||||
throw new Error("UI build failed");
|
||||
}
|
||||
return checkoutCommands(file, args, options);
|
||||
});
|
||||
await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow("UI build failed");
|
||||
expect(failedCheckout).toBeDefined();
|
||||
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
|
||||
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
|
||||
expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false);
|
||||
expect(fs.existsSync(paths.lockPath)).toBe(false);
|
||||
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
|
||||
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
|
||||
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds git checkouts with NODE_ENV cleared so ambient production mode keeps devDependencies", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
const sha = "d".repeat(40);
|
||||
@@ -178,7 +594,7 @@ describe("managed install commands", () => {
|
||||
file === "corepack" ||
|
||||
(file === "npm" && args[0] === "pack") ||
|
||||
(file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")));
|
||||
expect(buildCalls).toHaveLength(9);
|
||||
expect(buildCalls).toHaveLength(11);
|
||||
for (const call of buildCalls) {
|
||||
const env = call[2]?.env;
|
||||
expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined();
|
||||
|
||||
@@ -275,19 +275,61 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C
|
||||
await runGitHubCurl(["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], runCommand, { maxBuffer: 4 * 1024 * 1024 });
|
||||
await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 });
|
||||
await runCommand("corepack", ["enable", "pnpm", "--install-directory", pnpmShimDir], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 4 * 1024 * 1024 });
|
||||
// Exact source commits can precede the lockfile bot. Resolve only inside this
|
||||
// downloaded checkout, without lifecycle scripts, before its frozen install.
|
||||
await runCommand("corepack", ["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
// Bundled package staging bypasses the server's prepack hook. Produce its
|
||||
// declared UI files from this same checkout before staging that package.
|
||||
await runCommand("corepack", ["pnpm", "--dir", "server", "prepare:ui-dist"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
// Match release.sh's runtime-skill preparation. These packages declare
|
||||
// skills separately from their build output; bundled staging bypasses it.
|
||||
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
|
||||
const destination = path.join(checkoutPath, packageDir, "skills");
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(checkoutPath, "skills"), destination, { recursive: true });
|
||||
}
|
||||
const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string };
|
||||
const workspacePackages = resolveGitInstallWorkspacePackages(checkoutPath);
|
||||
const workspaceVersions = new Map(workspacePackages.map((entry) => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(checkoutPath, entry.dir, "package.json"), "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || !EXACT_VERSION_PATTERN.test(packageJson.version)) {
|
||||
throw new Error(`Git install cannot stage ${entry.name}: its package version must be an exact version.`);
|
||||
}
|
||||
return [entry.name, packageJson.version] as const;
|
||||
}));
|
||||
for (const [index, workspacePackage] of workspacePackages.entries()) {
|
||||
const packageDir = path.join(checkoutPath, workspacePackage.dir);
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as { bundleDependencies?: string[]; bundledDependencies?: string[] };
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as {
|
||||
bundleDependencies?: string[]; bundledDependencies?: string[];
|
||||
dependencies?: Record<string, string>; optionalDependencies?: Record<string, string>; peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const bundledDependencies = packageJson.bundleDependencies ?? packageJson.bundledDependencies ?? [];
|
||||
if (bundledDependencies.length > 0) {
|
||||
const stagedPackage = path.join(stagingRoot, `workspace-package-${index}`);
|
||||
await runCommand(process.execPath, [path.join(checkoutPath, "scripts", "prepare-bundled-package.mjs"), packageDir, stagedPackage], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
||||
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
|
||||
// Release packaging gives workspace packages one version. A Git checkout
|
||||
// can retain independent versions, so bind each staged workspace reference
|
||||
// to that dependency's actual package rather than the owning package.
|
||||
const stagedManifestPath = path.join(stagedPackage, "package.json");
|
||||
const stagedManifest = JSON.parse(fs.readFileSync(stagedManifestPath, "utf8")) as typeof packageJson;
|
||||
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"] as const) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[section] ?? {})) {
|
||||
if (!specifier.startsWith("workspace:")) continue;
|
||||
const version = workspaceVersions.get(name);
|
||||
if (!version) throw new Error(`Git install cannot stage workspace dependency ${name}: its package version is missing.`);
|
||||
const range = specifier.slice("workspace:".length);
|
||||
const prefix = range === "^" || range === "~" ? range : "";
|
||||
stagedManifest[section] = { ...stagedManifest[section], [name]: `${prefix}${version}` };
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(stagedManifestPath, `${JSON.stringify(stagedManifest, null, 2)}\n`);
|
||||
// The stage contains compiled runtime files, not the source workspace.
|
||||
// Match release packaging: source-only prepack/postpack hooks must not
|
||||
// rebuild it or remove its prepared assets. Consumer install hooks stay enabled.
|
||||
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot, "--ignore-scripts"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
|
||||
} else {
|
||||
await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv({ PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }), maxBuffer: 32 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user