Skip to content

Commit ea3d0f3

Browse files
committed
fix(ci): restore app lint and test checks
1 parent eeeea45 commit ea3d0f3

7 files changed

Lines changed: 413 additions & 344 deletions

File tree

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ export const runCommandWithExitCodes = <E>(
4444
): Effect.Effect<void, E | PlatformError, CommandExecutor.CommandExecutor> =>
4545
Effect.gen(function*(_) {
4646
const exitCode = yield* _(Command.exitCode(buildCommand(spec, "inherit", "inherit", "inherit")))
47-
const numericExitCode = Number(exitCode)
48-
yield* _(ensureExitCode(numericExitCode, okExitCodes, onFailure))
47+
yield* _(ensureExitCode(exitCode, okExitCodes, onFailure))
4948
})
5049

5150
// CHANGE: run a command and return the exit code, draining stdout/stderr to prevent buffer deadlock
@@ -68,7 +67,7 @@ export const runCommandExitCode = (
6867
yield* _(Effect.forkDaemon(Stream.runDrain(process.stdout)))
6968
yield* _(Effect.forkDaemon(Stream.runDrain(process.stderr)))
7069
const exitCode = yield* _(process.exitCode)
71-
return Number(exitCode)
70+
return exitCode
7271
})
7372
)
7473

@@ -114,8 +113,7 @@ export const runCommandCapture = <E>(
114113
pipe(process.stdout, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
115114
)
116115
const exitCode = yield* _(process.exitCode)
117-
const numericExitCode = Number(exitCode)
118-
yield* _(ensureExitCode(numericExitCode, okExitCodes, onFailure))
116+
yield* _(ensureExitCode(exitCode, okExitCodes, onFailure))
119117
return decodeUint8Array(bytes)
120118
})
121119
)
@@ -129,20 +127,19 @@ export const runCommandWithCapturedOutput = <E>(
129127
Effect.gen(function*(_) {
130128
const executor = yield* _(CommandExecutor.CommandExecutor)
131129
const process = yield* _(executor.start(buildCommand(spec, "pipe", "pipe", "pipe")))
132-
const [stdout, stderr, exitCode] = yield* _(
130+
const [stdout, stderr] = yield* _(
133131
Effect.all(
134132
[
135133
collectStreamText(process.stdout),
136-
collectStreamText(process.stderr),
137-
Effect.map(process.exitCode, (value) => Number(value))
134+
collectStreamText(process.stderr)
138135
],
139136
{ concurrency: "unbounded" }
140137
)
141138
)
139+
const exitCode = yield* _(process.exitCode)
140+
const output = combineCommandOutput(stdout, stderr)
142141
yield* _(
143-
ensureExitCode(exitCode, okExitCodes, (numericExitCode) =>
144-
onFailure(numericExitCode, combineCommandOutput(stdout, stderr))
145-
)
142+
ensureExitCode(exitCode, okExitCodes, (numericExitCode) => onFailure(numericExitCode, output))
146143
)
147144
})
148145
)
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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 resolveInteractiveRemoteCommand = (
53+
projectConfig: CreateCommand["config"],
54+
interactiveAgent: boolean
55+
): string | undefined =>
56+
interactiveAgent && projectConfig.agentMode !== undefined
57+
? `cd '${projectConfig.targetDir}' && ${projectConfig.agentMode}`
58+
: undefined
59+
60+
const openSshBestEffort = (
61+
template: CreateCommand["config"],
62+
remoteCommand?: string
63+
): Effect.Effect<void, never, CreateProjectOpenSshRuntime> =>
64+
Effect.gen(function*(_) {
65+
const fs = yield* _(FileSystem.FileSystem)
66+
const path = yield* _(Path.Path)
67+
const ipAddress = yield* _(
68+
getContainerIpIfInsideContainer(fs, process.cwd(), template.containerName).pipe(
69+
Effect.orElse(() => Effect.succeed<string | undefined>(""))
70+
)
71+
)
72+
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
73+
const sshCommand = buildSshCommand(template, sshKey, ipAddress)
74+
const remoteCommandLabel = remoteCommand === undefined ? "" : ` (${remoteCommand})`
75+
76+
yield* _(Effect.log(`Opening SSH: ${sshCommand}${remoteCommandLabel}`))
77+
yield* _(ensureTerminalCursorVisible())
78+
yield* _(
79+
runCommandWithExitCodes(
80+
{
81+
cwd: process.cwd(),
82+
command: "ssh",
83+
args: buildSshArgs(template, sshKey, remoteCommand, ipAddress)
84+
},
85+
[0, 130],
86+
(exitCode) => new CommandFailedError({ command: "ssh", exitCode })
87+
).pipe(Effect.ensuring(ensureTerminalCursorVisible()))
88+
)
89+
}).pipe(
90+
Effect.asVoid,
91+
Effect.matchEffect({
92+
onFailure: (error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`),
93+
onSuccess: () => Effect.void
94+
})
95+
)
96+
97+
export const maybeOpenSsh = (
98+
command: CreateCommand,
99+
hasAgent: boolean,
100+
waitForAgent: boolean,
101+
projectConfig: CreateCommand["config"]
102+
): Effect.Effect<void, never, CreateProjectOpenSshRuntime> =>
103+
Effect.gen(function*(_) {
104+
const interactiveAgent = hasAgent && !waitForAgent
105+
if (!command.openSsh || (hasAgent && !interactiveAgent)) {
106+
return
107+
}
108+
if (!command.runUp) {
109+
yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up)."))
110+
return
111+
}
112+
if (!isInteractiveTty()) {
113+
yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY."))
114+
return
115+
}
116+
117+
const remoteCommand = resolveInteractiveRemoteCommand(projectConfig, interactiveAgent)
118+
yield* _(openSshBestEffort(projectConfig, remoteCommand))
119+
}).pipe(Effect.asVoid)

0 commit comments

Comments
 (0)