Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/services/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,16 @@ export const uiHtml = `<!doctype html>
</select>
</div>
</div>
<div class="row" style="margin-top:0.5rem">
<div>
<label for="create-cpu">CPU limit</label>
<input id="create-cpu" type="text" placeholder="30% or 1.5" />
</div>
<div>
<label for="create-ram">RAM limit</label>
<input id="create-ram" type="text" placeholder="30% or 4g" />
</div>
</div>
<div class="checkbox-row" style="margin-top:0.6rem">
<input id="create-up" type="checkbox" checked />
<label for="create-up" style="margin:0">run up</label>
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 17 additions & 9 deletions packages/app/src/docker-git/cli/parser-apply.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -17,12 +18,19 @@ import { parseProjectDirWithOptions } from "./parser-shared.js"
export const parseApply = (
args: ReadonlyArray<string>
): Either.Either<ApplyCommand, ParseError> =>
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
}
})
8 changes: 8 additions & 0 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface ValueOptionSpec {
| "envProjectPath"
| "codexAuthPath"
| "codexHome"
| "cpuLimit"
| "ramLimit"
| "dockerNetworkMode"
| "dockerSharedNetworkName"
| "archivePath"
Expand Down Expand Up @@ -54,6 +56,10 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
{ 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" },
Expand Down Expand Up @@ -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 }),
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Options:
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
--codex-auth <path> Host path for Codex auth cache (default: <projectsRoot>/.orch/auth/codex)
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
--cpu <value> CPU limit: percent or cores (examples: 30%, 1.5; default: 30%)
--ram <value> RAM limit: percent or size (examples: 30%, 512m, 4g; default: 30%)
--network-mode <mode> Compose network mode: shared|project (default: shared)
--shared-network <name> Shared Docker network name when network-mode=shared (default: docker-git-shared)
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
Expand Down
58 changes: 39 additions & 19 deletions packages/app/src/docker-git/menu-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,38 @@ type CreateReturnContext = CreateContext & {
readonly view: Extract<ViewState, { readonly _tag: "Create" }>
}

type OptionalCreateArg = {
readonly value: string
readonly args: readonly [string, string]
}

const optionalCreateArgs = (input: CreateInputs): ReadonlyArray<OptionalCreateArg> => [
{ 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<string> =>
[
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<string> => {
const args: Array<string> = ["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.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
}
Expand Down Expand Up @@ -118,6 +128,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,
Expand Down Expand Up @@ -196,6 +208,14 @@ 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
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,6 +60,8 @@ export type CreateStep =
| "repoUrl"
| "repoRef"
| "outDir"
| "cpuLimit"
| "ramLimit"
| "runUp"
| "mcpPlaywright"
| "force"
Expand All @@ -66,6 +70,8 @@ export const createSteps: ReadonlyArray<CreateStep> = [
"repoUrl",
"repoRef",
"outDir",
"cpuLimit",
"ramLimit",
"runUp",
"mcpPlaywright",
"force"
Expand Down
27 changes: 27 additions & 0 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down Expand Up @@ -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"
])
Expand All @@ -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)
}))

Expand Down
53 changes: 53 additions & 0 deletions packages/lib/src/core/command-builders-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Either } from "effect"

import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, type ParseError } from "./domain.js"

const parsePort = (value: string): Either.Either<number, ParseError> => {
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<number, ParseError> => parsePort(value)

export const parseDockerNetworkMode = (
value: string | undefined
): Either.Either<CreateCommand["config"]["dockerNetworkMode"], ParseError> => {
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<string, ParseError> => {
const candidate = value?.trim() ?? fallback
if (candidate === undefined || candidate.length === 0) {
return Either.left({
_tag: "MissingRequiredOption",
option
})
}
return Either.right(candidate)
}
Loading
Loading