From 7b62362a961231a9de4b33b4979198f6b7003064 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:31:35 +0000 Subject: [PATCH 1/2] feat(core): add nix base flavor for generated containers --- README.md | 13 +++++ .../app/src/docker-git/cli/parser-options.ts | 5 ++ packages/app/src/docker-git/cli/usage.ts | 2 + packages/app/src/docker-git/menu-create.ts | 32 ++++++++++- packages/app/src/docker-git/menu-render.ts | 1 + packages/app/src/docker-git/menu-types.ts | 3 + packages/app/tests/docker-git/parser.test.ts | 26 +++++++++ .../docker-git/tests/core/templates.test.ts | 40 ++++++++++++- packages/lib/src/core/command-builders.ts | 16 ++++++ packages/lib/src/core/command-options.ts | 1 + packages/lib/src/core/domain.ts | 6 +- packages/lib/src/core/templates/dockerfile.ts | 57 +++++++++++++++++-- packages/lib/src/shell/config.ts | 3 + 13 files changed, 198 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9ed4a275..d7891068 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index ec488476..d1fd547c 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -22,6 +22,7 @@ interface ValueOptionSpec { | "codexHome" | "archivePath" | "scrapMode" + | "baseFlavor" | "label" | "token" | "scopes" @@ -50,6 +51,7 @@ const valueOptionSpecs: ReadonlyArray = [ { 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" }, @@ -73,6 +75,8 @@ const booleanFlagUpdaters: Readonly 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 }), @@ -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 }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 630c3915..46f2a426 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -46,6 +46,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) + --base-flavor Container base/toolchain flavor: ubuntu|nix (default: ubuntu) + --nix | --ubuntu Shorthand for --base-flavor nix|ubuntu --out-dir Output directory (default: //[/issue-|/pr-]) --project-dir Project directory for attach (default: .) --archive Scrap snapshot directory (default: .orch/scrap/session) diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 6b43e6ab..b63b856f 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -45,7 +45,15 @@ type CreateReturnContext = CreateContext & { } export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { - const args: Array = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot] + const args: Array = [ + "create", + "--repo-url", + input.repoUrl, + "--secrets-root", + input.secretsRoot, + "--base-flavor", + input.baseFlavor + ] if (input.repoRef.length > 0) { args.push("--repo-ref", input.repoRef) } @@ -114,6 +122,7 @@ export const resolveCreateInputs = ( repoRef: values.repoRef ?? resolvedRepoRef ?? "main", outDir, secretsRoot, + baseFlavor: values.baseFlavor ?? "ubuntu", runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, force: values.force === true, @@ -132,6 +141,20 @@ const parseYesDefault = (input: string, fallback: boolean): boolean => { return fallback } +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 +} + const applyCreateCommand = ( state: MenuState, create: CreateCommand @@ -196,6 +219,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 diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 440029a1..cd1106db 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -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", diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 755e8206..28550d07 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -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 @@ -57,6 +58,7 @@ export type CreateStep = | "repoUrl" | "repoRef" | "outDir" + | "baseFlavor" | "runUp" | "mcpPlaywright" | "force" @@ -65,6 +67,7 @@ export const createSteps: ReadonlyArray = [ "repoUrl", "repoRef", "outDir", + "baseFlavor", "runUp", "mcpPlaywright", "force" diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index 637fd7cc..a3253b6a 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -47,6 +47,7 @@ const expectCreateCommand = ( const expectCreateDefaults = (command: CreateCommand) => { expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef) + expect(command.config.baseFlavor).toBe(defaultTemplateConfig.baseFlavor) expect(command.outDir).toBe(".docker-git/org/repo") expect(command.runUp).toBe(true) expect(command.forceEnv).toBe(false) @@ -113,6 +114,31 @@ describe("parseArgs", () => { expect(command.forceEnv).toBe(true) })) + it.effect("parses nix shorthand flag", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--nix"], (command) => { + expect(command.config.baseFlavor).toBe("nix") + })) + + it.effect("parses explicit base flavor", () => + expectCreateCommand( + ["clone", "https://github.com/org/repo.git", "--base-flavor", "ubuntu"], + (command) => { + expect(command.config.baseFlavor).toBe("ubuntu") + } + )) + + it.effect("fails on unsupported base flavor value", () => + Effect.sync(() => { + Either.match(parseArgs(["clone", "https://github.com/org/repo.git", "--base-flavor", "guix"]), { + onLeft: (error) => { + expect(error._tag).toBe("InvalidOption") + }, + onRight: () => { + throw new Error("expected parse error") + } + }) + })) + it.effect("parses GitHub tree url as repo + ref", () => expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => { expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git") diff --git a/packages/docker-git/tests/core/templates.test.ts b/packages/docker-git/tests/core/templates.test.ts index afbca5ba..d7804ab1 100644 --- a/packages/docker-git/tests/core/templates.test.ts +++ b/packages/docker-git/tests/core/templates.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { planFiles } from "../../src/core/templates.js" import { type TemplateConfig } from "../../src/core/domain.js" +import { planFiles } from "../../src/core/templates.js" describe("planFiles", () => { it.effect("includes docker and config files", () => @@ -22,6 +22,7 @@ describe("planFiles", () => { codexAuthPath: "./.orch/auth/codex", codexSharedAuthPath: "../../.orch/auth/codex", codexHome: "/home/dev/.codex", + baseFlavor: "ubuntu", enableMcpPlaywright: false, pnpmVersion: "10.27.0" } @@ -97,6 +98,7 @@ describe("planFiles", () => { codexAuthPath: "./.orch/auth/codex", codexSharedAuthPath: "../../.orch/auth/codex", codexHome: "/home/dev/.codex", + baseFlavor: "ubuntu", enableMcpPlaywright: true, pnpmVersion: "10.27.0" } @@ -113,6 +115,40 @@ describe("planFiles", () => { expect(browserScript !== undefined && browserScript._tag === "File").toBe(true) })) + it.effect("renders Nix flavor Dockerfile when requested", () => + Effect.sync(() => { + const config: TemplateConfig = { + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/app", + volumeName: "dg-test-home", + authorizedKeysPath: "./authorized_keys", + envGlobalPath: "./.orch/env/global.env", + envProjectPath: "./.orch/env/project.env", + codexAuthPath: "./.orch/auth/codex", + codexSharedAuthPath: "../../.orch/auth/codex", + codexHome: "/home/dev/.codex", + baseFlavor: "nix", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + const specs = planFiles(config) + const dockerfileSpec = specs.find( + (spec) => spec._tag === "File" && spec.relativePath === "Dockerfile" + ) + expect(dockerfileSpec !== undefined && dockerfileSpec._tag === "File").toBe(true) + if (dockerfileSpec && dockerfileSpec._tag === "File") { + expect(dockerfileSpec.contents).toContain("FROM nixos/nix:latest") + expect(dockerfileSpec.contents).toContain("nix profile install --profile /nix/var/nix/profiles/default") + expect(dockerfileSpec.contents).not.toContain("FROM ubuntu:24.04") + } + })) + it.effect("embeds issue workspace AGENTS context in entrypoint", () => Effect.sync(() => { const config: TemplateConfig = { @@ -130,6 +166,7 @@ describe("planFiles", () => { codexAuthPath: "./.orch/auth/codex", codexSharedAuthPath: "../../.orch/auth/codex", codexHome: "/home/dev/.codex", + baseFlavor: "ubuntu", enableMcpPlaywright: false, pnpmVersion: "10.27.0" } @@ -169,6 +206,7 @@ describe("planFiles", () => { codexAuthPath: "./.orch/auth/codex", codexSharedAuthPath: "../../.orch/auth/codex", codexHome: "/home/dev/.codex", + baseFlavor: "ubuntu", enableMcpPlaywright: false, pnpmVersion: "10.27.0" } diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 6c775a35..f373c2bc 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -30,6 +30,20 @@ const parsePort = (value: string): Either.Either => { return Either.right(parsed) } +const parseBaseFlavor = ( + value: string | undefined +): Either.Either<"ubuntu" | "nix", ParseError> => { + const candidate = value?.trim() ?? defaultTemplateConfig.baseFlavor + if (candidate === "ubuntu" || candidate === "nix") { + return Either.right(candidate) + } + return Either.left({ + _tag: "InvalidOption", + option: "--base-flavor", + reason: `expected one of: ubuntu, nix (got: ${candidate})` + }) +} + export const nonEmpty = ( option: string, value: string | undefined, @@ -203,6 +217,7 @@ export const buildCreateCommand = ( const openSsh = raw.openSsh ?? false const force = raw.force ?? false const forceEnv = raw.forceEnv ?? false + const baseFlavor = yield* _(parseBaseFlavor(raw.baseFlavor)) const enableMcpPlaywright = raw.enableMcpPlaywright ?? false return { @@ -229,6 +244,7 @@ export const buildCreateCommand = ( codexAuthPath: paths.codexAuthPath, codexSharedAuthPath: paths.codexSharedAuthPath, codexHome: paths.codexHome, + baseFlavor, enableMcpPlaywright, pnpmVersion: defaultTemplateConfig.pnpmVersion } diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index d2b3bdd9..035a054c 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -25,6 +25,7 @@ export interface RawOptions { readonly envProjectPath?: string readonly codexAuthPath?: string readonly codexHome?: string + readonly baseFlavor?: string readonly enableMcpPlaywright?: boolean readonly archivePath?: string readonly scrapMode?: string diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 75bc4e67..05e8e1cb 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -19,6 +19,7 @@ export interface TemplateConfig { readonly codexAuthPath: string readonly codexSharedAuthPath: string readonly codexHome: string + readonly baseFlavor: "ubuntu" | "nix" readonly enableMcpPlaywright: boolean readonly pnpmVersion: string } @@ -227,7 +228,9 @@ export type Command = | StateCommand | AuthCommand -export const defaultTemplateConfig = { +type DefaultTemplateConfig = Omit + +export const defaultTemplateConfig: DefaultTemplateConfig = { containerName: "dev-ssh", serviceName: "dev", sshUser: "dev", @@ -242,6 +245,7 @@ export const defaultTemplateConfig = { codexAuthPath: "./.docker-git/.orch/auth/codex", codexSharedAuthPath: "./.docker-git/.orch/auth/codex", codexHome: "/home/dev/.codex", + baseFlavor: "ubuntu", enableMcpPlaywright: false, pnpmVersion: "10.27.0" } diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 65ed3693..5000f0ae 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -1,7 +1,9 @@ import type { TemplateConfig } from "../domain.js" import { renderDockerfilePrompt } from "../templates-prompt.js" -const renderDockerfilePrelude = (): string => +type BaseFlavor = TemplateConfig["baseFlavor"] + +const renderDockerfilePreludeUbuntu = (): string => `FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive @@ -17,7 +19,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` -const renderDockerfileNode = (): string => +const renderDockerfilePreludeNix = (): string => + `FROM nixos/nix:latest + +ENV NIX_CONFIG="experimental-features = nix-command flakes" +ENV PATH="/nix/var/nix/profiles/default/bin:$PATH" + +RUN nix profile install --profile /nix/var/nix/profiles/default \ + nixpkgs#bash \ + nixpkgs#bash-completion \ + nixpkgs#cacert \ + nixpkgs#curl \ + nixpkgs#docker \ + nixpkgs#gh \ + nixpkgs#git \ + nixpkgs#gnumake \ + nixpkgs#ncurses \ + nixpkgs#openssh \ + nixpkgs#shadow \ + nixpkgs#sudo \ + nixpkgs#unzip \ + nixpkgs#util-linux \ + nixpkgs#xorg.xauth \ + nixpkgs#zsh \ + nixpkgs#zsh-autosuggestions + +# Keep path compatibility with existing entrypoint (/usr/bin/zsh, /usr/sbin/sshd) +RUN mkdir -p /usr/bin /usr/sbin /etc/sudoers.d \ + && ln -sf "$(command -v bash)" /usr/bin/bash \ + && ln -sf "$(command -v zsh)" /usr/bin/zsh \ + && ln -sf "$(command -v sshd)" /usr/sbin/sshd \ + && printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ + && chmod 0440 /etc/sudoers.d/zz-all` + +const renderDockerfilePrelude = (baseFlavor: BaseFlavor): string => + baseFlavor === "nix" ? renderDockerfilePreludeNix() : renderDockerfilePreludeUbuntu() + +const renderDockerfileNodeUbuntu = (): string => `# Tooling: Node 24 (NodeSource) + nvm RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ @@ -30,6 +68,17 @@ RUN mkdir -p /usr/local/nvm \ RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\\n" \ > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh` +const renderDockerfileNodeNix = (): string => + `# Tooling: Node 24 via nixpkgs +RUN nix profile install --profile /nix/var/nix/profiles/default nixpkgs#nodejs_24 \ + || nix profile install --profile /nix/var/nix/profiles/default nixpkgs#nodejs +RUN node -v \ + && npm -v \ + && corepack --version` + +const renderDockerfileNode = (baseFlavor: BaseFlavor): string => + baseFlavor === "nix" ? renderDockerfileNodeNix() : renderDockerfileNodeUbuntu() + const renderDockerfileBunPrelude = (config: TemplateConfig): string => `# Tooling: pnpm + Codex CLI + oh-my-opencode (bun) RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate @@ -167,9 +216,9 @@ ENTRYPOINT ["/entrypoint.sh"]` export const renderDockerfile = (config: TemplateConfig): string => [ - renderDockerfilePrelude(), + renderDockerfilePrelude(config.baseFlavor), renderDockerfilePrompt(), - renderDockerfileNode(), + renderDockerfileNode(config.baseFlavor), renderDockerfileBun(config), renderDockerfileOpenCode(), renderDockerfileGitleaks(), diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index 65557349..b81aab8e 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -34,6 +34,9 @@ const TemplateConfigSchema = Schema.Struct({ default: () => defaultTemplateConfig.codexSharedAuthPath }), codexHome: Schema.String, + baseFlavor: Schema.optionalWith(Schema.Union(Schema.Literal("ubuntu"), Schema.Literal("nix")), { + default: () => defaultTemplateConfig.baseFlavor + }), enableMcpPlaywright: Schema.optionalWith(Schema.Boolean, { default: () => defaultTemplateConfig.enableMcpPlaywright }), From ef8d81ad4dfc4d41f7dd5cc0d4f4fd9ab153f953 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:44:40 +0000 Subject: [PATCH 2/2] fix(app): split create helpers to satisfy lint max-lines --- .../app/src/docker-git/menu-create-helpers.ts | 147 ++++++++++++++++++ packages/app/src/docker-git/menu-create.ts | 123 ++------------- 2 files changed, 157 insertions(+), 113 deletions(-) create mode 100644 packages/app/src/docker-git/menu-create-helpers.ts diff --git a/packages/app/src/docker-git/menu-create-helpers.ts b/packages/app/src/docker-git/menu-create-helpers.ts new file mode 100644 index 00000000..7c74d494 --- /dev/null +++ b/packages/app/src/docker-git/menu-create-helpers.ts @@ -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 => { + const args: Array = [ + "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 => { + 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 +): 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 +): string => values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets") + +const resolveOutDir = ( + cwd: string, + repoUrl: string, + values: Partial +): 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 => { + 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 +} diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index b63b856f..a2d77764 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -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 { @@ -44,117 +50,6 @@ type CreateReturnContext = CreateContext & { readonly view: Extract } -export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { - const args: Array = [ - "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 => { - 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 => { - 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, - baseFlavor: values.baseFlavor ?? "ubuntu", - 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 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 -} - const applyCreateCommand = ( state: MenuState, create: CreateCommand @@ -348,3 +243,5 @@ export const handleCreateInput = ( context.setView({ ...view, buffer: view.buffer + input }) } } + +export { buildCreateArgs, resolveCreateInputs } from "./menu-create-helpers.js"