spaps 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AI_TOOLS.json ADDED
@@ -0,0 +1,114 @@
1
+ {
2
+ "name": "spaps",
3
+ "version": "0.1.0",
4
+ "description": "Auth + payments via SPAPS (local by default). Start with: npx spaps local",
5
+ "base_url": "http://localhost:3300",
6
+ "auth": {
7
+ "local_mode": true,
8
+ "production": {
9
+ "header": "X-API-Key",
10
+ "env": "SPAPS_API_KEY"
11
+ }
12
+ },
13
+ "tools": [
14
+ {
15
+ "name": "login",
16
+ "description": "Login with email/password. Local mode accepts any values.",
17
+ "method": "POST",
18
+ "path": "/api/auth/login",
19
+ "parameters": {
20
+ "type": "object",
21
+ "required": ["email", "password"],
22
+ "properties": {
23
+ "email": { "type": "string" },
24
+ "password": { "type": "string" }
25
+ }
26
+ }
27
+ },
28
+ {
29
+ "name": "register",
30
+ "description": "Register a new user with email/password.",
31
+ "method": "POST",
32
+ "path": "/api/auth/register",
33
+ "parameters": {
34
+ "type": "object",
35
+ "required": ["email", "password"],
36
+ "properties": {
37
+ "email": { "type": "string" },
38
+ "password": { "type": "string" }
39
+ }
40
+ }
41
+ },
42
+ {
43
+ "name": "get_current_user",
44
+ "description": "Get the currently authenticated user.",
45
+ "method": "GET",
46
+ "path": "/api/auth/user",
47
+ "parameters": {
48
+ "type": "object",
49
+ "properties": {
50
+ "authorization": { "type": "string", "description": "Bearer <access_token>" }
51
+ }
52
+ }
53
+ },
54
+ {
55
+ "name": "create_checkout_session",
56
+ "description": "Create a Stripe Checkout session.",
57
+ "method": "POST",
58
+ "path": "/api/stripe/checkout-sessions",
59
+ "parameters": {
60
+ "type": "object",
61
+ "required": ["success_url", "cancel_url"],
62
+ "properties": {
63
+ "price_id": { "type": "string" },
64
+ "product_name": { "type": "string" },
65
+ "amount": { "type": "number" },
66
+ "currency": { "type": "string", "default": "usd" },
67
+ "success_url": { "type": "string" },
68
+ "cancel_url": { "type": "string" }
69
+ }
70
+ }
71
+ },
72
+ {
73
+ "name": "list_products",
74
+ "description": "List products (Stripe-backed or local).",
75
+ "method": "GET",
76
+ "path": "/api/stripe/products",
77
+ "parameters": {
78
+ "type": "object",
79
+ "properties": {
80
+ "active": { "type": "boolean" },
81
+ "limit": { "type": "number" }
82
+ }
83
+ }
84
+ },
85
+ {
86
+ "name": "request_magic_link",
87
+ "description": "Send a magic link for passwordless login (simulated locally).",
88
+ "method": "POST",
89
+ "path": "/api/auth/magic-link",
90
+ "parameters": {
91
+ "type": "object",
92
+ "required": ["email"],
93
+ "properties": {
94
+ "email": { "type": "string" }
95
+ }
96
+ }
97
+ },
98
+ {
99
+ "name": "get_wallet_nonce",
100
+ "description": "Get a nonce to sign for wallet authentication.",
101
+ "method": "POST",
102
+ "path": "/api/auth/nonce",
103
+ "parameters": {
104
+ "type": "object",
105
+ "required": ["wallet_address"],
106
+ "properties": {
107
+ "wallet_address": { "type": "string" },
108
+ "chain_type": { "type": "string", "enum": ["solana", "ethereum", "bitcoin", "base"] }
109
+ }
110
+ }
111
+ }
112
+ ]
113
+ }
114
+
package/README.md CHANGED
@@ -25,9 +25,9 @@ npm install spaps-sdk
25
25
  Minimal init (works for both local and prod):
26
26
 
27
27
  ```ts
28
- import { SweetPotatoSDK } from 'spaps-sdk'
28
+ import { SPAPSClient } from 'spaps-sdk'
29
29
 
30
- export const sdk = new SweetPotatoSDK({
30
+ export const sdk = new SPAPSClient({
31
31
  apiUrl: process.env.SPAPS_API_URL || 'http://localhost:3300',
32
32
  apiKey: process.env.SPAPS_API_KEY, // not required in local mode
33
33
  })
@@ -46,6 +46,9 @@ spaps local
46
46
 
47
47
  Your local SPAPS server runs at `http://localhost:3300` šŸŽ‰
48
48
 
49
+ - API docs (Swagger UI): `http://localhost:3300/docs`
50
+ - OpenAPI JSON: `http://localhost:3300/openapi.json`
51
+
49
52
  Point your app (via `SPAPS_API_URL`) to that URL and use `spaps-sdk` for calls.
50
53
 
51
54
  ## Local → Prod
@@ -83,9 +86,11 @@ Perfect for **rapid prototyping**, **hackathons**, and **local development**.
83
86
  Start a full-featured local server with zero configuration:
84
87
 
85
88
  ```bash
86
- spaps local # Default: http://localhost:3300
87
- spaps local --port 3000 # Custom port
88
- spaps local --json # JSON output (CI-friendly)
89
+ spaps local # Default: http://localhost:3300
90
+ spaps local --port 3000 # Custom port
91
+ spaps local --stripe mock|real # Choose Stripe mode (default: mock)
92
+ spaps local --seed demo # Seed demo products/customers/orders
93
+ spaps local --json # JSON output (CI-friendly)
89
94
  ```
90
95
 
91
96
  Includes:
@@ -98,7 +103,9 @@ Includes:
98
103
 
99
104
  Flags:
100
105
 
101
- - `--port <number>`: Set a custom port (default: 3456)
106
+ - `--port <number>`: Set a custom port (default: 3300)
107
+ - `--stripe <mode>`: Stripe mode `mock` (offline, default) or `real` (test API)
108
+ - `--seed <preset>`: Seed local data; supported: `demo`
102
109
  - `--open`: Open docs in your browser after start
103
110
  - `--json`: JSON machine-readable output (ideal for CI)
104
111
 
@@ -127,6 +134,8 @@ spaps status
127
134
  - `spaps help` — Quick help; `spaps help --interactive` for guided setup
128
135
  - `spaps docs` — SDK docs; `spaps docs --interactive` or `--search "query"`
129
136
  - `spaps quickstart` — Minimal SDK usage instructions
137
+ - `spaps tools` — Output AI tool spec (use `--json` to save)
138
+ - `spaps doctor` — Diagnose local environment and config
130
139
 
131
140
  ### JSON Mode (CI)
132
141
 
@@ -136,6 +145,24 @@ All commands that support `--json` will print machine-readable output. Example:
136
145
  npx spaps local --port 0 --json | jq '.'
137
146
  ```
138
147
 
148
+ AI tool spec (OpenAI-style):
149
+
150
+ ```bash
151
+ npx spaps tools --json > spaps-tools.json
152
+ ```
153
+
154
+ Run diagnostics:
155
+
156
+ ```bash
157
+ npx spaps doctor --json
158
+
159
+ OpenAPI JSON:
160
+
161
+ ```bash
162
+ curl http://localhost:3300/openapi.json | jq '.'
163
+ ```
164
+ ```
165
+
139
166
  ## šŸŽÆ Key Features
140
167
 
141
168
  ### šŸ”§ **Zero Configuration**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Sweet Potato Authentication & Payment Service CLI - Zero-config local development with built-in admin middleware and permission utilities",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -29,21 +29,23 @@
29
29
  "ethereum"
30
30
  ],
31
31
  "author": "buildooor",
32
- "license": "MIT",
32
+ "license": "UNLICENSED",
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/buildooor/sweet-potato"
35
+ "url": "https://github.com/build000r"
36
36
  },
37
37
  "bugs": {
38
- "url": "https://github.com/build000r/sweet-potato/issues"
38
+ "email": "buildooor@gmail.com"
39
39
  },
40
- "homepage": "https://sweetpotato.dev",
40
+ "homepage": "https://www.buildooor.com/services",
41
41
  "dependencies": {
42
42
  "axios": "^1.6.0",
43
43
  "chalk": "^4.1.2",
44
44
  "commander": "^11.1.0",
45
45
  "cors": "^2.8.5",
46
46
  "express": "^4.18.2",
47
+ "js-yaml": "^4.1.0",
48
+ "swagger-ui-dist": "^5.17.14",
47
49
  "ora": "^5.4.1",
48
50
  "prompts": "^2.4.2",
49
51
  "stripe": "^18.5.0",
@@ -55,6 +57,7 @@
55
57
  "files": [
56
58
  "bin",
57
59
  "src",
60
+ "AI_TOOLS.json",
58
61
  "client.js",
59
62
  "README.md"
60
63
  ]
@@ -0,0 +1,298 @@
1
+ /**
2
+ * AI Tool Spec generator for SPAPS
3
+ * - Produces OpenAI-style function schemas for common SPAPS actions
4
+ * - Keeps defaults safe for local development (no API key required)
5
+ */
6
+
7
+ const { DEFAULT_PORT } = require('./config');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ function tryLoadManifest() {
12
+ const candidates = [
13
+ path.resolve(process.cwd(), 'docs/manifest.json'),
14
+ path.resolve(__dirname, '../../../docs/manifest.json')
15
+ ];
16
+ for (const p of candidates) {
17
+ try {
18
+ if (fs.existsSync(p)) {
19
+ const raw = fs.readFileSync(p, 'utf8');
20
+ return JSON.parse(raw);
21
+ }
22
+ } catch {}
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function tryLoadOpenAPI() {
28
+ const candidates = [
29
+ path.resolve(process.cwd(), 'docs/api-reference.yaml'),
30
+ path.resolve(__dirname, '../../../docs/api-reference.yaml')
31
+ ];
32
+ for (const p of candidates) {
33
+ try {
34
+ if (fs.existsSync(p)) {
35
+ const yaml = require('js-yaml');
36
+ const raw = fs.readFileSync(p, 'utf8');
37
+ return yaml.load(raw);
38
+ }
39
+ } catch {}
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function buildOpenAIToolSpec({ port = DEFAULT_PORT } = {}) {
45
+ const baseUrl = `http://localhost:${port}`;
46
+ const spec = {
47
+ name: 'spaps',
48
+ version: '0.1.0',
49
+ description: 'Auth + payments via SPAPS (local by default). Start with: npx spaps local',
50
+ base_url: baseUrl,
51
+ auth: {
52
+ local_mode: true,
53
+ production: {
54
+ header: 'X-API-Key',
55
+ env: 'SPAPS_API_KEY'
56
+ }
57
+ },
58
+ tools: [
59
+ {
60
+ name: 'login',
61
+ description: 'Login with email/password. Local mode accepts any values.',
62
+ method: 'POST',
63
+ path: '/api/auth/login',
64
+ parameters: {
65
+ type: 'object',
66
+ required: ['email', 'password'],
67
+ properties: {
68
+ email: { type: 'string', description: 'Email address' },
69
+ password: { type: 'string', description: 'Plain text password' }
70
+ }
71
+ }
72
+ },
73
+ {
74
+ name: 'register',
75
+ description: 'Register a new user with email/password.',
76
+ method: 'POST',
77
+ path: '/api/auth/register',
78
+ parameters: {
79
+ type: 'object',
80
+ required: ['email', 'password'],
81
+ properties: {
82
+ email: { type: 'string' },
83
+ password: { type: 'string' }
84
+ }
85
+ }
86
+ },
87
+ {
88
+ name: 'get_current_user',
89
+ description: 'Get the currently authenticated user. Uses bearer token from previous login.',
90
+ method: 'GET',
91
+ path: '/api/auth/user',
92
+ parameters: {
93
+ type: 'object',
94
+ properties: {
95
+ authorization: { type: 'string', description: 'Bearer <access_token>' }
96
+ }
97
+ }
98
+ },
99
+ {
100
+ name: 'create_checkout_session',
101
+ description: 'Create a Stripe Checkout session. In local mode uses Stripe test or mock based on USE_REAL_STRIPE.',
102
+ method: 'POST',
103
+ path: '/api/stripe/checkout-sessions',
104
+ parameters: {
105
+ type: 'object',
106
+ required: ['success_url', 'cancel_url'],
107
+ properties: {
108
+ price_id: { type: 'string', description: 'Existing Stripe price ID (preferred)' },
109
+ product_name: { type: 'string', description: 'Used when price_id not provided' },
110
+ amount: { type: 'number', description: 'Amount in cents if creating ad-hoc price' },
111
+ currency: { type: 'string', default: 'usd' },
112
+ success_url: { type: 'string' },
113
+ cancel_url: { type: 'string' }
114
+ }
115
+ }
116
+ },
117
+ {
118
+ name: 'list_products',
119
+ description: 'List products (Stripe-backed or local).',
120
+ method: 'GET',
121
+ path: '/api/stripe/products',
122
+ parameters: {
123
+ type: 'object',
124
+ properties: {
125
+ active: { type: 'boolean' },
126
+ limit: { type: 'number' }
127
+ }
128
+ }
129
+ },
130
+ {
131
+ name: 'request_magic_link',
132
+ description: 'Send a magic link for passwordless login (local mode simulates delivery).',
133
+ method: 'POST',
134
+ path: '/api/auth/magic-link',
135
+ parameters: {
136
+ type: 'object',
137
+ required: ['email'],
138
+ properties: {
139
+ email: { type: 'string' }
140
+ }
141
+ }
142
+ },
143
+ {
144
+ name: 'get_wallet_nonce',
145
+ description: 'Get a nonce to sign for wallet authentication.',
146
+ method: 'POST',
147
+ path: '/api/auth/nonce',
148
+ parameters: {
149
+ type: 'object',
150
+ required: ['wallet_address'],
151
+ properties: {
152
+ wallet_address: { type: 'string' },
153
+ chain_type: { type: 'string', enum: ['solana', 'ethereum', 'bitcoin', 'base'] }
154
+ }
155
+ }
156
+ }
157
+ ]
158
+ };
159
+
160
+ // Default error shapes used for enrichment/merging
161
+ const defaultErrors = {
162
+ '400': {
163
+ type: 'object',
164
+ properties: {
165
+ success: { type: 'boolean' },
166
+ error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } }, required: ['message'] }
167
+ },
168
+ required: ['error']
169
+ },
170
+ '401': {
171
+ type: 'object',
172
+ properties: { error: { type: 'string', enum: ['unauthorized'] }, message: { type: 'string' } },
173
+ required: ['error']
174
+ },
175
+ '403': {
176
+ type: 'object',
177
+ properties: { error: { type: 'string', enum: ['forbidden'] }, message: { type: 'string' } },
178
+ required: ['error']
179
+ },
180
+ '429': {
181
+ type: 'object',
182
+ properties: { error: { type: 'string', enum: ['rate_limited'] }, message: { type: 'string' } },
183
+ required: ['error']
184
+ },
185
+ '500': {
186
+ type: 'object',
187
+ properties: { error: { type: 'string', enum: ['server_error'] }, message: { type: 'string' } },
188
+ required: ['error']
189
+ }
190
+ };
191
+
192
+ // Attempt to align paths/methods with docs/manifest.json if available
193
+ try {
194
+ const manifest = tryLoadManifest();
195
+ if (manifest && Array.isArray(manifest.endpoints)) {
196
+ const find = (method, pathStr) => manifest.endpoints.find(e => e.method === method && e.path === pathStr);
197
+ const patchTool = (toolName, method, pathStr) => {
198
+ const t = spec.tools.find(x => x.name === toolName);
199
+ const ep = find(method, pathStr);
200
+ if (t && ep) {
201
+ t.method = ep.method;
202
+ t.path = ep.path;
203
+ }
204
+ };
205
+ patchTool('login', 'POST', '/api/auth/login');
206
+ patchTool('register', 'POST', '/api/auth/register');
207
+ patchTool('get_current_user', 'GET', '/api/auth/user');
208
+ patchTool('create_checkout_session', 'POST', '/api/stripe/checkout-sessions');
209
+ patchTool('list_products', 'GET', '/api/stripe/products');
210
+ patchTool('request_magic_link', 'POST', '/api/auth/magic-link');
211
+ patchTool('get_wallet_nonce', 'POST', '/api/auth/nonce');
212
+ }
213
+ } catch {
214
+ // Best-effort alignment only
215
+ }
216
+
217
+ // Attempt to enrich parameter schemas from OpenAPI
218
+ try {
219
+ const openapi = tryLoadOpenAPI();
220
+ if (openapi && openapi.paths) {
221
+ const findOp = (method, pathStr) => {
222
+ const ops = openapi.paths[pathStr];
223
+ if (!ops) return null;
224
+ return ops[String(method).toLowerCase()] || null;
225
+ };
226
+ const setBodySchema = (toolName, method, pathStr) => {
227
+ const t = spec.tools.find(x => x.name === toolName);
228
+ const op = findOp(method, pathStr);
229
+ const schema = op?.requestBody?.content?.['application/json']?.schema;
230
+ if (t && schema) {
231
+ t.parameters = schema;
232
+ }
233
+ };
234
+ const setResponses = (toolName, method, pathStr) => {
235
+ const t = spec.tools.find(x => x.name === toolName);
236
+ const op = findOp(method, pathStr);
237
+ if (t && op && op.responses) {
238
+ const responses = {};
239
+ const examples = {};
240
+ for (const [code, obj] of Object.entries(op.responses)) {
241
+ const schema = obj?.content?.['application/json']?.schema;
242
+ if (schema) responses[code] = schema;
243
+ const content = obj?.content?.['application/json'];
244
+ if (content?.example !== undefined) {
245
+ examples[code] = content.example;
246
+ } else if (content?.examples && typeof content.examples === 'object') {
247
+ const ex = {};
248
+ for (const [name, val] of Object.entries(content.examples)) {
249
+ if (val && typeof val === 'object') {
250
+ if ('value' in val) ex[name] = val.value;
251
+ }
252
+ }
253
+ if (Object.keys(ex).length) examples[code] = ex;
254
+ }
255
+ }
256
+ if (Object.keys(responses).length) {
257
+ // Merge with default errors for completeness
258
+ const merged = { ...defaultErrors, ...responses };
259
+ t.responses = merged;
260
+ }
261
+ if (Object.keys(examples).length) t.examples = examples;
262
+ }
263
+ };
264
+ setBodySchema('login', 'POST', '/api/auth/login');
265
+ setBodySchema('register', 'POST', '/api/auth/register');
266
+ setBodySchema('create_checkout_session', 'POST', '/api/stripe/checkout-sessions');
267
+ setBodySchema('request_magic_link', 'POST', '/api/auth/magic-link');
268
+ setBodySchema('get_wallet_nonce', 'POST', '/api/auth/nonce');
269
+ setResponses('login', 'POST', '/api/auth/login');
270
+ setResponses('register', 'POST', '/api/auth/register');
271
+ setResponses('get_current_user', 'GET', '/api/auth/user');
272
+ setResponses('create_checkout_session', 'POST', '/api/stripe/checkout-sessions');
273
+ setResponses('list_products', 'GET', '/api/stripe/products');
274
+ setResponses('request_magic_link', 'POST', '/api/auth/magic-link');
275
+ setResponses('get_wallet_nonce', 'POST', '/api/auth/nonce');
276
+ // For GET endpoints with query/headers, leave minimal schema for simplicity
277
+ }
278
+ } catch {
279
+ // Ignore enrichment errors
280
+ }
281
+
282
+ // Add default error shapes if responses missing
283
+ spec.tools.forEach(t => {
284
+ if (!t.responses) t.responses = defaultErrors;
285
+ });
286
+
287
+ return spec;
288
+ }
289
+
290
+ function buildToolSpec({ format = 'openai', port = DEFAULT_PORT } = {}) {
291
+ switch (format) {
292
+ case 'openai':
293
+ default:
294
+ return buildOpenAIToolSpec({ port });
295
+ }
296
+ }
297
+
298
+ module.exports = { buildToolSpec };
@@ -10,6 +10,10 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
10
10
 
11
11
  if (dryRun) {
12
12
  program.allowUnknownOption(true);
13
+ // Tolerate stray operands during unit tests (Commander normally errors)
14
+ if (typeof program.allowExcessArguments === 'function') {
15
+ program.allowExcessArguments(true);
16
+ }
13
17
  }
14
18
 
15
19
  program
@@ -44,16 +48,37 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
44
48
  .command('local')
45
49
  .description('Start local SPAPS server (no API keys required!)')
46
50
  .option('-p, --port <port>', 'Port to run on', String(DEFAULT_PORT))
51
+ .option('-s, --stripe <mode>', 'Stripe mode: mock|real', 'mock')
52
+ .option('--seed <preset>', 'Seed local data: demo', 'none')
47
53
  .option('-o, --open', 'Open browser automatically', false)
48
54
  .option('--json', 'Output in JSON format')
49
55
  .action(
50
- makeAction('local', (opts, _cmd, isJson) => ({
51
- port: Number(opts.port),
52
- open: Boolean(opts.open),
53
- json: isJson,
54
- }))
56
+ makeAction('local', (opts, cmd, isJson) => {
57
+ const out = {
58
+ port: Number(opts.port),
59
+ open: Boolean(opts.open),
60
+ json: isJson,
61
+ };
62
+ // Include optional flags only if explicitly provided by user
63
+ try {
64
+ const srcStripe = typeof cmd.getOptionValueSource === 'function' ? cmd.getOptionValueSource('stripe') : null;
65
+ const srcSeed = typeof cmd.getOptionValueSource === 'function' ? cmd.getOptionValueSource('seed') : null;
66
+ if (srcStripe === 'cli') out.stripe = String(opts.stripe || '').toLowerCase();
67
+ if (srcSeed === 'cli') out.seed = String(opts.seed || '').toLowerCase();
68
+ } catch (_) {
69
+ // Commander versions without getOptionValueSource; fall back to only including when present
70
+ if (typeof opts.stripe !== 'undefined') out.stripe = String(opts.stripe).toLowerCase();
71
+ if (typeof opts.seed !== 'undefined') out.seed = String(opts.seed).toLowerCase();
72
+ }
73
+ return out;
74
+ })
55
75
  );
56
- if (dryRun) cmdLocal.allowUnknownOption(true);
76
+ if (dryRun) {
77
+ cmdLocal.allowUnknownOption(true);
78
+ if (typeof cmdLocal.allowExcessArguments === 'function') {
79
+ cmdLocal.allowExcessArguments(true);
80
+ }
81
+ }
57
82
 
58
83
  // spaps quickstart
59
84
  const cmdQuick = program
@@ -62,7 +87,12 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
62
87
  .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT))
63
88
  .option('--json', 'Output in JSON format')
64
89
  .action(makeAction('quickstart', (opts, _cmd, isJson) => ({ port: Number(opts.port), json: isJson })));
65
- if (dryRun) cmdQuick.allowUnknownOption(true);
90
+ if (dryRun) {
91
+ cmdQuick.allowUnknownOption(true);
92
+ if (typeof cmdQuick.allowExcessArguments === 'function') {
93
+ cmdQuick.allowExcessArguments(true);
94
+ }
95
+ }
66
96
 
67
97
  // spaps status
68
98
  const cmdStatus = program
@@ -71,7 +101,12 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
71
101
  .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT))
72
102
  .option('--json', 'Output in JSON format')
73
103
  .action(makeAction('status', (opts, _cmd, isJson) => ({ port: Number(opts.port), json: isJson })));
74
- if (dryRun) cmdStatus.allowUnknownOption(true);
104
+ if (dryRun) {
105
+ cmdStatus.allowUnknownOption(true);
106
+ if (typeof cmdStatus.allowExcessArguments === 'function') {
107
+ cmdStatus.allowExcessArguments(true);
108
+ }
109
+ }
75
110
 
76
111
  // spaps init
77
112
  const cmdInit = program
@@ -79,21 +114,36 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
79
114
  .description('Initialize SPAPS in current project')
80
115
  .option('--json', 'Output in JSON format')
81
116
  .action(makeAction('init', (_opts, _cmd, isJson) => ({ json: isJson })));
82
- if (dryRun) cmdInit.allowUnknownOption(true);
117
+ if (dryRun) {
118
+ cmdInit.allowUnknownOption(true);
119
+ if (typeof cmdInit.allowExcessArguments === 'function') {
120
+ cmdInit.allowExcessArguments(true);
121
+ }
122
+ }
83
123
 
84
124
  // spaps create <name>
85
125
  const cmdCreate = program
86
126
  .command('create <name>')
87
127
  .description('Create a new project with SPAPS (coming soon)')
88
128
  .action(makeAction('create', (optsOrName, cmd) => ({ name: typeof optsOrName === 'string' ? optsOrName : cmd.args[0] })));
89
- if (dryRun) cmdCreate.allowUnknownOption(true);
129
+ if (dryRun) {
130
+ cmdCreate.allowUnknownOption(true);
131
+ if (typeof cmdCreate.allowExcessArguments === 'function') {
132
+ cmdCreate.allowExcessArguments(true);
133
+ }
134
+ }
90
135
 
91
136
  // spaps types
92
137
  const cmdTypes = program
93
138
  .command('types')
94
139
  .description('Generate TypeScript types (coming soon)')
95
140
  .action(makeAction('types', () => ({})));
96
- if (dryRun) cmdTypes.allowUnknownOption(true);
141
+ if (dryRun) {
142
+ cmdTypes.allowUnknownOption(true);
143
+ if (typeof cmdTypes.allowExcessArguments === 'function') {
144
+ cmdTypes.allowExcessArguments(true);
145
+ }
146
+ }
97
147
 
98
148
  // spaps help
99
149
  const cmdHelp = program
@@ -104,7 +154,12 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
104
154
  .action(
105
155
  makeAction('help', (opts) => ({ interactive: Boolean(opts.interactive), quick: Boolean(opts.quick) }))
106
156
  );
107
- if (dryRun) cmdHelp.allowUnknownOption(true);
157
+ if (dryRun) {
158
+ cmdHelp.allowUnknownOption(true);
159
+ if (typeof cmdHelp.allowExcessArguments === 'function') {
160
+ cmdHelp.allowExcessArguments(true);
161
+ }
162
+ }
108
163
 
109
164
  // spaps docs
110
165
  const cmdDocs = program
@@ -116,7 +171,46 @@ function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo
116
171
  .action(
117
172
  makeAction('docs', (opts, _cmd, isJson) => ({ interactive: Boolean(opts.interactive), search: opts.search || null, json: isJson }))
118
173
  );
119
- if (dryRun) cmdDocs.allowUnknownOption(true);
174
+ if (dryRun) {
175
+ cmdDocs.allowUnknownOption(true);
176
+ if (typeof cmdDocs.allowExcessArguments === 'function') {
177
+ cmdDocs.allowExcessArguments(true);
178
+ }
179
+ }
180
+
181
+ // spaps tools
182
+ const cmdTools = program
183
+ .command('tools')
184
+ .description('Output AI tool spec (OpenAI-style)')
185
+ .option('-p, --port <port>', 'Port to use for base_url', String(DEFAULT_PORT))
186
+ .option('-f, --format <format>', 'Spec format (openai)', 'openai')
187
+ .option('--json', 'Output in JSON format')
188
+ .action(
189
+ makeAction('tools', (opts, _cmd, isJson) => ({ port: Number(opts.port), format: String(opts.format || 'openai'), json: isJson }))
190
+ );
191
+ if (dryRun) {
192
+ cmdTools.allowUnknownOption(true);
193
+ if (typeof cmdTools.allowExcessArguments === 'function') {
194
+ cmdTools.allowExcessArguments(true);
195
+ }
196
+ }
197
+
198
+ // spaps doctor
199
+ const cmdDoctor = program
200
+ .command('doctor')
201
+ .description('Diagnose local environment and config')
202
+ .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT))
203
+ .option('-s, --stripe <mode>', 'Stripe mode: mock|real')
204
+ .option('--json', 'Output in JSON format')
205
+ .action(
206
+ makeAction('doctor', (opts, _cmd, isJson) => ({ port: Number(opts.port), stripe: opts.stripe || null, json: isJson }))
207
+ );
208
+ if (dryRun) {
209
+ cmdDoctor.allowUnknownOption(true);
210
+ if (typeof cmdDoctor.allowExcessArguments === 'function') {
211
+ cmdDoctor.allowExcessArguments(true);
212
+ }
213
+ }
120
214
 
121
215
  return { program, getIntents: () => intents };
122
216
  }
package/src/docs-html.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Generates comprehensive documentation page
4
4
  */
5
5
 
6
- function generateDocsHTML(port = 3300) {
6
+ function generateDocsHTML(port = 3300, notice) {
7
7
  return `<!DOCTYPE html>
8
8
  <html lang="en">
9
9
  <head>
@@ -277,6 +277,7 @@ function generateDocsHTML(port = 3300) {
277
277
  <p class="subtitle">Sweet Potato Authentication & Payment Service</p>
278
278
  <span class="status-badge">āœ… Local Mode Active - Port ${port}</span>
279
279
  </div>
280
+ ${notice ? `<div class="alert">${notice}</div>` : ''}
280
281
 
281
282
  <nav class="nav">
282
283
  <ul>
@@ -807,4 +808,4 @@ test().catch(console.error);</code></pre>
807
808
  </html>`;
808
809
  }
809
810
 
810
- module.exports = { generateDocsHTML };
811
+ module.exports = { generateDocsHTML };
package/src/doctor.js ADDED
@@ -0,0 +1,217 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const net = require('net');
5
+ const chalk = require('chalk');
6
+
7
+ const { getServerStatus } = require('./ai-helper');
8
+ const { DEFAULT_PORT } = require('./config');
9
+
10
+ function checkNodeVersion() {
11
+ const version = process.versions.node || '0.0.0';
12
+ const major = parseInt(version.split('.')[0], 10) || 0;
13
+ const ok = major >= 16;
14
+ return {
15
+ check: 'node_version',
16
+ success: ok,
17
+ details: { version, requirement: '>=16' },
18
+ fix: ok ? null : 'Upgrade Node.js to v18+ (recommended)'
19
+ };
20
+ }
21
+
22
+ async function checkPort(port) {
23
+ // If server is running, we consider port check OK
24
+ const status = await getServerStatus(port);
25
+ if (status.running) {
26
+ return {
27
+ check: 'port',
28
+ success: true,
29
+ details: { port, running: true, url: status.url },
30
+ fix: null
31
+ };
32
+ }
33
+ // Otherwise ensure port is free to bind
34
+ const free = await new Promise((resolve) => {
35
+ const tester = net.createServer()
36
+ .once('error', () => resolve(false))
37
+ .once('listening', () => tester.once('close', () => resolve(true)).close())
38
+ .listen(port, '127.0.0.1');
39
+ });
40
+ return {
41
+ check: 'port',
42
+ success: free,
43
+ details: { port, running: false, free },
44
+ fix: free ? null : `Use a different port: npx spaps local --port ${port + 1}`
45
+ };
46
+ }
47
+
48
+ function checkEnvFile() {
49
+ const envPath = path.resolve(process.cwd(), '.env.local');
50
+ const exists = fs.existsSync(envPath);
51
+ let hasApiUrl = false;
52
+ if (exists) {
53
+ try {
54
+ const content = fs.readFileSync(envPath, 'utf8');
55
+ hasApiUrl = /SPAPS_API_URL\s*=/.test(content);
56
+ } catch {}
57
+ }
58
+ return {
59
+ check: 'env_file',
60
+ success: exists && hasApiUrl,
61
+ details: { path: envPath, exists, hasApiUrl },
62
+ fix: exists ? (hasApiUrl ? null : 'Add SPAPS_API_URL to .env.local (http://localhost:3300)') : 'Run: npx spaps init'
63
+ };
64
+ }
65
+
66
+ function checkWritePermissions() {
67
+ const dir = path.resolve(process.cwd(), '.spaps');
68
+ try {
69
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
70
+ const tmp = path.join(dir, '_doctor.tmp');
71
+ fs.writeFileSync(tmp, 'ok');
72
+ fs.unlinkSync(tmp);
73
+ return { check: 'write_permissions', success: true, details: { dir }, fix: null };
74
+ } catch (e) {
75
+ return { check: 'write_permissions', success: false, details: { dir, error: e.message }, fix: `Make directory writable: chmod -R u+rw ${dir}` };
76
+ }
77
+ }
78
+
79
+ function checkSDKInstalled() {
80
+ try {
81
+ require.resolve('spaps-sdk', { paths: [process.cwd()] });
82
+ return { check: 'sdk_installed', success: true, details: { package: 'spaps-sdk' }, fix: null };
83
+ } catch {
84
+ return { check: 'sdk_installed', success: false, details: { package: 'spaps-sdk' }, fix: 'npm install spaps-sdk' };
85
+ }
86
+ }
87
+
88
+ function checkStripeMode(stripeModeOpt) {
89
+ const mode = (stripeModeOpt || (process.env.USE_REAL_STRIPE === 'false' ? 'mock' : 'real')).toLowerCase();
90
+ const needsKey = mode === 'real';
91
+ const hasKey = Boolean(process.env.STRIPE_SECRET_KEY);
92
+ const ok = mode === 'mock' || (mode === 'real' && hasKey);
93
+ return {
94
+ check: 'stripe_mode',
95
+ success: ok,
96
+ details: { mode, needsKey, hasKey },
97
+ fix: ok ? null : (mode === 'real' ? 'Set STRIPE_SECRET_KEY or run with --stripe mock' : null)
98
+ };
99
+ }
100
+
101
+ function checkEnvTest() {
102
+ const envPath = path.resolve(process.cwd(), '.env.test');
103
+ if (!fs.existsSync(envPath)) {
104
+ return {
105
+ check: 'env_test',
106
+ success: false,
107
+ details: { path: envPath, exists: false },
108
+ fix: 'Create .env.test with SPAPS_API_URL=http://localhost:3300 (no real network keys)'
109
+ };
110
+ }
111
+ try {
112
+ const content = fs.readFileSync(envPath, 'utf8');
113
+ const hasLocalUrl = /SPAPS_API_URL\s*=\s*http:\/\/localhost:\d+/.test(content);
114
+ const hasApiKey = /SPAPS_API_KEY\s*=\s*\S+/.test(content);
115
+ const warns = [];
116
+ if (!hasLocalUrl) warns.push('SPAPS_API_URL should point to localhost');
117
+ if (hasApiKey) warns.push('SPAPS_API_KEY should not be set in tests');
118
+ return {
119
+ check: 'env_test',
120
+ success: hasLocalUrl && !hasApiKey,
121
+ details: { path: envPath, hasLocalUrl, hasApiKey },
122
+ fix: warns.length ? warns.join(' | ') : null
123
+ };
124
+ } catch (e) {
125
+ return { check: 'env_test', success: false, details: { error: e.message }, fix: 'Ensure .env.test is readable' };
126
+ }
127
+ }
128
+
129
+ async function checkNextJsPort() {
130
+ const defaultNextPort = 3000;
131
+ const inUse = await new Promise((resolve) => {
132
+ const tester = net.createServer()
133
+ .once('error', () => resolve(true))
134
+ .once('listening', () => tester.once('close', () => resolve(false)).close())
135
+ .listen(defaultNextPort, '127.0.0.1');
136
+ });
137
+ return {
138
+ check: 'next_port',
139
+ success: true,
140
+ details: { port: defaultNextPort, inUse, note: inUse ? 'Next.js likely running (good)' : 'Port free' },
141
+ fix: null
142
+ };
143
+ }
144
+
145
+ async function checkWebhook(port) {
146
+ const status = await getServerStatus(port);
147
+ if (!status.running) {
148
+ return {
149
+ check: 'webhook',
150
+ success: false,
151
+ details: { running: false },
152
+ fix: `Start server: npx spaps local --port ${port} --stripe mock`
153
+ };
154
+ }
155
+ try {
156
+ const http = require('http');
157
+ const payload = JSON.stringify({ id: 'evt_doctor_' + Date.now(), type: 'checkout.session.completed', data: { object: { id: 'cs_doctor_' + Date.now() } } });
158
+ const ok = await new Promise((resolve) => {
159
+ const req = http.request({ hostname: 'localhost', port, path: '/api/stripe/webhooks', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }, (res) => {
160
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
161
+ });
162
+ req.on('error', () => resolve(false));
163
+ req.write(payload);
164
+ req.end();
165
+ });
166
+ return { check: 'webhook', success: ok, details: { path: '/api/stripe/webhooks' }, fix: ok ? null : 'Use --stripe mock or ensure webhook handler is reachable' };
167
+ } catch (e) {
168
+ return { check: 'webhook', success: false, details: { error: e.message }, fix: 'Use --stripe mock or ensure server is running' };
169
+ }
170
+ }
171
+
172
+ function formatHuman(results) {
173
+ const ok = results.every(r => r.success);
174
+ console.log(chalk.yellow('\nšŸ  SPAPS Doctor\n'));
175
+ results.forEach(r => {
176
+ const icon = r.success ? chalk.green('āœ”') : chalk.red('āœ–');
177
+ console.log(`${icon} ${r.check} ${chalk.gray(JSON.stringify(r.details))}`);
178
+ if (!r.success && r.fix) console.log(chalk.cyan(` fix: ${r.fix}`));
179
+ });
180
+ console.log();
181
+ console.log(ok ? chalk.green('All checks passed!') : chalk.red('Some checks failed. See fixes above.'));
182
+ }
183
+
184
+ async function runDoctor({ port = DEFAULT_PORT, stripe = null, json = false } = {}) {
185
+ const results = [];
186
+ results.push(checkNodeVersion());
187
+ results.push(await checkPort(port));
188
+ // Warn if using 3000 which often collides with Next.js
189
+ if (port === 3000) {
190
+ results.push({
191
+ check: 'spaps_port_vs_next',
192
+ success: false,
193
+ details: { spaps_port: port, suggestion: 'Use 3300 for SPAPS to avoid Next.js conflicts' },
194
+ fix: 'Run: npx spaps local --port 3300'
195
+ });
196
+ } else {
197
+ results.push({ check: 'spaps_port_vs_next', success: true, details: { spaps_port: port }, fix: null });
198
+ }
199
+ results.push(checkEnvFile());
200
+ results.push(checkWritePermissions());
201
+ results.push(checkSDKInstalled());
202
+ results.push(checkStripeMode(stripe));
203
+ results.push(checkEnvTest());
204
+ results.push(await checkNextJsPort());
205
+ results.push(await checkWebhook(port));
206
+
207
+ const ok = results.every(r => r.success);
208
+ const payload = { success: ok, results, next_steps: ok ? [] : ['Apply suggested fixes and re-run: npx spaps doctor --json'] };
209
+ if (json) {
210
+ console.log(JSON.stringify(payload, null, 2));
211
+ } else {
212
+ formatHuman(results);
213
+ }
214
+ return payload;
215
+ }
216
+
217
+ module.exports = { runDoctor };
package/src/handlers.js CHANGED
@@ -5,6 +5,8 @@ const { handleError } = require('./error-handler');
5
5
  const { showInteractiveHelp, showQuickHelp } = require('./help-system');
6
6
  const { showInteractiveDocs, showQuickReference, searchDocs } = require('./docs-system');
7
7
  const { getQuickStartInstructions, getServerStatus, runQuickTest } = require('./ai-helper');
8
+ const { buildToolSpec } = require('./ai-tool-spec');
9
+ const { runDoctor } = require('./doctor');
8
10
 
9
11
  function createHandlers(version, logo) {
10
12
  return {
@@ -13,7 +15,7 @@ function createHandlers(version, logo) {
13
15
  if (!isJson) console.log(logo);
14
16
  try {
15
17
  const LocalServer = require('./local-server.js');
16
- const server = new LocalServer({ port: options.port, json: isJson });
18
+ const server = new LocalServer({ port: options.port, json: isJson, stripeMode: options.stripe, seedMode: options.seed });
17
19
  if (isJson) {
18
20
  await server.start();
19
21
  console.log(JSON.stringify({
@@ -24,7 +26,7 @@ function createHandlers(version, logo) {
24
26
  docs: `http://localhost:${options.port}/docs`,
25
27
  mode: 'local-development',
26
28
  port: Number(options.port),
27
- features: { autoAuth: true, corsEnabled: true, testUsers: ['user', 'admin', 'premium'], apiKeyRequired: false }
29
+ features: { autoAuth: true, corsEnabled: true, testUsers: ['user', 'admin', 'premium'], apiKeyRequired: false, stripeMode: options.stripe, seeded: options.seed }
28
30
  }
29
31
  }));
30
32
  } else {
@@ -147,9 +149,26 @@ function createHandlers(version, logo) {
147
149
  } else {
148
150
  showQuickReference();
149
151
  }
152
+ },
153
+ tools: async ({ options }) => {
154
+ const spec = buildToolSpec({ format: options.format || 'openai', port: options.port });
155
+ if (options.json) {
156
+ console.log(JSON.stringify(spec, null, 2));
157
+ } else {
158
+ console.log(chalk.yellow('\nšŸ  SPAPS AI Tool Spec (OpenAI-style)\n'));
159
+ console.log('Base URL:', spec.base_url);
160
+ console.log('Tools:');
161
+ spec.tools.forEach((t, i) => {
162
+ console.log(chalk.green(` ${i + 1}. ${t.name}`), '-', t.description);
163
+ console.log(chalk.gray(` ${t.method} ${t.path}`));
164
+ });
165
+ console.log('\nTip: npx spaps tools --json > spaps-tools.json');
166
+ }
167
+ },
168
+ doctor: async ({ options }) => {
169
+ await runDoctor({ port: options.port || DEFAULT_PORT, stripe: options.stripe || null, json: options.json });
150
170
  }
151
171
  };
152
172
  }
153
173
 
154
174
  module.exports = { createHandlers };
155
-
@@ -8,19 +8,24 @@
8
8
  const express = require('express');
9
9
  const cors = require('cors');
10
10
  const chalk = require('chalk');
11
+ const fs = require('fs');
12
+ const path = require('path');
11
13
  const { generateDocsHTML } = require('./docs-html');
14
+ let swaggerUiDist = null;
15
+ try { swaggerUiDist = require('swagger-ui-dist'); } catch {}
12
16
  const StripeLocalManager = require('./stripe-local');
13
17
  const LocalAdminManager = require('./admin-local');
14
18
 
15
19
  // Stripe configuration for test mode
16
20
  const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY || 'sk_test_51S1WOy2HT0E1dOewiHvzt7T96PDwjocSDDUuc2ur569AVA5fDj4UpNM66lujrda1tTYrgooG0Z1dNFZfwEZuZdcA00nuVLJW67');
17
21
  const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || 'pk_test_51S1WOy2HT0E1dOewb2EkxZIaPkz7v3zMM9VxuBoxgNILYMmS85I4zrAWTkevyUQcaWlWUoC2NYnB8X5ZKd5e7Ifc005IzIW6H2';
18
- const USE_REAL_STRIPE = process.env.USE_REAL_STRIPE !== 'false'; // Default to true
19
22
 
20
23
  class LocalServer {
21
24
  constructor(options = {}) {
22
25
  this.port = options.port || process.env.PORT || 3456;
23
26
  this.json = options.json || false;
27
+ this.stripeMode = options.stripeMode || (process.env.USE_REAL_STRIPE === 'false' ? 'mock' : 'real');
28
+ this.seedMode = options.seedMode || 'none';
24
29
  this.app = express();
25
30
  this.stripeManager = null;
26
31
  this.adminManager = new LocalAdminManager();
@@ -29,6 +34,15 @@ class LocalServer {
29
34
  this.setupStripeRoutes();
30
35
  this.setupAdminRoutes();
31
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
+ }
32
46
  }
33
47
 
34
48
  setupMiddleware() {
@@ -44,6 +58,12 @@ class LocalServer {
44
58
  this.app.use(express.json());
45
59
  this.app.use(express.urlencoded({ extended: true }));
46
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
+
47
67
  // Local mode indicator
48
68
  this.app.use((req, res, next) => {
49
69
  res.setHeader('X-SPAPS-Mode', 'local-development');
@@ -67,6 +87,16 @@ class LocalServer {
67
87
  }
68
88
 
69
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
+
70
100
  // Health check
71
101
  this.app.get('/health', (req, res) => {
72
102
  res.json({
@@ -167,7 +197,7 @@ class LocalServer {
167
197
  // Stripe checkout sessions endpoint - REAL or MOCK based on config
168
198
  this.app.post('/api/stripe/checkout-sessions', async (req, res) => {
169
199
  try {
170
- if (USE_REAL_STRIPE) {
200
+ if (this.stripeMode === 'real') {
171
201
  // Real Stripe checkout session
172
202
  const { product_name, amount, currency = 'usd', success_url, cancel_url, price_id } = req.body;
173
203
 
@@ -254,7 +284,7 @@ class LocalServer {
254
284
  // Stripe products endpoint - REAL or MOCK based on config
255
285
  this.app.get('/api/stripe/products', async (req, res) => {
256
286
  try {
257
- if (USE_REAL_STRIPE) {
287
+ if (this.stripeMode === 'real') {
258
288
  // Fetch real Stripe products
259
289
  const products = await stripe.products.list({
260
290
  active: req.query.active !== undefined ? req.query.active === 'true' : undefined,
@@ -418,7 +448,7 @@ class LocalServer {
418
448
  // Admin product sync endpoint - REAL or MOCK based on config
419
449
  this.app.post('/api/v1/admin/products/sync', async (req, res) => {
420
450
  try {
421
- if (USE_REAL_STRIPE) {
451
+ if (this.stripeMode === 'real') {
422
452
  // Get local products from admin manager
423
453
  const localProducts = this.adminManager.listProducts();
424
454
  const syncResults = [];
@@ -532,19 +562,148 @@ class LocalServer {
532
562
  });
533
563
  });
534
564
 
535
- // Documentation endpoint
565
+ // Documentation endpoint: prefer Swagger UI bound to /openapi.json
536
566
  this.app.get('/docs', (req, res) => {
537
- res.send(generateDocsHTML(this.port));
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
+ }
538
599
  });
539
600
  }
540
601
 
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 {}
624
+ }
625
+
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 {}
638
+ }
639
+
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
+ };
667
+ }
668
+ return spec;
669
+ }
670
+
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
695
+ });
696
+ if (!this.json) console.log(chalk.gray('🌱 Seeded demo customers and orders'));
697
+ }
698
+ }
699
+
541
700
  setupStripeRoutes() {
542
701
  // Enhanced Stripe Product Management - Full CRUD
543
702
 
544
703
  // GET /api/stripe/products - List all products with filtering
545
704
  this.app.get('/api/stripe/products', async (req, res) => {
546
705
  try {
547
- if (USE_REAL_STRIPE) {
706
+ if (this.stripeMode === 'real') {
548
707
  const { active, category, limit = '100' } = req.query;
549
708
 
550
709
  const products = await stripe.products.list({
@@ -620,7 +779,7 @@ class LocalServer {
620
779
  });
621
780
  }
622
781
 
623
- if (USE_REAL_STRIPE) {
782
+ if (this.stripeMode === 'real') {
624
783
  // Create product in Stripe
625
784
  const stripeProduct = await stripe.products.create({
626
785
  name,
@@ -718,7 +877,7 @@ class LocalServer {
718
877
  const { productId } = req.params;
719
878
  const { name, description, images, metadata, active } = req.body;
720
879
 
721
- if (USE_REAL_STRIPE) {
880
+ if (this.stripeMode === 'real') {
722
881
  // Update in Stripe
723
882
  const updateData = {};
724
883
  if (name !== undefined) updateData.name = name;
@@ -780,7 +939,7 @@ class LocalServer {
780
939
  });
781
940
  }
782
941
 
783
- if (USE_REAL_STRIPE) {
942
+ if (this.stripeMode === 'real') {
784
943
  const priceData = {
785
944
  product: product_id,
786
945
  unit_amount: parseInt(unit_amount),
@@ -852,7 +1011,7 @@ class LocalServer {
852
1011
  });
853
1012
  }
854
1013
 
855
- if (USE_REAL_STRIPE) {
1014
+ if (this.stripeMode === 'real') {
856
1015
  const stripeProduct = await stripe.products.update(productId, {
857
1016
  default_price: price_id
858
1017
  });
@@ -895,7 +1054,7 @@ class LocalServer {
895
1054
  try {
896
1055
  const { productId } = req.params;
897
1056
 
898
- if (USE_REAL_STRIPE) {
1057
+ if (this.stripeMode === 'real') {
899
1058
  // Archive in Stripe (can't truly delete)
900
1059
  const stripeProduct = await stripe.products.update(productId, {
901
1060
  active: false
@@ -1058,7 +1217,7 @@ class LocalServer {
1058
1217
  try {
1059
1218
  let event;
1060
1219
 
1061
- if (USE_REAL_STRIPE) {
1220
+ if (this.stripeMode === 'real') {
1062
1221
  // Real Stripe webhook verification
1063
1222
  const sig = req.headers['stripe-signature'];
1064
1223
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
@@ -1076,7 +1235,13 @@ class LocalServer {
1076
1235
  }
1077
1236
  } else {
1078
1237
  // Mock mode - accept all webhooks
1079
- event = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
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
+ }
1080
1245
  }
1081
1246
 
1082
1247
  if (!this.json) {
@@ -1494,7 +1659,7 @@ class LocalServer {
1494
1659
  console.log(chalk.yellow('šŸ  SPAPS Local Development Server'));
1495
1660
  console.log(chalk.green(`✨ Running at: http://localhost:${this.port}`));
1496
1661
  console.log(chalk.blue(`šŸ“ Documentation: http://localhost:${this.port}/docs`));
1497
- if (USE_REAL_STRIPE) {
1662
+ if (this.stripeMode === 'real') {
1498
1663
  console.log(chalk.magenta('šŸ’³ Stripe: Real test mode (live API calls)'));
1499
1664
  } else {
1500
1665
  console.log(chalk.gray('šŸ’³ Stripe: Mock mode (simulated responses)'));
@@ -1535,4 +1700,4 @@ if (require.main === module) {
1535
1700
  console.log(chalk.yellow('\nšŸ‘‹ Shutting down...'));
1536
1701
  process.exit(0);
1537
1702
  });
1538
- }
1703
+ }