Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/api/src/modules/deployments/prepare.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type RepoTreeEntry,
} from "../../lib/project-root-detector";
import {
ALL_PACKAGE_MANAGERS,
parseDeploymentMetadata,
parseOpenshipConfigJson,
METADATA_FILES,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions apps/api/test/modules/deployments/prepare.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions apps/dashboard/src/context/deployment/mode-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 1 addition & 1 deletion apps/dashboard/src/context/deployment/mode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/lib/api/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down