@saaspro/wizard 0.0.0-stage → 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/README.md +38 -2
- package/bin/saaspro-wizard.mjs +241 -0
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @saaspro/wizard
|
|
2
2
|
|
|
3
|
-
|
|
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: --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, Vite/React, Expo/React Native, Node and Python. Uses your lockfile
|
|
22
|
+
to choose npm, pnpm, Yarn or Bun. Writes .env.local for Next.js or .env otherwise,
|
|
23
|
+
adds the env filename to .gitignore, and creates initialization only when the
|
|
24
|
+
pattern is unambiguous and the target is absent. Otherwise prints the exact code
|
|
25
|
+
and remaining steps. Conflicting env values cause a refusal instead of overwrite.
|
|
26
|
+
|
|
27
|
+
Node uses @saaspro/node/native for public-key manual events. Python's SDK requires
|
|
28
|
+
a separate secret key, so the wizard installs the SDK and prints explicit manual
|
|
29
|
+
initialization instructions, without writing SPM_INGEST_KEY. It still verifies the
|
|
30
|
+
public key. Mobile storage, randomness, lifecycle and consent wiring remain
|
|
31
|
+
explicit in the printed instructions. Review imports and consent before deployment.
|
|
32
|
+
|
|
33
|
+
Hosted MCP is merged into .mcp.json for Claude Code and .cursor/mcp.json for Cursor.
|
|
34
|
+
Other settings stay intact. A conflicting saaspro entry is refused. These files
|
|
35
|
+
contain a URL, not tokens; complete separate browser OAuth in your MCP client.
|
|
36
|
+
|
|
37
|
+
Finally sends spm.wizard.verified and requires one accepted event. This proves
|
|
38
|
+
CLI collection; verify your application's real events after importing the setup.
|
|
39
|
+
The public download is byte-identical to the packaged ESM source.
|
|
@@ -0,0 +1,241 @@
|
|
|
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, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
class WizardError extends Error {}
|
|
9
|
+
|
|
10
|
+
const VERSION = "0.1.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
|
+
--no-install Skip SDK installation
|
|
23
|
+
--no-mcp Skip Claude Code and Cursor MCP configuration
|
|
24
|
+
--no-open Print the approval link without opening a browser
|
|
25
|
+
--help Show this help
|
|
26
|
+
|
|
27
|
+
No terminal prompts or stdin reads. Only public keys are accepted.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export function detectFramework(pkg, files = []) {
|
|
31
|
+
const deps = { ...pkg?.devDependencies, ...pkg?.dependencies };
|
|
32
|
+
if (deps.expo) return "expo";
|
|
33
|
+
if (deps["react-native"]) return "react-native";
|
|
34
|
+
if (deps.next) return "nextjs";
|
|
35
|
+
if (deps.react) return deps.vite ? "vite-react" : "react";
|
|
36
|
+
if (files.includes("pyproject.toml") || files.includes("requirements.txt")) return "python";
|
|
37
|
+
return pkg ? "node" : "unknown";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectPackageManager(files) {
|
|
41
|
+
if (files.includes("pnpm-lock.yaml")) return "pnpm";
|
|
42
|
+
if (files.includes("yarn.lock")) return "yarn";
|
|
43
|
+
if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun";
|
|
44
|
+
return "npm";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
48
|
+
|
|
49
|
+
export function mergeMcpConfig(existing, endpoint, client = "claude") {
|
|
50
|
+
if (!object(existing) || (existing.mcpServers !== undefined && !object(existing.mcpServers))) throw new WizardError("MCP config must contain a JSON object and an object-valued mcpServers");
|
|
51
|
+
const servers = existing.mcpServers ?? {};
|
|
52
|
+
if (Object.hasOwn(servers, "saaspro")) {
|
|
53
|
+
if (!object(servers.saaspro) || servers.saaspro.url !== endpoint) throw new WizardError("The saaspro MCP entry already exists with different settings; merge it manually");
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
return { ...existing, mcpServers: { ...servers, saaspro: client === "claude" ? { type: "http", url: endpoint } : { url: endpoint } } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mergeEnv(existing, values) {
|
|
60
|
+
let result = existing;
|
|
61
|
+
for (const [name, value] of Object.entries(values)) {
|
|
62
|
+
const found = new RegExp(`^(?:export\\s+)?${name}\\s*=.*$`, "m").exec(result);
|
|
63
|
+
if (found) {
|
|
64
|
+
const old = found[0].slice(found[0].indexOf("=") + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
65
|
+
if (old !== value) throw new WizardError(`${name} already exists with a different value; preserve it and review the new public key before replacing it`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
result += `${result && !result.endsWith("\n") ? "\n" : ""}${name}=${value}\n`;
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseFlags(args) {
|
|
74
|
+
const options = { host: "https://saaspro.dev", dryRun: false, install: true, mcp: true, open: true, key: null, help: false };
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
const flag = args[i];
|
|
77
|
+
if (flag === "--help") options.help = true;
|
|
78
|
+
else if (flag === "--dry-run") options.dryRun = true;
|
|
79
|
+
else if (flag === "--no-install") options.install = false;
|
|
80
|
+
else if (flag === "--no-mcp") options.mcp = false;
|
|
81
|
+
else if (flag === "--no-open") options.open = false;
|
|
82
|
+
else if (flag === "--host" || flag === "--key") {
|
|
83
|
+
const value = args[++i];
|
|
84
|
+
if (!value || value.startsWith("--")) throw new WizardError(`${flag} requires a value`);
|
|
85
|
+
options[flag === "--host" ? "host" : "key"] = value;
|
|
86
|
+
} else throw new WizardError("Unknown option. Use --help for supported flags.");
|
|
87
|
+
}
|
|
88
|
+
const url = new URL(options.host);
|
|
89
|
+
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
|
+
options.host = url.origin;
|
|
91
|
+
if (options.key && !PUBLIC_KEY.test(options.key)) throw new WizardError("--key requires a public ingest key; secret keys are never accepted");
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function post(url, body) {
|
|
96
|
+
const response = await fetch(url, { method: "POST", redirect: "error", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
|
|
97
|
+
let data;
|
|
98
|
+
try { data = await response.json(); } catch { throw new WizardError(`Invalid API response (HTTP ${response.status})`); }
|
|
99
|
+
if (!response.ok && !(response.status === 429 && data.status === "slow_down")) throw new WizardError(`Request failed (HTTP ${response.status}); check the host and console permissions`);
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function openBrowser(url) {
|
|
104
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
105
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
106
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
107
|
+
child.on("error", () => {});
|
|
108
|
+
child.unref();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function deviceLogin(host, framework, open) {
|
|
112
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
113
|
+
const started = await post(`${host}/api/v1/wizard/device`, { publicKey: publicKey.export({ type: "spki", format: "pem" }), client: { name: "@saaspro/wizard", version: VERSION, framework } });
|
|
114
|
+
const verification = new URL(started.verificationUrl);
|
|
115
|
+
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");
|
|
116
|
+
console.log(`Confirm code ${started.userCode} in your browser:\n${verification.href}\nChoose your organization, application and environment, then approve.`);
|
|
117
|
+
if (open) openBrowser(verification.href);
|
|
118
|
+
const deadline = Date.now() + Math.min(Number(started.expiresIn) || 600, 600) * 1000;
|
|
119
|
+
let interval = Math.max(3, Number(started.interval) || 3);
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
await new Promise(resolve => setTimeout(resolve, interval * 1000));
|
|
122
|
+
const token = await post(`${host}/api/v1/wizard/device/token`, { deviceCode: started.deviceCode });
|
|
123
|
+
if (token.status === "pending") continue;
|
|
124
|
+
if (token.status === "slow_down") { interval = Math.max(interval + 1, Number(token.interval) || 3); continue; }
|
|
125
|
+
if (token.status !== "approved") throw new WizardError(`Device approval ${["denied", "expired", "consumed"].includes(token.status) ? token.status : "failed"}; start the wizard again`);
|
|
126
|
+
const key = privateDecrypt({ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(token.ciphertext, "base64")).toString();
|
|
127
|
+
if (!PUBLIC_KEY.test(key) || new URL(token.ingestUrl).origin !== host || new URL(token.consoleUrl).origin !== host) throw new WizardError("Invalid device token response");
|
|
128
|
+
return { ...token, key };
|
|
129
|
+
}
|
|
130
|
+
throw new WizardError("Device approval expired; start the wizard again");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); }
|
|
134
|
+
|
|
135
|
+
function noSymlinks(root, relative) {
|
|
136
|
+
let path = root;
|
|
137
|
+
for (const part of relative.split(/[\\/]/)) {
|
|
138
|
+
path = join(path, part);
|
|
139
|
+
try {
|
|
140
|
+
if (lstatSync(path).isSymbolicLink()) throw new WizardError(`Refusing to write through a symbolic link: ${relative}`);
|
|
141
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writePlanned(root, path, text, options) {
|
|
146
|
+
noSymlinks(root, path);
|
|
147
|
+
console.log(`${options.dryRun ? "Would write" : "Write"} ${path}`);
|
|
148
|
+
if (!options.dryRun) {
|
|
149
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
150
|
+
writeFileSync(join(root, path), text, { mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function installPackages(manager, packages, options) {
|
|
155
|
+
if (!packages.length) return;
|
|
156
|
+
const args = [manager === "npm" ? "install" : "add", ...packages];
|
|
157
|
+
console.log(`${options.dryRun || !options.install ? "Run manually:" : "Run:"} ${manager} ${args.join(" ")}`);
|
|
158
|
+
if (options.install && !options.dryRun) execFileSync(manager, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function integrationPlan(framework, host, paths) {
|
|
162
|
+
if (framework === "nextjs") {
|
|
163
|
+
const roots = ["app", "src/app"].filter(root => paths.some(path => new RegExp(`^${root}/layout\\.(tsx|jsx|js)$`).test(path)));
|
|
164
|
+
const directory = roots.length === 1 ? roots[0] : null;
|
|
165
|
+
const typed = directory ? paths.includes(`${directory}/layout.tsx`) : true;
|
|
166
|
+
const code = `"use client";\n${typed ? 'import type { ReactNode } from "react";\n' : ""}import { Suspense } from "react";\nimport { SaaSProMaxProvider } from "@saaspro/react";\nimport { SPMPageview } from "@saaspro/react/next";\n\nexport function SaaSProProvider({ children }${typed ? ": { children: ReactNode }" : ""}) {\n return <SaaSProMaxProvider config={{ key: process.env.NEXT_PUBLIC_SPM_KEY, host: ${JSON.stringify(host)}, autoPageviews: false, consent: "pending" }}>\n <Suspense fallback={null}><SPMPageview /></Suspense>\n {children}\n </SaaSProMaxProvider>;\n}\n`;
|
|
167
|
+
return { packages: ["@saaspro/react"], env: "NEXT_PUBLIC_SPM_KEY", file: ".env.local", target: directory ? `${directory}/saaspro-provider.${typed ? "tsx" : "jsx"}` : null, code, instructions: `Import { SaaSProProvider } from "./saaspro-provider" in your app layout and wrap its children. Connect your consent controls to useSaaSProMax().consent("granted"). For Pages Router or multiple app roots, place this provider in your existing root component.` };
|
|
168
|
+
}
|
|
169
|
+
if (framework === "vite-react" || framework === "react") {
|
|
170
|
+
const typed = paths.includes("src/main.tsx");
|
|
171
|
+
const code = `${typed ? 'import type { ReactNode } from "react";\n' : ""}import { SaaSProMaxProvider, SPMPageview } from "@saaspro/react";\n\nexport function SaaSProProvider({ children }${typed ? ": { children: ReactNode }" : ""}) {\n return <SaaSProMaxProvider config={{ key: ${framework === "vite-react" ? "import.meta.env.VITE_SPM_KEY" : "process.env.REACT_APP_SPM_KEY"}, host: ${JSON.stringify(host)}, autoPageviews: false, consent: "pending" }}>\n <SPMPageview pathname={location.pathname} search={location.search} />\n {children}\n </SaaSProMaxProvider>;\n}\n`;
|
|
172
|
+
return { packages: ["@saaspro/react"], env: framework === "vite-react" ? "VITE_SPM_KEY" : "REACT_APP_SPM_KEY", file: ".env", target: framework === "vite-react" && (paths.includes("src/main.jsx") || typed) ? `src/saaspro-provider.${typed ? "tsx" : "jsx"}` : null, code, instructions: `Import { SaaSProProvider } from "./saaspro-provider" and wrap your root component. Supply route changes to SPMPageview from your router. Connect consent controls to useSaaSProMax().consent("granted"). Confirm your bundler exposes the configured public environment variable.` };
|
|
173
|
+
}
|
|
174
|
+
if (framework === "expo" || framework === "react-native") {
|
|
175
|
+
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, consent: "pending", privacyMode: "persistent",\n});\n`;
|
|
177
|
+
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
|
+
}
|
|
179
|
+
if (framework === "node") {
|
|
180
|
+
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`;
|
|
181
|
+
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." };
|
|
182
|
+
}
|
|
183
|
+
if (framework === "python") {
|
|
184
|
+
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
|
+
}
|
|
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." };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function run(args = process.argv.slice(2), root = process.cwd()) {
|
|
190
|
+
const options = parseFlags(args);
|
|
191
|
+
if (options.help) { console.log(HELP); return; }
|
|
192
|
+
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
|
+
const pkg = files.includes("package.json") ? readJson(join(root, "package.json")) : null;
|
|
194
|
+
const framework = detectFramework(pkg, files);
|
|
195
|
+
const manager = detectPackageManager(files);
|
|
196
|
+
console.log(`Detected: ${framework}; package manager: ${manager}`);
|
|
197
|
+
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
|
+
const key = login.key;
|
|
199
|
+
console.log(`Public key (shown once): ${key}`);
|
|
200
|
+
const paths = ["app/layout.tsx", "app/layout.jsx", "app/layout.js", "src/app/layout.tsx", "src/app/layout.jsx", "src/app/layout.js", "src/main.tsx", "src/main.jsx"].filter(path => existsSync(join(root, path)));
|
|
201
|
+
const plan = integrationPlan(framework, options.host, paths);
|
|
202
|
+
const envPath = join(root, plan.file);
|
|
203
|
+
noSymlinks(root, plan.file);
|
|
204
|
+
const env = mergeEnv(existsSync(envPath) ? readFileSync(envPath, "utf8") : "", { [plan.env]: key, SPM_HOST: options.host });
|
|
205
|
+
const configs = [];
|
|
206
|
+
if (options.mcp) {
|
|
207
|
+
for (const [path, client] of [[".mcp.json", "claude"], [".cursor/mcp.json", "cursor"]]) {
|
|
208
|
+
noSymlinks(root, path);
|
|
209
|
+
const existing = existsSync(join(root, path)) ? readJson(join(root, path)) : {};
|
|
210
|
+
const merged = mergeMcpConfig(existing, `${options.host}/api/mcp`, client);
|
|
211
|
+
configs.push([path, `${JSON.stringify(merged, null, 2)}\n`]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
installPackages(manager, plan.packages, options);
|
|
215
|
+
if (framework === "python" && options.install) {
|
|
216
|
+
const args = ["-m", "pip", "install", `${options.host}/downloads/saaspro-0.1.0.tar.gz`];
|
|
217
|
+
console.log(`${options.dryRun ? "Would run:" : "Run:"} python ${args.join(" ")}`);
|
|
218
|
+
if (!options.dryRun) execFileSync("python", args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
219
|
+
}
|
|
220
|
+
console.log(`Set ${plan.env}=<public key shown above> and SPM_HOST=${options.host} in ${plan.file}.`);
|
|
221
|
+
writePlanned(root, plan.file, env, options);
|
|
222
|
+
const ignored = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
|
|
223
|
+
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);
|
|
224
|
+
if (plan.target && !existsSync(join(root, plan.target))) { writePlanned(root, plan.target, plan.code, options); console.log(plan.code); }
|
|
225
|
+
else console.log(`${plan.target ? `Preserve existing ${plan.target}.` : "Add this initialization to your existing setup:"}\n${plan.code}`);
|
|
226
|
+
console.log(plan.instructions);
|
|
227
|
+
for (const [path, content] of configs) { console.log(`Merge saaspro MCP endpoint ${options.host}/api/mcp into ${path}.`); writePlanned(root, path, content, options); }
|
|
228
|
+
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
|
+
if (options.dryRun) console.log("Dry run: no files, packages or verification events were changed.");
|
|
230
|
+
else {
|
|
231
|
+
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 } }] });
|
|
232
|
+
if (result.accepted !== 1) throw new WizardError("Verification event was not accepted; inspect the environment's Install receipt");
|
|
233
|
+
console.log("Verification event accepted: spm.wizard.verified. Review initialization and consent wiring before deploying your app.");
|
|
234
|
+
}
|
|
235
|
+
console.log(`Setup ${options.dryRun ? "preview" : "files"} ready for ${framework}.\nConsole: ${login.consoleUrl}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const piped = import.meta.url.endsWith("/[eval1]");
|
|
239
|
+
if (piped || (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href)) {
|
|
240
|
+
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; });
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saaspro/wizard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
}
|