spaps 0.7.3 → 0.7.4

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.
Files changed (48) hide show
  1. package/AI_TOOLS.json +10 -11
  2. package/README.md +216 -36
  3. package/assets/local-runtime/Dockerfile +28 -0
  4. package/assets/local-runtime/alembic/env.py +101 -0
  5. package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
  6. package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
  7. package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
  8. package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
  9. package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
  10. package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
  11. package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
  12. package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
  13. package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
  14. package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
  15. package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
  16. package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
  17. package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
  18. package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
  19. package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
  20. package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
  21. package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
  22. package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
  23. package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
  24. package/assets/local-runtime/alembic.ini +47 -0
  25. package/assets/local-runtime/docker-compose.yml +61 -0
  26. package/assets/local-runtime/manifest.json +8 -0
  27. package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
  28. package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
  29. package/assets/local-runtime/scripts/run-migrations.sh +96 -0
  30. package/package.json +2 -1
  31. package/src/ai-helper.js +176 -234
  32. package/src/ai-tool-spec.js +52 -20
  33. package/src/auth/api-key.js +119 -0
  34. package/src/auth/client-id.js +136 -0
  35. package/src/auth/client.js +169 -0
  36. package/src/auth/credentials.js +110 -0
  37. package/src/auth/device-flow.js +159 -0
  38. package/src/auth/env.js +57 -0
  39. package/src/auth/handlers.js +462 -0
  40. package/src/auth/http.js +74 -0
  41. package/src/cli-dispatcher.js +134 -21
  42. package/src/docs-system.js +7 -7
  43. package/src/fixture-kernel.js +1143 -0
  44. package/src/handlers.js +202 -11
  45. package/src/help-system.js +2 -0
  46. package/src/local-runtime.js +258 -0
  47. package/src/local-server.js +597 -199
  48. package/src/project-scaffolder.js +185 -45
@@ -2,56 +2,210 @@
2
2
 
3
3
  /**
4
4
  * SPAPS Local Development Server - Docker Compose Orchestrator
5
- * Manages the real Python/FastAPI SPAPS server via docker-compose.spaps-dev.yml
5
+ * Uses repo-native assets inside sweet-potato when available and falls back to a
6
+ * bundled portable runtime when installed from npm elsewhere.
6
7
  */
7
8
 
8
- const { spawn, execSync } = require('child_process');
9
+ const { spawn, execSync, execFileSync } = require('child_process');
9
10
  const chalk = require('chalk');
10
11
  const axios = require('axios');
12
+ const os = require('os');
11
13
  const path = require('path');
12
14
  const fs = require('fs');
13
15
 
16
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
17
+ const BUNDLED_RUNTIME_ROOT = path.join(PACKAGE_ROOT, 'assets', 'local-runtime');
18
+ const BUNDLED_RUNTIME_MANIFEST = path.join(BUNDLED_RUNTIME_ROOT, 'manifest.json');
19
+ const DEFAULT_RUNTIME_SOURCE = 'auto';
20
+ const DEFAULT_AUTH_BASE_URL = 'http://localhost:5173';
21
+ const DEFAULT_DATA_SOURCE = 'empty';
22
+ const EXTERNAL_NETWORKS = ['reverse-proxy'];
23
+
24
+ function readBundledRuntimeManifest() {
25
+ if (!fs.existsSync(BUNDLED_RUNTIME_MANIFEST)) {
26
+ throw new Error(`Bundled runtime manifest not found at ${BUNDLED_RUNTIME_MANIFEST}`);
27
+ }
28
+ return JSON.parse(fs.readFileSync(BUNDLED_RUNTIME_MANIFEST, 'utf8'));
29
+ }
30
+
31
+ function normalizeRuntimeSource(rawValue) {
32
+ const value = String(rawValue || DEFAULT_RUNTIME_SOURCE).trim().toLowerCase();
33
+ if (!value) {
34
+ return DEFAULT_RUNTIME_SOURCE;
35
+ }
36
+ if (!['auto', 'repo', 'bundle'].includes(value)) {
37
+ throw new Error(`Unsupported runtime source "${rawValue}". Use auto, repo, or bundle.`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function normalizeDataSource(rawValue) {
43
+ const value = String(rawValue || DEFAULT_DATA_SOURCE).trim().toLowerCase();
44
+ if (!value) {
45
+ return DEFAULT_DATA_SOURCE;
46
+ }
47
+ if (!['empty', 'prod-cache', 'prod-fresh'].includes(value)) {
48
+ throw new Error(
49
+ `Unsupported data source "${rawValue}". Use empty, prod-cache, or prod-fresh.`
50
+ );
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function tryFindRepoRoot(startDir = __dirname) {
56
+ let current = startDir;
57
+ const maxDepth = 10;
58
+ let depth = 0;
59
+
60
+ while (depth < maxDepth) {
61
+ const candidate = path.join(current, 'docker-compose.spaps-dev.yml');
62
+ if (fs.existsSync(candidate)) {
63
+ return current;
64
+ }
65
+ const parent = path.dirname(current);
66
+ if (parent === current) {
67
+ break;
68
+ }
69
+ current = parent;
70
+ depth += 1;
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ function defaultBundledRuntimeDir(port) {
77
+ const xdgCacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
78
+ return path.join(xdgCacheHome, 'spaps', `local-${port}`);
79
+ }
80
+
81
+ function defaultCacheRoot() {
82
+ const xdgCacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
83
+ return path.join(xdgCacheHome, 'spaps');
84
+ }
85
+
86
+ function shellEscape(value) {
87
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
88
+ }
89
+
90
+ function ensureBundledRuntime(runtimeDir) {
91
+ if (!fs.existsSync(BUNDLED_RUNTIME_ROOT)) {
92
+ throw new Error(
93
+ `Bundled runtime assets not found at ${BUNDLED_RUNTIME_ROOT}. Reinstall the spaps package.`
94
+ );
95
+ }
96
+
97
+ fs.mkdirSync(runtimeDir, { recursive: true });
98
+ fs.cpSync(BUNDLED_RUNTIME_ROOT, runtimeDir, { recursive: true, force: true });
99
+
100
+ for (const scriptName of ['container-entrypoint.sh', 'run-migrations.sh']) {
101
+ const scriptPath = path.join(runtimeDir, 'scripts', scriptName);
102
+ if (fs.existsSync(scriptPath)) {
103
+ fs.chmodSync(scriptPath, 0o755);
104
+ }
105
+ }
106
+ }
107
+
108
+ function buildComposeEnv({ port, authBaseUrl }) {
109
+ const manifest = readBundledRuntimeManifest();
110
+ const portableRuntime = manifest.portable_runtime || {};
111
+ return {
112
+ ...process.env,
113
+ SPAPS_LOCAL_PORT: String(port),
114
+ SPAPS_LOCAL_MODE: String(process.env.SPAPS_LOCAL_MODE ?? portableRuntime.default_local_mode ?? true),
115
+ SPAPS_AUTH_BASE_URL:
116
+ process.env.SPAPS_AUTH_BASE_URL ||
117
+ authBaseUrl ||
118
+ portableRuntime.default_auth_base_url ||
119
+ DEFAULT_AUTH_BASE_URL,
120
+ JWT_SECRET: process.env.JWT_SECRET || 'spaps_local_dev_jwt_secret',
121
+ REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET || 'spaps_local_dev_refresh_secret',
122
+ SELF_SERVICE_PASSWORD:
123
+ process.env.SELF_SERVICE_PASSWORD ||
124
+ portableRuntime.default_self_service_password ||
125
+ 'spaps_local_self_service_password',
126
+ CORS_ALLOW_ORIGINS: process.env.CORS_ALLOW_ORIGINS || '*',
127
+ LEGACY_API_KEY_AUTH_ENABLED: process.env.LEGACY_API_KEY_AUTH_ENABLED || 'true',
128
+ SPAPS_SERVER_QUICKSTART_VERSION:
129
+ process.env.SPAPS_SERVER_QUICKSTART_VERSION || manifest.spaps_server_quickstart_version,
130
+ };
131
+ }
132
+
133
+ function resolveLocalRuntime({
134
+ port = 3301,
135
+ runtimeDir = null,
136
+ runtimeSource = DEFAULT_RUNTIME_SOURCE,
137
+ authBaseUrl = DEFAULT_AUTH_BASE_URL,
138
+ } = {}) {
139
+ const source = normalizeRuntimeSource(runtimeSource || process.env.SPAPS_LOCAL_RUNTIME_SOURCE);
140
+ const repoRoot = tryFindRepoRoot();
141
+
142
+ if (source === 'repo' || (source === 'auto' && repoRoot)) {
143
+ if (!repoRoot) {
144
+ throw new Error('Repo runtime requested but sweet-potato repo root was not found');
145
+ }
146
+ return {
147
+ mode: 'repo',
148
+ runtimeDir: repoRoot,
149
+ cwd: repoRoot,
150
+ composeFile: path.join(repoRoot, 'docker-compose.spaps-dev.yml'),
151
+ composeEnv: buildComposeEnv({ port, authBaseUrl }),
152
+ projectName: path.basename(repoRoot),
153
+ };
154
+ }
155
+
156
+ const resolvedRuntimeDir = path.resolve(
157
+ runtimeDir || process.env.SPAPS_LOCAL_RUNTIME_DIR || defaultBundledRuntimeDir(port)
158
+ );
159
+ ensureBundledRuntime(resolvedRuntimeDir);
160
+
161
+ return {
162
+ mode: 'bundle',
163
+ runtimeDir: resolvedRuntimeDir,
164
+ cwd: resolvedRuntimeDir,
165
+ composeFile: path.join(resolvedRuntimeDir, 'docker-compose.yml'),
166
+ composeEnv: buildComposeEnv({ port, authBaseUrl }),
167
+ projectName: `spaps-local-${port}`,
168
+ };
169
+ }
170
+
14
171
  class LocalServer {
15
172
  constructor(options = {}) {
16
- this.port = options.port || 3301; // Dev API external port
173
+ this.port = options.port || 3301;
17
174
  this.json = options.json || false;
18
175
  this.detach = options.detach || false;
19
176
  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');
177
+ this.fromBackup = options.fromBackup ? path.resolve(options.fromBackup) : null;
178
+ this.dataSource = this.fromBackup
179
+ ? 'backup'
180
+ : normalizeDataSource(options.dataSource || process.env.SPAPS_LOCAL_DATA_SOURCE);
181
+ this.runtimeSource = normalizeRuntimeSource(
182
+ options.runtimeSource || process.env.SPAPS_LOCAL_RUNTIME_SOURCE || DEFAULT_RUNTIME_SOURCE
183
+ );
184
+ this.authBaseUrl = options.authBaseUrl || DEFAULT_AUTH_BASE_URL;
185
+ this.runtime = resolveLocalRuntime({
186
+ port: this.port,
187
+ runtimeDir: options.runtimeDir || null,
188
+ runtimeSource: this.runtimeSource,
189
+ authBaseUrl: this.authBaseUrl,
190
+ });
191
+ this.runtimeMode = this.runtime.mode;
192
+ this.runtimeDir = this.runtime.runtimeDir;
193
+ this.cwd = this.runtime.cwd;
194
+ this.composeFile = this.runtime.composeFile;
195
+ this.composeEnv = this.runtime.composeEnv;
196
+ this.projectName = this.runtime.projectName;
23
197
  this.apiUrl = `http://localhost:${this.port}`;
24
198
  this.healthUrl = `${this.apiUrl}/health`;
25
199
  this.logProcess = null;
200
+ this.cacheRoot = path.resolve(process.env.SPAPS_CACHE_ROOT || defaultCacheRoot());
201
+ this.restoreStateFile = path.join(
202
+ this.cacheRoot,
203
+ 'restore-state',
204
+ `${this.projectName}.json`
205
+ );
206
+ this.cachedProdDumpPath = path.join(this.cacheRoot, 'db', 'prod.sql.gz');
26
207
  }
27
208
 
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;
40
- }
41
- const parent = path.dirname(current);
42
- if (parent === current) {
43
- break; // Reached filesystem root
44
- }
45
- current = parent;
46
- depth++;
47
- }
48
-
49
- throw new Error('Could not find sweet-potato repo root (docker-compose.spaps-dev.yml not found)');
50
- }
51
-
52
- /**
53
- * Check if docker compose is available
54
- */
55
209
  checkDockerCompose() {
56
210
  try {
57
211
  execSync('docker compose version', { stdio: 'ignore' });
@@ -66,143 +220,297 @@ class LocalServer {
66
220
  }
67
221
  }
68
222
 
69
- /**
70
- * Run docker compose command
71
- */
72
223
  runCompose(args, options = {}) {
73
224
  const composeCmd = this.checkDockerCompose();
74
- const fullArgs = ['-f', this.composeFile, ...args];
225
+ const fullArgs = ['-p', this.projectName, '-f', this.composeFile, ...args];
226
+ const command = `${composeCmd} ${fullArgs.map(shellEscape).join(' ')}`;
75
227
 
76
228
  if (options.silent) {
77
- return execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
78
- cwd: this.repoRoot,
79
- stdio: 'ignore'
229
+ return execSync(command, {
230
+ cwd: this.cwd,
231
+ stdio: 'ignore',
232
+ env: this.composeEnv,
80
233
  });
81
234
  }
82
235
 
83
- const result = execSync(`${composeCmd} ${fullArgs.join(' ')}`, {
84
- cwd: this.repoRoot,
85
- encoding: 'utf-8'
236
+ return execSync(command, {
237
+ cwd: this.cwd,
238
+ encoding: 'utf-8',
239
+ env: this.composeEnv,
86
240
  });
241
+ }
87
242
 
88
- return result;
243
+ runBash(command, options = {}) {
244
+ const stdio = options.silent ? 'ignore' : (options.stdio || 'inherit');
245
+ return execFileSync('bash', ['-lc', command], {
246
+ cwd: this.cwd,
247
+ env: this.composeEnv,
248
+ stdio,
249
+ encoding: options.encoding || 'utf8',
250
+ });
251
+ }
252
+
253
+ ensureExternalNetworks() {
254
+ if (this.runtimeMode !== 'repo') {
255
+ return;
256
+ }
257
+ for (const network of EXTERNAL_NETWORKS) {
258
+ try {
259
+ execSync(`docker network inspect ${network}`, { stdio: 'ignore' });
260
+ } catch {
261
+ if (!this.json) {
262
+ console.log(chalk.dim(`🔌 Creating Docker network ${network}...`));
263
+ }
264
+ execSync(`docker network create ${network}`, {
265
+ stdio: this.json ? 'ignore' : 'inherit',
266
+ });
267
+ }
268
+ }
89
269
  }
90
270
 
91
- /**
92
- * Wait for health check to pass
93
- */
94
271
  async waitForHealthCheck(maxAttempts = 60, intervalMs = 1000) {
95
272
  if (!this.json) {
96
273
  console.log(chalk.dim(`⏳ Waiting for SPAPS API at ${this.healthUrl}...`));
97
274
  }
98
275
 
99
- for (let i = 0; i < maxAttempts; i++) {
276
+ for (let i = 0; i < maxAttempts; i += 1) {
100
277
  try {
101
278
  const response = await axios.get(this.healthUrl, { timeout: 2000 });
102
279
  if (response.status === 200) {
103
280
  return true;
104
281
  }
105
- } catch (error) {
106
- // Continue waiting
282
+ } catch {
283
+ // Keep polling.
107
284
  }
108
- await new Promise(resolve => setTimeout(resolve, intervalMs));
285
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
109
286
  }
110
287
 
111
288
  throw new Error(`Health check failed after ${maxAttempts} attempts`);
112
289
  }
113
290
 
114
- /**
115
- * Load data from backup if provided
116
- */
117
- async loadFromBackup() {
118
- if (!this.fromBackup) {
119
- return;
291
+ async waitForDatabaseReady(maxAttempts = 60, intervalMs = 1000) {
292
+ if (!this.json) {
293
+ console.log(chalk.dim('⏳ Waiting for SPAPS database...'));
120
294
  }
121
295
 
122
- const backupPath = path.resolve(this.fromBackup);
123
- if (!fs.existsSync(backupPath)) {
124
- throw new Error(`Backup file not found: ${backupPath}`);
296
+ for (let i = 0; i < maxAttempts; i += 1) {
297
+ try {
298
+ this.runCompose(['exec', '-T', 'spaps-dev-db', 'pg_isready', '-U', 'postgres'], {
299
+ silent: true,
300
+ });
301
+ return true;
302
+ } catch {
303
+ // Keep polling.
304
+ }
305
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
125
306
  }
126
307
 
127
- if (!this.json) {
128
- console.log(chalk.blue(`📦 Loading from backup: ${backupPath}`));
129
- }
308
+ throw new Error(`Database did not become ready after ${maxAttempts} attempts`);
309
+ }
130
310
 
131
- // Run the transform + load pipeline
311
+ readRestoreState() {
312
+ if (!fs.existsSync(this.restoreStateFile)) {
313
+ return null;
314
+ }
132
315
  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
- }
140
- });
316
+ return JSON.parse(fs.readFileSync(this.restoreStateFile, 'utf8'));
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
141
321
 
142
- if (!this.json) {
143
- console.log(chalk.green('✅ Backup loaded successfully'));
322
+ restoreStateMatches(signature) {
323
+ const payload = this.readRestoreState();
324
+ if (!payload || !payload.signature) {
325
+ return false;
326
+ }
327
+ return JSON.stringify(payload.signature) === JSON.stringify(signature);
328
+ }
329
+
330
+ writeRestoreState(signature) {
331
+ fs.mkdirSync(path.dirname(this.restoreStateFile), { recursive: true });
332
+ fs.writeFileSync(
333
+ this.restoreStateFile,
334
+ JSON.stringify(
335
+ {
336
+ signature,
337
+ updated_at: new Date().toISOString(),
338
+ },
339
+ null,
340
+ 2
341
+ )
342
+ );
343
+ }
344
+
345
+ clearRestoreState() {
346
+ if (fs.existsSync(this.restoreStateFile)) {
347
+ fs.unlinkSync(this.restoreStateFile);
348
+ }
349
+ }
350
+
351
+ resolveFetchProdScript() {
352
+ const scriptPath = path.join(BUNDLED_RUNTIME_ROOT, 'scripts', 'fetch-prod-db.sh');
353
+ if (!fs.existsSync(scriptPath)) {
354
+ throw new Error(`Bundled prod dump fetch script not found at ${scriptPath}`);
355
+ }
356
+ return scriptPath;
357
+ }
358
+
359
+ fetchProdDump(forceFresh = false) {
360
+ const scriptPath = this.resolveFetchProdScript();
361
+ const env = {
362
+ ...process.env,
363
+ SPAPS_CACHE_ROOT: this.cacheRoot,
364
+ };
365
+ if (forceFresh) {
366
+ env.SPAPS_PROD_DB_FRESH = '1';
367
+ }
368
+ if (!this.json) {
369
+ const modeLabel = forceFresh ? 'fresh production dump' : 'cached production dump';
370
+ console.log(chalk.blue(`📦 Resolving ${modeLabel}...`));
371
+ }
372
+ execFileSync('bash', [scriptPath], {
373
+ cwd: this.cwd,
374
+ env,
375
+ stdio: this.json ? 'ignore' : 'inherit',
376
+ });
377
+ return this.cachedProdDumpPath;
378
+ }
379
+
380
+ resolveRestorePlan() {
381
+ if (this.fromBackup) {
382
+ if (!fs.existsSync(this.fromBackup)) {
383
+ throw new Error(`Backup file not found: ${this.fromBackup}`);
144
384
  }
145
- } catch (error) {
146
- throw new Error(`Failed to load backup: ${error.message}`);
385
+ const stat = fs.statSync(this.fromBackup);
386
+ return {
387
+ source: 'backup',
388
+ path: this.fromBackup,
389
+ signature: {
390
+ source: 'backup',
391
+ path: this.fromBackup,
392
+ size: stat.size,
393
+ mtime_ms: stat.mtimeMs,
394
+ },
395
+ };
147
396
  }
397
+
398
+ if (this.dataSource === 'empty') {
399
+ return null;
400
+ }
401
+
402
+ const dumpPath = this.fetchProdDump(this.dataSource === 'prod-fresh');
403
+ if (!fs.existsSync(dumpPath)) {
404
+ throw new Error(`Resolved dump file not found: ${dumpPath}`);
405
+ }
406
+ const stat = fs.statSync(dumpPath);
407
+ return {
408
+ source: this.dataSource,
409
+ path: dumpPath,
410
+ signature: {
411
+ source: 'prod-dump',
412
+ path: dumpPath,
413
+ size: stat.size,
414
+ mtime_ms: stat.mtimeMs,
415
+ },
416
+ };
148
417
  }
149
418
 
150
- /**
151
- * Start the Docker Compose stack
152
- */
153
- async start() {
419
+ databaseHasRestoredBaseData() {
154
420
  try {
155
- // Check prerequisites
156
- this.checkDockerCompose();
421
+ const count = this.runCompose(
422
+ [
423
+ 'exec',
424
+ '-T',
425
+ 'spaps-dev-db',
426
+ 'psql',
427
+ '-U',
428
+ 'postgres',
429
+ '-d',
430
+ 'spaps',
431
+ '-At',
432
+ '-c',
433
+ 'SELECT COUNT(*) FROM applications;',
434
+ ],
435
+ { silent: false }
436
+ );
437
+ return Number.parseInt(String(count).trim(), 10) > 0;
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
157
442
 
158
- if (!fs.existsSync(this.composeFile)) {
159
- throw new Error(`docker-compose.spaps-dev.yml not found at ${this.composeFile}`);
160
- }
443
+ restoreDumpIntoDatabase(dumpPath) {
444
+ const composeCmd = this.checkDockerCompose();
445
+ const fullArgs = [
446
+ '-p',
447
+ this.projectName,
448
+ '-f',
449
+ this.composeFile,
450
+ 'exec',
451
+ '-T',
452
+ 'spaps-dev-db',
453
+ 'psql',
454
+ '-U',
455
+ 'postgres',
456
+ '-d',
457
+ 'spaps',
458
+ ];
459
+ const composeInvocation = `${composeCmd} ${fullArgs.map(shellEscape).join(' ')}`;
460
+ const restoreCommand =
461
+ `set -o pipefail; gunzip -c ${shellEscape(dumpPath)} | ${composeInvocation} >/dev/null`;
161
462
 
162
- // Handle fresh mode
163
- if (this.fresh) {
164
- if (!this.json) {
165
- console.log(chalk.yellow('🔄 Fresh mode: tearing down existing stack...'));
166
- }
167
- try {
168
- this.runCompose(['down', '-v'], { silent: true });
169
- } catch {
170
- // Ignore errors if stack doesn't exist
171
- }
172
- }
463
+ if (!this.json) {
464
+ console.log(chalk.blue(`📥 Restoring data from ${dumpPath}...`));
465
+ }
173
466
 
174
- // Start the stack
467
+ let restoreHadWarnings = false;
468
+ try {
469
+ this.runBash(restoreCommand, { silent: this.json });
470
+ } catch (error) {
471
+ restoreHadWarnings = true;
175
472
  if (!this.json) {
176
- console.log();
177
- console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
178
- console.log(chalk.blue('🐳 Starting Docker Compose stack...'));
473
+ console.log(
474
+ chalk.yellow(
475
+ '⚠️ Database restore reported warnings. Continuing because SPAPS dumps can contain benign owner/duplicate-object noise.'
476
+ )
477
+ );
179
478
  }
479
+ }
180
480
 
181
- const startArgs = ['up', '-d'];
182
- this.runCompose(startArgs, { silent: this.json });
183
-
184
- // Wait for health check
185
- await this.waitForHealthCheck();
481
+ if (!this.databaseHasRestoredBaseData()) {
482
+ throw new Error('Database restore did not materialize SPAPS base data.');
483
+ }
186
484
 
187
- // Load backup if provided
188
- if (this.fromBackup) {
189
- await this.loadFromBackup();
190
- }
485
+ return { restoreHadWarnings };
486
+ }
191
487
 
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' }
201
- }
202
- };
488
+ composeStartArgs(...services) {
489
+ if (this.runtimeMode === 'bundle') {
490
+ return ['up', '-d', '--build', ...services];
491
+ }
492
+ return ['up', '-d', ...services];
493
+ }
203
494
 
204
- if (this.json) {
205
- console.log(JSON.stringify({
495
+ printConnectionInfo({ restorePlan, restoreApplied }) {
496
+ const connectionInfo = {
497
+ SPAPS_API_URL: this.apiUrl,
498
+ SPAPS_API_KEY: 'spaps_local_development_key',
499
+ SPAPS_APPLICATION_ID: '00000000-0000-0000-0000-000000000100',
500
+ SELF_SERVICE_PASSWORD: this.composeEnv.SELF_SERVICE_PASSWORD,
501
+ data_source: this.dataSource,
502
+ restore_applied: Boolean(restoreApplied),
503
+ restore_source: restorePlan ? restorePlan.source : null,
504
+ test_users: {
505
+ admin: { email: 'admin@spaps.dev', password: 'Admin1234x' },
506
+ user: { email: 'user@spaps.dev', password: 'User1234x' },
507
+ premium: { email: 'premium@spaps.dev', password: 'Premium1234x' },
508
+ },
509
+ };
510
+
511
+ if (this.json) {
512
+ console.log(
513
+ JSON.stringify({
206
514
  success: true,
207
515
  command: 'local',
208
516
  server: {
@@ -210,51 +518,134 @@ class LocalServer {
210
518
  docs: `${this.apiUrl}/docs`,
211
519
  health: `${this.apiUrl}/health`,
212
520
  mode: 'docker-compose',
521
+ runtime_source: this.runtimeMode,
522
+ runtime_dir: this.runtimeDir,
523
+ data_source: this.dataSource,
524
+ restore_applied: Boolean(restoreApplied),
213
525
  port: this.port,
214
- connection: connectionInfo
526
+ connection: connectionInfo,
527
+ },
528
+ })
529
+ );
530
+ return;
531
+ }
532
+
533
+ console.log();
534
+ console.log(chalk.green('✨ SPAPS server is running!'));
535
+ console.log();
536
+ console.log(chalk.cyan('📡 Connection Info:'));
537
+ console.log(` ${chalk.bold('API URL:')} ${this.apiUrl}`);
538
+ console.log(` ${chalk.bold('Documentation:')} ${this.apiUrl}/docs`);
539
+ console.log(` ${chalk.bold('Health Check:')} ${this.apiUrl}/health`);
540
+ console.log(` ${chalk.bold('Runtime:')} ${this.runtimeMode} (${this.runtimeDir})`);
541
+ console.log(` ${chalk.bold('Data Source:')} ${this.dataSource}`);
542
+ if (restorePlan) {
543
+ console.log(` ${chalk.bold('Restore:')} ${restoreApplied ? 'applied' : 'reused existing data'} (${restorePlan.source})`);
544
+ }
545
+ console.log();
546
+ console.log(chalk.cyan('🔑 Credentials:'));
547
+ console.log(` ${chalk.bold('API Key:')} ${connectionInfo.SPAPS_API_KEY}`);
548
+ console.log(` ${chalk.bold('Application ID:')} ${connectionInfo.SPAPS_APPLICATION_ID}`);
549
+ console.log(` ${chalk.bold('Self-service:')} ${connectionInfo.SELF_SERVICE_PASSWORD}`);
550
+ console.log();
551
+ console.log(chalk.cyan('👥 Test Users:'));
552
+ console.log(` ${chalk.bold('Admin:')} ${connectionInfo.test_users.admin.email} / ${connectionInfo.test_users.admin.password}`);
553
+ console.log(` ${chalk.bold('User:')} ${connectionInfo.test_users.user.email} / ${connectionInfo.test_users.user.password}`);
554
+ console.log(` ${chalk.bold('Premium:')} ${connectionInfo.test_users.premium.email} / ${connectionInfo.test_users.premium.password}`);
555
+ console.log();
556
+
557
+ if (this.detach) {
558
+ console.log(chalk.dim(' Running in background. Use `npx spaps local stop` to stop.'));
559
+ console.log(chalk.dim(` View logs: docker compose -p ${this.projectName} -f ${this.composeFile} logs -f`));
560
+ console.log();
561
+ return;
562
+ }
563
+
564
+ console.log(chalk.dim(' Press Ctrl+C to stop'));
565
+ console.log();
566
+ console.log(chalk.gray('─'.repeat(60)));
567
+ console.log();
568
+ this.tailLogs();
569
+ }
570
+
571
+ async start() {
572
+ try {
573
+ this.checkDockerCompose();
574
+
575
+ if (!fs.existsSync(this.composeFile)) {
576
+ throw new Error(`Compose file not found at ${this.composeFile}`);
577
+ }
578
+
579
+ this.ensureExternalNetworks();
580
+
581
+ if (!this.json) {
582
+ console.log();
583
+ console.log(chalk.yellow('🍠 SPAPS Local Development Server'));
584
+ }
585
+
586
+ const restorePlan = this.resolveRestorePlan();
587
+ let restoreApplied = false;
588
+
589
+ if (restorePlan) {
590
+ if (!this.json) {
591
+ console.log(chalk.blue('🐳 Starting SPAPS data services...'));
592
+ }
593
+ this.runCompose(['up', '-d', 'spaps-dev-db', 'spaps-dev-redis'], { silent: this.json });
594
+ await this.waitForDatabaseReady();
595
+
596
+ const canReuseRestoredData =
597
+ !this.fresh &&
598
+ this.restoreStateMatches(restorePlan.signature) &&
599
+ this.databaseHasRestoredBaseData();
600
+
601
+ if (!canReuseRestoredData) {
602
+ if (!this.json) {
603
+ const reason = this.fresh ? 'fresh mode requested' : 'base data missing or stale';
604
+ console.log(chalk.yellow(`🔄 Reinitializing SPAPS data volumes (${reason})...`));
605
+ }
606
+ try {
607
+ this.runCompose(['down', '-v'], { silent: true });
608
+ } catch {
609
+ // Ignore teardown errors when the stack is partially absent.
215
610
  }
216
- }));
611
+ this.clearRestoreState();
612
+ this.ensureExternalNetworks();
613
+ this.runCompose(['up', '-d', 'spaps-dev-db', 'spaps-dev-redis'], { silent: this.json });
614
+ await this.waitForDatabaseReady();
615
+ this.restoreDumpIntoDatabase(restorePlan.path);
616
+ this.writeRestoreState(restorePlan.signature);
617
+ restoreApplied = true;
618
+ }
619
+
620
+ if (!this.json) {
621
+ console.log(chalk.blue('🚀 Starting SPAPS API...'));
622
+ }
623
+ this.runCompose(this.composeStartArgs('spaps-dev-api'), { silent: this.json });
217
624
  } 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();
625
+ if (this.fresh) {
626
+ if (!this.json) {
627
+ console.log(chalk.yellow('🔄 Fresh mode: tearing down existing stack...'));
628
+ }
629
+ try {
630
+ this.runCompose(['down', '-v'], { silent: true });
631
+ } catch {
632
+ // Ignore teardown errors when the stack does not exist yet.
633
+ }
634
+ this.clearRestoreState();
635
+ }
235
636
 
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();
240
- } else {
241
- console.log(chalk.dim(' Press Ctrl+C to stop'));
242
- console.log();
243
- console.log(chalk.gray('─'.repeat(60)));
244
- console.log();
245
-
246
- // Tail logs
247
- this.tailLogs();
637
+ if (!this.json) {
638
+ console.log(chalk.blue('🐳 Starting Docker Compose stack...'));
248
639
  }
640
+ this.runCompose(this.composeStartArgs(), { silent: this.json });
249
641
  }
250
642
 
643
+ await this.waitForHealthCheck();
644
+ this.printConnectionInfo({ restorePlan, restoreApplied });
251
645
  return { success: true };
252
646
  } catch (error) {
253
647
  if (this.json) {
254
- console.log(JSON.stringify({
255
- success: false,
256
- error: error.message
257
- }));
648
+ console.log(JSON.stringify({ success: false, error: error.message }));
258
649
  } else {
259
650
  console.error(chalk.red('❌ Failed to start SPAPS server:'), error.message);
260
651
  }
@@ -262,9 +653,6 @@ class LocalServer {
262
653
  }
263
654
  }
264
655
 
265
- /**
266
- * Stop the Docker Compose stack
267
- */
268
656
  stop() {
269
657
  try {
270
658
  if (!this.json) {
@@ -274,11 +662,13 @@ class LocalServer {
274
662
  this.runCompose(['down'], { silent: this.json });
275
663
 
276
664
  if (this.json) {
277
- console.log(JSON.stringify({
278
- success: true,
279
- command: 'local stop',
280
- message: 'SPAPS server stopped'
281
- }));
665
+ console.log(
666
+ JSON.stringify({
667
+ success: true,
668
+ command: 'local stop',
669
+ message: 'SPAPS server stopped',
670
+ })
671
+ );
282
672
  } else {
283
673
  console.log(chalk.green('✅ SPAPS server stopped'));
284
674
  }
@@ -286,10 +676,7 @@ class LocalServer {
286
676
  return { success: true };
287
677
  } catch (error) {
288
678
  if (this.json) {
289
- console.log(JSON.stringify({
290
- success: false,
291
- error: error.message
292
- }));
679
+ console.log(JSON.stringify({ success: false, error: error.message }));
293
680
  } else {
294
681
  console.error(chalk.red('❌ Failed to stop SPAPS server:'), error.message);
295
682
  }
@@ -297,19 +684,27 @@ class LocalServer {
297
684
  }
298
685
  }
299
686
 
300
- /**
301
- * Tail logs from the API container
302
- */
303
687
  tailLogs() {
304
688
  const composeCmd = this.checkDockerCompose();
305
689
  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'];
690
+ const command = cmdParts[0];
691
+ const subArgs = cmdParts.slice(1);
692
+ const args = [
693
+ ...subArgs,
694
+ '-p',
695
+ this.projectName,
696
+ '-f',
697
+ this.composeFile,
698
+ 'logs',
699
+ '-f',
700
+ '--tail=50',
701
+ 'spaps-dev-api',
702
+ ];
309
703
 
310
704
  this.logProcess = spawn(command, args, {
311
- cwd: this.repoRoot,
312
- stdio: 'inherit'
705
+ cwd: this.cwd,
706
+ stdio: 'inherit',
707
+ env: this.composeEnv,
313
708
  });
314
709
 
315
710
  this.logProcess.on('error', (error) => {
@@ -319,47 +714,50 @@ class LocalServer {
319
714
  });
320
715
  }
321
716
 
322
- /**
323
- * Clean shutdown
324
- */
325
717
  async shutdown() {
326
718
  if (this.logProcess) {
327
719
  this.logProcess.kill();
328
720
  }
329
721
 
330
- if (!this.detach) {
331
- if (!this.json) {
332
- console.log();
333
- console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
334
- }
722
+ if (this.detach) {
723
+ return;
724
+ }
335
725
 
336
- try {
337
- this.runCompose(['down'], { silent: this.json });
726
+ if (!this.json) {
727
+ console.log();
728
+ console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
729
+ }
338
730
 
339
- if (!this.json) {
340
- console.log(chalk.green(' Server stopped'));
341
- }
342
- } catch (error) {
343
- if (!this.json) {
344
- console.error(chalk.red('❌ Error during shutdown:'), error.message);
345
- }
731
+ try {
732
+ this.runCompose(['down'], { silent: this.json });
733
+ if (!this.json) {
734
+ console.log(chalk.green('✅ Server stopped'));
735
+ }
736
+ } catch (error) {
737
+ if (!this.json) {
738
+ console.error(chalk.red('❌ Error during shutdown:'), error.message);
346
739
  }
347
740
  }
348
741
  }
349
742
  }
350
743
 
351
- // Export for use in CLI
352
744
  module.exports = LocalServer;
745
+ module.exports.readBundledRuntimeManifest = readBundledRuntimeManifest;
746
+ module.exports.resolveLocalRuntime = resolveLocalRuntime;
747
+ module.exports.normalizeRuntimeSource = normalizeRuntimeSource;
748
+ module.exports.normalizeDataSource = normalizeDataSource;
749
+ module.exports.defaultBundledRuntimeDir = defaultBundledRuntimeDir;
750
+ module.exports.defaultCacheRoot = defaultCacheRoot;
353
751
 
354
- // Run directly if called as script
355
752
  if (require.main === module) {
356
753
  const server = new LocalServer();
357
754
 
358
- // Handle Ctrl+C
359
- process.on('SIGINT', async () => {
755
+ const shutdown = async () => {
360
756
  await server.shutdown();
361
757
  process.exit(0);
362
- });
758
+ };
759
+ process.on('SIGINT', shutdown);
760
+ process.on('SIGTERM', shutdown);
363
761
 
364
762
  server.start().catch((error) => {
365
763
  console.error(chalk.red('❌ Fatal error:'), error.message);