diff --git a/apps/api/src/modules/deployments/prepare.service.ts b/apps/api/src/modules/deployments/prepare.service.ts index 623c3bb4..a256bc00 100644 --- a/apps/api/src/modules/deployments/prepare.service.ts +++ b/apps/api/src/modules/deployments/prepare.service.ts @@ -24,6 +24,7 @@ import { type RepoTreeEntry, } from "../../lib/project-root-detector"; import { + ALL_PACKAGE_MANAGERS, parseDeploymentMetadata, parseOpenshipConfigJson, METADATA_FILES, @@ -184,7 +185,18 @@ export interface ProjectInfo { stack: StackResult["stack"]; projectType: ProjectType; category: string; - packageManager: string; + /** + * Absent when the source carries no package manager at all — a stock Compose + * project has neither a manifest nor a lockfile, and `detectPackageManager` + * reports the `"unknown"` sentinel for it. Only values in + * `ALL_PACKAGE_MANAGERS` are surfaced, which is the same list every write + * body's `packageManager` is generated from: a scan echoed back into + * `POST /projects/ensure` (exactly what the wizard does) is then valid by + * construction rather than because the one sentinel we know about was + * filtered out (#389). Omitted is the shape the field being optional on + * every write body already allows. + */ + packageManager?: string; buildCommand: string; installCommand: string; startCommand: string; @@ -838,7 +850,9 @@ function toProjectInfo( stack: stack.stack, projectType, category: stack.category, - packageManager: stack.packageManager, + ...(ALL_PACKAGE_MANAGERS.includes(stack.packageManager) && { + packageManager: stack.packageManager, + }), buildCommand: stack.buildCommand, installCommand: stack.installCommand, startCommand: stack.startCommand, diff --git a/apps/api/test/modules/deployments/prepare.service.test.ts b/apps/api/test/modules/deployments/prepare.service.test.ts index f2f8ddc8..9547b0aa 100644 --- a/apps/api/test/modules/deployments/prepare.service.test.ts +++ b/apps/api/test/modules/deployments/prepare.service.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Value } from "@sinclair/typebox/value"; import { resolveProjectInfo } from "../../../src/modules/deployments/prepare.service"; +import { EnsureProjectBody } from "../../../src/modules/projects/project.schema"; describe("resolveProjectInfo", () => { const tempDirs: string[] = []; @@ -109,6 +111,30 @@ describe("resolveProjectInfo", () => { expect(result.rootEnv).toEqual({ PORT: "9090" }); }); + it("omits the package manager when the source has none, so the scan stays a valid project body", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "openship-prepare-")); + tempDirs.push(tempDir); + + // #389: a stock Compose project has no package.json and no lockfile, so + // detection reports the "unknown" sentinel. The wizard echoes the scan + // straight back into POST /projects/ensure, whose packageManager is drawn + // from ALL_PACKAGE_MANAGERS, and the deploy 400s. The scan must only ever + // surface a value from that same list, whatever detection reports. + await writeFile( + join(tempDir, "docker-compose.yml"), + ["services:", " immich-server:", " image: ghcr.io/immich-app/immich-server:release"].join( + "\n", + ), + ); + + const result = await resolveProjectInfo({ source: "local", path: tempDir }); + + expect(result.packageManager).toBeUndefined(); + expect( + Value.Check(EnsureProjectBody, { name: "immich", packageManager: result.packageManager }), + ).toBe(true); + }); + // ── Declared compose path (issue #330) ──────────────────────────────────── // // The detector only PROMOTES a nested compose root when the repo root is diff --git a/apps/dashboard/src/context/deployment/mode-config.test.ts b/apps/dashboard/src/context/deployment/mode-config.test.ts new file mode 100644 index 00000000..5acee387 --- /dev/null +++ b/apps/dashboard/src/context/deployment/mode-config.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { buildSingleModeSnapshot } from "./mode-config"; +import type { DeploymentConfig } from "./types"; + +/** + * #389: the scan omits `packageManager` when the source has none — a stock + * Compose project carries neither a manifest nor a lockfile. The snapshot is + * persisted through `POST /projects/ensure`, whose `packageManager` is drawn + * from the real package-manager list, so an absent value has to resolve to a + * real one here rather than travel as undefined. + */ +describe("buildSingleModeSnapshot — compose primary without a package manager", () => { + const config = { + projectType: "services", + projectName: "immich", + repo: "immich", + services: [], + publicEndpoints: [], + buildStrategy: "auto", + options: { + productionPort: "", + rootDirectory: "./", + installCommand: "", + buildCommand: "", + startCommand: "", + outputDirectory: "", + productionPaths: "", + hasServer: false, + hasBuild: false, + }, + singleAppCandidate: { + stack: "docker-compose", + projectType: "services", + category: "docker", + // No packageManager: this is exactly what the scan now returns. + buildCommand: "", + installCommand: "", + startCommand: "", + buildImage: "node:22", + outputDirectory: "dist", + rootDirectory: "./", + productionPaths: [], + port: 2283, + hasServer: false, + hasBuild: false, + }, + } as unknown as DeploymentConfig; + + it("falls back to npm instead of carrying an absent package manager", () => { + const snapshot = buildSingleModeSnapshot(config); + + expect(snapshot?.packageManager).toBe("npm"); + }); +}); diff --git a/apps/dashboard/src/context/deployment/mode-config.ts b/apps/dashboard/src/context/deployment/mode-config.ts index b2293e02..5862da76 100644 --- a/apps/dashboard/src/context/deployment/mode-config.ts +++ b/apps/dashboard/src/context/deployment/mode-config.ts @@ -305,7 +305,7 @@ function pickComposePrimary( const primary: SingleAppPrimary = { framework: candidate.stack as FrameworkId, detectedFramework: candidate.stack as FrameworkId, - packageManager: candidate.packageManager, + packageManager: candidate.packageManager || "npm", buildImage: candidate.buildImage, rootDirectory: candidate.rootDirectory, installCommand: candidate.installCommand, diff --git a/apps/dashboard/src/lib/api/deploy.ts b/apps/dashboard/src/lib/api/deploy.ts index 4a24253b..1ab06bce 100644 --- a/apps/dashboard/src/lib/api/deploy.ts +++ b/apps/dashboard/src/lib/api/deploy.ts @@ -74,7 +74,9 @@ export interface PrepareAppConfig { stack: StackId; projectType: "app" | "docker" | "services" | "monorepo"; category: string; - packageManager: string; + /** Absent when the source has none — a stock Compose project carries neither + * a manifest nor a lockfile. Every read falls back to npm. */ + packageManager?: string; buildCommand: string; installCommand: string; startCommand: string;