Skip to content

Commit d85fc95

Browse files
committed
feat(scrap): export/import workspace cache archive
1 parent d24de22 commit d85fc95

14 files changed

Lines changed: 608 additions & 56 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface ValueOptionSpec {
2020
| "envProjectPath"
2121
| "codexAuthPath"
2222
| "codexHome"
23+
| "archivePath"
2324
| "label"
2425
| "token"
2526
| "scopes"
@@ -46,6 +47,7 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
4647
{ flag: "--env-project", key: "envProjectPath" },
4748
{ flag: "--codex-auth", key: "codexAuthPath" },
4849
{ flag: "--codex-home", key: "codexHome" },
50+
{ flag: "--archive", key: "archivePath" },
4951
{ flag: "--label", key: "label" },
5052
{ flag: "--token", key: "token" },
5153
{ flag: "--scopes", key: "scopes" },
@@ -69,6 +71,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
6971
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
7072
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
7173
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
74+
"--wipe": (raw) => ({ ...raw, wipe: true }),
75+
"--no-wipe": (raw) => ({ ...raw, wipe: false }),
7276
"--web": (raw) => ({ ...raw, authWeb: true }),
7377
"--include-default": (raw) => ({ ...raw, includeDefault: true })
7478
}
@@ -88,6 +92,7 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
8892
envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
8993
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
9094
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
95+
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
9196
label: (raw, value) => ({ ...raw, label: value }),
9297
token: (raw, value) => ({ ...raw, token: value }),
9398
scopes: (raw, value) => ({ ...raw, scopes: value }),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Either, Match } from "effect"
2+
3+
import type { Command, ParseError } from "@effect-template/lib/core/domain"
4+
5+
import { parseProjectDirWithOptions } from "./parser-shared.js"
6+
7+
const missingRequired = (option: string): ParseError => ({
8+
_tag: "MissingRequiredOption",
9+
option
10+
})
11+
12+
const invalidScrapAction = (value: string): ParseError => ({
13+
_tag: "InvalidOption",
14+
option: "scrap",
15+
reason: `unknown action: ${value}`
16+
})
17+
18+
const defaultArchivePath = ".orch/scrap/workspace.tar.gz"
19+
20+
const makeScrapExportCommand = (projectDir: string, archivePath: string): Command => ({
21+
_tag: "ScrapExport",
22+
projectDir,
23+
archivePath
24+
})
25+
26+
const makeScrapImportCommand = (
27+
projectDir: string,
28+
archivePath: string,
29+
wipe: boolean
30+
): Command => ({
31+
_tag: "ScrapImport",
32+
projectDir,
33+
archivePath,
34+
wipe
35+
})
36+
37+
// CHANGE: parse scrap (workspace cache) export/import commands
38+
// WHY: allow copying docker-git workspace caches (deps, .env, build artifacts) across machines
39+
// QUOTE(ТЗ): "мог копировать скрап (кеш) от докер контейнеров"
40+
// REF: issue-27
41+
// SOURCE: n/a
42+
// FORMAT THEOREM: forall argv: parseScrap(argv) = cmd -> deterministic(cmd)
43+
// PURITY: CORE
44+
// EFFECT: Effect<Command, ParseError, never>
45+
// INVARIANT: export/import always resolves a projectDir
46+
// COMPLEXITY: O(n) where n = |argv|
47+
export const parseScrap = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> => {
48+
const action = args[0]?.trim()
49+
if (!action || action.length === 0) {
50+
return Either.left(missingRequired("scrap <action>"))
51+
}
52+
53+
const rest = args.slice(1)
54+
55+
return Match.value(action).pipe(
56+
Match.when("export", () =>
57+
Either.map(parseProjectDirWithOptions(rest), ({ projectDir, raw }) =>
58+
makeScrapExportCommand(
59+
projectDir,
60+
raw.archivePath?.trim() && raw.archivePath.trim().length > 0
61+
? raw.archivePath.trim()
62+
: defaultArchivePath
63+
))),
64+
Match.when("import", () =>
65+
Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => {
66+
const archivePath = raw.archivePath?.trim()
67+
if (!archivePath || archivePath.length === 0) {
68+
return Either.left(missingRequired("--archive"))
69+
}
70+
return Either.right(makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true))
71+
})),
72+
Match.orElse(() => Either.left(invalidScrapAction(action)))
73+
)
74+
}

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

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseClone } from "./parser-clone.js"
88
import { buildCreateCommand } from "./parser-create.js"
99
import { parseRawOptions } from "./parser-options.js"
1010
import { parsePanes } from "./parser-panes.js"
11+
import { parseScrap } from "./parser-scrap.js"
1112
import { parseSessions } from "./parser-sessions.js"
1213
import { parseState } from "./parser-state.js"
1314
import { usageText } from "./usage.js"
@@ -48,26 +49,28 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
4849
command: command ?? ""
4950
}
5051

51-
return Match.value(command).pipe(
52-
Match.when("create", () => parseCreate(rest)),
53-
Match.when("init", () => parseCreate(rest)),
54-
Match.when("clone", () => parseClone(rest)),
55-
Match.when("attach", () => parseAttach(rest)),
56-
Match.when("tmux", () => parseAttach(rest)),
57-
Match.when("panes", () => parsePanes(rest)),
58-
Match.when("terms", () => parsePanes(rest)),
59-
Match.when("terminals", () => parsePanes(rest)),
60-
Match.when("sessions", () => parseSessions(rest)),
61-
Match.when("help", () => Either.right(helpCommand)),
62-
Match.when("ps", () => Either.right(statusCommand)),
63-
Match.when("status", () => Either.right(statusCommand)),
64-
Match.when("down-all", () => Either.right(downAllCommand)),
65-
Match.when("stop-all", () => Either.right(downAllCommand)),
66-
Match.when("kill-all", () => Either.right(downAllCommand)),
67-
Match.when("menu", () => Either.right(menuCommand)),
68-
Match.when("ui", () => Either.right(menuCommand)),
69-
Match.when("auth", () => parseAuth(rest)),
70-
Match.when("state", () => parseState(rest)),
71-
Match.orElse(() => Either.left(unknownCommandError))
72-
)
52+
return Match.value(command)
53+
.pipe(
54+
Match.when("create", () => parseCreate(rest)),
55+
Match.when("init", () => parseCreate(rest)),
56+
Match.when("clone", () => parseClone(rest)),
57+
Match.when("attach", () => parseAttach(rest)),
58+
Match.when("tmux", () => parseAttach(rest)),
59+
Match.when("panes", () => parsePanes(rest)),
60+
Match.when("terms", () => parsePanes(rest)),
61+
Match.when("terminals", () => parsePanes(rest)),
62+
Match.when("sessions", () => parseSessions(rest)),
63+
Match.when("scrap", () => parseScrap(rest)),
64+
Match.when("help", () => Either.right(helpCommand)),
65+
Match.when("ps", () => Either.right(statusCommand)),
66+
Match.when("status", () => Either.right(statusCommand)),
67+
Match.when("down-all", () => Either.right(downAllCommand)),
68+
Match.when("stop-all", () => Either.right(downAllCommand)),
69+
Match.when("kill-all", () => Either.right(downAllCommand)),
70+
Match.when("menu", () => Either.right(menuCommand)),
71+
Match.when("ui", () => Either.right(menuCommand)),
72+
Match.when("auth", () => parseAuth(rest)),
73+
Match.when("state", () => parseState(rest))
74+
)
75+
.pipe(Match.orElse(() => Either.left(unknownCommandError)))
7376
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ docker-git create --repo-url <url> [options]
77
docker-git clone <url> [options]
88
docker-git attach [<url>] [options]
99
docker-git panes [<url>] [options]
10+
docker-git scrap <action> [<url>] [options]
1011
docker-git sessions [list] [<url>] [options]
1112
docker-git sessions kill <pid> [<url>] [options]
1213
docker-git sessions logs <pid> [<url>] [options]
@@ -21,6 +22,7 @@ Commands:
2122
clone Create + run container and clone repo
2223
attach, tmux Open tmux workspace for a docker-git project
2324
panes, terms List tmux panes for a docker-git project
25+
scrap Export/import workspace cache (dependencies, .env, build artifacts)
2426
sessions List/kill/log container terminal processes
2527
ps, status Show docker compose status for all docker-git projects
2628
down-all Stop all docker-git containers (docker compose down)
@@ -44,6 +46,8 @@ Options:
4446
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
4547
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
4648
--project-dir <path> Project directory for attach (default: .)
49+
--archive <path> Scrap archive path (export: output, import: input; default: .orch/scrap/workspace.tar.gz)
50+
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
4751
--lines <n> Tail last N lines for sessions logs (default: 200)
4852
--include-default Show default/system processes in sessions list
4953
--up | --no-up Run docker compose up after init (default: --up)

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

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
listTerminalSessions,
2626
tailTerminalLogs
2727
} from "@effect-template/lib/usecases/terminal-sessions"
28+
import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
2829
import { Effect, Match, pipe } from "effect"
2930
import { readCommand } from "./cli/read-command.js"
3031
import { attachTmux, listTmuxPanes } from "./tmux.js"
@@ -68,27 +69,30 @@ type NonBaseCommand = Exclude<
6869
>
6970

7071
const handleNonBaseCommand = (command: NonBaseCommand) =>
71-
Match.value(command).pipe(
72-
Match.when({ _tag: "StatePath" }, () => statePath),
73-
Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)),
74-
Match.when({ _tag: "StateStatus" }, () => stateStatus),
75-
Match.when({ _tag: "StatePull" }, () => statePull),
76-
Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)),
77-
Match.when({ _tag: "StatePush" }, () => statePush),
78-
Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)),
79-
Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)),
80-
Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)),
81-
Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)),
82-
Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)),
83-
Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)),
84-
Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)),
85-
Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)),
86-
Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)),
87-
Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)),
88-
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
89-
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
90-
Match.exhaustive
91-
)
72+
Match.value(command)
73+
.pipe(
74+
Match.when({ _tag: "StatePath" }, () => statePath),
75+
Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)),
76+
Match.when({ _tag: "StateStatus" }, () => stateStatus),
77+
Match.when({ _tag: "StatePull" }, () => statePull),
78+
Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)),
79+
Match.when({ _tag: "StatePush" }, () => statePush),
80+
Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)),
81+
Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)),
82+
Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)),
83+
Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)),
84+
Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)),
85+
Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)),
86+
Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)),
87+
Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)),
88+
Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)),
89+
Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)),
90+
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
91+
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
92+
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
93+
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd))
94+
)
95+
.pipe(Match.exhaustive)
9296

9397
// CHANGE: compose CLI program with typed errors and shell effects
9498
// WHY: keep a thin entry layer over pure parsing and template generation
@@ -121,6 +125,9 @@ export const program = pipe(
121125
Effect.catchTag("DockerCommandError", logWarningAndExit),
122126
Effect.catchTag("AuthError", logWarningAndExit),
123127
Effect.catchTag("CommandFailedError", logWarningAndExit),
128+
Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
129+
Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),
130+
Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit),
124131
Effect.matchEffect({
125132
onFailure: (error) =>
126133
isParseError(error)

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

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ import { parseArgs } from "../../src/docker-git/cli/parser.js"
66

77
type CreateCommand = Extract<Command, { _tag: "Create" }>
88

9+
const expectParseErrorTag = (
10+
args: ReadonlyArray<string>,
11+
expectedTag: string
12+
) =>
13+
Effect.sync(() => {
14+
const parsed = parseArgs(args)
15+
Either.match(parsed, {
16+
onLeft: (error) => {
17+
expect(error._tag).toBe(expectedTag)
18+
},
19+
onRight: () => {
20+
throw new Error("expected parse error")
21+
}
22+
})
23+
})
24+
925
const parseOrThrow = (args: ReadonlyArray<string>): Command => {
1026
const parsed = parseArgs(args)
1127
return Either.match(parsed, {
@@ -56,17 +72,7 @@ describe("parseArgs", () => {
5672
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
5773
}))
5874

59-
it.effect("fails on missing repo url", () =>
60-
Effect.sync(() => {
61-
Either.match(parseArgs(["create"]), {
62-
onLeft: (error) => {
63-
expect(error._tag).toBe("MissingRequiredOption")
64-
},
65-
onRight: () => {
66-
throw new Error("expected parse error")
67-
}
68-
})
69-
}))
75+
it.effect("fails on missing repo url", () => expectParseErrorTag(["create"], "MissingRequiredOption"))
7076

7177
it.effect("parses clone command with positional repo url", () =>
7278
expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => {
@@ -169,4 +175,35 @@ describe("parseArgs", () => {
169175
}
170176
expect(command.message).toBe("sync state")
171177
}))
178+
179+
it.effect("parses scrap export with defaults", () =>
180+
Effect.sync(() => {
181+
const command = parseOrThrow(["scrap", "export"])
182+
if (command._tag !== "ScrapExport") {
183+
throw new Error("expected ScrapExport command")
184+
}
185+
expect(command.projectDir).toBe(".")
186+
expect(command.archivePath).toBe(".orch/scrap/workspace.tar.gz")
187+
}))
188+
189+
it.effect("fails scrap import without archive", () =>
190+
expectParseErrorTag(["scrap", "import"], "MissingRequiredOption"))
191+
192+
it.effect("parses scrap import wipe defaults", () =>
193+
Effect.sync(() => {
194+
const command = parseOrThrow(["scrap", "import", "--archive", "workspace.tar.gz"])
195+
if (command._tag !== "ScrapImport") {
196+
throw new Error("expected ScrapImport command")
197+
}
198+
expect(command.wipe).toBe(true)
199+
}))
200+
201+
it.effect("parses scrap import --no-wipe", () =>
202+
Effect.sync(() => {
203+
const command = parseOrThrow(["scrap", "import", "--archive", "workspace.tar.gz", "--no-wipe"])
204+
if (command._tag !== "ScrapImport") {
205+
throw new Error("expected ScrapImport command")
206+
}
207+
expect(command.wipe).toBe(false)
208+
}))
172209
})

packages/lib/src/core/command-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface RawOptions {
2626
readonly codexAuthPath?: string
2727
readonly codexHome?: string
2828
readonly enableMcpPlaywright?: boolean
29+
readonly archivePath?: string
30+
readonly wipe?: boolean
2931
readonly label?: string
3032
readonly token?: string
3133
readonly scopes?: string

packages/lib/src/core/domain.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ export interface SessionsLogsCommand {
7171
readonly lines: number
7272
}
7373

74+
export interface ScrapExportCommand {
75+
readonly _tag: "ScrapExport"
76+
readonly projectDir: string
77+
readonly archivePath: string
78+
}
79+
80+
export interface ScrapImportCommand {
81+
readonly _tag: "ScrapImport"
82+
readonly projectDir: string
83+
readonly archivePath: string
84+
readonly wipe: boolean
85+
}
86+
7487
export interface HelpCommand {
7588
readonly _tag: "Help"
7689
readonly message: string
@@ -158,6 +171,10 @@ export type SessionsCommand =
158171
| SessionsKillCommand
159172
| SessionsLogsCommand
160173

174+
export type ScrapCommand =
175+
| ScrapExportCommand
176+
| ScrapImportCommand
177+
161178
export type AuthCommand =
162179
| AuthGithubLoginCommand
163180
| AuthGithubStatusCommand
@@ -181,6 +198,7 @@ export type Command =
181198
| AttachCommand
182199
| PanesCommand
183200
| SessionsCommand
201+
| ScrapCommand
184202
| HelpCommand
185203
| StatusCommand
186204
| DownAllCommand

packages/lib/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export * from "./usecases/errors.js"
1515
export * from "./usecases/menu-helpers.js"
1616
export * from "./usecases/path-helpers.js"
1717
export * from "./usecases/projects.js"
18+
export * from "./usecases/scrap.js"
1819
export * from "./usecases/terminal-sessions.js"

0 commit comments

Comments
 (0)