@aion0/forge 0.1.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.
- package/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Tunnel (cloudflared) integration.
|
|
3
|
+
* Zero-config mode: no account needed, gives a temporary public URL.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
7
|
+
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync } from 'node:fs';
|
|
8
|
+
import { homedir, platform, arch } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
|
|
13
|
+
const BIN_DIR = join(homedir(), '.my-workflow', 'bin');
|
|
14
|
+
const BIN_NAME = platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
15
|
+
const BIN_PATH = join(BIN_DIR, BIN_NAME);
|
|
16
|
+
|
|
17
|
+
// ─── Download URL resolution ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function getDownloadUrl(): string {
|
|
20
|
+
const os = platform();
|
|
21
|
+
const cpu = arch();
|
|
22
|
+
const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
23
|
+
|
|
24
|
+
if (os === 'darwin') {
|
|
25
|
+
// macOS universal binary
|
|
26
|
+
return `${base}/cloudflared-darwin-amd64.tgz`;
|
|
27
|
+
}
|
|
28
|
+
if (os === 'linux') {
|
|
29
|
+
if (cpu === 'arm64') return `${base}/cloudflared-linux-arm64`;
|
|
30
|
+
return `${base}/cloudflared-linux-amd64`;
|
|
31
|
+
}
|
|
32
|
+
if (os === 'win32') {
|
|
33
|
+
return `${base}/cloudflared-windows-amd64.exe`;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Unsupported platform: ${os}/${cpu}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Download helper ────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function followRedirects(url: string, dest: string): Promise<void> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const client = url.startsWith('https') ? https : http;
|
|
43
|
+
client.get(url, { headers: { 'User-Agent': 'my-workflow' } }, (res) => {
|
|
44
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
45
|
+
followRedirects(res.headers.location, dest).then(resolve, reject);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (res.statusCode !== 200) {
|
|
49
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const file = createWriteStream(dest);
|
|
53
|
+
res.pipe(file);
|
|
54
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
55
|
+
file.on('error', reject);
|
|
56
|
+
}).on('error', reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function downloadCloudflared(): Promise<string> {
|
|
61
|
+
if (existsSync(BIN_PATH)) return BIN_PATH;
|
|
62
|
+
|
|
63
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
64
|
+
const url = getDownloadUrl();
|
|
65
|
+
const isTgz = url.endsWith('.tgz');
|
|
66
|
+
const tmpPath = isTgz ? `${BIN_PATH}.tgz` : BIN_PATH;
|
|
67
|
+
|
|
68
|
+
console.log(`[cloudflared] Downloading from ${url}...`);
|
|
69
|
+
await followRedirects(url, tmpPath);
|
|
70
|
+
|
|
71
|
+
if (isTgz) {
|
|
72
|
+
// Extract tgz (macOS)
|
|
73
|
+
execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
|
|
74
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (platform() !== 'win32') {
|
|
78
|
+
chmodSync(BIN_PATH, 0o755);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(`[cloudflared] Installed to ${BIN_PATH}`);
|
|
82
|
+
return BIN_PATH;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isInstalled(): boolean {
|
|
86
|
+
return existsSync(BIN_PATH);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Tunnel process management ──────────────────────────────────
|
|
90
|
+
// Use globalThis to persist state across hot-reloads
|
|
91
|
+
|
|
92
|
+
interface TunnelState {
|
|
93
|
+
process: ChildProcess | null;
|
|
94
|
+
url: string | null;
|
|
95
|
+
status: 'stopped' | 'starting' | 'running' | 'error';
|
|
96
|
+
error: string | null;
|
|
97
|
+
log: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stateKey = Symbol.for('mw-tunnel-state');
|
|
101
|
+
const gAny = globalThis as any;
|
|
102
|
+
if (!gAny[stateKey]) {
|
|
103
|
+
gAny[stateKey] = { process: null, url: null, status: 'stopped', error: null, log: [] } as TunnelState;
|
|
104
|
+
}
|
|
105
|
+
const state: TunnelState = gAny[stateKey];
|
|
106
|
+
|
|
107
|
+
const MAX_LOG_LINES = 100;
|
|
108
|
+
|
|
109
|
+
function pushLog(line: string) {
|
|
110
|
+
state.log.push(line);
|
|
111
|
+
if (state.log.length > MAX_LOG_LINES) state.log.shift();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function startTunnel(localPort: number = 3000): Promise<{ url?: string; error?: string }> {
|
|
115
|
+
if (state.process) {
|
|
116
|
+
return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
state.status = 'starting';
|
|
120
|
+
state.url = null;
|
|
121
|
+
state.error = null;
|
|
122
|
+
state.log = [];
|
|
123
|
+
|
|
124
|
+
let binPath: string;
|
|
125
|
+
try {
|
|
126
|
+
binPath = await downloadCloudflared();
|
|
127
|
+
} catch (e) {
|
|
128
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
129
|
+
state.status = 'error';
|
|
130
|
+
state.error = msg;
|
|
131
|
+
return { error: msg };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
let resolved = false;
|
|
136
|
+
|
|
137
|
+
state.process = spawn(binPath, ['tunnel', '--url', `http://localhost:${localPort}`], {
|
|
138
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
139
|
+
env: { ...process.env },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const handleOutput = (data: Buffer) => {
|
|
143
|
+
const text = data.toString();
|
|
144
|
+
for (const line of text.split('\n')) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
pushLog(line);
|
|
147
|
+
|
|
148
|
+
const urlMatch = line.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
|
|
149
|
+
if (urlMatch && !state.url) {
|
|
150
|
+
state.url = urlMatch[1];
|
|
151
|
+
state.status = 'running';
|
|
152
|
+
console.log(`[cloudflared] Tunnel URL: ${state.url}`);
|
|
153
|
+
if (!resolved) {
|
|
154
|
+
resolved = true;
|
|
155
|
+
resolve({ url: state.url });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
state.process.stdout?.on('data', handleOutput);
|
|
162
|
+
state.process.stderr?.on('data', handleOutput);
|
|
163
|
+
|
|
164
|
+
state.process.on('error', (err) => {
|
|
165
|
+
state.status = 'error';
|
|
166
|
+
state.error = err.message;
|
|
167
|
+
pushLog(`[error] ${err.message}`);
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
resolved = true;
|
|
170
|
+
resolve({ error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
state.process.on('exit', (code) => {
|
|
175
|
+
state.process = null;
|
|
176
|
+
if (state.status !== 'error') {
|
|
177
|
+
state.status = 'stopped';
|
|
178
|
+
}
|
|
179
|
+
state.url = null;
|
|
180
|
+
pushLog(`[exit] cloudflared exited with code ${code}`);
|
|
181
|
+
if (!resolved) {
|
|
182
|
+
resolved = true;
|
|
183
|
+
resolve({ error: `cloudflared exited with code ${code}` });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
if (!resolved) {
|
|
189
|
+
resolved = true;
|
|
190
|
+
if (!state.url) {
|
|
191
|
+
state.status = 'error';
|
|
192
|
+
state.error = 'Timeout waiting for tunnel URL';
|
|
193
|
+
resolve({ error: 'Timeout waiting for tunnel URL (30s)' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, 30000);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function stopTunnel() {
|
|
201
|
+
if (state.process) {
|
|
202
|
+
state.process.kill('SIGTERM');
|
|
203
|
+
state.process = null;
|
|
204
|
+
}
|
|
205
|
+
state.url = null;
|
|
206
|
+
state.status = 'stopped';
|
|
207
|
+
state.error = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getTunnelStatus() {
|
|
211
|
+
return {
|
|
212
|
+
status: state.status,
|
|
213
|
+
url: state.url,
|
|
214
|
+
error: state.error,
|
|
215
|
+
installed: isInstalled(),
|
|
216
|
+
log: state.log.slice(-20),
|
|
217
|
+
};
|
|
218
|
+
}
|
package/lib/flows.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow (Flow) engine — loads YAML flow definitions and executes them.
|
|
3
|
+
*
|
|
4
|
+
* Flow files live in ~/.my-workflow/flows/*.yaml
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { createTask } from './task-manager';
|
|
12
|
+
import { getProjectInfo } from './projects';
|
|
13
|
+
import type { Task } from '@/src/types';
|
|
14
|
+
|
|
15
|
+
const FLOWS_DIR = join(homedir(), '.my-workflow', 'flows');
|
|
16
|
+
|
|
17
|
+
export interface FlowStep {
|
|
18
|
+
project: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
priority?: number;
|
|
21
|
+
dependsOn?: string; // step name to wait for
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Flow {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
schedule?: string; // cron expression for auto-trigger
|
|
28
|
+
steps: FlowStep[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listFlows(): Flow[] {
|
|
32
|
+
if (!existsSync(FLOWS_DIR)) return [];
|
|
33
|
+
|
|
34
|
+
return readdirSync(FLOWS_DIR)
|
|
35
|
+
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
36
|
+
.map(f => {
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(join(FLOWS_DIR, f), 'utf-8');
|
|
39
|
+
const parsed = YAML.parse(raw);
|
|
40
|
+
return {
|
|
41
|
+
name: parsed.name || f.replace(/\.ya?ml$/, ''),
|
|
42
|
+
description: parsed.description,
|
|
43
|
+
schedule: parsed.schedule,
|
|
44
|
+
steps: parsed.steps || [],
|
|
45
|
+
} as Flow;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.filter(Boolean) as Flow[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getFlow(name: string): Flow | null {
|
|
54
|
+
return listFlows().find(f => f.name === name) || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run a flow — creates tasks for each step.
|
|
59
|
+
* Steps without dependsOn run immediately (queued).
|
|
60
|
+
* Steps with dependsOn will be handled by a follow-up mechanism.
|
|
61
|
+
*/
|
|
62
|
+
export function runFlow(name: string): { flow: Flow; tasks: Task[] } {
|
|
63
|
+
const flow = getFlow(name);
|
|
64
|
+
if (!flow) throw new Error(`Flow not found: ${name}`);
|
|
65
|
+
|
|
66
|
+
const tasks: Task[] = [];
|
|
67
|
+
|
|
68
|
+
for (const step of flow.steps) {
|
|
69
|
+
const project = getProjectInfo(step.project);
|
|
70
|
+
if (!project) {
|
|
71
|
+
console.error(`[flow] Project not found: ${step.project}, skipping step`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const task = createTask({
|
|
76
|
+
projectName: project.name,
|
|
77
|
+
projectPath: project.path,
|
|
78
|
+
prompt: step.prompt,
|
|
79
|
+
priority: step.priority || 0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
tasks.push(task);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { flow, tasks };
|
|
86
|
+
}
|
package/lib/init.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side initialization — called once on first API request.
|
|
3
|
+
* Starts background services: task runner, Telegram bot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ensureRunnerStarted } from './task-manager';
|
|
7
|
+
import { startTelegramBot, stopTelegramBot } from './telegram-bot';
|
|
8
|
+
import { startWatcherLoop } from './session-watcher';
|
|
9
|
+
import { getPassword } from './password';
|
|
10
|
+
import { loadSettings } from './settings';
|
|
11
|
+
import { startTunnel } from './cloudflared';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const initKey = Symbol.for('mw-initialized');
|
|
16
|
+
const gInit = globalThis as any;
|
|
17
|
+
|
|
18
|
+
export function ensureInitialized() {
|
|
19
|
+
if (gInit[initKey]) return;
|
|
20
|
+
gInit[initKey] = true;
|
|
21
|
+
|
|
22
|
+
// Display login password (auto-generated, rotates daily)
|
|
23
|
+
const password = getPassword();
|
|
24
|
+
console.log(`[init] Login password: ${password} (valid today)`);
|
|
25
|
+
console.log('[init] Forgot? Run: mw password');
|
|
26
|
+
|
|
27
|
+
// Start background task runner
|
|
28
|
+
ensureRunnerStarted();
|
|
29
|
+
|
|
30
|
+
// Start Telegram bot if configured
|
|
31
|
+
startTelegramBot();
|
|
32
|
+
|
|
33
|
+
// Start terminal WebSocket server as separate process (node-pty needs native module)
|
|
34
|
+
startTerminalProcess();
|
|
35
|
+
|
|
36
|
+
// Start session watcher loop
|
|
37
|
+
startWatcherLoop();
|
|
38
|
+
|
|
39
|
+
// Auto-start tunnel if configured
|
|
40
|
+
const settings = loadSettings();
|
|
41
|
+
if (settings.tunnelAutoStart) {
|
|
42
|
+
startTunnel().then(result => {
|
|
43
|
+
if (result.url) console.log(`[init] Tunnel started: ${result.url}`);
|
|
44
|
+
else if (result.error) console.log(`[init] Tunnel failed: ${result.error}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('[init] Background services started');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Restart Telegram bot (e.g. after settings change) */
|
|
52
|
+
export function restartTelegramBot() {
|
|
53
|
+
stopTelegramBot();
|
|
54
|
+
startTelegramBot();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let terminalChild: ReturnType<typeof spawn> | null = null;
|
|
58
|
+
|
|
59
|
+
function startTerminalProcess() {
|
|
60
|
+
if (terminalChild) return;
|
|
61
|
+
|
|
62
|
+
// Check if port 3001 is already in use
|
|
63
|
+
const net = require('node:net');
|
|
64
|
+
const tester = net.createServer();
|
|
65
|
+
tester.once('error', () => {
|
|
66
|
+
// Port in use — terminal server already running
|
|
67
|
+
console.log('[terminal] Port 3001 already in use, skipping');
|
|
68
|
+
});
|
|
69
|
+
tester.once('listening', () => {
|
|
70
|
+
tester.close();
|
|
71
|
+
// Port free — start terminal server
|
|
72
|
+
const script = join(process.cwd(), 'lib', 'terminal-standalone.ts');
|
|
73
|
+
terminalChild = spawn('npx', ['tsx', script], {
|
|
74
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
75
|
+
env: { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE')) } as NodeJS.ProcessEnv,
|
|
76
|
+
detached: false,
|
|
77
|
+
});
|
|
78
|
+
terminalChild.on('exit', () => { terminalChild = null; });
|
|
79
|
+
console.log('[terminal] Started standalone server (pid:', terminalChild.pid, ')');
|
|
80
|
+
});
|
|
81
|
+
tester.listen(3001);
|
|
82
|
+
}
|
package/lib/notify.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification module — sends task updates via Telegram.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadSettings } from './settings';
|
|
6
|
+
import type { Task } from '@/src/types';
|
|
7
|
+
|
|
8
|
+
export async function notifyTaskComplete(task: Task) {
|
|
9
|
+
const settings = loadSettings();
|
|
10
|
+
if (!settings.notifyOnComplete) return;
|
|
11
|
+
|
|
12
|
+
const cost = task.costUSD != null ? `$${task.costUSD.toFixed(4)}` : 'unknown';
|
|
13
|
+
const duration = task.startedAt && task.completedAt
|
|
14
|
+
? formatDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime())
|
|
15
|
+
: 'unknown';
|
|
16
|
+
|
|
17
|
+
await sendTelegram(
|
|
18
|
+
`✅ *Task Done*\n\n` +
|
|
19
|
+
`*Project:* ${esc(task.projectName)}\n` +
|
|
20
|
+
`*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
|
|
21
|
+
`*Duration:* ${duration}\n` +
|
|
22
|
+
`*Cost:* ${cost}\n\n` +
|
|
23
|
+
`${task.resultSummary ? `*Result:*\n${esc(task.resultSummary.slice(0, 500))}` : '_No summary_'}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function notifyTaskFailed(task: Task) {
|
|
28
|
+
const settings = loadSettings();
|
|
29
|
+
if (!settings.notifyOnFailure) return;
|
|
30
|
+
|
|
31
|
+
await sendTelegram(
|
|
32
|
+
`❌ *Task Failed*\n\n` +
|
|
33
|
+
`*Project:* ${esc(task.projectName)}\n` +
|
|
34
|
+
`*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
|
|
35
|
+
`*Error:* ${esc(task.error || 'Unknown error')}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function sendTelegram(text: string) {
|
|
40
|
+
const settings = loadSettings();
|
|
41
|
+
const { telegramBotToken, telegramChatId } = settings;
|
|
42
|
+
|
|
43
|
+
if (!telegramBotToken || !telegramChatId) return;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
chat_id: telegramChatId,
|
|
52
|
+
text,
|
|
53
|
+
parse_mode: 'Markdown',
|
|
54
|
+
disable_web_page_preview: true,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
console.error('[notify] Telegram error:', res.status, await res.text());
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[notify] Telegram send failed:', err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Escape Markdown special characters
|
|
67
|
+
function esc(s: string): string {
|
|
68
|
+
return s.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatDuration(ms: number): string {
|
|
72
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
73
|
+
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
|
|
74
|
+
return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
|
|
75
|
+
}
|
package/lib/password.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated login password.
|
|
3
|
+
* Rotates daily. Saved to ~/.my-workflow/password.json with date.
|
|
4
|
+
* CLI can read it via `mw password`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const PASSWORD_FILE = join(homedir(), '.my-workflow', 'password.json');
|
|
13
|
+
|
|
14
|
+
function generatePassword(): string {
|
|
15
|
+
// 8-char alphanumeric, easy to type
|
|
16
|
+
return randomBytes(6).toString('base64url').slice(0, 8);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function todayStr(): string {
|
|
20
|
+
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PasswordData {
|
|
24
|
+
password: string;
|
|
25
|
+
date: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readPasswordData(): PasswordData | null {
|
|
29
|
+
try {
|
|
30
|
+
if (!existsSync(PASSWORD_FILE)) return null;
|
|
31
|
+
return JSON.parse(readFileSync(PASSWORD_FILE, 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function savePasswordData(data: PasswordData) {
|
|
38
|
+
const dir = dirname(PASSWORD_FILE);
|
|
39
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
40
|
+
writeFileSync(PASSWORD_FILE, JSON.stringify(data), { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the current password. Priority:
|
|
45
|
+
* 1. MW_PASSWORD env var (user explicitly set, never rotates)
|
|
46
|
+
* 2. Saved password file if still valid today
|
|
47
|
+
* 3. Generate new one, save with today's date
|
|
48
|
+
*/
|
|
49
|
+
export function getPassword(): string {
|
|
50
|
+
// If user explicitly set MW_PASSWORD, use it (no rotation)
|
|
51
|
+
if (process.env.MW_PASSWORD && process.env.MW_PASSWORD !== 'auto') {
|
|
52
|
+
return process.env.MW_PASSWORD;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const today = todayStr();
|
|
56
|
+
const saved = readPasswordData();
|
|
57
|
+
|
|
58
|
+
// Valid for today
|
|
59
|
+
if (saved && saved.date === today && saved.password) {
|
|
60
|
+
return saved.password;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Expired or missing — generate new
|
|
64
|
+
const password = generatePassword();
|
|
65
|
+
savePasswordData({ password, date: today });
|
|
66
|
+
console.log(`[password] New daily password generated for ${today}`);
|
|
67
|
+
return password;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read password from file (for CLI use) */
|
|
71
|
+
export function readPasswordFile(): string | null {
|
|
72
|
+
const data = readPasswordData();
|
|
73
|
+
if (!data) return null;
|
|
74
|
+
// Only return if still valid today
|
|
75
|
+
if (data.date !== todayStr()) return null;
|
|
76
|
+
return data.password;
|
|
77
|
+
}
|
package/lib/projects.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadSettings } from './settings';
|
|
4
|
+
|
|
5
|
+
export interface LocalProject {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
root: string; // Which project root it came from
|
|
9
|
+
hasGit: boolean;
|
|
10
|
+
hasClaudeMd: boolean;
|
|
11
|
+
language: string | null;
|
|
12
|
+
lastModified: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function scanProjects(): LocalProject[] {
|
|
16
|
+
const settings = loadSettings();
|
|
17
|
+
const roots = settings.projectRoots;
|
|
18
|
+
|
|
19
|
+
if (roots.length === 0) return [];
|
|
20
|
+
|
|
21
|
+
const projects: LocalProject[] = [];
|
|
22
|
+
|
|
23
|
+
for (const root of roots) {
|
|
24
|
+
if (!existsSync(root)) continue;
|
|
25
|
+
|
|
26
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory()) continue;
|
|
30
|
+
if (entry.name.startsWith('.')) continue;
|
|
31
|
+
|
|
32
|
+
const projectPath = join(root, entry.name);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const hasGit = existsSync(join(projectPath, '.git'));
|
|
36
|
+
const hasClaudeMd = existsSync(join(projectPath, 'CLAUDE.md'));
|
|
37
|
+
const language = detectLanguage(projectPath);
|
|
38
|
+
const stat = statSync(projectPath);
|
|
39
|
+
|
|
40
|
+
projects.push({
|
|
41
|
+
name: entry.name,
|
|
42
|
+
path: projectPath,
|
|
43
|
+
root,
|
|
44
|
+
hasGit,
|
|
45
|
+
hasClaudeMd,
|
|
46
|
+
language,
|
|
47
|
+
lastModified: stat.mtime.toISOString(),
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
// Skip inaccessible directories
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function detectLanguage(projectPath: string): string | null {
|
|
59
|
+
const markers: [string, string][] = [
|
|
60
|
+
['pom.xml', 'java'],
|
|
61
|
+
['build.gradle', 'java'],
|
|
62
|
+
['build.gradle.kts', 'kotlin'],
|
|
63
|
+
['package.json', 'typescript'],
|
|
64
|
+
['tsconfig.json', 'typescript'],
|
|
65
|
+
['requirements.txt', 'python'],
|
|
66
|
+
['pyproject.toml', 'python'],
|
|
67
|
+
['go.mod', 'go'],
|
|
68
|
+
['Cargo.toml', 'rust'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const [file, lang] of markers) {
|
|
72
|
+
if (existsSync(join(projectPath, file))) return lang;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getProjectInfo(name: string): LocalProject | null {
|
|
78
|
+
const projects = scanProjects();
|
|
79
|
+
return projects.find(p => p.name === name) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getProjectClaudeMd(projectPath: string): string | null {
|
|
83
|
+
const claudeMdPath = join(projectPath, 'CLAUDE.md');
|
|
84
|
+
if (!existsSync(claudeMdPath)) return null;
|
|
85
|
+
return readFileSync(claudeMdPath, 'utf-8');
|
|
86
|
+
}
|