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.
Files changed (95) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.planning/codebase/ARCHITECTURE.md +151 -0
  3. package/.planning/codebase/CONCERNS.md +191 -0
  4. package/.planning/codebase/CONVENTIONS.md +169 -0
  5. package/.planning/codebase/INTEGRATIONS.md +94 -0
  6. package/.planning/codebase/STACK.md +77 -0
  7. package/.planning/codebase/STRUCTURE.md +157 -0
  8. package/.planning/codebase/TESTING.md +156 -0
  9. package/Dockerfile.cli +30 -0
  10. package/Dockerfile.server +32 -0
  11. package/README.md +157 -0
  12. package/docker-compose.yml +48 -0
  13. package/package.json +20 -55
  14. package/packages/cli/Dockerfile +26 -0
  15. package/packages/cli/jest.config.js +26 -0
  16. package/packages/cli/package.json +65 -0
  17. package/packages/cli/src/__tests__/commands/companion.test.ts +152 -0
  18. package/packages/cli/src/__tests__/commands/init.test.ts +154 -0
  19. package/packages/cli/src/__tests__/commands/list.test.ts +122 -0
  20. package/packages/cli/src/__tests__/commands/push.test.ts +155 -0
  21. package/packages/cli/src/__tests__/commands/restore.test.ts +135 -0
  22. package/packages/cli/src/__tests__/commands/status.test.ts +199 -0
  23. package/packages/cli/src/__tests__/config.test.ts +198 -0
  24. package/packages/cli/src/__tests__/e2e.test.ts +125 -0
  25. package/packages/cli/src/__tests__/errors.test.ts +66 -0
  26. package/packages/cli/src/__tests__/git.test.ts +250 -0
  27. package/packages/cli/src/__tests__/server.test.ts +371 -0
  28. package/packages/cli/src/commands/archive.ts +39 -0
  29. package/packages/cli/src/commands/companion.ts +205 -0
  30. package/packages/cli/src/commands/init.ts +130 -0
  31. package/packages/cli/src/commands/link.ts +151 -0
  32. package/packages/cli/src/commands/list.ts +94 -0
  33. package/packages/cli/src/commands/push.ts +77 -0
  34. package/packages/cli/src/commands/restore.ts +36 -0
  35. package/packages/cli/src/commands/status.ts +127 -0
  36. package/packages/cli/src/config.ts +73 -0
  37. package/packages/cli/src/errors.ts +23 -0
  38. package/packages/cli/src/git.ts +60 -0
  39. package/packages/cli/src/index.ts +129 -0
  40. package/packages/cli/src/server.ts +700 -0
  41. package/packages/cli/tsconfig.json +13 -0
  42. package/packages/git-drive-docker/package.json +15 -0
  43. package/packages/server/package.json +44 -0
  44. package/packages/server/src/index.ts +569 -0
  45. package/packages/server/tsconfig.json +9 -0
  46. package/packages/ui/README.md +73 -0
  47. package/packages/ui/eslint.config.js +23 -0
  48. package/packages/ui/index.html +13 -0
  49. package/packages/ui/package.json +52 -0
  50. package/packages/ui/postcss.config.js +6 -0
  51. package/packages/ui/public/vite.svg +1 -0
  52. package/packages/ui/src/App.css +23 -0
  53. package/packages/ui/src/App.test.tsx +248 -0
  54. package/packages/ui/src/App.tsx +803 -0
  55. package/packages/ui/src/assets/react.svg +8 -0
  56. package/packages/ui/src/assets/vite.svg +3 -0
  57. package/packages/ui/src/index.css +37 -0
  58. package/packages/ui/src/main.tsx +14 -0
  59. package/packages/ui/src/test/setup.ts +1 -0
  60. package/packages/ui/tailwind.config.js +11 -0
  61. package/packages/ui/tsconfig.app.json +28 -0
  62. package/packages/ui/tsconfig.json +26 -0
  63. package/packages/ui/tsconfig.node.json +12 -0
  64. package/packages/ui/vite.config.ts +7 -0
  65. package/packages/ui/vitest.config.ts +20 -0
  66. package/pnpm-workspace.yaml +4 -0
  67. package/rewrite_app.js +731 -0
  68. package/tsconfig.json +14 -0
  69. package/dist/__tests__/commands/init.test.js +0 -123
  70. package/dist/__tests__/commands/list.test.js +0 -91
  71. package/dist/__tests__/commands/push.test.js +0 -128
  72. package/dist/__tests__/commands/restore.test.js +0 -99
  73. package/dist/__tests__/commands/status.test.js +0 -151
  74. package/dist/__tests__/config.test.js +0 -150
  75. package/dist/__tests__/e2e.test.js +0 -107
  76. package/dist/__tests__/errors.test.js +0 -56
  77. package/dist/__tests__/git.test.js +0 -184
  78. package/dist/__tests__/server.test.js +0 -310
  79. package/dist/commands/archive.js +0 -32
  80. package/dist/commands/init.js +0 -55
  81. package/dist/commands/link.js +0 -175
  82. package/dist/commands/list.js +0 -83
  83. package/dist/commands/push.js +0 -112
  84. package/dist/commands/restore.js +0 -30
  85. package/dist/commands/status.js +0 -116
  86. package/dist/config.js +0 -62
  87. package/dist/errors.js +0 -30
  88. package/dist/git.js +0 -67
  89. package/dist/index.js +0 -108
  90. package/dist/server.js +0 -535
  91. /package/{ui → packages/cli/ui}/assets/index-Br8xQbJz.js +0 -0
  92. /package/{ui → packages/cli/ui}/assets/index-Cc2q1t5k.js +0 -0
  93. /package/{ui → packages/cli/ui}/assets/index-DrL7ojPA.css +0 -0
  94. /package/{ui → packages/cli/ui}/index.html +0 -0
  95. /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 };