Skip to content

Commit 7b62362

Browse files
committed
feat(core): add nix base flavor for generated containers
1 parent 18f37a2 commit 7b62362

13 files changed

Lines changed: 198 additions & 7 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env
2828

2929
# Same, but also enable Playwright MCP + Chromium sidecar for Codex
3030
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright
31+
32+
# Experimental: generate project with Nix-based container flavor
33+
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --nix
3134
```
3235

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

52+
## Container Base Flavor (Ubuntu/Nix)
53+
54+
By default, generated projects use an Ubuntu-based Dockerfile (`--base-flavor ubuntu`).
55+
56+
For migration experiments you can switch to Nix-based container setup:
57+
- `--base-flavor nix`
58+
- or shorthand `--nix`
59+
60+
This keeps the same docker-git workflow (SSH, compose, entrypoint logic), but installs toolchain packages via `nix profile install` instead of `apt`.
61+
4962
## Projects Root Layout
5063

5164
The projects root is:

packages/app/src/docker-git/cli/parser-options.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ValueOptionSpec {
2222
| "codexHome"
2323
| "archivePath"
2424
| "scrapMode"
25+
| "baseFlavor"
2526
| "label"
2627
| "token"
2728
| "scopes"
@@ -50,6 +51,7 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
5051
{ flag: "--codex-home", key: "codexHome" },
5152
{ flag: "--archive", key: "archivePath" },
5253
{ flag: "--mode", key: "scrapMode" },
54+
{ flag: "--base-flavor", key: "baseFlavor" },
5355
{ flag: "--label", key: "label" },
5456
{ flag: "--token", key: "token" },
5557
{ flag: "--scopes", key: "scopes" },
@@ -73,6 +75,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
7375
"--no-ssh": (raw) => ({ ...raw, openSsh: false }),
7476
"--force": (raw) => ({ ...raw, force: true }),
7577
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
78+
"--nix": (raw) => ({ ...raw, baseFlavor: "nix" }),
79+
"--ubuntu": (raw) => ({ ...raw, baseFlavor: "ubuntu" }),
7680
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
7781
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
7882
"--wipe": (raw) => ({ ...raw, wipe: true }),
@@ -98,6 +102,7 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
98102
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
99103
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
100104
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
105+
baseFlavor: (raw, value) => ({ ...raw, baseFlavor: value }),
101106
label: (raw, value) => ({ ...raw, label: value }),
102107
token: (raw, value) => ({ ...raw, token: value }),
103108
scopes: (raw, value) => ({ ...raw, scopes: value }),

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Options:
4646
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
4747
--codex-auth <path> Host path for Codex auth cache (default: <projectsRoot>/.orch/auth/codex)
4848
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
49+
--base-flavor <flavor> Container base/toolchain flavor: ubuntu|nix (default: ubuntu)
50+
--nix | --ubuntu Shorthand for --base-flavor nix|ubuntu
4951
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
5052
--project-dir <path> Project directory for attach (default: .)
5153
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)

packages/app/src/docker-git/menu-create.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,15 @@ type CreateReturnContext = CreateContext & {
4545
}
4646

4747
export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
48-
const args: Array<string> = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot]
48+
const args: Array<string> = [
49+
"create",
50+
"--repo-url",
51+
input.repoUrl,
52+
"--secrets-root",
53+
input.secretsRoot,
54+
"--base-flavor",
55+
input.baseFlavor
56+
]
4957
if (input.repoRef.length > 0) {
5058
args.push("--repo-ref", input.repoRef)
5159
}
@@ -114,6 +122,7 @@ export const resolveCreateInputs = (
114122
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
115123
outDir,
116124
secretsRoot,
125+
baseFlavor: values.baseFlavor ?? "ubuntu",
117126
runUp: values.runUp !== false,
118127
enableMcpPlaywright: values.enableMcpPlaywright === true,
119128
force: values.force === true,
@@ -132,6 +141,20 @@ const parseYesDefault = (input: string, fallback: boolean): boolean => {
132141
return fallback
133142
}
134143

144+
const parseBaseFlavorDefault = (
145+
input: string,
146+
fallback: CreateInputs["baseFlavor"]
147+
): CreateInputs["baseFlavor"] => {
148+
const normalized = input.trim().toLowerCase()
149+
if (normalized === "nix") {
150+
return "nix"
151+
}
152+
if (normalized === "ubuntu") {
153+
return "ubuntu"
154+
}
155+
return fallback
156+
}
157+
135158
const applyCreateCommand = (
136159
state: MenuState,
137160
create: CreateCommand
@@ -196,6 +219,13 @@ const applyCreateStep = (input: {
196219
input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir
197220
return true
198221
}),
222+
Match.when("baseFlavor", () => {
223+
input.nextValues.baseFlavor = parseBaseFlavorDefault(
224+
input.buffer,
225+
input.currentDefaults.baseFlavor
226+
)
227+
return true
228+
}),
199229
Match.when("runUp", () => {
200230
input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp)
201231
return true

packages/app/src/docker-git/menu-render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin
2929
Match.when("repoUrl", () => "Repo URL"),
3030
Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
3131
Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
32+
Match.when("baseFlavor", () => `Container base flavor [${defaults.baseFlavor}]`),
3233
Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),
3334
Match.when(
3435
"mcpPlaywright",

packages/app/src/docker-git/menu-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type CreateInputs = {
4747
readonly repoRef: string
4848
readonly outDir: string
4949
readonly secretsRoot: string
50+
readonly baseFlavor: "ubuntu" | "nix"
5051
readonly runUp: boolean
5152
readonly enableMcpPlaywright: boolean
5253
readonly force: boolean
@@ -57,6 +58,7 @@ export type CreateStep =
5758
| "repoUrl"
5859
| "repoRef"
5960
| "outDir"
61+
| "baseFlavor"
6062
| "runUp"
6163
| "mcpPlaywright"
6264
| "force"
@@ -65,6 +67,7 @@ export const createSteps: ReadonlyArray<CreateStep> = [
6567
"repoUrl",
6668
"repoRef",
6769
"outDir",
70+
"baseFlavor",
6871
"runUp",
6972
"mcpPlaywright",
7073
"force"

packages/app/tests/docker-git/parser.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const expectCreateCommand = (
4747
const expectCreateDefaults = (command: CreateCommand) => {
4848
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
4949
expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef)
50+
expect(command.config.baseFlavor).toBe(defaultTemplateConfig.baseFlavor)
5051
expect(command.outDir).toBe(".docker-git/org/repo")
5152
expect(command.runUp).toBe(true)
5253
expect(command.forceEnv).toBe(false)
@@ -113,6 +114,31 @@ describe("parseArgs", () => {
113114
expect(command.forceEnv).toBe(true)
114115
}))
115116

117+
it.effect("parses nix shorthand flag", () =>
118+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--nix"], (command) => {
119+
expect(command.config.baseFlavor).toBe("nix")
120+
}))
121+
122+
it.effect("parses explicit base flavor", () =>
123+
expectCreateCommand(
124+
["clone", "https://github.com/org/repo.git", "--base-flavor", "ubuntu"],
125+
(command) => {
126+
expect(command.config.baseFlavor).toBe("ubuntu")
127+
}
128+
))
129+
130+
it.effect("fails on unsupported base flavor value", () =>
131+
Effect.sync(() => {
132+
Either.match(parseArgs(["clone", "https://github.com/org/repo.git", "--base-flavor", "guix"]), {
133+
onLeft: (error) => {
134+
expect(error._tag).toBe("InvalidOption")
135+
},
136+
onRight: () => {
137+
throw new Error("expected parse error")
138+
}
139+
})
140+
}))
141+
116142
it.effect("parses GitHub tree url as repo + ref", () =>
117143
expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => {
118144
expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git")

packages/docker-git/tests/core/templates.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, expect, it } from "@effect/vitest"
22
import { Effect } from "effect"
33

4-
import { planFiles } from "../../src/core/templates.js"
54
import { type TemplateConfig } from "../../src/core/domain.js"
5+
import { planFiles } from "../../src/core/templates.js"
66

77
describe("planFiles", () => {
88
it.effect("includes docker and config files", () =>
@@ -22,6 +22,7 @@ describe("planFiles", () => {
2222
codexAuthPath: "./.orch/auth/codex",
2323
codexSharedAuthPath: "../../.orch/auth/codex",
2424
codexHome: "/home/dev/.codex",
25+
baseFlavor: "ubuntu",
2526
enableMcpPlaywright: false,
2627
pnpmVersion: "10.27.0"
2728
}
@@ -97,6 +98,7 @@ describe("planFiles", () => {
9798
codexAuthPath: "./.orch/auth/codex",
9899
codexSharedAuthPath: "../../.orch/auth/codex",
99100
codexHome: "/home/dev/.codex",
101+
baseFlavor: "ubuntu",
100102
enableMcpPlaywright: true,
101103
pnpmVersion: "10.27.0"
102104
}
@@ -113,6 +115,40 @@ describe("planFiles", () => {
113115
expect(browserScript !== undefined && browserScript._tag === "File").toBe(true)
114116
}))
115117

118+
it.effect("renders Nix flavor Dockerfile when requested", () =>
119+
Effect.sync(() => {
120+
const config: TemplateConfig = {
121+
containerName: "dg-test",
122+
serviceName: "dg-test",
123+
sshUser: "dev",
124+
sshPort: 2222,
125+
repoUrl: "https://github.com/org/repo.git",
126+
repoRef: "main",
127+
targetDir: "/home/dev/app",
128+
volumeName: "dg-test-home",
129+
authorizedKeysPath: "./authorized_keys",
130+
envGlobalPath: "./.orch/env/global.env",
131+
envProjectPath: "./.orch/env/project.env",
132+
codexAuthPath: "./.orch/auth/codex",
133+
codexSharedAuthPath: "../../.orch/auth/codex",
134+
codexHome: "/home/dev/.codex",
135+
baseFlavor: "nix",
136+
enableMcpPlaywright: false,
137+
pnpmVersion: "10.27.0"
138+
}
139+
140+
const specs = planFiles(config)
141+
const dockerfileSpec = specs.find(
142+
(spec) => spec._tag === "File" && spec.relativePath === "Dockerfile"
143+
)
144+
expect(dockerfileSpec !== undefined && dockerfileSpec._tag === "File").toBe(true)
145+
if (dockerfileSpec && dockerfileSpec._tag === "File") {
146+
expect(dockerfileSpec.contents).toContain("FROM nixos/nix:latest")
147+
expect(dockerfileSpec.contents).toContain("nix profile install --profile /nix/var/nix/profiles/default")
148+
expect(dockerfileSpec.contents).not.toContain("FROM ubuntu:24.04")
149+
}
150+
}))
151+
116152
it.effect("embeds issue workspace AGENTS context in entrypoint", () =>
117153
Effect.sync(() => {
118154
const config: TemplateConfig = {
@@ -130,6 +166,7 @@ describe("planFiles", () => {
130166
codexAuthPath: "./.orch/auth/codex",
131167
codexSharedAuthPath: "../../.orch/auth/codex",
132168
codexHome: "/home/dev/.codex",
169+
baseFlavor: "ubuntu",
133170
enableMcpPlaywright: false,
134171
pnpmVersion: "10.27.0"
135172
}
@@ -169,6 +206,7 @@ describe("planFiles", () => {
169206
codexAuthPath: "./.orch/auth/codex",
170207
codexSharedAuthPath: "../../.orch/auth/codex",
171208
codexHome: "/home/dev/.codex",
209+
baseFlavor: "ubuntu",
172210
enableMcpPlaywright: false,
173211
pnpmVersion: "10.27.0"
174212
}

packages/lib/src/core/command-builders.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3030
return Either.right(parsed)
3131
}
3232

33+
const parseBaseFlavor = (
34+
value: string | undefined
35+
): Either.Either<"ubuntu" | "nix", ParseError> => {
36+
const candidate = value?.trim() ?? defaultTemplateConfig.baseFlavor
37+
if (candidate === "ubuntu" || candidate === "nix") {
38+
return Either.right(candidate)
39+
}
40+
return Either.left({
41+
_tag: "InvalidOption",
42+
option: "--base-flavor",
43+
reason: `expected one of: ubuntu, nix (got: ${candidate})`
44+
})
45+
}
46+
3347
export const nonEmpty = (
3448
option: string,
3549
value: string | undefined,
@@ -203,6 +217,7 @@ export const buildCreateCommand = (
203217
const openSsh = raw.openSsh ?? false
204218
const force = raw.force ?? false
205219
const forceEnv = raw.forceEnv ?? false
220+
const baseFlavor = yield* _(parseBaseFlavor(raw.baseFlavor))
206221
const enableMcpPlaywright = raw.enableMcpPlaywright ?? false
207222

208223
return {
@@ -229,6 +244,7 @@ export const buildCreateCommand = (
229244
codexAuthPath: paths.codexAuthPath,
230245
codexSharedAuthPath: paths.codexSharedAuthPath,
231246
codexHome: paths.codexHome,
247+
baseFlavor,
232248
enableMcpPlaywright,
233249
pnpmVersion: defaultTemplateConfig.pnpmVersion
234250
}

packages/lib/src/core/command-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface RawOptions {
2525
readonly envProjectPath?: string
2626
readonly codexAuthPath?: string
2727
readonly codexHome?: string
28+
readonly baseFlavor?: string
2829
readonly enableMcpPlaywright?: boolean
2930
readonly archivePath?: string
3031
readonly scrapMode?: string

0 commit comments

Comments
 (0)