spaps 0.5.49 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1689 +1,350 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * SPAPS Local Development Server
5
- * Minimal, zero-config server for local development
4
+ * SPAPS Local Development Server - Docker Compose Orchestrator
5
+ * Manages the real Python/FastAPI SPAPS server via docker-compose.spaps-dev.yml
6
6
  */
7
7
 
8
- const express = require('express');
9
- const cors = require('cors');
8
+ const { spawn, execSync } = require('child_process');
10
9
  const chalk = require('chalk');
11
- const fs = require('fs');
10
+ const axios = require('axios');
12
11
  const path = require('path');
13
- const { generateDocsHTML } = require('./docs-html');
14
- let swaggerUiDist = null;
15
- try { swaggerUiDist = require('swagger-ui-dist'); } catch {}
16
- const StripeLocalManager = require('./stripe-local');
17
- const LocalAdminManager = require('./admin-local');
18
-
19
- // Stripe configuration for test mode
20
- const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY || 'sk_test_51S1WOy2HT0E1dOewiHvzt7T96PDwjocSDDUuc2ur569AVA5fDj4UpNM66lujrda1tTYrgooG0Z1dNFZfwEZuZdcA00nuVLJW67');
21
- const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || 'pk_test_51S1WOy2HT0E1dOewb2EkxZIaPkz7v3zMM9VxuBoxgNILYMmS85I4zrAWTkevyUQcaWlWUoC2NYnB8X5ZKd5e7Ifc005IzIW6H2';
12
+ const fs = require('fs');
22
13
 
23
14
  class LocalServer {
24
15
  constructor(options = {}) {
25
- this.port = options.port || process.env.PORT || 3456;
16
+ this.port = options.port || 3301; // Dev API external port
26
17
  this.json = options.json || false;
27
- this.stripeMode = options.stripeMode || (process.env.USE_REAL_STRIPE === 'false' ? 'mock' : 'real');
28
- this.seedMode = options.seedMode || 'none';
29
- this.app = express();
30
- this.stripeManager = null;
31
- this.adminManager = new LocalAdminManager();
32
- this.setupMiddleware();
33
- this.setupRoutes();
34
- this.setupStripeRoutes();
35
- this.setupAdminRoutes();
36
- this.setupCatchAll();
37
-
38
- // Optional demo seeding (idempotent)
39
- if (this.seedMode === 'demo') {
40
- try {
41
- this.seedDemoData();
42
- } catch (e) {
43
- if (!this.json) console.warn(chalk.yellow(`⚠️ Seed failed: ${e.message}`));
44
- }
45
- }
18
+ this.detach = options.detach || false;
19
+ this.fresh = options.fresh || false;
20
+ this.fromBackup = options.fromBackup || null;
21
+ this.repoRoot = this.findRepoRoot();
22
+ this.composeFile = path.join(this.repoRoot, 'docker-compose.spaps-dev.yml');
23
+ this.apiUrl = `http://localhost:${this.port}`;
24
+ this.healthUrl = `${this.apiUrl}/health`;
25
+ this.logProcess = null;
46
26
  }
47
27
 
48
- setupMiddleware() {
49
- // CORS - allow everything in local mode
50
- this.app.use(cors({
51
- origin: true,
52
- credentials: true,
53
- methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
54
- allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Test-User', 'x-local-mode', 'X-Local-Mode'],
55
- }));
56
-
57
- // Body parsing
58
- this.app.use(express.json());
59
- this.app.use(express.urlencoded({ extended: true }));
60
-
61
- // Serve Swagger UI assets locally if available
62
- if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') {
63
- const uiPath = swaggerUiDist.getAbsoluteFSPath();
64
- this.app.use('/swagger-ui', express.static(uiPath));
65
- }
66
-
67
- // Local mode indicator
68
- this.app.use((req, res, next) => {
69
- res.setHeader('X-SPAPS-Mode', 'local-development');
70
-
71
- // Auto-auth in local mode
72
- if (!req.headers.authorization && !req.headers['x-api-key']) {
73
- req.headers['x-api-key'] = 'local-dev-key';
74
- req.user = {
75
- id: 'local-user-123',
76
- email: 'dev@localhost',
77
- role: req.query._user || req.headers['x-test-user'] || 'user'
78
- };
79
- }
80
-
81
- // Log requests (unless in JSON mode)
82
- if (!this.json) {
83
- console.log(chalk.dim(`${req.method} ${req.path}`));
28
+ /**
29
+ * Find the sweet-potato repo root by walking up from __dirname
30
+ */
31
+ findRepoRoot() {
32
+ let current = __dirname;
33
+ const maxDepth = 10;
34
+ let depth = 0;
35
+
36
+ while (depth < maxDepth) {
37
+ const candidate = path.join(current, 'docker-compose.spaps-dev.yml');
38
+ if (fs.existsSync(candidate)) {
39
+ return current;
84
40
  }
85
- next();
86
- });
87
- }
88
-
89
- setupRoutes() {
90
- // OpenAPI JSON - try to serve repo spec; fallback to manifest; else minimal stub
91
- this.app.get('/openapi.json', async (_req, res) => {
92
- try {
93
- const spec = await this.loadOpenApiSpec();
94
- return res.json(spec);
95
- } catch (e) {
96
- return res.status(500).json({ error: 'Failed to generate OpenAPI', message: e.message });
97
- }
98
- });
99
-
100
- // Health check
101
- this.app.get('/health', (req, res) => {
102
- res.json({
103
- status: 'healthy',
104
- mode: 'local-development',
105
- version: '0.2.0',
106
- timestamp: new Date().toISOString()
107
- });
108
- });
109
-
110
- // Local mode status
111
- this.app.get('/health/local-mode', (req, res) => {
112
- res.json({
113
- enabled: true,
114
- environment: 'local-development',
115
- features: {
116
- autoAuth: true,
117
- corsEnabled: true,
118
- testUsers: ['user', 'admin', 'premium'],
119
- apiKeyRequired: false
120
- }
121
- });
122
- });
123
-
124
- // Mock authentication endpoints
125
- this.app.post('/api/auth/login', (req, res) => {
126
- const { email, password } = req.body;
127
- res.json({
128
- success: true,
129
- data: {
130
- access_token: 'local-jwt-token-' + Date.now(),
131
- refresh_token: 'local-refresh-token-' + Date.now(),
132
- user: {
133
- id: 'local-user-123',
134
- email: email || 'dev@localhost',
135
- role: 'user'
136
- }
137
- }
138
- });
139
- });
140
-
141
- this.app.post('/api/auth/register', (req, res) => {
142
- const { email, password } = req.body;
143
- res.json({
144
- success: true,
145
- data: {
146
- access_token: 'local-jwt-token-' + Date.now(),
147
- refresh_token: 'local-refresh-token-' + Date.now(),
148
- user: {
149
- id: 'local-user-' + Date.now(),
150
- email: email || 'dev@localhost',
151
- role: 'user'
152
- }
153
- }
154
- });
155
- });
156
-
157
- this.app.post('/api/auth/wallet-sign-in', (req, res) => {
158
- const { wallet_address, chain_type } = req.body;
159
- res.json({
160
- success: true,
161
- data: {
162
- access_token: 'local-jwt-token-' + Date.now(),
163
- refresh_token: 'local-refresh-token-' + Date.now(),
164
- user: {
165
- id: 'local-wallet-user-123',
166
- wallet_address,
167
- chain_type,
168
- role: 'user'
169
- }
170
- }
171
- });
172
- });
173
-
174
- this.app.post('/api/auth/refresh', (req, res) => {
175
- res.json({
176
- success: true,
177
- data: {
178
- access_token: 'local-jwt-token-refreshed-' + Date.now(),
179
- refresh_token: 'local-refresh-token-refreshed-' + Date.now()
180
- }
181
- });
182
- });
183
-
184
- this.app.post('/api/auth/logout', (req, res) => {
185
- res.json({ success: true, message: 'Logged out successfully' });
186
- });
187
-
188
- this.app.get('/api/auth/user', (req, res) => {
189
- res.json({
190
- id: req.user?.id || 'local-user-123',
191
- email: req.user?.email || 'dev@localhost',
192
- role: req.user?.role || 'user',
193
- created_at: new Date().toISOString()
194
- });
195
- });
196
-
197
- // Stripe checkout sessions endpoint - REAL or MOCK based on config
198
- this.app.post('/api/stripe/checkout-sessions', async (req, res) => {
199
- try {
200
- if (this.stripeMode === 'real') {
201
- // Real Stripe checkout session
202
- const { product_name, amount, currency = 'usd', success_url, cancel_url, price_id } = req.body;
203
-
204
- let lineItems;
205
- if (price_id) {
206
- // Use existing price
207
- lineItems = [{ price: price_id, quantity: 1 }];
208
- } else {
209
- // Create price on the fly
210
- lineItems = [{
211
- price_data: {
212
- currency,
213
- product_data: { name: product_name || 'Product' },
214
- unit_amount: amount || 999
215
- },
216
- quantity: 1
217
- }];
218
- }
219
-
220
- const session = await stripe.checkout.sessions.create({
221
- mode: 'payment',
222
- line_items: lineItems,
223
- success_url,
224
- cancel_url,
225
- automatic_tax: { enabled: false },
226
- customer_creation: 'always'
227
- });
228
-
229
- res.json({
230
- success: true,
231
- data: {
232
- sessionId: session.id,
233
- url: session.url,
234
- amount_total: session.amount_total,
235
- currency: session.currency,
236
- payment_status: session.payment_status,
237
- status: session.status
238
- }
239
- });
240
- } else {
241
- // Mock response (fallback)
242
- const sessionId = 'cs_local_' + Date.now();
243
- res.json({
244
- success: true,
245
- data: {
246
- sessionId,
247
- url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(req.body.success_url)}&cancel=${encodeURIComponent(req.body.cancel_url)}`,
248
- amount_total: req.body.amount || 999,
249
- currency: req.body.currency || 'usd',
250
- payment_status: 'unpaid',
251
- status: 'open'
252
- }
253
- });
254
- }
255
- } catch (error) {
256
- console.error('Stripe checkout error:', error);
257
- res.status(500).json({
258
- success: false,
259
- error: {
260
- code: 'CHECKOUT_ERROR',
261
- message: error.message || 'Failed to create checkout session'
262
- }
263
- });
41
+ const parent = path.dirname(current);
42
+ if (parent === current) {
43
+ break; // Reached filesystem root
264
44
  }
265
- });
266
-
267
- // Mock Stripe endpoints (legacy)
268
- this.app.post('/api/stripe/create-checkout-session', (req, res) => {
269
- res.json({
270
- sessionId: 'cs_test_local_' + Date.now(),
271
- url: 'https://checkout.stripe.com/pay/cs_test_local'
272
- });
273
- });
45
+ current = parent;
46
+ depth++;
47
+ }
274
48
 
275
- this.app.get('/api/stripe/subscription', (req, res) => {
276
- res.json({
277
- id: 'sub_local_123',
278
- status: 'active',
279
- plan: 'premium',
280
- current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
281
- });
282
- });
49
+ throw new Error('Could not find sweet-potato repo root (docker-compose.spaps-dev.yml not found)');
50
+ }
283
51
 
284
- // Stripe products endpoint - REAL or MOCK based on config
285
- this.app.get('/api/stripe/products', async (req, res) => {
52
+ /**
53
+ * Check if docker compose is available
54
+ */
55
+ checkDockerCompose() {
56
+ try {
57
+ execSync('docker compose version', { stdio: 'ignore' });
58
+ return 'docker compose';
59
+ } catch {
286
60
  try {
287
- if (this.stripeMode === 'real') {
288
- // Fetch real Stripe products
289
- const products = await stripe.products.list({
290
- active: req.query.active !== undefined ? req.query.active === 'true' : undefined,
291
- limit: req.query.limit ? parseInt(req.query.limit) : 10
292
- });
293
-
294
- // Get prices for each product and filter out local-only products
295
- const productsWithPrices = await Promise.all(
296
- products.data
297
- .filter(product => {
298
- // Only show products that don't start with prod_local_ (which are local-only placeholders)
299
- // These are the real Stripe products created from sync
300
- return !product.id.startsWith('prod_local_');
301
- })
302
- .map(async (product) => {
303
- const prices = await stripe.prices.list({
304
- product: product.id,
305
- active: true,
306
- limit: 1
307
- });
308
-
309
- const defaultPrice = prices.data[0];
310
- return {
311
- id: product.id,
312
- name: product.name,
313
- description: product.description,
314
- price: defaultPrice ? defaultPrice.unit_amount : 0,
315
- currency: defaultPrice ? defaultPrice.currency : 'usd',
316
- price_id: defaultPrice ? defaultPrice.id : null,
317
- active: product.active,
318
- metadata: product.metadata
319
- };
320
- })
321
- );
322
-
323
- res.json({
324
- success: true,
325
- data: productsWithPrices
326
- });
327
- } else {
328
- // Mock response (fallback)
329
- res.json({
330
- success: true,
331
- data: [
332
- {
333
- id: 'prod_local_validate',
334
- name: 'Validate',
335
- description: 'Proof of concept validation',
336
- price: 500,
337
- currency: 'usd',
338
- active: true
339
- },
340
- {
341
- id: 'prod_local_prototype',
342
- name: 'Prototype',
343
- description: 'Build an MVP prototype',
344
- price: 2500,
345
- currency: 'usd',
346
- active: true
347
- }
348
- ]
349
- });
350
- }
351
- } catch (error) {
352
- console.error('Stripe products error:', error);
353
- res.status(500).json({
354
- success: false,
355
- error: {
356
- code: 'PRODUCTS_ERROR',
357
- message: error.message || 'Failed to fetch products'
358
- }
359
- });
61
+ execSync('docker-compose version', { stdio: 'ignore' });
62
+ return 'docker-compose';
63
+ } catch {
64
+ throw new Error('docker compose is required but not installed');
360
65
  }
361
- });
362
-
363
- // Mock auth nonce endpoint
364
- this.app.post('/api/auth/nonce', (req, res) => {
365
- const { wallet_address } = req.body;
366
- res.json({
367
- success: true,
368
- data: {
369
- nonce: 'local-nonce-' + Date.now(),
370
- message: `Sign this message to authenticate your wallet ${wallet_address}.\n\nNonce: local-nonce-${Date.now()}`,
371
- wallet_address,
372
- expires_at: new Date(Date.now() + 300000).toISOString()
373
- }
374
- });
375
- });
66
+ }
67
+ }
376
68
 
377
- // Mock magic link endpoint
378
- this.app.post('/api/auth/magic-link', (req, res) => {
379
- const { email } = req.body;
380
- res.json({
381
- success: true,
382
- message: 'Magic link sent successfully (simulated in local mode)',
383
- data: {
384
- email,
385
- sent_at: new Date().toISOString()
386
- }
69
+ /**
70
+ * Run docker compose command
71
+ */
72
+ runCompose(args, options = {}) {
73
+ const composeCmd = this.checkDockerCompose();
74
+ const fullArgs = ['-f', this.composeFile, ...args];
75
+
76
+ if (options.silent) {
77
+ return execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
78
+ cwd: this.repoRoot,
79
+ stdio: 'ignore'
387
80
  });
388
- });
389
-
390
- // Mock customer portal endpoint
391
- this.app.post('/api/stripe/customer-portal', (req, res) => {
392
- res.json({
393
- success: true,
394
- data: {
395
- url: `http://localhost:${this.port}/customer-portal?return=${encodeURIComponent(req.body.return_url || 'http://localhost:3000')}`
396
- }
397
- });
398
- });
399
-
400
- // Admin whitelist check endpoint
401
- this.app.post('/api/v1/admin/whitelist/check', (req, res) => {
402
- const { email } = req.body;
403
-
404
- if (!email) {
405
- return res.status(400).json({
406
- success: false,
407
- error: { message: 'Email is required' }
408
- });
409
- }
410
-
411
- // Mock whitelist check - some emails are "whitelisted"
412
- const whitelistedEmails = ['vip@example.com', 'admin@example.com', 'premium@example.com'];
413
- const isWhitelisted = whitelistedEmails.includes(email.toLowerCase());
81
+ }
414
82
 
415
- res.json({
416
- success: true,
417
- data: {
418
- email,
419
- whitelisted: isWhitelisted,
420
- reason: isWhitelisted ? 'VIP access granted' : 'Standard access only'
421
- }
422
- });
83
+ const result = execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
84
+ cwd: this.repoRoot,
85
+ encoding: 'utf-8'
423
86
  });
424
87
 
425
- // Admin pricing update endpoint
426
- this.app.post('/api/v1/admin/pricing/update', (req, res) => {
427
- const { product_id, new_price, currency = 'usd' } = req.body;
428
-
429
- if (!product_id || !new_price) {
430
- return res.status(400).json({
431
- success: false,
432
- error: { message: 'Product ID and new price are required' }
433
- });
434
- }
88
+ return result;
89
+ }
435
90
 
436
- res.json({
437
- success: true,
438
- data: {
439
- product_id,
440
- old_price: 999,
441
- new_price,
442
- currency,
443
- updated_at: new Date().toISOString()
444
- }
445
- });
446
- });
91
+ /**
92
+ * Wait for health check to pass
93
+ */
94
+ async waitForHealthCheck(maxAttempts = 60, intervalMs = 1000) {
95
+ if (!this.json) {
96
+ console.log(chalk.dim(`⏳ Waiting for SPAPS API at ${this.healthUrl}...`));
97
+ }
447
98
 
448
- // Admin product sync endpoint - REAL or MOCK based on config
449
- this.app.post('/api/v1/admin/products/sync', async (req, res) => {
99
+ for (let i = 0; i < maxAttempts; i++) {
450
100
  try {
451
- if (this.stripeMode === 'real') {
452
- // Get local products from admin manager
453
- const localProducts = this.adminManager.listProducts();
454
- const syncResults = [];
455
-
456
- for (const product of localProducts) {
457
- try {
458
- // Check if product already exists in Stripe by searching metadata
459
- let stripeProduct;
460
- const existingProducts = await stripe.products.list({
461
- limit: 100,
462
- expand: ['data.default_price']
463
- });
464
-
465
- stripeProduct = existingProducts.data.find(p =>
466
- p.metadata && p.metadata.spaps_id === product.id
467
- );
468
-
469
- if (!stripeProduct) {
470
- // Create new product in Stripe
471
- stripeProduct = await stripe.products.create({
472
- name: product.name,
473
- description: product.description,
474
- metadata: {
475
- spaps_managed: 'true',
476
- created_by: 'spaps_admin',
477
- spaps_id: product.id
478
- }
479
- });
480
-
481
- // Create corresponding price
482
- await stripe.prices.create({
483
- product: stripeProduct.id,
484
- unit_amount: product.price,
485
- currency: product.currency,
486
- metadata: {
487
- spaps_managed: 'true',
488
- spaps_price_id: product.price_id
489
- }
490
- });
491
-
492
- syncResults.push({
493
- id: product.id,
494
- name: product.name,
495
- action: 'created',
496
- stripe_id: stripeProduct.id
497
- });
498
- } else {
499
- // Update existing product
500
- await stripe.products.update(stripeProduct.id, {
501
- name: product.name,
502
- description: product.description,
503
- active: product.active !== false
504
- });
505
-
506
- syncResults.push({
507
- id: product.id,
508
- name: product.name,
509
- action: 'updated',
510
- stripe_id: stripeProduct.id
511
- });
512
- }
513
- } catch (productError) {
514
- console.error(`Error syncing product ${product.id}:`, productError);
515
- syncResults.push({
516
- id: product.id,
517
- name: product.name,
518
- action: 'error',
519
- error: productError.message
520
- });
521
- }
522
- }
523
-
524
- res.json({
525
- success: true,
526
- message: `Successfully synced ${syncResults.filter(r => r.action !== 'error').length} products to Stripe`,
527
- data: {
528
- synced_count: syncResults.filter(r => r.action !== 'error').length,
529
- total_count: localProducts.length,
530
- results: syncResults
531
- }
532
- });
533
- } else {
534
- // Mock response (fallback)
535
- res.json({
536
- success: true,
537
- message: 'Products synced successfully (mock mode)',
538
- data: {
539
- synced_count: 2,
540
- products: ['Validate', 'Prototype']
541
- }
542
- });
101
+ const response = await axios.get(this.healthUrl, { timeout: 2000 });
102
+ if (response.status === 200) {
103
+ return true;
543
104
  }
544
105
  } catch (error) {
545
- console.error('Product sync error:', error);
546
- res.status(500).json({
547
- success: false,
548
- error: {
549
- code: 'SYNC_ERROR',
550
- message: error.message || 'Failed to sync products'
551
- }
552
- });
106
+ // Continue waiting
553
107
  }
554
- });
555
-
556
- // Mock usage endpoints
557
- this.app.get('/api/usage/balance', (req, res) => {
558
- res.json({
559
- balance: 1000,
560
- currency: 'credits',
561
- updated_at: new Date().toISOString()
562
- });
563
- });
108
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
109
+ }
564
110
 
565
- // Documentation endpoint: prefer Swagger UI bound to /openapi.json
566
- this.app.get('/docs', (req, res) => {
567
- if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') {
568
- res.type('html').send(`
569
- <!DOCTYPE html>
570
- <html>
571
- <head>
572
- <meta charset="utf-8" />
573
- <title>SPAPS API Docs</title>
574
- <link rel="stylesheet" href="/swagger-ui/swagger-ui.css" />
575
- <style>body { margin: 0; } #swagger-ui { max-width: 100%; }</style>
576
- </head>
577
- <body>
578
- <div id="swagger-ui"></div>
579
- <script src="/swagger-ui/swagger-ui-bundle.js"></script>
580
- <script src="/swagger-ui/swagger-ui-standalone-preset.js"></script>
581
- <script>
582
- window.onload = () => {
583
- window.ui = SwaggerUIBundle({
584
- url: '/openapi.json',
585
- dom_id: '#swagger-ui',
586
- deepLinking: true,
587
- presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
588
- layout: 'BaseLayout'
589
- });
590
- };
591
- </script>
592
- </body>
593
- </html>`);
594
- } else {
595
- // Fallback to the existing docs page if Swagger UI is not available
596
- const msg = 'Using fallback docs page. Install Swagger UI assets for the full API explorer: npm install swagger-ui-dist';
597
- res.send(generateDocsHTML(this.port, msg));
598
- }
599
- });
111
+ throw new Error(`Health check failed after ${maxAttempts} attempts`);
600
112
  }
601
113
 
602
- async loadOpenApiSpec() {
603
- // Try YAML OpenAPI
604
- const yamlCandidates = [
605
- path.resolve(process.cwd(), 'docs/api-reference.yaml'),
606
- path.resolve(__dirname, '../../../docs/api-reference.yaml')
607
- ];
608
- for (const p of yamlCandidates) {
609
- try {
610
- if (fs.existsSync(p)) {
611
- let yaml;
612
- try {
613
- yaml = require('js-yaml');
614
- } catch {}
615
- if (yaml) {
616
- const content = fs.readFileSync(p, 'utf8');
617
- const parsed = yaml.load(content);
618
- // ensure servers list points to local
619
- parsed.servers = [{ url: `http://localhost:${this.port}` }];
620
- return parsed;
621
- }
622
- }
623
- } catch {}
114
+ /**
115
+ * Load data from backup if provided
116
+ */
117
+ async loadFromBackup() {
118
+ if (!this.fromBackup) {
119
+ return;
624
120
  }
625
121
 
626
- // Fallback: build from manifest
627
- const manifestCandidates = [
628
- path.resolve(process.cwd(), 'docs/manifest.json'),
629
- path.resolve(__dirname, '../../../docs/manifest.json')
630
- ];
631
- for (const p of manifestCandidates) {
632
- try {
633
- if (fs.existsSync(p)) {
634
- const manifest = JSON.parse(fs.readFileSync(p, 'utf8'));
635
- return this.buildOpenApiFromManifest(manifest);
636
- }
637
- } catch {}
122
+ const backupPath = path.resolve(this.fromBackup);
123
+ if (!fs.existsSync(backupPath)) {
124
+ throw new Error(`Backup file not found: ${backupPath}`);
638
125
  }
639
126
 
640
- // Last resort: minimal stub
641
- return {
642
- openapi: '3.0.0',
643
- info: { title: 'SPAPS Local API', version: '0.0.0' },
644
- servers: [{ url: `http://localhost:${this.port}` }],
645
- paths: {
646
- '/health': { get: { summary: 'Health', responses: { '200': { description: 'OK' } } } }
647
- }
648
- };
649
- }
650
-
651
- buildOpenApiFromManifest(manifest) {
652
- const spec = {
653
- openapi: '3.0.0',
654
- info: { title: 'SPAPS API', version: String(manifest.version || '1.0.0') },
655
- servers: [{ url: `http://localhost:${this.port}` }],
656
- paths: {}
657
- };
658
- const toPath = (p) => p.replace(/:(\w+)/g, '{$1}');
659
- for (const ep of manifest.endpoints || []) {
660
- const pathKey = toPath(ep.path);
661
- if (!spec.paths[pathKey]) spec.paths[pathKey] = {};
662
- spec.paths[pathKey][String(ep.method || 'GET').toLowerCase()] = {
663
- summary: ep.description || `${ep.method} ${ep.path}`,
664
- tags: ep.tags || [],
665
- responses: { '200': { description: 'OK' } }
666
- };
127
+ if (!this.json) {
128
+ console.log(chalk.blue(`📦 Loading from backup: ${backupPath}`));
667
129
  }
668
- return spec;
669
- }
670
130
 
671
- seedDemoData() {
672
- // Add a couple of customers and a completed + pending order if none exist
673
- const existingCustomers = this.adminManager.listCustomers();
674
- const products = this.adminManager.listProducts();
675
- if (existingCustomers.length === 0 && products.length > 0) {
676
- const alice = this.adminManager.createCustomer({ email: 'alice@example.com', name: 'Alice' });
677
- const bob = this.adminManager.createCustomer({ email: 'bob@example.com', name: 'Bob' });
678
- const p = products[0];
679
- const order1 = this.adminManager.createOrder({
680
- customer_id: alice.id,
681
- customer_email: alice.email,
682
- product_id: p.id,
683
- price_id: p.price_id,
684
- amount: p.price,
685
- currency: p.currency
686
- });
687
- this.adminManager.updateOrderStatus(order1.id, 'completed');
688
- this.adminManager.createOrder({
689
- customer_id: bob.id,
690
- customer_email: bob.email,
691
- product_id: p.id,
692
- price_id: p.price_id,
693
- amount: p.price,
694
- currency: p.currency
131
+ // Run the transform + load pipeline
132
+ try {
133
+ execSync(`make load-dev-db`, {
134
+ cwd: this.repoRoot,
135
+ stdio: this.json ? 'ignore' : 'inherit',
136
+ env: {
137
+ ...process.env,
138
+ DATA_SQL: backupPath
139
+ }
695
140
  });
696
- if (!this.json) console.log(chalk.gray('🌱 Seeded demo customers and orders'));
141
+
142
+ if (!this.json) {
143
+ console.log(chalk.green('✅ Backup loaded successfully'));
144
+ }
145
+ } catch (error) {
146
+ throw new Error(`Failed to load backup: ${error.message}`);
697
147
  }
698
148
  }
699
149
 
700
- setupStripeRoutes() {
701
- // Enhanced Stripe Product Management - Full CRUD
702
-
703
- // GET /api/stripe/products - List all products with filtering
704
- this.app.get('/api/stripe/products', async (req, res) => {
705
- try {
706
- if (this.stripeMode === 'real') {
707
- const { active, category, limit = '100' } = req.query;
708
-
709
- const products = await stripe.products.list({
710
- active: active === 'true' ? true : active === 'false' ? false : undefined,
711
- limit: parseInt(limit),
712
- expand: ['data.default_price']
713
- });
150
+ /**
151
+ * Start the Docker Compose stack
152
+ */
153
+ async start() {
154
+ try {
155
+ // Check prerequisites
156
+ this.checkDockerCompose();
714
157
 
715
- // Filter out non-SPAPS managed products if needed
716
- let filteredProducts = products.data;
717
- if (category === 'spaps') {
718
- filteredProducts = products.data.filter(p =>
719
- p.metadata && p.metadata.spaps_managed === 'true'
720
- );
721
- }
722
-
723
- res.json({
724
- success: true,
725
- data: {
726
- products: filteredProducts.map(product => ({
727
- id: product.id,
728
- name: product.name,
729
- description: product.description,
730
- images: product.images,
731
- active: product.active,
732
- metadata: product.metadata,
733
- default_price: product.default_price ? {
734
- id: product.default_price.id,
735
- unit_amount: product.default_price.unit_amount,
736
- currency: product.default_price.currency,
737
- recurring: product.default_price.recurring
738
- } : null,
739
- created: product.created,
740
- updated: product.updated
741
- })),
742
- has_more: products.has_more,
743
- total_count: filteredProducts.length
744
- }
745
- });
746
- } else {
747
- // Mock response
748
- const localProducts = this.adminManager.listProducts();
749
- res.json({
750
- success: true,
751
- data: {
752
- products: localProducts,
753
- has_more: false,
754
- total_count: localProducts.length
755
- }
756
- });
757
- }
758
- } catch (error) {
759
- console.error('List products error:', error);
760
- res.status(500).json({
761
- success: false,
762
- error: {
763
- code: 'LIST_PRODUCTS_ERROR',
764
- message: error.message || 'Failed to list products'
765
- }
766
- });
158
+ if (!fs.existsSync(this.composeFile)) {
159
+ throw new Error(`docker-compose.spaps-dev.yml not found at ${this.composeFile}`);
767
160
  }
768
- });
769
-
770
- // POST /api/stripe/products - Create new product with optional price
771
- this.app.post('/api/stripe/products', async (req, res) => {
772
- try {
773
- const { name, description, images, metadata = {}, active = true, price, currency = 'usd' } = req.body;
774
161
 
775
- if (!name) {
776
- return res.status(400).json({
777
- success: false,
778
- error: { message: 'Product name is required' }
779
- });
162
+ // Handle fresh mode
163
+ if (this.fresh) {
164
+ if (!this.json) {
165
+ console.log(chalk.yellow('🔄 Fresh mode: tearing down existing stack...'));
780
166
  }
781
-
782
- if (this.stripeMode === 'real') {
783
- // Create product in Stripe
784
- const stripeProduct = await stripe.products.create({
785
- name,
786
- description,
787
- images,
788
- active,
789
- metadata: {
790
- ...metadata,
791
- spaps_managed: 'true',
792
- created_by: 'spaps_cli'
793
- }
794
- });
795
-
796
- let stripePrice = null;
797
-
798
- // If price is provided, create a price for the product
799
- if (price !== undefined && price !== null && price !== '') {
800
- stripePrice = await stripe.prices.create({
801
- product: stripeProduct.id,
802
- unit_amount: parseInt(price), // Price in cents
803
- currency: currency,
804
- metadata: {
805
- spaps_managed: 'true',
806
- created_by: 'spaps_cli'
807
- }
808
- });
809
-
810
- // Update product with default price
811
- await stripe.products.update(stripeProduct.id, {
812
- default_price: stripePrice.id
813
- });
814
- }
815
-
816
- // Fetch the updated product with default_price
817
- const updatedProduct = await stripe.products.retrieve(stripeProduct.id, {
818
- expand: ['default_price']
819
- });
820
-
821
- res.json({
822
- success: true,
823
- data: {
824
- product: {
825
- id: updatedProduct.id,
826
- name: updatedProduct.name,
827
- description: updatedProduct.description,
828
- images: updatedProduct.images,
829
- active: updatedProduct.active,
830
- metadata: updatedProduct.metadata,
831
- created: updatedProduct.created,
832
- default_price: updatedProduct.default_price ? {
833
- id: updatedProduct.default_price.id,
834
- unit_amount: updatedProduct.default_price.unit_amount,
835
- currency: updatedProduct.default_price.currency
836
- } : null
837
- }
838
- }
839
- });
840
- } else {
841
- // Create locally
842
- const productPrice = price ? parseInt(price) : 0;
843
- const product = this.adminManager.createProduct({
844
- name,
845
- description,
846
- price: productPrice,
847
- currency: currency
848
- });
849
-
850
- // Add mock default_price structure for consistency
851
- product.default_price = productPrice > 0 ? {
852
- id: `price_${product.id}`,
853
- unit_amount: productPrice,
854
- currency: currency
855
- } : null;
856
-
857
- res.json({
858
- success: true,
859
- data: { product }
860
- });
167
+ try {
168
+ this.runCompose(['down', '-v'], { silent: true });
169
+ } catch {
170
+ // Ignore errors if stack doesn't exist
861
171
  }
862
- } catch (error) {
863
- console.error('Create product error:', error);
864
- res.status(500).json({
865
- success: false,
866
- error: {
867
- code: 'CREATE_PRODUCT_ERROR',
868
- message: error.message || 'Failed to create product'
869
- }
870
- });
871
172
  }
872
- });
873
173
 
874
- // PUT /api/stripe/products/:productId - Update product
875
- this.app.put('/api/stripe/products/:productId', async (req, res) => {
876
- try {
877
- const { productId } = req.params;
878
- const { name, description, images, metadata, active } = req.body;
174
+ // Start the stack
175
+ if (!this.json) {
176
+ console.log();
177
+ console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
178
+ console.log(chalk.blue('🐳 Starting Docker Compose stack...'));
179
+ }
879
180
 
880
- if (this.stripeMode === 'real') {
881
- // Update in Stripe
882
- const updateData = {};
883
- if (name !== undefined) updateData.name = name;
884
- if (description !== undefined) updateData.description = description;
885
- if (images !== undefined) updateData.images = images;
886
- if (metadata !== undefined) updateData.metadata = metadata;
887
- if (active !== undefined) updateData.active = active;
181
+ const startArgs = ['up', '-d'];
182
+ this.runCompose(startArgs, { silent: this.json });
888
183
 
889
- const stripeProduct = await stripe.products.update(productId, updateData);
184
+ // Wait for health check
185
+ await this.waitForHealthCheck();
890
186
 
891
- res.json({
892
- success: true,
893
- data: {
894
- product: {
895
- id: stripeProduct.id,
896
- name: stripeProduct.name,
897
- description: stripeProduct.description,
898
- images: stripeProduct.images,
899
- active: stripeProduct.active,
900
- metadata: stripeProduct.metadata,
901
- updated: stripeProduct.updated
902
- }
903
- }
904
- });
905
- } else {
906
- // Update locally
907
- const product = this.adminManager.updateProduct(productId, {
908
- name,
909
- description,
910
- active
911
- });
912
-
913
- res.json({
914
- success: true,
915
- data: { product }
916
- });
917
- }
918
- } catch (error) {
919
- console.error('Update product error:', error);
920
- res.status(500).json({
921
- success: false,
922
- error: {
923
- code: 'UPDATE_PRODUCT_ERROR',
924
- message: error.message || 'Failed to update product'
925
- }
926
- });
187
+ // Load backup if provided
188
+ if (this.fromBackup) {
189
+ await this.loadFromBackup();
927
190
  }
928
- });
929
191
 
930
- // POST /api/stripe/prices - Create a new price for a product
931
- this.app.post('/api/stripe/prices', async (req, res) => {
932
- try {
933
- const { product_id, unit_amount, currency = 'usd', recurring, metadata = {} } = req.body;
934
-
935
- if (!product_id || !unit_amount) {
936
- return res.status(400).json({
937
- success: false,
938
- error: { message: 'Product ID and unit amount are required' }
939
- });
192
+ // Print connection info
193
+ const connectionInfo = {
194
+ SPAPS_API_URL: this.apiUrl,
195
+ SPAPS_API_KEY: 'spaps_local_development_key',
196
+ SPAPS_APPLICATION_ID: '00000000-0000-0000-0000-000000000100',
197
+ test_users: {
198
+ admin: { email: 'admin@spaps.dev', password: 'Admin1234x' },
199
+ user: { email: 'user@spaps.dev', password: 'User1234x' },
200
+ premium: { email: 'premium@spaps.dev', password: 'Premium1234x' }
940
201
  }
202
+ };
941
203
 
942
- if (this.stripeMode === 'real') {
943
- const priceData = {
944
- product: product_id,
945
- unit_amount: parseInt(unit_amount),
946
- currency,
947
- metadata: {
948
- ...metadata,
949
- spaps_managed: 'true'
950
- }
951
- };
952
-
953
- // Add recurring if specified
954
- if (recurring) {
955
- priceData.recurring = recurring;
204
+ if (this.json) {
205
+ console.log(JSON.stringify({
206
+ success: true,
207
+ command: 'local',
208
+ server: {
209
+ url: this.apiUrl,
210
+ docs: `${this.apiUrl}/docs`,
211
+ health: `${this.apiUrl}/health`,
212
+ mode: 'docker-compose',
213
+ port: this.port,
214
+ connection: connectionInfo
956
215
  }
957
-
958
- const stripePrice = await stripe.prices.create(priceData);
959
-
960
- res.json({
961
- success: true,
962
- data: {
963
- price: {
964
- id: stripePrice.id,
965
- product: stripePrice.product,
966
- unit_amount: stripePrice.unit_amount,
967
- currency: stripePrice.currency,
968
- recurring: stripePrice.recurring,
969
- metadata: stripePrice.metadata
970
- }
971
- }
972
- });
216
+ }));
217
+ } else {
218
+ console.log();
219
+ console.log(chalk.green('✨ SPAPS server is running!'));
220
+ console.log();
221
+ console.log(chalk.cyan('📡 Connection Info:'));
222
+ console.log(` ${chalk.bold('API URL:')} ${this.apiUrl}`);
223
+ console.log(` ${chalk.bold('Documentation:')} ${this.apiUrl}/docs`);
224
+ console.log(` ${chalk.bold('Health Check:')} ${this.apiUrl}/health`);
225
+ console.log();
226
+ console.log(chalk.cyan('🔑 Credentials:'));
227
+ console.log(` ${chalk.bold('API Key:')} ${connectionInfo.SPAPS_API_KEY}`);
228
+ console.log(` ${chalk.bold('Application ID:')} ${connectionInfo.SPAPS_APPLICATION_ID}`);
229
+ console.log();
230
+ console.log(chalk.cyan('👥 Test Users:'));
231
+ console.log(` ${chalk.bold('Admin:')} ${connectionInfo.test_users.admin.email} / ${connectionInfo.test_users.admin.password}`);
232
+ console.log(` ${chalk.bold('User:')} ${connectionInfo.test_users.user.email} / ${connectionInfo.test_users.user.password}`);
233
+ console.log(` ${chalk.bold('Premium:')} ${connectionInfo.test_users.premium.email} / ${connectionInfo.test_users.premium.password}`);
234
+ console.log();
235
+
236
+ if (this.detach) {
237
+ console.log(chalk.dim(' Running in background. Use `npx spaps local stop` to stop.'));
238
+ console.log(chalk.dim(' View logs: docker compose -f docker-compose.spaps-dev.yml logs -f'));
239
+ console.log();
973
240
  } else {
974
- // Mock price creation
975
- const price = {
976
- id: `price_${Date.now()}`,
977
- product: product_id,
978
- unit_amount: parseInt(unit_amount),
979
- currency,
980
- recurring,
981
- metadata
982
- };
241
+ console.log(chalk.dim(' Press Ctrl+C to stop'));
242
+ console.log();
243
+ console.log(chalk.gray('─'.repeat(60)));
244
+ console.log();
983
245
 
984
- res.json({
985
- success: true,
986
- data: { price }
987
- });
246
+ // Tail logs
247
+ this.tailLogs();
988
248
  }
989
- } catch (error) {
990
- console.error('Create price error:', error);
991
- res.status(500).json({
992
- success: false,
993
- error: {
994
- code: 'CREATE_PRICE_ERROR',
995
- message: error.message || 'Failed to create price'
996
- }
997
- });
998
249
  }
999
- });
1000
-
1001
- // PUT /api/stripe/products/:productId/default-price - Update product's default price
1002
- this.app.put('/api/stripe/products/:productId/default-price', async (req, res) => {
1003
- try {
1004
- const { productId } = req.params;
1005
- const { price_id } = req.body;
1006
250
 
1007
- if (!price_id) {
1008
- return res.status(400).json({
1009
- success: false,
1010
- error: { message: 'Price ID is required' }
1011
- });
1012
- }
1013
-
1014
- if (this.stripeMode === 'real') {
1015
- const stripeProduct = await stripe.products.update(productId, {
1016
- default_price: price_id
1017
- });
1018
-
1019
- res.json({
1020
- success: true,
1021
- data: {
1022
- product: {
1023
- id: stripeProduct.id,
1024
- name: stripeProduct.name,
1025
- default_price: stripeProduct.default_price
1026
- }
1027
- }
1028
- });
1029
- } else {
1030
- res.json({
1031
- success: true,
1032
- data: {
1033
- product: {
1034
- id: productId,
1035
- default_price: price_id
1036
- }
1037
- }
1038
- });
1039
- }
1040
- } catch (error) {
1041
- console.error('Update default price error:', error);
1042
- res.status(500).json({
251
+ return { success: true };
252
+ } catch (error) {
253
+ if (this.json) {
254
+ console.log(JSON.stringify({
1043
255
  success: false,
1044
- error: {
1045
- code: 'UPDATE_DEFAULT_PRICE_ERROR',
1046
- message: error.message || 'Failed to update default price'
1047
- }
1048
- });
256
+ error: error.message
257
+ }));
258
+ } else {
259
+ console.error(chalk.red('❌ Failed to start SPAPS server:'), error.message);
1049
260
  }
1050
- });
1051
-
1052
- // DELETE /api/stripe/products/:productId - Archive product
1053
- this.app.delete('/api/stripe/products/:productId', async (req, res) => {
1054
- try {
1055
- const { productId } = req.params;
1056
-
1057
- if (this.stripeMode === 'real') {
1058
- // Archive in Stripe (can't truly delete)
1059
- const stripeProduct = await stripe.products.update(productId, {
1060
- active: false
1061
- });
1062
-
1063
- res.json({
1064
- success: true,
1065
- message: 'Product archived successfully',
1066
- data: {
1067
- product: {
1068
- id: stripeProduct.id,
1069
- active: stripeProduct.active,
1070
- updated: stripeProduct.updated
1071
- }
1072
- }
1073
- });
1074
- } else {
1075
- // Soft delete locally
1076
- const result = this.adminManager.deleteProduct(productId);
261
+ throw error;
262
+ }
263
+ }
1077
264
 
1078
- res.json({
1079
- success: true,
1080
- message: 'Product deleted successfully',
1081
- data: result
1082
- });
1083
- }
1084
- } catch (error) {
1085
- console.error('Delete product error:', error);
1086
- res.status(500).json({
1087
- success: false,
1088
- error: {
1089
- code: 'DELETE_PRODUCT_ERROR',
1090
- message: error.message || 'Failed to delete product'
1091
- }
1092
- });
265
+ /**
266
+ * Stop the Docker Compose stack
267
+ */
268
+ stop() {
269
+ try {
270
+ if (!this.json) {
271
+ console.log(chalk.yellow('🛑 Stopping SPAPS Docker Compose stack...'));
1093
272
  }
1094
- });
1095
-
1096
- // Mock Stripe checkout session
1097
- this.app.post('/api/stripe/create-checkout-session', async (req, res) => {
1098
- const { price_id, success_url, cancel_url } = req.body;
1099
- const sessionId = 'cs_local_' + Date.now();
1100
-
1101
- res.json({
1102
- sessionId,
1103
- url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(success_url)}&cancel=${encodeURIComponent(cancel_url)}`
1104
- });
1105
-
1106
- // Simulate webhook after delay
1107
- setTimeout(async () => {
1108
- try {
1109
- await this.simulateCheckoutWebhook(sessionId, price_id);
1110
- if (!this.json) {
1111
- console.log(chalk.blue(`⚡ Webhook simulated: checkout.session.completed`));
1112
- }
1113
- } catch (error) {
1114
- console.error(chalk.red('Webhook simulation failed:'), error);
1115
- }
1116
- }, 2000);
1117
- });
1118
273
 
1119
- // Mock checkout page
1120
- this.app.get('/checkout/:sessionId', (req, res) => {
1121
- const { sessionId } = req.params;
1122
- const { success, cancel } = req.query;
1123
-
1124
- res.send(`
1125
- <!DOCTYPE html>
1126
- <html>
1127
- <head>
1128
- <title>SPAPS Local - Mock Checkout</title>
1129
- <style>
1130
- body { font-family: system-ui; max-width: 400px; margin: 100px auto; padding: 2rem; }
1131
- button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
1132
- .pay { background: #635bff; color: white; }
1133
- .pay:hover { background: #4b41e0; }
1134
- .cancel { background: #f5f5f5; }
1135
- .cancel:hover { background: #e5e5e5; }
1136
- </style>
1137
- </head>
1138
- <body>
1139
- <h1>🍠 Mock Checkout</h1>
1140
- <p>Session: ${sessionId}</p>
1141
- <p>This is a mock checkout page for local development.</p>
1142
-
1143
- <button class="pay" onclick="window.location='${success}'">
1144
- 💳 Complete Payment
1145
- </button>
1146
-
1147
- <button class="cancel" onclick="window.location='${cancel}'">
1148
- Cancel
1149
- </button>
1150
-
1151
- <p style="margin-top: 2rem; color: #666; font-size: 14px;">
1152
- In production, this would be a real Stripe Checkout page.
1153
- </p>
1154
- </body>
1155
- </html>
1156
- `);
1157
- });
274
+ this.runCompose(['down'], { silent: this.json });
1158
275
 
1159
- // Mock customer portal page
1160
- this.app.get('/customer-portal', (req, res) => {
1161
- const { return: returnUrl } = req.query;
1162
-
1163
- res.send(`
1164
- <!DOCTYPE html>
1165
- <html>
1166
- <head>
1167
- <title>SPAPS Local - Customer Portal</title>
1168
- <style>
1169
- body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 2rem; }
1170
- button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
1171
- .primary { background: #635bff; color: white; }
1172
- .primary:hover { background: #4b41e0; }
1173
- .secondary { background: #f5f5f5; }
1174
- .secondary:hover { background: #e5e5e5; }
1175
- .section { background: #f9f9f9; padding: 1.5rem; margin: 1rem 0; border-radius: 8px; }
1176
- </style>
1177
- </head>
1178
- <body>
1179
- <h1>🍠 Customer Portal (Local)</h1>
1180
- <p>Manage your subscription and billing information.</p>
1181
-
1182
- <div class="section">
1183
- <h3>Current Subscription</h3>
1184
- <p><strong>Plan:</strong> Premium Plan</p>
1185
- <p><strong>Status:</strong> Active</p>
1186
- <p><strong>Next billing:</strong> ${new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString()}</p>
1187
- </div>
1188
-
1189
- <div class="section">
1190
- <h3>Payment Method</h3>
1191
- <p><strong>Card:</strong> •••• •••• •••• 4242</p>
1192
- <p><strong>Expires:</strong> 12/2025</p>
1193
- <button class="secondary">Update Payment Method</button>
1194
- </div>
1195
-
1196
- <div class="section">
1197
- <h3>Billing History</h3>
1198
- <p>• $25.00 - Premium Plan (${new Date().toLocaleDateString()})</p>
1199
- <p>• $25.00 - Premium Plan (${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toLocaleDateString()})</p>
1200
- <button class="secondary">Download All Invoices</button>
1201
- </div>
1202
-
1203
- <button class="primary" onclick="window.location='${returnUrl || 'http://localhost:3000'}'">
1204
- Return to Application
1205
- </button>
1206
-
1207
- <p style="margin-top: 2rem; color: #666; font-size: 14px;">
1208
- This is a mock customer portal for local development.
1209
- </p>
1210
- </body>
1211
- </html>
1212
- `);
1213
- });
1214
-
1215
- // Stripe webhook endpoint - REAL or MOCK based on config
1216
- this.app.post('/api/stripe/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
1217
- try {
1218
- let event;
1219
-
1220
- if (this.stripeMode === 'real') {
1221
- // Real Stripe webhook verification
1222
- const sig = req.headers['stripe-signature'];
1223
- const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
1224
-
1225
- if (webhookSecret && sig) {
1226
- try {
1227
- event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
1228
- } catch (err) {
1229
- console.error('Webhook signature verification failed:', err.message);
1230
- return res.status(400).send(`Webhook Error: ${err.message}`);
1231
- }
1232
- } else {
1233
- // For local development without webhook secret
1234
- event = JSON.parse(req.body.toString());
1235
- }
1236
- } else {
1237
- // Mock mode - accept all webhooks
1238
- if (Buffer.isBuffer(req.body)) {
1239
- event = JSON.parse(req.body.toString());
1240
- } else if (typeof req.body === 'string') {
1241
- event = JSON.parse(req.body);
1242
- } else {
1243
- event = req.body;
1244
- }
1245
- }
1246
-
1247
- if (!this.json) {
1248
- console.log(chalk.blue(`⚡ Webhook received: ${event.type}`));
1249
- }
1250
-
1251
- // Handle the event
1252
- switch (event.type) {
1253
- case 'checkout.session.completed':
1254
- const session = event.data.object;
1255
- console.log(chalk.green(`✅ Payment successful: ${session.id}`));
1256
- break;
1257
- case 'payment_intent.succeeded':
1258
- const paymentIntent = event.data.object;
1259
- console.log(chalk.green(`💰 Payment intent succeeded: ${paymentIntent.id}`));
1260
- break;
1261
- case 'customer.subscription.created':
1262
- const subscription = event.data.object;
1263
- console.log(chalk.green(`📋 Subscription created: ${subscription.id}`));
1264
- break;
1265
- default:
1266
- console.log(chalk.yellow(`🔔 Unhandled event type: ${event.type}`));
1267
- }
1268
-
1269
- // Store for testing
1270
- this.lastWebhookEvent = event;
1271
-
1272
- res.json({ received: true });
1273
- } catch (error) {
1274
- console.error('Webhook processing error:', error);
1275
- res.status(500).json({ error: error.message });
276
+ if (this.json) {
277
+ console.log(JSON.stringify({
278
+ success: true,
279
+ command: 'local stop',
280
+ message: 'SPAPS server stopped'
281
+ }));
282
+ } else {
283
+ console.log(chalk.green('✅ SPAPS server stopped'));
1276
284
  }
1277
- });
1278
285
 
1279
- // Webhook testing UI
1280
- this.app.get('/api/stripe/webhooks/test', (req, res) => {
1281
- res.send(`
1282
- <!DOCTYPE html>
1283
- <html>
1284
- <head>
1285
- <title>SPAPS - Stripe Webhook Tester</title>
1286
- <style>
1287
- body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
1288
- button { background: #635bff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; margin: 0.25rem; }
1289
- button:hover { background: #4b41e0; }
1290
- .event { background: #f9f9f9; padding: 1rem; margin: 1rem 0; border-radius: 8px; border-left: 4px solid #635bff; }
1291
- </style>
1292
- </head>
1293
- <body>
1294
- <h1>🍠 Stripe Webhook Tester</h1>
1295
-
1296
- <h2>Simulate Events</h2>
1297
- <div>
1298
- <button onclick="simulate('checkout.session.completed')">Checkout Completed</button>
1299
- <button onclick="simulate('payment_intent.succeeded')">Payment Success</button>
1300
- <button onclick="simulate('customer.subscription.created')">Subscription Created</button>
1301
- </div>
1302
-
1303
- <h2>Last Event</h2>
1304
- <div id="lastEvent">No events yet</div>
1305
-
1306
- <script>
1307
- async function simulate(type) {
1308
- const response = await fetch('/api/stripe/webhooks', {
1309
- method: 'POST',
1310
- headers: { 'Content-Type': 'application/json' },
1311
- body: JSON.stringify({
1312
- id: 'evt_local_' + Date.now(),
1313
- type: type,
1314
- data: { object: { id: type.split('.')[0] + '_' + Date.now() } }
1315
- })
1316
- });
1317
-
1318
- if (response.ok) {
1319
- document.getElementById('lastEvent').innerHTML =
1320
- '<div class="event">✅ ' + type + ' - ' + new Date().toLocaleTimeString() + '</div>';
1321
- }
1322
- }
1323
- </script>
1324
- </body>
1325
- </html>
1326
- `);
1327
- });
1328
- }
1329
-
1330
- async simulateCheckoutWebhook(sessionId, priceId) {
1331
- const event = {
1332
- id: 'evt_local_' + Date.now(),
1333
- type: 'checkout.session.completed',
1334
- data: {
1335
- object: {
1336
- id: sessionId,
1337
- amount_total: this.getPriceAmount(priceId),
1338
- currency: 'usd',
1339
- customer: 'cus_local_' + Date.now(),
1340
- payment_status: 'paid',
1341
- status: 'complete',
1342
- metadata: { app_id: 'local-app-001', price_id: priceId }
1343
- }
286
+ return { success: true };
287
+ } catch (error) {
288
+ if (this.json) {
289
+ console.log(JSON.stringify({
290
+ success: false,
291
+ error: error.message
292
+ }));
293
+ } else {
294
+ console.error(chalk.red('❌ Failed to stop SPAPS server:'), error.message);
1344
295
  }
1345
- };
1346
-
1347
- // Send to webhook endpoint
1348
- const response = await fetch(`http://localhost:${this.port}/api/stripe/webhooks`, {
1349
- method: 'POST',
1350
- headers: { 'Content-Type': 'application/json' },
1351
- body: JSON.stringify(event)
1352
- });
1353
-
1354
- return response.ok;
1355
- }
1356
-
1357
- getPriceAmount(priceId) {
1358
- const prices = {
1359
- 'price_local_validate': 50000,
1360
- 'price_local_prototype': 250000,
1361
- 'price_local_strategy': 1000000,
1362
- 'price_local_build': 2500000
1363
- };
1364
- return prices[priceId] || 10000;
296
+ throw error;
297
+ }
1365
298
  }
1366
299
 
1367
- setupAdminRoutes() {
1368
- // Admin API endpoints
1369
-
1370
- // List products
1371
- this.app.get('/api/admin/products', (req, res) => {
1372
- const products = this.adminManager.listProducts();
1373
- res.json({ success: true, data: products });
1374
- });
1375
-
1376
- // Get single product
1377
- this.app.get('/api/admin/products/:id', (req, res) => {
1378
- try {
1379
- const product = this.adminManager.getProduct(req.params.id);
1380
- if (!product) {
1381
- return res.status(404).json({ success: false, error: 'Product not found' });
1382
- }
1383
- res.json({ success: true, data: product });
1384
- } catch (error) {
1385
- res.status(500).json({ success: false, error: error.message });
1386
- }
1387
- });
300
+ /**
301
+ * Tail logs from the API container
302
+ */
303
+ tailLogs() {
304
+ const composeCmd = this.checkDockerCompose();
305
+ const cmdParts = composeCmd.split(' ');
306
+ const command = cmdParts[0]; // 'docker'
307
+ const subArgs = cmdParts.slice(1); // ['compose'] or []
308
+ const args = [...subArgs, '-f', this.composeFile, 'logs', '-f', '--tail=50', 'spaps-dev-api'];
1388
309
 
1389
- // Create product
1390
- this.app.post('/api/admin/products', (req, res) => {
1391
- try {
1392
- const product = this.adminManager.createProduct(req.body);
1393
- res.json({ success: true, data: product });
1394
- } catch (error) {
1395
- res.status(400).json({ success: false, error: error.message });
1396
- }
310
+ this.logProcess = spawn(command, args, {
311
+ cwd: this.repoRoot,
312
+ stdio: 'inherit'
1397
313
  });
1398
314
 
1399
- // Update product
1400
- this.app.put('/api/admin/products/:id', (req, res) => {
1401
- try {
1402
- const product = this.adminManager.updateProduct(req.params.id, req.body);
1403
- res.json({ success: true, data: product });
1404
- } catch (error) {
1405
- res.status(400).json({ success: false, error: error.message });
1406
- }
1407
- });
1408
-
1409
- // Delete product
1410
- this.app.delete('/api/admin/products/:id', (req, res) => {
1411
- try {
1412
- const result = this.adminManager.deleteProduct(req.params.id);
1413
- res.json(result);
1414
- } catch (error) {
1415
- res.status(400).json({ success: false, error: error.message });
315
+ this.logProcess.on('error', (error) => {
316
+ if (!this.json) {
317
+ console.error(chalk.red('❌ Failed to tail logs:'), error.message);
1416
318
  }
1417
319
  });
320
+ }
1418
321
 
1419
- // List orders
1420
- this.app.get('/api/admin/orders', (req, res) => {
1421
- const orders = this.adminManager.listOrders(req.query);
1422
- res.json({ success: true, data: orders });
1423
- });
1424
-
1425
- // Create order (for testing)
1426
- this.app.post('/api/admin/orders', (req, res) => {
1427
- try {
1428
- const order = this.adminManager.createOrder(req.body);
1429
- res.json({ success: true, data: order });
1430
- } catch (error) {
1431
- res.status(400).json({ success: false, error: error.message });
1432
- }
1433
- });
322
+ /**
323
+ * Clean shutdown
324
+ */
325
+ async shutdown() {
326
+ if (this.logProcess) {
327
+ this.logProcess.kill();
328
+ }
1434
329
 
1435
- // Update order status
1436
- this.app.patch('/api/admin/orders/:id/status', (req, res) => {
1437
- try {
1438
- const order = this.adminManager.updateOrderStatus(req.params.id, req.body.status);
1439
- res.json({ success: true, data: order });
1440
- } catch (error) {
1441
- res.status(400).json({ success: false, error: error.message });
330
+ if (!this.detach) {
331
+ if (!this.json) {
332
+ console.log();
333
+ console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
1442
334
  }
1443
- });
1444
335
 
1445
- // Analytics dashboard
1446
- this.app.get('/api/admin/analytics', (req, res) => {
1447
- const analytics = this.adminManager.getAnalytics();
1448
- res.json({ success: true, data: analytics });
1449
- });
1450
-
1451
- // Export data (for migration to production)
1452
- this.app.get('/api/admin/export', (req, res) => {
1453
- const data = this.adminManager.exportData();
1454
- res.json({ success: true, data });
1455
- });
1456
-
1457
- // Import data
1458
- this.app.post('/api/admin/import', (req, res) => {
1459
336
  try {
1460
- const result = this.adminManager.importData(req.body);
1461
- res.json({ success: true, data: result });
1462
- } catch (error) {
1463
- res.status(400).json({ success: false, error: error.message });
1464
- }
1465
- });
1466
-
1467
- // Admin UI Dashboard
1468
- this.app.get('/admin', (req, res) => {
1469
- const analytics = this.adminManager.getAnalytics();
1470
- const products = this.adminManager.listProducts();
1471
-
1472
- res.send(`
1473
- <!DOCTYPE html>
1474
- <html>
1475
- <head>
1476
- <title>SPAPS Admin - Local Mode</title>
1477
- <style>
1478
- * { margin: 0; padding: 0; box-sizing: border-box; }
1479
- body { font-family: system-ui; background: #f5f5f5; }
1480
- .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; }
1481
- .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
1482
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin: 2rem 0; }
1483
- .card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
1484
- .card h3 { margin-bottom: 1rem; color: #333; }
1485
- .stat { font-size: 2rem; font-weight: bold; color: #667eea; }
1486
- .label { color: #666; font-size: 0.9rem; }
1487
- table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
1488
- th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
1489
- th { background: #f9f9f9; font-weight: 600; }
1490
- .btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
1491
- .btn:hover { background: #764ba2; }
1492
- .badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; }
1493
- .badge-success { background: #10b981; color: white; }
1494
- .badge-pending { background: #f59e0b; color: white; }
1495
- .nav { display: flex; gap: 2rem; margin-bottom: 2rem; }
1496
- .nav a { color: #667eea; text-decoration: none; font-weight: 500; }
1497
- .nav a:hover { text-decoration: underline; }
1498
- </style>
1499
- </head>
1500
- <body>
1501
- <div class="header">
1502
- <div class="container">
1503
- <h1>🍠 SPAPS Admin Dashboard</h1>
1504
- <p>Local Development Mode - Managing test data</p>
1505
- </div>
1506
- </div>
1507
-
1508
- <div class="container">
1509
- <div class="nav">
1510
- <a href="/admin">Dashboard</a>
1511
- <a href="/api/admin/products">Products API</a>
1512
- <a href="/api/admin/orders">Orders API</a>
1513
- <a href="/api/admin/export">Export Data</a>
1514
- <a href="/docs">Documentation</a>
1515
- </div>
1516
-
1517
- <div class="grid">
1518
- <div class="card">
1519
- <h3>Total Revenue</h3>
1520
- <div class="stat">$${(analytics.total_revenue / 100).toLocaleString()}</div>
1521
- <div class="label">From ${analytics.completed_orders} orders</div>
1522
- </div>
1523
-
1524
- <div class="card">
1525
- <h3>Active Products</h3>
1526
- <div class="stat">${analytics.total_products}</div>
1527
- <div class="label">Available for purchase</div>
1528
- </div>
1529
-
1530
- <div class="card">
1531
- <h3>Customers</h3>
1532
- <div class="stat">${analytics.total_customers}</div>
1533
- <div class="label">Total registered</div>
1534
- </div>
1535
-
1536
- <div class="card">
1537
- <h3>Recent Orders</h3>
1538
- <div class="stat">${analytics.recent_orders}</div>
1539
- <div class="label">Last 30 days</div>
1540
- </div>
1541
- </div>
1542
-
1543
- <div class="card">
1544
- <h3>Products</h3>
1545
- <table>
1546
- <thead>
1547
- <tr>
1548
- <th>Name</th>
1549
- <th>Price</th>
1550
- <th>Status</th>
1551
- <th>Actions</th>
1552
- </tr>
1553
- </thead>
1554
- <tbody>
1555
- ${products.map(p => `
1556
- <tr>
1557
- <td><strong>${p.name}</strong><br><small>${p.description}</small></td>
1558
- <td>$${(p.price / 100).toLocaleString()}</td>
1559
- <td><span class="badge badge-${p.active ? 'success' : 'pending'}">${p.active ? 'Active' : 'Inactive'}</span></td>
1560
- <td>
1561
- <button class="btn" onclick="editProduct('${p.id}')">Edit</button>
1562
- </td>
1563
- </tr>
1564
- `).join('')}
1565
- </tbody>
1566
- </table>
1567
-
1568
- <div style="margin-top: 1rem;">
1569
- <button class="btn" onclick="addProduct()">+ Add Product</button>
1570
- </div>
1571
- </div>
1572
-
1573
- <div class="card" style="margin-top: 2rem;">
1574
- <h3>Recent Activity</h3>
1575
- <table>
1576
- <tbody>
1577
- ${analytics.recent_activity.map(a => `
1578
- <tr>
1579
- <td>${a.message}</td>
1580
- <td style="text-align: right; color: #999;">${new Date(a.timestamp).toLocaleString()}</td>
1581
- </tr>
1582
- `).join('')}
1583
- </tbody>
1584
- </table>
1585
- </div>
1586
-
1587
- <div class="card" style="margin-top: 2rem; background: #fef3c7;">
1588
- <h3>💡 Local Mode Notice</h3>
1589
- <p style="margin-top: 1rem;">
1590
- You're running in local development mode. All data is stored locally in <code>.spaps/</code> directory.
1591
- </p>
1592
- <p style="margin-top: 0.5rem;">
1593
- To migrate to production:
1594
- </p>
1595
- <ol style="margin: 1rem 0 0 2rem;">
1596
- <li>Export your data: <code>GET /api/admin/export</code></li>
1597
- <li>Set up Stripe products in production</li>
1598
- <li>Import customer data to production database</li>
1599
- <li>Update environment variables</li>
1600
- </ol>
1601
- </div>
1602
- </div>
1603
-
1604
- <script>
1605
- function editProduct(id) {
1606
- window.location.href = '/api/admin/products/' + id;
1607
- }
1608
-
1609
- function addProduct() {
1610
- const name = prompt('Product name:');
1611
- if (!name) return;
1612
-
1613
- const price = prompt('Price (in dollars):');
1614
- if (!price) return;
1615
-
1616
- fetch('/api/admin/products', {
1617
- method: 'POST',
1618
- headers: { 'Content-Type': 'application/json' },
1619
- body: JSON.stringify({
1620
- name,
1621
- description: prompt('Description:') || '',
1622
- price: Math.round(parseFloat(price) * 100)
1623
- })
1624
- })
1625
- .then(r => r.json())
1626
- .then(data => {
1627
- if (data.success) {
1628
- alert('Product created!');
1629
- location.reload();
1630
- }
1631
- });
1632
- }
1633
- </script>
1634
- </body>
1635
- </html>
1636
- `);
1637
- });
1638
- }
337
+ this.runCompose(['down'], { silent: this.json });
1639
338
 
1640
- setupCatchAll() {
1641
- // Catch-all for unimplemented routes
1642
- this.app.use((req, res) => {
1643
- res.status(404).json({
1644
- error: 'Not found',
1645
- message: `Endpoint ${req.method} ${req.path} not implemented in local mode`,
1646
- suggestion: 'Check /docs for available endpoints'
1647
- });
1648
- });
1649
- }
1650
-
1651
- start() {
1652
- return new Promise((resolve, reject) => {
1653
- const server = this.app.listen(this.port, (err) => {
1654
- if (err) {
1655
- reject(err);
1656
- } else {
1657
- if (!this.json) {
1658
- console.log();
1659
- console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
1660
- console.log(chalk.green(`✨ Running at: http://localhost:${this.port}`));
1661
- console.log(chalk.blue(`📝 Documentation: http://localhost:${this.port}/docs`));
1662
- if (this.stripeMode === 'real') {
1663
- console.log(chalk.magenta('💳 Stripe: Real test mode (live API calls)'));
1664
- } else {
1665
- console.log(chalk.gray('💳 Stripe: Mock mode (simulated responses)'));
1666
- }
1667
- console.log(chalk.dim(' Press Ctrl+C to stop'));
1668
- console.log();
1669
- }
1670
- resolve(server);
339
+ if (!this.json) {
340
+ console.log(chalk.green('✅ Server stopped'));
1671
341
  }
1672
- });
1673
-
1674
- // Handle errors
1675
- server.on('error', (err) => {
342
+ } catch (error) {
1676
343
  if (!this.json) {
1677
- if (err.code === 'EADDRINUSE') {
1678
- console.error(chalk.red(`❌ Port ${this.port} is already in use`));
1679
- console.log(chalk.yellow('💡 Try: spaps local --port 3301'));
1680
- } else {
1681
- console.error(chalk.red('❌ Server error:'), err.message);
1682
- }
344
+ console.error(chalk.red('❌ Error during shutdown:'), error.message);
1683
345
  }
1684
- reject(err);
1685
- });
1686
- });
346
+ }
347
+ }
1687
348
  }
1688
349
  }
1689
350
 
@@ -1693,11 +354,15 @@ module.exports = LocalServer;
1693
354
  // Run directly if called as script
1694
355
  if (require.main === module) {
1695
356
  const server = new LocalServer();
1696
- server.start().catch(console.error);
1697
-
1698
- // Graceful shutdown
1699
- process.on('SIGINT', () => {
1700
- console.log(chalk.yellow('\n👋 Shutting down...'));
357
+
358
+ // Handle Ctrl+C
359
+ process.on('SIGINT', async () => {
360
+ await server.shutdown();
1701
361
  process.exit(0);
1702
362
  });
363
+
364
+ server.start().catch((error) => {
365
+ console.error(chalk.red('❌ Fatal error:'), error.message);
366
+ process.exit(1);
367
+ });
1703
368
  }