Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/app/src/docker-git/cli/parser-spawn.ts
Original file line number Diff line number Diff line change
@@ -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<string>): Either.Either<Command, ParseError> =>
Either.flatMap(parseRawOptions(args), (raw): Either.Either<Command, ParseError> => {
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)
})
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -97,6 +98,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
Match.when("state", () => parseState(rest)),
Match.when("session-gists", () => parseSessionGists(rest)),
Match.when("gists", () => parseSessionGists(rest)),
Match.when("spawn", () => parseSpawn(rest)),
Match.orElse(() => Either.left(unknownCommandError))
)
}
6 changes: 6 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pairing-token> [options]
docker-git create [--repo-url <url>] [options]
docker-git clone <url> [options]
docker-git open [<url>] [options]
Expand All @@ -26,6 +27,7 @@ docker-git state <action> [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
Expand All @@ -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 <token> Pairing token from SpawnDock Telegram bot (required for spawn)
--out-dir <dir> Output directory for spawn workspace (default: .spawn-dock/spawndock)

Options:
--repo-url <url> Repository URL (create: optional; clone: required via positional arg or flag)
--repo-ref <ref> Git ref/branch (default: main)
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -87,6 +88,7 @@ type NonBaseCommand = Exclude<
| { readonly _tag: "DownAll" }
| { readonly _tag: "ApplyAll" }
| { readonly _tag: "Menu" }
| { readonly _tag: "Spawn" }
>

const handleNonBaseCommand = (command: NonBaseCommand) =>
Expand Down Expand Up @@ -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))
)
),
Expand All @@ -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),
Expand Down
178 changes: 178 additions & 0 deletions packages/app/src/docker-git/spawn.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
const host = ipAddress ?? "localhost"
const port = ipAddress ? 22 : template.sshPort
const args: Array<string> = []
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<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => {
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<void, SpawnProjectDirError | SpawnSetupError | ..., CommandExecutor | FileSystem | Path>
// 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))
})
71 changes: 49 additions & 22 deletions packages/app/src/docker-git/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +240,53 @@ export const listTmuxPanes = (
}
})

const openTmuxWorkspace = (
template: TemplateConfig,
leftPaneCommand: string
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
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<void, CommandFailedError | PlatformError, CommandExecutor>
// 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<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
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"
Expand Down Expand Up @@ -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))
})
3 changes: 2 additions & 1 deletion packages/lib/src/core/domain.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/session-gist-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ export type SessionGistCommand =
| SessionGistListCommand
| SessionGistViewCommand
| SessionGistDownloadCommand

export type { SpawnCommand } from "./spawn-domain.js"
Loading