Skip to content
Draft
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env

# Same, but also enable Playwright MCP + Chromium sidecar for Codex
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright

# Experimental: generate project with Nix-based container flavor
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --nix
```

## Parallel Issues / PRs
Expand All @@ -46,6 +49,16 @@ Agent context for issue workspaces:
- Global `${CODEX_HOME}/AGENTS.md` includes workspace path + issue/PR context.
- For `issue-*` workspaces, docker-git creates `${TARGET_DIR}/AGENTS.md` (if missing) with issue context and auto-adds it to `.git/info/exclude`.

## Container Base Flavor (Ubuntu/Nix)

By default, generated projects use an Ubuntu-based Dockerfile (`--base-flavor ubuntu`).

For migration experiments you can switch to Nix-based container setup:
- `--base-flavor nix`
- or shorthand `--nix`

This keeps the same docker-git workflow (SSH, compose, entrypoint logic), but installs toolchain packages via `nix profile install` instead of `apt`.

## Projects Root Layout

The projects root is:
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface ValueOptionSpec {
| "codexHome"
| "archivePath"
| "scrapMode"
| "baseFlavor"
| "label"
| "token"
| "scopes"
Expand Down Expand Up @@ -50,6 +51,7 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
{ flag: "--codex-home", key: "codexHome" },
{ flag: "--archive", key: "archivePath" },
{ flag: "--mode", key: "scrapMode" },
{ flag: "--base-flavor", key: "baseFlavor" },
{ flag: "--label", key: "label" },
{ flag: "--token", key: "token" },
{ flag: "--scopes", key: "scopes" },
Expand All @@ -73,6 +75,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
"--no-ssh": (raw) => ({ ...raw, openSsh: false }),
"--force": (raw) => ({ ...raw, force: true }),
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
"--nix": (raw) => ({ ...raw, baseFlavor: "nix" }),
"--ubuntu": (raw) => ({ ...raw, baseFlavor: "ubuntu" }),
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
"--wipe": (raw) => ({ ...raw, wipe: true }),
Expand All @@ -98,6 +102,7 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
baseFlavor: (raw, value) => ({ ...raw, baseFlavor: value }),
label: (raw, value) => ({ ...raw, label: value }),
token: (raw, value) => ({ ...raw, token: value }),
scopes: (raw, value) => ({ ...raw, scopes: 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 @@ -46,6 +46,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)
--base-flavor <flavor> Container base/toolchain flavor: ubuntu|nix (default: ubuntu)
--nix | --ubuntu Shorthand for --base-flavor nix|ubuntu
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
--project-dir <path> Project directory for attach (default: .)
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)
Expand Down
147 changes: 147 additions & 0 deletions packages/app/src/docker-git/menu-create-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"

import type { CreateInputs } from "./menu-types.js"

export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
const args: Array<string> = [
"create",
"--repo-url",
input.repoUrl,
"--secrets-root",
input.secretsRoot,
"--base-flavor",
input.baseFlavor
]
if (input.repoRef.length > 0) {
args.push("--repo-ref", input.repoRef)
}
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")
}
if (input.forceEnv) {
args.push("--force-env")
}
return args
}

const trimLeftSlash = (value: string): string => {
let start = 0
while (start < value.length && value[start] === "/") {
start += 1
}
return value.slice(start)
}

const trimRightSlash = (value: string): string => {
let end = value.length
while (end > 0 && value[end - 1] === "/") {
end -= 1
}
return value.slice(0, end)
}

const joinPath = (...parts: ReadonlyArray<string>): string => {
const cleaned = parts
.filter((part) => part.length > 0)
.map((part, index) => {
if (index === 0) {
return trimRightSlash(part)
}
return trimRightSlash(trimLeftSlash(part))
})
return cleaned.join("/")
}

export const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
const resolvedRepo = resolveRepoInput(repoUrl)
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
}

const resolveRepoRef = (
repoUrl: string,
values: Partial<CreateInputs>
): string => {
if (values.repoRef !== undefined) {
return values.repoRef
}
if (repoUrl.length === 0) {
return "main"
}
return resolveRepoInput(repoUrl).repoRef ?? "main"
}

const resolveSecretsRoot = (
cwd: string,
values: Partial<CreateInputs>
): string => values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")

const resolveOutDir = (
cwd: string,
repoUrl: string,
values: Partial<CreateInputs>
): string => {
if (values.outDir !== undefined) {
return values.outDir
}
if (repoUrl.length === 0) {
return ""
}
return resolveDefaultOutDir(cwd, repoUrl)
}

export const resolveCreateInputs = (
cwd: string,
values: Partial<CreateInputs>
): CreateInputs => {
const repoUrl = values.repoUrl ?? ""
const repoRef = resolveRepoRef(repoUrl, values)
const secretsRoot = resolveSecretsRoot(cwd, values)
const outDir = resolveOutDir(cwd, repoUrl, values)

return {
repoUrl,
repoRef,
outDir,
secretsRoot,
baseFlavor: values.baseFlavor ?? "ubuntu",
runUp: values.runUp !== false,
enableMcpPlaywright: values.enableMcpPlaywright === true,
force: values.force === true,
forceEnv: values.forceEnv === true
}
}

export const parseYesDefault = (input: string, fallback: boolean): boolean => {
const normalized = input.trim().toLowerCase()
if (normalized === "y" || normalized === "yes") {
return true
}
if (normalized === "n" || normalized === "no") {
return false
}
return fallback
}

export const parseBaseFlavorDefault = (
input: string,
fallback: CreateInputs["baseFlavor"]
): CreateInputs["baseFlavor"] => {
const normalized = input.trim().toLowerCase()
if (normalized === "nix") {
return "nix"
}
if (normalized === "ubuntu") {
return "ubuntu"
}
return fallback
}
107 changes: 17 additions & 90 deletions packages/app/src/docker-git/menu-create.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
import { type CreateCommand } from "@effect-template/lib/core/domain"
import { createProject } from "@effect-template/lib/usecases/actions"
import type { AppError } from "@effect-template/lib/usecases/errors"
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
import * as Path from "@effect/platform/Path"
import { Effect, Either, Match, pipe } from "effect"
import { parseArgs } from "./cli/parser.js"
import { formatParseError, usageText } from "./cli/usage.js"
import {
buildCreateArgs,
parseBaseFlavorDefault,
parseYesDefault,
resolveCreateInputs,
resolveDefaultOutDir
} from "./menu-create-helpers.js"

import { resetToMenu } from "./menu-shared.js"
import {
Expand Down Expand Up @@ -44,94 +50,6 @@ type CreateReturnContext = CreateContext & {
readonly view: Extract<ViewState, { readonly _tag: "Create" }>
}

export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
const args: Array<string> = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot]
if (input.repoRef.length > 0) {
args.push("--repo-ref", input.repoRef)
}
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")
}
if (input.forceEnv) {
args.push("--force-env")
}
return args
}

const trimLeftSlash = (value: string): string => {
let start = 0
while (start < value.length && value[start] === "/") {
start += 1
}
return value.slice(start)
}

const trimRightSlash = (value: string): string => {
let end = value.length
while (end > 0 && value[end - 1] === "/") {
end -= 1
}
return value.slice(0, end)
}

const joinPath = (...parts: ReadonlyArray<string>): string => {
const cleaned = parts
.filter((part) => part.length > 0)
.map((part, index) => {
if (index === 0) {
return trimRightSlash(part)
}
return trimRightSlash(trimLeftSlash(part))
})
return cleaned.join("/")
}

const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
const resolvedRepo = resolveRepoInput(repoUrl)
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
}

export const resolveCreateInputs = (
cwd: string,
values: Partial<CreateInputs>
): CreateInputs => {
const repoUrl = values.repoUrl ?? ""
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")

return {
repoUrl,
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
outDir,
secretsRoot,
runUp: values.runUp !== false,
enableMcpPlaywright: values.enableMcpPlaywright === true,
force: values.force === true,
forceEnv: values.forceEnv === true
}
}

const parseYesDefault = (input: string, fallback: boolean): boolean => {
const normalized = input.trim().toLowerCase()
if (normalized === "y" || normalized === "yes") {
return true
}
if (normalized === "n" || normalized === "no") {
return false
}
return fallback
}

const applyCreateCommand = (
state: MenuState,
create: CreateCommand
Expand Down Expand Up @@ -196,6 +114,13 @@ const applyCreateStep = (input: {
input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir
return true
}),
Match.when("baseFlavor", () => {
input.nextValues.baseFlavor = parseBaseFlavorDefault(
input.buffer,
input.currentDefaults.baseFlavor
)
return true
}),
Match.when("runUp", () => {
input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp)
return true
Expand Down Expand Up @@ -318,3 +243,5 @@ export const handleCreateInput = (
context.setView({ ...view, buffer: view.buffer + input })
}
}

export { buildCreateArgs, resolveCreateInputs } from "./menu-create-helpers.js"
1 change: 1 addition & 0 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin
Match.when("repoUrl", () => "Repo URL"),
Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
Match.when("baseFlavor", () => `Container base flavor [${defaults.baseFlavor}]`),
Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),
Match.when(
"mcpPlaywright",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type CreateInputs = {
readonly repoRef: string
readonly outDir: string
readonly secretsRoot: string
readonly baseFlavor: "ubuntu" | "nix"
readonly runUp: boolean
readonly enableMcpPlaywright: boolean
readonly force: boolean
Expand All @@ -57,6 +58,7 @@ export type CreateStep =
| "repoUrl"
| "repoRef"
| "outDir"
| "baseFlavor"
| "runUp"
| "mcpPlaywright"
| "force"
Expand All @@ -65,6 +67,7 @@ export const createSteps: ReadonlyArray<CreateStep> = [
"repoUrl",
"repoRef",
"outDir",
"baseFlavor",
"runUp",
"mcpPlaywright",
"force"
Expand Down
Loading