Skip to content

Commit 9b0e6ca

Browse files
committed
packages/
1 parent 3fb7962 commit 9b0e6ca

File tree

3 files changed

+284
-4
lines changed

3 files changed

+284
-4
lines changed

packages/lib/src/usecases/actions/create-project.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ensureDockerDaemonAccess } from "../../shell/docker.js"
1111
import { CommandFailedError } from "../../shell/errors.js"
1212
import type {
1313
AgentFailedError,
14+
AuthError,
1415
CloneFailedError,
1516
DockerAccessError,
1617
DockerCommandError,
@@ -21,6 +22,7 @@ import { logDockerAccessInfo } from "../access-log.js"
2122
import { resolveAutoAgentMode } from "../agent-auto-select.js"
2223
import { renderError } from "../errors.js"
2324
import { applyGithubForkConfig } from "../github-fork.js"
25+
import { validateGithubCloneAuthTokenPreflight } from "../github-token-preflight.js"
2426
import { defaultProjectsRoot } from "../menu-helpers.js"
2527
import { findSshPrivateKey } from "../path-helpers.js"
2628
import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js"
@@ -38,6 +40,7 @@ type CreateProjectError =
3840
| FileExistsError
3941
| CloneFailedError
4042
| AgentFailedError
43+
| AuthError
4144
| DockerAccessError
4245
| DockerCommandError
4346
| PortProbeError
@@ -66,15 +69,14 @@ const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): Create
6669
})
6770

6871
const resolveCreateConfig = (
69-
command: CreateCommand,
70-
ctx: CreateContext,
72+
rootedConfig: CreateCommand["config"],
7173
resolvedOutDir: string
7274
): Effect.Effect<
7375
CreateCommand["config"],
7476
PortProbeError | PlatformError,
7577
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
7678
> =>
77-
resolveSshPort(resolveRootedConfig(command, ctx), resolvedOutDir).pipe(
79+
resolveSshPort(rootedConfig, resolvedOutDir).pipe(
7880
Effect.flatMap((config) => applyGithubForkConfig(config)),
7981
Effect.flatMap((config) => resolveTemplateResourceLimits(config))
8082
)
@@ -245,8 +247,11 @@ const runCreateProject = (
245247

246248
const ctx = makeCreateContext(path, process.cwd())
247249
const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir))
250+
const rootedConfig = resolveRootedConfig(command, ctx)
251+
252+
yield* _(validateGithubCloneAuthTokenPreflight(rootedConfig))
248253

249-
const resolvedConfig = yield* _(resolveCreateConfig(command, ctx, resolvedOutDir))
254+
const resolvedConfig = yield* _(resolveCreateConfig(rootedConfig, resolvedOutDir))
250255
const finalConfig = yield* _(resolveFinalAgentConfig(resolvedConfig))
251256
const { globalConfig, projectConfig } = buildProjectConfigs(path, ctx.baseDir, resolvedOutDir, finalConfig)
252257

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { FetchHttpClient, HttpClient } from "@effect/platform"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import { Effect } from "effect"
5+
6+
import type { TemplateConfig } from "../core/domain.js"
7+
import { parseGithubRepoUrl } from "../core/repo.js"
8+
import { normalizeGitTokenLabel } from "../core/token-labels.js"
9+
import { AuthError } from "../shell/errors.js"
10+
import { findEnvValue, readEnvText } from "./env-file.js"
11+
12+
const githubTokenValidationUrl = "https://api.github.com/user"
13+
const githubTokenValidationWarning = "Unable to validate GitHub token before start; continuing."
14+
export const githubInvalidTokenMessage =
15+
"GitHub token is invalid. Register GitHub again: docker-git auth github login --web"
16+
17+
const defaultGithubTokenKeys: ReadonlyArray<string> = [
18+
"GIT_AUTH_TOKEN",
19+
"GITHUB_TOKEN",
20+
"GH_TOKEN"
21+
]
22+
23+
const findFirstEnvValue = (input: string, keys: ReadonlyArray<string>): string | null => {
24+
for (const key of keys) {
25+
const value = findEnvValue(input, key)
26+
if (value !== null) {
27+
return value
28+
}
29+
}
30+
return null
31+
}
32+
33+
const resolvePreferredGithubTokenLabel = (
34+
config: Pick<TemplateConfig, "repoUrl" | "gitTokenLabel">
35+
): string | undefined => {
36+
const explicit = normalizeGitTokenLabel(config.gitTokenLabel)
37+
if (explicit !== undefined) {
38+
return explicit
39+
}
40+
41+
const repo = parseGithubRepoUrl(config.repoUrl)
42+
if (repo === null) {
43+
return undefined
44+
}
45+
46+
return normalizeGitTokenLabel(repo.owner)
47+
}
48+
49+
// CHANGE: resolve the GitHub token that clone will actually use for a repo URL
50+
// WHY: preflight must validate the same labeled/default token selection as the entrypoint
51+
// QUOTE(ТЗ): "ПУсть всегда проверяет токен гитхаба перед запуском"
52+
// REF: user-request-2026-03-19-github-token-preflight
53+
// SOURCE: n/a
54+
// FORMAT THEOREM: ∀cfg,env: resolve(cfg, env) = token_clone(cfg, env) ∨ null
55+
// PURITY: CORE
56+
// INVARIANT: labeled token has priority; falls back to default token keys
57+
// COMPLEXITY: O(k) where k = |token keys|
58+
export const resolveGithubCloneAuthToken = (
59+
envText: string,
60+
config: Pick<TemplateConfig, "repoUrl" | "gitTokenLabel">
61+
): string | null => {
62+
if (parseGithubRepoUrl(config.repoUrl) === null) {
63+
return null
64+
}
65+
66+
const preferredLabel = resolvePreferredGithubTokenLabel(config)
67+
if (preferredLabel !== undefined) {
68+
const labeledKeys = defaultGithubTokenKeys.map((key) => `${key}__${preferredLabel}`)
69+
const labeledToken = findFirstEnvValue(envText, labeledKeys)
70+
if (labeledToken !== null) {
71+
return labeledToken
72+
}
73+
}
74+
75+
return findFirstEnvValue(envText, defaultGithubTokenKeys)
76+
}
77+
78+
type GithubTokenValidationStatus = "valid" | "invalid" | "unknown"
79+
80+
const unknownGithubTokenValidationStatus = (): GithubTokenValidationStatus => "unknown"
81+
82+
const mapGithubTokenValidationStatus = (status: number): GithubTokenValidationStatus => {
83+
if (status === 401) {
84+
return "invalid"
85+
}
86+
return status >= 200 && status < 300 ? "valid" : "unknown"
87+
}
88+
89+
const validateGithubTokenStatus = (token: string): Effect.Effect<GithubTokenValidationStatus> =>
90+
Effect.gen(function*(_) {
91+
const client = yield* _(HttpClient.HttpClient)
92+
const response = yield* _(
93+
client.get(githubTokenValidationUrl, {
94+
headers: {
95+
Authorization: `Bearer ${token}`,
96+
Accept: "application/vnd.github+json"
97+
}
98+
})
99+
)
100+
return mapGithubTokenValidationStatus(response.status)
101+
}).pipe(
102+
Effect.provide(FetchHttpClient.layer),
103+
Effect.match({
104+
onFailure: unknownGithubTokenValidationStatus,
105+
onSuccess: (status) => status
106+
})
107+
)
108+
109+
// CHANGE: validate GitHub auth token before clone/create starts mutating the project
110+
// WHY: dead tokens make git clone fail later with a misleading branch/auth error inside the container
111+
// QUOTE(ТЗ): "Если токен мёртв то пусть пишет что надо зарегистрировать github используй docker-git auth github login --web"
112+
// REF: user-request-2026-03-19-github-token-preflight
113+
// SOURCE: n/a
114+
// FORMAT THEOREM: ∀cfg: invalid_token(cfg) → fail_before_start(cfg)
115+
// PURITY: SHELL
116+
// EFFECT: Effect<void, AuthError | PlatformError, FileSystem>
117+
// INVARIANT: only GitHub repo URLs with a configured token are validated
118+
// COMPLEXITY: O(|env|) + O(1) network round-trip
119+
export const validateGithubCloneAuthTokenPreflight = (
120+
config: Pick<TemplateConfig, "repoUrl" | "gitTokenLabel" | "envGlobalPath">
121+
): Effect.Effect<void, AuthError | PlatformError, FileSystem.FileSystem> =>
122+
Effect.gen(function*(_) {
123+
const fs = yield* _(FileSystem.FileSystem)
124+
const envText = yield* _(readEnvText(fs, config.envGlobalPath))
125+
const token = resolveGithubCloneAuthToken(envText, config)
126+
127+
if (token === null) {
128+
return
129+
}
130+
131+
const status = yield* _(validateGithubTokenStatus(token))
132+
if (status === "invalid") {
133+
return yield* _(Effect.fail(new AuthError({ message: githubInvalidTokenMessage })))
134+
}
135+
if (status === "unknown") {
136+
yield* _(Effect.logWarning(githubTokenValidationWarning))
137+
}
138+
})
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as FileSystem from "@effect/platform/FileSystem"
2+
import * as Path from "@effect/platform/Path"
3+
import { NodeContext } from "@effect/platform-node"
4+
import { describe, expect, it } from "@effect/vitest"
5+
import { Effect } from "effect"
6+
import { vi } from "vitest"
7+
8+
import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js"
9+
import { createProject } from "../../src/usecases/actions/create-project.js"
10+
import {
11+
githubInvalidTokenMessage,
12+
resolveGithubCloneAuthToken
13+
} from "../../src/usecases/github-token-preflight.js"
14+
15+
const withTempDir = <A, E, R>(
16+
use: (tempDir: string) => Effect.Effect<A, E, R>
17+
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
18+
Effect.scoped(
19+
Effect.gen(function*(_) {
20+
const fs = yield* _(FileSystem.FileSystem)
21+
const tempDir = yield* _(
22+
fs.makeTempDirectoryScoped({
23+
prefix: "docker-git-github-token-preflight-"
24+
})
25+
)
26+
return yield* _(use(tempDir))
27+
})
28+
)
29+
30+
const withPatchedFetch = <A, E, R>(
31+
fetchImpl: typeof globalThis.fetch,
32+
effect: Effect.Effect<A, E, R>
33+
): Effect.Effect<A, E, R> =>
34+
Effect.acquireUseRelease(
35+
Effect.sync(() => {
36+
const previous = globalThis.fetch
37+
globalThis.fetch = fetchImpl
38+
return previous
39+
}),
40+
() => effect,
41+
(previous) =>
42+
Effect.sync(() => {
43+
globalThis.fetch = previous
44+
})
45+
)
46+
47+
const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => {
48+
const template: TemplateConfig = {
49+
containerName: "dg-test",
50+
serviceName: "dg-test",
51+
sshUser: "dev",
52+
sshPort: 2222,
53+
repoUrl: "https://github.com/TelegramGPT/go-login-ozon.git",
54+
repoRef: "main",
55+
targetDir: "/home/dev/workspaces/telegramgpt/go-login-ozon",
56+
volumeName: "dg-test-home",
57+
dockerGitPath: path.join(root, ".docker-git"),
58+
authorizedKeysPath: path.join(root, "authorized_keys"),
59+
envGlobalPath: path.join(root, ".orch/env/global.env"),
60+
envProjectPath: path.join(root, ".orch/env/project.env"),
61+
codexAuthPath: path.join(root, ".orch/auth/codex"),
62+
codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"),
63+
codexHome: "/home/dev/.codex",
64+
dockerNetworkMode: "shared",
65+
dockerSharedNetworkName: "docker-git-shared",
66+
enableMcpPlaywright: false,
67+
pnpmVersion: "10.27.0"
68+
}
69+
70+
return {
71+
_tag: "Create",
72+
config: template,
73+
outDir,
74+
runUp: false,
75+
openSsh: false,
76+
force: true,
77+
forceEnv: false,
78+
waitForClone: true
79+
}
80+
}
81+
82+
describe("github token preflight", () => {
83+
it("prefers the owner-labeled token over the default token", () => {
84+
const envText = [
85+
"# docker-git env",
86+
"GITHUB_TOKEN=default-token",
87+
"GITHUB_TOKEN__TELEGRAMGPT=labeled-token",
88+
""
89+
].join("\n")
90+
91+
const token = resolveGithubCloneAuthToken(envText, {
92+
repoUrl: "https://github.com/TelegramGPT/go-login-ozon.git",
93+
gitTokenLabel: undefined
94+
})
95+
96+
expect(token).toBe("labeled-token")
97+
})
98+
99+
it.effect("fails createProject before writing files when the selected GitHub token is invalid", () =>
100+
withTempDir((root) =>
101+
Effect.gen(function*(_) {
102+
const fs = yield* _(FileSystem.FileSystem)
103+
const path = yield* _(Path.Path)
104+
const outDir = path.join(root, "project")
105+
const command = makeCommand(root, outDir, path)
106+
const fetchMock = vi.fn<typeof globalThis.fetch>(() =>
107+
Effect.runPromise(Effect.succeed(new Response(null, { status: 401 })))
108+
)
109+
110+
yield* _(fs.makeDirectory(path.join(root, ".orch", "env"), { recursive: true }))
111+
yield* _(
112+
fs.writeFileString(
113+
command.config.envGlobalPath,
114+
[
115+
"# docker-git env",
116+
"GITHUB_TOKEN=dead-token",
117+
""
118+
].join("\n")
119+
)
120+
)
121+
122+
const error = yield* _(
123+
withPatchedFetch(
124+
fetchMock,
125+
createProject(command).pipe(Effect.flip)
126+
)
127+
)
128+
129+
expect(error._tag).toBe("AuthError")
130+
expect(error.message).toBe(githubInvalidTokenMessage)
131+
expect(fetchMock).toHaveBeenCalledTimes(1)
132+
133+
const outDirExists = yield* _(fs.exists(outDir))
134+
expect(outDirExists).toBe(false)
135+
})
136+
).pipe(Effect.provide(NodeContext.layer)))
137+
})

0 commit comments

Comments
 (0)