loom-agent 1.2.36 → 1.2.37

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,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
+ };