diff --git a/packages/app/src/docker-git/cli/parser-spawn.ts b/packages/app/src/docker-git/cli/parser-spawn.ts new file mode 100644 index 00000000..7179dbfa --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-spawn.ts @@ -0,0 +1,29 @@ +import { Either } from "effect" + +import type { Command, ParseError } from "@effect-template/lib/core/domain" +import type { SpawnCommand } from "@effect-template/lib/core/spawn-domain" + +import { parseRawOptions } from "./parser-options.js" + +// CHANGE: parse spawn command from CLI args into a typed SpawnCommand +// WHY: validate --token presence before any effects run +// REF: spawn-command +// FORMAT THEOREM: forall argv: parseSpawn(argv) = cmd -> deterministic(cmd) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns MissingRequiredOption when --token is absent or blank +// COMPLEXITY: O(n) where n = |args| +export const parseSpawn = (args: ReadonlyArray): Either.Either => + Either.flatMap(parseRawOptions(args), (raw): Either.Either => { + const token = raw.token + if (!token || token.trim().length === 0) { + const missingToken: ParseError = { _tag: "MissingRequiredOption", option: "--token" } + return Either.left(missingToken) + } + const spawnCmd: SpawnCommand = { + _tag: "Spawn", + token: token.trim(), + outDir: raw.outDir ?? ".spawn-dock/spawndock" + } + return Either.right(spawnCmd) + }) diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index f20c63c2..d2f9b649 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -13,6 +13,7 @@ import { parsePanes } from "./parser-panes.js" import { parseScrap } from "./parser-scrap.js" import { parseSessionGists } from "./parser-session-gists.js" import { parseSessions } from "./parser-sessions.js" +import { parseSpawn } from "./parser-spawn.js" import { parseState } from "./parser-state.js" import { usageText } from "./usage.js" @@ -97,6 +98,7 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parseState(rest)), Match.when("session-gists", () => parseSessionGists(rest)), Match.when("gists", () => parseSessionGists(rest)), + Match.when("spawn", () => parseSpawn(rest)), Match.orElse(() => Either.left(unknownCommandError)) ) } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 90a43638..755ccb41 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -3,6 +3,7 @@ import { Match } from "effect" import type { ParseError } from "@effect-template/lib/core/domain" export const usageText = `docker-git menu +docker-git spawn --token [options] docker-git create [--repo-url ] [options] docker-git clone [options] docker-git open [] [options] @@ -26,6 +27,7 @@ docker-git state [options] Commands: menu Interactive menu (default when no args) + spawn Create a SpawnDock workspace container, bootstrap with @spawn-dock/create, and open opencode — all in one command create, init Generate docker development environment (repo URL optional) clone Create + run container and clone repo open Open existing docker-git project workspace @@ -42,6 +44,10 @@ Commands: auth Manage GitHub/Codex/Claude Code auth for docker-git state Manage docker-git state directory via git (sync across machines) +Spawn options: + --token Pairing token from SpawnDock Telegram bot (required for spawn) + --out-dir Output directory for spawn workspace (default: .spawn-dock/spawndock) + Options: --repo-url Repository URL (create: optional; clone: required via positional arg or flag) --repo-ref Git ref/branch (default: main) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 34124dc4..77243cfe 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -48,6 +48,7 @@ import { } from "@effect-template/lib/usecases/terminal-sessions" import { Effect, Match, pipe } from "effect" import { readCommand } from "./cli/read-command.js" +import { spawnProject } from "./spawn.js" import { attachTmux, listTmuxPanes } from "./tmux.js" import { runMenu } from "./menu.js" @@ -87,6 +88,7 @@ type NonBaseCommand = Exclude< | { readonly _tag: "DownAll" } | { readonly _tag: "ApplyAll" } | { readonly _tag: "Menu" } + | { readonly _tag: "Spawn" } > const handleNonBaseCommand = (command: NonBaseCommand) => @@ -150,6 +152,7 @@ export const program = pipe( Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), Match.when({ _tag: "ApplyAll" }, (cmd) => applyAllDockerGitProjects(cmd)), Match.when({ _tag: "Menu" }, () => runMenu), + Match.when({ _tag: "Spawn" }, (cmd) => spawnProject(cmd)), Match.orElse((cmd) => handleNonBaseCommand(cmd)) ) ), @@ -163,6 +166,8 @@ export const program = pipe( Effect.catchTag("AuthError", logWarningAndExit), Effect.catchTag("AgentFailedError", logWarningAndExit), Effect.catchTag("CommandFailedError", logWarningAndExit), + Effect.catchTag("SpawnProjectDirError", logWarningAndExit), + Effect.catchTag("SpawnSetupError", logWarningAndExit), Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit), Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit), Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit), diff --git a/packages/app/src/docker-git/spawn.ts b/packages/app/src/docker-git/spawn.ts new file mode 100644 index 00000000..ef851d23 --- /dev/null +++ b/packages/app/src/docker-git/spawn.ts @@ -0,0 +1,178 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Duration, Effect, pipe, Schedule } from "effect" + +import { + type CreateCommand, + defaultTemplateConfig, + deriveRepoSlug, + type TemplateConfig +} from "@effect-template/lib/core/domain" +import type { SpawnCommand } from "@effect-template/lib/core/spawn-domain" +import { runCommandCapture, runCommandExitCode } from "@effect-template/lib/shell/command-runner" +import { readProjectConfig } from "@effect-template/lib/shell/config" +import { CommandFailedError, SpawnProjectDirError, SpawnSetupError } from "@effect-template/lib/shell/errors" +import { createProject } from "@effect-template/lib/usecases/actions" +import { findSshPrivateKey } from "@effect-template/lib/usecases/path-helpers" +import { getContainerIpIfInsideContainer } from "@effect-template/lib/usecases/projects-core" + +import { spawnAttachTmux } from "./tmux.js" + +const SPAWNDOCK_REPO_URL = "https://github.com/SpawnDock/tma-project" +const SPAWNDOCK_REPO_REF = "main" + +// remoteCommand = undefined → probe mode (ssh -T BatchMode + "true"), string → execute mode +const buildSshArgs = ( + template: TemplateConfig, + sshKey: string | null, + ipAddress: string | undefined, + remoteCommand?: string +): ReadonlyArray => { + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : template.sshPort + const args: Array = [] + if (sshKey !== null) { + args.push("-i", sshKey) + } + if (remoteCommand === undefined) { + args.push("-T", "-o", "ConnectTimeout=2", "-o", "ConnectionAttempts=1") + } + args.push( + "-o", + "BatchMode=yes", + "-o", + "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(port), + `${template.sshUser}@${host}`, + remoteCommand ?? "true" + ) + return args +} + +const waitForSshReady = ( + template: TemplateConfig, + sshKey: string | null, + ipAddress?: string +): Effect.Effect => { + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : template.sshPort + const probe = Effect.gen(function*(_) { + const exitCode = yield* _( + runCommandExitCode({ + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(template, sshKey, ipAddress) + }) + ) + if (exitCode !== 0) { + return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode }))) + } + }) + + return pipe( + Effect.log(`Waiting for SSH on ${host}:${port} ...`), + Effect.zipRight( + Effect.retry( + probe, + pipe( + Schedule.spaced(Duration.seconds(2)), + Schedule.intersect(Schedule.recurs(30)) + ) + ) + ), + Effect.tap(() => Effect.log("SSH is ready.")) + ) +} + +const parseProjectDir = (output: string): string | null => { + const match = /SpawnDock project created at (.+)/.exec(output) + return match?.[1]?.trim() ?? null +} + +const buildSpawnCreateCommand = (outDir: string): CreateCommand => { + const repoSlug = deriveRepoSlug(SPAWNDOCK_REPO_URL) + const containerName = `dg-${repoSlug}` + const serviceName = `dg-${repoSlug}` + const volumeName = `dg-${repoSlug}-home` + + return { + _tag: "Create", + config: { + ...defaultTemplateConfig, + repoUrl: SPAWNDOCK_REPO_URL, + repoRef: SPAWNDOCK_REPO_REF, + containerName, + serviceName, + volumeName + }, + outDir, + runUp: true, + force: false, + forceEnv: false, + waitForClone: true, + openSsh: false + } +} + +// CHANGE: orchestrate spawn-dock spawn — creates container, runs @spawn-dock/create, opens tmux+opencode +// WHY: provide one-command bootstrap from a Telegram bot pairing token +// REF: spawn-command +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: container is started before SSH connection; tmux session opens after successful bootstrap +// COMPLEXITY: O(1) + docker + ssh +export const spawnProject = (command: SpawnCommand) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + yield* _(Effect.log("Creating SpawnDock container...")) + const syntheticCreate = buildSpawnCreateCommand(command.outDir) + yield* _(createProject(syntheticCreate)) + + const resolvedOutDir = path.resolve(command.outDir) + const projectConfig = yield* _(readProjectConfig(resolvedOutDir)) + const template = projectConfig.template + + const containerIpRaw = yield* _( + getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe( + Effect.map((ip) => ip ?? ""), + Effect.orElse(() => Effect.succeed("")) + ) + ) + const ipAddress: string | undefined = containerIpRaw.length > 0 ? containerIpRaw : undefined + + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + + yield* _(waitForSshReady(template, sshKey, ipAddress)) + + const createCmd = `npx -y @spawn-dock/create@beta --token ${command.token}` + yield* _(Effect.log("Running @spawn-dock/create inside container...")) + + const output = yield* _( + runCommandCapture( + { + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(template, sshKey, ipAddress, createCmd) + }, + [0], + (exitCode) => new SpawnSetupError({ exitCode }) + ) + ) + + const projectDir = parseProjectDir(output) + if (projectDir === null) { + return yield* _(Effect.fail(new SpawnProjectDirError({ output }))) + } + + yield* _(Effect.log(`Project bootstrapped at ${projectDir}`)) + yield* _(spawnAttachTmux(template, projectDir, sshKey)) + }) diff --git a/packages/app/src/docker-git/tmux.ts b/packages/app/src/docker-git/tmux.ts index a2434fab..0a79dd6d 100644 --- a/packages/app/src/docker-git/tmux.ts +++ b/packages/app/src/docker-git/tmux.ts @@ -4,7 +4,7 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect, pipe } from "effect" -import type { AttachCommand, PanesCommand } from "@effect-template/lib/core/domain" +import type { AttachCommand, PanesCommand, TemplateConfig } from "@effect-template/lib/core/domain" import { deriveRepoPathParts, deriveRepoSlug } from "@effect-template/lib/core/domain" import { runCommandCapture, @@ -240,6 +240,53 @@ export const listTmuxPanes = ( } }) +const openTmuxWorkspace = ( + template: TemplateConfig, + leftPaneCommand: string +): Effect.Effect => + Effect.gen(function*(_) { + const repoDisplayName = formatRepoDisplayName(template.repoUrl) + const refLabel = formatRepoRefLabel(template.repoRef) + const statusRight = + `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running` + const session = `dg-${deriveRepoSlug(template.repoUrl)}` + const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session])) + + if (hasSessionCode === 0) { + const existingLayout = yield* _(readLayoutVersion(session)) + if (existingLayout === layoutVersion) { + yield* _(runTmux(["attach", "-t", session])) + return + } + yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`)) + yield* _(runTmux(["kill-session", "-t", session])) + } + + yield* _(createLayout(session)) + yield* _(configureSession(session, repoDisplayName, statusRight)) + yield* _(setupPanes(session, leftPaneCommand, template.containerName)) + yield* _(runTmux(["attach", "-t", session])) + }) + +// CHANGE: attach a tmux workspace after spawn-dock spawn bootstraps the container +// WHY: open opencode agent inside the spawned container in one step +// REF: spawn-command +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: SSH pane is pre-loaded with cd + spawn-dock agent; delegates session lifecycle to openTmuxWorkspace +// COMPLEXITY: O(1) +export const spawnAttachTmux = ( + template: TemplateConfig, + projectDir: string, + sshKey: string | null +): Effect.Effect => + Effect.gen(function*(_) { + const keyArgs = sshKey === null ? "" : `-i ${sshKey} ` + const agentSshCommand = + `ssh -tt ${keyArgs}-o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${template.sshPort} ${template.sshUser}@localhost "cd '${projectDir}' && spawn-dock agent"` + yield* _(openTmuxWorkspace(template, agentSshCommand)) + }) + // CHANGE: attach a tmux workspace for a docker-git project // WHY: provide multi-pane terminal layout for sandbox work // QUOTE(ТЗ): "окей Давай подключим tmux" @@ -268,25 +315,5 @@ export const attachTmux = ( const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) const template = yield* _(runDockerComposeUpWithPortCheck(resolved)) const sshCommand = buildSshCommand(template, sshKey) - const repoDisplayName = formatRepoDisplayName(template.repoUrl) - const refLabel = formatRepoRefLabel(template.repoRef) - const statusRight = - `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running` - const session = `dg-${deriveRepoSlug(template.repoUrl)}` - const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session])) - - if (hasSessionCode === 0) { - const existingLayout = yield* _(readLayoutVersion(session)) - if (existingLayout === layoutVersion) { - yield* _(runTmux(["attach", "-t", session])) - return - } - yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`)) - yield* _(runTmux(["kill-session", "-t", session])) - } - - yield* _(createLayout(session)) - yield* _(configureSession(session, repoDisplayName, statusRight)) - yield* _(setupPanes(session, sshCommand, template.containerName)) - yield* _(runTmux(["attach", "-t", session])) + yield* _(openTmuxWorkspace(template, sshCommand)) }) diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 15abf7ce..2614dc27 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -1,4 +1,4 @@ -import type { SessionGistCommand } from "./session-gist-domain.js" +import type { SessionGistCommand, SpawnCommand } from "./session-gist-domain.js" export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" @@ -343,6 +343,7 @@ export type Command = | DownAllCommand | StateCommand | AuthCommand + | SpawnCommand // CHANGE: validate docker network mode values at the CLI/config boundary // WHY: keep compose network behavior explicit and type-safe diff --git a/packages/lib/src/core/session-gist-domain.ts b/packages/lib/src/core/session-gist-domain.ts index 3cb6bfc7..2885401d 100644 --- a/packages/lib/src/core/session-gist-domain.ts +++ b/packages/lib/src/core/session-gist-domain.ts @@ -34,3 +34,5 @@ export type SessionGistCommand = | SessionGistListCommand | SessionGistViewCommand | SessionGistDownloadCommand + +export type { SpawnCommand } from "./spawn-domain.js" diff --git a/packages/lib/src/core/spawn-domain.ts b/packages/lib/src/core/spawn-domain.ts new file mode 100644 index 00000000..eb335c8a --- /dev/null +++ b/packages/lib/src/core/spawn-domain.ts @@ -0,0 +1,10 @@ +// CHANGE: spawn command for one-shot container + bootstrap + agent open +// WHY: keep domain.ts under max-lines; mirrors session-gist-domain split +// REF: spawn-command +// PURITY: CORE + +export interface SpawnCommand { + readonly _tag: "Spawn" + readonly token: string + readonly outDir: string +} diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 38a4b501..b4e77456 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -77,3 +77,11 @@ export class ScrapWipeRefusedError extends Data.TaggedError("ScrapWipeRefusedErr readonly targetDir: string readonly reason: string }> {} + +export class SpawnProjectDirError extends Data.TaggedError("SpawnProjectDirError")<{ + readonly output: string +}> {} + +export class SpawnSetupError extends Data.TaggedError("SpawnSetupError")<{ + readonly exitCode: number +}> {} diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 74c2196e..569f5510 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -18,7 +18,9 @@ import type { ScrapArchiveInvalidError, ScrapArchiveNotFoundError, ScrapTargetDirUnsupportedError, - ScrapWipeRefusedError + ScrapWipeRefusedError, + SpawnProjectDirError, + SpawnSetupError } from "../shell/errors.js" export type AppError = @@ -39,6 +41,8 @@ export type AppError = | PortProbeError | AuthError | CommandFailedError + | SpawnProjectDirError + | SpawnSetupError | PlatformError type NonParseError = Exclude @@ -129,6 +133,19 @@ const renderPrimaryError = (error: NonParseError): string | null => "Hint: re-run with --no-wipe, or set a narrower --target-dir when creating the project." ].join("\n")), Match.when({ _tag: "AuthError" }, ({ message }) => message), + Match.when( + { _tag: "SpawnProjectDirError" }, + ({ output }) => + [ + "Failed to parse project directory from @spawn-dock/create output.", + `Hint: expected a line matching "SpawnDock project created at " in the output.`, + `Output:\n${output}` + ].join("\n") + ), + Match.when( + { _tag: "SpawnSetupError" }, + ({ exitCode }) => `@spawn-dock/create failed with exit code ${exitCode}` + ), Match.orElse(() => null) )