spaps 0.7.5 → 0.7.7
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 +5566 -38
- package/README.md +65 -13
- package/assets/local-runtime/Dockerfile +13 -0
- package/assets/local-runtime/docker-compose.yml +2 -1
- package/assets/local-runtime/manifest.json +3 -1
- package/bin/spaps.js +34 -8
- package/package.json +3 -4
- package/src/ai-helper.js +44 -10
- package/src/ai-tool-spec.js +19 -4
- package/src/cli-dispatcher.js +365 -91
- package/src/doctor.js +58 -1
- package/src/domain-cli.js +79 -0
- package/src/domains.js +193 -0
- package/src/fixture-kernel.js +898 -29
- package/src/handlers.js +471 -4
- package/src/home-view.js +200 -0
- package/src/local-runtime.js +19 -4
- package/src/local-server.js +30 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Shared helper for thin CLI subcommands that proxy to SPAPS domain endpoints.
|
|
2
|
+
// Reuses auth/client.js authFetch: SPAPS_ACCESS_TOKEN env or stored creds,
|
|
3
|
+
// auto-refresh on 401. Domain commands focus on routing, not auth.
|
|
4
|
+
|
|
5
|
+
const { authFetch, resolveServerUrl } = require('./auth/client');
|
|
6
|
+
|
|
7
|
+
function normalizeError(err) {
|
|
8
|
+
if (err && err.code) return err;
|
|
9
|
+
const wrapped = new Error(err && err.message ? err.message : String(err));
|
|
10
|
+
wrapped.code = 'DOMAIN_REQUEST_FAILED';
|
|
11
|
+
return wrapped;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function callEndpoint({ options = {}, method = 'GET', path, body = null, query = null } = {}) {
|
|
15
|
+
if (!path) throw new Error('callEndpoint requires a path');
|
|
16
|
+
const serverUrl = resolveServerUrl(options);
|
|
17
|
+
let fullPath = path;
|
|
18
|
+
if (query && typeof query === 'object') {
|
|
19
|
+
const params = new URLSearchParams();
|
|
20
|
+
for (const [k, v] of Object.entries(query)) {
|
|
21
|
+
if (v === undefined || v === null) continue;
|
|
22
|
+
params.append(k, String(v));
|
|
23
|
+
}
|
|
24
|
+
const qs = params.toString();
|
|
25
|
+
if (qs) fullPath = `${path}${path.includes('?') ? '&' : '?'}${qs}`;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const res = await authFetch(fullPath, { serverUrl, method, body });
|
|
29
|
+
return {
|
|
30
|
+
status: res.status,
|
|
31
|
+
data: res.data,
|
|
32
|
+
ok: res.status >= 200 && res.status < 300,
|
|
33
|
+
};
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw normalizeError(err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emit({ intent, result, isJson, successMessage = null }) {
|
|
40
|
+
if (isJson) {
|
|
41
|
+
console.log(JSON.stringify({
|
|
42
|
+
success: Boolean(result.ok),
|
|
43
|
+
command: intent,
|
|
44
|
+
status: result.status,
|
|
45
|
+
data: result.data,
|
|
46
|
+
}, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
const msg = (result.data && (result.data.error?.message || result.data.message)) || `HTTP ${result.status}`;
|
|
51
|
+
console.error(`\u2717 ${intent}: ${msg}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (successMessage) {
|
|
55
|
+
console.log(successMessage);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
59
|
+
} catch {
|
|
60
|
+
console.log(String(result.data));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function emitAuthError(command, err, isJson) {
|
|
65
|
+
if (isJson) {
|
|
66
|
+
console.log(JSON.stringify({
|
|
67
|
+
success: false,
|
|
68
|
+
command,
|
|
69
|
+
error: { code: err.code || 'ERROR', message: err.message || String(err) },
|
|
70
|
+
}, null, 2));
|
|
71
|
+
} else {
|
|
72
|
+
const hint = err.code === 'NOT_AUTHENTICATED' || err.code === 'SESSION_EXPIRED'
|
|
73
|
+
? ' (run `spaps login` first)'
|
|
74
|
+
: '';
|
|
75
|
+
console.error(`\u2717 ${command}: ${err.message}${hint}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { callEndpoint, emit, emitAuthError };
|
package/src/domains.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain registry for SPAPS CLI.
|
|
3
|
+
*
|
|
4
|
+
* Each entry declares the routes a domain exposes and how the CLI should
|
|
5
|
+
* surface them: tool-spec entries, doctor mount probe, and metadata flags.
|
|
6
|
+
*
|
|
7
|
+
* Shape:
|
|
8
|
+
* {
|
|
9
|
+
* key: 'dayrate',
|
|
10
|
+
* label: 'Dayrate',
|
|
11
|
+
* probe: { method: 'GET', path: '/api/dayrate/availability' },
|
|
12
|
+
* tools: [
|
|
13
|
+
* { name, description, method, path,
|
|
14
|
+
* parameters?: JSONSchema,
|
|
15
|
+
* admin_required?: boolean, // D1 + D5
|
|
16
|
+
* agent_callable?: boolean } // D5, default true
|
|
17
|
+
* ]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Path prefixes reflect the live backend routers as of 2026-04-16.
|
|
21
|
+
* Public = API key only. Admin = API key + JWT + admin role (admin_required:true).
|
|
22
|
+
* The CLI does not invent schemas; when OpenAPI is available ai-tool-spec.js
|
|
23
|
+
* enriches parameters/responses automatically.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DAYRATE = {
|
|
27
|
+
key: 'dayrate',
|
|
28
|
+
label: 'Dayrate',
|
|
29
|
+
probe: { method: 'GET', path: '/api/dayrate/availability' },
|
|
30
|
+
tools: [
|
|
31
|
+
{ name: 'dayrate_availability', method: 'GET', path: '/api/dayrate/availability',
|
|
32
|
+
description: 'List bookable slots within the configured horizon with dynamic pricing.' },
|
|
33
|
+
{ name: 'dayrate_book', method: 'POST', path: '/api/dayrate/book',
|
|
34
|
+
description: 'Book a single slot. Returns FreeBookResponse when an entitled user has remaining allotment, otherwise a Stripe Checkout URL.' },
|
|
35
|
+
{ name: 'dayrate_book_multi', method: 'POST', path: '/api/dayrate/book-multi',
|
|
36
|
+
description: 'Book multiple slots in one Stripe session with running fill-rate pricing.' },
|
|
37
|
+
{ name: 'dayrate_checkout_status', method: 'GET', path: '/api/dayrate/checkout-status',
|
|
38
|
+
description: 'Poll a Stripe session to learn the resulting booking status.' },
|
|
39
|
+
{ name: 'dayrate_cancel', method: 'POST', path: '/api/dayrate/cancel',
|
|
40
|
+
description: 'Cancel an existing booking. Owner verified by user_id then email.' },
|
|
41
|
+
{ name: 'dayrate_allotment', method: 'GET', path: '/api/dayrate/allotment',
|
|
42
|
+
description: 'Inspect free allotment status for a policy in the rolling window.' },
|
|
43
|
+
{ name: 'dayrate_admin_get_config', method: 'GET', path: '/api/dayrate/admin/config',
|
|
44
|
+
description: 'Fetch dayrate pricing/availability config.', admin_required: true },
|
|
45
|
+
{ name: 'dayrate_admin_update_config', method: 'PUT', path: '/api/dayrate/admin/config',
|
|
46
|
+
description: 'Upsert dayrate config (rates, tiers, horizon, timezone, slot definitions).', admin_required: true },
|
|
47
|
+
{ name: 'dayrate_admin_list_bookings', method: 'GET', path: '/api/dayrate/admin/bookings',
|
|
48
|
+
description: 'Admin view of bookings with status/date/email/enrollment/user/is_free filters.', admin_required: true },
|
|
49
|
+
{ name: 'dayrate_admin_list_policies', method: 'GET', path: '/api/dayrate/admin/policies',
|
|
50
|
+
description: 'List active booking policies.', admin_required: true },
|
|
51
|
+
{ name: 'dayrate_admin_create_policy', method: 'POST', path: '/api/dayrate/admin/policies',
|
|
52
|
+
description: 'Create a booking policy (entitlement key, free allotment, overage rate, reschedule rules).', admin_required: true },
|
|
53
|
+
{ name: 'dayrate_admin_update_policy', method: 'PUT', path: '/api/dayrate/admin/policies/{policy_id}',
|
|
54
|
+
description: 'Partial update of a booking policy.', admin_required: true },
|
|
55
|
+
{ name: 'dayrate_admin_delete_policy', method: 'DELETE', path: '/api/dayrate/admin/policies/{policy_id}',
|
|
56
|
+
description: 'Delete a booking policy.', admin_required: true },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const EMAIL = {
|
|
61
|
+
key: 'email',
|
|
62
|
+
label: 'Email',
|
|
63
|
+
probe: { method: 'GET', path: '/api/email/templates/__doctor__/preview', ok_on_404: true },
|
|
64
|
+
tools: [
|
|
65
|
+
{ name: 'email_send', method: 'POST', path: '/api/email/send',
|
|
66
|
+
description: 'Send a transactional email by template key. In local mode Mailgun calls are short-circuited.' },
|
|
67
|
+
{ name: 'email_get_template', method: 'GET', path: '/api/email/templates/{template_key}',
|
|
68
|
+
description: 'Fetch a template definition by key.' },
|
|
69
|
+
{ name: 'email_preview_template', method: 'GET', path: '/api/email/templates/{template_key}/preview',
|
|
70
|
+
description: 'Render a template with its sample context.' },
|
|
71
|
+
{ name: 'email_preview_template_with_context', method: 'POST', path: '/api/email/templates/{template_key}/preview',
|
|
72
|
+
description: 'Render a template with caller-provided context.' },
|
|
73
|
+
{ name: 'email_list_logs', method: 'GET', path: '/api/email/logs',
|
|
74
|
+
description: 'List email sends. Non-admin users are scoped to their own logs; admins can filter by user_id or owner_id.' },
|
|
75
|
+
{ name: 'email_admin_list_templates', method: 'GET', path: '/api/email/templates',
|
|
76
|
+
description: 'List all templates for the application.', admin_required: true },
|
|
77
|
+
{ name: 'email_admin_create_template', method: 'POST', path: '/api/email/templates',
|
|
78
|
+
description: 'Create a template (subject, html, text, variables, sample context).', admin_required: true },
|
|
79
|
+
{ name: 'email_admin_update_template', method: 'PUT', path: '/api/email/templates/{template_key}',
|
|
80
|
+
description: 'Update an existing template.', admin_required: true },
|
|
81
|
+
{ name: 'email_admin_get_override', method: 'GET', path: '/api/email/templates/{template_key}/override',
|
|
82
|
+
description: 'Get subject/body override for a template.' },
|
|
83
|
+
{ name: 'email_admin_set_override', method: 'PUT', path: '/api/email/templates/{template_key}/override',
|
|
84
|
+
description: 'Set subject/body override for a template.', admin_required: true },
|
|
85
|
+
{ name: 'email_admin_clear_override', method: 'DELETE', path: '/api/email/templates/{template_key}/override',
|
|
86
|
+
description: 'Clear subject/body override for a template.', admin_required: true },
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const WEBHOOKS = {
|
|
91
|
+
key: 'webhooks',
|
|
92
|
+
label: 'Webhooks',
|
|
93
|
+
probe: { method: 'GET', path: '/api/webhooks/events' },
|
|
94
|
+
tools: [
|
|
95
|
+
{ name: 'webhook_list', method: 'GET', path: '/api/webhooks',
|
|
96
|
+
description: 'List registered outbound webhooks.' },
|
|
97
|
+
{ name: 'webhook_register', method: 'POST', path: '/api/webhooks',
|
|
98
|
+
description: 'Register an outbound webhook. The create response returns a one-time signing_secret.' },
|
|
99
|
+
{ name: 'webhook_update', method: 'PUT', path: '/api/webhooks/{webhook_id}',
|
|
100
|
+
description: 'Update url, events, is_active, or headers on an existing webhook.' },
|
|
101
|
+
{ name: 'webhook_delete', method: 'DELETE', path: '/api/webhooks/{webhook_id}',
|
|
102
|
+
description: 'Remove a registered webhook.' },
|
|
103
|
+
{ name: 'webhook_deliveries', method: 'GET', path: '/api/webhooks/{webhook_id}/deliveries',
|
|
104
|
+
description: 'List delivery attempts for a webhook.' },
|
|
105
|
+
{ name: 'webhook_events', method: 'GET', path: '/api/webhooks/events',
|
|
106
|
+
description: 'List supported outbound event types.' },
|
|
107
|
+
{ name: 'webhook_mailgun_inbound', method: 'POST', path: '/api/webhooks/mailgun/events',
|
|
108
|
+
description: 'Mailgun inbound event receiver. Signature-verified; not callable from agents.',
|
|
109
|
+
agent_callable: false },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const POLICIES = {
|
|
114
|
+
key: 'policies',
|
|
115
|
+
label: 'Policies',
|
|
116
|
+
// /policies/evaluations is admin-gated; 401/403 still proves the router is mounted.
|
|
117
|
+
probe: { method: 'GET', path: '/api/policies/evaluations', ok_on_auth_error: true },
|
|
118
|
+
tools: [
|
|
119
|
+
{ name: 'policy_authorize', method: 'POST', path: '/api/policies/authorize',
|
|
120
|
+
description: 'Evaluate a named policy against a user/context. Returns allow/deny with reasons.' },
|
|
121
|
+
{ name: 'policy_admin_list', method: 'GET', path: '/api/policies',
|
|
122
|
+
description: 'List policies (is_active filter).', admin_required: true },
|
|
123
|
+
{ name: 'policy_admin_create', method: 'POST', path: '/api/policies',
|
|
124
|
+
description: 'Create a policy (name, effect, conditions, priority, metadata).', admin_required: true },
|
|
125
|
+
{ name: 'policy_admin_get', method: 'GET', path: '/api/policies/{policy_id}',
|
|
126
|
+
description: 'Fetch a single policy.', admin_required: true },
|
|
127
|
+
{ name: 'policy_admin_update', method: 'PUT', path: '/api/policies/{policy_id}',
|
|
128
|
+
description: 'Partial update of a policy.', admin_required: true },
|
|
129
|
+
{ name: 'policy_admin_delete', method: 'DELETE', path: '/api/policies/{policy_id}',
|
|
130
|
+
description: 'Delete a policy.', admin_required: true },
|
|
131
|
+
{ name: 'policy_admin_evaluations', method: 'GET', path: '/api/policies/evaluations',
|
|
132
|
+
description: 'List policy evaluation audit logs.', admin_required: true },
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const ISSUE_REPORTING = {
|
|
137
|
+
key: 'issue_reporting',
|
|
138
|
+
label: 'Issue Reporting',
|
|
139
|
+
// /v1/issue-reports/status requires auth; any 401/403 still proves mounted.
|
|
140
|
+
probe: { method: 'GET', path: '/api/v1/issue-reports/status', ok_on_auth_error: true },
|
|
141
|
+
tools: [
|
|
142
|
+
{ name: 'issue_report_create', method: 'POST', path: '/api/v1/issue-reports',
|
|
143
|
+
description: 'Create an issue report. Requires the issue_reporting capability.' },
|
|
144
|
+
{ name: 'issue_report_list_mine', method: 'GET', path: '/api/v1/issue-reports',
|
|
145
|
+
description: 'List the caller\u2019s issue reports.' },
|
|
146
|
+
{ name: 'issue_report_status', method: 'GET', path: '/api/v1/issue-reports/status',
|
|
147
|
+
description: 'Aggregate status for the caller\u2019s issues.' },
|
|
148
|
+
{ name: 'issue_report_get', method: 'GET', path: '/api/v1/issue-reports/{report_id}',
|
|
149
|
+
description: 'Fetch a single issue report.' },
|
|
150
|
+
{ name: 'issue_report_update_note', method: 'PATCH', path: '/api/v1/issue-reports/{report_id}',
|
|
151
|
+
description: 'Update the note on an existing report.' },
|
|
152
|
+
{ name: 'issue_report_reply', method: 'POST', path: '/api/v1/issue-reports/{report_id}/replies',
|
|
153
|
+
description: 'Reply to an issue; creates a child report and reopens the linked support case.' },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const DOMAINS = [DAYRATE, EMAIL, WEBHOOKS, POLICIES, ISSUE_REPORTING];
|
|
158
|
+
|
|
159
|
+
function listDomains() {
|
|
160
|
+
return DOMAINS;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findDomain(key) {
|
|
164
|
+
return DOMAINS.find((d) => d.key === key) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildDomainTools({ includeNonAgent = false } = {}) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const domain of DOMAINS) {
|
|
170
|
+
for (const t of domain.tools) {
|
|
171
|
+
const agentCallable = t.agent_callable !== false;
|
|
172
|
+
if (!agentCallable && !includeNonAgent) continue;
|
|
173
|
+
out.push({
|
|
174
|
+
name: t.name,
|
|
175
|
+
description: t.description,
|
|
176
|
+
method: t.method,
|
|
177
|
+
path: t.path,
|
|
178
|
+
parameters: t.parameters || { type: 'object', properties: {} },
|
|
179
|
+
domain: domain.key,
|
|
180
|
+
admin_required: Boolean(t.admin_required),
|
|
181
|
+
agent_callable: agentCallable,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
DOMAINS,
|
|
190
|
+
listDomains,
|
|
191
|
+
findDomain,
|
|
192
|
+
buildDomainTools,
|
|
193
|
+
};
|