Skip to content

Commit 8e6aaed

Browse files
authored
Merge pull request #3 from SpawnDock/feat/spawn-command
feat: add spawn command — one-command SpawnDock container bootstrap
2 parents 6c7e426 + 7084717 commit 8e6aaed

11 files changed

Lines changed: 309 additions & 24 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Either } from "effect"
2+
3+
import type { Command, ParseError } from "@effect-template/lib/core/domain"
4+
import type { SpawnCommand } from "@effect-template/lib/core/spawn-domain"
5+
6+
import { parseRawOptions } from "./parser-options.js"
7+
8+
// CHANGE: parse spawn command from CLI args into a typed SpawnCommand
9+
// WHY: validate --token presence before any effects run
10+
// REF: spawn-command
11+
// FORMAT THEOREM: forall argv: parseSpawn(argv) = cmd -> deterministic(cmd)
12+
// PURITY: CORE
13+
// EFFECT: n/a
14+
// INVARIANT: returns MissingRequiredOption when --token is absent or blank
15+
// COMPLEXITY: O(n) where n = |args|
16+
export const parseSpawn = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> =>
17+
Either.flatMap(parseRawOptions(args), (raw): Either.Either<Command, ParseError> => {
18+
const token = raw.token
19+
if (!token || token.trim().length === 0) {
20+
const missingToken: ParseError = { _tag: "MissingRequiredOption", option: "--token" }
21+
return Either.left(missingToken)
22+
}
23+
const spawnCmd: SpawnCommand = {
24+
_tag: "Spawn",
25+
token: token.trim(),
26+
outDir: raw.outDir ?? ".spawn-dock/spawndock"
27+
}
28+
return Either.right(spawnCmd)
29+
})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { parsePanes } from "./parser-panes.js"
1313
import { parseScrap } from "./parser-scrap.js"
1414
import { parseSessionGists } from "./parser-session-gists.js"
1515
import { parseSessions } from "./parser-sessions.js"
16+
import { parseSpawn } from "./parser-spawn.js"
1617
import { parseState } from "./parser-state.js"
1718
import { usageText } from "./usage.js"
1819

@@ -97,6 +98,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
9798
Match.when("state", () => parseState(rest)),
9899
Match.when("session-gists", () => parseSessionGists(rest)),
99100
Match.when("gists", () => parseSessionGists(rest)),
101+
Match.when("spawn", () => parseSpawn(rest)),
100102
Match.orElse(() => Either.left(unknownCommandError))
101103
)
102104
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Match } from "effect"
33
import type { ParseError } from "@effect-template/lib/core/domain"
44

55
export const usageText = `docker-git menu
6+
docker-git spawn --token <pairing-token> [options]
67
docker-git create [--repo-url <url>] [options]
78
docker-git clone <url> [options]
89
docker-git open [<url>] [options]
@@ -26,6 +27,7 @@ docker-git state <action> [options]
2627
2728
Commands:
2829
menu Interactive menu (default when no args)
30+
spawn Create a SpawnDock workspace container, bootstrap with @spawn-dock/create, and open opencode — all in one command
2931
create, init Generate docker development environment (repo URL optional)
3032
clone Create + run container and clone repo
3133
open Open existing docker-git project workspace
@@ -42,6 +44,10 @@ Commands:
4244
auth Manage GitHub/Codex/Claude Code auth for docker-git
4345
state Manage docker-git state directory via git (sync across machines)
4446
47+
Spawn options:
48+
--token <token> Pairing token from SpawnDock Telegram bot (required for spawn)
49+
--out-dir <dir> Output directory for spawn workspace (default: .spawn-dock/spawndock)
50+
4551
Options:
4652
--repo-url <url> Repository URL (create: optional; clone: required via positional arg or flag)
4753
--repo-ref <ref> Git ref/branch (default: main)

packages/app/src/docker-git/program.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
} from "@effect-template/lib/usecases/terminal-sessions"
4949
import { Effect, Match, pipe } from "effect"
5050
import { readCommand } from "./cli/read-command.js"
51+
import { spawnProject } from "./spawn.js"
5152
import { attachTmux, listTmuxPanes } from "./tmux.js"
5253

5354
import { runMenu } from "./menu.js"
@@ -87,6 +88,7 @@ type NonBaseCommand = Exclude<
8788
| { readonly _tag: "DownAll" }
8889
| { readonly _tag: "ApplyAll" }
8990
| { readonly _tag: "Menu" }
91+
| { readonly _tag: "Spawn" }
9092
>
9193

9294
const handleNonBaseCommand = (command: NonBaseCommand) =>
@@ -150,6 +152,7 @@ export const program = pipe(
150152
Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects),
151153
Match.when({ _tag: "ApplyAll" }, (cmd) => applyAllDockerGitProjects(cmd)),
152154
Match.when({ _tag: "Menu" }, () => runMenu),
155+
Match.when({ _tag: "Spawn" }, (cmd) => spawnProject(cmd)),
153156
Match.orElse((cmd) => handleNonBaseCommand(cmd))
154157
)
155158
),
@@ -163,6 +166,8 @@ export const program = pipe(
163166
Effect.catchTag("AuthError", logWarningAndExit),
164167
Effect.catchTag("AgentFailedError", logWarningAndExit),
165168
Effect.catchTag("CommandFailedError", logWarningAndExit),
169+
Effect.catchTag("SpawnProjectDirError", logWarningAndExit),
170+
Effect.catchTag("SpawnSetupError", logWarningAndExit),
166171
Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
167172
Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),
168173
Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit),
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { Duration, Effect, pipe, Schedule } from "effect"
6+
7+
import {
8+
type CreateCommand,
9+
defaultTemplateConfig,
10+
deriveRepoSlug,
11+
type TemplateConfig
12+
} from "@effect-template/lib/core/domain"
13+
import type { SpawnCommand } from "@effect-template/lib/core/spawn-domain"
14+
import { runCommandCapture, runCommandExitCode } from "@effect-template/lib/shell/command-runner"
15+
import { readProjectConfig } from "@effect-template/lib/shell/config"
16+
import { CommandFailedError, SpawnProjectDirError, SpawnSetupError } from "@effect-template/lib/shell/errors"
17+
import { createProject } from "@effect-template/lib/usecases/actions"
18+
import { findSshPrivateKey } from "@effect-template/lib/usecases/path-helpers"
19+
import { getContainerIpIfInsideContainer } from "@effect-template/lib/usecases/projects-core"
20+
21+
import { spawnAttachTmux } from "./tmux.js"
22+
23+
const SPAWNDOCK_REPO_URL = "https://github.com/SpawnDock/tma-project"
24+
const SPAWNDOCK_REPO_REF = "main"
25+
26+
// remoteCommand = undefined → probe mode (ssh -T BatchMode + "true"), string → execute mode
27+
const buildSshArgs = (
28+
template: TemplateConfig,
29+
sshKey: string | null,
30+
ipAddress: string | undefined,
31+
remoteCommand?: string
32+
): ReadonlyArray<string> => {
33+
const host = ipAddress ?? "localhost"
34+
const port = ipAddress ? 22 : template.sshPort
35+
const args: Array<string> = []
36+
if (sshKey !== null) {
37+
args.push("-i", sshKey)
38+
}
39+
if (remoteCommand === undefined) {
40+
args.push("-T", "-o", "ConnectTimeout=2", "-o", "ConnectionAttempts=1")
41+
}
42+
args.push(
43+
"-o",
44+
"BatchMode=yes",
45+
"-o",
46+
"LogLevel=ERROR",
47+
"-o",
48+
"StrictHostKeyChecking=no",
49+
"-o",
50+
"UserKnownHostsFile=/dev/null",
51+
"-p",
52+
String(port),
53+
`${template.sshUser}@${host}`,
54+
remoteCommand ?? "true"
55+
)
56+
return args
57+
}
58+
59+
const waitForSshReady = (
60+
template: TemplateConfig,
61+
sshKey: string | null,
62+
ipAddress?: string
63+
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => {
64+
const host = ipAddress ?? "localhost"
65+
const port = ipAddress ? 22 : template.sshPort
66+
const probe = Effect.gen(function*(_) {
67+
const exitCode = yield* _(
68+
runCommandExitCode({
69+
cwd: process.cwd(),
70+
command: "ssh",
71+
args: buildSshArgs(template, sshKey, ipAddress)
72+
})
73+
)
74+
if (exitCode !== 0) {
75+
return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode })))
76+
}
77+
})
78+
79+
return pipe(
80+
Effect.log(`Waiting for SSH on ${host}:${port} ...`),
81+
Effect.zipRight(
82+
Effect.retry(
83+
probe,
84+
pipe(
85+
Schedule.spaced(Duration.seconds(2)),
86+
Schedule.intersect(Schedule.recurs(30))
87+
)
88+
)
89+
),
90+
Effect.tap(() => Effect.log("SSH is ready."))
91+
)
92+
}
93+
94+
const parseProjectDir = (output: string): string | null => {
95+
const match = /SpawnDock project created at (.+)/.exec(output)
96+
return match?.[1]?.trim() ?? null
97+
}
98+
99+
const buildSpawnCreateCommand = (outDir: string): CreateCommand => {
100+
const repoSlug = deriveRepoSlug(SPAWNDOCK_REPO_URL)
101+
const containerName = `dg-${repoSlug}`
102+
const serviceName = `dg-${repoSlug}`
103+
const volumeName = `dg-${repoSlug}-home`
104+
105+
return {
106+
_tag: "Create",
107+
config: {
108+
...defaultTemplateConfig,
109+
repoUrl: SPAWNDOCK_REPO_URL,
110+
repoRef: SPAWNDOCK_REPO_REF,
111+
containerName,
112+
serviceName,
113+
volumeName
114+
},
115+
outDir,
116+
runUp: true,
117+
force: false,
118+
forceEnv: false,
119+
waitForClone: true,
120+
openSsh: false
121+
}
122+
}
123+
124+
// CHANGE: orchestrate spawn-dock spawn — creates container, runs @spawn-dock/create, opens tmux+opencode
125+
// WHY: provide one-command bootstrap from a Telegram bot pairing token
126+
// REF: spawn-command
127+
// PURITY: SHELL
128+
// EFFECT: Effect<void, SpawnProjectDirError | SpawnSetupError | ..., CommandExecutor | FileSystem | Path>
129+
// INVARIANT: container is started before SSH connection; tmux session opens after successful bootstrap
130+
// COMPLEXITY: O(1) + docker + ssh
131+
export const spawnProject = (command: SpawnCommand) =>
132+
Effect.gen(function*(_) {
133+
const fs = yield* _(FileSystem.FileSystem)
134+
const path = yield* _(Path.Path)
135+
136+
yield* _(Effect.log("Creating SpawnDock container..."))
137+
const syntheticCreate = buildSpawnCreateCommand(command.outDir)
138+
yield* _(createProject(syntheticCreate))
139+
140+
const resolvedOutDir = path.resolve(command.outDir)
141+
const projectConfig = yield* _(readProjectConfig(resolvedOutDir))
142+
const template = projectConfig.template
143+
144+
const containerIpRaw = yield* _(
145+
getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe(
146+
Effect.map((ip) => ip ?? ""),
147+
Effect.orElse(() => Effect.succeed(""))
148+
)
149+
)
150+
const ipAddress: string | undefined = containerIpRaw.length > 0 ? containerIpRaw : undefined
151+
152+
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
153+
154+
yield* _(waitForSshReady(template, sshKey, ipAddress))
155+
156+
const createCmd = `npx -y @spawn-dock/create@beta --token ${command.token}`
157+
yield* _(Effect.log("Running @spawn-dock/create inside container..."))
158+
159+
const output = yield* _(
160+
runCommandCapture(
161+
{
162+
cwd: process.cwd(),
163+
command: "ssh",
164+
args: buildSshArgs(template, sshKey, ipAddress, createCmd)
165+
},
166+
[0],
167+
(exitCode) => new SpawnSetupError({ exitCode })
168+
)
169+
)
170+
171+
const projectDir = parseProjectDir(output)
172+
if (projectDir === null) {
173+
return yield* _(Effect.fail(new SpawnProjectDirError({ output })))
174+
}
175+
176+
yield* _(Effect.log(`Project bootstrapped at ${projectDir}`))
177+
yield* _(spawnAttachTmux(template, projectDir, sshKey))
178+
})

packages/app/src/docker-git/tmux.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type * as FileSystem from "@effect/platform/FileSystem"
44
import type * as Path from "@effect/platform/Path"
55
import { Effect, pipe } from "effect"
66

7-
import type { AttachCommand, PanesCommand } from "@effect-template/lib/core/domain"
7+
import type { AttachCommand, PanesCommand, TemplateConfig } from "@effect-template/lib/core/domain"
88
import { deriveRepoPathParts, deriveRepoSlug } from "@effect-template/lib/core/domain"
99
import {
1010
runCommandCapture,
@@ -240,6 +240,53 @@ export const listTmuxPanes = (
240240
}
241241
})
242242

243+
const openTmuxWorkspace = (
244+
template: TemplateConfig,
245+
leftPaneCommand: string
246+
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
247+
Effect.gen(function*(_) {
248+
const repoDisplayName = formatRepoDisplayName(template.repoUrl)
249+
const refLabel = formatRepoRefLabel(template.repoRef)
250+
const statusRight =
251+
`SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`
252+
const session = `dg-${deriveRepoSlug(template.repoUrl)}`
253+
const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]))
254+
255+
if (hasSessionCode === 0) {
256+
const existingLayout = yield* _(readLayoutVersion(session))
257+
if (existingLayout === layoutVersion) {
258+
yield* _(runTmux(["attach", "-t", session]))
259+
return
260+
}
261+
yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`))
262+
yield* _(runTmux(["kill-session", "-t", session]))
263+
}
264+
265+
yield* _(createLayout(session))
266+
yield* _(configureSession(session, repoDisplayName, statusRight))
267+
yield* _(setupPanes(session, leftPaneCommand, template.containerName))
268+
yield* _(runTmux(["attach", "-t", session]))
269+
})
270+
271+
// CHANGE: attach a tmux workspace after spawn-dock spawn bootstraps the container
272+
// WHY: open opencode agent inside the spawned container in one step
273+
// REF: spawn-command
274+
// PURITY: SHELL
275+
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor>
276+
// INVARIANT: SSH pane is pre-loaded with cd + spawn-dock agent; delegates session lifecycle to openTmuxWorkspace
277+
// COMPLEXITY: O(1)
278+
export const spawnAttachTmux = (
279+
template: TemplateConfig,
280+
projectDir: string,
281+
sshKey: string | null
282+
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
283+
Effect.gen(function*(_) {
284+
const keyArgs = sshKey === null ? "" : `-i ${sshKey} `
285+
const agentSshCommand =
286+
`ssh -tt ${keyArgs}-o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${template.sshPort} ${template.sshUser}@localhost "cd '${projectDir}' && spawn-dock agent"`
287+
yield* _(openTmuxWorkspace(template, agentSshCommand))
288+
})
289+
243290
// CHANGE: attach a tmux workspace for a docker-git project
244291
// WHY: provide multi-pane terminal layout for sandbox work
245292
// QUOTE(ТЗ): "окей Давай подключим tmux"
@@ -268,25 +315,5 @@ export const attachTmux = (
268315
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
269316
const template = yield* _(runDockerComposeUpWithPortCheck(resolved))
270317
const sshCommand = buildSshCommand(template, sshKey)
271-
const repoDisplayName = formatRepoDisplayName(template.repoUrl)
272-
const refLabel = formatRepoRefLabel(template.repoRef)
273-
const statusRight =
274-
`SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`
275-
const session = `dg-${deriveRepoSlug(template.repoUrl)}`
276-
const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]))
277-
278-
if (hasSessionCode === 0) {
279-
const existingLayout = yield* _(readLayoutVersion(session))
280-
if (existingLayout === layoutVersion) {
281-
yield* _(runTmux(["attach", "-t", session]))
282-
return
283-
}
284-
yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`))
285-
yield* _(runTmux(["kill-session", "-t", session]))
286-
}
287-
288-
yield* _(createLayout(session))
289-
yield* _(configureSession(session, repoDisplayName, statusRight))
290-
yield* _(setupPanes(session, sshCommand, template.containerName))
291-
yield* _(runTmux(["attach", "-t", session]))
318+
yield* _(openTmuxWorkspace(template, sshCommand))
292319
})

packages/lib/src/core/domain.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SessionGistCommand } from "./session-gist-domain.js"
1+
import type { SessionGistCommand, SpawnCommand } from "./session-gist-domain.js"
22

33
export type { MenuAction, ParseError } from "./menu.js"
44
export { parseMenuSelection } from "./menu.js"
@@ -343,6 +343,7 @@ export type Command =
343343
| DownAllCommand
344344
| StateCommand
345345
| AuthCommand
346+
| SpawnCommand
346347

347348
// CHANGE: validate docker network mode values at the CLI/config boundary
348349
// WHY: keep compose network behavior explicit and type-safe

packages/lib/src/core/session-gist-domain.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ export type SessionGistCommand =
3434
| SessionGistListCommand
3535
| SessionGistViewCommand
3636
| SessionGistDownloadCommand
37+
38+
export type { SpawnCommand } from "./spawn-domain.js"

0 commit comments

Comments
 (0)