Skip to content

Commit eb80bbf

Browse files
committed
fix(shell): allow default local api url bootstrap
1 parent ac93a7b commit eb80bbf

9 files changed

Lines changed: 224 additions & 53 deletions

File tree

packages/api/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ glance and the CLI now distinguishes them in its error output:
3838
(`docker` group / rootless Docker / socket ownership). This is a host
3939
configuration problem, not a `docker-git` outage.
4040
- **Controller container not running / unreachable** – the API at
41-
`DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer.
42-
Bring the controller up with `docker compose up -d --build` or point the
43-
CLI at an existing controller via `DOCKER_GIT_API_URL`.
41+
a custom `DOCKER_GIT_API_URL` does not answer. Bring the controller up
42+
with `docker compose up -d --build` or point the CLI at an existing
43+
controller via `DOCKER_GIT_API_URL`. The default local value
44+
(`http://127.0.0.1:3334`, `http://localhost:3334`, or `http://[::1]:3334`)
45+
does not block local Docker bootstrap.
4446

4547
Diagnostic classification + remediation messages live in
4648
`packages/app/src/docker-git/controller-docker-diagnostics.ts` and are

packages/app/src/docker-git/browser-frontend.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
shouldReuseBrowserFrontend
1414
} from "./browser-frontend-state.js"
1515
import { findReachableApiBaseUrl } from "./controller-health.js"
16-
import { resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl } from "./controller-reachability.js"
16+
import {
17+
resolveConfiguredApiBaseUrl,
18+
resolveDefaultLocalApiBaseUrl,
19+
resolveExplicitApiBaseUrl,
20+
uniqueStrings
21+
} from "./controller-reachability.js"
1722
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js"
1823
import {
1924
runCommandCapture,
@@ -153,19 +158,19 @@ const readBrowserFrontendRuntimeState = (
153158
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
154159
// REF: PR #344 E2E (Browser command) regression.
155160
// SOURCE: n/a
156-
// FORMAT THEOREM: explicit_api -> explicit_api; reachable(configured_api) -> configured_api; otherwise -> selected_api
161+
// FORMAT THEOREM: strict_explicit_api -> strict_explicit_api; reachable(local_api) -> local_api; otherwise -> selected_api
157162
// PURITY: SHELL
158163
// EFFECT: Effect<string, never, ControllerRuntime>
159-
// INVARIANT: explicit DOCKER_GIT_API_URL is never overridden by auto-discovery.
164+
// INVARIANT: strict explicit DOCKER_GIT_API_URL is never overridden by auto-discovery.
160165
// COMPLEXITY: O(1) probes/O(1) space.
161166
/**
162167
* Resolves the API URL used by the browser frontend proxy.
163168
*
164-
* @returns Effect with the explicit API URL, the reachable configured host URL, or the selected controller URL.
169+
* @returns Effect with the strict explicit API URL, a reachable local host URL, or the selected controller URL.
165170
*
166171
* @pure false
167172
* @effect FetchHttpClient through controller health probing.
168-
* @invariant Explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints.
173+
* @invariant Strict explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints.
169174
* @precondition `ensureControllerReady` has already completed for inferred endpoints.
170175
* @postcondition A configured host URL is used only after a successful health probe.
171176
* @complexity O(1) time and O(1) space for the bounded candidate set.
@@ -179,11 +184,15 @@ const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect<string, never, Contro
179184
}
180185

181186
const configuredApiBaseUrl = resolveConfiguredApiBaseUrl()
182-
if (configuredApiBaseUrl === selectedApiBaseUrl) {
187+
const candidateApiBaseUrls = uniqueStrings([
188+
resolveDefaultLocalApiBaseUrl() ?? "",
189+
configuredApiBaseUrl
190+
].filter((value) => value.length > 0))
191+
if (candidateApiBaseUrls.includes(selectedApiBaseUrl)) {
183192
return Effect.succeed(selectedApiBaseUrl)
184193
}
185194

186-
return findReachableApiBaseUrl([configuredApiBaseUrl]).pipe(
195+
return findReachableApiBaseUrl(candidateApiBaseUrls).pipe(
187196
Effect.match({
188197
onFailure: () => selectedApiBaseUrl,
189198
onSuccess: (apiBaseUrl) => apiBaseUrl

packages/app/src/docker-git/controller-health.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ export const findReachableApiBaseUrl = (
100100

101101
export const findReachableDirectHealthProbe = (options: {
102102
readonly explicitApiBaseUrl: string | undefined
103+
readonly defaultLocalApiBaseUrl: string | undefined
103104
readonly cachedApiBaseUrl: string | undefined
104105
}): Effect.Effect<HealthProbeResult | null> =>
105106
findReachableHealthProbeOrNull(
106107
buildApiBaseUrlCandidates({
107108
explicitApiBaseUrl: options.explicitApiBaseUrl,
109+
defaultLocalApiBaseUrl: options.defaultLocalApiBaseUrl,
108110
cachedApiBaseUrl: options.cachedApiBaseUrl,
109111
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
110112
currentContainerNetworks: {},

packages/app/src/docker-git/controller-reachability.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type DockerNetworkIps = Readonly<Record<string, string>>
55

66
export type ApiBaseUrlCandidatesInput = {
77
readonly explicitApiBaseUrl?: string | undefined
8+
readonly defaultLocalApiBaseUrl?: string | undefined
89
readonly cachedApiBaseUrl?: string | undefined
910
readonly defaultApiBaseUrl: string
1011
readonly currentContainerNetworks: DockerNetworkIps
@@ -28,11 +29,69 @@ const normalizePort = (value: string | undefined): string => {
2829
return trimmed.length > 0 ? trimmed : defaultApiPort
2930
}
3031

32+
const normalizeApiBaseUrl = (value: string | undefined): string | undefined => {
33+
const trimmed = value?.trim()
34+
return trimmed !== undefined && trimmed.length > 0 ? trimTrailingSlashes(trimmed) : undefined
35+
}
36+
3137
export const resolveApiPort = (): string => normalizePort(process.env["DOCKER_GIT_API_PORT"])
3238

39+
const defaultLocalHostnames = new Set(["127.0.0.1", "localhost", "[::1]"])
40+
41+
const isRootApiUrlPath = (url: URL): boolean => url.pathname === "/" && url.search.length === 0 && url.hash.length === 0
42+
43+
const isDefaultLocalApiUrlObject = (url: URL, port: string): boolean =>
44+
url.protocol === "http:" &&
45+
defaultLocalHostnames.has(url.hostname) &&
46+
url.port === port &&
47+
isRootApiUrlPath(url)
48+
49+
// CHANGE: classify default localhost API URLs as non-strict bootstrap hints.
50+
// WHY: Windows shells can persist DOCKER_GIT_API_URL=http://127.0.0.1:3334, which should not block local controller startup.
51+
// QUOTE(ТЗ): "сделать из коробки что бы всё само работало"
52+
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
53+
// SOURCE: n/a
54+
// FORMAT THEOREM: local_http(url, port) and empty(path, query, hash) -> default_local(url)
55+
// PURITY: CORE
56+
// EFFECT: n/a
57+
// INVARIANT: only localhost loopback HTTP URLs on the configured API port are default-local.
58+
// COMPLEXITY: O(n) where n = |value|.
59+
export const isDefaultLocalApiBaseUrl = (value: string, port = resolveApiPort()): boolean => {
60+
const normalized = normalizeApiBaseUrl(value)
61+
if (normalized === undefined || !URL.canParse(normalized)) {
62+
return false
63+
}
64+
return isDefaultLocalApiUrlObject(new URL(normalized), port)
65+
}
66+
67+
// CHANGE: preserve default-local DOCKER_GIT_API_URL as an endpoint candidate instead of a strict override.
68+
// WHY: a stale default localhost env var should still allow compose bootstrap when nothing is listening yet.
69+
// QUOTE(ТЗ): "fallback только для дефолтного localhost URL"
70+
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
71+
// SOURCE: n/a
72+
// FORMAT THEOREM: default_local(env) -> env; otherwise -> undefined
73+
// PURITY: SHELL
74+
// EFFECT: reads process.env
75+
// INVARIANT: custom DOCKER_GIT_API_URL values are never returned here.
76+
// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|.
77+
export const resolveDefaultLocalApiBaseUrl = (): string | undefined => {
78+
const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"])
79+
return explicit !== undefined && isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined
80+
}
81+
82+
// CHANGE: treat only custom DOCKER_GIT_API_URL values as strict explicit controller endpoints.
83+
// WHY: custom remote backends should fail loudly when unreachable, while default localhost should bootstrap locally.
84+
// QUOTE(ТЗ): "кастомные URL остаются строгими"
85+
// REF: user-request-2026-05-29-default-local-api-url-bootstrap
86+
// SOURCE: n/a
87+
// FORMAT THEOREM: nonempty(env) and not default_local(env) -> env; otherwise -> undefined
88+
// PURITY: SHELL
89+
// EFFECT: reads process.env
90+
// INVARIANT: default-local URLs do not block local bootstrap.
91+
// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|.
3392
export const resolveExplicitApiBaseUrl = (): string | undefined => {
34-
const explicit = process.env["DOCKER_GIT_API_URL"]?.trim()
35-
return explicit !== undefined && explicit.length > 0 ? trimTrailingSlashes(explicit) : undefined
93+
const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"])
94+
return explicit !== undefined && !isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined
3695
}
3796

3897
export const resolveConfiguredApiBaseUrl = (): string => {
@@ -126,6 +185,7 @@ export const buildApiBaseUrlCandidates = ({
126185
controllerNetworks,
127186
currentContainerNetworks,
128187
defaultApiBaseUrl,
188+
defaultLocalApiBaseUrl,
129189
explicitApiBaseUrl,
130190
port
131191
}: ApiBaseUrlCandidatesInput): ReadonlyArray<string> => {
@@ -150,6 +210,7 @@ export const buildApiBaseUrlCandidates = ({
150210

151211
return uniqueStrings(
152212
[
213+
defaultLocalApiBaseUrl ?? "",
153214
cachedApiBaseUrl ?? "",
154215
defaultApiBaseUrl,
155216
resolveControllerDnsApiBaseUrl(),

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

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
import { Duration, Effect, pipe, Schedule } from "effect"
22

33
import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./controller-bootstrap-plan.js"
4-
import {
5-
controllerContainerName,
6-
controllerExists,
7-
type ControllerRuntime,
8-
ensureControllerReachabilityNetworks,
9-
inspectContainerNetworks,
10-
inspectControllerPublishedPorts,
11-
inspectControllerRevision,
12-
prepareLocalControllerRevision,
13-
resolveCurrentContainerNetworks,
14-
runCompose
15-
} from "./controller-docker.js"
4+
import * as ControllerDocker from "./controller-docker.js"
165
import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js"
176
import { inspectControllerImageRevision } from "./controller-image-revision.js"
187
import {
@@ -21,6 +10,7 @@ import {
2110
formatNetworkIps,
2211
resolveApiPort,
2312
resolveConfiguredApiBaseUrl,
13+
resolveDefaultLocalApiBaseUrl,
2414
resolveExplicitApiBaseUrl,
2515
shouldRequireExplicitApiUrlForRemoteDocker,
2616
trimTrailingSlashes
@@ -43,6 +33,8 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError =>
4333
message
4434
})
4535

36+
type ControllerEffect<A> = Effect.Effect<A, ControllerBootstrapError, ControllerDocker.ControllerRuntime>
37+
4638
const rememberSelectedApiBaseUrl = (value: string): void => {
4739
selectedApiBaseUrl = trimTrailingSlashes(value)
4840
}
@@ -54,9 +46,9 @@ const collectReachabilityDiagnostics = (
5446
candidateUrls: ReadonlyArray<string>,
5547
currentContainerNetworks: DockerNetworkIps,
5648
controllerNetworks: DockerNetworkIps
57-
): Effect.Effect<string, never, ControllerRuntime> =>
49+
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
5850
Effect.gen(function*(_) {
59-
const publishedPorts = yield* _(inspectControllerPublishedPorts())
51+
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())
6052

6153
return [
6254
"Tried endpoints:",
@@ -71,7 +63,7 @@ const waitForReachableApiBaseUrl = (
7163
candidateUrls: ReadonlyArray<string>,
7264
currentContainerNetworks: DockerNetworkIps,
7365
controllerNetworks: DockerNetworkIps
74-
): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
66+
): ControllerEffect<string> =>
7567
pipe(
7668
findReachableApiBaseUrl(candidateUrls),
7769
Effect.retry(
@@ -160,21 +152,19 @@ type ControllerBootstrapContext = {
160152
readonly initialControllerNetworks: DockerNetworkIps
161153
}
162154

163-
const loadControllerBootstrapContext = (): Effect.Effect<
164-
ControllerBootstrapContext,
165-
ControllerBootstrapError,
166-
ControllerRuntime
167-
> =>
155+
const loadControllerBootstrapContext = (): ControllerEffect<ControllerBootstrapContext> =>
168156
Effect.gen(function*(_) {
169157
yield* _(prepareControllerRuntimeEnv())
170158
yield* _(prepareControllerResourceLimitEnv())
171159
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
172-
const localControllerRevision = yield* _(prepareLocalControllerRevision())
173-
const currentControllerExists = yield* _(controllerExists())
174-
const currentControllerRevision = yield* _(inspectControllerRevision())
160+
const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision())
161+
const currentControllerExists = yield* _(ControllerDocker.controllerExists())
162+
const currentControllerRevision = yield* _(ControllerDocker.inspectControllerRevision())
175163
const currentImageRevision = yield* _(inspectControllerImageRevision())
176-
const currentContainerNetworks = yield* _(resolveCurrentContainerNetworks())
177-
const initialControllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName))
164+
const currentContainerNetworks = yield* _(ControllerDocker.resolveCurrentContainerNetworks())
165+
const initialControllerNetworks = yield* _(
166+
ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName)
167+
)
178168
const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
179169
const forceRecreateController = forceRecreateForResourceLimits ||
180170
shouldForceRecreateController(currentControllerExists, localControllerRevision, currentControllerRevision)
@@ -203,6 +193,7 @@ const buildBootstrapCandidateUrls = (
203193
): ReadonlyArray<string> =>
204194
buildApiBaseUrlCandidates({
205195
explicitApiBaseUrl,
196+
defaultLocalApiBaseUrl: resolveDefaultLocalApiBaseUrl(),
206197
cachedApiBaseUrl: selectedApiBaseUrl,
207198
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
208199
currentContainerNetworks,
@@ -243,16 +234,18 @@ const logControllerStart = (
243234

244235
const startAndRememberController = (
245236
context: ControllerBootstrapContext
246-
): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
237+
): ControllerEffect<void> =>
247238
Effect.gen(function*(_) {
248239
if (context.forceRecreateController || context.buildController) {
249240
yield* _(logControllerStart(context))
250241
}
251242

252-
yield* _(runCompose(resolveControllerComposeUpArgs(context)))
253-
yield* _(ensureControllerReachabilityNetworks(context.currentContainerNetworks))
243+
yield* _(ControllerDocker.runCompose(resolveControllerComposeUpArgs(context)))
244+
yield* _(ControllerDocker.ensureControllerReachabilityNetworks(context.currentContainerNetworks))
254245

255-
const controllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName))
246+
const controllerNetworks = yield* _(
247+
ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName)
248+
)
256249
const candidateUrls = buildBootstrapCandidateUrls(
257250
context.explicitApiBaseUrl,
258251
context.currentContainerNetworks,
@@ -274,12 +267,17 @@ const startAndRememberController = (
274267
// EFFECT: Effect<void, ControllerBootstrapError, CommandExecutor>
275268
// INVARIANT: controller is reachable from the current runtime before any host API dispatch
276269
// COMPLEXITY: O(1) compose + O(k) health checks
277-
export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
270+
export const ensureControllerReady = (): ControllerEffect<void> =>
278271
Effect.gen(function*(_) {
279272
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
273+
const defaultLocalApiBaseUrl = resolveDefaultLocalApiBaseUrl()
280274
if (explicitApiBaseUrl !== undefined) {
281275
const reachableBeforeDocker = yield* _(
282-
findReachableDirectHealthProbe({ explicitApiBaseUrl, cachedApiBaseUrl: selectedApiBaseUrl })
276+
findReachableDirectHealthProbe({
277+
cachedApiBaseUrl: selectedApiBaseUrl,
278+
defaultLocalApiBaseUrl,
279+
explicitApiBaseUrl
280+
})
283281
)
284282
if (reachableBeforeDocker !== null) {
285283
rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl)
@@ -288,12 +286,13 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
288286
yield* _(failIfExplicitApiUrlIsUnreachable(explicitApiBaseUrl))
289287
}
290288

291-
const localControllerRevision = yield* _(prepareLocalControllerRevision())
289+
const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision())
292290
const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
293291
const reachableBeforeDocker = yield* _(
294292
findReachableDirectHealthProbe({
295-
explicitApiBaseUrl,
296-
cachedApiBaseUrl: selectedApiBaseUrl
293+
cachedApiBaseUrl: selectedApiBaseUrl,
294+
defaultLocalApiBaseUrl,
295+
explicitApiBaseUrl
297296
})
298297
)
299298
if (
@@ -314,7 +313,7 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
314313
yield* _(startAndRememberController(bootstrapContext))
315314
})
316315

317-
export const restartController = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
316+
export const restartController = (): ControllerEffect<void> =>
318317
Effect.gen(function*(_) {
319318
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
320319
if (explicitApiBaseUrl !== undefined) {

0 commit comments

Comments
 (0)