From c9559a5d90aeec2f7b7ab6181db63b618d5c610c Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 21:21:12 +0000 Subject: [PATCH] fix(service): make managed installs start reliably Co-Authored-By: Paperclip --- cli/src/__tests__/install-command.test.ts | 13 ++++++-- .../__tests__/service-health-check.test.ts | 13 ++++++++ cli/src/checks/service-health-check.ts | 2 ++ cli/src/commands/install.ts | 13 ++++++-- package.json | 1 - .../db/src/embedded-postgres-native.test.ts | 22 +++++++++++++- packages/db/src/embedded-postgres-native.ts | 27 ++++++++++++++++- packages/db/src/index.ts | 1 + .../embedded-postgres@18.1.0-beta.16.patch | 30 ------------------- pnpm-lock.yaml | 11 +++---- 10 files changed, 88 insertions(+), 45 deletions(-) delete mode 100644 patches/embedded-postgres@18.1.0-beta.16.patch diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index 495e4fab671..6dfe6eba20a 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -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 () => { diff --git a/cli/src/__tests__/service-health-check.test.ts b/cli/src/__tests__/service-health-check.test.ts index 934633bd4d0..b6a9c4cd307 100644 --- a/cli/src/__tests__/service-health-check.test.ts +++ b/cli/src/__tests__/service-health-check.test.ts @@ -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(); diff --git a/cli/src/checks/service-health-check.ts b/cli/src/checks/service-health-check.ts index 47d62586a77..27f3a3a3ad7 100644 --- a/cli/src/checks/service-health-check.ts +++ b/cli/src/checks/service-health-check.ts @@ -40,6 +40,8 @@ export async function serviceHealthChecks( config: PaperclipConfig, dependencies: Partial = {}, ): Promise { + if (process.env.PAPERCLIP_SERVICE_MANAGED === "1") return []; + const deps: ServiceCheckDependencies = { detect: (instanceId) => detectServiceManager({ instanceId }), probe: probeHealth, diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 6c8a9b3a178..d1dd2c9f510 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -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 }; diff --git a/package.json b/package.json index 38a3abc0235..b44ed8dba20 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/db/src/embedded-postgres-native.test.ts b/packages/db/src/embedded-postgres-native.test.ts index 7335f2c6492..06ef70d4386 100644 --- a/packages/db/src/embedded-postgres-native.test.ts +++ b/packages/db/src/embedded-postgres-native.test.ts @@ -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); + }); +}); diff --git a/packages/db/src/embedded-postgres-native.ts b/packages/db/src/embedded-postgres-native.ts index a304f8a18ec..ddeb3f7a75e 100644 --- a/packages/db/src/embedded-postgres-native.ts +++ b/packages/db/src/embedded-postgres-native.ts @@ -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 { + installEmbeddedPostgresSpawnPatch(); const nativePackageName = resolveNativePackageName(); const packageRoot = resolveEmbeddedPostgresPackageRoot(); if (!nativePackageName || !packageRoot) return; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ff1a803dba6..065fa183c42 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/patches/embedded-postgres@18.1.0-beta.16.patch b/patches/embedded-postgres@18.1.0-beta.16.patch deleted file mode 100644 index 0030bd9987c..00000000000 --- a/patches/embedded-postgres@18.1.0-beta.16.patch +++ /dev/null @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 185c29bb884..57c3dcc5221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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