Skip to content

Commit 5c2a977

Browse files
committed
fix(ci): split app lint and test helpers
1 parent ea3d0f3 commit 5c2a977

10 files changed

Lines changed: 566 additions & 744 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { defaultTemplateConfig } from "@lib/core/domain"
2+
import { runDockerInspectContainerRuntimeInfo } from "@lib/shell/docker"
3+
import { buildSshCommand, connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects"
4+
import { Effect, pipe } from "effect"
5+
6+
import { connectMenuProjectSshWithUp } from "./menu-api.js"
7+
8+
export type OpenResolvedProjectSshDeps<E, R> = {
9+
readonly log: (message: string) => Effect.Effect<void, E, R>
10+
readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect<ProjectItem | null, E, R>
11+
readonly probeReady: (item: ProjectItem) => Effect.Effect<boolean, E, R>
12+
readonly connect: (item: ProjectItem) => Effect.Effect<void, E, R>
13+
readonly connectWithUp: (item: ProjectItem) => Effect.Effect<void, E, R>
14+
}
15+
16+
const withProjectItemIpAddress = (
17+
item: ProjectItem,
18+
ipAddress: string
19+
): ProjectItem => ({
20+
...item,
21+
ipAddress,
22+
sshCommand: buildSshCommand(
23+
{
24+
...defaultTemplateConfig,
25+
containerName: item.containerName,
26+
serviceName: item.serviceName,
27+
sshUser: item.sshUser,
28+
sshPort: item.sshPort,
29+
repoUrl: item.repoUrl,
30+
repoRef: item.repoRef,
31+
targetDir: item.targetDir,
32+
envGlobalPath: item.envGlobalPath,
33+
envProjectPath: item.envProjectPath,
34+
codexAuthPath: item.codexAuthPath,
35+
codexSharedAuthPath: item.codexAuthPath,
36+
codexHome: item.codexHome,
37+
clonedOnHostname: item.clonedOnHostname
38+
},
39+
item.sshKeyPath,
40+
ipAddress
41+
)
42+
})
43+
44+
const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean =>
45+
left.ipAddress === right.ipAddress &&
46+
left.sshPort === right.sshPort &&
47+
left.sshKeyPath === right.sshKeyPath &&
48+
left.sshUser === right.sshUser
49+
50+
const attemptDirectConnect = <E, R>(
51+
item: ProjectItem,
52+
deps: Pick<OpenResolvedProjectSshDeps<E, R>, "connect" | "log" | "probeReady">
53+
): Effect.Effect<boolean, E, R> =>
54+
deps.probeReady(item).pipe(
55+
Effect.flatMap((ready) =>
56+
ready
57+
? pipe(
58+
deps.log(`Opening SSH: ${item.sshCommand}`),
59+
Effect.zipRight(deps.connect(item)),
60+
Effect.as(true)
61+
)
62+
: Effect.succeed(false)
63+
)
64+
)
65+
66+
export const openResolvedProjectSshEffect = <E, R>(
67+
item: ProjectItem,
68+
deps: OpenResolvedProjectSshDeps<E, R>
69+
) =>
70+
Effect.gen(function*(_) {
71+
const preferredItem = yield* _(deps.resolvePreferredItem(item))
72+
if (preferredItem !== null) {
73+
const connected = yield* _(attemptDirectConnect(preferredItem, deps))
74+
if (connected) {
75+
return
76+
}
77+
}
78+
79+
const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item)
80+
if (shouldRetryOriginal) {
81+
const connected = yield* _(attemptDirectConnect(item, deps))
82+
if (connected) {
83+
return
84+
}
85+
}
86+
87+
yield* _(deps.log(`Opening SSH: ${item.sshCommand}`))
88+
yield* _(deps.connectWithUp(item))
89+
})
90+
91+
export const openResolvedProjectSsh = (item: ProjectItem) =>
92+
openResolvedProjectSshEffect(item, {
93+
log: (message) => Effect.log(message),
94+
resolvePreferredItem: (selected) =>
95+
runDockerInspectContainerRuntimeInfo(process.cwd(), selected.containerName).pipe(
96+
Effect.map((runtime) =>
97+
runtime !== null && runtime.ipAddress.length > 0
98+
? withProjectItemIpAddress(selected, runtime.ipAddress)
99+
: null
100+
)
101+
),
102+
probeReady: (selected) => probeProjectSshReady(selected),
103+
connect: (selected) => connectProjectSsh(selected),
104+
connectWithUp: (selected) => connectMenuProjectSshWithUp(selected)
105+
})

packages/app/src/docker-git/open-project.ts

Lines changed: 14 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
1-
import { defaultTemplateConfig } from "@lib/core/domain"
2-
import { runDockerInspectContainerRuntimeInfo, type DockerContainerRuntimeInfo } from "@lib/shell/docker"
3-
import { buildSshCommand, connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects"
4-
import { Effect, pipe } from "effect"
1+
import { type DockerContainerRuntimeInfo, runDockerInspectContainerRuntimeInfo } from "@lib/shell/docker"
2+
import { Effect } from "effect"
53

64
import type { OpenCommand } from "@lib/core/domain"
75
import { parseGithubRepoUrl, resolveRepoInput } from "@lib/core/repo"
86

97
import { getProject, listProjects } from "./api-client.js"
108
import type { ApiProjectDetails } from "./api-project-codec.js"
119
import type { ProjectResolutionError } from "./host-errors.js"
12-
import { connectMenuProjectSshWithUp } from "./menu-api.js"
10+
import { openResolvedProjectSsh } from "./open-project-ssh.js"
1311
import { resolveApiProjectItem } from "./project-item.js"
1412

15-
type OpenResolvedProjectSshDeps<E, R> = {
16-
readonly log: (message: string) => Effect.Effect<void, E, R>
17-
readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect<ProjectItem | null, E, R>
18-
readonly probeReady: (item: ProjectItem) => Effect.Effect<boolean, E, R>
19-
readonly connect: (item: ProjectItem) => Effect.Effect<void, E, R>
20-
readonly connectWithUp: (item: ProjectItem) => Effect.Effect<void, E, R>
21-
}
13+
export {
14+
openResolvedProjectSsh,
15+
type OpenResolvedProjectSshDeps,
16+
openResolvedProjectSshEffect
17+
} from "./open-project-ssh.js"
2218

2319
type ResolveOpenProjectDeps<E, R> = {
2420
readonly inspectRuntime: (containerName: string) => Effect.Effect<DockerContainerRuntimeInfo | null, E, R>
@@ -221,8 +217,9 @@ export const selectOpenProject = (
221217
)
222218
}
223219

224-
const uniqueContainerNames = (projects: ReadonlyArray<ApiProjectDetails>): ReadonlyArray<string> =>
225-
Array.from(new Set(projects.map((project) => project.containerName)))
220+
const uniqueContainerNames = (
221+
projects: ReadonlyArray<ApiProjectDetails>
222+
): ReadonlyArray<string> => [...new Set(projects.map((project) => project.containerName))]
226223

227224
export const resolveRuntimeOwnedProject = <E, R>(
228225
projects: ReadonlyArray<ApiProjectDetails>,
@@ -257,7 +254,9 @@ export const resolveOpenProjectEffect = <E, R>(
257254
deps: ResolveOpenProjectDeps<E, R>
258255
): Effect.Effect<ApiProjectDetails, ProjectResolutionError | E, R> =>
259256
resolveRuntimeOwnedProject(projects, selector, deps).pipe(
260-
Effect.flatMap((ownedProject) => ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject))
257+
Effect.flatMap((ownedProject) =>
258+
ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject)
259+
)
261260
)
262261

263262
const listProjectDetails = () =>
@@ -273,99 +272,6 @@ const listProjectDetails = () =>
273272
return details.filter((project): project is ApiProjectDetails => project !== null)
274273
})
275274

276-
const withProjectItemIpAddress = (
277-
item: ProjectItem,
278-
ipAddress: string
279-
): ProjectItem => ({
280-
...item,
281-
ipAddress,
282-
sshCommand: buildSshCommand(
283-
{
284-
...defaultTemplateConfig,
285-
containerName: item.containerName,
286-
serviceName: item.serviceName,
287-
sshUser: item.sshUser,
288-
sshPort: item.sshPort,
289-
repoUrl: item.repoUrl,
290-
repoRef: item.repoRef,
291-
targetDir: item.targetDir,
292-
envGlobalPath: item.envGlobalPath,
293-
envProjectPath: item.envProjectPath,
294-
codexAuthPath: item.codexAuthPath,
295-
codexSharedAuthPath: item.codexAuthPath,
296-
codexHome: item.codexHome,
297-
clonedOnHostname: item.clonedOnHostname
298-
},
299-
item.sshKeyPath,
300-
ipAddress
301-
)
302-
})
303-
304-
const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean =>
305-
left.ipAddress === right.ipAddress &&
306-
left.sshPort === right.sshPort &&
307-
left.sshKeyPath === right.sshKeyPath &&
308-
left.sshUser === right.sshUser
309-
310-
const attemptDirectConnect = <E, R>(
311-
item: ProjectItem,
312-
deps: Pick<OpenResolvedProjectSshDeps<E, R>, "connect" | "log" | "probeReady">
313-
): Effect.Effect<boolean, E, R> =>
314-
deps.probeReady(item).pipe(
315-
Effect.flatMap((ready) =>
316-
ready
317-
? pipe(
318-
deps.log(`Opening SSH: ${item.sshCommand}`),
319-
Effect.zipRight(deps.connect(item)),
320-
Effect.as(true)
321-
)
322-
: Effect.succeed(false)
323-
)
324-
)
325-
326-
export const openResolvedProjectSshEffect = <E, R>(
327-
item: ProjectItem,
328-
deps: OpenResolvedProjectSshDeps<E, R>
329-
) =>
330-
Effect.gen(function*(_) {
331-
const preferredItem = yield* _(deps.resolvePreferredItem(item))
332-
if (preferredItem !== null) {
333-
const connected = yield* _(attemptDirectConnect(preferredItem, deps))
334-
if (connected) {
335-
return
336-
}
337-
}
338-
339-
const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item)
340-
if (shouldRetryOriginal) {
341-
const connected = yield* _(attemptDirectConnect(item, deps))
342-
if (connected) {
343-
return
344-
}
345-
}
346-
347-
yield* _(deps.log(`Opening SSH: ${item.sshCommand}`))
348-
yield* _(deps.connectWithUp(item))
349-
})
350-
351-
export const openResolvedProjectSsh = (
352-
item: ProjectItem
353-
) =>
354-
openResolvedProjectSshEffect(item, {
355-
log: (message) => Effect.log(message),
356-
resolvePreferredItem: (selected) =>
357-
runDockerInspectContainerRuntimeInfo(process.cwd(), selected.containerName).pipe(
358-
Effect.map((runtime) =>
359-
runtime !== null && runtime.ipAddress.length > 0
360-
? withProjectItemIpAddress(selected, runtime.ipAddress)
361-
: null
362-
)
363-
),
364-
probeReady: (selected) => probeProjectSshReady(selected),
365-
connect: (selected) => connectProjectSsh(selected),
366-
connectWithUp: (selected) => connectMenuProjectSshWithUp(selected)
367-
})
368-
369275
export const openExistingProjectSsh = (
370276
command: OpenCommand
371277
) =>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { ExitCode } from "@effect/platform/CommandExecutor"
2+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import type { PlatformError } from "@effect/platform/Error"
4+
import { Duration, Effect, pipe, Schedule } from "effect"
5+
6+
import { runCommandCapture, runCommandWithCapturedOutput } from "./command-runner.js"
7+
import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js"
8+
import { DockerCommandError } from "./errors.js"
9+
10+
const runCompose = (
11+
cwd: string,
12+
args: ReadonlyArray<string>,
13+
okExitCodes: ReadonlyArray<number>
14+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
15+
Effect.gen(function*(_) {
16+
const env = yield* _(resolveDockerComposeEnv(cwd))
17+
yield* _(
18+
runCommandWithCapturedOutput(
19+
{
20+
...composeSpec(cwd, args),
21+
...(Object.keys(env).length > 0 ? { env } : {})
22+
},
23+
okExitCodes,
24+
(exitCode, output) => new DockerCommandError({ exitCode, ...(output.length > 0 ? { details: output } : {}) })
25+
)
26+
)
27+
})
28+
29+
const runComposeCapture = (
30+
cwd: string,
31+
args: ReadonlyArray<string>,
32+
okExitCodes: ReadonlyArray<number>
33+
): Effect.Effect<string, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
34+
Effect.gen(function*(_) {
35+
const env = yield* _(resolveDockerComposeEnv(cwd))
36+
return yield* _(
37+
runCommandCapture(
38+
{
39+
...composeSpec(cwd, args),
40+
...(Object.keys(env).length > 0 ? { env } : {})
41+
},
42+
okExitCodes,
43+
(exitCode) => new DockerCommandError({ exitCode })
44+
)
45+
)
46+
})
47+
48+
const dockerComposeUpRetrySchedule = Schedule.addDelay(
49+
Schedule.recurs(2),
50+
() => Duration.seconds(2)
51+
)
52+
53+
const retryDockerComposeUp = (
54+
cwd: string,
55+
effect: Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor>
56+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
57+
effect.pipe(
58+
Effect.tapError(() =>
59+
Effect.logWarning(
60+
`docker compose up failed in ${cwd}; retrying (possible transient Docker Hub/DNS issue)...`
61+
)
62+
),
63+
Effect.retry(dockerComposeUpRetrySchedule)
64+
)
65+
66+
export const runDockerComposeUp = (
67+
cwd: string
68+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
69+
retryDockerComposeUp(cwd, runCompose(cwd, ["up", "-d", "--build"], [Number(ExitCode(0))]))
70+
71+
export const dockerComposeUpRecreateArgs: ReadonlyArray<string> = [
72+
"up",
73+
"-d",
74+
"--build",
75+
"--force-recreate"
76+
]
77+
78+
export const runDockerComposeUpRecreate = (
79+
cwd: string
80+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
81+
retryDockerComposeUp(cwd, runCompose(cwd, dockerComposeUpRecreateArgs, [Number(ExitCode(0))]))
82+
83+
export const runDockerComposeDown = (
84+
cwd: string
85+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
86+
runCompose(cwd, ["down"], [Number(ExitCode(0))])
87+
88+
export const runDockerComposeDownVolumes = (
89+
cwd: string
90+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
91+
runCompose(cwd, ["down", "-v", "--remove-orphans"], [Number(ExitCode(0))])
92+
93+
export const runDockerComposeRecreate = (
94+
cwd: string
95+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
96+
pipe(runDockerComposeDown(cwd), Effect.zipRight(runDockerComposeUp(cwd)))
97+
98+
export const runDockerComposePs = (
99+
cwd: string
100+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
101+
runCompose(cwd, ["ps"], [Number(ExitCode(0))])
102+
103+
export const runDockerComposePsFormatted = (
104+
cwd: string
105+
): Effect.Effect<string, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
106+
runComposeCapture(
107+
cwd,
108+
["ps", "--format", "{{.Name}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}"],
109+
[Number(ExitCode(0))]
110+
)
111+
112+
export const runDockerComposeLogs = (
113+
cwd: string
114+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
115+
runCompose(cwd, ["logs", "--tail", "200"], [Number(ExitCode(0)), 130])
116+
117+
export const runDockerComposeLogsFollow = (
118+
cwd: string
119+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
120+
runCompose(cwd, ["logs", "--follow", "--tail", "0"], [Number(ExitCode(0)), 130])

0 commit comments

Comments
 (0)