Skip to content

Commit 8ccb732

Browse files
committed
fix(lib): normalize ~ paths in docker-git project and scrap flows
1 parent 9e2ddef commit 8ccb732

File tree

12 files changed

+69
-34
lines changed

12 files changed

+69
-34
lines changed

packages/app/src/docker-git/cli/parser-clone.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import { Either } from "effect"
22

33
import { buildCreateCommand, nonEmpty } from "@effect-template/lib/core/command-builders"
44
import type { RawOptions } from "@effect-template/lib/core/command-options"
5-
import {
6-
type Command,
7-
defaultTemplateConfig,
8-
type ParseError,
9-
resolveRepoInput
10-
} from "@effect-template/lib/core/domain"
5+
import { type Command, type ParseError, resolveRepoInput } from "@effect-template/lib/core/domain"
116

127
import { parseRawOptions } from "./parser-options.js"
138
import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js"
@@ -18,13 +13,12 @@ const applyCloneDefaults = (
1813
resolvedRepo: ReturnType<typeof resolveRepoInput>
1914
): RawOptions => {
2015
const repoPath = resolveWorkspaceRepoPath(resolvedRepo)
21-
const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser
22-
const homeDir = `/home/${sshUser}`
16+
const targetHome = "~"
2317
return {
2418
...raw,
2519
repoUrl: rawRepoUrl,
2620
outDir: raw.outDir ?? `.docker-git/${repoPath}`,
27-
targetDir: raw.targetDir ?? `${homeDir}/${repoPath}`
21+
targetDir: raw.targetDir ?? `${targetHome}/.docker-git/workspaces/${repoPath}`
2822
}
2923
}
3024

packages/app/src/docker-git/cli/usage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Commands:
3434
Options:
3535
--repo-ref <ref> Git ref/branch (default: main)
3636
--branch, -b <ref> Alias for --repo-ref
37-
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: /home/dev/<org>/<repo>[/issue-<id>|/pr-<id>])
37+
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: ~/.docker-git/workspaces/<org>/<repo>[/issue-<id>|/pr-<id>])
3838
--ssh-port <port> Local SSH port (default: 2222)
3939
--ssh-user <user> SSH user inside container (default: dev)
4040
--container-name <name> Docker container name (default: dg-<repo>)

packages/app/tests/docker-git/parser.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("parseArgs", () => {
8383
expectCreateDefaults(command)
8484
expect(command.openSsh).toBe(true)
8585
expect(command.waitForClone).toBe(true)
86-
expect(command.config.targetDir).toBe("/home/dev/org/repo")
86+
expect(command.config.targetDir).toBe("~/.docker-git/workspaces/org/repo")
8787
}))
8888

8989
it.effect("parses clone branch alias", () =>
@@ -118,15 +118,15 @@ describe("parseArgs", () => {
118118
expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git")
119119
expect(command.config.repoRef).toBe("vova-fork")
120120
expect(command.outDir).toBe(".docker-git/agiens/crm")
121-
expect(command.config.targetDir).toBe("/home/dev/agiens/crm")
121+
expect(command.config.targetDir).toBe("~/.docker-git/workspaces/agiens/crm")
122122
}))
123123

124124
it.effect("parses GitHub issue url as isolated project + issue branch", () =>
125125
expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => {
126126
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
127127
expect(command.config.repoRef).toBe("issue-5")
128128
expect(command.outDir).toBe(".docker-git/org/repo/issue-5")
129-
expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5")
129+
expect(command.config.targetDir).toBe("~/.docker-git/workspaces/org/repo/issue-5")
130130
expect(command.config.containerName).toBe("dg-repo-issue-5")
131131
expect(command.config.serviceName).toBe("dg-repo-issue-5")
132132
expect(command.config.volumeName).toBe("dg-repo-issue-5-home")
@@ -137,7 +137,7 @@ describe("parseArgs", () => {
137137
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
138138
expect(command.config.repoRef).toBe("refs/pull/42/head")
139139
expect(command.outDir).toBe(".docker-git/org/repo/pr-42")
140-
expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42")
140+
expect(command.config.targetDir).toBe("~/.docker-git/workspaces/org/repo/pr-42")
141141
expect(command.config.containerName).toBe("dg-repo-pr-42")
142142
expect(command.config.serviceName).toBe("dg-repo-pr-42")
143143
expect(command.config.volumeName).toBe("dg-repo-pr-42-home")

packages/docker-git/src/server/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor
890890
})
891891
),
892892
Effect.flatMap(({ project, globalEnv, projectEnv, integrationsEnv, codexAccounts, codexProject }) =>
893-
Effect.gen(function* (_) {
893+
Effect.sync(() => {
894894
const githubTokenEntries = countKeyEntries(integrationsEnv, "GITHUB_TOKEN")
895895
const gitTokenEntries = countKeyEntries(integrationsEnv, "GIT_AUTH_TOKEN")
896896
const claudeKeyEntries = countKeyEntries(integrationsEnv, "ANTHROPIC_API_KEY")

packages/lib/src/core/templates-entrypoint/base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ REPO_URL="\${REPO_URL:-}"
99
REPO_REF="\${REPO_REF:-}"
1010
FORK_REPO_URL="\${FORK_REPO_URL:-}"
1111
TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}"
12+
if [[ "$TARGET_DIR" == "~" ]]; then
13+
TARGET_DIR="$HOME"
14+
elif [[ "$TARGET_DIR" == "~/"* ]]; then
15+
TARGET_DIR="$HOME\${TARGET_DIR:1}"
16+
fi
1217
CLAUDE_AUTH_LABEL="\${CLAUDE_AUTH_LABEL:-}"
1318
GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-x-access-token}}"
1419
GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-\${GH_TOKEN:-}}}"

packages/lib/src/usecases/errors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,11 @@ const renderPrimaryError = (error: NonParseError): string | null =>
8686
{ _tag: "ScrapArchiveInvalidError" },
8787
({ message, path }) => `Invalid scrap archive: ${path}\nDetails: ${message}`
8888
),
89-
Match.when({ _tag: "ScrapTargetDirUnsupportedError" }, ({ reason, sshUser, targetDir }) =>
89+
Match.when({ _tag: "ScrapTargetDirUnsupportedError" }, ({ reason, targetDir }) =>
9090
[
9191
`Cannot use scrap with targetDir ${targetDir}.`,
9292
`Reason: ${reason}`,
93-
`Hint: scrap currently supports workspaces under /home/${sshUser}/... only.`
93+
`Hint: scrap currently supports workspaces under the ssh home directory only (for example: ~/repo).`
9494
].join("\n")),
9595
Match.when({ _tag: "ScrapWipeRefusedError" }, ({ reason, targetDir }) =>
9696
[

packages/lib/src/usecases/scrap-common.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CommandFailedError as CommandFailedErrorClass,
1010
ScrapWipeRefusedError as ScrapWipeRefusedErrorClass
1111
} from "../shell/errors.js"
12+
import { expandContainerHome } from "./scrap-path.js"
1213

1314
const dockerOk = [0]
1415

@@ -23,7 +24,7 @@ export type ScrapTemplate = {
2324
export const buildScrapTemplate = (config: ProjectConfig): ScrapTemplate => ({
2425
sshUser: config.template.sshUser,
2526
containerName: config.template.containerName,
26-
targetDir: config.template.targetDir,
27+
targetDir: expandContainerHome(config.template.sshUser, config.template.targetDir),
2728
volumeName: config.template.volumeName,
2829
codexHome: config.template.codexHome
2930
})

packages/lib/src/usecases/scrap-path.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import { ScrapTargetDirUnsupportedError } from "../shell/errors.js"
44

55
const normalizeContainerPath = (value: string): string => value.replaceAll("\\", "/").trim()
66

7+
export const expandContainerHome = (sshUser: string, value: string): string => {
8+
if (value === "~") {
9+
return `/home/${sshUser}`
10+
}
11+
if (value.startsWith("~/")) {
12+
return `/home/${sshUser}${value.slice(1)}`
13+
}
14+
return value
15+
}
16+
717
const trimTrailingPosixSlashes = (value: string): string => {
818
let end = value.length
919
while (end > 0 && value[end - 1] === "/") {
@@ -24,7 +34,9 @@ export const deriveScrapWorkspaceRelativePath = (
2434
sshUser: string,
2535
targetDir: string
2636
): Either.Either<string, ScrapTargetDirUnsupportedError> => {
27-
const normalizedTarget = trimTrailingPosixSlashes(normalizeContainerPath(targetDir))
37+
const normalizedTarget = trimTrailingPosixSlashes(
38+
normalizeContainerPath(expandContainerHome(sshUser, targetDir))
39+
)
2840
const normalizedHome = trimTrailingPosixSlashes(`/home/${sshUser}`)
2941

3042
if (hasParentTraversalSegment(normalizedTarget)) {

packages/lib/src/usecases/scrap-session-export.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,15 @@ const captureRepoInfo = (
7575
ctx: SessionExportContext
7676
): Effect.Effect<SessionRepoInfo, ScrapError, ScrapRequirements> =>
7777
Effect.gen(function*(_) {
78-
const base = `set -e; cd ${shellEscape(ctx.template.targetDir)};`
78+
const targetDir = shellEscape(ctx.template.targetDir)
79+
const base = `set -e; cd ${targetDir};`
7980
const capture = (label: string, cmd: string) =>
8081
runDockerExecCapture(ctx.resolved, label, ctx.template.containerName, `${base} ${cmd}`, ctx.template.sshUser)
8182
.pipe(
8283
Effect.map((value) => value.trim())
8384
)
8485

85-
const safe = shellEscape(ctx.template.targetDir)
86+
const safe = targetDir
8687
const head = yield* _(capture("scrap session rev-parse", `git -c safe.directory=${safe} rev-parse HEAD`))
8788
const branch = yield* _(
8889
capture("scrap session branch", `git -c safe.directory=${safe} rev-parse --abbrev-ref HEAD`)
@@ -120,9 +121,10 @@ const detectRebuildCommands = (
120121
ctx: SessionExportContext
121122
): Effect.Effect<ReadonlyArray<string>, ScrapError, ScrapRequirements> =>
122123
Effect.gen(function*(_) {
124+
const targetDir = shellEscape(ctx.template.targetDir)
123125
const script = [
124126
"set -e",
125-
`cd ${shellEscape(ctx.template.targetDir)}`,
127+
`cd ${targetDir}`,
126128
// Priority: pnpm > npm > yarn. Keep commands deterministic and rebuildable.
127129
"if [ -f pnpm-lock.yaml ]; then echo 'pnpm install --frozen-lockfile'; exit 0; fi",
128130
"if [ -f package-lock.json ]; then echo 'npm ci'; exit 0; fi",
@@ -148,14 +150,15 @@ const exportWorktreePatchChunks = (
148150
ctx: SessionExportContext
149151
): Effect.Effect<string, ScrapError, ScrapRequirements> =>
150152
Effect.gen(function*(_) {
153+
const targetDir = shellEscape(ctx.template.targetDir)
151154
const patchAbs = ctx.path.join(ctx.snapshotDir, "worktree.patch.gz")
152155
const patchPartsPrefix = `${patchAbs}.part`
153156
yield* _(removeChunkArtifacts(ctx.fs, ctx.path, patchAbs))
154157

155158
const patchInner = [
156159
"set -e",
157-
`cd ${shellEscape(ctx.template.targetDir)}`,
158-
`SAFE=${shellEscape(ctx.template.targetDir)}`,
160+
`cd ${targetDir}`,
161+
`SAFE=${targetDir}`,
159162
"TMP_INDEX=$(mktemp)",
160163
"trap 'rm -f \"$TMP_INDEX\"' EXIT",
161164
"GIT_INDEX_FILE=\"$TMP_INDEX\" git -c safe.directory=\"$SAFE\" read-tree HEAD",

packages/lib/src/usecases/scrap-session-import.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,20 @@ const prepareRepoForImport = (
140140
ctx: SessionImportContext,
141141
wipe: boolean
142142
): Effect.Effect<void, ScrapError, ScrapRequirements> => {
143-
const wipeLine = wipe ? `rm -rf ${shellEscape(ctx.template.targetDir)}` : ":"
143+
const targetDir = shellEscape(ctx.template.targetDir)
144+
const wipeLine = wipe ? `rm -rf ${targetDir}` : ":"
144145
const gitDir = `${ctx.template.targetDir}/.git`
145146
const prepScript = [
146147
"set -e",
147148
wipeLine,
148-
`mkdir -p ${shellEscape(ctx.template.targetDir)}`,
149+
`mkdir -p ${targetDir}`,
149150
`if [ ! -d ${shellEscape(gitDir)} ]; then`,
150-
` PARENT=$(dirname ${shellEscape(ctx.template.targetDir)})`,
151+
` PARENT=$(dirname ${targetDir})`,
151152
" mkdir -p \"$PARENT\"",
152-
` git clone ${shellEscape(ctx.manifest.repo.originUrl)} ${shellEscape(ctx.template.targetDir)}`,
153+
` git clone ${shellEscape(ctx.manifest.repo.originUrl)} ${targetDir}`,
153154
"fi",
154-
`cd ${shellEscape(ctx.template.targetDir)}`,
155-
`SAFE=${shellEscape(ctx.template.targetDir)}`,
155+
`cd ${targetDir}`,
156+
`SAFE=${targetDir}`,
156157
"git -c safe.directory=\"$SAFE\" fetch --all --prune",
157158
`git -c safe.directory="$SAFE" checkout --detach ${shellEscape(ctx.manifest.repo.head)}`,
158159
`git -c safe.directory="$SAFE" reset --hard ${shellEscape(ctx.manifest.repo.head)}`,
@@ -174,10 +175,11 @@ const applyWorktreePatch = (ctx: SessionImportContext): Effect.Effect<void, Scra
174175
Effect.gen(function*(_) {
175176
const patchPartsAbs = yield* _(resolveSnapshotPartsAbs(ctx, ctx.manifest.artifacts.worktreePatchChunks))
176177
const patchCatArgs = patchPartsAbs.map((p) => shellEscape(p)).join(" ")
178+
const targetDir = shellEscape(ctx.template.targetDir)
177179
const applyInner = [
178180
"set -e",
179-
`cd ${shellEscape(ctx.template.targetDir)}`,
180-
`SAFE=${shellEscape(ctx.template.targetDir)}`,
181+
`cd ${targetDir}`,
182+
`SAFE=${targetDir}`,
181183
"git -c safe.directory=\"$SAFE\" apply --allow-empty --binary --whitespace=nowarn -"
182184
].join("; ")
183185
const applyScript = [
@@ -225,6 +227,7 @@ const restoreTarChunksIntoContainerDir = (
225227

226228
const runRebuildCommands = (ctx: SessionImportContext): Effect.Effect<void, ScrapError, ScrapRequirements> =>
227229
Effect.gen(function*(_) {
230+
const targetDir = shellEscape(ctx.template.targetDir)
228231
const commands = ctx.manifest.rebuild.commands
229232
if (commands.length === 0) {
230233
return
@@ -236,7 +239,7 @@ const runRebuildCommands = (ctx: SessionImportContext): Effect.Effect<void, Scra
236239
continue
237240
}
238241
yield* _(Effect.log(`Rebuilding: ${trimmed}`))
239-
const script = `set -e; cd ${shellEscape(ctx.template.targetDir)}; ${trimmed}`
242+
const script = `set -e; cd ${targetDir}; ${trimmed}`
240243
yield* _(
241244
runDockerExec(ctx.resolved, "scrap session rebuild", ctx.template.containerName, script, ctx.template.sshUser)
242245
)

0 commit comments

Comments
 (0)