@scadable/wizard 0.1.2 → 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 +56 -12
- package/dist/cli.js +478 -78
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,24 +1,65 @@
|
|
|
1
1
|
# @scadable/wizard
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The zero-friction setup wizard for SCADABLE. One command, one token, done: it
|
|
4
|
+
detects your framework and wires your always-current legal documents (privacy
|
|
5
|
+
policy, terms of use, and more) into your app, pulled live from SCADABLE. Publish
|
|
6
|
+
a document in the SCADABLE app, copy the install command from Settings, and run it
|
|
7
|
+
in your repo:
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npx @scadable/wizard@latest --token <TEMP_TOKEN>
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
The wizard verifies your token, detects your web framework,
|
|
12
|
-
|
|
13
|
-
page. After this,
|
|
14
|
-
redeploy when you update it.
|
|
13
|
+
The wizard verifies your token, detects your web framework, installs the matching
|
|
14
|
+
`@scadable/*` plugin (or sets up the universal CDN embed for a static site), and
|
|
15
|
+
creates your page(s). After this, every page always renders the version you last
|
|
16
|
+
published, with no redeploy when you update it.
|
|
17
|
+
|
|
18
|
+
## Frameworks
|
|
19
|
+
|
|
20
|
+
The wizard detects your stack automatically and scaffolds the right package:
|
|
21
|
+
|
|
22
|
+
| Stack | Package it installs | Where the page goes |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Next.js (App Router) | `@scadable/next` | `app/privacy/page.tsx` |
|
|
25
|
+
| Next.js (Pages Router) | `@scadable/next` | `pages/privacy.tsx` |
|
|
26
|
+
| Astro | `@scadable/astro` | `src/pages/privacy.astro` |
|
|
27
|
+
| Vue (Vite) | `@scadable/vue` | `src/views/PrivacyPolicyView.vue` |
|
|
28
|
+
| Nuxt | `@scadable/vue` | `pages/privacy.vue` |
|
|
29
|
+
| Svelte (Vite) | `@scadable/svelte` | `src/lib/PrivacyPolicyPage.svelte` |
|
|
30
|
+
| SvelteKit | `@scadable/svelte` | `src/routes/privacy/+page.svelte` |
|
|
31
|
+
| React: Vite / CRA | `@scadable/react` | `src/PrivacyPolicyPage.tsx` |
|
|
32
|
+
| React: Remix | `@scadable/react` | `app/routes/privacy.tsx` |
|
|
33
|
+
| React: Gatsby | `@scadable/react` | `src/pages/privacy.tsx` |
|
|
34
|
+
| Plain HTML / unknown | none (CDN embed) | `privacy.html` + a paste-anywhere snippet |
|
|
35
|
+
|
|
36
|
+
For file-based routers (Next, Astro, Nuxt, SvelteKit, Remix, Gatsby) the page is
|
|
37
|
+
live at `/privacy` (and `/terms`) right away. For the others the wizard tells you
|
|
38
|
+
the one line to add to your router.
|
|
39
|
+
|
|
40
|
+
## Document types
|
|
41
|
+
|
|
42
|
+
Pick what you want to add with `--doc-type`, or let the wizard ask:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @scadable/wizard@latest --token <TEMP_TOKEN> --doc-type both
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `privacy` creates your privacy policy page (`/privacy`).
|
|
49
|
+
- `terms` creates your terms of use page (`/terms`).
|
|
50
|
+
- `both` creates both.
|
|
51
|
+
|
|
52
|
+
New document types are a prop value, never a new package: the components are
|
|
53
|
+
generic (`<PrivacyPolicy token=... />`, `<TermsOfUse token=... />`, or
|
|
54
|
+
`<ScadablePolicy token=... docType=... />`).
|
|
15
55
|
|
|
16
56
|
## What it sends
|
|
17
57
|
|
|
18
|
-
Your code stays private. The wizard reads only `./package.json` (dependency names)
|
|
19
|
-
shallow yes/no listing of a few known folders (`app`,
|
|
20
|
-
|
|
21
|
-
|
|
58
|
+
Your code stays private. The wizard reads only `./package.json` (dependency names)
|
|
59
|
+
and a shallow yes/no listing of a few known folders and config files (`app`,
|
|
60
|
+
`src/app`, `pages`, `astro.config.*`, ...). It never reads or transmits your
|
|
61
|
+
source, and never touches `.env*` files. The one exception is opt-in
|
|
62
|
+
`--patch <file>` mode, which reads exactly that one file, and refuses to upload it
|
|
22
63
|
if it looks like it contains secrets.
|
|
23
64
|
|
|
24
65
|
## Options
|
|
@@ -26,10 +67,11 @@ if it looks like it contains secrets.
|
|
|
26
67
|
| Flag | Default | What it does |
|
|
27
68
|
| --- | --- | --- |
|
|
28
69
|
| `--token <token>` | required | Install token from the SCADABLE app. |
|
|
70
|
+
| `--doc-type <type>` | ask | `privacy`, `terms`, or `both` (privacy if non-interactive). |
|
|
29
71
|
| `--api <url>` | `https://api.scadable.com` | API base. |
|
|
30
72
|
| `--dry-run` | off | Show the plan, write nothing. |
|
|
31
73
|
| `--yes`, `-y` | off | Skip confirmation prompts (non-interactive). |
|
|
32
|
-
| `--patch <file>` | off |
|
|
74
|
+
| `--patch <file>` | off | Add the privacy policy to an existing page instead of creating one. |
|
|
33
75
|
| `--help`, `-h` | | Show usage. |
|
|
34
76
|
|
|
35
77
|
The package manager is detected from your lockfile (`pnpm-lock.yaml` -> pnpm,
|
|
@@ -39,3 +81,5 @@ The package manager is detected from your lockfile (`pnpm-lock.yaml` -> pnpm,
|
|
|
39
81
|
|
|
40
82
|
- This package only talks to the public SCADABLE API. It stores no secrets.
|
|
41
83
|
- It is meant to be run once, via `npx`, to connect a repo.
|
|
84
|
+
- Using a strict Content-Security-Policy? Add `api.scadable.com` to `connect-src`
|
|
85
|
+
so the page can refresh live (it falls back to the baked copy if not).
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
5
5
|
import { resolve } from "path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
cancel,
|
|
8
|
+
confirm,
|
|
9
|
+
intro,
|
|
10
|
+
isCancel,
|
|
11
|
+
log,
|
|
12
|
+
note,
|
|
13
|
+
outro,
|
|
14
|
+
select,
|
|
15
|
+
spinner
|
|
16
|
+
} from "@clack/prompts";
|
|
7
17
|
import pc from "picocolors";
|
|
8
18
|
|
|
9
19
|
// src/api.ts
|
|
@@ -82,7 +92,7 @@ var INSTALL_VERB = {
|
|
|
82
92
|
function resolvePackages(install) {
|
|
83
93
|
const noise = /^(?:npm|yarn|pnpm|install|add|i|--save|-S|-D|--save-dev|--dev)$/;
|
|
84
94
|
const specs = install.flatMap((line) => line.split(/\s+/)).map((token) => token.trim()).filter((token) => token && !noise.test(token));
|
|
85
|
-
return [
|
|
95
|
+
return [...new Set(specs)];
|
|
86
96
|
}
|
|
87
97
|
function formatInstall(pm, packages) {
|
|
88
98
|
return `${pm} ${INSTALL_VERB[pm]} ${packages.join(" ")}`;
|
|
@@ -104,12 +114,35 @@ function writeEdit(cwd, edit) {
|
|
|
104
114
|
// src/detect.ts
|
|
105
115
|
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
106
116
|
import { join as join2 } from "path";
|
|
107
|
-
var
|
|
117
|
+
var KNOWN_DIRS = ["app", "src/app", "pages", "src/pages", "src/routes", "src", "public"];
|
|
118
|
+
var KNOWN_CONFIGS = [
|
|
119
|
+
"next.config",
|
|
120
|
+
"astro.config",
|
|
121
|
+
"nuxt.config",
|
|
122
|
+
"svelte.config",
|
|
123
|
+
"vite.config",
|
|
124
|
+
"remix.config",
|
|
125
|
+
"gatsby-config"
|
|
126
|
+
];
|
|
127
|
+
var KNOWN_FILES = ["app/layout.tsx", "index.html"];
|
|
128
|
+
function hasConfig(cwd, base) {
|
|
129
|
+
return ["js", "mjs", "cjs", "ts"].some((ext) => existsSync2(join2(cwd, `${base}.${ext}`)));
|
|
130
|
+
}
|
|
108
131
|
function detectRepo(cwd) {
|
|
132
|
+
const has = (rel) => existsSync2(join2(cwd, rel));
|
|
109
133
|
const pkgPath = join2(cwd, "package.json");
|
|
110
|
-
|
|
134
|
+
const hasPkg = existsSync2(pkgPath);
|
|
135
|
+
if (!hasPkg) {
|
|
136
|
+
if (has("index.html") || has("public/index.html")) {
|
|
137
|
+
return {
|
|
138
|
+
framework: "html",
|
|
139
|
+
deps: [],
|
|
140
|
+
paths: probePaths(cwd, has),
|
|
141
|
+
warning: "No package.json found; setting up the universal embed for a static site."
|
|
142
|
+
};
|
|
143
|
+
}
|
|
111
144
|
throw new Error(
|
|
112
|
-
"No package.json found here. Run this inside your web app, next to its package.json."
|
|
145
|
+
"No package.json or index.html found here. Run this inside your web app, next to its package.json."
|
|
113
146
|
);
|
|
114
147
|
}
|
|
115
148
|
let pkg;
|
|
@@ -119,24 +152,49 @@ function detectRepo(cwd) {
|
|
|
119
152
|
throw new Error("Could not parse package.json. Is it valid JSON?");
|
|
120
153
|
}
|
|
121
154
|
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const hasReact = deps.includes("react");
|
|
155
|
+
const dep = (name) => deps.includes(name);
|
|
156
|
+
const depPrefix = (prefix) => deps.some((d) => d.startsWith(prefix));
|
|
157
|
+
const paths = probePaths(cwd, has);
|
|
126
158
|
let framework;
|
|
127
159
|
let warning;
|
|
128
|
-
if (
|
|
160
|
+
if (dep("next")) {
|
|
129
161
|
if (has("app") || has("src/app")) framework = "next-app";
|
|
130
162
|
else if (has("pages") || has("src/pages")) framework = "next-pages";
|
|
131
163
|
else framework = "next-app";
|
|
132
|
-
} else if (
|
|
133
|
-
framework = "
|
|
134
|
-
} else {
|
|
135
|
-
framework = "
|
|
136
|
-
|
|
164
|
+
} else if (dep("astro")) {
|
|
165
|
+
framework = "astro";
|
|
166
|
+
} else if (dep("nuxt") || dep("nuxt3")) {
|
|
167
|
+
framework = "nuxt";
|
|
168
|
+
} else if (dep("vue")) {
|
|
169
|
+
framework = "vue";
|
|
170
|
+
} else if (dep("@sveltejs/kit")) {
|
|
171
|
+
framework = "sveltekit";
|
|
172
|
+
} else if (dep("svelte")) {
|
|
173
|
+
framework = "svelte";
|
|
174
|
+
} else if (depPrefix("@remix-run/")) {
|
|
175
|
+
framework = "remix";
|
|
176
|
+
} else if (dep("gatsby")) {
|
|
177
|
+
framework = "gatsby";
|
|
178
|
+
} else if (dep("react-scripts") || dep("vite") && dep("react") || dep("react")) {
|
|
179
|
+
framework = "vite-react";
|
|
180
|
+
}
|
|
181
|
+
if (!framework) {
|
|
182
|
+
if (has("index.html") || has("public/index.html")) {
|
|
183
|
+
framework = "html";
|
|
184
|
+
warning = "No known framework dependency found; using the universal embed for a static site.";
|
|
185
|
+
} else {
|
|
186
|
+
framework = "vite-react";
|
|
187
|
+
warning = "No known framework dependency found; assuming a generic React app.";
|
|
188
|
+
}
|
|
137
189
|
}
|
|
138
190
|
return { framework, deps, paths, warning };
|
|
139
191
|
}
|
|
192
|
+
function probePaths(cwd, has) {
|
|
193
|
+
const dirs = KNOWN_DIRS.filter(has);
|
|
194
|
+
const configs = KNOWN_CONFIGS.filter((base) => hasConfig(cwd, base)).map((base) => `${base}.*`);
|
|
195
|
+
const files = KNOWN_FILES.filter(has);
|
|
196
|
+
return [...dirs, ...configs, ...files];
|
|
197
|
+
}
|
|
140
198
|
var SECRET_PATTERNS = [
|
|
141
199
|
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/,
|
|
142
200
|
/\bapi[_-]?key\s*[:=]/i,
|
|
@@ -150,6 +208,203 @@ function looksLikeSecret(contents) {
|
|
|
150
208
|
return SECRET_PATTERNS.some((re) => re.test(contents));
|
|
151
209
|
}
|
|
152
210
|
|
|
211
|
+
// src/scaffold.ts
|
|
212
|
+
var DOC = {
|
|
213
|
+
privacy_policy: { title: "Privacy Policy", slug: "privacy", component: "PrivacyPolicy" },
|
|
214
|
+
terms_of_use: { title: "Terms of Use", slug: "terms", component: "TermsOfUse" }
|
|
215
|
+
};
|
|
216
|
+
var PACKAGE = {
|
|
217
|
+
"next-app": "@scadable/next",
|
|
218
|
+
"next-pages": "@scadable/next",
|
|
219
|
+
astro: "@scadable/astro",
|
|
220
|
+
vue: "@scadable/vue",
|
|
221
|
+
nuxt: "@scadable/vue",
|
|
222
|
+
svelte: "@scadable/svelte",
|
|
223
|
+
sveltekit: "@scadable/svelte",
|
|
224
|
+
"vite-react": "@scadable/react",
|
|
225
|
+
remix: "@scadable/react",
|
|
226
|
+
gatsby: "@scadable/react",
|
|
227
|
+
html: null
|
|
228
|
+
};
|
|
229
|
+
function packageFor(framework) {
|
|
230
|
+
return PACKAGE[framework];
|
|
231
|
+
}
|
|
232
|
+
var JSX_STYLE = "{{ maxWidth: 820, margin: '0 auto', padding: '40px 20px' }}";
|
|
233
|
+
var CSS_STYLE = "max-width: 820px; margin: 0 auto; padding: 40px 20px;";
|
|
234
|
+
var EMBED_SRC = "https://cdn.jsdelivr.net/npm/@scadable/embed/dist/embed.js";
|
|
235
|
+
function scaffoldOne(framework, docType, publicId) {
|
|
236
|
+
const doc = DOC[docType];
|
|
237
|
+
const route = (hint) => ({ docType, hint });
|
|
238
|
+
switch (framework) {
|
|
239
|
+
// ── Next.js App Router: a Server Component page (SEO-baked + live). ──────
|
|
240
|
+
case "next-app": {
|
|
241
|
+
const contents = `import { ${doc.component} } from '@scadable/next';
|
|
242
|
+
|
|
243
|
+
export const metadata = { title: '${doc.title}' };
|
|
244
|
+
|
|
245
|
+
export default function ${doc.component}Page() {
|
|
246
|
+
return (
|
|
247
|
+
<main style=${JSX_STYLE}>
|
|
248
|
+
<${doc.component} token="${publicId}" />
|
|
249
|
+
</main>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
`;
|
|
253
|
+
return { files: [{ path: `app/${doc.slug}/page.tsx`, contents }], route: route(`/${doc.slug}`) };
|
|
254
|
+
}
|
|
255
|
+
// ── Next.js Pages Router: fetch on the server, render the fragment. ──────
|
|
256
|
+
case "next-pages": {
|
|
257
|
+
const contents = `import { fetchPolicy, type Policy } from '@scadable/next';
|
|
258
|
+
|
|
259
|
+
export async function getServerSideProps() {
|
|
260
|
+
const policy = await fetchPolicy('${publicId}', { docType: '${docType}' });
|
|
261
|
+
return { props: { policy } };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export default function ${doc.component}Page({ policy }: { policy: Policy }) {
|
|
265
|
+
return (
|
|
266
|
+
<main style=${JSX_STYLE}>
|
|
267
|
+
<div className="scadable-policy" dangerouslySetInnerHTML={{ __html: policy.html }} />
|
|
268
|
+
</main>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
`;
|
|
272
|
+
return { files: [{ path: `pages/${doc.slug}.tsx`, contents }], route: route(`/${doc.slug}`) };
|
|
273
|
+
}
|
|
274
|
+
// ── Astro: file-based page importing the .astro component. ───────────────
|
|
275
|
+
case "astro": {
|
|
276
|
+
const contents = `---
|
|
277
|
+
import ${doc.component} from '@scadable/astro/${doc.component}.astro';
|
|
278
|
+
---
|
|
279
|
+
<main style="${CSS_STYLE}">
|
|
280
|
+
<${doc.component} token="${publicId}" />
|
|
281
|
+
</main>
|
|
282
|
+
`;
|
|
283
|
+
return { files: [{ path: `src/pages/${doc.slug}.astro`, contents }], route: route(`/${doc.slug}`) };
|
|
284
|
+
}
|
|
285
|
+
// ── Nuxt: file-based page under pages/. ──────────────────────────────────
|
|
286
|
+
case "nuxt": {
|
|
287
|
+
const contents = vueSfc(doc.component, publicId);
|
|
288
|
+
return { files: [{ path: `pages/${doc.slug}.vue`, contents }], route: route(`/${doc.slug}`) };
|
|
289
|
+
}
|
|
290
|
+
// ── Vue (Vite SPA): a view the user adds to their router. ────────────────
|
|
291
|
+
case "vue": {
|
|
292
|
+
const contents = vueSfc(doc.component, publicId);
|
|
293
|
+
return {
|
|
294
|
+
files: [{ path: `src/views/${doc.component}View.vue`, contents }],
|
|
295
|
+
route: route(`add <${doc.component}View /> to your router (e.g. a /${doc.slug} route)`)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// ── SvelteKit: file-based route. ─────────────────────────────────────────
|
|
299
|
+
case "sveltekit": {
|
|
300
|
+
const contents = svelteComponent(doc.component, publicId);
|
|
301
|
+
return {
|
|
302
|
+
files: [{ path: `src/routes/${doc.slug}/+page.svelte`, contents }],
|
|
303
|
+
route: route(`/${doc.slug}`)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// ── Svelte (Vite SPA): a component the user adds to their router. ────────
|
|
307
|
+
case "svelte": {
|
|
308
|
+
const contents = svelteComponent(doc.component, publicId);
|
|
309
|
+
return {
|
|
310
|
+
files: [{ path: `src/lib/${doc.component}Page.svelte`, contents }],
|
|
311
|
+
route: route(`add <${doc.component}Page /> to your router (e.g. a /${doc.slug} route)`)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// ── Remix: file-based route under app/routes/. ───────────────────────────
|
|
315
|
+
case "remix": {
|
|
316
|
+
const contents = reactPage(doc.component, publicId, `${doc.component}Route`);
|
|
317
|
+
return { files: [{ path: `app/routes/${doc.slug}.tsx`, contents }], route: route(`/${doc.slug}`) };
|
|
318
|
+
}
|
|
319
|
+
// ── Gatsby: file-based page under src/pages/. ────────────────────────────
|
|
320
|
+
case "gatsby": {
|
|
321
|
+
const contents = reactPage(doc.component, publicId, `${doc.component}Page`);
|
|
322
|
+
return { files: [{ path: `src/pages/${doc.slug}.tsx`, contents }], route: route(`/${doc.slug}`) };
|
|
323
|
+
}
|
|
324
|
+
// ── Generic React (Vite, CRA): a page the user adds to their router. ─────
|
|
325
|
+
case "vite-react": {
|
|
326
|
+
const contents = reactPage(doc.component, publicId, `${doc.component}Page`);
|
|
327
|
+
return {
|
|
328
|
+
files: [{ path: `src/${doc.component}Page.tsx`, contents }],
|
|
329
|
+
route: route(`add <${doc.component}Page /> to your router (e.g. a /${doc.slug} route)`)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// ── Plain HTML / unknown: a standalone page + a paste-anywhere snippet. ──
|
|
333
|
+
case "html": {
|
|
334
|
+
const snippet = embedSnippet(docType, publicId);
|
|
335
|
+
const contents = `<!doctype html>
|
|
336
|
+
<html lang="en">
|
|
337
|
+
<head>
|
|
338
|
+
<meta charset="utf-8" />
|
|
339
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
340
|
+
<title>${doc.title}</title>
|
|
341
|
+
</head>
|
|
342
|
+
<body>
|
|
343
|
+
<main style="${CSS_STYLE}">
|
|
344
|
+
${snippet.replace(/\n/g, "\n ")}
|
|
345
|
+
</main>
|
|
346
|
+
</body>
|
|
347
|
+
</html>
|
|
348
|
+
`;
|
|
349
|
+
return {
|
|
350
|
+
files: [{ path: `${doc.slug}.html`, contents }],
|
|
351
|
+
route: route(`${doc.slug}.html (open it, or link to it from your nav)`),
|
|
352
|
+
snippet
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function vueSfc(component, publicId) {
|
|
358
|
+
return `<script setup lang="ts">
|
|
359
|
+
import { ${component} } from '@scadable/vue';
|
|
360
|
+
</script>
|
|
361
|
+
|
|
362
|
+
<template>
|
|
363
|
+
<main style="${CSS_STYLE}">
|
|
364
|
+
<${component} token="${publicId}" />
|
|
365
|
+
</main>
|
|
366
|
+
</template>
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
function svelteComponent(component, publicId) {
|
|
370
|
+
return `<script lang="ts">
|
|
371
|
+
import { ${component} } from '@scadable/svelte';
|
|
372
|
+
</script>
|
|
373
|
+
|
|
374
|
+
<main style="${CSS_STYLE}">
|
|
375
|
+
<${component} token="${publicId}" />
|
|
376
|
+
</main>
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
function reactPage(component, publicId, fnName) {
|
|
380
|
+
return `import { ${component} } from '@scadable/react';
|
|
381
|
+
|
|
382
|
+
export default function ${fnName}() {
|
|
383
|
+
return (
|
|
384
|
+
<main style=${JSX_STYLE}>
|
|
385
|
+
<${component} token="${publicId}" />
|
|
386
|
+
</main>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
`;
|
|
390
|
+
}
|
|
391
|
+
function embedSnippet(docType, publicId) {
|
|
392
|
+
return `<div class="scadable-policy" data-token="${publicId}" data-doc-type="${docType}"></div>
|
|
393
|
+
<script src="${EMBED_SRC}" defer></script>`;
|
|
394
|
+
}
|
|
395
|
+
function buildScaffold(framework, docTypes, publicId) {
|
|
396
|
+
const files = [];
|
|
397
|
+
const routes = [];
|
|
398
|
+
const snippets = [];
|
|
399
|
+
for (const docType of docTypes) {
|
|
400
|
+
const one = scaffoldOne(framework, docType, publicId);
|
|
401
|
+
files.push(...one.files);
|
|
402
|
+
routes.push(one.route);
|
|
403
|
+
if (one.snippet) snippets.push(one.snippet);
|
|
404
|
+
}
|
|
405
|
+
return { install: packageFor(framework), files, routes, snippets };
|
|
406
|
+
}
|
|
407
|
+
|
|
153
408
|
// src/cli.ts
|
|
154
409
|
function parseArgs(argv) {
|
|
155
410
|
const args = { api: DEFAULT_API, dryRun: false, yes: false, help: false };
|
|
@@ -157,20 +412,23 @@ function parseArgs(argv) {
|
|
|
157
412
|
const a = argv[i];
|
|
158
413
|
if (a === "--token") args.token = argv[++i];
|
|
159
414
|
else if (a === "--api") args.api = argv[++i];
|
|
415
|
+
else if (a === "--doc-type") args.docType = argv[++i];
|
|
160
416
|
else if (a === "--patch") args.patch = argv[++i];
|
|
161
417
|
else if (a === "--dry-run") args.dryRun = true;
|
|
162
418
|
else if (a === "--yes" || a === "-y") args.yes = true;
|
|
163
419
|
else if (a === "--help" || a === "-h") args.help = true;
|
|
164
420
|
else if (a.startsWith("--token=")) args.token = a.slice("--token=".length);
|
|
165
421
|
else if (a.startsWith("--api=")) args.api = a.slice("--api=".length);
|
|
422
|
+
else if (a.startsWith("--doc-type=")) args.docType = a.slice("--doc-type=".length);
|
|
166
423
|
else if (a.startsWith("--patch=")) args.patch = a.slice("--patch=".length);
|
|
167
424
|
else console.error(pc.yellow(`Ignoring unknown option: ${a}`));
|
|
168
425
|
}
|
|
169
426
|
return args;
|
|
170
427
|
}
|
|
171
428
|
var HELP = `
|
|
172
|
-
${pc.bgCyan(pc.black(" SCADABLE "))} ${pc.bold("
|
|
173
|
-
Add
|
|
429
|
+
${pc.bgCyan(pc.black(" SCADABLE "))} ${pc.bold("setup wizard")}
|
|
430
|
+
Add your always-current legal documents (privacy policy, terms of use) to any app
|
|
431
|
+
in about 20 seconds. One command, one token, done. Commit, deploy, live.
|
|
174
432
|
|
|
175
433
|
${pc.bold("Usage")}
|
|
176
434
|
npx @scadable/wizard@latest --token <TEMP_TOKEN> [options]
|
|
@@ -178,12 +436,17 @@ ${pc.bold("Usage")}
|
|
|
178
436
|
${pc.dim("Get your token from Settings in the SCADABLE app, then paste the whole command here.")}
|
|
179
437
|
|
|
180
438
|
${pc.bold("Options")}
|
|
181
|
-
--token <token>
|
|
182
|
-
--
|
|
183
|
-
--
|
|
184
|
-
--
|
|
185
|
-
--
|
|
186
|
-
--
|
|
439
|
+
--token <token> Install token from the SCADABLE app (required).
|
|
440
|
+
--doc-type <type> privacy | terms | both. Default: ask (privacy if non-interactive).
|
|
441
|
+
--api <url> API base. Default ${DEFAULT_API}.
|
|
442
|
+
--dry-run Preview the plan without writing anything.
|
|
443
|
+
--yes, -y Skip the prompts (non-interactive).
|
|
444
|
+
--patch <file> Add the privacy policy to an existing page instead of creating one.
|
|
445
|
+
--help, -h Show this help.
|
|
446
|
+
|
|
447
|
+
${pc.bold("Frameworks")}
|
|
448
|
+
${pc.dim("Next.js, Astro, Vue/Nuxt, Svelte/SvelteKit, React (Vite, CRA, Remix, Gatsby),")}
|
|
449
|
+
${pc.dim("and plain HTML (universal CDN embed). The wizard detects yours automatically.")}
|
|
187
450
|
|
|
188
451
|
${pc.dim("Your code stays private: only package.json and folder names are read.")}
|
|
189
452
|
`;
|
|
@@ -214,7 +477,65 @@ function previewContents(contents, maxLines = 12) {
|
|
|
214
477
|
}
|
|
215
478
|
return shown.join("\n");
|
|
216
479
|
}
|
|
217
|
-
function
|
|
480
|
+
function docLabel(docType) {
|
|
481
|
+
return docType === "privacy_policy" ? "Privacy Policy" : "Terms of Use";
|
|
482
|
+
}
|
|
483
|
+
function liveUrl(api, publicId, docType) {
|
|
484
|
+
return docType === "privacy_policy" ? `${api}/policy/${publicId}` : `${api}/policy/${publicId}?doc_type=${docType}`;
|
|
485
|
+
}
|
|
486
|
+
function docTypesFromFlag(value) {
|
|
487
|
+
if (!value) return void 0;
|
|
488
|
+
switch (value.trim().toLowerCase()) {
|
|
489
|
+
case "privacy":
|
|
490
|
+
case "privacy_policy":
|
|
491
|
+
return ["privacy_policy"];
|
|
492
|
+
case "terms":
|
|
493
|
+
case "terms_of_use":
|
|
494
|
+
return ["terms_of_use"];
|
|
495
|
+
case "both":
|
|
496
|
+
case "all":
|
|
497
|
+
return ["privacy_policy", "terms_of_use"];
|
|
498
|
+
default:
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function chooseDocTypes(args) {
|
|
503
|
+
const fromFlag = docTypesFromFlag(args.docType);
|
|
504
|
+
if (fromFlag) return fromFlag;
|
|
505
|
+
if (args.docType) {
|
|
506
|
+
log.warn(`Unknown --doc-type "${args.docType}". Expected privacy, terms, or both.`);
|
|
507
|
+
}
|
|
508
|
+
if (args.yes || args.dryRun) return ["privacy_policy"];
|
|
509
|
+
const choice = await select({
|
|
510
|
+
message: "Which documents do you want to add?",
|
|
511
|
+
options: [
|
|
512
|
+
{ value: "privacy", label: "Privacy Policy", hint: "/privacy" },
|
|
513
|
+
{ value: "terms", label: "Terms of Use", hint: "/terms" },
|
|
514
|
+
{ value: "both", label: "Both", hint: "/privacy and /terms" }
|
|
515
|
+
],
|
|
516
|
+
initialValue: "privacy"
|
|
517
|
+
});
|
|
518
|
+
if (isCancel(choice)) {
|
|
519
|
+
cancel("No problem. Nothing was changed. Run this again whenever you are ready.");
|
|
520
|
+
process.exit(0);
|
|
521
|
+
}
|
|
522
|
+
return docTypesFromFlag(String(choice)) ?? ["privacy_policy"];
|
|
523
|
+
}
|
|
524
|
+
function showScaffoldPlan(plan, installCmd) {
|
|
525
|
+
const blocks = [];
|
|
526
|
+
blocks.push(
|
|
527
|
+
installCmd ? `${pc.bold("Install")}
|
|
528
|
+
${pc.cyan(installCmd)}` : `${pc.bold("Install")}
|
|
529
|
+
${pc.dim("Nothing to install. The embed loads from the CDN.")}`
|
|
530
|
+
);
|
|
531
|
+
for (const file of plan.files) {
|
|
532
|
+
blocks.push(`${pc.bold(`Create ${file.path}`)}
|
|
533
|
+
${previewContents(file.contents)}`);
|
|
534
|
+
}
|
|
535
|
+
blocks.push(pc.dim("Built from a vetted template. Same result every time."));
|
|
536
|
+
note(blocks.join("\n\n"), "Here's the plan");
|
|
537
|
+
}
|
|
538
|
+
function showPlanEdits(installCmd, edits, deterministic) {
|
|
218
539
|
const blocks = [`${pc.bold("Install")}
|
|
219
540
|
${pc.cyan(installCmd)}`];
|
|
220
541
|
for (const edit of edits) {
|
|
@@ -225,6 +546,26 @@ ${previewContents(edit.contents)}`);
|
|
|
225
546
|
const source = deterministic ? pc.dim("Built from a vetted template. Same result every time.") : pc.dim("Tailored to your project.");
|
|
226
547
|
note(blocks.join("\n\n") + "\n\n" + source, "Here's the plan");
|
|
227
548
|
}
|
|
549
|
+
async function pickWritableEdits(cwd, edits, yes) {
|
|
550
|
+
const toWrite = [];
|
|
551
|
+
for (const edit of edits) {
|
|
552
|
+
const abs = resolve(cwd, edit.path);
|
|
553
|
+
if (edit.action === "create" && existsSync3(abs) && !yes) {
|
|
554
|
+
const overwrite = await confirm({
|
|
555
|
+
message: `${edit.path} already exists. Replace it?`,
|
|
556
|
+
active: "Replace it",
|
|
557
|
+
inactive: "Keep mine",
|
|
558
|
+
initialValue: false
|
|
559
|
+
});
|
|
560
|
+
if (isCancel(overwrite) || !overwrite) {
|
|
561
|
+
log.warn(`Kept your existing ${edit.path}`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
toWrite.push(edit);
|
|
566
|
+
}
|
|
567
|
+
return toWrite;
|
|
568
|
+
}
|
|
228
569
|
async function main() {
|
|
229
570
|
const args = parseArgs(process.argv.slice(2));
|
|
230
571
|
if (args.help) {
|
|
@@ -232,8 +573,8 @@ async function main() {
|
|
|
232
573
|
return;
|
|
233
574
|
}
|
|
234
575
|
printBanner();
|
|
235
|
-
intro(`${pc.bgCyan(pc.black(" SCADABLE "))} ${pc.bold("
|
|
236
|
-
log.message(pc.dim("
|
|
576
|
+
intro(`${pc.bgCyan(pc.black(" SCADABLE "))} ${pc.bold("setup wizard")}`);
|
|
577
|
+
log.message(pc.dim("Wiring your legal pages in, live from SCADABLE. About 20 seconds."));
|
|
237
578
|
if (!args.token) {
|
|
238
579
|
fail(
|
|
239
580
|
"No install token yet. Open Settings in SCADABLE, then copy and paste the whole install command."
|
|
@@ -271,32 +612,107 @@ async function main() {
|
|
|
271
612
|
spin.stop(`Found ${pc.bold(ctx.framework)}`);
|
|
272
613
|
log.message(pc.dim("Only package.json and folder names were read. Your code stays on your machine."));
|
|
273
614
|
if (ctx.warning) log.warn(ctx.warning);
|
|
274
|
-
let mode = "create";
|
|
275
|
-
let target;
|
|
276
615
|
if (args.patch) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
616
|
+
await runPatch(args, api, token, ctx.framework, ctx.deps, ctx.paths, cwd, spin);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const docTypes = await chooseDocTypes(args);
|
|
620
|
+
const plan = buildScaffold(ctx.framework, docTypes, session.public_id);
|
|
621
|
+
const pm = detectPackageManager(cwd);
|
|
622
|
+
const packages = plan.install ? [plan.install] : [];
|
|
623
|
+
const installCmd = packages.length ? formatInstall(pm, packages) : null;
|
|
624
|
+
showScaffoldPlan(plan, installCmd);
|
|
625
|
+
if (args.dryRun) {
|
|
626
|
+
for (const r of plan.routes) {
|
|
627
|
+
log.message(
|
|
628
|
+
`${pc.dim(`${docLabel(r.docType)} would live at`)} ${pc.cyan(liveUrl(api, session.public_id, r.docType))}`
|
|
285
629
|
);
|
|
286
630
|
}
|
|
287
|
-
|
|
288
|
-
|
|
631
|
+
outro(pc.dim("Dry run. Nothing was written. Run again without --dry-run to set it up."));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (!args.yes) {
|
|
635
|
+
const ok = await confirm({
|
|
636
|
+
message: "Set up your pages?",
|
|
637
|
+
active: "Yes, do it",
|
|
638
|
+
inactive: "Not now",
|
|
639
|
+
initialValue: true
|
|
640
|
+
});
|
|
641
|
+
if (isCancel(ok) || !ok) {
|
|
642
|
+
cancel("No problem. Nothing was changed. Run this again whenever you are ready.");
|
|
643
|
+
process.exit(0);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (packages.length) {
|
|
647
|
+
spin.start(`Installing ${plan.install}`);
|
|
648
|
+
try {
|
|
649
|
+
runInstall(cwd, pm, packages);
|
|
650
|
+
spin.stop(`Installed ${plan.install} ${pc.dim(`(${pm})`)}`);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
spin.stop("Install failed", 2);
|
|
653
|
+
fail(`Could not run "${installCmd}": ${reason(err)}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const edits = plan.files.map((f) => ({ path: f.path, action: "create", contents: f.contents }));
|
|
657
|
+
const toWrite = await pickWritableEdits(cwd, edits, args.yes);
|
|
658
|
+
spin.start("Writing your pages");
|
|
659
|
+
for (const edit of toWrite) writeEdit(cwd, edit);
|
|
660
|
+
spin.stop(toWrite.length ? "Pages written" : "Nothing new to write");
|
|
661
|
+
spin.start("Finishing up");
|
|
662
|
+
try {
|
|
663
|
+
await postComplete(api, token);
|
|
664
|
+
spin.stop("Connected to SCADABLE");
|
|
665
|
+
} catch (err) {
|
|
666
|
+
spin.stop("Files are in place, but could not reach SCADABLE to finish", 1);
|
|
667
|
+
log.warn(`Your files are written. Marking the connection failed: ${reason(err)}`);
|
|
289
668
|
}
|
|
669
|
+
const where = [`${pc.bold("Where they live")}`];
|
|
670
|
+
for (const r of plan.routes) {
|
|
671
|
+
where.push(` ${pc.dim(docLabel(r.docType).padEnd(15))} ${pc.cyan(r.hint)}`);
|
|
672
|
+
where.push(` ${pc.dim("Live".padEnd(15))} ${pc.cyan(liveUrl(api, session.public_id, r.docType))}`);
|
|
673
|
+
}
|
|
674
|
+
const payoff = [
|
|
675
|
+
`${pc.bold("Next:")} commit and deploy. That's it.`,
|
|
676
|
+
` ${pc.cyan('git add -A && git commit -m "Add legal pages"')}`,
|
|
677
|
+
` ${pc.dim("then push, or run your usual deploy")}`,
|
|
678
|
+
``,
|
|
679
|
+
...where,
|
|
680
|
+
``,
|
|
681
|
+
pc.dim("They stay current. Update a document in SCADABLE and every page"),
|
|
682
|
+
pc.dim("updates automatically. No redeploy.")
|
|
683
|
+
];
|
|
684
|
+
if (plan.snippets.length) {
|
|
685
|
+
payoff.push(
|
|
686
|
+
``,
|
|
687
|
+
pc.dim("Prefer to drop it into an existing page? Paste this where you want it:"),
|
|
688
|
+
...plan.snippets.flatMap((s) => s.split("\n").map((l) => ` ${pc.cyan(l)}`))
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
payoff.push(
|
|
692
|
+
``,
|
|
693
|
+
pc.dim("Using a Content-Security-Policy? Add api.scadable.com to connect-src"),
|
|
694
|
+
pc.dim("so the page can refresh live (it falls back to the saved copy if not).")
|
|
695
|
+
);
|
|
696
|
+
note(payoff.join("\n"), pc.green("Your pages are ready"));
|
|
697
|
+
outro(`${pc.green("Done.")} ${pc.dim("Commit and deploy, and you are live.")}`);
|
|
698
|
+
}
|
|
699
|
+
async function runPatch(args, api, token, framework, deps, paths, cwd, spin) {
|
|
700
|
+
const patchPath = args.patch;
|
|
701
|
+
const abs = resolve(cwd, patchPath);
|
|
702
|
+
if (!existsSync3(abs)) {
|
|
703
|
+
fail(`Could not find the file to patch: ${patchPath}`);
|
|
704
|
+
}
|
|
705
|
+
const contents = readFileSync2(abs, "utf8");
|
|
706
|
+
if (looksLikeSecret(contents)) {
|
|
707
|
+
fail(
|
|
708
|
+
`Not uploading ${patchPath}: it looks like it holds secrets. Drop --patch and the wizard will create a fresh page instead.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
const target = { path: patchPath, contents };
|
|
290
712
|
spin.start("Building your plan");
|
|
291
713
|
let plan;
|
|
292
714
|
try {
|
|
293
|
-
plan = await postPlan(api, token, {
|
|
294
|
-
framework: ctx.framework,
|
|
295
|
-
mode,
|
|
296
|
-
deps: ctx.deps,
|
|
297
|
-
paths: ctx.paths,
|
|
298
|
-
target
|
|
299
|
-
});
|
|
715
|
+
plan = await postPlan(api, token, { framework, mode: "patch", deps, paths, target });
|
|
300
716
|
} catch (err) {
|
|
301
717
|
spin.stop("Could not build a plan", 2);
|
|
302
718
|
if (err instanceof WizardApiError && err.status === 503) {
|
|
@@ -311,16 +727,16 @@ async function main() {
|
|
|
311
727
|
const pm = detectPackageManager(cwd);
|
|
312
728
|
const packages = resolvePackages(plan.install);
|
|
313
729
|
const installCmd = formatInstall(pm, packages);
|
|
314
|
-
const
|
|
315
|
-
|
|
730
|
+
const liveLink = `${api}/policy/${plan.public_id}`;
|
|
731
|
+
showPlanEdits(installCmd, plan.edits, plan.deterministic);
|
|
316
732
|
if (args.dryRun) {
|
|
317
|
-
log.message(`${pc.dim("Your page would live at")} ${pc.cyan(
|
|
733
|
+
log.message(`${pc.dim("Your page would live at")} ${pc.cyan(liveLink)}`);
|
|
318
734
|
outro(pc.dim("Dry run. Nothing was written. Run again without --dry-run to set it up."));
|
|
319
735
|
return;
|
|
320
736
|
}
|
|
321
737
|
if (!args.yes) {
|
|
322
738
|
const ok = await confirm({
|
|
323
|
-
message: "
|
|
739
|
+
message: "Add the privacy policy to this page?",
|
|
324
740
|
active: "Yes, do it",
|
|
325
741
|
inactive: "Not now",
|
|
326
742
|
initialValue: true
|
|
@@ -330,36 +746,20 @@ async function main() {
|
|
|
330
746
|
process.exit(0);
|
|
331
747
|
}
|
|
332
748
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const toWrite = [];
|
|
342
|
-
for (const edit of plan.edits) {
|
|
343
|
-
const abs = resolve(cwd, edit.path);
|
|
344
|
-
if (edit.action === "create" && existsSync3(abs) && !args.yes) {
|
|
345
|
-
const overwrite = await confirm({
|
|
346
|
-
message: `${edit.path} already exists. Replace it?`,
|
|
347
|
-
active: "Replace it",
|
|
348
|
-
inactive: "Keep mine",
|
|
349
|
-
initialValue: false
|
|
350
|
-
});
|
|
351
|
-
if (isCancel(overwrite) || !overwrite) {
|
|
352
|
-
log.warn(`Kept your existing ${edit.path}`);
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
749
|
+
if (packages.length) {
|
|
750
|
+
spin.start(`Installing ${packages.join(" ")}`);
|
|
751
|
+
try {
|
|
752
|
+
runInstall(cwd, pm, packages);
|
|
753
|
+
spin.stop(`Installed ${packages.join(" ")} ${pc.dim(`(${pm})`)}`);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
spin.stop("Install failed", 2);
|
|
756
|
+
fail(`Could not run "${installCmd}": ${reason(err)}`);
|
|
355
757
|
}
|
|
356
|
-
toWrite.push(edit);
|
|
357
|
-
}
|
|
358
|
-
spin.start("Writing your privacy page");
|
|
359
|
-
for (const edit of toWrite) {
|
|
360
|
-
writeEdit(cwd, edit);
|
|
361
758
|
}
|
|
362
|
-
|
|
759
|
+
const toWrite = await pickWritableEdits(cwd, plan.edits, args.yes);
|
|
760
|
+
spin.start("Writing your page");
|
|
761
|
+
for (const edit of toWrite) writeEdit(cwd, edit);
|
|
762
|
+
spin.stop(toWrite.length ? "Page written" : "Nothing new to write");
|
|
363
763
|
spin.start("Finishing up");
|
|
364
764
|
try {
|
|
365
765
|
await postComplete(api, token);
|
|
@@ -375,7 +775,7 @@ async function main() {
|
|
|
375
775
|
``,
|
|
376
776
|
`${pc.bold("Where it lives")}`,
|
|
377
777
|
` ${pc.dim("Page ")} ${pc.cyan(plan.route_hint)}`,
|
|
378
|
-
` ${pc.dim("Live ")} ${pc.cyan(
|
|
778
|
+
` ${pc.dim("Live ")} ${pc.cyan(liveLink)}`,
|
|
379
779
|
``,
|
|
380
780
|
pc.dim("It stays current. Update your policy in SCADABLE and every page"),
|
|
381
781
|
pc.dim("updates automatically. No redeploy."),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scadable/wizard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The SCADABLE setup wizard.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The SCADABLE setup wizard. Detects your framework and wires your always-current legal documents (privacy policy, terms of use) into any app, live from SCADABLE.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|