memoir-cli 3.0.3 → 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 +1 -1
- package/src/adapters/index.js +11 -4
- package/src/commands/push.js +20 -1
- package/src/commands/restore.js +42 -1
- package/src/context/capture.js +12 -6
- package/src/workspace/tracker.js +350 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.0
|
|
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",
|
package/src/adapters/index.js
CHANGED
|
@@ -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 {
|
|
48
|
-
|
|
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
|
package/src/commands/push.js
CHANGED
|
@@ -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);
|
|
@@ -101,6 +102,15 @@ export async function pushCommand(options = {}) {
|
|
|
101
102
|
// Context capture is best-effort — don't fail the push
|
|
102
103
|
}
|
|
103
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
|
+
|
|
104
114
|
// Count what was found
|
|
105
115
|
const found = [];
|
|
106
116
|
for (const adapter of adapters) {
|
|
@@ -220,9 +230,18 @@ export async function pushCommand(options = {}) {
|
|
|
220
230
|
contextLine += chalk.yellow(` 🔒 ${sessionInfo.secretsRedacted} secret(s) auto-redacted`) + '\n';
|
|
221
231
|
}
|
|
222
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
|
+
}
|
|
223
242
|
console.log('\n' + boxen(
|
|
224
243
|
gradient.pastel(' Backed up! ') + '\n\n' +
|
|
225
|
-
toolList + contextLine + '\n' +
|
|
244
|
+
toolList + contextLine + workspaceLine + '\n' +
|
|
226
245
|
chalk.white(`${totalFiles} files from ${found.length} tool${found.length !== 1 ? 's' : ''}`) + '\n' +
|
|
227
246
|
(encrypted ? chalk.green(' 🔒 E2E encrypted') + '\n' : '') +
|
|
228
247
|
chalk.gray(`→ ${dest}`) + '\n\n' +
|
package/src/commands/restore.js
CHANGED
|
@@ -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');
|
package/src/context/capture.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
+
}
|