@soloworks/smking-wizard 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 +31 -0
- package/README.md +90 -0
- package/dist/bin.mjs +1916 -0
- package/dist/bin.mjs.map +1 -0
- package/package.json +60 -0
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,1916 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createElement, useEffect } from "react";
|
|
3
|
+
import { Box, Text, render, useApp, useInput } from "ink";
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
import { create } from "zustand";
|
|
7
|
+
import { appendFileSync, existsSync, promises, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { Spinner } from "@inkjs/ui";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import open from "open";
|
|
14
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
15
|
+
import { join, relative, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
//#region src/ui/store.ts
|
|
18
|
+
const DEFAULT_FLAGS = {
|
|
19
|
+
allowProd: false,
|
|
20
|
+
allowDirty: false,
|
|
21
|
+
dryRun: false,
|
|
22
|
+
debug: false
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Cap progress history at 50 lines. Past that, oldest entries drop
|
|
26
|
+
* — keeps the in-memory log bounded for long agent loops and the
|
|
27
|
+
* TUI render fast (we don't want to re-render 500 progress lines).
|
|
28
|
+
*/
|
|
29
|
+
const MAX_PROGRESS_LINES = 50;
|
|
30
|
+
const useWizardStore = create((set) => ({
|
|
31
|
+
cliFlags: DEFAULT_FLAGS,
|
|
32
|
+
setCliFlags: (cliFlags) => set({ cliFlags }),
|
|
33
|
+
screen: "welcome",
|
|
34
|
+
setScreen: (screen) => set({ screen }),
|
|
35
|
+
oauthUrl: null,
|
|
36
|
+
setOauthUrl: (oauthUrl) => set({ oauthUrl }),
|
|
37
|
+
oauth: null,
|
|
38
|
+
setOauth: (oauth) => set({ oauth }),
|
|
39
|
+
agentStatus: "idle",
|
|
40
|
+
setAgentStatus: (agentStatus) => set({ agentStatus }),
|
|
41
|
+
agentProgress: [],
|
|
42
|
+
pushProgress: (text) => set((s) => {
|
|
43
|
+
const next = [...s.agentProgress, text];
|
|
44
|
+
if (next.length > MAX_PROGRESS_LINES) next.splice(0, next.length - MAX_PROGRESS_LINES);
|
|
45
|
+
return { agentProgress: next };
|
|
46
|
+
}),
|
|
47
|
+
agentSummary: null,
|
|
48
|
+
setAgentSummary: (agentSummary) => set({ agentSummary }),
|
|
49
|
+
fatal: null,
|
|
50
|
+
setFatal: (fatal) => set({
|
|
51
|
+
fatal,
|
|
52
|
+
screen: "error"
|
|
53
|
+
})
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region ../shared/src/constants/oauth.ts
|
|
58
|
+
const TOKEN_EXPIRY = {
|
|
59
|
+
ACCESS_TOKEN: 1440 * 60,
|
|
60
|
+
REFRESH_TOKEN: 2160 * 60 * 60,
|
|
61
|
+
AUTH_CODE: 600,
|
|
62
|
+
WIZARD_ACCESS_TOKEN: 1800
|
|
63
|
+
};
|
|
64
|
+
const WIZARD_SCOPES = ["wizard:install", "wizard:llm"];
|
|
65
|
+
/**
|
|
66
|
+
* Localhost callback ports the @soloworks/smking-wizard CLI tries in order. Six
|
|
67
|
+
* ports give us headroom when the user already has a dev server on one
|
|
68
|
+
* — wizard falls through on `EADDRINUSE`. Mirrors PostHog wizard's
|
|
69
|
+
* `OAUTH_PORTS` for the same reason.
|
|
70
|
+
*/
|
|
71
|
+
const WIZARD_OAUTH_PORTS = [
|
|
72
|
+
8239,
|
|
73
|
+
8238,
|
|
74
|
+
8240,
|
|
75
|
+
8237,
|
|
76
|
+
8236,
|
|
77
|
+
8235
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/constants.ts
|
|
82
|
+
/**
|
|
83
|
+
* Re-export the constants the SaaS already pins so the wizard CLI
|
|
84
|
+
* and the backend stay in sync — changing a port list in one place
|
|
85
|
+
* silently breaking the other would be a nasty bug, so we centralise.
|
|
86
|
+
*/
|
|
87
|
+
const OAUTH_PORTS = WIZARD_OAUTH_PORTS;
|
|
88
|
+
const SCOPES = WIZARD_SCOPES;
|
|
89
|
+
/**
|
|
90
|
+
* smking SaaS origin the wizard talks to. Defaults to the current
|
|
91
|
+
* Vercel production deployment; override via `SMKING_SAAS_URL` for
|
|
92
|
+
* local dev (e.g. `http://localhost:3001`) or staging. Trailing slash
|
|
93
|
+
* is stripped to make URL concatenation safe.
|
|
94
|
+
*
|
|
95
|
+
* Once a custom domain (e.g. `smking.com`) is wired up to the Vercel
|
|
96
|
+
* project, swap the default here in the same commit that updates DNS.
|
|
97
|
+
*/
|
|
98
|
+
const SAAS_URL = (process.env.SMKING_SAAS_URL ?? "https://smking-alone.vercel.app").replace(/\/$/, "");
|
|
99
|
+
/**
|
|
100
|
+
* Public OAuth client_id registered on the SaaS for the wizard. Must
|
|
101
|
+
* match `WIZARD_OAUTH_CLIENT_ID` env var on the SaaS side, which
|
|
102
|
+
* `validateClientCredentials` checks. Public client — no secret.
|
|
103
|
+
*/
|
|
104
|
+
const WIZARD_CLIENT_ID = process.env.SMKING_WIZARD_CLIENT_ID ?? "smking_wizard_v1";
|
|
105
|
+
/**
|
|
106
|
+
* How long the wizard waits for the OAuth browser flow to complete
|
|
107
|
+
* before giving up. 6 minutes covers slow logins, MFA prompts, and
|
|
108
|
+
* SSH users who have to manually copy the URL to a different browser.
|
|
109
|
+
*/
|
|
110
|
+
const OAUTH_TIMEOUT_MS = 360 * 1e3;
|
|
111
|
+
/**
|
|
112
|
+
* PKCE code verifier length (RFC 7636 §4.1 says 43-128 chars). 96
|
|
113
|
+
* is a comfortable middle that still fits a URL nicely if anyone
|
|
114
|
+
* needs to copy/paste it for debugging.
|
|
115
|
+
*/
|
|
116
|
+
const PKCE_VERIFIER_LENGTH = 96;
|
|
117
|
+
/**
|
|
118
|
+
* Read from package.json at build time. tsdown inlines it.
|
|
119
|
+
*/
|
|
120
|
+
const WIZARD_VERSION = "0.1.0";
|
|
121
|
+
/**
|
|
122
|
+
* Minimum Node version. ink 6 needs Node 18.0.0, but we set 20.10
|
|
123
|
+
* to align with the SaaS's `engines` and to get fetch / native test
|
|
124
|
+
* runner stability.
|
|
125
|
+
*/
|
|
126
|
+
const MIN_NODE_MAJOR = 20;
|
|
127
|
+
const MIN_NODE_MINOR = 10;
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/guards/production-check.ts
|
|
131
|
+
function checkProduction(flags = {}) {
|
|
132
|
+
if (flags.allowProd) return { ok: true };
|
|
133
|
+
const signals = [];
|
|
134
|
+
if (process.env.NODE_ENV === "production") signals.push("NODE_ENV=production");
|
|
135
|
+
if (process.env.VERCEL_ENV === "production") signals.push("VERCEL_ENV=production");
|
|
136
|
+
if (process.env.RAILWAY_ENVIRONMENT === "production") signals.push("RAILWAY_ENVIRONMENT=production");
|
|
137
|
+
if (process.env.FLY_APP_NAME) signals.push("FLY_APP_NAME set (running on fly.io)");
|
|
138
|
+
if (existsSync("/.dockerenv")) signals.push("/.dockerenv present (inside Docker container)");
|
|
139
|
+
if (existsSync("/var/www") && signals.length > 0) signals.push("/var/www present");
|
|
140
|
+
if (signals.length === 0) return { ok: true };
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: `Refusing to run on a production-like environment. Signals detected:\n - ${signals.join("\n - ")}\n\nRun the wizard on your local dev machine, then deploy normally. If you genuinely need to run here, pass --allow-prod.`,
|
|
144
|
+
override: "--allow-prod"
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/guards/git-status-check.ts
|
|
150
|
+
/**
|
|
151
|
+
* Require a clean git working tree before wizard runs. Two reasons:
|
|
152
|
+
*
|
|
153
|
+
* 1. **Predictable diff.** After wizard finishes, `git diff` shows
|
|
154
|
+
* exactly what wizard did and nothing else — the customer can
|
|
155
|
+
* review one cohesive change. Mixed in with prior uncommitted
|
|
156
|
+
* work, the wizard's edits are hard to isolate.
|
|
157
|
+
*
|
|
158
|
+
* 2. **Easy rollback.** If wizard does something wrong, `git
|
|
159
|
+
* restore .` cleanly reverts to the pre-wizard state. With
|
|
160
|
+
* uncommitted work mixed in, that command also wipes the
|
|
161
|
+
* customer's own work.
|
|
162
|
+
*
|
|
163
|
+
* Override via `--allow-dirty` for users who know what they're
|
|
164
|
+
* doing. Repos without git (rare for a real project) pass the
|
|
165
|
+
* guard since there's nothing to dirty.
|
|
166
|
+
*/
|
|
167
|
+
function checkGitStatus(flags = {}, cwd = process.cwd()) {
|
|
168
|
+
if (flags.allowDirty) return { ok: true };
|
|
169
|
+
const result = spawnSync("git", ["status", "--porcelain"], {
|
|
170
|
+
cwd,
|
|
171
|
+
encoding: "utf-8"
|
|
172
|
+
});
|
|
173
|
+
if (result.error || result.status !== 0) return { ok: true };
|
|
174
|
+
const dirty = result.stdout.trim();
|
|
175
|
+
if (dirty === "") return { ok: true };
|
|
176
|
+
const lines = dirty.split("\n");
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: `Working tree has uncommitted changes. Wizard wants a clean slate so its diff is unambiguous.\n\nUncommitted files:\n${lines.slice(0, 10).join("\n")}${lines.length > 10 ? `\n ... and ${lines.length - 10} more files` : ""}\n\nCommit, stash, or discard them first. If you really need to run on a dirty tree, pass --allow-dirty.`,
|
|
180
|
+
override: "--allow-dirty"
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/ui/screens/welcome-screen.tsx
|
|
186
|
+
/**
|
|
187
|
+
* First thing the user sees. Trust signals up front — what the
|
|
188
|
+
* wizard will do and what it will NEVER do. Press enter to continue.
|
|
189
|
+
*
|
|
190
|
+
* On Enter the screen also runs sync guards (production-env refuse
|
|
191
|
+
* + git-status-must-be-clean) before advancing. Either guard failing
|
|
192
|
+
* routes to the error screen with an actionable override hint.
|
|
193
|
+
*/
|
|
194
|
+
function WelcomeScreen() {
|
|
195
|
+
const setScreen = useWizardStore((s) => s.setScreen);
|
|
196
|
+
const setFatal = useWizardStore((s) => s.setFatal);
|
|
197
|
+
const cliFlags = useWizardStore((s) => s.cliFlags);
|
|
198
|
+
useInput((_, key) => {
|
|
199
|
+
if (!key.return) return;
|
|
200
|
+
const prodCheck = checkProduction({ allowProd: cliFlags.allowProd });
|
|
201
|
+
if (!prodCheck.ok) {
|
|
202
|
+
setFatal(prodCheck.reason);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const gitCheck = checkGitStatus({ allowDirty: cliFlags.allowDirty });
|
|
206
|
+
if (!gitCheck.ok) {
|
|
207
|
+
setFatal(gitCheck.reason);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
setScreen("oauth");
|
|
211
|
+
});
|
|
212
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
213
|
+
flexDirection: "column",
|
|
214
|
+
gap: 1,
|
|
215
|
+
children: [
|
|
216
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
217
|
+
bold: true,
|
|
218
|
+
color: "cyan",
|
|
219
|
+
children: "🪄 smking install wizard"
|
|
220
|
+
}), /* @__PURE__ */ jsxs(Text, {
|
|
221
|
+
color: "gray",
|
|
222
|
+
children: [" v", WIZARD_VERSION]
|
|
223
|
+
})] }),
|
|
224
|
+
/* @__PURE__ */ jsx(Text, { children: "This will install the smking SDK for your project (Laravel or Next.js), configure your environment, and verify the install with a doctor check." }),
|
|
225
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
226
|
+
flexDirection: "column",
|
|
227
|
+
children: [
|
|
228
|
+
/* @__PURE__ */ jsx(Text, {
|
|
229
|
+
bold: true,
|
|
230
|
+
children: "This wizard will:"
|
|
231
|
+
}),
|
|
232
|
+
/* @__PURE__ */ jsx(Text, {
|
|
233
|
+
color: "green",
|
|
234
|
+
children: " ✓ Open your browser to log in to smking"
|
|
235
|
+
}),
|
|
236
|
+
/* @__PURE__ */ jsx(Text, {
|
|
237
|
+
color: "green",
|
|
238
|
+
children: " ✓ Detect your framework (Laravel / Next.js)"
|
|
239
|
+
}),
|
|
240
|
+
/* @__PURE__ */ jsx(Text, {
|
|
241
|
+
color: "green",
|
|
242
|
+
children: " ✓ Install the smking SDK package"
|
|
243
|
+
}),
|
|
244
|
+
/* @__PURE__ */ jsx(Text, {
|
|
245
|
+
color: "green",
|
|
246
|
+
children: " ✓ Write SMKING_API_KEY + SMKING_BASE_URL to your .env"
|
|
247
|
+
}),
|
|
248
|
+
/* @__PURE__ */ jsx(Text, {
|
|
249
|
+
color: "green",
|
|
250
|
+
children: " ✓ Run doctor to verify the install"
|
|
251
|
+
})
|
|
252
|
+
]
|
|
253
|
+
}),
|
|
254
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
255
|
+
flexDirection: "column",
|
|
256
|
+
children: [
|
|
257
|
+
/* @__PURE__ */ jsx(Text, {
|
|
258
|
+
bold: true,
|
|
259
|
+
children: "This wizard will NEVER:"
|
|
260
|
+
}),
|
|
261
|
+
/* @__PURE__ */ jsx(Text, {
|
|
262
|
+
color: "red",
|
|
263
|
+
children: " ✗ Commit or push to git (you review the diff yourself)"
|
|
264
|
+
}),
|
|
265
|
+
/* @__PURE__ */ jsx(Text, {
|
|
266
|
+
color: "red",
|
|
267
|
+
children: " ✗ Read or upload your .env values to any server"
|
|
268
|
+
}),
|
|
269
|
+
/* @__PURE__ */ jsx(Text, {
|
|
270
|
+
color: "red",
|
|
271
|
+
children: " ✗ Run destructive commands (rm, drop, migrate:fresh, etc.)"
|
|
272
|
+
}),
|
|
273
|
+
/* @__PURE__ */ jsx(Text, {
|
|
274
|
+
color: "red",
|
|
275
|
+
children: " ✗ Modify env variables other than SMKING_*"
|
|
276
|
+
}),
|
|
277
|
+
/* @__PURE__ */ jsx(Text, {
|
|
278
|
+
color: "red",
|
|
279
|
+
children: " ✗ Install packages other than the smking SDK"
|
|
280
|
+
})
|
|
281
|
+
]
|
|
282
|
+
}),
|
|
283
|
+
/* @__PURE__ */ jsx(Box, {
|
|
284
|
+
marginTop: 1,
|
|
285
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
286
|
+
color: "cyan",
|
|
287
|
+
children: "Press Enter to continue · Ctrl-C to abort"
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
]
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/oauth.ts
|
|
296
|
+
/**
|
|
297
|
+
* Generate a PKCE code verifier per RFC 7636 §4.1. Cryptographically
|
|
298
|
+
* random base64url string in [43, 128] chars.
|
|
299
|
+
*/
|
|
300
|
+
function generateCodeVerifier() {
|
|
301
|
+
return base64UrlEncode(randomBytes(Math.ceil(PKCE_VERIFIER_LENGTH * 6 / 8))).slice(0, PKCE_VERIFIER_LENGTH);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* S256 challenge: BASE64URL(SHA256(verifier)).
|
|
305
|
+
*/
|
|
306
|
+
function generateCodeChallenge(verifier) {
|
|
307
|
+
return base64UrlEncode(createHash("sha256").update(verifier).digest());
|
|
308
|
+
}
|
|
309
|
+
function base64UrlEncode(buf) {
|
|
310
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
311
|
+
}
|
|
312
|
+
function generateState() {
|
|
313
|
+
return randomBytes(16).toString("hex");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Try each port in OAUTH_PORTS until one binds, then run `handle` on
|
|
317
|
+
* the first matching `/callback` request. Rejects on timeout or if
|
|
318
|
+
* all ports are occupied.
|
|
319
|
+
*
|
|
320
|
+
* Returns the port number it bound to (so the redirect_uri can be
|
|
321
|
+
* built to match) and a promise that resolves with the OAuth code +
|
|
322
|
+
* state when the callback fires.
|
|
323
|
+
*/
|
|
324
|
+
async function startCallbackServer(expectedState, signal) {
|
|
325
|
+
let resolver;
|
|
326
|
+
let rejecter;
|
|
327
|
+
const result = new Promise((res, rej) => {
|
|
328
|
+
resolver = res;
|
|
329
|
+
rejecter = rej;
|
|
330
|
+
});
|
|
331
|
+
const handler = (req, res) => {
|
|
332
|
+
if (!req.url) {
|
|
333
|
+
res.writeHead(404);
|
|
334
|
+
res.end();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const url = new URL(req.url, "http://localhost");
|
|
338
|
+
if (url.pathname !== "/callback") {
|
|
339
|
+
res.writeHead(404);
|
|
340
|
+
res.end();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const code = url.searchParams.get("code");
|
|
344
|
+
const state = url.searchParams.get("state");
|
|
345
|
+
const error = url.searchParams.get("error");
|
|
346
|
+
if (error) {
|
|
347
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
348
|
+
res.end(renderErrorPage(error));
|
|
349
|
+
rejecter(/* @__PURE__ */ new Error(`OAuth error: ${error}`));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!code || !state) {
|
|
353
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
354
|
+
res.end(renderErrorPage("missing code or state"));
|
|
355
|
+
rejecter(/* @__PURE__ */ new Error("OAuth callback missing code or state"));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (state !== expectedState) {
|
|
359
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
360
|
+
res.end(renderErrorPage("state mismatch"));
|
|
361
|
+
rejecter(/* @__PURE__ */ new Error("OAuth state mismatch — possible CSRF"));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
365
|
+
res.end(renderSuccessPage());
|
|
366
|
+
resolver({ code });
|
|
367
|
+
};
|
|
368
|
+
for (const port of OAUTH_PORTS) {
|
|
369
|
+
const server = createServer(handler);
|
|
370
|
+
try {
|
|
371
|
+
await new Promise((res, rej) => {
|
|
372
|
+
server.once("error", rej);
|
|
373
|
+
server.listen(port, "127.0.0.1", () => res());
|
|
374
|
+
});
|
|
375
|
+
signal.addEventListener("abort", () => {
|
|
376
|
+
server.close();
|
|
377
|
+
rejecter(/* @__PURE__ */ new Error("OAuth aborted"));
|
|
378
|
+
});
|
|
379
|
+
result.finally(() => server.close()).catch(() => {});
|
|
380
|
+
return {
|
|
381
|
+
port,
|
|
382
|
+
result
|
|
383
|
+
};
|
|
384
|
+
} catch (err) {
|
|
385
|
+
server.close();
|
|
386
|
+
if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") continue;
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
throw new Error(`All OAuth ports occupied: ${OAUTH_PORTS.join(", ")}. Close other dev servers and retry.`);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Build the SaaS authorize URL. The SaaS authorize page expects:
|
|
394
|
+
* client_id, redirect_uri, state, site_url, scope, response_type,
|
|
395
|
+
* code_challenge, code_challenge_method
|
|
396
|
+
*
|
|
397
|
+
* `site_url` is the customer's project URL — used by the SaaS to
|
|
398
|
+
* find-or-create the site row and tag the OAuth token to it. For
|
|
399
|
+
* the wizard, we send the current working directory's git remote
|
|
400
|
+
* (or a placeholder when none exists; SaaS uses it for display only).
|
|
401
|
+
*/
|
|
402
|
+
function buildAuthorizeUrl(opts) {
|
|
403
|
+
return `${SAAS_URL}/oauth/authorize?${new URLSearchParams({
|
|
404
|
+
client_id: WIZARD_CLIENT_ID,
|
|
405
|
+
redirect_uri: `http://localhost:${opts.port}/callback`,
|
|
406
|
+
state: opts.state,
|
|
407
|
+
site_url: opts.siteUrl,
|
|
408
|
+
scope: SCOPES.join(" "),
|
|
409
|
+
response_type: "code",
|
|
410
|
+
code_challenge: opts.codeChallenge,
|
|
411
|
+
code_challenge_method: "S256"
|
|
412
|
+
}).toString()}`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Exchange the authorization code for an access token. Public client
|
|
416
|
+
* — no client_secret, code_verifier proves possession instead.
|
|
417
|
+
*/
|
|
418
|
+
async function exchangeCodeForToken(opts) {
|
|
419
|
+
const response = await fetch(`${SAAS_URL}/api/oauth/token`, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
headers: { "content-type": "application/json" },
|
|
422
|
+
body: JSON.stringify({
|
|
423
|
+
grant_type: "authorization_code",
|
|
424
|
+
code: opts.code,
|
|
425
|
+
redirect_uri: `http://localhost:${opts.port}/callback`,
|
|
426
|
+
client_id: WIZARD_CLIENT_ID,
|
|
427
|
+
code_verifier: opts.codeVerifier,
|
|
428
|
+
site_url: opts.siteUrl
|
|
429
|
+
})
|
|
430
|
+
});
|
|
431
|
+
if (!response.ok) {
|
|
432
|
+
const text = await response.text().catch(() => "");
|
|
433
|
+
throw new Error(`Token exchange failed: HTTP ${response.status} — ${text.slice(0, 200)}`);
|
|
434
|
+
}
|
|
435
|
+
const data = await response.json();
|
|
436
|
+
if (!data.access_token || !data.refresh_token) throw new Error("Token response missing access_token or refresh_token");
|
|
437
|
+
return {
|
|
438
|
+
accessToken: data.access_token,
|
|
439
|
+
refreshToken: data.refresh_token,
|
|
440
|
+
expiresIn: data.expires_in ?? 1800,
|
|
441
|
+
siteId: data.site_id ?? null
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Top-level OAuth orchestrator. Caller passes a `siteUrl` (typically
|
|
446
|
+
* `git remote get-url origin` output or a placeholder) and an
|
|
447
|
+
* `onUrlReady` callback so the TUI can display the URL for SSH
|
|
448
|
+
* fallback at the same time the browser is opened.
|
|
449
|
+
*
|
|
450
|
+
* Returns the token bundle on success; throws on timeout, state
|
|
451
|
+
* mismatch, or token exchange failure.
|
|
452
|
+
*/
|
|
453
|
+
async function performOAuthFlow(opts) {
|
|
454
|
+
const verifier = generateCodeVerifier();
|
|
455
|
+
const challenge = generateCodeChallenge(verifier);
|
|
456
|
+
const state = generateState();
|
|
457
|
+
const controller = new AbortController();
|
|
458
|
+
const timeoutHandle = setTimeout(() => controller.abort(), OAUTH_TIMEOUT_MS);
|
|
459
|
+
try {
|
|
460
|
+
const { port, result } = await startCallbackServer(state, controller.signal);
|
|
461
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
462
|
+
port,
|
|
463
|
+
state,
|
|
464
|
+
codeChallenge: challenge,
|
|
465
|
+
siteUrl: opts.siteUrl
|
|
466
|
+
});
|
|
467
|
+
opts.onUrlReady(authorizeUrl);
|
|
468
|
+
open(authorizeUrl).catch(() => {});
|
|
469
|
+
const { code } = await result;
|
|
470
|
+
return await exchangeCodeForToken({
|
|
471
|
+
code,
|
|
472
|
+
codeVerifier: verifier,
|
|
473
|
+
port,
|
|
474
|
+
siteUrl: opts.siteUrl
|
|
475
|
+
});
|
|
476
|
+
} finally {
|
|
477
|
+
clearTimeout(timeoutHandle);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function renderSuccessPage() {
|
|
481
|
+
return `<!doctype html>
|
|
482
|
+
<html lang="en">
|
|
483
|
+
<head>
|
|
484
|
+
<meta charset="utf-8" />
|
|
485
|
+
<title>smking — connected</title>
|
|
486
|
+
<style>
|
|
487
|
+
body { font: 14px/1.5 -apple-system, "Helvetica Neue", sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; color: #222; }
|
|
488
|
+
h1 { font-size: 24px; }
|
|
489
|
+
.hint { color: #666; }
|
|
490
|
+
</style>
|
|
491
|
+
</head>
|
|
492
|
+
<body>
|
|
493
|
+
<h1>✅ Connected to smking</h1>
|
|
494
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
495
|
+
<p class="hint">smking install wizard is now finishing setup.</p>
|
|
496
|
+
<script>setTimeout(() => window.close(), 1500);<\/script>
|
|
497
|
+
</body>
|
|
498
|
+
</html>`;
|
|
499
|
+
}
|
|
500
|
+
function renderErrorPage(error) {
|
|
501
|
+
return `<!doctype html>
|
|
502
|
+
<html lang="en">
|
|
503
|
+
<head>
|
|
504
|
+
<meta charset="utf-8" />
|
|
505
|
+
<title>smking — error</title>
|
|
506
|
+
<style>
|
|
507
|
+
body { font: 14px/1.5 -apple-system, "Helvetica Neue", sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; color: #222; }
|
|
508
|
+
h1 { font-size: 24px; color: #c00; }
|
|
509
|
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
|
510
|
+
</style>
|
|
511
|
+
</head>
|
|
512
|
+
<body>
|
|
513
|
+
<h1>❌ smking OAuth failed</h1>
|
|
514
|
+
<p>Error: <code>${escapeHtml(error)}</code></p>
|
|
515
|
+
<p>Return to your terminal and re-run <code>npx @soloworks/smking-wizard</code>.</p>
|
|
516
|
+
</body>
|
|
517
|
+
</html>`;
|
|
518
|
+
}
|
|
519
|
+
function escapeHtml(input) {
|
|
520
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/ui/screens/oauth-screen.tsx
|
|
525
|
+
/**
|
|
526
|
+
* OAuth screen — kicks off the browser flow on mount and waits for
|
|
527
|
+
* the localhost callback. Displays the authorize URL in the TUI as
|
|
528
|
+
* a fallback for SSH users who don't have a graphical browser
|
|
529
|
+
* (mirroring PostHog's pattern).
|
|
530
|
+
*
|
|
531
|
+
* Lifecycle:
|
|
532
|
+
* 1. mount → performOAuthFlow() starts
|
|
533
|
+
* 2. callback server emits URL via onUrlReady → store.setOauthUrl
|
|
534
|
+
* 3. user logs in + redirects to localhost → callback fires
|
|
535
|
+
* 4. OAuth tokens stored → setScreen("done")
|
|
536
|
+
* 5. any error → setFatal() → screen routes to "error"
|
|
537
|
+
*/
|
|
538
|
+
function OAuthScreen() {
|
|
539
|
+
const oauthUrl = useWizardStore((s) => s.oauthUrl);
|
|
540
|
+
const setOauthUrl = useWizardStore((s) => s.setOauthUrl);
|
|
541
|
+
const setOauth = useWizardStore((s) => s.setOauth);
|
|
542
|
+
const setScreen = useWizardStore((s) => s.setScreen);
|
|
543
|
+
const setFatal = useWizardStore((s) => s.setFatal);
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
let cancelled = false;
|
|
546
|
+
performOAuthFlow({
|
|
547
|
+
siteUrl: process.cwd(),
|
|
548
|
+
onUrlReady: (url) => {
|
|
549
|
+
if (!cancelled) setOauthUrl(url);
|
|
550
|
+
}
|
|
551
|
+
}).then((tokens) => {
|
|
552
|
+
if (cancelled) return;
|
|
553
|
+
setOauth(tokens);
|
|
554
|
+
setScreen("run");
|
|
555
|
+
}).catch((err) => {
|
|
556
|
+
if (cancelled) return;
|
|
557
|
+
setFatal(`OAuth failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
558
|
+
});
|
|
559
|
+
return () => {
|
|
560
|
+
cancelled = true;
|
|
561
|
+
};
|
|
562
|
+
}, []);
|
|
563
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
564
|
+
flexDirection: "column",
|
|
565
|
+
gap: 1,
|
|
566
|
+
children: [
|
|
567
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Spinner, { label: "Waiting for browser login…" }) }),
|
|
568
|
+
oauthUrl ? /* @__PURE__ */ jsxs(Box, {
|
|
569
|
+
flexDirection: "column",
|
|
570
|
+
marginTop: 1,
|
|
571
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
572
|
+
color: "gray",
|
|
573
|
+
children: "If your browser didn't open, paste this URL into a browser:"
|
|
574
|
+
}), /* @__PURE__ */ jsx(Box, {
|
|
575
|
+
marginTop: 1,
|
|
576
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
577
|
+
color: "cyan",
|
|
578
|
+
children: oauthUrl
|
|
579
|
+
})
|
|
580
|
+
})]
|
|
581
|
+
}) : /* @__PURE__ */ jsx(Text, {
|
|
582
|
+
color: "gray",
|
|
583
|
+
children: "Generating PKCE challenge…"
|
|
584
|
+
}),
|
|
585
|
+
/* @__PURE__ */ jsx(Box, {
|
|
586
|
+
marginTop: 1,
|
|
587
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
588
|
+
color: "gray",
|
|
589
|
+
children: "Timeout: 6 minutes · Ctrl-C to abort"
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
]
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/agent/commandments.ts
|
|
598
|
+
/**
|
|
599
|
+
* System-prompt rules appended to whatever Claude's default coding
|
|
600
|
+
* agent persona provides. Kept deliberately short — 11 numbered
|
|
601
|
+
* rules, no preamble, no explanation. The install prompt itself
|
|
602
|
+
* carries the framework-specific instructions; commandments cover
|
|
603
|
+
* what's true regardless of framework.
|
|
604
|
+
*
|
|
605
|
+
* Each rule maps to one of the wizard's safety guards or design
|
|
606
|
+
* decisions documented in the implementation plan. Don't add prose
|
|
607
|
+
* paragraphs here — every word costs every wizard run a token.
|
|
608
|
+
*/
|
|
609
|
+
const COMMANDMENTS = `# Wizard agent commandments
|
|
610
|
+
|
|
611
|
+
You are the install agent inside @soloworks/smking-wizard. The user's project is open in the cwd. Follow these rules without exception:
|
|
612
|
+
|
|
613
|
+
1. Use only the dedicated tools listed below. You have NO Bash, NO general Read/Write/Edit. The available tools are: \`detect_framework\`, \`detect_package_manager\`, \`install_package\`, \`set_env\`, \`run_doctor\`, \`read_project_file\`, \`run_artisan\` (Laravel only), and \`report_failure\`. If a step seems to need a different tool, that step is out of scope.
|
|
614
|
+
|
|
615
|
+
2. Start by calling \`detect_framework\`. If it returns \`unknown\`, call \`report_failure\` with the evidence string and stop — do not guess.
|
|
616
|
+
|
|
617
|
+
3. After detecting framework, call \`install_package\` once with the detected framework.
|
|
618
|
+
|
|
619
|
+
4. After \`install_package\` succeeds, call \`set_env\` with SMKING_API_KEY and SMKING_BASE_URL extracted from the install prompt the user gave you. Both must match the values in the prompt verbatim.
|
|
620
|
+
|
|
621
|
+
5. After \`set_env\`, call \`run_doctor\`. Parse the JSON result.
|
|
622
|
+
|
|
623
|
+
6. If \`run_doctor\` returns \`ok: true\`, you are done. Output a one-line confirmation and stop.
|
|
624
|
+
|
|
625
|
+
7. If \`run_doctor\` returns \`ok: false\`, identify which check failed and try to fix it BEFORE giving up. Diagnose first, retry second:
|
|
626
|
+
- For Laravel cache/config-stale symptoms (API reachable: fail but env is set correctly; doctor flips red right after \`set_env\`): try \`run_artisan(command="config:clear")\` then \`run_artisan(command="cache:clear")\`, then re-run \`run_doctor\`. This solves a common stale-config scenario.
|
|
627
|
+
- For "X-Smking-Status: server_error" / circuit-breaker symptoms: try \`run_artisan(command="smking:cache:purge")\`, then re-run \`run_doctor\`.
|
|
628
|
+
- For unknown failures, use \`read_project_file\` to inspect \`composer.json\` (version pin), \`app/Http/Kernel.php\` (middleware register state), or \`.env\` (actual values). The content often reveals the root cause and tells you whether a retry has any chance.
|
|
629
|
+
- Only retry \`install_package\` or \`set_env\` when the inspected state confirms those are the layer to fix.
|
|
630
|
+
- Maximum THREE retries for the same failing check across ALL fix attempts combined (not three retries per fix type).
|
|
631
|
+
|
|
632
|
+
8. After three failed retries on the same check — or when \`read_project_file\` reveals an unfixable condition (Laravel version too old, custom Kernel, missing PHP extension) — call \`report_failure\` with the failed check list, environment details, the raw doctor output, AND any relevant content from \`read_project_file\` that explains the root cause. Then stop.
|
|
633
|
+
|
|
634
|
+
9. NEVER attempt to use git, edit files outside the wizard tools, run shell commands, or install packages other than smking SDKs. The wizard tools are the complete surface.
|
|
635
|
+
|
|
636
|
+
10. NEVER include API keys, secrets, or token values in your text response. If you need to mention them, refer by name (e.g. "SMKING_API_KEY") not by value.
|
|
637
|
+
|
|
638
|
+
11. Be terse. The user sees a TUI with a spinner — every paragraph you emit is a paragraph they have to wait through. One sentence per major action is enough.`;
|
|
639
|
+
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/agent/prompt.ts
|
|
642
|
+
/**
|
|
643
|
+
* Fetch the install prompt for the detected framework from the
|
|
644
|
+
* smking SaaS. The prompt content lives in
|
|
645
|
+
* `apps/web/src/features/aeo/lib/install-prompts/{laravel,nextjs}.ts`
|
|
646
|
+
* and has the customer's `publicApiKey` and `baseUrl` already
|
|
647
|
+
* substituted server-side — wizard never receives a raw key
|
|
648
|
+
* separately from the prompt itself.
|
|
649
|
+
*
|
|
650
|
+
* The agent reads this markdown as its initial user message, then
|
|
651
|
+
* walks through the steps using the wizard's dedicated tools.
|
|
652
|
+
*/
|
|
653
|
+
async function fetchInstallPrompt(opts) {
|
|
654
|
+
if (opts.framework !== "laravel" && opts.framework !== "nextjs") throw new Error(`fetchInstallPrompt called with unsupported framework: ${opts.framework}`);
|
|
655
|
+
const url = new URL(`${SAAS_URL}/api/v1/wizard/install-prompt`);
|
|
656
|
+
url.searchParams.set("framework", opts.framework);
|
|
657
|
+
const response = await fetch(url, { headers: {
|
|
658
|
+
authorization: `Bearer ${opts.oauthToken}`,
|
|
659
|
+
accept: "text/markdown"
|
|
660
|
+
} });
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
const text = await response.text().catch(() => "");
|
|
663
|
+
throw new Error(`Failed to fetch install prompt: HTTP ${response.status} — ${text.slice(0, 200)}`);
|
|
664
|
+
}
|
|
665
|
+
return response.text();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/detection/framework.ts
|
|
670
|
+
function detectFramework(cwd = process.cwd()) {
|
|
671
|
+
const artisanPath = join(cwd, "artisan");
|
|
672
|
+
if (existsSync(artisanPath)) return {
|
|
673
|
+
framework: "laravel",
|
|
674
|
+
evidence: `artisan present at ${artisanPath}`
|
|
675
|
+
};
|
|
676
|
+
const pkgPath = join(cwd, "package.json");
|
|
677
|
+
if (existsSync(pkgPath)) {
|
|
678
|
+
let pkg;
|
|
679
|
+
try {
|
|
680
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
681
|
+
} catch {
|
|
682
|
+
return {
|
|
683
|
+
framework: "unknown",
|
|
684
|
+
evidence: "package.json present but unparseable"
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (!!pkg.dependencies?.["next"] || !!pkg.devDependencies?.["next"]) {
|
|
688
|
+
const appDir = ["app", "src/app"].find((d) => existsSync(join(cwd, d)));
|
|
689
|
+
if (!appDir) return {
|
|
690
|
+
framework: "unknown",
|
|
691
|
+
evidence: "Next.js detected but no app/ or src/app/ — wizard only supports App Router"
|
|
692
|
+
};
|
|
693
|
+
return {
|
|
694
|
+
framework: "nextjs",
|
|
695
|
+
evidence: `next in package.json + ${appDir}/ directory`,
|
|
696
|
+
appDir
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
framework: "unknown",
|
|
702
|
+
evidence: "no `artisan` (Laravel) and no `package.json` with `next` (Next.js) found in cwd"
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region src/detection/package-manager.ts
|
|
708
|
+
const LOCKFILES = [
|
|
709
|
+
{
|
|
710
|
+
name: "pnpm-lock.yaml",
|
|
711
|
+
pm: "pnpm"
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: "bun.lockb",
|
|
715
|
+
pm: "bun"
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: "bun.lock",
|
|
719
|
+
pm: "bun"
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "yarn.lock",
|
|
723
|
+
pm: "yarn"
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: "package-lock.json",
|
|
727
|
+
pm: "npm"
|
|
728
|
+
}
|
|
729
|
+
];
|
|
730
|
+
function detectNodePackageManager(cwd = process.cwd()) {
|
|
731
|
+
for (const { name, pm } of LOCKFILES) if (existsSync(join(cwd, name))) return {
|
|
732
|
+
packageManager: pm,
|
|
733
|
+
lockfile: name
|
|
734
|
+
};
|
|
735
|
+
return {
|
|
736
|
+
packageManager: "npm",
|
|
737
|
+
lockfile: null
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
//#endregion
|
|
742
|
+
//#region src/lib/exec.ts
|
|
743
|
+
function run(cmd, args, opts = {}) {
|
|
744
|
+
return new Promise((resolve$1, reject) => {
|
|
745
|
+
const proc = spawn(cmd, args, {
|
|
746
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
747
|
+
env: {
|
|
748
|
+
...process.env,
|
|
749
|
+
...opts.env
|
|
750
|
+
},
|
|
751
|
+
stdio: [
|
|
752
|
+
"ignore",
|
|
753
|
+
"pipe",
|
|
754
|
+
"pipe"
|
|
755
|
+
]
|
|
756
|
+
});
|
|
757
|
+
let stdout = "";
|
|
758
|
+
let stderr = "";
|
|
759
|
+
proc.stdout?.on("data", (chunk) => {
|
|
760
|
+
stdout += chunk.toString();
|
|
761
|
+
});
|
|
762
|
+
proc.stderr?.on("data", (chunk) => {
|
|
763
|
+
stderr += chunk.toString();
|
|
764
|
+
});
|
|
765
|
+
let timer;
|
|
766
|
+
if (opts.timeoutMs) timer = setTimeout(() => {
|
|
767
|
+
proc.kill("SIGTERM");
|
|
768
|
+
}, opts.timeoutMs);
|
|
769
|
+
proc.on("error", (err) => {
|
|
770
|
+
if (timer) clearTimeout(timer);
|
|
771
|
+
reject(err);
|
|
772
|
+
});
|
|
773
|
+
proc.on("close", (code) => {
|
|
774
|
+
if (timer) clearTimeout(timer);
|
|
775
|
+
resolve$1({
|
|
776
|
+
stdout,
|
|
777
|
+
stderr,
|
|
778
|
+
code: code ?? 1
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/lib/env-file.ts
|
|
786
|
+
function setEnvKeys(envPath, updates) {
|
|
787
|
+
const lines = (existsSync(envPath) ? readFileSync(envPath, "utf-8") : "").split("\n");
|
|
788
|
+
const changed = [];
|
|
789
|
+
const unchanged = [];
|
|
790
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
791
|
+
const idx = lines.findIndex((line) => {
|
|
792
|
+
const trimmed = line.trim();
|
|
793
|
+
if (!trimmed || trimmed.startsWith("#")) return false;
|
|
794
|
+
return trimmed.startsWith(`${key}=`);
|
|
795
|
+
});
|
|
796
|
+
if (idx >= 0) {
|
|
797
|
+
const currentLine = lines[idx];
|
|
798
|
+
if (currentLine.slice(currentLine.indexOf("=") + 1).replace(/^["']|["']$/g, "") === value) {
|
|
799
|
+
unchanged.push(key);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
lines[idx] = `${key}=${value}`;
|
|
803
|
+
changed.push(key);
|
|
804
|
+
} else {
|
|
805
|
+
if (!lines.some((l) => l.includes("Added by @soloworks/smking-wizard"))) {
|
|
806
|
+
if (lines.at(-1)?.trim() !== "") lines.push("");
|
|
807
|
+
lines.push("# Added by @soloworks/smking-wizard");
|
|
808
|
+
}
|
|
809
|
+
lines.push(`${key}=${value}`);
|
|
810
|
+
changed.push(key);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (changed.length > 0) writeFileSync(envPath, lines.join("\n"), "utf-8");
|
|
814
|
+
return {
|
|
815
|
+
changed,
|
|
816
|
+
unchanged
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
//#endregion
|
|
821
|
+
//#region src/lib/registry.ts
|
|
822
|
+
/**
|
|
823
|
+
* Live registry queries for the latest published smking SDK versions.
|
|
824
|
+
*
|
|
825
|
+
* Why this exists: hardcoding a version (`"smking/laravel:^0.10"`) in
|
|
826
|
+
* the installer goes stale the moment we ship a new minor — every
|
|
827
|
+
* wizard `npx` run would then install the *previous* major.minor line
|
|
828
|
+
* even though packagist already has a newer one. Querying the registry
|
|
829
|
+
* at install time means the wizard always proposes the freshest stable
|
|
830
|
+
* release without us re-cutting a wizard build.
|
|
831
|
+
*
|
|
832
|
+
* Failure mode: network down / registry returning 500 / unparseable
|
|
833
|
+
* response → callers fall back to bare `composer require <pkg>` (no
|
|
834
|
+
* constraint). That still installs the latest version compatible with
|
|
835
|
+
* the customer's existing `composer.json` — not ideal if they're
|
|
836
|
+
* pinned to an old `^0.X`, but never breaks the install.
|
|
837
|
+
*/
|
|
838
|
+
const REGISTRY_TIMEOUT_MS = 5e3;
|
|
839
|
+
/**
|
|
840
|
+
* Convert `"0.10.1"` → `"^0.10"`. Pins major.minor so consumers get
|
|
841
|
+
* the latest patch in this line automatically. For composer 0.x:
|
|
842
|
+
* `^0.10` resolves to `>=0.10.0 <0.11.0`. For npm same semantics.
|
|
843
|
+
*/
|
|
844
|
+
function toCaretRange(version) {
|
|
845
|
+
const match = version.match(/^(\d+)\.(\d+)/);
|
|
846
|
+
if (!match) throw new Error(`unparseable version string: ${version}`);
|
|
847
|
+
return `^${match[1]}.${match[2]}`;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* `https://repo.packagist.org/p2/<name>.json` returns the metadata
|
|
851
|
+
* with `packages.<name>` as a newest-first array of `{version, ...}`.
|
|
852
|
+
* We pick the first entry matching a clean semver tag (no `-rc`,
|
|
853
|
+
* `-dev`, `-alpha`).
|
|
854
|
+
*/
|
|
855
|
+
async function getLatestPackagistVersion(packageName) {
|
|
856
|
+
const url = `https://repo.packagist.org/p2/${packageName}.json`;
|
|
857
|
+
const res = await fetch(url, {
|
|
858
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
859
|
+
headers: { accept: "application/json" }
|
|
860
|
+
});
|
|
861
|
+
if (!res.ok) throw new Error(`packagist ${packageName} HTTP ${res.status}`);
|
|
862
|
+
const stable = ((await res.json()).packages?.[packageName] ?? []).find((v) => {
|
|
863
|
+
const ver = v.version ?? "";
|
|
864
|
+
return /^v?\d+\.\d+\.\d+$/.test(ver);
|
|
865
|
+
});
|
|
866
|
+
if (!stable?.version) throw new Error(`packagist ${packageName}: no stable version found`);
|
|
867
|
+
const version = stable.version.replace(/^v/, "");
|
|
868
|
+
return {
|
|
869
|
+
version,
|
|
870
|
+
caretRange: toCaretRange(version),
|
|
871
|
+
source: "packagist"
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* npm root metadata returns `dist-tags.latest` — the version the
|
|
876
|
+
* publisher tagged as the default for `npm install <name>` (with no
|
|
877
|
+
* version specifier). This is what we want for "install the freshest
|
|
878
|
+
* stable line"; explicit prerelease tags like `next` / `beta` are
|
|
879
|
+
* intentionally ignored.
|
|
880
|
+
*/
|
|
881
|
+
async function getLatestNpmVersion(packageName) {
|
|
882
|
+
const url = `https://registry.npmjs.org/${packageName.replace("/", "%2F")}`;
|
|
883
|
+
const res = await fetch(url, {
|
|
884
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
885
|
+
headers: { accept: "application/json" }
|
|
886
|
+
});
|
|
887
|
+
if (!res.ok) throw new Error(`npm ${packageName} HTTP ${res.status}`);
|
|
888
|
+
const latest = (await res.json())["dist-tags"]?.latest;
|
|
889
|
+
if (!latest) throw new Error(`npm ${packageName}: no latest tag`);
|
|
890
|
+
return {
|
|
891
|
+
version: latest,
|
|
892
|
+
caretRange: toCaretRange(latest),
|
|
893
|
+
source: "npm"
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/installers/laravel.ts
|
|
899
|
+
async function installLaravel(ctx) {
|
|
900
|
+
const steps = [];
|
|
901
|
+
let constraint = "smking/laravel";
|
|
902
|
+
let bumpedTo = null;
|
|
903
|
+
try {
|
|
904
|
+
const latest = await getLatestPackagistVersion("smking/laravel");
|
|
905
|
+
constraint = `smking/laravel:${latest.caretRange}`;
|
|
906
|
+
bumpedTo = latest.version;
|
|
907
|
+
} catch {}
|
|
908
|
+
const composerResult = await run("composer", ["require", constraint], {
|
|
909
|
+
cwd: ctx.cwd,
|
|
910
|
+
timeoutMs: 18e4
|
|
911
|
+
});
|
|
912
|
+
if (composerResult.code !== 0) {
|
|
913
|
+
steps.push({
|
|
914
|
+
name: `composer require ${constraint}`,
|
|
915
|
+
status: "failed",
|
|
916
|
+
detail: composerResult.stderr.slice(0, 500)
|
|
917
|
+
});
|
|
918
|
+
return {
|
|
919
|
+
ok: false,
|
|
920
|
+
steps
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const alreadyInstalled = composerResult.stdout.includes("is already in the composer.json");
|
|
924
|
+
steps.push({
|
|
925
|
+
name: `composer require ${constraint}`,
|
|
926
|
+
status: alreadyInstalled ? "skipped" : "done",
|
|
927
|
+
detail: bumpedTo ? `latest packagist: v${bumpedTo} (caret bumped)` : "registry query failed — installed at existing constraint"
|
|
928
|
+
});
|
|
929
|
+
const publishResult = await run("php", [
|
|
930
|
+
"artisan",
|
|
931
|
+
"vendor:publish",
|
|
932
|
+
"--tag=smking-config"
|
|
933
|
+
], {
|
|
934
|
+
cwd: ctx.cwd,
|
|
935
|
+
timeoutMs: 6e4
|
|
936
|
+
});
|
|
937
|
+
if (publishResult.code !== 0) {
|
|
938
|
+
steps.push({
|
|
939
|
+
name: "php artisan vendor:publish --tag=smking-config",
|
|
940
|
+
status: "failed",
|
|
941
|
+
detail: publishResult.stderr.slice(0, 500)
|
|
942
|
+
});
|
|
943
|
+
return {
|
|
944
|
+
ok: false,
|
|
945
|
+
steps
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
steps.push({
|
|
949
|
+
name: "php artisan vendor:publish --tag=smking-config",
|
|
950
|
+
status: "done"
|
|
951
|
+
});
|
|
952
|
+
if (ctx.apiKey && ctx.baseUrl && !ctx.apiKey.startsWith("<") && !ctx.baseUrl.startsWith("<")) {
|
|
953
|
+
const envChange = setEnvKeys(join(ctx.cwd, ".env"), {
|
|
954
|
+
SMKING_API_KEY: ctx.apiKey,
|
|
955
|
+
SMKING_BASE_URL: ctx.baseUrl
|
|
956
|
+
});
|
|
957
|
+
steps.push({
|
|
958
|
+
name: "Write SMKING_API_KEY + SMKING_BASE_URL to .env",
|
|
959
|
+
status: envChange.changed.length > 0 ? "done" : "skipped",
|
|
960
|
+
detail: envChange.changed.length > 0 ? `wrote: ${envChange.changed.join(", ")}` : `already set: ${envChange.unchanged.join(", ")}`
|
|
961
|
+
});
|
|
962
|
+
} else steps.push({
|
|
963
|
+
name: "Write SMKING_API_KEY + SMKING_BASE_URL to .env",
|
|
964
|
+
status: "skipped",
|
|
965
|
+
detail: "env handled by separate set_env step (wizard agent path)"
|
|
966
|
+
});
|
|
967
|
+
const clearResult = await run("php", ["artisan", "config:clear"], {
|
|
968
|
+
cwd: ctx.cwd,
|
|
969
|
+
timeoutMs: 3e4
|
|
970
|
+
});
|
|
971
|
+
if (clearResult.code !== 0) steps.push({
|
|
972
|
+
name: "php artisan config:clear",
|
|
973
|
+
status: "failed",
|
|
974
|
+
detail: clearResult.stderr.slice(0, 500)
|
|
975
|
+
});
|
|
976
|
+
else steps.push({
|
|
977
|
+
name: "php artisan config:clear",
|
|
978
|
+
status: "done"
|
|
979
|
+
});
|
|
980
|
+
return {
|
|
981
|
+
ok: true,
|
|
982
|
+
steps
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/installers/nextjs.ts
|
|
988
|
+
const PM_ADD_ARGS = {
|
|
989
|
+
pnpm: ["add"],
|
|
990
|
+
bun: ["add"],
|
|
991
|
+
yarn: ["add"],
|
|
992
|
+
npm: ["install"]
|
|
993
|
+
};
|
|
994
|
+
async function installNextjs(ctx) {
|
|
995
|
+
const steps = [];
|
|
996
|
+
let packageSpec = "@soloworks/smking-next";
|
|
997
|
+
let bumpedTo = null;
|
|
998
|
+
try {
|
|
999
|
+
const latest = await getLatestNpmVersion("@soloworks/smking-next");
|
|
1000
|
+
packageSpec = `@soloworks/smking-next@${latest.caretRange}`;
|
|
1001
|
+
bumpedTo = latest.version;
|
|
1002
|
+
} catch {}
|
|
1003
|
+
const addArgs = [...PM_ADD_ARGS[ctx.packageManager], packageSpec];
|
|
1004
|
+
const installResult = await run(ctx.packageManager, addArgs, {
|
|
1005
|
+
cwd: ctx.cwd,
|
|
1006
|
+
timeoutMs: 18e4
|
|
1007
|
+
});
|
|
1008
|
+
if (installResult.code !== 0) {
|
|
1009
|
+
steps.push({
|
|
1010
|
+
name: `${ctx.packageManager} ${addArgs.join(" ")}`,
|
|
1011
|
+
status: "failed",
|
|
1012
|
+
detail: installResult.stderr.slice(0, 500)
|
|
1013
|
+
});
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
steps
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
steps.push({
|
|
1020
|
+
name: `${ctx.packageManager} ${addArgs.join(" ")}`,
|
|
1021
|
+
status: "done",
|
|
1022
|
+
detail: bumpedTo ? `latest npm: v${bumpedTo} (caret bumped)` : "registry query failed — installed at existing constraint"
|
|
1023
|
+
});
|
|
1024
|
+
if (!(ctx.apiKey && ctx.baseUrl && !ctx.apiKey.startsWith("<") && !ctx.baseUrl.startsWith("<"))) steps.push({
|
|
1025
|
+
name: "Write SMKING_API_KEY + SMKING_BASE_URL to .env.local",
|
|
1026
|
+
status: "skipped",
|
|
1027
|
+
detail: "env handled by separate set_env step (wizard agent path)"
|
|
1028
|
+
});
|
|
1029
|
+
else {
|
|
1030
|
+
const envChange = setEnvKeys(join(ctx.cwd, ".env.local"), {
|
|
1031
|
+
SMKING_API_KEY: ctx.apiKey,
|
|
1032
|
+
SMKING_BASE_URL: ctx.baseUrl
|
|
1033
|
+
});
|
|
1034
|
+
steps.push({
|
|
1035
|
+
name: "Write SMKING_API_KEY + SMKING_BASE_URL to .env.local",
|
|
1036
|
+
status: envChange.changed.length > 0 ? "done" : "skipped",
|
|
1037
|
+
detail: envChange.changed.length > 0 ? `wrote: ${envChange.changed.join(", ")}` : `already set: ${envChange.unchanged.join(", ")}`
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
const layoutResult = addSmkingAEOToLayout(ctx.cwd, ctx.appDir);
|
|
1041
|
+
steps.push(layoutResult);
|
|
1042
|
+
if (layoutResult.status === "failed") return {
|
|
1043
|
+
ok: false,
|
|
1044
|
+
steps
|
|
1045
|
+
};
|
|
1046
|
+
return {
|
|
1047
|
+
ok: true,
|
|
1048
|
+
steps
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Find `<appDir>/layout.tsx` (or .jsx/.ts/.js), inject SmkingAEO
|
|
1053
|
+
* import + render. Idempotent: if `SmkingAEO` substring is already
|
|
1054
|
+
* present, returns skipped without re-editing.
|
|
1055
|
+
*
|
|
1056
|
+
* Uses targeted string manipulation rather than full AST parsing
|
|
1057
|
+
* because:
|
|
1058
|
+
* - The edit shape is fixed (import + JSX inside <body>)
|
|
1059
|
+
* - Layout files are conventional Next.js boilerplate; the
|
|
1060
|
+
* vanilla case covers 95% of customers
|
|
1061
|
+
* - Edge cases (custom layouts, no <body>) gracefully degrade
|
|
1062
|
+
* to "manual instructions in error message"
|
|
1063
|
+
*
|
|
1064
|
+
* AST parsing (magicast / recast) is an option we may revisit if
|
|
1065
|
+
* field reports show this is too fragile.
|
|
1066
|
+
*/
|
|
1067
|
+
function addSmkingAEOToLayout(cwd, appDir) {
|
|
1068
|
+
const layoutPath = [
|
|
1069
|
+
join(cwd, appDir, "layout.tsx"),
|
|
1070
|
+
join(cwd, appDir, "layout.jsx"),
|
|
1071
|
+
join(cwd, appDir, "layout.ts"),
|
|
1072
|
+
join(cwd, appDir, "layout.js")
|
|
1073
|
+
].find((p) => existsSync(p));
|
|
1074
|
+
if (!layoutPath) return {
|
|
1075
|
+
name: "Inject <SmkingAEO /> into root layout",
|
|
1076
|
+
status: "failed",
|
|
1077
|
+
detail: `no layout file found under ${appDir}/ — add <SmkingAEO apiKey={process.env.SMKING_API_KEY!} /> to your root layout manually`
|
|
1078
|
+
};
|
|
1079
|
+
const content = readFileSync(layoutPath, "utf-8");
|
|
1080
|
+
if (content.includes("SmkingAEO")) return {
|
|
1081
|
+
name: "Inject <SmkingAEO /> into root layout",
|
|
1082
|
+
status: "skipped",
|
|
1083
|
+
detail: `${layoutPath} already references SmkingAEO`
|
|
1084
|
+
};
|
|
1085
|
+
const importMatches = [...content.matchAll(/^import\s+[^;]+;?\s*$/gm)];
|
|
1086
|
+
if (importMatches.length === 0) return {
|
|
1087
|
+
name: "Inject <SmkingAEO /> into root layout",
|
|
1088
|
+
status: "failed",
|
|
1089
|
+
detail: `${layoutPath} has no import statements — add SmkingAEO manually`
|
|
1090
|
+
};
|
|
1091
|
+
const lastImport = importMatches[importMatches.length - 1];
|
|
1092
|
+
const lastImportEnd = lastImport.index + lastImport[0].length;
|
|
1093
|
+
let updated = content.slice(0, lastImportEnd) + "\nimport { SmkingAEO } from \"@soloworks/smking-next\";" + content.slice(lastImportEnd);
|
|
1094
|
+
const bodyMatch = updated.match(/<body([^>]*)>/);
|
|
1095
|
+
if (!bodyMatch || bodyMatch.index === void 0) return {
|
|
1096
|
+
name: "Inject <SmkingAEO /> into root layout",
|
|
1097
|
+
status: "failed",
|
|
1098
|
+
detail: `${layoutPath} has no <body> tag — add SmkingAEO manually inside <body>`
|
|
1099
|
+
};
|
|
1100
|
+
const bodyEnd = bodyMatch.index + bodyMatch[0].length;
|
|
1101
|
+
updated = updated.slice(0, bodyEnd) + "\n <SmkingAEO apiKey={process.env.SMKING_API_KEY!} />" + updated.slice(bodyEnd);
|
|
1102
|
+
writeFileSync(layoutPath, updated, "utf-8");
|
|
1103
|
+
return {
|
|
1104
|
+
name: "Inject <SmkingAEO /> into root layout",
|
|
1105
|
+
status: "done",
|
|
1106
|
+
detail: `wrote import + render to ${layoutPath}`
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/agent/tools.ts
|
|
1112
|
+
function asResult(value) {
|
|
1113
|
+
return JSON.stringify(value);
|
|
1114
|
+
}
|
|
1115
|
+
function stepIcon(status) {
|
|
1116
|
+
return status === "done" ? "✓" : status === "skipped" ? "⊝" : "✗";
|
|
1117
|
+
}
|
|
1118
|
+
function buildWizardTools(ctx) {
|
|
1119
|
+
const pushProgress = (text) => useWizardStore.getState().pushProgress(text);
|
|
1120
|
+
return [
|
|
1121
|
+
{
|
|
1122
|
+
name: "detect_framework",
|
|
1123
|
+
description: "Detect whether the current project is Laravel or Next.js. Returns { framework, evidence, appDir? }. Always call this first before any install steps.",
|
|
1124
|
+
input_schema: {
|
|
1125
|
+
type: "object",
|
|
1126
|
+
properties: {}
|
|
1127
|
+
},
|
|
1128
|
+
run: async () => {
|
|
1129
|
+
pushProgress("Detecting framework…");
|
|
1130
|
+
const result = detectFramework(ctx.cwd);
|
|
1131
|
+
pushProgress(result.framework === "unknown" ? `Framework: unknown (${result.evidence})` : `Framework: ${result.framework}`);
|
|
1132
|
+
return asResult(result);
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
name: "detect_package_manager",
|
|
1137
|
+
description: "Detect the Node package manager from the project's lockfile (pnpm / bun / yarn / npm). Returns { packageManager, lockfile }.",
|
|
1138
|
+
input_schema: {
|
|
1139
|
+
type: "object",
|
|
1140
|
+
properties: {}
|
|
1141
|
+
},
|
|
1142
|
+
run: async () => {
|
|
1143
|
+
const result = detectNodePackageManager(ctx.cwd);
|
|
1144
|
+
pushProgress(`Package manager: ${result.packageManager}${result.lockfile ? ` (${result.lockfile})` : " (no lockfile, assuming npm)"}`);
|
|
1145
|
+
return asResult(result);
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: "install_package",
|
|
1150
|
+
description: "Install the smking SDK for the detected framework. Runs composer require + vendor:publish + config:clear (Laravel), or pnpm/npm/yarn/bun add + edit layout.tsx (Next.js). Idempotent — already-installed steps return 'skipped'. Returns { ok, steps[] }.",
|
|
1151
|
+
input_schema: {
|
|
1152
|
+
type: "object",
|
|
1153
|
+
properties: { framework: {
|
|
1154
|
+
type: "string",
|
|
1155
|
+
enum: ["laravel", "nextjs"],
|
|
1156
|
+
description: "Framework to install for (must match detect_framework result)"
|
|
1157
|
+
} },
|
|
1158
|
+
required: ["framework"]
|
|
1159
|
+
},
|
|
1160
|
+
run: async (input) => {
|
|
1161
|
+
const { framework } = input;
|
|
1162
|
+
pushProgress(`Installing smking SDK for ${framework}…`);
|
|
1163
|
+
if (framework === "laravel") {
|
|
1164
|
+
const result$1 = await installLaravel({
|
|
1165
|
+
apiKey: "<set-by-set_env>",
|
|
1166
|
+
baseUrl: "<set-by-set_env>",
|
|
1167
|
+
cwd: ctx.cwd
|
|
1168
|
+
});
|
|
1169
|
+
for (const step of result$1.steps) pushProgress(` ${stepIcon(step.status)} ${step.name}`);
|
|
1170
|
+
return asResult(result$1);
|
|
1171
|
+
}
|
|
1172
|
+
const detection = detectFramework(ctx.cwd);
|
|
1173
|
+
if (detection.framework !== "nextjs" || !detection.appDir) return asResult({
|
|
1174
|
+
ok: false,
|
|
1175
|
+
steps: [{
|
|
1176
|
+
name: "install_nextjs",
|
|
1177
|
+
status: "failed",
|
|
1178
|
+
detail: "framework re-detection returned non-nextjs — call detect_framework first and confirm"
|
|
1179
|
+
}]
|
|
1180
|
+
});
|
|
1181
|
+
const pmDetection = detectNodePackageManager(ctx.cwd);
|
|
1182
|
+
const result = await installNextjs({
|
|
1183
|
+
apiKey: "<set-by-set_env>",
|
|
1184
|
+
baseUrl: "<set-by-set_env>",
|
|
1185
|
+
cwd: ctx.cwd,
|
|
1186
|
+
packageManager: pmDetection.packageManager,
|
|
1187
|
+
appDir: detection.appDir
|
|
1188
|
+
});
|
|
1189
|
+
for (const step of result.steps) pushProgress(` ${stepIcon(step.status)} ${step.name}`);
|
|
1190
|
+
return asResult(result);
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: "set_env",
|
|
1195
|
+
description: "Write SMKING_API_KEY and/or SMKING_BASE_URL to the project's env file (.env for Laravel, .env.local for Next.js). Other keys are rejected. Idempotent.",
|
|
1196
|
+
input_schema: {
|
|
1197
|
+
type: "object",
|
|
1198
|
+
properties: {
|
|
1199
|
+
SMKING_API_KEY: {
|
|
1200
|
+
type: "string",
|
|
1201
|
+
description: "Publishable site API key (starts with pk_)"
|
|
1202
|
+
},
|
|
1203
|
+
SMKING_BASE_URL: {
|
|
1204
|
+
type: "string",
|
|
1205
|
+
description: "smking SaaS base URL (e.g. https://smking.com)"
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
run: async (input) => {
|
|
1210
|
+
pushProgress("Setting env values…");
|
|
1211
|
+
const typed = input;
|
|
1212
|
+
const allowedKeys = new Set(["SMKING_API_KEY", "SMKING_BASE_URL"]);
|
|
1213
|
+
const updates = {};
|
|
1214
|
+
for (const [k, v] of Object.entries(typed)) {
|
|
1215
|
+
if (!allowedKeys.has(k) || typeof v !== "string") continue;
|
|
1216
|
+
updates[k] = v;
|
|
1217
|
+
}
|
|
1218
|
+
if (Object.keys(updates).length === 0) return asResult({ error: "set_env called with no valid keys. Allowed: SMKING_API_KEY, SMKING_BASE_URL." });
|
|
1219
|
+
const envFile = detectFramework(ctx.cwd).framework === "nextjs" ? ".env.local" : ".env";
|
|
1220
|
+
const result = setEnvKeys(join(ctx.cwd, envFile), updates);
|
|
1221
|
+
pushProgress(`Env: changed=[${result.changed.join(", ")}] unchanged=[${result.unchanged.join(", ")}]`);
|
|
1222
|
+
return asResult({
|
|
1223
|
+
envFile,
|
|
1224
|
+
...result
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
name: "run_doctor",
|
|
1230
|
+
description: "Run the smking doctor self-check (php artisan smking:doctor --json for Laravel, smking-next doctor --json for Next.js). Returns structured JSON: { checks: [{name, status, detail}], summary: {passed, failed, info, ok} }.",
|
|
1231
|
+
input_schema: {
|
|
1232
|
+
type: "object",
|
|
1233
|
+
properties: {}
|
|
1234
|
+
},
|
|
1235
|
+
run: async () => {
|
|
1236
|
+
pushProgress("Running smking:doctor…");
|
|
1237
|
+
const framework = detectFramework(ctx.cwd).framework;
|
|
1238
|
+
let cmd;
|
|
1239
|
+
let args;
|
|
1240
|
+
if (framework === "laravel") {
|
|
1241
|
+
cmd = "php";
|
|
1242
|
+
args = [
|
|
1243
|
+
"artisan",
|
|
1244
|
+
"smking:doctor",
|
|
1245
|
+
"--json"
|
|
1246
|
+
];
|
|
1247
|
+
} else if (framework === "nextjs") {
|
|
1248
|
+
const pm = detectNodePackageManager(ctx.cwd).packageManager;
|
|
1249
|
+
if (pm === "pnpm") {
|
|
1250
|
+
cmd = "pnpm";
|
|
1251
|
+
args = [
|
|
1252
|
+
"exec",
|
|
1253
|
+
"smking-next",
|
|
1254
|
+
"doctor",
|
|
1255
|
+
"--json"
|
|
1256
|
+
];
|
|
1257
|
+
} else if (pm === "bun") {
|
|
1258
|
+
cmd = "bunx";
|
|
1259
|
+
args = [
|
|
1260
|
+
"smking-next",
|
|
1261
|
+
"doctor",
|
|
1262
|
+
"--json"
|
|
1263
|
+
];
|
|
1264
|
+
} else if (pm === "yarn") {
|
|
1265
|
+
cmd = "yarn";
|
|
1266
|
+
args = [
|
|
1267
|
+
"smking-next",
|
|
1268
|
+
"doctor",
|
|
1269
|
+
"--json"
|
|
1270
|
+
];
|
|
1271
|
+
} else {
|
|
1272
|
+
cmd = "npx";
|
|
1273
|
+
args = [
|
|
1274
|
+
"smking-next",
|
|
1275
|
+
"doctor",
|
|
1276
|
+
"--json"
|
|
1277
|
+
];
|
|
1278
|
+
}
|
|
1279
|
+
} else return asResult({
|
|
1280
|
+
ok: false,
|
|
1281
|
+
error: `Cannot run doctor — framework is ${framework}`
|
|
1282
|
+
});
|
|
1283
|
+
const result = await run(cmd, args, {
|
|
1284
|
+
cwd: ctx.cwd,
|
|
1285
|
+
timeoutMs: 6e4
|
|
1286
|
+
});
|
|
1287
|
+
try {
|
|
1288
|
+
const parsed = JSON.parse(result.stdout);
|
|
1289
|
+
const failed = parsed.summary?.failed ?? 0;
|
|
1290
|
+
pushProgress(`Doctor: ${parsed.summary?.passed ?? 0} pass · ${failed} fail · ${parsed.summary?.info ?? 0} info`);
|
|
1291
|
+
return asResult(parsed);
|
|
1292
|
+
} catch {
|
|
1293
|
+
return asResult({
|
|
1294
|
+
ok: false,
|
|
1295
|
+
error: `Doctor output was not JSON (exit code ${result.code})`,
|
|
1296
|
+
stdout: result.stdout.slice(0, 1e3),
|
|
1297
|
+
stderr: result.stderr.slice(0, 1e3)
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
{
|
|
1303
|
+
name: "read_project_file",
|
|
1304
|
+
description: "Read a project file (relative path inside cwd) so you can inspect its content when debugging an install or doctor failure. Use this BEFORE concluding a problem is unfixable — composer.json reveals constraint pins, Kernel.php reveals middleware register state, .env reveals what's actually set. Allowed file types: json, php, ts, tsx, js, jsx, env, conf, yaml, yml, md, lock, htaccess. Returns { path, content, bytes } or { error }.",
|
|
1305
|
+
input_schema: {
|
|
1306
|
+
type: "object",
|
|
1307
|
+
properties: { path: {
|
|
1308
|
+
type: "string",
|
|
1309
|
+
description: "Project-relative path, e.g. `composer.json`, `app/Http/Kernel.php`, `.env`, `app/layout.tsx`. No absolute paths, no `..`."
|
|
1310
|
+
} },
|
|
1311
|
+
required: ["path"]
|
|
1312
|
+
},
|
|
1313
|
+
run: async (input) => {
|
|
1314
|
+
const { path } = input;
|
|
1315
|
+
pushProgress(`Reading ${path}…`);
|
|
1316
|
+
if (path.startsWith("/") || path.includes("..")) return asResult({ error: "absolute paths and `..` traversal are not allowed" });
|
|
1317
|
+
const absPath = resolve(ctx.cwd, path);
|
|
1318
|
+
const rel = relative(ctx.cwd, absPath);
|
|
1319
|
+
if (rel.startsWith("..") || resolve(rel) === resolve("")) return asResult({ error: "resolved path escapes cwd" });
|
|
1320
|
+
const allowedExt = /\.(json|php|ts|tsx|js|jsx|env|conf|yaml|yml|md|lock|htaccess)$/i;
|
|
1321
|
+
const basename = path.split("/").pop() ?? "";
|
|
1322
|
+
const allowedBasename = new Set([
|
|
1323
|
+
".env",
|
|
1324
|
+
".env.local",
|
|
1325
|
+
".env.production",
|
|
1326
|
+
".env.example",
|
|
1327
|
+
".htaccess"
|
|
1328
|
+
]);
|
|
1329
|
+
if (!allowedExt.test(basename) && !allowedBasename.has(basename)) return asResult({ error: `file extension not allowed: ${basename}` });
|
|
1330
|
+
try {
|
|
1331
|
+
const content = await promises.readFile(absPath, "utf-8");
|
|
1332
|
+
const truncated = content.length > 5e4 ? content.slice(0, 5e4) + "\n... [truncated at 50KB]" : content;
|
|
1333
|
+
pushProgress(` read ${path} (${content.length} bytes)`);
|
|
1334
|
+
return asResult({
|
|
1335
|
+
path,
|
|
1336
|
+
content: truncated,
|
|
1337
|
+
bytes: content.length
|
|
1338
|
+
});
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
return asResult({ error: err instanceof Error ? err.message : String(err) });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
name: "run_artisan",
|
|
1346
|
+
description: "Run a Laravel artisan command from a fixed allowlist. Use for cache/config recovery (cache:clear, config:clear), SDK-specific inspection (smking:status, smking:cache:purge), or route confirmation (route:list). Not for arbitrary fixes — if you need a command not in the allowlist, call report_failure instead. Returns { exitCode, stdout, stderr }.",
|
|
1347
|
+
input_schema: {
|
|
1348
|
+
type: "object",
|
|
1349
|
+
properties: {
|
|
1350
|
+
command: {
|
|
1351
|
+
type: "string",
|
|
1352
|
+
enum: [
|
|
1353
|
+
"smking:doctor",
|
|
1354
|
+
"smking:cache:purge",
|
|
1355
|
+
"smking:status",
|
|
1356
|
+
"smking:publish-robots",
|
|
1357
|
+
"config:clear",
|
|
1358
|
+
"config:cache",
|
|
1359
|
+
"cache:clear",
|
|
1360
|
+
"route:list"
|
|
1361
|
+
],
|
|
1362
|
+
description: "Artisan command name (no `php artisan` prefix). Must be one of the allowlist values."
|
|
1363
|
+
},
|
|
1364
|
+
args: {
|
|
1365
|
+
type: "array",
|
|
1366
|
+
items: { type: "string" },
|
|
1367
|
+
description: "Optional CLI flags. Allowed forms: `--json`, `--force`, `--tag=<value>`, `--path=<value>`, `--key=<value>`. Other flags are rejected."
|
|
1368
|
+
}
|
|
1369
|
+
},
|
|
1370
|
+
required: ["command"]
|
|
1371
|
+
},
|
|
1372
|
+
run: async (input) => {
|
|
1373
|
+
const { command, args = [] } = input;
|
|
1374
|
+
const framework = detectFramework(ctx.cwd).framework;
|
|
1375
|
+
if (framework !== "laravel") return asResult({ error: `run_artisan only works in Laravel projects (detected: ${framework})` });
|
|
1376
|
+
if (!new Set([
|
|
1377
|
+
"smking:doctor",
|
|
1378
|
+
"smking:cache:purge",
|
|
1379
|
+
"smking:status",
|
|
1380
|
+
"smking:publish-robots",
|
|
1381
|
+
"config:clear",
|
|
1382
|
+
"config:cache",
|
|
1383
|
+
"cache:clear",
|
|
1384
|
+
"route:list"
|
|
1385
|
+
]).has(command)) return asResult({ error: `command not in allowlist: ${command}` });
|
|
1386
|
+
const PLAIN_FLAGS = new Set(["--json", "--force"]);
|
|
1387
|
+
const PARAMETRIC = /^--(tag|path|key)=[\w/.:_\-+]+$/;
|
|
1388
|
+
for (const arg of args) if (!PLAIN_FLAGS.has(arg) && !PARAMETRIC.test(arg)) return asResult({ error: `disallowed flag: ${arg}` });
|
|
1389
|
+
pushProgress(`php artisan ${command} ${args.join(" ")}`.trim());
|
|
1390
|
+
const result = await run("php", [
|
|
1391
|
+
"artisan",
|
|
1392
|
+
command,
|
|
1393
|
+
...args
|
|
1394
|
+
], {
|
|
1395
|
+
cwd: ctx.cwd,
|
|
1396
|
+
timeoutMs: 6e4
|
|
1397
|
+
});
|
|
1398
|
+
return asResult({
|
|
1399
|
+
command,
|
|
1400
|
+
args,
|
|
1401
|
+
exitCode: result.code,
|
|
1402
|
+
stdout: result.stdout.slice(0, 5e3),
|
|
1403
|
+
stderr: result.stderr.slice(0, 2e3)
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
name: "report_failure",
|
|
1409
|
+
description: "Report an unfixable install failure to smking support. Call this only after retrying the same failing check 3 times. Posts to smking dashboard. Returns { ticketId }.",
|
|
1410
|
+
input_schema: {
|
|
1411
|
+
type: "object",
|
|
1412
|
+
properties: {
|
|
1413
|
+
failed_checks: {
|
|
1414
|
+
type: "array",
|
|
1415
|
+
items: {
|
|
1416
|
+
type: "object",
|
|
1417
|
+
properties: {
|
|
1418
|
+
name: { type: "string" },
|
|
1419
|
+
status: {
|
|
1420
|
+
type: "string",
|
|
1421
|
+
enum: [
|
|
1422
|
+
"pass",
|
|
1423
|
+
"fail",
|
|
1424
|
+
"info"
|
|
1425
|
+
]
|
|
1426
|
+
},
|
|
1427
|
+
detail: { type: "string" }
|
|
1428
|
+
},
|
|
1429
|
+
required: [
|
|
1430
|
+
"name",
|
|
1431
|
+
"status",
|
|
1432
|
+
"detail"
|
|
1433
|
+
]
|
|
1434
|
+
},
|
|
1435
|
+
minItems: 1
|
|
1436
|
+
},
|
|
1437
|
+
environment: {
|
|
1438
|
+
type: "object",
|
|
1439
|
+
additionalProperties: { type: "string" }
|
|
1440
|
+
},
|
|
1441
|
+
raw_output: { type: "string" }
|
|
1442
|
+
},
|
|
1443
|
+
required: [
|
|
1444
|
+
"failed_checks",
|
|
1445
|
+
"environment",
|
|
1446
|
+
"raw_output"
|
|
1447
|
+
]
|
|
1448
|
+
},
|
|
1449
|
+
run: async (input) => {
|
|
1450
|
+
pushProgress("Reporting failure to smking support…");
|
|
1451
|
+
const typed = input;
|
|
1452
|
+
const framework = detectFramework(ctx.cwd).framework;
|
|
1453
|
+
const response = await fetch(`${SAAS_URL}/api/v1/doctor-reports`, {
|
|
1454
|
+
method: "POST",
|
|
1455
|
+
headers: {
|
|
1456
|
+
authorization: `Bearer ${ctx.oauthToken}`,
|
|
1457
|
+
"content-type": "application/json"
|
|
1458
|
+
},
|
|
1459
|
+
body: JSON.stringify({
|
|
1460
|
+
framework,
|
|
1461
|
+
failed_checks: typed.failed_checks,
|
|
1462
|
+
environment: typed.environment,
|
|
1463
|
+
raw_output: typed.raw_output
|
|
1464
|
+
})
|
|
1465
|
+
});
|
|
1466
|
+
if (!response.ok) {
|
|
1467
|
+
const text = await response.text().catch(() => "");
|
|
1468
|
+
return asResult({
|
|
1469
|
+
ok: false,
|
|
1470
|
+
error: `report_failure HTTP ${response.status}: ${text.slice(0, 200)}`
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
const data = await response.json();
|
|
1474
|
+
pushProgress(`Reported · ticket ${data.ticketId ?? "(no id)"}`);
|
|
1475
|
+
return asResult(data);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
];
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
//#endregion
|
|
1482
|
+
//#region src/agent/runtime.ts
|
|
1483
|
+
/**
|
|
1484
|
+
* File-based debug logger. ink TUI takes over stdout/stderr so
|
|
1485
|
+
* `console.log` is invisible during a wizard run. Writing to
|
|
1486
|
+
* `/tmp/smking-wizard.log` gives us a tail-able stream of what's
|
|
1487
|
+
* happening inside the agent loop.
|
|
1488
|
+
*/
|
|
1489
|
+
const DEBUG_LOG = "/tmp/smking-wizard.log";
|
|
1490
|
+
function debugLog(msg, data) {
|
|
1491
|
+
try {
|
|
1492
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1493
|
+
appendFileSync(DEBUG_LOG, data ? `${stamp} ${msg} ${JSON.stringify(data, null, 2)}\n` : `${stamp} ${msg}\n`);
|
|
1494
|
+
} catch {}
|
|
1495
|
+
}
|
|
1496
|
+
const MAX_ITERATIONS = 30;
|
|
1497
|
+
async function runAgent(ctx) {
|
|
1498
|
+
debugLog("=== runAgent starting ===", {
|
|
1499
|
+
cwd: ctx.cwd,
|
|
1500
|
+
siteId: ctx.siteId,
|
|
1501
|
+
saasUrl: SAAS_URL
|
|
1502
|
+
});
|
|
1503
|
+
const detection = detectFramework(ctx.cwd);
|
|
1504
|
+
debugLog("framework detection result", detection);
|
|
1505
|
+
if (detection.framework === "unknown") return {
|
|
1506
|
+
ok: false,
|
|
1507
|
+
message: `Unable to detect framework in ${ctx.cwd}. ${detection.evidence}`
|
|
1508
|
+
};
|
|
1509
|
+
const client = new Anthropic({
|
|
1510
|
+
baseURL: `${SAAS_URL}/api/v1/wizard/gateway`,
|
|
1511
|
+
authToken: ctx.oauthToken,
|
|
1512
|
+
maxRetries: 0
|
|
1513
|
+
});
|
|
1514
|
+
debugLog("fetching install prompt", { framework: detection.framework });
|
|
1515
|
+
const installPrompt = await fetchInstallPrompt({
|
|
1516
|
+
framework: detection.framework,
|
|
1517
|
+
oauthToken: ctx.oauthToken
|
|
1518
|
+
});
|
|
1519
|
+
debugLog("install prompt fetched", { length: installPrompt.length });
|
|
1520
|
+
const toolDefs = buildWizardTools(ctx);
|
|
1521
|
+
debugLog("tools built", {
|
|
1522
|
+
count: toolDefs.length,
|
|
1523
|
+
names: toolDefs.map((t) => t.name)
|
|
1524
|
+
});
|
|
1525
|
+
const tools = toolDefs.map((t) => ({
|
|
1526
|
+
name: t.name,
|
|
1527
|
+
description: t.description,
|
|
1528
|
+
input_schema: t.input_schema
|
|
1529
|
+
}));
|
|
1530
|
+
const handlers = new Map(toolDefs.map((t) => [t.name, t.run]));
|
|
1531
|
+
const messages = [{
|
|
1532
|
+
role: "user",
|
|
1533
|
+
content: installPrompt
|
|
1534
|
+
}];
|
|
1535
|
+
try {
|
|
1536
|
+
let lastTextSummary = "";
|
|
1537
|
+
let doctorPassed = false;
|
|
1538
|
+
let reportedFailure = false;
|
|
1539
|
+
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
1540
|
+
debugLog(`iteration ${iteration} — calling messages.create`);
|
|
1541
|
+
const apiCallPromise = client.messages.create({
|
|
1542
|
+
model: "claude-opus-4-7",
|
|
1543
|
+
max_tokens: 16e3,
|
|
1544
|
+
thinking: { type: "adaptive" },
|
|
1545
|
+
system: [{
|
|
1546
|
+
type: "text",
|
|
1547
|
+
text: COMMANDMENTS,
|
|
1548
|
+
cache_control: { type: "ephemeral" }
|
|
1549
|
+
}],
|
|
1550
|
+
tools,
|
|
1551
|
+
messages
|
|
1552
|
+
});
|
|
1553
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("messages.create timed out after 60s")), 6e4));
|
|
1554
|
+
const response = await Promise.race([apiCallPromise, timeoutPromise]);
|
|
1555
|
+
debugLog(`iteration ${iteration} — response received`, {
|
|
1556
|
+
stop_reason: response.stop_reason,
|
|
1557
|
+
content_block_types: response.content.map((c) => c.type),
|
|
1558
|
+
usage: response.usage
|
|
1559
|
+
});
|
|
1560
|
+
messages.push({
|
|
1561
|
+
role: "assistant",
|
|
1562
|
+
content: response.content
|
|
1563
|
+
});
|
|
1564
|
+
const texts = response.content.filter((c) => c.type === "text").map((c) => c.text).join("\n").trim();
|
|
1565
|
+
if (texts) lastTextSummary = texts;
|
|
1566
|
+
if (response.stop_reason === "end_turn") {
|
|
1567
|
+
debugLog("end_turn reached — agent done");
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
if (response.stop_reason !== "tool_use") {
|
|
1571
|
+
debugLog("non-tool_use stop_reason — exiting loop", { stop_reason: response.stop_reason });
|
|
1572
|
+
return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
message: `Agent stopped unexpectedly (${response.stop_reason}): ${lastTextSummary || "no text emitted"}`
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
const toolResults = [];
|
|
1578
|
+
for (const block of response.content) {
|
|
1579
|
+
if (block.type !== "tool_use") continue;
|
|
1580
|
+
const handler = handlers.get(block.name);
|
|
1581
|
+
if (!handler) {
|
|
1582
|
+
debugLog("unknown tool called", { name: block.name });
|
|
1583
|
+
toolResults.push({
|
|
1584
|
+
type: "tool_result",
|
|
1585
|
+
tool_use_id: block.id,
|
|
1586
|
+
content: JSON.stringify({ error: `Unknown tool: ${block.name}. Available tools: ${[...handlers.keys()].join(", ")}` }),
|
|
1587
|
+
is_error: true
|
|
1588
|
+
});
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
debugLog("calling tool handler", {
|
|
1592
|
+
name: block.name,
|
|
1593
|
+
input: block.input
|
|
1594
|
+
});
|
|
1595
|
+
try {
|
|
1596
|
+
const result = await handler(block.input);
|
|
1597
|
+
debugLog("tool handler returned", {
|
|
1598
|
+
name: block.name,
|
|
1599
|
+
resultLength: result.length
|
|
1600
|
+
});
|
|
1601
|
+
if (block.name === "run_doctor") try {
|
|
1602
|
+
if (JSON.parse(result).summary?.ok === true) doctorPassed = true;
|
|
1603
|
+
} catch {}
|
|
1604
|
+
else if (block.name === "report_failure") reportedFailure = true;
|
|
1605
|
+
toolResults.push({
|
|
1606
|
+
type: "tool_result",
|
|
1607
|
+
tool_use_id: block.id,
|
|
1608
|
+
content: result
|
|
1609
|
+
});
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1612
|
+
debugLog("tool handler threw", {
|
|
1613
|
+
name: block.name,
|
|
1614
|
+
error: msg
|
|
1615
|
+
});
|
|
1616
|
+
toolResults.push({
|
|
1617
|
+
type: "tool_result",
|
|
1618
|
+
tool_use_id: block.id,
|
|
1619
|
+
content: JSON.stringify({ error: msg }),
|
|
1620
|
+
is_error: true
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
messages.push({
|
|
1625
|
+
role: "user",
|
|
1626
|
+
content: toolResults
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
const ok = doctorPassed && !reportedFailure;
|
|
1630
|
+
debugLog("loop exited — returning summary", {
|
|
1631
|
+
ok,
|
|
1632
|
+
doctorPassed,
|
|
1633
|
+
reportedFailure,
|
|
1634
|
+
summary: lastTextSummary
|
|
1635
|
+
});
|
|
1636
|
+
return {
|
|
1637
|
+
ok,
|
|
1638
|
+
message: lastTextSummary || (ok ? "Wizard completed." : "Wizard ended without a successful doctor run.")
|
|
1639
|
+
};
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
debugLog("agent loop threw", {
|
|
1642
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1643
|
+
stack: err instanceof Error ? err.stack : void 0
|
|
1644
|
+
});
|
|
1645
|
+
return {
|
|
1646
|
+
ok: false,
|
|
1647
|
+
message: err instanceof Error ? err.message : `Agent loop failed: ${String(err)}`
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
//#endregion
|
|
1653
|
+
//#region src/ui/screens/run-screen.tsx
|
|
1654
|
+
/**
|
|
1655
|
+
* Live agent screen. Mounts → kicks off the install agent loop →
|
|
1656
|
+
* routes to done/error based on result. Renders the rolling
|
|
1657
|
+
* progress log + a spinner showing the latest action.
|
|
1658
|
+
*
|
|
1659
|
+
* Progress entries come from `pushProgress` calls inside each
|
|
1660
|
+
* wizard tool's `run` handler. The log is capped at 50 lines in
|
|
1661
|
+
* the store; we render the latest 12 to fit in a typical terminal
|
|
1662
|
+
* without scrolling.
|
|
1663
|
+
*/
|
|
1664
|
+
function RunScreen() {
|
|
1665
|
+
const oauth = useWizardStore((s) => s.oauth);
|
|
1666
|
+
const agentStatus = useWizardStore((s) => s.agentStatus);
|
|
1667
|
+
const agentProgress = useWizardStore((s) => s.agentProgress);
|
|
1668
|
+
const setAgentStatus = useWizardStore((s) => s.setAgentStatus);
|
|
1669
|
+
const setAgentSummary = useWizardStore((s) => s.setAgentSummary);
|
|
1670
|
+
const setScreen = useWizardStore((s) => s.setScreen);
|
|
1671
|
+
const setFatal = useWizardStore((s) => s.setFatal);
|
|
1672
|
+
useEffect(() => {
|
|
1673
|
+
if (agentStatus !== "idle") return;
|
|
1674
|
+
if (!oauth) {
|
|
1675
|
+
setFatal("Reached run screen without OAuth tokens — restart wizard.");
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
let cancelled = false;
|
|
1679
|
+
setAgentStatus("running");
|
|
1680
|
+
runAgent({
|
|
1681
|
+
cwd: process.cwd(),
|
|
1682
|
+
oauthToken: oauth.accessToken,
|
|
1683
|
+
siteId: oauth.siteId
|
|
1684
|
+
}).then((result) => {
|
|
1685
|
+
if (cancelled) return;
|
|
1686
|
+
setAgentSummary(result.message);
|
|
1687
|
+
if (result.ok) {
|
|
1688
|
+
setAgentStatus("succeeded");
|
|
1689
|
+
setScreen("done");
|
|
1690
|
+
} else {
|
|
1691
|
+
setAgentStatus("failed");
|
|
1692
|
+
setFatal(result.message);
|
|
1693
|
+
}
|
|
1694
|
+
}).catch((err) => {
|
|
1695
|
+
if (cancelled) return;
|
|
1696
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1697
|
+
setAgentStatus("failed");
|
|
1698
|
+
setFatal(`Agent crashed: ${msg}`);
|
|
1699
|
+
});
|
|
1700
|
+
return () => {
|
|
1701
|
+
cancelled = true;
|
|
1702
|
+
};
|
|
1703
|
+
}, []);
|
|
1704
|
+
const visibleLines = agentProgress.slice(-12);
|
|
1705
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1706
|
+
flexDirection: "column",
|
|
1707
|
+
gap: 1,
|
|
1708
|
+
children: [
|
|
1709
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Spinner, { label: agentProgress[agentProgress.length - 1] ?? "Starting agent…" }) }),
|
|
1710
|
+
visibleLines.length > 1 ? /* @__PURE__ */ jsx(Box, {
|
|
1711
|
+
flexDirection: "column",
|
|
1712
|
+
marginTop: 1,
|
|
1713
|
+
children: visibleLines.slice(0, -1).map((line, idx) => /* @__PURE__ */ jsx(Text, {
|
|
1714
|
+
color: "gray",
|
|
1715
|
+
children: line
|
|
1716
|
+
}, idx))
|
|
1717
|
+
}) : null,
|
|
1718
|
+
/* @__PURE__ */ jsx(Box, {
|
|
1719
|
+
marginTop: 1,
|
|
1720
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
1721
|
+
color: "gray",
|
|
1722
|
+
children: "Ctrl-C to abort"
|
|
1723
|
+
})
|
|
1724
|
+
})
|
|
1725
|
+
]
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
//#endregion
|
|
1730
|
+
//#region src/ui/screens/done-screen.tsx
|
|
1731
|
+
/**
|
|
1732
|
+
* Terminal state after a successful wizard run. Reached when
|
|
1733
|
+
* `runAgent` returns `ok: true` — meaning `run_doctor` returned
|
|
1734
|
+
* `summary.ok === true` AND `report_failure` was never called
|
|
1735
|
+
* (see runtime.ts for the gate).
|
|
1736
|
+
*
|
|
1737
|
+
* Shows the agent's own summary message so the customer sees what
|
|
1738
|
+
* actually got installed / verified, not a generic "connected".
|
|
1739
|
+
*
|
|
1740
|
+
* Press any key to exit. Auto-exits after 15s — longer than the
|
|
1741
|
+
* error-screen's 8s because customers need time to read the
|
|
1742
|
+
* pass/fail breakdown and notice any info-level recommendations.
|
|
1743
|
+
*/
|
|
1744
|
+
function DoneScreen() {
|
|
1745
|
+
const { exit } = useApp();
|
|
1746
|
+
const oauth = useWizardStore((s) => s.oauth);
|
|
1747
|
+
const agentSummary = useWizardStore((s) => s.agentSummary);
|
|
1748
|
+
useInput(() => {
|
|
1749
|
+
exit();
|
|
1750
|
+
});
|
|
1751
|
+
useEffect(() => {
|
|
1752
|
+
const timer = setTimeout(() => exit(), 15e3);
|
|
1753
|
+
return () => clearTimeout(timer);
|
|
1754
|
+
}, [exit]);
|
|
1755
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1756
|
+
flexDirection: "column",
|
|
1757
|
+
gap: 1,
|
|
1758
|
+
children: [
|
|
1759
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1760
|
+
bold: true,
|
|
1761
|
+
color: "green",
|
|
1762
|
+
children: "✅ smking installed"
|
|
1763
|
+
}),
|
|
1764
|
+
agentSummary ? /* @__PURE__ */ jsx(Text, { children: agentSummary }) : null,
|
|
1765
|
+
oauth?.siteId ? /* @__PURE__ */ jsxs(Text, {
|
|
1766
|
+
color: "gray",
|
|
1767
|
+
children: [
|
|
1768
|
+
"Site: ",
|
|
1769
|
+
oauth.siteId,
|
|
1770
|
+
" · token expires in",
|
|
1771
|
+
" ",
|
|
1772
|
+
Math.round(oauth.expiresIn / 60),
|
|
1773
|
+
" minutes"
|
|
1774
|
+
]
|
|
1775
|
+
}) : null,
|
|
1776
|
+
/* @__PURE__ */ jsx(Box, {
|
|
1777
|
+
marginTop: 1,
|
|
1778
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
1779
|
+
color: "gray",
|
|
1780
|
+
children: "Press any key to exit (auto-exit in 15s)."
|
|
1781
|
+
})
|
|
1782
|
+
})
|
|
1783
|
+
]
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
//#endregion
|
|
1788
|
+
//#region src/ui/screens/error-screen.tsx
|
|
1789
|
+
/**
|
|
1790
|
+
* Fatal-error terminal screen. Any `setFatal()` call from anywhere
|
|
1791
|
+
* in the wizard routes here. Shows the error message and exits 1
|
|
1792
|
+
* on key press / auto-exit so callers can detect failure.
|
|
1793
|
+
*/
|
|
1794
|
+
function ErrorScreen() {
|
|
1795
|
+
const { exit } = useApp();
|
|
1796
|
+
const fatal = useWizardStore((s) => s.fatal);
|
|
1797
|
+
useInput(() => {
|
|
1798
|
+
exit(new Error(fatal ?? "wizard failed"));
|
|
1799
|
+
});
|
|
1800
|
+
useEffect(() => {
|
|
1801
|
+
const timer = setTimeout(() => exit(new Error(fatal ?? "wizard failed")), 8e3);
|
|
1802
|
+
return () => clearTimeout(timer);
|
|
1803
|
+
}, [exit, fatal]);
|
|
1804
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1805
|
+
flexDirection: "column",
|
|
1806
|
+
gap: 1,
|
|
1807
|
+
children: [
|
|
1808
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1809
|
+
bold: true,
|
|
1810
|
+
color: "red",
|
|
1811
|
+
children: "❌ Wizard failed"
|
|
1812
|
+
}),
|
|
1813
|
+
/* @__PURE__ */ jsx(Text, { children: fatal ?? "Unknown error" }),
|
|
1814
|
+
/* @__PURE__ */ jsx(Box, {
|
|
1815
|
+
marginTop: 1,
|
|
1816
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
1817
|
+
color: "gray",
|
|
1818
|
+
children: [
|
|
1819
|
+
"Press any key to exit (auto-exit in 8s). Re-run",
|
|
1820
|
+
" ",
|
|
1821
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1822
|
+
color: "cyan",
|
|
1823
|
+
children: "npx @soloworks/smking-wizard"
|
|
1824
|
+
}),
|
|
1825
|
+
" after fixing the issue."
|
|
1826
|
+
]
|
|
1827
|
+
})
|
|
1828
|
+
})
|
|
1829
|
+
]
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
//#endregion
|
|
1834
|
+
//#region src/ui/app.tsx
|
|
1835
|
+
/**
|
|
1836
|
+
* Root component. Routes by `store.screen` and renders the matching
|
|
1837
|
+
* full-screen view. Each screen owns its own lifecycle (useEffect)
|
|
1838
|
+
* — App.tsx itself is a switch, nothing more.
|
|
1839
|
+
*/
|
|
1840
|
+
function App() {
|
|
1841
|
+
const screen = useWizardStore((s) => s.screen);
|
|
1842
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1843
|
+
flexDirection: "column",
|
|
1844
|
+
paddingX: 1,
|
|
1845
|
+
paddingY: 1,
|
|
1846
|
+
children: [
|
|
1847
|
+
screen === "welcome" && /* @__PURE__ */ jsx(WelcomeScreen, {}),
|
|
1848
|
+
screen === "oauth" && /* @__PURE__ */ jsx(OAuthScreen, {}),
|
|
1849
|
+
screen === "run" && /* @__PURE__ */ jsx(RunScreen, {}),
|
|
1850
|
+
screen === "done" && /* @__PURE__ */ jsx(DoneScreen, {}),
|
|
1851
|
+
screen === "error" && /* @__PURE__ */ jsx(ErrorScreen, {})
|
|
1852
|
+
]
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
//#endregion
|
|
1857
|
+
//#region src/bin.ts
|
|
1858
|
+
/**
|
|
1859
|
+
* `npx @soloworks/smking-wizard` entry point.
|
|
1860
|
+
*
|
|
1861
|
+
* Phase 3 scope: parse args, check Node version, render the ink TUI.
|
|
1862
|
+
* Phase 4+ adds framework detection / installer dispatch / agent
|
|
1863
|
+
* loop — those plug into the TUI via the zustand store, not via new
|
|
1864
|
+
* command-line subcommands. The wizard is single-command on purpose
|
|
1865
|
+
* (PostHog model): one `npx @soloworks/smking-wizard` does everything.
|
|
1866
|
+
*/
|
|
1867
|
+
function checkNodeVersion() {
|
|
1868
|
+
const [maj, min] = process.versions.node.split(".").map(Number);
|
|
1869
|
+
if (maj < MIN_NODE_MAJOR || maj === MIN_NODE_MAJOR && min < MIN_NODE_MINOR) {
|
|
1870
|
+
process.stderr.write(`smking wizard requires Node ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+. You have ${process.versions.node}.\n`);
|
|
1871
|
+
process.exit(1);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
async function main() {
|
|
1875
|
+
checkNodeVersion();
|
|
1876
|
+
const argv = await yargs(hideBin(process.argv)).scriptName("smking-wizard").usage("$0 [options]").option("debug", {
|
|
1877
|
+
type: "boolean",
|
|
1878
|
+
default: false,
|
|
1879
|
+
describe: "Enable verbose debug output"
|
|
1880
|
+
}).option("dry-run", {
|
|
1881
|
+
type: "boolean",
|
|
1882
|
+
default: false,
|
|
1883
|
+
describe: "Print intended changes without writing files (Phase 4+)"
|
|
1884
|
+
}).option("allow-prod", {
|
|
1885
|
+
type: "boolean",
|
|
1886
|
+
default: false,
|
|
1887
|
+
describe: "Override the production-environment refusal (use only if you know why)"
|
|
1888
|
+
}).option("allow-dirty", {
|
|
1889
|
+
type: "boolean",
|
|
1890
|
+
default: false,
|
|
1891
|
+
describe: "Override the git-status-must-be-clean check (use only if you know why)"
|
|
1892
|
+
}).version(WIZARD_VERSION).help().strict().parse();
|
|
1893
|
+
if (argv.debug) process.env.SMKING_WIZARD_DEBUG = "1";
|
|
1894
|
+
useWizardStore.getState().setCliFlags({
|
|
1895
|
+
allowProd: !!argv["allow-prod"],
|
|
1896
|
+
allowDirty: !!argv["allow-dirty"],
|
|
1897
|
+
dryRun: !!argv["dry-run"],
|
|
1898
|
+
debug: !!argv.debug
|
|
1899
|
+
});
|
|
1900
|
+
const { waitUntilExit } = render(createElement(App));
|
|
1901
|
+
try {
|
|
1902
|
+
await waitUntilExit();
|
|
1903
|
+
return 0;
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
1906
|
+
return 1;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
main().then((code) => process.exit(code), (err) => {
|
|
1910
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1911
|
+
process.exit(1);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
//#endregion
|
|
1915
|
+
export { };
|
|
1916
|
+
//# sourceMappingURL=bin.mjs.map
|