@scadable/wizard 0.1.1 → 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.
Files changed (3) hide show
  1. package/README.md +56 -12
  2. package/dist/cli.js +482 -79
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,24 +1,65 @@
1
1
  # @scadable/wizard
2
2
 
3
- A one-time setup wizard that wires your published SCADABLE privacy policy into your
4
- codebase. Publish your policy in the SCADABLE app, copy the install command from
5
- Settings, and run it in your repo:
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, asks the SCADABLE API for a
12
- tailored plan, then installs [`@scadable/privacy`](../next) and creates your privacy
13
- page. After this, your page always renders the version you last published, with no
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) and a
19
- shallow yes/no listing of a few known folders (`app`, `src/app`, `pages`, ...). It never
20
- reads or transmits your source, and never touches `.env*` files. The one exception is
21
- opt-in `--patch <file>` mode, which reads exactly that one file, and refuses to upload it
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 | Patch an existing file instead of creating a new page. |
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 { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
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 [.../* @__PURE__ */ new Set(["@scadable/privacy", ...specs])];
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 KNOWN_PATHS = ["app", "src/app", "pages", "src/pages", "src", "app/layout.tsx"];
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
- if (!existsSync2(pkgPath)) {
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 has = (rel) => existsSync2(join2(cwd, rel));
123
- const paths = KNOWN_PATHS.filter(has);
124
- const hasNext = deps.includes("next");
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 (hasNext) {
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 (hasReact) {
133
- framework = "react";
134
- } else {
135
- framework = "react";
136
- warning = 'No "next" or "react" dependency found; assuming a generic React app.';
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("privacy wizard")}
173
- Add a live privacy page to your app in about 20 seconds. Commit, deploy, done.
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> Install token from the SCADABLE app (required).
182
- --api <url> API base. Default ${DEFAULT_API}.
183
- --dry-run Preview the plan without writing anything.
184
- --yes, -y Skip the prompts (non-interactive).
185
- --patch <file> Add the policy to an existing page instead of creating one.
186
- --help, -h Show this help.
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 showPlan(installCmd, edits, deterministic) {
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("privacy wizard")}`);
236
- log.message(pc.dim("Setting up your privacy page. About 20 seconds."));
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
- const abs = resolve(cwd, args.patch);
278
- if (!existsSync3(abs)) {
279
- fail(`Could not find the file to patch: ${args.patch}`);
280
- }
281
- const contents = readFileSync2(abs, "utf8");
282
- if (looksLikeSecret(contents)) {
283
- fail(
284
- `Not uploading ${args.patch}: it looks like it holds secrets. Drop --patch and the wizard will create a fresh privacy page instead.`
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
- mode = "patch";
288
- target = { path: args.patch, contents };
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)}`);
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))}`);
289
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 liveUrl = `${api}/policy/${plan.public_id}`;
315
- showPlan(installCmd, plan.edits, plan.deterministic);
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(liveUrl)}`);
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: "Set up your privacy page?",
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
- spin.start("Installing @scadable/privacy");
334
- try {
335
- runInstall(cwd, pm, packages);
336
- spin.stop(`Installed @scadable/privacy ${pc.dim(`(${pm})`)}`);
337
- } catch (err) {
338
- spin.stop("Install failed", 2);
339
- fail(`Could not run "${installCmd}": ${reason(err)}`);
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
- spin.stop(toWrite.length ? "Privacy page written" : "Nothing new to write");
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,10 +775,13 @@ 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(liveUrl)}`,
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
- pc.dim("updates automatically. No redeploy.")
781
+ pc.dim("updates automatically. No redeploy."),
782
+ ``,
783
+ pc.dim("Using a Content-Security-Policy? Add api.scadable.com to connect-src"),
784
+ pc.dim("so the page can refresh live (it falls back to the saved copy if not).")
382
785
  ].join("\n");
383
786
  note(payoff, pc.green("Your privacy page is ready"));
384
787
  outro(`${pc.green("Done.")} ${pc.dim("Commit and deploy, and you are live.")}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scadable/wizard",
3
- "version": "0.1.1",
4
- "description": "The SCADABLE setup wizard. Wires SCADABLE into your codebase (privacy policy now, more to come).",
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": {