import type { Plugin } from "@opencode-ai/plugin" import { spawnSync } from "node:child_process" import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs" import { hostname } from "node:os" import { join } from "node:path" const HOME = process.env.HOME ?? "" const ROOT = process.env.SESSIONS_ROOT ?? HOME const REPO_NAME = process.env.BACKUP_REPO ?? "cloudshell-sessions" const REMOTE_OVERRIDE = process.env.BACKUP_REMOTE ?? "" const INTERVAL = Math.max(5, Number(process.env.BACKUP_INTERVAL ?? 180)) const IDLE_LIMIT = Math.max(1, Number(process.env.IDLE_LIMIT ?? 3)) const MAX_FILE_BYTES = Number(process.env.BACKUP_MAX_FILE_MB ?? 40) * 1024 * 1024 const BRANCH = process.env.BACKUP_BRANCH ?? "main" const STATE = process.env.SESSIONS_STATE_DIR ?? join(ROOT, ".cloudshell-sessions") const GIT_DIR = join(STATE, "repo.git") const EXCLUDES = join(STATE, "exclude") const TOOBIG = join(STATE, "toobig") const LOG = join(STATE, "sessions.log") const MARKER = join(STATE, ".activity") const IDLE_FILE = join(STATE, "idle-count") const PAUSED_FLAG = join(STATE, ".paused") const PAUSED_FOLDERS = join(STATE, ".paused-folders") const FOLDER_RE = /^new[0-9]+$/ const FOLDER_GLOB = "new[0-9]*" const SKIP_DIRS = new Set([ "node_modules", ".git", "__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit", ".turbo", ".parcel-cache", ".esbuild", ".gradle", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ]) let busy = false let lastTick = 0 function stamp(): string { return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z") } function note(msg: string): void { const line = `[${stamp()}] ${msg}` console.log(`[sessions-push] ${line}`) try { appendFileSync(LOG, line + "\n") } catch {} } function warn(msg: string): void { const line = `[${stamp()}] warning: ${msg}` console.error(`[sessions-push] ${line}`) try { appendFileSync(LOG, line + "\n") } catch {} } type RunResult = { status: number | null; stdout: string; stderr: string } function run(cmd: string, args: string[]): RunResult { const r = spawnSync(cmd, args, { encoding: "utf8" }) return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" } } function git(...args: string[]): RunResult { return run("git", ["--git-dir", GIT_DIR, "--work-tree", ROOT, ...args]) } function hasCmd(cmd: string): boolean { return run(cmd, ["--version"]).status === 0 } function ensureFolders(): void { for (const n of [1, 2]) { const dir = join(ROOT, `new${n}`) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) note(`created session folder ${dir}`) } if (readdirSync(dir).length === 0) { writeFileSync(join(dir, ".gitkeep"), "") } } } function discoverFolders(): string[] { try { return readdirSync(ROOT, { withFileTypes: true }) .filter((e) => e.isDirectory() && FOLDER_RE.test(e.name)) .map((e) => e.name) .sort((a, b) => Number(a.slice(3)) - Number(b.slice(3))) } catch { return [] } } function writeExcludes(toobig: string[]): void { const lines = [ "# Generated by sessions-push - rewritten on every pass, so edit the plugin instead.", ".git/", "node_modules/", "__pycache__/", "*.pyc", "*.pyo", ".venv/", "venv/", ".pytest_cache/", ".mypy_cache/", ".ruff_cache/", ".tox/", ".next/", ".nuxt/", ".svelte-kit/", ".turbo/", ".parcel-cache/", ".esbuild/", ".gradle/", ".cargo/registry/", "target/", ".DS_Store", "*.swp", "*.swo", "*~", ...toobig.map((p) => `/${p}`), ] writeFileSync(EXCLUDES, lines.join("\n") + "\n") } function scanToobig(folders: string[]): string[] { const big: string[] = [] const walk = (dir: string): void => { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const e of entries) { const path = join(dir, e.name) if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path) continue } if (e.isSymbolicLink()) continue try { if (statSync(path).size > MAX_FILE_BYTES) big.push(path.slice(ROOT.length + 1)) } catch {} } } for (const folder of folders) walk(join(ROOT, folder)) big.sort() const previous = existsSync(TOOBIG) ? readFileSync(TOOBIG, "utf8").trim() : "" const current = big.join("\n") if (current !== previous && big.length > 0) { note(`skipping files over ${Math.round(MAX_FILE_BYTES / 1024 / 1024)} MB (GitHub would reject them):`) for (const file of big) note(` ${file}`) } writeFileSync(TOOBIG, current ? current + "\n" : "") return big } function ensureRepo(): void { if (!existsSync(GIT_DIR)) { note(`creating the local push repository in ${GIT_DIR}`) run("git", ["init", "--quiet", "--bare", GIT_DIR]) git("config", "core.bare", "false") git("config", "core.worktree", ROOT) git("config", "core.autocrlf", "false") git("config", "core.safecrlf", "false") run("git", ["--git-dir", GIT_DIR, "symbolic-ref", "HEAD", `refs/heads/${BRANCH}`]) } git("config", "core.excludesFile", EXCLUDES) git("config", "gc.auto", "6700") git("config", "push.default", "simple") if (!git("config", "user.email").stdout.trim()) { git("config", "user.name", process.env.BACKUP_AUTHOR_NAME ?? "cloudshell-sessions") git( "config", "user.email", process.env.BACKUP_AUTHOR_EMAIL ?? "cloudshell-sessions@users.noreply.github.com", ) } if (hasCmd("gh")) { git("config", "credential.helper", "!gh auth git-credential") } } function ensureRemote(): boolean { if (git("remote", "get-url", "origin").status === 0) return true let url = REMOTE_OVERRIDE if (!url) { if (!hasCmd("gh")) { warn("no gh CLI and no BACKUP_REMOTE: cannot work out where to push.") return false } const login = run("gh", ["api", "user", "--jq", ".login"]).stdout.trim() if (!login) { warn("gh is not authenticated (run `gh auth login`, or pass GH_TOKEN to install.sh).") return false } const repo = `${login}/${REPO_NAME}` if (run("gh", ["repo", "view", repo]).status !== 0) { note(`creating private repo ${repo}`) const created = run("gh", [ "repo", "create", repo, "--private", "--description", `Cloud Shell session folders, pushed every ${INTERVAL}s`, ]) if (created.status !== 0) warn(`could not create ${repo} (it may already exist)`) } url = `https://github.com/${repo}.git` } const added = git("remote", "add", "origin", url) if (added.status !== 0) { warn(`could not add remote origin: ${added.stderr.trim()}`) return false } note(`pushing session folders to ${url}`) return true } function pushCommit(): boolean { if (git("push", "--quiet", "origin", BRANCH).status === 0) return true warn("push failed, checking whether the remote simply moved ahead of us") if (git("fetch", "--quiet", "origin", BRANCH).status !== 0) return false if (git("merge-base", "--is-ancestor", `origin/${BRANCH}`, "HEAD").status === 0) { return git("push", "--quiet", "origin", BRANCH).status === 0 } warn(`remote ${BRANCH} has commits this VM does not; not forcing over them.`) return false } type SyncResult = "pushed" | "idle" | "failed" function syncOnce(): SyncResult { ensureFolders() ensureRepo() if (!ensureRemote()) return "failed" const folders = discoverFolders() if (folders.length === 0) { warn(`no session folders (new1, new2, ...) found in ${ROOT}`) return "failed" } writeExcludes(scanToobig(folders)) const tracked = git("ls-tree", "--name-only", "HEAD").stdout .split("\n") .filter((name) => FOLDER_RE.test(name)) for (const name of tracked) { if (!folders.includes(name)) { git("rm", "-r", "--cached", "--quiet", "--ignore-unmatch", "--", name) note(`session folder ${name} was deleted; removing it from the repo`) } } const added = git("add", "-A", "--", ...folders) if (added.status !== 0) { warn(`git add failed: ${added.stderr.trim()}`) return "failed" } if (git("diff", "--cached", "--quiet").status === 0) { note("no changes") return "idle" } const message = `sessions: ${stamp()} from ${hostname()}` if (git("commit", "--quiet", "--no-verify", "-m", message).status !== 0) { warn("git commit failed") return "failed" } if (!pushCommit()) { warn("commit is local only; it will be retried next pass.") return "failed" } note(`pushed -> ${git("rev-parse", "--short", "HEAD").stdout.trim()}`) return "pushed" } async function tickFull(): Promise { if (busy) return busy = true lastTick = Date.now() try { writeFileSync(MARKER, "") const result = syncOnce() if (result === "pushed") { writeFileSync(IDLE_FILE, "0\n") rmSync(PAUSED_FLAG, { force: true }) return } if (result === "idle") { let idle = 0 try { idle = Number(readFileSync(IDLE_FILE, "utf8").trim()) || 0 } catch {} idle += 1 writeFileSync(IDLE_FILE, `${idle}\n`) if (idle >= IDLE_LIMIT && !existsSync(PAUSED_FLAG)) { writeFileSync(PAUSED_FLAG, "") writeFileSync(PAUSED_FOLDERS, discoverFolders().join(" ")) note( `no changes on ${idle} consecutive checks - pausing pushes; watching ${ROOT}/${FOLDER_GLOB} for changes (resumes automatically)`, ) } return } warn("pass failed; retrying next tick") } finally { busy = false } } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function findNewer(paths: string[]): boolean { if (paths.length === 0) return false const result = run("find", [ ...paths, "-xdev", "-newer", MARKER, "-type", "f", "!", "-path", "*/node_modules/*", "!", "-path", "*/.git/*", "!", "-path", "*/__pycache__/*", "!", "-path", "*/.venv/*", "!", "-path", "*/venv/*", "-print", "-quit", ]) return result.stdout.trim().length > 0 } async function watchOnce(): Promise { let previous = "" try { previous = readFileSync(PAUSED_FOLDERS, "utf8").trim() } catch {} while (true) { await sleep(INTERVAL * 1000) const current = discoverFolders().join(" ") if (current !== previous) return true if (findNewer(current.split(" ").filter(Boolean).map((f) => join(ROOT, f)))) return true } } async function loop(): Promise { writeFileSync(IDLE_FILE, "0\n") rmSync(PAUSED_FLAG, { force: true }) writeFileSync(MARKER, "") for (;;) { if (existsSync(PAUSED_FLAG)) { if (await watchOnce()) { rmSync(PAUSED_FLAG, { force: true }) rmSync(PAUSED_FOLDERS, { force: true }) writeFileSync(IDLE_FILE, "0\n") note("change detected - resuming pushes") await tickFull() } } else { await tickFull() } await sleep(INTERVAL * 1000) } } const startedKey = "__opencodeSessionsPushStarted" export const SessionsPush: Plugin = async () => { const globals = globalThis as Record if (!globals[startedKey]) { globals[startedKey] = true mkdirSync(STATE, { recursive: true }) ensureFolders() note(`plugin loaded - pushing ${ROOT}/${FOLDER_GLOB} every ${INTERVAL}s`) void loop() } return { event: async ({ event }) => { if (event?.type !== "session.idle") return if (busy || Date.now() - lastTick < 5000) return await tickFull() }, } } export default SessionsPush const isMain = typeof (import.meta as unknown as { main?: unknown }).main === "boolean" ? (import.meta as unknown as { main: boolean }).main : Boolean(process.argv[1]?.endsWith("sessions-push.ts")) if (isMain) { mkdirSync(STATE, { recursive: true }) const arg = process.argv[2] ?? "--status" if (arg === "--help" || arg === "-h") { console.log(`Push session folders (new1, new2, new3, ...) to a private GitHub repo. sessions-push.ts run the pusher in the foreground sessions-push.ts --once take one snapshot now sessions-push.ts --status show folders, pause state and the last commits sessions-push.ts --help As an opencode plugin it starts by itself, pushes every ${INTERVAL}s and once more each time a session goes idle, pauses after ${IDLE_LIMIT} empty passes and resumes as soon as a folder changes. Environment variables: BACKUP_REPO private repo name (default: cloudshell-sessions) BACKUP_REMOTE full remote URL; skips \`gh repo create\` BACKUP_INTERVAL seconds between passes (default: 180) IDLE_LIMIT idle passes before pausing (default: 3) BACKUP_MAX_FILE_MB largest file to take (default: 40) BACKUP_BRANCH branch to push (default: main) SESSIONS_ROOT where the new folders live (default: $HOME) SESSIONS_STATE_DIR where the git dir and log live (default: $HOME/.cloudshell-sessions)`) process.exit(0) } if (arg === "--once") { ensureFolders() process.exit(syncOnce() === "failed" ? 1 : 0) } if (arg === "--loop") { void loop() await new Promise(() => {}) } if (arg === "--status") { const folders = discoverFolders() console.log(`root ${ROOT}`) console.log(`repository ${GIT_DIR}`) console.log(`folders ${folders.length ? folders.join(" ") : "none"}`) console.log(`state ${existsSync(PAUSED_FLAG) ? "paused - watching for changes" : "active"}`) if (existsSync(GIT_DIR)) { const remote = git("remote", "get-url", "origin") console.log(`remote ${remote.status === 0 ? remote.stdout.trim() : "(not set up yet - no gh auth?)"}`) const last = git("log", "-1", "--format=%h %ad %s", "--date=iso") console.log(`last local ${last.status === 0 ? last.stdout.trim() : "(nothing committed yet)"}`) } if (existsSync(LOG)) { console.log("--- last 8 log lines ---") console.log(readFileSync(LOG, "utf8").trimEnd().split("\n").slice(-8).join("\n")) } process.exit(0) } console.error(`error: unknown argument '${arg}' (try --help)`) process.exit(1) }