@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.
@@ -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
+ }