Skip to content

Commit 50ceb3f

Browse files
committed
fix(ci): split lib create-project lint paths
1 parent 7095672 commit 50ceb3f

5 files changed

Lines changed: 353 additions & 261 deletions

File tree

packages/lib/src/shell/command-runner.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,20 +128,19 @@ export const runCommandWithCapturedOutput = <E>(
128128
Effect.gen(function*(_) {
129129
const executor = yield* _(CommandExecutor.CommandExecutor)
130130
const process = yield* _(executor.start(buildCommand(spec, "pipe", "pipe", "pipe")))
131-
const [stdout, stderr, exitCode] = yield* _(
131+
const [stdout, stderr] = yield* _(
132132
Effect.all(
133133
[
134134
collectStreamText(process.stdout),
135-
collectStreamText(process.stderr),
136-
Effect.map(process.exitCode, (value) => Number(value))
135+
collectStreamText(process.stderr)
137136
],
138137
{ concurrency: "unbounded" }
139138
)
140139
)
140+
const exitCode = yield* _(process.exitCode)
141141
yield* _(
142142
ensureExitCode(exitCode, okExitCodes, (numericExitCode) =>
143-
onFailure(numericExitCode, combineCommandOutput(stdout, stderr))
144-
)
143+
onFailure(numericExitCode, combineCommandOutput(stdout, stderr)))
145144
)
146145
})
147146
)

packages/lib/src/shell/docker.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { ExitCode } from "@effect/platform/CommandExecutor"
44
import type { PlatformError } from "@effect/platform/Error"
55
import { Duration, Effect, pipe, Schedule } from "effect"
66

7-
import { runCommandCapture, runCommandExitCode, runCommandWithCapturedOutput, runCommandWithExitCodes } from "./command-runner.js"
7+
import {
8+
runCommandCapture,
9+
runCommandExitCode,
10+
runCommandWithCapturedOutput,
11+
runCommandWithExitCodes
12+
} from "./command-runner.js"
813
import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js"
914
import { parseInspectNetworkEntry } from "./docker-inspect-parse.js"
1015
import { CommandFailedError, DockerCommandError } from "./errors.js"
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/* jscpd:ignore-start */
2+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import type { PlatformError } from "@effect/platform/Error"
4+
import type * as FileSystem from "@effect/platform/FileSystem"
5+
import type * as Path from "@effect/platform/Path"
6+
import { Effect } from "effect"
7+
8+
import type { TemplateConfig } from "../../core/domain.js"
9+
import { resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js"
10+
import { type DockerCommandError, DockerIdentityConflictError } from "../../shell/errors.js"
11+
import type { ProjectStatus } from "../projects-core.js"
12+
import { loadProjectIndex, loadProjectStatus } from "../projects-core.js"
13+
import { deleteDockerGitProject } from "../projects-delete.js"
14+
15+
type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
16+
17+
type DockerIdentityOwner = Pick<
18+
TemplateConfig,
19+
"containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright"
20+
>
21+
22+
type DockerIdentityNamespace = "container" | "composeProject" | "volume"
23+
24+
type DockerIdentityClaim = Omit<DockerIdentityConflictError["conflicts"][number], "conflictingProjectDir"> & {
25+
readonly namespace: DockerIdentityNamespace
26+
}
27+
28+
type ConflictState = {
29+
readonly conflicts: Array<DockerIdentityConflictError["conflicts"][number]>
30+
readonly conflictingProjects: Map<
31+
string,
32+
{
33+
readonly projectDir: string
34+
readonly repoUrl: string
35+
readonly containerName: string
36+
readonly serviceName: string
37+
}
38+
>
39+
}
40+
41+
const resolveBrowserContainerClaims = (
42+
config: DockerIdentityOwner
43+
): ReadonlyArray<DockerIdentityClaim> =>
44+
config.enableMcpPlaywright
45+
? [{ namespace: "container", kind: "browserContainerName", name: `${config.containerName}-browser` }]
46+
: []
47+
48+
const resolveBrowserVolumeClaims = (
49+
config: DockerIdentityOwner
50+
): ReadonlyArray<DockerIdentityClaim> =>
51+
config.enableMcpPlaywright
52+
? [{ namespace: "volume", kind: "browserVolumeName", name: `${config.volumeName}-browser` }]
53+
: []
54+
55+
const resolveDockerIdentityClaims = (
56+
config: DockerIdentityOwner
57+
): ReadonlyArray<DockerIdentityClaim> => [
58+
{ namespace: "container", kind: "containerName", name: config.containerName },
59+
...resolveBrowserContainerClaims(config),
60+
{ namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) },
61+
{ namespace: "volume", kind: "volumeName", name: config.volumeName },
62+
...resolveBrowserVolumeClaims(config),
63+
{ namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) }
64+
]
65+
66+
const loadProjectStatusOrNull = (configPath: string) =>
67+
loadProjectStatus(configPath).pipe(
68+
Effect.match({
69+
onFailure: () => null,
70+
onSuccess: (value) => value
71+
})
72+
)
73+
74+
const collectSharedClaims = (
75+
candidateClaims: ReadonlyArray<DockerIdentityClaim>,
76+
existingClaims: ReadonlyArray<DockerIdentityClaim>,
77+
projectDir: string
78+
): ReadonlyArray<DockerIdentityConflictError["conflicts"][number]> =>
79+
candidateClaims.flatMap((candidate) =>
80+
existingClaims.some(
81+
(existing) => existing.namespace === candidate.namespace && existing.name === candidate.name
82+
)
83+
? [{ conflictingProjectDir: projectDir, kind: candidate.kind, name: candidate.name }]
84+
: []
85+
)
86+
87+
const appendClaims = (
88+
conflicts: Array<DockerIdentityConflictError["conflicts"][number]>,
89+
sharedClaims: ReadonlyArray<DockerIdentityConflictError["conflicts"][number]>
90+
): void => {
91+
for (const claim of sharedClaims) {
92+
conflicts.push(claim)
93+
}
94+
}
95+
96+
const rememberConflictingProject = (
97+
conflictingProjects: ConflictState["conflictingProjects"],
98+
status: ProjectStatus
99+
): void => {
100+
conflictingProjects.set(status.projectDir, {
101+
projectDir: status.projectDir,
102+
repoUrl: status.config.template.repoUrl,
103+
containerName: status.config.template.containerName,
104+
serviceName: status.config.template.serviceName
105+
})
106+
}
107+
108+
const scanConflicts = (
109+
resolvedOutDir: string,
110+
config: DockerIdentityOwner
111+
): Effect.Effect<ConflictState | null, PlatformError, CreateProjectRuntime> =>
112+
Effect.gen(function*(_) {
113+
const index = yield* _(loadProjectIndex())
114+
if (index === null) {
115+
return null
116+
}
117+
118+
const candidateClaims = resolveDockerIdentityClaims(config)
119+
const state: ConflictState = {
120+
conflicts: [],
121+
conflictingProjects: new Map()
122+
}
123+
124+
for (const configPath of index.configPaths) {
125+
const status = yield* _(loadProjectStatusOrNull(configPath))
126+
if (status === null || status.projectDir === resolvedOutDir) {
127+
continue
128+
}
129+
130+
const sharedClaims = collectSharedClaims(
131+
candidateClaims,
132+
resolveDockerIdentityClaims(status.config.template),
133+
status.projectDir
134+
)
135+
if (sharedClaims.length === 0) {
136+
continue
137+
}
138+
139+
appendClaims(state.conflicts, sharedClaims)
140+
rememberConflictingProject(state.conflictingProjects, status)
141+
}
142+
143+
return state
144+
})
145+
146+
const deleteConflictingProjects = (
147+
conflictingProjects: ConflictState["conflictingProjects"]
148+
): Effect.Effect<void, DockerCommandError | PlatformError, CreateProjectRuntime> =>
149+
Effect.gen(function*(_) {
150+
for (const conflictingProject of conflictingProjects.values()) {
151+
yield* _(
152+
Effect.logWarning(
153+
`Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}`
154+
)
155+
)
156+
yield* _(deleteDockerGitProject(conflictingProject))
157+
}
158+
})
159+
160+
export const deleteConflictingProjectsIfNeeded = (
161+
resolvedOutDir: string,
162+
config: DockerIdentityOwner,
163+
force: boolean
164+
): Effect.Effect<void, DockerIdentityConflictError | PlatformError | DockerCommandError, CreateProjectRuntime> =>
165+
Effect.gen(function*(_) {
166+
const state = yield* _(scanConflicts(resolvedOutDir, config))
167+
if (state === null || state.conflicts.length === 0) {
168+
return
169+
}
170+
171+
if (!force) {
172+
return yield* _(
173+
Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts: state.conflicts }))
174+
)
175+
}
176+
177+
yield* _(deleteConflictingProjects(state.conflictingProjects))
178+
})
179+
/* jscpd:ignore-end */
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import * as FileSystem from "@effect/platform/FileSystem"
3+
import * as Path from "@effect/platform/Path"
4+
import { Effect } from "effect"
5+
6+
import type { CreateCommand } from "../../core/domain.js"
7+
import { runCommandWithExitCodes } from "../../shell/command-runner.js"
8+
import { CommandFailedError } from "../../shell/errors.js"
9+
import { renderError } from "../errors.js"
10+
import { findSshPrivateKey } from "../path-helpers.js"
11+
import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js"
12+
import { ensureTerminalCursorVisible } from "../terminal-cursor.js"
13+
14+
type CreateProjectOpenSshRuntime =
15+
| FileSystem.FileSystem
16+
| Path.Path
17+
| CommandExecutor.CommandExecutor
18+
19+
const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY
20+
21+
const buildSshArgs = (
22+
config: CreateCommand["config"],
23+
sshKeyPath: string | null,
24+
remoteCommand?: string,
25+
ipAddress?: string
26+
): ReadonlyArray<string> => {
27+
const host = ipAddress ?? "localhost"
28+
const port = ipAddress ? 22 : config.sshPort
29+
const args: Array<string> = []
30+
if (sshKeyPath !== null) {
31+
args.push("-i", sshKeyPath)
32+
}
33+
args.push(
34+
"-tt",
35+
"-Y",
36+
"-o",
37+
"LogLevel=ERROR",
38+
"-o",
39+
"StrictHostKeyChecking=no",
40+
"-o",
41+
"UserKnownHostsFile=/dev/null",
42+
"-p",
43+
String(port),
44+
`${config.sshUser}@${host}`
45+
)
46+
if (remoteCommand !== undefined) {
47+
args.push(remoteCommand)
48+
}
49+
return args
50+
}
51+
52+
const openSshBestEffort = (
53+
template: CreateCommand["config"],
54+
remoteCommand?: string
55+
): Effect.Effect<void, never, CreateProjectOpenSshRuntime> =>
56+
Effect.gen(function*(_) {
57+
const fs = yield* _(FileSystem.FileSystem)
58+
const path = yield* _(Path.Path)
59+
60+
const ipAddress = yield* _(
61+
getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe(
62+
Effect.orElse(() => Effect.succeed<string | undefined>(""))
63+
)
64+
)
65+
66+
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
67+
const sshCommand = buildSshCommand(template, sshKey, ipAddress)
68+
const remoteCommandLabel = remoteCommand === undefined ? "" : ` (${remoteCommand})`
69+
70+
yield* _(Effect.log(`Opening SSH: ${sshCommand}${remoteCommandLabel}`))
71+
yield* _(ensureTerminalCursorVisible())
72+
yield* _(
73+
runCommandWithExitCodes(
74+
{
75+
cwd: process.cwd(),
76+
command: "ssh",
77+
args: buildSshArgs(template, sshKey, remoteCommand, ipAddress)
78+
},
79+
[0, 130],
80+
(exitCode) => new CommandFailedError({ command: "ssh", exitCode })
81+
).pipe(Effect.ensuring(ensureTerminalCursorVisible()))
82+
)
83+
}).pipe(
84+
Effect.asVoid,
85+
Effect.matchEffect({
86+
onFailure: (error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`),
87+
onSuccess: () => Effect.void
88+
})
89+
)
90+
91+
const resolveInteractiveRemoteCommand = (
92+
projectConfig: CreateCommand["config"],
93+
interactiveAgent: boolean
94+
): string | undefined =>
95+
interactiveAgent && projectConfig.agentMode !== undefined
96+
? `cd '${projectConfig.targetDir}' && ${projectConfig.agentMode}`
97+
: undefined
98+
99+
export const maybeOpenSsh = (
100+
command: CreateCommand,
101+
hasAgent: boolean,
102+
waitForAgent: boolean,
103+
projectConfig: CreateCommand["config"]
104+
): Effect.Effect<void, never, CreateProjectOpenSshRuntime> =>
105+
Effect.gen(function*(_) {
106+
const interactiveAgent = hasAgent && !waitForAgent
107+
if (!command.openSsh || (hasAgent && !interactiveAgent)) {
108+
return
109+
}
110+
111+
if (!command.runUp) {
112+
yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up)."))
113+
return
114+
}
115+
116+
if (!isInteractiveTty()) {
117+
yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY."))
118+
return
119+
}
120+
121+
const remoteCommand = resolveInteractiveRemoteCommand(projectConfig, interactiveAgent)
122+
yield* _(openSshBestEffort(projectConfig, remoteCommand))
123+
}).pipe(Effect.asVoid)

0 commit comments

Comments
 (0)