tmux-team 1.0.0 → 2.0.0-alpha.1
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 +197 -24
- package/bin/tmux-team +31 -430
- package/package.json +26 -6
- package/src/cli.ts +212 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/help.ts +51 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +652 -0
- package/src/commands/talk.ts +261 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +159 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +158 -0
- package/src/pm/commands.ts +654 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +140 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +255 -0
- package/src/pm/storage/github.ts +751 -0
- package/src/pm/types.ts +79 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +67 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// PM Manager Tests - Team resolution and adapter factory
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import {
|
|
10
|
+
findCurrentTeamId,
|
|
11
|
+
getTeamsDir,
|
|
12
|
+
getTeamConfig,
|
|
13
|
+
saveTeamConfig,
|
|
14
|
+
getStorageAdapter,
|
|
15
|
+
createStorageAdapter,
|
|
16
|
+
generateTeamId,
|
|
17
|
+
listTeams,
|
|
18
|
+
linkTeam,
|
|
19
|
+
} from './manager.js';
|
|
20
|
+
import { FSAdapter } from './storage/fs.js';
|
|
21
|
+
import { GitHubAdapter } from './storage/github.js';
|
|
22
|
+
|
|
23
|
+
describe('getTeamsDir', () => {
|
|
24
|
+
it('returns <globalDir>/teams path', () => {
|
|
25
|
+
expect(getTeamsDir('/home/user/.config/tmux-team')).toBe('/home/user/.config/tmux-team/teams');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles trailing slash in globalDir', () => {
|
|
29
|
+
expect(getTeamsDir('/home/user/.tmux-team')).toBe('/home/user/.tmux-team/teams');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('findCurrentTeamId', () => {
|
|
34
|
+
let testDir: string;
|
|
35
|
+
let originalEnv: string | undefined;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-manager-test-'));
|
|
39
|
+
originalEnv = process.env.TMUX_TEAM_ID;
|
|
40
|
+
delete process.env.TMUX_TEAM_ID;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (fs.existsSync(testDir)) {
|
|
45
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
if (originalEnv) {
|
|
48
|
+
process.env.TMUX_TEAM_ID = originalEnv;
|
|
49
|
+
} else {
|
|
50
|
+
delete process.env.TMUX_TEAM_ID;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reads team ID from .tmux-team-id file', () => {
|
|
55
|
+
const teamId = 'test-team-123';
|
|
56
|
+
fs.writeFileSync(path.join(testDir, '.tmux-team-id'), teamId);
|
|
57
|
+
|
|
58
|
+
const result = findCurrentTeamId(testDir, testDir);
|
|
59
|
+
|
|
60
|
+
expect(result).toBe(teamId);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('uses TMUX_TEAM_ID environment variable when no file exists', () => {
|
|
64
|
+
process.env.TMUX_TEAM_ID = 'env-team-456';
|
|
65
|
+
|
|
66
|
+
const result = findCurrentTeamId(testDir, testDir);
|
|
67
|
+
|
|
68
|
+
expect(result).toBe('env-team-456');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('prefers .tmux-team-id file over TMUX_TEAM_ID env', () => {
|
|
72
|
+
const fileTeamId = 'file-team-id';
|
|
73
|
+
process.env.TMUX_TEAM_ID = 'env-team-id';
|
|
74
|
+
fs.writeFileSync(path.join(testDir, '.tmux-team-id'), fileTeamId);
|
|
75
|
+
|
|
76
|
+
const result = findCurrentTeamId(testDir, testDir);
|
|
77
|
+
|
|
78
|
+
expect(result).toBe(fileTeamId);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('searches parent directories for .tmux-team-id', () => {
|
|
82
|
+
const parentDir = path.join(testDir, 'parent');
|
|
83
|
+
const childDir = path.join(parentDir, 'child');
|
|
84
|
+
const grandchildDir = path.join(childDir, 'grandchild');
|
|
85
|
+
|
|
86
|
+
fs.mkdirSync(grandchildDir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(parentDir, '.tmux-team-id'), 'parent-team');
|
|
88
|
+
|
|
89
|
+
const result = findCurrentTeamId(grandchildDir, testDir);
|
|
90
|
+
|
|
91
|
+
expect(result).toBe('parent-team');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns null when no team ID found', () => {
|
|
95
|
+
const result = findCurrentTeamId(testDir, testDir);
|
|
96
|
+
|
|
97
|
+
expect(result).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('trims whitespace from team ID file', () => {
|
|
101
|
+
fs.writeFileSync(path.join(testDir, '.tmux-team-id'), ' team-with-spaces \n');
|
|
102
|
+
|
|
103
|
+
const result = findCurrentTeamId(testDir, testDir);
|
|
104
|
+
|
|
105
|
+
expect(result).toBe('team-with-spaces');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('getTeamConfig', () => {
|
|
110
|
+
let testDir: string;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-config-test-'));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
if (fs.existsSync(testDir)) {
|
|
118
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns config from config.json', () => {
|
|
123
|
+
const config = { backend: 'github' as const, repo: 'owner/repo' };
|
|
124
|
+
fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(config));
|
|
125
|
+
|
|
126
|
+
const result = getTeamConfig(testDir);
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual(config);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns default fs backend when no config exists', () => {
|
|
132
|
+
const result = getTeamConfig(testDir);
|
|
133
|
+
|
|
134
|
+
expect(result).toEqual({ backend: 'fs' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns null for malformed config.json', () => {
|
|
138
|
+
fs.writeFileSync(path.join(testDir, 'config.json'), 'not json');
|
|
139
|
+
|
|
140
|
+
const result = getTeamConfig(testDir);
|
|
141
|
+
|
|
142
|
+
expect(result).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('saveTeamConfig', () => {
|
|
147
|
+
let testDir: string;
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-save-config-test-'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
if (fs.existsSync(testDir)) {
|
|
155
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('writes config to config.json', () => {
|
|
160
|
+
const config = { backend: 'github' as const, repo: 'owner/repo' };
|
|
161
|
+
|
|
162
|
+
saveTeamConfig(testDir, config);
|
|
163
|
+
|
|
164
|
+
const content = fs.readFileSync(path.join(testDir, 'config.json'), 'utf-8');
|
|
165
|
+
expect(JSON.parse(content)).toEqual(config);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('creates team directory if it does not exist', () => {
|
|
169
|
+
const nestedDir = path.join(testDir, 'nested', 'team');
|
|
170
|
+
const config = { backend: 'fs' as const };
|
|
171
|
+
|
|
172
|
+
saveTeamConfig(nestedDir, config);
|
|
173
|
+
|
|
174
|
+
expect(fs.existsSync(nestedDir)).toBe(true);
|
|
175
|
+
expect(fs.existsSync(path.join(nestedDir, 'config.json'))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('getStorageAdapter', () => {
|
|
180
|
+
let testDir: string;
|
|
181
|
+
let globalDir: string;
|
|
182
|
+
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-adapter-test-'));
|
|
185
|
+
globalDir = testDir;
|
|
186
|
+
fs.mkdirSync(path.join(testDir, 'teams', 'test-team'), { recursive: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
if (fs.existsSync(testDir)) {
|
|
191
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('returns FSAdapter by default', () => {
|
|
196
|
+
const adapter = getStorageAdapter('test-team', globalDir);
|
|
197
|
+
|
|
198
|
+
expect(adapter).toBeInstanceOf(FSAdapter);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('returns GitHubAdapter when configured', () => {
|
|
202
|
+
const teamDir = path.join(testDir, 'teams', 'test-team');
|
|
203
|
+
saveTeamConfig(teamDir, { backend: 'github', repo: 'owner/repo' });
|
|
204
|
+
|
|
205
|
+
const adapter = getStorageAdapter('test-team', globalDir);
|
|
206
|
+
|
|
207
|
+
expect(adapter).toBeInstanceOf(GitHubAdapter);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('configures adapter with correct team directory', () => {
|
|
211
|
+
const adapter = getStorageAdapter('my-team', globalDir);
|
|
212
|
+
|
|
213
|
+
// FSAdapter exposes its teamDir
|
|
214
|
+
expect((adapter as FSAdapter)['teamDir']).toBe(path.join(globalDir, 'teams', 'my-team'));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('createStorageAdapter', () => {
|
|
219
|
+
let testDir: string;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-create-adapter-test-'));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
if (fs.existsSync(testDir)) {
|
|
227
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('creates FSAdapter for fs backend', () => {
|
|
232
|
+
const adapter = createStorageAdapter(testDir, 'fs');
|
|
233
|
+
|
|
234
|
+
expect(adapter).toBeInstanceOf(FSAdapter);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('creates GitHubAdapter for github backend with repo', () => {
|
|
238
|
+
const adapter = createStorageAdapter(testDir, 'github', 'owner/repo');
|
|
239
|
+
|
|
240
|
+
expect(adapter).toBeInstanceOf(GitHubAdapter);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('throws error for github backend without repo', () => {
|
|
244
|
+
expect(() => createStorageAdapter(testDir, 'github')).toThrow(
|
|
245
|
+
'GitHub backend requires --repo flag'
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('generateTeamId', () => {
|
|
251
|
+
it('generates valid UUID v4 format', () => {
|
|
252
|
+
const id = generateTeamId();
|
|
253
|
+
|
|
254
|
+
// UUID v4 pattern
|
|
255
|
+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('generates unique IDs on each call', () => {
|
|
259
|
+
const ids = new Set<string>();
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < 100; i++) {
|
|
262
|
+
ids.add(generateTeamId());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
expect(ids.size).toBe(100);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('linkTeam', () => {
|
|
270
|
+
let testDir: string;
|
|
271
|
+
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-link-test-'));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
if (fs.existsSync(testDir)) {
|
|
278
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('creates .tmux-team-id file with team ID', () => {
|
|
283
|
+
linkTeam(testDir, 'my-team-id');
|
|
284
|
+
|
|
285
|
+
const content = fs.readFileSync(path.join(testDir, '.tmux-team-id'), 'utf-8');
|
|
286
|
+
expect(content.trim()).toBe('my-team-id');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('overwrites existing .tmux-team-id file', () => {
|
|
290
|
+
fs.writeFileSync(path.join(testDir, '.tmux-team-id'), 'old-team');
|
|
291
|
+
|
|
292
|
+
linkTeam(testDir, 'new-team');
|
|
293
|
+
|
|
294
|
+
const content = fs.readFileSync(path.join(testDir, '.tmux-team-id'), 'utf-8');
|
|
295
|
+
expect(content.trim()).toBe('new-team');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('listTeams', () => {
|
|
300
|
+
let testDir: string;
|
|
301
|
+
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-list-test-'));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
afterEach(() => {
|
|
307
|
+
if (fs.existsSync(testDir)) {
|
|
308
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns empty array when teams directory does not exist', () => {
|
|
313
|
+
const teams = listTeams(testDir);
|
|
314
|
+
|
|
315
|
+
expect(teams).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('returns empty array when teams directory is empty', () => {
|
|
319
|
+
fs.mkdirSync(path.join(testDir, 'teams'), { recursive: true });
|
|
320
|
+
|
|
321
|
+
const teams = listTeams(testDir);
|
|
322
|
+
|
|
323
|
+
expect(teams).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('returns all teams with metadata', () => {
|
|
327
|
+
const teamsDir = path.join(testDir, 'teams');
|
|
328
|
+
fs.mkdirSync(path.join(teamsDir, 'team-1'), { recursive: true });
|
|
329
|
+
fs.mkdirSync(path.join(teamsDir, 'team-2'), { recursive: true });
|
|
330
|
+
|
|
331
|
+
const team1 = { id: 'team-1', name: 'Project A', createdAt: '2024-01-01' };
|
|
332
|
+
const team2 = { id: 'team-2', name: 'Project B', createdAt: '2024-02-01' };
|
|
333
|
+
|
|
334
|
+
fs.writeFileSync(path.join(teamsDir, 'team-1', 'team.json'), JSON.stringify(team1));
|
|
335
|
+
fs.writeFileSync(path.join(teamsDir, 'team-2', 'team.json'), JSON.stringify(team2));
|
|
336
|
+
|
|
337
|
+
const teams = listTeams(testDir);
|
|
338
|
+
|
|
339
|
+
expect(teams).toHaveLength(2);
|
|
340
|
+
expect(teams.find((t) => t.id === 'team-1')?.name).toBe('Project A');
|
|
341
|
+
expect(teams.find((t) => t.id === 'team-2')?.name).toBe('Project B');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('skips directories without team.json', () => {
|
|
345
|
+
const teamsDir = path.join(testDir, 'teams');
|
|
346
|
+
fs.mkdirSync(path.join(teamsDir, 'valid-team'), { recursive: true });
|
|
347
|
+
fs.mkdirSync(path.join(teamsDir, 'invalid-team'), { recursive: true });
|
|
348
|
+
|
|
349
|
+
fs.writeFileSync(
|
|
350
|
+
path.join(teamsDir, 'valid-team', 'team.json'),
|
|
351
|
+
JSON.stringify({ id: 'valid-team', name: 'Valid' })
|
|
352
|
+
);
|
|
353
|
+
// invalid-team has no team.json
|
|
354
|
+
|
|
355
|
+
const teams = listTeams(testDir);
|
|
356
|
+
|
|
357
|
+
expect(teams).toHaveLength(1);
|
|
358
|
+
expect(teams[0].id).toBe('valid-team');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('skips malformed team.json files', () => {
|
|
362
|
+
const teamsDir = path.join(testDir, 'teams');
|
|
363
|
+
fs.mkdirSync(path.join(teamsDir, 'good-team'), { recursive: true });
|
|
364
|
+
fs.mkdirSync(path.join(teamsDir, 'bad-team'), { recursive: true });
|
|
365
|
+
|
|
366
|
+
fs.writeFileSync(
|
|
367
|
+
path.join(teamsDir, 'good-team', 'team.json'),
|
|
368
|
+
JSON.stringify({ id: 'good-team', name: 'Good' })
|
|
369
|
+
);
|
|
370
|
+
fs.writeFileSync(path.join(teamsDir, 'bad-team', 'team.json'), 'not json');
|
|
371
|
+
|
|
372
|
+
const teams = listTeams(testDir);
|
|
373
|
+
|
|
374
|
+
expect(teams).toHaveLength(1);
|
|
375
|
+
expect(teams[0].id).toBe('good-team');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// PM Manager - handles team resolution and storage adapter
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import type { StorageAdapter } from './storage/adapter.js';
|
|
9
|
+
import { createFSAdapter } from './storage/fs.js';
|
|
10
|
+
import { createGitHubAdapter } from './storage/github.js';
|
|
11
|
+
import type { Team, TeamConfig, StorageBackend } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the teams directory from global config path.
|
|
15
|
+
*/
|
|
16
|
+
export function getTeamsDir(globalDir: string): string {
|
|
17
|
+
return path.join(globalDir, 'teams');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the current team by looking for a .tmux-team-id file in cwd or parents,
|
|
22
|
+
* or by matching the current tmux window.
|
|
23
|
+
*/
|
|
24
|
+
export function findCurrentTeamId(cwd: string, _globalDir: string): string | null {
|
|
25
|
+
// 1. Check for .tmux-team-id file in cwd or parents
|
|
26
|
+
// Note: _globalDir reserved for future tmux window matching feature
|
|
27
|
+
let dir = cwd;
|
|
28
|
+
while (dir !== path.dirname(dir)) {
|
|
29
|
+
const idFile = path.join(dir, '.tmux-team-id');
|
|
30
|
+
if (fs.existsSync(idFile)) {
|
|
31
|
+
return fs.readFileSync(idFile, 'utf-8').trim();
|
|
32
|
+
}
|
|
33
|
+
dir = path.dirname(dir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Check TMUX_TEAM_ID env
|
|
37
|
+
if (process.env.TMUX_TEAM_ID) {
|
|
38
|
+
return process.env.TMUX_TEAM_ID;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Try to match by tmux window (future enhancement)
|
|
42
|
+
// For now, return null if no explicit team found
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get team config from team directory.
|
|
49
|
+
*/
|
|
50
|
+
export function getTeamConfig(teamDir: string): TeamConfig | null {
|
|
51
|
+
const configFile = path.join(teamDir, 'config.json');
|
|
52
|
+
if (fs.existsSync(configFile)) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(configFile, 'utf-8')) as TeamConfig;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Default to fs backend if no config
|
|
60
|
+
return { backend: 'fs' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Save team config to team directory.
|
|
65
|
+
*/
|
|
66
|
+
export function saveTeamConfig(teamDir: string, config: TeamConfig): void {
|
|
67
|
+
fs.mkdirSync(teamDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get storage adapter for a specific team.
|
|
73
|
+
*/
|
|
74
|
+
export function getStorageAdapter(teamId: string, globalDir: string): StorageAdapter {
|
|
75
|
+
const teamDir = path.join(getTeamsDir(globalDir), teamId);
|
|
76
|
+
const config = getTeamConfig(teamDir);
|
|
77
|
+
|
|
78
|
+
if (config?.backend === 'github' && config.repo) {
|
|
79
|
+
return createGitHubAdapter(teamDir, config.repo);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return createFSAdapter(teamDir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get storage adapter with explicit backend.
|
|
87
|
+
*/
|
|
88
|
+
export function createStorageAdapter(
|
|
89
|
+
teamDir: string,
|
|
90
|
+
backend: StorageBackend,
|
|
91
|
+
repo?: string
|
|
92
|
+
): StorageAdapter {
|
|
93
|
+
if (backend === 'github') {
|
|
94
|
+
if (!repo) {
|
|
95
|
+
throw new Error('GitHub backend requires --repo flag');
|
|
96
|
+
}
|
|
97
|
+
return createGitHubAdapter(teamDir, repo);
|
|
98
|
+
}
|
|
99
|
+
return createFSAdapter(teamDir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate a new team ID.
|
|
104
|
+
*/
|
|
105
|
+
export function generateTeamId(): string {
|
|
106
|
+
return crypto.randomUUID();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* List all teams.
|
|
111
|
+
*/
|
|
112
|
+
export function listTeams(globalDir: string): Team[] {
|
|
113
|
+
const teamsDir = getTeamsDir(globalDir);
|
|
114
|
+
if (!fs.existsSync(teamsDir)) return [];
|
|
115
|
+
|
|
116
|
+
const teams: Team[] = [];
|
|
117
|
+
const dirs = fs.readdirSync(teamsDir);
|
|
118
|
+
|
|
119
|
+
for (const dir of dirs) {
|
|
120
|
+
const teamFile = path.join(teamsDir, dir, 'team.json');
|
|
121
|
+
if (fs.existsSync(teamFile)) {
|
|
122
|
+
try {
|
|
123
|
+
const team = JSON.parse(fs.readFileSync(teamFile, 'utf-8')) as Team;
|
|
124
|
+
teams.push(team);
|
|
125
|
+
} catch {
|
|
126
|
+
// Skip malformed team files
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return teams;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a team ID file in the current directory.
|
|
136
|
+
*/
|
|
137
|
+
export function linkTeam(cwd: string, teamId: string): void {
|
|
138
|
+
const idFile = path.join(cwd, '.tmux-team-id');
|
|
139
|
+
fs.writeFileSync(idFile, teamId + '\n');
|
|
140
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Storage adapter interface for PM backends
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Team,
|
|
7
|
+
Milestone,
|
|
8
|
+
Task,
|
|
9
|
+
AuditEvent,
|
|
10
|
+
CreateTaskInput,
|
|
11
|
+
UpdateTaskInput,
|
|
12
|
+
CreateMilestoneInput,
|
|
13
|
+
UpdateMilestoneInput,
|
|
14
|
+
ListTasksFilter,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Abstract storage adapter interface.
|
|
19
|
+
* Implemented by:
|
|
20
|
+
* - FSAdapter (Phase 4): Local filesystem storage
|
|
21
|
+
* - GitHubAdapter (Phase 5): GitHub Issues as storage
|
|
22
|
+
*/
|
|
23
|
+
export interface StorageAdapter {
|
|
24
|
+
// Team operations
|
|
25
|
+
initTeam(name: string, windowId?: string): Promise<Team>;
|
|
26
|
+
getTeam(): Promise<Team | null>;
|
|
27
|
+
updateTeam(updates: Partial<Team>): Promise<Team>;
|
|
28
|
+
|
|
29
|
+
// Milestone operations
|
|
30
|
+
createMilestone(input: CreateMilestoneInput): Promise<Milestone>;
|
|
31
|
+
getMilestone(id: string): Promise<Milestone | null>;
|
|
32
|
+
listMilestones(): Promise<Milestone[]>;
|
|
33
|
+
updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone>;
|
|
34
|
+
deleteMilestone(id: string): Promise<void>;
|
|
35
|
+
|
|
36
|
+
// Task operations
|
|
37
|
+
createTask(input: CreateTaskInput): Promise<Task>;
|
|
38
|
+
getTask(id: string): Promise<Task | null>;
|
|
39
|
+
listTasks(filter?: ListTasksFilter): Promise<Task[]>;
|
|
40
|
+
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
|
|
41
|
+
deleteTask(id: string): Promise<void>;
|
|
42
|
+
|
|
43
|
+
// Documentation
|
|
44
|
+
getTaskDoc(id: string): Promise<string | null>;
|
|
45
|
+
setTaskDoc(id: string, content: string): Promise<void>;
|
|
46
|
+
|
|
47
|
+
// Audit log
|
|
48
|
+
appendEvent(event: AuditEvent): Promise<void>;
|
|
49
|
+
getEvents(limit?: number): Promise<AuditEvent[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Factory function type for creating storage adapters.
|
|
54
|
+
*/
|
|
55
|
+
export type StorageAdapterFactory = (teamDir: string) => StorageAdapter;
|