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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/bin/claude-backup.js +56 -0
- package/package.json +54 -0
- package/src/backends/github.js +583 -0
- package/src/commands/config-cmd.js +139 -0
- package/src/commands/init.js +138 -0
- package/src/commands/pull.js +161 -0
- package/src/commands/push.js +14 -0
- package/src/commands/service.js +168 -0
- package/src/commands/status.js +80 -0
- package/src/commands/watch.js +101 -0
- package/src/core/collector.js +87 -0
- package/src/core/config.js +54 -0
- package/src/utils/logger.js +15 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, saveConfig, requireConfig, expandPath } from '../core/config.js';
|
|
3
|
+
import { log } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export function runConfig(action, key, value) {
|
|
6
|
+
switch (action) {
|
|
7
|
+
case 'show': return showConfig();
|
|
8
|
+
case 'set': return setConfig(key, value);
|
|
9
|
+
case 'add-dir': return addDir(key);
|
|
10
|
+
case 'remove-dir': return removeDir(key);
|
|
11
|
+
case 'add-project': return addProject(key);
|
|
12
|
+
case 'remove-project': return removeProject(key);
|
|
13
|
+
default:
|
|
14
|
+
log.error(`Unknown action: "${action}"`);
|
|
15
|
+
log.info('Valid actions:');
|
|
16
|
+
log.dim(' show');
|
|
17
|
+
log.dim(' set <key> <value>');
|
|
18
|
+
log.dim(' add-dir <path> — fully mirror a directory');
|
|
19
|
+
log.dim(' remove-dir <path>');
|
|
20
|
+
log.dim(' add-project <path> — back up only CLAUDE.md from a project root');
|
|
21
|
+
log.dim(' remove-project <path>');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function showConfig() {
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
if (!config) {
|
|
29
|
+
log.warn('Not configured yet. Run: claude-backup init');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const safe = JSON.parse(JSON.stringify(config));
|
|
33
|
+
if (safe.github?.pat) {
|
|
34
|
+
safe.github.pat = safe.github.pat.slice(0, 8) + '••••••••';
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.bold('\nCurrent config:\n'));
|
|
37
|
+
console.log(JSON.stringify(safe, null, 2));
|
|
38
|
+
console.log('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setConfig(key, value) {
|
|
42
|
+
if (!key || value === undefined) {
|
|
43
|
+
log.error('Usage: claude-backup config set <key> <value>');
|
|
44
|
+
log.dim('Example: claude-backup config set auto_sync.debounce_ms 3000');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const config = requireConfig();
|
|
48
|
+
const parts = key.split('.');
|
|
49
|
+
let obj = config;
|
|
50
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
51
|
+
if (typeof obj[parts[i]] !== 'object') obj[parts[i]] = {};
|
|
52
|
+
obj = obj[parts[i]];
|
|
53
|
+
}
|
|
54
|
+
let parsed = value;
|
|
55
|
+
if (value === 'true') parsed = true;
|
|
56
|
+
else if (value === 'false') parsed = false;
|
|
57
|
+
else if (!isNaN(value) && value !== '') parsed = Number(value);
|
|
58
|
+
|
|
59
|
+
obj[parts[parts.length - 1]] = parsed;
|
|
60
|
+
saveConfig(config);
|
|
61
|
+
log.success(`Set ${key} = ${key.includes('pat') ? '••••' : parsed}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addDir(dirPath) {
|
|
65
|
+
if (!dirPath) {
|
|
66
|
+
log.error('Usage: claude-backup config add-dir <path>');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const config = requireConfig();
|
|
70
|
+
const expanded = expandPath(dirPath);
|
|
71
|
+
if (config.watched_dirs.some(d => d === dirPath || d === expanded)) {
|
|
72
|
+
log.warn('Directory is already in the watch list');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
config.watched_dirs.push(expanded);
|
|
76
|
+
saveConfig(config);
|
|
77
|
+
log.success(`Added watched dir: ${expanded}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function removeDir(dirPath) {
|
|
81
|
+
if (!dirPath) {
|
|
82
|
+
log.error('Usage: claude-backup config remove-dir <path>');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const config = requireConfig();
|
|
86
|
+
const expanded = expandPath(dirPath);
|
|
87
|
+
const before = config.watched_dirs.length;
|
|
88
|
+
config.watched_dirs = config.watched_dirs.filter(
|
|
89
|
+
d => d !== dirPath && d !== expanded
|
|
90
|
+
);
|
|
91
|
+
if (config.watched_dirs.length < before) {
|
|
92
|
+
saveConfig(config);
|
|
93
|
+
log.success(`Removed watched dir: ${expanded}`);
|
|
94
|
+
} else {
|
|
95
|
+
log.warn('Directory not found in watch list');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function addProject(dirPath) {
|
|
100
|
+
if (!dirPath) {
|
|
101
|
+
log.error('Usage: claude-backup config add-project <project-root-path>');
|
|
102
|
+
log.dim('Only the CLAUDE.md file at that path will be backed up.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const config = requireConfig();
|
|
106
|
+
if (!config.claude_md_dirs) config.claude_md_dirs = [];
|
|
107
|
+
const expanded = expandPath(dirPath);
|
|
108
|
+
if (config.claude_md_dirs.some(d => d === dirPath || d === expanded)) {
|
|
109
|
+
log.warn('Project is already in the list');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
config.claude_md_dirs.push(expanded);
|
|
113
|
+
saveConfig(config);
|
|
114
|
+
log.success(`Added project: ${expanded}`);
|
|
115
|
+
log.dim(`Will back up: ${expanded}/CLAUDE.md`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function removeProject(dirPath) {
|
|
119
|
+
if (!dirPath) {
|
|
120
|
+
log.error('Usage: claude-backup config remove-project <project-root-path>');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const config = requireConfig();
|
|
124
|
+
if (!config.claude_md_dirs) {
|
|
125
|
+
log.warn('No projects configured');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const expanded = expandPath(dirPath);
|
|
129
|
+
const before = config.claude_md_dirs.length;
|
|
130
|
+
config.claude_md_dirs = config.claude_md_dirs.filter(
|
|
131
|
+
d => d !== dirPath && d !== expanded
|
|
132
|
+
);
|
|
133
|
+
if (config.claude_md_dirs.length < before) {
|
|
134
|
+
saveConfig(config);
|
|
135
|
+
log.success(`Removed project: ${expanded}`);
|
|
136
|
+
} else {
|
|
137
|
+
log.warn('Project not found in list');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { loadConfig, saveConfig } from '../core/config.js';
|
|
6
|
+
import { ensureRepo } from '../backends/github.js';
|
|
7
|
+
import { log } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
export async function runInit() {
|
|
10
|
+
log.header('Claude Backup — Setup Wizard');
|
|
11
|
+
|
|
12
|
+
const existing = loadConfig();
|
|
13
|
+
|
|
14
|
+
// ── Step 1: GitHub PAT ───────────────────────────────────────────────────
|
|
15
|
+
console.log(chalk.bold.underline('Step 1 of 4 — GitHub Personal Access Token') + '\n');
|
|
16
|
+
console.log('claude-backup needs a PAT with ' + chalk.cyan('"repo"') + ' scope to create');
|
|
17
|
+
console.log('and push to a private GitHub repository on your behalf.\n');
|
|
18
|
+
console.log(chalk.bold(' Create your token here:'));
|
|
19
|
+
console.log(' ' + chalk.underline.blue('https://github.com/settings/tokens/new') + '\n');
|
|
20
|
+
console.log(chalk.dim(' Instructions:'));
|
|
21
|
+
console.log(chalk.dim(' 1. Note name → e.g. "claude-backup"'));
|
|
22
|
+
console.log(chalk.dim(' 2. Expiration → your preference (No expiration is fine)'));
|
|
23
|
+
console.log(chalk.dim(' 3. Scopes → tick ') + chalk.cyan('repo') + chalk.dim(' (the top-level checkbox covers everything needed)'));
|
|
24
|
+
console.log(chalk.dim(' 4. Click "Generate token" and copy the value\n'));
|
|
25
|
+
console.log(chalk.dim(' Fine-grained token alternative:'));
|
|
26
|
+
console.log(chalk.dim(' ' + chalk.underline('https://github.com/settings/personal-access-tokens/new')));
|
|
27
|
+
console.log(chalk.dim(' → Repository permissions: Contents (Read & Write), Metadata (Read)\n'));
|
|
28
|
+
|
|
29
|
+
const { pat } = await inquirer.prompt([
|
|
30
|
+
{
|
|
31
|
+
type: 'password',
|
|
32
|
+
name: 'pat',
|
|
33
|
+
message: 'Paste your GitHub PAT:',
|
|
34
|
+
default: existing?.github?.pat || '',
|
|
35
|
+
validate: v => v.trim().length > 0 || 'PAT is required',
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// ── Step 2: Repo & branch ────────────────────────────────────────────────
|
|
40
|
+
console.log('\n' + chalk.bold.underline('Step 2 of 4 — Repository & Branch') + '\n');
|
|
41
|
+
console.log(chalk.dim(' The repo will be created as private if it does not exist yet.\n'));
|
|
42
|
+
|
|
43
|
+
const { repo, branch } = await inquirer.prompt([
|
|
44
|
+
{
|
|
45
|
+
type: 'input',
|
|
46
|
+
name: 'repo',
|
|
47
|
+
message: 'GitHub repo name (e.g. yourname/claude-backup):',
|
|
48
|
+
default: existing?.github?.repo || '',
|
|
49
|
+
validate: v =>
|
|
50
|
+
/^[\w.-]+\/[\w.-]+$/.test(v.trim()) || 'Format must be owner/repo',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'input',
|
|
54
|
+
name: 'branch',
|
|
55
|
+
message: 'Branch name:',
|
|
56
|
+
default: existing?.github?.branch || 'main',
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// ── Step 3: Watched dirs & filters ───────────────────────────────────────
|
|
61
|
+
console.log('\n' + chalk.bold.underline('Step 3 of 4 — Watched Directories & Filters') + '\n');
|
|
62
|
+
console.log(chalk.dim(' These directories are fully mirrored to GitHub on every change.\n'));
|
|
63
|
+
|
|
64
|
+
const { watched_dirs, exclude, debounce_ms } = await inquirer.prompt([
|
|
65
|
+
{
|
|
66
|
+
type: 'input',
|
|
67
|
+
name: 'watched_dirs',
|
|
68
|
+
message: 'Directories to watch (comma-separated):',
|
|
69
|
+
default: existing?.watched_dirs?.join(', ') || join(homedir(), '.claude'),
|
|
70
|
+
filter: v => v.split(',').map(s => s.trim()).filter(Boolean),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: 'input',
|
|
74
|
+
name: 'exclude',
|
|
75
|
+
message: 'Files to exclude (comma-separated, * wildcards ok):',
|
|
76
|
+
default:
|
|
77
|
+
existing?.exclude?.join(', ') ||
|
|
78
|
+
'settings.local.json, *.log, .DS_Store',
|
|
79
|
+
filter: v => v.split(',').map(s => s.trim()).filter(Boolean),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'number',
|
|
83
|
+
name: 'debounce_ms',
|
|
84
|
+
message: 'Debounce delay in ms (wait time after a change before pushing):',
|
|
85
|
+
default: existing?.auto_sync?.debounce_ms ?? 2000,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// ── Step 4: Project CLAUDE.md files ──────────────────────────────────────
|
|
90
|
+
console.log('\n' + chalk.bold.underline('Step 4 of 4 — Project CLAUDE.md Files') + '\n');
|
|
91
|
+
console.log('Each Claude Code project can have a ' + chalk.cyan('CLAUDE.md') + ' file at its root.');
|
|
92
|
+
console.log('These contain project-specific instructions Claude reads at the start of every session.\n');
|
|
93
|
+
console.log(chalk.dim(' Only the CLAUDE.md file is backed up from each path — not your source code.\n'));
|
|
94
|
+
console.log(chalk.dim(' Example paths:'));
|
|
95
|
+
console.log(chalk.dim(` ${join(homedir(), 'projects', 'my-app')}`));
|
|
96
|
+
console.log(chalk.dim(` ${join(homedir(), 'work', 'client-site')}\n`));
|
|
97
|
+
|
|
98
|
+
const { claude_md_dirs } = await inquirer.prompt([
|
|
99
|
+
{
|
|
100
|
+
type: 'input',
|
|
101
|
+
name: 'claude_md_dirs',
|
|
102
|
+
message: 'Project root directories with CLAUDE.md (comma-separated, blank to skip):',
|
|
103
|
+
default: existing?.claude_md_dirs?.join(', ') || '',
|
|
104
|
+
filter: v => v.split(',').map(s => s.trim()).filter(Boolean),
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
// ── Save & connect ───────────────────────────────────────────────────────
|
|
109
|
+
console.log('');
|
|
110
|
+
|
|
111
|
+
const config = {
|
|
112
|
+
backend: 'github',
|
|
113
|
+
github: {
|
|
114
|
+
pat: pat.trim(),
|
|
115
|
+
repo: repo.trim(),
|
|
116
|
+
branch: branch.trim(),
|
|
117
|
+
},
|
|
118
|
+
watched_dirs,
|
|
119
|
+
claude_md_dirs,
|
|
120
|
+
exclude,
|
|
121
|
+
auto_sync: { debounce_ms },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
saveConfig(config);
|
|
125
|
+
log.success('Config saved (~/.config/claude-backup/config.json, chmod 600)');
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await ensureRepo(config);
|
|
129
|
+
log.success('GitHub setup complete\n');
|
|
130
|
+
log.info('Next steps:');
|
|
131
|
+
log.dim(' claude-backup push — do your first backup now');
|
|
132
|
+
log.dim(' claude-backup service install — enable auto-sync on login');
|
|
133
|
+
} catch (err) {
|
|
134
|
+
log.error(`GitHub setup failed: ${err.message}`);
|
|
135
|
+
if (process.env.DEBUG) console.error(err);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync, cpSync, readdirSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { requireConfig, CONFIG_DIR, REPO_DIR, expandPath } from '../core/config.js';
|
|
6
|
+
import { fetchForRestore, getHistory } from '../backends/github.js';
|
|
7
|
+
import { log, spinner } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
function walkRepoDir(dir, results = [], rel = '') {
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13
|
+
} catch {
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (entry.name === '.git' || entry.name === 'backup-manifest.json') continue;
|
|
18
|
+
const fullPath = join(dir, entry.name);
|
|
19
|
+
const relPath = rel ? join(rel, entry.name) : entry.name;
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
walkRepoDir(fullPath, results, relPath);
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
results.push({ repoPath: fullPath, rel: relPath });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildRestoreOps(manifest) {
|
|
30
|
+
const repoFiles = walkRepoDir(REPO_DIR);
|
|
31
|
+
const ops = [];
|
|
32
|
+
|
|
33
|
+
for (const { repoPath, rel } of repoFiles) {
|
|
34
|
+
// rel looks like "Users_apple_.claude/settings.json"
|
|
35
|
+
const sep = rel.indexOf('/') !== -1 ? rel.indexOf('/') : rel.indexOf('\\');
|
|
36
|
+
const label = sep === -1 ? rel : rel.slice(0, sep);
|
|
37
|
+
const rest = sep === -1 ? '' : rel.slice(sep + 1);
|
|
38
|
+
|
|
39
|
+
const targetBase = manifest[label];
|
|
40
|
+
if (!targetBase) continue;
|
|
41
|
+
|
|
42
|
+
ops.push({
|
|
43
|
+
src: repoPath,
|
|
44
|
+
dest: join(expandPath(targetBase), rest),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return ops;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runPull(options) {
|
|
52
|
+
const config = requireConfig();
|
|
53
|
+
|
|
54
|
+
let ref;
|
|
55
|
+
|
|
56
|
+
if (options.history) {
|
|
57
|
+
const spin = spinner('Loading commit history...').start();
|
|
58
|
+
const history = await getHistory(config);
|
|
59
|
+
spin.stop();
|
|
60
|
+
|
|
61
|
+
if (history.length === 0) {
|
|
62
|
+
log.warn('No commits found. Run: claude-backup push first');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { chosen } = await inquirer.prompt([
|
|
67
|
+
{
|
|
68
|
+
type: 'list',
|
|
69
|
+
name: 'chosen',
|
|
70
|
+
message: 'Select a backup to restore:',
|
|
71
|
+
choices: history.map(c => ({
|
|
72
|
+
name: `${String(c.date).slice(0, 16)} ${c.message} (${String(c.hash).slice(0, 7)})`,
|
|
73
|
+
value: c.hash,
|
|
74
|
+
})),
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
ref = chosen;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const spin = spinner('Fetching backup from GitHub...').start();
|
|
81
|
+
let manifest;
|
|
82
|
+
try {
|
|
83
|
+
manifest = await fetchForRestore(config, ref);
|
|
84
|
+
spin.stop();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
spin.fail(`Fetch failed: ${err.message}`);
|
|
87
|
+
if (process.env.DEBUG) console.error(err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!manifest || Object.keys(manifest).length === 0) {
|
|
92
|
+
log.error('No backup manifest found. The repo may be empty or from an older version.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ops = buildRestoreOps(manifest);
|
|
97
|
+
if (ops.length === 0) {
|
|
98
|
+
log.warn('No files to restore — watched dirs may not match this backup');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (options.dryRun) {
|
|
103
|
+
log.header('Dry run — files that would be restored:');
|
|
104
|
+
for (const { dest } of ops) {
|
|
105
|
+
console.log(chalk.cyan(' →'), dest);
|
|
106
|
+
}
|
|
107
|
+
log.info(`\nTotal: ${ops.length} file(s)`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log.info(`Will restore ${ops.length} file(s) to:`);
|
|
112
|
+
const preview = ops.slice(0, 8);
|
|
113
|
+
for (const { dest } of preview) {
|
|
114
|
+
console.log(chalk.dim(' ' + dest));
|
|
115
|
+
}
|
|
116
|
+
if (ops.length > 8) {
|
|
117
|
+
console.log(chalk.dim(` ... and ${ops.length - 8} more`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { confirmed } = await inquirer.prompt([
|
|
121
|
+
{
|
|
122
|
+
type: 'confirm',
|
|
123
|
+
name: 'confirmed',
|
|
124
|
+
message: 'Proceed? A safety snapshot of your current state will be created first.',
|
|
125
|
+
default: false,
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
if (!confirmed) {
|
|
130
|
+
log.info('Restore cancelled');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Safety snapshot — copy current watched dirs to a timestamped backup
|
|
135
|
+
const snapshotDir = join(CONFIG_DIR, `pre-restore-${Date.now()}`);
|
|
136
|
+
const snapSpin = spinner('Creating safety snapshot of current state...').start();
|
|
137
|
+
mkdirSync(snapshotDir, { recursive: true });
|
|
138
|
+
for (const rawDir of config.watched_dirs) {
|
|
139
|
+
const dir = expandPath(rawDir);
|
|
140
|
+
if (existsSync(dir)) {
|
|
141
|
+
const label = dir.replace(/[^a-zA-Z0-9]/g, '_');
|
|
142
|
+
try {
|
|
143
|
+
cpSync(dir, join(snapshotDir, label), { recursive: true });
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
snapSpin.succeed(`Safety snapshot saved → ${snapshotDir}`);
|
|
148
|
+
|
|
149
|
+
// Restore
|
|
150
|
+
const restoreSpin = spinner(`Restoring ${ops.length} files...`).start();
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (const { src, dest } of ops) {
|
|
153
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
154
|
+
try {
|
|
155
|
+
copyFileSync(src, dest);
|
|
156
|
+
count++;
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
restoreSpin.succeed(`Restored ${count} file(s)`);
|
|
160
|
+
log.success('Restore complete');
|
|
161
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { requireConfig } from '../core/config.js';
|
|
2
|
+
import { push } from '../backends/github.js';
|
|
3
|
+
import { log } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export async function runPush(options) {
|
|
6
|
+
const config = requireConfig();
|
|
7
|
+
try {
|
|
8
|
+
await push(config, options.message);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
log.error(`Push failed: ${err.message}`);
|
|
11
|
+
if (process.env.DEBUG) console.error(err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
2
|
+
import { exec, spawn } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { LAUNCHD_PLIST, LAUNCHD_LABEL, LOG_FILE, ERROR_LOG_FILE } from '../core/config.js';
|
|
5
|
+
import { log } from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
|
|
9
|
+
function buildPlist(nodePath, scriptPath) {
|
|
10
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
11
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
12
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
13
|
+
<plist version="1.0">
|
|
14
|
+
<dict>
|
|
15
|
+
<key>Label</key>
|
|
16
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
17
|
+
|
|
18
|
+
<key>ProgramArguments</key>
|
|
19
|
+
<array>
|
|
20
|
+
<string>${nodePath}</string>
|
|
21
|
+
<string>${scriptPath}</string>
|
|
22
|
+
<string>watch</string>
|
|
23
|
+
</array>
|
|
24
|
+
|
|
25
|
+
<key>RunAtLoad</key>
|
|
26
|
+
<true/>
|
|
27
|
+
|
|
28
|
+
<key>KeepAlive</key>
|
|
29
|
+
<true/>
|
|
30
|
+
|
|
31
|
+
<key>ThrottleInterval</key>
|
|
32
|
+
<integer>10</integer>
|
|
33
|
+
|
|
34
|
+
<key>StandardOutPath</key>
|
|
35
|
+
<string>${LOG_FILE}</string>
|
|
36
|
+
|
|
37
|
+
<key>StandardErrorPath</key>
|
|
38
|
+
<string>${ERROR_LOG_FILE}</string>
|
|
39
|
+
</dict>
|
|
40
|
+
</plist>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadService() {
|
|
44
|
+
const uid = process.getuid();
|
|
45
|
+
// Try modern launchctl first (macOS 13+), fall back to legacy
|
|
46
|
+
try {
|
|
47
|
+
await execAsync(`launchctl bootstrap gui/${uid} "${LAUNCHD_PLIST}"`);
|
|
48
|
+
} catch {
|
|
49
|
+
await execAsync(`launchctl load -w "${LAUNCHD_PLIST}"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function unloadService() {
|
|
54
|
+
const uid = process.getuid();
|
|
55
|
+
try {
|
|
56
|
+
await execAsync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`);
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
await execAsync(`launchctl unload -w "${LAUNCHD_PLIST}"`);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function installService() {
|
|
65
|
+
const nodePath = process.execPath; // path to the node binary running this script
|
|
66
|
+
const scriptPath = process.argv[1]; // absolute path to claude-backup.js
|
|
67
|
+
|
|
68
|
+
const plist = buildPlist(nodePath, scriptPath);
|
|
69
|
+
writeFileSync(LAUNCHD_PLIST, plist, 'utf8');
|
|
70
|
+
log.info(`Plist written to: ${LAUNCHD_PLIST}`);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Unload first in case already loaded
|
|
74
|
+
await unloadService();
|
|
75
|
+
} catch {}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await loadService();
|
|
79
|
+
log.success('Service installed and started');
|
|
80
|
+
log.dim(`Node: ${nodePath}`);
|
|
81
|
+
log.dim(`Script: ${scriptPath}`);
|
|
82
|
+
log.dim(`Stdout: ${LOG_FILE}`);
|
|
83
|
+
log.dim(`Stderr: ${ERROR_LOG_FILE}`);
|
|
84
|
+
log.info('The watcher will now auto-start on every login');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log.error(`Failed to start service: ${err.message}`);
|
|
87
|
+
log.dim('Try running manually: launchctl load -w ' + LAUNCHD_PLIST);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function uninstallService() {
|
|
92
|
+
if (!existsSync(LAUNCHD_PLIST)) {
|
|
93
|
+
log.warn('Service is not installed');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await unloadService();
|
|
98
|
+
log.success('Service stopped and unloaded');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn(`Could not unload: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
unlinkSync(LAUNCHD_PLIST);
|
|
103
|
+
log.success('Service plist removed — auto-sync disabled');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function serviceStatus() {
|
|
107
|
+
if (!existsSync(LAUNCHD_PLIST)) {
|
|
108
|
+
log.warn('Service is not installed');
|
|
109
|
+
log.dim('Run: claude-backup service install');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const uid = process.getuid();
|
|
114
|
+
try {
|
|
115
|
+
// Modern approach
|
|
116
|
+
const { stdout } = await execAsync(
|
|
117
|
+
`launchctl print gui/${uid}/${LAUNCHD_LABEL} 2>/dev/null`
|
|
118
|
+
);
|
|
119
|
+
const pidMatch = stdout.match(/pid\s*=\s*(\d+)/);
|
|
120
|
+
if (pidMatch) {
|
|
121
|
+
log.success(`Service is running (PID: ${pidMatch[1]})`);
|
|
122
|
+
} else {
|
|
123
|
+
log.warn('Service is loaded but not currently running');
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Fallback to list
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await execAsync(`launchctl list | grep "${LAUNCHD_LABEL}"`);
|
|
129
|
+
const parts = stdout.trim().split(/\s+/);
|
|
130
|
+
if (parts[0] !== '-') {
|
|
131
|
+
log.success(`Service is running (PID: ${parts[0]})`);
|
|
132
|
+
} else {
|
|
133
|
+
log.warn(`Service is loaded but stopped (last exit code: ${parts[1]})`);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
log.warn('Service is not running');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log.dim(`Plist: ${LAUNCHD_PLIST}`);
|
|
141
|
+
log.dim(`Log: ${LOG_FILE}`);
|
|
142
|
+
log.dim(`Errors: ${ERROR_LOG_FILE}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function serviceLogs() {
|
|
146
|
+
if (!existsSync(LOG_FILE)) {
|
|
147
|
+
log.warn(`Log file not found: ${LOG_FILE}`);
|
|
148
|
+
log.dim('The service may not have run yet');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
log.dim(`Tailing: ${LOG_FILE} (Ctrl+C to stop)\n`);
|
|
152
|
+
const tail = spawn('tail', ['-f', LOG_FILE], { stdio: 'inherit' });
|
|
153
|
+
tail.on('close', () => process.exit(0));
|
|
154
|
+
process.on('SIGINT', () => { tail.kill(); process.exit(0); });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function runService(action) {
|
|
158
|
+
switch (action) {
|
|
159
|
+
case 'install': return installService();
|
|
160
|
+
case 'uninstall': return uninstallService();
|
|
161
|
+
case 'status': return serviceStatus();
|
|
162
|
+
case 'logs': return serviceLogs();
|
|
163
|
+
default:
|
|
164
|
+
log.error(`Unknown action: "${action}"`);
|
|
165
|
+
log.info('Valid actions: install | uninstall | status | logs');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|