@lovelybunch/api 1.0.57 → 1.0.59

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 (35) hide show
  1. package/dist/lib/git.d.ts +13 -0
  2. package/dist/lib/git.js +137 -5
  3. package/dist/lib/jobs/global-job-scheduler.d.ts +2 -0
  4. package/dist/lib/jobs/global-job-scheduler.js +11 -0
  5. package/dist/lib/jobs/job-runner.d.ts +17 -0
  6. package/dist/lib/jobs/job-runner.js +167 -0
  7. package/dist/lib/jobs/job-scheduler.d.ts +39 -0
  8. package/dist/lib/jobs/job-scheduler.js +309 -0
  9. package/dist/lib/jobs/job-store.d.ts +16 -0
  10. package/dist/lib/jobs/job-store.js +211 -0
  11. package/dist/lib/storage/file-storage.js +7 -5
  12. package/dist/lib/terminal/terminal-manager.d.ts +2 -0
  13. package/dist/lib/terminal/terminal-manager.js +65 -0
  14. package/dist/routes/api/v1/ai/route.d.ts +1 -7
  15. package/dist/routes/api/v1/ai/route.js +25 -12
  16. package/dist/routes/api/v1/git/index.js +63 -1
  17. package/dist/routes/api/v1/jobs/[id]/route.d.ts +133 -0
  18. package/dist/routes/api/v1/jobs/[id]/route.js +135 -0
  19. package/dist/routes/api/v1/jobs/[id]/run/route.d.ts +31 -0
  20. package/dist/routes/api/v1/jobs/[id]/run/route.js +37 -0
  21. package/dist/routes/api/v1/jobs/index.d.ts +3 -0
  22. package/dist/routes/api/v1/jobs/index.js +14 -0
  23. package/dist/routes/api/v1/jobs/route.d.ts +108 -0
  24. package/dist/routes/api/v1/jobs/route.js +144 -0
  25. package/dist/routes/api/v1/jobs/status/route.d.ts +23 -0
  26. package/dist/routes/api/v1/jobs/status/route.js +21 -0
  27. package/dist/routes/api/v1/resources/[id]/route.d.ts +2 -44
  28. package/dist/server-with-static.js +5 -0
  29. package/dist/server.js +5 -0
  30. package/package.json +4 -4
  31. package/static/assets/index-CHq6mL1J.css +33 -0
  32. package/static/assets/index-QHnHUcsV.js +820 -0
  33. package/static/index.html +2 -2
  34. package/static/assets/index-CRg4lVi6.js +0 -779
  35. package/static/assets/index-VqhUTak4.css +0 -33
package/dist/lib/git.d.ts CHANGED
@@ -4,6 +4,7 @@ export declare function sanitizeBranchName(name: string): string;
4
4
  export declare function resolveSafeWorktreePath(name: string): Promise<string>;
5
5
  export declare function runGit(args: string[], opts?: {
6
6
  cwd?: string;
7
+ timeout?: number;
7
8
  }): Promise<{
8
9
  stdout: string;
9
10
  stderr: string;
@@ -25,9 +26,21 @@ export declare function listBranches(): Promise<{
25
26
  }[]>;
26
27
  export declare function createBranch(name: string, from?: string): Promise<void>;
27
28
  export declare function deleteBranch(name: string): Promise<void>;
29
+ export declare function switchBranch(name: string): Promise<void>;
30
+ export declare function mergeBranch(branchName: string, strategy?: 'merge' | 'squash' | 'rebase'): Promise<string>;
28
31
  export declare function pushCurrent(): Promise<string>;
29
32
  export type PullStrategy = 'merge' | 'rebase' | 'ff-only';
30
33
  export declare function pullCurrent(strategy?: PullStrategy): Promise<string>;
34
+ export declare function checkRemoteAuth(): Promise<{
35
+ authenticated: boolean;
36
+ remote?: string;
37
+ error?: string;
38
+ }>;
39
+ export declare function getCredentialConfig(): Promise<{
40
+ helper?: string;
41
+ origin?: string;
42
+ }>;
43
+ export declare function storeCredentials(username: string, password: string): Promise<void>;
31
44
  export interface WorktreeInfo {
32
45
  name: string;
33
46
  path: string;
package/dist/lib/git.js CHANGED
@@ -43,7 +43,13 @@ export async function resolveSafeWorktreePath(name) {
43
43
  export async function runGit(args, opts) {
44
44
  const repoRoot = await getRepoRoot();
45
45
  const cwd = opts?.cwd || repoRoot;
46
- return execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
46
+ const timeout = opts?.timeout || 120000; // 2 minutes default
47
+ // Prevent git from prompting for credentials in non-interactive environment
48
+ const env = {
49
+ ...process.env,
50
+ GIT_TERMINAL_PROMPT: '0',
51
+ };
52
+ return execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024, timeout, env });
47
53
  }
48
54
  // --- Status ---
49
55
  export async function getRepoStatus() {
@@ -106,16 +112,142 @@ export async function deleteBranch(name) {
106
112
  const safe = sanitizeBranchName(name);
107
113
  await runGit(['branch', '-D', safe]);
108
114
  }
115
+ export async function switchBranch(name) {
116
+ const safe = sanitizeBranchName(name);
117
+ await runGit(['switch', safe]);
118
+ }
119
+ export async function mergeBranch(branchName, strategy = 'merge') {
120
+ const safe = sanitizeBranchName(branchName);
121
+ if (strategy === 'rebase') {
122
+ const { stdout } = await runGit(['rebase', safe]);
123
+ return stdout;
124
+ }
125
+ else if (strategy === 'squash') {
126
+ const { stdout } = await runGit(['merge', '--squash', safe]);
127
+ return stdout;
128
+ }
129
+ else {
130
+ const { stdout } = await runGit(['merge', safe]);
131
+ return stdout;
132
+ }
133
+ }
109
134
  // --- Push / Pull ---
110
135
  export async function pushCurrent() {
111
- const { stdout } = await runGit(['push']);
136
+ const { stdout } = await runGit(['push'], { timeout: 30000 }); // 30 second timeout for push
112
137
  return stdout;
113
138
  }
114
139
  export async function pullCurrent(strategy = 'rebase') {
115
140
  const args = strategy === 'rebase' ? ['pull', '--rebase'] : strategy === 'ff-only' ? ['pull', '--ff-only'] : ['pull', '--no-rebase'];
116
- const { stdout } = await runGit(args);
141
+ const { stdout } = await runGit(args, { timeout: 30000 }); // 30 second timeout for pull
117
142
  return stdout;
118
143
  }
144
+ // --- Auth Status ---
145
+ export async function checkRemoteAuth() {
146
+ try {
147
+ // Get remote URL
148
+ const { stdout: remoteUrl } = await runGit(['config', '--get', 'remote.origin.url']);
149
+ const remote = remoteUrl.trim();
150
+ // Try to ls-remote with minimal timeout
151
+ await runGit(['ls-remote', '--exit-code', 'origin', 'HEAD'], { timeout: 10000 });
152
+ return { authenticated: true, remote };
153
+ }
154
+ catch (error) {
155
+ // Check if it's an auth error vs other errors
156
+ const message = error.message || error.stderr || '';
157
+ const isAuthError = message.includes('Authentication failed') ||
158
+ message.includes('could not read Username') ||
159
+ message.includes('could not read Password') ||
160
+ message.includes('terminal prompts disabled') ||
161
+ error.code === 'ETIMEDOUT';
162
+ return {
163
+ authenticated: false,
164
+ error: isAuthError ? 'Authentication required' : message
165
+ };
166
+ }
167
+ }
168
+ export async function getCredentialConfig() {
169
+ try {
170
+ const { stdout } = await runGit(['config', '--show-origin', '--get', 'credential.helper']);
171
+ const lines = stdout.trim().split('\n');
172
+ const lastLine = lines[lines.length - 1]; // Get the last configured helper (takes precedence)
173
+ if (lastLine) {
174
+ // Format: "file:/path/to/config\thelper-value"
175
+ const [origin, helper] = lastLine.split('\t');
176
+ return { helper: helper?.trim(), origin: origin?.replace('file:', '').trim() };
177
+ }
178
+ return {};
179
+ }
180
+ catch {
181
+ // No credential helper configured
182
+ return {};
183
+ }
184
+ }
185
+ export async function storeCredentials(username, password) {
186
+ // Get remote URL to determine protocol and host
187
+ const { stdout: remoteUrl } = await runGit(['config', '--get', 'remote.origin.url']);
188
+ const remote = remoteUrl.trim();
189
+ // Parse the remote URL to extract protocol and host
190
+ let protocol = 'https';
191
+ let host = '';
192
+ if (remote.startsWith('https://')) {
193
+ protocol = 'https';
194
+ host = remote.replace('https://', '').split('/')[0];
195
+ }
196
+ else if (remote.startsWith('http://')) {
197
+ protocol = 'http';
198
+ host = remote.replace('http://', '').split('/')[0];
199
+ }
200
+ else {
201
+ throw new Error('Remote URL is not HTTPS/HTTP. Please use SSH keys for SSH remotes.');
202
+ }
203
+ // Ensure a credential helper is configured
204
+ const config = await getCredentialConfig();
205
+ if (!config.helper) {
206
+ // Auto-configure based on platform
207
+ const platform = process.platform;
208
+ if (platform === 'darwin') {
209
+ await runGit(['config', '--global', 'credential.helper', 'osxkeychain']);
210
+ }
211
+ else if (platform === 'win32') {
212
+ await runGit(['config', '--global', 'credential.helper', 'manager']);
213
+ }
214
+ else {
215
+ // Linux - use store as fallback
216
+ await runGit(['config', '--global', 'credential.helper', 'store']);
217
+ }
218
+ }
219
+ // Use git credential approve to store credentials via stdin
220
+ const credentialInput = `protocol=${protocol}\nhost=${host}\nusername=${username}\npassword=${password}\n\n`;
221
+ const repoRoot = await getRepoRoot();
222
+ const { spawn } = await import('child_process');
223
+ return new Promise((resolve, reject) => {
224
+ const child = spawn('git', ['credential', 'approve'], {
225
+ cwd: repoRoot,
226
+ env: {
227
+ ...process.env,
228
+ GIT_TERMINAL_PROMPT: '0',
229
+ },
230
+ });
231
+ let stderr = '';
232
+ child.stderr?.on('data', (data) => {
233
+ stderr += data.toString();
234
+ });
235
+ child.on('error', (err) => {
236
+ reject(err);
237
+ });
238
+ child.on('close', (code) => {
239
+ if (code === 0) {
240
+ resolve();
241
+ }
242
+ else {
243
+ reject(new Error(`git credential approve failed with code ${code}: ${stderr}`));
244
+ }
245
+ });
246
+ // Write credentials to stdin
247
+ child.stdin?.write(credentialInput);
248
+ child.stdin?.end();
249
+ });
250
+ }
119
251
  export async function listWorktrees() {
120
252
  const { stdout } = await runGit(['worktree', 'list', '--porcelain']);
121
253
  // Parse porcelain groups separated by blank lines with fields: worktree <path>, HEAD <sha>, branch <refs/heads/x>, locked
@@ -195,13 +327,13 @@ export async function commitInWorktree(name, message, files) {
195
327
  export async function pushWorktree(name) {
196
328
  const found = await getWorktreeByName(name);
197
329
  const wtPath = found ? found.path : await resolveSafeWorktreePath(name);
198
- const { stdout } = await runGit(['-C', wtPath, 'push']);
330
+ const { stdout } = await runGit(['-C', wtPath, 'push'], { timeout: 30000 }); // 30 second timeout
199
331
  return stdout;
200
332
  }
201
333
  export async function pullWorktree(name, strategy = 'rebase') {
202
334
  const found = await getWorktreeByName(name);
203
335
  const wtPath = found ? found.path : await resolveSafeWorktreePath(name);
204
336
  const args = strategy === 'rebase' ? ['-C', wtPath, 'pull', '--rebase'] : strategy === 'ff-only' ? ['-C', wtPath, 'pull', '--ff-only'] : ['-C', wtPath, 'pull', '--no-rebase'];
205
- const { stdout } = await runGit(args);
337
+ const { stdout } = await runGit(args, { timeout: 30000 }); // 30 second timeout
206
338
  return stdout;
207
339
  }
@@ -0,0 +1,2 @@
1
+ import { JobScheduler } from './job-scheduler.js';
2
+ export declare function getGlobalJobScheduler(): JobScheduler;
@@ -0,0 +1,11 @@
1
+ import { JobScheduler } from './job-scheduler.js';
2
+ let scheduler = null;
3
+ export function getGlobalJobScheduler() {
4
+ if (!scheduler) {
5
+ scheduler = new JobScheduler();
6
+ scheduler.initialize().catch((error) => {
7
+ console.error('Failed to initialize job scheduler:', error);
8
+ });
9
+ }
10
+ return scheduler;
11
+ }
@@ -0,0 +1,17 @@
1
+ import { ScheduledJob } from '@lovelybunch/types';
2
+ interface JobRunResult {
3
+ status: 'succeeded' | 'failed';
4
+ summary?: string;
5
+ outputPath?: string;
6
+ error?: string;
7
+ cliCommand: string;
8
+ }
9
+ export declare class JobRunner {
10
+ private projectRootPromise;
11
+ constructor();
12
+ private ensureCliAvailable;
13
+ private ensureLogPath;
14
+ private buildInstruction;
15
+ run(job: ScheduledJob, runId: string): Promise<JobRunResult>;
16
+ }
17
+ export {};
@@ -0,0 +1,167 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import { createWriteStream } from 'fs';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import { getProjectRoot } from '../project-paths.js';
6
+ function shellQuote(value) {
7
+ if (value === '')
8
+ return "''";
9
+ return `'${value.replace(/'/g, "'\\''")}'`;
10
+ }
11
+ function resolveAgent(model) {
12
+ if (!model)
13
+ return 'claude';
14
+ const lower = model.toLowerCase();
15
+ if (lower.includes('gemini'))
16
+ return 'gemini';
17
+ if (lower.includes('codex') || lower.includes('gpt') || lower.includes('openai'))
18
+ return 'codex';
19
+ return 'claude';
20
+ }
21
+ function buildCommand(agent, instruction) {
22
+ const quotedInstruction = shellQuote(instruction);
23
+ switch (agent) {
24
+ case 'gemini': {
25
+ const cmd = `gemini --yolo -i ${quotedInstruction}`;
26
+ return { command: 'gemini', shellCommand: cmd };
27
+ }
28
+ case 'codex': {
29
+ const cmd = `codex ${quotedInstruction} --dangerously-bypass-approvals-and-sandbox`;
30
+ return { command: 'codex', shellCommand: cmd };
31
+ }
32
+ case 'claude':
33
+ default: {
34
+ const cmd = `claude ${quotedInstruction} --dangerously-skip-permissions`;
35
+ return { command: 'claude', shellCommand: cmd };
36
+ }
37
+ }
38
+ }
39
+ const CLI_AGENT_LABEL = {
40
+ claude: 'Claude',
41
+ gemini: 'Gemini',
42
+ codex: 'Code'
43
+ };
44
+ const CLI_AGENT_BINARY = {
45
+ claude: 'claude',
46
+ gemini: 'gemini',
47
+ codex: 'codex'
48
+ };
49
+ const DEFAULT_MAX_RUNTIME_MS = 30 * 60 * 1000; // 30 minutes
50
+ function getMaxRuntime() {
51
+ const raw = process.env.COCONUT_JOB_MAX_RUNTIME_MS;
52
+ if (!raw)
53
+ return DEFAULT_MAX_RUNTIME_MS;
54
+ const parsed = Number(raw);
55
+ if (!Number.isFinite(parsed) || parsed <= 0) {
56
+ return DEFAULT_MAX_RUNTIME_MS;
57
+ }
58
+ return parsed;
59
+ }
60
+ export class JobRunner {
61
+ projectRootPromise;
62
+ constructor() {
63
+ this.projectRootPromise = getProjectRoot();
64
+ }
65
+ ensureCliAvailable(agent) {
66
+ const binary = CLI_AGENT_BINARY[agent];
67
+ const result = spawnSync('bash', ['-lc', `command -v ${binary}`], { stdio: 'ignore' });
68
+ if (result.status !== 0) {
69
+ throw new Error(`${CLI_AGENT_LABEL[agent]} CLI (“${binary}”) is not installed or not on PATH.`);
70
+ }
71
+ }
72
+ async ensureLogPath(jobId, runId) {
73
+ const projectRoot = await this.projectRootPromise;
74
+ const logsDir = path.join(projectRoot, '.nut', 'jobs', 'logs', jobId);
75
+ await fs.mkdir(logsDir, { recursive: true });
76
+ return path.join(logsDir, `${runId}.log`);
77
+ }
78
+ buildInstruction(job, agentLabel) {
79
+ const prompt = job.prompt.trim();
80
+ const scheduleDescription = job.schedule.type === 'cron'
81
+ ? `Cron: ${job.schedule.expression}`
82
+ : `Interval: every ${job.schedule.hours}h on ${job.schedule.daysOfWeek.join(', ')}`;
83
+ return `Run this scheduled Coconut job (${job.id}).\nSchedule: ${scheduleDescription}.\nPreferred CLI agent: ${agentLabel}.\nInstruction:\n${prompt}`;
84
+ }
85
+ async run(job, runId) {
86
+ const agent = resolveAgent(job.model);
87
+ const instruction = this.buildInstruction(job, CLI_AGENT_LABEL[agent] || agent);
88
+ const { shellCommand } = buildCommand(agent, instruction);
89
+ const projectRoot = await this.projectRootPromise;
90
+ const logPath = await this.ensureLogPath(job.id, runId);
91
+ const logStream = createWriteStream(logPath, { flags: 'a' });
92
+ const summaryChunks = [];
93
+ logStream.write(`[${new Date().toISOString()}] Starting job ${job.id} using ${agent} CLI\n`);
94
+ logStream.write(`Instruction: ${instruction}\n`);
95
+ logStream.write(`Command: ${shellCommand}\n`);
96
+ return new Promise((resolve) => {
97
+ let cliMissingError = null;
98
+ try {
99
+ this.ensureCliAvailable(agent);
100
+ }
101
+ catch (error) {
102
+ cliMissingError = error instanceof Error ? error : new Error(String(error));
103
+ }
104
+ if (cliMissingError) {
105
+ const message = cliMissingError.message;
106
+ logStream.write(`${message}\n`);
107
+ logStream.end();
108
+ resolve({
109
+ status: 'failed',
110
+ error: message,
111
+ summary: message,
112
+ outputPath: path.relative(projectRoot, logPath),
113
+ cliCommand: shellCommand,
114
+ });
115
+ return;
116
+ }
117
+ const child = spawn('bash', ['-lc', shellCommand], {
118
+ cwd: projectRoot,
119
+ env: process.env,
120
+ stdio: ['ignore', 'pipe', 'pipe'],
121
+ });
122
+ const maxRuntime = getMaxRuntime();
123
+ const abortTimeout = setTimeout(() => {
124
+ logStream.write(`\n[${new Date().toISOString()}] Max runtime ${maxRuntime}ms exceeded. Sending SIGTERM...\n`);
125
+ child.kill('SIGTERM');
126
+ setTimeout(() => child.kill('SIGKILL'), 10_000);
127
+ }, maxRuntime);
128
+ child.stdout?.on('data', (chunk) => {
129
+ const text = chunk.toString();
130
+ logStream.write(text);
131
+ summaryChunks.push(text);
132
+ });
133
+ child.stderr?.on('data', (chunk) => {
134
+ const text = chunk.toString();
135
+ logStream.write(text);
136
+ summaryChunks.push(text);
137
+ });
138
+ child.on('error', (error) => {
139
+ const message = `Failed to start CLI command: ${error.message}`;
140
+ logStream.write(`${message}\n`);
141
+ logStream.end();
142
+ clearTimeout(abortTimeout);
143
+ resolve({
144
+ status: 'failed',
145
+ error: message,
146
+ summary: summaryChunks.join('').slice(-600),
147
+ outputPath: path.relative(projectRoot, logPath),
148
+ cliCommand: shellCommand,
149
+ });
150
+ });
151
+ child.on('close', (code) => {
152
+ const status = code === 0 ? 'succeeded' : 'failed';
153
+ logStream.write(`\n[${new Date().toISOString()}] Job ${job.id} completed with exit code ${code}\n`);
154
+ logStream.end();
155
+ clearTimeout(abortTimeout);
156
+ const summary = summaryChunks.join('');
157
+ resolve({
158
+ status,
159
+ summary: summary.slice(Math.max(0, summary.length - 2000)),
160
+ outputPath: path.relative(projectRoot, logPath),
161
+ error: code === 0 ? undefined : `CLI exited with code ${code}`,
162
+ cliCommand: shellCommand,
163
+ });
164
+ });
165
+ });
166
+ }
167
+ }
@@ -0,0 +1,39 @@
1
+ import { ScheduledJob, ScheduledJobRun, ScheduledJobStatus, ScheduledJobTrigger } from '@lovelybunch/types';
2
+ import { JobStore } from './job-store.js';
3
+ import { JobRunner } from './job-runner.js';
4
+ export declare class JobScheduler {
5
+ private store;
6
+ private runner;
7
+ private jobs;
8
+ private runningJobs;
9
+ private initialized;
10
+ constructor(store?: JobStore, runner?: JobRunner);
11
+ initialize(): Promise<void>;
12
+ register(job: ScheduledJob): Promise<void>;
13
+ unregister(jobId: string): Promise<void>;
14
+ refresh(jobId: string): Promise<void>;
15
+ runNow(jobId: string, trigger?: ScheduledJobTrigger): Promise<ScheduledJobRun | null>;
16
+ private clearTimer;
17
+ private execute;
18
+ private updateJobMetadata;
19
+ private calculateNextRun;
20
+ private calculateNextInterval;
21
+ private calculateNextCron;
22
+ private resolveDayMatch;
23
+ private parseCronField;
24
+ private expandCronSegment;
25
+ private storeCronValue;
26
+ getStatus(): {
27
+ initialized: boolean;
28
+ jobCount: number;
29
+ runningCount: number;
30
+ jobs: {
31
+ id: string;
32
+ status: ScheduledJobStatus;
33
+ nextRunAt?: Date;
34
+ lastRunAt?: Date;
35
+ timerActive: boolean;
36
+ running: boolean;
37
+ }[];
38
+ };
39
+ }