@saaspro/wizard 0.1.0 → 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 +40 -7
- package/bin/saaspro-wizard.mjs +210 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,16 +13,49 @@ Approval requires keys.write and a fresh passkey. It creates a public key labell
|
|
|
13
13
|
Wizard and encrypts the one-time reveal to an RSA key generated in the CLI. The
|
|
14
14
|
private key stays in memory. Codes expire in ten minutes and are single use.
|
|
15
15
|
|
|
16
|
-
Flags: --host <origin>, --dry-run, --key <spm_pub_...>, --no-install, --no-mcp, --no-open,
|
|
16
|
+
Flags: --consent <granted|pending>, --host <origin>, --dry-run, --key <spm_pub_...>, --no-install, --no-mcp, --no-open,
|
|
17
17
|
--help. Dry runs still need approval unless --key is given; no files, packages
|
|
18
18
|
or verification events change. HTTPS is required except for loopback development.
|
|
19
19
|
Secret keys are rejected. The public key is printed once; later output omits it.
|
|
20
20
|
|
|
21
|
-
Detects Next.js
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
26
59
|
|
|
27
60
|
Node uses @saaspro/node/native for public-key manual events. Python's SDK requires
|
|
28
61
|
a separate secret key, so the wizard installs the SDK and prints explicit manual
|
|
@@ -35,5 +68,5 @@ Other settings stay intact. A conflicting saaspro entry is refused. These files
|
|
|
35
68
|
contain a URL, not tokens; complete separate browser OAuth in your MCP client.
|
|
36
69
|
|
|
37
70
|
Finally sends spm.wizard.verified and requires one accepted event. This proves
|
|
38
|
-
CLI collection; verify your application's real events after
|
|
71
|
+
CLI collection; verify your application's real events after completing the printed steps.
|
|
39
72
|
The public download is byte-identical to the packaged ESM source.
|
package/bin/saaspro-wizard.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { constants, generateKeyPairSync, privateDecrypt, randomUUID } from "node:crypto";
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
8
|
class WizardError extends Error {}
|
|
9
9
|
|
|
10
|
-
const VERSION = "0.
|
|
10
|
+
const VERSION = "0.2.0";
|
|
11
11
|
const PUBLIC_KEY = /^spm_pub_(?:prod|stg|dev)_[A-Za-z0-9]{22}$/;
|
|
12
12
|
const HELP = `SaaS Pro Max wizard
|
|
13
13
|
|
|
@@ -19,19 +19,28 @@ Options:
|
|
|
19
19
|
--host <url> API origin (default https://saaspro.dev)
|
|
20
20
|
--dry-run Preview file changes and commands; device approval still runs
|
|
21
21
|
--key <key> Use a public ingest key and skip device approval
|
|
22
|
+
--consent <granted|pending> Override consent-manager detection
|
|
22
23
|
--no-install Skip SDK installation
|
|
23
24
|
--no-mcp Skip Claude Code and Cursor MCP configuration
|
|
24
25
|
--no-open Print the approval link without opening a browser
|
|
25
26
|
--help Show this help
|
|
26
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.
|
|
27
30
|
No terminal prompts or stdin reads. Only public keys are accepted.
|
|
28
31
|
`;
|
|
29
32
|
|
|
30
33
|
export function detectFramework(pkg, files = []) {
|
|
31
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";
|
|
32
42
|
if (deps.expo) return "expo";
|
|
33
43
|
if (deps["react-native"]) return "react-native";
|
|
34
|
-
if (deps.next) return "nextjs";
|
|
35
44
|
if (deps.react) return deps.vite ? "vite-react" : "react";
|
|
36
45
|
if (files.includes("pyproject.toml") || files.includes("requirements.txt")) return "python";
|
|
37
46
|
return pkg ? "node" : "unknown";
|
|
@@ -71,7 +80,7 @@ export function mergeEnv(existing, values) {
|
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
export function parseFlags(args) {
|
|
74
|
-
const options = { host: "https://saaspro.dev", dryRun: false, install: true, mcp: true, open: true, key: null, help: false };
|
|
83
|
+
const options = { host: "https://saaspro.dev", dryRun: false, install: true, mcp: true, open: true, key: null, consent: null, help: false };
|
|
75
84
|
for (let i = 0; i < args.length; i++) {
|
|
76
85
|
const flag = args[i];
|
|
77
86
|
if (flag === "--help") options.help = true;
|
|
@@ -79,12 +88,13 @@ export function parseFlags(args) {
|
|
|
79
88
|
else if (flag === "--no-install") options.install = false;
|
|
80
89
|
else if (flag === "--no-mcp") options.mcp = false;
|
|
81
90
|
else if (flag === "--no-open") options.open = false;
|
|
82
|
-
else if (flag === "--host" || flag === "--key") {
|
|
91
|
+
else if (flag === "--host" || flag === "--key" || flag === "--consent") {
|
|
83
92
|
const value = args[++i];
|
|
84
93
|
if (!value || value.startsWith("--")) throw new WizardError(`${flag} requires a value`);
|
|
85
|
-
options[flag
|
|
94
|
+
options[flag.slice(2)] = value;
|
|
86
95
|
} else throw new WizardError("Unknown option. Use --help for supported flags.");
|
|
87
96
|
}
|
|
97
|
+
if (options.consent && !["granted", "pending"].includes(options.consent)) throw new WizardError("--consent must be granted or pending");
|
|
88
98
|
const url = new URL(options.host);
|
|
89
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");
|
|
90
100
|
options.host = url.origin;
|
|
@@ -158,22 +168,96 @@ function installPackages(manager, packages, options) {
|
|
|
158
168
|
if (options.install && !options.dryRun) execFileSync(manager, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
export
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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." : ""}` };
|
|
168
224
|
}
|
|
169
|
-
if (
|
|
170
|
-
const typed = paths.includes("
|
|
171
|
-
const
|
|
172
|
-
|
|
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 };
|
|
173
257
|
}
|
|
174
258
|
if (framework === "expo" || framework === "react-native") {
|
|
175
259
|
const expo = framework === "expo";
|
|
176
|
-
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,
|
|
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`;
|
|
177
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.` };
|
|
178
262
|
}
|
|
179
263
|
if (framework === "node") {
|
|
@@ -183,7 +267,95 @@ export function integrationPlan(framework, host, paths) {
|
|
|
183
267
|
if (framework === "python") {
|
|
184
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.` };
|
|
185
269
|
}
|
|
186
|
-
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}"></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." };
|
|
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]}` };
|
|
187
359
|
}
|
|
188
360
|
|
|
189
361
|
export async function run(args = process.argv.slice(2), root = process.cwd()) {
|
|
@@ -191,14 +363,16 @@ export async function run(args = process.argv.slice(2), root = process.cwd()) {
|
|
|
191
363
|
if (options.help) { console.log(HELP); return; }
|
|
192
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)));
|
|
193
365
|
const pkg = files.includes("package.json") ? readJson(join(root, "package.json")) : null;
|
|
194
|
-
const
|
|
366
|
+
const paths = projectPaths(root);
|
|
367
|
+
const framework = detectFramework(pkg, [...files, ...paths]);
|
|
195
368
|
const manager = detectPackageManager(files);
|
|
196
369
|
console.log(`Detected: ${framework}; package manager: ${manager}`);
|
|
197
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);
|
|
198
371
|
const key = login.key;
|
|
199
372
|
console.log(`Public key (shown once): ${key}`);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
373
|
+
const managers = detectConsentManagers(pkg);
|
|
374
|
+
const consent = options.consent ?? (managers.length ? "pending" : null);
|
|
375
|
+
const plan = integrationPlan(framework, options.host, paths, consent);
|
|
202
376
|
const envPath = join(root, plan.file);
|
|
203
377
|
noSymlinks(root, plan.file);
|
|
204
378
|
const env = mergeEnv(existsSync(envPath) ? readFileSync(envPath, "utf8") : "", { [plan.env]: key, SPM_HOST: options.host });
|
|
@@ -221,9 +395,22 @@ export async function run(args = process.argv.slice(2), root = process.cwd()) {
|
|
|
221
395
|
writePlanned(root, plan.file, env, options);
|
|
222
396
|
const ignored = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
|
|
223
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;
|
|
224
399
|
if (plan.target && !existsSync(join(root, plan.target))) { writePlanned(root, plan.target, plan.code, options); console.log(plan.code); }
|
|
225
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.");
|
|
226
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").');
|
|
227
414
|
for (const [path, content] of configs) { console.log(`Merge saaspro MCP endpoint ${options.host}/api/mcp into ${path}.`); writePlanned(root, path, content, options); }
|
|
228
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.");
|
|
229
416
|
if (options.dryRun) console.log("Dry run: no files, packages or verification events were changed.");
|