From ba78ce6c61810d83f2175c0c78e25f4e641d1c3a Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 12:03:19 +0800 Subject: [PATCH 01/10] feat(core): large-change guard, category toggles, push race retry, recovery copies, child proxy env --- src/core/categories.ts | 136 +++++++++++++++++++++ src/core/env.ts | 57 +++++++++ src/core/git.ts | 7 +- src/core/guard.ts | 89 ++++++++++++++ src/core/recovery.ts | 71 +++++++++++ src/core/repo.ts | 20 +++- src/core/settings.ts | 97 ++++++++++++++- src/core/setup.ts | 8 +- src/core/stage.ts | 11 +- src/core/sync.ts | 260 ++++++++++++++++++++++++++++++++++------- 10 files changed, 708 insertions(+), 48 deletions(-) create mode 100644 src/core/categories.ts create mode 100644 src/core/env.ts create mode 100644 src/core/guard.ts create mode 100644 src/core/recovery.ts diff --git a/src/core/categories.ts b/src/core/categories.ts new file mode 100644 index 0000000..bcba600 --- /dev/null +++ b/src/core/categories.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SyncCategories } from "./settings.js"; + +/** + * Optional parts of the config directory. + * + * The repository worktree is `~/.config/opencode` itself, so a category is a + * set of paths at its root. Turning a category off makes those paths local to + * this machine in both directions: they are never committed from here, and an + * incoming version never replaces them. + */ + +export type CategoryKey = keyof SyncCategories; + +interface CategoryPaths { + files?: string[]; + dirs?: string[]; + /** Match root-level files by extension. */ + rootExtensions?: string[]; +} + +export const CATEGORY_PATHS: Record = { + config: { files: ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"] }, + instructions: { rootExtensions: [".md"] }, + agents: { dirs: ["agent", "agents"] }, + commands: { dirs: ["command", "commands"] }, + plugins: { dirs: ["plugin", "plugins"] }, +}; + +export function disabledCategories(categories: SyncCategories | undefined): CategoryKey[] { + if (!categories) return []; + return (Object.keys(CATEGORY_PATHS) as CategoryKey[]).filter((key) => categories[key] === false); +} + +/** True when a repository-relative path belongs to a disabled category. */ +export function isDisabledPath(rel: string, disabled: CategoryKey[]): boolean { + const clean = rel.replace(/\\/g, "/").replace(/^\.\//, ""); + for (const key of disabled) { + const spec = CATEGORY_PATHS[key]; + if (spec.files?.includes(clean)) return true; + if (spec.dirs?.some((d) => clean === d || clean.startsWith(`${d}/`))) return true; + if ( + spec.rootExtensions && + !clean.includes("/") && + spec.rootExtensions.some((ext) => clean.toLowerCase().endsWith(ext)) + ) { + return true; + } + } + return false; +} + +/** Git pathspecs (top-level, literal) covering the disabled categories. */ +export function disabledPathspecs(disabled: CategoryKey[]): string[] { + const specs: string[] = []; + for (const key of disabled) { + const spec = CATEGORY_PATHS[key]; + for (const f of spec.files ?? []) specs.push(`:(top,literal)${f}`); + for (const d of spec.dirs ?? []) specs.push(`:(top,literal)${d}`); + for (const ext of spec.rootExtensions ?? []) specs.push(`:(top,glob)*${ext}`); + } + return specs; +} + +/** Every existing file under `root` that belongs to a disabled category. */ +export function listDisabledFiles(root: string, disabled: CategoryKey[]): string[] { + if (disabled.length === 0 || !fs.existsSync(root)) return []; + const out: string[] = []; + const walk = (rel: string): void => { + const abs = path.join(root, rel); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) walk(childRel); + else out.push(childRel); + } + }; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name === ".git" || entry.isSymbolicLink()) continue; + if (!isDisabledPath(entry.name, disabled)) continue; + if (entry.isDirectory()) walk(entry.name); + else out.push(entry.name); + } + return out; +} + +/** + * Copy the disabled categories aside, so they can be put back exactly as they + * were after the worktree is reset to the remote. + */ +export interface CategorySnapshot { + dir: string; + files: string[]; +} + +export function snapshotDisabled(root: string, disabled: CategoryKey[]): CategorySnapshot | null { + if (disabled.length === 0) return null; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-keep-")); + const files = listDisabledFiles(root, disabled); + for (const rel of files) { + const target = path.join(dir, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(root, rel), target); + } + return { dir, files }; +} + +/** Put the snapshot back: incoming files in those categories are removed. */ +export function restoreDisabled( + root: string, + disabled: CategoryKey[], + snapshot: CategorySnapshot | null, +): void { + if (!snapshot) return; + try { + const keep = new Set(snapshot.files); + for (const rel of listDisabledFiles(root, disabled)) { + if (!keep.has(rel)) fs.rmSync(path.join(root, rel), { force: true }); + } + for (const rel of snapshot.files) { + const target = path.join(root, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(snapshot.dir, rel), target); + } + } finally { + fs.rmSync(snapshot.dir, { recursive: true, force: true }); + } +} diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 0000000..ff2dbd1 --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,57 @@ +/** + * Environment handed to child processes (git, gh, npm). + * + * A proxy configured in the local settings must reach every network-touching + * child, but must never leak into the parent process or into OpenCode itself, + * so it is kept here and merged in only when a child is spawned. + */ + +let extra: Record = {}; + +/** Environment variables that express a proxy to git, gh, curl and npm alike. */ +export function proxyEnv(proxy: string | undefined): Record { + const value = (proxy ?? "").trim(); + if (!value) return {}; + return { + HTTP_PROXY: value, + HTTPS_PROXY: value, + http_proxy: value, + https_proxy: value, + npm_config_proxy: value, + npm_config_https_proxy: value, + }; +} + +/** Set (or clear, with `undefined`) the proxy used by every child process. */ +export function setChildProxy(proxy: string | undefined): void { + extra = proxyEnv(proxy); +} + +/** Additional variables currently applied to children. */ +export function childEnvExtras(): Record { + return { ...extra }; +} + +/** `process.env` plus the child-only additions plus caller-specific overrides. */ +export function childEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { ...process.env, ...extra, ...overrides }; +} + +/** Prepend directories to PATH (whatever its casing is on this platform). */ +export function prependPath(env: NodeJS.ProcessEnv, dirs: string[]): NodeJS.ProcessEnv { + if (dirs.length === 0) return env; + const key = Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH"; + const sep = process.platform === "win32" ? ";" : ":"; + const current = String(env[key] ?? ""); + const present = new Set( + current + .split(sep) + .filter(Boolean) + .map((p) => (process.platform === "win32" ? p.toLowerCase() : p)), + ); + const missing = dirs.filter( + (d) => d && !present.has(process.platform === "win32" ? d.toLowerCase() : d), + ); + if (missing.length === 0) return env; + return { ...env, [key]: [...missing, current].filter(Boolean).join(sep) }; +} diff --git a/src/core/git.ts b/src/core/git.ts index b09032d..9c43f1a 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -1,5 +1,6 @@ import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; +import { childEnv } from "./env.js"; const execFileAsync = promisify(execFile); @@ -38,7 +39,8 @@ export function git(args: string[], options: GitOptions): GitResult { encoding: "utf8", stdio: "pipe", timeout: options.timeoutMs ?? 0, - env: options.interactive ? process.env : { ...process.env, ...NO_PROMPT_ENV }, + windowsHide: true, + env: options.interactive ? childEnv() : childEnv(NO_PROMPT_ENV), }); return { ok: true, stdout: stdout.trim(), stderr: "", code: 0 }; } catch (e: any) { @@ -57,7 +59,8 @@ export async function gitAsync(args: string[], options: GitOptions): Promise b.bytes - a.bytes); + const oversized = sizes.filter((s) => s.bytes > limits.maxFileBytes); + const reasons: GuardReport["reasons"] = []; + if (addedFiles > limits.maxFiles) reasons.push("files"); + if (totalBytes > limits.maxBytes) reasons.push("bytes"); + if (oversized.length > 0) reasons.push("file"); + + return { addedFiles, totalBytes, largest: sizes[0], oversized, reasons }; +} + +export class LargeChangeError extends Error { + constructor(readonly report: GuardReport) { + super( + `This push is unusually large (${report.addedFiles} new file(s), ${report.totalBytes} bytes).\n` + + "Confirm it interactively, raise the limits in the menu, or add the directory to the skip list.", + ); + this.name = "LargeChangeError"; + } +} + +/** Patterns written to `.git/info/exclude` so generated trees are never staged. */ +export function excludeFileContent(skipDirs: string[]): string { + const lines = [ + "# Managed by opencode-github-sync: generated directories never synced.", + ...skipDirs.filter((d) => /^[^\\/*?[\]]+$/.test(d)).map((d) => `${d}/`), + ]; + return `${lines.join("\n")}\n`; +} + +export function writeLocalExcludes(root: string, skipDirs: string[]): void { + const file = path.join(root, ".git", "info", "exclude"); + if (!fs.existsSync(path.join(root, ".git"))) return; + const content = excludeFileContent(skipDirs); + try { + if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === content) return; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } catch { + // Best effort: the large-change guard still catches what slips through. + } +} diff --git a/src/core/recovery.ts b/src/core/recovery.ts new file mode 100644 index 0000000..e11f554 --- /dev/null +++ b/src/core/recovery.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Roots } from "./paths.js"; + +/** + * Recovery copies. + * + * Whenever this tool is about to replace bytes the user may still want — a + * forced pull, a "repository wins" first link, a file restored from history — + * the current version is copied here first. The directory lives under the data + * root, which is never mirrored into the repository as a whole, so recovery + * copies stay on this machine. + */ + +export const RECOVERY_DIR = "opencode-sync-recovery"; + +export function recoveryRoot(roots: Roots): string { + return path.join(roots.data, RECOVERY_DIR); +} + +export interface RecoveryEntry { + /** Absolute path of the file being replaced. */ + source: string; + /** Relative path used inside the recovery folder. */ + rel: string; +} + +function stamp(now: Date): string { + return now.toISOString().replace(/[:.]/g, "-").replace("Z", ""); +} + +/** + * Copy files into a fresh recovery folder. Missing sources are skipped. + * Returns the folder, or `undefined` when nothing needed saving. + */ +export function saveToRecovery( + roots: Roots, + label: string, + entries: RecoveryEntry[], + now = new Date(), +): string | undefined { + const existing = entries.filter((e) => { + try { + return fs.statSync(e.source).isFile(); + } catch { + return false; + } + }); + if (existing.length === 0) return undefined; + const safeLabel = label.replace(/[^A-Za-z0-9._-]+/g, "-"); + const dir = path.join(recoveryRoot(roots), `${stamp(now)}-${safeLabel}`); + for (const entry of existing) { + const rel = entry.rel.replace(/^[A-Za-z]:/, "").replace(/^[\\/]+/, ""); + const target = path.join(dir, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(entry.source, target); + } + return dir; +} + +/** Recovery folders, newest first. */ +export function listRecovery(roots: Roots): string[] { + const root = recoveryRoot(roots); + if (!fs.existsSync(root)) return []; + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => path.join(root, d.name)) + .sort() + .reverse(); +} diff --git a/src/core/repo.ts b/src/core/repo.ts index 67f9280..89d5e41 100644 --- a/src/core/repo.ts +++ b/src/core/repo.ts @@ -38,8 +38,10 @@ ${DATA_DIR}/snapshot/ ${DATA_DIR}/storage/session ${DATA_DIR}/storage/session_diff -# Machine-local UI state +# Machine-local UI state and the autosync runtime state ${STATE_DIR}/session.json +${STATE_DIR}/opencode-sync-autosync.json +${STATE_DIR}/opencode-sync-autosync.json.tmp # Empty directories recreated by OpenCode at runtime plugin/ @@ -101,6 +103,15 @@ export interface EnsureRepoOptions { root: string; remote: string; branch: string; + /** + * How an existing remote history is adopted by a directory that is not yet + * a repository. `remote` (default) checks the remote files out over the + * local ones; `local` keeps every local file and only moves the branch, so + * the next commit records this machine's state on top of the remote. + */ + adopt?: "remote" | "local"; + /** Called before `remote` adoption overwrites the worktree. */ + beforeAdopt?: (root: string, ref: string) => void; } export type RepoState = "ready" | "adopted" | "fresh"; @@ -147,7 +158,12 @@ export function initRepo(options: EnsureRepoOptions): RepoState { const fetched = git(["fetch", "origin", branch], { cwd: root }); if (fetched.ok && git(["rev-parse", `origin/${branch}`], { cwd: root }).ok) { - git(["reset", "--hard", `origin/${branch}`], { cwd: root }); + if (options.adopt === "local") { + git(["reset", "-q", `origin/${branch}`], { cwd: root }); + } else { + options.beforeAdopt?.(root, `origin/${branch}`); + git(["reset", "--hard", `origin/${branch}`], { cwd: root }); + } git(["branch", `--set-upstream-to=origin/${branch}`, branch], { cwd: root }); return "adopted"; } diff --git a/src/core/settings.ts b/src/core/settings.ts index e03ff03..eabaf3e 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -37,6 +37,48 @@ export interface SessionSettings { directories: string[]; } +/** Background sync driven by the operating system's scheduler. Machine-local. */ +export interface AutosyncSettings { + /** Opt-in. Only a machine that turned this on ever syncs unattended. */ + enabled: boolean; + /** Minutes between scheduled runs. Minimum 2. */ + intervalMinutes: number; + /** Seconds the synced trees must be quiet before a push. */ + settleSeconds: number; + /** Windows only: register with S4U so it runs while logged off (needs elevation). */ + runWhenLoggedOff: boolean; + /** Epoch milliseconds; no unattended sync before this moment. 0 = not paused. */ + pausedUntil: number; + /** Check npm once a day from the scheduled job and upgrade when newer. */ + autoUpdate: boolean; +} + +/** Thresholds above which a push needs explicit confirmation. */ +export interface GuardSettings { + /** Maximum number of newly added files in one push. */ + maxFiles: number; + /** Maximum total size of added and modified files in one push. */ + maxBytes: number; + /** Maximum size of any single added or modified file. */ + maxFileBytes: number; + /** Directory names never copied out of synced trees (generated content). */ + skipDirs: string[]; +} + +/** + * Which parts of the config directory this machine shares. + * + * A category switched off is local to this machine in both directions: its + * local changes are never committed and incoming versions never replace it. + */ +export interface SyncCategories { + config: boolean; + instructions: boolean; + agents: boolean; + commands: boolean; + plugins: boolean; +} + export interface Settings { repo: RepoSettings; /** Stable alias for this machine used in commit messages. */ @@ -54,8 +96,20 @@ export interface Settings { autoPullOnStartup: boolean; /** Push automatically when a session goes idle (plugin only). */ autoPushOnIdle: boolean; + /** Parts of the config directory to share. */ + categories: SyncCategories; + autosync: AutosyncSettings; + guard: GuardSettings; + /** HTTP(S) proxy for git, gh and npm child processes. Empty = none. */ + proxy: string; + /** Interface language; empty = auto-detect. */ + language: string; + /** Desktop notifications from the scheduled job. */ + notifications: boolean; } +export const DEFAULT_SKIP_DIRS = ["node_modules", ".venv", "venv", "__pycache__", "dist", ".cache"]; + export const DEFAULT_SETTINGS: Settings = { repo: { branch: "main" }, includeCredentials: false, @@ -73,8 +127,28 @@ export const DEFAULT_SETTINGS: Settings = { }, autoPullOnStartup: true, autoPushOnIdle: false, + categories: { config: true, instructions: true, agents: true, commands: true, plugins: true }, + autosync: { + enabled: false, + intervalMinutes: 5, + settleSeconds: 60, + runWhenLoggedOff: false, + pausedUntil: 0, + autoUpdate: false, + }, + guard: { + maxFiles: 300, + maxBytes: 10 * 1024 * 1024, + maxFileBytes: 2 * 1024 * 1024, + skipDirs: [...DEFAULT_SKIP_DIRS], + }, + proxy: "", + language: "", + notifications: true, }; +export const MIN_AUTOSYNC_INTERVAL = 2; + export function settingsPath(configRoot = getConfigRoot()): string { return path.join(configRoot, SETTINGS_FILE); } @@ -89,19 +163,38 @@ function mergeSettings(base: Settings, patch: Partial): Settings { ...patch, repo: { ...base.repo, ...(patch.repo ?? {}) }, sessions: { ...base.sessions, ...(patch.sessions ?? {}) }, + categories: { ...base.categories, ...(patch.categories ?? {}) }, + autosync: { ...base.autosync, ...(patch.autosync ?? {}) }, + guard: { ...base.guard, ...(patch.guard ?? {}) }, }; } +/** A deep copy of the defaults, so callers can mutate the result freely. */ +export function defaultSettings(): Settings { + return JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); +} + export function loadSettings(configRoot = getConfigRoot()): Settings { const file = settingsPath(configRoot); - if (!fs.existsSync(file)) return { ...DEFAULT_SETTINGS }; + if (!fs.existsSync(file)) return defaultSettings(); let parsed: Partial; try { parsed = parseJsonc>(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(`Cannot read ${SETTINGS_FILE}: ${(e as Error).message}`); } - return mergeSettings(DEFAULT_SETTINGS, parsed ?? {}); + return mergeSettings(defaultSettings(), parsed ?? {}); +} + +/** Load, change and save in one step. Returns the saved settings. */ +export function updateSettings( + mutate: (settings: Settings) => void, + configRoot = getConfigRoot(), +): Settings { + const settings = loadSettings(configRoot); + mutate(settings); + saveSettings(settings, configRoot); + return settings; } export function saveSettings(settings: Settings, configRoot = getConfigRoot()): void { diff --git a/src/core/setup.ts b/src/core/setup.ts index 5e56ed4..d5922c1 100644 --- a/src/core/setup.ts +++ b/src/core/setup.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; +import { childEnv } from "./env.js"; import { ensureFile } from "./fsx.js"; import { type Roots, getRoots } from "./paths.js"; import type { Reporter } from "./reporter.js"; @@ -33,7 +34,12 @@ export class SetupError extends Error { function gh(args: string[]): { ok: boolean; stdout: string; stderr: string } { try { - const stdout = execFileSync("gh", args, { encoding: "utf8", stdio: "pipe" }); + const stdout = execFileSync("gh", args, { + encoding: "utf8", + stdio: "pipe", + windowsHide: true, + env: childEnv(), + }); return { ok: true, stdout: stdout.trim(), stderr: "" }; } catch (e: any) { return { diff --git a/src/core/stage.ts b/src/core/stage.ts index 418ec45..43931b7 100644 --- a/src/core/stage.ts +++ b/src/core/stage.ts @@ -46,8 +46,15 @@ const DATA_FILES = ["sync-state.json"]; * and `storage/migration` is a one-byte file — `copyPath` resolves which. */ const DATA_PATHS = ["storage/project", "storage/migration"]; -/** State files that describe this machine's current UI, not its configuration. */ -const MACHINE_STATE_FILES = new Set(["session.json"]); +/** + * State files that describe this machine, not its configuration: the current + * UI session and the autosync scheduler's own bookkeeping. + */ +export const MACHINE_STATE_FILES = new Set([ + "session.json", + "opencode-sync-autosync.json", + "opencode-sync-autosync.json.tmp", +]); const SKILL_LOCK = ".skill-lock.json"; diff --git a/src/core/sync.ts b/src/core/sync.ts index 46c81e0..a61bc3e 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -11,6 +11,17 @@ import { import { commitMessage, hostAlias } from "./host.js"; import { applyOverrides, configMatchesRepo, findConfigFile } from "./overrides.js"; import { type Roots, getDatabasePath, getRoots } from "./paths.js"; +import { + type CategoryKey, + type CategorySnapshot, + disabledCategories, + disabledPathspecs, + isDisabledPath, + restoreDisabled, + snapshotDisabled, +} from "./categories.js"; +import { type GuardReport, LargeChangeError, evaluateGuard, writeLocalExcludes } from "./guard.js"; +import { saveToRecovery } from "./recovery.js"; import { LOCAL_RUNTIME_PATHS, assertPushLanded, @@ -33,7 +44,13 @@ import { totalChanges, } from "./reporter.js"; import { exportSessions, importSessions } from "./sessions.js"; -import { type Settings, loadSettings, repoBranch, repoUrl } from "./settings.js"; +import { + DEFAULT_SETTINGS, + type Settings, + loadSettings, + repoBranch, + repoUrl, +} from "./settings.js"; import { sqliteAvailable } from "./sqlite.js"; import { createStageContext, @@ -51,6 +68,19 @@ export interface SyncOptions { force?: boolean; /** Compute the result without writing anything to the remote. */ dryRun?: boolean; + /** + * Called when a push exceeds the large-change limits. Return true to send it + * anyway. Without a callback an oversized push is refused. + */ + confirmLarge?: (report: GuardReport) => boolean | Promise; + /** Skip the large-change guard entirely. */ + allowLarge?: boolean; + /** + * First push from a directory that is not a repository yet, against a + * remote that already has history: `remote` (default) takes the remote + * files first, `local` keeps every local file and records it on top. + */ + adopt?: "remote" | "local"; } export interface SyncResult { @@ -62,6 +92,8 @@ export interface SyncResult { message: string; /** True when OpenCode must restart for the pulled config to take effect. */ restartRequired: boolean; + /** Present on a dry-run push that would trip the large-change guard. */ + guard?: GuardReport; } class SyncError extends Error { @@ -71,6 +103,22 @@ class SyncError extends Error { } } +/** + * Another machine published between our rebase and our push, every time we + * retried. That is contention, not an outage: the next run simply tries again. + */ +export class PushRaceError extends Error { + constructor(message: string) { + super(message); + this.name = "PushRaceError"; + } +} + +/** Commit trailer naming the machine that produced a sync commit. */ +export const DEVICE_TRAILER = "Opencode-Sync-Device"; + +const PUSH_RACE_RETRIES = 3; + interface Context { root: string; branch: string; @@ -80,6 +128,9 @@ interface Context { reporter: Reporter; force: boolean; dryRun: boolean; + confirmLarge?: SyncOptions["confirmLarge"]; + allowLarge: boolean; + disabled: CategoryKey[]; } function prepare(options: SyncOptions): Context { @@ -101,6 +152,9 @@ function prepare(options: SyncOptions): Context { reporter: options.reporter ?? silentReporter, force: options.force ?? false, dryRun: options.dryRun ?? false, + confirmLarge: options.confirmLarge, + allowLarge: options.allowLarge ?? false, + disabled: disabledCategories(settings.categories), }; } @@ -118,9 +172,16 @@ export async function push(options: SyncOptions = {}): Promise { if (!fs.existsSync(root)) throw new SyncError(`Config directory not found: ${root}`); - ensureRepo({ root, remote: ctx.remote, branch }); + ensureRepo({ + root, + remote: ctx.remote, + branch, + adopt: options.adopt ?? "remote", + beforeAdopt: (_root, ref) => saveOverwritten(ctx, ref, "first-push"), + }); assertRemoteReachable(root, branch, "Push", true); writeRepoMetadata(root); + writeLocalExcludes(root, ctx.settings.guard?.skipDirs ?? []); try { return await runPush(ctx); @@ -154,6 +215,7 @@ async function runPush(ctx: Context): Promise { git(["add", "-A"], { cwd: root }); unstageLocalRuntimePaths(root); + unstageDisabled(ctx); const staged = git(["diff", "--cached", "--name-status"], { cwd: root }); const files = toChangedFiles(staged, true); @@ -194,7 +256,13 @@ async function runPush(ctx: Context): Promise { // anything — and the result must reach the real directories, or the next // push would read everything new as deleted on this machine. reporter.step(`Fast-forwarding ${behind} remote commit(s)`); - const ff = git(["merge", "--ff-only", `origin/${branch}`], { cwd: root }); + const aside = setAsideDisabled(ctx); + let ff: GitResult; + try { + ff = git(["merge", "--ff-only", `origin/${branch}`], { cwd: root }); + } finally { + restoreDisabled(root, ctx.disabled, aside); + } if (!ff.ok) throw toGitError("Fast-forward", ff); await finishPush(ctx, true); return done( @@ -212,8 +280,11 @@ async function runPush(ctx: Context): Promise { } reporter.changes(summary, files); + const guard = ctx.allowLarge + ? undefined + : evaluateGuard(root, files, ctx.settings.guard ?? DEFAULT_SETTINGS.guard); if (ctx.dryRun) { - return done( + const result = done( "push", true, summary, @@ -221,10 +292,24 @@ async function runPush(ctx: Context): Promise { `${totalChanges(summary)} change(s) would be pushed`, sessions, ); + if (guard && guard.reasons.length > 0) result.guard = guard; + return result; + } + + if (guard && guard.reasons.length > 0) { + const allowed = ctx.confirmLarge ? await ctx.confirmLarge(guard) : false; + if (!allowed) { + // Leave nothing staged: the next attempt must re-evaluate from scratch. + git(["reset", "-q"], { cwd: root }); + throw new LargeChangeError(guard); + } } const alias = hostAlias(ctx.settings.machineAlias); - const commit = git(["commit", "-m", commitMessage("sync", alias)], { cwd: root }); + const commit = git( + ["commit", "-m", commitMessage("sync", alias), "-m", `${DEVICE_TRAILER}: ${alias}`], + { cwd: root }, + ); if (!commit.ok) throw toGitError("Commit", commit); // The commit was built from this machine's directories, so they match it. // If the rebase or the network push below fails, the next run sees the mark @@ -268,7 +353,14 @@ function pushBranch(ctx: Context, alreadyCommitted: boolean): boolean { const hasRemoteBranch = git(["rev-parse", `origin/${branch}`], { cwd: root }).ok; if (!hasRemoteBranch) { const result = git(["push", "-u", "origin", branch], { cwd: root }); - if (!result.ok) throw toGitError("Push", result); + if (!result.ok) { + if (isRejected(result)) { + throw new PushRaceError( + "Another machine created the branch at the same moment. The next sync will retry.", + ); + } + throw toGitError("Push", result); + } assertPushLanded(root, branch, "Push"); return false; } @@ -289,26 +381,75 @@ function pushBranch(ctx: Context, alreadyCommitted: boolean): boolean { return false; } - const before = headCommit(root); - const rebase = git(["pull", "--rebase", "origin", branch], { cwd: root }); - if (!rebase.ok) { - git(["rebase", "--abort"], { cwd: root }); - throw new SyncError( - "The remote has changes that conflict with this machine.\n" + - "Run `opencode-sync pull` to take the remote version, or `opencode-sync push --force` to overwrite it.", - ); + // Another machine can publish between our rebase and our push. Losing that + // race is contention, not failure: rebase onto the newcomer and try again. + // Files in disabled categories are set aside so local-only edits never + // block the rebase. + const aside = setAsideDisabled(ctx); + let integrated = false; + try { + for (let attempt = 1; ; attempt++) { + const before = headCommit(root); + const rebase = git(["pull", "--rebase", "origin", branch], { cwd: root }); + if (!rebase.ok) { + git(["rebase", "--abort"], { cwd: root }); + throw new SyncError( + "The remote has changes that conflict with this machine.\n" + + "Run `opencode-sync pull` to take the remote version, or `opencode-sync push --force` to overwrite it.", + ); + } + // The rebase moves HEAD only when it replayed local commits onto new + // remote ones; an unchanged HEAD means nothing arrived. + if (headCommit(root) !== before) integrated = true; + + const result = git(["push", "origin", branch], { cwd: root }); + if (result.ok) break; + if (!isRejected(result)) throw toGitError("Push", result); + if (attempt >= PUSH_RACE_RETRIES) { + throw new PushRaceError( + `Another machine kept publishing first (${attempt} attempts). The next sync will retry.`, + ); + } + reporter.step("Another machine pushed first; rebasing and retrying"); + } + } finally { + restoreDisabled(root, ctx.disabled, aside); } - // The rebase moves HEAD only when it replayed local commits onto new remote - // ones; an unchanged HEAD means nothing arrived. - const integrated = headCommit(root) !== before; - - const result = git(["push", "origin", branch], { cwd: root }); - if (!result.ok) throw toGitError("Push", result); assertPushLanded(root, branch, "Push"); void alreadyCommitted; return integrated; } +function isRejected(result: GitResult): boolean { + return /non-fast-forward|fetch first|\[rejected\]|failed to push some refs|stale info/i.test( + `${result.stderr}\n${result.stdout}`, + ); +} + +// ── Disabled categories ───────────────────────────────────────────────────── + +function unstageDisabled(ctx: Context): void { + if (ctx.disabled.length === 0) return; + git(["reset", "-q", "--", ...disabledPathspecs(ctx.disabled)], { cwd: ctx.root }); +} + +/** + * Copy the disabled categories aside and bring their tracked files back to + * HEAD, so a rebase or reset can run on a clean tree. `restoreDisabled` puts + * the local versions back afterwards. + */ +function setAsideDisabled(ctx: Context): CategorySnapshot | null { + if (ctx.disabled.length === 0) return null; + const snapshot = snapshotDisabled(ctx.root, ctx.disabled); + if (!snapshot) return null; + // Untracked local-only files would block an incoming file at the same path. + for (const rel of snapshot.files) fs.rmSync(path.join(ctx.root, rel), { force: true }); + const tracked = git(["ls-files", "--", ...disabledPathspecs(ctx.disabled)], { cwd: ctx.root }); + const files = tracked.ok ? tracked.stdout.split("\n").filter(Boolean) : []; + if (files.length > 0) git(["checkout", "HEAD", "--", ...files], { cwd: ctx.root }); + return snapshot; +} + // ── Pull ──────────────────────────────────────────────────────────────────── export async function pull(options: SyncOptions = {}): Promise { @@ -318,7 +459,13 @@ export async function pull(options: SyncOptions = {}): Promise { fs.mkdirSync(root, { recursive: true }); if (!isGitRepo(root)) { - const state = ensureRepo({ root, remote: ctx.remote, branch }); + const state = ensureRepo({ + root, + remote: ctx.remote, + branch, + adopt: "remote", + beforeAdopt: (_root, ref) => saveOverwritten(ctx, ref, "first-pull"), + }); if (state === "fresh") { return done( "pull", @@ -392,28 +539,37 @@ export async function pull(options: SyncOptions = {}): Promise { // that would conflict on every single run. normalizeConfigForRepo(stageCtx); - const stashed = prepareWorktree(ctx); - const nameStatus = git(["diff", "--name-status", "HEAD", `origin/${branch}`], { cwd: root }); - const files = toChangedFiles(nameStatus, true); - const summary = summarize(files); + if (force && !ctx.dryRun) saveOverwritten(ctx, `origin/${branch}`, "force-pull"); + const aside = ctx.dryRun ? null : setAsideDisabled(ctx); + let stashed = false; + let files: ChangedFile[] = []; + let summary = emptySummary(); + try { + stashed = prepareWorktree(ctx); + const nameStatus = git(["diff", "--name-status", "HEAD", `origin/${branch}`], { cwd: root }); + files = toChangedFiles(nameStatus, true).filter((f) => !isDisabledPath(f.path, ctx.disabled)); + summary = summarize(files); - if (ctx.dryRun) { - if (stashed) restoreStash(ctx); - return done( - "pull", - files.length > 0, - summary, - files, - `${totalChanges(summary)} change(s) would be pulled`, - ); - } + if (ctx.dryRun) { + if (stashed) restoreStash(ctx); + return done( + "pull", + files.length > 0, + summary, + files, + `${totalChanges(summary)} change(s) would be pulled`, + ); + } - const reset = git(["reset", "--hard", `origin/${branch}`], { cwd: root }); - if (!reset.ok) { + const reset = git(["reset", "--hard", `origin/${branch}`], { cwd: root }); + if (!reset.ok) { + if (stashed) restoreStash(ctx); + throw toGitError("Reset", reset); + } if (stashed) restoreStash(ctx); - throw toGitError("Reset", reset); + } finally { + restoreDisabled(root, ctx.disabled, aside); } - if (stashed) restoreStash(ctx); stageOut(createStageContext(root, ctx.settings, reporter, force, ctx.roots)); const sessions = await maybeImportSessions(ctx); @@ -643,6 +799,32 @@ function countRealChanges(root: string, porcelain: string): number { // ── Helpers ───────────────────────────────────────────────────────────────── +/** + * Copy every local file that a move to `ref` would overwrite or delete into + * the recovery folder. Called before a forced pull and before a first link + * adopts the remote, the two moments local bytes are replaced wholesale. + */ +function saveOverwritten(ctx: Context, ref: string, label: string): string | undefined { + const { root } = ctx; + const candidates = new Set(); + const hasHead = git(["rev-parse", "--verify", "HEAD"], { cwd: root }).ok; + if (hasHead) { + const diff = git(["diff", "--name-only", "HEAD", ref], { cwd: root }); + for (const p of diff.ok ? diff.stdout.split("\n") : []) if (p) candidates.add(p); + const dirty = git(["status", "--porcelain", "--", ".", ...excludePathspecs()], { cwd: root }); + for (const p of parsePorcelainPaths(dirty.ok ? dirty.stdout : "")) candidates.add(p); + } else { + const files = git(["ls-tree", "-r", "--name-only", ref], { cwd: root }); + for (const p of files.ok ? files.stdout.split("\n") : []) if (p) candidates.add(p); + } + const entries = [...candidates] + .filter((rel) => !isLocalRuntimePath(rel)) + .map((rel) => ({ source: path.join(root, rel), rel })); + const dir = saveToRecovery(ctx.roots, label, entries); + if (dir) ctx.reporter.detail(`Saved the replaced local files to ${dir}`); + return dir; +} + function countCommits(root: string, range: string): number { const result = git(["rev-list", "--count", range], { cwd: root }); return result.ok ? Number(result.stdout) || 0 : 0; From c4f426a1a2d737ac81ad78183b44decc66ab7e84 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 12:28:31 +0800 Subject: [PATCH 02/10] feat: interactive menu with i18n, autosync engine, OS scheduler definitions, restore, devices, support bundle --- src/cli/app.ts | 226 ++++++++ src/cli/args.ts | 45 ++ src/cli/i18n.ts | 843 +++++++++++++++++++++++++++ src/cli/index.ts | 481 +++++++++++----- src/cli/menu.ts | 1272 +++++++++++++++++++++++++++++++++++++++++ src/cli/render.ts | 9 +- src/cli/ui.ts | 404 +++++++++++++ src/core/autosync.ts | 598 +++++++++++++++++++ src/core/bundle.ts | 100 ++++ src/core/compare.ts | 138 +++++ src/core/history.ts | 119 ++++ src/core/index.ts | 11 + src/core/notify.ts | 78 +++ src/core/repo.ts | 12 + src/core/scheduler.ts | 619 ++++++++++++++++++++ src/core/settings.ts | 9 + src/core/setup.ts | 88 ++- src/core/sync.ts | 28 +- src/core/update.ts | 87 +++ src/plugin/index.ts | 7 +- 20 files changed, 5013 insertions(+), 161 deletions(-) create mode 100644 src/cli/app.ts create mode 100644 src/cli/args.ts create mode 100644 src/cli/i18n.ts create mode 100644 src/cli/menu.ts create mode 100644 src/cli/ui.ts create mode 100644 src/core/autosync.ts create mode 100644 src/core/bundle.ts create mode 100644 src/core/compare.ts create mode 100644 src/core/history.ts create mode 100644 src/core/notify.ts create mode 100644 src/core/scheduler.ts create mode 100644 src/core/update.ts diff --git a/src/cli/app.ts b/src/cli/app.ts new file mode 100644 index 0000000..a6d824d --- /dev/null +++ b/src/cli/app.ts @@ -0,0 +1,226 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type AutosyncDeps, + type AutosyncMessages, + defaultDeps, + readState, +} from "../core/autosync.js"; +import { setChildProxy } from "../core/env.js"; +import { git } from "../core/git.js"; +import { notify } from "../core/notify.js"; +import type { Roots } from "../core/paths.js"; +import { + type InstallContext, + type InstallResult, + type Platform, + type ScheduleSpec, + type SchedulerStatus, + installScheduler, + isElevated, + realRunner, + resolveNode, + schedulerStatus, + uninstallScheduler, +} from "../core/scheduler.js"; +import { type Settings, loadSettings } from "../core/settings.js"; +import { checkForUpdate, installLatest, uninstallGlobal } from "../core/update.js"; +import { type Lang, detectLang, setLang, t } from "./i18n.js"; + +/** Directory of the compiled CLI module (`dist/cli`). */ +export const CLI_DIR = path.dirname(fileURLToPath(import.meta.url)); + +export function homeDir(): string { + return process.env.SYNC_HOME || os.homedir(); +} + +/** Apply the language and proxy from settings (and flags) to this process. */ +export function applyLocalSettings(settings: Settings | undefined, langFlag?: string): Lang { + const lang = detectLang(langFlag, settings?.language); + setLang(lang); + setChildProxy(process.env.OPENCODE_SYNC_PROXY || settings?.proxy || undefined); + return lang; +} + +export function safeLoadSettings(roots: Roots): Settings | undefined { + try { + return loadSettings(roots.config); + } catch { + return undefined; + } +} + +// ── Scheduler wiring ──────────────────────────────────────────────────────── + +/** Environment variables carried into the scheduled job when set at install time. */ +const PASSTHROUGH_ENV = [ + "SYNC_CONFIG_ROOT", + "SYNC_DATA_ROOT", + "SYNC_STATE_ROOT", + "SYNC_AGENTS_ROOT", + "SYNC_CACHE_ROOT", + "SYNC_HOME", + "SYNC_REMOTE_URL", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + "OPENCODE_SYNC_LANG", + "OPENCODE_SYNC_HOST_ALIAS", +]; + +function gitDirectory(): string | undefined { + const probe = + process.platform === "win32" + ? realRunner.run("where.exe", ["git"]) + : realRunner.run("/bin/sh", ["-c", "command -v git"]); + const first = probe.stdout.split(/\r?\n/).find(Boolean)?.trim(); + return first ? path.dirname(first) : undefined; +} + +export function scheduleSpec(roots: Roots, settings: Settings): ScheduleSpec { + const node = resolveNode(); + const env: Record = {}; + for (const key of PASSTHROUGH_ENV) { + const value = process.env[key]; + if (value) env[key] = value; + } + if (process.platform !== "win32") { + // launchd, systemd and cron start with a bare PATH; git and node must be + // reachable from it. + const dirs = [ + path.dirname(node), + gitDirectory(), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + ]; + env.PATH = [...new Set(dirs.filter(Boolean) as string[])].join(":"); + } + return { + node, + cli: path.join(CLI_DIR, "index.js"), + intervalMinutes: settings.autosync.intervalMinutes, + env, + logDir: path.join(roots.config, "logs"), + }; +} + +export interface SchedulerApi { + install: (roots: Roots, settings: Settings) => InstallResult; + uninstall: (roots: Roots) => { removed: boolean }; + status: () => SchedulerStatus; + elevated: () => boolean; +} + +function installContext(roots: Roots, settings: Settings): InstallContext { + return { + platform: process.platform as Platform, + spec: scheduleSpec(roots, settings), + home: os.homedir(), + runner: realRunner, + configRoot: roots.config, + runWhenLoggedOff: settings.autosync.runWhenLoggedOff, + elevated: isElevated(), + }; +} + +export const realScheduler: SchedulerApi = { + install: (roots, settings) => installScheduler(installContext(roots, settings)), + uninstall: (roots) => + uninstallScheduler( + installContext(roots, safeLoadSettings(roots) ?? loadSettings(roots.config)), + ), + status: () => + schedulerStatus({ + platform: process.platform as Platform, + runner: realRunner, + home: os.homedir(), + }), + elevated: () => isElevated(), +}; + +// ── Update wiring ─────────────────────────────────────────────────────────── + +export interface UpdateApi { + check: () => ReturnType; + install: () => void; + uninstall: () => void; +} + +export const realUpdater: UpdateApi = { + check: () => checkForUpdate(path.resolve(CLI_DIR, "..", "core")), + install: () => installLatest(), + uninstall: () => uninstallGlobal(), +}; + +// ── Autosync wiring ───────────────────────────────────────────────────────── + +export function localizedMessages(): AutosyncMessages { + return { + backoffTitle: t("ntBackoffTitle"), + backoffBody: (failures, minutes) => t("ntBackoffBody", { n: failures, m: minutes }), + recoveredTitle: t("ntRecoveredTitle"), + recoveredBody: t("ntRecoveredBody"), + largeTitle: t("ntLargeTitle"), + largeBody: t("ntLargeBody"), + }; +} + +export function autosyncDeps(roots: Roots, updater: UpdateApi = realUpdater): AutosyncDeps { + return defaultDeps(roots, { + home: homeDir(), + notify: (title, body) => notify(title, body), + messages: localizedMessages(), + selfUpdate: () => { + const check = updater.check(); + if (check.error) return `check failed: ${check.error}`; + if (!check.newer) return `up to date (${check.current})`; + updater.install(); + return `upgraded ${check.current} -> ${check.latest}`; + }, + }); +} + +// ── Display helpers ───────────────────────────────────────────────────────── + +/** ISO date of the newest commit in the sync repository, if any. */ +export function lastCommitTime(roots: Roots): number | undefined { + if (!fs.existsSync(path.join(roots.config, ".git"))) return undefined; + const log = git(["log", "-1", "--format=%cI"], { cwd: roots.config }); + const time = log.ok ? Date.parse(log.stdout) : Number.NaN; + return Number.isFinite(time) ? time : undefined; +} + +/** Best estimate of the next scheduled run, from the last run and the interval. */ +export function nextRunEstimate( + roots: Roots, + settings: Settings, + now = Date.now(), +): number | undefined { + const state = readState(roots); + if (!state.lastRunAt) return undefined; + const next = state.lastRunAt + settings.autosync.intervalMinutes * 60000; + return next > now ? next : undefined; +} + +export function formatTime(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Tomorrow at 08:00 local time. */ +export function tomorrowMorning(now = Date.now()): number { + const d = new Date(now); + d.setDate(d.getDate() + 1); + d.setHours(8, 0, 0, 0); + return d.getTime(); +} + +export function isValidProxy(value: string): boolean { + return /^(https?|socks5h?|socks4):\/\/[^\s]+$/i.test(value.trim()); +} diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..1ab31db --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,45 @@ +/** Flags that take a value, so `--repo o/r` works as well as `--repo=o/r`. */ +export const VALUE_FLAGS = new Set([ + "mode", + "repo", + "interval", + "lang", + "proxy", + "limit", + "prefer", + "trigger", +]); + +export interface Args { + command: string; + positional: string[]; + flags: Set; + values: Map; +} + +export function parseArgs(argv: string[]): Args { + const positional: string[] = []; + const flags = new Set(); + const values = new Map(); + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (!arg.startsWith("-") || arg === "-") { + positional.push(arg); + continue; + } + const clean = arg.replace(/^--?/, ""); + const eq = clean.indexOf("="); + if (eq !== -1) { + values.set(clean.slice(0, eq), clean.slice(eq + 1)); + } else if (VALUE_FLAGS.has(clean) && i + 1 < argv.length && !argv[i + 1]!.startsWith("--")) { + values.set(clean, argv[++i]!); + } else { + flags.add(clean); + } + } + + let command = positional.shift() ?? "menu"; + if (command === "menu" && (flags.has("help") || flags.has("h"))) command = "help"; + return { command, positional, flags, values }; +} diff --git a/src/cli/i18n.ts b/src/cli/i18n.ts new file mode 100644 index 0000000..f13cada --- /dev/null +++ b/src/cli/i18n.ts @@ -0,0 +1,843 @@ +/** + * User-visible strings, English and Simplified Chinese. + * + * The Chinese catalogue is typed against the English keys, so a missing or + * extra key fails the build; a test also asserts the key sets are identical. + * Placeholders are `{name}` and are filled by `t()`. + */ + +const en = { + // common + back: "Back", + quit: "Quit", + cancelled: "Cancelled.", + saved: "Saved.", + on: "on", + off: "off", + never: "never", + none: "none", + unknown: "unknown", + pressKey: "Press any key to continue", + invalidValue: "Not a valid value: {value}", + recommended: "recommended", + failed: "Failed: {error}", + justNow: "just now", + minutesAgo: "{n} min ago", + hoursAgo: "{n} h ago", + daysAgo: "{n} d ago", + inMinutes: "in about {n} min", + soon: "soon", + + // header + hdrRepo: "repository", + hdrMachine: "this machine", + hdrLastSync: "last sync", + hdrAutosync: "autosync", + hdrAutosyncOn: "on · every {n} min · next {next}", + hdrAutosyncPaused: "paused until {time}", + hdrLastFailure: "last failure", + hdrFailures: "{n} in a row · {error}", + + // first run + firstTitle: "This machine is not linked to a sync repository yet.", + firstInstallOnly: "Just install the command (set up later)", + firstLink: "Link a repository", + firstPrimary: "Link and make this the primary device with autosync", + installOnlyDone: "The command is installed. Run `opencode-sync` whenever you are ready.", + linkTitle: "Which repository?", + linkCreate: "Create a new private repository on GitHub", + linkExisting: "Use an existing repository", + linkAskName: "Repository name or owner/name", + linkAskExisting: "owner/name, or a git URL", + linkFound: "Your private repositories that look like a sync repository:", + linkOther: "Something else", + linkDone: "Linked {repo}", + linkNeedGh: + "Creating a repository needs the GitHub CLI (gh), signed in. You can also paste a git URL.", + resolveTitle: "This machine's files differ from the repository in {n} place(s).", + resolveRepo: "Use the repository's version (replaced files go to recovery)", + resolveLocal: "Keep this machine's version and push it", + resolveDiff: "Show the differences first", + resolveEmpty: + "The repository is empty, so this machine's configuration is pushed as the first version.", + resolveSame: "This machine already matches the repository.", + diffOnlyHere: "only here", + diffDifferent: "different", + diffOnlyRepo: "only in the repository", + diffMore: "… and {n} more", + recoverySaved: "Replaced files were saved to {dir}", + + // main menu + menuSync: "Sync now", + menuWhat: "What to sync", + menuAutosync: "Autosync", + menuUpdate: "Update", + menuOverrides: "This machine's overrides", + menuDiagnostics: "Diagnostics", + menuDanger: "Danger zone", + menuUninstall: "Uninstall", + menuProxy: "Network proxy", + menuRestore: "Restore a file from history", + menuDevices: "Devices", + menuLanguage: "Language", + menuGuard: "Large-change guard", + + // sync + syncTitle: "Sync", + syncBoth: "Push and receive", + syncReceive: "Receive only", + syncPush: "Push only", + syncPreview: "Preview (change nothing)", + previewNothing: "Nothing would change.", + previewOutgoing: "Would push {n} change(s):", + previewIncoming: "Would receive {n} change(s):", + previewConfirm: "Go ahead?", + restartHint: "Restart OpenCode for the new configuration to take effect.", + largeTitle: "This push is unusually large:", + largeFiles: "{n} new files (limit {limit})", + largeBytes: "{size} in total (limit {limit})", + largeFile: "{path} is {size} (limit {limit})", + largeConfirm: "Push it anyway?", + largeRefused: "Held back by the large-change guard. Nothing was pushed.", + + // what to sync + whatTitle: "What to sync", + whatCategories: "Choose what to sync", + whatSessions: "Session settings", + whatExtra: "Extra paths", + catConfig: "Main config (opencode.json)", + catInstructions: "Instruction files (*.md)", + catAgents: "Agents", + catCommands: "Commands", + catPlugins: "Plugins", + catSkills: "Skills (~/.agents/skills)", + catCredentials: "Credentials (auth.json)", + catSessions: "Sessions", + checkboxKeys: "Space toggles · Enter saves · Esc cancels", + checkboxLines: "Type numbers to toggle, an empty line saves, q cancels", + credPublic: "Refused: credentials are only synced to a private repository.", + credUnverified: "Could not confirm that the repository is private.", + credConfirm: "Sync credentials anyway?", + sessionsWarn: "Only use this with a private repository: conversations are private data.", + sessionsDays: "Sessions from the last N days", + sessionsMax: "At most N sessions per push", + sessionsMb: "Skip any session above N MB", + sessionsNeedNode: "Session sync needs Node 22.5 or newer.", + extraTitle: "Extra paths (relative to your home folder)", + extraAdd: "Add a path", + extraRemove: "Remove {path}", + extraAsk: "Path to add", + extraEmpty: "No extra paths yet.", + + // autosync + autoTitle: "Autosync", + autoTurnOn: "Make this the primary device (turn autosync on)", + autoTurnOff: "Turn autosync off on this machine", + autoInterval: "Interval: every {n} min", + autoLoggedOff: "Run while signed out: {state}", + autoPause: "Pause", + autoNotifications: "Notifications: {state}", + autoRunNow: "Run once now", + autoReset: "Clear the failure counter", + autoOn: "Autosync is on: every {n} min.", + autoOff: "Autosync is off on this machine.", + autoInstallFailed: "Could not register the scheduled job: {error}", + autoWarning: "Note: {text}", + intervalAsk: "Minutes between runs (minimum 2)", + intervalCustom: "Custom", + intervalMinutes: "{n} min", + loggedOffExplain: + "Windows only registers a job that runs while you are signed out from an administrator terminal. Without that it runs while you are signed in.", + loggedOffNeedAdmin: + "This terminal is not elevated. The setting is saved and applies the next time autosync is installed from an administrator terminal.", + notWindows: "This setting only applies on Windows.", + pause1h: "1 hour", + pause4h: "4 hours", + pauseTomorrow: "Until tomorrow morning", + pauseResume: "Resume now", + pausedUntil: "Paused until {time}.", + resumed: "Resumed.", + resetDone: "Cleared {n} failure(s).", + + // update + updTitle: "Update", + updCheck: "Check for a newer version", + updUpgrade: "Upgrade now", + updAuto: "Update automatically once a day: {state}", + updVersions: "Installed {current} · latest {latest}", + updNewer: "A newer version is available.", + updLatest: "You have the latest version.", + updFailed: "Could not reach npm: {error}", + updDone: "Upgraded. Restart OpenCode and reopen this menu.", + updRunning: "Upgrading through npm…", + + // overrides + ovTitle: "This machine's overrides", + ovOpen: "Open in an editor", + ovShow: "Show what the overrides change", + ovEditorFailed: "Could not start an editor. The file is: {file}", + ovNone: "No overrides: this machine uses the shared configuration as it is.", + ovFile: "File: {file}", + ovRemoved: "(removed)", + + // diagnostics + diagTitle: "Diagnostics", + diagGit: "git", + diagNode: "node", + diagGh: "gh", + diagNetwork: "repository access", + diagScheduler: "scheduled job", + diagSettings: "settings file", + diagLog: "Last log lines", + diagOk: "ok", + diagMissing: "not found", + diagFail: "failed: {error}", + diagSessionsNode: "below 22.5, session sync unavailable", + diagRegistered: "registered ({kind})", + diagNotRegistered: "not registered", + diagIgnored: "excluded from the repository", + diagNotIgnored: "NOT excluded from the repository", + diagBundle: "Export a support bundle", + bundleWritten: "Support bundle written: {file}", + + // danger + dangerTitle: "Danger zone", + dangerPull: "Force pull: make this machine match the repository", + dangerPush: "Force push: make the repository match this machine", + dangerWarnPull: + "Local changes that are not in the repository are replaced. Replaced files are saved to recovery first.", + dangerWarnPush: "Changes from other machines that this one has not received are overwritten.", + dangerType: "Type", + + // uninstall + unTitle: "Uninstall", + unSchedulerRemoved: "Removed the scheduled job.", + unSchedulerNone: "No scheduled job was registered.", + unUnlink: "Also unlink this machine from the repository?", + unUnlinked: "Unlinked. No configuration file was touched.", + unNpm: "Also remove the opencode-sync command (npm uninstall -g)?", + unNpmDone: "Removed the command.", + unKeep: "Your configuration files are never deleted.", + + // proxy + proxyTitle: "Network proxy", + proxyCurrent: "Current proxy: {proxy}", + proxyAsk: "Proxy URL, e.g. http://127.0.0.1:7890 (empty clears it)", + proxySet: "Saved. git, gh and npm started by opencode-sync now use it.", + proxyCleared: "Proxy cleared.", + proxyInvalid: "Not an http(s) or socks URL: {value}", + + // restore + rsAsk: "Part of the file name (empty lists everything)", + rsNoMatch: "No synced file matches.", + rsPickFile: "Which file?", + rsPickVersion: "Which version?", + rsNoHistory: "This file has no history yet.", + rsConfirm: "Restore {file} to the version from {date}?", + rsDone: "Restored {file}.", + rsPushNow: "Push this change now?", + + // devices + devTitle: "Devices", + devRow: "last sync {when} · {n} sync(s)", + devThis: "this machine", + devNone: "No sync history yet.", + devRename: "Rename this machine", + devAsk: "New name for this machine", + devRenamed: "This machine is now called {alias}.", + + // language + langTitle: "Language", + langAuto: "Automatic (follow the system)", + langSet: "Language saved.", + + // guard + guardTitle: "Large-change guard", + guardFiles: "New files per push: {n}", + guardBytes: "Total size per push: {size}", + guardFile: "Single file: {size}", + guardSkip: "Never copied: {dirs}", + guardAskNumber: "New limit", + guardAskMb: "New limit in MB", + guardAskSkip: "Directory names, comma separated", + + // command line + cliUnknown: "Unknown command: {cmd}", + cliHelpHint: "Run `opencode-sync help` to see everything.", + cliNoRepo: "No sync repository configured.", + cliRunInit: "Run `opencode-sync` to set one up.", + cliFoundRepos: "Private repositories that look like a match: {list}", + cliLinkHint: "Link one with: opencode-sync link ", + cliNotInitialised: "The local repository is not initialised yet. Run `opencode-sync pull`.", + cliForceTitle: "Force {action}?", + cliForcePushWarn: "Any change made on another machine and not pulled here will be overwritten.", + cliForcePullWarn: "Uncommitted local changes are replaced; replaced files go to recovery.", + cliTypeYes: "Type yes to continue", + cliPushTitle: "push to GitHub", + cliPullTitle: "pull from GitHub", + cliPreparingPush: "Preparing push", + cliPreparingPull: "Preparing pull", + cliDryRun: "{message} (dry run)", + cliSessionsLine: "Sessions: {text}", + cliExported: "{n} exported", + cliImported: "{n} imported", + cliSkipped: "{n} skipped", + cliNeedTty: + "This needs an interactive terminal; pass --yes to accept, or run it from a terminal.", + + stTitle: "status", + stRepository: "repository", + stBranch: "branch", + stMachine: "machine", + stState: "state", + stInSync: "in sync", + stToPush: "{n} to push", + stToPull: "{n} to pull", + stUncommitted: "uncommitted", + stClean: "clean", + stFiles: "{n} file(s)", + stOverrides: "overrides", + stActive: "active", + stSessions: "sessions", + stSessionsOn: "enabled · {n} shard(s)", + stDisabled: "disabled", + stLastCommit: "Last commit", + + initTitle: "create a sync repository", + linkTitleCli: "link an existing repository", + initCreated: "Created {repo}", + initLinked: "Linked {repo}", + initSettings: "settings {file}", + initOverrides: "overrides {file}", + initNext: "Next", + initRestart: "Restart OpenCode", + + sesTitle: "sessions", + sesNoSqlite: "No SQLite driver available. Use Node 22.5+ or run under Bun.", + sesNone: "No sessions found.", + sesUntitled: "(untitled)", + sesPinHint: "Pin a session with: opencode-sync sessions include ", + sesUsage: "Usage: opencode-sync sessions {sub} ", + sesPinned: "Pinned {id}", + sesExcluded: "Excluded {id}", + sesEnabled: "Session sync enabled", + sesDisabled: "Session sync disabled", + sesWindow: "Window: {days} day(s), up to {max} sessions", + sesCap: "Per-session cap: {size}", + sesUnknown: "Unknown sessions subcommand: {sub}", + + ovCliTitle: "per-machine overrides", + ovCreated: "Created the overrides file", + ovCliInfo: "Anything in this file stays on this machine and is re-applied after every pull.", + cfgTitle: "settings", + cfgMissing: "No settings file yet. Run `opencode-sync`.", + + asTitle: "autosync", + asInstalled: "Scheduled job registered ({kind}).", + asUninstalled: "Scheduled job removed.", + asNotInstalled: "No scheduled job was registered.", + asEnabled: "enabled", + asInterval: "interval", + asScheduler: "scheduled job", + asLastRun: "last run", + asLastSuccess: "last success", + asLastError: "last error", + asFailures: "failures in a row", + asNextAttempt: "next attempt", + asPaused: "paused until", + asOutcome: "last outcome", + asUsage: + "Usage: opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume", + asPauseUsage: "Usage: opencode-sync autosync pause |tomorrow", + asRunSkipped: "Skipped: {reason}", + asRunDone: "{action}: {message}", + + setupNeedRepo: "--repo is required with --mode {mode}.", + setupCliOnly: "opencode-sync is installed. Run `opencode-sync` to open the menu.", + setupDone: "Setup complete.", + + helpTitle: "sync OpenCode across machines", + helpSetup: "Setup", + helpSync: "Sync", + helpSessions: "Sessions", + helpAutosync: "Autosync", + helpLocal: "Machine-local", + helpFlags: "Flags", + helpMenu: "Open the interactive menu (also: no arguments)", + helpInit: "Create a private sync repository", + helpLink: "Use an existing sync repository", + helpSetupCmd: "Non-interactive setup used by the installers", + helpPush: "Send this machine's configuration", + helpPull: "Apply the shared configuration", + helpStatus: "Show what is in sync and what is not", + helpSesList: "Show recent sessions and their ids", + helpSesToggle: "Turn selective session sync on or off", + helpSesInclude: "Always sync a session, ignoring the time window", + helpSesExclude: "Never sync a session", + helpAsInstall: "Register or remove the scheduled job", + helpAsToggle: "Turn autosync on or off for this machine", + helpAsStatus: "Show autosync state and the last error", + helpAsRun: "Run one scheduled pass now", + helpAsPause: "Pause or resume autosync", + helpAsReset: "Clear the failure counter", + helpOverrides: "Open the per-machine configuration patch", + helpConfig: "Show the current settings", + helpForce: "Overwrite the other side on conflict", + helpDryRun: "Report what would change without doing it", + helpLang: "Interface language: en or zh-CN", + helpAllowLarge: "Push even when the large-change guard objects", + + // notifications from the scheduled job + ntBackoffTitle: "OpenCode sync is failing", + ntBackoffBody: + "{n} attempts in a row failed. Retrying every {m} min. Details: opencode-sync autosync status", + ntRecoveredTitle: "OpenCode sync recovered", + ntRecoveredBody: "Automatic sync is working again.", + ntLargeTitle: "OpenCode sync needs you", + ntLargeBody: "A very large change is waiting. Open opencode-sync to review it.", +}; + +export type MessageKey = keyof typeof en; +type Catalog = Record; + +const zhCN: Catalog = { + back: "返回", + quit: "退出", + cancelled: "已取消。", + saved: "已保存。", + on: "开", + off: "关", + never: "从未", + none: "无", + unknown: "未知", + pressKey: "按任意键继续", + invalidValue: "无效的值:{value}", + recommended: "推荐", + failed: "失败:{error}", + justNow: "刚刚", + minutesAgo: "{n} 分钟前", + hoursAgo: "{n} 小时前", + daysAgo: "{n} 天前", + inMinutes: "约 {n} 分钟后", + soon: "很快", + + hdrRepo: "仓库", + hdrMachine: "本机", + hdrLastSync: "上次同步", + hdrAutosync: "自动同步", + hdrAutosyncOn: "已开启 · 每 {n} 分钟 · 下次 {next}", + hdrAutosyncPaused: "暂停到 {time}", + hdrLastFailure: "上次失败", + hdrFailures: "连续 {n} 次 · {error}", + + firstTitle: "这台电脑还没有关联同步仓库。", + firstInstallOnly: "只装好命令,以后再设置", + firstLink: "关联一个仓库", + firstPrimary: "关联仓库,并把本机设为自动同步的主力设备", + installOnlyDone: "命令已经装好。准备好了随时运行 `opencode-sync`。", + linkTitle: "用哪个仓库?", + linkCreate: "在 GitHub 上新建一个私有仓库", + linkExisting: "用已有的仓库", + linkAskName: "仓库名,或 用户名/仓库名", + linkAskExisting: "用户名/仓库名,或者 git 地址", + linkFound: "你的私有仓库里,像是同步仓库的有:", + linkOther: "都不是,我自己输入", + linkDone: "已关联 {repo}", + linkNeedGh: "新建仓库需要已登录的 GitHub CLI(gh)。也可以直接粘贴 git 地址。", + resolveTitle: "本机文件和仓库有 {n} 处不一样。", + resolveRepo: "以仓库为准(被替换的文件先放进恢复目录)", + resolveLocal: "以本机为准,推送上去", + resolveDiff: "先看看哪里不一样", + resolveEmpty: "仓库是空的,本机的配置会作为第一个版本推上去。", + resolveSame: "本机和仓库已经一致。", + diffOnlyHere: "只在本机", + diffDifferent: "内容不同", + diffOnlyRepo: "只在仓库", + diffMore: "……还有 {n} 个", + recoverySaved: "被替换的文件已保存到 {dir}", + + menuSync: "立即同步", + menuWhat: "同步哪些内容", + menuAutosync: "自动同步", + menuUpdate: "更新", + menuOverrides: "本机专属设置", + menuDiagnostics: "诊断", + menuDanger: "危险操作", + menuUninstall: "卸载", + menuProxy: "网络代理", + menuRestore: "从历史版本恢复文件", + menuDevices: "设备列表", + menuLanguage: "界面语言", + menuGuard: "大改动保护", + + syncTitle: "同步", + syncBoth: "推送并接收", + syncReceive: "只接收", + syncPush: "只推送", + syncPreview: "预览(不做任何改动)", + previewNothing: "没有需要同步的改动。", + previewOutgoing: "将推送 {n} 处改动:", + previewIncoming: "将接收 {n} 处改动:", + previewConfirm: "继续吗?", + restartHint: "重启 OpenCode 后新配置才会生效。", + largeTitle: "这次推送的改动特别大:", + largeFiles: "新增 {n} 个文件(上限 {limit})", + largeBytes: "总共 {size}(上限 {limit})", + largeFile: "{path} 有 {size}(上限 {limit})", + largeConfirm: "仍然推送吗?", + largeRefused: "被大改动保护拦下,什么都没推送。", + + whatTitle: "同步哪些内容", + whatCategories: "勾选要同步的内容", + whatSessions: "会话同步设置", + whatExtra: "额外路径", + catConfig: "主配置(opencode.json)", + catInstructions: "指令文件(*.md)", + catAgents: "智能体(agents)", + catCommands: "自定义命令(commands)", + catPlugins: "插件(plugins)", + catSkills: "技能(~/.agents/skills)", + catCredentials: "登录凭据(auth.json)", + catSessions: "会话记录", + checkboxKeys: "空格勾选 · 回车保存 · Esc 取消", + checkboxLines: "输入编号切换勾选,直接回车保存,q 取消", + credPublic: "已拒绝:登录凭据只能同步到私有仓库。", + credUnverified: "没法确认这个仓库是私有的。", + credConfirm: "仍然同步登录凭据吗?", + sessionsWarn: "只在私有仓库里开启:对话内容属于隐私。", + sessionsDays: "同步最近几天的会话", + sessionsMax: "每次最多推送几个会话", + sessionsMb: "单个会话超过多少 MB 就跳过", + sessionsNeedNode: "会话同步需要 Node 22.5 或更新版本。", + extraTitle: "额外路径(相对于用户主目录)", + extraAdd: "添加路径", + extraRemove: "移除 {path}", + extraAsk: "要添加的路径", + extraEmpty: "还没有额外路径。", + + autoTitle: "自动同步", + autoTurnOn: "设为主力设备(开启自动同步)", + autoTurnOff: "在本机关闭自动同步", + autoInterval: "间隔:每 {n} 分钟", + autoLoggedOff: "注销后也运行:{state}", + autoPause: "暂停", + autoNotifications: "桌面通知:{state}", + autoRunNow: "马上跑一次", + autoReset: "清零失败次数", + autoOn: "自动同步已开启:每 {n} 分钟一次。", + autoOff: "本机的自动同步已关闭。", + autoInstallFailed: "定时任务注册失败:{error}", + autoWarning: "提示:{text}", + intervalAsk: "每隔几分钟运行一次(最少 2)", + intervalCustom: "自定义", + intervalMinutes: "{n} 分钟", + loggedOffExplain: + "Windows 只有在管理员终端里才能注册“注销后也运行”的任务。否则只在你登录时运行。", + loggedOffNeedAdmin: "当前终端没有管理员权限。设置已保存,下次在管理员终端里安装自动同步时生效。", + notWindows: "这项设置只对 Windows 有效。", + pause1h: "1 小时", + pause4h: "4 小时", + pauseTomorrow: "到明天早上", + pauseResume: "现在恢复", + pausedUntil: "已暂停到 {time}。", + resumed: "已恢复。", + resetDone: "已清零 {n} 次失败。", + + updTitle: "更新", + updCheck: "检查新版本", + updUpgrade: "立即升级", + updAuto: "每天自动更新:{state}", + updVersions: "已安装 {current} · 最新 {latest}", + updNewer: "有新版本可以升级。", + updLatest: "已经是最新版本。", + updFailed: "连不上 npm:{error}", + updDone: "升级完成。请重启 OpenCode,再重新打开这个菜单。", + updRunning: "正在通过 npm 升级……", + + ovTitle: "本机专属设置", + ovOpen: "用编辑器打开", + ovShow: "看看专属设置改了什么", + ovEditorFailed: "打不开编辑器。文件在:{file}", + ovNone: "没有专属设置:本机直接使用共享配置。", + ovFile: "文件:{file}", + ovRemoved: "(已删除)", + + diagTitle: "诊断", + diagGit: "git", + diagNode: "node", + diagGh: "gh", + diagNetwork: "仓库访问", + diagScheduler: "定时任务", + diagSettings: "设置文件", + diagLog: "最近的日志", + diagOk: "正常", + diagMissing: "未找到", + diagFail: "失败:{error}", + diagSessionsNode: "低于 22.5,无法同步会话", + diagRegistered: "已注册({kind})", + diagNotRegistered: "未注册", + diagIgnored: "不会提交到仓库", + diagNotIgnored: "没有排除,会被提交到仓库", + diagBundle: "导出排障包", + bundleWritten: "排障包已生成:{file}", + + dangerTitle: "危险操作", + dangerPull: "强制拉取:让本机和仓库完全一致", + dangerPush: "强制推送:让仓库和本机完全一致", + dangerWarnPull: "本机上仓库里没有的改动会被替换。被替换的文件会先保存到恢复目录。", + dangerWarnPush: "其他电脑推上去、本机还没收到的改动会被覆盖。", + dangerType: "请输入", + + unTitle: "卸载", + unSchedulerRemoved: "已删除定时任务。", + unSchedulerNone: "没有注册过定时任务。", + unUnlink: "同时解除本机和仓库的关联吗?", + unUnlinked: "已解除关联,没有动任何配置文件。", + unNpm: "同时删除 opencode-sync 命令吗(npm uninstall -g)?", + unNpmDone: "命令已删除。", + unKeep: "你的配置文件永远不会被删除。", + + proxyTitle: "网络代理", + proxyCurrent: "当前代理:{proxy}", + proxyAsk: "代理地址,例如 http://127.0.0.1:7890(留空则清除)", + proxySet: "已保存。opencode-sync 调用的 git、gh、npm 都会走这个代理。", + proxyCleared: "已清除代理。", + proxyInvalid: "不是 http(s) 或 socks 地址:{value}", + + rsAsk: "文件名里的关键字(留空列出全部)", + rsNoMatch: "没有匹配的同步文件。", + rsPickFile: "恢复哪个文件?", + rsPickVersion: "恢复到哪个版本?", + rsNoHistory: "这个文件还没有历史版本。", + rsConfirm: "把 {file} 恢复到 {date} 的版本吗?", + rsDone: "已恢复 {file}。", + rsPushNow: "现在就推送这个改动吗?", + + devTitle: "设备列表", + devRow: "上次同步 {when} · 共 {n} 次", + devThis: "本机", + devNone: "还没有同步记录。", + devRename: "给本机改名", + devAsk: "本机的新名字", + devRenamed: "本机现在叫 {alias}。", + + langTitle: "界面语言", + langAuto: "自动(跟随系统)", + langSet: "语言已保存。", + + guardTitle: "大改动保护", + guardFiles: "每次推送的新文件数:{n}", + guardBytes: "每次推送的总大小:{size}", + guardFile: "单个文件大小:{size}", + guardSkip: "永不复制的目录:{dirs}", + guardAskNumber: "新的上限", + guardAskMb: "新的上限(MB)", + guardAskSkip: "目录名,用逗号分隔", + + cliUnknown: "未知命令:{cmd}", + cliHelpHint: "运行 `opencode-sync help` 查看全部命令。", + cliNoRepo: "还没有配置同步仓库。", + cliRunInit: "运行 `opencode-sync` 进行设置。", + cliFoundRepos: "可能匹配的私有仓库:{list}", + cliLinkHint: "关联方法:opencode-sync link <用户名/仓库名>", + cliNotInitialised: "本地仓库还没初始化。请运行 `opencode-sync pull`。", + cliForceTitle: "确定强制{action}吗?", + cliForcePushWarn: "其他电脑上做了、本机还没拉取的改动会被覆盖。", + cliForcePullWarn: "本机未提交的改动会被替换;被替换的文件会放进恢复目录。", + cliTypeYes: "输入 yes 继续", + cliPushTitle: "推送到 GitHub", + cliPullTitle: "从 GitHub 拉取", + cliPreparingPush: "正在准备推送", + cliPreparingPull: "正在准备拉取", + cliDryRun: "{message}(演练,未实际执行)", + cliSessionsLine: "会话:{text}", + cliExported: "导出 {n} 个", + cliImported: "导入 {n} 个", + cliSkipped: "跳过 {n} 个", + cliNeedTty: "这一步需要交互式终端;可以加 --yes 直接同意,或者在终端里运行。", + + stTitle: "状态", + stRepository: "仓库", + stBranch: "分支", + stMachine: "本机", + stState: "同步状态", + stInSync: "已同步", + stToPush: "{n} 个待推送", + stToPull: "{n} 个待拉取", + stUncommitted: "未提交", + stClean: "无", + stFiles: "{n} 个文件", + stOverrides: "专属设置", + stActive: "已启用", + stSessions: "会话", + stSessionsOn: "已开启 · {n} 个分片", + stDisabled: "未开启", + stLastCommit: "最近一次提交", + + initTitle: "新建同步仓库", + linkTitleCli: "关联已有仓库", + initCreated: "已新建 {repo}", + initLinked: "已关联 {repo}", + initSettings: "设置文件 {file}", + initOverrides: "专属设置 {file}", + initNext: "下一步", + initRestart: "重启 OpenCode", + + sesTitle: "会话", + sesNoSqlite: "没有可用的 SQLite 驱动。请使用 Node 22.5+ 或 Bun。", + sesNone: "没有找到会话。", + sesUntitled: "(无标题)", + sesPinHint: "固定同步某个会话:opencode-sync sessions include ", + sesUsage: "用法:opencode-sync sessions {sub} <会话 id>", + sesPinned: "已固定 {id}", + sesExcluded: "已排除 {id}", + sesEnabled: "会话同步已开启", + sesDisabled: "会话同步已关闭", + sesWindow: "范围:最近 {days} 天,最多 {max} 个会话", + sesCap: "单个会话上限:{size}", + sesUnknown: "未知的 sessions 子命令:{sub}", + + ovCliTitle: "本机专属设置", + ovCreated: "已创建专属设置文件", + ovCliInfo: "这个文件里的内容只留在本机,每次拉取后都会重新套用。", + cfgTitle: "设置", + cfgMissing: "还没有设置文件。请运行 `opencode-sync`。", + + asTitle: "自动同步", + asInstalled: "定时任务已注册({kind})。", + asUninstalled: "定时任务已删除。", + asNotInstalled: "没有注册过定时任务。", + asEnabled: "已开启", + asInterval: "间隔", + asScheduler: "定时任务", + asLastRun: "上次运行", + asLastSuccess: "上次成功", + asLastError: "最后的错误", + asFailures: "连续失败", + asNextAttempt: "下次尝试", + asPaused: "暂停到", + asOutcome: "上次结果", + asUsage: + "用法:opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume", + asPauseUsage: "用法:opencode-sync autosync pause <小时数>|tomorrow", + asRunSkipped: "跳过:{reason}", + asRunDone: "{action}:{message}", + + setupNeedRepo: "--mode {mode} 需要同时提供 --repo。", + setupCliOnly: "opencode-sync 已安装。运行 `opencode-sync` 打开菜单。", + setupDone: "设置完成。", + + helpTitle: "在多台电脑之间同步 OpenCode", + helpSetup: "设置", + helpSync: "同步", + helpSessions: "会话", + helpAutosync: "自动同步", + helpLocal: "仅限本机", + helpFlags: "参数", + helpMenu: "打开交互菜单(不带参数也可以)", + helpInit: "新建一个私有同步仓库", + helpLink: "使用已有的同步仓库", + helpSetupCmd: "安装脚本使用的非交互设置", + helpPush: "把本机配置推送上去", + helpPull: "把共享配置拉到本机", + helpStatus: "查看哪些已同步、哪些没有", + helpSesList: "列出最近的会话和 id", + helpSesToggle: "开启或关闭会话同步", + helpSesInclude: "始终同步某个会话,不受时间范围限制", + helpSesExclude: "永不同步某个会话", + helpAsInstall: "注册或删除定时任务", + helpAsToggle: "在本机开启或关闭自动同步", + helpAsStatus: "查看自动同步状态和最后的错误", + helpAsRun: "马上执行一次定时同步", + helpAsPause: "暂停或恢复自动同步", + helpAsReset: "清零失败次数", + helpOverrides: "打开本机专属配置文件", + helpConfig: "显示当前设置", + helpForce: "冲突时以本端为准覆盖另一端", + helpDryRun: "只报告会改什么,不实际执行", + helpLang: "界面语言:en 或 zh-CN", + helpAllowLarge: "即使大改动保护反对也照样推送", + + ntBackoffTitle: "OpenCode 配置同步出错", + ntBackoffBody: "已连续失败 {n} 次,现在每 {m} 分钟重试一次。详情:opencode-sync autosync status", + ntRecoveredTitle: "OpenCode 配置同步已恢复", + ntRecoveredBody: "自动同步又正常了。", + ntLargeTitle: "OpenCode 同步需要你确认", + ntLargeBody: "有一个特别大的改动在等待。打开 opencode-sync 看一下。", +}; + +export type Lang = "en" | "zh-CN"; + +export const CATALOGS: Record = { en, "zh-CN": zhCN }; + +/** Map any locale string to a supported language, or undefined. */ +export function normalizeLang(value: string | undefined | null): Lang | undefined { + const v = String(value ?? "") + .trim() + .toLowerCase(); + if (!v || v === "c" || v === "posix" || v === "auto") return undefined; + if (v.startsWith("zh")) return "zh-CN"; + if (v.startsWith("en")) return "en"; + return undefined; +} + +/** + * Resolve the language. + * + * Order: explicit `--lang`, then OPENCODE_SYNC_LANG, then the menu setting, + * then LC_ALL / LC_MESSAGES / LANG, then the runtime's Intl locale. + */ +export function detectLang( + explicit?: string, + setting?: string, + env: NodeJS.ProcessEnv = process.env, + intlLocale: () => string = () => Intl.DateTimeFormat().resolvedOptions().locale, +): Lang { + const candidates = [ + explicit, + env.OPENCODE_SYNC_LANG, + setting, + env.LC_ALL, + env.LC_MESSAGES, + env.LANG, + ]; + for (const c of candidates) { + const lang = normalizeLang(c); + if (lang) return lang; + } + try { + return normalizeLang(intlLocale()) ?? "en"; + } catch { + return "en"; + } +} + +let current: Lang = "en"; + +export function setLang(lang: Lang): void { + current = lang; +} + +export function getLang(): Lang { + return current; +} + +export type Vars = Record; + +export function t(key: MessageKey, vars: Vars = {}, lang: Lang = current): string { + const template = CATALOGS[lang][key] ?? CATALOGS.en[key] ?? key; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in vars ? String(vars[name]) : match, + ); +} + +/** Relative time in the current language. */ +export function ago(timestamp: number | undefined, now = Date.now()): string { + if (!timestamp) return t("never"); + const minutes = Math.round((now - timestamp) / 60000); + if (minutes < 1) return t("justNow"); + if (minutes < 60) return t("minutesAgo", { n: minutes }); + const hours = Math.round(minutes / 60); + if (hours < 48) return t("hoursAgo", { n: hours }); + return t("daysAgo", { n: Math.round(hours / 24) }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 51f184a..03121ec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,15 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; -import readline from "node:readline"; +import { + appendLog, + backoffRemainingMs, + failureCount, + readState, + runAutosync, + writeState, +} from "../core/autosync.js"; +import { LargeChangeError } from "../core/guard.js"; import { withSyncLock } from "../core/lock.js"; import { getDatabasePath, getRoots } from "../core/paths.js"; import { listSessions } from "../core/sessions.js"; @@ -12,13 +20,30 @@ import { saveSettings, settingsPath, } from "../core/settings.js"; -import { SetupError, discoverSyncRepos, initSync, linkSync } from "../core/setup.js"; +import { + SetupError, + discoverSyncRepos, + initSync, + isRepoUrl, + linkByUrl, + linkSync, +} from "../core/setup.js"; import { sqliteAvailable } from "../core/sqlite.js"; import { SyncError, formatBytes, pull, push, status } from "../core/sync.js"; +import { + applyLocalSettings, + autosyncDeps, + formatTime, + realScheduler, + safeLoadSettings, + tomorrowMorning, +} from "./app.js"; +import { type Args, parseArgs } from "./args.js"; +import { ago, t } from "./i18n.js"; +import { type SetupFlags, disableAutosync, enableAutosync, runMenu, runSetup } from "./menu.js"; import { Spinner, banner, - changeList, createCliReporter, detail, fail, @@ -28,57 +53,38 @@ import { ok, outcome, rule, - summaryLine, warn, write, } from "./render.js"; import { glyph, paint, style } from "./theme.js"; +import { Ui } from "./ui.js"; const LOCK_WAIT_MS = 5 * 60 * 1000; -interface Args { - command: string; - positional: string[]; - flags: Set; - values: Map; +function newUi(): Ui { + return new Ui({ + input: process.stdin, + output: process.stdout, + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + }); } -function parseArgs(argv: string[]): Args { - const positional: string[] = []; - const flags = new Set(); - const values = new Map(); - - for (const arg of argv) { - if (!arg.startsWith("-")) { - positional.push(arg); - continue; - } - const clean = arg.replace(/^--?/, ""); - const eq = clean.indexOf("="); - if (eq !== -1) values.set(clean.slice(0, eq), clean.slice(eq + 1)); - else flags.add(clean); +async function confirmForce(action: string, lines: string[], args: Args): Promise { + if (args.flags.has("yes") || args.flags.has("y")) return true; + if (!process.stdin.isTTY) { + fail(t("cliNeedTty")); + return false; } - - return { command: positional.shift() ?? "help", positional, flags, values }; -} - -async function confirm(question: string, lines: string[]): Promise { - if (!process.stdin.isTTY) return false; write(); - write(` ${paint(" FORCE ", style.bold, style.red)} ${paint(question, style.bold, style.text)}`); + write( + ` ${paint(" FORCE ", style.bold, style.red)} ${paint(t("cliForceTitle", { action }), style.bold, style.text)}`, + ); for (const line of lines) write(` ${paint(`${glyph.bullet} ${line}`, style.muted)}`); write(); - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((resolve) => { - rl.question( - ` ${paint("Type", style.muted)} ${paint("yes", style.bold, style.red)} ${paint("to continue: ", style.muted)}`, - resolve, - ); - }); - rl.close(); - write(); - return answer.trim().toLowerCase() === "yes"; + const ui = newUi(); + const answer = await ui.text(t("cliTypeYes")); + ui.close(); + return answer?.trim().toLowerCase() === "yes"; } // ── Commands ──────────────────────────────────────────────────────────────── @@ -86,113 +92,122 @@ async function confirm(question: string, lines: string[]): Promise { async function runSync(action: "push" | "pull", args: Args): Promise { const force = args.flags.has("force") || args.flags.has("f"); const dryRun = args.flags.has("dry-run") || args.flags.has("n"); + const allowLarge = args.flags.has("allow-large"); const roots = getRoots(); - banner(action === "push" ? "push to GitHub" : "pull from GitHub", force ? "FORCE" : undefined); + banner(action === "push" ? t("cliPushTitle") : t("cliPullTitle"), force ? "FORCE" : undefined); if (force && !dryRun) { - const consequences = - action === "push" - ? ["Any change made on another machine and not pulled here will be overwritten."] - : [ - "Uncommitted local changes will be discarded.", - "Skills and state directories will be rewritten from the repository.", - ]; - if (!(await confirm(`Force ${action}?`, consequences))) { - info("Cancelled."); + const consequences = action === "push" ? [t("cliForcePushWarn")] : [t("cliForcePullWarn")]; + if (!(await confirmForce(action, consequences, args))) { + info(t("cancelled")); return; } } - const spinner = new Spinner(action === "push" ? "Preparing push" : "Preparing pull"); + const spinner = new Spinner(action === "push" ? t("cliPreparingPush") : t("cliPreparingPull")); spinner.start(); const reporter = createCliReporter(spinner); try { const result = await withSyncLock(roots.config, { waitMs: LOCK_WAIT_MS }, () => action === "push" - ? push({ force, dryRun, reporter, roots }) + ? push({ force, dryRun, reporter, roots, allowLarge }) : pull({ force, dryRun, reporter, roots }), ); spinner.stop(); if (result.sessions) { const bits: string[] = []; - if (result.sessions.exported !== undefined) bits.push(`${result.sessions.exported} exported`); - if (result.sessions.imported !== undefined) bits.push(`${result.sessions.imported} imported`); - if (result.sessions.skipped) bits.push(`${result.sessions.skipped} skipped`); - if (bits.length > 0) detail(`Sessions: ${bits.join(", ")}`); + if (result.sessions.exported !== undefined) + bits.push(t("cliExported", { n: result.sessions.exported })); + if (result.sessions.imported !== undefined) + bits.push(t("cliImported", { n: result.sessions.imported })); + if (result.sessions.skipped) bits.push(t("cliSkipped", { n: result.sessions.skipped })); + if (bits.length > 0) detail(t("cliSessionsLine", { text: bits.join(", ") })); + } + if (result.guard) { + warn(t("largeTitle")); + detail(`${result.guard.addedFiles} / ${formatBytes(result.guard.totalBytes)}`); } - outcome(dryRun ? `${result.message} (dry run)` : result.message, result.changed); + outcome(dryRun ? t("cliDryRun", { message: result.message }) : result.message, result.changed); if (result.restartRequired && result.changed) { - info("Restart OpenCode for the new configuration to take effect."); + info(t("restartHint")); write(); } + } catch (error) { + if (error instanceof LargeChangeError) { + spinner.stop(); + fail(`${t("largeRefused")}\n${error.message}`); + process.exitCode = 1; + return; + } + throw error; } finally { spinner.stop(); } } function runStatus(): void { - banner("status"); + banner(t("stTitle")); const roots = getRoots(); const state = status({ roots }); if (!state.configured) { - fail("No sync repository configured."); + fail(t("cliNoRepo")); write(); - info("Run `opencode-sync init` to create one."); + info(t("cliRunInit")); const found = discoverSyncRepos(); if (found.length > 0) { write(); - detail( - `Existing private repositories that look like a match: ${found.slice(0, 5).join(", ")}`, - ); - detail("Link one with: opencode-sync link "); + detail(t("cliFoundRepos", { list: found.slice(0, 5).join(", ") })); + detail(t("cliLinkHint")); } write(); return; } - keyValue("repository", paint(state.remote ?? "-", style.text)); - keyValue("branch", paint(state.branch, style.text)); - keyValue("machine", paint(state.machineAlias, style.text)); + keyValue(t("stRepository"), paint(state.remote ?? "-", style.text)); + keyValue(t("stBranch"), paint(state.branch, style.text)); + keyValue(t("stMachine"), paint(state.machineAlias, style.text)); if (!state.initialized) { write(); - warn("The local repository is not initialised yet. Run `opencode-sync pull`."); + warn(t("cliNotInitialised")); write(); return; } const sync = state.ahead === 0 && state.behind === 0 - ? paint("in sync", style.green) + ? paint(t("stInSync"), style.green) : [ - state.ahead > 0 ? paint(`${state.ahead} to push`, style.yellow) : "", - state.behind > 0 ? paint(`${state.behind} to pull`, style.cyan) : "", + state.ahead > 0 ? paint(t("stToPush", { n: state.ahead }), style.yellow) : "", + state.behind > 0 ? paint(t("stToPull", { n: state.behind }), style.cyan) : "", ] .filter(Boolean) .join(paint(" ", style.faint)); - keyValue("state", sync); + keyValue(t("stState"), sync); keyValue( - "uncommitted", - state.dirty === 0 ? paint("clean", style.green) : paint(`${state.dirty} file(s)`, style.yellow), + t("stUncommitted"), + state.dirty === 0 + ? paint(t("stClean"), style.green) + : paint(t("stFiles", { n: state.dirty }), style.yellow), ); keyValue( - "overrides", - state.overridesActive ? paint("active", style.accent) : paint("none", style.muted), + t("stOverrides"), + state.overridesActive ? paint(t("stActive"), style.accent) : paint(t("none"), style.muted), ); keyValue( - "sessions", + t("stSessions"), state.sessionsEnabled - ? paint(`enabled · ${state.sessionShards} shard(s)`, style.text) - : paint("disabled", style.muted), + ? paint(t("stSessionsOn", { n: state.sessionShards }), style.text) + : paint(t("stDisabled"), style.muted), ); if (state.lastCommit) { - heading("Last commit"); + heading(t("stLastCommit")); detail(state.lastCommit); if (state.lastCommitDate) detail(state.lastCommitDate); } @@ -200,7 +215,7 @@ function runStatus(): void { } function runInit(args: Args, mode: "init" | "link"): void { - banner(mode === "init" ? "create a sync repository" : "link an existing repository"); + banner(mode === "init" ? t("initTitle") : t("linkTitleCli")); const reporter = createCliReporter(); const options = { repo: args.positional[0], @@ -209,21 +224,29 @@ function runInit(args: Args, mode: "init" | "link"): void { includeSessions: args.flags.has("sessions"), }; - const result = mode === "init" ? initSync(options) : linkSync(options); + const result = + mode === "link" && isRepoUrl(options.repo) + ? linkByUrl({ ...options, url: options.repo! }) + : mode === "init" + ? initSync(options) + : linkSync(options); + const label = result.target.owner + ? `${result.target.owner}/${result.target.name}` + : result.target.name; ok( result.created - ? `Created ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}` - : `Linked ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}`, + ? t("initCreated", { repo: paint(label, style.bold, style.text) }) + : t("initLinked", { repo: paint(label, style.bold, style.text) }), ); - detail(`settings ${result.settingsFile}`); - detail(`overrides ${result.overridesFile}`); + detail(t("initSettings", { file: result.settingsFile })); + detail(t("initOverrides", { file: result.overridesFile })); - heading("Next"); + heading(t("initNext")); write( ` ${paint("1.", style.faint)} ${mode === "init" ? "opencode-sync push" : "opencode-sync pull"}`, ); - write(` ${paint("2.", style.faint)} Restart OpenCode`); + write(` ${paint("2.", style.faint)} ${t("initRestart")}`); write(); } @@ -233,39 +256,43 @@ async function runSessions(args: Args): Promise { const settings = loadSettings(roots.config); if (sub === "list") { - banner("sessions"); + banner(t("sesTitle")); if (!(await sqliteAvailable())) { - fail("No SQLite driver available. Use Node 22.5+ or run under Bun."); + fail(t("sesNoSqlite")); write(); return; } const limit = Number(args.values.get("limit") ?? 20); const sessions = await listSessions(getDatabasePath(roots), limit); if (sessions.length === 0) { - info("No sessions found."); + info(t("sesNone")); write(); return; } for (const session of sessions) { const when = new Date(session.timeUpdated).toISOString().slice(0, 16).replace("T", " "); - const title = session.title || paint("(untitled)", style.faint); + const title = session.title || paint(t("sesUntitled"), style.faint); write(` ${paint(when, style.faint)} ${title}`); write(` ${paint(" ".repeat(16), style.faint)} ${paint(session.id, style.faint)}`); } write(); - detail(`Pin a session with: opencode-sync sessions include `); + detail(t("sesPinHint")); write(); return; } if (sub === "include" || sub === "exclude") { const id = args.positional[1]; - if (!id) throw new SyncError(`Usage: opencode-sync sessions ${sub} `); + if (!id) throw new SyncError(t("sesUsage", { sub })); const list = sub === "include" ? settings.sessions.include : settings.sessions.exclude; if (!list.includes(id)) list.push(id); saveSettings(settings, roots.config); - banner("sessions"); - ok(`${sub === "include" ? "Pinned" : "Excluded"} ${paint(id, style.text)}`); + banner(t("sesTitle")); + ok( + sub === "include" + ? t("sesPinned", { id: paint(id, style.text) }) + : t("sesExcluded", { id: paint(id, style.text) }), + ); write(); return; } @@ -273,43 +300,41 @@ async function runSessions(args: Args): Promise { if (sub === "enable" || sub === "disable") { settings.sessions.enabled = sub === "enable"; saveSettings(settings, roots.config); - banner("sessions"); - ok(`Session sync ${sub}d`); + banner(t("sesTitle")); + ok(sub === "enable" ? t("sesEnabled") : t("sesDisabled")); if (sub === "enable") { - detail( - `Window: ${settings.sessions.days} day(s), up to ${settings.sessions.maxSessions} sessions`, - ); - detail(`Per-session cap: ${formatBytes(settings.sessions.maxSessionBytes)}`); - warn("Only enable this for a private repository — conversations are private data."); + detail(t("sesWindow", { days: settings.sessions.days, max: settings.sessions.maxSessions })); + detail(t("sesCap", { size: formatBytes(settings.sessions.maxSessionBytes) })); + warn(t("sessionsWarn")); } write(); return; } - throw new SyncError(`Unknown sessions subcommand: ${sub}`); + throw new SyncError(t("sesUnknown", { sub })); } function runOverrides(): void { - banner("per-machine overrides"); + banner(t("ovCliTitle")); const roots = getRoots(); const file = overridesPath(roots.config); if (!fs.existsSync(file)) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, OVERRIDES_TEMPLATE); - ok("Created the overrides file"); + ok(t("ovCreated")); } detail(file); write(); - info("Anything in this file stays on this machine and is re-applied after every pull."); + info(t("ovCliInfo")); write(); } function runConfig(): void { - banner("settings"); + banner(t("cfgTitle")); const roots = getRoots(); const file = settingsPath(roots.config); if (!fs.existsSync(file)) { - fail("No settings file yet. Run `opencode-sync init`."); + fail(t("cfgMissing")); write(); return; } @@ -319,31 +344,190 @@ function runConfig(): void { write(); } -function runHelp(): void { - banner("sync OpenCode across machines"); - - heading("Setup"); - command("init [name|owner/repo]", "Create a private sync repository"); - command("link ", "Use an existing sync repository"); - - heading("Sync"); - command("push", "Send this machine's configuration"); - command("pull", "Apply the shared configuration"); - command("status", "Show what is in sync and what is not"); - - heading("Sessions"); - command("sessions list", "Show recent sessions and their ids"); - command("sessions enable | disable", "Turn selective session sync on or off"); - command("sessions include ", "Always sync a session, ignoring the time window"); - command("sessions exclude ", "Never sync a session"); +async function runAutosyncCommand(args: Args): Promise { + const sub = args.positional[0] ?? "status"; + const roots = getRoots(); + const ctx = { roots, scheduler: realScheduler }; + + switch (sub) { + case "run": { + // Called by the scheduler: no banner, no colour noise, one log line. + const trigger = args.values.get("trigger") ?? "scheduled"; + const outcomeResult = await runAutosync(autosyncDeps(roots), trigger); + write( + outcomeResult.action === "skip" + ? t("asRunSkipped", { reason: outcomeResult.reason }) + : t("asRunDone", { + action: outcomeResult.action, + message: outcomeResult.message ?? outcomeResult.reason, + }), + ); + if (!outcomeResult.ok) process.exitCode = 1; + return; + } + case "install": { + banner(t("asTitle")); + const settings = loadSettings(roots.config); + const result = realScheduler.install(roots, settings); + if (!result.ok) { + fail(t("autoInstallFailed", { error: result.detail.join("; ") })); + process.exitCode = 1; + return; + } + ok(t("asInstalled", { kind: result.kind })); + for (const d of result.detail) detail(d); + for (const w of result.warnings) warn(t("autoWarning", { text: w })); + if (!settings.autosync.enabled) warn(`${t("autoOff")} opencode-sync autosync enable`); + write(); + return; + } + case "uninstall": { + banner(t("asTitle")); + const result = realScheduler.uninstall(roots); + ok(result.removed ? t("asUninstalled") : t("asNotInstalled")); + write(); + return; + } + case "enable": { + banner(t("asTitle")); + const interval = Number(args.values.get("interval")); + if (Number.isFinite(interval) && interval >= 2) { + const s = loadSettings(roots.config); + s.autosync.intervalMinutes = Math.floor(interval); + saveSettings(s, roots.config); + } + if (!(await enableAutosync(ctx))) process.exitCode = 1; + write(); + return; + } + case "disable": + banner(t("asTitle")); + disableAutosync(ctx); + write(); + return; + case "reset": { + banner(t("asTitle")); + const before = failureCount(readState(roots)); + writeState(roots, { + consecutiveFailures: 0, + lastError: null, + failureNotifiedAt: null, + lastAttemptAt: 0, + }); + appendLog(roots, `reset: cleared ${before} failure(s)`); + ok(t("resetDone", { n: before })); + write(); + return; + } + case "pause": + case "resume": { + banner(t("asTitle")); + const s = loadSettings(roots.config); + if (sub === "resume") { + s.autosync.pausedUntil = 0; + saveSettings(s, roots.config); + ok(t("resumed")); + } else { + const arg = args.positional[1] ?? "1"; + const until = arg === "tomorrow" ? tomorrowMorning() : Date.now() + Number(arg) * 3600_000; + if (!Number.isFinite(until) || until <= Date.now()) throw new SyncError(t("asPauseUsage")); + s.autosync.pausedUntil = until; + saveSettings(s, roots.config); + ok(t("pausedUntil", { time: formatTime(until) })); + } + write(); + return; + } + case "status": { + banner(t("asTitle")); + const s = loadSettings(roots.config); + const state = readState(roots); + const sched = realScheduler.status(); + keyValue( + t("asEnabled"), + s.autosync.enabled ? paint(t("on"), style.green) : paint(t("off"), style.muted), + 20, + ); + keyValue(t("asInterval"), t("intervalMinutes", { n: s.autosync.intervalMinutes }), 20); + keyValue( + t("asScheduler"), + sched.installed + ? `${t("diagRegistered", { kind: sched.kind })}${sched.nextRun ? ` · ${sched.nextRun}` : ""}` + : t("diagNotRegistered"), + 20, + ); + if (s.autosync.pausedUntil > Date.now()) + keyValue(t("asPaused"), formatTime(s.autosync.pausedUntil), 20); + keyValue(t("asLastRun"), ago(state.lastRunAt), 20); + keyValue(t("asLastSuccess"), ago(state.lastSuccessAt), 20); + if (state.lastOutcome) keyValue(t("asOutcome"), state.lastOutcome, 20); + const failures = failureCount(state); + if (failures) keyValue(t("asFailures"), String(failures), 20); + const wait = backoffRemainingMs(state, Date.now()); + if (wait > 0) + keyValue(t("asNextAttempt"), t("inMinutes", { n: Math.ceil(wait / 60000) }), 20); + if (state.lastError) keyValue(t("asLastError"), paint(state.lastError, style.red), 20); + write(); + return; + } + default: + throw new SyncError(t("asUsage")); + } +} - heading("Machine-local"); - command("overrides", "Open the per-machine configuration patch"); - command("config", "Show the current settings"); +function setupFlags(args: Args): SetupFlags { + const mode = (args.values.get("mode") ?? "cli") as SetupFlags["mode"]; + if (!["cli", "link", "auto"].includes(mode)) + throw new SetupError(t("invalidValue", { value: mode })); + const interval = args.values.get("interval"); + const prefer = args.values.get("prefer"); + return { + mode, + repo: args.values.get("repo") ?? args.positional[0], + interval: interval ? Number(interval) : undefined, + lang: args.values.get("lang"), + proxy: args.values.get("proxy"), + prefer: prefer === "local" ? "local" : prefer === "repo" ? "repo" : undefined, + }; +} - heading("Flags"); - command("--force", "Overwrite the other side on conflict"); - command("--dry-run", "Report what would change without doing it"); +function runHelp(): void { + banner(t("helpTitle")); + + heading(t("helpSetup")); + command("(no arguments) | menu", t("helpMenu")); + command("init [name|owner/repo]", t("helpInit")); + command("link ", t("helpLink")); + command("setup --mode cli|link|auto", t("helpSetupCmd")); + + heading(t("helpSync")); + command("push", t("helpPush")); + command("pull", t("helpPull")); + command("status", t("helpStatus")); + + heading(t("helpAutosync")); + command("autosync install | uninstall", t("helpAsInstall")); + command("autosync enable | disable", t("helpAsToggle")); + command("autosync status", t("helpAsStatus")); + command("autosync run", t("helpAsRun")); + command("autosync pause | resume", t("helpAsPause")); + command("autosync reset", t("helpAsReset")); + + heading(t("helpSessions")); + command("sessions list", t("helpSesList")); + command("sessions enable | disable", t("helpSesToggle")); + command("sessions include ", t("helpSesInclude")); + command("sessions exclude ", t("helpSesExclude")); + + heading(t("helpLocal")); + command("overrides", t("helpOverrides")); + command("config", t("helpConfig")); + + heading(t("helpFlags")); + command("--force", t("helpForce")); + command("--dry-run", t("helpDryRun")); + command("--allow-large", t("helpAllowLarge")); + command("--lang en|zh-CN", t("helpLang")); write(); rule(); @@ -354,7 +538,7 @@ function runHelp(): void { } function command(name: string, description: string): void { - const padded = name.padEnd(30); + const padded = name.padEnd(32); write(` ${paint(padded, style.cyan)}${paint(description, style.muted)}`); } @@ -362,8 +546,28 @@ function command(name: string, description: string): void { async function main(): Promise { const args = parseArgs(process.argv.slice(2)); + const roots = getRoots(); + applyLocalSettings(safeLoadSettings(roots), args.values.get("lang")); switch (args.command) { + case "menu": { + const ui = newUi(); + try { + await runMenu({ ui, roots }); + } finally { + ui.close(); + } + return; + } + case "setup": { + const ui = newUi(); + try { + await runSetup(setupFlags(args), { ui, roots }); + } finally { + ui.close(); + } + return; + } case "push": case "pull": await runSync(args.command, args); @@ -380,6 +584,9 @@ async function main(): Promise { case "sessions": await runSessions(args); return; + case "autosync": + await runAutosyncCommand(args); + return; case "overrides": runOverrides(); return; @@ -393,9 +600,9 @@ async function main(): Promise { return; default: banner(); - fail(`Unknown command: ${args.command}`); + fail(t("cliUnknown", { cmd: args.command })); write(); - info("Run `opencode-sync help` to see everything."); + info(t("cliHelpHint")); write(); process.exitCode = 1; } diff --git a/src/cli/menu.ts b/src/cli/menu.ts new file mode 100644 index 0000000..fdadd96 --- /dev/null +++ b/src/cli/menu.ts @@ -0,0 +1,1272 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { appendLog, readState, tailLog, writeState } from "../core/autosync.js"; +import { redactValue, writeBundle } from "../core/bundle.js"; +import { compareWithRemote } from "../core/compare.js"; +import { setChildProxy } from "../core/env.js"; +import { git } from "../core/git.js"; +import type { GuardReport } from "../core/guard.js"; +import { fileHistory, listDevices, restoreFile, trackedFiles } from "../core/history.js"; +import { hostAlias, sanitizeAlias } from "../core/host.js"; +import { parseJsonc } from "../core/jsonc.js"; +import { withSyncLock } from "../core/lock.js"; +import { findConfigFile, loadOverrides } from "../core/overrides.js"; +import { type Roots, getRoots } from "../core/paths.js"; +import { listRecovery } from "../core/recovery.js"; +import { CollectingReporter } from "../core/reporter.js"; +import { + MIN_AUTOSYNC_INTERVAL, + OVERRIDES_TEMPLATE, + type Settings, + loadSettings, + overridesPath, + repoBranch, + repoUrl, + saveSettings, + settingsPath, +} from "../core/settings.js"; +import { + SetupError, + discoverSyncRepos, + ghAvailable, + githubTargetFromUrl, + initSync, + isRepoUrl, + linkByUrl, + linkSync, + repoIsPrivate, + repoLabel, + unlinkSync, +} from "../core/setup.js"; +import { sqliteAvailable } from "../core/sqlite.js"; +import { type SyncResult, formatBytes, pull, push } from "../core/sync.js"; +import { + type SchedulerApi, + type UpdateApi, + applyLocalSettings, + formatTime, + isValidProxy, + lastCommitTime, + nextRunEstimate, + realScheduler, + realUpdater, + tomorrowMorning, +} from "./app.js"; +import { type Lang, type MessageKey, ago, getLang, setLang, t } from "./i18n.js"; +import { + banner, + changeList, + createCliReporter, + detail, + fail, + heading, + info, + keyValue, + ok, + outcome, + summaryLine, + warn, + write, +} from "./render.js"; +import { paint, style } from "./theme.js"; +import type { Choice, Ui } from "./ui.js"; + +export interface MenuDeps { + ui: Ui; + roots?: Roots; + scheduler?: SchedulerApi; + updater?: UpdateApi; + /** Opens a file for editing; returns false when no editor could start. */ + openEditor?: (file: string) => boolean; + now?: () => number; + /** Directory the support bundle is written to. */ + cwd?: string; +} + +interface Ctx { + ui: Ui; + roots: Roots; + scheduler: SchedulerApi; + updater: UpdateApi; + openEditor: (file: string) => boolean; + now: () => number; + cwd: string; +} + +type Next = "stay" | "exit"; + +// ── Entry ─────────────────────────────────────────────────────────────────── + +export async function runMenu(deps: MenuDeps): Promise { + const ctx: Ctx = { + ui: deps.ui, + roots: deps.roots ?? getRoots(), + scheduler: deps.scheduler ?? realScheduler, + updater: deps.updater ?? realUpdater, + openEditor: deps.openEditor ?? defaultEditor, + now: deps.now ?? Date.now, + cwd: deps.cwd ?? process.cwd(), + }; + + if (!repoUrl(settings(ctx))) { + const next = await firstRun(ctx); + if (next === "exit") return; + } + + for (;;) { + const s = settings(ctx); + header(ctx, s); + const choice = await ctx.ui.select("", [ + { value: "sync", label: t("menuSync"), key: "1" }, + { value: "what", label: t("menuWhat"), key: "2" }, + { value: "auto", label: t("menuAutosync"), key: "3" }, + { value: "update", label: t("menuUpdate"), key: "4" }, + { value: "overrides", label: t("menuOverrides"), key: "5" }, + { value: "diag", label: t("menuDiagnostics"), key: "6" }, + { value: "danger", label: t("menuDanger"), key: "7" }, + { value: "uninstall", label: t("menuUninstall"), key: "8" }, + { value: "proxy", label: t("menuProxy"), key: "p" }, + { value: "restore", label: t("menuRestore"), key: "r" }, + { value: "devices", label: t("menuDevices"), key: "d" }, + { value: "guard", label: t("menuGuard"), key: "g" }, + { value: "lang", label: t("menuLanguage"), key: "l" }, + { value: "quit", label: t("quit"), key: "q" }, + ]); + if (choice === undefined || choice === "quit") return; + const next = await dispatch(ctx, choice); + if (next === "exit") return; + } +} + +async function dispatch(ctx: Ctx, choice: string): Promise { + try { + switch (choice) { + case "sync": + return await syncMenu(ctx); + case "what": + return await whatMenu(ctx); + case "auto": + return await autosyncMenu(ctx); + case "update": + return await updateMenu(ctx); + case "overrides": + return await overridesMenu(ctx); + case "diag": + return await diagnosticsMenu(ctx); + case "danger": + return await dangerMenu(ctx); + case "uninstall": + return await uninstallMenu(ctx); + case "proxy": + return await proxyMenu(ctx); + case "restore": + return await restoreMenu(ctx); + case "devices": + return await devicesMenu(ctx); + case "guard": + return await guardMenu(ctx); + case "lang": + return await languageMenu(ctx); + } + } catch (error) { + write(); + fail((error as Error).message); + write(); + await ctx.ui.pause(t("pressKey")); + } + return "stay"; +} + +function settings(ctx: Ctx): Settings { + return loadSettings(ctx.roots.config); +} + +function save(ctx: Ctx, mutate: (s: Settings) => void): Settings { + const s = settings(ctx); + mutate(s); + saveSettings(s, ctx.roots.config); + return s; +} + +function onOff(value: boolean): string { + return value ? paint(t("on"), style.green) : paint(t("off"), style.muted); +} + +// ── Header ────────────────────────────────────────────────────────────────── + +function header(ctx: Ctx, s: Settings): void { + banner(); + keyValue(t("hdrRepo"), paint(repoLabel(s) ?? "-", style.text)); + keyValue(t("hdrMachine"), paint(hostAlias(s.machineAlias), style.text)); + const last = lastCommitTime(ctx.roots); + keyValue( + t("hdrLastSync"), + last ? `${formatTime(last)} ${paint(ago(last, ctx.now()), style.faint)}` : t("never"), + ); + + const now = ctx.now(); + let auto = paint(t("off"), style.muted); + if (s.autosync.enabled) { + if (s.autosync.pausedUntil > now) { + auto = paint( + t("hdrAutosyncPaused", { time: formatTime(s.autosync.pausedUntil) }), + style.yellow, + ); + } else { + const next = nextRunEstimate(ctx.roots, s, now); + const nextText = next + ? t("inMinutes", { n: Math.max(1, Math.round((next - now) / 60000)) }) + : t("soon"); + auto = paint( + t("hdrAutosyncOn", { n: s.autosync.intervalMinutes, next: nextText }), + style.green, + ); + } + } + keyValue(t("hdrAutosync"), auto); + + const state = readState(ctx.roots); + if (state.lastError && state.lastFailureAt) { + const failures = state.consecutiveFailures ?? 0; + keyValue( + t("hdrLastFailure"), + paint( + `${ago(state.lastFailureAt, now)} · ${t("hdrFailures", { n: failures, error: truncate(state.lastError, 60) })}`, + style.red, + ), + ); + } + write(); +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +// ── First run ─────────────────────────────────────────────────────────────── + +async function firstRun(ctx: Ctx): Promise { + banner(); + info(t("firstTitle")); + write(); + const mode = await ctx.ui.select("", [ + { value: "cli", label: t("firstInstallOnly"), key: "1" }, + { value: "link", label: t("firstLink"), key: "2" }, + { value: "primary", label: t("firstPrimary"), key: "3", hint: t("recommended") }, + { value: "quit", label: t("quit"), key: "q" }, + ]); + if (mode === undefined || mode === "quit") return "exit"; + if (mode === "cli") { + ok(t("installOnlyDone")); + write(); + return "exit"; + } + const linked = await linkFlow(ctx); + if (!linked) return "exit"; + const resolved = await resolveFirstSync(ctx); + if (!resolved) return "exit"; + if (mode === "primary") await enableAutosync(ctx); + return "stay"; +} + +async function linkFlow(ctx: Ctx): Promise { + const how = await ctx.ui.select(t("linkTitle"), [ + { value: "create", label: t("linkCreate"), key: "1" }, + { value: "existing", label: t("linkExisting"), key: "2" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (how === undefined || how === "back") return false; + const reporter = createCliReporter(); + + if (how === "create") { + if (!ghAvailable()) { + warn(t("linkNeedGh")); + return false; + } + const name = await ctx.ui.text(t("linkAskName"), "my-opencode-config"); + if (name === undefined) return false; + const result = initSync({ repo: name, reporter, roots: ctx.roots }); + ok(t("linkDone", { repo: `${result.target.owner}/${result.target.name}` })); + return true; + } + + let target: string | undefined; + const found = ghAvailable() ? safe(() => discoverSyncRepos(), [] as string[]) : []; + if (found.length > 0) { + const choices: Choice[] = found.slice(0, 9).map((r, i) => ({ + value: r, + label: r, + key: String(i + 1), + })); + choices.push({ value: "", label: t("linkOther"), key: "o" }); + target = await ctx.ui.select(t("linkFound"), choices); + if (target === undefined) return false; + } + if (!target) { + target = await ctx.ui.text(t("linkAskExisting")); + if (!target) return false; + } + const result = isRepoUrl(target) + ? linkByUrl({ url: target, reporter, roots: ctx.roots }) + : linkSync({ repo: target, reporter, roots: ctx.roots }); + ok( + t("linkDone", { + repo: result.target.owner ? `${result.target.owner}/${result.target.name}` : target, + }), + ); + return true; +} + +function safe(fn: () => T, fallback: T): T { + try { + return fn(); + } catch { + return fallback; + } +} + +/** + * The first sync on a newly linked machine. When the repository and the + * machine differ the user picks a side; nothing is replaced silently. + */ +async function resolveFirstSync(ctx: Ctx, prefer?: "repo" | "local"): Promise { + const s = settings(ctx); + const remote = repoUrl(s)!; + if (fs.existsSync(path.join(ctx.roots.config, ".git"))) { + // Already a working copy (re-link): an ordinary sync handles it. + await runSync(ctx, "both"); + return true; + } + const comparison = compareWithRemote(ctx.roots.config, remote, repoBranch(s), s.guard.skipDirs); + if (comparison.empty) { + info(t("resolveEmpty")); + await runSync(ctx, "push"); + return true; + } + if (comparison.differences.length === 0) { + info(t("resolveSame")); + await runSync(ctx, "receive"); + return true; + } + + for (;;) { + let choice: string | undefined = prefer; + if (!choice) { + write(); + warn(t("resolveTitle", { n: comparison.differences.length })); + choice = await ctx.ui.select("", [ + { value: "repo", label: t("resolveRepo"), key: "1" }, + { value: "local", label: t("resolveLocal"), key: "2" }, + { value: "diff", label: t("resolveDiff"), key: "3" }, + { value: "back", label: t("back"), key: "b" }, + ]); + } + if (choice === undefined || choice === "back") return false; + if (choice === "diff") { + const labels: Record = { + onlyLocal: "diffOnlyHere", + different: "diffDifferent", + onlyRemote: "diffOnlyRepo", + }; + for (const d of comparison.differences.slice(0, 40)) { + detail(`${paint(t(labels[d.kind]!).padEnd(12), style.yellow)} ${d.path}`); + } + if (comparison.differences.length > 40) { + detail(t("diffMore", { n: comparison.differences.length - 40 })); + } + continue; + } + if (choice === "repo") { + await runSync(ctx, "receive"); + const latest = listRecovery(ctx.roots)[0]; + if (latest) info(t("recoverySaved", { dir: latest })); + return true; + } + await runSync(ctx, "push", { adopt: "local" }); + return true; + } +} + +// ── Sync ──────────────────────────────────────────────────────────────────── + +async function syncMenu(ctx: Ctx): Promise { + const choice = await ctx.ui.select(t("syncTitle"), [ + { value: "both", label: t("syncBoth"), key: "1" }, + { value: "receive", label: t("syncReceive"), key: "2" }, + { value: "push", label: t("syncPush"), key: "3" }, + { value: "preview", label: t("syncPreview"), key: "4" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "preview") { + const go = await preview(ctx); + if (go) await runSync(ctx, "both"); + } else { + await runSync(ctx, choice as "both" | "receive" | "push"); + } + await ctx.ui.pause(t("pressKey")); + return "stay"; +} + +async function confirmLarge(ctx: Ctx, report: GuardReport): Promise { + const limits = settings(ctx).guard; + write(); + warn(t("largeTitle")); + if (report.reasons.includes("files")) + detail(t("largeFiles", { n: report.addedFiles, limit: limits.maxFiles })); + if (report.reasons.includes("bytes")) { + detail( + t("largeBytes", { + size: formatBytes(report.totalBytes), + limit: formatBytes(limits.maxBytes), + }), + ); + } + for (const f of report.oversized.slice(0, 5)) { + detail( + t("largeFile", { + path: f.path, + size: formatBytes(f.bytes), + limit: formatBytes(limits.maxFileBytes), + }), + ); + } + return ctx.ui.confirm(t("largeConfirm"), false); +} + +async function runSync( + ctx: Ctx, + mode: "both" | "receive" | "push", + extra: { adopt?: "local" | "remote"; force?: boolean } = {}, +): Promise { + const results: SyncResult[] = []; + const reporter = createCliReporter(); + await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, async () => { + if (mode !== "receive") { + try { + results.push( + await push({ + roots: ctx.roots, + reporter, + force: extra.force, + adopt: extra.adopt, + confirmLarge: (r) => confirmLarge(ctx, r), + }), + ); + } catch (error) { + if ((error as Error).name === "LargeChangeError") { + warn(t("largeRefused")); + return; + } + throw error; + } + } + if (mode !== "push") { + results.push( + await pull({ roots: ctx.roots, reporter, force: mode === "receive" && extra.force }), + ); + } + }); + for (const r of results) outcome(r.message, r.changed); + if (results.some((r) => r.restartRequired && r.changed)) info(t("restartHint")); + return results; +} + +async function preview(ctx: Ctx): Promise { + const reporter = new CollectingReporter(); + const out = await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, () => + push({ roots: ctx.roots, reporter, dryRun: true }), + ); + let incoming: SyncResult | undefined; + try { + incoming = await withSyncLock(ctx.roots.config, { waitMs: 60_000 }, () => + pull({ roots: ctx.roots, reporter, dryRun: true }), + ); + } catch { + incoming = undefined; + } + const outFiles = out.files; + const inFiles = incoming?.files ?? []; + if (outFiles.length === 0 && inFiles.length === 0) { + ok(t("previewNothing")); + return false; + } + if (outFiles.length > 0) { + heading(t("previewOutgoing", { n: outFiles.length })); + write(` ${summaryLine(out.summary)}`); + changeList(outFiles); + } + if (inFiles.length > 0 && incoming) { + heading(t("previewIncoming", { n: inFiles.length })); + write(` ${summaryLine(incoming.summary)}`); + changeList(inFiles); + } + write(); + return ctx.ui.confirm(t("previewConfirm"), true); +} + +// ── What to sync ──────────────────────────────────────────────────────────── + +type CatValue = + | "config" + | "instructions" + | "agents" + | "commands" + | "plugins" + | "skills" + | "credentials" + | "sessions"; + +async function whatMenu(ctx: Ctx): Promise { + for (;;) { + const choice = await ctx.ui.select(t("whatTitle"), [ + { value: "cats", label: t("whatCategories"), key: "1" }, + { value: "sessions", label: t("whatSessions"), key: "2" }, + { value: "extra", label: t("whatExtra"), key: "3" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "cats") await categoriesMenu(ctx); + if (choice === "sessions") await sessionSettings(ctx); + if (choice === "extra") await extraPathsMenu(ctx); + } +} + +/** Whether the linked repository is confirmed private, public, or unknown. */ +function repoVisibility(s: Settings): "private" | "public" | "unknown" { + const target = + s.repo.owner && s.repo.name + ? { owner: s.repo.owner, name: s.repo.name } + : s.repo.url + ? githubTargetFromUrl(s.repo.url) + : undefined; + if (!target || !ghAvailable()) return "unknown"; + return repoIsPrivate(target) ? "private" : "public"; +} + +async function categoriesMenu(ctx: Ctx): Promise { + const s = settings(ctx); + const choices: Choice[] = [ + { value: "config", label: t("catConfig") }, + { value: "instructions", label: t("catInstructions") }, + { value: "agents", label: t("catAgents") }, + { value: "commands", label: t("catCommands") }, + { value: "plugins", label: t("catPlugins") }, + { value: "skills", label: t("catSkills") }, + { value: "credentials", label: t("catCredentials") }, + { value: "sessions", label: t("catSessions") }, + ]; + const selected = new Set(); + for (const key of ["config", "instructions", "agents", "commands", "plugins"] as const) { + if (s.categories[key]) selected.add(key); + } + if (s.includeSkills) selected.add("skills"); + if (s.includeCredentials) selected.add("credentials"); + if (s.sessions.enabled) selected.add("sessions"); + + const footer = ctx.ui.io.interactive ? t("checkboxKeys") : t("checkboxLines"); + const result = await ctx.ui.checkbox(t("whatCategories"), choices, selected, footer); + if (!result) { + info(t("cancelled")); + return; + } + + // Credentials and sessions are private data: never to a public repository. + for (const sensitive of ["credentials", "sessions"] as const) { + if (!result.has(sensitive) || selected.has(sensitive)) continue; + const visibility = repoVisibility(s); + if (visibility === "public") { + fail(t("credPublic")); + result.delete(sensitive); + continue; + } + if (visibility === "unknown") { + warn(t("credUnverified")); + if (!(await ctx.ui.confirm(t("credConfirm"), false))) result.delete(sensitive); + } + if (sensitive === "sessions" && result.has("sessions")) { + warn(t("sessionsWarn")); + if (!(await sqliteAvailable())) warn(t("sessionsNeedNode")); + } + } + + save(ctx, (n) => { + for (const key of ["config", "instructions", "agents", "commands", "plugins"] as const) { + n.categories[key] = result.has(key); + } + n.includeSkills = result.has("skills"); + n.includeCredentials = result.has("credentials"); + n.sessions.enabled = result.has("sessions"); + }); + ok(t("saved")); +} + +async function askNumber( + ctx: Ctx, + question: string, + current: number, + min = 1, +): Promise { + const answer = await ctx.ui.text(question, String(current)); + if (answer === undefined) return undefined; + const value = Number(answer); + if (!Number.isFinite(value) || value < min) { + warn(t("invalidValue", { value: answer })); + return undefined; + } + return value; +} + +async function sessionSettings(ctx: Ctx): Promise { + const s = settings(ctx); + warn(t("sessionsWarn")); + const days = await askNumber(ctx, t("sessionsDays"), s.sessions.days); + if (days === undefined) return; + const max = await askNumber(ctx, t("sessionsMax"), s.sessions.maxSessions); + if (max === undefined) return; + const mb = await askNumber( + ctx, + t("sessionsMb"), + Math.round(s.sessions.maxSessionBytes / 1048576), + ); + if (mb === undefined) return; + save(ctx, (n) => { + n.sessions.days = Math.floor(days); + n.sessions.maxSessions = Math.floor(max); + n.sessions.maxSessionBytes = Math.floor(mb * 1048576); + }); + ok(t("saved")); +} + +async function extraPathsMenu(ctx: Ctx): Promise { + for (;;) { + const s = settings(ctx); + if (s.extraPaths.length === 0) detail(t("extraEmpty")); + const choices: Choice[] = [{ value: "+add", label: t("extraAdd"), key: "a" }]; + s.extraPaths.forEach((p, i) => + choices.push({ value: p, label: t("extraRemove", { path: p }), key: String(i + 1) }), + ); + choices.push({ value: "+back", label: t("back"), key: "b" }); + const choice = await ctx.ui.select(t("extraTitle"), choices); + if (choice === undefined || choice === "+back") return; + if (choice === "+add") { + const value = await ctx.ui.text(t("extraAsk")); + if (!value) continue; + save(ctx, (n) => { + if (!n.extraPaths.includes(value)) n.extraPaths.push(value); + }); + ok(t("saved")); + } else { + save(ctx, (n) => { + n.extraPaths = n.extraPaths.filter((p) => p !== choice); + }); + ok(t("saved")); + } + } +} + +// ── Autosync ──────────────────────────────────────────────────────────────── + +export async function enableAutosync(ctx: Pick): Promise { + const s = loadSettings(ctx.roots.config); + s.autosync.enabled = true; + saveSettings(s, ctx.roots.config); + const result = ctx.scheduler.install(ctx.roots, s); + if (!result.ok) { + fail(t("autoInstallFailed", { error: result.detail.join("; ") })); + return false; + } + for (const w of result.warnings) warn(t("autoWarning", { text: w })); + ok(t("autoOn", { n: s.autosync.intervalMinutes })); + return true; +} + +export function disableAutosync(ctx: Pick): void { + const s = loadSettings(ctx.roots.config); + s.autosync.enabled = false; + saveSettings(s, ctx.roots.config); + ctx.scheduler.uninstall(ctx.roots); + ok(t("autoOff")); +} + +async function autosyncMenu(ctx: Ctx): Promise { + for (;;) { + const s = settings(ctx); + const choice = await ctx.ui.select(t("autoTitle"), [ + s.autosync.enabled + ? { value: "off", label: t("autoTurnOff"), key: "1" } + : { value: "on", label: t("autoTurnOn"), key: "1" }, + { value: "interval", label: t("autoInterval", { n: s.autosync.intervalMinutes }), key: "2" }, + { + value: "loggedoff", + label: t("autoLoggedOff", { state: onOff(s.autosync.runWhenLoggedOff) }), + key: "3", + }, + { value: "pause", label: t("autoPause"), key: "4" }, + { + value: "notify", + label: t("autoNotifications", { state: onOff(s.notifications) }), + key: "5", + }, + { value: "reset", label: t("autoReset"), key: "6" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "on") await enableAutosync(ctx); + if (choice === "off") disableAutosync(ctx); + if (choice === "interval") await intervalMenu(ctx); + if (choice === "loggedoff") await loggedOffMenu(ctx); + if (choice === "pause") await pauseMenu(ctx); + if (choice === "notify") { + save(ctx, (n) => { + n.notifications = !n.notifications; + }); + ok(t("saved")); + } + if (choice === "reset") { + const before = readState(ctx.roots).consecutiveFailures ?? 0; + writeState(ctx.roots, { + consecutiveFailures: 0, + lastError: null, + failureNotifiedAt: null, + lastAttemptAt: 0, + }); + ok(t("resetDone", { n: before })); + } + } +} + +async function intervalMenu(ctx: Ctx): Promise { + const presets = [5, 10, 15, 30, 60]; + const choices: Choice[] = presets.map((n, i) => ({ + value: n, + label: t("intervalMinutes", { n }), + key: String(i + 1), + })); + choices.push({ value: -1, label: t("intervalCustom"), key: "c" }); + let minutes = await ctx.ui.select(t("intervalAsk"), choices); + if (minutes === undefined) return; + if (minutes === -1) { + const custom = await askNumber( + ctx, + t("intervalAsk"), + settings(ctx).autosync.intervalMinutes, + MIN_AUTOSYNC_INTERVAL, + ); + if (custom === undefined) return; + minutes = Math.floor(custom); + } + const s = save(ctx, (n) => { + n.autosync.intervalMinutes = minutes!; + }); + if (s.autosync.enabled) await enableAutosync(ctx); + else ok(t("saved")); +} + +async function loggedOffMenu(ctx: Ctx): Promise { + if (process.platform !== "win32") { + info(t("notWindows")); + } + info(t("loggedOffExplain")); + const s = save(ctx, (n) => { + n.autosync.runWhenLoggedOff = !n.autosync.runWhenLoggedOff; + }); + ok(t("autoLoggedOff", { state: onOff(s.autosync.runWhenLoggedOff) })); + if (s.autosync.runWhenLoggedOff && process.platform === "win32" && !ctx.scheduler.elevated()) { + warn(t("loggedOffNeedAdmin")); + return; + } + if (s.autosync.enabled) await enableAutosync(ctx); +} + +async function pauseMenu(ctx: Ctx): Promise { + const now = ctx.now(); + const choice = await ctx.ui.select(t("autoPause"), [ + { value: now + 3600_000, label: t("pause1h"), key: "1" }, + { value: now + 4 * 3600_000, label: t("pause4h"), key: "2" }, + { value: tomorrowMorning(now), label: t("pauseTomorrow"), key: "3" }, + { value: 0, label: t("pauseResume"), key: "4" }, + ]); + if (choice === undefined) return; + save(ctx, (n) => { + n.autosync.pausedUntil = choice; + }); + ok(choice ? t("pausedUntil", { time: formatTime(choice) }) : t("resumed")); +} + +// ── Update ────────────────────────────────────────────────────────────────── + +async function updateMenu(ctx: Ctx): Promise { + for (;;) { + const s = settings(ctx); + const choice = await ctx.ui.select(t("updTitle"), [ + { value: "check", label: t("updCheck"), key: "1" }, + { value: "upgrade", label: t("updUpgrade"), key: "2" }, + { value: "auto", label: t("updAuto", { state: onOff(s.autosync.autoUpdate) }), key: "3" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "check") { + const result = ctx.updater.check(); + if (result.error) fail(t("updFailed", { error: result.error })); + else { + info(t("updVersions", { current: result.current, latest: result.latest ?? "?" })); + ok(result.newer ? t("updNewer") : t("updLatest")); + } + } + if (choice === "upgrade") { + info(t("updRunning")); + ctx.updater.install(); + ok(t("updDone")); + return "exit"; + } + if (choice === "auto") { + save(ctx, (n) => { + n.autosync.autoUpdate = !n.autosync.autoUpdate; + }); + ok(t("saved")); + } + } +} + +// ── Overrides ─────────────────────────────────────────────────────────────── + +function defaultEditor(file: string): boolean { + const editor = + process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad.exe" : ""); + if (editor) { + const result = spawnSync(editor, [file], { + stdio: "inherit", + shell: !path.isAbsolute(editor) && /\s/.test(editor), + }); + return !result.error; + } + const opener = process.platform === "darwin" ? "open" : "xdg-open"; + return !spawnSync(opener, [file], { stdio: "ignore" }).error; +} + +function flatten(value: unknown, prefix = ""): [string, unknown][] { + if (value && typeof value === "object" && !Array.isArray(value)) { + const entries = Object.entries(value as Record); + if (entries.length === 0) return [[prefix, value]]; + return entries.flatMap(([k, v]) => flatten(v, prefix ? `${prefix}.${k}` : k)); + } + return [[prefix, value]]; +} + +function lookup(obj: unknown, dotted: string): unknown { + return dotted + .split(".") + .reduce( + (acc, k) => + acc && typeof acc === "object" ? (acc as Record)[k] : undefined, + obj, + ); +} + +async function overridesMenu(ctx: Ctx): Promise { + const file = overridesPath(ctx.roots.config); + for (;;) { + const choice = await ctx.ui.select(t("ovTitle"), [ + { value: "open", label: t("ovOpen"), key: "1" }, + { value: "show", label: t("ovShow"), key: "2" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "open") { + if (!fs.existsSync(file)) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, OVERRIDES_TEMPLATE); + } + if (!ctx.openEditor(file)) warn(t("ovEditorFailed", { file })); + } + if (choice === "show") showOverrides(ctx, file); + } +} + +function showOverrides(ctx: Ctx, file: string): void { + detail(t("ovFile", { file })); + const overrides = loadOverrides(ctx.roots.config); + if (Object.keys(overrides).length === 0) { + info(t("ovNone")); + return; + } + let shared: unknown; + const configFile = findConfigFile(ctx.roots.config); + if (configFile) { + const rel = path.relative(ctx.roots.config, configFile).split(path.sep).join("/"); + const head = git(["show", `HEAD:${rel}`], { cwd: ctx.roots.config }); + if (head.ok) shared = safe(() => parseJsonc(head.stdout), undefined); + } + for (const [key, value] of flatten(overrides)) { + const before = lookup(shared, key); + const after = + value === null ? paint(t("ovRemoved"), style.red) : paint(JSON.stringify(value), style.green); + write( + ` ${paint(key, style.cyan)} ${paint(JSON.stringify(before) ?? "-", style.faint)} → ${after}`, + ); + } +} + +// ── Diagnostics ───────────────────────────────────────────────────────────── + +function toolVersion(cmd: string, args: string[]): string | undefined { + const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 20_000, windowsHide: true }); + if (result.error || result.status !== 0) return undefined; + return String(result.stdout).split(/\r?\n/)[0]?.trim(); +} + +export function collectDiagnostics(ctx: Pick): [string, string][] { + const rows: [string, string][] = []; + rows.push([t("diagNode"), process.version]); + const major = Number(process.versions.node.split(".")[0]); + const minor = Number(process.versions.node.split(".")[1]); + if (major < 22 || (major === 22 && minor < 5)) rows.push(["", t("diagSessionsNode")]); + rows.push([t("diagGit"), toolVersion("git", ["--version"]) ?? t("diagMissing")]); + rows.push([t("diagGh"), toolVersion("gh", ["--version"]) ?? t("diagMissing")]); + const s = safe(() => loadSettings(ctx.roots.config), undefined); + const remote = s ? repoUrl(s) : undefined; + if (remote) { + const probe = git(["ls-remote", "--heads", remote], { + cwd: ctx.roots.config, + timeoutMs: 60_000, + }); + rows.push([ + t("diagNetwork"), + probe.ok + ? t("diagOk") + : t("diagFail", { error: (probe.stderr.split("\n")[0] ?? "").slice(0, 120) }), + ]); + } + const sched = safe(() => ctx.scheduler.status(), { installed: false, kind: "none" as const }); + rows.push([ + t("diagScheduler"), + sched.installed + ? `${t("diagRegistered", { kind: sched.kind })}${sched.nextRun ? ` · ${sched.nextRun}` : ""}` + : t("diagNotRegistered"), + ]); + if (fs.existsSync(path.join(ctx.roots.config, ".git"))) { + const ignored = git(["check-ignore", "-q", "opencode-sync.jsonc"], { cwd: ctx.roots.config }); + rows.push([t("diagSettings"), ignored.ok ? t("diagIgnored") : t("diagNotIgnored")]); + } + return rows; +} + +async function diagnosticsMenu(ctx: Ctx): Promise { + heading(t("diagTitle")); + for (const [k, v] of collectDiagnostics(ctx)) keyValue(k, v, 20); + heading(t("diagLog")); + for (const line of tailLog(ctx.roots, 15)) detail(line); + write(); + const choice = await ctx.ui.select("", [ + { value: "bundle", label: t("diagBundle"), key: "1" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === "bundle") { + const file = exportBundle(ctx); + ok(t("bundleWritten", { file })); + } + return "stay"; +} + +export function exportBundle(ctx: Pick): string { + const s = safe(() => loadSettings(ctx.roots.config), undefined); + const entries = [ + { + name: "diagnostics.txt", + content: collectDiagnostics(ctx) + .map(([k, v]) => `${k.padEnd(20)} ${v}`) + .concat([ + `platform ${process.platform} ${process.arch}`, + `lang ${getLang()}`, + ]) + .join("\n"), + }, + { name: "settings.json", content: JSON.stringify(redactValue(s ?? {}), null, 2) }, + { + name: "autosync-state.json", + content: JSON.stringify(redactValue(readState(ctx.roots)), null, 2), + }, + { name: "autosync.log", content: tailLog(ctx.roots, 400).join("\n") }, + ]; + const gitLog = git(["log", "-20", "--format=%h %cI %s"], { cwd: ctx.roots.config }); + if (gitLog.ok) entries.push({ name: "git-log.txt", content: gitLog.stdout }); + const status = git(["status", "--short"], { cwd: ctx.roots.config }); + if (status.ok) entries.push({ name: "git-status.txt", content: status.stdout }); + return writeBundle(ctx.cwd, entries); +} + +// ── Danger zone ───────────────────────────────────────────────────────────── + +const CONFIRM_WORD = "overwrite"; + +async function dangerMenu(ctx: Ctx): Promise { + const choice = await ctx.ui.select(t("dangerTitle"), [ + { value: "pull", label: t("dangerPull"), key: "1" }, + { value: "push", label: t("dangerPush"), key: "2" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + warn(choice === "pull" ? t("dangerWarnPull") : t("dangerWarnPush")); + if (!(await ctx.ui.confirmWord(t("dangerType"), CONFIRM_WORD))) { + info(t("cancelled")); + return "stay"; + } + await runSync(ctx, choice === "pull" ? "receive" : "push", { force: true }); + await ctx.ui.pause(t("pressKey")); + return "stay"; +} + +// ── Uninstall ─────────────────────────────────────────────────────────────── + +async function uninstallMenu(ctx: Ctx): Promise { + heading(t("unTitle")); + info(t("unKeep")); + const removed = ctx.scheduler.uninstall(ctx.roots); + ok(removed.removed ? t("unSchedulerRemoved") : t("unSchedulerNone")); + save(ctx, (n) => { + n.autosync.enabled = false; + }); + if (await ctx.ui.confirm(t("unUnlink"), false)) { + unlinkSync(ctx.roots); + ok(t("unUnlinked")); + } + if (await ctx.ui.confirm(t("unNpm"), false)) { + ctx.updater.uninstall(); + ok(t("unNpmDone")); + return "exit"; + } + return repoUrl(settings(ctx)) ? "stay" : "exit"; +} + +// ── Extras ────────────────────────────────────────────────────────────────── + +async function proxyMenu(ctx: Ctx): Promise { + const s = settings(ctx); + info(t("proxyCurrent", { proxy: s.proxy || t("none") })); + const value = await ctx.ui.text(t("proxyAsk")); + if (value === undefined) return "stay"; + if (value && !isValidProxy(value)) { + fail(t("proxyInvalid", { value })); + return "stay"; + } + const saved = save(ctx, (n) => { + n.proxy = value; + }); + applyLocalSettings(saved); + ok(value ? t("proxySet") : t("proxyCleared")); + return "stay"; +} + +async function restoreMenu(ctx: Ctx): Promise { + const filter = await ctx.ui.text(t("rsAsk")); + if (filter === undefined) return "stay"; + const files = trackedFiles(ctx.roots.config, filter).slice(0, 30); + if (files.length === 0) { + warn(t("rsNoMatch")); + return "stay"; + } + const file = await ctx.ui.select( + t("rsPickFile"), + files.map((f, i) => ({ value: f, label: f, key: String(i + 1) })), + ); + if (!file) return "stay"; + const versions = fileHistory(ctx.roots.config, file, 20); + if (versions.length === 0) { + warn(t("rsNoHistory")); + return "stay"; + } + const commit = await ctx.ui.select( + t("rsPickVersion"), + versions.map((v, i) => ({ + value: v.commit, + label: `${formatTime(Date.parse(v.date))} ${v.commit.slice(0, 8)}`, + hint: v.subject, + key: String(i + 1), + })), + ); + if (!commit) return "stay"; + const version = versions.find((v) => v.commit === commit)!; + if ( + !(await ctx.ui.confirm( + t("rsConfirm", { file, date: formatTime(Date.parse(version.date)) }), + false, + )) + ) { + info(t("cancelled")); + return "stay"; + } + const result = restoreFile(ctx.roots, file, commit); + ok(t("rsDone", { file })); + if (result.recovery) info(t("recoverySaved", { dir: result.recovery })); + if (await ctx.ui.confirm(t("rsPushNow"), false)) await runSync(ctx, "push"); + return "stay"; +} + +async function devicesMenu(ctx: Ctx): Promise { + const s = settings(ctx); + const me = hostAlias(s.machineAlias); + const devices = listDevices(ctx.roots.config); + heading(t("devTitle")); + if (devices.length === 0) detail(t("devNone")); + for (const d of devices) { + const when = ago(Date.parse(d.lastSync), ctx.now()); + const tag = d.alias === me ? paint(` (${t("devThis")})`, style.accent) : ""; + write( + ` ${paint(d.alias.padEnd(24), style.text)} ${paint(t("devRow", { when, n: d.commits }), style.muted)}${tag}`, + ); + } + write(); + const choice = await ctx.ui.select("", [ + { value: "rename", label: t("devRename"), key: "1" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === "rename") { + const name = await ctx.ui.text(t("devAsk"), me); + if (!name) return "stay"; + const alias = sanitizeAlias(name); + save(ctx, (n) => { + n.machineAlias = alias; + }); + ok(t("devRenamed", { alias })); + } + return "stay"; +} + +async function guardMenu(ctx: Ctx): Promise { + for (;;) { + const g = settings(ctx).guard; + const choice = await ctx.ui.select(t("guardTitle"), [ + { value: "files", label: t("guardFiles", { n: g.maxFiles }), key: "1" }, + { value: "bytes", label: t("guardBytes", { size: formatBytes(g.maxBytes) }), key: "2" }, + { value: "file", label: t("guardFile", { size: formatBytes(g.maxFileBytes) }), key: "3" }, + { value: "skip", label: t("guardSkip", { dirs: g.skipDirs.join(", ") }), key: "4" }, + { value: "back", label: t("back"), key: "b" }, + ]); + if (choice === undefined || choice === "back") return "stay"; + if (choice === "files") { + const n = await askNumber(ctx, t("guardAskNumber"), g.maxFiles); + if (n !== undefined) { + save(ctx, (s) => { + s.guard.maxFiles = Math.floor(n); + }); + } + } + if (choice === "bytes" || choice === "file") { + const key = choice === "bytes" ? "maxBytes" : "maxFileBytes"; + const n = await askNumber(ctx, t("guardAskMb"), Math.round(g[key] / 1048576)); + if (n !== undefined) { + save(ctx, (s) => { + s.guard[key] = Math.floor(n * 1048576); + }); + } + } + if (choice === "skip") { + const value = await ctx.ui.text(t("guardAskSkip"), g.skipDirs.join(", ")); + if (value !== undefined) { + const dirs = value + .split(",") + .map((d) => d.trim()) + .filter(Boolean); + save(ctx, (s) => { + s.guard.skipDirs = dirs; + }); + } + } + } +} + +async function languageMenu(ctx: Ctx): Promise { + const choice = await ctx.ui.select(t("langTitle"), [ + { value: "", label: t("langAuto"), key: "1" }, + { value: "en", label: "English", key: "2" }, + { value: "zh-CN", label: "简体中文", key: "3" }, + ]); + if (choice === undefined) return "stay"; + const s = save(ctx, (n) => { + n.language = choice; + }); + applyLocalSettings(s); + if (choice) setLang(choice as Lang); + ok(t("langSet")); + return "stay"; +} + +// ── Non-interactive setup (installers) ────────────────────────────────────── + +export interface SetupFlags { + mode: "cli" | "link" | "auto"; + repo?: string; + interval?: number; + lang?: string; + proxy?: string; + prefer?: "repo" | "local"; +} + +/** + * The non-interactive path the installers call with `--mode`. Mirrors the + * first-run menu: link (creating the repository when `gh` can and it does not + * exist), resolve the first sync by `prefer`, and with `auto` turn autosync on. + */ +export async function runSetup( + flags: SetupFlags, + deps: { ui: Ui; roots?: Roots; scheduler?: SchedulerApi; updater?: UpdateApi }, +): Promise { + const ctx: Ctx = { + ui: deps.ui, + roots: deps.roots ?? getRoots(), + scheduler: deps.scheduler ?? realScheduler, + updater: deps.updater ?? realUpdater, + openEditor: defaultEditor, + now: Date.now, + cwd: process.cwd(), + }; + banner(); + // Local-only preferences go into the settings file once it exists (after + // linking); before that they only apply to this process. + const applyFlags = () => { + if (!fs.existsSync(settingsPath(ctx.roots.config))) return; + save(ctx, (s) => { + if (flags.lang) s.language = flags.lang; + if (flags.proxy !== undefined) s.proxy = flags.proxy; + if (flags.interval) { + s.autosync.intervalMinutes = Math.max(MIN_AUTOSYNC_INTERVAL, Math.floor(flags.interval)); + } + }); + }; + const current = safe(() => settings(ctx), undefined); + applyLocalSettings( + current && flags.proxy !== undefined ? { ...current, proxy: flags.proxy } : current, + flags.lang, + ); + if (flags.proxy) setChildProxy(flags.proxy); + + if (flags.mode === "cli") { + applyFlags(); + ok(t("setupCliOnly")); + return; + } + if (!flags.repo) throw new SetupError(t("setupNeedRepo", { mode: flags.mode })); + + const reporter = createCliReporter(); + if (isRepoUrl(flags.repo)) linkByUrl({ url: flags.repo, reporter, roots: ctx.roots }); + else { + try { + linkSync({ repo: flags.repo, reporter, roots: ctx.roots }); + } catch (error) { + if (!/was not found/.test((error as Error).message)) throw error; + initSync({ repo: flags.repo, reporter, roots: ctx.roots }); + } + } + applyFlags(); + ok(t("linkDone", { repo: repoLabel(settings(ctx)) ?? flags.repo })); + + await resolveFirstSync(ctx, flags.prefer ?? "repo"); + if (flags.mode === "auto") { + const enabled = await enableAutosync(ctx); + if (!enabled) process.exitCode = 1; + } + appendLog(ctx.roots, `setup: mode=${flags.mode}`); + ok(t("setupDone")); +} diff --git a/src/cli/render.ts b/src/cli/render.ts index 34d15a1..fea1929 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -3,8 +3,15 @@ import { SPINNER, canAnimate, displayWidth, glyph, paint, style } from "./theme. const INDENT = " "; +/** Where rendered output goes. Swapped by tests that drive the menu. */ +let sink: { write: (text: string) => unknown } = process.stdout; + +export function setOutput(target: { write: (text: string) => unknown }): void { + sink = target; +} + export function write(line = ""): void { - process.stdout.write(`${line}\n`); + sink.write(`${line}\n`); } /** diff --git a/src/cli/ui.ts b/src/cli/ui.ts new file mode 100644 index 0000000..989a24f --- /dev/null +++ b/src/cli/ui.ts @@ -0,0 +1,404 @@ +import readline from "node:readline"; +import type { Readable, Writable } from "node:stream"; +import { displayWidth, paint, stripAnsi, style } from "./theme.js"; + +/** + * Interactive terminal primitives with no dependencies. + * + * Two input modes share one interface: + * + * - raw mode, when the input is a TTY: arrow keys move, space toggles, + * enter confirms, a hotkey jumps straight to an item; + * - line mode otherwise: each prompt prints numbered choices and reads one + * line. This is what runs under `curl | sh`, in CI and in the tests, where + * the input is a pipe. + * + * Every prompt resolves to `undefined` at end of input, so a caller that runs + * out of answers backs out instead of hanging. + */ + +export interface Io { + input: Readable; + output: Writable; + /** Use raw-mode key handling. */ + interactive: boolean; +} + +export interface Choice { + value: T; + label: string; + /** Single character typed to pick this item directly. */ + key?: string; + hint?: string; + disabled?: boolean; +} + +interface Key { + name?: string; + sequence?: string; + ctrl?: boolean; +} + +/** Queued reader over a stream: lines in line mode, keys in raw mode. */ +class InputQueue { + private lines: string[] = []; + private keys: Key[] = []; + private waiters: (() => void)[] = []; + private ended = false; + private buffer = ""; + private rawDepth = 0; + + constructor(private readonly io: Io) { + if (io.interactive) { + readline.emitKeypressEvents(io.input); + io.input.on("keypress", (str: string, key: Key) => { + this.keys.push(key ?? { sequence: str, name: str }); + this.wake(); + }); + } + io.input.on("data", (chunk: Buffer | string) => { + if (io.interactive) return; + this.buffer += chunk.toString(); + let index = this.buffer.indexOf("\n"); + while (index !== -1) { + this.lines.push(this.buffer.slice(0, index).replace(/\r$/, "")); + this.buffer = this.buffer.slice(index + 1); + index = this.buffer.indexOf("\n"); + } + this.wake(); + }); + io.input.on("end", () => { + if (this.buffer) this.lines.push(this.buffer.replace(/\r$/, "")); + this.buffer = ""; + this.ended = true; + this.wake(); + }); + io.input.on("close", () => { + this.ended = true; + this.wake(); + }); + } + + private wake(): void { + const waiters = this.waiters; + this.waiters = []; + for (const w of waiters) w(); + } + + private wait(): Promise { + return new Promise((resolve) => this.waiters.push(resolve)); + } + + async line(): Promise { + for (;;) { + if (this.lines.length > 0) return this.lines.shift(); + if (this.ended) return undefined; + this.io.input.resume?.(); + await this.wait(); + } + } + + async key(): Promise { + for (;;) { + if (this.keys.length > 0) return this.keys.shift(); + if (this.ended) return undefined; + this.io.input.resume?.(); + await this.wait(); + } + } + + setRaw(on: boolean): void { + const input = this.io.input as Readable & { setRawMode?: (mode: boolean) => void }; + if (typeof input.setRawMode !== "function") return; + this.rawDepth += on ? 1 : -1; + input.setRawMode(this.rawDepth > 0); + } + + pause(): void { + this.io.input.pause?.(); + } +} + +export class Ui { + private readonly queue: InputQueue; + private renderedLines = 0; + + constructor(readonly io: Io) { + this.queue = new InputQueue(io); + } + + write(text = ""): void { + this.io.output.write(`${text}\n`); + } + + /** Release the input so the process can exit. */ + close(): void { + this.queue.pause(); + } + + private clearRendered(): void { + if (this.renderedLines > 0) { + this.io.output.write(`\x1b[${this.renderedLines}A\x1b[0J`); + this.renderedLines = 0; + } + } + + private render(lines: string[]): void { + this.clearRendered(); + this.io.output.write(`${lines.join("\n")}\n`); + this.renderedLines = lines.length; + } + + /** Pick one item. Returns `undefined` on Escape, `q` or end of input. */ + async select(title: string, choices: Choice[], initial = 0): Promise { + const enabled = choices.map((c, i) => (c.disabled ? -1 : i)).filter((i) => i >= 0); + if (enabled.length === 0) return undefined; + const keyOf = (c: Choice, i: number) => (c.key ?? String(i + 1)).toLowerCase(); + + if (!this.io.interactive) { + this.write(title ? ` ${paint(title, style.bold, style.text)}` : ""); + choices.forEach((c, i) => { + const tag = `[${keyOf(c, i)}]`.padEnd(5); + const line = ` ${paint(tag, style.cyan)}${c.label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`; + this.write(c.disabled ? paint(stripAnsi(line), style.faint) : line); + }); + for (;;) { + this.io.output.write(` ${paint("›", style.brand)} `); + const answer = await this.queue.line(); + if (answer === undefined) { + this.write(); + return undefined; + } + const text = answer.trim().toLowerCase(); + if (text === "") continue; + const index = choices.findIndex((c, i) => !c.disabled && keyOf(c, i) === text); + if (index >= 0) return choices[index]!.value; + if (text === "q") return undefined; + this.write(` ${paint("?", style.yellow)} ${text}`); + } + } + + let cursor = enabled.includes(initial) ? initial : enabled[0]!; + const draw = () => { + const lines = [title ? ` ${paint(title, style.bold, style.text)}` : ""]; + choices.forEach((c, i) => { + const active = i === cursor; + const pointer = active ? paint("❯", style.brand) : " "; + const tag = paint(`${keyOf(c, i)}`.padEnd(2), style.faint); + const label = c.disabled + ? paint(c.label, style.faint) + : active + ? paint(c.label, style.bold, style.text) + : c.label; + lines.push( + ` ${pointer} ${tag} ${label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`, + ); + }); + this.render(lines); + }; + this.queue.setRaw(true); + try { + draw(); + for (;;) { + const key = await this.queue.key(); + if (!key) return undefined; + if (key.ctrl && key.name === "c") { + this.queue.setRaw(false); + process.exit(130); + } + const pos = enabled.indexOf(cursor); + if (key.name === "up" || key.name === "k") { + cursor = enabled[(pos - 1 + enabled.length) % enabled.length]!; + } else if (key.name === "down" || key.name === "j" || key.name === "tab") { + cursor = enabled[(pos + 1) % enabled.length]!; + } else if (key.name === "return" || key.name === "enter") { + this.renderedLines = 0; + return choices[cursor]!.value; + } else if (key.name === "escape" || key.sequence === "q") { + this.renderedLines = 0; + return undefined; + } else if (key.sequence) { + const typed = key.sequence.toLowerCase(); + const index = choices.findIndex((c, i) => !c.disabled && keyOf(c, i) === typed); + if (index >= 0) { + cursor = index; + draw(); + this.renderedLines = 0; + return choices[index]!.value; + } + } + draw(); + } + } finally { + this.queue.setRaw(false); + } + } + + /** + * Toggle several items. Space toggles, enter saves. In line mode the user + * types the numbers to toggle (space or comma separated) and an empty line + * to save. Returns `undefined` when cancelled. + */ + async checkbox( + title: string, + choices: Choice[], + selected: Set, + footer = "", + ): Promise | undefined> { + const current = new Set(selected); + const mark = (on: boolean) => (on ? paint("[x]", style.green) : paint("[ ]", style.faint)); + + if (!this.io.interactive) { + for (;;) { + this.write(` ${paint(title, style.bold, style.text)}`); + choices.forEach((c, i) => { + this.write( + ` ${paint(String(i + 1).padEnd(3), style.cyan)}${mark(current.has(c.value))} ${c.label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`, + ); + }); + if (footer) this.write(` ${paint(footer, style.faint)}`); + this.io.output.write(` ${paint("›", style.brand)} `); + const answer = await this.queue.line(); + if (answer === undefined) return undefined; + const text = answer.trim().toLowerCase(); + if (text === "" || text === "s") return current; + if (text === "q") return undefined; + for (const token of text.split(/[\s,]+/)) { + const index = Number(token) - 1; + const choice = choices[index]; + if (!choice || choice.disabled) continue; + if (current.has(choice.value)) current.delete(choice.value); + else current.add(choice.value); + } + } + } + + let cursor = 0; + const draw = () => { + const lines = [` ${paint(title, style.bold, style.text)}`]; + choices.forEach((c, i) => { + const pointer = i === cursor ? paint("❯", style.brand) : " "; + const label = c.disabled ? paint(c.label, style.faint) : c.label; + lines.push( + ` ${pointer} ${mark(current.has(c.value))} ${label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`, + ); + }); + if (footer) lines.push(` ${paint(footer, style.faint)}`); + this.render(lines); + }; + this.queue.setRaw(true); + try { + draw(); + for (;;) { + const key = await this.queue.key(); + if (!key) return undefined; + if (key.ctrl && key.name === "c") { + this.queue.setRaw(false); + process.exit(130); + } + if (key.name === "up" || key.name === "k") { + cursor = (cursor - 1 + choices.length) % choices.length; + } else if (key.name === "down" || key.name === "j" || key.name === "tab") { + cursor = (cursor + 1) % choices.length; + } else if (key.name === "space") { + const choice = choices[cursor]!; + if (!choice.disabled) { + if (current.has(choice.value)) current.delete(choice.value); + else current.add(choice.value); + } + } else if (key.name === "return" || key.name === "enter") { + this.renderedLines = 0; + return current; + } else if (key.name === "escape" || key.sequence === "q") { + this.renderedLines = 0; + return undefined; + } + draw(); + } + } finally { + this.queue.setRaw(false); + } + } + + /** Read one line of text. Returns `undefined` at end of input. */ + async text(question: string, initial = ""): Promise { + const suffix = initial ? paint(` (${initial})`, style.faint) : ""; + if (!this.io.interactive) { + this.io.output.write(` ${question}${suffix} ${paint("›", style.brand)} `); + const answer = await this.queue.line(); + if (answer === undefined) { + this.write(); + return undefined; + } + return answer.trim() === "" ? initial : answer.trim(); + } + + this.io.output.write(` ${question}${suffix} ${paint("›", style.brand)} `); + let value = ""; + this.queue.setRaw(true); + try { + for (;;) { + const key = await this.queue.key(); + if (!key) return undefined; + if (key.ctrl && key.name === "c") { + this.queue.setRaw(false); + process.exit(130); + } + if (key.name === "return" || key.name === "enter") { + this.write(); + return value.trim() === "" ? initial : value.trim(); + } + if (key.name === "escape") { + this.write(); + return undefined; + } + if (key.name === "backspace") { + if (value.length > 0) { + const last = [...value].pop() ?? ""; + value = [...value].slice(0, -1).join(""); + const width = displayWidth(last); + this.io.output.write("\b \b".repeat(width)); + } + continue; + } + const seq = key.sequence ?? ""; + const control = seq.length > 0 && (seq.charCodeAt(0) < 0x20 || seq.charCodeAt(0) === 0x7f); + if (seq && !key.ctrl && !control) { + value += seq; + this.io.output.write(seq); + } + } + } finally { + this.queue.setRaw(false); + } + } + + /** Yes/no question. Enter takes the default. */ + async confirm(question: string, defaultYes = false, yesWords = ["y", "yes"]): Promise { + const hint = defaultYes ? "Y/n" : "y/N"; + const answer = await this.text(`${question} ${paint(`[${hint}]`, style.faint)}`); + if (answer === undefined) return false; + const text = answer.trim().toLowerCase(); + if (text === "") return defaultYes; + return yesWords.includes(text); + } + + /** Require the user to type an exact word. */ + async confirmWord(question: string, word: string): Promise { + const answer = await this.text(`${question} ${paint(word, style.bold, style.red)}`); + return answer !== undefined && answer.trim() === word; + } + + /** Wait for enter (or any key) before returning to a menu. */ + async pause(message: string): Promise { + if (!this.io.interactive) return; + this.io.output.write(` ${paint(message, style.faint)}`); + this.queue.setRaw(true); + try { + await this.queue.key(); + } finally { + this.queue.setRaw(false); + this.write(); + } + } +} diff --git a/src/core/autosync.ts b/src/core/autosync.ts new file mode 100644 index 0000000..a806029 --- /dev/null +++ b/src/core/autosync.ts @@ -0,0 +1,598 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { git, parsePorcelainPaths } from "./git.js"; +import { LargeChangeError } from "./guard.js"; +import { hostAlias } from "./host.js"; +import { acquireSyncLock, releaseSyncLock } from "./lock.js"; +import { configMatchesRepo, findConfigFile } from "./overrides.js"; +import type { Roots } from "./paths.js"; +import { excludePathspecs, readStageOutMarkTime } from "./repo.js"; +import { CollectingReporter } from "./reporter.js"; +import { type Settings, loadSettings, repoBranch } from "./settings.js"; +import { PushRaceError, type SyncOptions, type SyncResult, pull, push } from "./sync.js"; + +/** + * Unattended sync, run by the operating system's scheduler. + * + * The rules below exist because an unattended writer is dangerous in ways a + * person at the keyboard is not: + * + * - Opt-in per machine. The flag lives in the local-only settings file, so + * enabling it here can never enable it on another machine. + * - Never write while something else is writing. A sync rewrites the skills + * directory; doing that while an editor or another OpenCode window is + * mid-save can revert the edit. What matters is whether files are moving, + * so the gate is the newest modification time in the synced trees. + * - Receiving is not read-only either: it mirrors the repository out over + * untracked directories. It runs only when this machine has nothing of its + * own in flight — no uncommitted edits, no unpushed commits, and no file in + * the untracked trees newer than the last time they matched the repository. + * - Back off, but never give up. After repeated failures the gap between + * attempts widens to an hour, and the next success resets it. + * - Machines that fire on the same minute collide on every run, so each one + * waits a stable per-machine offset before touching the network. + */ + +export const STATE_FILE = "opencode-sync-autosync.json"; +export const LOG_FILE = "opencode-sync-autosync.log"; +export const LOG_ROTATE_BYTES = 512 * 1024; + +export const MAX_CONSECUTIVE_FAILURES = 3; +export const BACKOFF_BASE_MS = 30 * 60 * 1000; +export const BACKOFF_MAX_MS = 60 * 60 * 1000; +export const STAGGER_MAX_S = 45; +export const JITTER_MAX_MS = 5000; +/** Scheduler start times drift by the stagger; allow that much early. */ +export const INTERVAL_TOLERANCE_MS = 60 * 1000; +export const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000; + +export interface AutosyncState { + lastRunAt?: number; + lastAttemptAt?: number; + lastReceiveAt?: number; + lastSuccessAt?: number; + lastFailureAt?: number; + consecutiveFailures?: number; + lastError?: string | null; + lastOutcome?: string; + failureNotifiedAt?: number | null; + largeChangeNotifiedAt?: number | null; + lastUpdateCheckAt?: number; +} + +// ── State and log files ───────────────────────────────────────────────────── + +export function statePath(roots: Roots): string { + return path.join(roots.state, STATE_FILE); +} + +export function logPath(roots: Roots): string { + return path.join(roots.config, "logs", LOG_FILE); +} + +export function readState(roots: Roots): AutosyncState { + try { + const parsed = JSON.parse(fs.readFileSync(statePath(roots), "utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +export function writeState(roots: Roots, patch: Partial): AutosyncState { + const next = { ...readState(roots), ...patch }; + try { + const file = statePath(roots); + fs.mkdirSync(path.dirname(file), { recursive: true }); + // Write-then-rename, so a concurrent reader never sees half a file and + // silently resets the failure counter. + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); + fs.renameSync(tmp, file); + } catch { + // Best effort: losing bookkeeping costs one extra attempt, nothing more. + } + return next; +} + +export function appendLog(roots: Roots, line: string, now = Date.now()): void { + const file = logPath(roots); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (fs.existsSync(file) && fs.statSync(file).size > LOG_ROTATE_BYTES) { + fs.renameSync(file, `${file}.1`); + } + fs.appendFileSync(file, `[${new Date(now).toISOString()}] ${line}\n`, "utf8"); + } catch { + // Logging must never be what breaks a sync. + } +} + +export function tailLog(roots: Roots, lines = 20): string[] { + try { + return fs.readFileSync(logPath(roots), "utf8").split(/\r?\n/).filter(Boolean).slice(-lines); + } catch { + return []; + } +} + +// ── Pure decision helpers ─────────────────────────────────────────────────── + +/** The failure counter as a sane integer, whatever the file holds. */ +export function failureCount(state: AutosyncState): number { + const value = state.consecutiveFailures; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; +} + +/** Delay before the next attempt once backing off: 30 min doubling to 60. */ +export function backoffDelayMs(failures: number): number { + const extra = Math.max(0, failures - MAX_CONSECUTIVE_FAILURES); + return Math.min(BACKOFF_BASE_MS * 2 ** extra, BACKOFF_MAX_MS); +} + +/** Milliseconds still to wait because of backoff; 0 when free to try. */ +export function backoffRemainingMs(state: AutosyncState, now: number): number { + const failures = failureCount(state); + if (failures < MAX_CONSECUTIVE_FAILURES) return 0; + const last = typeof state.lastFailureAt === "number" ? state.lastFailureAt : 0; + return Math.max(0, backoffDelayMs(failures) - (now - last)); +} + +/** A stable 0–45 s offset per machine plus up to 5 s of noise. */ +export function staggerMs(alias: string, random: () => number): number { + const digest = createHash("sha256").update(alias).digest(); + const offset = (digest.readUInt32BE(0) % (STAGGER_MAX_S + 1)) * 1000; + return offset + Math.floor(random() * JITTER_MAX_MS); +} + +/** Directory names skipped by the settle check: churn that says nothing. */ +export const SETTLE_SKIP_DIRS = new Set([ + ".git", + "logs", + "log", + ".opencode-sync.lock", + "node_modules", + ".venv", + "venv", + "__pycache__", +]); + +/** File names skipped by the settle check. */ +export const SETTLE_SKIP_FILES = new Set([ + "session.json", + "prompt-history.jsonl", + "frecency.jsonl", + "model.json", + "bun.lock", + "package-lock.json", + "opencode-sync.jsonc", + STATE_FILE, + `${STATE_FILE}.tmp`, +]); + +function isNoisyFile(name: string): boolean { + return ( + SETTLE_SKIP_FILES.has(name) || + name.endsWith(".log") || + name.endsWith(".lock") || + name.endsWith(".tmp") + ); +} + +/** Newest modification time below `dirs`, ignoring noise. 0 when nothing found. */ +export function newestWrite(dirs: string[], skipDirs: Iterable = []): number { + const skip = new Set([...SETTLE_SKIP_DIRS, ...skipDirs]); + let newest = 0; + const walk = (dir: string, depth: number): void => { + if (depth > 10) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skip.has(entry.name)) walk(full, depth + 1); + continue; + } + if (isNoisyFile(entry.name)) continue; + try { + const mtime = fs.statSync(full).mtimeMs; + if (mtime > newest) newest = mtime; + } catch { + // Vanished mid-walk. + } + } + }; + for (const dir of dirs) { + try { + if (fs.statSync(dir).isFile()) { + newest = Math.max(newest, fs.statSync(dir).mtimeMs); + continue; + } + } catch { + continue; + } + walk(dir, 0); + } + return newest; +} + +// ── Dependencies ──────────────────────────────────────────────────────────── + +export interface RepoSnapshot { + ok: boolean; + dirty: boolean; + ahead: number; + error?: string; +} + +export interface AutosyncDeps { + roots: Roots; + now: () => number; + sleep: (ms: number) => Promise; + random: () => number; + loadSettings: () => Settings; + push: (options: SyncOptions) => Promise; + pull: (options: SyncOptions) => Promise; + /** Fetch and count incoming commits. */ + fetchBehind: () => { ok: boolean; behind: number; error?: string }; + repoSnapshot: () => RepoSnapshot; + /** Newest write in the trees the settle rule watches. */ + newestWrite: (settings: Settings) => number; + /** Newest write in the untracked trees a receive would overwrite. */ + newestUntrackedWrite: (settings: Settings) => number; + /** When the untracked trees last matched the repository; 0 if unknown. */ + stageOutTime: () => number; + tryLock: () => { release: () => void } | null; + notify: (title: string, body: string) => boolean; + log: (line: string) => void; + /** Daily self-update hook; returns a one-line summary for the log. */ + selfUpdate?: () => string; + messages: AutosyncMessages; +} + +export interface AutosyncMessages { + backoffTitle: string; + backoffBody: (failures: number, minutes: number) => string; + recoveredTitle: string; + recoveredBody: string; + largeTitle: string; + largeBody: string; +} + +export const ENGLISH_MESSAGES: AutosyncMessages = { + backoffTitle: "OpenCode sync is failing", + backoffBody: (failures, minutes) => + `${failures} attempts in a row failed. Retrying every ${minutes} min. Run: opencode-sync autosync status`, + recoveredTitle: "OpenCode sync recovered", + recoveredBody: "Automatic sync is working again.", + largeTitle: "OpenCode sync needs you", + largeBody: "A very large change is waiting. Open opencode-sync to review and confirm it.", +}; + +// ── The run ───────────────────────────────────────────────────────────────── + +export type RunAction = "push" | "receive" | "skip"; + +export interface RunOutcome { + action: RunAction; + ok: boolean; + reason: string; + message?: string; +} + +/** Why a push should not run now, or null when it may. */ +export function pushBlockedReason( + settings: Settings, + state: AutosyncState, + now: number, +): string | null { + if (!settings.autosync.enabled) return "autosync is not enabled on this machine"; + if (settings.autosync.pausedUntil > now) { + return `paused until ${new Date(settings.autosync.pausedUntil).toISOString()}`; + } + const backoff = backoffRemainingMs(state, now); + if (backoff > 0) { + return `backing off after ${failureCount(state)} failures, retry in ${Math.ceil(backoff / 60000)} min`; + } + const intervalMs = Math.max(2, settings.autosync.intervalMinutes) * 60000; + const since = now - (state.lastAttemptAt ?? 0); + if (since < intervalMs - INTERVAL_TOLERANCE_MS) { + return `throttled, ${Math.ceil((intervalMs - since) / 60000)} min to go`; + } + return null; +} + +/** Run one scheduled pass. Never throws. */ +export async function runAutosync(deps: AutosyncDeps, trigger = "scheduled"): Promise { + const now = deps.now(); + let settings: Settings; + try { + settings = deps.loadSettings(); + } catch (error) { + const reason = `cannot read settings: ${(error as Error).message}`; + deps.log(`${trigger}: skipped (${reason})`); + return { action: "skip", ok: false, reason }; + } + let state = writeState(deps.roots, { lastRunAt: now }); + + const blocked = pushBlockedReason(settings, state, now); + if (blocked) { + deps.log(`${trigger}: skipped (${blocked})`); + return { action: "skip", ok: true, reason: blocked }; + } + + const settleMs = Math.max(0, settings.autosync.settleSeconds) * 1000; + const newest = deps.newestWrite(settings); + const busyFor = newest > 0 ? Math.max(0, now - newest) : null; + const busy = busyFor !== null && busyFor < settleMs; + + const alias = hostAlias(settings.machineAlias); + const delay = process.env.OPENCODE_SYNC_NO_STAGGER === "1" ? 0 : staggerMs(alias, deps.random); + + if (busy) { + const why = `files still changing (${Math.round((busyFor ?? 0) / 1000)}s ago)`; + const receiveBlocked = receiveBlockedReason(deps, settings); + if (receiveBlocked) { + deps.log(`${trigger}: skipped (${why}; receive: ${receiveBlocked})`); + return { action: "skip", ok: true, reason: `${why}; ${receiveBlocked}` }; + } + if (delay > 0) await deps.sleep(delay); + return receive(deps, settings, trigger, why); + } + + if (delay > 0) await deps.sleep(delay); + const lock = deps.tryLock(); + if (!lock) { + deps.log(`${trigger}: skipped (another sync is in progress)`); + return { action: "skip", ok: true, reason: "another sync is in progress" }; + } + + state = writeState(deps.roots, { lastAttemptAt: deps.now() }); + try { + const result = await deps.push({ + settings, + roots: deps.roots, + reporter: new CollectingReporter(), + }); + recordSuccess(deps, `push: ${result.message}`); + deps.log(`${trigger}: push ${result.message}`); + maybeSelfUpdate(deps, settings); + return { action: "push", ok: true, reason: "", message: result.message }; + } catch (error) { + recordFailure(deps, settings, error as Error); + deps.log(`${trigger}: push FAILED (${oneLine((error as Error).message)})`); + return { action: "push", ok: false, reason: oneLine((error as Error).message) }; + } finally { + lock.release(); + } +} + +function receiveBlockedReason(deps: AutosyncDeps, settings: Settings): string | null { + const repo = deps.repoSnapshot(); + if (!repo.ok) return `cannot read the repository (${repo.error ?? "unknown"})`; + if (repo.dirty) return "local edits are not committed yet"; + if (repo.ahead > 0) return `${repo.ahead} local commit(s) waiting`; + // Untracked trees (skills, extra paths) cannot be seen by git status. Any + // file there newer than the last write-out is an edit a receive would revert. + const mark = deps.stageOutTime(); + if (mark <= 0) return "no record of the last sync on this machine"; + if (deps.newestUntrackedWrite(settings) > mark) return "skills or extra paths have local edits"; + return null; +} + +async function receive( + deps: AutosyncDeps, + settings: Settings, + trigger: string, + why: string, +): Promise { + const lock = deps.tryLock(); + if (!lock) { + deps.log(`${trigger}: skipped (${why}; another sync is in progress)`); + return { action: "skip", ok: true, reason: "another sync is in progress" }; + } + try { + const fetched = deps.fetchBehind(); + if (!fetched.ok) throw new Error(`fetch failed: ${fetched.error ?? "unknown"}`); + if (fetched.behind === 0) { + deps.log(`${trigger}: skipped (${why}; nothing waiting to receive)`); + return { action: "skip", ok: true, reason: `${why}; nothing to receive` }; + } + writeState(deps.roots, { lastReceiveAt: deps.now() }); + const result = await deps.pull({ + settings, + roots: deps.roots, + reporter: new CollectingReporter(), + }); + recordSuccess(deps, `receive: ${result.message}`); + deps.log(`${trigger}: receive ${result.message} (push held back: ${why})`); + return { action: "receive", ok: true, reason: why, message: result.message }; + } catch (error) { + recordFailure(deps, settings, error as Error); + deps.log(`${trigger}: receive FAILED (${oneLine((error as Error).message)})`); + return { action: "receive", ok: false, reason: oneLine((error as Error).message) }; + } finally { + lock.release(); + } +} + +function oneLine(text: string): string { + return String(text ?? "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .join(" ") + .slice(0, 400); +} + +function recordSuccess(deps: AutosyncDeps, outcome: string): void { + const state = readState(deps.roots); + const patch: Partial = { + consecutiveFailures: 0, + lastSuccessAt: deps.now(), + lastError: null, + lastOutcome: outcome, + largeChangeNotifiedAt: null, + }; + if (state.failureNotifiedAt) { + const settings = safeSettings(deps); + if (settings?.notifications !== false) { + deps.notify(deps.messages.recoveredTitle, deps.messages.recoveredBody); + } + patch.failureNotifiedAt = null; + } + writeState(deps.roots, patch); +} + +function recordFailure(deps: AutosyncDeps, settings: Settings, error: Error): void { + const state = readState(deps.roots); + const lastError = oneLine(error.message); + if (error instanceof PushRaceError || error.name === "PushRaceError") { + // Contention with another machine is not an outage; it must not push this + // machine into the backoff meant for a dead network. + writeState(deps.roots, { lastError, lastOutcome: "lost a push race" }); + return; + } + const failures = failureCount(state) + 1; + const patch: Partial = { + consecutiveFailures: failures, + lastError, + lastFailureAt: deps.now(), + lastOutcome: "failed", + }; + const notifications = settings.notifications !== false; + if ( + (error instanceof LargeChangeError || error.name === "LargeChangeError") && + !state.largeChangeNotifiedAt + ) { + const shown = notifications && deps.notify(deps.messages.largeTitle, deps.messages.largeBody); + patch.largeChangeNotifiedAt = deps.now(); + if (shown) patch.failureNotifiedAt = deps.now(); + } + if ( + failures >= MAX_CONSECUTIVE_FAILURES && + !state.failureNotifiedAt && + !patch.failureNotifiedAt + ) { + const minutes = Math.round(backoffDelayMs(failures) / 60000); + const shown = + notifications && + deps.notify(deps.messages.backoffTitle, deps.messages.backoffBody(failures, minutes)); + // Recorded even when notifications are off, so recovery is announced + // consistently if they are switched back on. + patch.failureNotifiedAt = deps.now(); + deps.log(`backing off after ${failures} failures (notified: ${Boolean(shown)})`); + } + writeState(deps.roots, patch); +} + +function safeSettings(deps: AutosyncDeps): Settings | undefined { + try { + return deps.loadSettings(); + } catch { + return undefined; + } +} + +function maybeSelfUpdate(deps: AutosyncDeps, settings: Settings): void { + if (!settings.autosync.autoUpdate || !deps.selfUpdate) return; + const state = readState(deps.roots); + if (deps.now() - (state.lastUpdateCheckAt ?? 0) < UPDATE_CHECK_MS) return; + writeState(deps.roots, { lastUpdateCheckAt: deps.now() }); + try { + deps.log(`update: ${deps.selfUpdate()}`); + } catch (error) { + deps.log(`update FAILED (${oneLine((error as Error).message)})`); + } +} + +// ── Default wiring ────────────────────────────────────────────────────────── + +/** Directories whose writes mean "someone is working right now". */ +export function watchedTrees(roots: Roots, settings: Settings): string[] { + const dirs = [roots.config]; + if (settings.includeSkills) dirs.push(roots.agents); + return dirs; +} + +/** Untracked trees that a receive writes over. */ +export function untrackedTrees(roots: Roots, settings: Settings, home: string): string[] { + const dirs: string[] = []; + if (settings.includeSkills) dirs.push(roots.agents); + for (const entry of settings.extraPaths) { + dirs.push(path.isAbsolute(entry) ? entry : path.join(home, entry)); + } + return dirs; +} + +export function defaultDeps( + roots: Roots, + overrides: Partial & { home: string }, +): AutosyncDeps { + const settingsLoader = () => loadSettings(roots.config); + return { + roots, + now: () => Date.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + random: Math.random, + loadSettings: settingsLoader, + push: (options) => push(options), + pull: (options) => pull(options), + fetchBehind: () => { + const settings = settingsLoader(); + const branch = repoBranch(settings); + const fetched = git(["fetch", "origin", branch], { cwd: roots.config, timeoutMs: 120_000 }); + if (!fetched.ok) return { ok: false, behind: 0, error: oneLine(fetched.stderr) }; + const count = git(["rev-list", "--count", `HEAD..origin/${branch}`], { cwd: roots.config }); + return { ok: true, behind: Number(count.stdout) || 0 }; + }, + repoSnapshot: () => snapshotRepo(roots.config, repoBranch(settingsLoader())), + newestWrite: (settings) => + newestWrite(watchedTrees(roots, settings), settings.guard?.skipDirs ?? []), + newestUntrackedWrite: (settings) => + newestWrite(untrackedTrees(roots, settings, overrides.home), settings.guard?.skipDirs ?? []), + stageOutTime: () => readStageOutMarkTime(roots.config), + tryLock: () => { + const lock = acquireSyncLock(roots.config, { waitMs: 0 }); + return lock ? { release: () => releaseSyncLock(lock) } : null; + }, + notify: () => false, + log: (line) => appendLog(roots, line), + messages: ENGLISH_MESSAGES, + ...overrides, + }; +} + +/** Local view of the repository: no network. */ +export function snapshotRepo(root: string, branch: string): RepoSnapshot { + const status = git(["status", "--porcelain", "--", ".", ...excludePathspecs()], { cwd: root }); + if (!status.ok) return { ok: false, dirty: false, ahead: 0, error: oneLine(status.stderr) }; + const ahead = git(["rev-list", "--count", `origin/${branch}..HEAD`], { cwd: root }); + return { + ok: true, + dirty: realChanges(root, status.stdout) > 0, + ahead: ahead.ok ? Number(ahead.stdout) || 0 : 0, + }; +} + +/** + * Uncommitted files, not counting the config file when its only difference + * is this machine's overrides — the expected, permanent difference. + */ +function realChanges(root: string, porcelain: string): number { + const paths = parsePorcelainPaths(porcelain); + if (paths.length === 0) return 0; + const configFile = findConfigFile(root); + if (!configFile) return paths.length; + const rel = path.relative(root, configFile).split(path.sep).join("/"); + if (!paths.includes(rel)) return paths.length; + const head = git(["show", `HEAD:${rel}`], { cwd: root }); + if (!configMatchesRepo(root, head.ok ? head.stdout : undefined)) return paths.length; + return paths.length - 1; +} diff --git a/src/core/bundle.ts b/src/core/bundle.ts new file mode 100644 index 0000000..dbcbfc5 --- /dev/null +++ b/src/core/bundle.ts @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; + +/** + * Support bundle: diagnostics, logs and settings with secrets removed, packed + * as a .tar.gz using only the standard library. + */ + +const SECRET_KEY = + /(token|secret|password|passwd|apikey|api_key|api-key|auth|credential|cookie|key)$/i; + +/** Remove anything that looks like a credential from free text. */ +export function redactText(text: string): string { + return ( + String(text) + // user:pass@ and token@ in URLs + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, "$1[REDACTED]@") + // GitHub, npm, OpenAI-style and generic bearer tokens + .replace(/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, "[REDACTED]") + .replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "[REDACTED]") + .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]") + .replace(/(bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]") + // key = value / "key": "value" pairs whose key names a secret + .replace( + /("?[A-Za-z0-9_-]*(?:token|secret|password|passwd|api[_-]?key)[A-Za-z0-9_-]*"?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,}]+)/gi, + '$1"[REDACTED]"', + ) + ); +} + +/** Deep-copy a JSON value with secret-looking keys blanked and strings scrubbed. */ +export function redactValue(value: unknown, key = ""): unknown { + if (typeof value === "string") { + return key && SECRET_KEY.test(key) && value ? "[REDACTED]" : redactText(value); + } + if (Array.isArray(value)) return value.map((v) => redactValue(v)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k); + return out; + } + return value; +} + +// ── Minimal ustar writer ──────────────────────────────────────────────────── + +function octal(value: number, width: number): string { + return `${value.toString(8).padStart(width - 1, "0")}\0`; +} + +function header(name: string, size: number, mtime: number): Buffer { + const buf = Buffer.alloc(512, 0); + const nameBytes = Buffer.from(name, "utf8"); + if (nameBytes.length > 100) throw new Error(`tar entry name too long: ${name}`); + nameBytes.copy(buf, 0); + buf.write(octal(0o644, 8), 100, "ascii"); + buf.write(octal(0, 8), 108, "ascii"); + buf.write(octal(0, 8), 116, "ascii"); + buf.write(octal(size, 12), 124, "ascii"); + buf.write(octal(Math.floor(mtime / 1000), 12), 136, "ascii"); + buf.write(" ", 148, "ascii"); + buf.write("0", 156, "ascii"); + buf.write("ustar\0", 257, "ascii"); + buf.write("00", 263, "ascii"); + let sum = 0; + for (const byte of buf) sum += byte; + buf.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, "ascii"); + return buf; +} + +export interface BundleEntry { + name: string; + content: string | Buffer; +} + +export function tarGz(entries: BundleEntry[], mtime = Date.now()): Buffer { + const parts: Buffer[] = []; + for (const entry of entries) { + const data = Buffer.isBuffer(entry.content) + ? entry.content + : Buffer.from(entry.content, "utf8"); + parts.push(header(entry.name, data.length, mtime), data); + const pad = (512 - (data.length % 512)) % 512; + if (pad) parts.push(Buffer.alloc(pad, 0)); + } + parts.push(Buffer.alloc(1024, 0)); + return gzipSync(Buffer.concat(parts)); +} + +export function writeBundle(dir: string, entries: BundleEntry[], now = new Date()): string { + const stamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); + const file = path.join(dir, `opencode-sync-support-${stamp}.tar.gz`); + const redacted = entries.map((e) => ({ + name: e.name, + content: Buffer.isBuffer(e.content) ? e.content : redactText(e.content), + })); + fs.writeFileSync(file, tarGz(redacted, now.getTime())); + return file; +} diff --git a/src/core/compare.ts b/src/core/compare.ts new file mode 100644 index 0000000..480e7f7 --- /dev/null +++ b/src/core/compare.ts @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { git } from "./git.js"; +import { AGENTS_DIR, DATA_DIR, SESSIONS_DIR, STATE_DIR } from "./paths.js"; +import { OVERRIDES_FILE, SETTINGS_FILE } from "./settings.js"; + +/** + * Compare this machine's config directory with a remote branch without + * touching either: the remote is fetched into a throwaway bare repository and + * local files are hashed in-process the way git would store them. + * + * Used before the first link, so the user can choose which side wins knowing + * what would change. + */ + +export interface FileDifference { + path: string; + kind: "onlyLocal" | "different" | "onlyRemote"; +} + +export interface CompareResult { + /** The remote branch has no commits yet. */ + empty: boolean; + differences: FileDifference[]; +} + +const SKIP_TOP = new Set([ + ".git", + DATA_DIR, + STATE_DIR, + AGENTS_DIR, + SESSIONS_DIR, + "_extra", + SETTINGS_FILE, + OVERRIDES_FILE, + ".opencode-sync.lock", + // Written by the tool itself on the first sync. + ".gitignore", + ".gitattributes", +]); +const SKIP_ANY = new Set(["node_modules", "logs", "log", "__pycache__", ".venv", "venv"]); +const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "bun.lock"]); + +/** The blob id git would assign to this content once normalised to LF. */ +export function gitBlobId(content: Buffer): string { + const binary = content.includes(0); + const data = binary ? content : Buffer.from(content.toString("utf8").replace(/\r\n/g, "\n")); + return createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex"); +} + +function localFiles(root: string, skipDirs: string[]): Map { + const files = new Map(); + const skipAny = new Set([...SKIP_ANY, ...skipDirs]); + const walk = (rel: string, depth: number): void => { + if (depth > 12) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (!rel && SKIP_TOP.has(entry.name)) continue; + if (entry.isDirectory()) { + if (!skipAny.has(entry.name)) walk(childRel, depth + 1); + continue; + } + if (SKIP_FILES.has(entry.name) || entry.name.endsWith(".pyc")) continue; + try { + files.set(childRel, gitBlobId(fs.readFileSync(path.join(root, childRel)))); + } catch { + // Unreadable files cannot be compared; skip them. + } + } + }; + if (fs.existsSync(root)) walk("", 0); + return files; +} + +function remoteFiles(tree: string): Map { + const files = new Map(); + for (const line of tree.split("\n")) { + const match = /^\d+ blob ([0-9a-f]{40})\t(.+)$/.exec(line); + if (!match) continue; + const [, id, rel] = match; + const top = rel!.split("/")[0]!; + if (SKIP_TOP.has(top)) continue; + files.set(rel!.replace(/^"(.*)"$/, "$1"), id!); + } + return files; +} + +export function compareWithRemote( + root: string, + remote: string, + branch: string, + skipDirs: string[] = [], +): CompareResult { + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-compare-")); + try { + git(["init", "--bare", "-q", scratch], { cwd: os.tmpdir() }); + const fetched = git(["fetch", "-q", "--depth=1", remote, branch], { + cwd: scratch, + timeoutMs: 120_000, + }); + if (!fetched.ok) { + if (/couldn't find remote ref|not found|does not appear/i.test(fetched.stderr)) { + return { empty: true, differences: [] }; + } + // An empty repository answers the fetch with a missing ref as well. + const listed = git(["ls-remote", remote], { cwd: scratch, timeoutMs: 60_000 }); + if (listed.ok && listed.stdout.trim() === "") return { empty: true, differences: [] }; + throw new Error(`Cannot read ${remote}: ${fetched.stderr.split("\n")[0]}`); + } + const tree = git(["-c", "core.quotepath=off", "ls-tree", "-r", "FETCH_HEAD"], { + cwd: scratch, + }); + const theirs = remoteFiles(tree.ok ? tree.stdout : ""); + const ours = localFiles(root, skipDirs); + const differences: FileDifference[] = []; + for (const [rel, id] of ours) { + const other = theirs.get(rel); + if (other === undefined) differences.push({ path: rel, kind: "onlyLocal" }); + else if (other !== id) differences.push({ path: rel, kind: "different" }); + } + for (const rel of theirs.keys()) { + if (!ours.has(rel)) differences.push({ path: rel, kind: "onlyRemote" }); + } + differences.sort((a, b) => a.path.localeCompare(b.path)); + return { empty: false, differences }; + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +} diff --git a/src/core/history.ts b/src/core/history.ts new file mode 100644 index 0000000..6dfa6dc --- /dev/null +++ b/src/core/history.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { git } from "./git.js"; +import type { Roots } from "./paths.js"; +import { saveToRecovery } from "./recovery.js"; +import { DEVICE_TRAILER } from "./sync.js"; + +/** + * Read-only views of the sync repository's history: which machines sync, when + * each last did, and earlier versions of a file. + */ + +export interface DeviceInfo { + alias: string; + lastSync: string; + commits: number; +} + +const SUBJECT_ALIAS = / from ([A-Za-z0-9._-]+)\s*$/; + +/** + * Machines seen in the history, newest first. + * + * The alias is taken from the commit trailer written since 3.2.0, falling back + * to the `... from ` subject that every earlier release used. + */ +export function listDevices(root: string, limit = 2000): DeviceInfo[] { + const sep = "\x1f"; + const end = "\x1e"; + const log = git( + [ + "log", + `-n${limit}`, + `--format=%cI${sep}%s${sep}%(trailers:key=${DEVICE_TRAILER},valueonly,separator=%x2C)${end}`, + ], + { cwd: root }, + ); + if (!log.ok) return []; + const seen = new Map(); + for (const record of log.stdout.split(end)) { + const [date, subject, trailer] = record.trim().split(sep); + if (!date) continue; + const alias = (trailer ?? "").trim() || SUBJECT_ALIAS.exec(subject ?? "")?.[1]; + if (!alias) continue; + const existing = seen.get(alias); + if (existing) existing.commits++; + else seen.set(alias, { alias, lastSync: date, commits: 1 }); + } + return [...seen.values()]; +} + +export interface FileVersion { + commit: string; + date: string; + subject: string; +} + +/** Commits that touched a repository-relative file, newest first. */ +export function fileHistory(root: string, rel: string, limit = 30): FileVersion[] { + const sep = "\x1f"; + const log = git(["log", `-n${limit}`, `--format=%H${sep}%cI${sep}%s`, "--", rel], { + cwd: root, + }); + if (!log.ok) return []; + return log.stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const [commit = "", date = "", subject = ""] = line.split(sep); + return { commit, date, subject }; + }); +} + +/** Tracked files, optionally filtered by a case-insensitive substring. */ +export function trackedFiles(root: string, filter = ""): string[] { + const result = git(["ls-files"], { cwd: root }); + if (!result.ok) return []; + const needle = filter.toLowerCase(); + return result.stdout + .split("\n") + .filter(Boolean) + .filter((f) => !needle || f.toLowerCase().includes(needle)); +} + +/** + * Put the content a file had at `commit` back into the worktree. + * + * The current bytes are copied to the recovery folder first, so a restore can + * itself be undone. The file is left as an ordinary local edit; the next push + * shares it. Returns the recovery folder, if anything was saved. + */ +export function restoreFile( + roots: Roots, + rel: string, + commit: string, +): { recovery?: string; target: string } { + const root = roots.config; + const target = path.join(root, rel); + const show = git(["cat-file", "-e", `${commit}:${rel}`], { cwd: root }); + if (!show.ok) throw new Error(`${rel} does not exist in ${commit.slice(0, 8)}`); + const recovery = saveToRecovery(roots, "restore", [{ source: target, rel }]); + + // A string pipe would decode as UTF-8 and corrupt binary files, so the blob + // is read as raw bytes and renamed into place atomically. + const blob = git(["rev-parse", `${commit}:${rel}`], { cwd: root }); + if (!blob.ok) throw new Error(`Cannot read ${rel} at ${commit.slice(0, 8)}`); + const raw = execFileSync("git", ["cat-file", "blob", blob.stdout], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + maxBuffer: 256 * 1024 * 1024, + }); + const tmp = `${target}.restore-${process.pid}`; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(tmp, raw); + fs.renameSync(tmp, target); + return { recovery, target }; +} diff --git a/src/core/index.ts b/src/core/index.ts index d1b7e7d..70b3243 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -13,3 +13,14 @@ export * from "./sqlite.js"; export * from "./stage.js"; export * from "./sync.js"; export * from "./setup.js"; +export * from "./autosync.js"; +export * from "./bundle.js"; +export * from "./categories.js"; +export * from "./env.js"; +export * from "./guard.js"; +export * from "./history.js"; +export * from "./notify.js"; +export * from "./recovery.js"; +export * from "./scheduler.js"; +export * from "./update.js"; +export * from "./compare.js"; diff --git a/src/core/notify.ts b/src/core/notify.ts new file mode 100644 index 0000000..6eeeaf9 --- /dev/null +++ b/src/core/notify.ts @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { windowsPowerShell } from "./scheduler.js"; + +/** + * Desktop notifications from the unattended job. Never throws: a machine that + * cannot show a notification must still sync. + */ + +function xmlText(text: string): string { + return ( + String(text) + // biome-ignore lint/suspicious/noControlCharactersInRegex: XML rejects control characters, so they are stripped + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + ); +} + +/** + * The PowerShell script that shows a Windows toast. Windows PowerShell 5.1 is + * used because it projects the WinRT types implicitly; the script travels as + * base64 UTF-16LE so non-ASCII text never crosses an ANSI code page. + */ +export function windowsToastScript(title: string, body: string): string { + const xml = `${xmlText(title)}${xmlText(body)}`; + // Windows PowerShell's own AppUserModelID is always registered; an + // unregistered id makes Windows drop the toast silently. + const appId = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"; + return [ + "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]", + "[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime]", + "$d = New-Object Windows.Data.Xml.Dom.XmlDocument", + `$d.LoadXml('${xml.replace(/'/g, "''")}')`, + "$t = New-Object Windows.UI.Notifications.ToastNotification $d", + `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}').Show($t)`, + ].join("\n"); +} + +export function appleScriptNotification(title: string, body: string): string { + const flat = (s: string) => String(s).replace(/[\n\r\u2028\u2029]+/g, " · "); + const quote = (s: string) => `"${flat(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + return `display notification ${quote(body)} with title ${quote(title)}`; +} + +export function notify(title: string, body: string, platform = process.platform): boolean { + try { + if (platform === "win32") { + const encoded = Buffer.from(windowsToastScript(title, body), "utf16le").toString("base64"); + execFileSync( + windowsPowerShell(), + ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], + { + stdio: "ignore", + windowsHide: true, + timeout: 20_000, + }, + ); + return true; + } + if (platform === "darwin") { + execFileSync("/usr/bin/osascript", ["-e", appleScriptNotification(title, body)], { + stdio: "ignore", + timeout: 20_000, + }); + return true; + } + execFileSync("notify-send", ["--app-name=opencode-sync", title, body], { + stdio: "ignore", + timeout: 20_000, + }); + return true; + } catch { + return false; + } +} diff --git a/src/core/repo.ts b/src/core/repo.ts index 89d5e41..56b5005 100644 --- a/src/core/repo.ts +++ b/src/core/repo.ts @@ -252,6 +252,18 @@ export function readStageOutMark(root: string): string | undefined { } } +/** + * When the real directories last matched the repository, in epoch ms; 0 when + * unknown. Any untracked file newer than this is a local edit. + */ +export function readStageOutMarkTime(root: string): number { + try { + return fs.statSync(stageOutMarkPath(root)).mtimeMs; + } catch { + return 0; + } +} + /** Record that the real directories now match HEAD. */ export function writeStageOutMark(root: string): void { const head = headCommit(root); diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts new file mode 100644 index 0000000..b2b3556 --- /dev/null +++ b/src/core/scheduler.ts @@ -0,0 +1,619 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Operating-system scheduler definitions. + * + * Every definition is produced by a pure function from a `ScheduleSpec`, so the + * exact XML, plist and unit text can be snapshot-tested on any platform. The + * installers below are thin wrappers that write those strings and call the + * platform tool. + * + * Paths are resolved once, at install time, from the running Node binary and + * this package's own location. They are never rebuilt from a user name: the + * account name and the profile folder name differ on many machines. + */ + +export const TASK_NAME = "opencode-github-sync"; +export const LAUNCHD_LABEL = "com.opencode-github-sync.autosync"; +export const SYSTEMD_UNIT = "opencode-github-sync"; + +export interface ScheduleSpec { + /** Absolute path of the Node binary. */ + node: string; + /** Absolute path of the CLI entry script. */ + cli: string; + intervalMinutes: number; + /** Extra environment for the job (root overrides used by tests and CI). */ + env?: Record; + /** Directory for scheduler-level stdout/stderr logs. */ + logDir: string; +} + +export function runArgs(spec: ScheduleSpec): string[] { + return [spec.cli, "autosync", "run"]; +} + +// ── Windows ───────────────────────────────────────────────────────────────── + +function xmlEscape(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Quote one argument for the Windows command line (CommandLineToArgvW rules). */ +export function quoteWindowsArg(arg: string): string { + if (arg !== "" && !/[\s"]/.test(arg)) return arg; + let out = '"'; + let backslashes = 0; + for (const ch of arg) { + if (ch === "\\") { + backslashes++; + continue; + } + if (ch === '"') { + out += "\\".repeat(backslashes * 2 + 1) + '"'; + } else { + out += "\\".repeat(backslashes) + ch; + } + backslashes = 0; + } + return `${out}${"\\".repeat(backslashes * 2)}"`; +} + +export type WindowsLauncher = + | { kind: "conhost"; conhost: string } + | { kind: "wscript"; wscript: string; vbs: string }; + +export interface WindowsTaskOptions { + /** `DOMAIN\user` or `MACHINE\user` of the account the task runs as. */ + userId: string; + /** S4U runs while logged off; requires an elevated registration. */ + logonType: "InteractiveToken" | "S4U"; + launcher: WindowsLauncher; + /** Start boundary for the time trigger, local ISO without offset is fine. */ + startBoundary: string; +} + +/** The command and arguments the task's Exec action runs. */ +export function windowsAction( + spec: ScheduleSpec, + launcher: WindowsLauncher, +): { command: string; args: string } { + if (launcher.kind === "conhost") { + return { + command: launcher.conhost, + args: ["--headless", spec.node, ...runArgs(spec)].map(quoteWindowsArg).join(" "), + }; + } + return { command: launcher.wscript, args: `//B //Nologo ${quoteWindowsArg(launcher.vbs)}` }; +} + +/** + * Task Scheduler XML. + * + * - A time trigger repeating every N minutes carries the steady cadence. A + * logon trigger's repetition only starts counting after a real logon, so a + * task registered mid-session would otherwise never fire on schedule. + * - Logon (+30 s), session unlock and remote connect (+15 s) cover returning + * to the machine. + * - IgnoreNew, a 10-minute limit and no network gate: a roaming laptop is + * offline often, and the job handles an unreachable remote itself. + */ +export function windowsTaskXml(spec: ScheduleSpec, options: WindowsTaskOptions): string { + const action = windowsAction(spec, options.launcher); + const user = xmlEscape(options.userId); + const interval = `PT${Math.max(2, Math.floor(spec.intervalMinutes))}M`; + return ` + + + Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync). + \\${TASK_NAME} + + + + ${xmlEscape(options.startBoundary)} + true + + ${interval} + false + + + + true + ${user} + PT30S + + + true + ${user} + PT15S + SessionUnlock + + + true + ${user} + PT15S + RemoteConnect + + + + + ${user} + ${options.logonType} + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT10M + 7 + + + + ${xmlEscape(action.command)} + ${xmlEscape(action.args)} + + + +`; +} + +/** + * The VBScript fallback launcher, used where `conhost --headless` is not + * available. `Run ..., 0, True` starts Node with no window and waits, so the + * task's own run time and exit code are meaningful. + */ +export function windowsVbs(spec: ScheduleSpec): string { + const vbQuote = (s: string) => `""${s.replace(/"/g, '""""')}""`; + const command = [spec.node, ...runArgs(spec)].map(vbQuote).join(" "); + const envLines = Object.entries(spec.env ?? {}).map( + ([k, v]) => `env("${k.replace(/"/g, "")}") = "${v.replace(/"/g, '""')}"`, + ); + return [ + "' Generated by opencode-github-sync. Starts the autosync job with no window.", + "' Rewritten on every `opencode-sync autosync install`.", + "Dim shell, env, code", + 'Set shell = CreateObject("WScript.Shell")', + 'Set env = shell.Environment("PROCESS")', + ...envLines, + `code = shell.Run("${command}", 0, True)`, + "WScript.Quit code", + "", + ].join("\r\n"); +} + +/** UTF-16LE with BOM: wscript reads anything else in the ANSI code page. */ +export function encodeVbs(text: string): Buffer { + return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")]); +} + +/** UTF-16LE with BOM, as schtasks expects for an XML that declares UTF-16. */ +export function encodeTaskXml(text: string): Buffer { + return encodeVbs(text); +} + +// ── macOS ─────────────────────────────────────────────────────────────────── + +export function launchdPlist(spec: ScheduleSpec): string { + const esc = xmlEscape; + const args = [spec.node, ...runArgs(spec)].map((a) => ` ${esc(a)}`); + const envEntries = Object.entries(spec.env ?? {}); + const env = + envEntries.length === 0 + ? "" + : ` EnvironmentVariables + +${envEntries.map(([k, v]) => ` ${esc(k)}\n ${esc(v)}`).join("\n")} + +`; + return ` + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + +${args.join("\n")} + +${env} RunAtLoad + + StartInterval + ${Math.max(2, Math.floor(spec.intervalMinutes)) * 60} + ProcessType + Background + StandardOutPath + ${esc(path.posix.join(toPosix(spec.logDir), "autosync-launchd.log"))} + StandardErrorPath + ${esc(path.posix.join(toPosix(spec.logDir), "autosync-launchd.log"))} + + +`; +} + +function toPosix(p: string): string { + return p.replace(/\\/g, "/"); +} + +// ── Linux ─────────────────────────────────────────────────────────────────── + +/** Quote one word for a systemd `ExecStart=` line. */ +export function systemdQuote(arg: string): string { + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; + return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`; +} + +export function systemdService(spec: ScheduleSpec): string { + const exec = [spec.node, ...runArgs(spec)].map(systemdQuote).join(" "); + const env = Object.entries(spec.env ?? {}).map( + ([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}`, + ); + return [ + "[Unit]", + "Description=opencode-github-sync autosync", + "", + "[Service]", + "Type=oneshot", + `ExecStart=${exec}`, + ...env, + "TimeoutStartSec=600", + "", + ].join("\n"); +} + +export function systemdTimer(spec: ScheduleSpec): string { + const minutes = Math.max(2, Math.floor(spec.intervalMinutes)); + return [ + "[Unit]", + "Description=Run opencode-github-sync autosync periodically", + "", + "[Timer]", + "OnBootSec=1min", + `OnUnitActiveSec=${minutes}min`, + "Persistent=true", + `Unit=${SYSTEMD_UNIT}.service`, + "", + "[Install]", + "WantedBy=timers.target", + "", + ].join("\n"); +} + +/** Single-quote for POSIX sh. */ +export function shQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +export const CRON_MARKER = "# opencode-github-sync autosync"; + +export function crontabLine(spec: ScheduleSpec): string { + const minutes = Math.max(2, Math.floor(spec.intervalMinutes)); + const env = Object.entries(spec.env ?? {}) + .map(([k, v]) => `${k}=${shQuote(v)}`) + .join(" "); + const cmd = [spec.node, ...runArgs(spec)].map(shQuote).join(" "); + const log = shQuote(path.posix.join(toPosix(spec.logDir), "autosync-cron.log")); + return `*/${minutes} * * * * ${env ? `${env} ` : ""}${cmd} >> ${log} 2>&1 ${CRON_MARKER}`; +} + +/** Replace (or remove, with `line` undefined) our entry in a crontab text. */ +export function editCrontab(current: string, line: string | undefined): string { + const kept = current.split("\n").filter((l) => l.trim() && !l.includes(CRON_MARKER)); + if (line) kept.push(line); + return kept.length > 0 ? `${kept.join("\n")}\n` : ""; +} + +// ── Resolution of absolute paths ──────────────────────────────────────────── + +/** The Node binary to schedule: the one running this CLI, unless it is Bun or OpenCode. */ +export function resolveNode(execPath = process.execPath): string { + if (process.env.OPENCODE_SYNC_NODE) return process.env.OPENCODE_SYNC_NODE; + const base = path.basename(execPath).toLowerCase(); + if (base === "node" || base === "node.exe") return execPath; + const candidates = + process.platform === "win32" + ? [path.join(process.env.ProgramFiles || "C:\\Program Files", "nodejs", "node.exe")] + : ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"]; + return candidates.find((c) => fs.existsSync(c)) ?? execPath; +} + +/** The CLI entry script of this installation. */ +export function resolveCli(moduleDir: string): string { + return path.resolve(moduleDir, "..", "cli", "index.js"); +} + +// ── Installation (thin wrappers) ──────────────────────────────────────────── + +export type Platform = "win32" | "darwin" | "linux"; + +export interface Runner { + run: ( + cmd: string, + args: string[], + input?: string, + ) => { ok: boolean; stdout: string; stderr: string }; +} + +export const realRunner: Runner = { + run(cmd, args, input) { + try { + const stdout = execFileSync(cmd, args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + input, + windowsHide: true, + timeout: 60_000, + }); + return { ok: true, stdout: String(stdout), stderr: "" }; + } catch (e: any) { + return { + ok: false, + stdout: String(e.stdout ?? ""), + stderr: String(e.stderr ?? e.message ?? ""), + }; + } + }, +}; + +export interface InstallContext { + platform: Platform; + spec: ScheduleSpec; + home: string; + runner: Runner; + /** Directory for generated launcher files (the config root). */ + configRoot: string; + runWhenLoggedOff: boolean; + elevated: boolean; +} + +export interface InstallResult { + ok: boolean; + kind: "schtasks" | "launchd" | "systemd" | "cron"; + detail: string[]; + warnings: string[]; +} + +function windowsUser(runner: Runner): string { + const who = runner.run("whoami", []); + return who.ok && who.stdout.trim() + ? who.stdout.trim() + : `${os.hostname()}\\${os.userInfo().username}`; +} + +function windowsLauncher(ctx: InstallContext): WindowsLauncher { + const systemRoot = process.env.SystemRoot || "C:\\Windows"; + const conhost = path.join(systemRoot, "System32", "conhost.exe"); + // `--headless` exists from Windows 10 1809 (build 17763). It cannot carry + // environment variables, so a job that needs any uses the VBScript launcher. + const build = Number(os.release().split(".")[2] ?? 0); + const needsEnv = Object.keys(ctx.spec.env ?? {}).length > 0; + if ( + !needsEnv && + process.env.OPENCODE_SYNC_LAUNCHER !== "wscript" && + fs.existsSync(conhost) && + build >= 17763 + ) { + return { kind: "conhost", conhost }; + } + const vbs = path.join(ctx.configRoot, "opencode-sync-autosync.vbs"); + fs.mkdirSync(path.dirname(vbs), { recursive: true }); + fs.writeFileSync(vbs, encodeVbs(windowsVbs(ctx.spec))); + return { kind: "wscript", wscript: path.join(systemRoot, "System32", "wscript.exe"), vbs }; +} + +function localIsoNow(): string { + const d = new Date(Date.now() - 60_000); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; +} + +export function installScheduler(ctx: InstallContext): InstallResult { + const warnings: string[] = []; + fs.mkdirSync(ctx.spec.logDir, { recursive: true }); + + if (ctx.platform === "win32") { + let logonType: WindowsTaskOptions["logonType"] = "InteractiveToken"; + if (ctx.runWhenLoggedOff) { + if (ctx.elevated) logonType = "S4U"; + else warnings.push("not elevated: registered to run only while you are signed in"); + } + const launcher = windowsLauncher(ctx); + const xml = windowsTaskXml(ctx.spec, { + userId: windowsUser(ctx.runner), + logonType, + launcher, + startBoundary: localIsoNow(), + }); + const xmlFile = path.join(os.tmpdir(), `ogs-task-${process.pid}.xml`); + fs.writeFileSync(xmlFile, encodeTaskXml(xml)); + try { + const created = ctx.runner.run("schtasks", [ + "/Create", + "/TN", + TASK_NAME, + "/XML", + xmlFile, + "/F", + ]); + if (!created.ok) { + return { ok: false, kind: "schtasks", detail: [created.stderr.trim()], warnings }; + } + } finally { + fs.rmSync(xmlFile, { force: true }); + } + const check = schedulerStatus(ctx); + if (!check.installed) { + return { ok: false, kind: "schtasks", detail: ["task missing after registration"], warnings }; + } + if (!check.nextRun) { + return { ok: false, kind: "schtasks", detail: ["task has no next run time"], warnings }; + } + return { + ok: true, + kind: "schtasks", + detail: [`launcher ${launcher.kind}`, `logon ${logonType}`, `next run ${check.nextRun}`], + warnings, + }; + } + + if (ctx.platform === "darwin") { + const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); + fs.mkdirSync(path.dirname(plistPath), { recursive: true }); + fs.writeFileSync(plistPath, launchdPlist(ctx.spec)); + const uid = String(process.getuid?.() ?? ""); + ctx.runner.run("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]); + const boot = ctx.runner.run("launchctl", ["bootstrap", `gui/${uid}`, plistPath]); + if (!boot.ok) { + // A headless CI session has no GUI domain; the per-user domain still works. + const alt = ctx.runner.run("launchctl", ["bootstrap", `user/${uid}`, plistPath]); + if (!alt.ok) return { ok: false, kind: "launchd", detail: [boot.stderr.trim()], warnings }; + warnings.push("loaded into the user domain (no GUI session)"); + } + return { ok: true, kind: "launchd", detail: [plistPath], warnings }; + } + + // Linux: systemd --user when it answers, otherwise cron. + const probe = ctx.runner.run("systemctl", ["--user", "show-environment"]); + if (probe.ok) { + const dir = path.join( + process.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"), + "systemd", + "user", + ); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${SYSTEMD_UNIT}.service`), systemdService(ctx.spec)); + fs.writeFileSync(path.join(dir, `${SYSTEMD_UNIT}.timer`), systemdTimer(ctx.spec)); + ctx.runner.run("systemctl", ["--user", "daemon-reload"]); + const enabled = ctx.runner.run("systemctl", [ + "--user", + "enable", + "--now", + `${SYSTEMD_UNIT}.timer`, + ]); + if (!enabled.ok) + return { ok: false, kind: "systemd", detail: [enabled.stderr.trim()], warnings }; + warnings.push("headless host: run `loginctl enable-linger` so the timer runs while logged out"); + return { ok: true, kind: "systemd", detail: [dir], warnings }; + } + + const current = ctx.runner.run("crontab", ["-l"]); + const next = editCrontab(current.ok ? current.stdout : "", crontabLine(ctx.spec)); + const written = ctx.runner.run("crontab", ["-"], next); + if (!written.ok) return { ok: false, kind: "cron", detail: [written.stderr.trim()], warnings }; + return { ok: true, kind: "cron", detail: ["crontab entry added"], warnings }; +} + +export interface SchedulerStatus { + installed: boolean; + kind: InstallResult["kind"] | "none"; + nextRun?: string; + detail?: string; +} + +export function schedulerStatus( + ctx: Pick, +): SchedulerStatus { + if (ctx.platform === "win32") { + const q = ctx.runner.run("schtasks", ["/Query", "/TN", TASK_NAME]); + if (!q.ok) return { installed: false, kind: "none" }; + // schtasks' own output is localized; the cmdlet returns a real DateTime. + const info = ctx.runner.run(windowsPowerShell(), [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$i = Get-ScheduledTaskInfo -TaskName '${TASK_NAME}'; if ($i.NextRunTime) { $i.NextRunTime.ToString('s') }`, + ]); + const next = info.ok ? info.stdout.trim() : ""; + return { installed: true, kind: "schtasks", nextRun: next || undefined }; + } + if (ctx.platform === "darwin") { + const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); + if (!fs.existsSync(plistPath)) return { installed: false, kind: "none" }; + const listed = ctx.runner.run("launchctl", ["list", LAUNCHD_LABEL]); + return { installed: true, kind: "launchd", detail: listed.ok ? "loaded" : "not loaded" }; + } + const timer = ctx.runner.run("systemctl", ["--user", "is-enabled", `${SYSTEMD_UNIT}.timer`]); + if (timer.ok && /enabled/.test(timer.stdout)) { + return { installed: true, kind: "systemd" }; + } + const cron = ctx.runner.run("crontab", ["-l"]); + if (cron.ok && cron.stdout.includes(CRON_MARKER)) return { installed: true, kind: "cron" }; + return { installed: false, kind: "none" }; +} + +export function uninstallScheduler(ctx: InstallContext): { removed: boolean; detail: string[] } { + const detail: string[] = []; + let removed = false; + if (ctx.platform === "win32") { + const del = ctx.runner.run("schtasks", ["/Delete", "/TN", TASK_NAME, "/F"]); + removed = del.ok; + const vbs = path.join(ctx.configRoot, "opencode-sync-autosync.vbs"); + if (fs.existsSync(vbs)) fs.rmSync(vbs, { force: true }); + return { removed, detail }; + } + if (ctx.platform === "darwin") { + const uid = String(process.getuid?.() ?? ""); + ctx.runner.run("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]); + ctx.runner.run("launchctl", ["bootout", `user/${uid}/${LAUNCHD_LABEL}`]); + const plistPath = path.join(ctx.home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); + removed = fs.existsSync(plistPath); + fs.rmSync(plistPath, { force: true }); + return { removed, detail }; + } + const dir = path.join( + process.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"), + "systemd", + "user", + ); + const timer = path.join(dir, `${SYSTEMD_UNIT}.timer`); + if (fs.existsSync(timer)) { + ctx.runner.run("systemctl", ["--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`]); + fs.rmSync(timer, { force: true }); + fs.rmSync(path.join(dir, `${SYSTEMD_UNIT}.service`), { force: true }); + ctx.runner.run("systemctl", ["--user", "daemon-reload"]); + removed = true; + } + const cron = ctx.runner.run("crontab", ["-l"]); + if (cron.ok && cron.stdout.includes(CRON_MARKER)) { + ctx.runner.run("crontab", ["-"], editCrontab(cron.stdout, undefined)); + removed = true; + } + return { removed, detail }; +} + +/** True when this process runs with administrator rights (Windows only). */ +export function isElevated(runner: Runner = realRunner): boolean { + if (process.platform !== "win32") return process.getuid?.() === 0; + return runner.run("net", ["session"]).ok; +} + +export function windowsPowerShell(): string { + return path.join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} diff --git a/src/core/settings.ts b/src/core/settings.ts index eabaf3e..c41869c 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -215,6 +215,15 @@ export function repoBranch(settings: Settings): string { return settings.repo.branch || "main"; } +/** + * Idle pushes from inside OpenCode race the scheduled autosync job for the + * same files, so once autosync owns a machine the plugin keeps only its + * startup pull. + */ +export function idlePushEnabled(settings: Settings): boolean { + return settings.autoPushOnIdle && !settings.autosync?.enabled; +} + export const SETTINGS_TEMPLATE = `{ // opencode-github-sync settings. This file is never committed. "repo": { diff --git a/src/core/setup.ts b/src/core/setup.ts index d5922c1..5485529 100644 --- a/src/core/setup.ts +++ b/src/core/setup.ts @@ -5,10 +5,10 @@ import { ensureFile } from "./fsx.js"; import { type Roots, getRoots } from "./paths.js"; import type { Reporter } from "./reporter.js"; import { - DEFAULT_SETTINGS, OVERRIDES_TEMPLATE, SETTINGS_TEMPLATE, type Settings, + defaultSettings, loadSettings, overridesPath, saveSettings, @@ -198,12 +198,12 @@ function writeSetupFiles( const existing = fs.existsSync(settingsPath(roots.config)) ? loadSettings(roots.config) - : { ...DEFAULT_SETTINGS }; + : defaultSettings(); const settings: Settings = { ...existing, repo: { - ...existing.repo, + // owner/name replaces any URL from an earlier link; the URL would win. owner: target.owner, name: target.name, branch: existing.repo.branch || "main", @@ -240,6 +240,88 @@ function writeSetupFiles( }; } +/** + * True when the input is a git URL or a local path rather than `owner/name`. + * Such a repository is linked as given, without the GitHub CLI. + */ +export function isRepoUrl(input: string | undefined): boolean { + const value = (input ?? "").trim(); + return ( + /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || + /^[^@\s]+@[^:\s]+:/.test(value) || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("..") || + value.endsWith(".git") + ); +} + +/** Extract `owner/name` from a GitHub URL, if it is one. */ +export function githubTargetFromUrl(url: string): RepoTarget | undefined { + const match = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim()); + return match ? { owner: match[1]!, name: match[2]! } : undefined; +} + +/** + * Link by URL. Visibility cannot be checked without the GitHub API, so + * credential and session syncing are refused unless the URL is a GitHub + * repository that `gh` confirms is private. + */ +export function linkByUrl(options: SetupOptions & { url: string }): SetupResult { + const roots = options.roots ?? getRoots(); + const url = options.url.trim(); + const github = githubTargetFromUrl(url); + const isPrivate = github && ghAvailable() ? repoIsPrivate(github) : false; + if ((options.includeCredentials || options.includeSessions) && !isPrivate) { + throw new SetupError( + "Credential and session syncing need a repository confirmed to be private.\n" + + "Link it as owner/name with the GitHub CLI signed in, or leave those options off.", + ); + } + fs.mkdirSync(roots.config, { recursive: true }); + const existing = fs.existsSync(settingsPath(roots.config)) + ? loadSettings(roots.config) + : defaultSettings(); + const settings: Settings = { + ...existing, + repo: { url, branch: existing.repo.branch || "main" }, + includeCredentials: options.includeCredentials ?? existing.includeCredentials, + sessions: { + ...existing.sessions, + enabled: options.includeSessions ?? existing.sessions.enabled, + }, + }; + saveSettings(settings, roots.config); + ensureFile(overridesPath(roots.config), OVERRIDES_TEMPLATE); + return { + target: github ?? { owner: "", name: url }, + created: false, + settingsFile: settingsPath(roots.config), + overridesFile: overridesPath(roots.config), + }; +} + +/** Human-readable name of the configured repository. */ +export function repoLabel(settings: Settings): string | undefined { + if (settings.repo.owner && settings.repo.name) + return `${settings.repo.owner}/${settings.repo.name}`; + if (settings.repo.url) { + const gh = githubTargetFromUrl(settings.repo.url); + return gh ? `${gh.owner}/${gh.name}` : settings.repo.url.replace(/\/\/[^@/]+@/, "//"); + } + return undefined; +} + +/** Remove the repository from the local settings. No file is deleted. */ +export function unlinkSync(roots: Roots = getRoots()): void { + if (!fs.existsSync(settingsPath(roots.config))) return; + const settings = loadSettings(roots.config); + settings.repo = { branch: settings.repo.branch || "main" }; + settings.autosync.enabled = false; + saveSettings(settings, roots.config); +} + /** Guess which of the user's repositories is a sync repository. */ export function discoverSyncRepos(): string[] { const result = gh([ diff --git a/src/core/sync.ts b/src/core/sync.ts index a61bc3e..00f1c61 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,5 +1,14 @@ import fs from "node:fs"; import path from "node:path"; +import { + type CategoryKey, + type CategorySnapshot, + disabledCategories, + disabledPathspecs, + isDisabledPath, + restoreDisabled, + snapshotDisabled, +} from "./categories.js"; import { type GitResult, git, @@ -8,19 +17,10 @@ import { parsePorcelainPaths, toGitError, } from "./git.js"; +import { type GuardReport, LargeChangeError, evaluateGuard, writeLocalExcludes } from "./guard.js"; import { commitMessage, hostAlias } from "./host.js"; import { applyOverrides, configMatchesRepo, findConfigFile } from "./overrides.js"; import { type Roots, getDatabasePath, getRoots } from "./paths.js"; -import { - type CategoryKey, - type CategorySnapshot, - disabledCategories, - disabledPathspecs, - isDisabledPath, - restoreDisabled, - snapshotDisabled, -} from "./categories.js"; -import { type GuardReport, LargeChangeError, evaluateGuard, writeLocalExcludes } from "./guard.js"; import { saveToRecovery } from "./recovery.js"; import { LOCAL_RUNTIME_PATHS, @@ -44,13 +44,7 @@ import { totalChanges, } from "./reporter.js"; import { exportSessions, importSessions } from "./sessions.js"; -import { - DEFAULT_SETTINGS, - type Settings, - loadSettings, - repoBranch, - repoUrl, -} from "./settings.js"; +import { DEFAULT_SETTINGS, type Settings, loadSettings, repoBranch, repoUrl } from "./settings.js"; import { sqliteAvailable } from "./sqlite.js"; import { createStageContext, diff --git a/src/core/update.ts b/src/core/update.ts new file mode 100644 index 0000000..01a1d22 --- /dev/null +++ b/src/core/update.ts @@ -0,0 +1,87 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { childEnv } from "./env.js"; + +/** + * Self-update through npm. + * + * Only ever invoked by an explicit menu choice, or by the scheduled job when + * the user switched auto-update on. Never runs on its own otherwise. + */ + +export const PACKAGE_NAME = "opencode-github-sync"; + +export function currentVersion(moduleDir: string): string { + try { + const pkg = JSON.parse( + fs.readFileSync(path.resolve(moduleDir, "..", "..", "package.json"), "utf8"), + ); + return String(pkg.version ?? "0.0.0"); + } catch { + return "0.0.0"; + } +} + +/** Compare dotted numeric versions; pre-release suffixes sort before release. */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => { + const [main = "", pre = ""] = v.replace(/^v/, "").split("-", 2); + return { nums: main.split(".").map((n) => Number(n) || 0), pre }; + }; + const x = parse(a); + const y = parse(b); + for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) { + const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + if (x.pre === y.pre) return 0; + if (!x.pre) return 1; + if (!y.pre) return -1; + return x.pre > y.pre ? 1 : -1; +} + +function npm(args: string[], timeout = 120_000): string { + // npm is a batch file on Windows and Node refuses to spawn .cmd directly; + // going through cmd.exe with fixed arguments avoids a shell-string build. + const [command, prefix] = + process.platform === "win32" + ? [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm"]] + : ["npm", []]; + return execFileSync(command, [...prefix, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: childEnv(), + timeout, + windowsHide: true, + }).trim(); +} + +export function latestVersion(): string { + return npm(["view", PACKAGE_NAME, "version"], 60_000); +} + +export interface UpdateCheck { + current: string; + latest?: string; + newer: boolean; + error?: string; +} + +export function checkForUpdate(moduleDir: string): UpdateCheck { + const current = currentVersion(moduleDir); + try { + const latest = latestVersion(); + return { current, latest, newer: compareVersions(latest, current) > 0 }; + } catch (error) { + return { current, newer: false, error: (error as Error).message.split("\n")[0] }; + } +} + +export function installLatest(): void { + npm(["install", "-g", `${PACKAGE_NAME}@latest`], 300_000); +} + +export function uninstallGlobal(): void { + npm(["uninstall", "-g", PACKAGE_NAME], 300_000); +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 76592e6..c0cce3b 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import { withSyncLock } from "../core/lock.js"; import { getRoots } from "../core/paths.js"; import { CollectingReporter } from "../core/reporter.js"; -import { loadSettings, repoUrl, settingsPath } from "../core/settings.js"; +import { idlePushEnabled, loadSettings, repoUrl, settingsPath } from "../core/settings.js"; import { pull, push, status } from "../core/sync.js"; /** @@ -239,6 +239,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => { } const settings = loadSettings(roots.config); + const idlePush = idlePushEnabled(settings); if (settings.autoPullOnStartup) { // Deliberately not awaited: OpenCode should finish starting even when the @@ -248,7 +249,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => { return { event: async ({ event }: { event: { type: string } }) => { - if (event.type === "session.idle" && settings.autoPushOnIdle) { + if (event.type === "session.idle" && idlePush) { void runPush(client, false); } }, @@ -302,7 +303,7 @@ async function setup(ctx: V2Context): Promise<() => void> { }); }); - if (settings.autoPushOnIdle) { + if (idlePushEnabled(settings)) { void (async () => { try { for await (const event of ctx.event.subscribe({ signal: controller.signal })) { From 298b501eff75dc5eb14f1519ab97ce0b4b73809a Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 12:41:54 +0800 Subject: [PATCH 03/10] feat: one-line installers, non-interactive setup, tests for autosync, schedulers, menu and two machines --- install.ps1 | 192 +++++++++++ install.sh | 208 ++++++++++++ src/cli/menu.ts | 11 +- src/cli/ui.ts | 29 +- src/core/scheduler.ts | 4 +- src/core/setup.ts | 2 + src/core/sync.ts | 50 ++- tests/__snapshots__/scheduler.test.ts.snap | 216 ++++++++++++ tests/autosync.test.ts | 356 +++++++++++++++++++ tests/installers.test.ts | 149 ++++++++ tests/menu.test.ts | 333 ++++++++++++++++++ tests/scheduler.test.ts | 248 ++++++++++++++ tests/two-machines.test.ts | 376 +++++++++++++++++++++ tests/units.test.ts | 228 +++++++++++++ 14 files changed, 2373 insertions(+), 29 deletions(-) create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 tests/__snapshots__/scheduler.test.ts.snap create mode 100644 tests/autosync.test.ts create mode 100644 tests/installers.test.ts create mode 100644 tests/menu.test.ts create mode 100644 tests/scheduler.test.ts create mode 100644 tests/two-machines.test.ts create mode 100644 tests/units.test.ts diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..2d44c86 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,192 @@ +<# +.SYNOPSIS +Install opencode-github-sync on Windows and open its menu. + +.DESCRIPTION + irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1 | iex + + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1))) -Mode auto -Repo owner/name + +What it does, and nothing else: + 1. checks for Node.js 18+ and git, and offers to install a missing one with + winget (it asks first; it never installs anything silently) + 2. npm install -g opencode-github-sync@latest + 3. starts opencode-sync: the interactive menu, or a non-interactive setup + when -Mode is given + +Works in Windows PowerShell 5.1 and PowerShell 7. Running through `irm | iex` +does not touch the execution policy, because no script file is executed. + +.PARAMETER Mode +cli: install only. link: link -Repo. auto: link and make this the primary +device with autosync. + +.PARAMETER Repo +Repository to link, as owner/name or any git URL. + +.PARAMETER Interval +Autosync interval in minutes (minimum 2). + +.PARAMETER Lang +Interface language: en or zh-CN. + +.PARAMETER Proxy +HTTP(S) proxy for npm and git during the install and for later syncs. + +.PARAMETER Yes +Do not ask before installing missing prerequisites. + +.PARAMETER Package +npm package spec to install. Default: opencode-github-sync@latest. + +.PARAMETER DryRun +Print what would run and exit. +#> +[CmdletBinding()] +param( + [ValidateSet('', 'cli', 'link', 'auto')] + [string]$Mode = '', + [string]$Repo = '', + [int]$Interval = 0, + [ValidateSet('', 'en', 'zh-CN', 'zh')] + [string]$Lang = '', + [string]$Proxy = '', + [switch]$Yes, + [string]$Package = 'opencode-github-sync@latest', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' + +function Write-Step([string]$Text) { Write-Host $Text } +function Stop-Install([string]$Text) { + Write-Host "error: $Text" -ForegroundColor Red + # `exit` would close the window when this runs through `irm | iex`. + throw $Text +} + +if (($Mode -eq 'link' -or $Mode -eq 'auto') -and -not $Repo) { + Stop-Install "-Mode $Mode needs -Repo" +} +if ($Interval -ne 0 -and $Interval -lt 2) { + Stop-Install '-Interval must be at least 2' +} + +# The proxy applies to everything this script starts: npm, git and the CLI. +if ($Proxy) { + foreach ($name in 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'npm_config_proxy', 'npm_config_https_proxy') { + Set-Item -Path "Env:$name" -Value $Proxy + } +} + +# Arguments handed to `opencode-sync setup`. +$cliArgs = @() +if ($Mode) { $cliArgs += @('setup', '--mode', $Mode) } +if ($Repo) { $cliArgs += @('--repo', $Repo) } +if ($Interval -gt 0) { $cliArgs += @('--interval', [string]$Interval) } +if ($Lang) { $cliArgs += @('--lang', $Lang) } +if ($Proxy) { $cliArgs += @('--proxy', $Proxy) } + +function ConvertTo-ArgumentString([string[]]$Items) { + # Windows command-line quoting, so paths and URLs with spaces survive + # Start-Process in both PowerShell editions. + $quoted = foreach ($item in $Items) { + if ($item -notmatch '[\s"]' -and $item -ne '') { $item; continue } + $out = '"' + $slashes = 0 + foreach ($ch in $item.ToCharArray()) { + if ($ch -eq '\') { $slashes++; continue } + if ($ch -eq '"') { $out += ('\' * ($slashes * 2 + 1)) + '"' } + else { $out += ('\' * $slashes) + $ch } + $slashes = 0 + } + $out + ('\' * ($slashes * 2)) + '"' + } + return ($quoted -join ' ') +} + +if ($DryRun) { + Write-Output "npm install -g $Package" + if ($cliArgs.Count -gt 0) { Write-Output ("opencode-sync " + (ConvertTo-ArgumentString $cliArgs)) } + else { Write-Output 'opencode-sync' } + if ($Proxy) { Write-Output "proxy $Proxy" } + return +} + +# --- Prerequisites --- + +function Update-SessionPath { + # A fresh winget install updates the registry, not this session. + $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = (@($machine, $user) | Where-Object { $_ }) -join ';' +} + +function Test-Command([string]$Name) { + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Request-Install([string]$What, [string]$WingetId, [string]$Why, [string]$Manual) { + Write-Step $Why + if (-not (Test-Command 'winget')) { + Stop-Install "Install $What from $Manual and run this again." + } + $command = "winget install --id $WingetId -e --source winget" + Write-Step 'It can be installed with:' + Write-Step " $command" + $go = [bool]$Yes + if (-not $go) { + $answer = Read-Host 'Run that now? [y/N]' + $go = $answer -match '^(y|yes)$' + } + if (-not $go) { + Stop-Install 'Not installed. Run the command above, then this installer again.' + } + & winget install --id $WingetId -e --source winget --accept-package-agreements --accept-source-agreements + Update-SessionPath +} + +function Get-NodeVersion { + if (-not (Test-Command 'node')) { return $null } + try { return [version]((& node -p 'process.versions.node') -replace '[^0-9.]', '') } catch { return $null } +} + +$nodeVersion = Get-NodeVersion +if (-not $nodeVersion -or -not (Test-Command 'npm')) { + Request-Install 'Node.js' 'OpenJS.NodeJS.LTS' 'Node.js (18 or newer) and npm are needed and were not found.' 'https://nodejs.org' + $nodeVersion = Get-NodeVersion +} +if (-not $nodeVersion -or $nodeVersion.Major -lt 18) { + Request-Install 'Node.js' 'OpenJS.NodeJS.LTS' "Node.js $nodeVersion is too old; 18 or newer is needed." 'https://nodejs.org' + $nodeVersion = Get-NodeVersion + if (-not $nodeVersion -or $nodeVersion.Major -lt 18) { Stop-Install 'Node.js 18 or newer is still not available on PATH.' } +} +if ($nodeVersion -lt [version]'22.5.0') { + Write-Step "Note: Node.js $nodeVersion syncs configuration fine; session sync needs 22.5 or newer." +} +if (-not (Test-Command 'git')) { + Request-Install 'git' 'Git.Git' 'git is needed and was not found.' 'https://git-scm.com' + if (-not (Test-Command 'git')) { Stop-Install 'git is still not available on PATH.' } +} + +# --- Install --- + +Write-Step "Installing $Package ..." +& npm install -g $Package +if ($LASTEXITCODE -ne 0) { Stop-Install 'npm install -g failed.' } + +$prefix = (& npm prefix -g).Trim() +$bin = Join-Path $prefix 'opencode-sync.cmd' +if (-not (Test-Path -LiteralPath $bin)) { + $found = Get-Command 'opencode-sync.cmd' -ErrorAction SilentlyContinue + if ($found) { $bin = $found.Source } else { Stop-Install "opencode-sync was installed but cannot be found. Add $prefix to PATH." } +} + +# Started as its own process in this console, so the menu owns the keyboard +# even when this script itself arrived through a pipe (`irm | iex`). +$startArgs = @{ FilePath = $bin; NoNewWindow = $true; Wait = $true; PassThru = $true } +if ($cliArgs.Count -gt 0) { $startArgs.ArgumentList = ConvertTo-ArgumentString $cliArgs } +$process = Start-Process @startArgs +if ($process.ExitCode -ne 0) { + Stop-Install "opencode-sync exited with code $($process.ExitCode)." +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..62aa9ee --- /dev/null +++ b/install.sh @@ -0,0 +1,208 @@ +#!/bin/sh +# opencode-github-sync installer for macOS and Linux. +# +# curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh -s -- --mode auto --repo owner/name +# +# What it does, and nothing else: +# 1. checks for Node.js 18+ and git, and offers to install a missing one +# (it asks first; it never installs anything silently) +# 2. npm install -g opencode-github-sync@latest +# 3. starts opencode-sync: the interactive menu, or a non-interactive setup +# when --mode is given +# +# Options (all optional): +# --mode cli|link|auto cli: install only; link: link --repo; auto: link and +# make this the primary device with autosync +# --repo owner/name repository to link (or any git URL) +# --interval N autosync interval in minutes (minimum 2) +# --lang en|zh-CN interface language +# --proxy URL HTTP(S) proxy for npm and git during install and sync +# --yes do not ask before installing missing prerequisites +# --package SPEC npm package spec to install (default: opencode-github-sync@latest) +# --dry-run print what would run and exit +# +# Under `curl | sh` the script itself arrives on standard input, so every +# question is read from /dev/tty instead. + +set -eu + +MODE="" +REPO="" +INTERVAL="" +LANG_OPT="" +PROXY="" +YES=0 +DRY_RUN=0 +PACKAGE="opencode-github-sync@latest" + +say() { printf '%s\n' "$*"; } +err() { printf 'error: %s\n' "$*" >&2; } + +need_value() { + if [ $# -lt 2 ] || [ -z "$2" ]; then + err "$1 needs a value" + exit 2 + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --mode) need_value "$@"; MODE="$2"; shift 2 ;; + --mode=*) MODE="${1#*=}"; shift ;; + --repo) need_value "$@"; REPO="$2"; shift 2 ;; + --repo=*) REPO="${1#*=}"; shift ;; + --interval) need_value "$@"; INTERVAL="$2"; shift 2 ;; + --interval=*) INTERVAL="${1#*=}"; shift ;; + --lang) need_value "$@"; LANG_OPT="$2"; shift 2 ;; + --lang=*) LANG_OPT="${1#*=}"; shift ;; + --proxy) need_value "$@"; PROXY="$2"; shift 2 ;; + --proxy=*) PROXY="${1#*=}"; shift ;; + --package) need_value "$@"; PACKAGE="$2"; shift 2 ;; + --package=*) PACKAGE="${1#*=}"; shift ;; + --yes|-y) YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) + sed -n '2,30p' "$0" 2>/dev/null || say "See https://github.com/doomsday616/opencode-github-sync" + exit 0 ;; + *) err "unknown option: $1"; exit 2 ;; + esac +done + +case "$MODE" in + ""|cli|link|auto) ;; + *) err "--mode must be cli, link or auto"; exit 2 ;; +esac +if [ "$MODE" = "link" ] || [ "$MODE" = "auto" ]; then + if [ -z "$REPO" ]; then err "--mode $MODE needs --repo"; exit 2; fi +fi +if [ -n "$INTERVAL" ]; then + case "$INTERVAL" in + *[!0-9]*) err "--interval must be a whole number of minutes"; exit 2 ;; + esac + if [ "$INTERVAL" -lt 2 ]; then err "--interval must be at least 2"; exit 2; fi +fi +case "$LANG_OPT" in + ""|en|zh-CN|zh) ;; + *) err "--lang must be en or zh-CN"; exit 2 ;; +esac + +# The proxy applies to everything this script starts: npm, git and the CLI. +if [ -n "$PROXY" ]; then + export HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY" http_proxy="$PROXY" https_proxy="$PROXY" + export npm_config_proxy="$PROXY" npm_config_https_proxy="$PROXY" +fi + +# Arguments handed to `opencode-sync setup`. +set -- +if [ -n "$MODE" ]; then set -- "$@" setup --mode "$MODE"; fi +if [ -n "$REPO" ]; then set -- "$@" --repo "$REPO"; fi +if [ -n "$INTERVAL" ]; then set -- "$@" --interval "$INTERVAL"; fi +if [ -n "$LANG_OPT" ]; then set -- "$@" --lang "$LANG_OPT"; fi +if [ -n "$PROXY" ]; then set -- "$@" --proxy "$PROXY"; fi + +if [ "$DRY_RUN" -eq 1 ]; then + say "npm install -g $PACKAGE" + if [ $# -gt 0 ]; then say "opencode-sync $*"; else say "opencode-sync"; fi + if [ -n "$PROXY" ]; then say "proxy $PROXY"; fi + exit 0 +fi + +# --- Questions go to the terminal, not to the pipe --- + +HAVE_TTY=0 +if [ -r /dev/tty ] && [ -w /dev/tty ] && { : /dev/null; then HAVE_TTY=1; fi + +ask_yes_no() { + # $1 = question. Default is No. + if [ "$HAVE_TTY" -ne 1 ]; then return 1; fi + printf '%s [y/N] ' "$1" >/dev/tty + answer="" + read -r answer /dev/null 2>&1; then + if [ "$what" = node ]; then echo "brew install node"; else echo "brew install git"; fi + elif command -v apt-get >/dev/null 2>&1; then + if [ "$what" = node ]; then echo "sudo apt-get install -y nodejs npm"; else echo "sudo apt-get install -y git"; fi + elif command -v dnf >/dev/null 2>&1; then + if [ "$what" = node ]; then echo "sudo dnf install -y nodejs npm"; else echo "sudo dnf install -y git"; fi + elif command -v pacman >/dev/null 2>&1; then + if [ "$what" = node ]; then echo "sudo pacman -S --noconfirm nodejs npm"; else echo "sudo pacman -S --noconfirm git"; fi + elif command -v apk >/dev/null 2>&1; then + if [ "$what" = node ]; then echo "sudo apk add nodejs npm"; else echo "sudo apk add git"; fi + elif command -v zypper >/dev/null 2>&1; then + if [ "$what" = node ]; then echo "sudo zypper install -y nodejs npm"; else echo "sudo zypper install -y git"; fi + fi +} + +offer_install() { + # $1 = node | git, $2 = why it is needed + what="$1" + say "$2" + cmd=$(package_manager_command "$what") + if [ -z "$cmd" ]; then + if [ "$what" = node ]; then say "Install Node.js 18 or newer from https://nodejs.org and run this again." + else say "Install git from https://git-scm.com and run this again."; fi + exit 1 + fi + say "It can be installed with:" + say " $cmd" + if [ "$YES" -eq 1 ] || ask_yes_no "Run that now?"; then + if [ "$HAVE_TTY" -eq 1 ]; then sh -c "$cmd" /dev/null || echo 0 +} + +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + offer_install node "Node.js (18 or newer) and npm are needed and were not found." +fi +if [ "$(node_major)" -lt 18 ]; then + offer_install node "Node.js $(node -v) is too old; 18 or newer is needed." +fi +if ! node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=5)?0:1)' 2>/dev/null; then + say "Note: Node.js $(node -v) syncs configuration fine; session sync needs 22.5 or newer." +fi +if ! command -v git >/dev/null 2>&1; then + offer_install git "git is needed and was not found." +fi + +# --- Install --- + +say "Installing $PACKAGE ..." +if ! npm install -g "$PACKAGE"; then + err "npm install -g failed." + say "If this is a permissions error, point npm at a folder you own:" + say " npm config set prefix \"\$HOME/.local\" && export PATH=\"\$HOME/.local/bin:\$PATH\"" + exit 1 +fi + +BIN="$(npm prefix -g 2>/dev/null)/bin/opencode-sync" +if [ ! -x "$BIN" ]; then BIN="$(command -v opencode-sync 2>/dev/null || true)"; fi +if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then + err "opencode-sync was installed but cannot be found on PATH." + say "Add \"$(npm prefix -g)/bin\" to PATH, then run: opencode-sync" + exit 1 +fi + +if [ $# -gt 0 ]; then + if [ "$HAVE_TTY" -eq 1 ]; then exec "$BIN" "$@" number; /** Directory the support bundle is written to. */ cwd?: string; + /** Lists candidate repositories; defaults to the GitHub CLI. */ + discover?: () => string[]; } interface Ctx { @@ -92,6 +94,7 @@ interface Ctx { openEditor: (file: string) => boolean; now: () => number; cwd: string; + discover: () => string[]; } type Next = "stay" | "exit"; @@ -107,6 +110,7 @@ export async function runMenu(deps: MenuDeps): Promise { openEditor: deps.openEditor ?? defaultEditor, now: deps.now ?? Date.now, cwd: deps.cwd ?? process.cwd(), + discover: deps.discover ?? defaultDiscover, }; if (!repoUrl(settings(ctx))) { @@ -292,7 +296,7 @@ async function linkFlow(ctx: Ctx): Promise { } let target: string | undefined; - const found = ghAvailable() ? safe(() => discoverSyncRepos(), [] as string[]) : []; + const found = safe(() => ctx.discover(), [] as string[]); if (found.length > 0) { const choices: Choice[] = found.slice(0, 9).map((r, i) => ({ value: r, @@ -326,6 +330,10 @@ function safe(fn: () => T, fallback: T): T { } } +function defaultDiscover(): string[] { + return ghAvailable() ? discoverSyncRepos() : []; +} + /** * The first sync on a newly linked machine. When the repository and the * machine differ the user picks a side; nothing is replaced silently. @@ -1221,6 +1229,7 @@ export async function runSetup( openEditor: defaultEditor, now: Date.now, cwd: process.cwd(), + discover: () => [], }; banner(); // Local-only preferences go into the settings file once it exists (after diff --git a/src/cli/ui.ts b/src/cli/ui.ts index 989a24f..936a3b3 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -136,6 +136,17 @@ export class Ui { this.queue.pause(); } + /** + * Read one line in line mode. A terminal echoes what was typed; a pipe does + * not, so the answer is echoed here to keep transcripts readable. + */ + private async readLine(): Promise { + const answer = await this.queue.line(); + const echoed = Boolean((this.io.input as { isTTY?: boolean }).isTTY); + if (!echoed) this.io.output.write(`${answer ?? ""}\n`); + return answer; + } + private clearRendered(): void { if (this.renderedLines > 0) { this.io.output.write(`\x1b[${this.renderedLines}A\x1b[0J`); @@ -156,7 +167,7 @@ export class Ui { const keyOf = (c: Choice, i: number) => (c.key ?? String(i + 1)).toLowerCase(); if (!this.io.interactive) { - this.write(title ? ` ${paint(title, style.bold, style.text)}` : ""); + if (title) this.write(` ${paint(title, style.bold, style.text)}`); choices.forEach((c, i) => { const tag = `[${keyOf(c, i)}]`.padEnd(5); const line = ` ${paint(tag, style.cyan)}${c.label}${c.hint ? paint(` ${c.hint}`, style.faint) : ""}`; @@ -164,11 +175,8 @@ export class Ui { }); for (;;) { this.io.output.write(` ${paint("›", style.brand)} `); - const answer = await this.queue.line(); - if (answer === undefined) { - this.write(); - return undefined; - } + const answer = await this.readLine(); + if (answer === undefined) return undefined; const text = answer.trim().toLowerCase(); if (text === "") continue; const index = choices.findIndex((c, i) => !c.disabled && keyOf(c, i) === text); @@ -258,7 +266,7 @@ export class Ui { }); if (footer) this.write(` ${paint(footer, style.faint)}`); this.io.output.write(` ${paint("›", style.brand)} `); - const answer = await this.queue.line(); + const answer = await this.readLine(); if (answer === undefined) return undefined; const text = answer.trim().toLowerCase(); if (text === "" || text === "s") return current; @@ -325,11 +333,8 @@ export class Ui { const suffix = initial ? paint(` (${initial})`, style.faint) : ""; if (!this.io.interactive) { this.io.output.write(` ${question}${suffix} ${paint("›", style.brand)} `); - const answer = await this.queue.line(); - if (answer === undefined) { - this.write(); - return undefined; - } + const answer = await this.readLine(); + if (answer === undefined) return undefined; return answer.trim() === "" ? initial : answer.trim(); } diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index b2b3556..bb87dd5 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -258,9 +258,9 @@ function toPosix(p: string): string { // ── Linux ─────────────────────────────────────────────────────────────────── -/** Quote one word for a systemd `ExecStart=` line. */ +/** Quote one word for a systemd `ExecStart=` line; `%` is a specifier there. */ export function systemdQuote(arg: string): string { - if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; + if (/^[A-Za-z0-9_@+=:,./-]+$/.test(arg)) return arg; return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`; } diff --git a/src/core/setup.ts b/src/core/setup.ts index 5485529..c59056e 100644 --- a/src/core/setup.ts +++ b/src/core/setup.ts @@ -51,6 +51,8 @@ function gh(args: string[]): { ok: boolean; stdout: string; stderr: string } { } export function ghAvailable(): boolean { + // Lets tests and CI run without touching whatever account `gh` is signed in to. + if (process.env.OPENCODE_SYNC_NO_GH === "1") return false; return gh(["--version"]).ok; } diff --git a/src/core/sync.ts b/src/core/sync.ts index 00f1c61..9c59b2b 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -24,6 +24,7 @@ import { type Roots, getDatabasePath, getRoots } from "./paths.js"; import { saveToRecovery } from "./recovery.js"; import { LOCAL_RUNTIME_PATHS, + type RepoState, assertPushLanded, assertRemoteReachable, ensureRepo, @@ -166,13 +167,15 @@ export async function push(options: SyncOptions = {}): Promise { if (!fs.existsSync(root)) throw new SyncError(`Config directory not found: ${root}`); - ensureRepo({ - root, - remote: ctx.remote, - branch, - adopt: options.adopt ?? "remote", - beforeAdopt: (_root, ref) => saveOverwritten(ctx, ref, "first-push"), - }); + const state = adoptRepo(ctx, options.adopt ?? "remote", "first-push"); + if (state === "adopted" && (options.adopt ?? "remote") === "remote" && !ctx.dryRun) { + // The remote's files are now in the worktree, but the untracked trees + // (skills, state, extra paths) still hold this machine's old contents. + // Write the remote's versions out first; staging the stale trees in would + // otherwise publish every skill this machine lacks as a deletion. + stageOut(createStageContext(root, ctx.settings, reporter, false, ctx.roots)); + writeStageOutMark(root); + } assertRemoteReachable(root, branch, "Push", true); writeRepoMetadata(root); writeLocalExcludes(root, ctx.settings.guard?.skipDirs ?? []); @@ -453,13 +456,7 @@ export async function pull(options: SyncOptions = {}): Promise { fs.mkdirSync(root, { recursive: true }); if (!isGitRepo(root)) { - const state = ensureRepo({ - root, - remote: ctx.remote, - branch, - adopt: "remote", - beforeAdopt: (_root, ref) => saveOverwritten(ctx, ref, "first-pull"), - }); + const state = adoptRepo(ctx, "remote", "first-pull"); if (state === "fresh") { return done( "pull", @@ -793,6 +790,31 @@ function countRealChanges(root: string, porcelain: string): number { // ── Helpers ───────────────────────────────────────────────────────────────── +/** + * Make the config directory a working copy of the remote. + * + * When the remote already has history and `mode` is `remote`, the files it + * is about to overwrite are copied to recovery first, and files in disabled + * categories are set aside and put back, so they stay this machine's own. + */ +function adoptRepo(ctx: Context, mode: "remote" | "local", label: string): RepoState { + let aside: CategorySnapshot | null = null; + try { + return ensureRepo({ + root: ctx.root, + remote: ctx.remote, + branch: ctx.branch, + adopt: mode, + beforeAdopt: (_root, ref) => { + saveOverwritten(ctx, ref, label); + aside = snapshotDisabled(ctx.root, ctx.disabled); + }, + }); + } finally { + restoreDisabled(ctx.root, ctx.disabled, aside); + } +} + /** * Copy every local file that a move to `ref` would overwrite or delete into * the recovery folder. Called before a forced pull and before a first link diff --git a/tests/__snapshots__/scheduler.test.ts.snap b/tests/__snapshots__/scheduler.test.ts.snap new file mode 100644 index 0000000..453813e --- /dev/null +++ b/tests/__snapshots__/scheduler.test.ts.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Linux systemd and cron > renders a crontab line and edits only its own entry 1`] = `"*/10 * * * * PATH='/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin' '/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node' '/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js' 'autosync' 'run' >> '/Users/李 雷/.config/opencode/logs/autosync-cron.log' 2>&1 # opencode-github-sync autosync"`; + +exports[`Linux systemd and cron > renders the user service and timer 1`] = ` +"[Unit] +Description=opencode-github-sync autosync + +[Service] +Type=oneshot +ExecStart="/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node" "/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js" autosync run +Environment="PATH=/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin" +TimeoutStartSec=600 +" +`; + +exports[`Linux systemd and cron > renders the user service and timer 2`] = ` +"[Unit] +Description=Run opencode-github-sync autosync periodically + +[Timer] +OnBootSec=1min +OnUnitActiveSec=10min +Persistent=true +Unit=opencode-github-sync.service + +[Install] +WantedBy=timers.target +" +`; + +exports[`Windows Task Scheduler > renders S4U with the wscript launcher 1`] = ` +" + + + Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync). + \\opencode-github-sync + + + + 2026-10-26T08:00:00 + true + + PT5M + false + + + + true + CORP\\alex + PT30S + + + true + CORP\\alex + PT15S + SessionUnlock + + + true + CORP\\alex + PT15S + RemoteConnect + + + + + CORP\\alex + S4U + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT10M + 7 + + + + C:\\Windows\\System32\\wscript.exe + //B //Nologo "C:\\Users\\鸽子 Lee\\.config\\opencode\\opencode-sync-autosync.vbs" + + + +" +`; + +exports[`Windows Task Scheduler > renders the task XML with conhost --headless 1`] = ` +" + + + Keeps OpenCode configuration in sync with its GitHub repository (opencode-github-sync). + \\opencode-github-sync + + + + 2026-10-26T08:00:00 + true + + PT5M + false + + + + true + DESKTOP-1\\鸽子 + PT30S + + + true + DESKTOP-1\\鸽子 + PT15S + SessionUnlock + + + true + DESKTOP-1\\鸽子 + PT15S + RemoteConnect + + + + + DESKTOP-1\\鸽子 + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT10M + 7 + + + + C:\\Windows\\System32\\conhost.exe + --headless "C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\鸽子 Lee\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js" autosync run + + + +" +`; + +exports[`Windows Task Scheduler > writes the VBScript launcher as UTF-16LE with a BOM 1`] = ` +"' Generated by opencode-github-sync. Starts the autosync job with no window. +' Rewritten on every \`opencode-sync autosync install\`. +Dim shell, env, code +Set shell = CreateObject("WScript.Shell") +Set env = shell.Environment("PROCESS") +env("SYNC_HOME") = "C:\\Users\\鸽子 Lee" +code = shell.Run("""C:\\Program Files\\nodejs\\node.exe"" ""C:\\Users\\鸽子 Lee\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js"" ""autosync"" ""run""", 0, True) +WScript.Quit code +" +`; + +exports[`macOS launchd > renders the agent plist 1`] = ` +" + + + + Label + com.opencode-github-sync.autosync + ProgramArguments + + /Users/李 雷/.nvm/versions/node/v22.11.0/bin/node + /Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js + autosync + run + + EnvironmentVariables + + PATH + /Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin + + RunAtLoad + + StartInterval + 600 + ProcessType + Background + StandardOutPath + /Users/李 雷/.config/opencode/logs/autosync-launchd.log + StandardErrorPath + /Users/李 雷/.config/opencode/logs/autosync-launchd.log + + +" +`; diff --git a/tests/autosync.test.ts b/tests/autosync.test.ts new file mode 100644 index 0000000..2034e79 --- /dev/null +++ b/tests/autosync.test.ts @@ -0,0 +1,356 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + type AutosyncDeps, + BACKOFF_MAX_MS, + ENGLISH_MESSAGES, + backoffDelayMs, + backoffRemainingMs, + newestWrite, + readState, + runAutosync, + staggerMs, + writeState, +} from "../src/core/autosync.js"; +import { LargeChangeError } from "../src/core/guard.js"; +import type { Roots } from "../src/core/paths.js"; +import { emptySummary } from "../src/core/reporter.js"; +import { type Settings, defaultSettings } from "../src/core/settings.js"; +import { PushRaceError, type SyncResult } from "../src/core/sync.js"; + +/** + * The scheduling rules, exercised with a fake clock and fake sync functions. + * End-to-end runs against a real bare repository live in two-machines.test.ts. + */ + +let sandbox: string; +let roots: Roots; + +beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-auto-")); + roots = { + config: path.join(sandbox, "config"), + data: path.join(sandbox, "data"), + state: path.join(sandbox, "state"), + agents: path.join(sandbox, "agents"), + cache: path.join(sandbox, "cache"), + }; + for (const dir of Object.values(roots)) fs.mkdirSync(dir, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +const MIN = 60_000; +const T0 = Date.UTC(2026, 9, 26, 8, 0, 0); + +function result(message: string): SyncResult { + return { + action: "push", + changed: true, + summary: emptySummary(), + files: [], + message, + restartRequired: false, + }; +} + +interface Harness { + deps: AutosyncDeps; + clock: { now: number }; + calls: string[]; + notes: string[]; + settings: Settings; + logs: string[]; +} + +function harness(patch: Partial = {}): Harness { + const clock = { now: T0 }; + const calls: string[] = []; + const notes: string[] = []; + const logs: string[] = []; + const settings = defaultSettings(); + settings.autosync.enabled = true; + const deps: AutosyncDeps = { + roots, + now: () => clock.now, + sleep: async (ms) => { + calls.push(`sleep:${ms}`); + }, + random: () => 0, + loadSettings: () => settings, + push: async () => { + calls.push("push"); + return result("Pushed 1 change(s)"); + }, + pull: async () => { + calls.push("pull"); + return result("Pulled 1 change(s)"); + }, + fetchBehind: () => { + calls.push("fetch"); + return { ok: true, behind: 1 }; + }, + repoSnapshot: () => ({ ok: true, dirty: false, ahead: 0 }), + newestWrite: () => 0, + newestUntrackedWrite: () => 0, + stageOutTime: () => T0 - 10 * MIN, + tryLock: () => ({ release: () => calls.push("release") }), + notify: (title) => { + notes.push(title); + return true; + }, + log: (line) => logs.push(line), + messages: ENGLISH_MESSAGES, + ...patch, + }; + return { deps, clock, calls, notes, settings, logs }; +} + +describe("autosync gate", () => { + it("does nothing unless enabled on this machine", async () => { + const h = harness(); + h.settings.autosync.enabled = false; + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("skip"); + expect(h.calls).not.toContain("push"); + }); + + it("pushes when idle, enabled and due", async () => { + const h = harness(); + const outcome = await runAutosync(h.deps); + expect(outcome).toMatchObject({ action: "push", ok: true }); + expect(h.calls).toContain("push"); + expect(h.calls).toContain("release"); + expect(readState(roots).lastSuccessAt).toBe(T0); + }); + + it("respects the interval since the last attempt", async () => { + const h = harness(); + writeState(roots, { lastAttemptAt: T0 - 2 * MIN }); + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("skip"); + expect(outcome.reason).toMatch(/throttled/); + }); + + it("skips while paused and resumes after", async () => { + const h = harness(); + h.settings.autosync.pausedUntil = T0 + 60 * MIN; + expect((await runAutosync(h.deps)).reason).toMatch(/paused/); + h.clock.now = T0 + 61 * MIN; + expect((await runAutosync(h.deps)).action).toBe("push"); + }); + + it("skips when another sync holds the lock", async () => { + const h = harness({ tryLock: () => null }); + const outcome = await runAutosync(h.deps); + expect(outcome).toMatchObject({ action: "skip", reason: "another sync is in progress" }); + expect(h.calls).not.toContain("push"); + }); + + it("staggers by a stable per-machine offset", async () => { + const h = harness(); + delete process.env.OPENCODE_SYNC_NO_STAGGER; + await runAutosync(h.deps); + const sleep = h.calls.find((c) => c.startsWith("sleep:")); + expect(sleep).toBeDefined(); + const ms = Number(sleep!.slice(6)); + expect(ms).toBeGreaterThanOrEqual(0); + expect(ms).toBeLessThanOrEqual(45_000); + expect(staggerMs("laptop", () => 0)).toBe(staggerMs("laptop", () => 0)); + expect(staggerMs("laptop", () => 0.999)).toBeLessThan(staggerMs("laptop", () => 0) + 5000); + }); +}); + +describe("settle window", () => { + it("holds the push back while files are still changing and receives instead", async () => { + const h = harness({ newestWrite: () => T0 - 10_000 }); + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("receive"); + expect(h.calls).toEqual(expect.arrayContaining(["fetch", "pull"])); + expect(h.calls).not.toContain("push"); + }); + + it("does not receive when the repository has local work", async () => { + const h = harness({ + newestWrite: () => T0 - 10_000, + repoSnapshot: () => ({ ok: true, dirty: true, ahead: 0 }), + }); + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("skip"); + expect(h.calls).not.toContain("pull"); + }); + + it("does not receive when commits are waiting to be pushed", async () => { + const h = harness({ + newestWrite: () => T0 - 10_000, + repoSnapshot: () => ({ ok: true, dirty: false, ahead: 2 }), + }); + expect((await runAutosync(h.deps)).reason).toMatch(/2 local commit/); + }); + + it("does not receive over skills edited since the last write-out", async () => { + const h = harness({ + newestWrite: () => T0 - 10_000, + newestUntrackedWrite: () => T0 - 20_000, + stageOutTime: () => T0 - 60_000, + }); + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("skip"); + expect(outcome.reason).toMatch(/skills/); + }); + + it("does not pull when nothing is waiting", async () => { + const h = harness({ + newestWrite: () => T0 - 10_000, + fetchBehind: () => ({ ok: true, behind: 0 }), + }); + const outcome = await runAutosync(h.deps); + expect(outcome.action).toBe("skip"); + expect(h.calls).not.toContain("pull"); + }); + + it("pushes once the tree has been quiet for the settle time", async () => { + const h = harness({ newestWrite: () => T0 - 61_000 }); + expect((await runAutosync(h.deps)).action).toBe("push"); + }); + + it("ignores noisy files when measuring the newest write", () => { + const dir = path.join(sandbox, "tree"); + fs.mkdirSync(path.join(dir, "logs"), { recursive: true }); + fs.mkdirSync(path.join(dir, "node_modules", "x"), { recursive: true }); + fs.writeFileSync(path.join(dir, "real.md"), "x"); + const old = new Date(T0 - 3600_000); + fs.utimesSync(path.join(dir, "real.md"), old, old); + for (const noisy of ["logs/a.log", "node_modules/x/i.js", "bun.lock", "prompt-history.jsonl"]) { + fs.writeFileSync(path.join(dir, noisy), "n"); + } + expect(Math.round(newestWrite([dir]))).toBe(Math.round(old.getTime())); + }); +}); + +describe("backoff", () => { + it("doubles from 30 to a 60 minute cap", () => { + expect(backoffDelayMs(3)).toBe(30 * MIN); + expect(backoffDelayMs(4)).toBe(60 * MIN); + expect(backoffDelayMs(10)).toBe(BACKOFF_MAX_MS); + }); + + it("enters backoff after three failures, notifies once, and recovers", async () => { + const h = harness({ + push: async () => { + throw new Error("Push failed: could not resolve host"); + }, + }); + + for (let i = 1; i <= 3; i++) { + h.clock.now = T0 + i * 10 * MIN; + const outcome = await runAutosync(h.deps); + expect(outcome).toMatchObject({ action: "push", ok: false }); + } + let state = readState(roots); + expect(state.consecutiveFailures).toBe(3); + expect(h.notes).toEqual([ENGLISH_MESSAGES.backoffTitle]); + expect(backoffRemainingMs(state, h.clock.now)).toBe(30 * MIN); + + // Inside the backoff window nothing is attempted. + h.clock.now += 10 * MIN; + expect((await runAutosync(h.deps)).reason).toMatch(/backing off/); + + // A fourth failure widens the gap and does not notify again. + h.clock.now += 21 * MIN; + await runAutosync(h.deps); + state = readState(roots); + expect(state.consecutiveFailures).toBe(4); + expect(backoffRemainingMs(state, h.clock.now)).toBe(60 * MIN); + expect(h.notes).toHaveLength(1); + + // Never gives up: after the cap it tries again, and success resets. + h.deps.push = async () => result("Pushed 1 change(s)"); + h.clock.now += 61 * MIN; + expect((await runAutosync(h.deps)).ok).toBe(true); + state = readState(roots); + expect(state.consecutiveFailures).toBe(0); + expect(h.notes).toEqual([ENGLISH_MESSAGES.backoffTitle, ENGLISH_MESSAGES.recoveredTitle]); + }); + + it("treats a lost push race as contention, not failure", async () => { + const h = harness({ + push: async () => { + throw new PushRaceError("Another machine kept publishing first"); + }, + }); + for (let i = 0; i < 5; i++) { + h.clock.now = T0 + i * 10 * MIN; + await runAutosync(h.deps); + } + expect(readState(roots).consecutiveFailures ?? 0).toBe(0); + expect(h.notes).toHaveLength(0); + }); + + it("respects the notification toggle", async () => { + const h = harness({ + push: async () => { + throw new Error("network down"); + }, + }); + h.settings.notifications = false; + for (let i = 1; i <= 4; i++) { + h.clock.now = T0 + i * 40 * MIN; + await runAutosync(h.deps); + } + expect(h.notes).toHaveLength(0); + }); + + it("never confirms a large change: records a failure and notifies", async () => { + const h = harness({ + push: async () => { + throw new LargeChangeError({ + addedFiles: 999, + totalBytes: 1, + oversized: [], + reasons: ["files"], + }); + }, + }); + const outcome = await runAutosync(h.deps); + expect(outcome.ok).toBe(false); + expect(readState(roots).consecutiveFailures).toBe(1); + expect(h.notes).toEqual([ENGLISH_MESSAGES.largeTitle]); + }); + + it("tolerates a corrupted state file", async () => { + fs.writeFileSync( + path.join(roots.state, "opencode-sync-autosync.json"), + '{"consecutiveFailures":"41"', + ); + const h = harness(); + expect((await runAutosync(h.deps)).ok).toBe(true); + }); +}); + +describe("self-update", () => { + it("checks at most once a day and only when switched on", async () => { + let checks = 0; + const h = harness({ + selfUpdate: () => { + checks++; + return "up to date"; + }, + }); + await runAutosync(h.deps); + expect(checks).toBe(0); + h.settings.autosync.autoUpdate = true; + h.clock.now += 10 * MIN; + await runAutosync(h.deps); + h.clock.now += 10 * MIN; + await runAutosync(h.deps); + expect(checks).toBe(1); + h.clock.now += 25 * 60 * MIN; + await runAutosync(h.deps); + expect(checks).toBe(2); + }); +}); diff --git a/tests/installers.test.ts b/tests/installers.test.ts new file mode 100644 index 0000000..4cc4f59 --- /dev/null +++ b/tests/installers.test.ts @@ -0,0 +1,149 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * The installers are checked for syntax and argument handling only. `--dry-run` + * prints the plan and exits before anything is installed; the real install + * runs in the CI `installer` job on disposable runners. + */ + +const root = path.resolve(__dirname, ".."); +const installSh = path.join(root, "install.sh"); +const installPs1 = path.join(root, "install.ps1"); + +function findSh(): string | undefined { + const candidates = + process.platform === "win32" + ? ["C:\\Program Files\\Git\\bin\\sh.exe", "C:\\Program Files\\Git\\usr\\bin\\sh.exe"] + : ["/bin/sh"]; + return candidates.find((c) => fs.existsSync(c)); +} + +function hasCommand(cmd: string): boolean { + return !spawnSync(cmd, ["--version"], { stdio: "ignore" }).error; +} + +const sh = findSh(); +const pwsh = hasCommand("pwsh") ? "pwsh" : undefined; + +describe("install.sh", () => { + it.skipIf(!sh)("parses as POSIX sh", () => { + expect(spawnSync(sh!, ["-n", installSh]).status).toBe(0); + }); + + it.skipIf(!hasCommand("shellcheck"))("passes shellcheck", () => { + const result = spawnSync("shellcheck", ["-s", "sh", installSh], { encoding: "utf8" }); + expect(result.stdout + result.stderr).toBe(""); + }); + + it("is plain ASCII with LF line endings", () => { + const bytes = fs.readFileSync(installSh); + expect(bytes.includes(13)).toBe(false); + expect([...bytes].every((b) => b < 128)).toBe(true); + }); + + it.skipIf(!sh)("passes every option through to the CLI", () => { + const out = execFileSync( + sh!, + [ + installSh, + "--dry-run", + "--mode", + "auto", + "--repo=o/r", + "--interval", + "15", + "--lang", + "zh-CN", + "--proxy", + "http://127.0.0.1:7890", + ], + { encoding: "utf8" }, + ); + expect(out.split("\n")).toEqual([ + "npm install -g opencode-github-sync@latest", + "opencode-sync setup --mode auto --repo o/r --interval 15 --lang zh-CN --proxy http://127.0.0.1:7890", + "proxy http://127.0.0.1:7890", + "", + ]); + }); + + it.skipIf(!sh)("opens the menu when no mode is given", () => { + const out = execFileSync(sh!, [installSh, "--dry-run"], { encoding: "utf8" }); + expect(out).toBe("npm install -g opencode-github-sync@latest\nopencode-sync\n"); + }); + + it.skipIf(!sh)("rejects invalid options", () => { + for (const args of [ + ["--mode", "link"], + ["--mode", "bogus"], + ["--interval", "1"], + ["--interval", "abc"], + ["--lang", "fr"], + ["--unknown"], + ]) { + const result = spawnSync(sh!, [installSh, "--dry-run", ...args], { encoding: "utf8" }); + expect(result.status, args.join(" ")).toBe(2); + } + }); +}); + +describe("install.ps1", () => { + it("is plain ASCII", () => { + const bytes = fs.readFileSync(installPs1); + expect([...bytes].every((b) => b < 128)).toBe(true); + }); + + it.skipIf(!pwsh)("parses with the PowerShell parser", () => { + const script = `$e=$null; [void][System.Management.Automation.Language.Parser]::ParseFile('${installPs1.replace(/'/g, "''")}',[ref]$null,[ref]$e); $e.Count`; + const out = execFileSync(pwsh!, ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + }); + expect(out.trim()).toBe("0"); + }); + + it.skipIf(!pwsh)("passes parameters through, quoting paths with spaces", () => { + const out = execFileSync( + pwsh!, + [ + "-NoProfile", + "-NonInteractive", + "-File", + installPs1, + "-DryRun", + "-Mode", + "link", + "-Repo", + "C:\\my repos\\sync.git", + "-Interval", + "10", + "-Lang", + "en", + ], + { encoding: "utf8" }, + ); + expect(out.trim().split(/\r?\n/)).toEqual([ + "npm install -g opencode-github-sync@latest", + 'opencode-sync setup --mode link --repo "C:\\my repos\\sync.git" --interval 10 --lang en', + ]); + }); + + it.skipIf(!pwsh)("works as a script block, the documented way to pass parameters", () => { + const script = `$s=[IO.File]::ReadAllText('${installPs1.replace(/'/g, "''")}'); & ([scriptblock]::Create($s)) -Mode auto -Repo o/r -DryRun`; + const out = execFileSync(pwsh!, ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + }); + expect(out).toContain("opencode-sync setup --mode auto --repo o/r"); + }); + + it.skipIf(!pwsh)("refuses -Mode link without -Repo", () => { + const result = spawnSync( + pwsh!, + ["-NoProfile", "-NonInteractive", "-File", installPs1, "-DryRun", "-Mode", "link"], + { encoding: "utf8" }, + ); + expect(result.status).not.toBe(0); + }); +}); diff --git a/tests/menu.test.ts b/tests/menu.test.ts new file mode 100644 index 0000000..e8569b9 --- /dev/null +++ b/tests/menu.test.ts @@ -0,0 +1,333 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SchedulerApi, UpdateApi } from "../src/cli/app.js"; +import { type Lang, setLang } from "../src/cli/i18n.js"; +import { runMenu, runSetup } from "../src/cli/menu.js"; +import { setOutput } from "../src/cli/render.js"; +import { Ui } from "../src/cli/ui.js"; +import { git } from "../src/core/git.js"; +import type { Roots } from "../src/core/paths.js"; +import { CollectingReporter } from "../src/core/reporter.js"; +import { defaultSettings, loadSettings, saveSettings } from "../src/core/settings.js"; +import { push } from "../src/core/sync.js"; + +/** + * The menu, driven end to end with an injected input stream. Line mode is what + * runs whenever stdin is not a TTY, so every prompt is answerable from a pipe. + */ + +let sandbox: string; +let remote: string; +let roots: Roots; +let output: string; + +beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-menu-")); + remote = path.join(sandbox, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remote], { stdio: "pipe" }); + roots = makeRoots("me"); + output = ""; + setOutput({ + write: (text: string) => { + output += text; + return true; + }, + }); + process.env.OPENCODE_SYNC_NO_GH = "1"; +}); + +afterEach(() => { + setOutput(process.stdout); + setLang("en"); + delete process.env.OPENCODE_SYNC_NO_GH; + fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +function makeRoots(name: string): Roots { + const base = path.join(sandbox, name); + const r: Roots = { + config: path.join(base, "config"), + data: path.join(base, "data"), + state: path.join(base, "state"), + agents: path.join(base, "agents", "skills"), + cache: path.join(base, "cache"), + }; + for (const dir of Object.values(r)) fs.mkdirSync(dir, { recursive: true }); + return r; +} + +function fakeScheduler(): SchedulerApi & { installed: number; removed: number } { + const api = { + installed: 0, + removed: 0, + install: () => { + api.installed++; + return { ok: true, kind: "schtasks" as const, detail: [], warnings: [] }; + }, + uninstall: () => { + api.removed++; + return { removed: true }; + }, + status: () => ({ installed: api.installed > api.removed, kind: "schtasks" as const }), + elevated: () => false, + }; + return api; +} + +const fakeUpdater = (): UpdateApi & { installs: number } => { + const api = { + installs: 0, + check: () => ({ current: "3.2.0", latest: "3.3.0", newer: true }), + install: () => { + api.installs++; + }, + uninstall: () => {}, + }; + return api; +}; + +async function drive( + lines: string[], + lang: Lang, + extra: { scheduler?: SchedulerApi; updater?: UpdateApi } = {}, +) { + setLang(lang); + let echo = ""; + const ui = new Ui({ + input: Readable.from([`${lines.join("\n")}\n`]), + output: new Writable({ + write(chunk, _enc, cb) { + echo += chunk.toString(); + cb(); + }, + }), + interactive: false, + }); + const scheduler = extra.scheduler ?? fakeScheduler(); + await runMenu({ + ui, + roots, + scheduler, + updater: extra.updater ?? fakeUpdater(), + openEditor: () => true, + discover: () => [], + cwd: sandbox, + now: () => Date.UTC(2026, 9, 26, 8, 0, 0), + }); + return `${output}\n${echo}`; +} + +function linkSettings(): void { + const s = defaultSettings(); + s.repo = { url: remote, branch: "main" }; + s.machineAlias = "me"; + saveSettings(s, roots.config); +} + +describe("first run", () => { + it("install-only exits without writing settings (English)", async () => { + const text = await drive(["1"], "en"); + expect(text).toContain("This machine is not linked to a sync repository yet."); + expect(text).toContain("Just install the command"); + expect(fs.existsSync(path.join(roots.config, "opencode-sync.jsonc"))).toBe(false); + }); + + it("links an empty repository, pushes, and turns on autosync (Chinese)", async () => { + fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), '{ "model": "x" }\n'); + const scheduler = fakeScheduler(); + const text = await drive(["3", "2", remote, "q"], "zh-CN", { scheduler }); + expect(text).toContain("这台电脑还没有关联同步仓库。"); + expect(text).toContain("仓库是空的"); + expect(text).toContain("自动同步已开启"); + expect(scheduler.installed).toBe(1); + const settings = loadSettings(roots.config); + expect(settings.autosync.enabled).toBe(true); + expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toContain("refs/heads/main"); + }); + + it("shows the differences, then lets the repository win", async () => { + const other = makeRoots("other"); + fs.writeFileSync(path.join(other.config, "opencode.jsonc"), '{ "model": "shared" }\n'); + const s = defaultSettings(); + s.repo = { url: remote, branch: "main" }; + await push({ settings: s, roots: other, reporter: new CollectingReporter() }); + + fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), '{ "model": "mine" }\n'); + const text = await drive(["2", "2", remote, "3", "1", "q"], "en"); + expect(text).toContain("differ from the repository in 1 place"); + expect(text).toContain("different"); + expect(fs.readFileSync(path.join(roots.config, "opencode.jsonc"), "utf8")).toContain("shared"); + expect(text).toContain("Replaced files were saved to"); + }); +}); + +describe("main menu", () => { + it("shows the header and every item in both languages", async () => { + linkSettings(); + const en = await drive(["q"], "en"); + for (const label of [ + "repository", + "this machine", + "autosync", + "Sync now", + "What to sync", + "Autosync", + "Update", + "This machine's overrides", + "Diagnostics", + "Danger zone", + "Uninstall", + "Network proxy", + "Restore a file from history", + "Devices", + "Large-change guard", + "Language", + ]) { + expect(en).toContain(label); + } + output = ""; + const zh = await drive(["q"], "zh-CN"); + for (const label of [ + "立即同步", + "同步哪些内容", + "自动同步", + "危险操作", + "从历史版本恢复文件", + "大改动保护", + ]) { + expect(zh).toContain(label); + } + }); + + it("toggles categories with the checkbox and persists them", async () => { + linkSettings(); + // 2 What to sync -> 1 categories -> toggle 2 (instructions) and 6 (skills) -> save + await drive(["2", "1", "2 6", "", "b", "q"], "en"); + const s = loadSettings(roots.config); + expect(s.categories.instructions).toBe(false); + expect(s.includeSkills).toBe(false); + expect(s.categories.config).toBe(true); + }); + + it("asks before syncing credentials when visibility cannot be checked", async () => { + linkSettings(); + await drive(["2", "1", "7", "", "n", "b", "q"], "en"); + expect(loadSettings(roots.config).includeCredentials).toBe(false); + }); + + it("changes the interval, pauses and resumes autosync", async () => { + linkSettings(); + const scheduler = fakeScheduler(); + await drive(["3", "1", "2", "2", "4", "2", "b", "q"], "en", { scheduler }); + let s = loadSettings(roots.config); + expect(s.autosync.enabled).toBe(true); + expect(s.autosync.intervalMinutes).toBe(10); + expect(s.autosync.pausedUntil).toBe(Date.UTC(2026, 9, 26, 12, 0, 0)); + expect(scheduler.installed).toBe(2); + + await drive(["3", "4", "4", "1", "b", "q"], "en", { scheduler }); + s = loadSettings(roots.config); + expect(s.autosync.pausedUntil).toBe(0); + expect(s.autosync.enabled).toBe(false); + expect(scheduler.removed).toBe(1); + }); + + it("rejects an interval below two minutes", async () => { + linkSettings(); + await drive(["3", "2", "c", "1", "b", "q"], "en"); + expect(loadSettings(roots.config).autosync.intervalMinutes).toBe(5); + }); + + it("saves a proxy and refuses a malformed one", async () => { + linkSettings(); + await drive(["p", "not a url", "q"], "en"); + expect(loadSettings(roots.config).proxy).toBe(""); + await drive(["p", "http://127.0.0.1:7890", "q"], "en"); + expect(loadSettings(roots.config).proxy).toBe("http://127.0.0.1:7890"); + }); + + it("switches the language and persists it", async () => { + linkSettings(); + const text = await drive(["l", "3", "q"], "en"); + expect(loadSettings(roots.config).language).toBe("zh-CN"); + expect(text).toContain("语言已保存。"); + }); + + it("requires the confirmation word in the danger zone", async () => { + linkSettings(); + fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n"); + const text = await drive(["7", "2", "nope", "q"], "en"); + expect(text).toContain("Cancelled."); + expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toBe(""); + }); + + it("renames this machine from the device list", async () => { + linkSettings(); + await drive(["d", "1", "Work Laptop", "q"], "en"); + expect(loadSettings(roots.config).machineAlias).toBe("Work-Laptop"); + }); + + it("checks for updates and upgrades on request", async () => { + linkSettings(); + const updater = fakeUpdater(); + const text = await drive(["4", "1", "2"], "en", { updater }); + expect(text).toContain("Installed 3.2.0 · latest 3.3.0"); + expect(updater.installs).toBe(1); + }); + + it("writes a redacted support bundle", async () => { + linkSettings(); + const s = loadSettings(roots.config); + s.proxy = "http://user:secret@proxy:8080"; + saveSettings(s, roots.config); + await drive(["6", "1", "q"], "en"); + const bundle = fs.readdirSync(sandbox).find((f) => f.startsWith("opencode-sync-support-")); + expect(bundle).toBeDefined(); + const tar = execFileSync("tar", ["-xzOf", path.join(sandbox, bundle!), "settings.json"], { + encoding: "utf8", + }); + expect(tar).not.toContain("secret"); + }); + + it("uninstall removes the job and keeps configuration files", async () => { + linkSettings(); + fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n"); + const scheduler = fakeScheduler(); + await drive(["8", "y", "n"], "en", { scheduler }); + expect(scheduler.removed).toBe(1); + expect(loadSettings(roots.config).repo.url).toBeUndefined(); + expect(fs.existsSync(path.join(roots.config, "opencode.jsonc"))).toBe(true); + }); +}); + +describe("non-interactive setup", () => { + const ui = () => + new Ui({ + input: Readable.from([""]), + output: new Writable({ write: (_c, _e, cb) => cb() }), + interactive: false, + }); + + it("--mode auto links, pushes and enables autosync", async () => { + fs.writeFileSync(path.join(roots.config, "opencode.jsonc"), "{}\n"); + const scheduler = fakeScheduler(); + await runSetup( + { mode: "auto", repo: remote, interval: 15, lang: "zh-CN", proxy: "http://127.0.0.1:1" }, + { ui: ui(), roots, scheduler }, + ); + const s = loadSettings(roots.config); + expect(s.autosync.enabled).toBe(true); + expect(s.autosync.intervalMinutes).toBe(15); + expect(s.language).toBe("zh-CN"); + expect(s.proxy).toBe("http://127.0.0.1:1"); + expect(scheduler.installed).toBe(1); + }); + + it("--mode link without --repo fails clearly", async () => { + await expect(runSetup({ mode: "link" }, { ui: ui(), roots })).rejects.toThrow(/--repo/); + }); +}); diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts new file mode 100644 index 0000000..b244371 --- /dev/null +++ b/tests/scheduler.test.ts @@ -0,0 +1,248 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + CRON_MARKER, + type InstallContext, + type Runner, + type ScheduleSpec, + crontabLine, + editCrontab, + encodeVbs, + installScheduler, + launchdPlist, + quoteWindowsArg, + systemdService, + systemdTimer, + uninstallScheduler, + windowsTaskXml, + windowsVbs, +} from "../src/core/scheduler.js"; + +const windowsSpec: ScheduleSpec = { + node: "C:\\Program Files\\nodejs\\node.exe", + cli: "C:\\Users\\鸽子 Lee\\AppData\\Roaming\\npm\\node_modules\\opencode-github-sync\\dist\\cli\\index.js", + intervalMinutes: 5, + env: {}, + logDir: "C:\\Users\\鸽子 Lee\\.config\\opencode\\logs", +}; + +const posixSpec: ScheduleSpec = { + node: "/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node", + cli: "/Users/李 雷/.nvm/versions/node/v22.11.0/lib/node_modules/opencode-github-sync/dist/cli/index.js", + intervalMinutes: 10, + env: { PATH: "/Users/李 雷/.nvm/versions/node/v22.11.0/bin:/usr/bin:/bin" }, + logDir: "/Users/李 雷/.config/opencode/logs", +}; + +describe("Windows Task Scheduler", () => { + it("renders the task XML with conhost --headless", () => { + const xml = windowsTaskXml(windowsSpec, { + userId: "DESKTOP-1\\鸽子", + logonType: "InteractiveToken", + launcher: { kind: "conhost", conhost: "C:\\Windows\\System32\\conhost.exe" }, + startBoundary: "2026-10-26T08:00:00", + }); + expect(xml).toMatchSnapshot(); + expect(xml).toContain("IgnoreNew"); + expect(xml).toContain("PT10M"); + expect(xml).toContain("false"); + expect(xml).toContain("PT5M"); + expect(xml).toContain("SessionUnlock"); + expect(xml).toContain("RemoteConnect"); + expect(xml).toContain("PT30S"); + expect(xml).toContain("InteractiveToken"); + expect(xml).toContain("LeastPrivilege"); + }); + + it("renders S4U with the wscript launcher", () => { + const xml = windowsTaskXml(windowsSpec, { + userId: "CORP\\alex", + logonType: "S4U", + launcher: { + kind: "wscript", + wscript: "C:\\Windows\\System32\\wscript.exe", + vbs: "C:\\Users\\鸽子 Lee\\.config\\opencode\\opencode-sync-autosync.vbs", + }, + startBoundary: "2026-10-26T08:00:00", + }); + expect(xml).toMatchSnapshot(); + expect(xml).toContain("S4U"); + }); + + it("escapes XML metacharacters in paths", () => { + const xml = windowsTaskXml( + { ...windowsSpec, cli: "C:\\A&B \\index.js" }, + { + userId: "PC\\a&b", + logonType: "InteractiveToken", + launcher: { kind: "conhost", conhost: "C:\\Windows\\System32\\conhost.exe" }, + startBoundary: "2026-10-26T08:00:00", + }, + ); + expect(xml).toContain("A&B <x>"); + expect(xml).toContain("PC\\a&b"); + }); + + it("quotes Windows command-line arguments", () => { + expect(quoteWindowsArg("plain")).toBe("plain"); + expect(quoteWindowsArg("C:\\Program Files\\x")).toBe('"C:\\Program Files\\x"'); + expect(quoteWindowsArg('a"b')).toBe('"a\\"b"'); + expect(quoteWindowsArg("C:\\dir with space\\")).toBe('"C:\\dir with space\\\\"'); + expect(quoteWindowsArg("")).toBe('""'); + }); + + it("writes the VBScript launcher as UTF-16LE with a BOM", () => { + const text = windowsVbs({ ...windowsSpec, env: { SYNC_HOME: "C:\\Users\\鸽子 Lee" } }); + expect(text).toMatchSnapshot(); + const bytes = encodeVbs(text); + expect(bytes[0]).toBe(0xff); + expect(bytes[1]).toBe(0xfe); + expect(bytes.subarray(2).toString("utf16le")).toBe(text); + expect(text).toContain("鸽子 Lee"); + expect(text).toContain(", 0, True)"); + }); +}); + +describe("macOS launchd", () => { + it("renders the agent plist", () => { + const plist = launchdPlist(posixSpec); + expect(plist).toMatchSnapshot(); + expect(plist).toContain("RunAtLoad\n "); + expect(plist).toContain("600"); + expect(plist).toContain("/Users/李 雷/.nvm/versions/node/v22.11.0/bin/node"); + }); +}); + +describe("Linux systemd and cron", () => { + it("renders the user service and timer", () => { + expect(systemdService(posixSpec)).toMatchSnapshot(); + const timer = systemdTimer(posixSpec); + expect(timer).toMatchSnapshot(); + expect(timer).toContain("OnBootSec=1min"); + expect(timer).toContain("OnUnitActiveSec=10min"); + expect(timer).toContain("Persistent=true"); + expect(systemdService(posixSpec)).toContain('ExecStart="/Users/李 雷/'); + }); + + it("renders a crontab line and edits only its own entry", () => { + const line = crontabLine(posixSpec); + expect(line).toMatchSnapshot(); + expect(line.startsWith("*/10 * * * * ")).toBe(true); + const existing = "0 3 * * * /usr/bin/backup\n"; + const added = editCrontab(existing, line); + expect(added).toBe(`0 3 * * * /usr/bin/backup\n${line}\n`); + expect(editCrontab(added, line)).toBe(added); + expect(editCrontab(added, undefined)).toBe(existing); + expect(line).toContain(CRON_MARKER); + }); +}); + +/** A fake platform tool runner, so installation logic runs without registering anything. */ +function fakeRunner(responses: Record): Runner & { + calls: string[]; +} { + const calls: string[] = []; + return { + calls, + run(cmd, args, input) { + const key = `${path.basename(cmd).toLowerCase()} ${args.join(" ")}`; + calls.push(input ? `${key} <<${input.trim()}` : key); + const match = Object.entries(responses).find(([prefix]) => key.startsWith(prefix)); + const r = match?.[1] ?? { ok: true }; + return { ok: r.ok, stdout: r.stdout ?? "", stderr: r.ok ? "" : "failed" }; + }, + }; +} + +describe("installation wrappers (no real registration)", () => { + const base = (platform: InstallContext["platform"], runner: Runner): InstallContext => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-sched-")); + return { + platform, + spec: { ...posixSpec, logDir: path.join(home, "logs") }, + home, + runner, + configRoot: path.join(home, "config"), + runWhenLoggedOff: false, + elevated: false, + }; + }; + + it("falls back to cron when systemd --user is unavailable", () => { + const runner = fakeRunner({ + "systemctl --user show-environment": { ok: false }, + "crontab -l": { ok: false }, + }); + const ctx = base("linux", runner); + const result = installScheduler(ctx); + expect(result).toMatchObject({ ok: true, kind: "cron" }); + expect(runner.calls.some((c) => c.startsWith("crontab - <<") && c.includes(CRON_MARKER))).toBe( + true, + ); + fs.rmSync(ctx.home, { recursive: true, force: true }); + }); + + it("writes and enables the systemd timer when available", () => { + const runner = fakeRunner({ "systemctl --user is-enabled": { ok: true, stdout: "enabled" } }); + const ctx = base("linux", runner); + const saved = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = path.join(ctx.home, ".config"); + try { + const result = installScheduler(ctx); + expect(result).toMatchObject({ ok: true, kind: "systemd" }); + const unitDir = path.join(ctx.home, ".config", "systemd", "user"); + expect(fs.existsSync(path.join(unitDir, "opencode-github-sync.timer"))).toBe(true); + expect(runner.calls).toContain("systemctl --user enable --now opencode-github-sync.timer"); + expect(uninstallScheduler(ctx).removed).toBe(true); + expect(fs.existsSync(path.join(unitDir, "opencode-github-sync.timer"))).toBe(false); + } finally { + if (saved === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = saved; + fs.rmSync(ctx.home, { recursive: true, force: true }); + } + }); + + it("writes the plist and bootstraps it on macOS", () => { + const runner = fakeRunner({}); + const ctx = base("darwin", runner); + expect(installScheduler(ctx)).toMatchObject({ ok: true, kind: "launchd" }); + const plist = path.join( + ctx.home, + "Library", + "LaunchAgents", + "com.opencode-github-sync.autosync.plist", + ); + expect(fs.existsSync(plist)).toBe(true); + expect(runner.calls.some((c) => c.startsWith("launchctl bootstrap gui/"))).toBe(true); + uninstallScheduler(ctx); + expect(fs.existsSync(plist)).toBe(false); + fs.rmSync(ctx.home, { recursive: true, force: true }); + }); + + it("registers through schtasks and verifies the next run time on Windows", () => { + const runner = fakeRunner({ + "whoami ": { ok: true, stdout: "pc\\alex\r\n" }, + "powershell.exe": { ok: true, stdout: "2026-10-26T08:05:00\r\n" }, + }); + const ctx = base("win32", runner); + ctx.runWhenLoggedOff = true; + ctx.elevated = false; + const result = installScheduler(ctx); + expect(result.ok).toBe(true); + expect(result.warnings.join(" ")).toMatch(/not elevated/); + expect(result.detail.join(" ")).toContain("logon InteractiveToken"); + expect( + runner.calls.some((c) => c.startsWith("schtasks /Create /TN opencode-github-sync /XML")), + ).toBe(true); + fs.rmSync(ctx.home, { recursive: true, force: true }); + }); + + it("fails the Windows install when no next run is scheduled", () => { + const runner = fakeRunner({ "powershell.exe": { ok: true, stdout: "" } }); + const ctx = base("win32", runner); + expect(installScheduler(ctx)).toMatchObject({ ok: false }); + fs.rmSync(ctx.home, { recursive: true, force: true }); + }); +}); diff --git a/tests/two-machines.test.ts b/tests/two-machines.test.ts new file mode 100644 index 0000000..24d2fb7 --- /dev/null +++ b/tests/two-machines.test.ts @@ -0,0 +1,376 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ENGLISH_MESSAGES, defaultDeps, readState, runAutosync } from "../src/core/autosync.js"; +import { compareWithRemote } from "../src/core/compare.js"; +import { git } from "../src/core/git.js"; +import { LargeChangeError } from "../src/core/guard.js"; +import { fileHistory, listDevices, restoreFile } from "../src/core/history.js"; +import type { Roots } from "../src/core/paths.js"; +import { listRecovery } from "../src/core/recovery.js"; +import { CollectingReporter } from "../src/core/reporter.js"; +import { type Settings, defaultSettings, saveSettings } from "../src/core/settings.js"; +import { pull, push } from "../src/core/sync.js"; + +/** + * Two simulated machines, each with its own set of roots, syncing through a + * bare repository on disk. Nothing touches the real home directory. + */ + +let sandbox: string; +let remote: string; + +beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-2m-")); + remote = path.join(sandbox, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remote], { stdio: "pipe" }); + process.env.OPENCODE_SYNC_NO_STAGGER = "1"; +}); + +afterEach(() => { + delete process.env.OPENCODE_SYNC_NO_STAGGER; + delete process.env.OPENCODE_SYNC_HOST_ALIAS; + fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +interface Machine { + name: string; + roots: Roots; + settings: Settings; +} + +function machine(name: string, patch: (s: Settings) => void = () => {}): Machine { + const base = path.join(sandbox, name); + const roots: Roots = { + config: path.join(base, "config"), + data: path.join(base, "data"), + state: path.join(base, "state"), + agents: path.join(base, "agents", "skills"), + cache: path.join(base, "cache"), + }; + for (const dir of Object.values(roots)) fs.mkdirSync(dir, { recursive: true }); + const settings = defaultSettings(); + settings.repo = { url: remote, branch: "main" }; + settings.machineAlias = name; + settings.autosync.enabled = true; + settings.autosync.settleSeconds = 60; + patch(settings); + saveSettings(settings, roots.config); + return { name, roots, settings }; +} + +const write = (file: string, content: string, mtime?: number): void => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + if (mtime) fs.utimesSync(file, new Date(mtime), new Date(mtime)); +}; +const read = (file: string): string => fs.readFileSync(file, "utf8"); + +/** Push with the machine's own settings. */ +const pushFrom = (m: Machine, extra = {}) => + push({ settings: m.settings, roots: m.roots, reporter: new CollectingReporter(), ...extra }); +const pullTo = (m: Machine, extra = {}) => + pull({ settings: m.settings, roots: m.roots, reporter: new CollectingReporter(), ...extra }); + +/** Age every file under the machine's trees so the settle rule sees quiet. */ +function settle(m: Machine, ageMs = 10 * 60_000): void { + const stamp = new Date(Date.now() - ageMs); + const walk = (dir: string) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.name === ".git") continue; + if (e.isDirectory()) walk(p); + else fs.utimesSync(p, stamp, stamp); + } + }; + for (const dir of [m.roots.config, m.roots.agents]) if (fs.existsSync(dir)) walk(dir); +} + +function autosync(m: Machine) { + const deps = defaultDeps(m.roots, { + home: path.join(sandbox, m.name, "home"), + notify: () => false, + messages: ENGLISH_MESSAGES, + sleep: async () => {}, + }); + return runAutosync(deps, "test"); +} + +describe("autosync between two machines", () => { + it("propagates an edit from A to B through scheduled runs", async () => { + const a = machine("alpha"); + const b = machine("beta"); + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "one" }\n'); + write(path.join(a.roots.agents, "s1", "SKILL.md"), "skill one\n"); + settle(a); + expect((await autosync(a)).action).toBe("push"); + + // B starts empty: its first scheduled run sends nothing and receives. + settle(b); + const first = await autosync(b); + expect(first.ok).toBe(true); + expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain("one"); + expect(read(path.join(b.roots.agents, "s1", "SKILL.md"))).toBe("skill one\n"); + + // A edits again; B picks it up on its next due run. + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "two" }\n'); + settle(a); + fs.rmSync(path.join(a.roots.state, "opencode-sync-autosync.json"), { force: true }); + expect((await autosync(a)).action).toBe("push"); + settle(b); + fs.rmSync(path.join(b.roots.state, "opencode-sync-autosync.json"), { force: true }); + expect((await autosync(b)).ok).toBe(true); + expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain("two"); + }); + + it("receives only while this machine's files are still changing", async () => { + const a = machine("alpha"); + const b = machine("beta"); + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "one" }\n'); + await pushFrom(a); + await pullTo(b); + + write(path.join(a.roots.config, "AGENTS.md"), "from alpha\n"); + await pushFrom(a); + + // B is busy: a file just changed in its config directory, but the edit is + // in a tracked file, so the repository is dirty and nothing is received. + write(path.join(b.roots.config, "notes.md"), "typing...\n"); + let outcome = await autosync(b); + expect(outcome.action).toBe("skip"); + expect(fs.existsSync(path.join(b.roots.config, "AGENTS.md"))).toBe(false); + + // Once the edit is gone but the tree was just touched, receive-only runs. + fs.rmSync(path.join(b.roots.config, "notes.md")); + write(path.join(b.roots.cache, "touch"), "x"); + const busy = path.join(b.roots.config, "opencode.jsonc"); + fs.utimesSync(busy, new Date(), new Date()); + outcome = await autosync(b); + expect(outcome.action).toBe("receive"); + expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("from alpha\n"); + expect(git(["log", "-1", "--format=%s"], { cwd: b.roots.config }).stdout).toContain("alpha"); + }); + + it("skips while another process holds the lock", async () => { + const a = machine("alpha"); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + settle(a); + const lockDir = path.join(a.roots.config, ".opencode-sync.lock"); + fs.mkdirSync(lockDir); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + JSON.stringify({ id: "x", pid: process.pid, startedAt: new Date().toISOString() }), + ); + const outcome = await autosync(a); + expect(outcome).toMatchObject({ action: "skip", reason: "another sync is in progress" }); + fs.rmSync(lockDir, { recursive: true }); + }); + + it("records failures and backs off when the remote is unreachable", async () => { + const a = machine("alpha", (s) => { + s.repo.url = path.join(sandbox, "missing.git"); + }); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + settle(a); + const outcome = await autosync(a); + expect(outcome.ok).toBe(false); + expect(readState(a.roots).consecutiveFailures).toBe(1); + }); +}); + +describe("push race", () => { + it("rebases and retries when another machine published in between", async () => { + const a = machine("alpha"); + const b = machine("beta"); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + await pushFrom(a); + await pullTo(b); + + write(path.join(a.roots.config, "a.md"), "a\n"); + write(path.join(b.roots.config, "b.md"), "b\n"); + + // Install a pre-push hook on B that lets A publish first, once. + const hook = path.join(b.roots.config, ".git", "hooks", "pre-push"); + const marker = path.join(sandbox, "raced"); + const script = [ + "#!/bin/sh", + `if [ ! -f '${marker.replace(/\\/g, "/")}' ]; then`, + ` touch '${marker.replace(/\\/g, "/")}'`, + ` cd '${a.roots.config.replace(/\\/g, "/")}' && git add -A && git commit -qm 'race: from alpha' && git push -q origin main`, + "fi", + "exit 0", + "", + ].join("\n"); + fs.writeFileSync(hook, script, { mode: 0o755 }); + + const result = await pushFrom(b); + expect(result.changed).toBe(true); + const log = git(["log", "--format=%s", "origin/main"], { cwd: b.roots.config }).stdout; + expect(log).toContain("race: from alpha"); + expect(log.split("\n")[0]).toContain("beta"); + expect(read(path.join(b.roots.config, "a.md"))).toBe("a\n"); + }); +}); + +describe("large-change guard", () => { + it("refuses an oversized push unless confirmed", async () => { + const a = machine("alpha", (s) => { + s.guard.maxFiles = 5; + }); + for (let i = 0; i < 8; i++) write(path.join(a.roots.config, "command", `c${i}.md`), `${i}\n`); + await expect(pushFrom(a)).rejects.toBeInstanceOf(LargeChangeError); + expect(git(["ls-remote", remote], { cwd: sandbox }).stdout).toBe(""); + + let asked = 0; + const confirmed = await pushFrom(a, { + confirmLarge: () => { + asked++; + return true; + }, + }); + expect(asked).toBe(1); + expect(confirmed.changed).toBe(true); + }); + + it("flags a single oversized file", async () => { + const a = machine("alpha", (s) => { + s.guard.maxFileBytes = 1024; + }); + write(path.join(a.roots.config, "big.bin"), "x".repeat(4096)); + const dry = await pushFrom(a, { dryRun: true }); + expect(dry.guard?.reasons).toContain("file"); + expect(dry.guard?.oversized[0]?.path).toBe("big.bin"); + }); + + it("never stages generated directories inside synced trees", async () => { + const a = machine("alpha"); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + write(path.join(a.roots.config, "tools", ".venv", "lib", "x.py"), "x\n"); + write(path.join(a.roots.config, "tools", "dist", "bundle.js"), "x\n"); + write(path.join(a.roots.config, "tools", "main.py"), "x\n"); + await pushFrom(a); + const files = git(["ls-files"], { cwd: a.roots.config }).stdout; + expect(files).toContain("tools/main.py"); + expect(files).not.toContain(".venv"); + expect(files).not.toContain("dist/bundle.js"); + }); +}); + +describe("category toggles", () => { + it("keeps a disabled category local in both directions", async () => { + const a = machine("alpha"); + const b = machine("beta", (s) => { + s.categories.instructions = false; + }); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + write(path.join(a.roots.config, "AGENTS.md"), "shared\n"); + await pushFrom(a); + + write(path.join(b.roots.config, "AGENTS.md"), "local only\n"); + await pullTo(b); + expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("local only\n"); + expect(fs.existsSync(path.join(b.roots.config, "opencode.jsonc"))).toBe(true); + + write(path.join(b.roots.config, "AGENTS.md"), "local edit\n"); + write(path.join(b.roots.config, "command", "x.md"), "x\n"); + await pushFrom(b); + const shared = git(["show", "origin/main:AGENTS.md"], { cwd: b.roots.config }).stdout; + expect(shared).toBe("shared"); + expect(read(path.join(b.roots.config, "AGENTS.md"))).toBe("local edit\n"); + + await pullTo(a); + expect(read(path.join(a.roots.config, "command", "x.md"))).toBe("x\n"); + expect(read(path.join(a.roots.config, "AGENTS.md"))).toBe("shared\n"); + }); +}); + +describe("history", () => { + it("restores an earlier version and keeps the replaced bytes", async () => { + const a = machine("alpha"); + const file = path.join(a.roots.config, "AGENTS.md"); + write(file, "version 1\n"); + await pushFrom(a); + write(file, "version 2\n"); + await pushFrom(a); + + const history = fileHistory(a.roots.config, "AGENTS.md"); + expect(history).toHaveLength(2); + const result = restoreFile(a.roots, "AGENTS.md", history[1]!.commit); + expect(read(file)).toBe("version 1\n"); + expect(result.recovery).toBeDefined(); + expect(read(path.join(result.recovery!, "AGENTS.md"))).toBe("version 2\n"); + }); + + it("lists devices from trailers and legacy subjects", async () => { + const a = machine("alpha"); + const b = machine("beta"); + write(path.join(a.roots.config, "opencode.jsonc"), "{}\n"); + await pushFrom(a); + await pullTo(b); + write(path.join(b.roots.config, "x.md"), "x\n"); + await pushFrom(b); + // A commit from an older release has no trailer, only the subject. + write(path.join(b.roots.config, "y.md"), "y\n"); + git(["add", "-A"], { cwd: b.roots.config }); + git(["commit", "-qm", "sync: 2026-01-01 00:00:00 from gamma-old"], { cwd: b.roots.config }); + + const devices = listDevices(b.roots.config).map((d) => d.alias); + expect(devices).toEqual(["gamma-old", "beta", "alpha"]); + }); +}); + +describe("first link", () => { + it("compares local files with the remote without touching either", async () => { + const a = machine("alpha"); + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n'); + write(path.join(a.roots.config, "AGENTS.md"), "same\n"); + await pushFrom(a); + + const b = machine("beta"); + write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\r\n'); + write(path.join(b.roots.config, "AGENTS.md"), "same\r\n"); + write(path.join(b.roots.config, "mine.md"), "mine\n"); + + const diff = compareWithRemote(b.roots.config, remote, "main"); + expect(diff.empty).toBe(false); + expect(diff.differences).toEqual([ + { path: "mine.md", kind: "onlyLocal" }, + { path: "opencode.jsonc", kind: "different" }, + ]); + expect(fs.existsSync(path.join(b.roots.config, ".git"))).toBe(false); + + const empty = path.join(sandbox, "empty.git"); + execFileSync("git", ["init", "--bare", "-b", "main", empty], { stdio: "pipe" }); + expect(compareWithRemote(b.roots.config, empty, "main").empty).toBe(true); + }); + + it("repository wins: local files go to recovery first", async () => { + const a = machine("alpha"); + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n'); + await pushFrom(a); + + const b = machine("beta"); + write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\n'); + await pullTo(b); + expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain('"a"'); + const saved = listRecovery(b.roots)[0]; + expect(saved).toBeDefined(); + expect(read(path.join(saved!, "opencode.jsonc"))).toContain('"b"'); + }); + + it("local wins: this machine's files are pushed on top", async () => { + const a = machine("alpha"); + write(path.join(a.roots.config, "opencode.jsonc"), '{ "model": "a" }\n'); + write(path.join(a.roots.config, "theirs.md"), "theirs\n"); + await pushFrom(a); + + const b = machine("beta"); + write(path.join(b.roots.config, "opencode.jsonc"), '{ "model": "b" }\n'); + await pushFrom(b, { adopt: "local" }); + const shared = git(["show", "origin/main:opencode.jsonc"], { cwd: b.roots.config }).stdout; + expect(shared).toContain('"b"'); + expect(read(path.join(b.roots.config, "opencode.jsonc"))).toContain('"b"'); + }); +}); diff --git a/tests/units.test.ts b/tests/units.test.ts new file mode 100644 index 0000000..f42233d --- /dev/null +++ b/tests/units.test.ts @@ -0,0 +1,228 @@ +import { execFileSync } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { gunzipSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { parseArgs } from "../src/cli/args.js"; +import { CATALOGS, detectLang, normalizeLang, t } from "../src/cli/i18n.js"; +import { Ui } from "../src/cli/ui.js"; +import { redactText, redactValue, tarGz } from "../src/core/bundle.js"; +import { disabledCategories, disabledPathspecs, isDisabledPath } from "../src/core/categories.js"; +import { gitBlobId } from "../src/core/compare.js"; +import { proxyEnv } from "../src/core/env.js"; +import { excludeFileContent } from "../src/core/guard.js"; +import { appleScriptNotification, windowsToastScript } from "../src/core/notify.js"; +import { defaultSettings, idlePushEnabled } from "../src/core/settings.js"; +import { githubTargetFromUrl, isRepoUrl } from "../src/core/setup.js"; +import { compareVersions } from "../src/core/update.js"; + +describe("i18n", () => { + it("has identical keys in every catalogue", () => { + const en = Object.keys(CATALOGS.en).sort(); + const zh = Object.keys(CATALOGS["zh-CN"]).sort(); + expect(zh).toEqual(en); + }); + + it("uses the same placeholders in both languages", () => { + for (const key of Object.keys(CATALOGS.en) as (keyof typeof CATALOGS.en)[]) { + const names = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); + expect(names(CATALOGS["zh-CN"][key]), key).toEqual(names(CATALOGS.en[key])); + } + }); + + it("has no empty strings", () => { + for (const catalog of Object.values(CATALOGS)) { + for (const [key, value] of Object.entries(catalog)) expect(value.trim(), key).not.toBe(""); + } + }); + + it("detects the language in the documented order", () => { + const intl = () => "fr-FR"; + expect(detectLang("zh-CN", "en", {}, intl)).toBe("zh-CN"); + expect(detectLang(undefined, "en", { OPENCODE_SYNC_LANG: "zh" }, intl)).toBe("zh-CN"); + expect(detectLang(undefined, "zh-CN", { LANG: "en_US.UTF-8" }, intl)).toBe("zh-CN"); + expect(detectLang(undefined, "", { LC_ALL: "zh_CN.UTF-8", LANG: "en_US" }, intl)).toBe("zh-CN"); + expect(detectLang(undefined, "", { LANG: "C" }, () => "zh-Hans-CN")).toBe("zh-CN"); + expect(detectLang(undefined, "", {}, intl)).toBe("en"); + expect(normalizeLang("POSIX")).toBeUndefined(); + }); + + it("fills placeholders", () => { + expect(t("autoOn", { n: 5 }, "en")).toBe("Autosync is on: every 5 min."); + expect(t("autoOn", { n: 5 }, "zh-CN")).toBe("自动同步已开启:每 5 分钟一次。"); + }); +}); + +describe("argument parsing", () => { + it("defaults to the menu", () => { + expect(parseArgs([]).command).toBe("menu"); + expect(parseArgs(["--help"]).command).toBe("help"); + }); + + it("accepts both --key value and --key=value", () => { + const args = parseArgs(["setup", "--mode", "auto", "--repo=o/r", "--interval", "10", "--yes"]); + expect(args.command).toBe("setup"); + expect(args.values.get("mode")).toBe("auto"); + expect(args.values.get("repo")).toBe("o/r"); + expect(args.values.get("interval")).toBe("10"); + expect(args.flags.has("yes")).toBe(true); + }); + + it("keeps boolean flags boolean", () => { + const args = parseArgs(["push", "--force", "--dry-run"]); + expect(args.flags.has("force")).toBe(true); + expect(args.flags.has("dry-run")).toBe(true); + expect(args.positional).toEqual([]); + }); +}); + +describe("categories", () => { + it("maps disabled categories to repository paths", () => { + const s = defaultSettings(); + s.categories.instructions = false; + s.categories.plugins = false; + const disabled = disabledCategories(s.categories); + expect(disabled).toEqual(["instructions", "plugins"]); + expect(isDisabledPath("AGENTS.md", disabled)).toBe(true); + expect(isDisabledPath("skills/x/SKILL.md", disabled)).toBe(false); + expect(isDisabledPath("plugin/x.ts", disabled)).toBe(true); + expect(isDisabledPath("opencode.jsonc", disabled)).toBe(false); + expect(disabledPathspecs(disabled)).toContain(":(top,glob)*.md"); + }); +}); + +describe("support bundle redaction", () => { + it("removes tokens, passwords and credentials in URLs", () => { + const text = [ + "remote https://user:hunter2@github.com/o/r.git", + "token ghp_abcdefghijklmnopqrstuvwxyz0123456789", + '"apiKey": "sk-1234567890abcdefghij"', + "password=letmein", + "Authorization: Bearer abc.def.ghi", + ].join("\n"); + const out = redactText(text); + for (const secret of ["hunter2", "ghp_abcdef", "sk-1234567890", "letmein", "abc.def.ghi"]) { + expect(out).not.toContain(secret); + } + expect(out).toContain("github.com/o/r.git"); + }); + + it("blanks secret-looking keys in structured settings", () => { + const value = redactValue({ + proxy: "http://u:p@proxy:8080", + repo: { url: "x" }, + token: "abc", + }) as any; + expect(value.token).toBe("[REDACTED]"); + expect(value.proxy).not.toContain("u:p@"); + }); + + it("writes a tar archive that tar can read", () => { + const archive = tarGz([{ name: "a.txt", content: "hello" }]); + const tar = gunzipSync(archive); + expect(tar.subarray(0, 5).toString()).toBe("a.txt"); + expect(tar.subarray(512, 517).toString()).toBe("hello"); + expect(tar.length % 512).toBe(0); + }); +}); + +describe("helpers", () => { + it("builds proxy variables for git, gh and npm", () => { + expect(proxyEnv("")).toEqual({}); + const env = proxyEnv("http://127.0.0.1:7890"); + expect(env.HTTPS_PROXY).toBe("http://127.0.0.1:7890"); + expect(env.npm_config_https_proxy).toBe("http://127.0.0.1:7890"); + }); + + it("recognises URLs and local paths as repositories", () => { + expect(isRepoUrl("https://github.com/o/r.git")).toBe(true); + expect(isRepoUrl("git@github.com:o/r.git")).toBe(true); + expect(isRepoUrl("C:\\repos\\sync.git")).toBe(true); + expect(isRepoUrl("/srv/sync.git")).toBe(true); + expect(isRepoUrl("o/r")).toBe(false); + expect(githubTargetFromUrl("https://github.com/o/r.git")).toEqual({ owner: "o", name: "r" }); + expect(githubTargetFromUrl("/srv/sync.git")).toBeUndefined(); + }); + + it("compares versions numerically", () => { + expect(compareVersions("3.10.0", "3.9.9")).toBe(1); + expect(compareVersions("3.2.0", "3.2.0")).toBe(0); + expect(compareVersions("3.2.0-beta.1", "3.2.0")).toBe(-1); + }); + + it("hashes files the way git does", () => { + const content = Buffer.from("hello\r\nworld\n"); + const expected = execFileSync("git", ["hash-object", "--stdin"], { + input: "hello\nworld\n", + encoding: "utf8", + }).trim(); + expect(gitBlobId(content)).toBe(expected); + }); + + it("keeps the plugin's idle push off while autosync owns the machine", () => { + const s = defaultSettings(); + s.autoPushOnIdle = true; + expect(idlePushEnabled(s)).toBe(true); + s.autosync.enabled = true; + expect(idlePushEnabled(s)).toBe(false); + }); + + it("writes generated-directory excludes", () => { + expect(excludeFileContent(["node_modules", ".venv", "bad/*"])).toBe( + "# Managed by opencode-github-sync: generated directories never synced.\nnode_modules/\n.venv/\n", + ); + }); + + it("escapes notification text for XML and AppleScript", () => { + expect(windowsToastScript("a { + function ui(lines: string) { + let out = ""; + const output = new Writable({ + write(chunk, _enc, cb) { + out += chunk.toString(); + cb(); + }, + }); + return { + ui: new Ui({ input: Readable.from([lines]), output, interactive: false }), + out: () => out, + }; + } + + it("selects by key and returns undefined at end of input", async () => { + const { ui: u } = ui("x\n2\n"); + const choices = [ + { value: "a", label: "A", key: "1" }, + { value: "b", label: "B", key: "2" }, + ]; + expect(await u.select("pick", choices)).toBe("b"); + expect(await u.select("pick", choices)).toBeUndefined(); + }); + + it("toggles checkboxes by number and saves on an empty line", async () => { + const { ui: u } = ui("1 3\n\n"); + const result = await u.checkbox( + "t", + [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + { value: "c", label: "C" }, + ], + new Set(["a", "b"]), + ); + expect([...(result ?? [])].sort()).toEqual(["b", "c"]); + }); + + it("requires the exact confirmation word", async () => { + const { ui: u } = ui("Overwrite\noverwrite\n"); + expect(await u.confirmWord("type", "overwrite")).toBe(false); + expect(await u.confirmWord("type", "overwrite")).toBe(true); + }); +}); From 2f3838091796a750a3140e4bc35994b4e9264f25 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 12:45:24 +0800 Subject: [PATCH 04/10] ci: installer job on all three platforms, shellcheck, tarball contents; chore: 3.2.0 --- .github/workflows/ci.yml | 144 +++++++++++++++++++++++++++++++++++++++ install.ps1 | 8 ++- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 152 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 678bec5..8027340 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,147 @@ jobs: - run: npm run build + installer: + # The only place a real scheduled job is ever registered: disposable + # runners. Installs the packed checkout through the real installer, links + # a local bare repository with every root redirected into the runner's + # temp directory, then installs, runs and removes the scheduler entry. + name: installer · ${{ matrix.os }} + runs-on: ${{ matrix.os }} + needs: check + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + OPENCODE_SYNC_NO_GH: "1" + OPENCODE_SYNC_NO_STAGGER: "1" + OPENCODE_SYNC_LANG: en + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Configure git identity + shell: bash + run: | + git config --global user.name "CI" + git config --global user.email "ci@example.com" + git config --global init.defaultBranch main + + - name: Build and pack the checkout + shell: bash + run: | + npm ci + npm run build + npm pack + echo "PKG=$(pwd)/$(ls opencode-github-sync-*.tgz)" >> "$GITHUB_ENV" + + - name: Isolated roots and a local bare repository + shell: bash + run: | + base="$RUNNER_TEMP/ogs" + mkdir -p "$base/home" "$base/config" + git init -q --bare -b main "$base/remote.git" + printf '{ "model": "ci" }\n' > "$base/config/opencode.jsonc" + { + echo "SYNC_CONFIG_ROOT=$base/config" + echo "SYNC_DATA_ROOT=$base/data" + echo "SYNC_STATE_ROOT=$base/state" + echo "SYNC_AGENTS_ROOT=$base/agents/skills" + echo "SYNC_CACHE_ROOT=$base/cache" + echo "SYNC_HOME=$base/home" + echo "OGS_REMOTE=$base/remote.git" + echo "OGS_LOG=$base/config/logs/opencode-sync-autosync.log" + } >> "$GITHUB_ENV" + + - name: Run install.sh non-interactively + if: runner.os != 'Windows' + shell: bash + run: sh install.sh --mode link --repo "$OGS_REMOTE" --interval 5 --lang en --package "$PKG" --yes /dev/null \ + || crontab -l | grep -q "opencode-github-sync autosync" ;; + esac + + - name: The scheduler actually starts the job + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then schtasks //Run //TN opencode-github-sync; fi + if [ "$RUNNER_OS" = "Linux" ]; then + if systemctl --user is-enabled opencode-github-sync.timer >/dev/null 2>&1; then + systemctl --user start opencode-github-sync.service || true + else + echo "cron fallback: not waiting for the next minute boundary"; exit 0 + fi + fi + for _ in $(seq 1 45); do + if grep -q "scheduled:" "$OGS_LOG" 2>/dev/null; then cat "$OGS_LOG"; exit 0; fi + sleep 2 + done + echo "the scheduled job did not write to $OGS_LOG" + cat "$OGS_LOG" 2>/dev/null || true + cat "$SYNC_CONFIG_ROOT/logs/autosync-launchd.log" 2>/dev/null || true + if [ "$RUNNER_OS" = "Windows" ]; then + # An InteractiveToken task needs a signed-in desktop session, which + # a hosted runner may not provide. Report what Task Scheduler saw. + powershell -NoProfile -Command "Get-ScheduledTaskInfo -TaskName opencode-github-sync | Format-List" + echo "::warning::Task Scheduler did not start the job in this runner session" + exit 0 + fi + exit 1 + + - name: autosync run + shell: bash + run: | + opencode-sync autosync run --trigger ci + grep "ci:" "$OGS_LOG" + + - name: autosync uninstall removes the scheduler entry + shell: bash + run: | + opencode-sync autosync uninstall + case "$RUNNER_OS" in + Windows) ! schtasks //Query //TN opencode-github-sync >/dev/null 2>&1 ;; + macOS) test ! -f "$HOME/Library/LaunchAgents/com.opencode-github-sync.autosync.plist" + ! launchctl list | grep -q com.opencode-github-sync.autosync ;; + Linux) ! systemctl --user is-enabled opencode-github-sync.timer >/dev/null 2>&1 + ! crontab -l 2>/dev/null | grep -q "opencode-github-sync autosync" ;; + esac + + shellcheck: + name: shellcheck install.sh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck -s sh install.sh + package: name: package contents runs-on: ubuntu-latest @@ -68,3 +209,6 @@ jobs: test -f dist/plugin/index.js test -f dist/cli/index.js test -f dist/core/index.js + # Installers and README images are served from GitHub, not npm. + files=$(npm pack --dry-run --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s)[0].files.map(f=>f.path).join("\n")))') + ! echo "$files" | grep -E '^(install\.(sh|ps1)|docs/)' diff --git a/install.ps1 b/install.ps1 index 2d44c86..a960ff3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -152,7 +152,7 @@ function Get-NodeVersion { } $nodeVersion = Get-NodeVersion -if (-not $nodeVersion -or -not (Test-Command 'npm')) { +if (-not $nodeVersion -or -not (Test-Command 'npm.cmd')) { Request-Install 'Node.js' 'OpenJS.NodeJS.LTS' 'Node.js (18 or newer) and npm are needed and were not found.' 'https://nodejs.org' $nodeVersion = Get-NodeVersion } @@ -172,10 +172,12 @@ if (-not (Test-Command 'git')) { # --- Install --- Write-Step "Installing $Package ..." -& npm install -g $Package +# npm.cmd, not npm: plain `npm` resolves to npm.ps1, which a Restricted +# execution policy blocks even though this script itself runs fine via iex. +& npm.cmd install -g $Package if ($LASTEXITCODE -ne 0) { Stop-Install 'npm install -g failed.' } -$prefix = (& npm prefix -g).Trim() +$prefix = (& npm.cmd prefix -g).Trim() $bin = Join-Path $prefix 'opencode-sync.cmd' if (-not (Test-Path -LiteralPath $bin)) { $found = Get-Command 'opencode-sync.cmd' -ErrorAction SilentlyContinue diff --git a/package-lock.json b/package-lock.json index 37bad5f..4f5802f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-github-sync", - "version": "3.1.1", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-github-sync", - "version": "3.1.1", + "version": "3.2.0", "license": "MIT", "bin": { "opencode-sync": "dist/cli/index.js" diff --git a/package.json b/package.json index 2e8d8eb..69937b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-github-sync", - "version": "3.1.1", + "version": "3.2.0", "description": "Sync your OpenCode config, skills, and selected sessions across machines via a private GitHub repo — as a plugin or a CLI.", "type": "module", "license": "MIT", From e23a3fd4a995ddcf25363b6dfceb17de472808e6 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 12:46:24 +0800 Subject: [PATCH 05/10] docs: 3.2.0 changelog in English and Chinese --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.zh-CN.md | 26 ++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07506e1..6c7cf6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ [简体中文](./CHANGELOG.zh-CN.md) +## 3.2.0 + +### Added +- **One-line installers.** `install.ps1` (Windows PowerShell 5.1 and + PowerShell 7) and `install.sh` (macOS, Linux) check for Node.js 18+ and git, + offer to install a missing one through winget, Homebrew or the system + package manager (they ask first and never install silently), run + `npm install -g opencode-github-sync@latest`, and open the menu. `--mode`, + `--repo`, `--interval`, `--lang`, `--proxy` and `--yes` run the whole setup + non-interactively. Under `curl | sh` questions are read from the terminal, + and on Windows the menu runs as its own process so it gets the keyboard. +- **Interactive menu.** `opencode-sync` with no arguments (or `opencode-sync + menu`) opens a menu with arrow keys, hotkeys and checkboxes, and falls back + to numbered prompts whenever input is not a terminal. Every existing + subcommand still works and stays scriptable. First run offers: install the + command only, link a repository, or link and make this the primary device + with autosync. When the machine and the repository differ, it shows the + differences and lets you pick a side; the losing side's files go to a + recovery folder. +- **English and Simplified Chinese** for every menu and command string, + detected from `OPENCODE_SYNC_LANG`, `LC_ALL`/`LC_MESSAGES`/`LANG` or the + system locale, and switchable with `--lang` or from the menu. +- **Autosync driven by the operating system.** `opencode-sync autosync + install|uninstall|enable|disable|status|run|reset|pause|resume`. Task + Scheduler on Windows (logon, unlock, remote connect and a repeating timer; + hidden through `conhost --headless` or a UTF-16 VBScript launcher), launchd + on macOS, a systemd user timer on Linux with a crontab fallback. Each run + pushes only when the synced trees have been quiet for a settle period, and + otherwise receives only when this machine has nothing of its own in flight. + Machines are staggered so they do not collide, failures back off from 30 to + 60 minutes without ever giving up, a lost push race is retried rather than + counted, and a desktop notification appears only when backing off starts + and when it recovers. +- **What to sync** as checkboxes: main config, instruction files, agents, + commands, plugins, skills, credentials and sessions. A switched-off category + stays local in both directions. +- **Large-change guard.** A push that adds more than 300 files, more than + 10 MB, or any file over 2 MB waits for confirmation (thresholds are + adjustable). Autosync never confirms: it records a failure and notifies. + Generated directories (`node_modules`, `.venv`, `venv`, `__pycache__`, + `dist`, `.cache`) are never staged. +- **Restore a file from history**, with the replaced version kept in the + recovery folder. +- **Device list** built from commit history, and renaming this machine. +- **Network proxy** for git, gh and npm, applied to child processes only. +- **Preview** of what a sync would change before running it. +- **Support bundle** export with tokens, passwords and URL credentials removed. +- **Update** menu: check npm, upgrade, and an optional daily auto-update run + by the autosync job. + +### Changed +- When autosync is on, the plugin no longer pushes on idle (it still pulls at + startup), so it never races the scheduled job. +- Sync commits carry an `Opencode-Sync-Device` trailer naming the machine. +- `link` accepts any git URL or local path in addition to `owner/name`. +- A forced pull, and the first pull onto a machine that already has files, copy + the files they replace into `opencode-sync-recovery` under the data + directory first. + +### Fixed +- A push that loses a race with another machine rebases and retries up to three + times instead of failing. +- The first push from a machine whose config directory is not yet a + repository now writes the remote's skills out before staging, so it can no + longer publish every skill it lacks as a deletion. + ## 3.1.1 ### Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 24f0d8b..5acf3a7 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,32 @@ [English](./CHANGELOG.md) +## 3.2.0 + +### 新增 +- **一行命令安装。** `install.ps1`(Windows PowerShell 5.1 和 PowerShell 7 都能用)和 `install.sh`(macOS、Linux)会检查 Node.js 18+ 和 git,缺了就提示用 winget、Homebrew 或系统包管理器安装(一定先问,绝不偷偷装),然后执行 `npm install -g opencode-github-sync@latest` 并打开菜单。带上 `--mode`、`--repo`、`--interval`、`--lang`、`--proxy`、`--yes` 可以全程无人值守完成设置。用 `curl | sh` 时问题从终端读取;在 Windows 上菜单作为独立进程启动,键盘输入不会被管道吃掉。 +- **交互菜单。** 直接运行 `opencode-sync`(或 `opencode-sync menu`)会打开菜单,支持方向键、快捷键和勾选框;输入不是终端时自动改用编号提问。原有子命令全部保留,照样可以写进脚本。第一次运行时可以选:只装命令、关联仓库、关联并把本机设为开启自动同步的主力设备。本机和仓库内容不一样时会先列出差异让你选以哪边为准,被替换的一方会先存进恢复目录。 +- **英文和简体中文界面**,覆盖所有菜单和命令输出。语言按 `OPENCODE_SYNC_LANG`、`LC_ALL`/`LC_MESSAGES`/`LANG`、系统区域设置依次判断,也可以用 `--lang` 或在菜单里切换。 +- **由操作系统驱动的自动同步。** 新增 `opencode-sync autosync install|uninstall|enable|disable|status|run|reset|pause|resume`。Windows 用任务计划程序(登录、解锁、远程连接时触发,外加定时重复;通过 `conhost --headless` 或 UTF-16 编码的 VBScript 隐藏窗口),macOS 用 launchd,Linux 用 systemd 用户定时器,不可用时退回 crontab。每次运行只在同步目录安静一段时间后才推送;还有文件在变化时,只有本机没有待处理的改动才会接收。多台机器会错开时间避免撞车;连续失败后退避间隔从 30 分钟加到 60 分钟,但永不放弃;推送被别的机器抢先不算失败,会自动重试;桌面通知只在开始退避和恢复正常时各弹一次。 +- **同步内容勾选**:主配置、指令文件、智能体、自定义命令、插件、技能、登录凭据、会话。关掉的类别双向都只留在本机。 +- **大改动保护。** 一次推送新增超过 300 个文件、总量超过 10 MB,或有单个文件超过 2 MB 时,要先确认(阈值可调)。自动同步从不自动确认,只记一次失败并发通知。`node_modules`、`.venv`、`venv`、`__pycache__`、`dist`、`.cache` 这类生成目录永远不会提交。 +- **从历史版本恢复单个文件**,被替换的版本保存在恢复目录里。 +- **设备列表**,根据提交历史显示每台机器上次同步的时间,并可以给本机改名。 +- **网络代理**,只作用于 opencode-sync 启动的 git、gh 和 npm。 +- **同步前预览**会改动哪些文件。 +- **导出排障包**,自动去掉令牌、密码和 URL 里的账号密码。 +- **更新**菜单:检查 npm 新版本、立即升级,以及可选的每日自动更新(由自动同步任务执行)。 + +### 变更 +- 开启自动同步后,插件不再在会话空闲时推送(启动时仍然拉取),避免和定时任务抢着同步。 +- 同步提交带上 `Opencode-Sync-Device` 尾注,记录是哪台机器提交的。 +- `link` 除了 `用户名/仓库名`,也接受任意 git 地址或本地路径。 +- 强制拉取,以及本机已有文件时的第一次拉取,会先把将被替换的文件复制到数据目录下的 `opencode-sync-recovery`。 + +### 修复 +- 推送时被别的机器抢先,会自动变基并最多重试三次,而不是直接失败。 +- 配置目录还不是仓库的机器第一次推送时,会先把仓库里的技能写回本机再打包,不会再把本机缺少的技能当成删除推送出去。 + ## 3.1.1 ### 修复 From d95b3bb8ba6cf7a9076df28f76f77ded0df646e5 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Mon, 26 Oct 2026 13:09:36 +0800 Subject: [PATCH 06/10] docs: rebuild READMEs with installer-first layout, generated illustrations and menu screenshots from the real menu --- .github/workflows/ci.yml | 4 +- README.md | 554 +++++++++++++-------- README.zh-CN.md | 436 ++++++++-------- docs/architecture.svg | 103 ---- docs/banner.svg | 71 --- docs/demo.svg | 50 -- docs/images/en/01-hero.png | Bin 0 -> 372075 bytes docs/images/en/02-how-it-works.png | Bin 0 -> 410160 bytes docs/images/en/03-setup-choice.png | Bin 0 -> 352366 bytes docs/images/en/04-autosync-timeline.png | Bin 0 -> 407873 bytes docs/images/en/05-safety-net.png | Bin 0 -> 431129 bytes docs/images/zh-CN/01-hero.png | Bin 0 -> 427802 bytes docs/images/zh-CN/02-how-it-works.png | Bin 0 -> 447386 bytes docs/images/zh-CN/03-setup-choice.png | Bin 0 -> 375652 bytes docs/images/zh-CN/04-autosync-timeline.png | Bin 0 -> 399875 bytes docs/images/zh-CN/05-safety-net.png | Bin 0 -> 443120 bytes docs/menu.svg | 46 ++ docs/menu.zh-CN.svg | 46 ++ scripts/render-menu-svg.mjs | 173 +++++++ tests/two-machines.test.ts | 10 +- 20 files changed, 847 insertions(+), 646 deletions(-) delete mode 100644 docs/architecture.svg delete mode 100644 docs/banner.svg delete mode 100644 docs/demo.svg create mode 100644 docs/images/en/01-hero.png create mode 100644 docs/images/en/02-how-it-works.png create mode 100644 docs/images/en/03-setup-choice.png create mode 100644 docs/images/en/04-autosync-timeline.png create mode 100644 docs/images/en/05-safety-net.png create mode 100644 docs/images/zh-CN/01-hero.png create mode 100644 docs/images/zh-CN/02-how-it-works.png create mode 100644 docs/images/zh-CN/03-setup-choice.png create mode 100644 docs/images/zh-CN/04-autosync-timeline.png create mode 100644 docs/images/zh-CN/05-safety-net.png create mode 100644 docs/menu.svg create mode 100644 docs/menu.zh-CN.svg create mode 100644 scripts/render-menu-svg.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8027340..e9cf10d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,9 @@ jobs: npm ci npm run build npm pack - echo "PKG=$(pwd)/$(ls opencode-github-sync-*.tgz)" >> "$GITHUB_ENV" + pkg="$(pwd)/$(ls opencode-github-sync-*.tgz)" + if [ "$RUNNER_OS" = "Windows" ]; then pkg=$(cygpath -w "$pkg"); fi + echo "PKG=$pkg" >> "$GITHUB_ENV" - name: Isolated roots and a local bare repository shell: bash diff --git a/README.md b/README.md index 49c1b5a..9edf773 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,222 @@

- opencode-github-sync + opencode-github-sync: one config, every machine

npm CI - license + license platform

- 简体中文 + 简体中文  ·  - Quick start + Install  ·  - Overrides + Setup  ·  - Sessions + Autosync  ·  Commands +  ·  + Troubleshooting

--- -You use OpenCode on more than one machine. Your config, agents, commands, skills -and MCP servers live on whichever one you touched last. - -**opencode-github-sync** keeps them all in step through a private GitHub -repository — as an OpenCode plugin, as a CLI, or both. - -```bash -npm install -g opencode-github-sync -opencode-sync init # creates the private repo and configures this machine -opencode-sync push -``` - -

- opencode-sync push -

- ---- - -## Why another sync tool - -Most config-sync tools stop being safe the moment real use hits them. These are -the three places that happens, and what this one does instead. - -| | Typical approach | Here | -| ------------------ | ----------------------------------------------- | -------------------------------------------------------------------------- | -| **Machine differences** | Every machine forced identical | [Per-machine overrides](#per-machine-overrides) that sync can never overwrite | -| **Sessions** | Commit `opencode.db` — gigabytes, unmergeable | [Selective per-session shards](#session-sync), a few MB, conflict-free | -| **When it breaks** | Plugin-only: bad config means no way back | The CLI runs outside OpenCode and can always recover you | +You use OpenCode on more than one computer. Your config, agents, commands, +skills and MCP servers end up wherever you last touched them. -Plus the boring things that turn out to matter: +**opencode-github-sync** keeps every machine on the same setup through a +private GitHub repository. Run it by hand, or let one machine sync itself in +the background. -- **Symlinks are refused, not followed.** No copying files out of the machine by accident, no junction turning a delete into the wrong delete. -- **Every replacement is atomic.** A crash mid-sync cannot leave a half-written config that stops OpenCode from starting. -- **A push is verified against the remote.** `git push` can exit `0` without the remote moving; that is checked, not assumed. -- **Nothing runs twice at once.** A cross-process lock covers the plugin, the CLI and every OpenCode window. -- **Credentials are opt-in** and refused outright on a public repository. -- **Your hostname never leaves the machine.** Commits are signed with a stable pseudonym, so a corporate asset tag never lands in a repo that might not stay private. +## Install ---- +**macOS / Linux** -## Install +```bash +curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh +``` -### As an OpenCode plugin +**Windows** (PowerShell 5.1 or 7) -```jsonc -// ~/.config/opencode/opencode.json -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-github-sync"] -} +```powershell +irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1 | iex ``` -This `"plugin"` form works on both OpenCode 1.x (1.3.4 and later) and -OpenCode 2.x: 1.x reads only `"plugin"`, and 2.x still accepts it alongside its -native `"plugins"` key. If you run only OpenCode 2, you can list it under -`"plugins"` instead. Do not list the plugin under both keys — OpenCode 2 loads -entries from both lists. +The installer checks for Node.js 18+ and git. If one is missing it shows the +command that would install it and asks before running anything. Then it runs +`npm install -g opencode-github-sync@latest` and opens the menu. -OpenCode 1.0–1.3.3 cannot load 3.1.0 and later; stay on -`opencode-github-sync@3.0.1` there. +Already have Node.js? `npm install -g opencode-github-sync` and then +`opencode-sync` does the same thing. -OpenCode installs it on the next start. The plugin adds a pull on startup, an -optional push when a session goes idle, and an `opencode_sync` tool so you can -just ask for a sync in plain language. +### Set up a machine in one line -### As a CLI +Pass options to skip the questions, for example when provisioning a new +machine: ```bash -npm install -g opencode-github-sync +curl -fsSL https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.sh | sh -s -- --mode auto --repo you/my-opencode-config ``` -Keep the CLI even if you use the plugin. A plugin cannot fix the configuration -that stops OpenCode from loading the plugin. +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/doomsday616/opencode-github-sync/main/install.ps1))) -Mode auto -Repo you/my-opencode-config +``` -**Requirements** — git, and [`gh`](https://cli.github.com) for automatic setup. -Node 18+ for config sync; Node 22.5+ or Bun for [session sync](#session-sync). +| Option | Meaning | +| --- | --- | +| `--mode cli` / `-Mode cli` | Install the command only | +| `--mode link` | Link `--repo` and sync once | +| `--mode auto` | Link, sync once and turn on autosync | +| `--repo owner/name` | The repository (a git URL works too); created as private if it does not exist | +| `--interval N` | Autosync every N minutes (minimum 2, default 5) | +| `--lang en` / `zh-CN` | Interface language | +| `--proxy URL` | Proxy for npm and git, during install and for later syncs | +| `--yes` | Install missing Node.js or git without asking | + +When the repository already has content and this machine also has its own +files, setup takes the repository's version and keeps the replaced files in a +[recovery folder](#safety-features). --- -## Quick start +## Pick your setup -### First machine +

+ Command only, or primary device with autosync +

-```bash -opencode-sync init # → /my-opencode-config -opencode-sync init team-config # custom name -opencode-sync init my-org/shared # inside an organisation -opencode-sync push -``` +The first time you run `opencode-sync` it asks how this machine should work. -`init` creates a **private** repository, writes -`~/.config/opencode/opencode-sync.jsonc`, and leaves an empty overrides file -ready for you. +| | Command only | Primary device + autosync | +| --- | --- | --- | +| How it syncs | You run `opencode-sync push` / `pull`, or the plugin pulls at startup | The operating system runs a sync every few minutes, even when OpenCode is closed | +| Good for | A machine you use now and then, a shared or locked-down machine | The machines you work on every day | +| Background job | None | Task Scheduler, launchd or a systemd timer | -### Every other machine +You can change your mind any time from the **Autosync** menu. Autosync is +opt-in per machine: turning it on here never turns it on anywhere else. -```bash -opencode-sync link doomsday616/my-opencode-config -opencode-sync pull -``` +--- -Then restart OpenCode. +## The menu -### Day to day +Run `opencode-sync` with no arguments. -```bash -opencode-sync push # share what changed here -opencode-sync pull # take what changed elsewhere -opencode-sync status # what is in sync, what is not -``` +

+ The opencode-sync menu +

+ +Arrow keys or the shown hotkey pick an item, space ticks a checkbox, Enter +confirms and Esc goes back. When the input is not a terminal (a pipe, a script, +CI) every prompt becomes a numbered question, so the menu can be driven from a +file. + +| Item | What you can do there | +| --- | --- | +| **Sync now** | Push and receive, receive only, push only, or preview first | +| **What to sync** | Tick main config, instruction files, agents, commands, plugins, skills, credentials, sessions; set session limits; add extra paths | +| **Autosync** | Turn it on or off, choose 5/10/15/30/60 minutes or your own interval, pause it, run while signed out (Windows), notifications | +| **Update** | Check npm for a newer version, upgrade, update automatically once a day | +| **This machine's overrides** | Open the overrides file in your editor, see what it changes | +| **Diagnostics** | git, node, gh, repository access, the scheduled job, the last log lines; export a support bundle | +| **Danger zone** | Make this machine match the repository, or the other way round (you type `overwrite` to confirm) | +| **Uninstall** | Remove the scheduled job, optionally unlink and remove the command. Your configuration is never deleted | +| **Network proxy** | A proxy for git, gh and npm, used only by opencode-sync | +| **Restore a file from history** | Pick a file, pick a version, put it back | +| **Devices** | Every machine that has synced and when, and renaming this one | +| **Large-change guard** | The limits that make a big push stop and ask | +| **Language** | English, 简体中文 or automatic | + +Every command from earlier versions still works and is still scriptable. --- ## What gets synced

- Architecture + Each machine pushes and pulls through one private repository

-| Source | In the repo | Contents | -| -------------------------- | ----------- | -------------------------------------------- | -| `~/.config/opencode/` | repo root | config, agents, commands, skills, themes, MCP | -| `~/.local/share/opencode/` | `_data/` | project metadata, optional credentials | -| `~/.local/state/opencode/` | `_state/` | frecency, model cache, prompt history | -| `~/.agents/skills/` | `_agents/` | skills installed by the `skills` CLI | -| selected sessions | `_sessions/`| one gzipped shard per session | +Your `~/.config/opencode` folder becomes a working copy of the repository. +Everything else is mirrored into a folder inside it. -**Never synced:** `opencode.db` and its journals, tool output, snapshots, logs, -and your local settings and overrides files. +| On your machine | In the repository | What it holds | +| --- | --- | --- | +| `~/.config/opencode/` | the root | config, agents, commands, plugins, themes, instruction files | +| `~/.local/share/opencode/` | `_data/` | project metadata, and credentials if you opt in | +| `~/.local/state/opencode/` | `_state/` | model cache, recent files, prompt history | +| `~/.agents/skills/` | `_agents/` | skills installed with the `skills` CLI | +| selected sessions | `_sessions/` | one small compressed file per session | +| your extra paths | `_extra/` | anything else you add | -Add anything else with `extraPaths`: +Each part can be switched off under **What to sync**. A part you switch off +stays local in both directions: your edits are not sent, and other machines' +versions do not replace yours. -```jsonc -{ - "extraPaths": [".tavily", ".config/gh/config.yml"] -} -``` +**Never synced:** the `opencode.db` database, tool output, snapshots, logs, +`node_modules` and other generated folders, and opencode-sync's own settings +and overrides files. --- -## Per-machine overrides +## How autosync works -A synced repository makes every machine identical. That is exactly wrong for the -settings that are genuinely machine-specific: a corporate proxy, a local -toolchain path, an MCP server that only exists on one box. +

+ Machines sync on a staggered schedule, wait while busy, and back off on errors +

+ +When autosync is on, the operating system starts `opencode-sync autosync run` +every few minutes and at sign-in (on Windows also when you unlock or reconnect +over Remote Desktop). Each run makes one decision: + +1. **Is it quiet?** If any file in your config or skills folders changed in the + last minute, something is still being written. Sending now could publish a + half-saved file, so the push waits. +2. **Can it at least receive?** While busy, it still takes other machines' + changes, but only when this machine has nothing of its own waiting. + Otherwise it leaves everything for the next run. +3. **Push and receive.** Your changes go up, other machines' changes come down, + in one step. + +The rules that keep this safe: + +- **Machines take turns.** Each machine waits a fixed few seconds based on its + name, so two machines on the same schedule do not collide every time. If + another machine still gets there first, this one picks up the new commit and + tries again; that does not count as an error. +- **It backs off, but never gives up.** After three failures in a row + (typically no network) it waits 30 minutes, then an hour between attempts. + The first success puts it back on schedule. +- **It only interrupts you twice.** One notification when it starts backing + off, one when it recovers. Nothing for ordinary syncs. +- **It never runs twice at once.** A lock is shared by the scheduled job, the + CLI, the menu and every OpenCode window; if one is busy, the others skip. +- **It never confirms a large change.** Anything that trips the + [large-change guard](#safety-features) is held back and you get a + notification. + +| Platform | Scheduler | Notes | +| --- | --- | --- | +| Windows | Task Scheduler | Runs hidden. To run while signed out, turn that on in the menu and install from an administrator terminal | +| macOS | launchd agent | `~/Library/LaunchAgents/com.opencode-github-sync.autosync.plist` | +| Linux | systemd user timer | Falls back to a crontab line. On a server, run `loginctl enable-linger` so it runs while you are logged out | + +`opencode-sync autosync status` shows the last run, the last error and when it +will try next. With autosync on, the OpenCode plugin stops pushing on idle so +the two never compete; it still pulls when OpenCode starts. -Put those in `~/.config/opencode/opencode-sync.overrides.jsonc`: +--- + +## Per-machine overrides + +Some settings belong to one machine only: a work proxy, a local tool path, an +MCP server that only exists on one box. Put them in +`~/.config/opencode/opencode-sync.overrides.jsonc`, or use **This machine's +overrides** in the menu: ```jsonc { @@ -181,175 +228,244 @@ Put those in `~/.config/opencode/opencode-sync.overrides.jsonc`: ``` ``` - repo opencode.jsonc shared baseline, committed + repo opencode.jsonc shared, committed + overrides.jsonc this machine only, never committed - ───────────────────────── deep merge - = effective opencode.jsonc what OpenCode actually reads + ───────────────────────── merged + = effective opencode.jsonc what OpenCode reads ``` -Merge rules: objects merge key by key, arrays and scalars replace, `null` -deletes a key. - -The half that other tools get wrong is **push**. Because the effective file is -what sits on disk, a naive push uploads your overrides to everyone. Here, any -key the overrides file claims is restored to the value the repository already -had before committing — so an overridden key is invisible to sync in both -directions, and your machine keeps its own settings the whole time. - -> With no overrides file the config is copied byte for byte and your comments -> and formatting are untouched. The structural rewrite only happens once you -> actually opt in. +Objects merge key by key, lists and plain values replace, and `null` removes a +key. Before every push, any key the overrides file claims is put back to the +repository's value, so your machine-only values never reach other machines. +Without an overrides file, the config is copied exactly as written, comments +and all. --- -## Session sync +## Sessions -Off by default. Turn it on only for a private repository — conversations are -private data. +Off by default. Only turn it on for a private repository: conversations are +private. ```bash opencode-sync sessions enable opencode-sync sessions list -opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # pin one forever +opencode-sync sessions include ses_065cad1caffeN3lf1RgLZno30 # always sync this one opencode-sync sessions exclude ses_0ba19b4e7ffeMbSmWYi3Nj6xfZ # never sync this one ``` -### Why not just commit the database +Sessions are not synced by committing the database. A real OpenCode database +grows to several gigabytes, mostly tool output, and git cannot merge it. Each +session is exported on its own as a small compressed file instead: + +- **Small.** Only recent sessions (7 days and 50 sessions by default), and any + single session over 5 MB is skipped. +- **No conflicts in practice.** Two machines working on different sessions + touch different files. The same session edited in two places keeps the newer + version. +- **Safe to import.** Importing runs in one transaction, so a failure leaves + your database as it was. + +Window, count and size limits are under **What to sync → Session settings**. +Session sync needs Node.js 22.5 or newer. -A real OpenCode database reaches several gigabytes, almost all of it tool output -inside `part.data`. Git cannot delta-compress it, GitHub's LFS free tier is one -gigabyte, and copying a live database alongside its write-ahead log can capture -a torn state that only shows up much later. +--- -So sessions are exported **one at a time**. Each becomes an independent gzipped -JSON shard holding its own rows plus the project and workspace rows it needs: +## Safety features -- **No large files.** Shards are small and compress well. -- **Conflicts isolate themselves.** Two machines working on different sessions - touch different files, so git merges them with no special handling. The only - real conflict is one session edited in two places, and there the newer - `time_updated` wins — an in-progress conversation is never clobbered by a - stale copy. -- **You choose what travels.** A time window, an explicit include list, a - project filter and a per-session size cap. Ancient sessions stay where they - are. +

+ Lock, atomic replace, recovery folder and large-change guard +

-Import is an upsert inside a transaction, so a failure leaves your database -exactly as it was. +- **Atomic replace.** Files are written beside their target and then swapped + in, so a crash mid-sync cannot leave a half-written config that stops + OpenCode from starting. +- **Push verification.** After pushing, the remote is read back. `git push` + can report success without the remote changing; that is checked, not + assumed. +- **One sync at a time.** A lock shared by the plugin, the CLI, the menu and + the scheduled job. +- **Large-change guard.** A push that adds more than 300 files, more than + 10 MB in total, or any single file over 2 MB stops and asks. The limits are + adjustable. Generated folders such as `node_modules`, `.venv`, `venv`, + `__pycache__`, `dist` and `.cache` are never sent. +- **Recovery folder.** Before a forced pull, a "repository wins" first link, + or a restore from history, the files about to be replaced are copied to + `~/.local/share/opencode/opencode-sync-recovery/