claude-code-backup 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ import chalk from 'chalk';
2
+ import { requireConfig, LAUNCHD_PLIST, LAUNCHD_LABEL } from '../core/config.js';
3
+ import { getStatus } from '../backends/github.js';
4
+ import { collectFiles } from '../core/collector.js';
5
+ import { log } from '../utils/logger.js';
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { existsSync } from 'fs';
9
+
10
+ const execAsync = promisify(exec);
11
+
12
+ async function getServiceStatus() {
13
+ if (!existsSync(LAUNCHD_PLIST)) return 'not installed';
14
+ try {
15
+ const uid = process.getuid();
16
+ const { stdout } = await execAsync(`launchctl print gui/${uid}/${LAUNCHD_LABEL} 2>/dev/null`);
17
+ return stdout.includes('pid') ? 'running' : 'loaded (not running)';
18
+ } catch {
19
+ try {
20
+ const { stdout } = await execAsync(`launchctl list | grep "${LAUNCHD_LABEL}"`);
21
+ const parts = stdout.trim().split(/\s+/);
22
+ return parts[0] !== '-' ? `running (PID ${parts[0]})` : 'loaded (stopped)';
23
+ } catch {
24
+ return 'not running';
25
+ }
26
+ }
27
+ }
28
+
29
+ export async function runStatus() {
30
+ const config = requireConfig();
31
+
32
+ log.header('Claude Backup Status');
33
+
34
+ // Watched dirs + file count
35
+ const files = collectFiles(config);
36
+ const claudeMdCount = files.filter(f => f.dest.startsWith('claude_md_')).length;
37
+ const watchedCount = files.length - claudeMdCount;
38
+
39
+ console.log(chalk.bold('Watched dirs:'));
40
+ for (const dir of config.watched_dirs) {
41
+ console.log(` ${dir}`);
42
+ }
43
+ console.log(`${chalk.bold('Files tracked:')} ${watchedCount}`);
44
+
45
+ const claudeMdDirs = config.claude_md_dirs || [];
46
+ if (claudeMdDirs.length > 0) {
47
+ console.log(`\n${chalk.bold('Project CLAUDE.md dirs:')}`);
48
+ for (const dir of claudeMdDirs) {
49
+ console.log(` ${dir}`);
50
+ }
51
+ console.log(`${chalk.bold('CLAUDE.md files:')} ${claudeMdCount}`);
52
+ }
53
+
54
+ // GitHub
55
+ console.log(`\n${chalk.bold('GitHub:')} ${config.github.repo} [${config.github.branch}]`);
56
+ const status = await getStatus(config);
57
+ if (status?.lastCommit) {
58
+ const c = status.lastCommit;
59
+ const dateStr = c.date ? String(c.date).slice(0, 19).replace('T', ' ') : 'unknown';
60
+ console.log(`${chalk.bold('Last backup:')} ${dateStr}`);
61
+ console.log(`${chalk.bold('Commit:')} ${String(c.hash).slice(0, 7)} ${c.message}`);
62
+ if (status.pending > 0) {
63
+ console.log(`${chalk.bold('Local changes:')} ${chalk.yellow(status.pending + ' file(s) not yet pushed')}`);
64
+ } else {
65
+ console.log(`${chalk.bold('Sync status:')} ${chalk.green('Up to date')}`);
66
+ }
67
+ } else {
68
+ console.log(chalk.dim(' No backups yet. Run: claude-backup push'));
69
+ }
70
+
71
+ // Launchd service
72
+ const svc = await getServiceStatus();
73
+ const svcColor = svc.startsWith('running') ? chalk.green : chalk.yellow;
74
+ console.log(`\n${chalk.bold('Auto-sync service:')} ${svcColor(svc)}`);
75
+ if (!svc.startsWith('running')) {
76
+ console.log(chalk.dim(' Run: claude-backup service install'));
77
+ }
78
+
79
+ console.log('');
80
+ }
@@ -0,0 +1,101 @@
1
+ import { existsSync } from 'fs';
2
+ import chokidar from 'chokidar';
3
+ import { requireConfig, expandPath } from '../core/config.js';
4
+ import { push } from '../backends/github.js';
5
+ import { log } from '../utils/logger.js';
6
+
7
+ export async function runWatch(options) {
8
+ const config = requireConfig();
9
+
10
+ if (options.interval) {
11
+ await runIntervalMode(config, parseFloat(options.interval));
12
+ return;
13
+ }
14
+
15
+ const dirs = config.watched_dirs
16
+ .map(expandPath)
17
+ .filter(d => existsSync(d));
18
+
19
+ if (dirs.length === 0) {
20
+ log.error('No valid watched directories found — check your config');
21
+ process.exit(1);
22
+ }
23
+
24
+ log.header('Claude Backup — File Watcher');
25
+ for (const dir of dirs) log.dim(` Watching: ${dir}`);
26
+ log.dim(` Debounce: ${config.auto_sync?.debounce_ms ?? 2000}ms`);
27
+ log.dim(' Press Ctrl+C to stop\n');
28
+
29
+ const debounceMs = config.auto_sync?.debounce_ms ?? 2000;
30
+ let timer = null;
31
+ let busy = false;
32
+
33
+ const watcher = chokidar.watch(dirs, {
34
+ persistent: true,
35
+ ignoreInitial: true,
36
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
37
+ ignored: [/(^|[/\\])\.git([/\\]|$)/],
38
+ });
39
+
40
+ function scheduleSync() {
41
+ if (timer) clearTimeout(timer);
42
+ timer = setTimeout(async () => {
43
+ if (busy) return;
44
+ busy = true;
45
+ const ts = new Date().toLocaleTimeString();
46
+ try {
47
+ const count = await push(config);
48
+ if (count > 0) {
49
+ log.success(`[${ts}] Synced ${count} file(s) → GitHub`);
50
+ } else {
51
+ log.dim(`[${ts}] No changes to push`);
52
+ }
53
+ } catch (err) {
54
+ log.error(`[${ts}] Sync failed: ${err.message}`);
55
+ } finally {
56
+ busy = false;
57
+ }
58
+ }, debounceMs);
59
+ }
60
+
61
+ watcher
62
+ .on('add', scheduleSync)
63
+ .on('change', scheduleSync)
64
+ .on('unlink', scheduleSync)
65
+ .on('error', err => log.error(`Watcher error: ${err.message}`));
66
+
67
+ function shutdown() {
68
+ watcher.close().then(() => {
69
+ log.info('File watcher stopped');
70
+ process.exit(0);
71
+ });
72
+ }
73
+
74
+ process.on('SIGTERM', shutdown);
75
+ process.on('SIGINT', () => { console.log(''); shutdown(); });
76
+ }
77
+
78
+ async function runIntervalMode(config, minutes) {
79
+ log.header(`Claude Backup — Interval Sync (every ${minutes}m)`);
80
+
81
+ async function sync() {
82
+ const ts = new Date().toLocaleTimeString();
83
+ try {
84
+ const count = await push(config);
85
+ if (count > 0) {
86
+ log.success(`[${ts}] Synced ${count} file(s) → GitHub`);
87
+ } else {
88
+ log.dim(`[${ts}] No changes`);
89
+ }
90
+ } catch (err) {
91
+ log.error(`[${ts}] Sync failed: ${err.message}`);
92
+ }
93
+ }
94
+
95
+ await sync();
96
+ const ms = minutes * 60 * 1000;
97
+ setInterval(sync, ms);
98
+
99
+ process.on('SIGTERM', () => { log.info('Interval sync stopped'); process.exit(0); });
100
+ process.on('SIGINT', () => { console.log(''); log.info('Interval sync stopped'); process.exit(0); });
101
+ }
@@ -0,0 +1,87 @@
1
+ import { readdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { expandPath, sanitizeDirName } from './config.js';
4
+
5
+ // Prefix used in the repo and manifest to distinguish project CLAUDE.md entries
6
+ // from regular watched-dir entries so the two namespaces never collide.
7
+ export const CLAUDE_MD_PREFIX = 'claude_md_';
8
+
9
+ function globMatch(name, pattern) {
10
+ if (!pattern.includes('*')) return name === pattern;
11
+ const re = new RegExp(
12
+ '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
13
+ );
14
+ return re.test(name);
15
+ }
16
+
17
+ function shouldExclude(name, excludes) {
18
+ return excludes.some(p => globMatch(name, p));
19
+ }
20
+
21
+ function walkDir(dir, excludes, results, relPath) {
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(dir, { withFileTypes: true });
25
+ } catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ if (shouldExclude(entry.name, excludes)) continue;
30
+ const fullPath = join(dir, entry.name);
31
+ const rel = relPath ? join(relPath, entry.name) : entry.name;
32
+ if (entry.isDirectory()) {
33
+ walkDir(fullPath, excludes, results, rel);
34
+ } else if (entry.isFile()) {
35
+ results.push({ src: fullPath, rel });
36
+ }
37
+ }
38
+ }
39
+
40
+ export function collectFiles(config) {
41
+ const excludes = config.exclude || [];
42
+ const result = [];
43
+
44
+ // Full recursive backup of each watched dir (e.g. ~/.claude)
45
+ for (const rawDir of config.watched_dirs) {
46
+ const dir = expandPath(rawDir);
47
+ if (!existsSync(dir)) continue;
48
+
49
+ const label = sanitizeDirName(dir);
50
+ const files = [];
51
+ walkDir(dir, excludes, files, '');
52
+
53
+ for (const { src, rel } of files) {
54
+ result.push({ src, dest: join(label, rel) });
55
+ }
56
+ }
57
+
58
+ // Only the CLAUDE.md file from each project root
59
+ for (const rawDir of (config.claude_md_dirs || [])) {
60
+ const dir = expandPath(rawDir);
61
+ const claudeMd = join(dir, 'CLAUDE.md');
62
+ if (!existsSync(claudeMd)) continue;
63
+
64
+ const label = CLAUDE_MD_PREFIX + sanitizeDirName(dir);
65
+ result.push({ src: claudeMd, dest: join(label, 'CLAUDE.md') });
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ export function buildManifest(config) {
72
+ const manifest = {};
73
+
74
+ // Watched dirs
75
+ for (const rawDir of config.watched_dirs) {
76
+ const dir = expandPath(rawDir);
77
+ manifest[sanitizeDirName(dir)] = dir;
78
+ }
79
+
80
+ // Project CLAUDE.md dirs — prefixed so restore knows to target the project root
81
+ for (const rawDir of (config.claude_md_dirs || [])) {
82
+ const dir = expandPath(rawDir);
83
+ manifest[CLAUDE_MD_PREFIX + sanitizeDirName(dir)] = dir;
84
+ }
85
+
86
+ return manifest;
87
+ }
@@ -0,0 +1,54 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
4
+
5
+ export const CONFIG_DIR = join(homedir(), '.config', 'claude-backup');
6
+ export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+ export const REPO_DIR = join(CONFIG_DIR, 'repo');
8
+ export const LOG_FILE = join(CONFIG_DIR, 'watch.log');
9
+ export const ERROR_LOG_FILE = join(CONFIG_DIR, 'watch.error.log');
10
+ export const LAUNCHD_PLIST = join(
11
+ homedir(), 'Library', 'LaunchAgents', 'com.claude-backup.watch.plist'
12
+ );
13
+ export const LAUNCHD_LABEL = 'com.claude-backup.watch';
14
+
15
+ export function ensureConfigDir() {
16
+ mkdirSync(CONFIG_DIR, { recursive: true });
17
+ }
18
+
19
+ export function loadConfig() {
20
+ if (!existsSync(CONFIG_FILE)) return null;
21
+ try {
22
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function saveConfig(config) {
29
+ ensureConfigDir();
30
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
31
+ try { chmodSync(CONFIG_FILE, 0o600); } catch {}
32
+ }
33
+
34
+ export function requireConfig() {
35
+ const config = loadConfig();
36
+ if (!config || !config.github?.pat || !config.github?.repo) {
37
+ console.error('Not configured. Run: claude-backup init');
38
+ process.exit(1);
39
+ }
40
+ return config;
41
+ }
42
+
43
+ export function expandPath(p) {
44
+ if (typeof p !== 'string') return p;
45
+ if (p.startsWith('~/') || p === '~') return join(homedir(), p.slice(1));
46
+ return p;
47
+ }
48
+
49
+ export function sanitizeDirName(dirPath) {
50
+ return dirPath
51
+ .replace(/^\//, '')
52
+ .replace(/\//g, '_')
53
+ .replace(/[^a-zA-Z0-9_.-]/g, '_');
54
+ }
@@ -0,0 +1,15 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ export const log = {
5
+ info: (msg) => console.log(chalk.blue('ℹ'), chalk.reset(msg)),
6
+ success: (msg) => console.log(chalk.green('✔'), chalk.reset(msg)),
7
+ warn: (msg) => console.log(chalk.yellow('⚠'), chalk.reset(msg)),
8
+ error: (msg) => console.error(chalk.red('✖'), chalk.reset(msg)),
9
+ dim: (msg) => console.log(chalk.dim(msg)),
10
+ header: (msg) => console.log('\n' + chalk.bold.cyan(msg) + '\n'),
11
+ };
12
+
13
+ export function spinner(text) {
14
+ return ora({ text, color: 'cyan' });
15
+ }