@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/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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