git-drive 0.1.6 → 0.1.7
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/.github/workflows/ci.yml +77 -0
- package/.planning/codebase/ARCHITECTURE.md +151 -0
- package/.planning/codebase/CONCERNS.md +191 -0
- package/.planning/codebase/CONVENTIONS.md +169 -0
- package/.planning/codebase/INTEGRATIONS.md +94 -0
- package/.planning/codebase/STACK.md +77 -0
- package/.planning/codebase/STRUCTURE.md +157 -0
- package/.planning/codebase/TESTING.md +156 -0
- package/Dockerfile.cli +30 -0
- package/Dockerfile.server +32 -0
- package/README.md +157 -0
- package/docker-compose.yml +48 -0
- package/package.json +20 -55
- package/packages/cli/Dockerfile +26 -0
- package/packages/cli/jest.config.js +26 -0
- package/packages/cli/package.json +65 -0
- package/packages/cli/src/__tests__/commands/companion.test.ts +152 -0
- package/packages/cli/src/__tests__/commands/init.test.ts +154 -0
- package/packages/cli/src/__tests__/commands/list.test.ts +122 -0
- package/packages/cli/src/__tests__/commands/push.test.ts +155 -0
- package/packages/cli/src/__tests__/commands/restore.test.ts +135 -0
- package/packages/cli/src/__tests__/commands/status.test.ts +199 -0
- package/packages/cli/src/__tests__/config.test.ts +198 -0
- package/packages/cli/src/__tests__/e2e.test.ts +125 -0
- package/packages/cli/src/__tests__/errors.test.ts +66 -0
- package/packages/cli/src/__tests__/git.test.ts +250 -0
- package/packages/cli/src/__tests__/server.test.ts +371 -0
- package/packages/cli/src/commands/archive.ts +39 -0
- package/packages/cli/src/commands/companion.ts +205 -0
- package/packages/cli/src/commands/init.ts +130 -0
- package/packages/cli/src/commands/link.ts +151 -0
- package/packages/cli/src/commands/list.ts +94 -0
- package/packages/cli/src/commands/push.ts +77 -0
- package/packages/cli/src/commands/restore.ts +36 -0
- package/packages/cli/src/commands/status.ts +127 -0
- package/packages/cli/src/config.ts +73 -0
- package/packages/cli/src/errors.ts +23 -0
- package/packages/cli/src/git.ts +60 -0
- package/packages/cli/src/index.ts +129 -0
- package/packages/cli/src/server.ts +700 -0
- package/packages/cli/tsconfig.json +13 -0
- package/packages/git-drive-docker/package.json +15 -0
- package/packages/server/package.json +44 -0
- package/packages/server/src/index.ts +569 -0
- package/packages/server/tsconfig.json +9 -0
- package/packages/ui/README.md +73 -0
- package/packages/ui/eslint.config.js +23 -0
- package/packages/ui/index.html +13 -0
- package/packages/ui/package.json +52 -0
- package/packages/ui/postcss.config.js +6 -0
- package/packages/ui/public/vite.svg +1 -0
- package/packages/ui/src/App.css +23 -0
- package/packages/ui/src/App.test.tsx +248 -0
- package/packages/ui/src/App.tsx +803 -0
- package/packages/ui/src/assets/react.svg +8 -0
- package/packages/ui/src/assets/vite.svg +3 -0
- package/packages/ui/src/index.css +37 -0
- package/packages/ui/src/main.tsx +14 -0
- package/packages/ui/src/test/setup.ts +1 -0
- package/packages/ui/tailwind.config.js +11 -0
- package/packages/ui/tsconfig.app.json +28 -0
- package/packages/ui/tsconfig.json +26 -0
- package/packages/ui/tsconfig.node.json +12 -0
- package/packages/ui/vite.config.ts +7 -0
- package/packages/ui/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +4 -0
- package/rewrite_app.js +731 -0
- package/tsconfig.json +14 -0
- package/dist/__tests__/commands/init.test.js +0 -123
- package/dist/__tests__/commands/list.test.js +0 -91
- package/dist/__tests__/commands/push.test.js +0 -128
- package/dist/__tests__/commands/restore.test.js +0 -99
- package/dist/__tests__/commands/status.test.js +0 -151
- package/dist/__tests__/config.test.js +0 -150
- package/dist/__tests__/e2e.test.js +0 -107
- package/dist/__tests__/errors.test.js +0 -56
- package/dist/__tests__/git.test.js +0 -184
- package/dist/__tests__/server.test.js +0 -310
- package/dist/commands/archive.js +0 -32
- package/dist/commands/init.js +0 -55
- package/dist/commands/link.js +0 -175
- package/dist/commands/list.js +0 -83
- package/dist/commands/push.js +0 -112
- package/dist/commands/restore.js +0 -30
- package/dist/commands/status.js +0 -116
- package/dist/config.js +0 -62
- package/dist/errors.js +0 -30
- package/dist/git.js +0 -67
- package/dist/index.js +0 -108
- package/dist/server.js +0 -535
- /package/{ui → packages/cli/ui}/assets/index-Br8xQbJz.js +0 -0
- /package/{ui → packages/cli/ui}/assets/index-Cc2q1t5k.js +0 -0
- /package/{ui → packages/cli/ui}/assets/index-DrL7ojPA.css +0 -0
- /package/{ui → packages/cli/ui}/index.html +0 -0
- /package/{ui → packages/cli/ui}/vite.svg +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import express, { Application } from 'express';
|
|
3
|
+
import { vol } from 'memfs';
|
|
4
|
+
|
|
5
|
+
// Mock fs
|
|
6
|
+
jest.mock('fs', () => {
|
|
7
|
+
const { fs } = require('memfs');
|
|
8
|
+
return fs;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Mock child_process
|
|
12
|
+
jest.mock('child_process', () => ({
|
|
13
|
+
execSync: jest.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock node-disk-info
|
|
17
|
+
jest.mock('node-disk-info', () => ({
|
|
18
|
+
getDiskInfo: jest.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { execSync } from 'child_process';
|
|
22
|
+
import { getDiskInfo } from 'node-disk-info';
|
|
23
|
+
|
|
24
|
+
const mockExecSync = execSync as jest.Mock;
|
|
25
|
+
const mockGetDiskInfo = getDiskInfo as jest.Mock;
|
|
26
|
+
|
|
27
|
+
// Create a test app with the same routes as server.ts
|
|
28
|
+
function createTestApp(): Application {
|
|
29
|
+
const app = express();
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
|
|
32
|
+
// Health check endpoint
|
|
33
|
+
app.get('/api/health', (_req, res) => {
|
|
34
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// List all connected drives
|
|
38
|
+
app.get('/api/drives', async (_req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const drives = await mockGetDiskInfo();
|
|
41
|
+
const result = drives
|
|
42
|
+
.filter((d: any) => {
|
|
43
|
+
const mp = d.mounted;
|
|
44
|
+
if (!mp) return false;
|
|
45
|
+
if (mp === '/' || mp === '100%') return false;
|
|
46
|
+
|
|
47
|
+
if (process.platform === 'darwin') {
|
|
48
|
+
return mp.startsWith('/Volumes/') && !mp.startsWith('/Volumes/Recovery');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (mp.startsWith('/sys') || mp.startsWith('/proc') || mp.startsWith('/run') || mp.startsWith('/snap') || mp.startsWith('/boot')) return false;
|
|
52
|
+
if (d.filesystem === 'tmpfs' || d.filesystem === 'devtmpfs' || d.filesystem === 'udev' || d.filesystem === 'overlay') return false;
|
|
53
|
+
|
|
54
|
+
return true;
|
|
55
|
+
})
|
|
56
|
+
.map((d: any) => ({
|
|
57
|
+
device: d.filesystem,
|
|
58
|
+
description: d.mounted,
|
|
59
|
+
size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
|
|
60
|
+
isRemovable: true,
|
|
61
|
+
isSystem: d.mounted === '/',
|
|
62
|
+
mountpoints: [d.mounted],
|
|
63
|
+
hasGitDrive: vol.existsSync(`${d.mounted}/.git-drive`),
|
|
64
|
+
}));
|
|
65
|
+
res.json(result);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
res.status(500).json({ error: 'Failed to list drives' });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// List repos on a specific drive
|
|
72
|
+
app.get('/api/drives/:mountpoint/repos', (req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
75
|
+
const gitDrivePath = `${mountpoint}/.git-drive`;
|
|
76
|
+
|
|
77
|
+
if (!vol.existsSync(mountpoint)) {
|
|
78
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const entries = vol.existsSync(gitDrivePath) ? vol.readdirSync(gitDrivePath) as string[] : [];
|
|
83
|
+
const repos = entries
|
|
84
|
+
.filter((entry: string) => {
|
|
85
|
+
const entryPath = `${gitDrivePath}/${entry}`;
|
|
86
|
+
const stat = vol.statSync(entryPath);
|
|
87
|
+
const isDir = stat.isDirectory();
|
|
88
|
+
return isDir && (entry.endsWith('.git') || vol.existsSync(`${entryPath}/HEAD`));
|
|
89
|
+
})
|
|
90
|
+
.map((entry: string) => {
|
|
91
|
+
const entryPath = `${gitDrivePath}/${entry}`;
|
|
92
|
+
const stat = vol.statSync(entryPath);
|
|
93
|
+
return {
|
|
94
|
+
name: entry.replace(/\.git$/, ''),
|
|
95
|
+
path: entryPath,
|
|
96
|
+
lastModified: stat.mtime.toISOString(),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
res.json({
|
|
101
|
+
mountpoint,
|
|
102
|
+
gitDrivePath,
|
|
103
|
+
initialized: vol.existsSync(gitDrivePath),
|
|
104
|
+
repos,
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
res.status(500).json({ error: 'Failed to list repos' });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Initialize git-drive on a drive
|
|
112
|
+
app.post('/api/drives/:mountpoint/init', (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
115
|
+
|
|
116
|
+
if (!vol.existsSync(mountpoint)) {
|
|
117
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const gitDrivePath = `${mountpoint}/.git-drive`;
|
|
122
|
+
vol.mkdirSync(gitDrivePath, { recursive: true });
|
|
123
|
+
|
|
124
|
+
res.json({
|
|
125
|
+
mountpoint,
|
|
126
|
+
gitDrivePath,
|
|
127
|
+
message: 'Git Drive initialized on this drive',
|
|
128
|
+
});
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
res.status(500).json({ error: err.message || 'Failed to initialize drive' });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Create a new bare repo on a drive
|
|
135
|
+
app.post('/api/drives/:mountpoint/repos', (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
138
|
+
const { name } = req.body;
|
|
139
|
+
|
|
140
|
+
if (!name || typeof name !== 'string') {
|
|
141
|
+
res.status(400).json({ error: 'Repo name is required' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
146
|
+
|
|
147
|
+
if (!vol.existsSync(mountpoint)) {
|
|
148
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const gitDrivePath = `${mountpoint}/.git-drive`;
|
|
153
|
+
if (!vol.existsSync(gitDrivePath)) {
|
|
154
|
+
vol.mkdirSync(gitDrivePath, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
|
|
158
|
+
const repoPath = `${gitDrivePath}/${repoName}`;
|
|
159
|
+
|
|
160
|
+
if (vol.existsSync(repoPath)) {
|
|
161
|
+
res.status(409).json({ error: 'Repository already exists' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Mock git init --bare
|
|
166
|
+
vol.mkdirSync(repoPath, { recursive: true });
|
|
167
|
+
vol.writeFileSync(`${repoPath}/HEAD`, 'ref: refs/heads/main');
|
|
168
|
+
|
|
169
|
+
res.status(201).json({
|
|
170
|
+
name: safeName.replace(/\.git$/, ''),
|
|
171
|
+
path: repoPath,
|
|
172
|
+
message: `Bare repository created: ${repoName}`,
|
|
173
|
+
remoteUrl: repoPath,
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
res.status(500).json({ error: 'Failed to create repository' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Delete a repo from a drive
|
|
181
|
+
app.delete('/api/drives/:mountpoint/repos/:repoName', (req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
184
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
185
|
+
const gitDrivePath = `${mountpoint}/.git-drive`;
|
|
186
|
+
|
|
187
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
188
|
+
const repoPath = `${gitDrivePath}/${bareRepoName}`;
|
|
189
|
+
|
|
190
|
+
if (!vol.existsSync(repoPath)) {
|
|
191
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Delete the repo directory
|
|
196
|
+
vol.rmSync(repoPath, { recursive: true, force: true });
|
|
197
|
+
|
|
198
|
+
res.json({ message: `Repository '${repoName}' deleted` });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
res.status(500).json({ error: 'Failed to delete repository' });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return app;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
describe('Server API', () => {
|
|
208
|
+
let app: Application;
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
jest.clearAllMocks();
|
|
212
|
+
vol.reset();
|
|
213
|
+
app = createTestApp();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('GET /api/health', () => {
|
|
217
|
+
it('should return health status', async () => {
|
|
218
|
+
const response = await request(app).get('/api/health');
|
|
219
|
+
|
|
220
|
+
expect(response.status).toBe(200);
|
|
221
|
+
expect(response.body).toHaveProperty('status', 'ok');
|
|
222
|
+
expect(response.body).toHaveProperty('timestamp');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('GET /api/drives', () => {
|
|
227
|
+
it('should return list of drives', async () => {
|
|
228
|
+
mockGetDiskInfo.mockResolvedValue([
|
|
229
|
+
{ mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
|
|
230
|
+
{ mounted: '/Volumes/External', filesystem: 'External', blocks: 1000000000, available: 500000000 },
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
vol.fromJSON({
|
|
234
|
+
'/Volumes/MyUSB/.git-drive': '',
|
|
235
|
+
'/Volumes/External': '',
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const response = await request(app).get('/api/drives');
|
|
239
|
+
|
|
240
|
+
expect(response.status).toBe(200);
|
|
241
|
+
expect(response.body).toHaveLength(2);
|
|
242
|
+
expect(response.body[0]).toHaveProperty('device');
|
|
243
|
+
expect(response.body[0]).toHaveProperty('mountpoints');
|
|
244
|
+
expect(response.body[0]).toHaveProperty('hasGitDrive');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle errors gracefully', async () => {
|
|
248
|
+
mockGetDiskInfo.mockRejectedValue(new Error('Failed to get drives'));
|
|
249
|
+
|
|
250
|
+
const response = await request(app).get('/api/drives');
|
|
251
|
+
|
|
252
|
+
expect(response.status).toBe(500);
|
|
253
|
+
expect(response.body).toHaveProperty('error', 'Failed to list drives');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('GET /api/drives/:mountpoint/repos', () => {
|
|
258
|
+
it('should return repos on a drive', async () => {
|
|
259
|
+
vol.fromJSON({
|
|
260
|
+
'/Volumes/MyUSB/.git-drive/my-project.git/HEAD': 'ref: refs/heads/main',
|
|
261
|
+
'/Volumes/MyUSB/.git-drive/another-repo.git/HEAD': 'ref: refs/heads/main',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const response = await request(app).get('/api/drives/%2FVolumes%2FMyUSB/repos');
|
|
265
|
+
|
|
266
|
+
expect(response.status).toBe(200);
|
|
267
|
+
expect(response.body).toHaveProperty('mountpoint', '/Volumes/MyUSB');
|
|
268
|
+
expect(response.body).toHaveProperty('initialized', true);
|
|
269
|
+
expect(response.body.repos).toHaveLength(2);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should return 404 for non-existent drive', async () => {
|
|
273
|
+
const response = await request(app).get('/api/drives/%2FVolumes%2FNonExistent/repos');
|
|
274
|
+
|
|
275
|
+
expect(response.status).toBe(404);
|
|
276
|
+
expect(response.body).toHaveProperty('error', 'Drive not found or not mounted');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('POST /api/drives/:mountpoint/init', () => {
|
|
281
|
+
it('should initialize git-drive on a drive', async () => {
|
|
282
|
+
vol.fromJSON({
|
|
283
|
+
'/Volumes/MyUSB': null,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const response = await request(app).post('/api/drives/%2FVolumes%2FMyUSB/init');
|
|
287
|
+
|
|
288
|
+
expect(response.status).toBe(200);
|
|
289
|
+
expect(response.body).toHaveProperty('message', 'Git Drive initialized on this drive');
|
|
290
|
+
expect(response.body).toHaveProperty('gitDrivePath', '/Volumes/MyUSB/.git-drive');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should return 404 for non-existent drive', async () => {
|
|
294
|
+
vol.fromJSON({});
|
|
295
|
+
|
|
296
|
+
const response = await request(app).post('/api/drives/%2FVolumes%2FNonExistent/init');
|
|
297
|
+
|
|
298
|
+
expect(response.status).toBe(404);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('POST /api/drives/:mountpoint/repos', () => {
|
|
303
|
+
it('should create a new repository', async () => {
|
|
304
|
+
vol.fromJSON({
|
|
305
|
+
'/Volumes/MyUSB/.git-drive': null,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const response = await request(app)
|
|
309
|
+
.post('/api/drives/%2FVolumes%2FMyUSB/repos')
|
|
310
|
+
.send({ name: 'new-project' });
|
|
311
|
+
|
|
312
|
+
expect(response.status).toBe(201);
|
|
313
|
+
expect(response.body).toHaveProperty('name', 'new-project');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should sanitize repository name', async () => {
|
|
317
|
+
vol.fromJSON({
|
|
318
|
+
'/Volumes/MyUSB/.git-drive': null,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const response = await request(app)
|
|
322
|
+
.post('/api/drives/%2FVolumes%2FMyUSB/repos')
|
|
323
|
+
.send({ name: 'my project with spaces!' });
|
|
324
|
+
|
|
325
|
+
expect(response.status).toBe(201);
|
|
326
|
+
expect(response.body.name).toBe('my-project-with-spaces-');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should return 400 if name is missing', async () => {
|
|
330
|
+
const response = await request(app)
|
|
331
|
+
.post('/api/drives/%2FVolumes%2FMyUSB/repos')
|
|
332
|
+
.send({});
|
|
333
|
+
|
|
334
|
+
expect(response.status).toBe(400);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should return 409 if repository already exists', async () => {
|
|
338
|
+
vol.fromJSON({
|
|
339
|
+
'/Volumes/MyUSB/.git-drive/existing-project.git/HEAD': '',
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const response = await request(app)
|
|
343
|
+
.post('/api/drives/%2FVolumes%2FMyUSB/repos')
|
|
344
|
+
.send({ name: 'existing-project' });
|
|
345
|
+
|
|
346
|
+
expect(response.status).toBe(409);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('DELETE /api/drives/:mountpoint/repos/:repoName', () => {
|
|
351
|
+
it('should delete a repository', async () => {
|
|
352
|
+
vol.fromJSON({
|
|
353
|
+
'/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const response = await request(app).delete('/api/drives/%2FVolumes%2FMyUSB/repos/my-project');
|
|
357
|
+
|
|
358
|
+
expect(response.status).toBe(200);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should return 404 for non-existent repository', async () => {
|
|
362
|
+
vol.fromJSON({
|
|
363
|
+
'/Volumes/MyUSB/.git-drive': '',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const response = await request(app).delete('/api/drives/%2FVolumes%2FMyUSB/repos/nonexistent');
|
|
367
|
+
|
|
368
|
+
expect(response.status).toBe(404);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { rmSync } from "fs";
|
|
2
|
+
import { requireConfig, assertDriveMounted } from "../config.js";
|
|
3
|
+
import { git, getRepoRoot, getProjectName, isGitRepo } from "../git.js";
|
|
4
|
+
import { push } from "./push.js";
|
|
5
|
+
import { GitDriveError } from "../errors.js";
|
|
6
|
+
|
|
7
|
+
export function archive(args: string[]): void {
|
|
8
|
+
if (!isGitRepo()) {
|
|
9
|
+
throw new GitDriveError("Not in a git repository.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const force = args.includes("--force");
|
|
13
|
+
|
|
14
|
+
// Check for uncommitted changes
|
|
15
|
+
if (!force) {
|
|
16
|
+
const status = git("status --porcelain");
|
|
17
|
+
if (status) {
|
|
18
|
+
throw new GitDriveError(
|
|
19
|
+
"Working tree has uncommitted changes.\nCommit first or use --force to archive anyway."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = requireConfig();
|
|
25
|
+
assertDriveMounted(config.drivePath);
|
|
26
|
+
|
|
27
|
+
const projectName = getProjectName();
|
|
28
|
+
const repoRoot = getRepoRoot();
|
|
29
|
+
|
|
30
|
+
// Push first
|
|
31
|
+
push([]);
|
|
32
|
+
|
|
33
|
+
// Remove local copy
|
|
34
|
+
process.chdir("..");
|
|
35
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
36
|
+
|
|
37
|
+
console.log(`Archived: ${projectName}`);
|
|
38
|
+
console.log(`Restore with: git drive restore ${projectName}`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { resolve, join } from "path";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import prompts from "prompts";
|
|
6
|
+
import { listDrives } from "../git.js";
|
|
7
|
+
import { GitDriveError } from "../errors.js";
|
|
8
|
+
import { getDriveStorePath } from "../config.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 4484;
|
|
11
|
+
const MAX_PORT_ATTEMPTS = 20;
|
|
12
|
+
|
|
13
|
+
// Check if a port is available
|
|
14
|
+
async function isPortAvailable(port: number): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`http://localhost:${port}/api/health`, {
|
|
17
|
+
method: 'HEAD',
|
|
18
|
+
signal: AbortSignal.timeout(500),
|
|
19
|
+
});
|
|
20
|
+
return false; // Port is in use
|
|
21
|
+
} catch {
|
|
22
|
+
return true; // Port is available
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Find the next available port starting from DEFAULT_PORT
|
|
27
|
+
async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
|
|
28
|
+
for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
|
|
29
|
+
if (await isPortAvailable(port)) {
|
|
30
|
+
return port;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw new GitDriveError(`Could not find an available port after ${MAX_PORT_ATTEMPTS} attempts.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get companion info from a drive
|
|
37
|
+
function getCompanionInfo(drivePath: string): { installed: boolean; version?: string; installedAt?: string } {
|
|
38
|
+
const storePath = getDriveStorePath(drivePath);
|
|
39
|
+
const companionVersionPath = join(storePath, 'companion.json');
|
|
40
|
+
const companionRepoPath = join(storePath, 'git-drive.git');
|
|
41
|
+
|
|
42
|
+
if (!existsSync(companionRepoPath)) {
|
|
43
|
+
return { installed: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(companionVersionPath)) {
|
|
48
|
+
const companionInfo = JSON.parse(readFileSync(companionVersionPath, 'utf-8'));
|
|
49
|
+
return {
|
|
50
|
+
installed: true,
|
|
51
|
+
version: companionInfo.version,
|
|
52
|
+
installedAt: companionInfo.installedAt,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { installed: true }; // Repo exists but no version file
|
|
56
|
+
} catch {
|
|
57
|
+
return { installed: true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Open browser to URL
|
|
62
|
+
function openBrowser(url: string): void {
|
|
63
|
+
const platform = process.platform;
|
|
64
|
+
let command: string;
|
|
65
|
+
|
|
66
|
+
if (platform === 'darwin') {
|
|
67
|
+
command = `open "${url}"`;
|
|
68
|
+
} else if (platform === 'win32') {
|
|
69
|
+
command = `start "" "${url}"`;
|
|
70
|
+
} else {
|
|
71
|
+
command = `xdg-open "${url}"`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
spawn(command, { shell: true, detached: true, stdio: 'ignore' });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.log(`Please open your browser to: ${url}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Wait for Enter key
|
|
82
|
+
async function waitForEnter(): Promise<void> {
|
|
83
|
+
const rl = createInterface({
|
|
84
|
+
input: process.stdin,
|
|
85
|
+
output: process.stdout,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
rl.question('\nPress Enter to stop the companion server...', () => {
|
|
90
|
+
rl.close();
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function companion(args: string[]): Promise<void> {
|
|
97
|
+
let drivePath: string;
|
|
98
|
+
|
|
99
|
+
const rawPath = args[0];
|
|
100
|
+
|
|
101
|
+
if (!rawPath) {
|
|
102
|
+
// No argument provided - prompt user to select a drive
|
|
103
|
+
const drives = await listDrives();
|
|
104
|
+
|
|
105
|
+
// Filter to only drives with git-drive initialized
|
|
106
|
+
const initializedDrives = drives.filter((d: any) => {
|
|
107
|
+
const storePath = getDriveStorePath(d.mounted);
|
|
108
|
+
return existsSync(storePath);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (initializedDrives.length === 0) {
|
|
112
|
+
throw new GitDriveError(
|
|
113
|
+
"No drives with git-drive initialized found.\nRun 'git-drive init' on a drive first."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { selectedDrive } = await prompts({
|
|
118
|
+
type: "select",
|
|
119
|
+
name: "selectedDrive",
|
|
120
|
+
message: "Select a drive to run in companion mode:",
|
|
121
|
+
choices: initializedDrives.map((d: any) => {
|
|
122
|
+
const companionInfo = getCompanionInfo(d.mounted);
|
|
123
|
+
const companionStatus = companionInfo.installed
|
|
124
|
+
? ` (Companion v${companionInfo.version || 'unknown'})`
|
|
125
|
+
: ' (No companion)';
|
|
126
|
+
return {
|
|
127
|
+
title: `${d.filesystem} (${d.mounted})${companionStatus}`,
|
|
128
|
+
value: d.mounted,
|
|
129
|
+
};
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!selectedDrive) {
|
|
134
|
+
console.log("Operation cancelled.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
drivePath = resolve(selectedDrive);
|
|
139
|
+
} else {
|
|
140
|
+
drivePath = resolve(rawPath);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Verify the drive has git-drive initialized
|
|
144
|
+
const storePath = getDriveStorePath(drivePath);
|
|
145
|
+
if (!existsSync(storePath)) {
|
|
146
|
+
throw new GitDriveError(
|
|
147
|
+
`Git Drive not initialized on ${drivePath}.\nRun 'git-drive init ${drivePath}' first.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check companion status
|
|
152
|
+
const companionInfo = getCompanionInfo(drivePath);
|
|
153
|
+
if (!companionInfo.installed) {
|
|
154
|
+
console.log(`\n⚠️ Warning: Companion not installed on this drive.`);
|
|
155
|
+
console.log(` Run 'git-drive init ${drivePath}' to install the companion.`);
|
|
156
|
+
console.log(` Continuing in standard mode...\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Find available port
|
|
160
|
+
const port = await findAvailablePort();
|
|
161
|
+
|
|
162
|
+
console.log(`\n🔌 Starting Git Drive in Companion Mode...`);
|
|
163
|
+
console.log(` Drive: ${drivePath}`);
|
|
164
|
+
console.log(` Port: ${port}`);
|
|
165
|
+
if (companionInfo.version) {
|
|
166
|
+
console.log(` Companion: v${companionInfo.version}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Start the server with companion mode environment variables
|
|
170
|
+
const serverPath = require.resolve('../server.js');
|
|
171
|
+
const env = {
|
|
172
|
+
...process.env,
|
|
173
|
+
GIT_DRIVE_PORT: String(port),
|
|
174
|
+
GIT_DRIVE_COMPANION_MODE: 'true',
|
|
175
|
+
GIT_DRIVE_COMPANION_DRIVE: drivePath,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
179
|
+
stdio: 'inherit',
|
|
180
|
+
env,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
child.on('error', (err) => {
|
|
184
|
+
console.error('Failed to start companion server:', err.message);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Wait a moment for server to start, then open browser
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
const url = `http://localhost:${port}`;
|
|
191
|
+
console.log(`\n 🌐 Opening browser: ${url}`);
|
|
192
|
+
openBrowser(url);
|
|
193
|
+
console.log(`\n Companion mode is running.`);
|
|
194
|
+
}, 1000);
|
|
195
|
+
|
|
196
|
+
// Wait for Enter to stop
|
|
197
|
+
await waitForEnter();
|
|
198
|
+
|
|
199
|
+
console.log('\n Stopping companion server...');
|
|
200
|
+
child.kill();
|
|
201
|
+
console.log(' 👋 Companion mode stopped.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Export helper functions for testing
|
|
205
|
+
export { getCompanionInfo, findAvailablePort, isPortAvailable };
|