@thenavidm/slipway 0.1.0
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/CHANGELOG.md +21 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +757 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +136 -0
- package/dist/app.d.ts +210 -0
- package/dist/app.js +364 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.js +178 -0
- package/dist/check.d.ts +52 -0
- package/dist/check.js +313 -0
- package/dist/cli/completion.d.ts +5 -0
- package/dist/cli/completion.js +72 -0
- package/dist/cli/context.d.ts +97 -0
- package/dist/cli/context.js +94 -0
- package/dist/cli/data.d.ts +17 -0
- package/dist/cli/data.js +120 -0
- package/dist/cli/flags.d.ts +35 -0
- package/dist/cli/flags.js +208 -0
- package/dist/cli/help.d.ts +15 -0
- package/dist/cli/help.js +213 -0
- package/dist/cli/install.d.ts +10 -0
- package/dist/cli/install.js +65 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.js +159 -0
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.js +343 -0
- package/dist/confirm.d.ts +90 -0
- package/dist/confirm.js +166 -0
- package/dist/data.d.ts +109 -0
- package/dist/data.js +324 -0
- package/dist/docs.d.ts +13 -0
- package/dist/docs.js +66 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +100 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +34 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.js +153 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +89 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/install.d.ts +98 -0
- package/dist/install.js +325 -0
- package/dist/jobs.d.ts +143 -0
- package/dist/jobs.js +247 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.js +549 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +61 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +74 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +60 -0
- package/dist/result.d.ts +44 -0
- package/dist/result.js +74 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +120 -0
- package/dist/schema.d.ts +99 -0
- package/dist/schema.js +192 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.js +119 -0
- package/dist/serve.d.ts +30 -0
- package/dist/serve.js +149 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +119 -0
- package/dist/testing.d.ts +35 -0
- package/dist/testing.js +41 -0
- package/dist/tool.d.ts +194 -0
- package/dist/tool.js +151 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +35 -0
- package/package.json +89 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app: one service, its tools, and the single path every call takes.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server and the CLI are thin. Both hand a tool and its arguments to
|
|
5
|
+
* `run`, which applies visibility, the write guard, timeouts, cancellation and
|
|
6
|
+
* redaction in one place. A rule added here holds on both surfaces at once,
|
|
7
|
+
* which is why the two cannot drift.
|
|
8
|
+
*/
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import { CanceledError, NotConfiguredError, RefusedError, SlipwayError, TimeoutError, UsageError, toSlipwayError, } from "./errors.js";
|
|
11
|
+
import * as z from "zod";
|
|
12
|
+
import { cacheKey, dataDir, scopeOf, storeAt } from "./data.js";
|
|
13
|
+
import { Guard } from "./guard.js";
|
|
14
|
+
import { isBackground, JobRegistry, runJob, waitSecondsFor } from "./jobs.js";
|
|
15
|
+
import { policyEnvNames, readPolicy, visibility } from "./policy.js";
|
|
16
|
+
import { Secrets } from "./redact.js";
|
|
17
|
+
import { isContentResult } from "./result.js";
|
|
18
|
+
import { formatIssues, validate } from "./schema.js";
|
|
19
|
+
import { buildServer } from "./server.js";
|
|
20
|
+
import { localDataTools } from "./sync.js";
|
|
21
|
+
import { defineTool, isTool, summarize } from "./tool.js";
|
|
22
|
+
const SLUG = /^[a-z][a-z0-9-]{0,40}$/;
|
|
23
|
+
export function stderrLogger(prefix, env) {
|
|
24
|
+
const debug = /^(1|true|yes)$/i.test(env[`${prefix}_DEBUG`] ?? "");
|
|
25
|
+
const write = (level, message, data) => process.stderr.write(`[${prefix.toLowerCase()}] ${level} ${message}${data === undefined ? "" : ` ${JSON.stringify(data)}`}\n`);
|
|
26
|
+
return {
|
|
27
|
+
debug: (message, data) => {
|
|
28
|
+
if (debug)
|
|
29
|
+
write("debug", message, data);
|
|
30
|
+
},
|
|
31
|
+
info: (message, data) => write("info", message, data),
|
|
32
|
+
warn: (message, data) => write("warn", message, data),
|
|
33
|
+
error: (message, data) => write("error", message, data),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Create an app. Throws at load time on duplicate tools or a name that cannot become two binaries. */
|
|
37
|
+
export function slipway(definition) {
|
|
38
|
+
if (!SLUG.test(definition.name ?? "")) {
|
|
39
|
+
throw new Error(`App name '${definition.name}' must be a lowercase slug, like 'bluesky' or 'google-photos'.`);
|
|
40
|
+
}
|
|
41
|
+
if (!definition.version)
|
|
42
|
+
throw new Error(`App '${definition.name}': version is required.`);
|
|
43
|
+
for (const tool of definition.tools) {
|
|
44
|
+
if (!isTool(tool))
|
|
45
|
+
throw new Error(`App '${definition.name}': every entry in tools must come from defineTool.`);
|
|
46
|
+
}
|
|
47
|
+
// A synced list brings the tools that search and refresh local data. `app`
|
|
48
|
+
// is read when they run, after it exists.
|
|
49
|
+
const synced = definition.tools.filter((tool) => tool.sync);
|
|
50
|
+
const local = synced.length ? localDataTools(synced, () => app) : [];
|
|
51
|
+
// Each job tool brings the tool that checks on its jobs, right after it.
|
|
52
|
+
const allTools = [...definition.tools, ...local].flatMap((tool) => (tool.job && !tool.statusOf ? [tool, statusToolFor(tool)] : [tool]));
|
|
53
|
+
const caches = allTools.some((tool) => tool.cache);
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const tool of allTools) {
|
|
56
|
+
if (seen.has(tool.name)) {
|
|
57
|
+
throw new Error(`App '${definition.name}': two tools are named '${tool.name}'${tool.statusOf ? `, and Slipway names the status tool of ${tool.statusOf} that` : ""}.`);
|
|
58
|
+
}
|
|
59
|
+
seen.add(tool.name);
|
|
60
|
+
}
|
|
61
|
+
const jobs = new JobRegistry();
|
|
62
|
+
const envPrefix = definition.envPrefix ?? definition.name.toUpperCase().replace(/-/g, "_");
|
|
63
|
+
const bins = { mcp: definition.bins?.mcp ?? `${definition.name}-mcp`, cli: definition.bins?.cli ?? `${definition.name}-cli` };
|
|
64
|
+
const secrets = new Secrets();
|
|
65
|
+
const contexts = new WeakMap();
|
|
66
|
+
const byName = new Map();
|
|
67
|
+
for (const tool of allTools) {
|
|
68
|
+
byName.set(tool.name, tool);
|
|
69
|
+
byName.set(tool.command, tool);
|
|
70
|
+
}
|
|
71
|
+
const app = {
|
|
72
|
+
kind: "slipway.app",
|
|
73
|
+
name: definition.name,
|
|
74
|
+
title: definition.title ?? definition.name,
|
|
75
|
+
version: definition.version,
|
|
76
|
+
description: definition.description,
|
|
77
|
+
instructions: definition.instructions,
|
|
78
|
+
envPrefix,
|
|
79
|
+
bins,
|
|
80
|
+
definition,
|
|
81
|
+
allTools,
|
|
82
|
+
secrets,
|
|
83
|
+
policy(env = process.env) {
|
|
84
|
+
return readPolicy(env, envPrefix, definition.defaults);
|
|
85
|
+
},
|
|
86
|
+
tools(env = process.env) {
|
|
87
|
+
const policy = app.policy(env);
|
|
88
|
+
return allTools.filter((tool) => visibility(tool, policy).visible);
|
|
89
|
+
},
|
|
90
|
+
find(nameOrCommand) {
|
|
91
|
+
return byName.get(nameOrCommand) ?? byName.get(nameOrCommand.replace(/-/g, "_"));
|
|
92
|
+
},
|
|
93
|
+
context(env = process.env) {
|
|
94
|
+
let pending = contexts.get(env);
|
|
95
|
+
if (!pending) {
|
|
96
|
+
pending = (async () => {
|
|
97
|
+
let ctx;
|
|
98
|
+
try {
|
|
99
|
+
ctx = await definition.context(env);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// A context that cannot be built is a setup problem, so it gets the
|
|
103
|
+
// setup exit code and points at doctor, whatever threw it.
|
|
104
|
+
const known = error instanceof SlipwayError ? error : undefined;
|
|
105
|
+
throw (known ??
|
|
106
|
+
new NotConfiguredError(error?.message ?? String(error), {
|
|
107
|
+
hint: `Run \`${bins.cli} doctor\` to see what is missing.`,
|
|
108
|
+
cause: error,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
secrets.add(...(definition.secrets?.(ctx) ?? []));
|
|
112
|
+
return ctx;
|
|
113
|
+
})();
|
|
114
|
+
// A failed build is not cached, so fixing the environment and retrying works in a long-lived server.
|
|
115
|
+
pending.catch(() => contexts.delete(env));
|
|
116
|
+
contexts.set(env, pending);
|
|
117
|
+
}
|
|
118
|
+
return pending;
|
|
119
|
+
},
|
|
120
|
+
async invoke(nameOrCommand, args, options) {
|
|
121
|
+
const tool = app.find(nameOrCommand);
|
|
122
|
+
if (!tool) {
|
|
123
|
+
throw new UsageError(`Unknown tool '${nameOrCommand}'.`, { hint: `Run \`${bins.cli}\` to list the tools.` });
|
|
124
|
+
}
|
|
125
|
+
return app.run(tool, await app.parse(tool, args), options);
|
|
126
|
+
},
|
|
127
|
+
async parse(tool, args) {
|
|
128
|
+
const checked = await validate(tool.schema, args ?? {});
|
|
129
|
+
if (!checked.ok) {
|
|
130
|
+
throw new UsageError(`Invalid arguments for ${tool.name}: ${formatIssues(checked.issues)}`, {
|
|
131
|
+
hint: `Run \`${bins.cli} ${tool.command} --help\` for what it takes.`,
|
|
132
|
+
details: checked.issues,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return checked.value;
|
|
136
|
+
},
|
|
137
|
+
preflight(tool, rawArgs, options) {
|
|
138
|
+
const env = options.env ?? process.env;
|
|
139
|
+
const policy = app.policy(env);
|
|
140
|
+
assertVisible(tool, policy, envPrefix);
|
|
141
|
+
const { confirm: _confirm, ...args } = rawArgs;
|
|
142
|
+
const summary = summarize(tool, args);
|
|
143
|
+
new Guard(policy, options.surface, envPrefix).preflight(tool, summary);
|
|
144
|
+
return summary;
|
|
145
|
+
},
|
|
146
|
+
async run(tool, rawArgs, options) {
|
|
147
|
+
const env = options.env ?? process.env;
|
|
148
|
+
const policy = app.policy(env);
|
|
149
|
+
assertVisible(tool, policy, envPrefix);
|
|
150
|
+
// Slipway's own arguments are not the tool's, so the handler never sees them.
|
|
151
|
+
const { confirm, wait_seconds: waitSeconds, ...args } = rawArgs;
|
|
152
|
+
const confirmedBy = options.approvedBy ?? (options.confirmed === true || confirm === true ? "flag" : undefined);
|
|
153
|
+
const dryRun = options.dryRun === true;
|
|
154
|
+
const summary = summarize(tool, args);
|
|
155
|
+
const guard = new Guard(policy, options.surface, envPrefix);
|
|
156
|
+
guard.check(tool, { confirmedBy, dryRun, summary });
|
|
157
|
+
const ctx = await app.context(env);
|
|
158
|
+
const timeoutMs = tool.timeoutMs ?? policy.toolTimeoutMs;
|
|
159
|
+
const withDeadline = () => deadlineSignal(options.signal, timeoutMs);
|
|
160
|
+
const signal = withDeadline();
|
|
161
|
+
const log = options.log ?? stderrLogger(envPrefix, env);
|
|
162
|
+
const callProgress = increasing(options.onProgress);
|
|
163
|
+
// The app's own context stays the prototype, so a class instance keeps its
|
|
164
|
+
// methods and getters, and Slipway's fields sit on top.
|
|
165
|
+
const contextWith = (runSignal, report) => {
|
|
166
|
+
const runContext = {
|
|
167
|
+
signal: runSignal,
|
|
168
|
+
surface: options.surface,
|
|
169
|
+
env,
|
|
170
|
+
dryRun,
|
|
171
|
+
tool: { name: tool.name, risk: tool.risk },
|
|
172
|
+
secrets,
|
|
173
|
+
log,
|
|
174
|
+
async progress(progress, total, message) {
|
|
175
|
+
report({ progress, ...(total === undefined ? {} : { total }), ...(message ? { message } : {}) });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
return Object.assign(Object.create(ctx), runContext);
|
|
179
|
+
};
|
|
180
|
+
const toolContext = contextWith(signal, callProgress);
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
const wouldRun = tool.preview ? await tool.preview(args, toolContext) : args;
|
|
183
|
+
return { dry_run: true, tool: tool.name, summary, would_run: secrets.redactDeep(wouldRun) };
|
|
184
|
+
}
|
|
185
|
+
const writes = tool.risk !== "read" || tool.requireConfirm;
|
|
186
|
+
const cache = tool.cache && policy.cache ? { scope: app.dataScope(ctx), key: cacheKey(args), ttlSeconds: tool.cache.ttlSeconds } : undefined;
|
|
187
|
+
if (cache && !options.refresh) {
|
|
188
|
+
const hit = await quietly(log, async () => (await app.localData(env)).cacheGet(cache.scope, tool.name, cache.key));
|
|
189
|
+
if (hit) {
|
|
190
|
+
options.onCache?.({ ageSeconds: Math.max(0, Math.round((Date.now() - hit.storedAt) / 1000)) });
|
|
191
|
+
return hit.value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// A write may change anything a cached read returned, even one that failed partway.
|
|
195
|
+
const forget = async () => {
|
|
196
|
+
if (writes && caches && policy.cache)
|
|
197
|
+
await quietly(log, async () => (await app.localData(env)).cacheClear(app.dataScope(ctx)));
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
let result;
|
|
201
|
+
const jobTool = tool.statusOf ? byName.get(tool.statusOf) : tool;
|
|
202
|
+
if (jobTool?.job) {
|
|
203
|
+
const statusTool = tool.statusOf ? tool : allTools.find((candidate) => candidate.statusOf === tool.name);
|
|
204
|
+
// A background job in a terminal ends with the command, so the command waits for it.
|
|
205
|
+
const waitMs = options.waitMs ??
|
|
206
|
+
(options.surface === "cli" && isBackground(jobTool.job) ? Number.POSITIVE_INFINITY : (typeof waitSeconds === "number" ? waitSeconds : waitSecondsFor(jobTool.job)) * 1000);
|
|
207
|
+
result = await runJob({
|
|
208
|
+
jobTool: jobTool.name,
|
|
209
|
+
job: jobTool.job,
|
|
210
|
+
...(tool.statusOf ? { jobId: String(args.job_id) } : { start: (runCtx) => jobTool.handler(args, runCtx) }),
|
|
211
|
+
waitMs,
|
|
212
|
+
waitSignal: options.signal,
|
|
213
|
+
requestSignal: withDeadline,
|
|
214
|
+
context: contextWith,
|
|
215
|
+
progress: callProgress,
|
|
216
|
+
registry: jobs,
|
|
217
|
+
timeoutMs: jobTool.timeoutMs,
|
|
218
|
+
check: (id) => options.surface === "cli"
|
|
219
|
+
? `${bins.cli} ${statusTool.command} ${id} --wait`
|
|
220
|
+
: policy.surface === "search"
|
|
221
|
+
? `Call call_tool with name "${statusTool.name}" and arguments {"job_id": "${id}"} to check on it. Add wait_seconds to wait for it to finish.`
|
|
222
|
+
: `Call ${statusTool.name} with job_id "${id}" to check on it. Pass wait_seconds to wait for it to finish.`,
|
|
223
|
+
untilAborted: (work, runSignal) => untilAborted(work, runSignal, timeoutMs, tool.name),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
result = await untilAborted(Promise.resolve(tool.handler(args, toolContext)), signal, timeoutMs, tool.name);
|
|
228
|
+
}
|
|
229
|
+
if (writes)
|
|
230
|
+
guard.record(tool, summary, result?.done === false ? "started" : "done");
|
|
231
|
+
await forget();
|
|
232
|
+
// Only data is cached. Images and files are fetched again.
|
|
233
|
+
if (cache && !isContentResult(result)) {
|
|
234
|
+
const stored = secrets.redactDeep(result);
|
|
235
|
+
await quietly(log, async () => (await app.localData(env)).cachePut(cache.scope, tool.name, cache.key, stored, cache.ttlSeconds));
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (writes)
|
|
241
|
+
guard.record(tool, summary, "failed");
|
|
242
|
+
await forget();
|
|
243
|
+
throw toSlipwayError(error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
localData(env = process.env) {
|
|
247
|
+
return storeAt(dataDir(definition.name, envPrefix, env));
|
|
248
|
+
},
|
|
249
|
+
dataScope(ctx) {
|
|
250
|
+
return scopeOf(definition.secrets?.(ctx) ?? [], definition.dataScope?.(ctx));
|
|
251
|
+
},
|
|
252
|
+
createServer(env = process.env) {
|
|
253
|
+
return buildServer(app, env);
|
|
254
|
+
},
|
|
255
|
+
async runCli(argv, io) {
|
|
256
|
+
const { runCli } = await import("./cli/run.js");
|
|
257
|
+
return runCli(app, argv, io);
|
|
258
|
+
},
|
|
259
|
+
async main(argv = process.argv.slice(2)) {
|
|
260
|
+
const { main } = await import("./entry.js");
|
|
261
|
+
await main(app, argv, basename(process.argv[1] ?? ""));
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
return app;
|
|
265
|
+
}
|
|
266
|
+
/** Local data helps a call but never fails one: anything that goes wrong with it is a cache miss, logged for debugging. */
|
|
267
|
+
async function quietly(log, work) {
|
|
268
|
+
try {
|
|
269
|
+
return await work();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
log.debug("local data unavailable", { error: error?.message ?? String(error) });
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** The caller's cancel and the tool's deadline as one signal, new for each request so every request gets the full time. */
|
|
277
|
+
function deadlineSignal(signal, timeoutMs) {
|
|
278
|
+
const signals = [];
|
|
279
|
+
if (signal)
|
|
280
|
+
signals.push(signal);
|
|
281
|
+
if (timeoutMs)
|
|
282
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
283
|
+
return signals.length === 0 ? new AbortController().signal : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Progress for one caller. The protocol requires each update to be larger than
|
|
287
|
+
* the last, so a handler that repeats a value, or a job reporting to a second
|
|
288
|
+
* caller, would otherwise get the call rejected by a strict client.
|
|
289
|
+
*/
|
|
290
|
+
function increasing(onProgress) {
|
|
291
|
+
let last = -Infinity;
|
|
292
|
+
return (update) => {
|
|
293
|
+
if (!onProgress || update.progress <= last)
|
|
294
|
+
return;
|
|
295
|
+
last = update.progress;
|
|
296
|
+
void Promise.resolve(onProgress(update)).catch(() => undefined);
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** The tool that checks on a job tool's jobs. It reads, whatever the job tool does. */
|
|
300
|
+
function statusToolFor(tool) {
|
|
301
|
+
const input = z.object({ job_id: z.string().min(1).describe(`The job_id ${tool.name} returned.`) });
|
|
302
|
+
const status = defineTool({
|
|
303
|
+
name: `${tool.name}_status`,
|
|
304
|
+
title: `${tool.title}: status`,
|
|
305
|
+
description: `Check on a job ${tool.name} started: whether it is still running, and its result once it is done. Pass wait_seconds to wait for it to finish.`,
|
|
306
|
+
input,
|
|
307
|
+
risk: "read",
|
|
308
|
+
openWorld: tool.openWorld,
|
|
309
|
+
tags: [...tool.tags],
|
|
310
|
+
positional: ["job_id"],
|
|
311
|
+
job: tool.job,
|
|
312
|
+
examples: [],
|
|
313
|
+
handler: () => {
|
|
314
|
+
throw new Error(`${tool.name}_status runs through its job, never on its own.`);
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
return Object.freeze({ ...status, statusOf: tool.name });
|
|
318
|
+
}
|
|
319
|
+
/** A hidden tool is refused with the setting that hides it, so the refusal says how to turn it on. */
|
|
320
|
+
function assertVisible(tool, policy, envPrefix) {
|
|
321
|
+
const seen = visibility(tool, policy);
|
|
322
|
+
if (seen.visible)
|
|
323
|
+
return;
|
|
324
|
+
const names = policyEnvNames(envPrefix);
|
|
325
|
+
if (seen.reason === "read-only") {
|
|
326
|
+
throw new RefusedError(`${tool.name} is unavailable: this server is running with ${names.readOnly}=1.`, {
|
|
327
|
+
hint: `Unset ${names.readOnly} to allow writes.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
throw new UsageError(`${tool.name} is in a toolset that is off: ${tool.tags.join(", ")}.`, {
|
|
331
|
+
hint: `Add one of them to ${names.toolsets}, or set ${names.toolsets}=all.`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Race a handler against its abort signal.
|
|
336
|
+
*
|
|
337
|
+
* A handler that ignores the signal cannot be stopped, but its caller can stop
|
|
338
|
+
* waiting for it: a client that canceled, or a call past its deadline, gets an
|
|
339
|
+
* answer now rather than when the upstream API finally gives up.
|
|
340
|
+
*/
|
|
341
|
+
function untilAborted(work, signal, timeoutMs, name) {
|
|
342
|
+
if (signal.aborted)
|
|
343
|
+
return Promise.reject(abortError(signal, timeoutMs, name));
|
|
344
|
+
return new Promise((resolve, reject) => {
|
|
345
|
+
const onAbort = () => reject(abortError(signal, timeoutMs, name));
|
|
346
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
347
|
+
work.then((value) => {
|
|
348
|
+
signal.removeEventListener("abort", onAbort);
|
|
349
|
+
resolve(value);
|
|
350
|
+
}, (error) => {
|
|
351
|
+
signal.removeEventListener("abort", onAbort);
|
|
352
|
+
reject(error);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function abortError(signal, timeoutMs, name) {
|
|
357
|
+
const reason = signal.reason;
|
|
358
|
+
if (reason?.name === "TimeoutError") {
|
|
359
|
+
return new TimeoutError(`${name} did not finish within ${timeoutMs} ms.`, {
|
|
360
|
+
hint: "Narrow the request, or raise the tool timeout if the upstream API is genuinely slow.",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return new CanceledError(`${name} was canceled.`);
|
|
364
|
+
}
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `slipway` command, for people building on Slipway.
|
|
4
|
+
*
|
|
5
|
+
* slipway check [module] the release gate: schemas, parity, docs, startup
|
|
6
|
+
* slipway docs [module] the command and argument reference, as Markdown
|
|
7
|
+
* slipway inspect [module] what an MCP client receives
|
|
8
|
+
* slipway openapi <doc> the tools an OpenAPI document becomes, and its pin
|
|
9
|
+
*
|
|
10
|
+
* `module` is the built file that exports the app, `dist/app.js` by default.
|
|
11
|
+
* It must only export the app: the file that calls `app.main()` would start a
|
|
12
|
+
* server the moment it was imported.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `slipway` command, for people building on Slipway.
|
|
4
|
+
*
|
|
5
|
+
* slipway check [module] the release gate: schemas, parity, docs, startup
|
|
6
|
+
* slipway docs [module] the command and argument reference, as Markdown
|
|
7
|
+
* slipway inspect [module] what an MCP client receives
|
|
8
|
+
* slipway openapi <doc> the tools an OpenAPI document becomes, and its pin
|
|
9
|
+
*
|
|
10
|
+
* `module` is the built file that exports the app, `dist/app.js` by default.
|
|
11
|
+
* It must only export the app: the file that calls `app.main()` would start a
|
|
12
|
+
* server the moment it was imported.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { checkApp } from "./check.js";
|
|
18
|
+
import { SLIPWAY_VERSION } from "./cli/context.js";
|
|
19
|
+
import { renderDocs } from "./docs.js";
|
|
20
|
+
import { fromOpenAPI, openapiHash, readOperations } from "./openapi.js";
|
|
21
|
+
import { connectInMemory } from "./rpc.js";
|
|
22
|
+
const HELP = `slipway ${SLIPWAY_VERSION}
|
|
23
|
+
|
|
24
|
+
slipway check [module] [--bin <entry>] [--docs a.md,b.md] [--json] [--strict]
|
|
25
|
+
Check an app before release: names, descriptions, schema validity and
|
|
26
|
+
size, examples, MCP and CLI parity, the commands your docs mention, and
|
|
27
|
+
(with --bin) that the built server starts with nothing configured.
|
|
28
|
+
|
|
29
|
+
slipway docs [module]
|
|
30
|
+
Print the command and argument reference as Markdown.
|
|
31
|
+
|
|
32
|
+
slipway inspect [module] [--json]
|
|
33
|
+
List the tools exactly as an MCP client receives them.
|
|
34
|
+
|
|
35
|
+
slipway openapi <file|url> [--json]
|
|
36
|
+
Show the tools an OpenAPI 3 document becomes: names, risk, toolsets,
|
|
37
|
+
what is skipped and why, and the sha256 to pin it with.
|
|
38
|
+
|
|
39
|
+
module defaults to dist/app.js, a file that exports the app and does not start it.
|
|
40
|
+
`;
|
|
41
|
+
function option(argv, name) {
|
|
42
|
+
const at = argv.findIndex((token) => token === name || token.startsWith(`${name}=`));
|
|
43
|
+
if (at === -1)
|
|
44
|
+
return undefined;
|
|
45
|
+
return argv[at].includes("=") ? argv[at].split("=").slice(1).join("=") : argv[at + 1];
|
|
46
|
+
}
|
|
47
|
+
async function loadApp(path) {
|
|
48
|
+
const file = resolve(path ?? "dist/app.js");
|
|
49
|
+
if (!existsSync(file)) {
|
|
50
|
+
throw new Error(`${file} does not exist. Build first, or pass the module that exports your app.`);
|
|
51
|
+
}
|
|
52
|
+
const module = (await import(pathToFileURL(file).href));
|
|
53
|
+
const app = [module.default, module.app, ...Object.values(module)].find((value) => value?.kind === "slipway.app");
|
|
54
|
+
if (!app)
|
|
55
|
+
throw new Error(`${file} exports no Slipway app. Export the value slipway({...}) returns.`);
|
|
56
|
+
return app;
|
|
57
|
+
}
|
|
58
|
+
async function main(argv) {
|
|
59
|
+
const [command, ...rest] = argv;
|
|
60
|
+
const positional = rest.filter((token, i) => !token.startsWith("--") && !["--bin", "--docs"].includes(rest[i - 1] ?? ""))[0];
|
|
61
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
62
|
+
process.stdout.write(HELP);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
66
|
+
process.stdout.write(`${SLIPWAY_VERSION}\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (command === "openapi") {
|
|
70
|
+
if (!positional)
|
|
71
|
+
throw new Error("openapi expects a document: slipway openapi openapi.json");
|
|
72
|
+
return openapiPreview(positional, rest.includes("--json"));
|
|
73
|
+
}
|
|
74
|
+
const app = await loadApp(positional);
|
|
75
|
+
if (command === "docs") {
|
|
76
|
+
process.stdout.write(renderDocs(app, process.env));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (command === "inspect") {
|
|
80
|
+
const client = await connectInMemory(app, process.env);
|
|
81
|
+
const tools = await client.listTools();
|
|
82
|
+
await client.close();
|
|
83
|
+
if (rest.includes("--json"))
|
|
84
|
+
process.stdout.write(`${JSON.stringify({ initialize: client.initialize, tools }, null, 2)}\n`);
|
|
85
|
+
else {
|
|
86
|
+
process.stdout.write(`${client.initialize.serverInfo.name} ${client.initialize.serverInfo.version}, protocol ${client.initialize.protocolVersion}, ${tools.length} tools\n\n`);
|
|
87
|
+
for (const tool of tools) {
|
|
88
|
+
const a = tool.annotations ?? {};
|
|
89
|
+
const mark = a.readOnlyHint ? " " : a.destructiveHint ? "!" : "*";
|
|
90
|
+
process.stdout.write(` ${mark} ${tool.name} ${Math.round(JSON.stringify(tool).length / 102.4) / 10} KB${tool.outputSchema ? " typed" : ""}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (command === "check") {
|
|
96
|
+
const docs = option(rest, "--docs")?.split(",").filter(Boolean);
|
|
97
|
+
const bin = option(rest, "--bin");
|
|
98
|
+
const report = await checkApp(app, { env: process.env, ...(docs ? { docs } : {}), ...(bin ? { bin: resolve(bin) } : {}) });
|
|
99
|
+
const strict = rest.includes("--strict");
|
|
100
|
+
const failed = report.errors > 0 || (strict && report.warnings > 0);
|
|
101
|
+
if (rest.includes("--json")) {
|
|
102
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
103
|
+
return failed ? 1 : 0;
|
|
104
|
+
}
|
|
105
|
+
const s = report.stats;
|
|
106
|
+
process.stdout.write(`\n${app.title} ${app.version}: ${s.tools === s.allTools ? `${s.tools} tools` : `${s.tools} of ${s.allTools} tools on`} (${s.reads} read, ${s.writes} write, ${s.irreversible} irreversible, ${s.confirmed} confirmed, ${s.typedOutput} typed)\n` +
|
|
107
|
+
`Schemas: ${Math.round(s.schemaBytes / 1024)} KB across all ${s.allTools} tools${s.largestTool ? `, largest ${s.largestTool.name} at ${Math.round(s.largestTool.bytes / 1024)} KB` : ""}` +
|
|
108
|
+
`${s.startupMs !== undefined ? `. Answered initialize in ${s.startupMs} ms with nothing configured` : ""}.\n\n`);
|
|
109
|
+
for (const finding of report.findings) {
|
|
110
|
+
process.stdout.write(` ${finding.level === "error" ? "✗" : "!"} [${finding.check}]${finding.tool ? ` ${finding.tool}:` : ""} ${finding.message}\n`);
|
|
111
|
+
}
|
|
112
|
+
process.stdout.write(`\n${report.errors} errors, ${report.warnings} warnings. ${failed ? "Not ready." : "Ready."}\n`);
|
|
113
|
+
return failed ? 1 : 0;
|
|
114
|
+
}
|
|
115
|
+
process.stderr.write(`Unknown command '${command}'.\n\n${HELP}`);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
/** Read a document from a file or a URL, as JSON, or as YAML when the yaml package is installed. */
|
|
119
|
+
async function readDocument(source) {
|
|
120
|
+
const text = /^https?:\/\//.test(source)
|
|
121
|
+
? await fetch(source).then((response) => {
|
|
122
|
+
if (!response.ok)
|
|
123
|
+
throw new Error(`${source} answered ${response.status}.`);
|
|
124
|
+
return response.text();
|
|
125
|
+
})
|
|
126
|
+
: readFileSync(resolve(source), "utf8");
|
|
127
|
+
if (/^\s*[{[]/.test(text))
|
|
128
|
+
return JSON.parse(text);
|
|
129
|
+
try {
|
|
130
|
+
const yaml = (await import("yaml"));
|
|
131
|
+
return yaml.parse(text);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND")
|
|
135
|
+
throw new Error("This document is YAML. Install the yaml package (npm install --save-dev yaml), or convert it to JSON.");
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function openapiPreview(source, json) {
|
|
140
|
+
const document = await readDocument(source);
|
|
141
|
+
const { operations, skipped } = readOperations(document);
|
|
142
|
+
const tools = fromOpenAPI(document, { execute: () => undefined });
|
|
143
|
+
const sha256 = openapiHash(document);
|
|
144
|
+
const rows = tools.map((tool, i) => ({
|
|
145
|
+
tool: tool.name,
|
|
146
|
+
operation: `${operations[i].method} ${operations[i].path}`,
|
|
147
|
+
risk: tool.risk,
|
|
148
|
+
toolsets: tool.tags,
|
|
149
|
+
schema_kb: Math.round(JSON.stringify(tool.jsonSchema).length / 102.4) / 10,
|
|
150
|
+
}));
|
|
151
|
+
if (json) {
|
|
152
|
+
process.stdout.write(`${JSON.stringify({ tools: rows, skipped, pin: { sha256 } }, null, 2)}\n`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const info = document.info;
|
|
156
|
+
const width = Math.max(4, ...rows.map((row) => row.tool.length)) + 2;
|
|
157
|
+
process.stdout.write(`\n${info?.title ?? "API"} ${info?.version ?? ""}: ${tools.length} tools from ${operations.length + skipped.length} operations\n\n`);
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const mark = row.risk === "read" ? " " : row.risk === "destructive" ? "!" : "*";
|
|
160
|
+
process.stdout.write(` ${mark} ${row.tool.padEnd(width)}${row.operation}${row.toolsets.length ? ` [${row.toolsets.join(", ")}]` : ""}${row.schema_kb > 16 ? ` ${row.schema_kb} KB` : ""}\n`);
|
|
161
|
+
}
|
|
162
|
+
if (skipped.length) {
|
|
163
|
+
process.stdout.write(`\nSkipped:\n`);
|
|
164
|
+
for (const item of skipped)
|
|
165
|
+
process.stdout.write(` ${item.method} ${item.path}: ${item.reason}\n`);
|
|
166
|
+
}
|
|
167
|
+
const large = rows.filter((row) => row.schema_kb > 16).length;
|
|
168
|
+
if (large)
|
|
169
|
+
process.stdout.write(`\n${large} ${large === 1 ? "schema is" : "schemas are"} over 16 KB, which a model pays for each time it loads the tool. Leave such operations out with include, or offer the tools through ${"<PREFIX>"}_SURFACE=search.\n`);
|
|
170
|
+
process.stdout.write(`\n * writes ! public or irreversible\n\nPin this document in fromOpenAPI:\n pin: { sha256: "${sha256}" }\n\n`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
main(process.argv.slice(2)).then((code) => {
|
|
174
|
+
process.exitCode = code;
|
|
175
|
+
}, (error) => {
|
|
176
|
+
process.stderr.write(`slipway: ${error.message}\n`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
});
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slipway check`: the release gate.
|
|
3
|
+
*
|
|
4
|
+
* Unit tests prove a handler works. They do not prove that what a client
|
|
5
|
+
* receives matches what the CLI offers, that a schema will be accepted, that an
|
|
6
|
+
* example in the README still runs, or that the server answers when nothing is
|
|
7
|
+
* configured. Those are the failures that reach users, so they are checked here,
|
|
8
|
+
* against the real server, over the real protocol.
|
|
9
|
+
*/
|
|
10
|
+
import type { App } from "./app.js";
|
|
11
|
+
export type Finding = {
|
|
12
|
+
level: "error" | "warn";
|
|
13
|
+
check: string;
|
|
14
|
+
tool?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
export type CheckOptions = {
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
/** Markdown files whose commands and flags must exist: README.md, SKILL.md. */
|
|
20
|
+
docs?: string[];
|
|
21
|
+
/** The built entry point, to check it starts and answers with nothing configured. */
|
|
22
|
+
bin?: string;
|
|
23
|
+
/** Advertised schema size per tool that earns a warning, and an error. */
|
|
24
|
+
schemaBudget?: {
|
|
25
|
+
warnBytes: number;
|
|
26
|
+
errorBytes: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export type CheckReport = {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
errors: number;
|
|
32
|
+
warnings: number;
|
|
33
|
+
findings: Finding[];
|
|
34
|
+
stats: {
|
|
35
|
+
/** Tools on under this environment. */
|
|
36
|
+
tools: number;
|
|
37
|
+
/** Every tool the app defines, whatever the environment turns on. Schema sizes cover all of them. */
|
|
38
|
+
allTools: number;
|
|
39
|
+
reads: number;
|
|
40
|
+
writes: number;
|
|
41
|
+
irreversible: number;
|
|
42
|
+
confirmed: number;
|
|
43
|
+
typedOutput: number;
|
|
44
|
+
schemaBytes: number;
|
|
45
|
+
largestTool?: {
|
|
46
|
+
name: string;
|
|
47
|
+
bytes: number;
|
|
48
|
+
};
|
|
49
|
+
startupMs?: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export declare function checkApp(app: App, options?: CheckOptions): Promise<CheckReport>;
|