@john-ezra/openralph 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +25 -0
- package/AGENTS.md +148 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/PROMPT_build.md +48 -0
- package/PROMPT_plan.md +29 -0
- package/README.md +247 -0
- package/bin/openralph +5 -0
- package/bun.lock +85 -0
- package/container/Dockerfile +58 -0
- package/container/bin/chrome-devtools-mcp-wrapper +57 -0
- package/package.json +50 -0
- package/src/args.ts +236 -0
- package/src/artifacts.ts +183 -0
- package/src/cli.ts +125 -0
- package/src/design.ts +24 -0
- package/src/docker.ts +403 -0
- package/src/exec.ts +103 -0
- package/src/git.ts +62 -0
- package/src/launcher.ts +235 -0
- package/src/loop.ts +339 -0
- package/src/plugin.ts +56 -0
- package/src/release-check.ts +143 -0
- package/src/sentinels.ts +23 -0
- package/src/tags.ts +22 -0
- package/src/trust.ts +116 -0
- package/src/tui.ts +436 -0
- package/tsconfig.json +14 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readOptionsFromEnv, runOpenRalphLauncher, type RunLauncherInput } from "./launcher.ts"
|
|
2
|
+
import { DEFAULT_DOCKER_IMAGE, validateDockerImageReference, type LoopPhase } from "./args.ts"
|
|
3
|
+
import { buildLocalDockerImage } from "./docker.ts"
|
|
4
|
+
import type { CommandResult } from "./exec.ts"
|
|
5
|
+
|
|
6
|
+
export interface CliDeps {
|
|
7
|
+
cwd?: string
|
|
8
|
+
env?: NodeJS.ProcessEnv
|
|
9
|
+
stderr?: Pick<NodeJS.WritableStream, "write">
|
|
10
|
+
stdout?: Pick<NodeJS.WritableStream, "write">
|
|
11
|
+
runLauncher?: typeof runOpenRalphLauncher
|
|
12
|
+
buildDockerImage?: typeof buildLocalDockerImage
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runCli(argv = process.argv.slice(2), deps: CliDeps = {}): Promise<number> {
|
|
16
|
+
const stdout = deps.stdout ?? process.stdout
|
|
17
|
+
const stderr = deps.stderr ?? process.stderr
|
|
18
|
+
const command = argv[0]
|
|
19
|
+
|
|
20
|
+
if (command === "docker") return runDockerCli(argv.slice(1), deps)
|
|
21
|
+
|
|
22
|
+
if (command !== "plan" && command !== "build") {
|
|
23
|
+
stderr.write(`${usage()}\n`)
|
|
24
|
+
return 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const phase = command as LoopPhase
|
|
29
|
+
const options = readOptionsFromEnv(deps.env)
|
|
30
|
+
const input: RunLauncherInput = {
|
|
31
|
+
phase,
|
|
32
|
+
rawArgs: argv.slice(1).join(" "),
|
|
33
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
34
|
+
options,
|
|
35
|
+
streamOutput: true,
|
|
36
|
+
captureOutput: true,
|
|
37
|
+
}
|
|
38
|
+
const result = await (deps.runLauncher ?? runOpenRalphLauncher)(input, { env: deps.env })
|
|
39
|
+
stdout.write(`${result.summary}\n`)
|
|
40
|
+
return result.status === "failed" ? 1 : 0
|
|
41
|
+
} catch (error) {
|
|
42
|
+
stderr.write(`OpenRalph failed: ${formatError(error)}\n`)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runDockerCli(argv: string[], deps: CliDeps): Promise<number> {
|
|
48
|
+
const stdout = deps.stdout ?? process.stdout
|
|
49
|
+
const stderr = deps.stderr ?? process.stderr
|
|
50
|
+
const subcommand = argv[0]
|
|
51
|
+
|
|
52
|
+
if (subcommand !== "build") {
|
|
53
|
+
stderr.write(`${usage()}\n`)
|
|
54
|
+
return 2
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const input = parseDockerBuildArgs(argv.slice(1))
|
|
59
|
+
const result = await (deps.buildDockerImage ?? buildLocalDockerImage)({
|
|
60
|
+
tag: input.tag,
|
|
61
|
+
noCache: input.noCache,
|
|
62
|
+
streamOutput: true,
|
|
63
|
+
captureOutput: true,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (result.exitCode !== 0) {
|
|
67
|
+
stderr.write(`${formatDockerBuildFailure(result)}\n`)
|
|
68
|
+
return 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stdout.write(`OpenRalph Docker image built: ${input.tag}\n`)
|
|
72
|
+
return 0
|
|
73
|
+
} catch (error) {
|
|
74
|
+
stderr.write(`OpenRalph failed: ${formatError(error)}\n`)
|
|
75
|
+
return 1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseDockerBuildArgs(argv: string[]): { tag: string; noCache: boolean } {
|
|
80
|
+
const parsed = { tag: DEFAULT_DOCKER_IMAGE, noCache: false }
|
|
81
|
+
|
|
82
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
83
|
+
const token = argv[index]
|
|
84
|
+
|
|
85
|
+
if (token === "--tag") {
|
|
86
|
+
const value = argv[index + 1]
|
|
87
|
+
if (!value || value.startsWith("-")) throw new Error("--tag requires an image tag")
|
|
88
|
+
parsed.tag = validateDockerImageReference(value, "--tag")
|
|
89
|
+
index += 1
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (token === "--no-cache") {
|
|
94
|
+
parsed.noCache = true
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (token.startsWith("-")) throw new Error(`Unknown flag: ${token}`)
|
|
99
|
+
throw new Error(`Unexpected argument: ${token}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parsed
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatDockerBuildFailure(result: CommandResult): string {
|
|
106
|
+
const reason = result.exitCode === null ? `signal ${result.signal ?? "unknown"}` : `exit code ${result.exitCode}`
|
|
107
|
+
return `OpenRalph Docker image build failed: Docker exited with ${reason}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function usage(): string {
|
|
111
|
+
return [
|
|
112
|
+
"Usage:",
|
|
113
|
+
" openralph plan [max] [--model <model>] [--no-docker]",
|
|
114
|
+
" openralph build [max] [--model <model>] [--push] [--no-docker]",
|
|
115
|
+
" openralph docker build [--tag <image>] [--no-cache]",
|
|
116
|
+
].join("\n")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatError(error: unknown): string {
|
|
120
|
+
return error instanceof Error ? error.message : String(error)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (import.meta.main) {
|
|
124
|
+
process.exit(await runCli())
|
|
125
|
+
}
|
package/src/design.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const DESIGN_SYSTEM_PROMPT = `# Ralph Design Requirements
|
|
2
|
+
|
|
3
|
+
You are running Phase 1: Design Requirements for Ralph.
|
|
4
|
+
|
|
5
|
+
Your goal is to turn early ideation into planning-ready \`specs/*.md\` artifacts for the Ralph planning phase. The next phase, Ralph Plan, will compare \`specs/*\` against the current codebase and create or refine \`IMPLEMENTATION_PLAN.md\`.
|
|
6
|
+
|
|
7
|
+
Guide the user through requirements discovery when the idea is vague. Clarify Jobs to Be Done, users, workflows, constraints, risks, edge cases, acceptance criteria, validation expectations, and non-goals. Ask targeted questions only when the answer changes the required behavior or planning readiness.
|
|
8
|
+
|
|
9
|
+
Break each Job to Be Done into topics of concern. Apply the one-sentence-without-and topic scope test: if a topic requires "and" to describe unrelated capabilities, split it.
|
|
10
|
+
|
|
11
|
+
Write or refine one \`specs/*.md\` file per topic of concern. Keep specs behavioral rather than implementation-prescriptive unless an implementation detail is a hard constraint. Capture assumptions and unresolved questions explicitly.
|
|
12
|
+
|
|
13
|
+
Do not create or edit \`IMPLEMENTATION_PLAN.md\`. Do not implement code. Do not commit. Do not proceed to planning or building.
|
|
14
|
+
|
|
15
|
+
When specs are planning-ready, tell the user they are ready to run OpenRalph: Plan. If specs are not ready, list the unresolved questions or blockers.`
|
|
16
|
+
|
|
17
|
+
export function buildDesignUserPrompt(initialIdea: string): string {
|
|
18
|
+
const idea = initialIdea.trim()
|
|
19
|
+
if (!idea) {
|
|
20
|
+
return "Begin Ralph Design. The user did not provide an initial idea, so ask what we are working on before drafting or changing specs."
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [`Begin Ralph Design from this initial idea:`, "", idea].join("\n")
|
|
24
|
+
}
|
package/src/docker.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { access, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { homedir, tmpdir } from "node:os"
|
|
4
|
+
import { basename, join, relative, sep } from "node:path"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_DOCKER_IMAGE,
|
|
7
|
+
formatLoopArgsForReplay,
|
|
8
|
+
parseLoopArgs,
|
|
9
|
+
resolveDockerOptions,
|
|
10
|
+
type LoopPhase,
|
|
11
|
+
type OpenRalphOptions,
|
|
12
|
+
type ParsedLoopArgs,
|
|
13
|
+
type ResolvedDockerOptions,
|
|
14
|
+
} from "./args.ts"
|
|
15
|
+
import { startCommand, type CommandOutputEvent, type CommandResult } from "./exec.ts"
|
|
16
|
+
import { runGitCommand } from "./git.ts"
|
|
17
|
+
import { DOCKER_TOKEN_PATH } from "./trust.ts"
|
|
18
|
+
|
|
19
|
+
export const CONTAINER_WORKSPACE = "/workspace"
|
|
20
|
+
export const CONTAINER_HOME = "/home/opencode"
|
|
21
|
+
export const IMAGE_PLUGIN_PATH = "file:///opt/openralph/src/plugin.ts"
|
|
22
|
+
export const CHROME_DEVTOOLS_MCP_VERSION = "1.1.1"
|
|
23
|
+
export const CHROME_DEVTOOLS_MCP_PACKAGE = `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`
|
|
24
|
+
export const CHROME_DEVTOOLS_MCP_WRAPPER = "/opt/openralph/bin/chrome-devtools-mcp-wrapper"
|
|
25
|
+
export const CHROME_DEVTOOLS_MCP_COMMAND = [
|
|
26
|
+
CHROME_DEVTOOLS_MCP_WRAPPER,
|
|
27
|
+
"--no-usage-statistics",
|
|
28
|
+
"--no-performance-crux",
|
|
29
|
+
"--experimental-vision",
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
const ENV_EXAMPLE_FILES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"])
|
|
33
|
+
const ENV_SCAN_SKIP_DIRS = new Set([".git", "node_modules", "dist", "coverage"])
|
|
34
|
+
const CONTAINER_GIT_CONFIG = [
|
|
35
|
+
["safe.directory", CONTAINER_WORKSPACE],
|
|
36
|
+
["commit.gpgsign", "false"],
|
|
37
|
+
["tag.gpgsign", "false"],
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
export interface RunDockerLoopInput {
|
|
41
|
+
phase: LoopPhase
|
|
42
|
+
rawArgs: string
|
|
43
|
+
projectRoot: string
|
|
44
|
+
options: OpenRalphOptions
|
|
45
|
+
streamOutput?: boolean
|
|
46
|
+
captureOutput?: boolean
|
|
47
|
+
onOutput?: (event: CommandOutputEvent) => void
|
|
48
|
+
signal?: AbortSignal
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BuildLocalDockerImageInput {
|
|
52
|
+
tag?: string
|
|
53
|
+
noCache?: boolean
|
|
54
|
+
packageRoot?: string
|
|
55
|
+
streamOutput?: boolean
|
|
56
|
+
captureOutput?: boolean
|
|
57
|
+
onOutput?: (event: CommandOutputEvent) => void
|
|
58
|
+
signal?: AbortSignal
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BuildDockerImageArgsInput {
|
|
62
|
+
tag?: string
|
|
63
|
+
noCache?: boolean
|
|
64
|
+
packageRoot?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DockerMount {
|
|
68
|
+
source: string
|
|
69
|
+
target: string
|
|
70
|
+
readonly?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildDockerArgsInput {
|
|
74
|
+
phase: LoopPhase
|
|
75
|
+
replayArgs: string
|
|
76
|
+
projectRoot: string
|
|
77
|
+
authPath: string
|
|
78
|
+
options: OpenRalphOptions
|
|
79
|
+
docker: ResolvedDockerOptions
|
|
80
|
+
envMasks: DockerMount[]
|
|
81
|
+
uid?: number
|
|
82
|
+
gid?: number
|
|
83
|
+
gitIdentity?: GitIdentity
|
|
84
|
+
imagePluginPath?: string
|
|
85
|
+
dockerToken: DockerToken
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GitIdentity {
|
|
89
|
+
name: string
|
|
90
|
+
email: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PreparedEnvMasks {
|
|
94
|
+
mounts: DockerMount[]
|
|
95
|
+
cleanup: () => Promise<void>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DockerToken {
|
|
99
|
+
token: string
|
|
100
|
+
file: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runDockerLoop(input: RunDockerLoopInput): Promise<CommandResult> {
|
|
104
|
+
const parsed = parseLoopArgs(input.phase, input.rawArgs)
|
|
105
|
+
const docker = resolveDockerOptions(input.options)
|
|
106
|
+
const authPath = defaultAuthPath()
|
|
107
|
+
await requireReadableFile(authPath, "OpenCode auth file")
|
|
108
|
+
|
|
109
|
+
const envMasks = docker.maskEnv ? await prepareEnvMasks(input.projectRoot) : emptyEnvMasks()
|
|
110
|
+
const dockerToken = await prepareDockerToken()
|
|
111
|
+
let activeChild: ReturnType<typeof startCommand>["child"] | undefined
|
|
112
|
+
let stopRequested = false
|
|
113
|
+
|
|
114
|
+
const onSigint = () => {
|
|
115
|
+
stopRequested = true
|
|
116
|
+
activeChild?.kill("SIGINT")
|
|
117
|
+
process.stderr.write("\nOpenRalph Docker stop requested. Waiting for container to exit...\n")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const onAbort = () => {
|
|
121
|
+
stopRequested = true
|
|
122
|
+
activeChild?.kill("SIGINT")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.on("SIGINT", onSigint)
|
|
126
|
+
if (input.signal?.aborted) onAbort()
|
|
127
|
+
else input.signal?.addEventListener("abort", onAbort, { once: true })
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const user = hostUser()
|
|
131
|
+
const gitIdentity = input.phase === "build" ? await requireGitIdentity(input.projectRoot) : undefined
|
|
132
|
+
const running = startCommand(
|
|
133
|
+
"docker",
|
|
134
|
+
buildDockerArgs({
|
|
135
|
+
phase: input.phase,
|
|
136
|
+
replayArgs: formatLoopArgsForReplay(parsed),
|
|
137
|
+
projectRoot: input.projectRoot,
|
|
138
|
+
authPath,
|
|
139
|
+
options: input.options,
|
|
140
|
+
docker,
|
|
141
|
+
envMasks: envMasks.mounts,
|
|
142
|
+
uid: user.uid,
|
|
143
|
+
gid: user.gid,
|
|
144
|
+
gitIdentity,
|
|
145
|
+
dockerToken,
|
|
146
|
+
}),
|
|
147
|
+
{
|
|
148
|
+
cwd: input.projectRoot,
|
|
149
|
+
streamOutput: input.streamOutput ?? true,
|
|
150
|
+
captureOutput: input.captureOutput ?? true,
|
|
151
|
+
onOutput: input.onOutput,
|
|
152
|
+
signal: input.signal,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
activeChild = running.child
|
|
156
|
+
const result = await running.result
|
|
157
|
+
if (stopRequested) throw new Error("Docker execution stopped by user")
|
|
158
|
+
return result
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (stopRequested) throw new Error(`Docker execution stopped: ${formatError(error)}`)
|
|
161
|
+
throw error
|
|
162
|
+
} finally {
|
|
163
|
+
activeChild = undefined
|
|
164
|
+
process.off("SIGINT", onSigint)
|
|
165
|
+
input.signal?.removeEventListener("abort", onAbort)
|
|
166
|
+
await dockerToken.cleanup()
|
|
167
|
+
await envMasks.cleanup()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildDockerImageArgs(input: BuildDockerImageArgsInput = {}): string[] {
|
|
172
|
+
const packageRoot = input.packageRoot ?? defaultPackageRoot()
|
|
173
|
+
const args = ["build", "--file", join(packageRoot, "container", "Dockerfile"), "--tag", input.tag ?? DEFAULT_DOCKER_IMAGE]
|
|
174
|
+
if (input.noCache) args.push("--no-cache")
|
|
175
|
+
args.push(packageRoot)
|
|
176
|
+
return args
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function buildLocalDockerImage(input: BuildLocalDockerImageInput = {}): Promise<CommandResult> {
|
|
180
|
+
const packageRoot = input.packageRoot ?? defaultPackageRoot()
|
|
181
|
+
return startCommand("docker", buildDockerImageArgs({ tag: input.tag, noCache: input.noCache, packageRoot }), {
|
|
182
|
+
cwd: packageRoot,
|
|
183
|
+
streamOutput: input.streamOutput ?? true,
|
|
184
|
+
captureOutput: input.captureOutput ?? true,
|
|
185
|
+
onOutput: input.onOutput,
|
|
186
|
+
signal: input.signal,
|
|
187
|
+
}).result
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function buildDockerArgs(input: BuildDockerArgsInput): string[] {
|
|
191
|
+
const configContent = buildContainerConfig(input.options, input.docker, input.imagePluginPath)
|
|
192
|
+
const args = [
|
|
193
|
+
"run",
|
|
194
|
+
"--pull=never",
|
|
195
|
+
"--rm",
|
|
196
|
+
"--shm-size=1g",
|
|
197
|
+
"--workdir",
|
|
198
|
+
CONTAINER_WORKSPACE,
|
|
199
|
+
"--security-opt",
|
|
200
|
+
"no-new-privileges:true",
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
appendEnv(args, "OPENRALPH_IN_DOCKER", "1")
|
|
204
|
+
appendEnv(args, "OPENRALPH_DOCKER_TOKEN", input.dockerToken.token)
|
|
205
|
+
appendEnv(args, "OPENRALPH_OPTIONS_JSON", JSON.stringify(input.options))
|
|
206
|
+
appendEnv(args, "OPENCODE_DISABLE_PROJECT_CONFIG", "1")
|
|
207
|
+
appendEnv(args, "OPENCODE_CONFIG_CONTENT", configContent)
|
|
208
|
+
appendEnv(args, "HOME", CONTAINER_HOME)
|
|
209
|
+
appendGitConfigEnv(args)
|
|
210
|
+
|
|
211
|
+
if (input.gitIdentity) appendGitIdentityEnv(args, input.gitIdentity)
|
|
212
|
+
|
|
213
|
+
if (input.uid !== undefined && input.gid !== undefined) {
|
|
214
|
+
args.push("--user", `${input.uid}:${input.gid}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
args.push("--mount", bindMount(input.projectRoot, CONTAINER_WORKSPACE))
|
|
218
|
+
args.push("--mount", bindMount(input.authPath, `${CONTAINER_HOME}/.local/share/opencode/auth.json`, true))
|
|
219
|
+
args.push("--mount", bindMount(input.dockerToken.file, DOCKER_TOKEN_PATH, true))
|
|
220
|
+
|
|
221
|
+
for (const mask of input.envMasks) {
|
|
222
|
+
args.push("--mount", bindMount(mask.source, mask.target, mask.readonly ?? true))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
args.push(input.docker.image, "openralph", input.phase)
|
|
226
|
+
if (input.replayArgs) args.push(input.replayArgs)
|
|
227
|
+
return args
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function buildContainerConfig(
|
|
231
|
+
options: OpenRalphOptions,
|
|
232
|
+
docker: ResolvedDockerOptions = resolveDockerOptions(options),
|
|
233
|
+
imagePluginPath = IMAGE_PLUGIN_PATH,
|
|
234
|
+
): string {
|
|
235
|
+
const pluginOptions: OpenRalphOptions = {
|
|
236
|
+
...(options.defineModel ? { defineModel: options.defineModel } : {}),
|
|
237
|
+
...(options.planModel ? { planModel: options.planModel } : {}),
|
|
238
|
+
...(options.buildModel ? { buildModel: options.buildModel } : {}),
|
|
239
|
+
docker: {
|
|
240
|
+
enabled: docker.enabled,
|
|
241
|
+
image: docker.image,
|
|
242
|
+
maskEnv: docker.maskEnv,
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return JSON.stringify({
|
|
247
|
+
$schema: "https://opencode.ai/config.json",
|
|
248
|
+
plugin: [[imagePluginPath, pluginOptions]],
|
|
249
|
+
mcp: {
|
|
250
|
+
"chrome-devtools": {
|
|
251
|
+
type: "local",
|
|
252
|
+
command: [...CHROME_DEVTOOLS_MCP_COMMAND],
|
|
253
|
+
environment: {
|
|
254
|
+
CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: "1",
|
|
255
|
+
},
|
|
256
|
+
enabled: true,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function readGitIdentity(cwd: string): Promise<GitIdentity | undefined> {
|
|
263
|
+
const [name, email] = await Promise.all([readGitConfigValue(cwd, "user.name"), readGitConfigValue(cwd, "user.email")])
|
|
264
|
+
if (!name || !email) return undefined
|
|
265
|
+
return { name, email }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function requireGitIdentity(cwd: string): Promise<GitIdentity> {
|
|
269
|
+
const identity = await readGitIdentity(cwd)
|
|
270
|
+
if (!identity) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"Dockerized OpenRalph Build requires Git user.name and user.email in host or project Git config. Configure them before rerunning Dockerized builds.",
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return identity
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function detectMaskableEnvFiles(root: string): Promise<string[]> {
|
|
279
|
+
const files: string[] = []
|
|
280
|
+
await collectMaskableEnvFiles(root, files)
|
|
281
|
+
return files.sort()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function prepareEnvMasks(root: string): Promise<PreparedEnvMasks> {
|
|
285
|
+
const envFiles = await detectMaskableEnvFiles(root)
|
|
286
|
+
if (envFiles.length === 0) return emptyEnvMasks()
|
|
287
|
+
|
|
288
|
+
const tempDir = await mkdtemp(join(tmpdir(), "openralph-env-"))
|
|
289
|
+
const mounts: DockerMount[] = []
|
|
290
|
+
|
|
291
|
+
for (let index = 0; index < envFiles.length; index += 1) {
|
|
292
|
+
const source = join(tempDir, `env-${index}`)
|
|
293
|
+
await writeFile(source, "")
|
|
294
|
+
mounts.push({ source, target: containerPath(root, envFiles[index]), readonly: true })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
mounts,
|
|
299
|
+
cleanup: () => rm(tempDir, { recursive: true, force: true }),
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function shouldMaskEnvFile(path: string): boolean {
|
|
304
|
+
const name = basename(path)
|
|
305
|
+
return name.startsWith(".env") && !ENV_EXAMPLE_FILES.has(name)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function containerPath(root: string, path: string): string {
|
|
309
|
+
const rel = relative(root, path)
|
|
310
|
+
return `${CONTAINER_WORKSPACE}/${rel.split(sep).join("/")}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function bindMount(source: string, target: string, readonly = false): string {
|
|
314
|
+
validateMountPath(source, "source")
|
|
315
|
+
validateMountPath(target, "target")
|
|
316
|
+
return `type=bind,source=${source},target=${target}${readonly ? ",readonly" : ""}`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function validateMountPath(value: string, label: "source" | "target"): void {
|
|
320
|
+
if (value === "" || value.includes(",") || value.includes("\n") || value.includes("\r")) {
|
|
321
|
+
throw new Error(`Docker mount ${label} must not be empty or contain commas/newlines: ${value}`)
|
|
322
|
+
}
|
|
323
|
+
if (!value.startsWith("/")) {
|
|
324
|
+
throw new Error(`Docker mount ${label} must be an absolute path: ${value}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function appendEnv(args: string[], name: string, value: string): void {
|
|
329
|
+
args.push("--env", `${name}=${value}`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function appendGitConfigEnv(args: string[]): void {
|
|
333
|
+
appendEnv(args, "GIT_CONFIG_COUNT", String(CONTAINER_GIT_CONFIG.length))
|
|
334
|
+
CONTAINER_GIT_CONFIG.forEach(([key, value], index) => {
|
|
335
|
+
appendEnv(args, `GIT_CONFIG_KEY_${index}`, key)
|
|
336
|
+
appendEnv(args, `GIT_CONFIG_VALUE_${index}`, value)
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function appendGitIdentityEnv(args: string[], identity: GitIdentity): void {
|
|
341
|
+
appendEnv(args, "GIT_AUTHOR_NAME", identity.name)
|
|
342
|
+
appendEnv(args, "GIT_AUTHOR_EMAIL", identity.email)
|
|
343
|
+
appendEnv(args, "GIT_COMMITTER_NAME", identity.name)
|
|
344
|
+
appendEnv(args, "GIT_COMMITTER_EMAIL", identity.email)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function readGitConfigValue(cwd: string, key: string): Promise<string | undefined> {
|
|
348
|
+
const result = await runGitCommand(["config", "--get", key], cwd)
|
|
349
|
+
if (result.exitCode !== 0) return undefined
|
|
350
|
+
const value = result.stdout.trim()
|
|
351
|
+
return value || undefined
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function defaultAuthPath(): string {
|
|
355
|
+
return join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function defaultPackageRoot(): string {
|
|
359
|
+
return join(import.meta.dir, "..")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function requireReadableFile(path: string, label: string): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
await access(path)
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error(`${label} not found at ${path}`)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function collectMaskableEnvFiles(dir: string, files: string[]): Promise<void> {
|
|
371
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
372
|
+
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
const path = join(dir, entry.name)
|
|
375
|
+
if (entry.isDirectory()) {
|
|
376
|
+
if (!ENV_SCAN_SKIP_DIRS.has(entry.name)) await collectMaskableEnvFiles(path, files)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (entry.isFile() && shouldMaskEnvFile(path)) files.push(path)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function emptyEnvMasks(): PreparedEnvMasks {
|
|
385
|
+
return { mounts: [], cleanup: async () => {} }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function prepareDockerToken(): Promise<DockerToken & { cleanup: () => Promise<void> }> {
|
|
389
|
+
const tempDir = await mkdtemp(join(tmpdir(), "openralph-docker-"))
|
|
390
|
+
const token = randomBytes(32).toString("hex")
|
|
391
|
+
const file = join(tempDir, "docker-token")
|
|
392
|
+
await writeFile(file, token)
|
|
393
|
+
return { token, file, cleanup: () => rm(tempDir, { recursive: true, force: true }) }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function hostUser(): { uid?: number; gid?: number } {
|
|
397
|
+
if (typeof process.getuid !== "function" || typeof process.getgid !== "function") return {}
|
|
398
|
+
return { uid: process.getuid(), gid: process.getgid() }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function formatError(error: unknown): string {
|
|
402
|
+
return error instanceof Error ? error.message : String(error)
|
|
403
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
exitCode: number | null
|
|
5
|
+
signal: NodeJS.Signals | null
|
|
6
|
+
stdout: string
|
|
7
|
+
stderr: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type CommandOutputStream = "stdout" | "stderr"
|
|
11
|
+
|
|
12
|
+
export interface CommandOutputEvent {
|
|
13
|
+
stream: CommandOutputStream
|
|
14
|
+
chunk: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RunningCommand {
|
|
18
|
+
child: ChildProcess
|
|
19
|
+
result: Promise<CommandResult>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StartCommandOptions {
|
|
23
|
+
cwd: string
|
|
24
|
+
env?: Record<string, string | undefined>
|
|
25
|
+
streamOutput?: boolean
|
|
26
|
+
captureOutput?: boolean
|
|
27
|
+
onOutput?: (event: CommandOutputEvent) => void
|
|
28
|
+
signal?: AbortSignal
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runCommand(command: string, args: string[], cwd: string): Promise<CommandResult> {
|
|
32
|
+
return startCommand(command, args, { cwd, streamOutput: false }).result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function commandExists(command: string, cwd = process.cwd()): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await runCommand(command, ["--version"], cwd)
|
|
38
|
+
return true
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return !isMissingCommandError(error)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startCommand(command: string, args: string[], options: StartCommandOptions): RunningCommand {
|
|
45
|
+
const env = buildEnv(options.env)
|
|
46
|
+
const child = spawn(command, args, {
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
env,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const streamOutput = options.streamOutput ?? false
|
|
53
|
+
const captureOutput = options.captureOutput ?? true
|
|
54
|
+
let stdout = ""
|
|
55
|
+
let stderr = ""
|
|
56
|
+
|
|
57
|
+
const abort = () => child.kill("SIGINT")
|
|
58
|
+
if (options.signal?.aborted) abort()
|
|
59
|
+
else options.signal?.addEventListener("abort", abort, { once: true })
|
|
60
|
+
|
|
61
|
+
child.stdout.setEncoding("utf8")
|
|
62
|
+
child.stderr.setEncoding("utf8")
|
|
63
|
+
|
|
64
|
+
child.stdout.on("data", (chunk: string) => {
|
|
65
|
+
if (captureOutput) stdout += chunk
|
|
66
|
+
options.onOutput?.({ stream: "stdout", chunk })
|
|
67
|
+
if (streamOutput) process.stdout.write(chunk)
|
|
68
|
+
})
|
|
69
|
+
child.stderr.on("data", (chunk: string) => {
|
|
70
|
+
if (captureOutput) stderr += chunk
|
|
71
|
+
options.onOutput?.({ stream: "stderr", chunk })
|
|
72
|
+
if (streamOutput) process.stderr.write(chunk)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const result = new Promise<CommandResult>((resolve, reject) => {
|
|
76
|
+
child.on("error", (error) => {
|
|
77
|
+
options.signal?.removeEventListener("abort", abort)
|
|
78
|
+
reject(error)
|
|
79
|
+
})
|
|
80
|
+
child.on("close", (exitCode, signal) => {
|
|
81
|
+
options.signal?.removeEventListener("abort", abort)
|
|
82
|
+
resolve({ exitCode, signal, stdout, stderr })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return { child, result }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildEnv(overrides: Record<string, string | undefined> | undefined): NodeJS.ProcessEnv {
|
|
90
|
+
const env: NodeJS.ProcessEnv = { ...process.env }
|
|
91
|
+
if (!overrides) return env
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
94
|
+
if (value === undefined) delete env[key]
|
|
95
|
+
else env[key] = value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return env
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isMissingCommandError(error: unknown): boolean {
|
|
102
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
|
|
103
|
+
}
|