@prover-coder-ai/docker-git 1.0.8 → 1.0.10

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.
@@ -16,6 +16,8 @@ const valueOptionSpecs = [
16
16
  { flag: "--env-project", key: "envProjectPath" },
17
17
  { flag: "--codex-auth", key: "codexAuthPath" },
18
18
  { flag: "--codex-home", key: "codexHome" },
19
+ { flag: "--archive", key: "archivePath" },
20
+ { flag: "--mode", key: "scrapMode" },
19
21
  { flag: "--label", key: "label" },
20
22
  { flag: "--token", key: "token" },
21
23
  { flag: "--scopes", key: "scopes" },
@@ -33,6 +35,8 @@ const booleanFlagUpdaters = {
33
35
  "--force-env": (raw) => ({ ...raw, forceEnv: true }),
34
36
  "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
35
37
  "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
38
+ "--wipe": (raw) => ({ ...raw, wipe: true }),
39
+ "--no-wipe": (raw) => ({ ...raw, wipe: false }),
36
40
  "--web": (raw) => ({ ...raw, authWeb: true }),
37
41
  "--include-default": (raw) => ({ ...raw, includeDefault: true })
38
42
  };
@@ -51,6 +55,8 @@ const valueFlagUpdaters = {
51
55
  envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
52
56
  codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
53
57
  codexHome: (raw, value) => ({ ...raw, codexHome: value }),
58
+ archivePath: (raw, value) => ({ ...raw, archivePath: value }),
59
+ scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
54
60
  label: (raw, value) => ({ ...raw, label: value }),
55
61
  token: (raw, value) => ({ ...raw, token: value }),
56
62
  scopes: (raw, value) => ({ ...raw, scopes: value }),
@@ -0,0 +1,74 @@
1
+ import { Either, Match } from "effect";
2
+ import { parseProjectDirWithOptions } from "./parser-shared.js";
3
+ const missingRequired = (option) => ({
4
+ _tag: "MissingRequiredOption",
5
+ option
6
+ });
7
+ const invalidScrapAction = (value) => ({
8
+ _tag: "InvalidOption",
9
+ option: "scrap",
10
+ reason: `unknown action: ${value}`
11
+ });
12
+ const defaultSessionArchiveDir = ".orch/scrap/session";
13
+ const invalidScrapMode = (value) => ({
14
+ _tag: "InvalidOption",
15
+ option: "--mode",
16
+ reason: `unknown value: ${value} (expected session)`
17
+ });
18
+ const parseScrapMode = (raw) => {
19
+ const value = raw?.trim();
20
+ if (!value || value.length === 0) {
21
+ return Either.right("session");
22
+ }
23
+ if (value === "session") {
24
+ return Either.right("session");
25
+ }
26
+ if (value === "recipe") {
27
+ // Backwards/semantic alias: "recipe" behaves like "session" (git state + rebuildable deps).
28
+ return Either.right("session");
29
+ }
30
+ return Either.left(invalidScrapMode(value));
31
+ };
32
+ const makeScrapExportCommand = (projectDir, archivePath, mode) => ({
33
+ _tag: "ScrapExport",
34
+ projectDir,
35
+ archivePath,
36
+ mode
37
+ });
38
+ const makeScrapImportCommand = (projectDir, archivePath, wipe, mode) => ({
39
+ _tag: "ScrapImport",
40
+ projectDir,
41
+ archivePath,
42
+ wipe,
43
+ mode
44
+ });
45
+ // CHANGE: parse scrap session export/import commands
46
+ // WHY: store a small reproducible snapshot (git state + secrets) instead of large caches like node_modules
47
+ // QUOTE(ТЗ): "не должно быть старого режима где он качает весь шлак типо node_modules"
48
+ // REF: user-request-2026-02-15
49
+ // SOURCE: n/a
50
+ // FORMAT THEOREM: forall argv: parseScrap(argv) = cmd -> deterministic(cmd)
51
+ // PURITY: CORE
52
+ // EFFECT: Effect<Command, ParseError, never>
53
+ // INVARIANT: export/import always resolves a projectDir
54
+ // COMPLEXITY: O(n) where n = |argv|
55
+ export const parseScrap = (args) => {
56
+ const action = args[0]?.trim();
57
+ if (!action || action.length === 0) {
58
+ return Either.left(missingRequired("scrap <action>"));
59
+ }
60
+ const rest = args.slice(1);
61
+ return Match.value(action).pipe(Match.when("export", () => Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => Either.map(parseScrapMode(raw.scrapMode), (mode) => {
62
+ const archivePathRaw = raw.archivePath?.trim();
63
+ if (archivePathRaw && archivePathRaw.length > 0) {
64
+ return makeScrapExportCommand(projectDir, archivePathRaw, mode);
65
+ }
66
+ return makeScrapExportCommand(projectDir, defaultSessionArchiveDir, mode);
67
+ }))), Match.when("import", () => Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => {
68
+ const archivePath = raw.archivePath?.trim();
69
+ if (!archivePath || archivePath.length === 0) {
70
+ return Either.left(missingRequired("--archive"));
71
+ }
72
+ return Either.map(parseScrapMode(raw.scrapMode), (mode) => makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true, mode));
73
+ })), Match.orElse(() => Either.left(invalidScrapAction(action))));
74
+ };
@@ -6,6 +6,7 @@ import { parseClone } from "./parser-clone.js";
6
6
  import { buildCreateCommand } from "./parser-create.js";
7
7
  import { parseRawOptions } from "./parser-options.js";
8
8
  import { parsePanes } from "./parser-panes.js";
9
+ import { parseScrap } from "./parser-scrap.js";
9
10
  import { parseSessions } from "./parser-sessions.js";
10
11
  import { parseState } from "./parser-state.js";
11
12
  import { usageText } from "./usage.js";
@@ -38,5 +39,7 @@ export const parseArgs = (args) => {
38
39
  _tag: "UnknownCommand",
39
40
  command: command ?? ""
40
41
  };
41
- return Match.value(command).pipe(Match.when("create", () => parseCreate(rest)), Match.when("init", () => parseCreate(rest)), Match.when("clone", () => parseClone(rest)), Match.when("attach", () => parseAttach(rest)), Match.when("tmux", () => parseAttach(rest)), Match.when("panes", () => parsePanes(rest)), Match.when("terms", () => parsePanes(rest)), Match.when("terminals", () => parsePanes(rest)), Match.when("sessions", () => parseSessions(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), Match.when("down-all", () => Either.right(downAllCommand)), Match.when("stop-all", () => Either.right(downAllCommand)), Match.when("kill-all", () => Either.right(downAllCommand)), Match.when("menu", () => Either.right(menuCommand)), Match.when("ui", () => Either.right(menuCommand)), Match.when("auth", () => parseAuth(rest)), Match.when("state", () => parseState(rest)), Match.orElse(() => Either.left(unknownCommandError)));
42
+ return Match.value(command)
43
+ .pipe(Match.when("create", () => parseCreate(rest)), Match.when("init", () => parseCreate(rest)), Match.when("clone", () => parseClone(rest)), Match.when("attach", () => parseAttach(rest)), Match.when("tmux", () => parseAttach(rest)), Match.when("panes", () => parsePanes(rest)), Match.when("terms", () => parsePanes(rest)), Match.when("terminals", () => parsePanes(rest)), Match.when("sessions", () => parseSessions(rest)), Match.when("scrap", () => parseScrap(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), Match.when("down-all", () => Either.right(downAllCommand)), Match.when("stop-all", () => Either.right(downAllCommand)), Match.when("kill-all", () => Either.right(downAllCommand)), Match.when("menu", () => Either.right(menuCommand)), Match.when("ui", () => Either.right(menuCommand)), Match.when("auth", () => parseAuth(rest)), Match.when("state", () => parseState(rest)))
44
+ .pipe(Match.orElse(() => Either.left(unknownCommandError)));
42
45
  };
@@ -4,6 +4,7 @@ docker-git create --repo-url <url> [options]
4
4
  docker-git clone <url> [options]
5
5
  docker-git attach [<url>] [options]
6
6
  docker-git panes [<url>] [options]
7
+ docker-git scrap <action> [<url>] [options]
7
8
  docker-git sessions [list] [<url>] [options]
8
9
  docker-git sessions kill <pid> [<url>] [options]
9
10
  docker-git sessions logs <pid> [<url>] [options]
@@ -18,6 +19,7 @@ Commands:
18
19
  clone Create + run container and clone repo
19
20
  attach, tmux Open tmux workspace for a docker-git project
20
21
  panes, terms List tmux panes for a docker-git project
22
+ scrap Export/import project scrap (session snapshot + rebuildable deps)
21
23
  sessions List/kill/log container terminal processes
22
24
  ps, status Show docker compose status for all docker-git projects
23
25
  down-all Stop all docker-git containers (docker compose down)
@@ -41,6 +43,9 @@ Options:
41
43
  --codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
42
44
  --out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
43
45
  --project-dir <path> Project directory for attach (default: .)
46
+ --archive <path> Scrap snapshot directory (default: .orch/scrap/session)
47
+ --mode <session> Scrap mode (default: session)
48
+ --wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
44
49
  --lines <n> Tail last N lines for sessions logs (default: 200)
45
50
  --include-default Show default/system processes in sessions list
46
51
  --up | --no-up Run docker compose up after init (default: --up)
@@ -2,6 +2,7 @@ import { createProject } from "@effect-template/lib/usecases/actions";
2
2
  import { authCodexLogin, authCodexLogout, authCodexStatus, authGithubLogin, authGithubLogout, authGithubStatus } from "@effect-template/lib/usecases/auth";
3
3
  import { renderError } from "@effect-template/lib/usecases/errors";
4
4
  import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects";
5
+ import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap";
5
6
  import { stateCommit, stateInit, statePath, statePull, statePush, stateStatus, stateSync } from "@effect-template/lib/usecases/state-repo";
6
7
  import { killTerminalProcess, listTerminalSessions, tailTerminalLogs } from "@effect-template/lib/usecases/terminal-sessions";
7
8
  import { Effect, Match, pipe } from "effect";
@@ -19,7 +20,9 @@ const setExitCode = (code) => Effect.sync(() => {
19
20
  });
20
21
  const logWarningAndExit = (error) => pipe(Effect.logWarning(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
21
22
  const logErrorAndExit = (error) => pipe(Effect.logError(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
22
- const handleNonBaseCommand = (command) => Match.value(command).pipe(Match.when({ _tag: "StatePath" }, () => statePath), Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)), Match.when({ _tag: "StateStatus" }, () => stateStatus), Match.when({ _tag: "StatePull" }, () => statePull), Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)), Match.when({ _tag: "StatePush" }, () => statePush), Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)), Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)), Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)), Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)), Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)), Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)), Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)), Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)), Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), Match.exhaustive);
23
+ const handleNonBaseCommand = (command) => Match.value(command)
24
+ .pipe(Match.when({ _tag: "StatePath" }, () => statePath), Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)), Match.when({ _tag: "StateStatus" }, () => stateStatus), Match.when({ _tag: "StatePull" }, () => statePull), Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)), Match.when({ _tag: "StatePush" }, () => statePush), Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)), Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)), Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)), Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)), Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)), Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)), Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)), Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)), Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)))
25
+ .pipe(Match.exhaustive);
23
26
  // CHANGE: compose CLI program with typed errors and shell effects
24
27
  // WHY: keep a thin entry layer over pure parsing and template generation
25
28
  // QUOTE(ТЗ): "CLI команду... создавать докер образы"
@@ -30,7 +33,7 @@ const handleNonBaseCommand = (command) => Match.value(command).pipe(Match.when({
30
33
  // EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
31
34
  // INVARIANT: help is printed without side effects beyond logs
32
35
  // COMPLEXITY: O(n) where n = |files|
33
- export const program = pipe(readCommand, Effect.flatMap((command) => Match.value(command).pipe(Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), Match.when({ _tag: "Create" }, (create) => createProject(create)), Match.when({ _tag: "Status" }, () => listProjectStatus), Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), Match.when({ _tag: "Menu" }, () => runMenu), Match.orElse((cmd) => handleNonBaseCommand(cmd)))), Effect.catchTag("FileExistsError", (error) => pipe(Effect.logWarning(renderError(error)), Effect.asVoid)), Effect.catchTag("DockerAccessError", logWarningAndExit), Effect.catchTag("DockerCommandError", logWarningAndExit), Effect.catchTag("AuthError", logWarningAndExit), Effect.catchTag("CommandFailedError", logWarningAndExit), Effect.matchEffect({
36
+ export const program = pipe(readCommand, Effect.flatMap((command) => Match.value(command).pipe(Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), Match.when({ _tag: "Create" }, (create) => createProject(create)), Match.when({ _tag: "Status" }, () => listProjectStatus), Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), Match.when({ _tag: "Menu" }, () => runMenu), Match.orElse((cmd) => handleNonBaseCommand(cmd)))), Effect.catchTag("FileExistsError", (error) => pipe(Effect.logWarning(renderError(error)), Effect.asVoid)), Effect.catchTag("DockerAccessError", logWarningAndExit), Effect.catchTag("DockerCommandError", logWarningAndExit), Effect.catchTag("AuthError", logWarningAndExit), Effect.catchTag("CommandFailedError", logWarningAndExit), Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit), Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit), Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit), Effect.matchEffect({
34
37
  onFailure: (error) => isParseError(error)
35
38
  ? logErrorAndExit(error)
36
39
  : pipe(Effect.logError(renderError(error)), Effect.flatMap(() => Effect.fail(error))),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
@@ -44,9 +44,9 @@
44
44
  "build:app": "vite build --ssr src/app/main.ts",
45
45
  "dev": "vite build --watch --ssr src/app/main.ts",
46
46
  "prelint": "pnpm -C ../lib build",
47
- "lint": "npx @ton-ai-core/vibecode-linter src/",
48
- "lint:tests": "npx @ton-ai-core/vibecode-linter tests/",
49
- "lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
47
+ "lint": "PATH=../../scripts:$PATH vibecode-linter src/",
48
+ "lint:tests": "PATH=../../scripts:$PATH vibecode-linter tests/",
49
+ "lint:effect": "PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .",
50
50
  "prebuild:docker-git": "pnpm -C ../lib build",
51
51
  "build:docker-git": "tsc -p tsconfig.build.json",
52
52
  "check": "pnpm run typecheck",
@@ -20,6 +20,8 @@ interface ValueOptionSpec {
20
20
  | "envProjectPath"
21
21
  | "codexAuthPath"
22
22
  | "codexHome"
23
+ | "archivePath"
24
+ | "scrapMode"
23
25
  | "label"
24
26
  | "token"
25
27
  | "scopes"
@@ -46,6 +48,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
46
48
  { flag: "--env-project", key: "envProjectPath" },
47
49
  { flag: "--codex-auth", key: "codexAuthPath" },
48
50
  { flag: "--codex-home", key: "codexHome" },
51
+ { flag: "--archive", key: "archivePath" },
52
+ { flag: "--mode", key: "scrapMode" },
49
53
  { flag: "--label", key: "label" },
50
54
  { flag: "--token", key: "token" },
51
55
  { flag: "--scopes", key: "scopes" },
@@ -69,6 +73,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
69
73
  "--force-env": (raw) => ({ ...raw, forceEnv: true }),
70
74
  "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
71
75
  "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
76
+ "--wipe": (raw) => ({ ...raw, wipe: true }),
77
+ "--no-wipe": (raw) => ({ ...raw, wipe: false }),
72
78
  "--web": (raw) => ({ ...raw, authWeb: true }),
73
79
  "--include-default": (raw) => ({ ...raw, includeDefault: true })
74
80
  }
@@ -88,6 +94,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
88
94
  envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
89
95
  codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
90
96
  codexHome: (raw, value) => ({ ...raw, codexHome: value }),
97
+ archivePath: (raw, value) => ({ ...raw, archivePath: value }),
98
+ scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
91
99
  label: (raw, value) => ({ ...raw, label: value }),
92
100
  token: (raw, value) => ({ ...raw, token: value }),
93
101
  scopes: (raw, value) => ({ ...raw, scopes: value }),
@@ -0,0 +1,106 @@
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 defaultSessionArchiveDir = ".orch/scrap/session"
19
+
20
+ const invalidScrapMode = (value: string): ParseError => ({
21
+ _tag: "InvalidOption",
22
+ option: "--mode",
23
+ reason: `unknown value: ${value} (expected session)`
24
+ })
25
+
26
+ const parseScrapMode = (raw: string | undefined): Either.Either<"session", ParseError> => {
27
+ const value = raw?.trim()
28
+ if (!value || value.length === 0) {
29
+ return Either.right("session")
30
+ }
31
+ if (value === "session") {
32
+ return Either.right("session")
33
+ }
34
+ if (value === "recipe") {
35
+ // Backwards/semantic alias: "recipe" behaves like "session" (git state + rebuildable deps).
36
+ return Either.right("session")
37
+ }
38
+ return Either.left(invalidScrapMode(value))
39
+ }
40
+
41
+ const makeScrapExportCommand = (projectDir: string, archivePath: string, mode: "session"): Command => ({
42
+ _tag: "ScrapExport",
43
+ projectDir,
44
+ archivePath,
45
+ mode
46
+ })
47
+
48
+ const makeScrapImportCommand = (
49
+ projectDir: string,
50
+ archivePath: string,
51
+ wipe: boolean,
52
+ mode: "session"
53
+ ): Command => ({
54
+ _tag: "ScrapImport",
55
+ projectDir,
56
+ archivePath,
57
+ wipe,
58
+ mode
59
+ })
60
+
61
+ // CHANGE: parse scrap session export/import commands
62
+ // WHY: store a small reproducible snapshot (git state + secrets) instead of large caches like node_modules
63
+ // QUOTE(ТЗ): "не должно быть старого режима где он качает весь шлак типо node_modules"
64
+ // REF: user-request-2026-02-15
65
+ // SOURCE: n/a
66
+ // FORMAT THEOREM: forall argv: parseScrap(argv) = cmd -> deterministic(cmd)
67
+ // PURITY: CORE
68
+ // EFFECT: Effect<Command, ParseError, never>
69
+ // INVARIANT: export/import always resolves a projectDir
70
+ // COMPLEXITY: O(n) where n = |argv|
71
+ export const parseScrap = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> => {
72
+ const action = args[0]?.trim()
73
+ if (!action || action.length === 0) {
74
+ return Either.left(missingRequired("scrap <action>"))
75
+ }
76
+
77
+ const rest = args.slice(1)
78
+
79
+ return Match.value(action).pipe(
80
+ Match.when(
81
+ "export",
82
+ () =>
83
+ Either.flatMap(
84
+ parseProjectDirWithOptions(rest),
85
+ ({ projectDir, raw }) =>
86
+ Either.map(parseScrapMode(raw.scrapMode), (mode) => {
87
+ const archivePathRaw = raw.archivePath?.trim()
88
+ if (archivePathRaw && archivePathRaw.length > 0) {
89
+ return makeScrapExportCommand(projectDir, archivePathRaw, mode)
90
+ }
91
+ return makeScrapExportCommand(projectDir, defaultSessionArchiveDir, mode)
92
+ })
93
+ )
94
+ ),
95
+ Match.when("import", () =>
96
+ Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => {
97
+ const archivePath = raw.archivePath?.trim()
98
+ if (!archivePath || archivePath.length === 0) {
99
+ return Either.left(missingRequired("--archive"))
100
+ }
101
+ return Either.map(parseScrapMode(raw.scrapMode), (mode) =>
102
+ makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true, mode))
103
+ })),
104
+ Match.orElse(() => Either.left(invalidScrapAction(action)))
105
+ )
106
+ }
@@ -8,6 +8,7 @@ import { parseClone } from "./parser-clone.js"
8
8
  import { buildCreateCommand } from "./parser-create.js"
9
9
  import { parseRawOptions } from "./parser-options.js"
10
10
  import { parsePanes } from "./parser-panes.js"
11
+ import { parseScrap } from "./parser-scrap.js"
11
12
  import { parseSessions } from "./parser-sessions.js"
12
13
  import { parseState } from "./parser-state.js"
13
14
  import { usageText } from "./usage.js"
@@ -48,26 +49,28 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
48
49
  command: command ?? ""
49
50
  }
50
51
 
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)))
73
76
  }
@@ -7,6 +7,7 @@ docker-git create --repo-url <url> [options]
7
7
  docker-git clone <url> [options]
8
8
  docker-git attach [<url>] [options]
9
9
  docker-git panes [<url>] [options]
10
+ docker-git scrap <action> [<url>] [options]
10
11
  docker-git sessions [list] [<url>] [options]
11
12
  docker-git sessions kill <pid> [<url>] [options]
12
13
  docker-git sessions logs <pid> [<url>] [options]
@@ -21,6 +22,7 @@ Commands:
21
22
  clone Create + run container and clone repo
22
23
  attach, tmux Open tmux workspace for a docker-git project
23
24
  panes, terms List tmux panes for a docker-git project
25
+ scrap Export/import project scrap (session snapshot + rebuildable deps)
24
26
  sessions List/kill/log container terminal processes
25
27
  ps, status Show docker compose status for all docker-git projects
26
28
  down-all Stop all docker-git containers (docker compose down)
@@ -44,6 +46,9 @@ Options:
44
46
  --codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
45
47
  --out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
46
48
  --project-dir <path> Project directory for attach (default: .)
49
+ --archive <path> Scrap snapshot directory (default: .orch/scrap/session)
50
+ --mode <session> Scrap mode (default: session)
51
+ --wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
47
52
  --lines <n> Tail last N lines for sessions logs (default: 200)
48
53
  --include-default Show default/system processes in sessions list
49
54
  --up | --no-up Run docker compose up after init (default: --up)
@@ -11,6 +11,7 @@ import {
11
11
  import type { AppError } from "@effect-template/lib/usecases/errors"
12
12
  import { renderError } from "@effect-template/lib/usecases/errors"
13
13
  import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects"
14
+ import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
14
15
  import {
15
16
  stateCommit,
16
17
  stateInit,
@@ -68,27 +69,30 @@ type NonBaseCommand = Exclude<
68
69
  >
69
70
 
70
71
  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)
92
96
 
93
97
  // CHANGE: compose CLI program with typed errors and shell effects
94
98
  // WHY: keep a thin entry layer over pure parsing and template generation
@@ -121,6 +125,9 @@ export const program = pipe(
121
125
  Effect.catchTag("DockerCommandError", logWarningAndExit),
122
126
  Effect.catchTag("AuthError", logWarningAndExit),
123
127
  Effect.catchTag("CommandFailedError", logWarningAndExit),
128
+ Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
129
+ Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),
130
+ Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit),
124
131
  Effect.matchEffect({
125
132
  onFailure: (error) =>
126
133
  isParseError(error)
@@ -6,6 +6,22 @@ import { parseArgs } from "../../src/docker-git/cli/parser.js"
6
6
 
7
7
  type CreateCommand = Extract<Command, { _tag: "Create" }>
8
8
 
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
+
9
25
  const parseOrThrow = (args: ReadonlyArray<string>): Command => {
10
26
  const parsed = parseArgs(args)
11
27
  return Either.match(parsed, {
@@ -56,17 +72,7 @@ describe("parseArgs", () => {
56
72
  expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
57
73
  }))
58
74
 
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"))
70
76
 
71
77
  it.effect("parses clone command with positional repo url", () =>
72
78
  expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => {
@@ -169,4 +175,35 @@ describe("parseArgs", () => {
169
175
  }
170
176
  expect(command.message).toBe("sync state")
171
177
  }))
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/session")
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
+ }))
172
209
  })