From 248abe03cf95fc230932263fa04bcf1490b2a16a Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 14 Mar 2026 19:30:57 +0000
Subject: [PATCH 1/4] feat: cap docker-git container CPU and RAM by default
---
packages/api/src/api/contracts.ts | 2 +
packages/api/src/api/schema.ts | 2 +
packages/api/src/services/projects.ts | 2 +
packages/api/src/ui.ts | 14 ++
.../app/src/docker-git/cli/parser-apply.ts | 26 ++--
.../app/src/docker-git/cli/parser-options.ts | 8 +
packages/app/src/docker-git/cli/usage.ts | 2 +
packages/app/src/docker-git/menu-create.ts | 18 +++
packages/app/src/docker-git/menu-render.ts | 2 +
packages/app/src/docker-git/menu-types.ts | 6 +
packages/app/tests/docker-git/parser.test.ts | 27 ++++
packages/lib/src/core/command-builders.ts | 13 ++
packages/lib/src/core/command-options.ts | 2 +
packages/lib/src/core/domain.ts | 10 ++
packages/lib/src/core/resource-limits.ts | 142 ++++++++++++++++++
packages/lib/src/core/templates.ts | 12 +-
.../lib/src/core/templates/docker-compose.ts | 35 +++--
packages/lib/src/shell/config.ts | 6 +
packages/lib/src/shell/files.ts | 10 +-
.../src/usecases/actions/create-project.ts | 4 +-
packages/lib/src/usecases/apply-overrides.ts | 14 ++
packages/lib/src/usecases/apply.ts | 5 +-
packages/lib/src/usecases/projects-up.ts | 16 +-
packages/lib/src/usecases/resource-limits.ts | 19 +++
.../lib/tests/core/resource-limits.test.ts | 82 ++++++++++
packages/lib/tests/usecases/apply.test.ts | 19 +++
.../lib/tests/usecases/prepare-files.test.ts | 4 +
.../lib/tests/usecases/projects-up.test.ts | 8 +
28 files changed, 480 insertions(+), 30 deletions(-)
create mode 100644 packages/lib/src/core/resource-limits.ts
create mode 100644 packages/lib/src/usecases/resource-limits.ts
create mode 100644 packages/lib/tests/core/resource-limits.test.ts
diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts
index 9af63ebb..9a3f0d60 100644
--- a/packages/api/src/api/contracts.ts
+++ b/packages/api/src/api/contracts.ts
@@ -42,6 +42,8 @@ export type CreateProjectRequest = {
readonly envProjectPath?: string | undefined
readonly codexAuthPath?: string | undefined
readonly codexHome?: string | undefined
+ readonly cpuLimit?: string | undefined
+ readonly ramLimit?: string | undefined
readonly dockerNetworkMode?: string | undefined
readonly dockerSharedNetworkName?: string | undefined
readonly enableMcpPlaywright?: boolean | undefined
diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts
index 8b563c8f..eadcd700 100644
--- a/packages/api/src/api/schema.ts
+++ b/packages/api/src/api/schema.ts
@@ -18,6 +18,8 @@ export const CreateProjectRequestSchema = Schema.Struct({
envProjectPath: OptionalString,
codexAuthPath: OptionalString,
codexHome: OptionalString,
+ cpuLimit: OptionalString,
+ ramLimit: OptionalString,
dockerNetworkMode: OptionalString,
dockerSharedNetworkName: OptionalString,
enableMcpPlaywright: OptionalBoolean,
diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts
index 15b0a792..c91f7e4c 100644
--- a/packages/api/src/services/projects.ts
+++ b/packages/api/src/services/projects.ts
@@ -192,6 +192,8 @@ export const createProjectFromRequest = (
...(request.envProjectPath === undefined ? {} : { envProjectPath: request.envProjectPath }),
...(request.codexAuthPath === undefined ? {} : { codexAuthPath: request.codexAuthPath }),
...(request.codexHome === undefined ? {} : { codexHome: request.codexHome }),
+ ...(request.cpuLimit === undefined ? {} : { cpuLimit: request.cpuLimit }),
+ ...(request.ramLimit === undefined ? {} : { ramLimit: request.ramLimit }),
...(request.dockerNetworkMode === undefined ? {} : { dockerNetworkMode: request.dockerNetworkMode }),
...(request.dockerSharedNetworkName === undefined ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }),
...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }),
diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts
index 55e47f32..4e85a4b3 100644
--- a/packages/api/src/ui.ts
+++ b/packages/api/src/ui.ts
@@ -383,6 +383,16 @@ export const uiHtml = `
+
@@ -530,6 +540,8 @@ export const uiScript = `
createRepoRef: byId('create-repo-ref'),
createSshPort: byId('create-ssh-port'),
createNetworkMode: byId('create-network-mode'),
+ createCpu: byId('create-cpu'),
+ createRam: byId('create-ram'),
createUp: byId('create-up'),
createForce: byId('create-force'),
createForceEnv: byId('create-force-env')
@@ -818,6 +830,8 @@ export const uiScript = `
repoUrl: views.createRepoUrl.value.trim() || undefined,
repoRef: views.createRepoRef.value.trim() || undefined,
sshPort: views.createSshPort.value.trim() || undefined,
+ cpuLimit: views.createCpu.value.trim() || undefined,
+ ramLimit: views.createRam.value.trim() || undefined,
dockerNetworkMode: views.createNetworkMode.value.trim() || undefined,
up: views.createUp.checked,
force: views.createForce.checked,
diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts
index a3615eb9..38230071 100644
--- a/packages/app/src/docker-git/cli/parser-apply.ts
+++ b/packages/app/src/docker-git/cli/parser-apply.ts
@@ -1,6 +1,7 @@
import { Either } from "effect"
import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain"
+import { normalizeCpuLimit, normalizeRamLimit } from "@effect-template/lib/core/resource-limits"
import { parseProjectDirWithOptions } from "./parser-shared.js"
@@ -17,12 +18,19 @@ import { parseProjectDirWithOptions } from "./parser-shared.js"
export const parseApply = (
args: ReadonlyArray
): Either.Either =>
- Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
- _tag: "Apply",
- projectDir,
- runUp: raw.up ?? true,
- gitTokenLabel: raw.gitTokenLabel,
- codexTokenLabel: raw.codexTokenLabel,
- claudeTokenLabel: raw.claudeTokenLabel,
- enableMcpPlaywright: raw.enableMcpPlaywright
- }))
+ Either.gen(function*(_) {
+ const { projectDir, raw } = yield* _(parseProjectDirWithOptions(args))
+ const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit, "--cpu"))
+ const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit, "--ram"))
+ return {
+ _tag: "Apply",
+ projectDir,
+ runUp: raw.up ?? true,
+ gitTokenLabel: raw.gitTokenLabel,
+ codexTokenLabel: raw.codexTokenLabel,
+ claudeTokenLabel: raw.claudeTokenLabel,
+ cpuLimit,
+ ramLimit,
+ enableMcpPlaywright: raw.enableMcpPlaywright
+ }
+ })
diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts
index 9923480f..aed13181 100644
--- a/packages/app/src/docker-git/cli/parser-options.ts
+++ b/packages/app/src/docker-git/cli/parser-options.ts
@@ -20,6 +20,8 @@ interface ValueOptionSpec {
| "envProjectPath"
| "codexAuthPath"
| "codexHome"
+ | "cpuLimit"
+ | "ramLimit"
| "dockerNetworkMode"
| "dockerSharedNetworkName"
| "archivePath"
@@ -54,6 +56,10 @@ const valueOptionSpecs: ReadonlyArray = [
{ flag: "--env-project", key: "envProjectPath" },
{ flag: "--codex-auth", key: "codexAuthPath" },
{ flag: "--codex-home", key: "codexHome" },
+ { flag: "--cpu", key: "cpuLimit" },
+ { flag: "--cpus", key: "cpuLimit" },
+ { flag: "--ram", key: "ramLimit" },
+ { flag: "--memory", key: "ramLimit" },
{ flag: "--network-mode", key: "dockerNetworkMode" },
{ flag: "--shared-network", key: "dockerSharedNetworkName" },
{ flag: "--archive", key: "archivePath" },
@@ -109,6 +115,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
+ cpuLimit: (raw, value) => ({ ...raw, cpuLimit: value }),
+ ramLimit: (raw, value) => ({ ...raw, ramLimit: value }),
dockerNetworkMode: (raw, value) => ({ ...raw, dockerNetworkMode: value }),
dockerSharedNetworkName: (raw, value) => ({ ...raw, dockerSharedNetworkName: value }),
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts
index 33e129bb..53d7cad4 100644
--- a/packages/app/src/docker-git/cli/usage.ts
+++ b/packages/app/src/docker-git/cli/usage.ts
@@ -50,6 +50,8 @@ Options:
--env-project Host path to project env file (default: ./.orch/env/project.env)
--codex-auth Host path for Codex auth cache (default: /.orch/auth/codex)
--codex-home Container path for Codex auth (default: /home/dev/.codex)
+ --cpu CPU limit: percent or cores (examples: 30%, 1.5; default: 30%)
+ --ram RAM limit: percent or size (examples: 30%, 512m, 4g; default: 30%)
--network-mode Compose network mode: shared|project (default: shared)
--shared-network Shared Docker network name when network-mode=shared (default: docker-git-shared)
--out-dir Output directory (default: //[/issue-|/pr-])
diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts
index cc96be95..7f684721 100644
--- a/packages/app/src/docker-git/menu-create.ts
+++ b/packages/app/src/docker-git/menu-create.ts
@@ -56,6 +56,12 @@ export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => {
if (input.outDir.length > 0) {
args.push("--out-dir", input.outDir)
}
+ if (input.cpuLimit.length > 0) {
+ args.push("--cpu", input.cpuLimit)
+ }
+ if (input.ramLimit.length > 0) {
+ args.push("--ram", input.ramLimit)
+ }
if (!input.runUp) {
args.push("--no-up")
}
@@ -118,6 +124,8 @@ export const resolveCreateInputs = (
repoUrl,
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
outDir,
+ cpuLimit: values.cpuLimit ?? "",
+ ramLimit: values.ramLimit ?? "",
runUp: values.runUp !== false,
enableMcpPlaywright: values.enableMcpPlaywright === true,
force: values.force === true,
@@ -196,6 +204,16 @@ const applyCreateStep = (input: {
input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir
return true
}),
+ Match.when("cpuLimit", () => {
+ input.nextValues.cpuLimit =
+ input.buffer.length > 0 ? input.buffer : input.currentDefaults.cpuLimit
+ return true
+ }),
+ Match.when("ramLimit", () => {
+ input.nextValues.ramLimit =
+ input.buffer.length > 0 ? input.buffer : input.currentDefaults.ramLimit
+ return true
+ }),
Match.when("runUp", () => {
input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp)
return true
diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts
index 68a64acd..3318ea6f 100644
--- a/packages/app/src/docker-git/menu-render.ts
+++ b/packages/app/src/docker-git/menu-render.ts
@@ -31,6 +31,8 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin
Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"),
Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
+ Match.when("cpuLimit", () => `CPU limit [${defaults.cpuLimit || "30%"}]`),
+ Match.when("ramLimit", () => `RAM limit [${defaults.ramLimit || "30%"}]`),
Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),
Match.when(
"mcpPlaywright",
diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts
index 65f245e3..b80fa389 100644
--- a/packages/app/src/docker-git/menu-types.ts
+++ b/packages/app/src/docker-git/menu-types.ts
@@ -48,6 +48,8 @@ export type CreateInputs = {
readonly repoUrl: string
readonly repoRef: string
readonly outDir: string
+ readonly cpuLimit: string
+ readonly ramLimit: string
readonly runUp: boolean
readonly enableMcpPlaywright: boolean
readonly force: boolean
@@ -58,6 +60,8 @@ export type CreateStep =
| "repoUrl"
| "repoRef"
| "outDir"
+ | "cpuLimit"
+ | "ramLimit"
| "runUp"
| "mcpPlaywright"
| "force"
@@ -66,6 +70,8 @@ export const createSteps: ReadonlyArray = [
"repoUrl",
"repoRef",
"outDir",
+ "cpuLimit",
+ "ramLimit",
"runUp",
"mcpPlaywright",
"force"
diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts
index 8f39f7e0..47d807b9 100644
--- a/packages/app/tests/docker-git/parser.test.ts
+++ b/packages/app/tests/docker-git/parser.test.ts
@@ -18,6 +18,8 @@ const expectCreateDefaults = (command: CreateCommand) => {
expect(command.outDir).toBe(".docker-git/org/repo")
expect(command.runUp).toBe(true)
expect(command.forceEnv).toBe(false)
+ expect(command.config.cpuLimit).toBe("30%")
+ expect(command.config.ramLimit).toBe("30%")
expect(command.config.dockerNetworkMode).toBe("shared")
expect(command.config.dockerSharedNetworkName).toBe("docker-git-shared")
}
@@ -36,6 +38,27 @@ describe("parseArgs", () => {
expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort)
}))
+ it.effect("parses create resource limit flags", () =>
+ expectCreateCommand(
+ ["create", "--repo-url", "https://github.com/org/repo.git", "--cpu", "30%", "--ram", "3072m"],
+ (command) => {
+ expect(command.config.cpuLimit).toBe("30%")
+ expect(command.config.ramLimit).toBe("3072m")
+ }
+ ))
+
+ it.effect("accepts legacy compose-style limit aliases", () =>
+ expectCreateCommand(
+ ["create", "--repo-url", "https://github.com/org/repo.git", "--cpus", "1.5", "--memory", "4g"],
+ (command) => {
+ expect(command.config.cpuLimit).toBe("1.5")
+ expect(command.config.ramLimit).toBe("4g")
+ }
+ ))
+
+ it.effect("rejects unitless RAM absolute limit", () =>
+ expectParseErrorTag(["create", "--repo-url", "https://github.com/org/repo.git", "--ram", "4096"], "InvalidOption"))
+
it.effect("parses create command with issue url into isolated defaults", () =>
expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => {
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
@@ -225,6 +248,8 @@ describe("parseArgs", () => {
"--git-token=agien_main",
"--codex-token=Team A",
"--claude-token=Team B",
+ "--cpu=2",
+ "--ram=4g",
"--mcp-playwright",
"--no-up"
])
@@ -235,6 +260,8 @@ describe("parseArgs", () => {
expect(command.gitTokenLabel).toBe("agien_main")
expect(command.codexTokenLabel).toBe("Team A")
expect(command.claudeTokenLabel).toBe("Team B")
+ expect(command.cpuLimit).toBe("2")
+ expect(command.ramLimit).toBe("4g")
expect(command.enableMcpPlaywright).toBe(true)
}))
diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts
index 8b5d8510..0b8026b3 100644
--- a/packages/lib/src/core/command-builders.ts
+++ b/packages/lib/src/core/command-builders.ts
@@ -6,6 +6,8 @@ import { type RawOptions } from "./command-options.js"
import {
type AgentMode,
type CreateCommand,
+ defaultCpuLimit,
+ defaultRamLimit,
defaultTemplateConfig,
deriveRepoPathParts,
deriveRepoSlug,
@@ -13,6 +15,7 @@ import {
type ParseError,
resolveRepoInput
} from "./domain.js"
+import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js"
import { trimRightChar } from "./strings.js"
import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js"
@@ -222,6 +225,8 @@ type BuildTemplateConfigInput = {
readonly repo: RepoBasics
readonly names: NameConfig
readonly paths: PathConfig
+ readonly cpuLimit: string | undefined
+ readonly ramLimit: string | undefined
readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"]
readonly dockerSharedNetworkName: string
readonly gitTokenLabel: string | undefined
@@ -237,6 +242,8 @@ const buildTemplateConfig = ({
agentMode,
claudeAuthLabel,
codexAuthLabel,
+ cpuLimit,
+ ramLimit,
dockerNetworkMode,
dockerSharedNetworkName,
enableMcpPlaywright,
@@ -263,6 +270,8 @@ const buildTemplateConfig = ({
codexAuthPath: paths.codexAuthPath,
codexSharedAuthPath: paths.codexSharedAuthPath,
codexHome: paths.codexHome,
+ cpuLimit,
+ ramLimit,
dockerNetworkMode,
dockerSharedNetworkName,
enableMcpPlaywright,
@@ -292,6 +301,8 @@ export const buildCreateCommand = (
const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel)
const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel)
const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel)
+ const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu"))
+ const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram"))
const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode))
const dockerSharedNetworkName = yield* _(
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
@@ -310,6 +321,8 @@ export const buildCreateCommand = (
repo,
names,
paths,
+ cpuLimit,
+ ramLimit,
dockerNetworkMode,
dockerSharedNetworkName,
gitTokenLabel,
diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts
index cd2bf820..3afdfe8a 100644
--- a/packages/lib/src/core/command-options.ts
+++ b/packages/lib/src/core/command-options.ts
@@ -25,6 +25,8 @@ export interface RawOptions {
readonly envProjectPath?: string
readonly codexAuthPath?: string
readonly codexHome?: string
+ readonly cpuLimit?: string
+ readonly ramLimit?: string
readonly dockerNetworkMode?: string
readonly dockerSharedNetworkName?: string
readonly enableMcpPlaywright?: boolean
diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts
index 7944f0cd..5026fcf5 100644
--- a/packages/lib/src/core/domain.ts
+++ b/packages/lib/src/core/domain.ts
@@ -10,6 +10,10 @@ export const defaultDockerNetworkMode: DockerNetworkMode = "shared"
export const defaultDockerSharedNetworkName = "docker-git-shared"
+export const defaultCpuLimit = "30%"
+
+export const defaultRamLimit = "30%"
+
export interface TemplateConfig {
readonly containerName: string
readonly serviceName: string
@@ -30,6 +34,8 @@ export interface TemplateConfig {
readonly codexAuthPath: string
readonly codexSharedAuthPath: string
readonly codexHome: string
+ readonly cpuLimit?: string | undefined
+ readonly ramLimit?: string | undefined
readonly dockerNetworkMode: DockerNetworkMode
readonly dockerSharedNetworkName: string
readonly enableMcpPlaywright: boolean
@@ -127,6 +133,8 @@ export interface ApplyCommand {
readonly gitTokenLabel?: string | undefined
readonly codexTokenLabel?: string | undefined
readonly claudeTokenLabel?: string | undefined
+ readonly cpuLimit?: string | undefined
+ readonly ramLimit?: string | undefined
readonly enableMcpPlaywright?: boolean | undefined
}
@@ -319,6 +327,8 @@ export const defaultTemplateConfig = {
codexAuthPath: "./.docker-git/.orch/auth/codex",
codexSharedAuthPath: "./.docker-git/.orch/auth/codex",
codexHome: "/home/dev/.codex",
+ cpuLimit: defaultCpuLimit,
+ ramLimit: defaultRamLimit,
dockerNetworkMode: defaultDockerNetworkMode,
dockerSharedNetworkName: defaultDockerSharedNetworkName,
enableMcpPlaywright: false,
diff --git a/packages/lib/src/core/resource-limits.ts b/packages/lib/src/core/resource-limits.ts
new file mode 100644
index 00000000..15f7a9bf
--- /dev/null
+++ b/packages/lib/src/core/resource-limits.ts
@@ -0,0 +1,142 @@
+import { Either } from "effect"
+
+import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js"
+
+const mebibyte = 1024 ** 2
+const minimumResolvedCpuLimit = 0.25
+const minimumResolvedRamLimitMib = 512
+const precisionScale = 100
+
+type HostResources = {
+ readonly cpuCount: number
+ readonly totalMemoryBytes: number
+}
+
+export type ResolvedComposeResourceLimits = {
+ readonly cpuLimit: number
+ readonly ramLimit: string
+}
+
+const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u
+const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu
+const percentPattern = /^\d+(?:\.\d+)?%$/u
+
+const normalizePrecision = (value: number): number =>
+ Math.round(value * precisionScale) / precisionScale
+
+const parsePercent = (candidate: string): number | null => {
+ if (!percentPattern.test(candidate)) {
+ return null
+ }
+ const parsed = Number(candidate.slice(0, -1))
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {
+ return null
+ }
+ return normalizePrecision(parsed)
+}
+
+const percentReason = (kind: "cpu" | "ram"): string =>
+ kind === "cpu"
+ ? "expected CPU like 30% or 1.5"
+ : "expected RAM like 30%, 512m or 4g"
+
+const normalizePercent = (candidate: string, kind: "cpu" | "ram"): Either.Either => {
+ const parsed = parsePercent(candidate)
+ if (parsed === null) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option: kind === "cpu" ? "--cpu" : "--ram",
+ reason: percentReason(kind)
+ })
+ }
+ return Either.right(`${parsed}%`)
+}
+
+export const normalizeCpuLimit = (
+ value: string | undefined,
+ option: string
+): Either.Either => {
+ const candidate = value?.trim().toLowerCase() ?? ""
+ if (candidate.length === 0) {
+ return Either.right(undefined)
+ }
+ if (candidate.endsWith("%")) {
+ return normalizePercent(candidate, "cpu")
+ }
+ if (!cpuAbsolutePattern.test(candidate)) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option,
+ reason: "expected CPU like 30% or 1.5"
+ })
+ }
+ const parsed = Number(candidate)
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option,
+ reason: "must be greater than 0"
+ })
+ }
+ return Either.right(String(normalizePrecision(parsed)))
+}
+
+export const normalizeRamLimit = (
+ value: string | undefined,
+ option: string
+): Either.Either => {
+ const candidate = value?.trim().toLowerCase() ?? ""
+ if (candidate.length === 0) {
+ return Either.right(undefined)
+ }
+ if (candidate.endsWith("%")) {
+ return normalizePercent(candidate, "ram")
+ }
+ if (!ramAbsolutePattern.test(candidate)) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option,
+ reason: "expected RAM like 30%, 512m or 4g"
+ })
+ }
+ return Either.right(candidate)
+}
+
+export const withDefaultResourceLimitIntent = (
+ template: TemplateConfig
+): TemplateConfig => ({
+ ...template,
+ cpuLimit: template.cpuLimit ?? defaultCpuLimit,
+ ramLimit: template.ramLimit ?? defaultRamLimit
+})
+
+const resolvePercentCpuLimit = (percent: number, cpuCount: number): number =>
+ Math.max(
+ minimumResolvedCpuLimit,
+ normalizePrecision((Math.max(1, cpuCount) * percent) / 100)
+ )
+
+const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): string => {
+ const totalMib = Math.max(minimumResolvedRamLimitMib, Math.floor(totalMemoryBytes / mebibyte))
+ const targetMib = Math.max(minimumResolvedRamLimitMib, Math.floor((totalMib * percent) / 100))
+ return `${targetMib}m`
+}
+
+export const resolveComposeResourceLimits = (
+ template: Pick,
+ hostResources: HostResources
+): ResolvedComposeResourceLimits => {
+ const cpuLimitIntent = template.cpuLimit ?? defaultCpuLimit
+ const ramLimitIntent = template.ramLimit ?? defaultRamLimit
+ const cpuPercent = parsePercent(cpuLimitIntent)
+ const ramPercent = parsePercent(ramLimitIntent)
+
+ return {
+ cpuLimit: cpuPercent === null
+ ? Number(cpuLimitIntent)
+ : resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount),
+ ramLimit: ramPercent === null
+ ? ramLimitIntent
+ : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
+ }
+}
diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts
index 00b3b610..356215cb 100644
--- a/packages/lib/src/core/templates.ts
+++ b/packages/lib/src/core/templates.ts
@@ -1,4 +1,5 @@
import type { TemplateConfig } from "./domain.js"
+import type { ResolvedComposeResourceLimits } from "./resource-limits.js"
import { renderEntrypoint } from "./templates-entrypoint.js"
import { renderDockerCompose } from "./templates/docker-compose.js"
import { renderDockerfile } from "./templates/dockerfile.js"
@@ -30,7 +31,10 @@ const renderConfigJson = (config: TemplateConfig): string =>
`${JSON.stringify({ schemaVersion: 1, template: config }, null, 2)}
`
-export const planFiles = (config: TemplateConfig): ReadonlyArray => {
+export const planFiles = (
+ config: TemplateConfig,
+ composeResourceLimits?: ResolvedComposeResourceLimits
+): ReadonlyArray => {
const maybePlaywrightFiles = config.enableMcpPlaywright
? ([
{ _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() },
@@ -46,7 +50,11 @@ export const planFiles = (config: TemplateConfig): ReadonlyArray => {
return [
{ _tag: "File", relativePath: "Dockerfile", contents: renderDockerfile(config) },
{ _tag: "File", relativePath: "entrypoint.sh", contents: renderEntrypoint(config), mode: 0o755 },
- { _tag: "File", relativePath: "docker-compose.yml", contents: renderDockerCompose(config) },
+ {
+ _tag: "File",
+ relativePath: "docker-compose.yml",
+ contents: renderDockerCompose(config, composeResourceLimits)
+ },
{ _tag: "File", relativePath: ".dockerignore", contents: renderDockerignore() },
{ _tag: "File", relativePath: "docker-git.json", contents: renderConfigJson(config) },
{ _tag: "File", relativePath: ".gitignore", contents: renderGitignore() },
diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts
index a17b02bb..8b4fc175 100644
--- a/packages/lib/src/core/templates/docker-compose.ts
+++ b/packages/lib/src/core/templates/docker-compose.ts
@@ -1,4 +1,5 @@
import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js"
+import type { ResolvedComposeResourceLimits } from "../resource-limits.js"
type ComposeFragments = {
readonly networkMode: TemplateConfig["dockerNetworkMode"]
@@ -51,9 +52,15 @@ const renderProjectsRootHostMount = (projectsRoot: string): string =>
const renderSharedCodexHostMount = (projectsRoot: string): string =>
`\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}/.orch/auth/codex`
+const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string =>
+ resourceLimits === undefined
+ ? ""
+ : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n`
+
const buildPlaywrightFragments = (
config: TemplateConfig,
- networkName: string
+ networkName: string,
+ resourceLimits: ResolvedComposeResourceLimits | undefined
): PlaywrightFragments => {
if (!config.enableMcpPlaywright) {
return {
@@ -75,12 +82,15 @@ const buildPlaywrightFragments = (
maybePlaywrightEnv:
` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`,
maybeBrowserService:
- `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
+ `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${renderResourceLimits(resourceLimits)} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
maybeBrowserVolume: ` ${browserVolumeName}:\n`
}
}
-const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
+const buildComposeFragments = (
+ config: TemplateConfig,
+ resourceLimits: ResolvedComposeResourceLimits | undefined
+): ComposeFragments => {
const networkMode = config.dockerNetworkMode
const networkName = resolveComposeNetworkName(config)
const forkRepoUrl = config.forkRepoUrl ?? ""
@@ -92,7 +102,7 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel)
const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode)
const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto)
- const playwright = buildPlaywrightFragments(config, networkName)
+ const playwright = buildPlaywrightFragments(config, networkName, resourceLimits)
return {
networkMode,
@@ -110,7 +120,11 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
}
}
-const renderComposeServices = (config: TemplateConfig, fragments: ComposeFragments): string =>
+const renderComposeServices = (
+ config: TemplateConfig,
+ fragments: ComposeFragments,
+ resourceLimits: ResolvedComposeResourceLimits | undefined
+): string =>
`services:
${config.serviceName}:
build: .
@@ -130,7 +144,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file:
- ${config.envProjectPath}
ports:
- "127.0.0.1:${config.sshPort}:22"
- volumes:
+${renderResourceLimits(resourceLimits)} volumes:
- ${config.volumeName}:/home/${config.sshUser}
- ${renderProjectsRootHostMount(config.dockerGitPath)}:/home/${config.sshUser}/.docker-git
- ${config.authorizedKeysPath}:/authorized_keys:ro
@@ -158,10 +172,13 @@ const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string
${config.volumeName}:
${maybeBrowserVolume}`
-export const renderDockerCompose = (config: TemplateConfig): string => {
- const fragments = buildComposeFragments(config)
+export const renderDockerCompose = (
+ config: TemplateConfig,
+ resourceLimits?: ResolvedComposeResourceLimits
+): string => {
+ const fragments = buildComposeFragments(config, resourceLimits)
return [
- renderComposeServices(config, fragments),
+ renderComposeServices(config, fragments, resourceLimits),
renderComposeNetworks(fragments.networkMode, fragments.networkName),
renderComposeVolumes(config, fragments.maybeBrowserVolume)
].join("\n\n")
diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts
index b39d19b3..ff2a0e48 100644
--- a/packages/lib/src/shell/config.ts
+++ b/packages/lib/src/shell/config.ts
@@ -37,6 +37,12 @@ const TemplateConfigSchema = Schema.Struct({
default: () => defaultTemplateConfig.codexSharedAuthPath
}),
codexHome: Schema.String,
+ cpuLimit: Schema.optionalWith(Schema.String, {
+ default: () => defaultTemplateConfig.cpuLimit
+ }),
+ ramLimit: Schema.optionalWith(Schema.String, {
+ default: () => defaultTemplateConfig.ramLimit
+ }),
dockerNetworkMode: Schema.optionalWith(Schema.Literal("shared", "project"), {
default: () => defaultTemplateConfig.dockerNetworkMode
}),
diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts
index dfa2f470..410240aa 100644
--- a/packages/lib/src/shell/files.ts
+++ b/packages/lib/src/shell/files.ts
@@ -1,9 +1,12 @@
+import { availableParallelism, totalmem } from "node:os"
+
import type { PlatformError } from "@effect/platform/Error"
import type * as FileSystem from "@effect/platform/FileSystem"
import type * as Path from "@effect/platform/Path"
import { Effect, Match } from "effect"
import { type TemplateConfig } from "../core/domain.js"
+import { resolveComposeResourceLimits, withDefaultResourceLimitIntent } from "../core/resource-limits.js"
import { type FileSpec, planFiles } from "../core/templates.js"
import { FileExistsError } from "./errors.js"
import { resolveBaseDir } from "./paths.js"
@@ -104,7 +107,12 @@ export const writeProjectFiles = (
yield* _(fs.makeDirectory(baseDir, { recursive: true }))
- const specs = planFiles(config)
+ const normalizedConfig = withDefaultResourceLimitIntent(config)
+ const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, {
+ cpuCount: availableParallelism(),
+ totalMemoryBytes: totalmem()
+ })
+ const specs = planFiles(normalizedConfig, composeResourceLimits)
const created: Array = []
const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs))
const existingSet = new Set(existingFilePaths)
diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts
index 5f990fe6..dc61b555 100644
--- a/packages/lib/src/usecases/actions/create-project.ts
+++ b/packages/lib/src/usecases/actions/create-project.ts
@@ -24,6 +24,7 @@ import { applyGithubForkConfig } from "../github-fork.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
import { findSshPrivateKey } from "../path-helpers.js"
import { buildSshCommand } from "../projects-core.js"
+import { resolveTemplateResourceLimits } from "../resource-limits.js"
import { autoSyncState } from "../state-repo.js"
import { ensureTerminalCursorVisible } from "../terminal-cursor.js"
import { runDockerDownCleanup, runDockerUpIfNeeded } from "./docker-up.js"
@@ -74,7 +75,8 @@ const resolveCreateConfig = (
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
> =>
resolveSshPort(resolveRootedConfig(command, ctx), resolvedOutDir).pipe(
- Effect.flatMap((config) => applyGithubForkConfig(config))
+ Effect.flatMap((config) => applyGithubForkConfig(config)),
+ Effect.flatMap((config) => resolveTemplateResourceLimits(config))
)
const logCreatedProject = (resolvedOutDir: string, createdFiles: ReadonlyArray) =>
diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts
index 86a3adf5..e330938a 100644
--- a/packages/lib/src/usecases/apply-overrides.ts
+++ b/packages/lib/src/usecases/apply-overrides.ts
@@ -5,6 +5,8 @@ export const hasApplyOverrides = (command: ApplyCommand): boolean =>
command.gitTokenLabel !== undefined ||
command.codexTokenLabel !== undefined ||
command.claudeTokenLabel !== undefined ||
+ command.cpuLimit !== undefined ||
+ command.ramLimit !== undefined ||
command.enableMcpPlaywright !== undefined
export const applyTemplateOverrides = (
@@ -35,6 +37,18 @@ export const applyTemplateOverrides = (
claudeAuthLabel: normalizeAuthLabel(command.claudeTokenLabel)
}
}
+ if (command.cpuLimit !== undefined) {
+ nextTemplate = {
+ ...nextTemplate,
+ cpuLimit: command.cpuLimit
+ }
+ }
+ if (command.ramLimit !== undefined) {
+ nextTemplate = {
+ ...nextTemplate,
+ ramLimit: command.ramLimit
+ }
+ }
if (command.enableMcpPlaywright !== undefined) {
nextTemplate = {
...nextTemplate,
diff --git a/packages/lib/src/usecases/apply.ts b/packages/lib/src/usecases/apply.ts
index ec8b20a7..c3d98780 100644
--- a/packages/lib/src/usecases/apply.ts
+++ b/packages/lib/src/usecases/apply.ts
@@ -17,6 +17,7 @@ import { ensureClaudeAuthSeedFromHome, ensureCodexConfigFile } from "./auth-sync
import { findDockerGitConfigPaths } from "./docker-git-config-search.js"
import { defaultProjectsRoot, findExistingUpwards } from "./path-helpers.js"
import { runDockerComposeUpWithPortCheck } from "./projects-up.js"
+import { resolveTemplateResourceLimits } from "./resource-limits.js"
type ApplyProjectFilesError =
| ShellErrors.ConfigNotFoundError
@@ -42,7 +43,9 @@ export const applyProjectFiles = (
Effect.gen(function*(_) {
yield* _(Effect.log(`Applying docker-git config files in ${projectDir}...`))
const config = yield* _(readProjectConfig(projectDir))
- const resolvedTemplate = applyTemplateOverrides(config.template, command)
+ const resolvedTemplate = yield* _(
+ resolveTemplateResourceLimits(applyTemplateOverrides(config.template, command))
+ )
yield* _(writeProjectFiles(projectDir, resolvedTemplate, true))
yield* _(ensureCodexConfigFile(projectDir, resolvedTemplate.codexAuthPath))
yield* _(ensureClaudeAuthSeedFromHome(defaultProjectsRoot(projectDir), ".orch/auth/claude"))
diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts
index 07972034..ea80f4dd 100644
--- a/packages/lib/src/usecases/projects-up.ts
+++ b/packages/lib/src/usecases/projects-up.ts
@@ -25,6 +25,7 @@ import { ensureCodexConfigFile } from "./auth-sync.js"
import { ensureComposeNetworkReady } from "./docker-network-gc.js"
import { loadReservedPorts, selectAvailablePort } from "./ports-reserve.js"
import { parseComposePsOutput } from "./projects-core.js"
+import { resolveTemplateResourceLimits } from "./resource-limits.js"
const maxPortAttempts = 25
@@ -186,11 +187,12 @@ export const runDockerComposeUpWithPortCheck = (
const updated = alreadyRunning
? config.template
: yield* _(ensureAvailableSshPort(projectDir, config))
+ const resolvedTemplate = yield* _(resolveTemplateResourceLimits(updated))
// Keep generated templates in sync with the running CLI version.
- yield* _(syncManagedProjectFiles(projectDir, updated))
- yield* _(ensureComposeNetworkReady(projectDir, updated))
+ yield* _(syncManagedProjectFiles(projectDir, resolvedTemplate))
+ yield* _(ensureComposeNetworkReady(projectDir, resolvedTemplate))
yield* _(runDockerComposeUp(projectDir))
- yield* _(ensureClaudeCliReady(projectDir, updated.containerName))
+ yield* _(ensureClaudeCliReady(projectDir, resolvedTemplate.containerName))
const ensureBridgeAccess = (containerName: string) =>
runDockerInspectContainerBridgeIp(projectDir, containerName).pipe(
@@ -210,10 +212,10 @@ export const runDockerComposeUpWithPortCheck = (
})
)
- yield* _(ensureBridgeAccess(updated.containerName))
- if (updated.enableMcpPlaywright) {
- yield* _(ensureBridgeAccess(`${updated.containerName}-browser`))
+ yield* _(ensureBridgeAccess(resolvedTemplate.containerName))
+ if (resolvedTemplate.enableMcpPlaywright) {
+ yield* _(ensureBridgeAccess(`${resolvedTemplate.containerName}-browser`))
}
- return updated
+ return resolvedTemplate
})
diff --git a/packages/lib/src/usecases/resource-limits.ts b/packages/lib/src/usecases/resource-limits.ts
new file mode 100644
index 00000000..112754c7
--- /dev/null
+++ b/packages/lib/src/usecases/resource-limits.ts
@@ -0,0 +1,19 @@
+import { Effect } from "effect"
+
+import type { TemplateConfig } from "../core/domain.js"
+import { withDefaultResourceLimitIntent } from "../core/resource-limits.js"
+
+// CHANGE: backfill default resource limit intent for projects that do not specify it.
+// WHY: docker-git should persist the safe 30% CPU/RAM default unless the user overrides it.
+// QUOTE(ТЗ): "надо поставить лимит что если контейнер жрёт под максимум то не забивает всё"
+// REF: issue-135
+// SOURCE: n/a
+// FORMAT THEOREM: forall t: missing_limits(t) -> default_intent(resolve(t))
+// PURITY: SHELL
+// EFFECT: Effect
+// INVARIANT: explicit user limits always win over derived defaults
+// COMPLEXITY: O(1)
+export const resolveTemplateResourceLimits = (
+ template: TemplateConfig
+): Effect.Effect =>
+ Effect.succeed(withDefaultResourceLimitIntent(template))
diff --git a/packages/lib/tests/core/resource-limits.test.ts b/packages/lib/tests/core/resource-limits.test.ts
new file mode 100644
index 00000000..992643c4
--- /dev/null
+++ b/packages/lib/tests/core/resource-limits.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "@effect/vitest"
+
+import {
+ resolveComposeResourceLimits,
+ withDefaultResourceLimitIntent
+} from "../../src/core/resource-limits.js"
+import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js"
+
+const makeTemplate = (): TemplateConfig => ({
+ ...defaultTemplateConfig,
+ repoUrl: "https://github.com/org/repo.git"
+})
+
+describe("withDefaultResourceLimitIntent", () => {
+ it("fills missing limit intent with 30%", () => {
+ const resolved = withDefaultResourceLimitIntent(makeTemplate())
+
+ expect(resolved.cpuLimit).toBe("30%")
+ expect(resolved.ramLimit).toBe("30%")
+ })
+
+ it("preserves explicit limit intent", () => {
+ const resolved = withDefaultResourceLimitIntent({
+ ...makeTemplate(),
+ cpuLimit: "1.25",
+ ramLimit: "3g"
+ })
+
+ expect(resolved.cpuLimit).toBe("1.25")
+ expect(resolved.ramLimit).toBe("3g")
+ })
+})
+
+describe("resolveComposeResourceLimits", () => {
+ it("resolves percent intent against host capacity", () => {
+ const resolved = resolveComposeResourceLimits(
+ {
+ cpuLimit: "30%",
+ ramLimit: "30%"
+ },
+ {
+ cpuCount: 8,
+ totalMemoryBytes: 16 * 1024 ** 3
+ }
+ )
+
+ expect(resolved.cpuLimit).toBe(2.4)
+ expect(resolved.ramLimit).toBe("4915m")
+ })
+
+ it("applies minimum caps for small hosts", () => {
+ const resolved = resolveComposeResourceLimits(
+ {
+ cpuLimit: "30%",
+ ramLimit: "30%"
+ },
+ {
+ cpuCount: 1,
+ totalMemoryBytes: 1024 ** 3
+ }
+ )
+
+ expect(resolved.cpuLimit).toBe(0.3)
+ expect(resolved.ramLimit).toBe("512m")
+ })
+
+ it("keeps absolute intent as-is", () => {
+ const resolved = resolveComposeResourceLimits(
+ {
+ cpuLimit: "1.25",
+ ramLimit: "3g"
+ },
+ {
+ cpuCount: 32,
+ totalMemoryBytes: 64 * 1024 ** 3
+ }
+ )
+
+ expect(resolved.cpuLimit).toBe(1.25)
+ expect(resolved.ramLimit).toBe("3g")
+ })
+})
diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts
index fde16c87..b2f63353 100644
--- a/packages/lib/tests/usecases/apply.test.ts
+++ b/packages/lib/tests/usecases/apply.test.ts
@@ -145,9 +145,17 @@ describe("applyProjectFiles", () => {
const appliedTemplate = yield* _(applyProjectFiles(outDir))
expect(appliedTemplate.targetDir).toBe(updatedTargetDir)
+ expect(appliedTemplate.cpuLimit).toBe("30%")
+ expect(appliedTemplate.ramLimit).toBe("30%")
const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
expect(composeAfter).toContain(`TARGET_DIR: "${updatedTargetDir}"`)
+ expect(composeAfter).toContain("cpus:")
+ expect(composeAfter).toContain('mem_limit: "')
+
+ const configAfter = yield* _(fs.readFileString(configPath))
+ expect(configAfter).toContain('"cpuLimit": "30%"')
+ expect(configAfter).toContain('"ramLimit": "30%"')
const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile")))
expect(dockerfileAfter).toContain(`RUN mkdir -p ${updatedTargetDir}`)
@@ -182,12 +190,16 @@ describe("applyProjectFiles", () => {
gitTokenLabel: "agien_main",
codexTokenLabel: "Team A",
claudeTokenLabel: "Team B",
+ cpuLimit: "2",
+ ramLimit: "4g",
enableMcpPlaywright: true
})
)
expect(appliedTemplate.gitTokenLabel).toBe("AGIEN_MAIN")
expect(appliedTemplate.codexAuthLabel).toBe("team-a")
expect(appliedTemplate.claudeAuthLabel).toBe("team-b")
+ expect(appliedTemplate.cpuLimit).toBe("2")
+ expect(appliedTemplate.ramLimit).toBe("4g")
expect(appliedTemplate.enableMcpPlaywright).toBe(true)
const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
@@ -195,8 +207,15 @@ describe("applyProjectFiles", () => {
expect(composeAfter).toContain('GIT_AUTH_LABEL: "AGIEN_MAIN"')
expect(composeAfter).toContain('CODEX_AUTH_LABEL: "team-a"')
expect(composeAfter).toContain('CLAUDE_AUTH_LABEL: "team-b"')
+ expect(composeAfter).toContain("cpus: 2")
+ expect(composeAfter).toContain('mem_limit: "4g"')
+ expect(composeAfter).toContain('memswap_limit: "4g"')
expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"')
expect(composeAfter).toContain("dg-test-browser")
+
+ const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json")))
+ expect(configAfter).toContain('"cpuLimit": "2"')
+ expect(configAfter).toContain('"ramLimit": "4g"')
})
).pipe(Effect.provide(NodeContext.layer)))
})
diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts
index 99e13c96..1c8df2ae 100644
--- a/packages/lib/tests/usecases/prepare-files.test.ts
+++ b/packages/lib/tests/usecases/prepare-files.test.ts
@@ -150,6 +150,8 @@ describe("prepareProjectFiles", () => {
expect(composeBefore).toContain("container_name: dg-test")
expect(composeBefore).toContain("restart: unless-stopped")
expect(composeBefore).toContain(":/home/dev/.docker-git")
+ expect(composeBefore).toContain("cpus:")
+ expect(composeBefore).toContain('mem_limit: "')
expect(composeBefore).not.toContain("dg-test-browser")
expect(composeBefore).toContain("docker-git-shared")
expect(composeBefore).toContain("external: true")
@@ -177,6 +179,8 @@ describe("prepareProjectFiles", () => {
expect(composeAfter).toContain("docker-git-shared")
expect(composeAfter).toContain("external: true")
expect(readEnableMcpPlaywrightFlag(configAfter)).toBe(true)
+ expect(configAfterText).toContain('"cpuLimit": "30%"')
+ expect(configAfterText).toContain('"ramLimit": "30%"')
})
).pipe(Effect.provide(NodeContext.layer)))
diff --git a/packages/lib/tests/usecases/projects-up.test.ts b/packages/lib/tests/usecases/projects-up.test.ts
index 7ec589f5..6d477d1f 100644
--- a/packages/lib/tests/usecases/projects-up.test.ts
+++ b/packages/lib/tests/usecases/projects-up.test.ts
@@ -187,10 +187,18 @@ describe("runDockerComposeUpWithPortCheck", () => {
)
expect(updated.targetDir).toBe(updatedTargetDir)
+ expect(updated.cpuLimit).toBe("30%")
+ expect(updated.ramLimit).toBe("30%")
const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
expect(composeAfter).toContain(`TARGET_DIR: "${updatedTargetDir}"`)
expect(composeAfter).not.toContain("# stale compose")
+ expect(composeAfter).toContain("cpus:")
+ expect(composeAfter).toContain('mem_limit: "')
+
+ const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json")))
+ expect(configAfter).toContain('"cpuLimit": "30%"')
+ expect(configAfter).toContain('"ramLimit": "30%"')
expect(recorded.some((entry) => isDockerComposePsFormatted(entry))).toBe(true)
expect(recorded.some((entry) => isDockerComposeUp(entry))).toBe(true)
From 47d56a022156c693a298bf49488cce33f08d758b Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 14 Mar 2026 19:39:22 +0000
Subject: [PATCH 2/4] fix: satisfy lint rules for resource limit changes
---
packages/app/src/docker-git/menu-create.ts | 60 +++++++++++-----------
packages/lib/src/shell/files.ts | 2 +-
2 files changed, 32 insertions(+), 30 deletions(-)
diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts
index 7f684721..9a5223ef 100644
--- a/packages/app/src/docker-git/menu-create.ts
+++ b/packages/app/src/docker-git/menu-create.ts
@@ -45,34 +45,38 @@ type CreateReturnContext = CreateContext & {
readonly view: Extract
}
+type OptionalCreateArg = {
+ readonly value: string
+ readonly args: readonly [string, string]
+}
+
+const optionalCreateArgs = (input: CreateInputs): ReadonlyArray => [
+ { value: input.repoUrl, args: ["--repo-url", input.repoUrl] },
+ { value: input.repoRef, args: ["--repo-ref", input.repoRef] },
+ { value: input.outDir, args: ["--out-dir", input.outDir] },
+ { value: input.cpuLimit, args: ["--cpu", input.cpuLimit] },
+ { value: input.ramLimit, args: ["--ram", input.ramLimit] }
+]
+
+const booleanCreateFlags = (input: CreateInputs): ReadonlyArray =>
+ [
+ input.runUp ? null : "--no-up",
+ input.enableMcpPlaywright ? "--mcp-playwright" : null,
+ input.force ? "--force" : null,
+ input.forceEnv ? "--force-env" : null
+ ].filter((value): value is string => value !== null)
+
export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => {
const args: Array = ["create"]
- if (input.repoUrl.length > 0) {
- args.push("--repo-url", input.repoUrl)
- }
- if (input.repoRef.length > 0) {
- args.push("--repo-ref", input.repoRef)
- }
- if (input.outDir.length > 0) {
- args.push("--out-dir", input.outDir)
- }
- if (input.cpuLimit.length > 0) {
- args.push("--cpu", input.cpuLimit)
- }
- if (input.ramLimit.length > 0) {
- args.push("--ram", input.ramLimit)
- }
- if (!input.runUp) {
- args.push("--no-up")
- }
- if (input.enableMcpPlaywright) {
- args.push("--mcp-playwright")
- }
- if (input.force) {
- args.push("--force")
+
+ for (const spec of optionalCreateArgs(input)) {
+ if (spec.value.length > 0) {
+ args.push(spec.args[0], spec.args[1])
+ }
}
- if (input.forceEnv) {
- args.push("--force-env")
+
+ for (const flag of booleanCreateFlags(input)) {
+ args.push(flag)
}
return args
}
@@ -205,13 +209,11 @@ const applyCreateStep = (input: {
return true
}),
Match.when("cpuLimit", () => {
- input.nextValues.cpuLimit =
- input.buffer.length > 0 ? input.buffer : input.currentDefaults.cpuLimit
+ input.nextValues.cpuLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.cpuLimit
return true
}),
Match.when("ramLimit", () => {
- input.nextValues.ramLimit =
- input.buffer.length > 0 ? input.buffer : input.currentDefaults.ramLimit
+ input.nextValues.ramLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.ramLimit
return true
}),
Match.when("runUp", () => {
diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts
index 410240aa..21a2b300 100644
--- a/packages/lib/src/shell/files.ts
+++ b/packages/lib/src/shell/files.ts
@@ -1,4 +1,4 @@
-import { availableParallelism, totalmem } from "node:os"
+import { availableParallelism, totalmem } from "os"
import type { PlatformError } from "@effect/platform/Error"
import type * as FileSystem from "@effect/platform/FileSystem"
From 6bea8da1d6736c7b81e4dff28e1e7493361038af Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 14 Mar 2026 19:49:11 +0000
Subject: [PATCH 3/4] fix: satisfy lib lint constraints
---
.../lib/src/core/command-builders-shared.ts | 53 +++++
packages/lib/src/core/command-builders.ts | 54 +----
packages/lib/src/core/resource-limits.ts | 9 +-
.../lib/src/core/templates/docker-compose.ts | 4 +-
packages/lib/src/shell/files.ts | 2 +-
.../src/usecases/apply-project-discovery.ts | 210 ++++++++++++++++++
packages/lib/src/usecases/apply.ts | 209 +----------------
packages/lib/src/usecases/resource-limits.ts | 3 +-
8 files changed, 286 insertions(+), 258 deletions(-)
create mode 100644 packages/lib/src/core/command-builders-shared.ts
create mode 100644 packages/lib/src/usecases/apply-project-discovery.ts
diff --git a/packages/lib/src/core/command-builders-shared.ts b/packages/lib/src/core/command-builders-shared.ts
new file mode 100644
index 00000000..82535698
--- /dev/null
+++ b/packages/lib/src/core/command-builders-shared.ts
@@ -0,0 +1,53 @@
+import { Either } from "effect"
+
+import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, type ParseError } from "./domain.js"
+
+const parsePort = (value: string): Either.Either => {
+ const parsed = Number(value)
+ if (!Number.isInteger(parsed)) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option: "--ssh-port",
+ reason: `expected integer, got: ${value}`
+ })
+ }
+ if (parsed < 1 || parsed > 65_535) {
+ return Either.left({
+ _tag: "InvalidOption",
+ option: "--ssh-port",
+ reason: "must be between 1 and 65535"
+ })
+ }
+ return Either.right(parsed)
+}
+
+export const parseSshPort = (value: string): Either.Either => parsePort(value)
+
+export const parseDockerNetworkMode = (
+ value: string | undefined
+): Either.Either => {
+ const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode
+ if (isDockerNetworkMode(candidate)) {
+ return Either.right(candidate)
+ }
+ return Either.left({
+ _tag: "InvalidOption",
+ option: "--network-mode",
+ reason: "expected one of: shared, project"
+ })
+}
+
+export const nonEmpty = (
+ option: string,
+ value: string | undefined,
+ fallback?: string
+): Either.Either => {
+ const candidate = value?.trim() ?? fallback
+ if (candidate === undefined || candidate.length === 0) {
+ return Either.left({
+ _tag: "MissingRequiredOption",
+ option
+ })
+ }
+ return Either.right(candidate)
+}
diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts
index 0b8026b3..4435cc89 100644
--- a/packages/lib/src/core/command-builders.ts
+++ b/packages/lib/src/core/command-builders.ts
@@ -2,6 +2,7 @@ import { Either } from "effect"
import { expandContainerHome } from "../usecases/scrap-path.js"
import { resolveAutoAgentFlags } from "./auto-agent-flags.js"
+import { nonEmpty, parseDockerNetworkMode, parseSshPort } from "./command-builders-shared.js"
import { type RawOptions } from "./command-options.js"
import {
type AgentMode,
@@ -11,7 +12,6 @@ import {
defaultTemplateConfig,
deriveRepoPathParts,
deriveRepoSlug,
- isDockerNetworkMode,
type ParseError,
resolveRepoInput
} from "./domain.js"
@@ -19,53 +19,7 @@ import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js"
import { trimRightChar } from "./strings.js"
import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js"
-const parsePort = (value: string): Either.Either => {
- const parsed = Number(value)
- if (!Number.isInteger(parsed)) {
- return Either.left({
- _tag: "InvalidOption",
- option: "--ssh-port",
- reason: `expected integer, got: ${value}`
- })
- }
- if (parsed < 1 || parsed > 65_535) {
- return Either.left({
- _tag: "InvalidOption",
- option: "--ssh-port",
- reason: "must be between 1 and 65535"
- })
- }
- return Either.right(parsed)
-}
-
-const parseDockerNetworkMode = (
- value: string | undefined
-): Either.Either => {
- const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode
- if (isDockerNetworkMode(candidate)) {
- return Either.right(candidate)
- }
- return Either.left({
- _tag: "InvalidOption",
- option: "--network-mode",
- reason: "expected one of: shared, project"
- })
-}
-
-export const nonEmpty = (
- option: string,
- value: string | undefined,
- fallback?: string
-): Either.Either => {
- const candidate = value?.trim() ?? fallback
- if (candidate === undefined || candidate.length === 0) {
- return Either.left({
- _tag: "MissingRequiredOption",
- option
- })
- }
- return Either.right(candidate)
-}
+export { nonEmpty } from "./command-builders-shared.js"
const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/")
@@ -98,7 +52,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either ({
containerName: names.containerName,
diff --git a/packages/lib/src/core/resource-limits.ts b/packages/lib/src/core/resource-limits.ts
index 15f7a9bf..7d0e046e 100644
--- a/packages/lib/src/core/resource-limits.ts
+++ b/packages/lib/src/core/resource-limits.ts
@@ -21,8 +21,9 @@ const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u
const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu
const percentPattern = /^\d+(?:\.\d+)?%$/u
-const normalizePrecision = (value: number): number =>
- Math.round(value * precisionScale) / precisionScale
+const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale
+
+const missingLimit = (): string | undefined => undefined
const parsePercent = (candidate: string): number | null => {
if (!percentPattern.test(candidate)) {
@@ -58,7 +59,7 @@ export const normalizeCpuLimit = (
): Either.Either => {
const candidate = value?.trim().toLowerCase() ?? ""
if (candidate.length === 0) {
- return Either.right(undefined)
+ return Either.right(missingLimit())
}
if (candidate.endsWith("%")) {
return normalizePercent(candidate, "cpu")
@@ -87,7 +88,7 @@ export const normalizeRamLimit = (
): Either.Either => {
const candidate = value?.trim().toLowerCase() ?? ""
if (candidate.length === 0) {
- return Either.right(undefined)
+ return Either.right(missingLimit())
}
if (candidate.endsWith("%")) {
return normalizePercent(candidate, "ram")
diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts
index 8b4fc175..e8657cc2 100644
--- a/packages/lib/src/core/templates/docker-compose.ts
+++ b/packages/lib/src/core/templates/docker-compose.ts
@@ -82,7 +82,9 @@ const buildPlaywrightFragments = (
maybePlaywrightEnv:
` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`,
maybeBrowserService:
- `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${renderResourceLimits(resourceLimits)} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
+ `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${
+ renderResourceLimits(resourceLimits)
+ } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
maybeBrowserVolume: ` ${browserVolumeName}:\n`
}
}
diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts
index 21a2b300..410240aa 100644
--- a/packages/lib/src/shell/files.ts
+++ b/packages/lib/src/shell/files.ts
@@ -1,4 +1,4 @@
-import { availableParallelism, totalmem } from "os"
+import { availableParallelism, totalmem } from "node:os"
import type { PlatformError } from "@effect/platform/Error"
import type * as FileSystem from "@effect/platform/FileSystem"
diff --git a/packages/lib/src/usecases/apply-project-discovery.ts b/packages/lib/src/usecases/apply-project-discovery.ts
new file mode 100644
index 00000000..c775409f
--- /dev/null
+++ b/packages/lib/src/usecases/apply-project-discovery.ts
@@ -0,0 +1,210 @@
+import type { CommandExecutor } from "@effect/platform/CommandExecutor"
+import type { PlatformError } from "@effect/platform/Error"
+import type { FileSystem } from "@effect/platform/FileSystem"
+import type { Path } from "@effect/platform/Path"
+import { Effect } from "effect"
+
+import { deriveRepoPathParts } from "../core/domain.js"
+import { parseGithubRepoUrl } from "../core/repo.js"
+import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js"
+import { readProjectConfig } from "../shell/config.js"
+import { resolveBaseDir } from "../shell/paths.js"
+import { findDockerGitConfigPaths } from "./docker-git-config-search.js"
+
+export type RepoIdentity = {
+ readonly fullPath: string
+ readonly repo: string
+}
+
+export type ProjectCandidate = {
+ readonly projectDir: string
+ readonly repoUrl: string
+ readonly repoRef: string
+}
+
+const gitSuccessExitCode = 0
+const gitBaseEnv: Readonly> = {
+ GIT_TERMINAL_PROMPT: "0"
+}
+
+const emptyConfigPaths = (): ReadonlyArray => []
+const nullProjectCandidate = (): ProjectCandidate | null => null
+const nullString = (): string | null => null
+
+export const normalizeRepoIdentity = (repoUrl: string): RepoIdentity => {
+ const github = parseGithubRepoUrl(repoUrl)
+ if (github !== null) {
+ const owner = github.owner.trim().toLowerCase()
+ const repo = github.repo.trim().toLowerCase()
+ return { fullPath: `${owner}/${repo}`, repo }
+ }
+
+ const parts = deriveRepoPathParts(repoUrl)
+ const normalizedParts = parts.pathParts.map((part) => part.toLowerCase())
+ const repo = parts.repo.toLowerCase()
+ return {
+ fullPath: normalizedParts.join("/"),
+ repo
+ }
+}
+
+const toProjectDirBaseName = (projectDir: string): string => {
+ const normalized = projectDir.replaceAll("\\", "/")
+ const parts = normalized.split("/").filter((part) => part.length > 0)
+ return parts.at(-1)?.toLowerCase() ?? ""
+}
+
+const parsePrRefFromBranch = (branch: string): string | null => {
+ const prefix = "pr-"
+ if (!branch.toLowerCase().startsWith(prefix)) {
+ return null
+ }
+ const id = branch.slice(prefix.length).trim()
+ return id.length > 0 ? `refs/pull/${id}/head` : null
+}
+
+const scoreBranchMatch = (
+ branch: string | null,
+ candidate: ProjectCandidate
+): number => {
+ if (branch === null) {
+ return 0
+ }
+
+ const branchLower = branch.toLowerCase()
+ const candidateRef = candidate.repoRef.toLowerCase()
+ const prRef = parsePrRefFromBranch(branchLower)
+ const branchRefScore = candidateRef === branchLower ? 8 : 0
+ const prRefScore = prRef !== null && candidateRef === prRef.toLowerCase() ? 8 : 0
+ const dirNameScore = toProjectDirBaseName(candidate.projectDir) === branchLower ? 5 : 0
+ return branchRefScore + prRefScore + dirNameScore
+}
+
+const scoreCandidate = (
+ remoteIdentities: ReadonlyArray,
+ branch: string | null,
+ candidate: ProjectCandidate
+): number => {
+ const candidateIdentity = normalizeRepoIdentity(candidate.repoUrl)
+ const hasFullPathMatch = remoteIdentities.some((remote) => remote.fullPath === candidateIdentity.fullPath)
+ const hasRepoMatch = remoteIdentities.some((remote) => remote.repo === candidateIdentity.repo)
+ if (!hasFullPathMatch && !hasRepoMatch) {
+ return 0
+ }
+
+ const repoScore = hasFullPathMatch ? 100 : 10
+ return repoScore + scoreBranchMatch(branch, candidate)
+}
+
+export const selectCandidateProjectDir = (
+ remoteIdentities: ReadonlyArray,
+ branch: string | null,
+ candidates: ReadonlyArray
+): string | null => {
+ const scored = candidates
+ .map((candidate) => ({ candidate, score: scoreCandidate(remoteIdentities, branch, candidate) }))
+ .filter((entry) => entry.score > 0)
+
+ if (scored.length === 0) {
+ return null
+ }
+
+ const topScore = Math.max(...scored.map((entry) => entry.score))
+ const topCandidates = scored.filter((entry) => entry.score === topScore)
+ if (topCandidates.length !== 1) {
+ return null
+ }
+
+ return topCandidates[0]?.candidate.projectDir ?? null
+}
+
+const tryGitCapture = (
+ cwd: string,
+ args: ReadonlyArray
+): Effect.Effect => {
+ const spec = { cwd, command: "git", args, env: gitBaseEnv }
+
+ return runCommandExitCode(spec).pipe(
+ Effect.matchEffect({
+ onFailure: () => Effect.succeed(null),
+ onSuccess: (exitCode) =>
+ exitCode === gitSuccessExitCode
+ ? runCommandCapture(spec, [gitSuccessExitCode], (code) => ({ _tag: "ApplyGitCaptureError", code })).pipe(
+ Effect.map((value) => value.trim()),
+ Effect.match({
+ onFailure: nullString,
+ onSuccess: (value) => value
+ })
+ )
+ : Effect.succeed(null)
+ })
+ )
+}
+
+export const listProjectCandidates = (
+ projectsRoot: string
+): Effect.Effect, PlatformError, FileSystem | Path> =>
+ Effect.gen(function*(_) {
+ const { fs, path, resolved } = yield* _(resolveBaseDir(projectsRoot))
+ const configPaths = yield* _(
+ findDockerGitConfigPaths(fs, path, resolved).pipe(
+ Effect.match({
+ onFailure: emptyConfigPaths,
+ onSuccess: (value) => value
+ })
+ )
+ )
+
+ const candidates: Array = []
+ for (const configPath of configPaths) {
+ const projectDir = path.dirname(configPath)
+ const candidate = yield* _(
+ readProjectConfig(projectDir).pipe(
+ Effect.match({
+ onFailure: nullProjectCandidate,
+ onSuccess: (config) => ({
+ projectDir,
+ repoUrl: config.template.repoUrl,
+ repoRef: config.template.repoRef
+ })
+ })
+ )
+ )
+ if (candidate !== null) {
+ candidates.push(candidate)
+ }
+ }
+
+ return candidates
+ })
+
+export const collectRemoteIdentities = (
+ repoRoot: string
+): Effect.Effect, never, CommandExecutor> =>
+ Effect.gen(function*(_) {
+ const listedRemotes = yield* _(tryGitCapture(repoRoot, ["remote"]))
+ const dynamicNames = listedRemotes === null
+ ? []
+ : listedRemotes
+ .split(/\r?\n/)
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ const remoteNames = [...new Set([...dynamicNames, "origin", "upstream"])]
+ const urls: Array = []
+
+ for (const remoteName of remoteNames) {
+ const url = yield* _(tryGitCapture(repoRoot, ["remote", "get-url", remoteName]))
+ if (url !== null && url.length > 0) {
+ urls.push(url)
+ }
+ }
+
+ const identityMap = new Map()
+ for (const url of urls) {
+ const identity = normalizeRepoIdentity(url)
+ identityMap.set(`${identity.fullPath}|${identity.repo}`, identity)
+ }
+ return [...identityMap.values()]
+ })
+
+export const gitCapture = tryGitCapture
diff --git a/packages/lib/src/usecases/apply.ts b/packages/lib/src/usecases/apply.ts
index c3d98780..4aabcfd5 100644
--- a/packages/lib/src/usecases/apply.ts
+++ b/packages/lib/src/usecases/apply.ts
@@ -4,17 +4,20 @@ import type { FileSystem } from "@effect/platform/FileSystem"
import type { Path } from "@effect/platform/Path"
import { Effect } from "effect"
-import { type ApplyCommand, deriveRepoPathParts, type TemplateConfig } from "../core/domain.js"
-import { parseGithubRepoUrl } from "../core/repo.js"
-import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js"
+import { type ApplyCommand, type TemplateConfig } from "../core/domain.js"
import { readProjectConfig } from "../shell/config.js"
import { ensureDockerDaemonAccess } from "../shell/docker.js"
import type * as ShellErrors from "../shell/errors.js"
import { writeProjectFiles } from "../shell/files.js"
import { resolveBaseDir } from "../shell/paths.js"
import { applyTemplateOverrides, hasApplyOverrides } from "./apply-overrides.js"
+import {
+ collectRemoteIdentities,
+ gitCapture,
+ listProjectCandidates,
+ selectCandidateProjectDir
+} from "./apply-project-discovery.js"
import { ensureClaudeAuthSeedFromHome, ensureCodexConfigFile } from "./auth-sync.js"
-import { findDockerGitConfigPaths } from "./docker-git-config-search.js"
import { defaultProjectsRoot, findExistingUpwards } from "./path-helpers.js"
import { runDockerComposeUpWithPortCheck } from "./projects-up.js"
import { resolveTemplateResourceLimits } from "./resource-limits.js"
@@ -60,204 +63,10 @@ export type ApplyProjectConfigError =
type ApplyProjectConfigEnv = ApplyProjectFilesEnv | CommandExecutor
-type RepoIdentity = {
- readonly fullPath: string
- readonly repo: string
-}
-
-type ProjectCandidate = {
- readonly projectDir: string
- readonly repoUrl: string
- readonly repoRef: string
-}
-
-const gitSuccessExitCode = 0
const gitBranchDetached = "HEAD"
const maxLocalConfigSearchDepth = 6
-const gitBaseEnv: Readonly> = {
- GIT_TERMINAL_PROMPT: "0"
-}
-
-const emptyConfigPaths = (): ReadonlyArray => []
-const nullProjectCandidate = (): ProjectCandidate | null => null
const nullString = (): string | null => null
-const normalizeRepoIdentity = (repoUrl: string): RepoIdentity => {
- const github = parseGithubRepoUrl(repoUrl)
- if (github !== null) {
- const owner = github.owner.trim().toLowerCase()
- const repo = github.repo.trim().toLowerCase()
- return { fullPath: `${owner}/${repo}`, repo }
- }
-
- const parts = deriveRepoPathParts(repoUrl)
- const normalizedParts = parts.pathParts.map((part) => part.toLowerCase())
- const repo = parts.repo.toLowerCase()
- return {
- fullPath: normalizedParts.join("/"),
- repo
- }
-}
-
-const toProjectDirBaseName = (projectDir: string): string => {
- const normalized = projectDir.replaceAll("\\", "/")
- const parts = normalized.split("/").filter((part) => part.length > 0)
- return parts.at(-1)?.toLowerCase() ?? ""
-}
-
-const parsePrRefFromBranch = (branch: string): string | null => {
- const prefix = "pr-"
- if (!branch.toLowerCase().startsWith(prefix)) {
- return null
- }
- const id = branch.slice(prefix.length).trim()
- return id.length > 0 ? `refs/pull/${id}/head` : null
-}
-
-const scoreBranchMatch = (
- branch: string | null,
- candidate: ProjectCandidate
-): number => {
- if (branch === null) {
- return 0
- }
-
- const branchLower = branch.toLowerCase()
- const candidateRef = candidate.repoRef.toLowerCase()
- const prRef = parsePrRefFromBranch(branchLower)
- const branchRefScore = candidateRef === branchLower ? 8 : 0
- const prRefScore = prRef !== null && candidateRef === prRef.toLowerCase() ? 8 : 0
- const dirNameScore = toProjectDirBaseName(candidate.projectDir) === branchLower ? 5 : 0
- return branchRefScore + prRefScore + dirNameScore
-}
-
-const scoreCandidate = (
- remoteIdentities: ReadonlyArray,
- branch: string | null,
- candidate: ProjectCandidate
-): number => {
- const candidateIdentity = normalizeRepoIdentity(candidate.repoUrl)
- const hasFullPathMatch = remoteIdentities.some((remote) => remote.fullPath === candidateIdentity.fullPath)
- const hasRepoMatch = remoteIdentities.some((remote) => remote.repo === candidateIdentity.repo)
- if (!hasFullPathMatch && !hasRepoMatch) {
- return 0
- }
-
- const repoScore = hasFullPathMatch ? 100 : 10
- return repoScore + scoreBranchMatch(branch, candidate)
-}
-
-const selectCandidateProjectDir = (
- remoteIdentities: ReadonlyArray,
- branch: string | null,
- candidates: ReadonlyArray
-): string | null => {
- const scored = candidates
- .map((candidate) => ({ candidate, score: scoreCandidate(remoteIdentities, branch, candidate) }))
- .filter((entry) => entry.score > 0)
-
- if (scored.length === 0) {
- return null
- }
-
- const topScore = Math.max(...scored.map((entry) => entry.score))
- const topCandidates = scored.filter((entry) => entry.score === topScore)
- if (topCandidates.length !== 1) {
- return null
- }
-
- return topCandidates[0]?.candidate.projectDir ?? null
-}
-
-const tryGitCapture = (
- cwd: string,
- args: ReadonlyArray
-): Effect.Effect => {
- const spec = { cwd, command: "git", args, env: gitBaseEnv }
-
- return runCommandExitCode(spec).pipe(
- Effect.matchEffect({
- onFailure: () => Effect.succeed(null),
- onSuccess: (exitCode) =>
- exitCode === gitSuccessExitCode
- ? runCommandCapture(spec, [gitSuccessExitCode], (code) => ({ _tag: "ApplyGitCaptureError", code })).pipe(
- Effect.map((value) => value.trim()),
- Effect.match({
- onFailure: nullString,
- onSuccess: (value) => value
- })
- )
- : Effect.succeed(null)
- })
- )
-}
-
-const listProjectCandidates = (
- projectsRoot: string
-): Effect.Effect, PlatformError, ApplyProjectFilesEnv> =>
- Effect.gen(function*(_) {
- const { fs, path, resolved } = yield* _(resolveBaseDir(projectsRoot))
- const configPaths = yield* _(
- findDockerGitConfigPaths(fs, path, resolved).pipe(
- Effect.match({
- onFailure: emptyConfigPaths,
- onSuccess: (value) => value
- })
- )
- )
-
- const candidates: Array = []
- for (const configPath of configPaths) {
- const projectDir = path.dirname(configPath)
- const candidate = yield* _(
- readProjectConfig(projectDir).pipe(
- Effect.match({
- onFailure: nullProjectCandidate,
- onSuccess: (config) => ({
- projectDir,
- repoUrl: config.template.repoUrl,
- repoRef: config.template.repoRef
- })
- })
- )
- )
- if (candidate !== null) {
- candidates.push(candidate)
- }
- }
-
- return candidates
- })
-
-const collectRemoteIdentities = (
- repoRoot: string
-): Effect.Effect, never, CommandExecutor> =>
- Effect.gen(function*(_) {
- const listedRemotes = yield* _(tryGitCapture(repoRoot, ["remote"]))
- const dynamicNames = listedRemotes === null
- ? []
- : listedRemotes
- .split(/\r?\n/)
- .map((entry) => entry.trim())
- .filter((entry) => entry.length > 0)
- const remoteNames = [...new Set([...dynamicNames, "origin", "upstream"])]
- const urls: Array = []
-
- for (const remoteName of remoteNames) {
- const url = yield* _(tryGitCapture(repoRoot, ["remote", "get-url", remoteName]))
- if (url !== null && url.length > 0) {
- urls.push(url)
- }
- }
-
- const identityMap = new Map()
- for (const url of urls) {
- const identity = normalizeRepoIdentity(url)
- identityMap.set(`${identity.fullPath}|${identity.repo}`, identity)
- }
- return [...identityMap.values()]
- })
-
const resolveFromCurrentTree = (): Effect.Effect =>
Effect.gen(function*(_) {
const { fs, path, resolved } = yield* _(resolveBaseDir("."))
@@ -283,7 +92,7 @@ const normalizeBranch = (branch: string | null): string | null => {
const resolveFromCurrentRepository = (): Effect.Effect =>
Effect.gen(function*(_) {
const cwd = process.cwd()
- const repoRoot = yield* _(tryGitCapture(cwd, ["rev-parse", "--show-toplevel"]))
+ const repoRoot = yield* _(gitCapture(cwd, ["rev-parse", "--show-toplevel"]))
if (repoRoot === null) {
return null
}
@@ -293,7 +102,7 @@ const resolveFromCurrentRepository = (): Effect.Effect =>
- Effect.succeed(withDefaultResourceLimitIntent(template))
+): Effect.Effect => Effect.succeed(withDefaultResourceLimitIntent(template))
From 0ad8a0459814df6012f6bf43e30af39434d4396f Mon Sep 17 00:00:00 2001
From: skulidropek <66840575+skulidropek@users.noreply.github.com>
Date: Sat, 14 Mar 2026 19:54:23 +0000
Subject: [PATCH 4/4] fix: avoid direct node imports in shell resource probe
---
packages/lib/src/shell/files.ts | 30 ++++++++++++++++++++++++------
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts
index 410240aa..c527eef4 100644
--- a/packages/lib/src/shell/files.ts
+++ b/packages/lib/src/shell/files.ts
@@ -1,5 +1,3 @@
-import { availableParallelism, totalmem } from "node:os"
-
import type { PlatformError } from "@effect/platform/Error"
import type * as FileSystem from "@effect/platform/FileSystem"
import type * as Path from "@effect/platform/Path"
@@ -14,6 +12,28 @@ import { resolveBaseDir } from "./paths.js"
const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) =>
fs.makeDirectory(path.dirname(filePath), { recursive: true })
+const fallbackHostResources = {
+ cpuCount: 1,
+ totalMemoryBytes: 1024 ** 3
+}
+
+const loadHostResources = (): Effect.Effect<
+ { readonly cpuCount: number; readonly totalMemoryBytes: number }
+> =>
+ Effect.tryPromise({
+ try: () =>
+ import("node:os").then((os) => ({
+ cpuCount: os.availableParallelism(),
+ totalMemoryBytes: os.totalmem()
+ })),
+ catch: (error) => new Error(String(error))
+ }).pipe(
+ Effect.match({
+ onFailure: () => fallbackHostResources,
+ onSuccess: (value) => value
+ })
+ )
+
const isFileSpec = (spec: FileSpec): spec is Extract => spec._tag === "File"
const resolveSpecPath = (
@@ -108,10 +128,8 @@ export const writeProjectFiles = (
yield* _(fs.makeDirectory(baseDir, { recursive: true }))
const normalizedConfig = withDefaultResourceLimitIntent(config)
- const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, {
- cpuCount: availableParallelism(),
- totalMemoryBytes: totalmem()
- })
+ const hostResources = yield* _(loadHostResources())
+ const composeResourceLimits = resolveComposeResourceLimits(normalizedConfig, hostResources)
const specs = planFiles(normalizedConfig, composeResourceLimits)
const created: Array = []
const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs))