agserver 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import {
5
+ isGitRepo, getCurrentBranch, getDefaultBaseBranch, listBranchRefs, listGitTags,
6
+ resolveBranchNameById, buildGitGraph, parseGitStatusPorcelain, parseNameStatus,
7
+ buildChangeTree, truncatePatch, hashToColor, iconForBranchName, toProjectId, runGit, runGitRaw,
8
+ } from './git.mjs';
9
+ import { readFileContent, readFileContentFromRef } from './files.mjs';
10
+
11
+ const PROJECTS_ROOT = process.env.AGSERVER_PROJECTS_ROOT || '/Volumes/Agentic/AGProject';
12
+ const ACTIVE_BRANCH_DAYS = Number(process.env.AGSERVER_ACTIVE_BRANCH_DAYS || 30);
13
+
14
+ function getSettingsFile() {
15
+ return path.join(process.env.AGSERVER_PROJECTS_ROOT || '/Volumes/Agentic/AGProject', '.agserver-settings.json');
16
+ }
17
+
18
+ function loadSettings() {
19
+ try { return JSON.parse(fs.readFileSync(getSettingsFile(), 'utf8')); } catch { return {}; }
20
+ }
21
+
22
+ function saveSettings(data) {
23
+ fs.writeFileSync(getSettingsFile(), JSON.stringify(data, null, 2), 'utf8');
24
+ }
25
+
26
+ export function getProjectSettings(projectId) {
27
+ const settings = loadSettings();
28
+ return settings[projectId] ?? { worktreeEnabled: false };
29
+ }
30
+
31
+ const ALLOWED_SETTINGS_KEYS = new Set(['worktreeEnabled']);
32
+
33
+ export function setProjectSettings(projectId, patch) {
34
+ const settings = loadSettings();
35
+ const current = getProjectSettings(projectId);
36
+ const sanitized = {};
37
+ for (const key of ALLOWED_SETTINGS_KEYS) {
38
+ if (key in patch) sanitized[key] = patch[key];
39
+ }
40
+ settings[projectId] = { ...current, ...sanitized };
41
+ saveSettings(settings);
42
+ return settings[projectId];
43
+ }
44
+
45
+ // Scan PROJECTS_ROOT with two-level group support:
46
+ // - Subdirs that contain git repos are treated as groups; their children are the projects
47
+ // - Subdirs that are themselves git repos fall into the "Others" group
48
+ export function scanProjectsRoot() {
49
+ let topEntries;
50
+ try { topEntries = fs.readdirSync(PROJECTS_ROOT, { withFileTypes: true }); }
51
+ catch { return []; }
52
+
53
+ const projects = [];
54
+
55
+ for (const top of topEntries) {
56
+ if (!top.isDirectory()) continue;
57
+ const topPath = path.join(PROJECTS_ROOT, top.name);
58
+
59
+ if (isGitRepo(topPath)) {
60
+ // Direct git repo at top level → Others group
61
+ const id = `project-${toProjectId(top.name)}`;
62
+ projects.push({ id, name: top.name, repoPath: topPath, localPath: topPath, rootPath: '', group: 'Others' });
63
+ continue;
64
+ }
65
+
66
+ // Treat as a group folder — scan one level deeper
67
+ let groupEntries;
68
+ try { groupEntries = fs.readdirSync(topPath, { withFileTypes: true }); }
69
+ catch { continue; }
70
+
71
+ for (const child of groupEntries) {
72
+ if (!child.isDirectory()) continue;
73
+ const repoPath = path.join(topPath, child.name);
74
+ if (!isGitRepo(repoPath)) continue;
75
+ const id = `project-${toProjectId(top.name)}-${toProjectId(child.name)}`;
76
+ projects.push({ id, name: child.name, repoPath, localPath: repoPath, rootPath: '', group: top.name });
77
+ }
78
+ }
79
+
80
+ return projects;
81
+ }
82
+
83
+ export function findProjectById(projectId) {
84
+ return scanProjectsRoot().find(p => p.id === projectId) ?? null;
85
+ }
86
+
87
+ export function getProjectListData() {
88
+ const projects = scanProjectsRoot();
89
+ const viewProjects = projects.map(project => {
90
+ const currentBranch = runGit(project.repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'], 'unknown');
91
+ const lastTime = runGit(project.repoPath, ['log', '-1', '--date=short', '--pretty=%cd'], 'n/a');
92
+ const lastSummary = runGit(project.repoPath, ['log', '-1', '--pretty=%s'], 'No recent commits');
93
+ const statusInfo = parseGitStatusPorcelain(project.repoPath);
94
+ let status = 'online';
95
+ if (statusInfo.conflicts > 0) status = 'warning';
96
+ else if (statusInfo.total > 0) status = 'busy';
97
+ return {
98
+ id: project.id,
99
+ name: project.name,
100
+ path: path.basename(project.repoPath),
101
+ summary: `${lastSummary} (${currentBranch})`,
102
+ status,
103
+ lastChange: lastTime,
104
+ group: project.group,
105
+ unreadCount: statusInfo.total || undefined,
106
+ initials: project.name.slice(0, 2).toUpperCase(),
107
+ avatarColor: hashToColor(project.id),
108
+ };
109
+ });
110
+ const groups = ['All Project', ...Array.from(new Set(viewProjects.map(p => p.group)))];
111
+ return { version: 1, title: 'AGClient', groups, projects: viewProjects };
112
+ }
113
+
114
+ export function getBranchesData(projectId) {
115
+ const project = findProjectById(projectId);
116
+ if (!project) return null;
117
+ const refs = listBranchRefs(project.repoPath);
118
+ const baseBranch = getDefaultBaseBranch(project.repoPath);
119
+ const mergedInBase = new Set(
120
+ runGit(project.repoPath, ['branch', '--format=%(refname:short)', '--merged', baseBranch], '')
121
+ .split('\n').map(l => l.trim()).filter(Boolean),
122
+ );
123
+ const nowUnix = Math.floor(Date.now() / 1000);
124
+ const branches = refs
125
+ .filter(ref => ref.name !== baseBranch && !mergedInBase.has(ref.name))
126
+ .map(ref => {
127
+ const ageDays = ref.unix > 0 ? (nowUnix - ref.unix) / 86400 : 365;
128
+ const group = ageDays <= ACTIVE_BRANCH_DAYS ? 'active' : 'stale';
129
+ const commitCount = Number(runGit(project.repoPath, ['rev-list', '--count', `${baseBranch}..${ref.name}`], '0')) || 0;
130
+ return { id: ref.id, name: ref.name, brief: ref.subject, tag: ref.name.split('/')[0].toUpperCase(), color: hashToColor(ref.name), icon: iconForBranchName(ref.name), lastChange: ref.relativeDate, group, commitCount };
131
+ });
132
+ const tags = listGitTags(project.repoPath);
133
+ return { version: 1, projects: [{ projectId: project.id, projectName: project.name, branches, tags }] };
134
+ }
135
+
136
+ export function getWorkspaceData(projectId, branchId) {
137
+ const project = findProjectById(projectId);
138
+ if (!project) return null;
139
+ const currentBranch = getCurrentBranch(project.repoPath);
140
+ const branchName = resolveBranchNameById(project.repoPath, branchId);
141
+ if (!branchName) return null;
142
+ const baseBranch = getDefaultBaseBranch(project.repoPath);
143
+ const statusInfo = branchName === currentBranch
144
+ ? parseGitStatusPorcelain(project.repoPath)
145
+ : parseNameStatus(runGit(project.repoPath, ['diff', '--name-status', '--find-renames', `${baseBranch}...${branchName}`], ''));
146
+ const gitGraph = buildGitGraph(project.repoPath, branchName, baseBranch);
147
+ const chips = [`All ${statusInfo.total}`, `Modified ${statusInfo.modified}`, `New ${statusInfo.untracked}`, `Delete ${statusInfo.deleted}`];
148
+ const tree = buildChangeTree(statusInfo.entries);
149
+ return {
150
+ version: 1,
151
+ workspaces: [{
152
+ projectId, branchId,
153
+ tabs: {
154
+ git: { graph: gitGraph },
155
+ changes: { chips, tree },
156
+ chat: { mode: 'text-placeholder', placeholder: 'Chat payload is text-driven and will be defined later.' },
157
+ files: { rootPath: project.rootPath || '', initialDepth: 2, searchPlaceholder: 'Search files...' },
158
+ },
159
+ }],
160
+ };
161
+ }
162
+
163
+ export function buildPseudoWorkingChanges(repoPath, branchName, baseBranch, currentBranch) {
164
+ if (branchName === currentBranch) {
165
+ const statusInfo = parseGitStatusPorcelain(repoPath);
166
+ const patchRaw = runGitRaw(repoPath, ['diff', '--patch', '--find-renames', '--no-color', 'HEAD'], '');
167
+ const patchInfo = truncatePatch(patchRaw);
168
+ return { statusInfo, patch: patchInfo.patch, patchTruncated: patchInfo.patchTruncated, authoredAt: 'working tree', author: os.userInfo().username || 'You', subject: statusInfo.total > 0 ? `Current changes (${statusInfo.total} files)` : 'Working tree clean (no local changes)' };
169
+ }
170
+ const statusInfo = parseNameStatus(runGit(repoPath, ['diff', '--name-status', '--find-renames', `${baseBranch}...${branchName}`], ''));
171
+ const patchRaw = runGitRaw(repoPath, ['diff', '--patch', '--find-renames', '--no-color', `${baseBranch}...${branchName}`], '');
172
+ const patchInfo = truncatePatch(patchRaw);
173
+ return { statusInfo, patch: patchInfo.patch, patchTruncated: patchInfo.patchTruncated, authoredAt: `compared to ${baseBranch}`, author: branchName, subject: `Current ${branchName} delta vs ${baseBranch}` };
174
+ }
175
+
176
+ export function getCommitDetailData(projectId, branchId, commitId) {
177
+ const project = findProjectById(projectId);
178
+ if (!project) return null;
179
+ const branchName = resolveBranchNameById(project.repoPath, branchId);
180
+ if (!branchName) return null;
181
+ const baseBranch = getDefaultBaseBranch(project.repoPath);
182
+ const currentBranch = getCurrentBranch(project.repoPath);
183
+
184
+ if (commitId === 'working-tree') {
185
+ const working = buildPseudoWorkingChanges(project.repoPath, branchName, baseBranch, currentBranch);
186
+ return {
187
+ version: 1, projectId, branchId,
188
+ commit: { id: 'working-tree', hash: '', shortHash: 'WIP', subject: working.subject, author: working.author, authoredAt: working.authoredAt, isWorkingTree: true },
189
+ changes: { chips: [`All ${working.statusInfo.total}`, `Modified ${working.statusInfo.modified}`, `New ${working.statusInfo.untracked}`, `Delete ${working.statusInfo.deleted}`], tree: buildChangeTree(working.statusInfo.entries), patch: working.patch, patchTruncated: working.patchTruncated },
190
+ };
191
+ }
192
+
193
+ if (runGit(project.repoPath, ['cat-file', '-e', `${commitId}^{commit}`], 'missing') === 'missing') return null;
194
+ const metaRaw = runGit(project.repoPath, ['show', '-s', '--date=short', '--pretty=format:%H|%h|%an|%ad|%s', commitId], '');
195
+ const [hash = commitId, shortHash = commitId.slice(0, 7), author = 'unknown', authoredAt = 'n/a', ...subjectParts] = metaRaw.split('|');
196
+ const subject = subjectParts.join('|') || '(no subject)';
197
+ const statusInfo = parseNameStatus(runGit(project.repoPath, ['show', '--name-status', '--find-renames', '--format=', commitId], ''));
198
+ const patchRaw = runGitRaw(project.repoPath, ['show', '--patch', '--find-renames', '--no-color', '--format=', commitId], '');
199
+ const patchInfo = truncatePatch(patchRaw);
200
+ return {
201
+ version: 1, projectId, branchId,
202
+ commit: { id: commitId, hash, shortHash, subject, author, authoredAt, isWorkingTree: false },
203
+ changes: { chips: [`All ${statusInfo.total}`, `Modified ${statusInfo.modified}`, `New ${statusInfo.untracked}`, `Delete ${statusInfo.deleted}`], tree: buildChangeTree(statusInfo.entries), patch: patchInfo.patch, patchTruncated: patchInfo.patchTruncated },
204
+ };
205
+ }
206
+
207
+ export function getCommitFileContent(projectId, branchId, commitId, filePath) {
208
+ const project = findProjectById(projectId);
209
+ if (!project) return null;
210
+ const branchName = resolveBranchNameById(project.repoPath, branchId);
211
+ if (!branchName) return null;
212
+ const currentBranch = getCurrentBranch(project.repoPath);
213
+ if (commitId === 'working-tree') {
214
+ return branchName === currentBranch
215
+ ? readFileContent(project.repoPath, filePath)
216
+ : readFileContentFromRef(project.repoPath, branchName, filePath);
217
+ }
218
+ if (runGit(project.repoPath, ['cat-file', '-e', `${commitId}^{commit}`], 'missing') === 'missing') return null;
219
+ return readFileContentFromRef(project.repoPath, commitId, filePath);
220
+ }