spaps 0.7.2 → 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.
- package/AI_TOOLS.json +10 -11
- package/README.md +267 -110
- package/assets/local-runtime/Dockerfile +28 -0
- package/assets/local-runtime/alembic/env.py +101 -0
- package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
- package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
- package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
- package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
- package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
- package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
- package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
- package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
- package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
- package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
- package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
- package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
- package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
- package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
- package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
- package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
- package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
- package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
- package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
- package/assets/local-runtime/alembic.ini +47 -0
- package/assets/local-runtime/docker-compose.yml +61 -0
- package/assets/local-runtime/manifest.json +8 -0
- package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
- package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
- package/assets/local-runtime/scripts/run-migrations.sh +96 -0
- package/package.json +5 -4
- package/src/ai-helper.js +176 -234
- package/src/ai-tool-spec.js +52 -20
- package/src/auth/api-key.js +119 -0
- package/src/auth/client-id.js +136 -0
- package/src/auth/client.js +169 -0
- package/src/auth/credentials.js +110 -0
- package/src/auth/device-flow.js +159 -0
- package/src/auth/env.js +57 -0
- package/src/auth/handlers.js +462 -0
- package/src/auth/http.js +74 -0
- package/src/cli-dispatcher.js +155 -24
- package/src/docs-system.js +7 -7
- package/src/error-handler.js +42 -0
- package/src/fixture-kernel.js +1143 -0
- package/src/handlers.js +252 -15
- package/src/help-system.js +3 -1
- package/src/local-runtime.js +258 -0
- package/src/local-server.js +597 -199
- package/src/project-scaffolder.js +441 -0
package/src/local-server.js
CHANGED
|
@@ -2,56 +2,210 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* SPAPS Local Development Server - Docker Compose Orchestrator
|
|
5
|
-
*
|
|
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;
|
|
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
|
|
21
|
-
this.
|
|
22
|
-
|
|
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(
|
|
78
|
-
cwd: this.
|
|
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
|
-
|
|
84
|
-
cwd: this.
|
|
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
|
-
|
|
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
|
|
106
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
308
|
+
throw new Error(`Database did not become ready after ${maxAttempts} attempts`);
|
|
309
|
+
}
|
|
130
310
|
|
|
131
|
-
|
|
311
|
+
readRestoreState() {
|
|
312
|
+
if (!fs.existsSync(this.restoreStateFile)) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
132
315
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
await this.loadFromBackup();
|
|
190
|
-
}
|
|
485
|
+
return { restoreHadWarnings };
|
|
486
|
+
}
|
|
191
487
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
237
|
-
console.log(chalk.
|
|
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(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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];
|
|
307
|
-
const subArgs = cmdParts.slice(1);
|
|
308
|
-
const args = [
|
|
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.
|
|
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 (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
|
|
334
|
-
}
|
|
722
|
+
if (this.detach) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
335
725
|
|
|
336
|
-
|
|
337
|
-
|
|
726
|
+
if (!this.json) {
|
|
727
|
+
console.log();
|
|
728
|
+
console.log(chalk.yellow('👋 Shutting down SPAPS server...'));
|
|
729
|
+
}
|
|
338
730
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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);
|