diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index ade0d3c..f20c63c 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -22,7 +22,20 @@ const helpCommand: Command = { _tag: "Help", message: usageText } const menuCommand: Command = { _tag: "Menu" } const statusCommand: Command = { _tag: "Status" } const downAllCommand: Command = { _tag: "DownAll" } -const applyAllCommand: Command = { _tag: "ApplyAll" } + +// CHANGE: parse --active flag for apply-all command to restrict to running containers +// WHY: allow users to apply config only to currently active containers via --active flag +// QUOTE(ТЗ): "сделать это возможным через атрибут --active применять только к активным контейнерам, а не ко всем" +// REF: issue-185 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: activeOnly is true only when --active flag is present +// COMPLEXITY: O(n) where n = |args| +const parseApplyAll = (args: ReadonlyArray): Either.Either => { + const activeOnly = args.includes("--active") + const command: Command = { _tag: "ApplyAll", activeOnly } + return Either.right(command) +} const parseCreate = (args: ReadonlyArray): Either.Either => Either.flatMap(parseRawOptions(args), (raw) => buildCreateCommand(raw)) @@ -76,8 +89,8 @@ export const parseArgs = (args: ReadonlyArray): Either.Either Either.right(menuCommand)) ) .pipe( - Match.when("apply-all", () => Either.right(applyAllCommand)), - Match.when("update-all", () => Either.right(applyAllCommand)), + Match.when("apply-all", () => parseApplyAll(rest)), + Match.when("update-all", () => parseApplyAll(rest)), Match.when("auth", () => parseAuth(rest)), Match.when("open", () => parseAttach(rest)), Match.when("apply", () => parseApply(rest)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 8debd26..90a4363 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -19,7 +19,7 @@ docker-git session-gists backup [] [options] docker-git session-gists view docker-git session-gists download [options] docker-git ps -docker-git apply-all +docker-git apply-all [--active] docker-git down-all docker-git auth [options] docker-git state [options] @@ -37,7 +37,7 @@ Commands: sessions List/kill/log container terminal processes session-gists Manage AI session backups via a private session repository (backup/list/view/download) ps, status Show docker compose status for all docker-git projects - apply-all Apply docker-git config and refresh all containers (docker compose up) + apply-all Apply docker-git config and refresh all containers (docker compose up); use --active to restrict to running containers only down-all Stop all docker-git containers (docker compose down) auth Manage GitHub/Codex/Claude Code auth for docker-git state Manage docker-git state directory via git (sync across machines) @@ -80,6 +80,7 @@ Options: --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --auto[=claude|codex] Auto-execute an agent; without value picks by auth, random if both are available + --active apply-all: apply only to currently running containers (skip stopped ones) --force Overwrite existing files and wipe compose volumes (docker compose down -v) --force-env Reset project env defaults only (keep workspace volume/data) -h, --help Show this help diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 82f1e82..34124dc 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -148,7 +148,7 @@ export const program = pipe( Match.when({ _tag: "Create" }, (create) => createProject(create)), Match.when({ _tag: "Status" }, () => listProjectStatus), Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), - Match.when({ _tag: "ApplyAll" }, () => applyAllDockerGitProjects), + Match.when({ _tag: "ApplyAll" }, (cmd) => applyAllDockerGitProjects(cmd)), Match.when({ _tag: "Menu" }, () => runMenu), Match.orElse((cmd) => handleNonBaseCommand(cmd)) ) diff --git a/packages/app/tests/docker-git/parser-apply-all.test.ts b/packages/app/tests/docker-git/parser-apply-all.test.ts new file mode 100644 index 0000000..95af2b8 --- /dev/null +++ b/packages/app/tests/docker-git/parser-apply-all.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { parseOrThrow } from "./parser-helpers.js" + +const assertApplyAllActiveOnly = (args: ReadonlyArray, expectedActiveOnly: boolean) => { + const command = parseOrThrow(args) + expect(command._tag).toBe("ApplyAll") + if (command._tag === "ApplyAll") { + expect(command.activeOnly).toBe(expectedActiveOnly) + } +} + +describe("parseArgs apply-all --active", () => { + it.effect("parses apply-all without --active as activeOnly=false", () => + Effect.sync(() => { + assertApplyAllActiveOnly(["apply-all"], false) + })) + + it.effect("parses update-all without --active as activeOnly=false", () => + Effect.sync(() => { + assertApplyAllActiveOnly(["update-all"], false) + })) + + it.effect("parses apply-all with --active as activeOnly=true", () => + Effect.sync(() => { + assertApplyAllActiveOnly(["apply-all", "--active"], true) + })) + + it.effect("parses update-all with --active as activeOnly=true", () => + Effect.sync(() => { + assertApplyAllActiveOnly(["update-all", "--active"], true) + })) +}) diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 0767735..95ebe05 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -144,16 +144,18 @@ export interface ApplyCommand { readonly enableMcpPlaywright?: boolean | undefined } -// CHANGE: add apply-all command to apply docker-git config to every known project -// WHY: allow bulk-updating all containers in one command instead of running apply for each project manually +// CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag +// WHY: allow bulk-updating all containers in one command; --active restricts to currently running containers only // QUOTE(ТЗ): "Сделать команду которая сама на все контейнеры применит новые настройки" -// REF: issue-164 +// QUOTE(ТЗ): "сделать это возможным через атрибут --active применять только к активным контейнерам, а не ко всем" +// REF: issue-164, issue-185 // PURITY: CORE // EFFECT: n/a -// INVARIANT: applies to all discovered projects; individual failures do not abort the batch +// INVARIANT: when activeOnly=false applies to all discovered projects; when activeOnly=true applies only to running containers; individual failures do not abort the batch // COMPLEXITY: O(1) export interface ApplyAllCommand { readonly _tag: "ApplyAll" + readonly activeOnly: boolean } export interface HelpCommand { diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 20edb11..a2b3b93 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" + export { renderEntrypointCodexResumeHint } from "./codex-resume-hint.js" export const renderEntrypointCodexHome = (config: TemplateConfig): string => diff --git a/packages/lib/src/usecases/projects-apply-all.ts b/packages/lib/src/usecases/projects-apply-all.ts index d62d2e0..cf28539 100644 --- a/packages/lib/src/usecases/projects-apply-all.ts +++ b/packages/lib/src/usecases/projects-apply-all.ts @@ -4,34 +4,42 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { Path } from "@effect/platform/Path" import { Effect, pipe } from "effect" -import { ensureDockerDaemonAccess } from "../shell/docker.js" -import type { DockerAccessError, DockerCommandError } from "../shell/errors.js" +import type { ApplyAllCommand } from "../core/domain.js" +import { ensureDockerDaemonAccess, runDockerPsNames } from "../shell/docker.js" +import type { CommandFailedError, DockerAccessError, DockerCommandError } from "../shell/errors.js" import { renderError } from "./errors.js" -import { forEachProjectStatus, loadProjectIndex, renderProjectStatusHeader } from "./projects-core.js" +import { + forEachProjectStatus, + loadProjectIndex, + type ProjectIndex, + renderProjectStatusHeader +} from "./projects-core.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" -// CHANGE: provide an "apply all" helper for docker-git managed projects -// WHY: allow applying updated docker-git config to every known project in one command +// CHANGE: provide an "apply all" helper for docker-git managed projects; support --active flag to filter by running containers +// WHY: allow applying updated docker-git config to every known project in one command; --active restricts to currently running containers only // QUOTE(ТЗ): "Сделать команду которая сама на все контейнеры применит новые настройки" -// REF: issue-164 +// QUOTE(ТЗ): "сделать это возможным через атрибут --active применять только к активным контейнерам, а не ко всем" +// REF: issue-164, issue-185 // SOURCE: n/a -// FORMAT THEOREM: ∀p ∈ Projects: applyAll(p) → updated(p) ∨ warned(p) +// FORMAT THEOREM: ∀p ∈ Projects: applyAll(p) → updated(p) ∨ warned(p); activeOnly=true → ∀p ∈ result: running(container(p)) // PURITY: SHELL -// EFFECT: Effect -// INVARIANT: continues applying to other projects when one docker compose up fails with DockerCommandError +// EFFECT: Effect +// INVARIANT: continues applying to other projects when one docker compose up fails with DockerCommandError; when activeOnly=true skips non-running containers // COMPLEXITY: O(n) where n = |projects| -export const applyAllDockerGitProjects: Effect.Effect< - void, - PlatformError | DockerAccessError, - FileSystem | Path | CommandExecutor -> = pipe( - ensureDockerDaemonAccess(process.cwd()), - Effect.zipRight(loadProjectIndex()), - Effect.flatMap((index) => - index === null - ? Effect.void - : forEachProjectStatus(index.configPaths, (status) => - pipe( + +type RunningNames = ReadonlyArray | null + +const applyToProjects = ( + index: ProjectIndex, + runningNames: RunningNames +) => + forEachProjectStatus( + index.configPaths, + (status) => + runningNames !== null && !runningNames.includes(status.config.template.containerName) + ? Effect.log(`Skipping ${status.projectDir}: container is not running`) + : pipe( Effect.log(renderProjectStatusHeader(status)), Effect.zipRight( runDockerComposeUpWithPortCheck(status.projectDir).pipe( @@ -60,7 +68,30 @@ export const applyAllDockerGitProjects: Effect.Effect< Effect.asVoid ) ) - )) - ), - Effect.asVoid -) + ) + ) + +export const applyAllDockerGitProjects = ( + command: ApplyAllCommand +): Effect.Effect< + void, + PlatformError | DockerAccessError | CommandFailedError, + FileSystem | Path | CommandExecutor +> => + pipe( + ensureDockerDaemonAccess(process.cwd()), + Effect.zipRight(loadProjectIndex()), + Effect.flatMap((index) => { + if (index === null) { + return Effect.void + } + if (!command.activeOnly) { + return applyToProjects(index, null) + } + return pipe( + runDockerPsNames(process.cwd()), + Effect.flatMap((runningNames) => applyToProjects(index, runningNames)) + ) + }), + Effect.asVoid + ) diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 420141f..aa57a2f 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -277,7 +277,7 @@ export const formatComposeRows = (entries: ReadonlyArray): string return [header, ...lines].join("\n") } -type ProjectIndex = { +export type ProjectIndex = { readonly projectsRoot: string readonly configPaths: ReadonlyArray }