From 093b72c54ce27efa860fb8ba2c74e0289b2f1194 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:37:07 +0000 Subject: [PATCH] feat(core): allow empty docker-git create workspaces --- README.md | 5 +- packages/app/src/docker-git/cli/usage.ts | 5 +- packages/app/src/docker-git/menu-create.ts | 22 +++--- packages/app/src/docker-git/menu-render.ts | 2 +- packages/app/tests/docker-git/parser.test.ts | 15 +++- packages/lib/src/core/command-builders.ts | 80 +++++++++++++------- 6 files changed, 82 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 28ad751b..fe166f67 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # docker-git -`docker-git` generates a disposable Docker development environment per repository and stores it under a single projects root (default: `~/.docker-git`). +`docker-git` generates a disposable Docker development environment per repository (or empty workspace) and stores it under a single projects root (default: `~/.docker-git`). Key goals: - Functional Core, Imperative Shell implementation (pure templates + typed orchestration). @@ -18,6 +18,9 @@ pnpm install # Interactive TUI menu (default) pnpm run docker-git +# Create an empty workspace container (no git clone) +pnpm run docker-git create + # Clone a repo into its own container (creates under ~/.docker-git) pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 0c3befe4..b5eb81de 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -3,7 +3,7 @@ import { Match } from "effect" import type { ParseError } from "@effect-template/lib/core/domain" export const usageText = `docker-git menu -docker-git create --repo-url [options] +docker-git create [--repo-url ] [options] docker-git clone [options] docker-git apply [] [options] docker-git mcp-playwright [] [options] @@ -20,7 +20,7 @@ docker-git state [options] Commands: menu Interactive menu (default when no args) - create, init Generate docker development environment + create, init Generate docker development environment (repo URL optional) clone Create + run container and clone repo apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir @@ -34,6 +34,7 @@ Commands: state Manage docker-git state directory via git (sync across machines) Options: + --repo-url Repository URL (create: optional; clone: required via positional arg or flag) --repo-ref Git ref/branch (default: main) --branch, -b Alias for --repo-ref --target-dir Target dir inside container (create default: /home/dev/app, clone default: ~/workspaces//[/issue-|/pr-]) diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index c044ddf8..cc96be95 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -46,11 +46,16 @@ type CreateReturnContext = CreateContext & { } export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { - const args: Array = ["create", "--repo-url", input.repoUrl] + 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) } - args.push("--out-dir", input.outDir) + if (input.outDir.length > 0) { + args.push("--out-dir", input.outDir) + } if (!input.runUp) { args.push("--no-up") } @@ -106,8 +111,8 @@ export const resolveCreateInputs = ( values: Partial ): CreateInputs => { const repoUrl = values.repoUrl ?? "" - const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined - const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "") + const resolvedRepoRef = resolveRepoInput(repoUrl).repoRef + const outDir = values.outDir ?? resolveDefaultOutDir(cwd, repoUrl) return { repoUrl, @@ -179,10 +184,6 @@ const applyCreateStep = (input: { }): boolean => Match.value(input.step).pipe( Match.when("repoUrl", () => { - if (input.buffer.length === 0) { - input.setMessage("Repo URL is required.") - return false - } input.nextValues.repoUrl = input.buffer input.nextValues.outDir = resolveDefaultOutDir(input.cwd, input.buffer) return true @@ -222,11 +223,6 @@ const finalizeCreateFlow = (input: { readonly setActiveDir: (dir: string | null) => void }) => { const inputs = resolveCreateInputs(input.state.cwd, input.nextValues) - if (inputs.repoUrl.length === 0) { - input.setMessage("Repo URL is required.") - return - } - const parsed = parseArgs(buildCreateArgs(inputs)) if (Either.isLeft(parsed)) { input.setMessage(formatParseError(parsed.left)) diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index d074dc53..68a64acd 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -28,7 +28,7 @@ import { createSteps, menuItems } from "./menu-types.js" export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): string => Match.value(step).pipe( - Match.when("repoUrl", () => "Repo URL"), + 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("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index af1b3c5c..e0c98e6c 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -101,7 +101,20 @@ describe("parseArgs", () => { expect(command.config.volumeName).toBe("dg-repo-issue-9-home") })) - it.effect("fails on missing repo url", () => expectParseErrorTag(["create"], "MissingRequiredOption")) + it.effect("parses create command without repo url into empty workspace defaults", () => + expectCreateCommand(["create"], (command) => { + expect(command.config.repoUrl).toBe("") + expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef) + expect(command.outDir).toBe(".docker-git/app") + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) + expect(command.config.containerName).toBe("dg-app") + expect(command.config.serviceName).toBe("dg-app") + expect(command.config.volumeName).toBe("dg-app-home") + expect(command.config.targetDir).toBe(expandDefaultTargetDir(defaultTemplateConfig.targetDir)) + })) + + it.effect("fails clone when repo url is missing", () => expectParseErrorTag(["clone"], "MissingRequiredOption")) it.effect("parses clone command with positional repo url", () => expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => { diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 94fc0959..708b7253 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -77,7 +77,7 @@ type RepoBasics = { const resolveRepoBasics = (raw: RawOptions): Either.Either => Either.gen(function*(_) { - const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl)) + const rawRepoUrl = raw.repoUrl?.trim() ?? "" const resolvedRepo = resolveRepoInput(rawRepoUrl) const repoUrl = resolvedRepo.repoUrl const repoSlug = deriveRepoSlug(repoUrl) @@ -200,6 +200,47 @@ const resolvePaths = ( } }) +type BuildTemplateConfigInput = { + readonly raw: RawOptions + readonly repo: RepoBasics + readonly names: NameConfig + readonly paths: PathConfig + readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] + readonly dockerSharedNetworkName: string +} + +const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => { + const enableMcpPlaywright = input.raw.enableMcpPlaywright ?? false + const gitTokenLabel = normalizeGitTokenLabel(input.raw.gitTokenLabel) + const codexAuthLabel = normalizeAuthLabel(input.raw.codexTokenLabel) + const claudeAuthLabel = normalizeAuthLabel(input.raw.claudeTokenLabel) + + return { + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + gitTokenLabel, + codexAuthLabel, + claudeAuthLabel, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright, + pnpmVersion: defaultTemplateConfig.pnpmVersion + } +} + // CHANGE: build a typed create command from raw options (CLI or API) // WHY: share deterministic command construction across CLI and server // QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app" @@ -221,14 +262,18 @@ export const buildCreateCommand = ( const openSsh = raw.openSsh ?? false const force = raw.force ?? false const forceEnv = raw.forceEnv ?? false - const enableMcpPlaywright = raw.enableMcpPlaywright ?? false - const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) - const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) - const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) const dockerSharedNetworkName = yield* _( nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) ) + const config = buildTemplateConfig({ + raw, + repo, + names, + paths, + dockerNetworkMode, + dockerSharedNetworkName + }) return { _tag: "Create", @@ -238,29 +283,6 @@ export const buildCreateCommand = ( force, forceEnv, waitForClone: false, - config: { - containerName: names.containerName, - serviceName: names.serviceName, - sshUser: repo.sshUser, - sshPort: repo.sshPort, - repoUrl: repo.repoUrl, - repoRef: repo.repoRef, - gitTokenLabel, - codexAuthLabel, - claudeAuthLabel, - targetDir: repo.targetDir, - volumeName: names.volumeName, - dockerGitPath: paths.dockerGitPath, - authorizedKeysPath: paths.authorizedKeysPath, - envGlobalPath: paths.envGlobalPath, - envProjectPath: paths.envProjectPath, - codexAuthPath: paths.codexAuthPath, - codexSharedAuthPath: paths.codexSharedAuthPath, - codexHome: paths.codexHome, - dockerNetworkMode, - dockerSharedNetworkName, - enableMcpPlaywright, - pnpmVersion: defaultTemplateConfig.pnpmVersion - } + config } })