@aion0/forge 0.2.2 → 0.2.4

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,514 @@
1
+ /**
2
+ * Pipeline Engine — DAG-based workflow orchestration on top of the Task system.
3
+ *
4
+ * Workflow YAML → Pipeline instance → Nodes executed as Tasks
5
+ * Supports: dependencies, output passing, conditional routing, parallel execution, notifications.
6
+ */
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import YAML from 'yaml';
13
+ import { createTask, getTask, onTaskEvent } from './task-manager';
14
+ import { getProjectInfo } from './projects';
15
+ import { loadSettings } from './settings';
16
+ import type { Task } from '@/src/types';
17
+
18
+ const PIPELINES_DIR = join(homedir(), '.forge', 'pipelines');
19
+ const WORKFLOWS_DIR = join(homedir(), '.forge', 'flows');
20
+
21
+ // ─── Types ────────────────────────────────────────────────
22
+
23
+ export interface WorkflowNode {
24
+ id: string;
25
+ project: string;
26
+ prompt: string;
27
+ dependsOn: string[];
28
+ outputs: { name: string; extract: 'result' | 'git_diff' }[];
29
+ routes: { condition: string; next: string }[];
30
+ maxIterations: number;
31
+ }
32
+
33
+ export interface Workflow {
34
+ name: string;
35
+ description?: string;
36
+ vars: Record<string, string>;
37
+ input: Record<string, string>; // required input fields
38
+ nodes: Record<string, WorkflowNode>;
39
+ }
40
+
41
+ export type PipelineNodeStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
42
+
43
+ export interface PipelineNodeState {
44
+ status: PipelineNodeStatus;
45
+ taskId?: string;
46
+ outputs: Record<string, string>;
47
+ iterations: number;
48
+ startedAt?: string;
49
+ completedAt?: string;
50
+ error?: string;
51
+ }
52
+
53
+ export interface Pipeline {
54
+ id: string;
55
+ workflowName: string;
56
+ status: 'running' | 'done' | 'failed' | 'cancelled';
57
+ input: Record<string, string>;
58
+ vars: Record<string, string>;
59
+ nodes: Record<string, PipelineNodeState>;
60
+ nodeOrder: string[]; // for UI display
61
+ createdAt: string;
62
+ completedAt?: string;
63
+ }
64
+
65
+ // ─── Workflow Loading ─────────────────────────────────────
66
+
67
+ export function listWorkflows(): Workflow[] {
68
+ if (!existsSync(WORKFLOWS_DIR)) return [];
69
+ return readdirSync(WORKFLOWS_DIR)
70
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
71
+ .map(f => {
72
+ try {
73
+ return parseWorkflow(readFileSync(join(WORKFLOWS_DIR, f), 'utf-8'));
74
+ } catch {
75
+ return null;
76
+ }
77
+ })
78
+ .filter(Boolean) as Workflow[];
79
+ }
80
+
81
+ export function getWorkflow(name: string): Workflow | null {
82
+ return listWorkflows().find(w => w.name === name) || null;
83
+ }
84
+
85
+ function parseWorkflow(raw: string): Workflow {
86
+ const parsed = YAML.parse(raw);
87
+ const nodes: Record<string, WorkflowNode> = {};
88
+
89
+ for (const [id, def] of Object.entries(parsed.nodes || {})) {
90
+ const n = def as any;
91
+ nodes[id] = {
92
+ id,
93
+ project: n.project || '',
94
+ prompt: n.prompt || '',
95
+ dependsOn: n.depends_on || n.dependsOn || [],
96
+ outputs: (n.outputs || []).map((o: any) => ({
97
+ name: o.name,
98
+ extract: o.extract || 'result',
99
+ })),
100
+ routes: (n.routes || []).map((r: any) => ({
101
+ condition: r.condition || 'default',
102
+ next: r.next,
103
+ })),
104
+ maxIterations: n.max_iterations || n.maxIterations || 3,
105
+ };
106
+ }
107
+
108
+ return {
109
+ name: parsed.name || 'unnamed',
110
+ description: parsed.description,
111
+ vars: parsed.vars || {},
112
+ input: parsed.input || {},
113
+ nodes,
114
+ };
115
+ }
116
+
117
+ // ─── Pipeline Persistence ─────────────────────────────────
118
+
119
+ function ensureDir() {
120
+ if (!existsSync(PIPELINES_DIR)) mkdirSync(PIPELINES_DIR, { recursive: true });
121
+ }
122
+
123
+ function savePipeline(pipeline: Pipeline) {
124
+ ensureDir();
125
+ writeFileSync(join(PIPELINES_DIR, `${pipeline.id}.json`), JSON.stringify(pipeline, null, 2));
126
+ }
127
+
128
+ export function getPipeline(id: string): Pipeline | null {
129
+ try {
130
+ return JSON.parse(readFileSync(join(PIPELINES_DIR, `${id}.json`), 'utf-8'));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ export function deletePipeline(id: string): boolean {
137
+ const filePath = join(PIPELINES_DIR, `${id}.json`);
138
+ try {
139
+ if (existsSync(filePath)) {
140
+ const { unlinkSync } = require('node:fs');
141
+ unlinkSync(filePath);
142
+ return true;
143
+ }
144
+ } catch {}
145
+ return false;
146
+ }
147
+
148
+ export function listPipelines(): Pipeline[] {
149
+ ensureDir();
150
+ return readdirSync(PIPELINES_DIR)
151
+ .filter(f => f.endsWith('.json'))
152
+ .map(f => {
153
+ try {
154
+ return JSON.parse(readFileSync(join(PIPELINES_DIR, f), 'utf-8')) as Pipeline;
155
+ } catch {
156
+ return null;
157
+ }
158
+ })
159
+ .filter(Boolean) as Pipeline[];
160
+ }
161
+
162
+ // ─── Template Resolution ──────────────────────────────────
163
+
164
+ function resolveTemplate(template: string, ctx: {
165
+ input: Record<string, string>;
166
+ vars: Record<string, string>;
167
+ nodes: Record<string, PipelineNodeState>;
168
+ }): string {
169
+ return template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
170
+ const path = expr.trim();
171
+
172
+ // {{input.xxx}}
173
+ if (path.startsWith('input.')) return ctx.input[path.slice(6)] || '';
174
+
175
+ // {{vars.xxx}}
176
+ if (path.startsWith('vars.')) return ctx.vars[path.slice(5)] || '';
177
+
178
+ // {{nodes.xxx.outputs.yyy}}
179
+ const nodeMatch = path.match(/^nodes\.(\w+)\.outputs\.(\w+)$/);
180
+ if (nodeMatch) {
181
+ const [, nodeId, outputName] = nodeMatch;
182
+ return ctx.nodes[nodeId]?.outputs[outputName] || '';
183
+ }
184
+
185
+ return `{{${path}}}`;
186
+ });
187
+ }
188
+
189
+ // ─── Pipeline Execution ───────────────────────────────────
190
+
191
+ export function startPipeline(workflowName: string, input: Record<string, string>): Pipeline {
192
+ const workflow = getWorkflow(workflowName);
193
+ if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
194
+
195
+ const id = randomUUID().slice(0, 8);
196
+ const nodes: Record<string, PipelineNodeState> = {};
197
+ const nodeOrder = topologicalSort(workflow.nodes);
198
+
199
+ for (const nodeId of nodeOrder) {
200
+ nodes[nodeId] = {
201
+ status: 'pending',
202
+ outputs: {},
203
+ iterations: 0,
204
+ };
205
+ }
206
+
207
+ const pipeline: Pipeline = {
208
+ id,
209
+ workflowName,
210
+ status: 'running',
211
+ input,
212
+ vars: { ...workflow.vars },
213
+ nodes,
214
+ nodeOrder,
215
+ createdAt: new Date().toISOString(),
216
+ };
217
+
218
+ savePipeline(pipeline);
219
+
220
+ // Start nodes that have no dependencies
221
+ scheduleReadyNodes(pipeline, workflow);
222
+
223
+ // Listen for task completions
224
+ setupTaskListener(pipeline.id);
225
+
226
+ return pipeline;
227
+ }
228
+
229
+ export function cancelPipeline(id: string): boolean {
230
+ const pipeline = getPipeline(id);
231
+ if (!pipeline || pipeline.status !== 'running') return false;
232
+
233
+ pipeline.status = 'cancelled';
234
+ pipeline.completedAt = new Date().toISOString();
235
+
236
+ // Cancel all running tasks
237
+ for (const [, node] of Object.entries(pipeline.nodes)) {
238
+ if (node.status === 'running' && node.taskId) {
239
+ const { cancelTask } = require('./task-manager');
240
+ cancelTask(node.taskId);
241
+ }
242
+ if (node.status === 'pending') node.status = 'skipped';
243
+ }
244
+
245
+ savePipeline(pipeline);
246
+ return true;
247
+ }
248
+
249
+ // ─── Node Scheduling ──────────────────────────────────────
250
+
251
+ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
252
+ const ctx = { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes };
253
+
254
+ for (const nodeId of pipeline.nodeOrder) {
255
+ const nodeState = pipeline.nodes[nodeId];
256
+ if (nodeState.status !== 'pending') continue;
257
+
258
+ const nodeDef = workflow.nodes[nodeId];
259
+ if (!nodeDef) continue;
260
+
261
+ // Check all dependencies are done
262
+ const depsReady = nodeDef.dependsOn.every(dep => {
263
+ const depState = pipeline.nodes[dep];
264
+ return depState && depState.status === 'done';
265
+ });
266
+
267
+ // Check if any dependency failed (skip this node)
268
+ const depsFailed = nodeDef.dependsOn.some(dep => {
269
+ const depState = pipeline.nodes[dep];
270
+ return depState && (depState.status === 'failed' || depState.status === 'skipped');
271
+ });
272
+
273
+ if (depsFailed) {
274
+ nodeState.status = 'skipped';
275
+ savePipeline(pipeline);
276
+ continue;
277
+ }
278
+
279
+ if (!depsReady) continue;
280
+
281
+ // Resolve templates
282
+ const project = resolveTemplate(nodeDef.project, ctx);
283
+ const prompt = resolveTemplate(nodeDef.prompt, ctx);
284
+
285
+ const projectInfo = getProjectInfo(project);
286
+ if (!projectInfo) {
287
+ nodeState.status = 'failed';
288
+ nodeState.error = `Project not found: ${project}`;
289
+ savePipeline(pipeline);
290
+ notifyStep(pipeline, nodeId, 'failed', nodeState.error);
291
+ continue;
292
+ }
293
+
294
+ // Create task
295
+ const task = createTask({
296
+ projectName: projectInfo.name,
297
+ projectPath: projectInfo.path,
298
+ prompt,
299
+ });
300
+
301
+ nodeState.status = 'running';
302
+ nodeState.taskId = task.id;
303
+ nodeState.iterations++;
304
+ nodeState.startedAt = new Date().toISOString();
305
+ savePipeline(pipeline);
306
+
307
+ notifyStep(pipeline, nodeId, 'running');
308
+ }
309
+
310
+ // Check if pipeline is complete
311
+ checkPipelineCompletion(pipeline);
312
+ }
313
+
314
+ function checkPipelineCompletion(pipeline: Pipeline) {
315
+ const states = Object.values(pipeline.nodes);
316
+ const allDone = states.every(s => s.status === 'done' || s.status === 'skipped' || s.status === 'failed');
317
+
318
+ if (allDone && pipeline.status === 'running') {
319
+ const anyFailed = states.some(s => s.status === 'failed');
320
+ pipeline.status = anyFailed ? 'failed' : 'done';
321
+ pipeline.completedAt = new Date().toISOString();
322
+ savePipeline(pipeline);
323
+ notifyPipelineComplete(pipeline);
324
+ }
325
+ }
326
+
327
+ // ─── Task Event Listener ──────────────────────────────────
328
+
329
+ const activeListeners = new Set<string>();
330
+
331
+ function setupTaskListener(pipelineId: string) {
332
+ if (activeListeners.has(pipelineId)) return;
333
+ activeListeners.add(pipelineId);
334
+
335
+ const cleanup = onTaskEvent((taskId, event, data) => {
336
+ if (event !== 'status') return;
337
+ if (data !== 'done' && data !== 'failed') return;
338
+
339
+ const pipeline = getPipeline(pipelineId);
340
+ if (!pipeline || pipeline.status !== 'running') {
341
+ cleanup();
342
+ activeListeners.delete(pipelineId);
343
+ return;
344
+ }
345
+
346
+ // Find the node for this task
347
+ const nodeEntry = Object.entries(pipeline.nodes).find(([, n]) => n.taskId === taskId);
348
+ if (!nodeEntry) return;
349
+
350
+ const [nodeId, nodeState] = nodeEntry;
351
+ const workflow = getWorkflow(pipeline.workflowName);
352
+ if (!workflow) return;
353
+
354
+ const nodeDef = workflow.nodes[nodeId];
355
+ const task = getTask(taskId);
356
+
357
+ if (data === 'done' && task) {
358
+ // Extract outputs
359
+ for (const outputDef of nodeDef.outputs) {
360
+ if (outputDef.extract === 'result') {
361
+ nodeState.outputs[outputDef.name] = task.resultSummary || '';
362
+ } else if (outputDef.extract === 'git_diff') {
363
+ nodeState.outputs[outputDef.name] = task.gitDiff || '';
364
+ }
365
+ }
366
+
367
+ // Check routes for conditional next step
368
+ if (nodeDef.routes.length > 0) {
369
+ const nextNode = evaluateRoutes(nodeDef.routes, nodeState.outputs, pipeline);
370
+ if (nextNode && nextNode !== nodeId) {
371
+ // Route to next node — mark this as done
372
+ nodeState.status = 'done';
373
+ nodeState.completedAt = new Date().toISOString();
374
+ // Reset next node to pending so it gets scheduled
375
+ if (pipeline.nodes[nextNode] && pipeline.nodes[nextNode].status !== 'done') {
376
+ pipeline.nodes[nextNode].status = 'pending';
377
+ }
378
+ } else if (nextNode === nodeId) {
379
+ // Loop back — check iteration limit
380
+ if (nodeState.iterations < nodeDef.maxIterations) {
381
+ nodeState.status = 'pending';
382
+ nodeState.taskId = undefined;
383
+ } else {
384
+ nodeState.status = 'done';
385
+ nodeState.completedAt = new Date().toISOString();
386
+ }
387
+ } else {
388
+ nodeState.status = 'done';
389
+ nodeState.completedAt = new Date().toISOString();
390
+ }
391
+ } else {
392
+ nodeState.status = 'done';
393
+ nodeState.completedAt = new Date().toISOString();
394
+ }
395
+
396
+ savePipeline(pipeline);
397
+ notifyStep(pipeline, nodeId, 'done');
398
+ } else if (data === 'failed') {
399
+ nodeState.status = 'failed';
400
+ nodeState.error = task?.error || 'Task failed';
401
+ nodeState.completedAt = new Date().toISOString();
402
+ savePipeline(pipeline);
403
+ notifyStep(pipeline, nodeId, 'failed', nodeState.error);
404
+ }
405
+
406
+ // Schedule next ready nodes
407
+ scheduleReadyNodes(pipeline, workflow);
408
+ });
409
+ }
410
+
411
+ function evaluateRoutes(
412
+ routes: { condition: string; next: string }[],
413
+ outputs: Record<string, string>,
414
+ pipeline: Pipeline
415
+ ): string | null {
416
+ for (const route of routes) {
417
+ if (route.condition === 'default') return route.next;
418
+
419
+ // Simple "contains" check: {{outputs.xxx contains 'YYY'}}
420
+ const containsMatch = route.condition.match(/\{\{outputs\.(\w+)\s+contains\s+'([^']+)'\}\}/);
421
+ if (containsMatch) {
422
+ const [, outputName, keyword] = containsMatch;
423
+ if (outputs[outputName]?.includes(keyword)) return route.next;
424
+ continue;
425
+ }
426
+
427
+ // Default: treat as truthy check
428
+ return route.next;
429
+ }
430
+ return null;
431
+ }
432
+
433
+ // ─── Topological Sort ─────────────────────────────────────
434
+
435
+ function topologicalSort(nodes: Record<string, WorkflowNode>): string[] {
436
+ const sorted: string[] = [];
437
+ const visited = new Set<string>();
438
+ const visiting = new Set<string>();
439
+
440
+ function visit(id: string) {
441
+ if (visited.has(id)) return;
442
+ if (visiting.has(id)) return; // cycle — skip
443
+ visiting.add(id);
444
+
445
+ const node = nodes[id];
446
+ if (node) {
447
+ for (const dep of node.dependsOn) {
448
+ visit(dep);
449
+ }
450
+ // Also add route targets
451
+ for (const route of node.routes) {
452
+ if (nodes[route.next] && !node.dependsOn.includes(route.next)) {
453
+ // Don't visit route targets in topo sort to avoid cycles
454
+ }
455
+ }
456
+ }
457
+
458
+ visiting.delete(id);
459
+ visited.add(id);
460
+ sorted.push(id);
461
+ }
462
+
463
+ for (const id of Object.keys(nodes)) {
464
+ visit(id);
465
+ }
466
+
467
+ return sorted;
468
+ }
469
+
470
+ // ─── Notifications ────────────────────────────────────────
471
+
472
+ async function notifyStep(pipeline: Pipeline, nodeId: string, status: string, error?: string) {
473
+ const settings = loadSettings();
474
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
475
+
476
+ const icon = status === 'done' ? '✅' : status === 'failed' ? '❌' : status === 'running' ? '🔄' : '⏳';
477
+ const msg = `${icon} Pipeline ${pipeline.id}/${nodeId}: ${status}${error ? `\n${error}` : ''}`;
478
+
479
+ try {
480
+ await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`, {
481
+ method: 'POST',
482
+ headers: { 'Content-Type': 'application/json' },
483
+ body: JSON.stringify({
484
+ chat_id: settings.telegramChatId.split(',')[0].trim(),
485
+ text: msg,
486
+ disable_web_page_preview: true,
487
+ }),
488
+ });
489
+ } catch {}
490
+ }
491
+
492
+ async function notifyPipelineComplete(pipeline: Pipeline) {
493
+ const settings = loadSettings();
494
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
495
+
496
+ const icon = pipeline.status === 'done' ? '🎉' : '💥';
497
+ const nodes = Object.entries(pipeline.nodes)
498
+ .map(([id, n]) => ` ${n.status === 'done' ? '✅' : n.status === 'failed' ? '❌' : '⏭'} ${id}`)
499
+ .join('\n');
500
+
501
+ const msg = `${icon} Pipeline ${pipeline.id} (${pipeline.workflowName}) ${pipeline.status}\n\n${nodes}`;
502
+
503
+ try {
504
+ await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`, {
505
+ method: 'POST',
506
+ headers: { 'Content-Type': 'application/json' },
507
+ body: JSON.stringify({
508
+ chat_id: settings.telegramChatId.split(',')[0].trim(),
509
+ text: msg,
510
+ disable_web_page_preview: true,
511
+ }),
512
+ });
513
+ } catch {}
514
+ }
package/lib/settings.ts CHANGED
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join, dirname } from 'node:path';
4
4
  import YAML from 'yaml';
5
5
 
6
- const SETTINGS_FILE = join(homedir(), '.forge', 'settings.yaml');
6
+ const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
7
+ const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
7
8
 
8
9
  export interface Settings {
9
10
  projectRoots: string[]; // Multiple project directories
@@ -16,13 +16,11 @@ import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
16
16
  import { getPassword } from './password';
17
17
  import type { Task, TaskLogEntry } from '@/src/types';
18
18
 
19
- let polling = false;
20
- let pollTimer: ReturnType<typeof setTimeout> | null = null;
21
- let lastUpdateId = 0;
22
-
23
- // Prevent duplicate polling across hot-reloads
24
- const globalKey = Symbol.for('mw-telegram-polling');
19
+ // Prevent duplicate polling and state loss across hot-reloads
20
+ const globalKey = Symbol.for('mw-telegram-state');
25
21
  const g = globalThis as any;
22
+ if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0 };
23
+ const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number } = g[globalKey];
26
24
 
27
25
  // Track which Telegram message maps to which task (for reply-based interaction)
28
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -48,12 +46,11 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
48
46
  // ─── Start/Stop ──────────────────────────────────────────────
49
47
 
50
48
  export function startTelegramBot() {
51
- if (polling || g[globalKey]) return;
49
+ if (botState.polling) return;
52
50
  const settings = loadSettings();
53
51
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
54
52
 
55
- polling = true;
56
- g[globalKey] = true;
53
+ botState.polling = true;
57
54
  console.log('[telegram] Bot started');
58
55
 
59
56
  // Set bot command menu
@@ -76,39 +73,37 @@ export function startTelegramBot() {
76
73
  }
77
74
 
78
75
  export function stopTelegramBot() {
79
- polling = false;
80
- g[globalKey] = false;
81
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
76
+ botState.polling = false;
77
+ if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
82
78
  }
83
79
 
84
80
  // ─── Polling ─────────────────────────────────────────────────
85
81
 
86
82
  async function poll() {
87
- if (!polling) return;
83
+ if (!botState.polling) return;
88
84
 
89
85
  try {
90
86
  const settings = loadSettings();
91
- const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${lastUpdateId + 1}&timeout=30`;
87
+ const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${botState.lastUpdateId + 1}&timeout=30`;
92
88
  const res = await fetch(url);
93
89
  const data = await res.json();
94
90
 
95
91
  if (data.ok && data.result) {
96
92
  for (const update of data.result) {
97
- lastUpdateId = update.update_id;
93
+ botState.lastUpdateId = update.update_id;
98
94
  if (update.message?.text) {
99
95
  await handleMessage(update.message);
100
96
  }
101
97
  }
102
98
  }
103
99
  } catch (err: any) {
104
- // Network errors (ECONNRESET, fetch failed) are normal during sleep/wake — silent retry
105
100
  const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
106
101
  if (!isNetworkError) {
107
102
  console.error('[telegram] Poll error:', err);
108
103
  }
109
104
  }
110
105
 
111
- pollTimer = setTimeout(poll, 1000);
106
+ botState.pollTimer = setTimeout(poll, 1000);
112
107
  }
113
108
 
114
109
  // ─── Message Handler ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -34,6 +34,7 @@
34
34
  "@auth/core": "^0.34.3",
35
35
  "@xterm/addon-fit": "^0.11.0",
36
36
  "@xterm/xterm": "^6.0.0",
37
+ "@xyflow/react": "^12.10.1",
37
38
  "ai": "^6.0.116",
38
39
  "better-sqlite3": "^12.6.2",
39
40
  "next": "^16.1.6",
package/publish.sh ADDED
@@ -0,0 +1,45 @@
1
+ #!/bin/bash
2
+ # publish.sh — Bump version, commit, and publish to npm
3
+ #
4
+ # Usage:
5
+ # ./publish.sh # patch bump (0.2.3 → 0.2.4)
6
+ # ./publish.sh minor # minor bump (0.2.3 → 0.3.0)
7
+ # ./publish.sh major # major bump (0.2.3 → 1.0.0)
8
+ # ./publish.sh 0.5.0 # explicit version
9
+
10
+ set -e
11
+
12
+ VERSION_ARG=${1:-patch}
13
+ CURRENT=$(node -p "require('./package.json').version")
14
+
15
+ # Calculate new version
16
+ if [[ "$VERSION_ARG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
17
+ NEW_VERSION=$VERSION_ARG
18
+ elif [ "$VERSION_ARG" = "patch" ]; then
19
+ IFS='.' read -r major minor patch <<< "$CURRENT"
20
+ NEW_VERSION="$major.$minor.$((patch + 1))"
21
+ elif [ "$VERSION_ARG" = "minor" ]; then
22
+ IFS='.' read -r major minor patch <<< "$CURRENT"
23
+ NEW_VERSION="$major.$((minor + 1)).0"
24
+ elif [ "$VERSION_ARG" = "major" ]; then
25
+ IFS='.' read -r major minor patch <<< "$CURRENT"
26
+ NEW_VERSION="$((major + 1)).0.0"
27
+ else
28
+ echo "Usage: ./publish.sh [patch|minor|major|x.y.z]"
29
+ exit 1
30
+ fi
31
+
32
+ echo "Version: $CURRENT → $NEW_VERSION"
33
+ echo ""
34
+
35
+ # Update package.json
36
+ sed -i '' "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW_VERSION\"/" package.json
37
+
38
+ # Commit
39
+ git add -A
40
+ git commit -m "v$NEW_VERSION"
41
+ git tag "v$NEW_VERSION"
42
+
43
+ echo ""
44
+ echo "Ready to publish @aion0/forge@$NEW_VERSION"
45
+ echo "Run: npm login && npm publish --access public --otp=<code>"