@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +757 -0
  5. package/SECURITY.md +33 -0
  6. package/SKILL.md +136 -0
  7. package/dist/app.d.ts +210 -0
  8. package/dist/app.js +364 -0
  9. package/dist/bin.d.ts +14 -0
  10. package/dist/bin.js +178 -0
  11. package/dist/check.d.ts +52 -0
  12. package/dist/check.js +313 -0
  13. package/dist/cli/completion.d.ts +5 -0
  14. package/dist/cli/completion.js +72 -0
  15. package/dist/cli/context.d.ts +97 -0
  16. package/dist/cli/context.js +94 -0
  17. package/dist/cli/data.d.ts +17 -0
  18. package/dist/cli/data.js +120 -0
  19. package/dist/cli/flags.d.ts +35 -0
  20. package/dist/cli/flags.js +208 -0
  21. package/dist/cli/help.d.ts +15 -0
  22. package/dist/cli/help.js +213 -0
  23. package/dist/cli/install.d.ts +10 -0
  24. package/dist/cli/install.js +65 -0
  25. package/dist/cli/output.d.ts +22 -0
  26. package/dist/cli/output.js +159 -0
  27. package/dist/cli/run.d.ts +10 -0
  28. package/dist/cli/run.js +343 -0
  29. package/dist/confirm.d.ts +90 -0
  30. package/dist/confirm.js +166 -0
  31. package/dist/data.d.ts +109 -0
  32. package/dist/data.js +324 -0
  33. package/dist/docs.d.ts +13 -0
  34. package/dist/docs.js +66 -0
  35. package/dist/doctor.d.ts +12 -0
  36. package/dist/doctor.js +100 -0
  37. package/dist/entry.d.ts +11 -0
  38. package/dist/entry.js +34 -0
  39. package/dist/errors.d.ts +96 -0
  40. package/dist/errors.js +153 -0
  41. package/dist/guard.d.ts +46 -0
  42. package/dist/guard.js +89 -0
  43. package/dist/index.d.ts +27 -0
  44. package/dist/index.js +16 -0
  45. package/dist/install.d.ts +98 -0
  46. package/dist/install.js +325 -0
  47. package/dist/jobs.d.ts +143 -0
  48. package/dist/jobs.js +247 -0
  49. package/dist/openapi.d.ts +127 -0
  50. package/dist/openapi.js +549 -0
  51. package/dist/pages.d.ts +22 -0
  52. package/dist/pages.js +61 -0
  53. package/dist/policy.d.ts +66 -0
  54. package/dist/policy.js +74 -0
  55. package/dist/redact.d.ts +18 -0
  56. package/dist/redact.js +60 -0
  57. package/dist/result.d.ts +44 -0
  58. package/dist/result.js +74 -0
  59. package/dist/rpc.d.ts +69 -0
  60. package/dist/rpc.js +120 -0
  61. package/dist/schema.d.ts +99 -0
  62. package/dist/schema.js +192 -0
  63. package/dist/search.d.ts +18 -0
  64. package/dist/search.js +119 -0
  65. package/dist/serve.d.ts +30 -0
  66. package/dist/serve.js +149 -0
  67. package/dist/server.d.ts +20 -0
  68. package/dist/server.js +244 -0
  69. package/dist/sync.d.ts +35 -0
  70. package/dist/sync.js +119 -0
  71. package/dist/testing.d.ts +35 -0
  72. package/dist/testing.js +41 -0
  73. package/dist/tool.d.ts +194 -0
  74. package/dist/tool.js +151 -0
  75. package/dist/util.d.ts +12 -0
  76. package/dist/util.js +35 -0
  77. package/package.json +89 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `install <client>`: add this server to an MCP client's own configuration.
3
+ *
4
+ * Every client keeps its servers in a different file, in a different shape,
5
+ * and passes the environment on differently. Codex forwards only variables it
6
+ * is told to, Gemini CLI hides anything named like a key unless it is listed,
7
+ * and Claude Desktop sees nothing from a shell at all. Getting one of those
8
+ * wrong is a server that starts and then fails on its first call, so each
9
+ * client's shape lives here once, checked against that client's own docs.
10
+ *
11
+ * A credential is never written into a client's file unless `--copy-env`
12
+ * asks for it. Where a client can read a variable from its own environment,
13
+ * the entry names the variable instead of holding its value.
14
+ */
15
+ import type { App, CliIO } from "./app.js";
16
+ export type ClientId = "claude-code" | "codex" | "claude-desktop" | "cursor" | "vscode" | "gemini";
17
+ export type Scope = "user" | "project";
18
+ export declare const CLIENTS: Record<ClientId, {
19
+ title: string;
20
+ scopes: readonly Scope[];
21
+ }>;
22
+ /** How a client starts the server. */
23
+ export type Launch = {
24
+ command: string;
25
+ args: string[];
26
+ };
27
+ export type InstallOptions = {
28
+ client: ClientId;
29
+ scope: Scope;
30
+ /** The key the server is listed under. Defaults to the app's name. */
31
+ name: string;
32
+ /** Copy the current values of the app's settings into the client's file. Only where a client cannot read them itself. */
33
+ copyEnv: boolean;
34
+ /** Start the server from this copy on disk rather than from npm. */
35
+ local: boolean;
36
+ };
37
+ export type InstallPlan = {
38
+ client: ClientId;
39
+ scope: Scope;
40
+ name: string;
41
+ /** The file this changes, for clients configured by file. */
42
+ file?: string;
43
+ /** The command this runs, for a client with its own command for adding servers. */
44
+ run?: {
45
+ command: string;
46
+ args: string[];
47
+ };
48
+ /** The entry as written. */
49
+ entry: unknown;
50
+ /** The file's full text after the change. */
51
+ text?: string;
52
+ /** Settings the client passes on from its own environment, settings copied in, and settings left for the person to add. */
53
+ env: {
54
+ forwarded: string[];
55
+ copied: string[];
56
+ toAdd: string[];
57
+ };
58
+ notes: string[];
59
+ };
60
+ /**
61
+ * How a client should start this server: the published package through npx,
62
+ * pinned to this version, or this copy on disk.
63
+ *
64
+ * npx needs the binary named: a package with an MCP and a CLI binary leaves
65
+ * npx to pick one otherwise, and the CLI started with no arguments prints its
66
+ * command list instead of serving.
67
+ */
68
+ export declare function launchFor(app: App, options: {
69
+ local: boolean;
70
+ entry?: string;
71
+ platform?: NodeJS.Platform;
72
+ }): Launch;
73
+ /** Where each client keeps its servers, from its own documentation. */
74
+ export declare function configFile(client: ClientId, scope: Scope, env: NodeJS.ProcessEnv, cwd: string, platform?: NodeJS.Platform): string | undefined;
75
+ /**
76
+ * Put `[mcp_servers.<name>]` into a TOML file. An existing table of that name
77
+ * is updated in place: install's own keys are rewritten, `env_vars` keeps the
78
+ * names it already had, and every other key and subtable stays as written. A
79
+ * file that defines the server some other way is left alone, because
80
+ * rewriting it would mean parsing all of TOML.
81
+ */
82
+ export declare function upsertCodexServer(text: string, name: string, launch: Launch, forwarded: string[], usesNpx: boolean): string;
83
+ export declare function planInstall(app: App, options: InstallOptions, context: {
84
+ env: NodeJS.ProcessEnv;
85
+ cwd: string;
86
+ entry?: string;
87
+ platform?: NodeJS.Platform;
88
+ }): InstallPlan;
89
+ export type InstallResult = {
90
+ plan: InstallPlan;
91
+ backup?: string;
92
+ ran?: {
93
+ code: number;
94
+ output: string;
95
+ };
96
+ };
97
+ /** Write the plan's file, keeping a copy of the old one, or run the client's own command. */
98
+ export declare function applyInstall(plan: InstallPlan, io: Pick<CliIO, "env">): Promise<InstallResult>;
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `install <client>`: add this server to an MCP client's own configuration.
3
+ *
4
+ * Every client keeps its servers in a different file, in a different shape,
5
+ * and passes the environment on differently. Codex forwards only variables it
6
+ * is told to, Gemini CLI hides anything named like a key unless it is listed,
7
+ * and Claude Desktop sees nothing from a shell at all. Getting one of those
8
+ * wrong is a server that starts and then fails on its first call, so each
9
+ * client's shape lives here once, checked against that client's own docs.
10
+ *
11
+ * A credential is never written into a client's file unless `--copy-env`
12
+ * asks for it. Where a client can read a variable from its own environment,
13
+ * the entry names the variable instead of holding its value.
14
+ */
15
+ import { execFile } from "node:child_process";
16
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { UsageError } from "./errors.js";
20
+ export const CLIENTS = {
21
+ "claude-code": { title: "Claude Code", scopes: ["user", "project"] },
22
+ codex: { title: "Codex", scopes: ["user", "project"] },
23
+ "claude-desktop": { title: "Claude Desktop", scopes: ["user"] },
24
+ cursor: { title: "Cursor", scopes: ["user", "project"] },
25
+ vscode: { title: "VS Code", scopes: ["project"] },
26
+ gemini: { title: "Gemini CLI", scopes: ["user", "project"] },
27
+ };
28
+ /**
29
+ * How a client should start this server: the published package through npx,
30
+ * pinned to this version, or this copy on disk.
31
+ *
32
+ * npx needs the binary named: a package with an MCP and a CLI binary leaves
33
+ * npx to pick one otherwise, and the CLI started with no arguments prints its
34
+ * command list instead of serving.
35
+ */
36
+ export function launchFor(app, options) {
37
+ const platform = options.platform ?? process.platform;
38
+ let launch;
39
+ if (app.definition.package && !options.local) {
40
+ launch = { command: "npx", args: ["--yes", `--package=${app.definition.package}@${app.version}`, app.bins.mcp] };
41
+ }
42
+ else {
43
+ const entry = options.entry ?? (process.argv[1] ? realpathSync(process.argv[1]) : undefined);
44
+ if (!entry)
45
+ throw new UsageError("Could not tell where this server is installed.", { hint: "Run install from the installed binary." });
46
+ launch = { command: process.execPath, args: [entry] };
47
+ }
48
+ // Windows runs npx as a batch file, which a client that starts processes without a shell cannot launch directly.
49
+ if (platform === "win32" && launch.command === "npx")
50
+ return { command: "cmd", args: ["/c", "npx", ...launch.args] };
51
+ return launch;
52
+ }
53
+ /** "A", "A and B", "A, B and C". */
54
+ function list(names) {
55
+ return names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
56
+ }
57
+ /** "it" or "them", for a list of settings. */
58
+ function them(names) {
59
+ return names.length === 1 ? "it" : "them";
60
+ }
61
+ function home(env) {
62
+ return env.HOME || env.USERPROFILE || homedir();
63
+ }
64
+ /** Where each client keeps its servers, from its own documentation. */
65
+ export function configFile(client, scope, env, cwd, platform = process.platform) {
66
+ const h = home(env);
67
+ switch (client) {
68
+ case "codex":
69
+ return scope === "project" ? join(cwd, ".codex", "config.toml") : join(env.CODEX_HOME || join(h, ".codex"), "config.toml");
70
+ case "claude-desktop":
71
+ if (platform === "darwin")
72
+ return join(h, "Library", "Application Support", "Claude", "claude_desktop_config.json");
73
+ if (platform === "win32")
74
+ return join(env.APPDATA || join(h, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
75
+ throw new UsageError("Claude Desktop runs on macOS and Windows only.");
76
+ case "cursor":
77
+ return scope === "project" ? join(cwd, ".cursor", "mcp.json") : join(h, ".cursor", "mcp.json");
78
+ case "vscode":
79
+ return join(cwd, ".vscode", "mcp.json");
80
+ case "gemini":
81
+ return scope === "project" ? join(cwd, ".gemini", "settings.json") : join(h, ".gemini", "settings.json");
82
+ case "claude-code":
83
+ return undefined;
84
+ }
85
+ }
86
+ /** A TOML basic string. JSON's escapes are TOML's escapes for every character a command or path holds. */
87
+ function tomlString(value) {
88
+ return JSON.stringify(value);
89
+ }
90
+ /** The keys install writes in a Codex table. Every other key, and every subtable, is the person's and stays. */
91
+ const CODEX_KEYS = new Set(["command", "args", "env_vars", "startup_timeout_sec"]);
92
+ function bracketDepth(text) {
93
+ // Brackets inside quoted strings do not count.
94
+ const bare = text.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "");
95
+ return (bare.match(/\[/g)?.length ?? 0) - (bare.match(/\]/g)?.length ?? 0);
96
+ }
97
+ function quotedStrings(text) {
98
+ return [...text.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((match) => JSON.parse(`"${match[1]}"`));
99
+ }
100
+ /**
101
+ * Put `[mcp_servers.<name>]` into a TOML file. An existing table of that name
102
+ * is updated in place: install's own keys are rewritten, `env_vars` keeps the
103
+ * names it already had, and every other key and subtable stays as written. A
104
+ * file that defines the server some other way is left alone, because
105
+ * rewriting it would mean parsing all of TOML.
106
+ */
107
+ export function upsertCodexServer(text, name, launch, forwarded, usesNpx) {
108
+ const key = `(?:${escape(name)}|"${escape(name)}")`;
109
+ const own = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*${key}\\s*(?:\\]|\\.)`);
110
+ const header = /^\s*\[/;
111
+ const lines = text.split("\n");
112
+ let current = "";
113
+ for (const line of lines) {
114
+ if (header.test(line)) {
115
+ current = line.trim().replace(/\s+/g, "");
116
+ continue;
117
+ }
118
+ const inline = current === "[mcp_servers]" && new RegExp(`^\\s*${key}\\s*=`).test(line);
119
+ const dotted = current === "" && new RegExp(`^\\s*mcp_servers\\s*\\.\\s*${key}\\s*[.=]`).test(line);
120
+ if (inline || dotted) {
121
+ throw new UsageError(`This file already defines ${name} in a form install does not rewrite.`, { hint: "Edit that entry by hand, or remove it and run install again." });
122
+ }
123
+ }
124
+ const start = lines.findIndex((line) => own.test(line));
125
+ let end = start + 1;
126
+ if (start !== -1)
127
+ while (end < lines.length && !(header.test(lines[end]) && !own.test(lines[end])))
128
+ end++;
129
+ const block = start === -1 ? [] : lines.slice(start + 1, end);
130
+ // Split the old table into install's keys, which are replaced, and the rest, which stays.
131
+ const kept = [];
132
+ const names = new Set(forwarded);
133
+ let depth = 0;
134
+ let skippedKey = "";
135
+ let rest = block.length;
136
+ for (let i = 0; i < block.length; i++) {
137
+ const line = block[i];
138
+ if (depth > 0) {
139
+ if (skippedKey === "env_vars")
140
+ for (const value of quotedStrings(line))
141
+ names.add(value);
142
+ depth += bracketDepth(line);
143
+ continue;
144
+ }
145
+ if (header.test(line)) {
146
+ rest = i;
147
+ break;
148
+ }
149
+ const match = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
150
+ if (match && CODEX_KEYS.has(match[1])) {
151
+ skippedKey = match[1];
152
+ if (skippedKey === "env_vars")
153
+ for (const value of quotedStrings(match[2]))
154
+ names.add(value);
155
+ depth = Math.max(0, bracketDepth(match[2]));
156
+ continue;
157
+ }
158
+ kept.push(line);
159
+ }
160
+ const subtables = block.slice(rest);
161
+ const tableKey = /^[A-Za-z0-9_-]+$/.test(name) ? name : tomlString(name);
162
+ const table = [`[mcp_servers.${tableKey}]`, `command = ${tomlString(launch.command)}`, `args = [${launch.args.map(tomlString).join(", ")}]`];
163
+ if (names.size)
164
+ table.push(`env_vars = [${[...names].map(tomlString).join(", ")}]`);
165
+ // npx downloads the package the first time, which can outlast Codex's ten-second default.
166
+ if (usesNpx)
167
+ table.push(`startup_timeout_sec = 60`);
168
+ while (kept.length && kept[kept.length - 1].trim() === "")
169
+ kept.pop();
170
+ const ownLines = [...table, ...kept, ...(subtables.length ? ["", ...subtables] : [])];
171
+ while (ownLines.length && ownLines[ownLines.length - 1].trim() === "")
172
+ ownLines.pop();
173
+ if (start === -1) {
174
+ const body = text.replace(/\s*$/, "");
175
+ return `${body}${body ? "\n\n" : ""}${ownLines.join("\n")}\n`;
176
+ }
177
+ const after = lines.slice(end);
178
+ while (after.length && after[0].trim() === "")
179
+ after.shift();
180
+ return [...lines.slice(0, start), ...ownLines, ...(after.length ? ["", ...after] : [""])].join("\n");
181
+ }
182
+ function escape(text) {
183
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
+ }
185
+ function readJson(file) {
186
+ if (!existsSync(file))
187
+ return {};
188
+ const text = readFileSync(file, "utf8");
189
+ if (!text.trim())
190
+ return {};
191
+ try {
192
+ const value = JSON.parse(text);
193
+ if (value && typeof value === "object" && !Array.isArray(value))
194
+ return value;
195
+ }
196
+ catch {
197
+ // Falls through to the refusal below.
198
+ }
199
+ throw new UsageError(`${file} is not plain JSON, so install will not rewrite it. It may hold comments.`, { hint: "Add the entry by hand: run install again with --dry-run to see it." });
200
+ }
201
+ export function planInstall(app, options, context) {
202
+ const { client, scope, name } = options;
203
+ const info = CLIENTS[client];
204
+ if (!info.scopes.includes(scope)) {
205
+ throw new UsageError(`${info.title} has no ${scope} scope here. It takes: ${info.scopes.join(", ")}.`);
206
+ }
207
+ const launch = launchFor(app, { local: options.local, ...(context.entry ? { entry: context.entry } : {}), ...(context.platform ? { platform: context.platform } : {}) });
208
+ const usesNpx = launch.command === "npx" || launch.args.includes("npx");
209
+ const settings = app.definition.settings ?? [];
210
+ const variables = settings.map((setting) => setting.env);
211
+ const env = { forwarded: [], copied: [], toAdd: [] };
212
+ const notes = [];
213
+ const file = configFile(client, scope, context.env, context.cwd, context.platform);
214
+ if (client === "claude-code") {
215
+ const entry = { type: "stdio", command: launch.command, args: launch.args };
216
+ if (variables.length)
217
+ notes.push(`Claude Code passes its own environment to the server: set ${list(variables)} in the shell you start Claude Code from.`);
218
+ return { client, scope, name, entry, env, notes, run: { command: "claude", args: ["mcp", "add-json", name, JSON.stringify(entry), "--scope", scope] } };
219
+ }
220
+ if (client === "codex") {
221
+ env.forwarded = variables;
222
+ const before = existsSync(file) ? readFileSync(file, "utf8") : "";
223
+ const text = upsertCodexServer(before, name, launch, variables, usesNpx);
224
+ if (variables.length)
225
+ notes.push(`Codex passes ${list(variables)} on from its own environment: set ${them(variables)} where you start Codex.`);
226
+ if (scope === "project")
227
+ notes.push("Codex reads a project's .codex/config.toml only once the project is trusted.");
228
+ const entry = upsertCodexServer("", name, launch, variables, usesNpx).trim();
229
+ return { client, scope, name, file, entry, text, env, notes };
230
+ }
231
+ // The JSON clients. An existing entry keeps everything install does not manage: its own env values above all.
232
+ const config = readJson(file);
233
+ const key = client === "vscode" ? "servers" : "mcpServers";
234
+ const servers = (config[key] && typeof config[key] === "object" && !Array.isArray(config[key]) ? config[key] : {});
235
+ const previous = (servers[name] && typeof servers[name] === "object" ? servers[name] : {});
236
+ const previousEnv = (previous.env && typeof previous.env === "object" ? previous.env : {});
237
+ const values = { ...previousEnv };
238
+ const inputs = [];
239
+ for (const setting of settings) {
240
+ const variable = setting.env;
241
+ if (variable in previousEnv)
242
+ continue;
243
+ if (client === "cursor")
244
+ values[variable] = `\${env:${variable}}`;
245
+ else if (client === "gemini")
246
+ values[variable] = `\${${variable}}`;
247
+ else if (client === "vscode" && setting.secret) {
248
+ const id = variable.toLowerCase().replace(/_/g, "-");
249
+ inputs.push({ type: "promptString", id, description: setting.description, password: true });
250
+ values[variable] = `\${input:${id}}`;
251
+ }
252
+ else if (options.copyEnv && context.env[variable]) {
253
+ values[variable] = context.env[variable];
254
+ env.copied.push(variable);
255
+ continue;
256
+ }
257
+ else {
258
+ env.toAdd.push(variable);
259
+ continue;
260
+ }
261
+ env.forwarded.push(variable);
262
+ }
263
+ const server = {
264
+ ...(client === "cursor" || client === "vscode" ? { type: "stdio" } : {}),
265
+ ...previous,
266
+ command: launch.command,
267
+ args: launch.args,
268
+ ...(Object.keys(values).length ? { env: values } : {}),
269
+ };
270
+ const next = { ...config, [key]: { ...servers, [name]: server } };
271
+ if (client === "vscode" && inputs.length) {
272
+ const existing = Array.isArray(config.inputs) ? config.inputs : [];
273
+ next.inputs = [...existing, ...inputs.filter((input) => !existing.some((have) => have.id === input.id))];
274
+ }
275
+ if (client === "cursor" && env.forwarded.length)
276
+ notes.push(`Cursor reads ${list(env.forwarded)} from its own environment.`);
277
+ if (client === "gemini") {
278
+ if (env.forwarded.length)
279
+ notes.push(`Gemini CLI fills ${list(env.forwarded)} from its own environment. It hides variables named like keys from servers unless an entry lists them, as this one does.`);
280
+ notes.push("Gemini CLI starts servers only in folders you trust.");
281
+ }
282
+ if (client === "vscode") {
283
+ if (inputs.length)
284
+ notes.push(`VS Code asks for ${list(inputs.map((input) => String(input.id)))} the first time the server starts, and stores the answer securely.`);
285
+ if (env.toAdd.length)
286
+ notes.push(`${list(env.toAdd)} ${env.toAdd.length === 1 ? "keeps its default" : "keep their defaults"}. Add ${them(env.toAdd)} to the server's env in ${file} to change ${them(env.toAdd)}.`);
287
+ }
288
+ if (client === "claude-desktop") {
289
+ if (env.copied.length)
290
+ notes.push(`Copied ${list(env.copied)} from this shell into ${file}, which is now readable by you only.`);
291
+ if (env.toAdd.length) {
292
+ notes.push(`Claude Desktop does not read a shell's environment. Add ${list(env.toAdd)} to the env of "${name}" in ${file}, or run install again with --copy-env to copy ${them(env.toAdd)} from this shell.`);
293
+ }
294
+ }
295
+ return { client, scope, name, file, entry: server, text: `${JSON.stringify(next, null, 2)}\n`, env, notes };
296
+ }
297
+ /** A timestamp for a backup file name: 20261004-183000. */
298
+ function stamp(date = new Date()) {
299
+ return date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
300
+ }
301
+ /** Write the plan's file, keeping a copy of the old one, or run the client's own command. */
302
+ export async function applyInstall(plan, io) {
303
+ if (plan.run) {
304
+ const run = plan.run;
305
+ const ran = await new Promise((resolve) => {
306
+ execFile(run.command, run.args, { env: io.env, timeout: 60_000 }, (error, stdout, stderr) => {
307
+ const code = error ? (typeof error.code === "number" ? Number(error.code) : 1) : 0;
308
+ resolve({ code: error?.code === "ENOENT" ? 127 : code, output: `${stdout}${stderr}`.trim() });
309
+ });
310
+ });
311
+ return { plan, ran };
312
+ }
313
+ const file = plan.file;
314
+ mkdirSync(dirname(file), { recursive: true });
315
+ let backup;
316
+ if (existsSync(file)) {
317
+ backup = `${file}.bak-${stamp()}`;
318
+ copyFileSync(file, backup);
319
+ }
320
+ writeFileSync(file, plan.text);
321
+ // A file holding a copied credential is the owner's alone, whether or not install created it.
322
+ if (plan.env.copied.length && process.platform !== "win32")
323
+ chmodSync(file, 0o600);
324
+ return { plan, ...(backup ? { backup } : {}) };
325
+ }
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Work that takes longer than a client waits for one call.
3
+ *
4
+ * Clients stop waiting on a tool after about a minute, and some much sooner,
5
+ * so a render, an export or a long sync cannot simply run inside the call.
6
+ * A job tool starts the work and waits a bounded time. A job that finishes in
7
+ * time comes back with its result, as any call would. One that does not comes
8
+ * back as a job the caller checks with a generated `<name>_status` tool, so a
9
+ * model never polls blind and a script never guesses at a status endpoint.
10
+ *
11
+ * Two kinds:
12
+ * - the service runs the job and has its own status endpoint (`id`, `status`
13
+ * and `done` say how to read it), or
14
+ * - the handler itself is slow (`background: true`), and Slipway runs it in
15
+ * this process and keeps its result for an hour.
16
+ */
17
+ import { SlipwayError } from "./errors.js";
18
+ import type { ToolContext } from "./tool.js";
19
+ /** Progress a job reports, as MCP progress notifications carry it. */
20
+ export type JobProgress = {
21
+ progress: number;
22
+ total?: number;
23
+ message?: string;
24
+ };
25
+ /** A job the service runs, with an endpoint that reports how it is going. */
26
+ export type ServiceJob<Ctx = any, S = any> = {
27
+ /** Where the job id is in what the handler returned: a dotted path such as `id` or `job.id`, or a function. */
28
+ id: string | ((started: any) => string | number | undefined);
29
+ /** Read the job's current status from the service. */
30
+ status: (id: string, ctx: ToolContext<Ctx>) => S | Promise<S>;
31
+ /** Whether a status means the job has finished, either way. Called on what the handler returned, then on each status. */
32
+ done: (status: S) => boolean;
33
+ /** Whether a finished status means it failed. A failed job is reported as an error, with the status as its details. */
34
+ failed?: (status: S) => boolean;
35
+ /** Progress to report while a call waits, read from a status. */
36
+ progress?: (status: S) => JobProgress | undefined;
37
+ /** How often to check while waiting, in milliseconds. Defaults to 2000. */
38
+ pollMs?: number;
39
+ /** How long a call waits before handing back the job, in seconds. Defaults to 25. */
40
+ waitSeconds?: number;
41
+ };
42
+ /** A handler that is slow on its own: Slipway runs it in the background and keeps its result. */
43
+ export type BackgroundJob = {
44
+ background: true;
45
+ /** How long a call waits before handing back the job, in seconds. Defaults to 25. */
46
+ waitSeconds?: number;
47
+ };
48
+ export type JobDefinition<Ctx = any> = ServiceJob<Ctx> | BackgroundJob;
49
+ /** What a job tool and its status tool return. */
50
+ export type JobResult = {
51
+ job_id: string;
52
+ tool: string;
53
+ done: boolean;
54
+ /** The service's latest status, for a job the service runs. */
55
+ status?: unknown;
56
+ /** What the tool returned, for a background job that finished. */
57
+ result?: unknown;
58
+ /** The last progress a background job reported. */
59
+ progress?: JobProgress;
60
+ started_at?: string;
61
+ finished_at?: string;
62
+ /** How to check again, while the job is still running. */
63
+ check?: string;
64
+ };
65
+ /** A client stops waiting on a call after about a minute, so no call waits longer than this. */
66
+ export declare const MAX_WAIT_SECONDS = 55;
67
+ export declare const DEFAULT_WAIT_SECONDS = 25;
68
+ export declare function isBackground(job: JobDefinition): job is BackgroundJob;
69
+ export declare function waitSecondsFor(job: JobDefinition): number;
70
+ export declare function pollMsFor(job: ServiceJob): number;
71
+ export declare function readJobId(job: ServiceJob, started: unknown): string | undefined;
72
+ /**
73
+ * Wait until `finished` settles or `ms` passes, whichever is first. Infinity
74
+ * waits for as long as it takes. Resolves true when it finished. A canceled
75
+ * call stops waiting at once.
76
+ */
77
+ export declare function waitFor(finished: Promise<unknown>, ms: number, signal: AbortSignal | undefined): Promise<boolean>;
78
+ /** Sleep between status checks, cut short by a canceled call. */
79
+ export declare function pause(ms: number, signal: AbortSignal | undefined): Promise<void>;
80
+ /** A failed service job, reported with the status that says why. */
81
+ export declare function jobFailed(tool: string, id: string, status: unknown): SlipwayError;
82
+ type Entry = {
83
+ id: string;
84
+ tool: string;
85
+ startedAt: number;
86
+ finishedAt?: number;
87
+ state: "running" | "done" | "failed";
88
+ result?: unknown;
89
+ error?: SlipwayError;
90
+ progress?: JobProgress;
91
+ /** Settles when the job finishes, either way. Never rejects. */
92
+ finished: Promise<void>;
93
+ /** Whoever is waiting on the job right now, to forward its progress to. */
94
+ listener?: (progress: JobProgress) => void;
95
+ };
96
+ /**
97
+ * Background jobs of one process.
98
+ *
99
+ * Bounded both ways: at most 100 run at once, and a finished job is kept for
100
+ * an hour, so a server that runs for weeks never grows without limit. Ids are
101
+ * random, so one caller cannot read another's job by counting.
102
+ */
103
+ export declare class JobRegistry {
104
+ private readonly jobs;
105
+ private static readonly MAX_RUNNING;
106
+ private static readonly KEEP_MS;
107
+ private static readonly MAX_KEPT;
108
+ start(tool: string, work: (progress: (update: JobProgress) => void) => Promise<unknown>): Entry;
109
+ get(id: string, tool: string): Entry;
110
+ private prune;
111
+ }
112
+ export type BackgroundEntry = Entry;
113
+ /** A background job as its caller sees it. Throws the job's own error once it failed. */
114
+ export declare function backgroundResult(entry: Entry, check: string): JobResult;
115
+ /** Everything one job call needs from the app that runs it. */
116
+ export type JobCall = {
117
+ /** The job tool's name, and the job it defines. */
118
+ jobTool: string;
119
+ job: JobDefinition;
120
+ /** Start a new job by running the tool's handler with a context, or check the job with this id. */
121
+ start?: (ctx: ToolContext<any>) => unknown;
122
+ jobId?: string;
123
+ /** How long this call waits for the job to finish. Infinity waits to the end. */
124
+ waitMs: number;
125
+ /** The caller canceled: stop waiting. */
126
+ waitSignal?: AbortSignal;
127
+ /** A signal for one request to the service: the caller's cancel plus the tool's timeout. */
128
+ requestSignal: () => AbortSignal;
129
+ /** The handler's context for one request to the service, or for a background job from start to finish. */
130
+ context: (signal: AbortSignal, progress: (update: JobProgress) => void) => ToolContext<any>;
131
+ /** Progress to send the caller while it waits. */
132
+ progress: (update: JobProgress) => void;
133
+ registry: JobRegistry;
134
+ /** A background job's own deadline, from the tool's timeout. */
135
+ timeoutMs?: number;
136
+ /** How to check a job again, in the words of the surface that asked. */
137
+ check: (id: string) => string;
138
+ /** Race a request against its signal, so a handler that ignores it still stops being waited for. */
139
+ untilAborted: <T>(work: Promise<T>, signal: AbortSignal) => Promise<T>;
140
+ };
141
+ /** Start or check a job, and wait for it as long as this call may. */
142
+ export declare function runJob(call: JobCall): Promise<JobResult>;
143
+ export {};