@saaspro/wizard 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @saaspro/wizard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Node 18+ setup CLI with no runtime dependencies. Run in your project directory:
4
+
5
+ ```sh
6
+ npx @saaspro/wizard
7
+ curl -fsSL https://saaspro.dev/wizard.mjs | node --input-type=module
8
+ ```
9
+
10
+ No stdin reads or terminal prompts. Confirm the terminal code in your browser,
11
+ sign in with a passkey and choose your organization, application and environment.
12
+ Approval requires keys.write and a fresh passkey. It creates a public key labelled
13
+ Wizard and encrypts the one-time reveal to an RSA key generated in the CLI. The
14
+ private key stays in memory. Codes expire in ten minutes and are single use.
15
+
16
+ Flags: --consent <granted|pending>, --host <origin>, --dry-run, --key <spm_pub_...>, --no-install, --no-mcp, --no-open,
17
+ --help. Dry runs still need approval unless --key is given; no files, packages
18
+ or verification events change. HTTPS is required except for loopback development.
19
+ Secret keys are rejected. The public key is printed once; later output omits it.
20
+
21
+ Detects Next.js App/Pages Router, Remix/React Router framework mode, Vite/React,
22
+ SvelteKit, Nuxt, Vue with Vite, Astro, plain Svelte, Expo/React Native, Node and
23
+ Python. Meta-frameworks take precedence. Uses your lockfile to choose npm, pnpm,
24
+ Yarn or Bun. Writes .env.local for Next.js or .env otherwise and adds it to
25
+ .gitignore. Conflicting env values cause a refusal.
26
+
27
+ For one unambiguous entry and render point, imports the generated provider and
28
+ wraps Next.js layout children or the Pages Router Component, React's App render
29
+ root, or Remix/React Router's Outlet. Providers live beside that entry, except
30
+ Pages Router providers live outside pages/ so they do not become routes. Vue gets
31
+ browser SDK initialization before mount. SvelteKit gets src/hooks.client.ts/js;
32
+ existing hooks are preserved. Astro gets src/components/SaaSPro.astro and an
33
+ import/component insertion in a single src/layouts layout containing </head>.
34
+ Existing providers, multiple roots/matches or unusual syntax print manual steps;
35
+ source is not reformatted. Repeated runs do not add duplicate wrappers.
36
+
37
+ Nuxt gets plugins/saaspro.client.ts/js using useRuntimeConfig().public.spmKey,
38
+ with NUXT_PUBLIC_SPM_KEY. Merge the printed runtimeConfig.public.spmKey and
39
+ explicit plugin registration into nuxt.config; the wizard never edits that
40
+ config. Explicit registration also supports Nuxt 4's app/plugins convention.
41
+ SvelteKit uses PUBLIC_SPM_KEY from $env/static/public (deprecated in SvelteKit 3,
42
+ removed in 4 per current docs). Vue/Vite and Remix use VITE_SPM_KEY; Astro uses
43
+ PUBLIC_SPM_KEY. Legacy Remix must use Vite. Plain Svelte gets manual setup.
44
+
45
+ Without a consent manager, generated browser configuration omits consent,
46
+ using the SDK's granted default. Recognized managers (vanilla-cookieconsent,
47
+ cookieconsent, react-cookie-consent, @cookiehub/*, klaro, @osano/*, @onetrust/*,
48
+ cookiebot, @iubenda/*, tarteaucitronjs, @c15t/*, @consentmanager/*) generate
49
+ consent: "pending". Wire analytics acceptance and saved consent to
50
+ spm.consent("granted"), and rejection/withdrawal to spm.consent("revoked"). In
51
+ React use the client from useSaaSProMax(); mobile uses analytics.setConsent().
52
+ --consent granted or --consent pending overrides detection. Pending holds events
53
+ in memory until granted. Persistent identity remains enabled for identify and
54
+ revenue attribution; ephemeral mode is never selected by default.
55
+
56
+ Next.js App Router uses the route-aware SPMPageview component. Other browser
57
+ integrations use SDK auto page views for pushState, replaceState and popstate,
58
+ so client-side navigation works without reading location only at mount.
59
+
60
+ Node uses @saaspro/node/native for public-key manual events. Python's SDK requires
61
+ a separate secret key, so the wizard installs the SDK and prints explicit manual
62
+ initialization instructions, without writing SPM_INGEST_KEY. It still verifies the
63
+ public key. Mobile storage, randomness, lifecycle and consent wiring remain
64
+ explicit in the printed instructions. Review imports and consent before deployment.
65
+
66
+ Hosted MCP is merged into .mcp.json for Claude Code and .cursor/mcp.json for Cursor.
67
+ Other settings stay intact. A conflicting saaspro entry is refused. These files
68
+ contain a URL, not tokens; complete separate browser OAuth in your MCP client.
69
+
70
+ Finally sends spm.wizard.verified and requires one accepted event. This proves
71
+ CLI collection; verify your application's real events after completing the printed steps.
72
+ The public download is byte-identical to the packaged ESM source.
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ import { constants, generateKeyPairSync, privateDecrypt, randomUUID } from "node:crypto";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, posix, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ class WizardError extends Error {}
9
+
10
+ const VERSION = "0.2.0";
11
+ const PUBLIC_KEY = /^spm_pub_(?:prod|stg|dev)_[A-Za-z0-9]{22}$/;
12
+ const HELP = `SaaS Pro Max wizard
13
+
14
+ Run in your project directory:
15
+ npx @saaspro/wizard
16
+ curl -fsSL https://saaspro.dev/wizard.mjs | node --input-type=module
17
+
18
+ Options:
19
+ --host <url> API origin (default https://saaspro.dev)
20
+ --dry-run Preview file changes and commands; device approval still runs
21
+ --key <key> Use a public ingest key and skip device approval
22
+ --consent <granted|pending> Override consent-manager detection
23
+ --no-install Skip SDK installation
24
+ --no-mcp Skip Claude Code and Cursor MCP configuration
25
+ --no-open Print the approval link without opening a browser
26
+ --help Show this help
27
+
28
+ Frameworks: Next.js (App/Pages), Remix/React Router, Vite/React, SvelteKit,
29
+ Nuxt, Vue, Astro, Svelte (manual), Expo/React Native, Node and Python.
30
+ No terminal prompts or stdin reads. Only public keys are accepted.
31
+ `;
32
+
33
+ export function detectFramework(pkg, files = []) {
34
+ const deps = { ...pkg?.devDependencies, ...pkg?.dependencies };
35
+ if (deps.next) return "nextjs";
36
+ if (deps["@remix-run/react"] || deps["@react-router/dev"] || (deps["react-router"] && files.some(path => /^app\/root\.(tsx|jsx|ts|js)$/.test(path)))) return "remix";
37
+ if (deps["@sveltejs/kit"]) return "sveltekit";
38
+ if (deps.nuxt) return "nuxt";
39
+ if (deps.astro) return "astro";
40
+ if (deps.vue) return "vue";
41
+ if (deps.svelte) return "svelte";
42
+ if (deps.expo) return "expo";
43
+ if (deps["react-native"]) return "react-native";
44
+ if (deps.react) return deps.vite ? "vite-react" : "react";
45
+ if (files.includes("pyproject.toml") || files.includes("requirements.txt")) return "python";
46
+ return pkg ? "node" : "unknown";
47
+ }
48
+
49
+ export function detectPackageManager(files) {
50
+ if (files.includes("pnpm-lock.yaml")) return "pnpm";
51
+ if (files.includes("yarn.lock")) return "yarn";
52
+ if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun";
53
+ return "npm";
54
+ }
55
+
56
+ function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
57
+
58
+ export function mergeMcpConfig(existing, endpoint, client = "claude") {
59
+ if (!object(existing) || (existing.mcpServers !== undefined && !object(existing.mcpServers))) throw new WizardError("MCP config must contain a JSON object and an object-valued mcpServers");
60
+ const servers = existing.mcpServers ?? {};
61
+ if (Object.hasOwn(servers, "saaspro")) {
62
+ if (!object(servers.saaspro) || servers.saaspro.url !== endpoint) throw new WizardError("The saaspro MCP entry already exists with different settings; merge it manually");
63
+ return existing;
64
+ }
65
+ return { ...existing, mcpServers: { ...servers, saaspro: client === "claude" ? { type: "http", url: endpoint } : { url: endpoint } } };
66
+ }
67
+
68
+ export function mergeEnv(existing, values) {
69
+ let result = existing;
70
+ for (const [name, value] of Object.entries(values)) {
71
+ const found = new RegExp(`^(?:export\\s+)?${name}\\s*=.*$`, "m").exec(result);
72
+ if (found) {
73
+ const old = found[0].slice(found[0].indexOf("=") + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
74
+ if (old !== value) throw new WizardError(`${name} already exists with a different value; preserve it and review the new public key before replacing it`);
75
+ continue;
76
+ }
77
+ result += `${result && !result.endsWith("\n") ? "\n" : ""}${name}=${value}\n`;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ export function parseFlags(args) {
83
+ const options = { host: "https://saaspro.dev", dryRun: false, install: true, mcp: true, open: true, key: null, consent: null, help: false };
84
+ for (let i = 0; i < args.length; i++) {
85
+ const flag = args[i];
86
+ if (flag === "--help") options.help = true;
87
+ else if (flag === "--dry-run") options.dryRun = true;
88
+ else if (flag === "--no-install") options.install = false;
89
+ else if (flag === "--no-mcp") options.mcp = false;
90
+ else if (flag === "--no-open") options.open = false;
91
+ else if (flag === "--host" || flag === "--key" || flag === "--consent") {
92
+ const value = args[++i];
93
+ if (!value || value.startsWith("--")) throw new WizardError(`${flag} requires a value`);
94
+ options[flag.slice(2)] = value;
95
+ } else throw new WizardError("Unknown option. Use --help for supported flags.");
96
+ }
97
+ if (options.consent && !["granted", "pending"].includes(options.consent)) throw new WizardError("--consent must be granted or pending");
98
+ const url = new URL(options.host);
99
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) throw new WizardError("Host must be an HTTPS origin or a loopback HTTP origin");
100
+ options.host = url.origin;
101
+ if (options.key && !PUBLIC_KEY.test(options.key)) throw new WizardError("--key requires a public ingest key; secret keys are never accepted");
102
+ return options;
103
+ }
104
+
105
+ async function post(url, body) {
106
+ const response = await fetch(url, { method: "POST", redirect: "error", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
107
+ let data;
108
+ try { data = await response.json(); } catch { throw new WizardError(`Invalid API response (HTTP ${response.status})`); }
109
+ if (!response.ok && !(response.status === 429 && data.status === "slow_down")) throw new WizardError(`Request failed (HTTP ${response.status}); check the host and console permissions`);
110
+ return data;
111
+ }
112
+
113
+ function openBrowser(url) {
114
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
115
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
116
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
117
+ child.on("error", () => {});
118
+ child.unref();
119
+ }
120
+
121
+ async function deviceLogin(host, framework, open) {
122
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
123
+ const started = await post(`${host}/api/v1/wizard/device`, { publicKey: publicKey.export({ type: "spki", format: "pem" }), client: { name: "@saaspro/wizard", version: VERSION, framework } });
124
+ const verification = new URL(started.verificationUrl);
125
+ if (verification.origin !== host || verification.pathname !== "/device" || typeof started.deviceCode !== "string" || !/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/.test(started.userCode)) throw new WizardError("Invalid device approval response");
126
+ console.log(`Confirm code ${started.userCode} in your browser:\n${verification.href}\nChoose your organization, application and environment, then approve.`);
127
+ if (open) openBrowser(verification.href);
128
+ const deadline = Date.now() + Math.min(Number(started.expiresIn) || 600, 600) * 1000;
129
+ let interval = Math.max(3, Number(started.interval) || 3);
130
+ while (Date.now() < deadline) {
131
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
132
+ const token = await post(`${host}/api/v1/wizard/device/token`, { deviceCode: started.deviceCode });
133
+ if (token.status === "pending") continue;
134
+ if (token.status === "slow_down") { interval = Math.max(interval + 1, Number(token.interval) || 3); continue; }
135
+ if (token.status !== "approved") throw new WizardError(`Device approval ${["denied", "expired", "consumed"].includes(token.status) ? token.status : "failed"}; start the wizard again`);
136
+ const key = privateDecrypt({ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(token.ciphertext, "base64")).toString();
137
+ if (!PUBLIC_KEY.test(key) || new URL(token.ingestUrl).origin !== host || new URL(token.consoleUrl).origin !== host) throw new WizardError("Invalid device token response");
138
+ return { ...token, key };
139
+ }
140
+ throw new WizardError("Device approval expired; start the wizard again");
141
+ }
142
+
143
+ function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); }
144
+
145
+ function noSymlinks(root, relative) {
146
+ let path = root;
147
+ for (const part of relative.split(/[\\/]/)) {
148
+ path = join(path, part);
149
+ try {
150
+ if (lstatSync(path).isSymbolicLink()) throw new WizardError(`Refusing to write through a symbolic link: ${relative}`);
151
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
152
+ }
153
+ }
154
+
155
+ function writePlanned(root, path, text, options) {
156
+ noSymlinks(root, path);
157
+ console.log(`${options.dryRun ? "Would write" : "Write"} ${path}`);
158
+ if (!options.dryRun) {
159
+ mkdirSync(dirname(join(root, path)), { recursive: true });
160
+ writeFileSync(join(root, path), text, { mode: 0o600 });
161
+ }
162
+ }
163
+
164
+ function installPackages(manager, packages, options) {
165
+ if (!packages.length) return;
166
+ const args = [manager === "npm" ? "install" : "add", ...packages];
167
+ console.log(`${options.dryRun || !options.install ? "Run manually:" : "Run:"} ${manager} ${args.join(" ")}`);
168
+ if (options.install && !options.dryRun) execFileSync(manager, args, { stdio: ["ignore", "inherit", "inherit"] });
169
+ }
170
+
171
+ export const CONSENT_MANAGERS = [
172
+ "vanilla-cookieconsent", "cookieconsent", "react-cookie-consent", "@cookiehub/*",
173
+ "klaro", "@osano/*", "@onetrust/*", "cookiebot", "@iubenda/*", "tarteaucitronjs",
174
+ "@c15t/*", "@consentmanager/*",
175
+ ];
176
+
177
+ export function detectConsentManagers(pkg) {
178
+ return Object.keys({ ...pkg?.devDependencies, ...pkg?.dependencies }).filter(name =>
179
+ CONSENT_MANAGERS.some(pattern => pattern.endsWith("/*") ? name.startsWith(pattern.slice(0, -1)) : name === pattern));
180
+ }
181
+
182
+ const JSX_EXTENSIONS = ["tsx", "jsx", "js", "ts"];
183
+ const ROOTS = ["app/layout", "src/app/layout", "pages/_app", "src/pages/_app", "app/root", "src/main", "src/index"];
184
+ const ENTRY_PATHS = ROOTS.flatMap(path => JSX_EXTENSIONS.map(ext => `${path}.${ext}`));
185
+
186
+ function projectPaths(root) {
187
+ const paths = [...ENTRY_PATHS, "tsconfig.json", "src/hooks.client.ts", "src/hooks.client.js",
188
+ "plugins/saaspro.client.ts", "plugins/saaspro.client.js", "src/components/SaaSPro.astro"]
189
+ .filter(path => existsSync(join(root, path)));
190
+ const walk = directory => {
191
+ if (!existsSync(join(root, directory)) || lstatSync(join(root, directory)).isSymbolicLink()) return;
192
+ for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
193
+ const path = `${directory}/${entry.name}`;
194
+ if (entry.isDirectory()) walk(path);
195
+ else if (entry.isFile() && path.endsWith(".astro")) paths.push(path);
196
+ }
197
+ };
198
+ walk("src/layouts");
199
+ return paths;
200
+ }
201
+
202
+ export function integrationPlan(framework, host, paths, consent = null) {
203
+ const consentOption = consent ? `, consent: "${consent}"` : "";
204
+ const single = candidates => candidates.length === 1 ? candidates[0] : null;
205
+ const entries = pattern => paths.filter(path => pattern.test(path));
206
+ if (["nextjs", "vite-react", "react", "remix"].includes(framework)) {
207
+ const next = framework === "nextjs";
208
+ const appEntries = entries(/^(?:src\/)?app\/layout\.(tsx|jsx|js|ts)$/);
209
+ const candidates = next ? [...appEntries, ...entries(/^(?:src\/)?pages\/_app\.(tsx|jsx|js|ts)$/)]
210
+ : framework === "remix" ? entries(/^app\/root\.(tsx|jsx|js|ts)$/) : entries(/^src\/(?:main|index)\.(tsx|jsx|js|ts)$/);
211
+ const entry = single(candidates);
212
+ const appRouter = next && !!entry && appEntries.includes(entry);
213
+ const typed = entry ? /\.tsx?$/.test(entry) : paths.includes("tsconfig.json");
214
+ const directory = entry ? posix.dirname(entry) : null;
215
+ const providerDirectory = next && entry && !appRouter ? posix.dirname(directory) : directory;
216
+ const target = providerDirectory ? `${providerDirectory === "." ? "" : `${providerDirectory}/`}saaspro-provider.${typed ? "tsx" : "jsx"}` : null;
217
+ const providerImport = next && entry && !appRouter ? "../saaspro-provider" : "./saaspro-provider";
218
+ const env = next ? "NEXT_PUBLIC_SPM_KEY" : framework === "react" ? "REACT_APP_SPM_KEY" : "VITE_SPM_KEY";
219
+ const key = next || framework === "react" ? `process.env.${env}${typed ? "!" : ""}` : `import.meta.env.${env}`;
220
+ const code = `"use client";\n${typed ? 'import type { ReactNode } from "react";\n' : ""}import { SaaSProMaxProvider } from "@saaspro/react";\n${appRouter ? 'import { SPMPageview } from "@saaspro/react/next";\n' : ""}\nexport function SaaSProProvider({ children }${typed ? ": { children: ReactNode }" : ""}) {\n return <SaaSProMaxProvider config={{ key: ${key}, host: ${JSON.stringify(host)}, autoPageviews: ${!appRouter}${consentOption} }}>\n ${appRouter ? "<SPMPageview />\n " : ""}{children}\n </SaaSProMaxProvider>;\n}\n`;
221
+ const wrap = next ? appRouter ? "children" : "component" : framework === "remix" ? "outlet" : "app";
222
+ return { packages: ["@saaspro/react"], env, file: next ? ".env.local" : ".env", target, providerImport, entry, wrap, code,
223
+ instructions: `Manual setup if automatic wiring is skipped: place ${target ?? `saaspro-provider.${typed ? "tsx" : "jsx"}`} ${next && !appRouter ? "outside the pages directory" : "beside your root component"}, import { SaaSProProvider } from "${providerImport}", and wrap ${wrap === "children" ? "{children} inside <body>" : wrap === "component" ? "<Component {...pageProps} /> in pages/_app" : wrap === "outlet" ? "<Outlet /> in app/root" : "<App /> in src/main or src/index"} with <SaaSProProvider>...</SaaSProProvider>.${next && !entry ? " For App Router use SPMPageview from @saaspro/react/next with autoPageviews: false; for Pages Router keep autoPageviews: true." : ""}${framework === "remix" ? " VITE_SPM_KEY requires Remix Vite or React Router framework mode; migrate a legacy Remix compiler before using this code." : ""}` };
224
+ }
225
+ if (["sveltekit", "nuxt", "vue", "astro", "svelte"].includes(framework)) {
226
+ const typed = paths.includes("tsconfig.json") || paths.some(path => /(?:main|hooks\.client)\.ts$/.test(path));
227
+ const ext = typed ? "ts" : "js";
228
+ const env = framework === "nuxt" ? "NUXT_PUBLIC_SPM_KEY" : ["sveltekit", "astro"].includes(framework) ? "PUBLIC_SPM_KEY" : "VITE_SPM_KEY";
229
+ const key = framework === "sveltekit" ? "PUBLIC_SPM_KEY" : framework === "nuxt" ? "config.public.spmKey" : `import.meta.env.${env}`;
230
+ const init = `spm.init({ key: ${key}, host: ${JSON.stringify(host)}, autoPageviews: true${consentOption} });`;
231
+ let code = `import { spm } from "@saaspro/browser";\n${init}\n`;
232
+ let target = null, entry = null, wrap = null;
233
+ let instructions = "";
234
+ if (framework === "sveltekit") {
235
+ const hooks = entries(/^src\/hooks\.client\.(ts|js)$/);
236
+ target = hooks.length ? single(hooks) : `src/hooks.client.${ext}`;
237
+ code = `import { PUBLIC_SPM_KEY } from "$env/static/public";\n${code}`;
238
+ instructions = "If hooks.client already exists, merge this initialization into it once, preserving existing hooks. $env/static/public is supported by SvelteKit 2/3 (deprecated in 3); for SvelteKit 4 follow its environment migration guide.";
239
+ } else if (framework === "nuxt") {
240
+ const plugins = entries(/^plugins\/saaspro\.client\.(ts|js)$/);
241
+ target = plugins.length ? single(plugins) : `plugins/saaspro.client.${ext}`;
242
+ code = `import { defineNuxtPlugin, useRuntimeConfig } from "#app";\nimport { spm } from "@saaspro/browser";\n\nexport default defineNuxtPlugin(() => {\n const config = useRuntimeConfig();\n ${init}\n});\n`;
243
+ instructions = `Merge into nuxt.config (the wizard does not edit it):\nexport default defineNuxtConfig({\n runtimeConfig: { public: { spmKey: "" } },\n plugins: ["~~/plugins/saaspro.client.${ext}"],\n});\nPreserve existing config and plugins. NUXT_PUBLIC_SPM_KEY overrides public.spmKey. Explicit registration also supports Nuxt 4, whose default auto-scanned directory is app/plugins. Set NUXT_PUBLIC_SPM_KEY in the production runtime environment; .env is for development/build.`;
244
+ } else if (framework === "astro") {
245
+ target = "src/components/SaaSPro.astro";
246
+ code = `<script>\n import { spm } from "@saaspro/browser";\n ${init}\n</script>\n`;
247
+ wrap = "astro";
248
+ instructions = 'Manual setup if automatic wiring is skipped: import SaaSPro from the relative path to src/components/SaaSPro.astro in your layout frontmatter, then add <SaaSPro /> before </head>.';
249
+ } else if (framework === "vue") {
250
+ entry = single(entries(/^src\/main\.(ts|js)$/));
251
+ wrap = "vue";
252
+ instructions = `Manual setup if automatic wiring is skipped: add this code once in src/main.${ext}, before createApp(...).mount(...) or app.mount(...).`;
253
+ } else {
254
+ instructions = "Import this initialization once in your browser entry (src/main.ts or src/main.js), before mounting Svelte. No source file is automatically edited for plain Svelte.";
255
+ }
256
+ return { packages: ["@saaspro/browser"], env, file: ".env", target, entry, wrap, code, instructions };
257
+ }
258
+ if (framework === "expo" || framework === "react-native") {
259
+ const expo = framework === "expo";
260
+ const code = `${expo ? 'import * as Crypto from "expo-crypto";' : 'import "react-native-get-random-values";\nimport { v4 as randomUUID } from "uuid";'}\nimport AsyncStorage from "@react-native-async-storage/async-storage";\nimport { AppState, Platform } from "react-native";\nimport { SaaSProMobile } from "@saaspro/react-native";\n\nexport const analytics = new SaaSProMobile({\n key: ${expo ? "process.env.EXPO_PUBLIC_SPM_KEY" : '"<public key from .env: SPM_PUBLIC_KEY>"'},\n host: ${JSON.stringify(host)}, platform: Platform.OS, storage: AsyncStorage,\n randomUUID: ${expo ? "Crypto.randomUUID" : "randomUUID"}, appState: AppState${consentOption}, privacyMode: "persistent",\n});\n`;
261
+ return { packages: ["@saaspro/react-native"], env: expo ? "EXPO_PUBLIC_SPM_KEY" : "SPM_PUBLIC_KEY", file: ".env", target: null, code, instructions: `${expo ? "Run npx expo install expo-crypto @react-native-async-storage/async-storage." : "Install react-native-get-random-values, uuid and @react-native-async-storage/async-storage, then supply SPM_PUBLIC_KEY through your existing native configuration loader."} Put this code in your analytics module, wrap your app in SaaSProMobileProvider, and connect existing consent controls to analytics.setConsent("granted"). Rebuild native dependencies where required.` };
262
+ }
263
+ if (framework === "node") {
264
+ const code = `import { randomUUID } from "node:crypto";\nimport { SaaSProNative } from "@saaspro/node/native";\n\nexport const spm = new SaaSProNative({ key: process.env.SPM_PUBLIC_KEY, host: ${JSON.stringify(host)}, randomUUID });\nspm.track({ event: "app.started", properties: { source: "wizard" } });\n// Call spm.shutdown() from your application's existing graceful shutdown handler.\n`;
265
+ return { packages: ["@saaspro/node"], env: "SPM_PUBLIC_KEY", file: ".env", target: "saaspro-init.mjs", code, instructions: "Import ./saaspro-init.mjs from your entry point. Load .env with your existing environment loader before this import. The public-key native transport supports manual events only; do not add IP or user-agent context." };
266
+ }
267
+ if (framework === "python") {
268
+ return { packages: [], env: "SPM_PUBLIC_KEY", file: ".env", target: null, code: `import os\nfrom saaspro import SaaSProMax\n\n# Create a separate secret key in Settings -> API keys and load it from your server's secret manager.\nspm = SaaSProMax(os.environ["SPM_INGEST_KEY"], host=os.environ["SPM_HOST"])\nspm.track("app.started", {"source": "wizard"})\nspm.flush(timeout=10)\n`, instructions: `Install: python -m pip install ${host}/downloads/saaspro-0.1.0.tar.gz\nThe Python SDK requires a secret key. The wizard only provisions a public key, so it does not initialize the Python SDK or write SPM_INGEST_KEY. Set that separately in your secret manager. The wizard verification event uses the public key.` };
269
+ }
270
+ return { packages: [], env: "SPM_PUBLIC_KEY", file: ".env", target: null, code: `<script defer src="${host}/spm.js" data-key="<public key from .env: SPM_PUBLIC_KEY>" data-host="${host}"${consent ? ` data-consent="${consent}"` : ""}></script>`, instructions: "No supported framework was detected. Use the script tag for a website or open the Install guide for your framework. Preserve your site's consent controls." };
271
+ }
272
+
273
+ // Mask comments and literals so examples in them cannot become edit targets.
274
+ function sourceMask(code) {
275
+ return code.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g,
276
+ value => value.replace(/[^\r\n]/g, " "));
277
+ }
278
+
279
+ function sourceStyle(code) {
280
+ const statement = code.match(/^\s*import\s+[^\r\n]*?(["'])[^"']+\1[ \t]*(;?)/m);
281
+ return { quote: statement?.[1] ?? '"', semi: statement ? statement[2] : ";", newline: code.includes("\r\n") ? "\r\n" : "\n" };
282
+ }
283
+
284
+ function addImport(code, statement, newline) {
285
+ const prefix = code.match(/^(?:(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:["']use (?:client|strict|server)["'];?\s*))*/)?.[0] ?? "";
286
+ return `${prefix}${statement}${newline}${code.slice(prefix.length)}`;
287
+ }
288
+
289
+ export function integrationEdit(root, plan, paths) {
290
+ if (!plan.wrap) return null;
291
+ if (plan.wrap === "astro") {
292
+ const candidates = paths.filter(path => path.startsWith("src/layouts/") && path.endsWith(".astro"))
293
+ .map(path => {
294
+ const code = readFileSync(join(root, path), "utf8");
295
+ const frontmatter = code.startsWith("---") ? code.match(/^---\r?\n[\s\S]*?^---(?:\r?\n|$)/m)?.[0] : "";
296
+ const markup = code.slice(frontmatter?.length ?? 0).replace(/<!--[\s\S]*?-->|<script\b[^>]*>[\s\S]*?<\/script\s*>|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g,
297
+ value => value.replace(/[^\r\n]/g, " "));
298
+ return { path, code, frontmatter, heads: [...markup.matchAll(/<\/head\s*>/g)] };
299
+ }).filter(layout => layout.heads.length);
300
+ if (candidates.length !== 1) return null;
301
+ const { path, frontmatter, heads } = candidates[0];
302
+ let { code } = candidates[0];
303
+ noSymlinks(root, path);
304
+ if (frontmatter === undefined || /\bSaaSPro\b/.test(code) || heads.length !== 1) return null;
305
+ const { quote, semi, newline } = sourceStyle(code);
306
+ let relative = posix.relative(posix.dirname(path), plan.target);
307
+ if (!relative.startsWith(".")) relative = `./${relative}`;
308
+ const statement = `import SaaSPro from ${quote}${relative}${quote}${semi}${newline}`;
309
+ const headAt = frontmatter.length + heads[0].index;
310
+ code = `${code.slice(0, headAt)}<SaaSPro />${newline}${code.slice(headAt)}`;
311
+ if (frontmatter) code = code.slice(0, 3 + newline.length) + statement + code.slice(3 + newline.length);
312
+ else if (!code.trimStart().startsWith("---")) code = `---${newline}${statement}---${newline}${code}`;
313
+ else return null;
314
+ return { path, code, description: "import SaaSPro and insert <SaaSPro /> before </head>" };
315
+ }
316
+ if (!plan.entry) return null;
317
+ const path = plan.entry;
318
+ noSymlinks(root, path);
319
+ const code = readFileSync(join(root, path), "utf8");
320
+ if (/\bSaaSProProvider\b|saaspro-provider|@saaspro\//.test(code)) return null;
321
+ const masked = sourceMask(code);
322
+ const { quote, semi, newline } = sourceStyle(code);
323
+ if (plan.wrap === "vue") {
324
+ if (/\bspm\b/.test(masked)) return null;
325
+ const calls = [...masked.matchAll(/^[ \t]*(?:(?:const|let|var)\s+\w+\s*=\s*)?createApp\s*\(/gm)];
326
+ const mounts = [...masked.matchAll(/\.mount\s*\(/g)];
327
+ if (calls.length !== 1 || mounts.length !== 1 || calls[0].index >= mounts[0].index) return null;
328
+ const init = plan.code.slice(plan.code.indexOf("spm.init")).trim().replace(/;$/, semi);
329
+ const edited = code.slice(0, calls[0].index) + init + newline + code.slice(calls[0].index);
330
+ return { path, code: addImport(edited, `import { spm } from ${quote}@saaspro/browser${quote}${semi}`, newline), description: "initialize the browser SDK before createApp(...).mount(...)" };
331
+ }
332
+ const pattern = plan.wrap === "children" ? /\{\s*children\s*\}/g
333
+ : plan.wrap === "component" ? /<Component\s+\{\s*\.\.\.pageProps\s*\}\s*\/>/g
334
+ : plan.wrap === "outlet" ? /<Outlet\s*\/>/g : /<App\s*\/>/g;
335
+ let start = 0, scope = masked;
336
+ if (plan.wrap === "children") {
337
+ const bodies = [...masked.matchAll(/<body\b[^>]*>[\s\S]*?<\/body\s*>/g)];
338
+ if (bodies.length !== 1) return null;
339
+ start = bodies[0].index;
340
+ scope = bodies[0][0];
341
+ }
342
+ const matches = [...scope.matchAll(pattern)];
343
+ if (matches.length !== 1) return null;
344
+ const match = matches[0];
345
+ match.index += start;
346
+ if (plan.wrap === "app") {
347
+ const renders = [...masked.matchAll(/\b(?:render|hydrateRoot)\s*\(/g)];
348
+ if (renders.length !== 1 || renders[0].index >= match.index) return null;
349
+ let depth = 1;
350
+ for (let i = renders[0].index + renders[0][0].length; i < match.index; i++) {
351
+ if (masked[i] === "(") depth++;
352
+ else if (masked[i] === ")") depth--;
353
+ if (depth === 0) return null;
354
+ }
355
+ }
356
+ const wrapped = `<SaaSProProvider>${code.slice(match.index, match.index + match[0].length)}</SaaSProProvider>`;
357
+ const edited = code.slice(0, match.index) + wrapped + code.slice(match.index + match[0].length);
358
+ return { path, code: addImport(edited, `import { SaaSProProvider } from ${quote}${plan.providerImport}${quote}${semi}`, newline), description: `import SaaSProProvider and wrap ${match[0]}` };
359
+ }
360
+
361
+ export async function run(args = process.argv.slice(2), root = process.cwd()) {
362
+ const options = parseFlags(args);
363
+ if (options.help) { console.log(HELP); return; }
364
+ const files = ["package.json", "pyproject.toml", "requirements.txt", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "package-lock.json"].filter(file => existsSync(join(root, file)));
365
+ const pkg = files.includes("package.json") ? readJson(join(root, "package.json")) : null;
366
+ const paths = projectPaths(root);
367
+ const framework = detectFramework(pkg, [...files, ...paths]);
368
+ const manager = detectPackageManager(files);
369
+ console.log(`Detected: ${framework}; package manager: ${manager}`);
370
+ const login = options.key ? { key: options.key, ingestUrl: `${options.host}/api/v1/ingest`, consoleUrl: `${options.host}/o` } : await deviceLogin(options.host, framework, options.open);
371
+ const key = login.key;
372
+ console.log(`Public key (shown once): ${key}`);
373
+ const managers = detectConsentManagers(pkg);
374
+ const consent = options.consent ?? (managers.length ? "pending" : null);
375
+ const plan = integrationPlan(framework, options.host, paths, consent);
376
+ const envPath = join(root, plan.file);
377
+ noSymlinks(root, plan.file);
378
+ const env = mergeEnv(existsSync(envPath) ? readFileSync(envPath, "utf8") : "", { [plan.env]: key, SPM_HOST: options.host });
379
+ const configs = [];
380
+ if (options.mcp) {
381
+ for (const [path, client] of [[".mcp.json", "claude"], [".cursor/mcp.json", "cursor"]]) {
382
+ noSymlinks(root, path);
383
+ const existing = existsSync(join(root, path)) ? readJson(join(root, path)) : {};
384
+ const merged = mergeMcpConfig(existing, `${options.host}/api/mcp`, client);
385
+ configs.push([path, `${JSON.stringify(merged, null, 2)}\n`]);
386
+ }
387
+ }
388
+ installPackages(manager, plan.packages, options);
389
+ if (framework === "python" && options.install) {
390
+ const args = ["-m", "pip", "install", `${options.host}/downloads/saaspro-0.1.0.tar.gz`];
391
+ console.log(`${options.dryRun ? "Would run:" : "Run:"} python ${args.join(" ")}`);
392
+ if (!options.dryRun) execFileSync("python", args, { stdio: ["ignore", "inherit", "inherit"] });
393
+ }
394
+ console.log(`Set ${plan.env}=<public key shown above> and SPM_HOST=${options.host} in ${plan.file}.`);
395
+ writePlanned(root, plan.file, env, options);
396
+ const ignored = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
397
+ if (!ignored.split(/\r?\n/).some(line => line.trim() === plan.file || line.trim() === ".env*")) writePlanned(root, ".gitignore", `${ignored}${ignored.endsWith("\n") || !ignored ? "" : "\n"}${plan.file}\n`, options);
398
+ const setupReady = !plan.target || !existsSync(join(root, plan.target)) || readFileSync(join(root, plan.target), "utf8") === plan.code;
399
+ if (plan.target && !existsSync(join(root, plan.target))) { writePlanned(root, plan.target, plan.code, options); console.log(plan.code); }
400
+ else console.log(`${plan.target ? `Preserve existing ${plan.target}.` : "Add this initialization to your existing setup:"}\n${plan.code}`);
401
+ const edit = setupReady ? integrationEdit(root, plan, paths) : null;
402
+ if (edit) {
403
+ console.log(`${options.dryRun ? "Would edit" : "Edit"} ${edit.path}: ${edit.description}`);
404
+ writePlanned(root, edit.path, edit.code, options);
405
+ } else if (plan.wrap) console.log("Automatic wiring skipped: missing, existing or ambiguous integration. Existing application source is preserved.");
406
+ console.log(plan.instructions);
407
+ if (managers.length) console.log(`Consent manager detected: ${managers.join(", ")}. Consent: ${consent}.`);
408
+ if (consent === "pending" || managers.length) {
409
+ console.log('Connect the consent manager analytics-accept callback AND its saved analytics choice to consent("granted"); connect analytics rejection/withdrawal to consent("revoked").');
410
+ if (["expo", "react-native"].includes(framework)) console.log('Use analytics.setConsent("granted") / analytics.setConsent("revoked") on the mobile client.');
411
+ else if (plan.packages.includes("@saaspro/react")) console.log('Inside a React component: const client = useSaaSProMax() (import from "@saaspro/react"); call client.consent("granted") on analytics acceptance and client.consent("revoked") on rejection or withdrawal, including the saved choice on startup.');
412
+ else console.log('Import { spm } from "@saaspro/browser" in your consent controls; call spm.consent("granted") on analytics acceptance and spm.consent("revoked") on rejection or withdrawal, after initialization, including the saved choice on startup.');
413
+ } else console.log('--consent pending holds events until the site’s consent controls call consent("granted").');
414
+ for (const [path, content] of configs) { console.log(`Merge saaspro MCP endpoint ${options.host}/api/mcp into ${path}.`); writePlanned(root, path, content, options); }
415
+ if (options.mcp) console.log("MCP uses separate browser OAuth approval. In Claude Code run /mcp; in Cursor open MCP settings to sign in.");
416
+ if (options.dryRun) console.log("Dry run: no files, packages or verification events were changed.");
417
+ else {
418
+ const result = await post(login.ingestUrl, { key, sentAt: new Date().toISOString(), sdk: { name: "@saaspro/wizard", version: VERSION }, events: [{ id: randomUUID(), type: "track", timestamp: new Date().toISOString(), anonymousId: randomUUID(), name: "spm.wizard.verified", properties: { source: "wizard", framework } }] });
419
+ if (result.accepted !== 1) throw new WizardError("Verification event was not accepted; inspect the environment's Install receipt");
420
+ console.log("Verification event accepted: spm.wizard.verified. Review initialization and consent wiring before deploying your app.");
421
+ }
422
+ console.log(`Setup ${options.dryRun ? "preview" : "files"} ready for ${framework}.\nConsole: ${login.consoleUrl}`);
423
+ }
424
+
425
+ const piped = import.meta.url.endsWith("/[eval1]");
426
+ if (piped || (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href)) {
427
+ run(piped && process.argv[1] !== "-" ? process.argv.slice(1) : process.argv.slice(2)).catch(error => { console.error(error instanceof WizardError ? error.message : "Wizard failed. Check the host, device approval, existing configuration and package manager. Existing files are preserved when configuration conflicts; review any earlier completed steps before retrying."); process.exitCode = 1; });
428
+ }
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "@saaspro/wizard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Set up SaaS Pro Max with browser device approval.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": { "node": ">=18" },
8
+ "bin": { "saaspro-wizard": "./bin/saaspro-wizard.mjs" },
9
+ "files": ["bin", "README.md"],
10
+ "publishConfig": { "access": "public" },
11
+ "scripts": {
12
+ "build": "node --check bin/saaspro-wizard.mjs",
13
+ "lint": "node --check bin/saaspro-wizard.mjs",
14
+ "typecheck": "node --check bin/saaspro-wizard.mjs"
15
+ }
16
+ }