fix(service): make managed installs start reliably

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:32:13 +00:00
1 parent 8925d3f38a
commit c9559a5d90
10 files changed
+88 -45

No files matched your search

+11 -2
View File
@@ -93,7 +93,15 @@ describe("managed install commands", () => {
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: "" }; }
if (file === "tar") { const checkout = args[args.indexOf("-C") + 1]; fs.mkdirSync(path.join(checkout, "cli"), { recursive: true }); fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" })); return { stdout: "", stderr: "" }; }
if (file === "corepack" || file === "bash") return { stdout: "", stderr: "" };
if (file === "corepack") {
if (args.includes("pack")) {
const destination = args[args.indexOf("--pack-destination") + 1];
const packageName = args.includes("packages/db") ? "paperclipai-db" : "paperclipai-server";
fs.writeFileSync(path.join(destination, `${packageName}-0.3.1.tgz`), "package");
}
return { stdout: "", stderr: "" };
}
if (file === "bash") return { stdout: "", stderr: "" };
if (file === "npm" && args[0] === "pack") { fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], "paperclipai-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) return { stdout: "0.3.1\n", stderr: "" };
@@ -105,7 +113,8 @@ 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]) => command === "corepack")).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2);
});
it("installs through the shim, reports provenance, and uninstalls without deleting user data", async () => {
@@ -12,15 +12,19 @@ const config = {
} as PaperclipConfig;
let previousPaperclipHome: string | undefined;
let previousServiceManaged: string | undefined;
beforeEach(() => {
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousServiceManaged = process.env.PAPERCLIP_SERVICE_MANAGED;
process.env.PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-service-restart-"));
});
afterEach(() => {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousServiceManaged === undefined) delete process.env.PAPERCLIP_SERVICE_MANAGED;
else process.env.PAPERCLIP_SERVICE_MANAGED = previousServiceManaged;
});
function managerFixture(active = true) {
@@ -52,6 +56,15 @@ function managerFixture(active = true) {
}
describe("service health doctor checks", () => {
it("skips live service checks during the managed unit's own activation", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
const detect = vi.fn();
const probe = vi.fn();
await expect(serviceHealthChecks(config, { detect, probe })).resolves.toEqual([]);
expect(detect).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
});
it("skips exact version matching unless a restart version is explicit", () => {
expect(resolveRestartExpectedVersion(null)).toBeNull();
expect(resolveRestartExpectedVersion(undefined)).toBeNull();
+2
View File
@@ -40,6 +40,8 @@ export async function serviceHealthChecks(
config: PaperclipConfig,
dependencies: Partial<ServiceCheckDependencies> = {},
): Promise<CheckResult[]> {
if (process.env.PAPERCLIP_SERVICE_MANAGED === "1") return [];
const deps: ServiceCheckDependencies = {
detect: (instanceId) => detectServiceManager({ instanceId }),
probe: probeHealth,
+10 -3
View File
@@ -185,11 +185,18 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C
await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "--filter", "@paperclipai/db", "build"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "--filter", "@paperclipai/server", "build"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string };
await runCommand("corepack", ["pnpm", "--dir", "packages/db", "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, maxBuffer: 16 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "--dir", "server", "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: { ...process.env, PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }, maxBuffer: 32 * 1024 * 1024 });
await runCommand("npm", ["pack", "--pack-destination", stagingRoot], { cwd: path.join(checkoutPath, "cli"), maxBuffer: 16 * 1024 * 1024 });
const tarball = fs.readdirSync(stagingRoot).find((entry) => entry.endsWith(".tgz"));
if (!tarball) throw new Error("npm pack did not produce a paperclipai tarball.");
await runCommand("npm", ["install", "--prefix", stagedPayload, path.join(stagingRoot, tarball), "--no-audit", "--no-fund"], { cwd: stagingRoot, maxBuffer: 32 * 1024 * 1024 });
const tarballs = fs.readdirSync(stagingRoot).filter((entry) => entry.endsWith(".tgz"));
const cliTarball = tarballs.find((entry) => /^paperclipai-(?!db-|server-)/.test(entry));
const dbTarball = tarballs.find((entry) => entry.startsWith("paperclipai-db-"));
const serverTarball = tarballs.find((entry) => entry.startsWith("paperclipai-server-"));
if (!cliTarball || !dbTarball || !serverTarball) throw new Error("Git install packaging did not produce paperclipai, @paperclipai/db, and @paperclipai/server tarballs.");
await runCommand("npm", ["install", "--prefix", stagedPayload, path.join(stagingRoot, cliTarball), path.join(stagingRoot, dbTarball), path.join(stagingRoot, serverTarball), "--no-audit", "--no-fund"], { cwd: stagingRoot, maxBuffer: 32 * 1024 * 1024 });
await smokePayload(stagedPayload, metadata.version, runCommand);
fs.renameSync(stagedPayload, payloadPath);
return { payloadPath, reused: false, version: metadata.version };
-1
View File
@@ -79,7 +79,6 @@
"packageManager": "pnpm@9.15.4",
"pnpm": {
"patchedDependencies": {
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch",
"acpx@0.12.0": "patches/acpx@0.12.0.patch"
},
"overrides": {
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js";
import { ensureLinuxSharedLibraryAliases, mergeEmbeddedPostgresSpawnEnv } from "./embedded-postgres-native.js";
describe("embedded Postgres native runtime", () => {
const tempDirs: string[] = [];
@@ -41,3 +41,23 @@ describe("embedded Postgres native runtime", () => {
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
});
});
describe("embedded Postgres spawn environment", () => {
it("preserves the process environment for initdb and forces a portable locale", () => {
const previousPath = process.env.PATH;
process.env.PATH = "/paperclip/bin";
try {
expect(mergeEmbeddedPostgresSpawnEnv("/native/bin/initdb", { env: { LC_MESSAGES: "en_US.UTF-8" } })).toEqual({
env: expect.objectContaining({ PATH: "/paperclip/bin", LC_MESSAGES: "C" }),
});
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it("does not alter unrelated child processes", () => {
const options = { env: { ONLY: "value" } };
expect(mergeEmbeddedPostgresSpawnEnv("node", options)).toBe(options);
});
});
+26 -1
View File
@@ -1,8 +1,32 @@
import { promises as fs } from "node:fs";
import { createRequire } from "node:module";
import childProcess from "node:child_process";
import { createRequire, syncBuiltinESMExports } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
let embeddedPostgresSpawnPatchInstalled = false;
export function mergeEmbeddedPostgresSpawnEnv(
command: string,
options: { env?: NodeJS.ProcessEnv } | undefined,
): { env?: NodeJS.ProcessEnv } | undefined {
const executable = path.basename(command).replace(/\.exe$/i, "");
if (executable !== "initdb" && executable !== "postgres") return options;
return { ...options, env: { ...process.env, ...options?.env, LC_MESSAGES: "C" } };
}
function installEmbeddedPostgresSpawnPatch(): void {
if (embeddedPostgresSpawnPatchInstalled) return;
const originalSpawn = childProcess.spawn;
childProcess.spawn = ((command: string, args?: readonly string[], options?: object) =>
originalSpawn(
command,
args,
mergeEmbeddedPostgresSpawnEnv(command, options) as never,
)) as typeof childProcess.spawn;
syncBuiltinESMExports();
embeddedPostgresSpawnPatchInstalled = true;
}
function resolveNativePackageName(): string | null {
if (process.platform !== "linux") return null;
@@ -72,6 +96,7 @@ export async function ensureLinuxSharedLibraryAliases(libDir: string): Promise<s
}
export async function prepareEmbeddedPostgresNativeRuntime(): Promise<void> {
installEmbeddedPostgresSpawnPatch();
const nativePackageName = resolveNativePackageName();
const packageRoot = resolveEmbeddedPostgresPackageRoot();
if (!nativePackageName || !packageRoot) return;
+1
View File
@@ -33,6 +33,7 @@ export {
} from "./embedded-postgres-error.js";
export {
ensureLinuxSharedLibraryAliases,
mergeEmbeddedPostgresSpawnEnv,
prepareEmbeddedPostgresNativeRuntime,
} from "./embedded-postgres-native.js";
export { issueRelations } from "./schema/issue_relations.js";
@@ -1,30 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -23,7 +23,7 @@
* for a particular string, we need to force that string into the right locale.
* @see https://github.com/leinelissen/embedded-postgres/issues/15
*/
-const LC_MESSAGES_LOCALE = 'en_US.UTF-8';
+const LC_MESSAGES_LOCALE = 'C';
// The default configuration options for the class
const defaults = {
databaseDir: path.join(process.cwd(), 'data', 'db'),
@@ -133,7 +133,7 @@
`--pwfile=${passwordFile}`,
`--lc-messages=${LC_MESSAGES_LOCALE}`,
...this.options.initdbFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
@@ -177,7 +177,7 @@
'-p',
this.options.port.toString(),
...this.options.postgresFlags,
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
// Connect to stderr, as that is where the messages get sent
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
+4 -7
View File
@@ -23,9 +23,6 @@ patchedDependencies:
acpx@0.12.0:
hash: tb5cdbd7kiiblhylbkroxfdcha
path: patches/acpx@0.12.0.patch
embedded-postgres@18.1.0-beta.16:
hash: 55uhvnotpqyiy37rn3pqpukhei
path: patches/embedded-postgres@18.1.0-beta.16.patch
importers:
@@ -105,7 +102,7 @@ importers:
version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.29.3)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
version: 18.1.0-beta.16
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -340,7 +337,7 @@ importers:
version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.29.3)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
version: 18.1.0-beta.16
postgres:
specifier: ^3.4.9
version: 3.4.9
@@ -796,7 +793,7 @@ importers:
version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.29.3)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7)
embedded-postgres:
specifier: ^18.1.0-beta.16
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
version: 18.1.0-beta.16
express:
specifier: ^5.1.0
version: 5.2.1
@@ -13888,7 +13885,7 @@ snapshots:
electron-to-chromium@1.5.389: {}
embedded-postgres@18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei):
embedded-postgres@18.1.0-beta.16:
dependencies:
async-exit-hook: 2.0.1
pg: 8.18.0