@waniwani/kit 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/LICENSE +21 -0
- package/README.md +566 -0
- package/cli/account.mjs +264 -0
- package/cli/codegen.mjs +1069 -0
- package/cli/framework.mjs +244 -0
- package/cli/index.mjs +563 -0
- package/cli/log.mjs +177 -0
- package/cli/scan.mjs +84 -0
- package/cli/template.mjs +152 -0
- package/cli/tunnel.mjs +140 -0
- package/cli/validate.mjs +248 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +212 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +39 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +35 -0
- package/dist/web.js.map +1 -0
- package/package.json +88 -0
- package/src/index.ts +138 -0
- package/src/server.ts +285 -0
- package/src/web.tsx +68 -0
package/cli/account.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
}
|