@waniwani/kit 0.1.1 → 0.1.4
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/README.md +121 -22
- package/cli/codegen.mjs +36 -40
- package/cli/env.mjs +37 -0
- package/cli/framework.mjs +0 -1
- package/cli/index.mjs +31 -190
- package/cli/init.mjs +582 -0
- package/cli/log.mjs +5 -4
- package/cli/scan.mjs +42 -14
- package/cli/validate.mjs +81 -2
- package/dist/index.d.ts +40 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +7 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +76 -49
- package/dist/server.js.map +1 -1
- package/package.json +7 -6
- package/src/index.ts +45 -7
- package/src/server.ts +79 -56
- package/cli/account.mjs +0 -264
- package/cli/tunnel.mjs +0 -140
package/src/server.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* hands them to `registerApp()`. Nothing else.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import cors from "cors";
|
|
15
|
+
import express, { type ErrorRequestHandler, type RequestHandler } from "express";
|
|
14
16
|
import { McpServer, type ViewName } from "skybridge/server";
|
|
15
|
-
import {
|
|
16
|
-
import type { DocEntry, Shape, ToolHints, WidgetCsp } from "./index.js";
|
|
17
|
+
import type { EndpointDefinition, HttpMethod, Shape, ToolHints, WidgetCsp } from "./index.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The manifest holds definitions with unrelated schemas side by side, so the
|
|
@@ -52,7 +53,8 @@ export type Manifest = {
|
|
|
52
53
|
tools: Array<{ name: string; def: AnyToolDefinition }>;
|
|
53
54
|
widgets: Array<{ name: string; def: AnyWidgetDefinition }>;
|
|
54
55
|
flows: CompiledFlow[];
|
|
55
|
-
|
|
56
|
+
/** HTTP endpoints, each with the path its file position produced. */
|
|
57
|
+
endpoints?: Array<{ path: string; def: EndpointDefinition }>;
|
|
56
58
|
/**
|
|
57
59
|
* Origins the template's Tailwind entry loads from, read off it at build
|
|
58
60
|
* time. Every view imports that stylesheet, so every widget needs them.
|
|
@@ -127,61 +129,80 @@ function widgetError(name: string, error: unknown) {
|
|
|
127
129
|
};
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Answer anything outside the declared methods with 405 rather than running the
|
|
134
|
+
* handler. An endpoint is mounted with `use()`, which matches every method, so
|
|
135
|
+
* without this a `GET /api/cal/book` would reach a handler written for a POST
|
|
136
|
+
* body and fail somewhere less obvious.
|
|
137
|
+
*/
|
|
138
|
+
function methodGuard(allowed: string[]): RequestHandler {
|
|
139
|
+
// A preflight never carries the real method, and answering it 405 blocks the
|
|
140
|
+
// request it was asking about.
|
|
141
|
+
const pass = new Set([...allowed, "OPTIONS"]);
|
|
135
142
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
inputSchema: { question: z.string().describe("The user's question, in their own words.") },
|
|
143
|
-
outputSchema: {
|
|
144
|
-
results: z.array(z.object({ slug: z.string(), title: z.string(), body: z.string() })),
|
|
145
|
-
},
|
|
146
|
-
annotations: annotations("Search the documentation", undefined, { readOnly: true }),
|
|
147
|
-
},
|
|
148
|
-
async ({ question }) => {
|
|
149
|
-
const terms = question
|
|
150
|
-
.toLowerCase()
|
|
151
|
-
.split(/[^a-z0-9]+/)
|
|
152
|
-
.filter((term) => term.length > 2);
|
|
143
|
+
return (req, res, next) => {
|
|
144
|
+
if (pass.has(req.method)) return next();
|
|
145
|
+
res.setHeader("Allow", allowed.join(", "));
|
|
146
|
+
res.status(405).json({ error: `${req.method} not allowed` });
|
|
147
|
+
};
|
|
148
|
+
}
|
|
153
149
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The last link in every endpoint's chain: a 4-arity middleware, which is how
|
|
152
|
+
* Express recognises an error handler.
|
|
153
|
+
*
|
|
154
|
+
* Scoped to the endpoint's own mount path rather than the app, so it cannot
|
|
155
|
+
* catch anything the endpoint did not cause. Without it a rejected handler — or
|
|
156
|
+
* a malformed JSON body, which the parser reports the same way — reaches
|
|
157
|
+
* Express's default handler and answers an HTML error page to a `fetch()` that
|
|
158
|
+
* is waiting for JSON.
|
|
159
|
+
*/
|
|
160
|
+
function endpointErrorHandler(path: string): ErrorRequestHandler {
|
|
161
|
+
return (error, _req, res, next) => {
|
|
162
|
+
console.error(`[waniwani] endpoint "${path}" failed:`, error);
|
|
163
|
+
if (res.headersSent) return next(error);
|
|
164
|
+
// `express.json()` rejects a malformed body with a 400 already on the error.
|
|
165
|
+
const status = typeof (error as { status?: unknown })?.status === "number"
|
|
166
|
+
? (error as { status: number }).status
|
|
167
|
+
: 500;
|
|
168
|
+
res.status(status).json({
|
|
169
|
+
error: error instanceof Error ? error.message : "Internal server error",
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
}
|
|
163
173
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Mount an app's HTTP endpoints on the server's Express app.
|
|
176
|
+
*
|
|
177
|
+
* Everything the framework does not do for us is done here, once, rather than
|
|
178
|
+
* left to each endpoint to remember: CORS (a widget calls from another origin),
|
|
179
|
+
* a JSON body parser (the framework installs none, so `req.body` would be
|
|
180
|
+
* `undefined`), the method guard, and the error envelope.
|
|
181
|
+
*/
|
|
182
|
+
function registerEndpoints(
|
|
183
|
+
server: McpServer,
|
|
184
|
+
endpoints: NonNullable<Manifest["endpoints"]>,
|
|
185
|
+
): void {
|
|
186
|
+
for (const { path, def } of endpoints) {
|
|
187
|
+
const methods = def.method ? [def.method].flat().map((m) => m.toUpperCase()) : undefined;
|
|
168
188
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
189
|
+
const chain: Array<RequestHandler | ErrorRequestHandler> = [];
|
|
190
|
+
// The preflight answer names the methods the guard below actually accepts.
|
|
191
|
+
// Browsers cache it, so advertising a method that then answers 405 is a
|
|
192
|
+
// contradiction the widget author has to debug twice.
|
|
193
|
+
if (def.cors !== false) chain.push(cors(methods ? { methods } : undefined));
|
|
194
|
+
if (def.json !== false) chain.push(express.json());
|
|
195
|
+
if (methods) chain.push(methodGuard(methods));
|
|
196
|
+
chain.push(def.handler, endpointErrorHandler(path));
|
|
197
|
+
|
|
198
|
+
// The error handler's arity is what makes Express treat it as one, and
|
|
199
|
+
// `use()` is typed for request handlers only.
|
|
200
|
+
server.use(path, ...(chain as RequestHandler[]));
|
|
201
|
+
}
|
|
180
202
|
}
|
|
181
203
|
|
|
182
204
|
/**
|
|
183
|
-
* Register an app's tools, widgets
|
|
184
|
-
* built.
|
|
205
|
+
* Register an app's tools, widgets and flows onto a server the template built.
|
|
185
206
|
*
|
|
186
207
|
* The template owns construction, its own tools, `withWaniwani`, and `run()`.
|
|
187
208
|
* This adds to that server rather than replacing it, so a tool the template
|
|
@@ -189,7 +210,13 @@ function registerDocsTool(server: McpServer, docs: DocEntry[]) {
|
|
|
189
210
|
* app's own tools sit alongside it.
|
|
190
211
|
*/
|
|
191
212
|
export async function registerApp(server: McpServer, manifest: Manifest): Promise<McpServer> {
|
|
192
|
-
const { tools, widgets, flows,
|
|
213
|
+
const { tools, widgets, flows, endpoints = [], styleDomains = [] } = manifest;
|
|
214
|
+
|
|
215
|
+
// Before the tools, because Express matches in registration order and the
|
|
216
|
+
// framework mounts `/mcp` after this function returns. Nothing here can
|
|
217
|
+
// shadow it — `/api/...` and `/mcp` do not overlap — but the ordering is the
|
|
218
|
+
// reason an endpoint is reachable at all.
|
|
219
|
+
registerEndpoints(server, endpoints);
|
|
193
220
|
|
|
194
221
|
// Widgets: one `data` schema drives the input schema, the structured output,
|
|
195
222
|
// and the type the component receives.
|
|
@@ -274,10 +301,6 @@ export async function registerApp(server: McpServer, manifest: Manifest): Promis
|
|
|
274
301
|
server.registerTool({ ...flow.config, name: flow.name }, flow.handler);
|
|
275
302
|
}
|
|
276
303
|
|
|
277
|
-
if (docs.length > 0) {
|
|
278
|
-
registerDocsTool(server, docs);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
304
|
// `withWaniwani` is deliberately not called here. It wraps every registered
|
|
282
305
|
// handler in place, so it has to run after the last registration — which is
|
|
283
306
|
// the template's, not this function's. `src/server.ts` calls it.
|
package/cli/account.mjs
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The WaniWani account boundary.
|
|
3
|
-
*
|
|
4
|
-
* `tunnel` is the one command in this CLI that talks to app.waniwani.ai, and it
|
|
5
|
-
* needs two facts: who the developer is, and which agent this repo is bound to.
|
|
6
|
-
* Both already have a home that `@waniwani/cli` writes and `@waniwani/sdk`
|
|
7
|
-
* reads, so this file invents no third place to look:
|
|
8
|
-
*
|
|
9
|
-
* ~/.config/waniwani/settings.json credentials and the instance they were
|
|
10
|
-
* issued for. One login per machine, mode
|
|
11
|
-
* 0600, honours XDG_CONFIG_HOME.
|
|
12
|
-
* ./waniwani.json orgId, projectId, apiUrl, devPort. The
|
|
13
|
-
* file `waniwani connect` writes, and the
|
|
14
|
-
* one the SDK loads at runtime.
|
|
15
|
-
*
|
|
16
|
-
* `.waniwani/` holds neither of them. In the kit that directory is build output:
|
|
17
|
-
* every command regenerates it, the app's .gitignore covers it, and `eject` says
|
|
18
|
-
* to delete it. A refresh token written there lasts until the next `waniwani
|
|
19
|
-
* dev`. This is the same reasoning that moved the login CLI's own credentials
|
|
20
|
-
* out of it.
|
|
21
|
-
*
|
|
22
|
-
* `WANIWANI_API_KEY` is ignored here on purpose. A kit app usually carries that
|
|
23
|
-
* key for tracking, where it is scoped to the project's production environment,
|
|
24
|
-
* while the tunnel and dev-session routes are about the human at the terminal.
|
|
25
|
-
* Auth is therefore the OAuth token, refreshed in place when it has aged out.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { spawn } from "node:child_process";
|
|
29
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
30
|
-
import { homedir } from "node:os";
|
|
31
|
-
import { dirname, join } from "node:path";
|
|
32
|
-
import { fileURLToPath } from "node:url";
|
|
33
|
-
import { dim } from "./log.mjs";
|
|
34
|
-
|
|
35
|
-
const DEFAULT_API_URL = "https://app.waniwani.ai";
|
|
36
|
-
|
|
37
|
-
const CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
38
|
-
const SETTINGS_FILE = join(CONFIG_HOME, "waniwani", "settings.json");
|
|
39
|
-
const DIR_MODE = 0o700;
|
|
40
|
-
const FILE_MODE = 0o600;
|
|
41
|
-
|
|
42
|
-
/** A token this close to expiry is treated as expired, so a long run doesn't 401 mid-flight. */
|
|
43
|
-
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The CLI that owns logging in and binding a repo to an agent.
|
|
47
|
-
*
|
|
48
|
-
* Those two flows are an OAuth2 PKCE round trip with a local callback server,
|
|
49
|
-
* and a pair of pickers over the org's agents. Both live in `@waniwani/cli`
|
|
50
|
-
* already, and both write exactly the files above, so this CLI drives it for
|
|
51
|
-
* them instead of carrying a second copy that has to stay in step.
|
|
52
|
-
*/
|
|
53
|
-
const LOGIN_CLI = "@waniwani/cli";
|
|
54
|
-
|
|
55
|
-
function readSettings() {
|
|
56
|
-
try {
|
|
57
|
-
return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
|
|
58
|
-
} catch {
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function writeSettings(settings) {
|
|
64
|
-
mkdirSync(dirname(SETTINGS_FILE), { recursive: true, mode: DIR_MODE });
|
|
65
|
-
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, "\t"));
|
|
66
|
-
chmodSync(SETTINGS_FILE, FILE_MODE);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The repo's `waniwani.json`, or null when it has never been connected.
|
|
71
|
-
*
|
|
72
|
-
* Only the JSON file counts. An app folder also has a `waniwani.config.ts`, and
|
|
73
|
-
* in the kit that is the app itself (`defineApp`), which has nothing to say
|
|
74
|
-
* about org or agent ids.
|
|
75
|
-
*/
|
|
76
|
-
export function readProjectConfig(appRoot) {
|
|
77
|
-
const file = join(appRoot, "waniwani.json");
|
|
78
|
-
if (!existsSync(file)) return null;
|
|
79
|
-
try {
|
|
80
|
-
return JSON.parse(readFileSync(file, "utf-8"));
|
|
81
|
-
} catch (error) {
|
|
82
|
-
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Which instance to talk to, most specific wins. Mirrors the login CLI's order. */
|
|
87
|
-
function resolveApiUrl(project) {
|
|
88
|
-
return process.env.WANIWANI_API_URL || project?.apiUrl || readSettings().apiUrl || DEFAULT_API_URL;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function isExpired(settings) {
|
|
92
|
-
if (!settings.expiresAt) return true;
|
|
93
|
-
return new Date(settings.expiresAt).getTime() - EXPIRY_SKEW_MS < Date.now();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Trade the refresh token for a new access token and persist both.
|
|
98
|
-
*
|
|
99
|
-
* `resource` is RFC 8707 and load-bearing: without it the OAuth server issues an
|
|
100
|
-
* opaque token, which then fails JWKS validation on every API call.
|
|
101
|
-
*/
|
|
102
|
-
async function refreshTokens(apiUrl) {
|
|
103
|
-
const settings = readSettings();
|
|
104
|
-
if (!settings.refreshToken || !settings.clientId) return null;
|
|
105
|
-
|
|
106
|
-
const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
|
|
107
|
-
method: "POST",
|
|
108
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
109
|
-
body: new URLSearchParams({
|
|
110
|
-
grant_type: "refresh_token",
|
|
111
|
-
refresh_token: settings.refreshToken,
|
|
112
|
-
client_id: settings.clientId,
|
|
113
|
-
resource: apiUrl,
|
|
114
|
-
}).toString(),
|
|
115
|
-
});
|
|
116
|
-
if (!response.ok) return null;
|
|
117
|
-
|
|
118
|
-
const tokens = await response.json();
|
|
119
|
-
writeSettings({
|
|
120
|
-
...settings,
|
|
121
|
-
accessToken: tokens.access_token,
|
|
122
|
-
refreshToken: tokens.refresh_token,
|
|
123
|
-
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
124
|
-
});
|
|
125
|
-
return tokens.access_token;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The stored token when it is usable against `apiUrl`, else null.
|
|
130
|
-
*
|
|
131
|
-
* A token issued for another instance counts as no token at all: the US and EU
|
|
132
|
-
* deployments have separate identities, so carrying one over produces a 401 that
|
|
133
|
-
* reads like a broken account.
|
|
134
|
-
*/
|
|
135
|
-
async function usableToken(apiUrl) {
|
|
136
|
-
const settings = readSettings();
|
|
137
|
-
if (!settings.accessToken) return null;
|
|
138
|
-
if (settings.apiUrl && settings.apiUrl !== apiUrl) return null;
|
|
139
|
-
if (!isExpired(settings)) return settings.accessToken;
|
|
140
|
-
return refreshTokens(apiUrl);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** The login CLI's entry file when it is installed alongside us, else null. */
|
|
144
|
-
function localLoginCli() {
|
|
145
|
-
try {
|
|
146
|
-
return fileURLToPath(import.meta.resolve(LOGIN_CLI));
|
|
147
|
-
} catch {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Hand the terminal to the login CLI for one subcommand.
|
|
154
|
-
*
|
|
155
|
-
* An installed copy is run through `node` by absolute path. Both packages
|
|
156
|
-
* publish a `waniwani` binary, so going through `node_modules/.bin` would leave
|
|
157
|
-
* it to install order which of the two answers.
|
|
158
|
-
*/
|
|
159
|
-
async function runLoginCli(subcommand) {
|
|
160
|
-
const entry = localLoginCli();
|
|
161
|
-
const [command, args] = entry
|
|
162
|
-
? [process.execPath, [entry, subcommand]]
|
|
163
|
-
: ["npx", ["-y", `${LOGIN_CLI}@latest`, subcommand]];
|
|
164
|
-
|
|
165
|
-
console.log(dim(`[waniwani] running ${LOGIN_CLI} ${subcommand}…`));
|
|
166
|
-
const code = await new Promise((resolve) => {
|
|
167
|
-
const child = spawn(command, args, { stdio: "inherit" });
|
|
168
|
-
child.on("close", (exit) => resolve(exit ?? 1));
|
|
169
|
-
child.on("error", () => resolve(1));
|
|
170
|
-
});
|
|
171
|
-
if (code !== 0) {
|
|
172
|
-
throw new Error(`\`waniwani ${subcommand}\` exited with code ${code}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Resolve the account this repo runs against, filling in whatever is missing.
|
|
178
|
-
*
|
|
179
|
-
* A machine with no credentials gets the login flow, and a repo with no
|
|
180
|
-
* `waniwani.json` gets the connect flow, both in the terminal the user is
|
|
181
|
-
* already sitting in. When they come back the files are on disk and every later
|
|
182
|
-
* run of this command is silent.
|
|
183
|
-
*/
|
|
184
|
-
export async function connectAccount(appRoot) {
|
|
185
|
-
let project = readProjectConfig(appRoot);
|
|
186
|
-
let apiUrl = resolveApiUrl(project);
|
|
187
|
-
|
|
188
|
-
if (!(await usableToken(apiUrl))) {
|
|
189
|
-
await runLoginCli("login");
|
|
190
|
-
project = readProjectConfig(appRoot);
|
|
191
|
-
apiUrl = resolveApiUrl(project);
|
|
192
|
-
if (!(await usableToken(apiUrl))) {
|
|
193
|
-
throw new Error(`no credentials at ${SETTINGS_FILE} after logging in`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (!project?.projectId) {
|
|
198
|
-
await runLoginCli("connect");
|
|
199
|
-
project = readProjectConfig(appRoot);
|
|
200
|
-
}
|
|
201
|
-
if (!project?.projectId) {
|
|
202
|
-
throw new Error(`no projectId in ${join(appRoot, "waniwani.json")}: run \`waniwani connect\` to bind this repo to an agent`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
apiUrl,
|
|
207
|
-
projectId: project.projectId,
|
|
208
|
-
devPort: project.devPort,
|
|
209
|
-
playgroundUrl: `${apiUrl}/agents/${project.projectId}/playground?localMode=1`,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* An authenticated client for one instance.
|
|
215
|
-
*
|
|
216
|
-
* Responses come back in the API's `{ success, data, error }` envelope, so the
|
|
217
|
-
* payload is unwrapped here and a failure is raised as an ordinary Error the
|
|
218
|
-
* CLI's top-level handler prints. A 401 buys one refresh and one retry, which
|
|
219
|
-
* covers a token that aged out during a long dev session.
|
|
220
|
-
*/
|
|
221
|
-
export function createClient(apiUrl) {
|
|
222
|
-
const send = async (method, path, body, retry = true) => {
|
|
223
|
-
const token = await usableToken(apiUrl);
|
|
224
|
-
if (!token) {
|
|
225
|
-
throw new Error("not logged in: run `waniwani login`");
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const response = await fetch(`${apiUrl}${path}`, {
|
|
229
|
-
method,
|
|
230
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
231
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
if (response.status === 204) return undefined;
|
|
235
|
-
|
|
236
|
-
const text = await response.text();
|
|
237
|
-
let payload;
|
|
238
|
-
try {
|
|
239
|
-
payload = JSON.parse(text);
|
|
240
|
-
} catch {
|
|
241
|
-
throw new Error(`${method} ${path} failed with ${response.status}: ${text.slice(0, 200)}`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (response.ok && !payload.error) return payload.data;
|
|
245
|
-
|
|
246
|
-
if (response.status === 401 && retry && (await refreshTokens(apiUrl))) {
|
|
247
|
-
return send(method, path, body, false);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const error = payload.error;
|
|
251
|
-
const message =
|
|
252
|
-
(typeof error === "object" && error?.message) ||
|
|
253
|
-
payload.message ||
|
|
254
|
-
(typeof error === "string" ? error : null) ||
|
|
255
|
-
`${method} ${path} failed with ${response.status}`;
|
|
256
|
-
throw new Error(message);
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
post: (path, body) => send("POST", path, body),
|
|
261
|
-
patch: (path) => send("PATCH", path),
|
|
262
|
-
delete: (path) => send("DELETE", path),
|
|
263
|
-
};
|
|
264
|
-
}
|
package/cli/tunnel.mjs
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A public hostname for a dev server on this machine.
|
|
3
|
-
*
|
|
4
|
-
* The MCP endpoint has to be reachable from the internet before anything else
|
|
5
|
-
* can drive it: the WaniWani chat backend runs on Vercel and cannot see
|
|
6
|
-
* `localhost`, and neither can Claude Desktop or ChatGPT. Cloudflare answers
|
|
7
|
-
* that with a named tunnel per agent, provisioned server-side at agent creation,
|
|
8
|
-
* so the hostname is `<slug>.waniwani.dev` and stays that way across runs and is
|
|
9
|
-
* safe to paste into an MCP client's config.
|
|
10
|
-
*
|
|
11
|
-
* The API owns the pairing: it repoints the tunnel's ingress at the port passed
|
|
12
|
-
* to it, then mints a connector token for that one tunnel. This file runs
|
|
13
|
-
* cloudflared against the token and reports when the edge has the connection.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import { existsSync } from "node:fs";
|
|
18
|
-
import { createServer } from "node:net";
|
|
19
|
-
|
|
20
|
-
const TUNNEL_READY_TIMEOUT_MS = 30_000;
|
|
21
|
-
const SERVER_READY_TIMEOUT_MS = 30_000;
|
|
22
|
-
const SERVER_POLL_MS = 500;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Bind the wildcard, matching how a Node server binds, so a listener already on
|
|
26
|
-
* `::` or `0.0.0.0` reads as a conflict. A check against 127.0.0.1 misses those.
|
|
27
|
-
*/
|
|
28
|
-
export function isPortAvailable(port) {
|
|
29
|
-
return new Promise((resolve) => {
|
|
30
|
-
const server = createServer();
|
|
31
|
-
server.once("error", () => resolve(false));
|
|
32
|
-
server.once("listening", () => server.close(() => resolve(true)));
|
|
33
|
-
server.listen(port);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** The first free port at or above `start`. */
|
|
38
|
-
export async function findAvailablePort(start, attempts = 20) {
|
|
39
|
-
for (let port = start; port < start + attempts; port++) {
|
|
40
|
-
if (await isPortAvailable(port)) return port;
|
|
41
|
-
}
|
|
42
|
-
throw new Error(`no free port between ${start} and ${start + attempts - 1}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Wait until something answers on `url`.
|
|
47
|
-
*
|
|
48
|
-
* Any response counts, including a 404: the question is whether the dev server
|
|
49
|
-
* has the port, and the tunnel's ingress is pointed at it before it does.
|
|
50
|
-
*/
|
|
51
|
-
export async function waitForLocalServer(url, timeoutMs = SERVER_READY_TIMEOUT_MS) {
|
|
52
|
-
const deadline = Date.now() + timeoutMs;
|
|
53
|
-
while (Date.now() < deadline) {
|
|
54
|
-
try {
|
|
55
|
-
await fetch(url);
|
|
56
|
-
return;
|
|
57
|
-
} catch {
|
|
58
|
-
await new Promise((resolve) => setTimeout(resolve, SERVER_POLL_MS));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
throw new Error(`the dev server did not answer on ${url} within ${timeoutMs / 1000}s`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolve the cloudflared wrapper at call time.
|
|
66
|
-
*
|
|
67
|
-
* It is an optional dependency, and its install step fetches a platform binary
|
|
68
|
-
* that a CI image or a container build has no use for. A static import would
|
|
69
|
-
* make the whole CLI fail to load wherever that install was skipped, so the cost
|
|
70
|
-
* lands on the one command that needs it.
|
|
71
|
-
*/
|
|
72
|
-
async function loadCloudflared() {
|
|
73
|
-
try {
|
|
74
|
-
return await import("cloudflared");
|
|
75
|
-
} catch {
|
|
76
|
-
throw new Error(
|
|
77
|
-
"the tunnel needs the `cloudflared` package, which is not installed.\n" +
|
|
78
|
-
" It ships as an optional dependency, so an install run with --omit=optional skips it.\n" +
|
|
79
|
-
" Add it with `npm install cloudflared`.",
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** The package ships a wrapper, so the binary itself is fetched on first use. */
|
|
85
|
-
async function cloudflaredBinary() {
|
|
86
|
-
const { bin, install } = await loadCloudflared();
|
|
87
|
-
if (!existsSync(bin)) await install(bin);
|
|
88
|
-
return bin;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Run the agent's named tunnel under a connector token.
|
|
93
|
-
*
|
|
94
|
-
* The token encodes which tunnel to serve and the ingress was set when it was
|
|
95
|
-
* issued, so there is no `--url` to pass. Resolution waits for cloudflared to
|
|
96
|
-
* confirm an edge connection, since anything earlier races traffic against
|
|
97
|
-
* connector readiness.
|
|
98
|
-
*/
|
|
99
|
-
export async function startNamedTunnel({ hostname, token }) {
|
|
100
|
-
const bin = await cloudflaredBinary();
|
|
101
|
-
const child = spawn(bin, ["tunnel", "--no-autoupdate", "run", "--token", token], {
|
|
102
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
return new Promise((resolve, reject) => {
|
|
106
|
-
let settled = false;
|
|
107
|
-
const settle = (fn, value) => {
|
|
108
|
-
if (settled) return;
|
|
109
|
-
settled = true;
|
|
110
|
-
clearTimeout(timer);
|
|
111
|
-
fn(value);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const timer = setTimeout(() => {
|
|
115
|
-
child.kill("SIGTERM");
|
|
116
|
-
settle(reject, new Error(`cloudflared did not connect within ${TUNNEL_READY_TIMEOUT_MS / 1000}s`));
|
|
117
|
-
}, TUNNEL_READY_TIMEOUT_MS);
|
|
118
|
-
|
|
119
|
-
// cloudflared logs this line on each successful edge handshake, and the
|
|
120
|
-
// first one means the hostname is serving traffic.
|
|
121
|
-
const onOutput = (chunk) => {
|
|
122
|
-
if (chunk.toString().includes("Registered tunnel connection")) {
|
|
123
|
-
settle(resolve, {
|
|
124
|
-
hostname,
|
|
125
|
-
publicUrl: `https://${hostname}`,
|
|
126
|
-
stop: () => {
|
|
127
|
-
if (child.exitCode === null) child.kill("SIGTERM");
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
child.stdout?.on("data", onOutput);
|
|
134
|
-
child.stderr?.on("data", onOutput);
|
|
135
|
-
child.once("error", (error) => settle(reject, error));
|
|
136
|
-
child.once("exit", (code) => {
|
|
137
|
-
settle(reject, new Error(`cloudflared exited with code ${code ?? "unknown"} before connecting`));
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
}
|