s9n-devops-agent 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,316 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Session Cleanup Tool
5
+ * Safely closes and cleans up DevOps agent sessions
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execSync } = require('child_process');
11
+ const readline = require('readline');
12
+
13
+ // Configuration
14
+ const CONFIG = {
15
+ colors: {
16
+ reset: '\x1b[0m',
17
+ bright: '\x1b[1m',
18
+ red: '\x1b[31m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[34m',
22
+ cyan: '\x1b[36m',
23
+ dim: '\x1b[2m'
24
+ }
25
+ };
26
+
27
+ class SessionCloser {
28
+ constructor() {
29
+ this.repoRoot = this.getRepoRoot();
30
+ this.locksPath = path.join(this.repoRoot, 'local_deploy', 'session-locks');
31
+ this.worktreesPath = path.join(this.repoRoot, 'local_deploy', 'worktrees');
32
+ }
33
+
34
+ getRepoRoot() {
35
+ try {
36
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
37
+ } catch (error) {
38
+ console.error(`${CONFIG.colors.red}Error: Not in a git repository${CONFIG.colors.reset}`);
39
+ process.exit(1);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * List all active sessions
45
+ */
46
+ listSessions() {
47
+ if (!fs.existsSync(this.locksPath)) {
48
+ console.log(`${CONFIG.colors.yellow}No active sessions found${CONFIG.colors.reset}`);
49
+ return [];
50
+ }
51
+
52
+ const sessions = [];
53
+ const lockFiles = fs.readdirSync(this.locksPath).filter(f => f.endsWith('.lock'));
54
+
55
+ lockFiles.forEach(file => {
56
+ const lockPath = path.join(this.locksPath, file);
57
+ const sessionData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
58
+ sessions.push(sessionData);
59
+ });
60
+
61
+ return sessions;
62
+ }
63
+
64
+ /**
65
+ * Display sessions for selection
66
+ */
67
+ async selectSession() {
68
+ const sessions = this.listSessions();
69
+
70
+ if (sessions.length === 0) {
71
+ console.log(`${CONFIG.colors.yellow}No active sessions to close${CONFIG.colors.reset}`);
72
+ return null;
73
+ }
74
+
75
+ console.log(`\n${CONFIG.colors.bright}Active Sessions:${CONFIG.colors.reset}\n`);
76
+
77
+ sessions.forEach((session, index) => {
78
+ const status = session.status === 'active' ?
79
+ `${CONFIG.colors.green}ā—${CONFIG.colors.reset}` :
80
+ `${CONFIG.colors.yellow}ā—‹${CONFIG.colors.reset}`;
81
+
82
+ console.log(`${status} ${CONFIG.colors.bright}${index + 1})${CONFIG.colors.reset} ${session.sessionId}`);
83
+ console.log(` Task: ${session.task}`);
84
+ console.log(` Branch: ${session.branchName}`);
85
+ console.log(` Created: ${session.created}`);
86
+ console.log();
87
+ });
88
+
89
+ const rl = readline.createInterface({
90
+ input: process.stdin,
91
+ output: process.stdout
92
+ });
93
+
94
+ return new Promise((resolve) => {
95
+ rl.question(`Select session to close (1-${sessions.length}) or 'q' to quit: `, (answer) => {
96
+ rl.close();
97
+
98
+ if (answer.toLowerCase() === 'q') {
99
+ resolve(null);
100
+ } else {
101
+ const index = parseInt(answer) - 1;
102
+ if (index >= 0 && index < sessions.length) {
103
+ resolve(sessions[index]);
104
+ } else {
105
+ console.log(`${CONFIG.colors.red}Invalid selection${CONFIG.colors.reset}`);
106
+ resolve(null);
107
+ }
108
+ }
109
+ });
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Close a session with all cleanup
115
+ */
116
+ async closeSession(session) {
117
+ console.log(`\n${CONFIG.colors.yellow}Closing session: ${session.sessionId}${CONFIG.colors.reset}`);
118
+
119
+ const steps = [
120
+ { name: 'Kill agent process', fn: () => this.killAgent(session) },
121
+ { name: 'Check for uncommitted changes', fn: () => this.checkUncommittedChanges(session) },
122
+ { name: 'Push final changes', fn: () => this.pushChanges(session) },
123
+ { name: 'Remove worktree', fn: () => this.removeWorktree(session) },
124
+ { name: 'Delete local branch', fn: () => this.deleteLocalBranch(session) },
125
+ { name: 'Clean up lock file', fn: () => this.removeLockFile(session) }
126
+ ];
127
+
128
+ let allSuccess = true;
129
+
130
+ for (const step of steps) {
131
+ process.stdout.write(`${step.name}... `);
132
+ try {
133
+ const result = await step.fn();
134
+ if (result !== false) {
135
+ console.log(`${CONFIG.colors.green}āœ“${CONFIG.colors.reset}`);
136
+ }
137
+ } catch (error) {
138
+ console.log(`${CONFIG.colors.red}āœ— ${error.message}${CONFIG.colors.reset}`);
139
+ allSuccess = false;
140
+ }
141
+ }
142
+
143
+ // Ask about remote branch
144
+ const deleteRemote = await this.askDeleteRemote(session);
145
+ if (deleteRemote) {
146
+ process.stdout.write('Delete remote branch... ');
147
+ try {
148
+ this.deleteRemoteBranch(session);
149
+ console.log(`${CONFIG.colors.green}āœ“${CONFIG.colors.reset}`);
150
+ } catch (error) {
151
+ console.log(`${CONFIG.colors.yellow}⚠ ${error.message}${CONFIG.colors.reset}`);
152
+ }
153
+ }
154
+
155
+ if (allSuccess) {
156
+ console.log(`\n${CONFIG.colors.green}āœ“ Session closed successfully!${CONFIG.colors.reset}`);
157
+ } else {
158
+ console.log(`\n${CONFIG.colors.yellow}⚠ Session closed with warnings${CONFIG.colors.reset}`);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Kill the agent process if running
164
+ */
165
+ killAgent(session) {
166
+ if (session.agentPid) {
167
+ try {
168
+ process.kill(session.agentPid, 'SIGTERM');
169
+ return true;
170
+ } catch (error) {
171
+ // Process might already be dead
172
+ return true;
173
+ }
174
+ }
175
+ return true;
176
+ }
177
+
178
+ /**
179
+ * Check for uncommitted changes
180
+ */
181
+ checkUncommittedChanges(session) {
182
+ try {
183
+ const status = execSync(`git -C "${session.worktreePath}" status --porcelain`, { encoding: 'utf8' });
184
+ if (status.trim()) {
185
+ console.log(`\n${CONFIG.colors.yellow}Warning: Uncommitted changes in worktree:${CONFIG.colors.reset}`);
186
+ console.log(status);
187
+
188
+ // Optionally commit them
189
+ const rl = readline.createInterface({
190
+ input: process.stdin,
191
+ output: process.stdout
192
+ });
193
+
194
+ return new Promise((resolve) => {
195
+ rl.question('Commit these changes? (y/n): ', (answer) => {
196
+ rl.close();
197
+ if (answer.toLowerCase() === 'y') {
198
+ execSync(`git -C "${session.worktreePath}" add -A`, { stdio: 'pipe' });
199
+ execSync(`git -C "${session.worktreePath}" commit -m "chore: final session cleanup"`, { stdio: 'pipe' });
200
+ }
201
+ resolve(true);
202
+ });
203
+ });
204
+ }
205
+ } catch (error) {
206
+ // Worktree might not exist
207
+ }
208
+ return true;
209
+ }
210
+
211
+ /**
212
+ * Push any final changes
213
+ */
214
+ pushChanges(session) {
215
+ try {
216
+ // Check if there are unpushed commits
217
+ const unpushed = execSync(
218
+ `git -C "${session.worktreePath}" log origin/${session.branchName}..HEAD --oneline 2>/dev/null`,
219
+ { encoding: 'utf8' }
220
+ ).trim();
221
+
222
+ if (unpushed) {
223
+ execSync(`git -C "${session.worktreePath}" push origin ${session.branchName}`, { stdio: 'pipe' });
224
+ }
225
+ } catch (error) {
226
+ // Branch might not exist on remote or worktree might be gone
227
+ }
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * Remove the worktree
233
+ */
234
+ removeWorktree(session) {
235
+ try {
236
+ execSync(`git worktree remove "${session.worktreePath}" --force`, { stdio: 'pipe' });
237
+ execSync('git worktree prune', { stdio: 'pipe' });
238
+ } catch (error) {
239
+ // Worktree might already be removed
240
+ }
241
+ return true;
242
+ }
243
+
244
+ /**
245
+ * Delete local branch
246
+ */
247
+ deleteLocalBranch(session) {
248
+ try {
249
+ execSync(`git branch -D ${session.branchName}`, { stdio: 'pipe' });
250
+ } catch (error) {
251
+ // Branch might not exist locally
252
+ }
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Remove lock file
258
+ */
259
+ removeLockFile(session) {
260
+ const lockFile = path.join(this.locksPath, `${session.sessionId}.lock`);
261
+ if (fs.existsSync(lockFile)) {
262
+ fs.unlinkSync(lockFile);
263
+ }
264
+ return true;
265
+ }
266
+
267
+ /**
268
+ * Ask if remote branch should be deleted
269
+ */
270
+ askDeleteRemote(session) {
271
+ const rl = readline.createInterface({
272
+ input: process.stdin,
273
+ output: process.stdout
274
+ });
275
+
276
+ return new Promise((resolve) => {
277
+ rl.question(`\nDelete remote branch ${session.branchName}? (y/n): `, (answer) => {
278
+ rl.close();
279
+ resolve(answer.toLowerCase() === 'y');
280
+ });
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Delete remote branch
286
+ */
287
+ deleteRemoteBranch(session) {
288
+ execSync(`git push origin --delete ${session.branchName}`, { stdio: 'pipe' });
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Main execution
294
+ */
295
+ async run() {
296
+ console.log(`${CONFIG.colors.bright}\nšŸ”§ DevOps Session Cleanup Tool${CONFIG.colors.reset}`);
297
+
298
+ const session = await this.selectSession();
299
+ if (session) {
300
+ await this.closeSession(session);
301
+ } else {
302
+ console.log(`\n${CONFIG.colors.dim}No session selected${CONFIG.colors.reset}`);
303
+ }
304
+ }
305
+ }
306
+
307
+ // Run if called directly
308
+ if (require.main === module) {
309
+ const closer = new SessionCloser();
310
+ closer.run().catch(error => {
311
+ console.error(`${CONFIG.colors.red}Error: ${error.message}${CONFIG.colors.reset}`);
312
+ process.exit(1);
313
+ });
314
+ }
315
+
316
+ module.exports = SessionCloser;