memoir-cli 3.0.1 → 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Sync AI memory across devices. Back up and restore Claude, Gemini, Codex, Cursor, Copilot, Windsurf configs. Snapshot coding sessions and resume on another machine. Migrate instructions between AI assistants.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -37,15 +37,22 @@ export const adapters = [
37
37
  if (src === claudeDir) return true;
38
38
  // Only allow these top-level dirs
39
39
  const topDir = rel.split(path.sep)[0];
40
- const allowedDirs = ['projects', 'settings'];
41
40
  const allowedFiles = ['settings.json', 'settings.local.json'];
42
41
  // Allow specific top-level config files
43
42
  if (!rel.includes(path.sep) && allowedFiles.includes(basename)) return true;
44
43
  // Allow projects dir (contains memory .md files)
45
44
  if (topDir === 'projects') {
46
- // Allow directory traversal
47
- try { if (nodeFs.statSync(src).isDirectory()) return true; } catch {}
48
- // Only sync memory markdown files
45
+ // Allow directory traversal but skip dirs that only contain session data
46
+ try {
47
+ if (nodeFs.statSync(src).isDirectory()) {
48
+ // Skip subagents and UUID-named session dirs (contain large .jsonl files, no .md)
49
+ if (basename === 'subagents') return false;
50
+ // UUID pattern: 8-4-4-4-12 hex chars
51
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(basename)) return false;
52
+ return true;
53
+ }
54
+ } catch {}
55
+ // Only sync memory markdown files — skip .jsonl session files
49
56
  return basename.endsWith('.md');
50
57
  }
51
58
  // Allow settings dir
@@ -170,6 +170,60 @@ async function mergeMemoryDirs(src, dest) {
170
170
  }
171
171
  }
172
172
 
173
+ // After restore, ensure every memory .md file is referenced in its MEMORY.md index.
174
+ // Without this, files synced from another machine exist but Claude won't know about them.
175
+ async function reconcileMemoryIndexes(claudeSource) {
176
+ const projectsDir = path.join(claudeSource, 'projects');
177
+ if (!fs.existsSync(projectsDir)) return;
178
+
179
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
180
+ for (const entry of entries) {
181
+ if (!entry.isDirectory()) continue;
182
+ const memDir = path.join(projectsDir, entry.name, 'memory');
183
+ if (!fs.existsSync(memDir)) continue;
184
+
185
+ const memoryMdPath = path.join(memDir, 'MEMORY.md');
186
+ let memoryMd = '';
187
+ if (fs.existsSync(memoryMdPath)) {
188
+ memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
189
+ }
190
+
191
+ // Find all .md files in this memory dir
192
+ const mdFiles = fs.readdirSync(memDir)
193
+ .filter(f => f.endsWith('.md') && f !== 'MEMORY.md');
194
+
195
+ // Check which ones are NOT referenced in MEMORY.md
196
+ const unreferenced = mdFiles.filter(f => {
197
+ const name = f.replace('.md', '');
198
+ // Check for markdown link [text](file.md) or plain filename mention
199
+ return !memoryMd.includes(f) && !memoryMd.includes(`(${f})`);
200
+ });
201
+
202
+ if (unreferenced.length === 0) continue;
203
+
204
+ // Read each unreferenced file to get its name/description from frontmatter
205
+ let additions = '\n\n## Synced from another machine\n';
206
+ for (const file of unreferenced) {
207
+ const content = fs.readFileSync(path.join(memDir, file), 'utf8');
208
+ // Try to extract name from frontmatter
209
+ const nameMatch = content.match(/^name:\s*(.+)/m);
210
+ const descMatch = content.match(/^description:\s*(.+)/m);
211
+ const name = nameMatch ? nameMatch[1].trim() : file.replace('.md', '').replace(/-/g, ' ');
212
+ const desc = descMatch ? descMatch[1].trim() : '';
213
+ additions += `- [${name}](${file})${desc ? ' — ' + desc : ''}\n`;
214
+ }
215
+
216
+ // Append to MEMORY.md
217
+ if (!memoryMd) {
218
+ memoryMd = '# Project Memory\n';
219
+ }
220
+ // Remove old "Synced from another machine" section if it exists, then re-add
221
+ memoryMd = memoryMd.replace(/\n\n## Synced from another machine\n[\s\S]*$/, '');
222
+ memoryMd = memoryMd.trimEnd() + additions;
223
+ fs.writeFileSync(memoryMdPath, memoryMd);
224
+ }
225
+ }
226
+
173
227
  async function syncFiles(src, dest, changes) {
174
228
  const entries = await fs.readdir(src, { withFileTypes: true });
175
229
  for (const entry of entries) {
@@ -284,6 +338,13 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
284
338
  await syncFiles(backupDir, adapter.source, changes);
285
339
  }
286
340
 
341
+ // After syncing, reconcile MEMORY.md files
342
+ // MEMORY.md is an INDEX — it must reference all memory files from both machines
343
+ // This MUST run after syncFiles so newly copied files are included
344
+ if (adapter.name === 'Claude CLI') {
345
+ await reconcileMemoryIndexes(adapter.source);
346
+ }
347
+
287
348
  // Show summary of changes
288
349
  spinner.stop();
289
350
  const totalChanged = changes.added.length + changes.updated.length;
@@ -13,6 +13,7 @@ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreP
13
13
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
14
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
15
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
16
+ import { scanWorkspace } from '../workspace/tracker.js';
16
17
 
17
18
  export async function pushCommand(options = {}) {
18
19
  const config = await getConfig(options.profile);
@@ -32,6 +33,8 @@ export async function pushCommand(options = {}) {
32
33
  const stagingDir = path.join(os.tmpdir(), `memoir-staging-${Date.now()}`);
33
34
  await fs.ensureDir(stagingDir);
34
35
 
36
+ let encryptedDir = null;
37
+
35
38
  try {
36
39
  // Profile-level tool filter (config.only) merged with CLI --only flag
37
40
  const onlyRaw = options.only || (config.only ? config.only.join(',') : null);
@@ -99,6 +102,15 @@ export async function pushCommand(options = {}) {
99
102
  // Context capture is best-effort — don't fail the push
100
103
  }
101
104
 
105
+ // Scan workspace for projects (git repos + unbacked projects)
106
+ let workspaceManifest = null;
107
+ spinner.text = chalk.gray('Scanning workspace...');
108
+ try {
109
+ workspaceManifest = await scanWorkspace(stagingDir, spinner);
110
+ } catch {
111
+ // Workspace scan is best-effort
112
+ }
113
+
102
114
  // Count what was found
103
115
  const found = [];
104
116
  for (const adapter of adapters) {
@@ -160,7 +172,7 @@ export async function pushCommand(options = {}) {
160
172
  }]);
161
173
  spinner.start(chalk.gray('Encrypting...'));
162
174
 
163
- const encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
175
+ encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
164
176
  await fs.ensureDir(encryptedDir);
165
177
  await encryptDirectory(stagingDir, encryptedDir, passphrase);
166
178
 
@@ -218,9 +230,18 @@ export async function pushCommand(options = {}) {
218
230
  contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
219
231
  }
220
232
  }
233
+ let workspaceLine = '';
234
+ if (workspaceManifest && workspaceManifest.projects.length > 0) {
235
+ const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
236
+ const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
237
+ const parts = [];
238
+ if (gitCount > 0) parts.push(`${gitCount} git`);
239
+ if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
240
+ workspaceLine = '\n' + chalk.green(' ✔ Workspace') + chalk.gray(` (${workspaceManifest.projects.length} projects — ${parts.join(', ')})`) + '\n';
241
+ }
221
242
  console.log('\n' + boxen(
222
243
  gradient.pastel(' Backed up! ') + '\n\n' +
223
- toolList + contextLine + '\n' +
244
+ toolList + contextLine + workspaceLine + '\n' +
224
245
  chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
225
246
  (encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
226
247
  chalk.gray(`→ ${dest}`) + '\n\n' +
@@ -232,13 +253,8 @@ export async function pushCommand(options = {}) {
232
253
  } finally {
233
254
  await fs.remove(stagingDir);
234
255
  // Clean up encrypted dir if it was created
235
- if (true) {
236
- const encDirs = await fs.readdir(os.tmpdir());
237
- for (const d of encDirs) {
238
- if (d.startsWith('memoir-encrypted-')) {
239
- await fs.remove(path.join(os.tmpdir(), d)).catch(() => {});
240
- }
241
- }
256
+ if (encryptedDir) {
257
+ await fs.remove(encryptedDir).catch(() => {});
242
258
  }
243
259
  }
244
260
  }
@@ -10,6 +10,7 @@ import { getConfig } from '../config.js';
10
10
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
11
11
  import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
12
12
  import { detectLocalHomeKey } from '../adapters/restore.js';
13
+ import { restoreWorkspace } from '../workspace/tracker.js';
13
14
 
14
15
  const home = os.homedir();
15
16
 
@@ -169,6 +170,39 @@ export async function restoreCommand(options = {}) {
169
170
  }
170
171
  }
171
172
 
173
+ // Restore workspace (clone git projects, unpack bundles)
174
+ let workspaceResults = null;
175
+ try {
176
+ spinner.start(chalk.gray('Checking workspace...'));
177
+ workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
178
+ spinner.stop();
179
+
180
+ if (workspaceResults) {
181
+ const { cloned, unpacked, patched, skipped } = workspaceResults;
182
+ if (cloned.length > 0 || unpacked.length > 0) {
183
+ console.log('\n' + chalk.cyan.bold(' 📁 Workspace restored:'));
184
+ for (const p of cloned) {
185
+ console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path}`));
186
+ }
187
+ for (const p of unpacked) {
188
+ console.log(chalk.green(` ✔ ${p.name}`) + chalk.gray(` → ${p.path} (unpacked)`));
189
+ }
190
+ if (patched.length > 0) {
191
+ for (const p of patched) {
192
+ console.log(chalk.yellow(` ↻ ${p.name}`) + chalk.gray(` — uncommitted changes applied`));
193
+ }
194
+ }
195
+ const existingCount = skipped.filter(s => s.reason === 'exists').length;
196
+ if (existingCount > 0) {
197
+ console.log(chalk.gray(` ⏭ ${existingCount} project(s) already on this machine`));
198
+ }
199
+ restored = true;
200
+ }
201
+ }
202
+ } catch {
203
+ // Workspace restore is best-effort
204
+ }
205
+
172
206
  if (restored) {
173
207
  let handoffMsg = '';
174
208
  if (handoffInjected && handoffInfo) {
@@ -178,10 +212,17 @@ export async function restoreCommand(options = {}) {
178
212
  (handoffInfo.duration ? '\n' + chalk.gray(` Duration: ${handoffInfo.duration}`) : '') + '\n' +
179
213
  chalk.gray(' Your AI will pick up where you left off.');
180
214
  }
215
+ let workspaceMsg = '';
216
+ if (workspaceResults) {
217
+ const total = workspaceResults.cloned.length + workspaceResults.unpacked.length;
218
+ if (total > 0) {
219
+ workspaceMsg = '\n' + chalk.cyan(`📁 ${total} project(s) restored to this machine`);
220
+ }
221
+ }
181
222
  console.log(boxen(
182
223
  gradient.pastel(' Done! ') + '\n\n' +
183
224
  chalk.white('Your AI tools have their memories back.') +
184
- handoffMsg + '\n' +
225
+ handoffMsg + workspaceMsg + '\n' +
185
226
  chalk.gray(handoffInjected ? '' : 'Restart your AI tools to pick up the changes.'),
186
227
  { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
187
228
  ) + '\n');
@@ -42,12 +42,18 @@ export function findClaudeSessions() {
42
42
  */
43
43
  export function parseSession(sessionPath, maxSizeMB = 10) {
44
44
  const stat = fs.statSync(sessionPath);
45
- if (stat.size > maxSizeMB * 1024 * 1024) {
46
- // For large files, only parse the last portion
47
- const raw = fs.readFileSync(sessionPath, 'utf8');
48
- const lines = raw.split('\n');
49
- const lastLines = lines.slice(-500); // Last 500 lines
50
- return parseLines(lastLines);
45
+ const TAIL_BYTES = 2 * 1024 * 1024; // Read last 2MB max
46
+
47
+ if (stat.size > TAIL_BYTES) {
48
+ // For large files, only read the tail to avoid loading 20MB+ into memory
49
+ const fd = fs.openSync(sessionPath, 'r');
50
+ const buf = Buffer.alloc(TAIL_BYTES);
51
+ fs.readSync(fd, buf, 0, TAIL_BYTES, stat.size - TAIL_BYTES);
52
+ fs.closeSync(fd);
53
+ const raw = buf.toString('utf8');
54
+ // Skip the first (partial) line since we likely cut mid-line
55
+ const lines = raw.split('\n').slice(1);
56
+ return parseLines(lines);
51
57
  }
52
58
 
53
59
  const raw = fs.readFileSync(sessionPath, 'utf8').trim();
@@ -0,0 +1,350 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const home = os.homedir();
7
+
8
+ /**
9
+ * Scan home directory for git projects and build a workspace manifest.
10
+ * Tracks: git remote URL, branch, last commit, uncommitted changes (as patch).
11
+ * For non-git projects with AI configs, bundles them as tar.gz.
12
+ */
13
+ export async function scanWorkspace(stagingDir, spinner, opts = {}) {
14
+ const maxDepth = opts.maxDepth || 3;
15
+ const maxBundleSize = opts.maxBundleSize || 50 * 1024 * 1024; // 50MB
16
+
17
+ const skipDirs = new Set([
18
+ 'node_modules', '.git', '.next', '.vercel', 'dist', 'build',
19
+ '__pycache__', '.venv', 'venv', '.cache', '.npm', '.bun',
20
+ 'Library', '.Trash', 'Applications', 'Pictures', 'Music',
21
+ 'Movies', 'Public', 'Downloads', '.local', '.cargo', '.rustup',
22
+ '.docker', '.ssh', '.config', '.claude', '.gemini'
23
+ ]);
24
+
25
+ // Project markers — files that indicate "this is a project"
26
+ const projectMarkers = [
27
+ 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml',
28
+ 'requirements.txt', 'Gemfile', 'pom.xml', 'build.gradle',
29
+ 'Makefile', 'CMakeLists.txt', '.project', 'CLAUDE.md',
30
+ 'GEMINI.md', 'AGENTS.md', 'README.md',
31
+ // Also detect dirs with .git or multiple content files
32
+ '.gitignore', 'index.html', 'main.py', 'app.py', 'index.js',
33
+ ];
34
+
35
+ // Also detect dirs with multiple markdown/code files as potential projects
36
+ const isContentProject = (entries) => {
37
+ const mdFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md'));
38
+ return mdFiles.length >= 2; // 2+ markdown files = likely a writing project
39
+ };
40
+
41
+ const projects = [];
42
+
43
+ const scanDir = async (dir, depth = 0) => {
44
+ if (depth > maxDepth) return;
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(dir, { withFileTypes: true });
48
+ } catch { return; }
49
+
50
+ // Check if this dir is a project
51
+ const hasMarker = entries.some(e => !e.isDirectory() && projectMarkers.includes(e.name));
52
+ const hasContent = isContentProject(entries);
53
+
54
+ if ((hasMarker || hasContent) && dir !== home) {
55
+ const info = await getProjectInfo(dir);
56
+ if (info) projects.push(info);
57
+ // Don't recurse into sub-projects deeper than this
58
+ return;
59
+ }
60
+
61
+ // Recurse into subdirectories
62
+ for (const entry of entries) {
63
+ if (!entry.isDirectory()) continue;
64
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
65
+ if (skipDirs.has(entry.name)) continue;
66
+ await scanDir(path.join(dir, entry.name), depth + 1);
67
+ }
68
+ };
69
+
70
+ if (spinner) spinner.text = 'Scanning workspace for projects...';
71
+ await scanDir(home);
72
+
73
+ // Build manifest
74
+ const manifest = {
75
+ version: 1,
76
+ machine: os.hostname(),
77
+ platform: process.platform,
78
+ home: home,
79
+ scannedAt: new Date().toISOString(),
80
+ projects: []
81
+ };
82
+
83
+ const bundleDir = path.join(stagingDir, 'workspace-bundles');
84
+
85
+ for (const proj of projects) {
86
+ const entry = {
87
+ name: proj.name,
88
+ relativePath: proj.relativePath,
89
+ originalPath: proj.path,
90
+ type: proj.hasGit ? 'git' : 'bundle',
91
+ };
92
+
93
+ if (proj.hasGit) {
94
+ entry.gitRemote = proj.gitRemote;
95
+ entry.branch = proj.branch;
96
+ entry.lastCommit = proj.lastCommit;
97
+ entry.lastCommitMessage = proj.lastCommitMessage;
98
+
99
+ // Save uncommitted changes as patch
100
+ if (proj.hasDirtyWork && proj.lastCommit) {
101
+ try {
102
+ const patchDir = path.join(stagingDir, 'workspace-patches');
103
+ await fs.ensureDir(patchDir);
104
+ const diff = execFileSync('git', ['diff', 'HEAD'], {
105
+ cwd: proj.path,
106
+ maxBuffer: 10 * 1024 * 1024,
107
+ timeout: 10000
108
+ }).toString();
109
+ if (diff.trim()) {
110
+ const patchFile = `${proj.name}.patch`;
111
+ await fs.writeFile(path.join(patchDir, patchFile), diff);
112
+ entry.patchFile = patchFile;
113
+ }
114
+ } catch {
115
+ // Patch capture is best-effort
116
+ }
117
+ }
118
+ } else {
119
+ // Bundle non-git project (if small enough)
120
+ const size = await getDirSize(proj.path);
121
+ if (size <= maxBundleSize) {
122
+ try {
123
+ await fs.ensureDir(bundleDir);
124
+ const bundleName = `${proj.name}.tar.gz`;
125
+ execFileSync('tar', [
126
+ 'czf', path.join(bundleDir, bundleName),
127
+ '-C', path.dirname(proj.path),
128
+ '--exclude', 'node_modules',
129
+ '--exclude', '.git',
130
+ '--exclude', '__pycache__',
131
+ '--exclude', '.venv',
132
+ '--exclude', 'dist',
133
+ '--exclude', 'build',
134
+ proj.name
135
+ ], { stdio: 'ignore', timeout: 30000 });
136
+ entry.bundleFile = bundleName;
137
+ entry.bundleSize = (await fs.stat(path.join(bundleDir, bundleName))).size;
138
+ } catch {
139
+ // Bundle is best-effort
140
+ entry.bundleFailed = true;
141
+ }
142
+ } else {
143
+ entry.tooLarge = true;
144
+ entry.size = size;
145
+ }
146
+ }
147
+
148
+ manifest.projects.push(entry);
149
+ }
150
+
151
+ // Save manifest
152
+ await fs.writeFile(
153
+ path.join(stagingDir, 'workspace.json'),
154
+ JSON.stringify(manifest, null, 2)
155
+ );
156
+
157
+ return manifest;
158
+ }
159
+
160
+ /**
161
+ * Restore workspace on this machine from a manifest.
162
+ * Clones git projects, unpacks bundles, applies patches.
163
+ */
164
+ export async function restoreWorkspace(sourceDir, spinner, autoYes = false) {
165
+ const manifestPath = path.join(sourceDir, 'workspace.json');
166
+ if (!await fs.pathExists(manifestPath)) return null;
167
+
168
+ const manifest = await fs.readJson(manifestPath);
169
+ if (!manifest.projects || manifest.projects.length === 0) return null;
170
+
171
+ const results = { cloned: [], unpacked: [], patched: [], skipped: [] };
172
+
173
+ for (const proj of manifest.projects) {
174
+ // Determine where to put this project on the local machine
175
+ const localPath = resolveLocalPath(proj);
176
+
177
+ // Skip if project already exists locally
178
+ if (await fs.pathExists(localPath)) {
179
+ // But check if we should apply a patch
180
+ if (proj.patchFile) {
181
+ const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
182
+ if (await fs.pathExists(patchPath)) {
183
+ try {
184
+ execFileSync('git', ['apply', '--check', patchPath], {
185
+ cwd: localPath, stdio: 'ignore'
186
+ });
187
+ execFileSync('git', ['apply', patchPath], {
188
+ cwd: localPath, stdio: 'ignore'
189
+ });
190
+ results.patched.push({ name: proj.name, path: localPath });
191
+ } catch {
192
+ // Patch didn't apply cleanly — skip
193
+ }
194
+ }
195
+ }
196
+ results.skipped.push({ name: proj.name, path: localPath, reason: 'exists' });
197
+ continue;
198
+ }
199
+
200
+ if (proj.type === 'git' && proj.gitRemote) {
201
+ // Clone the repo
202
+ if (spinner) spinner.text = `Cloning ${proj.name}...`;
203
+ try {
204
+ await fs.ensureDir(path.dirname(localPath));
205
+ execFileSync('git', ['clone', proj.gitRemote, localPath], {
206
+ stdio: 'ignore',
207
+ timeout: 120000
208
+ });
209
+
210
+ // Checkout the right branch
211
+ if (proj.branch && proj.branch !== 'main' && proj.branch !== 'master') {
212
+ try {
213
+ execFileSync('git', ['checkout', proj.branch], {
214
+ cwd: localPath, stdio: 'ignore'
215
+ });
216
+ } catch {}
217
+ }
218
+
219
+ // Apply patch if available
220
+ if (proj.patchFile) {
221
+ const patchPath = path.join(sourceDir, 'workspace-patches', proj.patchFile);
222
+ if (await fs.pathExists(patchPath)) {
223
+ try {
224
+ execFileSync('git', ['apply', patchPath], {
225
+ cwd: localPath, stdio: 'ignore'
226
+ });
227
+ results.patched.push({ name: proj.name, path: localPath });
228
+ } catch {}
229
+ }
230
+ }
231
+
232
+ results.cloned.push({ name: proj.name, path: localPath, remote: proj.gitRemote });
233
+ } catch (err) {
234
+ results.skipped.push({ name: proj.name, reason: `clone failed: ${err.message}` });
235
+ }
236
+ } else if (proj.bundleFile) {
237
+ // Unpack bundle
238
+ const bundlePath = path.join(sourceDir, 'workspace-bundles', proj.bundleFile);
239
+ if (await fs.pathExists(bundlePath)) {
240
+ if (spinner) spinner.text = `Unpacking ${proj.name}...`;
241
+ try {
242
+ await fs.ensureDir(path.dirname(localPath));
243
+ execFileSync('tar', ['xzf', bundlePath, '-C', path.dirname(localPath)], {
244
+ stdio: 'ignore',
245
+ timeout: 60000
246
+ });
247
+ results.unpacked.push({ name: proj.name, path: localPath });
248
+ } catch (err) {
249
+ results.skipped.push({ name: proj.name, reason: `unpack failed: ${err.message}` });
250
+ }
251
+ }
252
+ } else {
253
+ results.skipped.push({ name: proj.name, reason: proj.tooLarge ? 'too large' : 'no source' });
254
+ }
255
+ }
256
+
257
+ return results;
258
+ }
259
+
260
+ /**
261
+ * Figure out where a project should live on this machine.
262
+ */
263
+ function resolveLocalPath(proj) {
264
+ // If the project had a relative path from home, use that
265
+ if (proj.relativePath) {
266
+ return path.join(home, proj.relativePath);
267
+ }
268
+ // Default: put it in home directory
269
+ return path.join(home, proj.name);
270
+ }
271
+
272
+ /**
273
+ * Get info about a project directory.
274
+ */
275
+ async function getProjectInfo(dir) {
276
+ const name = path.basename(dir);
277
+ const relativePath = path.relative(home, dir);
278
+ const info = {
279
+ name,
280
+ path: dir,
281
+ relativePath,
282
+ hasGit: false,
283
+ gitRemote: null,
284
+ branch: null,
285
+ lastCommit: null,
286
+ lastCommitMessage: null,
287
+ hasDirtyWork: false,
288
+ };
289
+
290
+ // Check for git
291
+ const gitDir = path.join(dir, '.git');
292
+ if (await fs.pathExists(gitDir)) {
293
+ info.hasGit = true;
294
+ try {
295
+ const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
296
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
297
+ }).toString().trim();
298
+ info.gitRemote = remote;
299
+ } catch {}
300
+
301
+ try {
302
+ info.branch = execFileSync('git', ['branch', '--show-current'], {
303
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
304
+ }).toString().trim();
305
+ } catch {}
306
+
307
+ try {
308
+ info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
309
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
310
+ }).toString().trim();
311
+ info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
312
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
313
+ }).toString().trim();
314
+ } catch {}
315
+
316
+ try {
317
+ const status = execFileSync('git', ['status', '--porcelain'], {
318
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
319
+ }).toString().trim();
320
+ info.hasDirtyWork = status.length > 0;
321
+ } catch {}
322
+ }
323
+
324
+ return info;
325
+ }
326
+
327
+ /**
328
+ * Get total size of a directory (excluding common heavy dirs).
329
+ */
330
+ async function getDirSize(dir) {
331
+ let size = 0;
332
+ const skip = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
333
+
334
+ const walk = async (d) => {
335
+ let entries;
336
+ try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
337
+ for (const e of entries) {
338
+ if (e.isDirectory()) {
339
+ if (!skip.has(e.name)) await walk(path.join(d, e.name));
340
+ } else {
341
+ try {
342
+ const stat = await fs.stat(path.join(d, e.name));
343
+ size += stat.size;
344
+ } catch {}
345
+ }
346
+ }
347
+ };
348
+ await walk(dir);
349
+ return size;
350
+ }