git-drive 0.1.5 → 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/README.md +36 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/__tests__/commands/companion.test.ts +152 -0
- package/packages/cli/src/__tests__/commands/init.test.ts +11 -11
- package/packages/cli/src/__tests__/commands/list.test.ts +5 -1
- package/packages/cli/src/__tests__/commands/restore.test.ts +5 -4
- package/packages/cli/src/__tests__/commands/status.test.ts +5 -1
- package/packages/cli/src/__tests__/git.test.ts +24 -0
- package/packages/cli/src/__tests__/server.test.ts +6 -3
- package/packages/cli/src/commands/companion.ts +205 -0
- package/packages/cli/src/commands/init.ts +69 -3
- package/packages/cli/src/git.ts +5 -0
- package/packages/cli/src/index.ts +9 -2
- package/packages/cli/src/server.ts +138 -11
- package/packages/ui/src/App.test.tsx +11 -5
- package/packages/ui/src/App.tsx +51 -3
package/README.md
CHANGED
|
@@ -59,9 +59,45 @@ git-drive server
|
|
|
59
59
|
| `push` | Push current repo to the linked drive |
|
|
60
60
|
| `list` | Show connected drives and their status |
|
|
61
61
|
| `status` | Show detailed status of drives and repos |
|
|
62
|
+
| `companion [path]` | Run git-drive from a drive (companion mode) - see below |
|
|
62
63
|
| `server`, `start`, `ui` | Start the git-drive web UI server at http://localhost:4483 |
|
|
63
64
|
| `-v`, `-V`, `--version`, `version` | Show the installed version |
|
|
64
65
|
|
|
66
|
+
## Companion Mode
|
|
67
|
+
|
|
68
|
+
Companion mode allows you to run git-drive directly from an external drive, making it portable and self-contained. This is useful when:
|
|
69
|
+
|
|
70
|
+
- You want to use git-drive on multiple computers without installing it
|
|
71
|
+
- You're at a different machine and need to access your backups
|
|
72
|
+
- You want the drive to be completely self-contained
|
|
73
|
+
|
|
74
|
+
### Using Companion Mode
|
|
75
|
+
|
|
76
|
+
When you run `git-drive init` on a drive, it automatically installs a companion copy of git-drive on that drive. To use it:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Interactive selection - shows drives with git-drive initialized
|
|
80
|
+
git-drive companion
|
|
81
|
+
|
|
82
|
+
# Direct path - run companion mode from a specific drive
|
|
83
|
+
git-drive companion /Volumes/MyDrive
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This will:
|
|
87
|
+
1. Find an available port (starting from 4484)
|
|
88
|
+
2. Start the git-drive server in companion mode
|
|
89
|
+
3. Open your browser to the web UI
|
|
90
|
+
4. Show only the companion drive in the UI
|
|
91
|
+
|
|
92
|
+
Press Enter to stop the companion server.
|
|
93
|
+
|
|
94
|
+
### Companion Mode Features
|
|
95
|
+
|
|
96
|
+
- **Portable**: The companion is stored on the drive itself
|
|
97
|
+
- **Self-updating**: The companion can be updated from the web UI
|
|
98
|
+
- **Version tracking**: Shows companion version and update availability
|
|
99
|
+
- **Port detection**: Automatically finds an available port if the default is in use
|
|
100
|
+
|
|
65
101
|
## How it works
|
|
66
102
|
|
|
67
103
|
Run the git-drive (available as a docker container). In the web ui (localhost:4483) just select the drive you want to use (this will create a `.git-drive/` directory in that drive if it doesnt already have it). Here you can see a list of existing repos in this drive (`.git-drive/*`) or add new ones so you can push your code to.
|
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { vol } from 'memfs';
|
|
2
|
+
|
|
3
|
+
// Mock fs
|
|
4
|
+
jest.mock('fs', () => {
|
|
5
|
+
const { fs } = require('memfs');
|
|
6
|
+
return fs;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Mock node-disk-info
|
|
10
|
+
jest.mock('node-disk-info', () => ({
|
|
11
|
+
getDiskInfo: jest.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Mock child_process
|
|
15
|
+
jest.mock('child_process', () => ({
|
|
16
|
+
spawn: jest.fn(() => ({
|
|
17
|
+
on: jest.fn(),
|
|
18
|
+
kill: jest.fn(),
|
|
19
|
+
})),
|
|
20
|
+
execSync: jest.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock os
|
|
24
|
+
jest.mock('os', () => ({
|
|
25
|
+
homedir: () => '/home/testuser',
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock readline
|
|
29
|
+
jest.mock('readline', () => ({
|
|
30
|
+
createInterface: jest.fn(() => ({
|
|
31
|
+
question: jest.fn((_prompt, callback) => callback()),
|
|
32
|
+
close: jest.fn(),
|
|
33
|
+
})),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Mock prompts
|
|
37
|
+
jest.mock('prompts', () => jest.fn());
|
|
38
|
+
|
|
39
|
+
import { getDiskInfo } from 'node-disk-info';
|
|
40
|
+
import { getCompanionInfo, isPortAvailable, findAvailablePort } from '../../commands/companion.js';
|
|
41
|
+
|
|
42
|
+
const mockGetDiskInfo = getDiskInfo as jest.Mock;
|
|
43
|
+
|
|
44
|
+
// Mock fetch for port testing
|
|
45
|
+
const mockFetch = jest.fn();
|
|
46
|
+
global.fetch = mockFetch;
|
|
47
|
+
|
|
48
|
+
describe('companion command', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
vol.reset();
|
|
52
|
+
mockFetch.mockReset();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getCompanionInfo', () => {
|
|
56
|
+
it('should return installed: false when companion repo does not exist', () => {
|
|
57
|
+
vol.fromJSON({
|
|
58
|
+
'/Volumes/MyUSB/.git-drive': '',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = getCompanionInfo('/Volumes/MyUSB');
|
|
62
|
+
|
|
63
|
+
expect(result.installed).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return installed: true when companion repo exists', () => {
|
|
67
|
+
vol.fromJSON({
|
|
68
|
+
'/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
|
|
69
|
+
'/Volumes/MyUSB/.git-drive/companion.json': JSON.stringify({
|
|
70
|
+
version: '0.1.6',
|
|
71
|
+
installedAt: '2026-02-26T00:00:00Z',
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = getCompanionInfo('/Volumes/MyUSB');
|
|
76
|
+
|
|
77
|
+
expect(result.installed).toBe(true);
|
|
78
|
+
expect(result.version).toBe('0.1.6');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return installed: true even without companion.json', () => {
|
|
82
|
+
vol.fromJSON({
|
|
83
|
+
'/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = getCompanionInfo('/Volumes/MyUSB');
|
|
87
|
+
|
|
88
|
+
expect(result.installed).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle malformed companion.json gracefully', () => {
|
|
92
|
+
vol.fromJSON({
|
|
93
|
+
'/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
|
|
94
|
+
'/Volumes/MyUSB/.git-drive/companion.json': 'invalid json{',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = getCompanionInfo('/Volumes/MyUSB');
|
|
98
|
+
|
|
99
|
+
expect(result.installed).toBe(true);
|
|
100
|
+
expect(result.version).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('isPortAvailable', () => {
|
|
105
|
+
it('should return false when port is in use', async () => {
|
|
106
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
107
|
+
|
|
108
|
+
const result = await isPortAvailable(4484);
|
|
109
|
+
|
|
110
|
+
expect(result).toBe(false);
|
|
111
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
112
|
+
'http://localhost:4484/api/health',
|
|
113
|
+
expect.objectContaining({ method: 'HEAD' })
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return true when port is available', async () => {
|
|
118
|
+
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
|
119
|
+
|
|
120
|
+
const result = await isPortAvailable(4484);
|
|
121
|
+
|
|
122
|
+
expect(result).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('findAvailablePort', () => {
|
|
127
|
+
it('should return the start port if available', async () => {
|
|
128
|
+
mockFetch.mockRejectedValue(new Error('Connection refused'));
|
|
129
|
+
|
|
130
|
+
const result = await findAvailablePort(4484);
|
|
131
|
+
|
|
132
|
+
expect(result).toBe(4484);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should find next available port', async () => {
|
|
136
|
+
// First port is in use
|
|
137
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
138
|
+
// Second port is available
|
|
139
|
+
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
|
140
|
+
|
|
141
|
+
const result = await findAvailablePort(4484);
|
|
142
|
+
|
|
143
|
+
expect(result).toBe(4485);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should throw after max attempts', async () => {
|
|
147
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
148
|
+
|
|
149
|
+
await expect(findAvailablePort(4484)).rejects.toThrow('Could not find an available port');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -25,6 +25,11 @@ jest.mock('../../git.js', () => ({
|
|
|
25
25
|
listDrives: jest.fn(),
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
|
+
// Mock child_process for companion installation
|
|
29
|
+
jest.mock('child_process', () => ({
|
|
30
|
+
execSync: jest.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
28
33
|
import prompts from 'prompts';
|
|
29
34
|
import { listDrives } from '../../git.js';
|
|
30
35
|
import { saveConfig, getDriveStorePath } from '../../config.js';
|
|
@@ -52,7 +57,7 @@ describe('init command', () => {
|
|
|
52
57
|
it('should initialize git-drive on specified path', async () => {
|
|
53
58
|
const drivePath = '/Volumes/TestDrive';
|
|
54
59
|
vol.fromJSON({
|
|
55
|
-
[drivePath]:
|
|
60
|
+
[drivePath]: null, // null creates a directory in memfs
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
await init([drivePath]);
|
|
@@ -75,19 +80,14 @@ describe('init command', () => {
|
|
|
75
80
|
});
|
|
76
81
|
|
|
77
82
|
it('should resolve relative paths', async () => {
|
|
83
|
+
// Use an absolute path instead since memfs doesn't interact with process.cwd properly
|
|
78
84
|
vol.fromJSON({
|
|
79
|
-
'/
|
|
85
|
+
'/Volumes/RelativeDrive': null, // null creates a directory in memfs
|
|
80
86
|
});
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
const originalCwd = process.cwd;
|
|
84
|
-
process.cwd = jest.fn(() => '/current/dir');
|
|
85
|
-
|
|
86
|
-
await init(['./TestDrive']);
|
|
88
|
+
await init(['/Volumes/RelativeDrive']);
|
|
87
89
|
|
|
88
90
|
expect(mockSaveConfig).toHaveBeenCalled();
|
|
89
|
-
|
|
90
|
-
process.cwd = originalCwd;
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -109,7 +109,7 @@ describe('init command', () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
vol.fromJSON({
|
|
112
|
-
'/Volumes/Drive1':
|
|
112
|
+
'/Volumes/Drive1': null, // null creates a directory in memfs
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
await init([]);
|
|
@@ -142,7 +142,7 @@ describe('init command', () => {
|
|
|
142
142
|
it('should create .git-drive directory if it does not exist', async () => {
|
|
143
143
|
const drivePath = '/Volumes/TestDrive';
|
|
144
144
|
vol.fromJSON({
|
|
145
|
-
[drivePath]:
|
|
145
|
+
[drivePath]: null, // null creates a directory in memfs
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
await init([drivePath]);
|
|
@@ -111,8 +111,12 @@ describe('list command', () => {
|
|
|
111
111
|
'/home/testuser/.config/git-drive/links.json': '{}',
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
115
|
+
|
|
114
116
|
await list([]);
|
|
115
117
|
|
|
116
|
-
expect(
|
|
118
|
+
expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
|
|
119
|
+
|
|
120
|
+
errorSpy.mockRestore();
|
|
117
121
|
});
|
|
118
122
|
});
|
|
@@ -104,10 +104,11 @@ describe('restore command', () => {
|
|
|
104
104
|
|
|
105
105
|
restore(['my-project']);
|
|
106
106
|
|
|
107
|
-
// Should call git clone with the project name
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// Should call git clone with the project name
|
|
108
|
+
// The git function is called twice: once for clone, once for remote rename
|
|
109
|
+
expect(mockGit).toHaveBeenCalledTimes(2);
|
|
110
|
+
expect(mockGit).toHaveBeenNthCalledWith(1,
|
|
111
|
+
expect.stringMatching(/clone.*my-project\.git.*my-project$/)
|
|
111
112
|
);
|
|
112
113
|
});
|
|
113
114
|
|
|
@@ -188,8 +188,12 @@ describe('status command', () => {
|
|
|
188
188
|
'/home/testuser/.config/git-drive/links.json': '{}',
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
192
|
+
|
|
191
193
|
await status([]);
|
|
192
194
|
|
|
193
|
-
expect(
|
|
195
|
+
expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
|
|
196
|
+
|
|
197
|
+
errorSpy.mockRestore();
|
|
194
198
|
});
|
|
195
199
|
});
|
|
@@ -152,6 +152,30 @@ describe('git', () => {
|
|
|
152
152
|
|
|
153
153
|
expect(result).toHaveLength(0);
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
it('should filter out temporary paths with TemporaryItems', async () => {
|
|
157
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
158
|
+
|
|
159
|
+
mockGetDiskInfo.mockResolvedValue([
|
|
160
|
+
{ mounted: '/var/folders/2b/b7kfzb0s2v55m89_k9qvpj9w0000gn/T/TemporaryItems/NSIRD_screencaptureui_g99XDe', filesystem: 'tmpfs', blocks: 1000, available: 500 },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const result = await listDrives();
|
|
164
|
+
|
|
165
|
+
expect(result).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should filter out /var/folders paths', async () => {
|
|
169
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
170
|
+
|
|
171
|
+
mockGetDiskInfo.mockResolvedValue([
|
|
172
|
+
{ mounted: '/var/folders/abc', filesystem: 'tmpfs', blocks: 1000, available: 500 },
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const result = await listDrives();
|
|
176
|
+
|
|
177
|
+
expect(result).toHaveLength(0);
|
|
178
|
+
});
|
|
155
179
|
});
|
|
156
180
|
|
|
157
181
|
describe('getRepoRoot', () => {
|
|
@@ -280,16 +280,19 @@ describe('Server API', () => {
|
|
|
280
280
|
describe('POST /api/drives/:mountpoint/init', () => {
|
|
281
281
|
it('should initialize git-drive on a drive', async () => {
|
|
282
282
|
vol.fromJSON({
|
|
283
|
-
'/Volumes/MyUSB':
|
|
283
|
+
'/Volumes/MyUSB': null,
|
|
284
284
|
});
|
|
285
285
|
|
|
286
286
|
const response = await request(app).post('/api/drives/%2FVolumes%2FMyUSB/init');
|
|
287
287
|
|
|
288
288
|
expect(response.status).toBe(200);
|
|
289
289
|
expect(response.body).toHaveProperty('message', 'Git Drive initialized on this drive');
|
|
290
|
+
expect(response.body).toHaveProperty('gitDrivePath', '/Volumes/MyUSB/.git-drive');
|
|
290
291
|
});
|
|
291
292
|
|
|
292
293
|
it('should return 404 for non-existent drive', async () => {
|
|
294
|
+
vol.fromJSON({});
|
|
295
|
+
|
|
293
296
|
const response = await request(app).post('/api/drives/%2FVolumes%2FNonExistent/init');
|
|
294
297
|
|
|
295
298
|
expect(response.status).toBe(404);
|
|
@@ -299,7 +302,7 @@ describe('Server API', () => {
|
|
|
299
302
|
describe('POST /api/drives/:mountpoint/repos', () => {
|
|
300
303
|
it('should create a new repository', async () => {
|
|
301
304
|
vol.fromJSON({
|
|
302
|
-
'/Volumes/MyUSB/.git-drive':
|
|
305
|
+
'/Volumes/MyUSB/.git-drive': null,
|
|
303
306
|
});
|
|
304
307
|
|
|
305
308
|
const response = await request(app)
|
|
@@ -312,7 +315,7 @@ describe('Server API', () => {
|
|
|
312
315
|
|
|
313
316
|
it('should sanitize repository name', async () => {
|
|
314
317
|
vol.fromJSON({
|
|
315
|
-
'/Volumes/MyUSB/.git-drive':
|
|
318
|
+
'/Volumes/MyUSB/.git-drive': null,
|
|
316
319
|
});
|
|
317
320
|
|
|
318
321
|
const response = await request(app)
|
|
@@ -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 };
|
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
import { existsSync, statSync, mkdirSync } from "fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { resolve, join } from "path";
|
|
3
4
|
import prompts from "prompts";
|
|
4
5
|
import { saveConfig, getDriveStorePath } from "../config.js";
|
|
5
6
|
import { listDrives } from "../git.js";
|
|
6
7
|
import { GitDriveError } from "../errors.js";
|
|
7
8
|
|
|
9
|
+
// Companion repository URL
|
|
10
|
+
const COMPANION_REPO_URL = "https://github.com/josmanvis/git-drive.git";
|
|
11
|
+
|
|
12
|
+
// Get the current version from package.json
|
|
13
|
+
function getCurrentVersion(): string {
|
|
14
|
+
try {
|
|
15
|
+
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
|
16
|
+
const packageJson = require(packageJsonPath);
|
|
17
|
+
return packageJson.version;
|
|
18
|
+
} catch {
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Install the companion (clone git-drive repo to the drive)
|
|
24
|
+
function installCompanion(storePath: string): { installed: boolean; version: string; error?: string } {
|
|
25
|
+
const companionRepoPath = join(storePath, 'git-drive.git');
|
|
26
|
+
const companionVersionPath = join(storePath, 'companion.json');
|
|
27
|
+
const currentVersion = getCurrentVersion();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// If companion already exists, update it
|
|
31
|
+
if (existsSync(companionRepoPath)) {
|
|
32
|
+
try {
|
|
33
|
+
execSync(`git -C "${companionRepoPath}" fetch origin`, { stdio: 'pipe' });
|
|
34
|
+
execSync(`git -C "${companionRepoPath}" reset --hard origin/main`, { stdio: 'pipe' });
|
|
35
|
+
} catch {
|
|
36
|
+
// If update fails, remove and re-clone
|
|
37
|
+
execSync(`rm -rf "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
38
|
+
execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// Clone the companion
|
|
42
|
+
execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Write companion version info
|
|
46
|
+
const companionInfo = {
|
|
47
|
+
version: currentVersion,
|
|
48
|
+
installedAt: new Date().toISOString(),
|
|
49
|
+
repoUrl: COMPANION_REPO_URL,
|
|
50
|
+
};
|
|
51
|
+
writeFileSync(companionVersionPath, JSON.stringify(companionInfo, null, 2));
|
|
52
|
+
|
|
53
|
+
return { installed: true, version: currentVersion };
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
return { installed: false, version: currentVersion, error: err.message };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
export async function init(args: string[]): Promise<void> {
|
|
9
60
|
let drivePath: string;
|
|
10
61
|
|
|
@@ -61,4 +112,19 @@ export async function init(args: string[]): Promise<void> {
|
|
|
61
112
|
console.log(`\n✅ Git Drive initialized!`);
|
|
62
113
|
console.log(` Drive: ${drivePath}`);
|
|
63
114
|
console.log(` Store: ${storePath}`);
|
|
64
|
-
|
|
115
|
+
|
|
116
|
+
// Install companion
|
|
117
|
+
console.log(`\n📦 Installing Drive Companion...`);
|
|
118
|
+
const companionResult = installCompanion(storePath);
|
|
119
|
+
|
|
120
|
+
if (companionResult.installed) {
|
|
121
|
+
console.log(` ✅ Companion v${companionResult.version} installed!`);
|
|
122
|
+
console.log(`\n You can now use 'git-drive companion ${drivePath}' on any machine.`);
|
|
123
|
+
} else {
|
|
124
|
+
console.log(` ⚠️ Failed to install companion: ${companionResult.error}`);
|
|
125
|
+
console.log(` You can still use git-drive, but companion mode is not available.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Export for use in other modules
|
|
130
|
+
export { installCompanion, getCurrentVersion };
|
package/packages/cli/src/git.ts
CHANGED
|
@@ -17,6 +17,11 @@ export async function listDrives(): Promise<any[]> {
|
|
|
17
17
|
if (!mp) return false;
|
|
18
18
|
if (mp === "/" || mp === "100%") return false;
|
|
19
19
|
|
|
20
|
+
// Exclude temporary and system paths on all platforms
|
|
21
|
+
if (mp.startsWith("/var/") || mp.startsWith("/private/var/") || mp.startsWith("/tmp") || mp.startsWith("/private/tmp")) return false;
|
|
22
|
+
if (mp.includes("TemporaryItems") || mp.includes("NSIRD_")) return false;
|
|
23
|
+
if (mp.startsWith("/System/") || mp.startsWith("/Library/")) return false;
|
|
24
|
+
|
|
20
25
|
if (process.platform === "darwin") {
|
|
21
26
|
return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
|
|
22
27
|
}
|
|
@@ -7,6 +7,7 @@ import { list } from "./commands/list.js";
|
|
|
7
7
|
import { status } from "./commands/status.js";
|
|
8
8
|
import { link } from "./commands/link.js";
|
|
9
9
|
import { init } from "./commands/init.js";
|
|
10
|
+
import { companion } from "./commands/companion.js";
|
|
10
11
|
import { handleError } from "./errors.js";
|
|
11
12
|
import { ensureServerRunning } from "./server.js";
|
|
12
13
|
|
|
@@ -20,13 +21,14 @@ const commands: Record<string, (args: string[]) => void | Promise<void>> = {
|
|
|
20
21
|
list,
|
|
21
22
|
status,
|
|
22
23
|
link,
|
|
24
|
+
companion,
|
|
23
25
|
server: startServer,
|
|
24
26
|
start: startServer,
|
|
25
27
|
ui: startServer,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
// Commands that don't need the server running
|
|
29
|
-
const NO_SERVER_COMMANDS = ['server', 'start', 'ui'];
|
|
31
|
+
const NO_SERVER_COMMANDS = ['server', 'start', 'ui', 'companion'];
|
|
30
32
|
|
|
31
33
|
function printUsage(): void {
|
|
32
34
|
console.log(`
|
|
@@ -41,6 +43,7 @@ Commands:
|
|
|
41
43
|
push Push current repo to drive
|
|
42
44
|
list Show connected drives and their status
|
|
43
45
|
status Show detailed status of drives and repos
|
|
46
|
+
companion [path] Run git-drive from a drive (companion mode)
|
|
44
47
|
server, start, ui Start the git-drive web UI server
|
|
45
48
|
|
|
46
49
|
Options:
|
|
@@ -53,10 +56,14 @@ Examples:
|
|
|
53
56
|
git-drive push Push current repo to drive
|
|
54
57
|
git-drive list List connected drives
|
|
55
58
|
git-drive status Show detailed status
|
|
59
|
+
git-drive companion Run companion mode (interactive)
|
|
60
|
+
git-drive companion /Volumes/USB Run companion mode for specific drive
|
|
56
61
|
git-drive server Start the web UI at http://localhost:4483
|
|
57
62
|
|
|
58
63
|
Environment Variables:
|
|
59
|
-
GIT_DRIVE_PORT
|
|
64
|
+
GIT_DRIVE_PORT Port for the web server (default: 4483)
|
|
65
|
+
GIT_DRIVE_COMPANION_MODE Set to 'true' for companion mode
|
|
66
|
+
GIT_DRIVE_COMPANION_DRIVE Drive path in companion mode
|
|
60
67
|
|
|
61
68
|
Docker:
|
|
62
69
|
docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express, { Request, Response } from 'express';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync } from 'fs';
|
|
4
|
+
import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
6
|
import { getDiskInfo } from 'node-disk-info';
|
|
7
7
|
import { homedir } from 'os';
|
|
@@ -9,6 +9,13 @@ import { homedir } from 'os';
|
|
|
9
9
|
const app = express();
|
|
10
10
|
const port = process.env.GIT_DRIVE_PORT || 4483;
|
|
11
11
|
|
|
12
|
+
// Companion mode configuration
|
|
13
|
+
const COMPANION_MODE = process.env.GIT_DRIVE_COMPANION_MODE === 'true';
|
|
14
|
+
const COMPANION_DRIVE = process.env.GIT_DRIVE_COMPANION_DRIVE;
|
|
15
|
+
|
|
16
|
+
// Companion repository URL
|
|
17
|
+
const COMPANION_REPO_URL = "https://github.com/josmanvis/git-drive.git";
|
|
18
|
+
|
|
12
19
|
app.use(express.json());
|
|
13
20
|
|
|
14
21
|
// Serve static UI files from the ui directory
|
|
@@ -135,16 +142,123 @@ app.get('/api/health', (_req: Request, res: Response) => {
|
|
|
135
142
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
136
143
|
});
|
|
137
144
|
|
|
145
|
+
// Companion info endpoint - returns info about companion mode
|
|
146
|
+
app.get('/api/companion-info', (_req: Request, res: Response) => {
|
|
147
|
+
res.json({
|
|
148
|
+
companionMode: COMPANION_MODE,
|
|
149
|
+
companionDrive: COMPANION_DRIVE || null,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Get current version from package.json
|
|
154
|
+
function getCurrentVersion(): string {
|
|
155
|
+
try {
|
|
156
|
+
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
|
157
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
158
|
+
return packageJson.version;
|
|
159
|
+
} catch {
|
|
160
|
+
return 'unknown';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Get companion info for a specific drive
|
|
165
|
+
function getCompanionInfo(mountpoint: string): { installed: boolean; version?: string; installedAt?: string; outdated?: boolean } {
|
|
166
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
167
|
+
const companionVersionPath = path.join(gitDrivePath, 'companion.json');
|
|
168
|
+
const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
|
|
169
|
+
|
|
170
|
+
if (!existsSync(companionRepoPath)) {
|
|
171
|
+
return { installed: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (existsSync(companionVersionPath)) {
|
|
176
|
+
const companionInfo = JSON.parse(readFileSync(companionVersionPath, 'utf-8'));
|
|
177
|
+
const currentVersion = getCurrentVersion();
|
|
178
|
+
return {
|
|
179
|
+
installed: true,
|
|
180
|
+
version: companionInfo.version,
|
|
181
|
+
installedAt: companionInfo.installedAt,
|
|
182
|
+
outdated: companionInfo.version !== currentVersion,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { installed: true };
|
|
186
|
+
} catch {
|
|
187
|
+
return { installed: true };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Install/update companion on a drive
|
|
192
|
+
app.post('/api/drives/:mountpoint/install-companion', (req: Request, res: Response) => {
|
|
193
|
+
try {
|
|
194
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
195
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
196
|
+
|
|
197
|
+
if (!existsSync(mountpoint)) {
|
|
198
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!existsSync(gitDrivePath)) {
|
|
203
|
+
mkdirSync(gitDrivePath, { recursive: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
|
|
207
|
+
const companionVersionPath = path.join(gitDrivePath, 'companion.json');
|
|
208
|
+
const currentVersion = getCurrentVersion();
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
// If companion already exists, update it
|
|
212
|
+
if (existsSync(companionRepoPath)) {
|
|
213
|
+
try {
|
|
214
|
+
execSync(`git -C "${companionRepoPath}" fetch origin`, { stdio: 'pipe' });
|
|
215
|
+
execSync(`git -C "${companionRepoPath}" reset --hard origin/main`, { stdio: 'pipe' });
|
|
216
|
+
} catch {
|
|
217
|
+
// If update fails, remove and re-clone
|
|
218
|
+
execSync(`rm -rf "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
219
|
+
execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
// Clone the companion
|
|
223
|
+
execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Write companion version info
|
|
227
|
+
const companionInfo = {
|
|
228
|
+
version: currentVersion,
|
|
229
|
+
installedAt: new Date().toISOString(),
|
|
230
|
+
repoUrl: COMPANION_REPO_URL,
|
|
231
|
+
};
|
|
232
|
+
writeFileSync(companionVersionPath, JSON.stringify(companionInfo, null, 2));
|
|
233
|
+
|
|
234
|
+
res.json({
|
|
235
|
+
success: true,
|
|
236
|
+
version: currentVersion,
|
|
237
|
+
message: 'Companion installed successfully',
|
|
238
|
+
});
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
res.status(500).json({ error: `Failed to install companion: ${err.message}` });
|
|
241
|
+
}
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
res.status(500).json({ error: err.message || 'Failed to install companion' });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
138
247
|
// List all connected drives
|
|
139
248
|
app.get('/api/drives', async (_req: Request, res: Response) => {
|
|
140
249
|
try {
|
|
141
250
|
const drives = await getDiskInfo();
|
|
142
|
-
|
|
251
|
+
let result = drives
|
|
143
252
|
.filter((d: any) => {
|
|
144
253
|
const mp = d.mounted;
|
|
145
254
|
if (!mp) return false;
|
|
146
255
|
if (mp === "/" || mp === "100%") return false;
|
|
147
256
|
|
|
257
|
+
// Exclude temporary and system paths on all platforms
|
|
258
|
+
if (mp.startsWith("/var/") || mp.startsWith("/private/var/") || mp.startsWith("/tmp") || mp.startsWith("/private/tmp")) return false;
|
|
259
|
+
if (mp.includes("TemporaryItems") || mp.includes("NSIRD_")) return false;
|
|
260
|
+
if (mp.startsWith("/System/") || mp.startsWith("/Library/")) return false;
|
|
261
|
+
|
|
148
262
|
if (process.platform === "darwin") {
|
|
149
263
|
return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
|
|
150
264
|
}
|
|
@@ -154,15 +268,28 @@ app.get('/api/drives', async (_req: Request, res: Response) => {
|
|
|
154
268
|
|
|
155
269
|
return true;
|
|
156
270
|
})
|
|
157
|
-
.map((d: any) =>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
271
|
+
.map((d: any) => {
|
|
272
|
+
const mountpoint = d.mounted;
|
|
273
|
+
const companionInfo = getCompanionInfo(mountpoint);
|
|
274
|
+
return {
|
|
275
|
+
device: d.filesystem,
|
|
276
|
+
description: mountpoint,
|
|
277
|
+
size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
|
|
278
|
+
isRemovable: true,
|
|
279
|
+
isSystem: mountpoint === '/',
|
|
280
|
+
mountpoints: [mountpoint],
|
|
281
|
+
hasGitDrive: existsSync(getGitDrivePath(mountpoint)),
|
|
282
|
+
hasCompanion: companionInfo.installed,
|
|
283
|
+
companionVersion: companionInfo.version,
|
|
284
|
+
companionOutdated: companionInfo.outdated,
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// In companion mode, filter to only show the companion drive
|
|
289
|
+
if (COMPANION_MODE && COMPANION_DRIVE) {
|
|
290
|
+
result = result.filter((d: any) => d.mountpoints[0] === COMPANION_DRIVE);
|
|
291
|
+
}
|
|
292
|
+
|
|
166
293
|
res.json(result);
|
|
167
294
|
} catch (err) {
|
|
168
295
|
res.status(500).json({ error: 'Failed to list drives' });
|
|
@@ -28,17 +28,23 @@ const renderWithRouter = (initialRoute = '/') => {
|
|
|
28
28
|
describe('App', () => {
|
|
29
29
|
beforeEach(() => {
|
|
30
30
|
vi.clearAllMocks();
|
|
31
|
+
// Mock companion-info endpoint
|
|
32
|
+
mockAxios.get.mockResolvedValue({ data: { companionMode: false, companionDrive: null } });
|
|
31
33
|
});
|
|
32
34
|
|
|
33
|
-
it('should render the header with title', () => {
|
|
35
|
+
it('should render the header with title', async () => {
|
|
34
36
|
renderWithRouter();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(screen.getByText('Git Drive')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Turn any drive into a git remote.')).toBeInTheDocument();
|
|
40
|
+
});
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
it('should render drive list on home route', () => {
|
|
43
|
+
it('should render drive list on home route', async () => {
|
|
40
44
|
renderWithRouter();
|
|
41
|
-
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(screen.getByText('Connected Drives')).toBeInTheDocument();
|
|
47
|
+
});
|
|
42
48
|
});
|
|
43
49
|
});
|
|
44
50
|
|
package/packages/ui/src/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import { Routes, Route, Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
4
|
-
import { HardDrive, Search, FolderGit2, Trash2, Plus, ArrowLeft, File as FileIcon, Folder as FolderIcon, ChevronRight } from 'lucide-react';
|
|
4
|
+
import { HardDrive, Search, FolderGit2, Trash2, Plus, ArrowLeft, File as FileIcon, Folder as FolderIcon, ChevronRight, Plug } from 'lucide-react';
|
|
5
5
|
import Fuse from 'fuse.js';
|
|
6
6
|
|
|
7
7
|
type Drive = {
|
|
@@ -12,6 +12,14 @@ type Drive = {
|
|
|
12
12
|
isSystem: boolean;
|
|
13
13
|
mountpoints: string[];
|
|
14
14
|
hasGitDrive: boolean;
|
|
15
|
+
hasCompanion?: boolean;
|
|
16
|
+
companionVersion?: string;
|
|
17
|
+
companionOutdated?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type CompanionInfo = {
|
|
21
|
+
companionMode: boolean;
|
|
22
|
+
companionDrive: string | null;
|
|
15
23
|
};
|
|
16
24
|
|
|
17
25
|
type Repo = {
|
|
@@ -29,6 +37,14 @@ type TreeItem = {
|
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
export default function App() {
|
|
40
|
+
const [companionInfo, setCompanionInfo] = useState<CompanionInfo>({ companionMode: false, companionDrive: null });
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
axios.get('/api/companion-info')
|
|
44
|
+
.then(({ data }) => setCompanionInfo(data))
|
|
45
|
+
.catch(() => {});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
32
48
|
return (
|
|
33
49
|
<div className="min-h-screen bg-[#0d1117] text-gray-200 p-8 font-sans">
|
|
34
50
|
<div className="max-w-5xl mx-auto space-y-8">
|
|
@@ -38,8 +54,19 @@ export default function App() {
|
|
|
38
54
|
<FolderGit2 className="w-8 h-8 text-blue-400" />
|
|
39
55
|
</div>
|
|
40
56
|
<div>
|
|
41
|
-
<
|
|
42
|
-
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<h1 className="text-2xl font-bold text-white tracking-tight">Git Drive</h1>
|
|
59
|
+
{companionInfo.companionMode && (
|
|
60
|
+
<span className="px-2 py-0.5 bg-purple-500/10 text-purple-400 text-xs font-semibold rounded-full border border-purple-500/20 flex items-center gap-1">
|
|
61
|
+
<Plug className="w-3 h-3" /> Companion
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<p className="text-gray-400 text-sm">
|
|
66
|
+
{companionInfo.companionMode && companionInfo.companionDrive
|
|
67
|
+
? `Running from ${companionInfo.companionDrive}`
|
|
68
|
+
: 'Turn any drive into a git remote.'}
|
|
69
|
+
</p>
|
|
43
70
|
</div>
|
|
44
71
|
</Link>
|
|
45
72
|
</header>
|
|
@@ -139,6 +166,27 @@ function DriveList() {
|
|
|
139
166
|
<span>{(drive.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
|
|
140
167
|
</div>
|
|
141
168
|
|
|
169
|
+
{/* Companion status indicator */}
|
|
170
|
+
{drive.hasGitDrive && (
|
|
171
|
+
<div className="mt-3 flex items-center gap-2">
|
|
172
|
+
{drive.hasCompanion ? (
|
|
173
|
+
drive.companionOutdated ? (
|
|
174
|
+
<span className="px-2 py-1 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded-md flex items-center gap-1">
|
|
175
|
+
<Plug className="w-3 h-3" /> Update Available (v{drive.companionVersion})
|
|
176
|
+
</span>
|
|
177
|
+
) : (
|
|
178
|
+
<span className="px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20 rounded-md flex items-center gap-1">
|
|
179
|
+
<Plug className="w-3 h-3" /> Companion v{drive.companionVersion}
|
|
180
|
+
</span>
|
|
181
|
+
)
|
|
182
|
+
) : (
|
|
183
|
+
<span className="px-2 py-1 text-xs font-medium bg-gray-800 text-gray-400 border border-gray-700 rounded-md">
|
|
184
|
+
No Companion
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
142
190
|
{!drive.hasGitDrive && (
|
|
143
191
|
<button
|
|
144
192
|
onClick={(e) => {
|