@substrio/cli 0.0.0-stage → 0.1.1
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/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio logs` command
|
|
3
|
+
*
|
|
4
|
+
* View and follow Cloud Function logs.
|
|
5
|
+
* substrio logs : Show recent logs
|
|
6
|
+
* --limit N, --since D|T, --until D|T : Bound the page (function.logs: Unix seconds; D is a duration ago, e.g. 1h)
|
|
7
|
+
* substrio logs --follow : Tail them live (WebSocket, function.logs.stream); --tail and -f are the same
|
|
8
|
+
* --follow --for D | --lines N | --until-match TEXT : A follow that ends on its own (required with no terminal or under --json)
|
|
9
|
+
* --function NAME (or --fn) : Filter by function name
|
|
10
|
+
* --request-id ID, --level LEVEL : Filter by call, by level
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CliError, ErrorCode, emitLine, fail, globals } from '../cli.mjs';
|
|
14
|
+
import { runOperation } from './op.mjs';
|
|
15
|
+
|
|
16
|
+
const C = {
|
|
17
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
18
|
+
red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m', magenta: '\x1b[95m',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The close code the server ends a socket with when the reader fell behind
|
|
22
|
+
// past its backlog bound (substrio_ws_send.h WsCloseCode::BACKLOG_LIMIT).
|
|
23
|
+
export const BACKLOG_LIMIT_CLOSE = 4290;
|
|
24
|
+
|
|
25
|
+
// The close code of a normal, intentional close (RFC 6455): the server ending
|
|
26
|
+
// the stream itself, not an error.
|
|
27
|
+
export const NORMAL_CLOSE = 1000;
|
|
28
|
+
|
|
29
|
+
// The value after the first of `names` in args, or null.
|
|
30
|
+
function flagValue(args, names) {
|
|
31
|
+
const at = args.findIndex((a) => names.includes(a));
|
|
32
|
+
return at >= 0 ? args[at + 1] ?? null : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An integer with an optional unit (ms, s, m, h; s is the default), in ms.
|
|
36
|
+
const DURATION_RE = /^(\d+)(ms|s|m|h)?$/;
|
|
37
|
+
const DURATION_SCALE = { ms: 1, s: 1000, m: 60000, h: 3600000 };
|
|
38
|
+
|
|
39
|
+
export function parseDuration(text) {
|
|
40
|
+
const m = DURATION_RE.exec(text);
|
|
41
|
+
if (!m) throw new CliError(`"${text}" is not a duration (an integer with ms, s, m or h, e.g. 30s)`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
42
|
+
return Number(m[1]) * DURATION_SCALE[m[2] || 's'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --since/--until: a duration (how long ago, from now; needs a unit, e.g.
|
|
46
|
+
// 30s/5m/1h) or a Unix-second/ISO timestamp, resolved to the Unix seconds
|
|
47
|
+
// function.logs' since/until take. A bare integer is a timestamp, not a
|
|
48
|
+
// unitless duration, so it round-trips (substrio logs --until $(date +%s)).
|
|
49
|
+
function resolveTimeFlag(text) {
|
|
50
|
+
const m = DURATION_RE.exec(text);
|
|
51
|
+
if (m && m[2]) return Math.floor((Date.now() - Number(m[1]) * DURATION_SCALE[m[2]]) / 1000);
|
|
52
|
+
if (m) return Number(text);
|
|
53
|
+
const parsed = Date.parse(text);
|
|
54
|
+
if (Number.isNaN(parsed)) throw new CliError(`"${text}" is not a duration or a timestamp`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
55
|
+
return Math.floor(parsed / 1000);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// function.logs's own query options (`function`, `request_id`, `level`,
|
|
59
|
+
// `limit`, `since`, `until`), never a spelling of the CLI's (the server
|
|
60
|
+
// refuses an option it does not declare).
|
|
61
|
+
export function logFilter(args) {
|
|
62
|
+
const filter = {};
|
|
63
|
+
const fn = flagValue(args, ['--fn', '--function']);
|
|
64
|
+
const requestId = flagValue(args, ['--request-id']);
|
|
65
|
+
const level = flagValue(args, ['--level']);
|
|
66
|
+
const limit = flagValue(args, ['--limit']);
|
|
67
|
+
const since = flagValue(args, ['--since']);
|
|
68
|
+
const until = flagValue(args, ['--until']);
|
|
69
|
+
if (fn) filter.function = fn;
|
|
70
|
+
if (requestId) filter.request_id = requestId;
|
|
71
|
+
if (level) filter.level = level;
|
|
72
|
+
if (limit !== null) {
|
|
73
|
+
const n = Number(limit);
|
|
74
|
+
if (!Number.isInteger(n) || n <= 0) throw new CliError(`--limit wants a positive integer, got "${limit}"`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
75
|
+
filter.limit = n;
|
|
76
|
+
}
|
|
77
|
+
if (since !== null) filter.since = resolveTimeFlag(since);
|
|
78
|
+
if (until !== null) filter.until = resolveTimeFlag(until);
|
|
79
|
+
return filter;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --follow's own end: --for DURATION, --lines N, --until-match TEXT (any
|
|
83
|
+
// combination; the first met wins). null when none was given.
|
|
84
|
+
export function followBound(args) {
|
|
85
|
+
const forText = flagValue(args, ['--for']);
|
|
86
|
+
const linesText = flagValue(args, ['--lines']);
|
|
87
|
+
const match = flagValue(args, ['--until-match']);
|
|
88
|
+
if (forText === null && linesText === null && match === null) return null;
|
|
89
|
+
const bound = {};
|
|
90
|
+
if (forText !== null) bound.forMs = parseDuration(forText);
|
|
91
|
+
if (linesText !== null) {
|
|
92
|
+
const n = Number(linesText);
|
|
93
|
+
if (!Number.isInteger(n) || n <= 0) throw new CliError(`--lines wants a positive integer, got "${linesText}"`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
94
|
+
bound.lines = n;
|
|
95
|
+
}
|
|
96
|
+
if (match !== null) bound.match = match;
|
|
97
|
+
return bound;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One frame of the tail socket applied to its state (pure): `state.cursor` is
|
|
102
|
+
* where a reconnect resumes. Returns the entries to print. `connected` sets
|
|
103
|
+
* the cursor when none is held; `logs` and `lagged` move it to the frame's (on
|
|
104
|
+
* `lagged` the server keeps going from that cursor itself).
|
|
105
|
+
*/
|
|
106
|
+
export function applyTailFrame(state, frame) {
|
|
107
|
+
if (frame.type === 'connected') {
|
|
108
|
+
if (state.cursor === null) state.cursor = frame.cursor;
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (typeof frame.cursor === 'string') state.cursor = frame.cursor;
|
|
112
|
+
return frame.type === 'logs' && Array.isArray(frame.logs) ? frame.logs : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The socket URL of the tail with its filters and resume cursor (pure). */
|
|
116
|
+
export function tailUrl(base, filter, cursor) {
|
|
117
|
+
const url = new URL('/v1/_logs/ws', base);
|
|
118
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
119
|
+
for (const [key, value] of Object.entries(filter)) url.searchParams.set(key, value);
|
|
120
|
+
if (cursor) url.searchParams.set('cursor', cursor);
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A self-initiated close (our own abort, not the server's) waits this long for
|
|
125
|
+
// the peer's close frame before the socket is treated as ended anyway: our own
|
|
126
|
+
// decision to stop must not depend on an unresponsive or slow-to-ack peer, or
|
|
127
|
+
// the WebSocket's 'close' event (which Node never fires without that frame)
|
|
128
|
+
// leaves this promise, and the whole follow, pending forever.
|
|
129
|
+
const CLOSE_ACK_MS = 2000;
|
|
130
|
+
|
|
131
|
+
// The spawned CLI process's own backstop, armed once --follow decides to
|
|
132
|
+
// stop: CLOSE_ACK_MS already bounds tailOnce's promise, so cmdLogs reaches
|
|
133
|
+
// this point well within it; this only fires if some other handle (not the
|
|
134
|
+
// tail socket's own close handshake) is still keeping the process open.
|
|
135
|
+
const FORCE_EXIT_MS = 5000;
|
|
136
|
+
|
|
137
|
+
// One socket: resolves with its close code once it ends (or `signal` aborts
|
|
138
|
+
// it, bounded by CLOSE_ACK_MS); each entry is handed to `onEntry` as frames
|
|
139
|
+
// arrive.
|
|
140
|
+
function tailOnce(api, filter, state, onEntry, signal, onConnected) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const headers = { 'X-Substrio-Master-Key': api.masterKey };
|
|
143
|
+
if (api.tenant !== null) headers['X-Substrio-Tenant'] = api.tenant;
|
|
144
|
+
let opened = false;
|
|
145
|
+
let settled = false;
|
|
146
|
+
let ackTimer = null;
|
|
147
|
+
const settle = (fn, value) => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
if (ackTimer) clearTimeout(ackTimer);
|
|
151
|
+
fn(value);
|
|
152
|
+
};
|
|
153
|
+
const ws = new WebSocket(tailUrl(api.url, filter, state.cursor), { headers });
|
|
154
|
+
signal?.addEventListener('abort', () => {
|
|
155
|
+
ws.close();
|
|
156
|
+
ackTimer = setTimeout(() => settle(resolve, NORMAL_CLOSE), CLOSE_ACK_MS);
|
|
157
|
+
ackTimer.unref();
|
|
158
|
+
}, { once: true });
|
|
159
|
+
ws.addEventListener('open', () => { opened = true; });
|
|
160
|
+
ws.addEventListener('message', (event) => {
|
|
161
|
+
let frame;
|
|
162
|
+
try {
|
|
163
|
+
frame = JSON.parse(String(event.data));
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (frame.type === 'connected') onConnected?.();
|
|
168
|
+
for (const entry of applyTailFrame(state, frame)) onEntry(entry);
|
|
169
|
+
});
|
|
170
|
+
ws.addEventListener('error', () => {
|
|
171
|
+
if (!opened) settle(reject, new CliError(`Cannot open the log stream at ${api.url}/v1/_logs/ws (the key, the tenant and the server version must allow function.logs.stream)`,
|
|
172
|
+
{ code: ErrorCode.NETWORK_ERROR }));
|
|
173
|
+
});
|
|
174
|
+
ws.addEventListener('close', (event) => settle(resolve, event.code));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Follows the tenant's function logs, each entry handed to `onEntry`, until
|
|
180
|
+
* `signal` aborts. Reconnects only when the server asked the reader to resume
|
|
181
|
+
* (it fell behind past its bound): from the last cursor, so no line is skipped
|
|
182
|
+
* or repeated. Any other end is the stream's end: a CliError, unless aborted.
|
|
183
|
+
* `onConnected` is called each time the server confirms the subscription (nothing logged after it is missed).
|
|
184
|
+
*/
|
|
185
|
+
export async function followLogs(api, filter, onEntry, signal = undefined, onConnected = undefined) {
|
|
186
|
+
if (typeof WebSocket !== 'function') {
|
|
187
|
+
throw new CliError('Following logs needs Node 22 or newer (its built-in WebSocket)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
188
|
+
}
|
|
189
|
+
const state = { cursor: null };
|
|
190
|
+
for (;;) {
|
|
191
|
+
const code = await tailOnce(api, filter, state, onEntry, signal, onConnected);
|
|
192
|
+
if (signal?.aborted || code === NORMAL_CLOSE) return;
|
|
193
|
+
if (code !== BACKLOG_LIMIT_CLOSE) {
|
|
194
|
+
throw new CliError(`The log stream closed (code ${code})${state.cursor ? `; resume with the cursor ${state.cursor}` : ''}`,
|
|
195
|
+
{ code: ErrorCode.NETWORK_ERROR });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function cmdLogs(api, args) {
|
|
201
|
+
const follow = args.includes('--follow') || args.includes('--tail') || args.includes('-f');
|
|
202
|
+
let filter;
|
|
203
|
+
try {
|
|
204
|
+
filter = logFilter(args);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return fail(err);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!follow) {
|
|
210
|
+
// --json prints function.logs's answer whole ({logs, count}).
|
|
211
|
+
return runOperation(api, 'function.logs', [], { defaults: filter, render: printLogs });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let bound;
|
|
215
|
+
try {
|
|
216
|
+
bound = followBound(args);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return fail(err);
|
|
219
|
+
}
|
|
220
|
+
if (!bound && (globals.json || !process.stdout.isTTY)) {
|
|
221
|
+
return fail('substrio logs --follow with no terminal needs a way to stop: --for DURATION, --lines N or --until-match TEXT',
|
|
222
|
+
'substrio logs --follow --for 30s | --lines 100 | --until-match "fatal"', ErrorCode.INVALID_ARGUMENT);
|
|
223
|
+
}
|
|
224
|
+
if (!globals.json) console.log(`${C.cyan}Following logs...${C.reset} ${C.dim}(Ctrl+C to stop)${C.reset}\n`);
|
|
225
|
+
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
let lines = 0;
|
|
228
|
+
let reason = null;
|
|
229
|
+
// Our own decision to stop must end the process even if the WebSocket's
|
|
230
|
+
// close handshake (its 'close' event) never completes: unref'd, so it
|
|
231
|
+
// never delays or forces anything on the path where the socket closes
|
|
232
|
+
// cleanly and the process exits on its own first.
|
|
233
|
+
const stop = (why) => {
|
|
234
|
+
if (controller.signal.aborted) return;
|
|
235
|
+
reason = why;
|
|
236
|
+
controller.abort();
|
|
237
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), FORCE_EXIT_MS).unref();
|
|
238
|
+
};
|
|
239
|
+
const timer = bound?.forMs ? setTimeout(() => stop('duration'), bound.forMs) : null;
|
|
240
|
+
process.once('SIGINT', () => stop('closed'));
|
|
241
|
+
try {
|
|
242
|
+
await followLogs(api, filter, (entry) => {
|
|
243
|
+
// One frame can carry several entries: once a stop is met the rest
|
|
244
|
+
// of that frame is not printed, so --lines N is exactly N (the
|
|
245
|
+
// entries stay on the server for a later page or follow).
|
|
246
|
+
if (controller.signal.aborted) return;
|
|
247
|
+
lines++;
|
|
248
|
+
emitLine(entry, printLogEntry);
|
|
249
|
+
if (bound?.lines && lines >= bound.lines) stop('lines');
|
|
250
|
+
if (bound?.match && String(entry.message ?? entry.msg ?? '').includes(bound.match)) stop('match');
|
|
251
|
+
}, controller.signal);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
return fail(err);
|
|
255
|
+
}
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
emitLine({ event: 'end', reason: reason ?? 'closed', lines }, () => console.log(`${C.dim}Stopped (${reason ?? 'closed'}).${C.reset}`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function printLogs(data) {
|
|
261
|
+
const logs = data?.logs || [];
|
|
262
|
+
if (logs.length === 0) {
|
|
263
|
+
console.log(`${C.dim}No logs found.${C.reset}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.log(`\n${C.bold}Recent Logs${C.reset} ${C.dim}(${logs.length} entries)${C.reset}\n`);
|
|
267
|
+
for (const entry of logs) printLogEntry(entry);
|
|
268
|
+
console.log('');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function printLogEntry(entry) {
|
|
272
|
+
const ts = entry.timestamp
|
|
273
|
+
? new Date(entry.timestamp).toISOString().replace('T', ' ').replace('Z', '')
|
|
274
|
+
: '';
|
|
275
|
+
const level = (entry.level || 'info').toUpperCase();
|
|
276
|
+
const levelColor = level === 'ERROR' ? C.red : level === 'WARN' ? C.yellow : C.dim;
|
|
277
|
+
const fn = entry.function || entry.fn || '';
|
|
278
|
+
const msg = entry.message || entry.msg || (typeof entry === 'string' ? entry : JSON.stringify(entry));
|
|
279
|
+
|
|
280
|
+
console.log(
|
|
281
|
+
`${C.dim}${ts}${C.reset} ${levelColor}${level.padEnd(5)}${C.reset} ` +
|
|
282
|
+
`${fn ? C.cyan + fn + C.reset + ' ' : ''}` +
|
|
283
|
+
`${msg}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio mcp config [--client claude|cursor|generic] [--name NAME] [--env VAR]`.
|
|
3
|
+
*
|
|
4
|
+
* Prints, as JSON on stdout, the config an MCP client needs to reach the app
|
|
5
|
+
* of the current profile: its server's `POST /mcp` endpoint, the tenant, and
|
|
6
|
+
* the key as a reference to an environment variable (default SUBSTRIO_KEY), in the
|
|
7
|
+
* spelling of the client. The key itself is never read, printed or written:
|
|
8
|
+
* the person sets the variable. What to do with the output goes to stderr, so
|
|
9
|
+
* substrio mcp config > .mcp.json
|
|
10
|
+
* is the file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CliError, ErrorCode, emit, fail, globals } from '../cli.mjs';
|
|
14
|
+
import { FlagKind, parseFlags } from '../local/args.mjs';
|
|
15
|
+
|
|
16
|
+
/** The MCP clients a config can be made for (the file each reads is in `MCP_CLIENT_FILES`). */
|
|
17
|
+
export const McpClient = Object.freeze({
|
|
18
|
+
CLAUDE: 'claude', // Claude Code: .mcp.json
|
|
19
|
+
CURSOR: 'cursor', // Cursor: .cursor/mcp.json
|
|
20
|
+
GENERIC: 'generic', // any other: the endpoint, transport and headers, to be translated
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const MCP_CLIENT_FILES = Object.freeze({
|
|
24
|
+
[McpClient.CLAUDE]: '.mcp.json',
|
|
25
|
+
[McpClient.CURSOR]: '.cursor/mcp.json',
|
|
26
|
+
[McpClient.GENERIC]: null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** The environment variable the key is read from unless --env names another. */
|
|
30
|
+
export const MCP_KEY_ENV = 'SUBSTRIO_KEY';
|
|
31
|
+
/** The path of an app's MCP endpoint on its server. */
|
|
32
|
+
export const MCP_PATH = '/mcp';
|
|
33
|
+
|
|
34
|
+
const FLAGS = Object.freeze({ '--client': FlagKind.VALUE, '--name': FlagKind.VALUE, '--env': FlagKind.VALUE });
|
|
35
|
+
const USAGE = 'substrio mcp config [--client claude|cursor|generic] [--name NAME] [--env VAR]';
|
|
36
|
+
|
|
37
|
+
// The spelling of an environment variable reference in each client.
|
|
38
|
+
const REFERENCE = Object.freeze({
|
|
39
|
+
[McpClient.CLAUDE]: (name) => `\${${name}}`,
|
|
40
|
+
[McpClient.CURSOR]: (name) => `\${env:${name}}`,
|
|
41
|
+
[McpClient.GENERIC]: (name) => `\${${name}}`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const isNameChar = (c, first) => c === '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (!first && c >= '0' && c <= '9');
|
|
45
|
+
|
|
46
|
+
/** An environment variable name: letters, digits and underscores, not starting with a digit. */
|
|
47
|
+
export function isEnvName(name) {
|
|
48
|
+
return name.length > 0 && [...name].every((c, i) => isNameChar(c, i === 0));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The config of `client` for the app `tenant` of the server at `url` (pure):
|
|
53
|
+
* the server `name` (default: the tenant), the key read from `env`.
|
|
54
|
+
* Throws a CliError when the client is none of McpClient or `env` is not a name.
|
|
55
|
+
*/
|
|
56
|
+
export function mcpClientConfig({ client, url, tenant, name = null, env = MCP_KEY_ENV }) {
|
|
57
|
+
if (!Object.hasOwn(REFERENCE, client)) {
|
|
58
|
+
throw new CliError(`unknown client "${client}"; it is one of ${Object.values(McpClient).join(', ')}`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
59
|
+
}
|
|
60
|
+
if (!isEnvName(env)) throw new CliError(`--env must be an environment variable name, not "${env}"`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
61
|
+
const endpoint = `${url}${MCP_PATH}`;
|
|
62
|
+
const server = name ?? (tenant || 'substrio');
|
|
63
|
+
const headers = {
|
|
64
|
+
Authorization: `Bearer ${REFERENCE[client](env)}`,
|
|
65
|
+
...(tenant ? { 'X-Substrio-Tenant': tenant } : {}),
|
|
66
|
+
};
|
|
67
|
+
switch (client) {
|
|
68
|
+
case McpClient.CLAUDE:
|
|
69
|
+
return { mcpServers: { [server]: { type: 'http', url: endpoint, headers } } };
|
|
70
|
+
case McpClient.CURSOR:
|
|
71
|
+
return { mcpServers: { [server]: { url: endpoint, headers } } };
|
|
72
|
+
default:
|
|
73
|
+
return { name: server, url: endpoint, method: 'POST', transport: 'streamable-http', headers, env: [env] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `substrio mcp config`: `resolved` is resolveGroup's {positional, rest}; `context.config` the resolved config. */
|
|
78
|
+
export async function cmdMcpConfig(_api, { positional, rest }, { config }) {
|
|
79
|
+
let flags;
|
|
80
|
+
let words;
|
|
81
|
+
try {
|
|
82
|
+
({ flags, positional: words } = parseFlags(rest, FLAGS));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return fail(err, USAGE);
|
|
85
|
+
}
|
|
86
|
+
if (positional.length || words.length) fail(`unexpected argument "${[...positional, ...words][0]}"`, USAGE);
|
|
87
|
+
const client = flags.get('--client') ?? McpClient.CLAUDE;
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = mcpClientConfig({
|
|
91
|
+
client, url: config.url, tenant: config.tenant ?? null, name: flags.get('--name') ?? null, env: flags.get('--env') ?? MCP_KEY_ENV,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return fail(err, USAGE);
|
|
95
|
+
}
|
|
96
|
+
emit(result);
|
|
97
|
+
if (!globals.json) {
|
|
98
|
+
const file = MCP_CLIENT_FILES[client];
|
|
99
|
+
const env = flags.get('--env') ?? MCP_KEY_ENV;
|
|
100
|
+
console.error(`${file ? `Save this as ${file}. ` : ''}Set ${env} to the tenant key before the client starts; the key is not in this config.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio op` (alias `substrio call`), every registry operation.
|
|
3
|
+
*
|
|
4
|
+
* substrio op list [filter] [--json] Operations the server has, by name
|
|
5
|
+
* substrio op NAME --help Its route and input schema
|
|
6
|
+
* substrio op NAME [--data '{...}' | --file f.json | key=value ...] [--raw]
|
|
7
|
+
* [--method M] (an operation with routes of several methods)
|
|
8
|
+
* [--page] (a paged list: one page; else every page)
|
|
9
|
+
*
|
|
10
|
+
* NAME is the dotted name (webhook.get) or the MCP tool name
|
|
11
|
+
* (substrio_webhook_get). The result is printed as JSON; a failed call prints the
|
|
12
|
+
* error body on stderr and exits with its ExitCode (cli.mjs). Operations come from the server
|
|
13
|
+
* (GET /v1/_openapi), so a new one works as soon as the server has it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CliError, ErrorCode, emit, errorText, fail, globals } from '../cli.mjs';
|
|
17
|
+
import {
|
|
18
|
+
PAGE_CURSOR_INPUT, PAGE_NEXT_CURSOR_FIELD, buildRequest, findOperation, indexOperations, matchesFilter,
|
|
19
|
+
mergePages, parseOpArgs, pickRoute,
|
|
20
|
+
} from '../ops.mjs';
|
|
21
|
+
|
|
22
|
+
const C = {
|
|
23
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
24
|
+
red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const USAGE = "Usage: substrio op list [filter] | substrio op NAME [--data '{...}' | --file f.json | key=value ...] [--help]";
|
|
28
|
+
|
|
29
|
+
export class OpError extends Error {}
|
|
30
|
+
|
|
31
|
+
// The wait a 429/503 asked for (Retry-After: delta-seconds or an HTTP date), else null.
|
|
32
|
+
function retryAfterMsOf(resp) {
|
|
33
|
+
const header = resp.headers.get('retry-after');
|
|
34
|
+
if (header === null) return null;
|
|
35
|
+
const seconds = Number(header);
|
|
36
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
37
|
+
const at = Date.parse(header);
|
|
38
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readBody(resp) {
|
|
42
|
+
const text = await resp.text();
|
|
43
|
+
if (!text) return { text, data: null };
|
|
44
|
+
try {
|
|
45
|
+
return { text, data: JSON.parse(text) };
|
|
46
|
+
} catch {
|
|
47
|
+
return { text, data: undefined };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server's operations, indexed (see ops.mjs indexOperations). */
|
|
52
|
+
export async function loadOperations(api) {
|
|
53
|
+
const resp = await api.get('/v1/_openapi');
|
|
54
|
+
const { data } = await readBody(resp);
|
|
55
|
+
if (!resp.ok) throw CliError.fromResponse(`Could not read the server's operations: ${errorText(data, resp.status)}`, data, resp.status);
|
|
56
|
+
return indexOperations(data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calls the operation `name` with `input`; returns {ok, status, data, text,
|
|
61
|
+
* request, retryAfterMs}. `retryAfterMs` is the server's Retry-After wait
|
|
62
|
+
* (null unless the status is 429 or 503, or it sent none). Throws OpError
|
|
63
|
+
* when the name or input does not fit. `body` and
|
|
64
|
+
* `headers` are for an operation whose HTTP body is raw bytes (file.upload,
|
|
65
|
+
* site.blob.upload, app.import): the bytes go as the body of the route the
|
|
66
|
+
* input picks. With `stream`, a successful answer is not read: the result's
|
|
67
|
+
* `response` is the fetch Response, its body unread (app.export; the Response
|
|
68
|
+
* is what keeps the body open, so the caller holds it while it reads); a
|
|
69
|
+
* failed one is read as any error.
|
|
70
|
+
*/
|
|
71
|
+
export async function callOperation(api, ops, name, input, method = null, { body, headers, stream = false } = {}) {
|
|
72
|
+
let routes;
|
|
73
|
+
let request;
|
|
74
|
+
try {
|
|
75
|
+
routes = findOperation(ops, name);
|
|
76
|
+
const route = pickRoute(routes, input, method);
|
|
77
|
+
request = buildRequest(route, input, { rawBody: body !== undefined });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new OpError(err.message);
|
|
80
|
+
}
|
|
81
|
+
const resp = await api.raw(request.method, request.path, body ?? request.body, headers);
|
|
82
|
+
if (stream && resp.ok) return { ok: true, status: resp.status, response: resp, request };
|
|
83
|
+
const { data, text } = await readBody(resp);
|
|
84
|
+
const retryAfterMs = (resp.status === 429 || resp.status === 503) ? retryAfterMsOf(resp) : null;
|
|
85
|
+
return { ok: resp.ok, status: resp.status, data, text, request, retryAfterMs };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Calls `name` with `input` and returns the server's answer; a name or input
|
|
90
|
+
* that does not fit, or a failed call, ends the run with its typed error
|
|
91
|
+
* (failResult). `ops` is the catalog when the caller has read it already;
|
|
92
|
+
* `transport` is callOperation's {body, headers, stream} (with stream, the
|
|
93
|
+
* unread Response is returned); `hint(result)` says what to do about
|
|
94
|
+
* a failed call.
|
|
95
|
+
*/
|
|
96
|
+
export async function invokeOperation(api, name, input, { method = null, ops = null, hint = null, ...transport } = {}) {
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await callOperation(api, ops ?? await loadOperations(api), name, input, method, transport);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof OpError) return fail(err, `substrio op ${name} --help shows its route and input`);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
if (!result.ok) failResult(result, hint?.(result));
|
|
105
|
+
return transport.stream ? result.response : result.data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The operation `name` with `input` as one step of a longer run: the server's data, or a
|
|
110
|
+
* thrown CliError (the server's typed error, or a name or input that does not fit), never
|
|
111
|
+
* the end of the run. `ops` is the catalog, read once by the caller.
|
|
112
|
+
*/
|
|
113
|
+
export async function callOrThrow(api, ops, name, input) {
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = await callOperation(api, ops, name, input);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof OpError) throw new CliError(err.message);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
if (result.ok) return result.data;
|
|
122
|
+
throw CliError.fromResponse(errorText(result.data, result.status), result.data, result.status);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ends the run on a failed callOperation result: the server's error (its
|
|
127
|
+
* body under --json, with its code and status, so its exit code).
|
|
128
|
+
*/
|
|
129
|
+
export function failResult(result, hint = null) {
|
|
130
|
+
if (!globals.json && result.data !== null) console.error(result.data === undefined ? result.text : JSON.stringify(result.data, null, 2));
|
|
131
|
+
fail(CliError.fromResponse(`${result.request.method} ${result.request.path}: ${errorText(result.data, result.status)}`,
|
|
132
|
+
result.data, result.status), hint);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Prints a result: JSON pretty unless --raw; text as it came. A failed call
|
|
136
|
+
// is the server's error (failResult).
|
|
137
|
+
function printResult(result, raw) {
|
|
138
|
+
if (!result.ok) failResult(result);
|
|
139
|
+
if (result.data === undefined) emit(result.text, console.log);
|
|
140
|
+
else if (result.data !== null) emit(result.data, (data) => console.log(raw ? JSON.stringify(data) : JSON.stringify(data, null, 2)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Runs `name` with CLI arguments; `positional` values fill the route's path
|
|
145
|
+
* parameters in order (for the group commands such as `substrio webhooks get ID`),
|
|
146
|
+
* `method` picks among the operation's routes, `defaults` are input fields
|
|
147
|
+
* used when the arguments give none, `present`, when given, turns a
|
|
148
|
+
* successful result into what is printed, `render`, when given, prints it
|
|
149
|
+
* as text (JSON under --json or --raw), and `ops` is the catalog when the
|
|
150
|
+
* caller has read it already. `body`, when given, names the input field
|
|
151
|
+
* that is the operation's whole body (data.insert: document): what --data,
|
|
152
|
+
* --file and key=value make is that field's value, so --data is the body
|
|
153
|
+
* itself, with no wrapper, and giving none is an error.
|
|
154
|
+
*/
|
|
155
|
+
export async function runOperation(api, name, args, { positional = [], present = null, render = null, method = null, defaults = {}, body = null, ops: catalog = null } = {}) {
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = parseOpArgs(args);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return fail(err, USAGE);
|
|
161
|
+
}
|
|
162
|
+
if (body !== null) {
|
|
163
|
+
if (parsed.input === undefined) return fail(`${name} needs its ${body}: --data '{...}', --file F or key=value fields`, `substrio op ${name} --help shows its input`);
|
|
164
|
+
parsed.input = { [body]: parsed.input };
|
|
165
|
+
}
|
|
166
|
+
let ops = catalog;
|
|
167
|
+
try {
|
|
168
|
+
ops ??= await loadOperations(api);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return fail(err);
|
|
171
|
+
}
|
|
172
|
+
const chosen = parsed.flags.method || method;
|
|
173
|
+
let input = parsed.input;
|
|
174
|
+
if (positional.length || Object.keys(defaults).length) {
|
|
175
|
+
let routes;
|
|
176
|
+
try {
|
|
177
|
+
routes = findOperation(ops, name);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return fail(err);
|
|
180
|
+
}
|
|
181
|
+
const route = routes.find((r) => !chosen || r.method === chosen) || routes[0];
|
|
182
|
+
const { params } = route;
|
|
183
|
+
if (positional.length > params.length) {
|
|
184
|
+
return fail(`${name} takes ${params.length ? params.join(', ') : 'no'} positional argument${params.length === 1 ? '' : 's'}`);
|
|
185
|
+
}
|
|
186
|
+
input = { ...defaults, ...Object.fromEntries(positional.map((v, i) => [params[i], v])), ...(input || {}) };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const result = await callOperation(api, ops, name, input, chosen);
|
|
190
|
+
// A paged list is read whole: each next_cursor is followed and the
|
|
191
|
+
// pages merged, unless --page or a cursor was given.
|
|
192
|
+
const follow = !parsed.flags.page && !(input && input[PAGE_CURSOR_INPUT] !== undefined);
|
|
193
|
+
for (let last = input?.[PAGE_CURSOR_INPUT]; follow && result.ok && result.data?.[PAGE_NEXT_CURSOR_FIELD];) {
|
|
194
|
+
const cursor = result.data[PAGE_NEXT_CURSOR_FIELD];
|
|
195
|
+
if (cursor === last) return fail(`${name} answered the cursor it was given (${cursor}) as its next one`, null, ErrorCode.UNKNOWN);
|
|
196
|
+
last = cursor;
|
|
197
|
+
const next = await callOperation(api, ops, name, { ...(input || {}), [PAGE_CURSOR_INPUT]: cursor }, chosen);
|
|
198
|
+
if (!next.ok) return failResult(next);
|
|
199
|
+
result.data = mergePages(result.data, next.data ?? {});
|
|
200
|
+
}
|
|
201
|
+
if (result.ok && present && result.data) result.data = present(result.data);
|
|
202
|
+
if (result.ok && render && result.data && !parsed.flags.raw) emit(result.data, render);
|
|
203
|
+
else printResult(result, parsed.flags.raw);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err instanceof OpError) return fail(err, `substrio op ${name} --help shows its route and input`);
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Prints one operation's routes and input schema, from the server's catalog
|
|
212
|
+
* (a read). Throws when the catalog cannot be read or has no such operation.
|
|
213
|
+
*/
|
|
214
|
+
export async function showOperationHelp(api, name) {
|
|
215
|
+
printHelp(findOperation(await loadOperations(api), name));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function printHelp(routes) {
|
|
219
|
+
const first = routes[0];
|
|
220
|
+
console.log(`\n${C.bold}${first.name}${C.reset}${first.id ? ` ${C.dim}(operation ${first.id})${C.reset}` : ''}`);
|
|
221
|
+
if (first.summary) console.log(` ${first.summary}`);
|
|
222
|
+
console.log('');
|
|
223
|
+
for (const r of routes) {
|
|
224
|
+
const { params } = r;
|
|
225
|
+
console.log(` ${C.cyan}${r.method} ${r.path}${C.reset}${params.length ? ` ${C.dim}path: ${params.join(', ')}${C.reset}` : ''}` +
|
|
226
|
+
`${r.query.length ? ` ${C.dim}query: ${r.query.join(', ')}${C.reset}` : ''}`);
|
|
227
|
+
}
|
|
228
|
+
const declared = first.parameters.filter((p) => p.in !== 'path');
|
|
229
|
+
if (declared.length) {
|
|
230
|
+
console.log(`\n${C.bold}Parameters${C.reset}`);
|
|
231
|
+
for (const p of declared) console.log(` ${p.name} ${C.dim}(${p.in}${p.required ? ', required' : ''})${C.reset}${p.description ? ` ${p.description}` : ''}`);
|
|
232
|
+
}
|
|
233
|
+
console.log(`\n${C.bold}Input schema${C.reset}`);
|
|
234
|
+
console.log(first.bodySchema ? JSON.stringify(first.bodySchema, null, 2) : `${C.dim} (none: the route's parameters only)${C.reset}`);
|
|
235
|
+
console.log(`\n${C.dim} substrio op ${first.name} key=value ... | --data '{...}' | --file f.json${C.reset}\n`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function listOperations(api, args) {
|
|
239
|
+
const filter = args.filter((a) => !a.startsWith('--')).join(' ');
|
|
240
|
+
let ops;
|
|
241
|
+
try {
|
|
242
|
+
ops = await loadOperations(api);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return fail(err);
|
|
245
|
+
}
|
|
246
|
+
const shown = ops.filter((op) => matchesFilter(op, filter));
|
|
247
|
+
if (globals.json) return emit(shown.map(({ name, id, method, path, summary }) => ({ name, id, method, path, summary })));
|
|
248
|
+
if (shown.length === 0) {
|
|
249
|
+
console.log(`${C.dim}No operation matches "${filter}".${C.reset}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const width = Math.max(...shown.map((op) => op.name.length));
|
|
253
|
+
const routeWidth = Math.max(...shown.map((op) => op.method.length + op.path.length + 1));
|
|
254
|
+
for (const op of shown) {
|
|
255
|
+
console.log(` ${C.cyan}${op.name.padEnd(width)}${C.reset} ${`${op.method} ${op.path}`.padEnd(routeWidth)} ${C.dim}${op.summary}${C.reset}`);
|
|
256
|
+
}
|
|
257
|
+
console.log(`\n${C.dim} ${shown.length} of ${ops.length} routes. substrio op NAME --help for one.${C.reset}\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function cmdOp(api, args) {
|
|
261
|
+
const sub = args[0];
|
|
262
|
+
if (!sub) fail('an operation name is needed', USAGE);
|
|
263
|
+
if (sub === 'list' || sub === 'ls') return listOperations(api, args.slice(1));
|
|
264
|
+
return runOperation(api, sub, args.slice(1));
|
|
265
|
+
}
|