diff --git a/.githooks/pre-push b/.githooks/pre-push index 7de9b5a2..f1dbb702 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -10,3 +10,14 @@ if [ "${DOCKER_GIT_SKIP_KNOWLEDGE_GUARD:-}" = "1" ]; then fi node scripts/pre-push-knowledge-guard.js "$@" + +# CHANGE: backup AI session to a private session repository on push (supports Claude, Codex, Gemini) +# WHY: allows returning to old AI sessions and provides PR context without gist limits +# QUOTE(ТЗ): "когда происходит push мы сразу заливаем текущую сессию с AI агентом в gits приватный" +# REF: issue-143 +# PURITY: SHELL +if [ "${DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then + if command -v gh >/dev/null 2>&1; then + node scripts/session-backup-gist.js --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" + fi +fi diff --git a/packages/app/.jscpd.json b/packages/app/.jscpd.json index dbb0615e..232bd6b9 100644 --- a/packages/app/.jscpd.json +++ b/packages/app/.jscpd.json @@ -11,6 +11,7 @@ ], "skipComments": true, "ignorePattern": [ - "private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;" + "private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;", + "const \\{ rest, subcommand \\} = splitSubcommand\\(args\\)\\s*if \\(subcommand === null\\) \\{\\s*return parseList\\(args\\)\\s*\\}\\s*return Match\\.value\\(subcommand\\)\\.pipe\\(" ] } diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index aed13181..5a733c61 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -37,6 +37,10 @@ interface ValueOptionSpec { | "projectDir" | "lines" | "agentAutoMode" + | "prNumber" + | "repo" + | "limit" + | "output" } const valueOptionSpecs: ReadonlyArray = [ @@ -75,7 +79,12 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--out-dir", key: "outDir" }, { flag: "--project-dir", key: "projectDir" }, { flag: "--lines", key: "lines" }, - { flag: "--auto", key: "agentAutoMode" } + { flag: "--auto", key: "agentAutoMode" }, + { flag: "--pr-number", key: "prNumber" }, + { flag: "--pr", key: "prNumber" }, + { flag: "--repo", key: "repo" }, + { flag: "--limit", key: "limit" }, + { flag: "--output", key: "output" } ] const valueOptionSpecByFlag: ReadonlyMap = new Map( @@ -97,7 +106,8 @@ const booleanFlagUpdaters: Readonly RawOptio "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), "--include-default": (raw) => ({ ...raw, includeDefault: true }), - "--auto": (raw) => ({ ...raw, agentAutoMode: "auto" }) + "--auto": (raw) => ({ ...raw, agentAutoMode: "auto" }), + "--no-comment": (raw) => ({ ...raw, noComment: true }) } const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = { @@ -131,7 +141,11 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st outDir: (raw, value) => ({ ...raw, outDir: value }), projectDir: (raw, value) => ({ ...raw, projectDir: value }), lines: (raw, value) => ({ ...raw, lines: value }), - agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }) + agentAutoMode: (raw, value) => ({ ...raw, agentAutoMode: value.trim().toLowerCase() }), + prNumber: (raw, value) => ({ ...raw, prNumber: value }), + repo: (raw, value) => ({ ...raw, repo: value }), + limit: (raw, value) => ({ ...raw, limit: value }), + output: (raw, value) => ({ ...raw, output: value }) } export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => { diff --git a/packages/app/src/docker-git/cli/parser-session-gists.ts b/packages/app/src/docker-git/cli/parser-session-gists.ts new file mode 100644 index 00000000..75f6105d --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-session-gists.ts @@ -0,0 +1,103 @@ +import { Either, Match } from "effect" + +import { + type ParseError, + type SessionGistBackupCommand, + type SessionGistCommand, + type SessionGistDownloadCommand, + type SessionGistListCommand, + type SessionGistViewCommand +} from "@effect-template/lib/core/domain" + +import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js" + +// CHANGE: parse session backup commands for backup/list/view/download +// WHY: enables CLI access to session backup repository functionality +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE +// EFFECT: Either +// INVARIANT: all subcommands are deterministically parsed +// COMPLEXITY: O(n) where n = |args| + +const defaultLimit = 20 +const defaultOutputDir = "./.session-restore" + +const missingSnapshotRefError: ParseError = { _tag: "MissingRequiredOption", option: "snapshot-ref" } + +const extractSnapshotRef = (args: ReadonlyArray): string | null => { + const snapshotRef = args[0] + return snapshotRef && !snapshotRef.startsWith("-") ? snapshotRef : null +} + +const parseBackup = ( + args: ReadonlyArray +): Either.Either => + Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ + _tag: "SessionGistBackup", + projectDir, + prNumber: raw.prNumber ? Number.parseInt(raw.prNumber, 10) : null, + repo: raw.repo ?? null, + postComment: raw.noComment !== true + })) + +const parseList = ( + args: ReadonlyArray +): Either.Either => + Either.gen(function*(_) { + const { raw } = yield* _(parseProjectDirWithOptions(args)) + const limit = raw.limit + ? yield* _(parsePositiveInt("--limit", raw.limit)) + : defaultLimit + return { + _tag: "SessionGistList", + limit, + repo: raw.repo ?? null + } + }) + +const parseView = ( + args: ReadonlyArray +): Either.Either => { + const snapshotRef = extractSnapshotRef(args) + return snapshotRef + ? Either.right({ _tag: "SessionGistView", snapshotRef }) + : Either.left(missingSnapshotRefError) +} + +const parseDownload = ( + args: ReadonlyArray +): Either.Either => { + const snapshotRef = extractSnapshotRef(args) + if (!snapshotRef) { + return Either.left(missingSnapshotRefError) + } + return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({ + _tag: "SessionGistDownload", + snapshotRef, + outputDir: raw.output ?? defaultOutputDir + })) +} + +const unknownActionError = (action: string): ParseError => ({ + _tag: "InvalidOption", + option: "session-gists", + reason: `unknown action ${action}` +}) + +export const parseSessionGists = ( + args: ReadonlyArray +): Either.Either => { + const { rest, subcommand } = splitSubcommand(args) + if (subcommand === null) { + return parseList(args) + } + + return Match.value(subcommand).pipe( + Match.when("backup", () => parseBackup(rest)), + Match.when("list", () => parseList(rest)), + Match.when("view", () => parseView(rest)), + Match.when("download", () => parseDownload(rest)), + Match.orElse(() => Either.left(unknownActionError(subcommand))) + ) +} diff --git a/packages/app/src/docker-git/cli/parser-sessions.ts b/packages/app/src/docker-git/cli/parser-sessions.ts index 1adca714..dda62f4b 100644 --- a/packages/app/src/docker-git/cli/parser-sessions.ts +++ b/packages/app/src/docker-git/cli/parser-sessions.ts @@ -2,26 +2,10 @@ import { Either, Match } from "effect" import { type ParseError, type SessionsCommand } from "@effect-template/lib/core/domain" -import { parseProjectDirWithOptions } from "./parser-shared.js" +import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js" const defaultLines = 200 -const parsePositiveInt = ( - option: string, - raw: string -): Either.Either => { - const value = Number.parseInt(raw, 10) - if (!Number.isFinite(value) || value <= 0) { - const error: ParseError = { - _tag: "InvalidOption", - option, - reason: "expected positive integer" - } - return Either.left(error) - } - return Either.right(value) -} - const parseList = (args: ReadonlyArray): Either.Either => Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ _tag: "SessionsList", @@ -73,17 +57,12 @@ const parseLogs = (args: ReadonlyArray): Either.Either ): Either.Either => { - if (args.length === 0) { - return parseList(args) - } - - const first = args[0] ?? "" - if (first.startsWith("-")) { + const { rest, subcommand } = splitSubcommand(args) + if (subcommand === null) { return parseList(args) } - const rest = args.slice(1) - return Match.value(first).pipe( + return Match.value(subcommand).pipe( Match.when("list", () => parseList(rest)), Match.when("kill", () => parseKill(rest)), Match.when("stop", () => parseKill(rest)), @@ -93,7 +72,7 @@ export const parseSessions = ( const error: ParseError = { _tag: "InvalidOption", option: "sessions", - reason: `unknown action ${first}` + reason: `unknown action ${subcommand}` } return Either.left(error) }) diff --git a/packages/app/src/docker-git/cli/parser-shared.ts b/packages/app/src/docker-git/cli/parser-shared.ts index eda4fc4c..94943111 100644 --- a/packages/app/src/docker-git/cli/parser-shared.ts +++ b/packages/app/src/docker-git/cli/parser-shared.ts @@ -49,3 +49,45 @@ export const parseProjectDirArgs = ( parseProjectDirWithOptions(args, defaultProjectDir), ({ projectDir }) => ({ projectDir }) ) + +// CHANGE: extract shared positive integer parser +// WHY: avoid code duplication across session parsers +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE +// EFFECT: Either +// INVARIANT: returns error for non-positive integers +// COMPLEXITY: O(1) +export const parsePositiveInt = ( + option: string, + raw: string +): Either.Either => { + const value = Number.parseInt(raw, 10) + if (!Number.isFinite(value) || value <= 0) { + const error: ParseError = { + _tag: "InvalidOption", + option, + reason: "expected positive integer" + } + return Either.left(error) + } + return Either.right(value) +} + +// CHANGE: shared helper to extract first arg and rest for subcommand parsing +// WHY: avoid code duplication in parser-sessions and parser-session-gists +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns null subcommand if first arg starts with dash or is empty +// COMPLEXITY: O(1) +export const splitSubcommand = ( + args: ReadonlyArray +): { readonly subcommand: string | null; readonly rest: ReadonlyArray } => { + const first = args[0] + if (!first || first.startsWith("-")) { + return { subcommand: null, rest: args } + } + return { subcommand: first, rest: args.slice(1) } +} diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index 542666a0..7892f660 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -11,6 +11,7 @@ import { parseMcpPlaywright } from "./parser-mcp-playwright.js" import { parseRawOptions } from "./parser-options.js" import { parsePanes } from "./parser-panes.js" import { parseScrap } from "./parser-scrap.js" +import { parseSessionGists } from "./parser-session-gists.js" import { parseSessions } from "./parser-sessions.js" import { parseState } from "./parser-state.js" import { usageText } from "./usage.js" @@ -71,13 +72,15 @@ export const parseArgs = (args: ReadonlyArray): Either.Either 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("ui", () => Either.right(menuCommand)) ) .pipe( + Match.when("auth", () => parseAuth(rest)), Match.when("open", () => parseAttach(rest)), Match.when("apply", () => parseApply(rest)), Match.when("state", () => parseState(rest)), + Match.when("session-gists", () => parseSessionGists(rest)), + Match.when("gists", () => parseSessionGists(rest)), Match.orElse(() => Either.left(unknownCommandError)) ) } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 53d7cad4..db3c53fb 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -14,6 +14,10 @@ docker-git scrap [] [options] docker-git sessions [list] [] [options] docker-git sessions kill [] [options] docker-git sessions logs [] [options] +docker-git session-gists [list] [options] +docker-git session-gists backup [] [options] +docker-git session-gists view +docker-git session-gists download [options] docker-git ps docker-git down-all docker-git auth [options] @@ -30,6 +34,7 @@ Commands: panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) sessions List/kill/log container terminal processes + session-gists Manage AI session backups via a private session repository (backup/list/view/download) ps, status Show docker compose status for all docker-git projects down-all Stop all docker-git containers (docker compose down) auth Manage GitHub/Codex/Claude Code auth for docker-git @@ -64,6 +69,11 @@ Options: --wipe | --no-wipe Wipe workspace before scrap import (default: --wipe) --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list + --pr-number PR number for session backup comment + --repo Repository for session backup operations + --limit Limit for session backup snapshot list (default: 20) + --output Output directory for session backup download (default: ./.session-restore) + --no-comment Skip posting PR comment after session backup --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 7e401955..67f5a6ad 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -21,6 +21,12 @@ import { renderError } from "@effect-template/lib/usecases/errors" import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects" import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" +import { + sessionGistBackup, + sessionGistDownload, + sessionGistList, + sessionGistView +} from "@effect-template/lib/usecases/session-gists" import { stateCommit, stateInit, @@ -110,6 +116,10 @@ const handleNonBaseCommand = (command: NonBaseCommand) => Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)), Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), + Match.when({ _tag: "SessionGistBackup" }, (cmd) => sessionGistBackup(cmd)), + Match.when({ _tag: "SessionGistList" }, (cmd) => sessionGistList(cmd)), + Match.when({ _tag: "SessionGistView" }, (cmd) => sessionGistView(cmd)), + Match.when({ _tag: "SessionGistDownload" }, (cmd) => sessionGistDownload(cmd)), Match.exhaustive ) diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 6fecf732..85f76c76 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -51,6 +51,12 @@ export interface RawOptions { readonly force?: boolean readonly forceEnv?: boolean readonly agentAutoMode?: string + // Session gist options (issue-143) + readonly prNumber?: string + readonly repo?: string + readonly noComment?: boolean + readonly limit?: string + readonly output?: string } // CHANGE: helper type alias for builder signatures that produce parse errors diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 39e31ff6..0c9f9083 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -1,3 +1,5 @@ +import type { SessionGistCommand } from "./session-gist-domain.js" + export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" @@ -271,10 +273,18 @@ export interface AuthGeminiLogoutCommand { readonly geminiAuthPath: string } +export type { + SessionGistBackupCommand, + SessionGistCommand, + SessionGistDownloadCommand, + SessionGistListCommand, + SessionGistViewCommand +} from "./session-gist-domain.js" export type SessionsCommand = | SessionsListCommand | SessionsKillCommand | SessionsLogsCommand + | SessionGistCommand export type ScrapCommand = | ScrapExportCommand diff --git a/packages/lib/src/core/session-gist-domain.ts b/packages/lib/src/core/session-gist-domain.ts new file mode 100644 index 00000000..3cb6bfc7 --- /dev/null +++ b/packages/lib/src/core/session-gist-domain.ts @@ -0,0 +1,36 @@ +// CHANGE: session backup commands for PR-based session history +// WHY: enables returning to old AI sessions via a private backup repository +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE + +export interface SessionGistBackupCommand { + readonly _tag: "SessionGistBackup" + readonly projectDir: string + readonly prNumber: number | null + readonly repo: string | null + readonly postComment: boolean +} + +export interface SessionGistListCommand { + readonly _tag: "SessionGistList" + readonly limit: number + readonly repo: string | null +} + +export interface SessionGistViewCommand { + readonly _tag: "SessionGistView" + readonly snapshotRef: string +} + +export interface SessionGistDownloadCommand { + readonly _tag: "SessionGistDownload" + readonly snapshotRef: string + readonly outputDir: string +} + +export type SessionGistCommand = + | SessionGistBackupCommand + | SessionGistListCommand + | SessionGistViewCommand + | SessionGistDownloadCommand diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 4f9fc348..e7d1de34 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -255,6 +255,18 @@ while read -r local_ref local_sha remote_ref remote_sha; do done EOF chmod 0755 "$PRE_PUSH_HOOK" + +cat <<'EOF' >> "$PRE_PUSH_HOOK" + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then + if command -v gh >/dev/null 2>&1; then + node scripts/session-backup-gist.js --verbose || echo "[session-backup] Warning: session backup failed (non-fatal)" + fi +fi +EOF git config --system core.hooksPath "$HOOKS_DIR" || true git config --global core.hooksPath "$HOOKS_DIR" || true` diff --git a/packages/lib/src/usecases/session-gists.ts b/packages/lib/src/usecases/session-gists.ts new file mode 100644 index 00000000..e29c1a5a --- /dev/null +++ b/packages/lib/src/usecases/session-gists.ts @@ -0,0 +1,92 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" + +import type { + SessionGistBackupCommand, + SessionGistDownloadCommand, + SessionGistListCommand, + SessionGistViewCommand +} from "../core/domain.js" +import { runCommandWithExitCodes } from "../shell/command-runner.js" +import { CommandFailedError } from "../shell/errors.js" + +// CHANGE: implement session backup repository operations via shell commands +// WHY: enables CLI access to session backup/list/view/download functionality +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: all operations require gh CLI authentication +// COMPLEXITY: O(n) where n = number of files/gists + +type SessionGistsError = CommandFailedError | PlatformError +type SessionGistsRequirements = CommandExecutor.CommandExecutor + +const nodeOk = [0] + +const makeNodeSpec = (scriptPath: string, args: ReadonlyArray) => ({ + cwd: process.cwd(), + command: "node", + args: [scriptPath, ...args] +}) + +const runNodeScript = ( + scriptPath: string, + args: ReadonlyArray +): Effect.Effect => + runCommandWithExitCodes( + makeNodeSpec(scriptPath, args), + nodeOk, + (exitCode) => new CommandFailedError({ command: `node ${scriptPath}`, exitCode }) + ) + +export const sessionGistBackup = ( + cmd: SessionGistBackupCommand +): Effect.Effect => { + const args: Array = ["--verbose"] + if (cmd.prNumber !== null) { + args.push("--pr-number", cmd.prNumber.toString()) + } + if (cmd.repo !== null) { + args.push("--repo", cmd.repo) + } + if (!cmd.postComment) { + args.push("--no-comment") + } + return Effect.gen(function*(_) { + yield* _(Effect.log("Backing up AI session to private session repository...")) + yield* _(runNodeScript("scripts/session-backup-gist.js", args)) + yield* _(Effect.log("Session backup complete.")) + }) +} + +export const sessionGistList = ( + cmd: SessionGistListCommand +): Effect.Effect => { + const args: Array = ["list", "--limit", cmd.limit.toString()] + if (cmd.repo !== null) { + args.push("--repo", cmd.repo) + } + return Effect.gen(function*(_) { + yield* _(Effect.log("Listing session backup snapshots...")) + yield* _(runNodeScript("scripts/session-list-gists.js", args)) + }) +} + +export const sessionGistView = ( + cmd: SessionGistViewCommand +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(Effect.log(`Viewing snapshot: ${cmd.snapshotRef}`)) + yield* _(runNodeScript("scripts/session-list-gists.js", ["view", cmd.snapshotRef])) + }) + +export const sessionGistDownload = ( + cmd: SessionGistDownloadCommand +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(Effect.log(`Downloading snapshot ${cmd.snapshotRef} to ${cmd.outputDir}...`)) + yield* _(runNodeScript("scripts/session-list-gists.js", ["download", cmd.snapshotRef, "--output", cmd.outputDir])) + yield* _(Effect.log("Download complete.")) + }) diff --git a/prehook-comment-smoke.txt b/prehook-comment-smoke.txt new file mode 100644 index 00000000..5acb5da5 --- /dev/null +++ b/prehook-comment-smoke.txt @@ -0,0 +1 @@ +smoke-second-push 2026-03-18T14:48:31Z diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js new file mode 100644 index 00000000..b47a65d4 --- /dev/null +++ b/scripts/session-backup-gist.js @@ -0,0 +1,552 @@ +#!/usr/bin/env node + +/** + * Session Backup to a private GitHub repository + * + * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.gemini) + * to a dedicated private repository and optionally posts a comment to the + * associated PR with direct links to the uploaded files. + * + * Usage: + * node scripts/session-backup-gist.js [options] + * + * Options: + * --session-dir Path to session directory (default: auto-detect ~/.codex, ~/.claude, or ~/.gemini) + * --pr-number PR number to post comment to (optional, auto-detected from branch) + * --repo Source repository (optional, auto-detected from git remote) + * --no-comment Skip posting PR comment + * --dry-run Show what would be uploaded without actually uploading + * --verbose Enable verbose logging + * + * Environment: + * DOCKER_GIT_SKIP_SESSION_BACKUP=1 Skip session backup entirely + * + * @pure false - contains IO effects (file system, network, git commands) + * @effect FileSystem, ProcessExec, GitHubRepo + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { execSync, spawnSync } = require("node:child_process"); +const os = require("node:os"); + +const { + buildSnapshotRef, + ensureBackupRepo, + resolveGhEnvironment, + prepareUploadArtifacts, + uploadSnapshot, +} = require("./session-backup-repo.js"); + +const SESSION_DIR_NAMES = [".codex", ".claude", ".gemini"]; +const KNOWLEDGE_DIR_NAME = ".knowledge"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const result = { + sessionDir: null, + prNumber: null, + repo: null, + postComment: true, + dryRun: false, + verbose: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case "--session-dir": + result.sessionDir = args[++i]; + break; + case "--pr-number": + result.prNumber = parseInt(args[++i], 10); + break; + case "--repo": + result.repo = args[++i]; + break; + case "--no-comment": + result.postComment = false; + break; + case "--dry-run": + result.dryRun = true; + break; + case "--verbose": + result.verbose = true; + break; + case "--help": + console.log(`Usage: session-backup-gist.js [options] + +Options: + --session-dir Path to session directory + --pr-number PR number to post comment to + --repo Source repository + --no-comment Skip posting PR comment + --dry-run Show what would be uploaded + --verbose Enable verbose logging + --help Show this help message`); + process.exit(0); + } + } + + return result; +}; + +const log = (verbose, message) => { + if (verbose) { + console.log(`[session-backup] ${message}`); + } +}; + +const execCommand = (command, options = {}) => { + try { + return execSync(command, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + ...options, + }).trim(); + } catch { + return null; + } +}; + +const ghCommand = (args, ghEnv) => { + const result = spawnSync("gh", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: ghEnv, + }); + + return { + success: result.status === 0, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +}; + +const parseGitHubRepoFromRemoteUrl = (remoteUrl) => { + if (!remoteUrl) { + return null; + } + + const sshMatch = remoteUrl.match(/git@github\.com:([^/]+\/[^.]+)(?:\.git)?$/); + if (sshMatch) { + return sshMatch[1]; + } + + const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+\/[^.]+)(?:\.git)?$/); + if (httpsMatch) { + return httpsMatch[1]; + } + + return null; +}; + +const rankRemoteName = (remoteName) => { + if (remoteName === "upstream") { + return 0; + } + if (remoteName === "origin") { + return 1; + } + return 2; +}; + +const getCurrentBranch = () => execCommand("git rev-parse --abbrev-ref HEAD"); + +const getHeadCommitSha = () => execCommand("git rev-parse HEAD"); + +const getRepoCandidates = (explicitRepo, verbose) => { + if (explicitRepo) { + return [explicitRepo]; + } + + const remoteOutput = execCommand("git remote -v"); + if (!remoteOutput) { + return []; + } + + const remotes = []; + const seenRepos = new Set(); + + for (const line of remoteOutput.split("\n")) { + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (!match || match[3] !== "fetch") { + continue; + } + + const [, remoteName, remoteUrl] = match; + const repo = parseGitHubRepoFromRemoteUrl(remoteUrl); + if (!repo || seenRepos.has(repo)) { + continue; + } + + remotes.push({ remoteName, repo }); + seenRepos.add(repo); + } + + remotes.sort((left, right) => { + const rankDiff = rankRemoteName(left.remoteName) - rankRemoteName(right.remoteName); + return rankDiff !== 0 ? rankDiff : left.remoteName.localeCompare(right.remoteName); + }); + + const repos = remotes.map(({ repo }) => repo); + if (repos.length > 0) { + log(verbose, `Repository candidates: ${repos.join(", ")}`); + } + return repos; +}; + +const getPrNumberFromBranch = (repo, branch, ghEnv) => { + const result = ghCommand([ + "pr", + "list", + "--repo", + repo, + "--head", + branch, + "--json", + "number", + "--jq", + ".[0].number", + ], ghEnv); + + if (result.success && result.stdout && !Number.isNaN(parseInt(result.stdout, 10))) { + return parseInt(result.stdout, 10); + } + return null; +}; + +const prExists = (repo, prNumber, ghEnv) => { + const result = ghCommand([ + "pr", + "view", + prNumber.toString(), + "--repo", + repo, + "--json", + "number", + "--jq", + ".number", + ], ghEnv); + + return result.success && result.stdout === prNumber.toString(); +}; + +const getPrNumberFromWorkspaceBranch = (branch) => { + const match = branch.match(/^pr-refs-pull-([0-9]+)-head$/); + if (!match) { + return null; + } + + const prNumber = parseInt(match[1], 10); + return Number.isNaN(prNumber) ? null : prNumber; +}; + +const findPrContext = (repos, branch, verbose, ghEnv) => { + for (const repo of repos) { + log(verbose, `Checking open PR in ${repo} for branch ${branch}`); + const prNumber = getPrNumberFromBranch(repo, branch, ghEnv); + if (prNumber !== null) { + return { repo, prNumber }; + } + } + + const workspacePrNumber = getPrNumberFromWorkspaceBranch(branch); + if (workspacePrNumber === null) { + return null; + } + + for (const repo of repos) { + log(verbose, `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}`); + if (prExists(repo, workspacePrNumber, ghEnv)) { + return { repo, prNumber: workspacePrNumber }; + } + } + + return null; +}; + +const findSessionDirs = (explicitPath, verbose) => { + const dirs = []; + + if (explicitPath) { + if (fs.existsSync(explicitPath)) { + dirs.push({ name: path.basename(explicitPath), path: explicitPath }); + } + return dirs; + } + + const homeDir = os.homedir(); + for (const dirName of SESSION_DIR_NAMES) { + const dirPath = path.join(homeDir, dirName); + if (fs.existsSync(dirPath)) { + log(verbose, `Found session directory: ${dirPath}`); + dirs.push({ name: dirName, path: dirPath }); + } + } + + const cwd = process.cwd(); + const knowledgePath = path.join(cwd, KNOWLEDGE_DIR_NAME); + if (fs.existsSync(knowledgePath)) { + log(verbose, `Found knowledge directory: ${knowledgePath}`); + dirs.push({ name: KNOWLEDGE_DIR_NAME, path: knowledgePath }); + } + + return dirs; +}; + +const collectSessionFiles = (dirPath, baseName, verbose) => { + const files = []; + + const walk = (currentPath, relativePath) => { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") { + continue; + } + walk(fullPath, relPath); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + const isSessionFile = + ext === ".jsonl" || + ext === ".json" || + entry.name.endsWith(".part1") || + entry.name.endsWith(".part2") || + entry.name.endsWith(".part3") || + entry.name.endsWith(".chunks.json"); + + if (!isSessionFile) { + continue; + } + + try { + const stats = fs.statSync(fullPath); + const logicalName = `${baseName}/${relPath}`.replace(/\//g, "_"); + files.push({ + logicalName, + sourcePath: fullPath, + size: stats.size, + }); + log(verbose, `Collected file: ${logicalName} (${stats.size} bytes)`); + } catch (error) { + log(verbose, `Error reading file ${fullPath}: ${error.message}`); + } + } + } + }; + + walk(dirPath, ""); + return files; +}; + +const buildManifest = ({ backupRepo, snapshotRef, source, files, createdAt }) => ({ + version: 1, + createdAt, + storage: { + repo: backupRepo.fullName, + branch: backupRepo.defaultBranch, + snapshotRef, + }, + source, + files, +}); + +const buildCommentBody = ({ backupRepo, source, manifestUrl, files }) => { + const lines = [ + "## AI Session Backup", + "", + "A snapshot of the AI agent session has been saved to the private session backup repository.", + "", + `**Backup Repo:** ${backupRepo.fullName}`, + `**Source Commit:** \`${source.commitSha}\``, + "", + `**Manifest:** ${manifestUrl}`, + "", + "**Files:**", + ]; + + for (const file of files) { + if (file.type === "chunked") { + lines.push(`- ${file.name} (chunked): ${file.chunkManifestUrl}`); + } else { + lines.push(`- ${file.name}: ${file.url}`); + } + } + + lines.push(""); + lines.push("For extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc"); + lines.push(""); + lines.push("---"); + lines.push(`*Backup created at: ${source.createdAt}*`); + lines.push(``); + return lines.join("\n"); +}; + +const postPrComment = (repo, prNumber, comment, verbose, ghEnv) => { + log(verbose, `Posting comment to PR #${prNumber}`); + + const result = ghCommand([ + "pr", + "comment", + prNumber.toString(), + "--repo", + repo, + "--body", + comment, + ], ghEnv); + + if (!result.success) { + console.error(`[session-backup] Failed to post PR comment: ${result.stderr}`); + return false; + } + + log(verbose, "Comment posted successfully"); + return true; +}; + +const main = () => { + if (process.env.DOCKER_GIT_SKIP_SESSION_BACKUP === "1") { + console.log("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)"); + return; + } + + const args = parseArgs(); + const verbose = args.verbose; + const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); + + log(verbose, "Starting session backup..."); + + const repoCandidates = getRepoCandidates(args.repo, verbose); + if (repoCandidates.length === 0) { + console.error("[session-backup] Could not determine source repository. Use --repo option."); + process.exit(1); + } + const sourceRepo = repoCandidates[0]; + log(verbose, `Repository: ${sourceRepo}`); + + const branch = getCurrentBranch(); + if (!branch) { + console.error("[session-backup] Could not determine current branch."); + process.exit(1); + } + log(verbose, `Branch: ${branch}`); + + const commitSha = getHeadCommitSha(); + if (!commitSha) { + console.error("[session-backup] Could not determine current commit."); + process.exit(1); + } + + let prContext = null; + if (args.prNumber !== null) { + prContext = { repo: sourceRepo, prNumber: args.prNumber }; + } else if (args.postComment) { + prContext = findPrContext(repoCandidates, branch, verbose, ghEnv); + } + + if (prContext !== null) { + log(verbose, `PR number: ${prContext.prNumber} (${prContext.repo})`); + } else if (args.postComment) { + log(verbose, "No PR found for current branch, skipping comment"); + } + + const sessionDirs = findSessionDirs(args.sessionDir, verbose); + if (sessionDirs.length === 0) { + log(verbose, "No session directories found"); + return; + } + + const sessionFiles = []; + for (const dir of sessionDirs) { + sessionFiles.push(...collectSessionFiles(dir.path, dir.name, verbose)); + } + + if (sessionFiles.length === 0) { + log(verbose, "No session files found to backup"); + return; + } + log(verbose, `Total files to backup: ${sessionFiles.length}`); + + const backupRepo = ensureBackupRepo(ghEnv, (message) => log(verbose, message), !args.dryRun); + if (backupRepo === null) { + console.error("[session-backup] Failed to resolve or create the private session backup repository"); + process.exit(1); + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-repo-")); + + try { + const snapshotCreatedAt = new Date().toISOString(); + const snapshotRef = buildSnapshotRef(sourceRepo, prContext?.prNumber ?? null, commitSha, snapshotCreatedAt); + const prepared = prepareUploadArtifacts( + sessionFiles, + snapshotRef, + backupRepo.fullName, + backupRepo.defaultBranch, + tmpDir, + (message) => log(verbose, message) + ); + + const source = { + repo: sourceRepo, + branch, + prNumber: prContext?.prNumber ?? null, + commitSha, + createdAt: snapshotCreatedAt, + }; + + const manifest = buildManifest({ + backupRepo, + snapshotRef, + source, + files: prepared.manifestFiles, + createdAt: snapshotCreatedAt, + }); + if (args.dryRun) { + console.log(`[dry-run] Would upload snapshot to ${backupRepo.fullName}:${snapshotRef}`); + console.log(`[dry-run] Would write ${prepared.uploadEntries.length + 1} file(s) including manifest.`); + const manifestUrl = `https://github.com/${backupRepo.fullName}/blob/${ + encodeURIComponent(backupRepo.defaultBranch) + }/${snapshotRef.split("/").map((segment) => encodeURIComponent(segment)).join("/")}/manifest.json`; + console.log(`[dry-run] Manifest URL: ${manifestUrl}`); + if (args.postComment && prContext !== null) { + console.log(`[dry-run] Would post comment to PR #${prContext.prNumber} in ${prContext.repo}:`); + console.log(buildCommentBody({ backupRepo, source, manifestUrl, files: prepared.manifestFiles })); + } + return; + } + + log(verbose, `Uploading snapshot to ${backupRepo.fullName}:${snapshotRef}`); + const uploadResult = uploadSnapshot( + backupRepo, + snapshotRef, + manifest, + prepared.uploadEntries, + ghEnv + ); + + console.log(`[session-backup] Uploaded snapshot to ${backupRepo.fullName}`); + console.log(`[session-backup] Manifest: ${uploadResult.manifestUrl}`); + + if (args.postComment && prContext !== null) { + const comment = buildCommentBody({ + backupRepo, + source, + manifestUrl: uploadResult.manifestUrl, + files: prepared.manifestFiles, + }); + postPrComment(prContext.repo, prContext.prNumber, comment, verbose, ghEnv); + } + + console.log("[session-backup] Session backup complete"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}; + +main(); diff --git a/scripts/session-backup-repo.js b/scripts/session-backup-repo.js new file mode 100644 index 00000000..55bcc7ae --- /dev/null +++ b/scripts/session-backup-repo.js @@ -0,0 +1,838 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const BACKUP_REPO_NAME = "docker-git-sessions"; +const BACKUP_DEFAULT_BRANCH = "main"; +// Keep each stored object below GitHub's 100 MB limit while transport batches stay smaller. +const MAX_REPO_FILE_SIZE = 99 * 1000 * 1000; +const MAX_PUSH_BATCH_BYTES = 50 * 1000 * 1000; +const GH_GIT_CREDENTIAL_HELPER = "!gh auth git-credential"; +const CHUNK_MANIFEST_SUFFIX = ".chunks.json"; +const DOCKER_GIT_CONFIG_FILE = "docker-git.json"; +const GITHUB_ENV_KEYS = ["GITHUB_TOKEN", "GH_TOKEN"]; + +const parseEnvText = (text) => { + const entries = []; + + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) { + continue; + } + entries.push({ key: match[1], value: match[2] }); + } + + return entries; +}; + +const findGithubTokenInEnvText = (text) => { + const entries = parseEnvText(text); + + for (const key of GITHUB_ENV_KEYS) { + const entry = entries.find((item) => item.key === key); + const token = entry?.value?.trim() ?? ""; + if (token.length > 0) { + return { key, token }; + } + } + + return null; +}; + +const getDockerGitProjectsRoot = () => { + const configured = process.env.DOCKER_GIT_PROJECTS_ROOT?.trim(); + if (configured && configured.length > 0) { + return configured; + } + return path.join(os.homedir(), ".docker-git"); +}; + +const readJsonFile = (filePath) => { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +}; + +const findDockerGitProjectForTarget = (projectsRoot, targetDir, log) => { + if (!fs.existsSync(projectsRoot)) { + return null; + } + + const stack = [projectsRoot]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + const configPath = path.join(currentDir, DOCKER_GIT_CONFIG_FILE); + if (fs.existsSync(configPath)) { + const config = readJsonFile(configPath); + const candidateTarget = config?.template?.targetDir; + if (typeof candidateTarget === "string" && candidateTarget === targetDir) { + log(`Resolved docker-git project config: ${configPath}`); + return { configPath, config }; + } + } + + let entries = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".cache") { + continue; + } + stack.push(path.join(currentDir, entry.name)); + } + } + + return null; +}; + +const getGithubEnvFileCandidates = (repoRoot, log) => { + const projectsRoot = getDockerGitProjectsRoot(); + const candidates = []; + const seen = new Set(); + + const project = findDockerGitProjectForTarget(projectsRoot, repoRoot, log); + const projectEnvGlobal = project?.config?.template?.envGlobalPath; + if (project?.configPath && typeof projectEnvGlobal === "string" && projectEnvGlobal.length > 0) { + const projectEnvPath = path.resolve(path.dirname(project.configPath), projectEnvGlobal); + candidates.push(projectEnvPath); + seen.add(projectEnvPath); + } + + const defaults = [ + path.join(projectsRoot, ".orch", "env", "global.env"), + path.join(projectsRoot, "secrets", "global.env"), + ]; + + for (const candidate of defaults) { + if (!seen.has(candidate)) { + candidates.push(candidate); + seen.add(candidate); + } + } + + return candidates; +}; + +const resolveGhEnvironment = (repoRoot, log) => { + const env = { ...process.env }; + const candidates = getGithubEnvFileCandidates(repoRoot, log); + + for (const envPath of candidates) { + if (!fs.existsSync(envPath)) { + continue; + } + const resolved = findGithubTokenInEnvText(fs.readFileSync(envPath, "utf8")); + if (resolved !== null) { + log(`Using ${resolved.key} from ${envPath} for GitHub CLI auth`); + env.GH_TOKEN = resolved.token; + env.GITHUB_TOKEN = resolved.token; + return env; + } + } + + const fromProcess = GITHUB_ENV_KEYS.find((key) => { + const value = process.env[key]?.trim() ?? ""; + return value.length > 0; + }); + + if (fromProcess) { + log(`Using ${fromProcess} from current process environment for GitHub CLI auth`); + } else { + log("No GitHub token found in docker-git env files or current process"); + } + + return env; +}; + +const ghCommand = (args, ghEnv, inputFilePath = null) => { + const resolvedArgs = inputFilePath ? [...args, "--input", inputFilePath] : args; + const result = spawnSync("gh", resolvedArgs, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: ghEnv, + }); + + return { + success: result.status === 0, + status: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +}; + +const ghApi = (endpoint, ghEnv, options = {}) => { + const args = ["api", endpoint]; + if (options.method && options.method !== "GET") { + args.push("-X", options.method); + } + if (options.jq) { + args.push("--jq", options.jq); + } + if (options.rawFields) { + for (const [key, value] of Object.entries(options.rawFields)) { + args.push("-f", `${key}=${value}`); + } + } + + let inputFilePath = null; + if (options.body !== undefined) { + inputFilePath = path.join(os.tmpdir(), `docker-git-gh-api-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + fs.writeFileSync(inputFilePath, JSON.stringify(options.body), "utf8"); + } + + try { + return ghCommand(args, ghEnv, inputFilePath); + } finally { + if (inputFilePath !== null) { + fs.rmSync(inputFilePath, { force: true }); + } + } +}; + +const ghApiJson = (endpoint, ghEnv, options = {}) => { + const result = ghApi(endpoint, ghEnv, options); + if (!result.success) { + return { ...result, json: null }; + } + + try { + return { ...result, json: JSON.parse(result.stdout) }; + } catch { + return { ...result, json: null }; + } +}; + +const ensureSuccess = (result, context) => { + if (!result.success) { + throw new Error(`${context}: ${result.stderr || result.stdout || `exit ${result.status}`}`); + } + return result; +}; + +const resolveViewerLogin = (ghEnv) => + ensureSuccess( + ghApi("/user", ghEnv, { jq: ".login" }), + "failed to resolve authenticated GitHub login" + ).stdout; + +const buildBlobUrl = (repoFullName, branch, repoPath) => + `https://github.com/${repoFullName}/blob/${encodeURIComponent(branch)}/${ + repoPath.split("/").map((segment) => encodeURIComponent(segment)).join("/") + }`; + +const toSnapshotStamp = (createdAt) => + createdAt.replaceAll(":", "-").replaceAll(".", "-"); + +const getRepoInfo = (repoFullName, ghEnv) => + ghApiJson(`/repos/${repoFullName}`, ghEnv); + +const ensureBackupRepo = (ghEnv, log, createIfMissing = true) => { + const login = resolveViewerLogin(ghEnv); + const repoFullName = `${login}/${BACKUP_REPO_NAME}`; + let repoResult = getRepoInfo(repoFullName, ghEnv); + + if (!repoResult.success && createIfMissing) { + log(`Creating private session backup repository for ${login}...`); + repoResult = ghApiJson("/user/repos", ghEnv, { + method: "POST", + body: { + name: BACKUP_REPO_NAME, + private: true, + auto_init: true, + description: "docker-git session backups", + }, + }); + } + + if (!repoResult.success || repoResult.json === null) { + return null; + } + + const defaultBranch = repoResult.json.default_branch || BACKUP_DEFAULT_BRANCH; + return { + owner: login, + repo: BACKUP_REPO_NAME, + fullName: repoFullName, + defaultBranch, + htmlUrl: repoResult.json.html_url, + }; +}; + +const getBranchHeadSha = (repoFullName, branch, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/ref/heads/${branch}`, ghEnv, { jq: ".object.sha" }), + `failed to resolve ${repoFullName}@${branch} ref` + ).stdout; + +const getCommitTreeSha = (repoFullName, commitSha, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/commits/${commitSha}`, ghEnv, { jq: ".tree.sha" }), + `failed to resolve tree for commit ${commitSha}` + ).stdout; + +const getTreeEntries = (repoFullName, branch, ghEnv) => { + const headSha = getBranchHeadSha(repoFullName, branch, ghEnv); + const treeSha = getCommitTreeSha(repoFullName, headSha, ghEnv); + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), + `failed to list tree for ${repoFullName}@${branch}` + ); + return { + headSha, + treeSha, + entries: Array.isArray(result.json?.tree) ? result.json.tree : [], + }; +}; + +const getTreeEntriesForCommit = (repoFullName, commitSha, ghEnv) => { + const treeSha = getCommitTreeSha(repoFullName, commitSha, ghEnv); + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/git/trees/${treeSha}?recursive=1`, ghEnv), + `failed to list tree for commit ${commitSha} in ${repoFullName}` + ); + return { + treeSha, + entries: Array.isArray(result.json?.tree) ? result.json.tree : [], + }; +}; + +const getFileContent = (repoFullName, repoPath, ghEnv, ref = BACKUP_DEFAULT_BRANCH) => { + const result = ensureSuccess( + ghApiJson(`/repos/${repoFullName}/contents/${repoPath}?ref=${encodeURIComponent(ref)}`, ghEnv), + `failed to fetch ${repoFullName}:${repoPath}` + ); + const encoding = result.json?.encoding; + const content = typeof result.json?.content === "string" ? result.json.content.replace(/\n/g, "") : ""; + if (encoding !== "base64" || content.length === 0) { + throw new Error(`unexpected content payload for ${repoFullName}:${repoPath}`); + } + return Buffer.from(content, "base64"); +}; + +const buildSnapshotRef = (sourceRepo, prNumber, commitSha, createdAt) => + `${sourceRepo}/pr-${prNumber === null ? "no-pr" : prNumber}/commit-${commitSha}/${toSnapshotStamp(createdAt)}`; + +const buildCommitMessage = ({ sourceRepo, repo, branch, commitSha, createdAt }) => + `session-backup: ${sourceRepo ?? repo ?? "unknown"} ${branch} ${commitSha.slice(0, 12)} ${toSnapshotStamp(createdAt)}`; + +const buildBatchCommitMessage = (source, batchIndex, batchCount) => + `${buildCommitMessage(source)} [files ${batchIndex}/${batchCount}]`; + +const buildManifestCommitMessage = (source) => + `${buildCommitMessage(source)} [manifest]`; + +const buildChunkManifest = (logicalName, originalSize, partNames) => ({ + original: logicalName, + originalSize, + parts: partNames, + splitAt: MAX_REPO_FILE_SIZE, + partsCount: partNames.length, + createdAt: new Date().toISOString(), +}); + +const splitLargeFile = (sourcePath, logicalName, outputDir) => { + const totalSize = fs.statSync(sourcePath).size; + const partNames = []; + const fd = fs.openSync(sourcePath, "r"); + const buffer = Buffer.alloc(1024 * 1024); + let offset = 0; + let remaining = totalSize; + let partIndex = 1; + let partBytesWritten = 0; + let partName = `${logicalName}.part${partIndex}`; + let partPath = path.join(outputDir, partName); + let partFd = fs.openSync(partPath, "w"); + partNames.push(partName); + + try { + while (remaining > 0) { + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, offset); + if (bytesRead === 0) { + break; + } + + let chunkOffset = 0; + while (chunkOffset < bytesRead) { + if (partBytesWritten >= MAX_REPO_FILE_SIZE) { + fs.closeSync(partFd); + partIndex += 1; + partBytesWritten = 0; + partName = `${logicalName}.part${partIndex}`; + partPath = path.join(outputDir, partName); + partFd = fs.openSync(partPath, "w"); + partNames.push(partName); + } + + const remainingChunk = bytesRead - chunkOffset; + const remainingPart = MAX_REPO_FILE_SIZE - partBytesWritten; + const toWrite = Math.min(remainingChunk, remainingPart); + fs.writeSync(partFd, buffer.subarray(chunkOffset, chunkOffset + toWrite)); + partBytesWritten += toWrite; + chunkOffset += toWrite; + } + + offset += bytesRead; + remaining -= bytesRead; + } + } finally { + fs.closeSync(fd); + fs.closeSync(partFd); + } + + return { + originalSize: totalSize, + partNames, + manifestName: `${logicalName}${CHUNK_MANIFEST_SUFFIX}`, + }; +}; + +const prepareUploadArtifacts = (sessionFiles, snapshotRef, repoFullName, branch, tmpDir, log) => { + const uploadEntries = []; + const manifestFiles = []; + + for (const file of sessionFiles) { + if (file.size <= MAX_REPO_FILE_SIZE) { + const repoPath = `${snapshotRef}/${file.logicalName}`; + uploadEntries.push({ + repoPath, + sourcePath: file.sourcePath, + type: "file", + size: file.size, + }); + manifestFiles.push({ + type: "file", + name: file.logicalName, + size: file.size, + repoPath, + url: buildBlobUrl(repoFullName, branch, repoPath), + }); + continue; + } + + log(`Splitting oversized file ${file.logicalName} (${file.size} bytes)`); + const split = splitLargeFile(file.sourcePath, file.logicalName, tmpDir); + const chunkManifest = buildChunkManifest(file.logicalName, split.originalSize, split.partNames); + const chunkManifestPath = path.join(tmpDir, split.manifestName); + fs.writeFileSync(chunkManifestPath, `${JSON.stringify(chunkManifest, null, 2)}\n`, "utf8"); + + const partEntries = split.partNames.map((partName) => { + const repoPath = `${snapshotRef}/${partName}`; + uploadEntries.push({ + repoPath, + sourcePath: path.join(tmpDir, partName), + type: "chunk-part", + size: fs.statSync(path.join(tmpDir, partName)).size, + }); + return { + name: partName, + repoPath, + url: buildBlobUrl(repoFullName, branch, repoPath), + }; + }); + + const chunkManifestRepoPath = `${snapshotRef}/${split.manifestName}`; + uploadEntries.push({ + repoPath: chunkManifestRepoPath, + sourcePath: chunkManifestPath, + type: "chunk-manifest", + size: fs.statSync(chunkManifestPath).size, + }); + + manifestFiles.push({ + type: "chunked", + name: file.logicalName, + originalSize: split.originalSize, + chunkManifestPath: chunkManifestRepoPath, + chunkManifestUrl: buildBlobUrl(repoFullName, branch, chunkManifestRepoPath), + parts: partEntries, + }); + } + + return { uploadEntries, manifestFiles }; +}; + +const splitUploadEntriesIntoBatches = (uploadEntries) => { + const batches = []; + let currentBatch = []; + let currentBatchBytes = 0; + + for (const entry of uploadEntries) { + if (currentBatch.length > 0 && currentBatchBytes + entry.size > MAX_PUSH_BATCH_BYTES) { + batches.push(currentBatch); + currentBatch = []; + currentBatchBytes = 0; + } + + currentBatch.push(entry); + currentBatchBytes += entry.size; + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + return batches; +}; + +const runGitCommand = (repoDir, args, env) => { + const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env, + }); + + return { + success: result.status === 0, + status: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +}; + +const ensureGitSuccess = (result, context) => { + if (!result.success) { + throw new Error(`${context}: ${result.stderr || result.stdout || `exit ${result.status}`}`); + } + return result; +}; + +const runGitCommandWithInput = (repoDir, args, env, input) => { + const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "protocol.version=2", "-C", repoDir, ...args], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env, + input, + }); + + return { + success: result.status === 0, + status: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +}; + +const buildGitPushEnv = (ghEnv, token) => ({ + ...ghEnv, + GH_TOKEN: token, + GITHUB_TOKEN: token, + GIT_AUTH_TOKEN: token, + GIT_TERMINAL_PROMPT: "0", +}); + +const initializeUploadRepo = (repoDir, backupRepo, gitEnv) => { + ensureGitSuccess(runGitCommand(repoDir, ["init", "-q"], gitEnv), `failed to init git repo ${repoDir}`); + ensureGitSuccess( + runGitCommand(repoDir, ["remote", "add", "origin", `https://github.com/${backupRepo.fullName}.git`], gitEnv), + `failed to configure git remote for ${backupRepo.fullName}` + ); +}; + +const fetchRemoteBranchTip = (repoDir, branch, gitEnv) => { + ensureGitSuccess( + runGitCommand( + repoDir, + [ + "-c", + `credential.helper=${GH_GIT_CREDENTIAL_HELPER}`, + "fetch", + "--quiet", + "--no-tags", + "--depth=1", + "--filter=blob:none", + "origin", + `refs/heads/${branch}:refs/remotes/origin/${branch}`, + ], + gitEnv + ), + `failed to fetch ${branch} tip from backup repository` + ); + return ensureGitSuccess( + runGitCommand(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], gitEnv), + `failed to resolve fetched ${branch} tip` + ).stdout; +}; + +const hashFileObject = (repoDir, sourcePath, gitEnv) => + ensureGitSuccess( + runGitCommand(repoDir, ["hash-object", "-w", sourcePath], gitEnv), + `failed to hash ${sourcePath}` + ).stdout; + +const createTreeObject = (repoDir, entries, gitEnv) => { + const body = entries + .slice() + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => `${entry.mode} ${entry.type} ${entry.sha}\t${entry.name}`) + .join("\n"); + return ensureGitSuccess( + runGitCommandWithInput(repoDir, ["mktree", "--missing"], gitEnv, body.length > 0 ? `${body}\n` : ""), + "failed to create git tree" + ).stdout; +}; + +const createCommitObject = (repoDir, treeSha, parentSha, message, createdAt, owner, gitEnv) => { + const authorEmail = `${owner}@users.noreply.github.com`; + const unixSeconds = Math.floor(new Date(createdAt).getTime() / 1000); + const commitBody = [ + `tree ${treeSha}`, + `parent ${parentSha}`, + `author ${owner} <${authorEmail}> ${unixSeconds} +0000`, + `committer ${owner} <${authorEmail}> ${unixSeconds} +0000`, + "", + message, + "", + ].join("\n"); + return ensureGitSuccess( + runGitCommandWithInput(repoDir, ["hash-object", "-t", "commit", "-w", "--stdin"], gitEnv, commitBody), + "failed to create git commit" + ).stdout; +}; + +const updateLocalRef = (repoDir, refName, commitSha, gitEnv) => + ensureGitSuccess( + runGitCommand(repoDir, ["update-ref", refName, commitSha], gitEnv), + `failed to update local ref ${refName}` + ); + +const isNonFastForwardPushError = (result) => + /non-fast-forward|fetch first|rejected/i.test(`${result.stderr}\n${result.stdout}`); + +const pushCommitToBranch = (repoDir, sourceRef, branch, gitEnv) => + runGitCommand( + repoDir, + [ + "-c", + `credential.helper=${GH_GIT_CREDENTIAL_HELPER}`, + "push", + "origin", + `${sourceRef}:refs/heads/${branch}`, + ], + gitEnv + ); + +const buildFileMapFromTreeEntries = (entries) => { + const fileMap = new Map(); + for (const entry of entries) { + if (entry.type === "tree") { + continue; + } + if (typeof entry.path !== "string" || typeof entry.sha !== "string" || typeof entry.mode !== "string") { + continue; + } + fileMap.set(entry.path, { + mode: entry.mode, + type: entry.type, + sha: entry.sha, + }); + } + return fileMap; +}; + +const buildDirectoryGraph = (fileMap) => { + const directories = new Set([""]); + const childrenByDir = new Map(); + + const addChild = (dirPath, child) => { + const current = childrenByDir.get(dirPath) ?? []; + current.push(child); + childrenByDir.set(dirPath, current); + }; + + for (const [repoPath, entry] of fileMap.entries()) { + const segments = repoPath.split("/"); + const name = segments.pop(); + const dirPath = segments.join("/"); + if (!name) { + continue; + } + directories.add(dirPath); + for (let index = 1; index <= segments.length; index += 1) { + directories.add(segments.slice(0, index).join("/")); + } + addChild(dirPath, { + name, + mode: entry.mode, + type: entry.type, + sha: entry.sha, + }); + } + + return { + directories: Array.from(directories).sort((left, right) => { + const depthDiff = right.split("/").length - left.split("/").length; + return depthDiff !== 0 ? depthDiff : right.localeCompare(left); + }), + childrenByDir, + addChild, + }; +}; + +const writeMergedTree = (repoDir, existingEntries, newEntries, gitEnv) => { + const fileMap = buildFileMapFromTreeEntries(existingEntries); + for (const entry of newEntries) { + fileMap.set(entry.repoPath, { + mode: "100644", + type: "blob", + sha: entry.sha, + }); + } + + const { directories, childrenByDir, addChild } = buildDirectoryGraph(fileMap); + + for (const dirPath of directories) { + if (dirPath.length === 0) { + continue; + } + const childEntries = childrenByDir.get(dirPath) ?? []; + const treeSha = createTreeObject(repoDir, childEntries, gitEnv); + const segments = dirPath.split("/"); + const name = segments.pop(); + const parentDir = segments.join("/"); + if (!name) { + continue; + } + addChild(parentDir, { + name, + mode: "040000", + type: "tree", + sha: treeSha, + }); + } + + return createTreeObject(repoDir, childrenByDir.get("") ?? [], gitEnv); +}; + +const buildUploadCommitMessage = (source, batchIndex, batchCount) => + batchCount <= 1 + ? buildCommitMessage(source) + : `${buildCommitMessage(source)} [batch ${batchIndex}/${batchCount}]`; + +const uploadSnapshot = (backupRepo, snapshotRef, snapshotManifest, uploadEntries, ghEnv) => { + const token = ghEnv.GITHUB_TOKEN?.trim() || ghEnv.GH_TOKEN?.trim() || ""; + if (token.length === 0) { + throw new Error("GitHub token missing for backup repository push"); + } + + const uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-git-push-")); + const manifestPath = `${snapshotRef}/manifest.json`; + const manifestTempPath = path.join(uploadRoot, "manifest.json"); + fs.writeFileSync(manifestTempPath, `${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8"); + const manifestEntry = { + repoPath: manifestPath, + sourcePath: manifestTempPath, + size: fs.statSync(manifestTempPath).size, + }; + const uploadBatches = splitUploadEntriesIntoBatches([...uploadEntries, manifestEntry]); + + try { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const attemptDir = path.join(uploadRoot, `attempt-${attempt}`); + const repoDir = path.join(attemptDir, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + const gitEnv = buildGitPushEnv(ghEnv, token); + + initializeUploadRepo(repoDir, backupRepo, gitEnv); + let headSha = fetchRemoteBranchTip(repoDir, backupRepo.defaultBranch, gitEnv); + let { entries: existingEntries } = getTreeEntriesForCommit(backupRepo.fullName, headSha, ghEnv); + let lastCommitSha = headSha; + let shouldRetry = false; + + for (let batchIndex = 0; batchIndex < uploadBatches.length; batchIndex += 1) { + const hashedEntries = uploadBatches[batchIndex].map((entry) => ({ + repoPath: entry.repoPath, + sha: hashFileObject(repoDir, entry.sourcePath, gitEnv), + })); + + const nextTreeSha = writeMergedTree(repoDir, existingEntries, hashedEntries, gitEnv); + const commitSha = createCommitObject( + repoDir, + nextTreeSha, + headSha, + buildUploadCommitMessage(snapshotManifest.source, batchIndex + 1, uploadBatches.length), + snapshotManifest.source.createdAt, + backupRepo.owner, + gitEnv + ); + const localRef = `refs/heads/session-backup-upload-${attempt}-${batchIndex + 1}`; + updateLocalRef(repoDir, localRef, commitSha, gitEnv); + const pushResult = pushCommitToBranch(repoDir, localRef, backupRepo.defaultBranch, gitEnv); + if (!pushResult.success) { + if (attempt < 3 && isNonFastForwardPushError(pushResult)) { + shouldRetry = true; + break; + } + throw new Error(`failed to push backup commit: ${pushResult.stderr || pushResult.stdout || `exit ${pushResult.status}`}`); + } + + headSha = commitSha; + lastCommitSha = commitSha; + existingEntries = existingEntries.concat( + hashedEntries.map((entry) => ({ + path: entry.repoPath, + mode: "100644", + type: "blob", + sha: entry.sha, + })) + ); + } + + if (shouldRetry) { + continue; + } + + return { + commitSha: lastCommitSha, + manifestPath, + manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath), + }; + } + + throw new Error("failed to push backup commit after 3 attempts"); + } finally { + fs.rmSync(uploadRoot, { recursive: true, force: true }); + } +}; + +const sanitizeSnapshotRefForOutput = (snapshotRef) => + snapshotRef.replace(/[\\/]/g, "_"); + +const decodeChunkManifestBuffer = (buffer, sourcePath) => { + try { + return JSON.parse(buffer.toString("utf8")); + } catch (error) { + throw new Error(`failed to parse chunk manifest ${sourcePath}: ${error.message}`); + } +}; + +module.exports = { + BACKUP_DEFAULT_BRANCH, + BACKUP_REPO_NAME, + CHUNK_MANIFEST_SUFFIX, + MAX_REPO_FILE_SIZE, + buildBlobUrl, + buildSnapshotRef, + decodeChunkManifestBuffer, + ensureBackupRepo, + getFileContent, + getTreeEntries, + parseEnvText, + prepareUploadArtifacts, + resolveGhEnvironment, + sanitizeSnapshotRefForOutput, + uploadSnapshot, +}; diff --git a/scripts/session-list-gists.js b/scripts/session-list-gists.js new file mode 100644 index 00000000..cace6779 --- /dev/null +++ b/scripts/session-list-gists.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +/** + * List AI Session Backups from the private session backup repository + * + * Usage: + * node scripts/session-list-gists.js [command] [options] + * + * Commands: + * list List session snapshots (default) + * view View metadata for a snapshot + * download Download snapshot contents to local directory + * + * Options: + * --limit Maximum number of snapshots to list (default: 20) + * --repo Filter by source repository + * --output Output directory for download (default: ./.session-restore) + * --verbose Enable verbose logging + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const { + ensureBackupRepo, + getFileContent, + getTreeEntries, + resolveGhEnvironment, + sanitizeSnapshotRefForOutput, +} = require("./session-backup-repo.js"); + +const parseArgs = () => { + const args = process.argv.slice(2); + const result = { + command: "list", + snapshotRef: null, + limit: 20, + repo: null, + output: "./.session-restore", + verbose: false, + }; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith("--")) { + switch (arg) { + case "--limit": + result.limit = parseInt(args[++i], 10); + break; + case "--repo": + result.repo = args[++i]; + break; + case "--output": + result.output = args[++i]; + break; + case "--verbose": + result.verbose = true; + break; + case "--help": + console.log(`Usage: session-list-gists.js [command] [options] + +Commands: + list List session snapshots (default) + view View metadata for a snapshot + download Download snapshot contents to local directory + +Options: + --limit Maximum number of snapshots to list (default: 20) + --repo Filter by source repository + --output Output directory for download (default: ./.session-restore) + --verbose Enable verbose logging + --help Show this help message`); + process.exit(0); + } + } else if (!result.command || result.command === "list") { + if (arg === "list" || arg === "view" || arg === "download") { + result.command = arg; + } else if (result.command !== "list") { + result.snapshotRef = arg; + } + } else if (!result.snapshotRef) { + result.snapshotRef = arg; + } + i++; + } + + return result; +}; + +const log = (verbose, message) => { + if (verbose) { + console.log(`[session-backups] ${message}`); + } +}; + +const ensureBackupRepoOrExit = (ghEnv, verbose) => { + const backupRepo = ensureBackupRepo(ghEnv, (message) => log(verbose, message), false); + if (backupRepo === null) { + console.log("No private session backup repository found."); + process.exit(0); + } + return backupRepo; +}; + +const decodeJsonBuffer = (buffer, context) => { + try { + return JSON.parse(buffer.toString("utf8")); + } catch (error) { + console.error(`Failed to parse JSON for ${context}: ${error.message}`); + process.exit(1); + } +}; + +const getManifestRepoPath = (snapshotRef) => `${snapshotRef}/manifest.json`; + +const fetchManifest = (backupRepo, snapshotRef, ghEnv) => { + const manifestPath = getManifestRepoPath(snapshotRef); + const buffer = getFileContent(backupRepo.fullName, manifestPath, ghEnv, backupRepo.defaultBranch); + return { + path: manifestPath, + data: decodeJsonBuffer(buffer, manifestPath), + }; +}; + +const listSnapshots = (limit, repoFilter, backupRepo, ghEnv, verbose) => { + log(verbose, `Listing snapshots from ${backupRepo.fullName}`); + const { entries } = getTreeEntries(backupRepo.fullName, backupRepo.defaultBranch, ghEnv); + const manifestPaths = entries + .filter((entry) => entry.type === "blob" && typeof entry.path === "string" && entry.path.endsWith("/manifest.json")) + .map((entry) => entry.path); + + const filtered = repoFilter + ? manifestPaths.filter((entryPath) => entryPath.startsWith(`${repoFilter}/`)) + : manifestPaths; + + if (filtered.length === 0) { + console.log("No session snapshots found."); + if (repoFilter) { + console.log(`(Filtered by repo: ${repoFilter})`); + } + return; + } + + const selected = filtered.slice(0, limit); + console.log("Session Snapshots:\n"); + for (const manifestPath of selected) { + const snapshotRef = manifestPath.slice(0, -"/manifest.json".length); + const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); + console.log(snapshotRef); + console.log(` Source: ${manifest.data.source.repo}`); + console.log(` Commit: ${manifest.data.source.commitSha}`); + console.log(` Created: ${manifest.data.createdAt}`); + console.log(` Manifest: https://github.com/${backupRepo.fullName}/blob/${encodeURIComponent(backupRepo.defaultBranch)}/${manifest.path.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`); + console.log(""); + } + + console.log(`Total: ${filtered.length} snapshot(s)`); +}; + +const viewSnapshot = (snapshotRef, backupRepo, ghEnv, verbose) => { + if (!snapshotRef) { + console.error("Error: snapshot-ref is required for view command"); + process.exit(1); + } + + log(verbose, `Viewing snapshot: ${snapshotRef}`); + const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); + console.log(JSON.stringify(manifest.data, null, 2)); +}; + +const downloadSnapshot = (snapshotRef, outputDir, backupRepo, ghEnv, verbose) => { + if (!snapshotRef) { + console.error("Error: snapshot-ref is required for download command"); + process.exit(1); + } + + log(verbose, `Downloading snapshot ${snapshotRef} to ${outputDir}`); + const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); + const outputPath = path.resolve(outputDir, sanitizeSnapshotRefForOutput(snapshotRef)); + fs.mkdirSync(outputPath, { recursive: true }); + fs.writeFileSync(path.join(outputPath, "manifest.json"), `${JSON.stringify(manifest.data, null, 2)}\n`, "utf8"); + + for (const file of manifest.data.files) { + const targetPath = path.join(outputPath, file.name); + if (file.type === "chunked") { + const buffers = file.parts.map((part) => + getFileContent(backupRepo.fullName, part.repoPath, ghEnv, backupRepo.defaultBranch) + ); + fs.writeFileSync(targetPath, Buffer.concat(buffers)); + continue; + } + + const buffer = getFileContent(backupRepo.fullName, file.repoPath, ghEnv, backupRepo.defaultBranch); + fs.writeFileSync(targetPath, buffer); + } + + console.log(`Downloaded snapshot to: ${outputPath}`); + console.log("\nTo restore session files, copy them to the appropriate location:"); + console.log(" - .codex_* files -> ~/.codex/"); + console.log(" - .claude_* files -> ~/.claude/"); + console.log(" - .gemini_* files -> ~/.gemini/"); + console.log(" - .knowledge_* files -> ./.knowledge/"); +}; + +const main = () => { + const args = parseArgs(); + const verbose = args.verbose; + const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); + const backupRepo = ensureBackupRepoOrExit(ghEnv, verbose); + + switch (args.command) { + case "list": + listSnapshots(args.limit, args.repo, backupRepo, ghEnv, verbose); + break; + case "view": + viewSnapshot(args.snapshotRef, backupRepo, ghEnv, verbose); + break; + case "download": + downloadSnapshot(args.snapshotRef, args.output, backupRepo, ghEnv, verbose); + break; + default: + console.error(`Unknown command: ${args.command}`); + process.exit(1); + } +}; + +main();