From bf73129359d8b0acf37ae3a368572d7acd335442 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 16 Mar 2026 09:43:09 +0000 Subject: [PATCH 1/9] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/143 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..a9fd2cc2 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-03-16T09:43:09.822Z for PR creation at branch issue-143-8b42b7a3b21f for issue https://github.com/ProverCoderAI/docker-git/issues/143 \ No newline at end of file From 915ca0890f230665608d0cb54f5c27b46303b691 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 16 Mar 2026 10:03:09 +0000 Subject: [PATCH 2/9] feat(session-gists): auto-backup AI sessions to private gists on push - Add pre-push hook to backup ~/.codex and ~/.claude sessions to GitHub Gists - Add CLI commands: docker-git session-gists [backup|list|view|download] - Post PR comment with gist link when pushing to an open PR - Support --pr-number, --repo, --no-comment, --limit, --output options - Extract shared parser helpers (splitSubcommand, parsePositiveInt) - Add jscpd ignore pattern for Effect-TS Match-based parser boilerplate Closes #143 Co-Authored-By: Claude Opus 4.5 --- .githooks/pre-push | 11 + packages/app/.jscpd.json | 3 +- .../app/src/docker-git/cli/parser-options.ts | 20 +- .../docker-git/cli/parser-session-gists.ts | 103 ++++ .../app/src/docker-git/cli/parser-sessions.ts | 31 +- .../app/src/docker-git/cli/parser-shared.ts | 42 ++ packages/app/src/docker-git/cli/parser.ts | 7 +- packages/app/src/docker-git/cli/usage.ts | 10 + packages/app/src/docker-git/program.ts | 10 + packages/lib/src/core/command-options.ts | 6 + packages/lib/src/core/domain.ts | 40 ++ packages/lib/src/usecases/session-gists.ts | 92 ++++ scripts/session-backup-gist.js | 454 ++++++++++++++++++ scripts/session-list-gists.js | 288 +++++++++++ 14 files changed, 1085 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/docker-git/cli/parser-session-gists.ts create mode 100644 packages/lib/src/usecases/session-gists.ts create mode 100644 scripts/session-backup-gist.js create mode 100644 scripts/session-list-gists.js diff --git a/.githooks/pre-push b/.githooks/pre-push index 7de9b5a2..4f1b0d8f 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 private gist on push +# WHY: allows returning to old AI sessions and provides PR context +# 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 && gh auth status >/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..0fe6694e --- /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 gist commands for backup/list/view/download +// WHY: enables CLI access to session backup gist 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 missingGistIdError: ParseError = { _tag: "MissingRequiredOption", option: "gist-id" } + +const extractGistId = (args: ReadonlyArray): string | null => { + const gistId = args[0] + return gistId && !gistId.startsWith("-") ? gistId : 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 gistId = extractGistId(args) + return gistId + ? Either.right({ _tag: "SessionGistView", gistId }) + : Either.left(missingGistIdError) +} + +const parseDownload = ( + args: ReadonlyArray +): Either.Either => { + const gistId = extractGistId(args) + if (!gistId) { + return Either.left(missingGistIdError) + } + return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({ + _tag: "SessionGistDownload", + gistId, + 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..a72fc923 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 GitHub Gists (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-gists list (default: 20) + --output Output directory for session-gists 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 dd97d1bc..36133b95 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -17,6 +17,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, @@ -103,6 +109,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 3afdfe8a..e100375c 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -50,6 +50,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 5026fcf5..de49e1fe 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -238,10 +238,50 @@ export interface AuthClaudeLogoutCommand { readonly claudeAuthPath: string } +// CHANGE: add session gist backup commands for PR-based session history +// WHY: enables returning to old AI sessions via private gists linked to PRs +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: gist commands are always tied to a repository context +// COMPLEXITY: O(1) +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 gistId: string +} + +export interface SessionGistDownloadCommand { + readonly _tag: "SessionGistDownload" + readonly gistId: string + readonly outputDir: string +} + +export type SessionGistCommand = + | SessionGistBackupCommand + | SessionGistListCommand + | SessionGistViewCommand + | SessionGistDownloadCommand + export type SessionsCommand = | SessionsListCommand | SessionsKillCommand | SessionsLogsCommand + | SessionGistCommand export type ScrapCommand = | ScrapExportCommand diff --git a/packages/lib/src/usecases/session-gists.ts b/packages/lib/src/usecases/session-gists.ts new file mode 100644 index 00000000..d716a463 --- /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 gist 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 gist...")) + 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 gists...")) + yield* _(runNodeScript("scripts/session-list-gists.js", args)) + }) +} + +export const sessionGistView = ( + cmd: SessionGistViewCommand +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(Effect.log(`Viewing gist: ${cmd.gistId}`)) + yield* _(runNodeScript("scripts/session-list-gists.js", ["view", cmd.gistId])) + }) + +export const sessionGistDownload = ( + cmd: SessionGistDownloadCommand +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(Effect.log(`Downloading gist ${cmd.gistId} to ${cmd.outputDir}...`)) + yield* _(runNodeScript("scripts/session-list-gists.js", ["download", cmd.gistId, "--output", cmd.outputDir])) + yield* _(Effect.log("Download complete.")) + }) diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js new file mode 100644 index 00000000..acc4c6aa --- /dev/null +++ b/scripts/session-backup-gist.js @@ -0,0 +1,454 @@ +#!/usr/bin/env node + +/** + * Session Backup to GitHub Gist + * + * This script backs up AI agent session files (~/.codex, ~/.claude) to a private GitHub Gist + * and optionally posts a comment to the associated PR with the gist link. + * + * Usage: + * node scripts/session-backup-gist.js [options] + * + * Options: + * --session-dir Path to session directory (default: auto-detect ~/.codex or ~/.claude) + * --pr-number PR number to post comment to (optional, auto-detected from branch) + * --repo 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, GitHubGist + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { execSync, spawnSync } = require("node:child_process"); +const os = require("node:os"); + +// Configuration +const MAX_GIST_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file limit for gists +const SESSION_DIR_NAMES = [".codex", ".claude"]; +const KNOWLEDGE_DIR_NAME = ".knowledge"; + +/** + * Parse command line arguments + * @returns {Object} Parsed arguments + */ +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 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; +}; + +/** + * Log message if verbose mode is enabled + * @param {boolean} verbose - Whether verbose mode is enabled + * @param {string} message - Message to log + */ +const log = (verbose, message) => { + if (verbose) { + console.log(`[session-backup] ${message}`); + } +}; + +/** + * Execute shell command and return stdout + * @param {string} command - Command to execute + * @param {Object} options - Execution options + * @returns {string|null} Command output or null on error + */ +const execCommand = (command, options = {}) => { + try { + return execSync(command, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + ...options, + }).trim(); + } catch { + return null; + } +}; + +/** + * Get current git branch name + * @returns {string|null} Branch name or null + */ +const getCurrentBranch = () => { + return execCommand("git rev-parse --abbrev-ref HEAD"); +}; + +/** + * Get repository owner/name from git remote + * @returns {string|null} Repository in owner/repo format or null + */ +const getRepoFromRemote = () => { + const remoteUrl = execCommand("git remote get-url origin"); + if (!remoteUrl) return null; + + // Handle SSH format: git@github.com:owner/repo.git + const sshMatch = remoteUrl.match(/git@github\.com:([^/]+\/[^.]+)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + + // Handle HTTPS format: https://github.com/owner/repo.git + const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+\/[^.]+)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + + return null; +}; + +/** + * Get PR number from current branch + * @param {string} repo - Repository in owner/repo format + * @param {string} branch - Branch name + * @returns {number|null} PR number or null + */ +const getPrNumberFromBranch = (repo, branch) => { + const result = execCommand( + `gh pr list --repo ${repo} --head ${branch} --json number --jq '.[0].number'` + ); + if (result && !isNaN(parseInt(result, 10))) { + return parseInt(result, 10); + } + return null; +}; + +/** + * Find session directories to backup + * @param {string|null} explicitPath - Explicit session directory path + * @param {boolean} verbose - Whether to log verbosely + * @returns {Array<{name: string, path: string}>} List of session directories + */ +const findSessionDirs = (explicitPath, verbose) => { + const dirs = []; + + if (explicitPath) { + if (fs.existsSync(explicitPath)) { + dirs.push({ name: path.basename(explicitPath), path: explicitPath }); + } + return dirs; + } + + // Check home directory for session directories + 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 }); + } + } + + // Check current working directory for .knowledge + 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; +}; + +/** + * Collect session files from a directory + * @param {string} dirPath - Directory path + * @param {string} baseName - Base name for the directory + * @param {boolean} verbose - Whether to log verbosely + * @returns {Array<{name: string, content: string}>} List of files with content + */ +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()) { + // Skip certain directories + if (entry.name === "node_modules" || entry.name === ".git") { + continue; + } + walk(fullPath, relPath); + } else if (entry.isFile()) { + // Only include specific file types + 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) { + try { + const stats = fs.statSync(fullPath); + if (stats.size <= MAX_GIST_FILE_SIZE) { + const content = fs.readFileSync(fullPath, "utf8"); + const fileName = `${baseName}/${relPath}`.replace(/\//g, "_"); + files.push({ name: fileName, content }); + log(verbose, `Collected file: ${fileName} (${stats.size} bytes)`); + } else { + log(verbose, `Skipping large file: ${relPath} (${stats.size} bytes)`); + } + } catch (err) { + log(verbose, `Error reading file ${fullPath}: ${err.message}`); + } + } + } + } + }; + + walk(dirPath, ""); + return files; +}; + +/** + * Create a gist with the given files + * @param {Array<{name: string, content: string}>} files - Files to upload + * @param {string} description - Gist description + * @param {boolean} dryRun - Whether to perform a dry run + * @param {boolean} verbose - Whether to log verbosely + * @returns {string|null} Gist URL or null on error + */ +const createGist = (files, description, dryRun, verbose) => { + if (files.length === 0) { + log(verbose, "No files to upload"); + return null; + } + + if (dryRun) { + console.log(`[dry-run] Would create gist with ${files.length} files:`); + for (const file of files) { + console.log(` - ${file.name} (${file.content.length} bytes)`); + } + return "https://gist.github.com/dry-run/example"; + } + + // Create temporary directory for files + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-")); + + try { + // Write files to temp directory + const filePaths = []; + for (const file of files) { + const filePath = path.join(tmpDir, file.name); + fs.writeFileSync(filePath, file.content, "utf8"); + filePaths.push(filePath); + } + + // Create gist using gh CLI + const fileArgs = filePaths.map(f => `"${f}"`).join(" "); + const command = `gh gist create ${fileArgs} --desc "${description}"`; + + log(verbose, `Creating gist: ${command}`); + + const result = spawnSync("gh", ["gist", "create", ...filePaths, "--desc", description], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + + if (result.status !== 0) { + console.error(`[session-backup] Failed to create gist: ${result.stderr}`); + return null; + } + + const gistUrl = result.stdout.trim(); + log(verbose, `Created gist: ${gistUrl}`); + return gistUrl; + } finally { + // Cleanup temp directory + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}; + +/** + * Post a comment to a PR with the gist link + * @param {string} repo - Repository in owner/repo format + * @param {number} prNumber - PR number + * @param {string} gistUrl - Gist URL + * @param {boolean} dryRun - Whether to perform a dry run + * @param {boolean} verbose - Whether to log verbosely + * @returns {boolean} Whether the comment was posted successfully + */ +const postPrComment = (repo, prNumber, gistUrl, dryRun, verbose) => { + const timestamp = new Date().toISOString(); + const comment = `## AI Session Backup + +A snapshot of the AI agent session has been saved to a private gist: + +**Gist URL:** ${gistUrl} + +To resume this session, you can use: +\`\`\`bash +# For Codex +codex resume + +# For Claude +claude --resume +\`\`\` + +--- +*Backup created at: ${timestamp}*`; + + if (dryRun) { + console.log(`[dry-run] Would post comment to PR #${prNumber} in ${repo}:`); + console.log(comment); + return true; + } + + log(verbose, `Posting comment to PR #${prNumber}`); + + const result = spawnSync( + "gh", + ["pr", "comment", prNumber.toString(), "--repo", repo, "--body", comment], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } + ); + + if (result.status !== 0) { + console.error(`[session-backup] Failed to post PR comment: ${result.stderr}`); + return false; + } + + log(verbose, "Comment posted successfully"); + return true; +}; + +/** + * Main function + */ +const main = () => { + // Check if backup is disabled + 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; + + log(verbose, "Starting session backup..."); + + // Get repository info + const repo = args.repo || getRepoFromRemote(); + if (!repo) { + console.error("[session-backup] Could not determine repository. Use --repo option."); + process.exit(1); + } + log(verbose, `Repository: ${repo}`); + + // Get current branch + const branch = getCurrentBranch(); + if (!branch) { + console.error("[session-backup] Could not determine current branch."); + process.exit(1); + } + log(verbose, `Branch: ${branch}`); + + // Get PR number + let prNumber = args.prNumber; + if (!prNumber && args.postComment) { + prNumber = getPrNumberFromBranch(repo, branch); + if (!prNumber) { + log(verbose, "No PR found for current branch, skipping comment"); + } + } + if (prNumber) { + log(verbose, `PR number: ${prNumber}`); + } + + // Find session directories + const sessionDirs = findSessionDirs(args.sessionDir, verbose); + if (sessionDirs.length === 0) { + log(verbose, "No session directories found"); + return; + } + + // Collect all session files + const allFiles = []; + for (const dir of sessionDirs) { + const files = collectSessionFiles(dir.path, dir.name, verbose); + allFiles.push(...files); + } + + if (allFiles.length === 0) { + log(verbose, "No session files found to backup"); + return; + } + + log(verbose, `Total files to backup: ${allFiles.length}`); + + // Create gist + const description = `AI Session Backup - ${repo} - ${branch} - ${new Date().toISOString()}`; + const gistUrl = createGist(allFiles, description, args.dryRun, verbose); + + if (!gistUrl) { + console.error("[session-backup] Failed to create gist"); + process.exit(1); + } + + console.log(`[session-backup] Created gist: ${gistUrl}`); + + // Post PR comment + if (args.postComment && prNumber) { + postPrComment(repo, prNumber, gistUrl, args.dryRun, verbose); + } + + console.log("[session-backup] Session backup complete"); +}; + +main(); diff --git a/scripts/session-list-gists.js b/scripts/session-list-gists.js new file mode 100644 index 00000000..30c7ab85 --- /dev/null +++ b/scripts/session-list-gists.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node + +/** + * List AI Session Backups from GitHub Gists + * + * This script lists all AI agent session backup gists created by session-backup-gist.js + * and allows downloading/restoring them. + * + * Usage: + * node scripts/session-list-gists.js [command] [options] + * + * Commands: + * list List all session backup gists (default) + * view View contents of a specific gist + * download Download gist contents to local directory + * + * Options: + * --limit Maximum number of gists to list (default: 20) + * --repo Filter by repository + * --output Output directory for download (default: ./.session-restore) + * --verbose Enable verbose logging + * + * @pure false - contains IO effects (network, file system) + * @effect GitHubGist, FileSystem + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +/** + * Parse command line arguments + * @returns {Object} Parsed arguments + */ +const parseArgs = () => { + const args = process.argv.slice(2); + const result = { + command: "list", + gistId: 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 all session backup gists (default) + view View contents of a specific gist + download Download gist contents to local directory + +Options: + --limit Maximum number of gists to list (default: 20) + --repo Filter by 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") { + // First non-flag argument is the command + if (arg === "list" || arg === "view" || arg === "download") { + result.command = arg; + } else if (result.command !== "list") { + result.gistId = arg; + } + } else if (!result.gistId) { + result.gistId = arg; + } + i++; + } + + return result; +}; + +/** + * Log message if verbose mode is enabled + * @param {boolean} verbose - Whether verbose mode is enabled + * @param {string} message - Message to log + */ +const log = (verbose, message) => { + if (verbose) { + console.log(`[session-gists] ${message}`); + } +}; + +/** + * Execute gh CLI command and return result + * @param {string[]} args - Command arguments + * @returns {{success: boolean, stdout: string, stderr: string}} + */ +const ghCommand = (args) => { + const result = spawnSync("gh", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + + return { + success: result.status === 0, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +}; + +/** + * List session backup gists + * @param {number} limit - Maximum number of gists to list + * @param {string|null} repoFilter - Repository filter + * @param {boolean} verbose - Whether to log verbosely + */ +const listGists = (limit, repoFilter, verbose) => { + log(verbose, `Fetching gists (limit: ${limit})`); + + const result = ghCommand([ + "gist", + "list", + "--limit", + limit.toString(), + ]); + + if (!result.success) { + console.error(`Failed to list gists: ${result.stderr}`); + process.exit(1); + } + + const lines = result.stdout.trim().split("\n").filter(Boolean); + const sessionBackups = []; + + for (const line of lines) { + // Parse gist list output: ID DESCRIPTION FILES VISIBILITY UPDATED + const parts = line.split("\t"); + if (parts.length < 2) continue; + + const [id, description] = parts; + + // Filter for session backups + if (description && description.includes("AI Session Backup")) { + // Check repo filter if specified + if (repoFilter && !description.includes(repoFilter)) { + continue; + } + + sessionBackups.push({ + id: id.trim(), + description: description.trim(), + raw: line, + }); + } + } + + if (sessionBackups.length === 0) { + console.log("No session backup gists found."); + if (repoFilter) { + console.log(`(Filtered by repo: ${repoFilter})`); + } + return; + } + + console.log("Session Backup Gists:\n"); + console.log("ID\t\t\t\t\tDescription"); + console.log("-".repeat(80)); + + for (const gist of sessionBackups) { + console.log(`${gist.id}\t${gist.description}`); + } + + console.log(`\nTotal: ${sessionBackups.length} session backup(s)`); + console.log("\nTo view a gist: node scripts/session-list-gists.js view "); + console.log("To download: node scripts/session-list-gists.js download "); +}; + +/** + * View contents of a gist + * @param {string} gistId - Gist ID + * @param {boolean} verbose - Whether to log verbosely + */ +const viewGist = (gistId, verbose) => { + if (!gistId) { + console.error("Error: gist-id is required for view command"); + process.exit(1); + } + + log(verbose, `Viewing gist: ${gistId}`); + + const result = ghCommand(["gist", "view", gistId]); + + if (!result.success) { + console.error(`Failed to view gist: ${result.stderr}`); + process.exit(1); + } + + console.log(result.stdout); +}; + +/** + * Download gist contents to local directory + * @param {string} gistId - Gist ID + * @param {string} outputDir - Output directory + * @param {boolean} verbose - Whether to log verbosely + */ +const downloadGist = (gistId, outputDir, verbose) => { + if (!gistId) { + console.error("Error: gist-id is required for download command"); + process.exit(1); + } + + log(verbose, `Downloading gist ${gistId} to ${outputDir}`); + + // Create output directory + const outputPath = path.resolve(outputDir, gistId); + fs.mkdirSync(outputPath, { recursive: true }); + + // Clone gist + const result = ghCommand(["gist", "clone", gistId, outputPath]); + + if (!result.success) { + console.error(`Failed to download gist: ${result.stderr}`); + process.exit(1); + } + + console.log(`Downloaded gist to: ${outputPath}`); + + // List downloaded files + const files = fs.readdirSync(outputPath).filter(f => !f.startsWith(".")); + console.log(`\nFiles (${files.length}):`); + for (const file of files) { + const stats = fs.statSync(path.join(outputPath, file)); + console.log(` - ${file} (${stats.size} bytes)`); + } + + console.log("\nTo restore session files, copy them to the appropriate location:"); + console.log(" - .codex/* files -> ~/.codex/"); + console.log(" - .claude/* files -> ~/.claude/"); + console.log(" - .knowledge/* files -> ./.knowledge/"); +}; + +/** + * Main function + */ +const main = () => { + const args = parseArgs(); + const verbose = args.verbose; + + // Check gh CLI availability + const authResult = ghCommand(["auth", "status"]); + if (!authResult.success) { + console.error("Error: GitHub CLI (gh) is not authenticated."); + console.error("Run 'gh auth login' to authenticate."); + process.exit(1); + } + + switch (args.command) { + case "list": + listGists(args.limit, args.repo, verbose); + break; + case "view": + viewGist(args.gistId, verbose); + break; + case "download": + downloadGist(args.gistId, args.output, verbose); + break; + default: + console.error(`Unknown command: ${args.command}`); + process.exit(1); + } +}; + +main(); From a32c8ba7c1f2946e10d04d57cd34467f7f4bc5be Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 16 Mar 2026 10:05:56 +0000 Subject: [PATCH 3/9] Revert "Initial commit with task details" This reverts commit bf73129359d8b0acf37ae3a368572d7acd335442. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index a9fd2cc2..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-03-16T09:43:09.822Z for PR creation at branch issue-143-8b42b7a3b21f for issue https://github.com/ProverCoderAI/docker-git/issues/143 \ No newline at end of file From b17eb5008db4605a84f286e4851c6b00b356a7b6 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 17 Mar 2026 20:28:44 +0000 Subject: [PATCH 4/9] feat(session-gists): add Gemini support and context-doc reference - Add ~/.gemini to session backup directories for Gemini CLI sessions - Extract SessionGistCommand types to dedicated session-gist-domain.ts module - Add context-doc reference link for extracting session dialogs - Update restore instructions to include .gemini files Co-Authored-By: Claude Opus 4.6 --- .githooks/pre-push | 2 +- packages/lib/src/core/domain.ts | 47 ++++---------------- packages/lib/src/core/session-gist-domain.ts | 36 +++++++++++++++ scripts/session-backup-gist.js | 11 +++-- scripts/session-list-gists.js | 2 + 5 files changed, 56 insertions(+), 42 deletions(-) create mode 100644 packages/lib/src/core/session-gist-domain.ts diff --git a/.githooks/pre-push b/.githooks/pre-push index 4f1b0d8f..a5a8b3ed 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -11,7 +11,7 @@ fi node scripts/pre-push-knowledge-guard.js "$@" -# CHANGE: backup AI session to private gist on push +# CHANGE: backup AI session to private gist on push (supports Claude, Codex, Gemini) # WHY: allows returning to old AI sessions and provides PR context # QUOTE(ТЗ): "когда происходит push мы сразу заливаем текущую сессию с AI агентом в gits приватный" # REF: issue-143 diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 3e7da5d8..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,44 +273,13 @@ export interface AuthGeminiLogoutCommand { readonly geminiAuthPath: string } -// CHANGE: add session gist backup commands for PR-based session history -// WHY: enables returning to old AI sessions via private gists linked to PRs -// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" -// REF: issue-143 -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: gist commands are always tied to a repository context -// COMPLEXITY: O(1) -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 gistId: string -} - -export interface SessionGistDownloadCommand { - readonly _tag: "SessionGistDownload" - readonly gistId: string - readonly outputDir: string -} - -export type SessionGistCommand = - | SessionGistBackupCommand - | SessionGistListCommand - | SessionGistViewCommand - | SessionGistDownloadCommand +export type { + SessionGistBackupCommand, + SessionGistCommand, + SessionGistDownloadCommand, + SessionGistListCommand, + SessionGistViewCommand +} from "./session-gist-domain.js" export type SessionsCommand = | SessionsListCommand | SessionsKillCommand 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..ce94eefa --- /dev/null +++ b/packages/lib/src/core/session-gist-domain.ts @@ -0,0 +1,36 @@ +// CHANGE: session gist backup commands for PR-based session history +// WHY: enables returning to old AI sessions via private gists linked to PRs +// 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 gistId: string +} + +export interface SessionGistDownloadCommand { + readonly _tag: "SessionGistDownload" + readonly gistId: string + readonly outputDir: string +} + +export type SessionGistCommand = + | SessionGistBackupCommand + | SessionGistListCommand + | SessionGistViewCommand + | SessionGistDownloadCommand diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index acc4c6aa..eb38c9d1 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -3,14 +3,14 @@ /** * Session Backup to GitHub Gist * - * This script backs up AI agent session files (~/.codex, ~/.claude) to a private GitHub Gist + * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.gemini) to a private GitHub Gist * and optionally posts a comment to the associated PR with the gist link. * * Usage: * node scripts/session-backup-gist.js [options] * * Options: - * --session-dir Path to session directory (default: auto-detect ~/.codex or ~/.claude) + * --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 Repository (optional, auto-detected from git remote) * --no-comment Skip posting PR comment @@ -31,7 +31,7 @@ const os = require("node:os"); // Configuration const MAX_GIST_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file limit for gists -const SESSION_DIR_NAMES = [".codex", ".claude"]; +const SESSION_DIR_NAMES = [".codex", ".claude", ".gemini"]; const KNOWLEDGE_DIR_NAME = ".knowledge"; /** @@ -337,8 +337,13 @@ codex resume # For Claude claude --resume + +# For Gemini +gemini --resume \`\`\` +For extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc + --- *Backup created at: ${timestamp}*`; diff --git a/scripts/session-list-gists.js b/scripts/session-list-gists.js index 30c7ab85..7a0b5fe4 100644 --- a/scripts/session-list-gists.js +++ b/scripts/session-list-gists.js @@ -251,7 +251,9 @@ const downloadGist = (gistId, outputDir, verbose) => { 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/"); + console.log("\nFor extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc"); }; /** From 123ce643d89910612ba86bea4a933a8293f72b68 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:45:00 +0000 Subject: [PATCH 5/9] test: prepare pre-push smoke source --- .../lib/src/core/templates-entrypoint/git.ts | 12 + scripts/session-backup-gist.js | 256 +++++++++++++++--- 2 files changed, 235 insertions(+), 33 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index 4f9fc348..ed1fe01d 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 && gh auth status >/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/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index eb38c9d1..9ad5c42b 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -118,32 +118,119 @@ const execCommand = (command, options = {}) => { }; /** - * Get current git branch name - * @returns {string|null} Branch name or null + * Execute gh CLI command and return result + * @param {string[]} args - Command arguments + * @returns {{success: boolean, stdout: string, stderr: string}} */ -const getCurrentBranch = () => { - return execCommand("git rev-parse --abbrev-ref HEAD"); +const ghCommand = (args) => { + const result = spawnSync("gh", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + + return { + success: result.status === 0, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; }; /** - * Get repository owner/name from git remote + * Parse a GitHub repository from a remote URL + * @param {string} remoteUrl - Remote URL * @returns {string|null} Repository in owner/repo format or null */ -const getRepoFromRemote = () => { - const remoteUrl = execCommand("git remote get-url origin"); - if (!remoteUrl) return null; +const parseGitHubRepoFromRemoteUrl = (remoteUrl) => { + if (!remoteUrl) { + return null; + } - // Handle SSH format: git@github.com:owner/repo.git const sshMatch = remoteUrl.match(/git@github\.com:([^/]+\/[^.]+)(?:\.git)?$/); - if (sshMatch) return sshMatch[1]; + if (sshMatch) { + return sshMatch[1]; + } - // Handle HTTPS format: https://github.com/owner/repo.git const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+\/[^.]+)(?:\.git)?$/); - if (httpsMatch) return httpsMatch[1]; + if (httpsMatch) { + return httpsMatch[1]; + } return null; }; +const rankRemoteName = (remoteName) => { + if (remoteName === "upstream") { + return 0; + } + if (remoteName === "origin") { + return 1; + } + return 2; +}; + +/** + * Get current git branch name + * @returns {string|null} Branch name or null + */ +const getCurrentBranch = () => { + return execCommand("git rev-parse --abbrev-ref HEAD"); +}; + +/** + * Get HEAD commit sha + * @returns {string|null} Commit sha or null + */ +const getHeadCommitSha = () => { + return execCommand("git rev-parse HEAD"); +}; + +/** + * Get repository candidates from git remotes + * @param {string|null} explicitRepo - Explicit repository override + * @param {boolean} verbose - Whether to log verbosely + * @returns {string[]} Candidate repositories in owner/repo format + */ +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; +}; + /** * Get PR number from current branch * @param {string} repo - Repository in owner/repo format @@ -151,15 +238,96 @@ const getRepoFromRemote = () => { * @returns {number|null} PR number or null */ const getPrNumberFromBranch = (repo, branch) => { - const result = execCommand( - `gh pr list --repo ${repo} --head ${branch} --json number --jq '.[0].number'` - ); - if (result && !isNaN(parseInt(result, 10))) { - return parseInt(result, 10); + const result = ghCommand([ + "pr", + "list", + "--repo", + repo, + "--head", + branch, + "--json", + "number", + "--jq", + ".[0].number", + ]); + + if (result.success && result.stdout && !isNaN(parseInt(result.stdout, 10))) { + return parseInt(result.stdout, 10); } return null; }; +/** + * Check whether a PR exists in a repository + * @param {string} repo - Repository in owner/repo format + * @param {number} prNumber - PR number + * @returns {boolean} Whether the PR exists + */ +const prExists = (repo, prNumber) => { + const result = ghCommand([ + "pr", + "view", + prNumber.toString(), + "--repo", + repo, + "--json", + "number", + "--jq", + ".number", + ]); + + return result.success && result.stdout === prNumber.toString(); +}; + +/** + * Extract a PR number from a docker-git workspace branch + * @param {string} branch - Branch name + * @returns {number|null} PR number or null + */ +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; +}; + +/** + * Find an open PR for the current branch across repo candidates + * @param {string[]} repos - Candidate repositories + * @param {string} branch - Branch name + * @param {boolean} verbose - Whether to log verbosely + * @returns {{repo: string, prNumber: number} | null} PR context or null + */ +const findPrContext = (repos, branch, verbose) => { + for (const repo of repos) { + log(verbose, `Checking open PR in ${repo} for branch ${branch}`); + const prNumber = getPrNumberFromBranch(repo, branch); + 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)) { + return { repo, prNumber: workspacePrNumber }; + } + } + + return null; +}; + /** * Find session directories to backup * @param {string|null} explicitPath - Explicit session directory path @@ -322,13 +490,15 @@ const createGist = (files, description, dryRun, verbose) => { * @param {boolean} verbose - Whether to log verbosely * @returns {boolean} Whether the comment was posted successfully */ -const postPrComment = (repo, prNumber, gistUrl, dryRun, verbose) => { +const postPrComment = (repo, prNumber, gistUrl, commitSha, dryRun, verbose) => { const timestamp = new Date().toISOString(); + const commitLine = commitSha ? `**Commit:** \`${commitSha}\`\n\n` : ""; + const commitMarker = commitSha ? `\n` : ""; const comment = `## AI Session Backup A snapshot of the AI agent session has been saved to a private gist: -**Gist URL:** ${gistUrl} +${commitLine}**Gist URL:** ${gistUrl} To resume this session, you can use: \`\`\`bash @@ -345,7 +515,7 @@ gemini --resume For extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc --- -*Backup created at: ${timestamp}*`; +*Backup created at: ${timestamp}*${commitMarker}`; if (dryRun) { console.log(`[dry-run] Would post comment to PR #${prNumber} in ${repo}:`); @@ -389,11 +559,12 @@ const main = () => { log(verbose, "Starting session backup..."); // Get repository info - const repo = args.repo || getRepoFromRemote(); - if (!repo) { + const repoCandidates = getRepoCandidates(args.repo, verbose); + if (repoCandidates.length === 0) { console.error("[session-backup] Could not determine repository. Use --repo option."); process.exit(1); } + const repo = repoCandidates[0]; log(verbose, `Repository: ${repo}`); // Get current branch @@ -405,15 +576,17 @@ const main = () => { log(verbose, `Branch: ${branch}`); // Get PR number - let prNumber = args.prNumber; - if (!prNumber && args.postComment) { - prNumber = getPrNumberFromBranch(repo, branch); - if (!prNumber) { - log(verbose, "No PR found for current branch, skipping comment"); - } + let prContext = null; + if (args.prNumber !== null) { + prContext = { repo, prNumber: args.prNumber }; + } else if (args.postComment) { + prContext = findPrContext(repoCandidates, branch, verbose); } - if (prNumber) { - log(verbose, `PR number: ${prNumber}`); + + 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"); } // Find session directories @@ -438,7 +611,17 @@ const main = () => { log(verbose, `Total files to backup: ${allFiles.length}`); // Create gist - const description = `AI Session Backup - ${repo} - ${branch} - ${new Date().toISOString()}`; + const commitSha = getHeadCommitSha(); + const descriptionParts = [ + "AI Session Backup", + prContext !== null ? prContext.repo : repo, + branch, + ]; + if (commitSha) { + descriptionParts.push(commitSha.slice(0, 12)); + } + descriptionParts.push(new Date().toISOString()); + const description = descriptionParts.join(" - "); const gistUrl = createGist(allFiles, description, args.dryRun, verbose); if (!gistUrl) { @@ -449,8 +632,15 @@ const main = () => { console.log(`[session-backup] Created gist: ${gistUrl}`); // Post PR comment - if (args.postComment && prNumber) { - postPrComment(repo, prNumber, gistUrl, args.dryRun, verbose); + if (args.postComment && prContext !== null) { + postPrComment( + prContext.repo, + prContext.prNumber, + gistUrl, + commitSha, + args.dryRun, + verbose + ); } console.log("[session-backup] Session backup complete"); From e3c3102507e72b3a9fbcffc779aa7a12cb3481cb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:53:12 +0000 Subject: [PATCH 6/9] feat(session-backups): store snapshots in private repo --- .githooks/pre-push | 6 +- .../docker-git/cli/parser-session-gists.ts | 28 +- packages/app/src/docker-git/cli/usage.ts | 10 +- packages/lib/src/core/session-gist-domain.ts | 8 +- .../lib/src/core/templates-entrypoint/git.ts | 2 +- packages/lib/src/usecases/session-gists.ts | 14 +- scripts/session-backup-gist.js | 447 ++++++-------- scripts/session-backup-repo.js | 574 ++++++++++++++++++ scripts/session-list-gists.js | 279 ++++----- 9 files changed, 892 insertions(+), 476 deletions(-) create mode 100644 scripts/session-backup-repo.js diff --git a/.githooks/pre-push b/.githooks/pre-push index a5a8b3ed..f1dbb702 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -11,13 +11,13 @@ fi node scripts/pre-push-knowledge-guard.js "$@" -# CHANGE: backup AI session to private gist on push (supports Claude, Codex, Gemini) -# WHY: allows returning to old AI sessions and provides PR context +# 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 && gh auth status >/dev/null 2>&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/src/docker-git/cli/parser-session-gists.ts b/packages/app/src/docker-git/cli/parser-session-gists.ts index 0fe6694e..75f6105d 100644 --- a/packages/app/src/docker-git/cli/parser-session-gists.ts +++ b/packages/app/src/docker-git/cli/parser-session-gists.ts @@ -11,8 +11,8 @@ import { import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from "./parser-shared.js" -// CHANGE: parse session gist commands for backup/list/view/download -// WHY: enables CLI access to session backup gist functionality +// 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 @@ -23,11 +23,11 @@ import { parsePositiveInt, parseProjectDirWithOptions, splitSubcommand } from ". const defaultLimit = 20 const defaultOutputDir = "./.session-restore" -const missingGistIdError: ParseError = { _tag: "MissingRequiredOption", option: "gist-id" } +const missingSnapshotRefError: ParseError = { _tag: "MissingRequiredOption", option: "snapshot-ref" } -const extractGistId = (args: ReadonlyArray): string | null => { - const gistId = args[0] - return gistId && !gistId.startsWith("-") ? gistId : null +const extractSnapshotRef = (args: ReadonlyArray): string | null => { + const snapshotRef = args[0] + return snapshotRef && !snapshotRef.startsWith("-") ? snapshotRef : null } const parseBackup = ( @@ -59,22 +59,22 @@ const parseList = ( const parseView = ( args: ReadonlyArray ): Either.Either => { - const gistId = extractGistId(args) - return gistId - ? Either.right({ _tag: "SessionGistView", gistId }) - : Either.left(missingGistIdError) + const snapshotRef = extractSnapshotRef(args) + return snapshotRef + ? Either.right({ _tag: "SessionGistView", snapshotRef }) + : Either.left(missingSnapshotRefError) } const parseDownload = ( args: ReadonlyArray ): Either.Either => { - const gistId = extractGistId(args) - if (!gistId) { - return Either.left(missingGistIdError) + const snapshotRef = extractSnapshotRef(args) + if (!snapshotRef) { + return Either.left(missingSnapshotRefError) } return Either.map(parseProjectDirWithOptions(args.slice(1)), ({ raw }) => ({ _tag: "SessionGistDownload", - gistId, + snapshotRef, outputDir: raw.output ?? defaultOutputDir })) } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index a72fc923..db3c53fb 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -16,8 +16,8 @@ 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 session-gists view +docker-git session-gists download [options] docker-git ps docker-git down-all docker-git auth [options] @@ -34,7 +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 GitHub Gists (backup/list/view/download) + 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 @@ -71,8 +71,8 @@ Options: --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-gists list (default: 20) - --output Output directory for session-gists download (default: ./.session-restore) + --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) diff --git a/packages/lib/src/core/session-gist-domain.ts b/packages/lib/src/core/session-gist-domain.ts index ce94eefa..3cb6bfc7 100644 --- a/packages/lib/src/core/session-gist-domain.ts +++ b/packages/lib/src/core/session-gist-domain.ts @@ -1,5 +1,5 @@ -// CHANGE: session gist backup commands for PR-based session history -// WHY: enables returning to old AI sessions via private gists linked to PRs +// 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 @@ -20,12 +20,12 @@ export interface SessionGistListCommand { export interface SessionGistViewCommand { readonly _tag: "SessionGistView" - readonly gistId: string + readonly snapshotRef: string } export interface SessionGistDownloadCommand { readonly _tag: "SessionGistDownload" - readonly gistId: string + readonly snapshotRef: string readonly outputDir: string } diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts index ed1fe01d..e7d1de34 100644 --- a/packages/lib/src/core/templates-entrypoint/git.ts +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -262,7 +262,7 @@ 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 && gh auth status >/dev/null 2>&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/lib/src/usecases/session-gists.ts b/packages/lib/src/usecases/session-gists.ts index d716a463..e29c1a5a 100644 --- a/packages/lib/src/usecases/session-gists.ts +++ b/packages/lib/src/usecases/session-gists.ts @@ -11,7 +11,7 @@ import type { import { runCommandWithExitCodes } from "../shell/command-runner.js" import { CommandFailedError } from "../shell/errors.js" -// CHANGE: implement session gist operations via shell commands +// CHANGE: implement session backup repository operations via shell commands // WHY: enables CLI access to session backup/list/view/download functionality // QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" // REF: issue-143 @@ -55,7 +55,7 @@ export const sessionGistBackup = ( args.push("--no-comment") } return Effect.gen(function*(_) { - yield* _(Effect.log("Backing up AI session to private gist...")) + 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.")) }) @@ -69,7 +69,7 @@ export const sessionGistList = ( args.push("--repo", cmd.repo) } return Effect.gen(function*(_) { - yield* _(Effect.log("Listing session backup gists...")) + yield* _(Effect.log("Listing session backup snapshots...")) yield* _(runNodeScript("scripts/session-list-gists.js", args)) }) } @@ -78,15 +78,15 @@ export const sessionGistView = ( cmd: SessionGistViewCommand ): Effect.Effect => Effect.gen(function*(_) { - yield* _(Effect.log(`Viewing gist: ${cmd.gistId}`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["view", cmd.gistId])) + 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 gist ${cmd.gistId} to ${cmd.outputDir}...`)) - yield* _(runNodeScript("scripts/session-list-gists.js", ["download", cmd.gistId, "--output", cmd.outputDir])) + 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/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index 9ad5c42b..b47a65d4 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -1,10 +1,11 @@ #!/usr/bin/env node /** - * Session Backup to GitHub Gist + * Session Backup to a private GitHub repository * - * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.gemini) to a private GitHub Gist - * and optionally posts a comment to the associated PR with the gist link. + * 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] @@ -12,7 +13,7 @@ * 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 Repository (optional, auto-detected from git remote) + * --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 @@ -21,7 +22,7 @@ * DOCKER_GIT_SKIP_SESSION_BACKUP=1 Skip session backup entirely * * @pure false - contains IO effects (file system, network, git commands) - * @effect FileSystem, ProcessExec, GitHubGist + * @effect FileSystem, ProcessExec, GitHubRepo */ const fs = require("node:fs"); @@ -29,15 +30,17 @@ const path = require("node:path"); const { execSync, spawnSync } = require("node:child_process"); const os = require("node:os"); -// Configuration -const MAX_GIST_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file limit for gists +const { + buildSnapshotRef, + ensureBackupRepo, + resolveGhEnvironment, + prepareUploadArtifacts, + uploadSnapshot, +} = require("./session-backup-repo.js"); + const SESSION_DIR_NAMES = [".codex", ".claude", ".gemini"]; const KNOWLEDGE_DIR_NAME = ".knowledge"; -/** - * Parse command line arguments - * @returns {Object} Parsed arguments - */ const parseArgs = () => { const args = process.argv.slice(2); const result = { @@ -76,7 +79,7 @@ const parseArgs = () => { Options: --session-dir Path to session directory --pr-number PR number to post comment to - --repo Repository + --repo Source repository --no-comment Skip posting PR comment --dry-run Show what would be uploaded --verbose Enable verbose logging @@ -88,23 +91,12 @@ Options: return result; }; -/** - * Log message if verbose mode is enabled - * @param {boolean} verbose - Whether verbose mode is enabled - * @param {string} message - Message to log - */ const log = (verbose, message) => { if (verbose) { console.log(`[session-backup] ${message}`); } }; -/** - * Execute shell command and return stdout - * @param {string} command - Command to execute - * @param {Object} options - Execution options - * @returns {string|null} Command output or null on error - */ const execCommand = (command, options = {}) => { try { return execSync(command, { @@ -117,15 +109,11 @@ const execCommand = (command, options = {}) => { } }; -/** - * Execute gh CLI command and return result - * @param {string[]} args - Command arguments - * @returns {{success: boolean, stdout: string, stderr: string}} - */ -const ghCommand = (args) => { +const ghCommand = (args, ghEnv) => { const result = spawnSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env: ghEnv, }); return { @@ -135,11 +123,6 @@ const ghCommand = (args) => { }; }; -/** - * Parse a GitHub repository from a remote URL - * @param {string} remoteUrl - Remote URL - * @returns {string|null} Repository in owner/repo format or null - */ const parseGitHubRepoFromRemoteUrl = (remoteUrl) => { if (!remoteUrl) { return null; @@ -168,28 +151,10 @@ const rankRemoteName = (remoteName) => { return 2; }; -/** - * Get current git branch name - * @returns {string|null} Branch name or null - */ -const getCurrentBranch = () => { - return execCommand("git rev-parse --abbrev-ref HEAD"); -}; +const getCurrentBranch = () => execCommand("git rev-parse --abbrev-ref HEAD"); -/** - * Get HEAD commit sha - * @returns {string|null} Commit sha or null - */ -const getHeadCommitSha = () => { - return execCommand("git rev-parse HEAD"); -}; +const getHeadCommitSha = () => execCommand("git rev-parse HEAD"); -/** - * Get repository candidates from git remotes - * @param {string|null} explicitRepo - Explicit repository override - * @param {boolean} verbose - Whether to log verbosely - * @returns {string[]} Candidate repositories in owner/repo format - */ const getRepoCandidates = (explicitRepo, verbose) => { if (explicitRepo) { return [explicitRepo]; @@ -231,13 +196,7 @@ const getRepoCandidates = (explicitRepo, verbose) => { return repos; }; -/** - * Get PR number from current branch - * @param {string} repo - Repository in owner/repo format - * @param {string} branch - Branch name - * @returns {number|null} PR number or null - */ -const getPrNumberFromBranch = (repo, branch) => { +const getPrNumberFromBranch = (repo, branch, ghEnv) => { const result = ghCommand([ "pr", "list", @@ -249,21 +208,15 @@ const getPrNumberFromBranch = (repo, branch) => { "number", "--jq", ".[0].number", - ]); + ], ghEnv); - if (result.success && result.stdout && !isNaN(parseInt(result.stdout, 10))) { + if (result.success && result.stdout && !Number.isNaN(parseInt(result.stdout, 10))) { return parseInt(result.stdout, 10); } return null; }; -/** - * Check whether a PR exists in a repository - * @param {string} repo - Repository in owner/repo format - * @param {number} prNumber - PR number - * @returns {boolean} Whether the PR exists - */ -const prExists = (repo, prNumber) => { +const prExists = (repo, prNumber, ghEnv) => { const result = ghCommand([ "pr", "view", @@ -274,16 +227,11 @@ const prExists = (repo, prNumber) => { "number", "--jq", ".number", - ]); + ], ghEnv); return result.success && result.stdout === prNumber.toString(); }; -/** - * Extract a PR number from a docker-git workspace branch - * @param {string} branch - Branch name - * @returns {number|null} PR number or null - */ const getPrNumberFromWorkspaceBranch = (branch) => { const match = branch.match(/^pr-refs-pull-([0-9]+)-head$/); if (!match) { @@ -294,17 +242,10 @@ const getPrNumberFromWorkspaceBranch = (branch) => { return Number.isNaN(prNumber) ? null : prNumber; }; -/** - * Find an open PR for the current branch across repo candidates - * @param {string[]} repos - Candidate repositories - * @param {string} branch - Branch name - * @param {boolean} verbose - Whether to log verbosely - * @returns {{repo: string, prNumber: number} | null} PR context or null - */ -const findPrContext = (repos, branch, verbose) => { +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); + const prNumber = getPrNumberFromBranch(repo, branch, ghEnv); if (prNumber !== null) { return { repo, prNumber }; } @@ -316,11 +257,8 @@ const findPrContext = (repos, branch, verbose) => { } for (const repo of repos) { - log( - verbose, - `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}` - ); - if (prExists(repo, workspacePrNumber)) { + log(verbose, `Checking workspace PR #${workspacePrNumber} in ${repo} for branch ${branch}`); + if (prExists(repo, workspacePrNumber, ghEnv)) { return { repo, prNumber: workspacePrNumber }; } } @@ -328,12 +266,6 @@ const findPrContext = (repos, branch, verbose) => { return null; }; -/** - * Find session directories to backup - * @param {string|null} explicitPath - Explicit session directory path - * @param {boolean} verbose - Whether to log verbosely - * @returns {Array<{name: string, path: string}>} List of session directories - */ const findSessionDirs = (explicitPath, verbose) => { const dirs = []; @@ -344,7 +276,6 @@ const findSessionDirs = (explicitPath, verbose) => { return dirs; } - // Check home directory for session directories const homeDir = os.homedir(); for (const dirName of SESSION_DIR_NAMES) { const dirPath = path.join(homeDir, dirName); @@ -354,7 +285,6 @@ const findSessionDirs = (explicitPath, verbose) => { } } - // Check current working directory for .knowledge const cwd = process.cwd(); const knowledgePath = path.join(cwd, KNOWLEDGE_DIR_NAME); if (fs.existsSync(knowledgePath)) { @@ -365,13 +295,6 @@ const findSessionDirs = (explicitPath, verbose) => { return dirs; }; -/** - * Collect session files from a directory - * @param {string} dirPath - Directory path - * @param {string} baseName - Base name for the directory - * @param {boolean} verbose - Whether to log verbosely - * @returns {Array<{name: string, content: string}>} List of files with content - */ const collectSessionFiles = (dirPath, baseName, verbose) => { const files = []; @@ -383,13 +306,11 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { - // Skip certain directories if (entry.name === "node_modules" || entry.name === ".git") { continue; } walk(fullPath, relPath); } else if (entry.isFile()) { - // Only include specific file types const ext = path.extname(entry.name).toLowerCase(); const isSessionFile = ext === ".jsonl" || @@ -399,20 +320,21 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { entry.name.endsWith(".part3") || entry.name.endsWith(".chunks.json"); - if (isSessionFile) { - try { - const stats = fs.statSync(fullPath); - if (stats.size <= MAX_GIST_FILE_SIZE) { - const content = fs.readFileSync(fullPath, "utf8"); - const fileName = `${baseName}/${relPath}`.replace(/\//g, "_"); - files.push({ name: fileName, content }); - log(verbose, `Collected file: ${fileName} (${stats.size} bytes)`); - } else { - log(verbose, `Skipping large file: ${relPath} (${stats.size} bytes)`); - } - } catch (err) { - log(verbose, `Error reading file ${fullPath}: ${err.message}`); - } + 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}`); } } } @@ -422,119 +344,63 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { return files; }; -/** - * Create a gist with the given files - * @param {Array<{name: string, content: string}>} files - Files to upload - * @param {string} description - Gist description - * @param {boolean} dryRun - Whether to perform a dry run - * @param {boolean} verbose - Whether to log verbosely - * @returns {string|null} Gist URL or null on error - */ -const createGist = (files, description, dryRun, verbose) => { - if (files.length === 0) { - log(verbose, "No files to upload"); - return null; - } +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:**", + ]; - if (dryRun) { - console.log(`[dry-run] Would create gist with ${files.length} files:`); - for (const file of files) { - console.log(` - ${file.name} (${file.content.length} bytes)`); + for (const file of files) { + if (file.type === "chunked") { + lines.push(`- ${file.name} (chunked): ${file.chunkManifestUrl}`); + } else { + lines.push(`- ${file.name}: ${file.url}`); } - return "https://gist.github.com/dry-run/example"; } - // Create temporary directory for files - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-")); - - try { - // Write files to temp directory - const filePaths = []; - for (const file of files) { - const filePath = path.join(tmpDir, file.name); - fs.writeFileSync(filePath, file.content, "utf8"); - filePaths.push(filePath); - } - - // Create gist using gh CLI - const fileArgs = filePaths.map(f => `"${f}"`).join(" "); - const command = `gh gist create ${fileArgs} --desc "${description}"`; - - log(verbose, `Creating gist: ${command}`); - - const result = spawnSync("gh", ["gist", "create", ...filePaths, "--desc", description], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); - - if (result.status !== 0) { - console.error(`[session-backup] Failed to create gist: ${result.stderr}`); - return null; - } - - const gistUrl = result.stdout.trim(); - log(verbose, `Created gist: ${gistUrl}`); - return gistUrl; - } finally { - // Cleanup temp directory - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + 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"); }; -/** - * Post a comment to a PR with the gist link - * @param {string} repo - Repository in owner/repo format - * @param {number} prNumber - PR number - * @param {string} gistUrl - Gist URL - * @param {boolean} dryRun - Whether to perform a dry run - * @param {boolean} verbose - Whether to log verbosely - * @returns {boolean} Whether the comment was posted successfully - */ -const postPrComment = (repo, prNumber, gistUrl, commitSha, dryRun, verbose) => { - const timestamp = new Date().toISOString(); - const commitLine = commitSha ? `**Commit:** \`${commitSha}\`\n\n` : ""; - const commitMarker = commitSha ? `\n` : ""; - const comment = `## AI Session Backup - -A snapshot of the AI agent session has been saved to a private gist: - -${commitLine}**Gist URL:** ${gistUrl} - -To resume this session, you can use: -\`\`\`bash -# For Codex -codex resume - -# For Claude -claude --resume - -# For Gemini -gemini --resume -\`\`\` - -For extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc - ---- -*Backup created at: ${timestamp}*${commitMarker}`; - - if (dryRun) { - console.log(`[dry-run] Would post comment to PR #${prNumber} in ${repo}:`); - console.log(comment); - return true; - } - +const postPrComment = (repo, prNumber, comment, verbose, ghEnv) => { log(verbose, `Posting comment to PR #${prNumber}`); - const result = spawnSync( - "gh", - ["pr", "comment", prNumber.toString(), "--repo", repo, "--body", comment], - { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - } - ); + const result = ghCommand([ + "pr", + "comment", + prNumber.toString(), + "--repo", + repo, + "--body", + comment, + ], ghEnv); - if (result.status !== 0) { + if (!result.success) { console.error(`[session-backup] Failed to post PR comment: ${result.stderr}`); return false; } @@ -543,11 +409,7 @@ For extracting session dialogs, see: https://github.com/ProverCoderAI/context-do return true; }; -/** - * Main function - */ const main = () => { - // Check if backup is disabled if (process.env.DOCKER_GIT_SKIP_SESSION_BACKUP === "1") { console.log("[session-backup] Skipped (DOCKER_GIT_SKIP_SESSION_BACKUP=1)"); return; @@ -555,19 +417,18 @@ const main = () => { const args = parseArgs(); const verbose = args.verbose; + const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); log(verbose, "Starting session backup..."); - // Get repository info const repoCandidates = getRepoCandidates(args.repo, verbose); if (repoCandidates.length === 0) { - console.error("[session-backup] Could not determine repository. Use --repo option."); + console.error("[session-backup] Could not determine source repository. Use --repo option."); process.exit(1); } - const repo = repoCandidates[0]; - log(verbose, `Repository: ${repo}`); + const sourceRepo = repoCandidates[0]; + log(verbose, `Repository: ${sourceRepo}`); - // Get current branch const branch = getCurrentBranch(); if (!branch) { console.error("[session-backup] Could not determine current branch."); @@ -575,12 +436,17 @@ const main = () => { } log(verbose, `Branch: ${branch}`); - // Get PR number + 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, prNumber: args.prNumber }; + prContext = { repo: sourceRepo, prNumber: args.prNumber }; } else if (args.postComment) { - prContext = findPrContext(repoCandidates, branch, verbose); + prContext = findPrContext(repoCandidates, branch, verbose, ghEnv); } if (prContext !== null) { @@ -589,61 +455,98 @@ const main = () => { log(verbose, "No PR found for current branch, skipping comment"); } - // Find session directories const sessionDirs = findSessionDirs(args.sessionDir, verbose); if (sessionDirs.length === 0) { log(verbose, "No session directories found"); return; } - // Collect all session files - const allFiles = []; + const sessionFiles = []; for (const dir of sessionDirs) { - const files = collectSessionFiles(dir.path, dir.name, verbose); - allFiles.push(...files); + sessionFiles.push(...collectSessionFiles(dir.path, dir.name, verbose)); } - if (allFiles.length === 0) { + if (sessionFiles.length === 0) { log(verbose, "No session files found to backup"); return; } + log(verbose, `Total files to backup: ${sessionFiles.length}`); - log(verbose, `Total files to backup: ${allFiles.length}`); - - // Create gist - const commitSha = getHeadCommitSha(); - const descriptionParts = [ - "AI Session Backup", - prContext !== null ? prContext.repo : repo, - branch, - ]; - if (commitSha) { - descriptionParts.push(commitSha.slice(0, 12)); - } - descriptionParts.push(new Date().toISOString()); - const description = descriptionParts.join(" - "); - const gistUrl = createGist(allFiles, description, args.dryRun, verbose); - - if (!gistUrl) { - console.error("[session-backup] Failed to create gist"); + 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); } - console.log(`[session-backup] Created gist: ${gistUrl}`); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-backup-repo-")); - // Post PR comment - if (args.postComment && prContext !== null) { - postPrComment( - prContext.repo, - prContext.prNumber, - gistUrl, + 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, - args.dryRun, - verbose + 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] Session backup complete"); + 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..699daa6a --- /dev/null +++ b/scripts/session-backup-repo.js @@ -0,0 +1,574 @@ +#!/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"; +// GitHub's git/blob API receives base64-encoded payloads, so files near 100 MB +// exceed the practical request size limit even though normal git push could handle them. +// Keep API uploads comfortably below that ceiling. +const MAX_REPO_FILE_SIZE = 50 * 1000 * 1000; +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 createBlob = (repoFullName, contentBase64, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/blobs`, ghEnv, { + method: "POST", + body: { + content: contentBase64, + encoding: "base64", + }, + jq: ".sha", + }), + `failed to create blob in ${repoFullName}` + ).stdout; + +const createTree = (repoFullName, baseTreeSha, treeEntries, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/trees`, ghEnv, { + method: "POST", + body: { + base_tree: baseTreeSha, + tree: treeEntries, + }, + jq: ".sha", + }), + `failed to create tree in ${repoFullName}` + ).stdout; + +const createCommit = (repoFullName, message, treeSha, parentSha, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/commits`, ghEnv, { + method: "POST", + body: { + message, + tree: treeSha, + parents: [parentSha], + }, + jq: ".sha", + }), + `failed to create commit in ${repoFullName}` + ).stdout; + +const updateBranchRef = (repoFullName, branch, commitSha, ghEnv) => + ensureSuccess( + ghApi(`/repos/${repoFullName}/git/refs/heads/${branch}`, ghEnv, { + method: "PATCH", + rawFields: { sha: commitSha }, + jq: ".object.sha", + }), + `failed to update ${repoFullName}@${branch}` + ).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 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, branch, commitSha, createdAt }) => + `session-backup: ${sourceRepo} ${branch} ${commitSha.slice(0, 12)} ${toSnapshotStamp(createdAt)}`; + +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 readFileAsBase64 = (filePath) => fs.readFileSync(filePath).toString("base64"); + +const uploadSnapshot = (backupRepo, snapshotRef, snapshotManifest, uploadEntries, ghEnv) => { + const headSha = getBranchHeadSha(backupRepo.fullName, backupRepo.defaultBranch, ghEnv); + const baseTreeSha = getCommitTreeSha(backupRepo.fullName, headSha, ghEnv); + const treeEntries = []; + + for (const entry of uploadEntries) { + const blobSha = createBlob(backupRepo.fullName, readFileAsBase64(entry.sourcePath), ghEnv); + treeEntries.push({ + path: entry.repoPath, + mode: "100644", + type: "blob", + sha: blobSha, + }); + } + + const manifestPath = `${snapshotRef}/manifest.json`; + const manifestBlobSha = createBlob( + backupRepo.fullName, + Buffer.from(`${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8").toString("base64"), + ghEnv + ); + treeEntries.push({ + path: manifestPath, + mode: "100644", + type: "blob", + sha: manifestBlobSha, + }); + + const nextTreeSha = createTree(backupRepo.fullName, baseTreeSha, treeEntries, ghEnv); + const nextCommitSha = createCommit( + backupRepo.fullName, + buildCommitMessage(snapshotManifest.source), + nextTreeSha, + headSha, + ghEnv + ); + updateBranchRef(backupRepo.fullName, backupRepo.defaultBranch, nextCommitSha, ghEnv); + + return { + commitSha: nextCommitSha, + manifestPath, + manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath), + }; +}; + +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 index 7a0b5fe4..cace6779 100644 --- a/scripts/session-list-gists.js +++ b/scripts/session-list-gists.js @@ -1,42 +1,39 @@ #!/usr/bin/env node /** - * List AI Session Backups from GitHub Gists - * - * This script lists all AI agent session backup gists created by session-backup-gist.js - * and allows downloading/restoring them. + * List AI Session Backups from the private session backup repository * * Usage: * node scripts/session-list-gists.js [command] [options] * * Commands: - * list List all session backup gists (default) - * view View contents of a specific gist - * download Download gist contents to local directory + * list List session snapshots (default) + * view View metadata for a snapshot + * download Download snapshot contents to local directory * * Options: - * --limit Maximum number of gists to list (default: 20) - * --repo Filter by repository - * --output Output directory for download (default: ./.session-restore) - * --verbose Enable verbose logging - * - * @pure false - contains IO effects (network, file system) - * @effect GitHubGist, FileSystem + * --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 { spawnSync } = require("node:child_process"); -/** - * Parse command line arguments - * @returns {Object} Parsed arguments - */ +const { + ensureBackupRepo, + getFileContent, + getTreeEntries, + resolveGhEnvironment, + sanitizeSnapshotRefForOutput, +} = require("./session-backup-repo.js"); + const parseArgs = () => { const args = process.argv.slice(2); const result = { command: "list", - gistId: null, + snapshotRef: null, limit: 20, repo: null, output: "./.session-restore", @@ -65,27 +62,26 @@ const parseArgs = () => { console.log(`Usage: session-list-gists.js [command] [options] Commands: - list List all session backup gists (default) - view View contents of a specific gist - download Download gist contents to local directory + list List session snapshots (default) + view View metadata for a snapshot + download Download snapshot contents to local directory Options: - --limit Maximum number of gists to list (default: 20) - --repo Filter by repository - --output Output directory for download (default: ./.session-restore) - --verbose Enable verbose logging - --help Show this help message`); + --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") { - // First non-flag argument is the command if (arg === "list" || arg === "view" || arg === "download") { result.command = arg; } else if (result.command !== "list") { - result.gistId = arg; + result.snapshotRef = arg; } - } else if (!result.gistId) { - result.gistId = arg; + } else if (!result.snapshotRef) { + result.snapshotRef = arg; } i++; } @@ -93,193 +89,136 @@ Options: return result; }; -/** - * Log message if verbose mode is enabled - * @param {boolean} verbose - Whether verbose mode is enabled - * @param {string} message - Message to log - */ const log = (verbose, message) => { if (verbose) { - console.log(`[session-gists] ${message}`); + console.log(`[session-backups] ${message}`); } }; -/** - * Execute gh CLI command and return result - * @param {string[]} args - Command arguments - * @returns {{success: boolean, stdout: string, stderr: string}} - */ -const ghCommand = (args) => { - const result = spawnSync("gh", args, { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); - - return { - success: result.status === 0, - stdout: result.stdout || "", - stderr: result.stderr || "", - }; +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; }; -/** - * List session backup gists - * @param {number} limit - Maximum number of gists to list - * @param {string|null} repoFilter - Repository filter - * @param {boolean} verbose - Whether to log verbosely - */ -const listGists = (limit, repoFilter, verbose) => { - log(verbose, `Fetching gists (limit: ${limit})`); - - const result = ghCommand([ - "gist", - "list", - "--limit", - limit.toString(), - ]); - - if (!result.success) { - console.error(`Failed to list gists: ${result.stderr}`); +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 lines = result.stdout.trim().split("\n").filter(Boolean); - const sessionBackups = []; +const getManifestRepoPath = (snapshotRef) => `${snapshotRef}/manifest.json`; - for (const line of lines) { - // Parse gist list output: ID DESCRIPTION FILES VISIBILITY UPDATED - const parts = line.split("\t"); - if (parts.length < 2) continue; +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 [id, description] = parts; +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); - // Filter for session backups - if (description && description.includes("AI Session Backup")) { - // Check repo filter if specified - if (repoFilter && !description.includes(repoFilter)) { - continue; - } + const filtered = repoFilter + ? manifestPaths.filter((entryPath) => entryPath.startsWith(`${repoFilter}/`)) + : manifestPaths; - sessionBackups.push({ - id: id.trim(), - description: description.trim(), - raw: line, - }); - } - } - - if (sessionBackups.length === 0) { - console.log("No session backup gists found."); + if (filtered.length === 0) { + console.log("No session snapshots found."); if (repoFilter) { console.log(`(Filtered by repo: ${repoFilter})`); } return; } - console.log("Session Backup Gists:\n"); - console.log("ID\t\t\t\t\tDescription"); - console.log("-".repeat(80)); - - for (const gist of sessionBackups) { - console.log(`${gist.id}\t${gist.description}`); + 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(`\nTotal: ${sessionBackups.length} session backup(s)`); - console.log("\nTo view a gist: node scripts/session-list-gists.js view "); - console.log("To download: node scripts/session-list-gists.js download "); + console.log(`Total: ${filtered.length} snapshot(s)`); }; -/** - * View contents of a gist - * @param {string} gistId - Gist ID - * @param {boolean} verbose - Whether to log verbosely - */ -const viewGist = (gistId, verbose) => { - if (!gistId) { - console.error("Error: gist-id is required for view command"); - process.exit(1); - } - - log(verbose, `Viewing gist: ${gistId}`); - - const result = ghCommand(["gist", "view", gistId]); - - if (!result.success) { - console.error(`Failed to view gist: ${result.stderr}`); +const viewSnapshot = (snapshotRef, backupRepo, ghEnv, verbose) => { + if (!snapshotRef) { + console.error("Error: snapshot-ref is required for view command"); process.exit(1); } - console.log(result.stdout); + log(verbose, `Viewing snapshot: ${snapshotRef}`); + const manifest = fetchManifest(backupRepo, snapshotRef, ghEnv); + console.log(JSON.stringify(manifest.data, null, 2)); }; -/** - * Download gist contents to local directory - * @param {string} gistId - Gist ID - * @param {string} outputDir - Output directory - * @param {boolean} verbose - Whether to log verbosely - */ -const downloadGist = (gistId, outputDir, verbose) => { - if (!gistId) { - console.error("Error: gist-id is required for download command"); +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 gist ${gistId} to ${outputDir}`); - - // Create output directory - const outputPath = path.resolve(outputDir, gistId); + 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; + } - // Clone gist - const result = ghCommand(["gist", "clone", gistId, outputPath]); - - if (!result.success) { - console.error(`Failed to download gist: ${result.stderr}`); - process.exit(1); - } - - console.log(`Downloaded gist to: ${outputPath}`); - - // List downloaded files - const files = fs.readdirSync(outputPath).filter(f => !f.startsWith(".")); - console.log(`\nFiles (${files.length}):`); - for (const file of files) { - const stats = fs.statSync(path.join(outputPath, file)); - console.log(` - ${file} (${stats.size} bytes)`); + 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/"); - console.log("\nFor extracting session dialogs, see: https://github.com/ProverCoderAI/context-doc"); + console.log(" - .codex_* files -> ~/.codex/"); + console.log(" - .claude_* files -> ~/.claude/"); + console.log(" - .gemini_* files -> ~/.gemini/"); + console.log(" - .knowledge_* files -> ./.knowledge/"); }; -/** - * Main function - */ const main = () => { const args = parseArgs(); const verbose = args.verbose; - - // Check gh CLI availability - const authResult = ghCommand(["auth", "status"]); - if (!authResult.success) { - console.error("Error: GitHub CLI (gh) is not authenticated."); - console.error("Run 'gh auth login' to authenticate."); - process.exit(1); - } + const ghEnv = resolveGhEnvironment(process.cwd(), (message) => log(verbose, message)); + const backupRepo = ensureBackupRepoOrExit(ghEnv, verbose); switch (args.command) { case "list": - listGists(args.limit, args.repo, verbose); + listSnapshots(args.limit, args.repo, backupRepo, ghEnv, verbose); break; case "view": - viewGist(args.gistId, verbose); + viewSnapshot(args.snapshotRef, backupRepo, ghEnv, verbose); break; case "download": - downloadGist(args.gistId, args.output, verbose); + downloadSnapshot(args.snapshotRef, args.output, backupRepo, ghEnv, verbose); break; default: console.error(`Unknown command: ${args.command}`); From abc22b9d567d1bb67cd04b9d5e4c75ffa250d116 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:23:06 +0000 Subject: [PATCH 7/9] fix(session-backup): batch repo-backed uploads --- scripts/session-backup-repo.js | 446 ++++++++++++++++++++++++++------- 1 file changed, 355 insertions(+), 91 deletions(-) diff --git a/scripts/session-backup-repo.js b/scripts/session-backup-repo.js index 699daa6a..55bcc7ae 100644 --- a/scripts/session-backup-repo.js +++ b/scripts/session-backup-repo.js @@ -7,10 +7,10 @@ const { spawnSync } = require("node:child_process"); const BACKUP_REPO_NAME = "docker-git-sessions"; const BACKUP_DEFAULT_BRANCH = "main"; -// GitHub's git/blob API receives base64-encoded payloads, so files near 100 MB -// exceed the practical request size limit even though normal git push could handle them. -// Keep API uploads comfortably below that ceiling. -const MAX_REPO_FILE_SIZE = 50 * 1000 * 1000; +// 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"]; @@ -284,56 +284,6 @@ const getCommitTreeSha = (repoFullName, commitSha, ghEnv) => `failed to resolve tree for commit ${commitSha}` ).stdout; -const createBlob = (repoFullName, contentBase64, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/blobs`, ghEnv, { - method: "POST", - body: { - content: contentBase64, - encoding: "base64", - }, - jq: ".sha", - }), - `failed to create blob in ${repoFullName}` - ).stdout; - -const createTree = (repoFullName, baseTreeSha, treeEntries, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/trees`, ghEnv, { - method: "POST", - body: { - base_tree: baseTreeSha, - tree: treeEntries, - }, - jq: ".sha", - }), - `failed to create tree in ${repoFullName}` - ).stdout; - -const createCommit = (repoFullName, message, treeSha, parentSha, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/commits`, ghEnv, { - method: "POST", - body: { - message, - tree: treeSha, - parents: [parentSha], - }, - jq: ".sha", - }), - `failed to create commit in ${repoFullName}` - ).stdout; - -const updateBranchRef = (repoFullName, branch, commitSha, ghEnv) => - ensureSuccess( - ghApi(`/repos/${repoFullName}/git/refs/heads/${branch}`, ghEnv, { - method: "PATCH", - rawFields: { sha: commitSha }, - jq: ".object.sha", - }), - `failed to update ${repoFullName}@${branch}` - ).stdout; - const getTreeEntries = (repoFullName, branch, ghEnv) => { const headSha = getBranchHeadSha(repoFullName, branch, ghEnv); const treeSha = getCommitTreeSha(repoFullName, headSha, ghEnv); @@ -348,6 +298,18 @@ const getTreeEntries = (repoFullName, branch, ghEnv) => { }; }; +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), @@ -364,8 +326,14 @@ const getFileContent = (repoFullName, repoPath, ghEnv, ref = BACKUP_DEFAULT_BRAN const buildSnapshotRef = (sourceRepo, prNumber, commitSha, createdAt) => `${sourceRepo}/pr-${prNumber === null ? "no-pr" : prNumber}/commit-${commitSha}/${toSnapshotStamp(createdAt)}`; -const buildCommitMessage = ({ sourceRepo, branch, commitSha, createdAt }) => - `session-backup: ${sourceRepo} ${branch} ${commitSha.slice(0, 12)} ${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, @@ -497,53 +465,349 @@ const prepareUploadArtifacts = (sessionFiles, snapshotRef, repoFullName, branch, return { uploadEntries, manifestFiles }; }; -const readFileAsBase64 = (filePath) => fs.readFileSync(filePath).toString("base64"); - -const uploadSnapshot = (backupRepo, snapshotRef, snapshotManifest, uploadEntries, ghEnv) => { - const headSha = getBranchHeadSha(backupRepo.fullName, backupRepo.defaultBranch, ghEnv); - const baseTreeSha = getCommitTreeSha(backupRepo.fullName, headSha, ghEnv); - const treeEntries = []; +const splitUploadEntriesIntoBatches = (uploadEntries) => { + const batches = []; + let currentBatch = []; + let currentBatchBytes = 0; for (const entry of uploadEntries) { - const blobSha = createBlob(backupRepo.fullName, readFileAsBase64(entry.sourcePath), ghEnv); - treeEntries.push({ - path: entry.repoPath, - mode: "100644", - type: "blob", - sha: blobSha, - }); + if (currentBatch.length > 0 && currentBatchBytes + entry.size > MAX_PUSH_BATCH_BYTES) { + batches.push(currentBatch); + currentBatch = []; + currentBatchBytes = 0; + } + + currentBatch.push(entry); + currentBatchBytes += entry.size; } - const manifestPath = `${snapshotRef}/manifest.json`; - const manifestBlobSha = createBlob( - backupRepo.fullName, - Buffer.from(`${JSON.stringify(snapshotManifest, null, 2)}\n`, "utf8").toString("base64"), - ghEnv - ); - treeEntries.push({ - path: manifestPath, - mode: "100644", - type: "blob", - sha: manifestBlobSha, + 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, }); - const nextTreeSha = createTree(backupRepo.fullName, baseTreeSha, treeEntries, ghEnv); - const nextCommitSha = createCommit( - backupRepo.fullName, - buildCommitMessage(snapshotManifest.source), - nextTreeSha, - headSha, - ghEnv + 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 ); - updateBranchRef(backupRepo.fullName, backupRepo.defaultBranch, nextCommitSha, ghEnv); + +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 { - commitSha: nextCommitSha, - manifestPath, - manifestUrl: buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, manifestPath), + 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, "_"); From 0622b72218b18fb4784191353f12381654260a99 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:40:36 +0000 Subject: [PATCH 8/9] fix(session-backup): limit home backups and normalize gemini home --- .../src/core/templates-entrypoint/gemini.ts | 39 ++++++--- .../lib/src/usecases/auth-gemini-helpers.ts | 1 + .../lib/tests/usecases/auth-gemini.test.ts | 17 ++++ scripts/session-backup-gist.js | 82 +++++++++++-------- scripts/session-backup-repo.js | 3 + scripts/session-list-gists.js | 9 +- 6 files changed, 100 insertions(+), 51 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint/gemini.ts b/packages/lib/src/core/templates-entrypoint/gemini.ts index 92581a8c..b7a6f8a0 100644 --- a/packages/lib/src/core/templates-entrypoint/gemini.ts +++ b/packages/lib/src/core/templates-entrypoint/gemini.ts @@ -12,7 +12,7 @@ import type { TemplateConfig } from "../domain.js" const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini` const geminiAuthConfigTemplate = String - .raw`# Gemini CLI: expose GEMINI_HOME for sessions (OAuth cache lives under ~/.docker-git/.orch/auth/gemini) + .raw`# Gemini CLI: keep ~/.gemini as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/gemini GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL" if [[ -z "$GEMINI_LABEL_RAW" ]]; then GEMINI_LABEL_RAW="default" @@ -31,6 +31,8 @@ export GEMINI_CONFIG_DIR="$GEMINI_AUTH_ROOT/$GEMINI_LABEL_NORM" mkdir -p "$GEMINI_CONFIG_DIR" || true GEMINI_HOME_DIR="__GEMINI_HOME_DIR__" mkdir -p "$GEMINI_HOME_DIR" || true +GEMINI_SHARED_HOME_DIR="$GEMINI_CONFIG_DIR/.gemini" +mkdir -p "$GEMINI_SHARED_HOME_DIR" || true docker_git_link_gemini_file() { local source_path="$1" @@ -47,9 +49,32 @@ docker_git_link_gemini_file() { ln -sfn "$source_path" "$link_path" || true } +docker_git_prepare_gemini_home_dir() { + if [[ -L "$GEMINI_HOME_DIR" ]]; then + local previous_target + previous_target="$(readlink -f "$GEMINI_HOME_DIR" || true)" + rm -f "$GEMINI_HOME_DIR" || true + mkdir -p "$GEMINI_HOME_DIR" || true + if [[ -n "$previous_target" && -d "$previous_target" ]]; then + cp -a "$previous_target"/. "$GEMINI_HOME_DIR"/ 2>/dev/null || true + fi + return 0 + fi + + mkdir -p "$GEMINI_HOME_DIR" || true +} + +docker_git_prepare_gemini_home_dir + # Link .api-key and .env from central auth storage to container home docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.api-key" "$GEMINI_HOME_DIR/.api-key" docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.env" "$GEMINI_HOME_DIR/.env" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth_creds.json" "$GEMINI_HOME_DIR/oauth_creds.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth-tokens.json" "$GEMINI_HOME_DIR/oauth-tokens.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/credentials.json" "$GEMINI_HOME_DIR/credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/application_default_credentials.json" "$GEMINI_HOME_DIR/application_default_credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/google_accounts.json" "$GEMINI_HOME_DIR/google_accounts.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/projects.json" "$GEMINI_HOME_DIR/projects.json" # Ensure gemini YOLO wrapper exists GEMINI_REAL_BIN="$(command -v gemini || echo "/usr/local/bin/gemini")" @@ -67,18 +92,6 @@ EOF fi fi -# Special case for .gemini folder: we want the folder itself to be the link if it doesn't exist -# or its content to be linked if we want to manage it. -if [[ -d "$GEMINI_CONFIG_DIR/.gemini" ]]; then - if [[ -L "$GEMINI_HOME_DIR" ]]; then - rm -f "$GEMINI_HOME_DIR" - elif [[ -d "$GEMINI_HOME_DIR" ]]; then - # If it's a real directory, move it aside if it's empty or just has our managed files - mv "$GEMINI_HOME_DIR" "$GEMINI_HOME_DIR.bak-$(date +%s)" || true - fi - ln -sfn "$GEMINI_CONFIG_DIR/.gemini" "$GEMINI_HOME_DIR" -fi - docker_git_refresh_gemini_env() { # If .api-key exists, export it as GEMINI_API_KEY if [[ -f "$GEMINI_HOME_DIR/.api-key" ]]; then diff --git a/packages/lib/src/usecases/auth-gemini-helpers.ts b/packages/lib/src/usecases/auth-gemini-helpers.ts index 292ea07e..2474dcbe 100644 --- a/packages/lib/src/usecases/auth-gemini-helpers.ts +++ b/packages/lib/src/usecases/auth-gemini-helpers.ts @@ -162,6 +162,7 @@ export const hasOauthCredentials = ( } // Check for various possible credential files Gemini CLI might create const possibleFiles = [ + `${credentialsDir}/oauth_creds.json`, `${credentialsDir}/oauth-tokens.json`, `${credentialsDir}/credentials.json`, `${credentialsDir}/application_default_credentials.json` diff --git a/packages/lib/tests/usecases/auth-gemini.test.ts b/packages/lib/tests/usecases/auth-gemini.test.ts index 2bc0e3ec..6208c2f6 100644 --- a/packages/lib/tests/usecases/auth-gemini.test.ts +++ b/packages/lib/tests/usecases/auth-gemini.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { authGeminiLogin, geminiAuthRoot } from "../../src/usecases/auth-gemini.js" +import { hasOauthCredentials } from "../../src/usecases/auth-gemini-helpers.js" const withTempDir = ( use: (tempDir: string) => Effect.Effect @@ -66,4 +67,20 @@ describe("authGeminiLogin", () => { expect(settings.tools.allowed).toContain("googleSearch") }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("detects oauth_creds.json as valid Gemini OAuth credentials", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const accountPath = path.join(root, "default") + const credentialsDir = path.join(accountPath, ".gemini") + + yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(credentialsDir, "oauth_creds.json"), "{\"access_token\":\"test\"}\n")) + + const detected = yield* _(hasOauthCredentials(fs, accountPath)) + expect(detected).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index b47a65d4..891eaa79 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -3,7 +3,7 @@ /** * Session Backup to a private GitHub repository * - * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.gemini) + * This script backs up AI agent session files (~/.codex, ~/.claude, ~/.qwen, ~/.gemini) * to a dedicated private repository and optionally posts a comment to the * associated PR with direct links to the uploaded files. * @@ -11,7 +11,7 @@ * node scripts/session-backup-gist.js [options] * * Options: - * --session-dir Path to session directory (default: auto-detect ~/.codex, ~/.claude, or ~/.gemini) + * --session-dir Path to session directory under $HOME (default: auto-detect ~/.codex, ~/.claude, ~/.qwen, 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 @@ -38,8 +38,36 @@ const { uploadSnapshot, } = require("./session-backup-repo.js"); -const SESSION_DIR_NAMES = [".codex", ".claude", ".gemini"]; -const KNOWLEDGE_DIR_NAME = ".knowledge"; +const SESSION_DIR_NAMES = [".codex", ".claude", ".qwen", ".gemini"]; + +const isPathWithinParent = (targetPath, parentPath) => { + const relative = path.relative(parentPath, targetPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; + +const getAllowedSessionRoots = () => { + const homeDir = os.homedir(); + return SESSION_DIR_NAMES.map((dirName) => ({ + name: dirName, + path: path.join(homeDir, dirName), + })).filter((entry) => fs.existsSync(entry.path)); +}; + +const resolveAllowedSessionDir = (candidatePath, verbose) => { + const resolvedPath = path.resolve(candidatePath); + if (!fs.existsSync(resolvedPath)) { + return null; + } + + for (const root of getAllowedSessionRoots()) { + if (isPathWithinParent(resolvedPath, root.path)) { + return resolvedPath; + } + } + + log(verbose, `Skipping non-session directory: ${candidatePath}`); + return null; +}; const parseArgs = () => { const args = process.argv.slice(2); @@ -77,7 +105,7 @@ const parseArgs = () => { console.log(`Usage: session-backup-gist.js [options] Options: - --session-dir Path to session directory + --session-dir Path to session directory under $HOME --pr-number PR number to post comment to --repo Source repository --no-comment Skip posting PR comment @@ -270,28 +298,27 @@ const findSessionDirs = (explicitPath, verbose) => { const dirs = []; if (explicitPath) { - if (fs.existsSync(explicitPath)) { - dirs.push({ name: path.basename(explicitPath), path: explicitPath }); + const allowedPath = resolveAllowedSessionDir(path.resolve(explicitPath), verbose); + if (allowedPath === null) { + console.error( + `[session-backup] --session-dir must point to a directory under ${SESSION_DIR_NAMES + .map((dirName) => `~/${dirName}`) + .join(", ")}` + ); + process.exit(1); } + dirs.push({ name: path.basename(allowedPath), path: allowedPath }); 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 }); + for (const root of getAllowedSessionRoots()) { + const allowedPath = resolveAllowedSessionDir(root.path, verbose); + if (allowedPath !== null) { + log(verbose, `Found session directory: ${allowedPath}`); + dirs.push({ name: root.name, path: allowedPath }); } } - 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; }; @@ -311,22 +338,9 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { } 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, "_"); + const logicalName = path.posix.join(baseName, relPath.split(path.sep).join(path.posix.sep)); files.push({ logicalName, sourcePath: fullPath, diff --git a/scripts/session-backup-repo.js b/scripts/session-backup-repo.js index 55bcc7ae..fcf69c42 100644 --- a/scripts/session-backup-repo.js +++ b/scripts/session-backup-repo.js @@ -355,6 +355,7 @@ const splitLargeFile = (sourcePath, logicalName, outputDir) => { let partBytesWritten = 0; let partName = `${logicalName}.part${partIndex}`; let partPath = path.join(outputDir, partName); + fs.mkdirSync(path.dirname(partPath), { recursive: true }); let partFd = fs.openSync(partPath, "w"); partNames.push(partName); @@ -373,6 +374,7 @@ const splitLargeFile = (sourcePath, logicalName, outputDir) => { partBytesWritten = 0; partName = `${logicalName}.part${partIndex}`; partPath = path.join(outputDir, partName); + fs.mkdirSync(path.dirname(partPath), { recursive: true }); partFd = fs.openSync(partPath, "w"); partNames.push(partName); } @@ -427,6 +429,7 @@ const prepareUploadArtifacts = (sessionFiles, snapshotRef, repoFullName, branch, 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.mkdirSync(path.dirname(chunkManifestPath), { recursive: true }); fs.writeFileSync(chunkManifestPath, `${JSON.stringify(chunkManifest, null, 2)}\n`, "utf8"); const partEntries = split.partNames.map((partName) => { diff --git a/scripts/session-list-gists.js b/scripts/session-list-gists.js index cace6779..8487fae6 100644 --- a/scripts/session-list-gists.js +++ b/scripts/session-list-gists.js @@ -184,6 +184,7 @@ const downloadSnapshot = (snapshotRef, outputDir, backupRepo, ghEnv, verbose) => for (const file of manifest.data.files) { const targetPath = path.join(outputPath, file.name); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); if (file.type === "chunked") { const buffers = file.parts.map((part) => getFileContent(backupRepo.fullName, part.repoPath, ghEnv, backupRepo.defaultBranch) @@ -198,10 +199,10 @@ const downloadSnapshot = (snapshotRef, outputDir, backupRepo, ghEnv, verbose) => 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/"); + console.log(" - .codex/... -> ~/.codex/"); + console.log(" - .claude/... -> ~/.claude/"); + console.log(" - .qwen/... -> ~/.qwen/"); + console.log(" - .gemini/... -> ~/.gemini/"); }; const main = () => { From 25caef95e8b76661a1d983840b1e823b760d23d9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:58:27 +0000 Subject: [PATCH 9/9] fix(session-backup): shorten PR comment with snapshot README --- scripts/session-backup-gist.js | 112 +++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index 891eaa79..cc352ced 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -31,6 +31,7 @@ const { execSync, spawnSync } = require("node:child_process"); const os = require("node:os"); const { + buildBlobUrl, buildSnapshotRef, ensureBackupRepo, resolveGhEnvironment, @@ -370,33 +371,66 @@ const buildManifest = ({ backupRepo, snapshotRef, source, files, createdAt }) => files, }); -const buildCommentBody = ({ backupRepo, source, manifestUrl, files }) => { +const formatBytes = (bytes) => { + if (bytes >= 1_000_000_000) { + return `${(bytes / 1_000_000_000).toFixed(2)} GB`; + } + if (bytes >= 1_000_000) { + return `${(bytes / 1_000_000).toFixed(2)} MB`; + } + if (bytes >= 1_000) { + return `${(bytes / 1_000).toFixed(2)} KB`; + } + return `${bytes} B`; +}; + +const summarizeFiles = (files) => ({ + fileCount: files.length, + totalBytes: files.reduce( + (sum, file) => sum + (file.type === "chunked" ? (file.originalSize ?? 0) : (file.size ?? 0)), + 0 + ), +}); + +const buildSnapshotReadme = ({ backupRepo, source, manifestUrl, summary, sessionRoots }) => + [ + "# AI Session Backup", + "", + "This snapshot contains AI session data used during development.", + "", + `- Backup Repo: \`${backupRepo.fullName}\``, + `- Source Repo: \`${source.repo}\``, + `- Source Branch: \`${source.branch}\``, + `- Source Commit: \`${source.commitSha}\``, + source.prNumber === null ? "- Pull Request: none" : `- Pull Request: #${source.prNumber}`, + `- Created At: \`${source.createdAt}\``, + `- Files: \`${summary.fileCount}\``, + `- Total Size: \`${formatBytes(summary.totalBytes)}\``, + `- Session Roots: \`${sessionRoots.join("`, `")}\``, + "", + `- Manifest: ${manifestUrl}`, + "", + "Generated automatically by the docker-git `pre-push` session backup hook.", + "", + ].join("\n"); + +const buildCommentBody = ({ backupRepo, source, manifestUrl, readmeUrl, summary }) => { const lines = [ "## AI Session Backup", "", - "A snapshot of the AI agent session has been saved to the private session backup repository.", + "A snapshot of the AI session context used during development has been saved.", "", - `**Backup Repo:** ${backupRepo.fullName}`, - `**Source Commit:** \`${source.commitSha}\``, + `Backup Repo: ${backupRepo.fullName}`, + `Source Commit: ${source.commitSha}`, + `Created At: ${source.createdAt}`, + `Files: ${summary.fileCount} (${formatBytes(summary.totalBytes)})`, "", - `**Manifest:** ${manifestUrl}`, + `README: ${readmeUrl}`, + `Manifest: ${manifestUrl}`, "", - "**Files:**", + "This snapshot metadata was used during development.", ]; - 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"); }; @@ -513,6 +547,11 @@ const main = () => { commitSha, createdAt: snapshotCreatedAt, }; + const summary = summarizeFiles(prepared.manifestFiles); + const sessionRoots = sessionDirs.map((dir) => `~/${dir.name}`); + const manifestUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, `${snapshotRef}/manifest.json`); + const readmeRepoPath = `${snapshotRef}/README.md`; + const readmeUrl = buildBlobUrl(backupRepo.fullName, backupRepo.defaultBranch, readmeRepoPath); const manifest = buildManifest({ backupRepo, @@ -521,16 +560,35 @@ const main = () => { files: prepared.manifestFiles, createdAt: snapshotCreatedAt, }); + const readmePath = path.join(tmpDir, "README.md"); + fs.writeFileSync( + readmePath, + buildSnapshotReadme({ + backupRepo, + source, + manifestUrl, + summary, + sessionRoots, + }), + "utf8" + ); + const uploadEntries = [ + ...prepared.uploadEntries, + { + repoPath: readmeRepoPath, + sourcePath: readmePath, + type: "readme", + size: fs.statSync(readmePath).size, + }, + ]; 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] Would write ${uploadEntries.length + 1} file(s) including README and manifest.`); + console.log(`[dry-run] README URL: ${readmeUrl}`); 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 })); + console.log(buildCommentBody({ backupRepo, source, manifestUrl, readmeUrl, summary })); } return; } @@ -540,11 +598,12 @@ const main = () => { backupRepo, snapshotRef, manifest, - prepared.uploadEntries, + uploadEntries, ghEnv ); console.log(`[session-backup] Uploaded snapshot to ${backupRepo.fullName}`); + console.log(`[session-backup] README: ${readmeUrl}`); console.log(`[session-backup] Manifest: ${uploadResult.manifestUrl}`); if (args.postComment && prContext !== null) { @@ -552,7 +611,8 @@ const main = () => { backupRepo, source, manifestUrl: uploadResult.manifestUrl, - files: prepared.manifestFiles, + readmeUrl, + summary, }); postPrComment(prContext.repo, prContext.prNumber, comment, verbose, ghEnv); }