loom-agent 1.2.36 → 1.3.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/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Deployment connectors — Vercel, Netlify, Cloudflare Pages, Railway, Render, Fly.io
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync, spawn } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = process.env.LOOM_CONFIG_DIR || path.join(require('os').homedir(), '.loom');
|
|
8
|
+
const CONNECTORS_FILE = path.join(CONFIG_DIR, 'connectors.json');
|
|
9
|
+
|
|
10
|
+
function loadConnectors() {
|
|
11
|
+
try {
|
|
12
|
+
if (fs.existsSync(CONNECTORS_FILE)) {
|
|
13
|
+
return JSON.parse(fs.readFileSync(CONNECTORS_FILE, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
return { supabase: {}, convex: {}, deploy: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function saveConnectors(cfg) {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
21
|
+
fs.writeFileSync(CONNECTORS_FILE, JSON.stringify(cfg, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDeployConfig() {
|
|
25
|
+
const cfg = loadConnectors();
|
|
26
|
+
return cfg.deploy || {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setDeployConfig(config) {
|
|
30
|
+
const cfg = loadConnectors();
|
|
31
|
+
cfg.deploy = { ...cfg.deploy, ...config };
|
|
32
|
+
saveConnectors(cfg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Vercel deployment
|
|
36
|
+
async function deployVercel(projectPath, options = {}) {
|
|
37
|
+
const config = getDeployConfig();
|
|
38
|
+
if (!config.vercel?.token) {
|
|
39
|
+
throw new Error('Vercel not configured. Run: loom connectors deploy vercel --token <token>');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const vercelCli = path.join(projectPath, 'node_modules', '.bin', 'vercel');
|
|
43
|
+
const hasVercel = fs.existsSync(vercelCli) || await which('vercel');
|
|
44
|
+
|
|
45
|
+
if (!hasVercel) {
|
|
46
|
+
// Install vercel CLI locally
|
|
47
|
+
execSync('npm install -D vercel', { cwd: projectPath, stdio: 'inherit' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const args = ['--token', config.vercel.token, '--yes'];
|
|
51
|
+
if (config.vercel.org) args.push('--scope', config.vercel.org);
|
|
52
|
+
if (options.prod) args.push('--prod');
|
|
53
|
+
if (options.buildCommand) args.push('--build-command', options.buildCommand);
|
|
54
|
+
if (options.outputDir) args.push('--output-dir', options.outputDir);
|
|
55
|
+
|
|
56
|
+
const result = execSync(`vercel ${args.join(' ')}`, {
|
|
57
|
+
cwd: projectPath,
|
|
58
|
+
stdio: 'pipe',
|
|
59
|
+
env: { ...process.env, VERCEL_TOKEN: config.vercel.token }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const output = result.toString();
|
|
63
|
+
const urlMatch = output.match(/https:\/\/[^\s]+/);
|
|
64
|
+
return { url: urlMatch ? urlMatch[0] : null, output };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Netlify deployment
|
|
68
|
+
async function deployNetlify(projectPath, options = {}) {
|
|
69
|
+
const config = getDeployConfig();
|
|
70
|
+
if (!config.netlify?.authToken) {
|
|
71
|
+
throw new Error('Netlify not configured. Run: loom connectors deploy netlify --auth-token <token>');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const netlifyCli = path.join(projectPath, 'node_modules', '.bin', 'netlify');
|
|
75
|
+
const hasNetlify = fs.existsSync(netlifyCli) || await which('netlify');
|
|
76
|
+
|
|
77
|
+
if (!hasNetlify) {
|
|
78
|
+
execSync('npm install -D netlify-cli', { cwd: projectPath, stdio: 'inherit' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const args = ['deploy', '--auth', config.netlify.authToken];
|
|
82
|
+
if (config.netlify.siteId) args.push('--site', config.netlify.siteId);
|
|
83
|
+
if (options.prod) args.push('--prod');
|
|
84
|
+
if (options.dir) args.push('--dir', options.dir);
|
|
85
|
+
|
|
86
|
+
const result = execSync(`netlify ${args.join(' ')}`, {
|
|
87
|
+
cwd: projectPath,
|
|
88
|
+
stdio: 'pipe',
|
|
89
|
+
env: { ...process.env, NETLIFY_AUTH_TOKEN: config.netlify.authToken }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const output = result.toString();
|
|
93
|
+
const urlMatch = output.match(/https:\/\/[^\s]+/);
|
|
94
|
+
return { url: urlMatch ? urlMatch[0] : null, output };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Cloudflare Pages deployment
|
|
98
|
+
async function deployCloudflarePages(projectPath, options = {}) {
|
|
99
|
+
const config = getDeployConfig();
|
|
100
|
+
if (!config.cloudflare?.apiToken || !config.cloudflare?.accountId) {
|
|
101
|
+
throw new Error('Cloudflare not configured. Run: loom connectors deploy cloudflare --api-token <token> --account-id <id>');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const wranglerCli = path.join(projectPath, 'node_modules', '.bin', 'wrangler');
|
|
105
|
+
const hasWrangler = fs.existsSync(wranglerCli) || await which('wrangler');
|
|
106
|
+
|
|
107
|
+
if (!hasWrangler) {
|
|
108
|
+
execSync('npm install -D wrangler', { cwd: projectPath, stdio: 'inherit' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For Pages, use wrangler pages deploy
|
|
112
|
+
const args = ['pages', 'deploy', options.dir || '.',
|
|
113
|
+
'--project-name', options.projectName || path.basename(projectPath),
|
|
114
|
+
'--branch', options.branch || 'main',
|
|
115
|
+
'--commit-hash', getGitHash(projectPath)];
|
|
116
|
+
|
|
117
|
+
const result = execSync(`wrangler ${args.join(' ')}`, {
|
|
118
|
+
cwd: projectPath,
|
|
119
|
+
stdio: 'pipe',
|
|
120
|
+
env: { ...process.env, CF_API_TOKEN: config.cloudflare.apiToken, CF_ACCOUNT_ID: config.cloudflare.accountId }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { url: `https://${options.projectName}.pages.dev`, output: result.toString() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Railway deployment
|
|
127
|
+
async function deployRailway(projectPath, options = {}) {
|
|
128
|
+
const config = getDeployConfig();
|
|
129
|
+
if (!config.railway?.token) {
|
|
130
|
+
throw new Error('Railway not configured. Run: loom connectors deploy railway --token <token>');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const railwayCli = path.join(projectPath, 'node_modules', '.bin', 'railway');
|
|
134
|
+
const hasRailway = fs.existsSync(railwayCli) || await which('railway');
|
|
135
|
+
|
|
136
|
+
if (!hasRailway) {
|
|
137
|
+
execSync('npm install -D @railway/cli', { cwd: projectPath, stdio: 'inherit' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const args = ['up', '--detach'];
|
|
141
|
+
if (config.railway.project) args.push('--project', config.railway.project);
|
|
142
|
+
if (config.railway.environment) args.push('--environment', config.railway.environment);
|
|
143
|
+
|
|
144
|
+
const result = execSync(`railway ${args.join(' ')}`, {
|
|
145
|
+
cwd: projectPath,
|
|
146
|
+
stdio: 'pipe',
|
|
147
|
+
env: { ...process.env, RAILWAY_TOKEN: config.railway.token }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { url: null, output: result.toString() };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Render deployment
|
|
154
|
+
async function deployRender(projectPath, options = {}) {
|
|
155
|
+
const config = getDeployConfig();
|
|
156
|
+
if (!config.render?.apiKey) {
|
|
157
|
+
throw new Error('Render not configured. Run: loom connectors deploy render --api-key <key>');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Render uses API-based deployment
|
|
161
|
+
const response = await fetch('https://api.render.com/v1/services', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Authorization': `Bearer ${config.render.apiKey}`,
|
|
165
|
+
'Content-Type': 'application/json'
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
type: 'web_service',
|
|
169
|
+
name: options.name || path.basename(projectPath),
|
|
170
|
+
repo: options.repoUrl,
|
|
171
|
+
branch: options.branch || 'main',
|
|
172
|
+
buildCommand: options.buildCommand || 'npm run build',
|
|
173
|
+
startCommand: options.startCommand || 'npm start',
|
|
174
|
+
envVars: options.envVars || []
|
|
175
|
+
})
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!response.ok) throw new Error(`Render deployment failed: ${await response.text()}`);
|
|
179
|
+
return await response.json();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fly.io deployment
|
|
183
|
+
async function deployFly(projectPath, options = {}) {
|
|
184
|
+
const config = getDeployConfig();
|
|
185
|
+
if (!config.fly?.token) {
|
|
186
|
+
throw new Error('Fly.io not configured. Run: loom connectors deploy fly --token <token>');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const flyCli = path.join(projectPath, 'node_modules', '.bin', 'flyctl');
|
|
190
|
+
const hasFly = fs.existsSync(flyCli) || await which('flyctl');
|
|
191
|
+
|
|
192
|
+
if (!hasFly) {
|
|
193
|
+
// Install flyctl
|
|
194
|
+
if (process.platform === 'win32') {
|
|
195
|
+
execSync('powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"', { stdio: 'inherit' });
|
|
196
|
+
} else {
|
|
197
|
+
execSync('curl -L https://fly.io/install.sh | sh', { stdio: 'inherit' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Ensure fly.toml exists
|
|
202
|
+
const flyToml = path.join(projectPath, 'fly.toml');
|
|
203
|
+
if (!fs.existsSync(flyToml)) {
|
|
204
|
+
const toml = `app = "${options.appName || path.basename(projectPath)}"
|
|
205
|
+
primary_region = "${options.region || 'ord'}"
|
|
206
|
+
[build]
|
|
207
|
+
dockerfile = "Dockerfile"
|
|
208
|
+
[http_service]
|
|
209
|
+
internal_port = 8080
|
|
210
|
+
force_https = true
|
|
211
|
+
auto_stop_machines = true
|
|
212
|
+
auto_start_machines = true
|
|
213
|
+
min_machines_running = 0`;
|
|
214
|
+
fs.writeFileSync(flyToml, toml);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = execSync(`flyctl deploy --access-token ${config.fly.token}`, {
|
|
218
|
+
cwd: projectPath,
|
|
219
|
+
stdio: 'pipe'
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return { url: `https://${options.appName}.fly.dev`, output: result.toString() };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Docker build helper
|
|
226
|
+
function buildDockerImage(projectPath, tag, dockerfile = 'Dockerfile') {
|
|
227
|
+
const fullTag = tag || `loom-${path.basename(projectPath)}:${Date.now()}`;
|
|
228
|
+
execSync(`docker build -t ${fullTag} -f ${dockerfile} .`, { cwd: projectPath, stdio: 'inherit' });
|
|
229
|
+
return fullTag;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Preview server (for local preview of built apps)
|
|
233
|
+
function startPreviewServer(projectPath, options = {}) {
|
|
234
|
+
const port = options.port || 3000;
|
|
235
|
+
const previewDir = options.previewDir || path.join(projectPath, 'dist');
|
|
236
|
+
|
|
237
|
+
if (!fs.existsSync(previewDir)) {
|
|
238
|
+
throw new Error('No build output found. Run build first.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const http = require('http');
|
|
242
|
+
const { createServer } = require('http');
|
|
243
|
+
const { readFileSync } = require('fs');
|
|
244
|
+
const { extname, join } = require('path');
|
|
245
|
+
|
|
246
|
+
const mimeTypes = {
|
|
247
|
+
'.html': 'text/html',
|
|
248
|
+
'.js': 'application/javascript',
|
|
249
|
+
'.css': 'text/css',
|
|
250
|
+
'.json': 'application/json',
|
|
251
|
+
'.png': 'image/png',
|
|
252
|
+
'.jpg': 'image/jpeg',
|
|
253
|
+
'.gif': 'image/gif',
|
|
254
|
+
'.svg': 'image/svg+xml',
|
|
255
|
+
'.ico': 'image/x-icon',
|
|
256
|
+
'.woff': 'font/woff',
|
|
257
|
+
'.woff2': 'font/woff2',
|
|
258
|
+
'.ttf': 'font/ttf',
|
|
259
|
+
'.eot': 'application/vnd.ms-fontobject'
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const server = createServer((req, res) => {
|
|
263
|
+
let filePath = join(previewDir, req.url === '/' ? 'index.html' : req.url);
|
|
264
|
+
|
|
265
|
+
// SPA fallback
|
|
266
|
+
if (!fs.existsSync(filePath)) {
|
|
267
|
+
filePath = join(previewDir, 'index.html');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const content = readFileSync(filePath);
|
|
272
|
+
const ext = extname(filePath);
|
|
273
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
|
|
274
|
+
res.end(content);
|
|
275
|
+
} catch {
|
|
276
|
+
res.writeHead(404);
|
|
277
|
+
res.end('Not found');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
server.listen(port, () => resolve({ server, port, url: `http://localhost:${port}` }));
|
|
283
|
+
server.on('error', reject);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Stop preview server
|
|
288
|
+
function stopPreviewServer(server) {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
server.close(() => resolve());
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Git helpers
|
|
295
|
+
function getGitHash(projectPath) {
|
|
296
|
+
try {
|
|
297
|
+
return execSync('git rev-parse --short HEAD', { cwd: projectPath, stdio: 'pipe' }).toString().trim();
|
|
298
|
+
} catch {
|
|
299
|
+
return 'unknown';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getGitBranch(projectPath) {
|
|
304
|
+
try {
|
|
305
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, stdio: 'pipe' }).toString().trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return 'main';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getGitRemoteUrl(projectPath) {
|
|
312
|
+
try {
|
|
313
|
+
return execSync('git config --get remote.origin.url', { cwd: projectPath, stdio: 'pipe' }).toString().trim();
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function which(cmd) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
322
|
+
const child = spawn(whichCmd, [cmd], { stdio: 'ignore' });
|
|
323
|
+
child.on('close', (code) => resolve(code === 0));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Auto-detect framework and build
|
|
328
|
+
async function detectAndBuild(projectPath) {
|
|
329
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
330
|
+
if (!fs.existsSync(pkgPath)) {
|
|
331
|
+
throw new Error('No package.json found');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
335
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
336
|
+
|
|
337
|
+
let framework = 'vite';
|
|
338
|
+
let buildCommand = pkg.scripts?.build || 'npm run build';
|
|
339
|
+
let outputDir = 'dist';
|
|
340
|
+
let devCommand = pkg.scripts?.dev || 'npm run dev';
|
|
341
|
+
|
|
342
|
+
if (deps.next) {
|
|
343
|
+
framework = 'nextjs';
|
|
344
|
+
buildCommand = 'next build';
|
|
345
|
+
outputDir = '.next';
|
|
346
|
+
devCommand = 'next dev';
|
|
347
|
+
} else if (deps['@remix-run/react'] || deps['@remix-run/node']) {
|
|
348
|
+
framework = 'remix';
|
|
349
|
+
buildCommand = 'remix build';
|
|
350
|
+
outputDir = 'build';
|
|
351
|
+
} else if (deps.astro) {
|
|
352
|
+
framework = 'astro';
|
|
353
|
+
buildCommand = 'astro build';
|
|
354
|
+
outputDir = 'dist';
|
|
355
|
+
} else if (deps.nuxt) {
|
|
356
|
+
framework = 'nuxt';
|
|
357
|
+
buildCommand = 'nuxt build';
|
|
358
|
+
outputDir = '.output/public';
|
|
359
|
+
} else if (deps.svelte || deps['@sveltejs/kit']) {
|
|
360
|
+
framework = 'svelte';
|
|
361
|
+
buildCommand = 'vite build';
|
|
362
|
+
outputDir = 'build';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Run build
|
|
366
|
+
execSync(buildCommand, { cwd: projectPath, stdio: 'inherit' });
|
|
367
|
+
|
|
368
|
+
return { framework, buildCommand, outputDir, devCommand };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
loadConnectors,
|
|
373
|
+
saveConnectors,
|
|
374
|
+
getDeployConfig,
|
|
375
|
+
setDeployConfig,
|
|
376
|
+
deployVercel,
|
|
377
|
+
deployNetlify,
|
|
378
|
+
deployCloudflarePages,
|
|
379
|
+
deployRailway,
|
|
380
|
+
deployRender,
|
|
381
|
+
deployFly,
|
|
382
|
+
buildDockerImage,
|
|
383
|
+
startPreviewServer,
|
|
384
|
+
stopPreviewServer,
|
|
385
|
+
getGitHash,
|
|
386
|
+
getGitBranch,
|
|
387
|
+
getGitRemoteUrl,
|
|
388
|
+
detectAndBuild,
|
|
389
|
+
which
|
|
390
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Connectors registry — unified interface for all external services
|
|
2
|
+
const supabase = require('./supabase');
|
|
3
|
+
const convex = require('./convex');
|
|
4
|
+
const deploy = require('./deploy');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
supabase,
|
|
8
|
+
convex,
|
|
9
|
+
deploy,
|
|
10
|
+
// Unified config management
|
|
11
|
+
loadAll: () => {
|
|
12
|
+
return {
|
|
13
|
+
supabase: supabase.getSupabaseConfig(),
|
|
14
|
+
convex: convex.getConvexConfig(),
|
|
15
|
+
deploy: deploy.getDeployConfig()
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
saveAll: (config) => {
|
|
19
|
+
if (config.supabase) supabase.setSupabaseConfig(config.supabase);
|
|
20
|
+
if (config.convex) convex.setConvexConfig(config.convex);
|
|
21
|
+
if (config.deploy) deploy.setDeployConfig(config.deploy);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Supabase connector — auth, database, storage, edge functions, realtime
|
|
2
|
+
const { createClient } = require('@supabase/supabase-js');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = process.env.LOOM_CONFIG_DIR || path.join(require('os').homedir(), '.loom');
|
|
8
|
+
const CONNECTORS_FILE = path.join(CONFIG_DIR, 'connectors.json');
|
|
9
|
+
|
|
10
|
+
function loadConnectors() {
|
|
11
|
+
try {
|
|
12
|
+
if (fs.existsSync(CONNECTORS_FILE)) {
|
|
13
|
+
return JSON.parse(fs.readFileSync(CONNECTORS_FILE, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
return { supabase: {}, convex: {}, deploy: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function saveConnectors(cfg) {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
21
|
+
fs.writeFileSync(CONNECTORS_FILE, JSON.stringify(cfg, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getSupabaseConfig() {
|
|
25
|
+
const cfg = loadConnectors();
|
|
26
|
+
return cfg.supabase || {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setSupabaseConfig(config) {
|
|
30
|
+
const cfg = loadConnectors();
|
|
31
|
+
cfg.supabase = { ...cfg.supabase, ...config };
|
|
32
|
+
saveConnectors(cfg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createSupabaseClient(overrideConfig = {}) {
|
|
36
|
+
const config = { ...getSupabaseConfig(), ...overrideConfig };
|
|
37
|
+
if (!config.url || !config.anonKey) {
|
|
38
|
+
throw new Error('Supabase not configured. Run: loom connectors supabase --url <url> --anon-key <key>');
|
|
39
|
+
}
|
|
40
|
+
return createClient(config.url, config.anonKey, {
|
|
41
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
42
|
+
...config.options
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Auth helpers
|
|
47
|
+
async function signUp(email, password, metadata = {}) {
|
|
48
|
+
const supabase = createSupabaseClient();
|
|
49
|
+
const { data, error } = await supabase.auth.signUp({ email, password, options: { data: metadata } });
|
|
50
|
+
if (error) throw error;
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function signIn(email, password) {
|
|
55
|
+
const supabase = createSupabaseClient();
|
|
56
|
+
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
|
57
|
+
if (error) throw error;
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function signOut() {
|
|
62
|
+
const supabase = createSupabaseClient();
|
|
63
|
+
const { error } = await supabase.auth.signOut();
|
|
64
|
+
if (error) throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function getSession() {
|
|
68
|
+
const supabase = createSupabaseClient();
|
|
69
|
+
const { data: { session } } = await supabase.auth.getSession();
|
|
70
|
+
return session;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getUser() {
|
|
74
|
+
const supabase = createSupabaseClient();
|
|
75
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
76
|
+
return user;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resetPassword(email) {
|
|
80
|
+
const supabase = createSupabaseClient();
|
|
81
|
+
const { error } = await supabase.auth.resetPasswordForEmail(email);
|
|
82
|
+
if (error) throw error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Database helpers (PostgreSQL via Supabase)
|
|
86
|
+
async function query(table, options = {}) {
|
|
87
|
+
const supabase = createSupabaseClient();
|
|
88
|
+
let query = supabase.from(table).select(options.select || '*');
|
|
89
|
+
|
|
90
|
+
if (options.filter) {
|
|
91
|
+
for (const [col, val] of Object.entries(options.filter)) {
|
|
92
|
+
query = query.eq(col, val);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (options.order) {
|
|
96
|
+
query = query.order(options.order.column, { ascending: options.order.ascending !== false });
|
|
97
|
+
}
|
|
98
|
+
if (options.limit) {
|
|
99
|
+
query = query.limit(options.limit);
|
|
100
|
+
}
|
|
101
|
+
if (options.range) {
|
|
102
|
+
query = query.range(options.range.from, options.range.to);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { data, error } = await query;
|
|
106
|
+
if (error) throw error;
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function insert(table, records, returning = true) {
|
|
111
|
+
const supabase = createSupabaseClient();
|
|
112
|
+
const { data, error } = await supabase.from(table).insert(records).select(returning ? '*' : '');
|
|
113
|
+
if (error) throw error;
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function update(table, filter, updates) {
|
|
118
|
+
const supabase = createSupabaseClient();
|
|
119
|
+
let query = supabase.from(table).update(updates);
|
|
120
|
+
for (const [col, val] of Object.entries(filter)) {
|
|
121
|
+
query = query.eq(col, val);
|
|
122
|
+
}
|
|
123
|
+
const { data, error } = await query.select('*');
|
|
124
|
+
if (error) throw error;
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function remove(table, filter) {
|
|
129
|
+
const supabase = createSupabaseClient();
|
|
130
|
+
let query = supabase.from(table).delete();
|
|
131
|
+
for (const [col, val] of Object.entries(filter)) {
|
|
132
|
+
query = query.eq(col, val);
|
|
133
|
+
}
|
|
134
|
+
const { error } = await query;
|
|
135
|
+
if (error) throw error;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function rpc(functionName, params = {}) {
|
|
139
|
+
const supabase = createSupabaseClient();
|
|
140
|
+
const { data, error } = await supabase.rpc(functionName, params);
|
|
141
|
+
if (error) throw error;
|
|
142
|
+
return data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Storage helpers
|
|
146
|
+
async function upload(bucket, path, fileBody, options = {}) {
|
|
147
|
+
const supabase = createSupabaseClient();
|
|
148
|
+
const { data, error } = await supabase.storage.from(bucket).upload(path, fileBody, options);
|
|
149
|
+
if (error) throw error;
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function download(bucket, path) {
|
|
154
|
+
const supabase = createSupabaseClient();
|
|
155
|
+
const { data, error } = await supabase.storage.from(bucket).download(path);
|
|
156
|
+
if (error) throw error;
|
|
157
|
+
return data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function removeFile(bucket, paths) {
|
|
161
|
+
const supabase = createSupabaseClient();
|
|
162
|
+
const { error } = await supabase.storage.from(bucket).remove(Array.isArray(paths) ? paths : [paths]);
|
|
163
|
+
if (error) throw error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function getPublicUrl(bucket, path) {
|
|
167
|
+
const supabase = createSupabaseClient();
|
|
168
|
+
const { data } = supabase.storage.from(bucket).getPublicUrl(path);
|
|
169
|
+
return data.publicUrl;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function listFiles(bucket, folder = '') {
|
|
173
|
+
const supabase = createSupabaseClient();
|
|
174
|
+
const { data, error } = await supabase.storage.from(bucket).list(folder);
|
|
175
|
+
if (error) throw error;
|
|
176
|
+
return data;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Edge Functions
|
|
180
|
+
async function invokeFunction(functionName, payload = {}) {
|
|
181
|
+
const supabase = createSupabaseClient();
|
|
182
|
+
const { data, error } = await supabase.functions.invoke(functionName, { body: payload });
|
|
183
|
+
if (error) throw error;
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Realtime subscriptions
|
|
188
|
+
function subscribe(table, event, callback, filter = {}) {
|
|
189
|
+
const supabase = createSupabaseClient();
|
|
190
|
+
let channel = supabase.channel(`loom:${table}:${event}`);
|
|
191
|
+
|
|
192
|
+
if (event === '*') {
|
|
193
|
+
channel = channel.on('postgres_changes', { event: '*', schema: 'public', table, filter: filter.column ? `${filter.column}=eq.${filter.value}` : undefined }, callback);
|
|
194
|
+
} else {
|
|
195
|
+
channel = channel.on('postgres_changes', { event, schema: 'public', table, filter: filter.column ? `${filter.column}=eq.${filter.value}` : undefined }, callback);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
channel.subscribe();
|
|
199
|
+
return () => { supabase.removeChannel(channel); };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Project scaffolding helpers
|
|
203
|
+
async function scaffoldProject(projectName, template = 'react') {
|
|
204
|
+
const projectPath = path.join(process.cwd(), projectName);
|
|
205
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
206
|
+
|
|
207
|
+
const templates = {
|
|
208
|
+
react: {
|
|
209
|
+
'package.json': JSON.stringify({
|
|
210
|
+
name: projectName,
|
|
211
|
+
version: '0.0.1',
|
|
212
|
+
private: true,
|
|
213
|
+
scripts: {
|
|
214
|
+
dev: 'vite',
|
|
215
|
+
build: 'vite build',
|
|
216
|
+
preview: 'vite preview'
|
|
217
|
+
},
|
|
218
|
+
dependencies: { react: '^18.3.0', 'react-dom': '^18.3.0' },
|
|
219
|
+
devDependencies: { vite: '^5.0.0', '@vitejs/plugin-react': '^4.0.0' }
|
|
220
|
+
}, null, 2),
|
|
221
|
+
'vite.config.js': `import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nexport default defineConfig({ plugins: [react()], server: { port: 3000, host: true } })`,
|
|
222
|
+
'index.html': `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${projectName}</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`,
|
|
223
|
+
'src/main.jsx': `import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\nimport './index.css'\nReactDOM.createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>)`,
|
|
224
|
+
'src/App.jsx': `function App() { return <div style={{padding: '2rem', fontFamily: 'system-ui'}}><h1>${projectName}</h1><p>Powered by Loom + Supabase</p></div> }\nexport default App`,
|
|
225
|
+
'src/index.css': `* { box-sizing: border-box; } body { margin: 0; font-family: system-ui; background: #fafafa; }`
|
|
226
|
+
},
|
|
227
|
+
nextjs: {
|
|
228
|
+
'package.json': JSON.stringify({
|
|
229
|
+
name: projectName,
|
|
230
|
+
version: '0.0.1',
|
|
231
|
+
private: true,
|
|
232
|
+
scripts: { dev: 'next dev', build: 'next build', start: 'next start' },
|
|
233
|
+
dependencies: { next: '^14.0.0', react: '^18.3.0', 'react-dom': '^18.3.0' }
|
|
234
|
+
}, null, 2),
|
|
235
|
+
'next.config.js': `/** @type {import('next').NextConfig} */\nconst nextConfig = {}\nmodule.exports = nextConfig`,
|
|
236
|
+
'app/page.js': `export default function Home() { return <main style={{padding: '2rem'}}><h1>${projectName}</h1><p>Next.js + Supabase</p></main> }`,
|
|
237
|
+
'app/layout.js': `export default function RootLayout({children}) { return <html lang="en"><body>{children}</body></html> }`
|
|
238
|
+
},
|
|
239
|
+
vue: {
|
|
240
|
+
'package.json': JSON.stringify({
|
|
241
|
+
name: projectName,
|
|
242
|
+
version: '0.0.1',
|
|
243
|
+
private: true,
|
|
244
|
+
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
|
|
245
|
+
dependencies: { vue: '^3.4.0' },
|
|
246
|
+
devDependencies: { vite: '^5.0.0', '@vitejs/plugin-vue': '^5.0.0' }
|
|
247
|
+
}, null, 2),
|
|
248
|
+
'vite.config.js': `import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nexport default defineConfig({ plugins: [vue()], server: { port: 3000, host: true } })`,
|
|
249
|
+
'index.html': `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${projectName}</title></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>`,
|
|
250
|
+
'src/main.js': `import { createApp } from 'vue'\nimport App from './App.vue'\nimport './style.css'\ncreateApp(App).mount('#app')`,
|
|
251
|
+
'src/App.vue': `<template><div style="padding: 2rem; font-family: system-ui"><h1>${projectName}</h1><p>Vue + Supabase</p></div></template><script setup></script><style>* { box-sizing: border-box; } body { margin: 0; }</style>`,
|
|
252
|
+
'src/style.css': `* { box-sizing: border-box; } body { margin: 0; font-family: system-ui; background: #fafafa; }`
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const tpl = templates[template] || templates.react;
|
|
257
|
+
for (const [file, content] of Object.entries(tpl)) {
|
|
258
|
+
const fullPath = path.join(projectPath, file);
|
|
259
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
260
|
+
fs.writeFileSync(fullPath, content);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Initialize git
|
|
264
|
+
try { require('child_process').execSync('git init', { cwd: projectPath, stdio: 'ignore' }); } catch {}
|
|
265
|
+
|
|
266
|
+
return projectPath;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
createSupabaseClient,
|
|
271
|
+
getSupabaseConfig,
|
|
272
|
+
setSupabaseConfig,
|
|
273
|
+
signUp, signIn, signOut, getSession, getUser, resetPassword,
|
|
274
|
+
query, insert, update, remove, rpc,
|
|
275
|
+
upload, download, removeFile, getPublicUrl, listFiles,
|
|
276
|
+
invokeFunction, subscribe,
|
|
277
|
+
scaffoldProject,
|
|
278
|
+
loadConnectors, saveConnectors
|
|
279
|
+
};
|