spaps 0.6.0 → 0.7.1

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