upfynai-code 2.9.1 → 2.9.2
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/README.md +91 -66
- package/client/dist/api-docs.html +838 -0
- package/client/dist/assets/AppContent-BXZDeSIC.js +545 -0
- package/client/dist/assets/CanvasFullScreen-mnpCnLZ9.js +1 -0
- package/client/dist/assets/CanvasWorkspace-4CqmjAVQ.js +163 -0
- package/client/dist/assets/DashboardPanel-zFIFlw56.js +1 -0
- package/client/dist/assets/FileTree-B0c_GaB3.js +1 -0
- package/client/dist/assets/GitPanel-DUP4zVU4.js +2 -0
- package/client/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/client/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/client/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/client/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/client/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/client/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/client/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/client/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/client/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/client/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/client/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/client/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/client/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/client/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/client/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/client/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/client/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/client/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/client/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/client/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/client/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/client/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/client/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/client/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/client/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/client/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/client/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/client/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/client/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/client/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/client/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/client/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/client/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/client/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/client/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/client/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/client/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/client/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/client/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/client/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/client/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/client/dist/assets/LoginModal-BRycfsyD.js +13 -0
- package/client/dist/assets/MarkdownPreview-DHmk3qzu.js +1 -0
- package/client/dist/assets/MermaidBlock-BuBc_G-F.js +2 -0
- package/client/dist/assets/Onboarding-BcnaZZ0o.js +1 -0
- package/client/dist/assets/PreviewPanel-CqCa92Tf.js +32 -0
- package/client/dist/assets/SetupForm-S0g6u5yT.js +1 -0
- package/client/dist/assets/WorkflowsPanel-CouH9JDO.js +1 -0
- package/client/dist/assets/index-BFuqS0tY.css +1 -0
- package/client/dist/assets/index-CNDcVl2g.js +68 -0
- package/client/dist/assets/pdf-CE_K4jFx.js +12 -0
- package/client/dist/assets/vendor-canvas-BZV40eAE.css +1 -0
- package/client/dist/assets/vendor-canvas-D39yWul6.js +49 -0
- package/client/dist/assets/vendor-codemirror-CbtmxxaB.js +35 -0
- package/client/dist/assets/vendor-diff-DNQpbhrT.js +69 -0
- package/client/dist/assets/vendor-i18n-DCFGyhQR.js +1 -0
- package/client/dist/assets/vendor-icons-BaD0x9SL.js +711 -0
- package/client/dist/assets/vendor-markdown-CimbIo6Y.js +296 -0
- package/client/dist/assets/vendor-mermaid-CH7SGc99.js +2556 -0
- package/client/dist/assets/vendor-react-96lCPsRK.js +67 -0
- package/client/dist/assets/vendor-syntax-DuHI9Ok6.js +16 -0
- package/client/dist/assets/vendor-xterm-CZq1hqo1.js +66 -0
- package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +32 -0
- package/client/dist/clear-cache.html +85 -0
- package/client/dist/convert-icons.md +53 -0
- package/client/dist/favicon.png +0 -0
- package/client/dist/favicon.svg +5 -0
- package/client/dist/generate-icons.js +49 -0
- package/client/dist/icons/claude-ai-icon.svg +1 -0
- package/client/dist/icons/codex-white.svg +3 -0
- package/client/dist/icons/codex.svg +3 -0
- package/client/dist/icons/cursor-white.svg +12 -0
- package/client/dist/icons/cursor.svg +1 -0
- package/client/dist/icons/icon-128x128.png +0 -0
- package/client/dist/icons/icon-128x128.svg +5 -0
- package/client/dist/icons/icon-144x144.png +0 -0
- package/client/dist/icons/icon-144x144.svg +5 -0
- package/client/dist/icons/icon-152x152.png +0 -0
- package/client/dist/icons/icon-152x152.svg +5 -0
- package/client/dist/icons/icon-192x192.png +0 -0
- package/client/dist/icons/icon-192x192.svg +5 -0
- package/client/dist/icons/icon-384x384.png +0 -0
- package/client/dist/icons/icon-384x384.svg +5 -0
- package/client/dist/icons/icon-512x512.png +0 -0
- package/client/dist/icons/icon-512x512.svg +5 -0
- package/client/dist/icons/icon-72x72.png +0 -0
- package/client/dist/icons/icon-72x72.svg +5 -0
- package/client/dist/icons/icon-96x96.png +0 -0
- package/client/dist/icons/icon-96x96.svg +5 -0
- package/client/dist/icons/icon-template.svg +5 -0
- package/client/dist/index.html +119 -0
- package/client/dist/logo-128.png +0 -0
- package/client/dist/logo-256.png +0 -0
- package/client/dist/logo-32.png +0 -0
- package/client/dist/logo-512.png +0 -0
- package/client/dist/logo-64.png +0 -0
- package/client/dist/logo.svg +14 -0
- package/client/dist/manifest.json +61 -0
- package/client/dist/mcp-docs.html +108 -0
- package/client/dist/offline.html +84 -0
- package/client/dist/screenshots/cli-selection.png +0 -0
- package/client/dist/screenshots/desktop-main.png +0 -0
- package/client/dist/screenshots/mobile-chat.png +0 -0
- package/client/dist/screenshots/tools-modal.png +0 -0
- package/client/dist/sw.js +82 -0
- package/commands/upfynai-connect.md +59 -0
- package/commands/upfynai-disconnect.md +31 -0
- package/commands/upfynai-doctor.md +99 -0
- package/commands/upfynai-export.md +49 -0
- package/commands/upfynai-local.md +82 -0
- package/commands/upfynai-status.md +75 -0
- package/commands/upfynai-stop.md +49 -0
- package/commands/upfynai-uninstall.md +58 -0
- package/commands/upfynai.md +69 -0
- package/package.json +143 -82
- package/scripts/build-client.js +17 -0
- package/scripts/fix-node-pty.js +67 -0
- package/scripts/install-commands.js +78 -0
- package/server/agent-loop.js +242 -0
- package/server/auto-compact.js +99 -0
- package/server/claude-sdk.js +797 -0
- package/server/cli-ui.js +785 -0
- package/server/cli.js +596 -0
- package/server/constants/config.js +31 -0
- package/server/cursor-cli.js +270 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +1391 -0
- package/server/database/init.sql +70 -0
- package/server/index.js +3799 -0
- package/server/load-env.js +26 -0
- package/server/mcp-server.js +621 -0
- package/server/middleware/auth.js +176 -0
- package/server/middleware/relayHelpers.js +44 -0
- package/server/middleware/sandboxRouter.js +174 -0
- package/server/openai-codex.js +403 -0
- package/server/openrouter.js +137 -0
- package/server/projects.js +1807 -0
- package/server/provider-factory.js +174 -0
- package/server/relay-client.js +379 -0
- package/server/routes/agent.js +1226 -0
- package/server/routes/auth.js +554 -0
- package/server/routes/canvas.js +53 -0
- package/server/routes/cli-auth.js +263 -0
- package/server/routes/codex.js +396 -0
- package/server/routes/commands.js +707 -0
- package/server/routes/composio.js +176 -0
- package/server/routes/cursor.js +770 -0
- package/server/routes/dashboard.js +295 -0
- package/server/routes/git.js +1208 -0
- package/server/routes/keys.js +34 -0
- package/server/routes/mcp-utils.js +48 -0
- package/server/routes/mcp.js +661 -0
- package/server/routes/payments.js +227 -0
- package/server/routes/projects.js +655 -0
- package/server/routes/sessions.js +146 -0
- package/server/routes/settings.js +261 -0
- package/server/routes/taskmaster.js +1928 -0
- package/server/routes/user.js +106 -0
- package/server/routes/vapi-chat.js +624 -0
- package/server/routes/voice.js +235 -0
- package/server/routes/webhooks.js +166 -0
- package/server/routes/workflows.js +312 -0
- package/server/sandbox.js +120 -0
- package/server/services/composio.js +204 -0
- package/server/services/sessionRegistry.js +139 -0
- package/server/services/whisperService.js +84 -0
- package/server/services/workflowScheduler.js +206 -0
- package/server/tests/relay-flow.test.js +570 -0
- package/server/tests/sessions.test.js +259 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/email.js +61 -0
- package/server/utils/gitConfig.js +24 -0
- package/server/utils/mcp-detector.js +198 -0
- package/server/utils/taskmaster-websocket.js +129 -0
- package/shared/integrationCatalog.d.ts +12 -0
- package/shared/integrationCatalog.js +172 -0
- package/shared/modelConstants.js +96 -0
- package/bin/cli.js +0 -97
- package/dist/agents/claude.js +0 -229
- package/dist/agents/codex.js +0 -48
- package/dist/agents/cursor.js +0 -48
- package/dist/agents/detect.js +0 -51
- package/dist/agents/exec.js +0 -31
- package/dist/agents/files.js +0 -105
- package/dist/agents/git.js +0 -18
- package/dist/agents/gitagent.js +0 -67
- package/dist/agents/index.js +0 -88
- package/dist/agents/shell.js +0 -38
- package/dist/agents/utils.js +0 -136
- package/scripts/postinstall.js +0 -9
- package/scripts/prepublish.js +0 -58
- package/src/animation.js +0 -228
- package/src/auth.js +0 -122
- package/src/config.js +0 -40
- package/src/connect.js +0 -416
- package/src/launch.js +0 -78
- package/src/mcp.js +0 -57
- package/src/permissions.js +0 -140
- package/src/persistent-shell.js +0 -261
- package/src/server.js +0 -54
- /package/{dist → shared}/gitagent/index.js +0 -0
- /package/{dist → shared}/gitagent/parser.js +0 -0
- /package/{dist → shared}/gitagent/prompt-builder.js +0 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { workflowDb, webhookDb, credentialsDb } from '../database/db.js';
|
|
3
|
+
import { refreshWorkflowSchedule, stopWorkflowSchedule, executeWorkflow } from '../services/workflowScheduler.js';
|
|
4
|
+
import { INTEGRATION_CATALOG } from '../../shared/integrationCatalog.js';
|
|
5
|
+
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
// ── BYOK helpers ────────────────────────────────────────────────────
|
|
9
|
+
async function getUserProviderKey(userId, providerType) {
|
|
10
|
+
if (!userId) return null;
|
|
11
|
+
try {
|
|
12
|
+
const creds = await credentialsDb.getCredentials(userId, providerType);
|
|
13
|
+
const active = creds.find(c => c.is_active);
|
|
14
|
+
return active?.credential_value || null;
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── AI workflow generation prompt ───────────────────────────────────
|
|
19
|
+
function buildWorkflowGenerationPrompt(description, availableWebhooks, connectedIntegrations) {
|
|
20
|
+
const integrationsList = INTEGRATION_CATALOG.map(i => {
|
|
21
|
+
const connected = connectedIntegrations.includes(i.id);
|
|
22
|
+
const actions = i.popularActions.map(a => ` - ${a.slug}: ${a.label} (params: ${a.params.join(', ')})`).join('\n');
|
|
23
|
+
return `${i.name} (${i.id})${connected ? ' [CONNECTED]' : ''}:\n${actions}`;
|
|
24
|
+
}).join('\n\n');
|
|
25
|
+
|
|
26
|
+
const webhooksList = availableWebhooks.length
|
|
27
|
+
? availableWebhooks.map(w => `- ID ${w.id}: "${w.name}" (${w.method} ${w.url})`).join('\n')
|
|
28
|
+
: 'No webhooks configured.';
|
|
29
|
+
|
|
30
|
+
return `You are a workflow automation builder. Given a natural language description, generate a workflow JSON.
|
|
31
|
+
|
|
32
|
+
AVAILABLE STEP TYPES:
|
|
33
|
+
1. "ai-prompt" — Run an AI prompt. Config: { prompt: string }
|
|
34
|
+
2. "webhook" — Call an HTTP endpoint. Config: { webhookId: string, payloadTemplate?: string }
|
|
35
|
+
3. "delay" — Wait N seconds (max 30). Config: { seconds: number }
|
|
36
|
+
4. "condition" — Branch on expression. Config: { expression: string }
|
|
37
|
+
5. "integration" — Use a connected app via Composio. Config: { integrationId: string, toolSlug: string, arguments: { param: value } }
|
|
38
|
+
|
|
39
|
+
AVAILABLE INTEGRATIONS:
|
|
40
|
+
${integrationsList}
|
|
41
|
+
|
|
42
|
+
AVAILABLE WEBHOOKS:
|
|
43
|
+
${webhooksList}
|
|
44
|
+
|
|
45
|
+
RULES:
|
|
46
|
+
- Each step needs: id (unique string), type, label (human-readable), config, order (0-indexed)
|
|
47
|
+
- Step IDs should be like "step_1", "step_2", etc.
|
|
48
|
+
- For integration steps, only use integrations marked [CONNECTED] or suggest connecting them
|
|
49
|
+
- Use {{prev.field}} syntax in config values to reference previous step output
|
|
50
|
+
- If the user mentions a schedule, include schedule (cron) and schedule_enabled: true
|
|
51
|
+
- Keep workflows focused and practical
|
|
52
|
+
|
|
53
|
+
Respond with ONLY valid JSON in this exact format (no markdown, no explanation):
|
|
54
|
+
{
|
|
55
|
+
"name": "Workflow Name",
|
|
56
|
+
"description": "What this workflow does",
|
|
57
|
+
"steps": [...],
|
|
58
|
+
"schedule": null,
|
|
59
|
+
"schedule_enabled": false,
|
|
60
|
+
"schedule_timezone": "UTC"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
USER REQUEST: ${description}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Routes ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
// GET /api/workflows — list all workflows for the user
|
|
69
|
+
router.get('/', async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const workflows = await workflowDb.getAll(req.user.id);
|
|
72
|
+
const parsed = workflows.map(w => ({
|
|
73
|
+
...w,
|
|
74
|
+
steps: typeof w.steps === 'string' ? JSON.parse(w.steps) : w.steps
|
|
75
|
+
}));
|
|
76
|
+
res.json({ workflows: parsed });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
res.status(500).json({ error: 'Failed to fetch workflows' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// POST /api/workflows — create a workflow
|
|
83
|
+
router.post('/', async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const { name, description, steps, schedule, schedule_enabled, schedule_timezone } = req.body;
|
|
86
|
+
if (!name || !name.trim()) return res.status(400).json({ error: 'Name is required' });
|
|
87
|
+
if (!Array.isArray(steps)) return res.status(400).json({ error: 'Steps must be an array' });
|
|
88
|
+
|
|
89
|
+
const workflow = await workflowDb.create(req.user.id, {
|
|
90
|
+
name: name.trim(),
|
|
91
|
+
description: description?.trim() || null,
|
|
92
|
+
steps,
|
|
93
|
+
schedule: schedule || null,
|
|
94
|
+
schedule_enabled: !!schedule_enabled,
|
|
95
|
+
schedule_timezone: schedule_timezone || 'UTC'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Sync cron scheduler
|
|
99
|
+
if (workflow.id) refreshWorkflowSchedule(workflow.id, req.user.id);
|
|
100
|
+
|
|
101
|
+
res.json({ success: true, workflow });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
res.status(500).json({ error: 'Failed to create workflow' });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// POST /api/workflows/generate — AI-powered workflow generation from natural language
|
|
108
|
+
router.post('/generate', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const { description } = req.body;
|
|
111
|
+
if (!description || !description.trim()) {
|
|
112
|
+
return res.status(400).json({ error: 'Description is required' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Get user's API key (try Anthropic first, then OpenRouter)
|
|
116
|
+
let apiKey = await getUserProviderKey(req.user.id, 'anthropic_key');
|
|
117
|
+
let provider = 'anthropic';
|
|
118
|
+
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
apiKey = await getUserProviderKey(req.user.id, 'openrouter_key');
|
|
121
|
+
provider = 'openrouter';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fall back to server key
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
127
|
+
provider = 'anthropic';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!apiKey) {
|
|
131
|
+
return res.status(400).json({ error: 'No AI provider key available. Add an API key in Settings > AI Providers.' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Get available webhooks and connected integrations for context
|
|
135
|
+
const webhooks = await webhookDb.getAll(req.user.id);
|
|
136
|
+
const connectedIntegrations = []; // Will be populated if Composio is available
|
|
137
|
+
|
|
138
|
+
const prompt = buildWorkflowGenerationPrompt(description.trim(), webhooks, connectedIntegrations);
|
|
139
|
+
|
|
140
|
+
let generatedJson;
|
|
141
|
+
|
|
142
|
+
if (provider === 'anthropic') {
|
|
143
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: {
|
|
146
|
+
'x-api-key': apiKey,
|
|
147
|
+
'anthropic-version': '2023-06-01',
|
|
148
|
+
'content-type': 'application/json',
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
model: 'claude-sonnet-4-20250514',
|
|
152
|
+
max_tokens: 2048,
|
|
153
|
+
messages: [{ role: 'user', content: prompt }],
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
return res.status(502).json({ error: 'AI provider returned an error' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = await response.json();
|
|
162
|
+
const text = data.content?.[0]?.text || '';
|
|
163
|
+
generatedJson = text.trim();
|
|
164
|
+
} else {
|
|
165
|
+
// OpenRouter
|
|
166
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
'HTTP-Referer': 'https://cli.upfyn.com',
|
|
172
|
+
'X-Title': 'Upfyn-Code',
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
model: 'anthropic/claude-sonnet-4',
|
|
176
|
+
messages: [{ role: 'user', content: prompt }],
|
|
177
|
+
max_tokens: 2048,
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
return res.status(502).json({ error: 'AI provider returned an error' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = await response.json();
|
|
186
|
+
generatedJson = data.choices?.[0]?.message?.content?.trim() || '';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Parse the AI response — strip markdown fences if present
|
|
190
|
+
let cleaned = generatedJson;
|
|
191
|
+
if (cleaned.startsWith('```')) {
|
|
192
|
+
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let workflow;
|
|
196
|
+
try {
|
|
197
|
+
workflow = JSON.parse(cleaned);
|
|
198
|
+
} catch {
|
|
199
|
+
return res.status(422).json({ error: 'AI generated invalid workflow format. Try rephrasing your description.' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate required fields
|
|
203
|
+
if (!workflow.name || !Array.isArray(workflow.steps)) {
|
|
204
|
+
return res.status(422).json({ error: 'AI generated incomplete workflow. Try being more specific.' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ensure step IDs are unique
|
|
208
|
+
workflow.steps = workflow.steps.map((step, i) => ({
|
|
209
|
+
...step,
|
|
210
|
+
id: step.id || `step_${Date.now()}_${i}`,
|
|
211
|
+
order: i,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
// Save to Turso
|
|
215
|
+
const saved = await workflowDb.create(req.user.id, {
|
|
216
|
+
name: workflow.name.trim(),
|
|
217
|
+
description: workflow.description?.trim() || description.trim(),
|
|
218
|
+
steps: workflow.steps,
|
|
219
|
+
schedule: workflow.schedule || null,
|
|
220
|
+
schedule_enabled: !!workflow.schedule_enabled,
|
|
221
|
+
schedule_timezone: workflow.schedule_timezone || 'UTC',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (saved.id && workflow.schedule_enabled) {
|
|
225
|
+
refreshWorkflowSchedule(saved.id, req.user.id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
res.json({
|
|
229
|
+
success: true,
|
|
230
|
+
workflow: {
|
|
231
|
+
...saved,
|
|
232
|
+
steps: workflow.steps,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
res.status(500).json({ error: 'Failed to generate workflow' });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// PUT /api/workflows/:id — update a workflow
|
|
241
|
+
router.put('/:id', async (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
const { name, description, steps, schedule, schedule_enabled, schedule_timezone } = req.body;
|
|
244
|
+
if (!name || !name.trim()) return res.status(400).json({ error: 'Name is required' });
|
|
245
|
+
|
|
246
|
+
const wfId = Number(req.params.id);
|
|
247
|
+
const updated = await workflowDb.update(wfId, req.user.id, {
|
|
248
|
+
name: name.trim(),
|
|
249
|
+
description: description?.trim() || null,
|
|
250
|
+
steps: steps || [],
|
|
251
|
+
schedule: schedule || null,
|
|
252
|
+
schedule_enabled: !!schedule_enabled,
|
|
253
|
+
schedule_timezone: schedule_timezone || 'UTC'
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!updated) return res.status(404).json({ error: 'Workflow not found' });
|
|
257
|
+
|
|
258
|
+
// Sync cron scheduler
|
|
259
|
+
refreshWorkflowSchedule(wfId, req.user.id);
|
|
260
|
+
|
|
261
|
+
res.json({ success: true });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
res.status(500).json({ error: 'Failed to update workflow' });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// DELETE /api/workflows/:id — delete a workflow
|
|
268
|
+
router.delete('/:id', async (req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const wfId = Number(req.params.id);
|
|
271
|
+
const deleted = await workflowDb.delete(wfId, req.user.id);
|
|
272
|
+
if (!deleted) return res.status(404).json({ error: 'Workflow not found' });
|
|
273
|
+
|
|
274
|
+
stopWorkflowSchedule(wfId);
|
|
275
|
+
|
|
276
|
+
res.json({ success: true });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
res.status(500).json({ error: 'Failed to delete workflow' });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// POST /api/workflows/:id/run — execute a workflow (manual trigger)
|
|
283
|
+
router.post('/:id/run', async (req, res) => {
|
|
284
|
+
try {
|
|
285
|
+
const workflow = await workflowDb.getById(Number(req.params.id), req.user.id);
|
|
286
|
+
if (!workflow) return res.status(404).json({ error: 'Workflow not found' });
|
|
287
|
+
|
|
288
|
+
const steps = typeof workflow.steps === 'string' ? JSON.parse(workflow.steps) : workflow.steps;
|
|
289
|
+
if (!steps.length) return res.status(400).json({ error: 'Workflow has no steps' });
|
|
290
|
+
|
|
291
|
+
const result = await executeWorkflow({ ...workflow, steps });
|
|
292
|
+
res.json(result);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
res.status(500).json({ error: 'Failed to run workflow' });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// GET /api/workflows/:id/runs — list execution history for a workflow
|
|
299
|
+
router.get('/:id/runs', async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
const runs = await workflowDb.getRuns(Number(req.params.id), req.user.id);
|
|
302
|
+
const parsed = runs.map(r => ({
|
|
303
|
+
...r,
|
|
304
|
+
result: typeof r.result === 'string' ? (() => { try { return JSON.parse(r.result); } catch { return r.result; } })() : r.result
|
|
305
|
+
}));
|
|
306
|
+
res.json({ runs: parsed });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
res.status(500).json({ error: 'Failed to fetch workflow runs' });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
export default router;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox Client — connects the backend to the separate sandbox-service on Railway.
|
|
3
|
+
* All sandbox operations are proxied to the sandbox service via HTTP.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SANDBOX_SERVICE_URL = process.env.SANDBOX_SERVICE_URL || 'http://localhost:4300';
|
|
7
|
+
const SANDBOX_SERVICE_SECRET = process.env.SANDBOX_SERVICE_SECRET || 'dev-sandbox-secret';
|
|
8
|
+
|
|
9
|
+
async function sandboxFetch(path, userId, body = null) {
|
|
10
|
+
const opts = {
|
|
11
|
+
method: body ? 'POST' : 'GET',
|
|
12
|
+
headers: {
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
'x-sandbox-secret': SANDBOX_SERVICE_SECRET,
|
|
15
|
+
'x-user-id': String(userId),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
if (body) opts.body = JSON.stringify(body);
|
|
19
|
+
|
|
20
|
+
const res = await fetch(`${SANDBOX_SERVICE_URL}${path}`, opts);
|
|
21
|
+
const data = await res.json();
|
|
22
|
+
if (!res.ok) throw new Error(data.error || `Sandbox service error: ${res.status}`);
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sandboxClient = {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if the sandbox service is reachable.
|
|
30
|
+
*/
|
|
31
|
+
async isAvailable() {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${SANDBOX_SERVICE_URL}/health`, { signal: AbortSignal.timeout(3000) });
|
|
34
|
+
return res.ok;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Initialize a user's sandbox (creates if doesn't exist).
|
|
42
|
+
*/
|
|
43
|
+
async initSandbox(userId) {
|
|
44
|
+
return sandboxFetch('/api/sandbox/init', userId, {});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get sandbox status.
|
|
49
|
+
*/
|
|
50
|
+
async getStatus(userId) {
|
|
51
|
+
return sandboxFetch('/api/sandbox/status', userId);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Destroy a user's sandbox.
|
|
56
|
+
*/
|
|
57
|
+
async destroySandbox(userId) {
|
|
58
|
+
const res = await fetch(`${SANDBOX_SERVICE_URL}/api/sandbox`, {
|
|
59
|
+
method: 'DELETE',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'x-sandbox-secret': SANDBOX_SERVICE_SECRET,
|
|
63
|
+
'x-user-id': String(userId),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const data = await res.json();
|
|
67
|
+
if (!res.ok) throw new Error(data.error || 'Failed to destroy sandbox');
|
|
68
|
+
return data;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Execute a command in the user's sandbox.
|
|
73
|
+
*/
|
|
74
|
+
async exec(userId, command, opts = {}) {
|
|
75
|
+
return sandboxFetch('/api/exec', userId, {
|
|
76
|
+
command,
|
|
77
|
+
cwd: opts.cwd,
|
|
78
|
+
timeout: opts.timeout,
|
|
79
|
+
userKeys: opts.userKeys,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read a file from the user's sandbox.
|
|
85
|
+
*/
|
|
86
|
+
async readFile(userId, filePath) {
|
|
87
|
+
return sandboxFetch('/api/file/read', userId, { filePath });
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write a file to the user's sandbox.
|
|
92
|
+
*/
|
|
93
|
+
async writeFile(userId, filePath, content) {
|
|
94
|
+
return sandboxFetch('/api/file/write', userId, { filePath, content });
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get file tree from the user's sandbox.
|
|
99
|
+
*/
|
|
100
|
+
async getFileTree(userId, dirPath, depth = 3) {
|
|
101
|
+
return sandboxFetch('/api/file/tree', userId, { dirPath, depth });
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run a git command in the user's sandbox.
|
|
106
|
+
*/
|
|
107
|
+
async gitOperation(userId, gitCommand, cwd) {
|
|
108
|
+
return sandboxFetch('/api/git', userId, { gitCommand, cwd });
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the WebSocket URL for an interactive shell session.
|
|
113
|
+
*/
|
|
114
|
+
getShellWsUrl(userId, sessionId) {
|
|
115
|
+
const wsBase = SANDBOX_SERVICE_URL.replace(/^http/, 'ws');
|
|
116
|
+
return `${wsBase}/shell?secret=${encodeURIComponent(SANDBOX_SERVICE_SECRET)}&userId=${userId}&sessionId=${sessionId || 'default'}`;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export { sandboxClient, SANDBOX_SERVICE_URL };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composio Service — wraps the Composio SDK for OAuth management + tool execution.
|
|
3
|
+
* Single API key for the app, user isolation via entity mapping.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
let Composio = null;
|
|
8
|
+
let composioClient = null;
|
|
9
|
+
let sdkReady = null; // resolved promise once SDK is loaded
|
|
10
|
+
|
|
11
|
+
// Eagerly attempt to load the SDK — store the promise so getClient can await it
|
|
12
|
+
sdkReady = (async () => {
|
|
13
|
+
try {
|
|
14
|
+
const mod = await import('composio-core');
|
|
15
|
+
Composio = mod.Composio || mod.default;
|
|
16
|
+
} catch {
|
|
17
|
+
// SDK not installed — composio features will be unavailable
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lazy-init the Composio client singleton.
|
|
23
|
+
* Returns null if COMPOSIO_API_KEY is not set.
|
|
24
|
+
*/
|
|
25
|
+
async function getClient() {
|
|
26
|
+
if (composioClient) return composioClient;
|
|
27
|
+
if (!process.env.COMPOSIO_API_KEY) return null;
|
|
28
|
+
|
|
29
|
+
// Wait for SDK to finish loading
|
|
30
|
+
await sdkReady;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (!Composio) return null;
|
|
34
|
+
composioClient = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
|
|
35
|
+
return composioClient;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map internal user ID to Composio entity ID.
|
|
43
|
+
*/
|
|
44
|
+
function composioUserId(internalId) {
|
|
45
|
+
return `upfyn_user_${internalId}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get or create an entity for the given user.
|
|
50
|
+
*/
|
|
51
|
+
async function getEntity(userId) {
|
|
52
|
+
const client = await getClient();
|
|
53
|
+
if (!client) throw new Error('Composio not configured');
|
|
54
|
+
return client.getEntity(composioUserId(userId));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initiate an OAuth connection for a user.
|
|
59
|
+
* OAuth credentials are managed on the Composio dashboard — not injected here.
|
|
60
|
+
*
|
|
61
|
+
* @param {number} userId - Internal user ID
|
|
62
|
+
* @param {string} appName - Composio app name (e.g., 'GMAIL', 'SLACK')
|
|
63
|
+
* @param {string|null} authConfigId - Optional specific auth config
|
|
64
|
+
* @returns {{ redirectUrl: string, connectedAccountId: string }}
|
|
65
|
+
*/
|
|
66
|
+
async function initiateConnection(userId, appName, authConfigId = null) {
|
|
67
|
+
const entity = await getEntity(userId);
|
|
68
|
+
|
|
69
|
+
const params = { appName };
|
|
70
|
+
if (authConfigId) params.authConfigId = authConfigId;
|
|
71
|
+
|
|
72
|
+
const connectionRequest = await entity.initiateConnection(params);
|
|
73
|
+
return {
|
|
74
|
+
redirectUrl: connectionRequest.redirectUrl,
|
|
75
|
+
connectedAccountId: connectionRequest.connectedAccountId,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wait/poll for a connection to complete.
|
|
81
|
+
* @param {string} connectedAccountId - The connection ID from initiateConnection
|
|
82
|
+
* @returns {{ status: string, appName: string }}
|
|
83
|
+
*/
|
|
84
|
+
async function waitForConnection(connectedAccountId) {
|
|
85
|
+
const client = await getClient();
|
|
86
|
+
if (!client) throw new Error('Composio not configured');
|
|
87
|
+
|
|
88
|
+
const connection = await client.connectedAccounts.get({ connectedAccountId });
|
|
89
|
+
return {
|
|
90
|
+
status: connection.status,
|
|
91
|
+
appName: connection.appName,
|
|
92
|
+
id: connection.id,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List connected accounts for a user.
|
|
98
|
+
* @param {number} userId
|
|
99
|
+
* @returns {Array<{ id, appName, status, createdAt }>}
|
|
100
|
+
*/
|
|
101
|
+
async function listConnectedAccounts(userId) {
|
|
102
|
+
const client = await getClient();
|
|
103
|
+
if (!client) throw new Error('Composio not configured');
|
|
104
|
+
|
|
105
|
+
const entityId = composioUserId(userId);
|
|
106
|
+
try {
|
|
107
|
+
const accounts = await client.connectedAccounts.list({ entityId });
|
|
108
|
+
return (accounts.items || accounts || []).map(a => ({
|
|
109
|
+
id: a.id,
|
|
110
|
+
appName: a.appName,
|
|
111
|
+
status: a.status,
|
|
112
|
+
createdAt: a.createdAt,
|
|
113
|
+
}));
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get available tools for given toolkits.
|
|
121
|
+
* @param {string[]} apps - e.g., ['GMAIL', 'SLACK']
|
|
122
|
+
* @returns {Array<{ name, description, parameters }>}
|
|
123
|
+
*/
|
|
124
|
+
async function getTools(apps = []) {
|
|
125
|
+
const client = await getClient();
|
|
126
|
+
if (!client) throw new Error('Composio not configured');
|
|
127
|
+
|
|
128
|
+
const tools = await client.actions.list({ apps });
|
|
129
|
+
return (tools.items || tools || []).map(t => ({
|
|
130
|
+
name: t.name,
|
|
131
|
+
displayName: t.displayName || t.name,
|
|
132
|
+
description: t.description,
|
|
133
|
+
parameters: t.parameters,
|
|
134
|
+
appName: t.appName,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get schema for a specific tool.
|
|
140
|
+
* @param {string} actionName - e.g., 'GMAIL_SEND_EMAIL'
|
|
141
|
+
* @returns {{ name, description, parameters }}
|
|
142
|
+
*/
|
|
143
|
+
async function getToolSchema(actionName) {
|
|
144
|
+
const client = await getClient();
|
|
145
|
+
if (!client) throw new Error('Composio not configured');
|
|
146
|
+
|
|
147
|
+
const action = await client.actions.get({ actionName });
|
|
148
|
+
return {
|
|
149
|
+
name: action.name,
|
|
150
|
+
displayName: action.displayName || action.name,
|
|
151
|
+
description: action.description,
|
|
152
|
+
parameters: action.parameters,
|
|
153
|
+
appName: action.appName,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Execute a Composio tool action.
|
|
159
|
+
* @param {number} userId - Internal user ID
|
|
160
|
+
* @param {string} actionName - e.g., 'GMAIL_SEND_EMAIL'
|
|
161
|
+
* @param {object} params - Action parameters
|
|
162
|
+
* @returns {{ success, data, error }}
|
|
163
|
+
*/
|
|
164
|
+
async function executeTool(userId, actionName, params = {}) {
|
|
165
|
+
const entity = await getEntity(userId);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const result = await entity.execute(actionName, params);
|
|
169
|
+
return { success: true, data: result };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { success: false, error: err.message };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Disconnect an account.
|
|
177
|
+
* @param {string} connectedAccountId
|
|
178
|
+
*/
|
|
179
|
+
async function disconnectAccount(connectedAccountId) {
|
|
180
|
+
const client = await getClient();
|
|
181
|
+
if (!client) throw new Error('Composio not configured');
|
|
182
|
+
|
|
183
|
+
await client.connectedAccounts.delete({ connectedAccountId });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if Composio is available and configured.
|
|
188
|
+
*/
|
|
189
|
+
async function isAvailable() {
|
|
190
|
+
await sdkReady;
|
|
191
|
+
return !!(Composio && process.env.COMPOSIO_API_KEY);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export {
|
|
195
|
+
isAvailable,
|
|
196
|
+
composioUserId,
|
|
197
|
+
initiateConnection,
|
|
198
|
+
waitForConnection,
|
|
199
|
+
listConnectedAccounts,
|
|
200
|
+
getTools,
|
|
201
|
+
getToolSchema,
|
|
202
|
+
executeTool,
|
|
203
|
+
disconnectAccount,
|
|
204
|
+
};
|