spaps 0.7.3 → 0.7.5

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
@@ -0,0 +1,57 @@
1
+ // Environment detection for the SPAPS CLI auth flow.
2
+ //
3
+ // Determines whether we're in a headless context (SSH, no GUI) so the login
4
+ // command can print the verification URL for copy-paste instead of trying to
5
+ // open a browser that isn't there.
6
+ //
7
+ // TODO(tier1/tier2): when the backend exposes loopback-PKCE routes, use this to
8
+ // pick Tier 1 (browser PKCE) on local GUI and Tier 2 (manual paste PKCE) on
9
+ // SSH, keeping Tier 3 (device code) as the fallback.
10
+
11
+ const { spawn } = require('node:child_process');
12
+
13
+ function isSsh(env = process.env) {
14
+ return Boolean(env.SSH_CLIENT || env.SSH_TTY || env.SSH_CONNECTION);
15
+ }
16
+
17
+ function hasGui(env = process.env, platform = process.platform) {
18
+ if (platform === 'darwin' || platform === 'win32') return true;
19
+ return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
20
+ }
21
+
22
+ function isHeadless(env = process.env, platform = process.platform) {
23
+ return isSsh(env) || !hasGui(env, platform);
24
+ }
25
+
26
+ function tryOpenBrowser(url, { env = process.env, platform = process.platform } = {}) {
27
+ if (isHeadless(env, platform)) return false;
28
+ try {
29
+ let cmd;
30
+ let args;
31
+ if (platform === 'darwin') {
32
+ cmd = 'open';
33
+ args = [url];
34
+ } else if (platform === 'win32') {
35
+ // `start` is a cmd.exe builtin; the empty title "" is required when the
36
+ // first quoted arg would otherwise be treated as the window title.
37
+ cmd = 'cmd';
38
+ args = ['/c', 'start', '""', url];
39
+ } else {
40
+ cmd = 'xdg-open';
41
+ args = [url];
42
+ }
43
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
44
+ child.on('error', () => {});
45
+ child.unref();
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ module.exports = {
53
+ isSsh,
54
+ hasGui,
55
+ isHeadless,
56
+ tryOpenBrowser,
57
+ };
@@ -0,0 +1,462 @@
1
+ // Command handlers for `spaps login`, `spaps logout`, `spaps whoami`,
2
+ // `spaps token`. Wired into the top-level handler map in src/handlers.js.
3
+ //
4
+ // Login uses RFC 8628 device flow (Tier 3). Tiers 1 (browser PKCE loopback)
5
+ // and 2 (manual paste PKCE) are deferred until the SPAPS backend exposes the
6
+ // `/auth/cli-login` / `/auth/callback` / `/auth/token` routes.
7
+
8
+ const chalk = require('chalk');
9
+
10
+ const {
11
+ startDeviceAuthorization,
12
+ pollForToken,
13
+ DeviceFlowError,
14
+ } = require('./device-flow');
15
+ const {
16
+ getCredentials,
17
+ setCredentials,
18
+ clearCredentials,
19
+ CREDENTIALS_PATH,
20
+ } = require('./credentials');
21
+ const { isHeadless, tryOpenBrowser } = require('./env');
22
+ const { authFetch, resolveServerUrl, refreshAccessToken } = require('./client');
23
+ const { resolveLoginClientId } = require('./client-id');
24
+
25
+ function emitJsonError(command, err, extra = {}) {
26
+ console.log(
27
+ JSON.stringify(
28
+ {
29
+ success: false,
30
+ command,
31
+ error: {
32
+ code: err.code || 'ERROR',
33
+ message: err.message || String(err),
34
+ },
35
+ ...extra,
36
+ },
37
+ null,
38
+ 2
39
+ )
40
+ );
41
+ }
42
+
43
+ async function loginHandler({ options }) {
44
+ const isJson = Boolean(options.json);
45
+ const serverUrl = resolveServerUrl(options);
46
+ const clientIdResult = await resolveLoginClientId({ options, serverUrl });
47
+ const clientId = clientIdResult.clientId;
48
+
49
+ if (!clientId) {
50
+ const err = new Error(
51
+ 'Could not resolve a SPAPS application slug for device login. Pass --client-id <app-slug>, set SPAPS_CLI_CLIENT_ID, or run this command inside a repo with spaps.app.json / .spaps/app.json.'
52
+ );
53
+ err.code = 'CLIENT_ID_REQUIRED';
54
+ if (isJson) {
55
+ emitJsonError('login', err, { server_url: serverUrl });
56
+ } else {
57
+ console.error(chalk.red(`\n❌ ${err.message}`));
58
+ }
59
+ process.exit(2);
60
+ }
61
+
62
+ if (!isJson) {
63
+ console.log(chalk.gray(`Requesting device code from ${serverUrl}...`));
64
+ if (clientIdResult.source) {
65
+ const from = clientIdResult.path
66
+ ? `${clientIdResult.source} (${clientIdResult.path})`
67
+ : clientIdResult.source;
68
+ console.log(chalk.gray(`Using client id ${chalk.cyan(clientId)} from ${from}`));
69
+ }
70
+ }
71
+
72
+ let authResult;
73
+ try {
74
+ authResult = await startDeviceAuthorization({ serverUrl, clientId });
75
+ } catch (err) {
76
+ if (isJson) {
77
+ emitJsonError('login', err, { server_url: serverUrl });
78
+ } else {
79
+ console.error(chalk.red(`\n❌ ${err.message}`));
80
+ if (err.code === 'network_error') {
81
+ console.error(
82
+ chalk.gray(` Is a SPAPS server running at ${serverUrl}?`)
83
+ );
84
+ console.error(
85
+ chalk.gray(` Try: npx spaps local (or set SPAPS_API_URL)`)
86
+ );
87
+ }
88
+ }
89
+ process.exit(1);
90
+ }
91
+
92
+ const verificationUri =
93
+ authResult.verification_uri_complete ||
94
+ authResult.verification_uri ||
95
+ authResult.auth_url;
96
+
97
+ if (!isJson) {
98
+ console.log();
99
+ console.log(chalk.bold('To finish signing in:'));
100
+ console.log();
101
+ console.log(` 1. Visit: ${chalk.cyan(verificationUri)}`);
102
+ if (authResult.user_code) {
103
+ console.log(` 2. Confirm code: ${chalk.bold.yellow(authResult.user_code)}`);
104
+ }
105
+ console.log();
106
+
107
+ if (isHeadless()) {
108
+ console.log(
109
+ chalk.gray(
110
+ '(Headless session detected — open the URL on another device)'
111
+ )
112
+ );
113
+ } else if (tryOpenBrowser(verificationUri)) {
114
+ console.log(chalk.gray('(Opened browser automatically)'));
115
+ } else {
116
+ console.log(
117
+ chalk.gray('(Could not auto-open a browser — visit the URL manually)')
118
+ );
119
+ }
120
+ console.log();
121
+ console.log(chalk.gray('Waiting for approval...'));
122
+ }
123
+
124
+ let tokenPayload;
125
+ try {
126
+ tokenPayload = await pollForToken({
127
+ serverUrl,
128
+ deviceCode: authResult.device_code,
129
+ clientId,
130
+ interval: authResult.interval,
131
+ expiresIn: authResult.expires_in,
132
+ onTick: (tick) => {
133
+ if (!isJson && tick.status === 'slow_down') {
134
+ console.log(
135
+ chalk.gray(` (server requested slow down — interval=${tick.interval}s)`)
136
+ );
137
+ }
138
+ },
139
+ });
140
+ } catch (err) {
141
+ if (isJson) {
142
+ emitJsonError('login', err, { server_url: serverUrl });
143
+ } else {
144
+ console.error(chalk.red(`\n❌ ${err.message}`));
145
+ if (err instanceof DeviceFlowError && err.code === 'access_denied') {
146
+ console.error(chalk.gray(' You (or another session) denied the request.'));
147
+ } else if (err instanceof DeviceFlowError && err.code === 'expired_token') {
148
+ console.error(chalk.gray(' Run `spaps login` again to retry.'));
149
+ }
150
+ }
151
+ process.exit(1);
152
+ }
153
+
154
+ const nowSec = Math.floor(Date.now() / 1000);
155
+ const expiresAt = tokenPayload.expires_in
156
+ ? nowSec + Number(tokenPayload.expires_in)
157
+ : null;
158
+
159
+ setCredentials(serverUrl, {
160
+ access_token: tokenPayload.access_token,
161
+ refresh_token: tokenPayload.refresh_token || null,
162
+ token_type: tokenPayload.token_type || 'Bearer',
163
+ expires_in: tokenPayload.expires_in || null,
164
+ expires_at: expiresAt,
165
+ user_id: tokenPayload.user_id || null,
166
+ client_id: clientId,
167
+ });
168
+
169
+ if (isJson) {
170
+ console.log(
171
+ JSON.stringify(
172
+ {
173
+ success: true,
174
+ command: 'login',
175
+ server_url: serverUrl,
176
+ client_id: clientId,
177
+ user_id: tokenPayload.user_id || null,
178
+ expires_at: expiresAt,
179
+ credentials_path: CREDENTIALS_PATH,
180
+ },
181
+ null,
182
+ 2
183
+ )
184
+ );
185
+ return;
186
+ }
187
+
188
+ console.log();
189
+ console.log(chalk.green('✅ Logged in to ') + chalk.cyan(serverUrl));
190
+ if (tokenPayload.user_id) {
191
+ console.log(chalk.gray(` user_id: ${tokenPayload.user_id}`));
192
+ }
193
+ console.log(
194
+ chalk.gray(` credentials saved to ${CREDENTIALS_PATH} (mode 0600)`)
195
+ );
196
+ console.log();
197
+ }
198
+
199
+ async function logoutHandler({ options }) {
200
+ const isJson = Boolean(options.json);
201
+ const serverUrl = resolveServerUrl(options);
202
+ const creds = getCredentials(serverUrl);
203
+
204
+ if (!creds || !creds.access_token) {
205
+ if (isJson) {
206
+ console.log(
207
+ JSON.stringify(
208
+ {
209
+ success: true,
210
+ command: 'logout',
211
+ server_url: serverUrl,
212
+ already_logged_out: true,
213
+ },
214
+ null,
215
+ 2
216
+ )
217
+ );
218
+ return;
219
+ }
220
+ console.log(chalk.yellow(`⚠️ Not currently logged in to ${serverUrl}`));
221
+ return;
222
+ }
223
+
224
+ // Best-effort server-side revoke. We intentionally do not refresh the access
225
+ // token if it has expired — the point of logout is to drop the session, and
226
+ // a failed revoke still clears local credentials.
227
+ let serverRevoked = false;
228
+ let revokeError = null;
229
+ try {
230
+ const res = await authFetch('/auth/logout', {
231
+ serverUrl,
232
+ method: 'POST',
233
+ body: creds.refresh_token ? { refresh_token: creds.refresh_token } : {},
234
+ allowRefresh: false,
235
+ });
236
+ serverRevoked = res.status >= 200 && res.status < 300;
237
+ if (!serverRevoked) {
238
+ revokeError = `HTTP ${res.status}`;
239
+ }
240
+ } catch (err) {
241
+ revokeError = err.message || String(err);
242
+ }
243
+
244
+ clearCredentials(serverUrl);
245
+
246
+ if (isJson) {
247
+ console.log(
248
+ JSON.stringify(
249
+ {
250
+ success: true,
251
+ command: 'logout',
252
+ server_url: serverUrl,
253
+ server_revoked: serverRevoked,
254
+ revoke_error: revokeError,
255
+ },
256
+ null,
257
+ 2
258
+ )
259
+ );
260
+ return;
261
+ }
262
+
263
+ console.log(chalk.green(`✅ Logged out of ${serverUrl}`));
264
+ if (!serverRevoked) {
265
+ console.log(
266
+ chalk.gray(' (server-side revoke failed — local credentials cleared)')
267
+ );
268
+ }
269
+ }
270
+
271
+ async function whoamiHandler({ options }) {
272
+ const isJson = Boolean(options.json);
273
+ const serverUrl = resolveServerUrl(options);
274
+
275
+ let res;
276
+ try {
277
+ res = await authFetch('/auth/user', { serverUrl, method: 'GET' });
278
+ } catch (err) {
279
+ if (isJson) {
280
+ emitJsonError('whoami', err, { server_url: serverUrl });
281
+ } else {
282
+ console.error(chalk.red(`\n❌ ${err.message}`));
283
+ if (err.code === 'NOT_AUTHENTICATED') {
284
+ console.error(chalk.gray(' Run: npx spaps login'));
285
+ } else if (err.code === 'SESSION_EXPIRED') {
286
+ console.error(chalk.gray(' Run: npx spaps login (your session expired)'));
287
+ }
288
+ }
289
+ process.exit(err.code === 'NOT_AUTHENTICATED' || err.code === 'SESSION_EXPIRED' ? 2 : 1);
290
+ }
291
+
292
+ if (res.status >= 400) {
293
+ const msg =
294
+ (res.data && (res.data.detail || res.data.message)) || `HTTP ${res.status}`;
295
+ const code = (res.data && res.data.code) || `HTTP_${res.status}`;
296
+ if (isJson) {
297
+ console.log(
298
+ JSON.stringify(
299
+ {
300
+ success: false,
301
+ command: 'whoami',
302
+ server_url: serverUrl,
303
+ status: res.status,
304
+ error: { code, message: msg },
305
+ },
306
+ null,
307
+ 2
308
+ )
309
+ );
310
+ } else {
311
+ console.error(chalk.red(`\n❌ ${msg}`));
312
+ }
313
+ process.exit(1);
314
+ }
315
+
316
+ const user = res.data && res.data.user ? res.data.user : res.data;
317
+
318
+ if (isJson) {
319
+ console.log(
320
+ JSON.stringify(
321
+ { success: true, command: 'whoami', server_url: serverUrl, user },
322
+ null,
323
+ 2
324
+ )
325
+ );
326
+ return;
327
+ }
328
+
329
+ console.log();
330
+ console.log(chalk.bold('Logged in as:'));
331
+ console.log(
332
+ ' ' + chalk.cyan(user.email || user.username || user.id || '(unknown)')
333
+ );
334
+ if (user.id) console.log(chalk.gray(` id: ${user.id}`));
335
+ if (Array.isArray(user.roles) && user.roles.length) {
336
+ console.log(chalk.gray(` roles: ${user.roles.join(', ')}`));
337
+ }
338
+ if (user.tier) console.log(chalk.gray(` tier: ${user.tier}`));
339
+ console.log(chalk.gray(` server: ${serverUrl}`));
340
+ console.log();
341
+ }
342
+
343
+ async function tokenHandler({ options }) {
344
+ const isJson = Boolean(options.json);
345
+ const serverUrl = resolveServerUrl(options);
346
+
347
+ // Env-var bypass (CI): print it and exit. Skips file read/write entirely.
348
+ if (process.env.SPAPS_ACCESS_TOKEN) {
349
+ if (isJson) {
350
+ console.log(
351
+ JSON.stringify(
352
+ {
353
+ success: true,
354
+ command: 'token',
355
+ source: 'env',
356
+ access_token: process.env.SPAPS_ACCESS_TOKEN,
357
+ },
358
+ null,
359
+ 2
360
+ )
361
+ );
362
+ return;
363
+ }
364
+ process.stdout.write(process.env.SPAPS_ACCESS_TOKEN + '\n');
365
+ return;
366
+ }
367
+
368
+ const creds = getCredentials(serverUrl);
369
+ if (!creds || !creds.access_token) {
370
+ const msg = `Not authenticated to ${serverUrl}. Run: npx spaps login`;
371
+ if (isJson) {
372
+ console.log(
373
+ JSON.stringify(
374
+ {
375
+ success: false,
376
+ command: 'token',
377
+ server_url: serverUrl,
378
+ error: { code: 'NOT_AUTHENTICATED', message: msg },
379
+ },
380
+ null,
381
+ 2
382
+ )
383
+ );
384
+ } else {
385
+ console.error(chalk.red(`❌ ${msg}`));
386
+ }
387
+ process.exit(2);
388
+ }
389
+
390
+ // If the token is within 30s of expiry, try a silent refresh so the caller
391
+ // gets a fresh token. If refresh fails, fall through and print whatever we
392
+ // have — the consumer will see a 401 and re-login.
393
+ const nowSec = Math.floor(Date.now() / 1000);
394
+ if (
395
+ creds.expires_at &&
396
+ creds.expires_at - 30 < nowSec &&
397
+ creds.refresh_token
398
+ ) {
399
+ try {
400
+ const refreshed = await refreshAccessToken({
401
+ serverUrl,
402
+ refreshToken: creds.refresh_token,
403
+ });
404
+ const newExpiresAt = refreshed.expires_in
405
+ ? nowSec + Number(refreshed.expires_in)
406
+ : null;
407
+ setCredentials(serverUrl, {
408
+ ...creds,
409
+ access_token: refreshed.access_token,
410
+ refresh_token: refreshed.refresh_token || creds.refresh_token,
411
+ expires_in: refreshed.expires_in || creds.expires_in,
412
+ expires_at: newExpiresAt,
413
+ token_type: refreshed.token_type || creds.token_type || 'Bearer',
414
+ });
415
+ if (isJson) {
416
+ console.log(
417
+ JSON.stringify(
418
+ {
419
+ success: true,
420
+ command: 'token',
421
+ source: 'refreshed',
422
+ access_token: refreshed.access_token,
423
+ expires_at: newExpiresAt,
424
+ },
425
+ null,
426
+ 2
427
+ )
428
+ );
429
+ } else {
430
+ process.stdout.write(refreshed.access_token + '\n');
431
+ }
432
+ return;
433
+ } catch {
434
+ // fall through — print the stale token
435
+ }
436
+ }
437
+
438
+ if (isJson) {
439
+ console.log(
440
+ JSON.stringify(
441
+ {
442
+ success: true,
443
+ command: 'token',
444
+ source: 'stored',
445
+ access_token: creds.access_token,
446
+ expires_at: creds.expires_at || null,
447
+ },
448
+ null,
449
+ 2
450
+ )
451
+ );
452
+ return;
453
+ }
454
+ process.stdout.write(creds.access_token + '\n');
455
+ }
456
+
457
+ module.exports = {
458
+ loginHandler,
459
+ logoutHandler,
460
+ whoamiHandler,
461
+ tokenHandler,
462
+ };
@@ -0,0 +1,74 @@
1
+ function normalizeServerUrl(serverUrl) {
2
+ return String(serverUrl || '').trim().replace(/\/+$/, '');
3
+ }
4
+
5
+ function buildApiUrl(serverUrl, path) {
6
+ const normalizedPath = path.startsWith('/api/')
7
+ ? path
8
+ : `/api${path.startsWith('/') ? path : `/${path}`}`;
9
+ return `${normalizeServerUrl(serverUrl)}${normalizedPath}`;
10
+ }
11
+
12
+ function unwrapApiData(payload) {
13
+ if (
14
+ payload &&
15
+ typeof payload === 'object' &&
16
+ Object.prototype.hasOwnProperty.call(payload, 'success')
17
+ ) {
18
+ if (payload.success === true && Object.prototype.hasOwnProperty.call(payload, 'data')) {
19
+ return payload.data;
20
+ }
21
+ if (payload.success === false && Object.prototype.hasOwnProperty.call(payload, 'error')) {
22
+ return payload.error;
23
+ }
24
+ }
25
+
26
+ return payload;
27
+ }
28
+
29
+ function extractApiError(payload, status) {
30
+ const source = unwrapApiData(payload);
31
+
32
+ if (source && typeof source === 'object') {
33
+ if (typeof source.code === 'string' || typeof source.message === 'string') {
34
+ return {
35
+ code: source.code || `HTTP_${status}`,
36
+ message: source.message || `Request failed with status ${status}`,
37
+ };
38
+ }
39
+
40
+ if (
41
+ typeof source.error === 'string' ||
42
+ typeof source.error_description === 'string' ||
43
+ typeof source.detail === 'string'
44
+ ) {
45
+ return {
46
+ code: source.error || `HTTP_${status}`,
47
+ message:
48
+ source.error_description ||
49
+ source.detail ||
50
+ source.message ||
51
+ `Request failed with status ${status}`,
52
+ };
53
+ }
54
+ }
55
+
56
+ if (typeof source === 'string' && source.trim()) {
57
+ return {
58
+ code: `HTTP_${status}`,
59
+ message: source,
60
+ };
61
+ }
62
+
63
+ return {
64
+ code: `HTTP_${status}`,
65
+ message: `Request failed with status ${status}`,
66
+ };
67
+ }
68
+
69
+ module.exports = {
70
+ buildApiUrl,
71
+ extractApiError,
72
+ normalizeServerUrl,
73
+ unwrapApiData,
74
+ };