@scadable/wizard 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 +41 -0
- package/dist/cli.js +338 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @scadable/wizard
|
|
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:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @scadable/wizard@latest --token <TEMP_TOKEN>
|
|
9
|
+
```
|
|
10
|
+
|
|
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.
|
|
15
|
+
|
|
16
|
+
## What it sends
|
|
17
|
+
|
|
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
|
|
22
|
+
if it looks like it contains secrets.
|
|
23
|
+
|
|
24
|
+
## Options
|
|
25
|
+
|
|
26
|
+
| Flag | Default | What it does |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `--token <token>` | required | Install token from the SCADABLE app. |
|
|
29
|
+
| `--api <url>` | `https://api.scadable.com` | API base. |
|
|
30
|
+
| `--dry-run` | off | Show the plan, write nothing. |
|
|
31
|
+
| `--yes`, `-y` | off | Skip confirmation prompts (non-interactive). |
|
|
32
|
+
| `--patch <file>` | off | Patch an existing file instead of creating a new page. |
|
|
33
|
+
| `--help`, `-h` | | Show usage. |
|
|
34
|
+
|
|
35
|
+
The package manager is detected from your lockfile (`pnpm-lock.yaml` -> pnpm,
|
|
36
|
+
`yarn.lock` -> yarn, otherwise npm).
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- This package only talks to the public SCADABLE API. It stores no secrets.
|
|
41
|
+
- It is meant to be run once, via `npx`, to connect a repo.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// src/api.ts
|
|
10
|
+
var DEFAULT_API = "https://api.scadable.com";
|
|
11
|
+
var WizardApiError = class extends Error {
|
|
12
|
+
status;
|
|
13
|
+
constructor(message, status) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "WizardApiError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var WizardNetworkError = class extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "WizardNetworkError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function endpoint(api, path) {
|
|
26
|
+
return `${api.replace(/\/$/, "")}${path}`;
|
|
27
|
+
}
|
|
28
|
+
async function request(api, path, token, init = { method: "GET" }) {
|
|
29
|
+
const url = endpoint(api, path);
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(url, {
|
|
33
|
+
method: init.method,
|
|
34
|
+
headers: {
|
|
35
|
+
"X-Wizard-Token": token,
|
|
36
|
+
...init.body ? { "Content-Type": "application/json" } : {}
|
|
37
|
+
},
|
|
38
|
+
body: init.body ? JSON.stringify(init.body) : void 0
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const reason2 = err instanceof Error ? err.message : String(err);
|
|
42
|
+
throw new WizardNetworkError(`could not reach ${url}: ${reason2}`);
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new WizardApiError(await detail(res, path), res.status);
|
|
46
|
+
}
|
|
47
|
+
return await res.json();
|
|
48
|
+
}
|
|
49
|
+
async function detail(res, path) {
|
|
50
|
+
let extra = "";
|
|
51
|
+
try {
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
if (typeof data?.detail === "string") extra = `: ${data.detail}`;
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return `${path} returned ${res.status}${extra}`;
|
|
57
|
+
}
|
|
58
|
+
function getSession(api, token) {
|
|
59
|
+
return request(api, "/wizard/session", token);
|
|
60
|
+
}
|
|
61
|
+
function postPlan(api, token, body) {
|
|
62
|
+
return request(api, "/wizard/plan", token, { method: "POST", body });
|
|
63
|
+
}
|
|
64
|
+
function postComplete(api, token) {
|
|
65
|
+
return request(api, "/wizard/complete", token, { method: "POST" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/apply.ts
|
|
69
|
+
import { execFileSync } from "child_process";
|
|
70
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
71
|
+
import { dirname, join } from "path";
|
|
72
|
+
function detectPackageManager(cwd) {
|
|
73
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
74
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
75
|
+
return "npm";
|
|
76
|
+
}
|
|
77
|
+
var INSTALL_VERB = {
|
|
78
|
+
pnpm: "add",
|
|
79
|
+
yarn: "add",
|
|
80
|
+
npm: "install"
|
|
81
|
+
};
|
|
82
|
+
function resolvePackages(install) {
|
|
83
|
+
const noise = /^(?:npm|yarn|pnpm|install|add|i|--save|-S|-D|--save-dev|--dev)$/;
|
|
84
|
+
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])];
|
|
86
|
+
}
|
|
87
|
+
function formatInstall(pm, packages) {
|
|
88
|
+
return `${pm} ${INSTALL_VERB[pm]} ${packages.join(" ")}`;
|
|
89
|
+
}
|
|
90
|
+
function runInstall(cwd, pm, packages) {
|
|
91
|
+
execFileSync(pm, [INSTALL_VERB[pm], ...packages], {
|
|
92
|
+
cwd,
|
|
93
|
+
stdio: "inherit",
|
|
94
|
+
// Windows resolves npm/yarn/pnpm through the shell.
|
|
95
|
+
shell: process.platform === "win32"
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function writeEdit(cwd, edit) {
|
|
99
|
+
const abs = join(cwd, edit.path);
|
|
100
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
101
|
+
writeFileSync(abs, edit.contents, "utf8");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/detect.ts
|
|
105
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
106
|
+
import { join as join2 } from "path";
|
|
107
|
+
var KNOWN_PATHS = ["app", "src/app", "pages", "src/pages", "src", "app/layout.tsx"];
|
|
108
|
+
function detectRepo(cwd) {
|
|
109
|
+
const pkgPath = join2(cwd, "package.json");
|
|
110
|
+
if (!existsSync2(pkgPath)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"No package.json found here. Run this inside your web app, next to its package.json."
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
let pkg;
|
|
116
|
+
try {
|
|
117
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error("Could not parse package.json. Is it valid JSON?");
|
|
120
|
+
}
|
|
121
|
+
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");
|
|
126
|
+
let framework;
|
|
127
|
+
let warning;
|
|
128
|
+
if (hasNext) {
|
|
129
|
+
if (has("app") || has("src/app")) framework = "next-app";
|
|
130
|
+
else if (has("pages") || has("src/pages")) framework = "next-pages";
|
|
131
|
+
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.';
|
|
137
|
+
}
|
|
138
|
+
return { framework, deps, paths, warning };
|
|
139
|
+
}
|
|
140
|
+
var SECRET_PATTERNS = [
|
|
141
|
+
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/,
|
|
142
|
+
/\bapi[_-]?key\s*[:=]/i,
|
|
143
|
+
/\b(?:access|secret)[_-]?key\s*[:=]/i,
|
|
144
|
+
/\bsecret\b/i,
|
|
145
|
+
/\bpassword\s*[:=]/i,
|
|
146
|
+
/\b(?:auth|access|bearer)[_-]?token\s*[:=]/i,
|
|
147
|
+
/\bAKIA[0-9A-Z]{16}\b/
|
|
148
|
+
];
|
|
149
|
+
function looksLikeSecret(contents) {
|
|
150
|
+
return SECRET_PATTERNS.some((re) => re.test(contents));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/cli.ts
|
|
154
|
+
function parseArgs(argv) {
|
|
155
|
+
const args = { api: DEFAULT_API, dryRun: false, yes: false, help: false };
|
|
156
|
+
for (let i = 0; i < argv.length; i++) {
|
|
157
|
+
const a = argv[i];
|
|
158
|
+
if (a === "--token") args.token = argv[++i];
|
|
159
|
+
else if (a === "--api") args.api = argv[++i];
|
|
160
|
+
else if (a === "--patch") args.patch = argv[++i];
|
|
161
|
+
else if (a === "--dry-run") args.dryRun = true;
|
|
162
|
+
else if (a === "--yes" || a === "-y") args.yes = true;
|
|
163
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
164
|
+
else if (a.startsWith("--token=")) args.token = a.slice("--token=".length);
|
|
165
|
+
else if (a.startsWith("--api=")) args.api = a.slice("--api=".length);
|
|
166
|
+
else if (a.startsWith("--patch=")) args.patch = a.slice("--patch=".length);
|
|
167
|
+
else console.error(pc.yellow(`Ignoring unknown option: ${a}`));
|
|
168
|
+
}
|
|
169
|
+
return args;
|
|
170
|
+
}
|
|
171
|
+
var HELP = `
|
|
172
|
+
${pc.bold("@scadable/wizard")} - wire your SCADABLE privacy policy into this repo.
|
|
173
|
+
|
|
174
|
+
${pc.bold("Usage")}
|
|
175
|
+
npx @scadable/wizard@latest --token <TEMP_TOKEN> [options]
|
|
176
|
+
|
|
177
|
+
${pc.bold("Options")}
|
|
178
|
+
--token <token> Install token from the SCADABLE app (required).
|
|
179
|
+
--api <url> API base. Default ${DEFAULT_API}.
|
|
180
|
+
--dry-run Show the plan, write nothing.
|
|
181
|
+
--yes, -y Skip confirmation prompts (non-interactive).
|
|
182
|
+
--patch <file> Patch an existing file instead of creating a new page.
|
|
183
|
+
--help, -h Show this help.
|
|
184
|
+
`;
|
|
185
|
+
function fail(message) {
|
|
186
|
+
log.error(message);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
function reason(err) {
|
|
190
|
+
return err instanceof Error ? err.message : String(err);
|
|
191
|
+
}
|
|
192
|
+
function previewContents(contents, maxLines = 12) {
|
|
193
|
+
const lines = contents.split("\n");
|
|
194
|
+
const shown = lines.slice(0, maxLines).map((l) => pc.dim(" " + l));
|
|
195
|
+
if (lines.length > maxLines) {
|
|
196
|
+
shown.push(pc.dim(` ... (${lines.length - maxLines} more lines)`));
|
|
197
|
+
}
|
|
198
|
+
return shown.join("\n");
|
|
199
|
+
}
|
|
200
|
+
function showPlan(installCmd, edits, deterministic) {
|
|
201
|
+
const blocks = [`${pc.bold("Install")}
|
|
202
|
+
${pc.cyan(installCmd)}`];
|
|
203
|
+
for (const edit of edits) {
|
|
204
|
+
const verb = edit.action === "patch" ? "Patch" : "Create";
|
|
205
|
+
blocks.push(`${pc.bold(`${verb} ${edit.path}`)}
|
|
206
|
+
${previewContents(edit.contents)}`);
|
|
207
|
+
}
|
|
208
|
+
const source = deterministic ? pc.dim("Plan is deterministic (template-based).") : pc.dim("Plan was generated for your repo.");
|
|
209
|
+
note(blocks.join("\n\n") + "\n\n" + source, "Plan");
|
|
210
|
+
}
|
|
211
|
+
async function main() {
|
|
212
|
+
const args = parseArgs(process.argv.slice(2));
|
|
213
|
+
if (args.help) {
|
|
214
|
+
console.log(HELP);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
intro(pc.bgCyan(pc.black(" SCADABLE privacy wizard ")));
|
|
218
|
+
if (!args.token) {
|
|
219
|
+
fail("Missing --token. Open Settings in the SCADABLE app and copy the install command.");
|
|
220
|
+
}
|
|
221
|
+
const token = args.token;
|
|
222
|
+
const api = args.api.replace(/\/$/, "");
|
|
223
|
+
const cwd = process.cwd();
|
|
224
|
+
const spin = spinner();
|
|
225
|
+
spin.start("Verifying your install token");
|
|
226
|
+
let session;
|
|
227
|
+
try {
|
|
228
|
+
session = await getSession(api, token);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
spin.stop("Token check failed");
|
|
231
|
+
if (err instanceof WizardApiError && err.status === 401) {
|
|
232
|
+
fail(
|
|
233
|
+
"Your install token expired. Reopen Settings in the SCADABLE app and copy a fresh command."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (err instanceof WizardNetworkError) {
|
|
237
|
+
fail(`Could not reach the SCADABLE API at ${api}. ${reason(err)}`);
|
|
238
|
+
}
|
|
239
|
+
fail(`Could not verify your token: ${reason(err)}`);
|
|
240
|
+
}
|
|
241
|
+
spin.stop(`Connected to ${pc.bold(session.scope_name)} (${session.domain})`);
|
|
242
|
+
let ctx;
|
|
243
|
+
try {
|
|
244
|
+
ctx = detectRepo(cwd);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
fail(reason(err));
|
|
247
|
+
}
|
|
248
|
+
if (ctx.warning) log.warn(ctx.warning);
|
|
249
|
+
let mode = "create";
|
|
250
|
+
let target;
|
|
251
|
+
if (args.patch) {
|
|
252
|
+
const abs = resolve(cwd, args.patch);
|
|
253
|
+
if (!existsSync3(abs)) {
|
|
254
|
+
fail(`--patch file not found: ${args.patch}`);
|
|
255
|
+
}
|
|
256
|
+
const contents = readFileSync2(abs, "utf8");
|
|
257
|
+
if (looksLikeSecret(contents)) {
|
|
258
|
+
fail(
|
|
259
|
+
`Refusing to upload ${args.patch}: it looks like it contains secrets. Drop --patch to create a fresh privacy page instead.`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
mode = "patch";
|
|
263
|
+
target = { path: args.patch, contents };
|
|
264
|
+
}
|
|
265
|
+
log.info(`Detected ${pc.bold(ctx.framework)} (mode: ${mode})`);
|
|
266
|
+
spin.start("Building your install plan");
|
|
267
|
+
let plan;
|
|
268
|
+
try {
|
|
269
|
+
plan = await postPlan(api, token, {
|
|
270
|
+
framework: ctx.framework,
|
|
271
|
+
mode,
|
|
272
|
+
deps: ctx.deps,
|
|
273
|
+
paths: ctx.paths,
|
|
274
|
+
target
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
spin.stop("Could not build a plan");
|
|
278
|
+
if (err instanceof WizardApiError && err.status === 503) {
|
|
279
|
+
fail("SCADABLE could not generate a plan right now (model unavailable). Try again shortly.");
|
|
280
|
+
}
|
|
281
|
+
if (err instanceof WizardNetworkError) {
|
|
282
|
+
fail(`Could not reach the SCADABLE API at ${api}. ${reason(err)}`);
|
|
283
|
+
}
|
|
284
|
+
fail(`Could not build a plan: ${reason(err)}`);
|
|
285
|
+
}
|
|
286
|
+
spin.stop("Plan ready");
|
|
287
|
+
const pm = detectPackageManager(cwd);
|
|
288
|
+
const packages = resolvePackages(plan.install);
|
|
289
|
+
const installCmd = formatInstall(pm, packages);
|
|
290
|
+
showPlan(installCmd, plan.edits, plan.deterministic);
|
|
291
|
+
if (args.dryRun) {
|
|
292
|
+
outro(pc.dim("Dry run complete. Nothing was written."));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!args.yes) {
|
|
296
|
+
const ok = await confirm({ message: "Apply this plan?" });
|
|
297
|
+
if (isCancel(ok) || !ok) {
|
|
298
|
+
cancel("No changes made.");
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
spin.start(`Installing with ${pm}`);
|
|
303
|
+
try {
|
|
304
|
+
runInstall(cwd, pm, packages);
|
|
305
|
+
spin.stop("Dependency installed");
|
|
306
|
+
} catch (err) {
|
|
307
|
+
spin.stop("Install failed");
|
|
308
|
+
fail(`Could not run "${installCmd}": ${reason(err)}`);
|
|
309
|
+
}
|
|
310
|
+
for (const edit of plan.edits) {
|
|
311
|
+
const abs = resolve(cwd, edit.path);
|
|
312
|
+
if (edit.action === "create" && existsSync3(abs) && !args.yes) {
|
|
313
|
+
const overwrite = await confirm({ message: `${edit.path} already exists. Overwrite it?` });
|
|
314
|
+
if (isCancel(overwrite) || !overwrite) {
|
|
315
|
+
log.warn(`Skipped ${edit.path}`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
writeEdit(cwd, edit);
|
|
320
|
+
log.success(`${edit.action === "patch" ? "Patched" : "Wrote"} ${edit.path}`);
|
|
321
|
+
}
|
|
322
|
+
spin.start("Finishing setup");
|
|
323
|
+
try {
|
|
324
|
+
await postComplete(api, token);
|
|
325
|
+
spin.stop("Marked connected");
|
|
326
|
+
} catch (err) {
|
|
327
|
+
spin.stop("Setup written, but could not notify SCADABLE");
|
|
328
|
+
log.warn(`The files are in place, but marking the connection failed: ${reason(err)}`);
|
|
329
|
+
}
|
|
330
|
+
outro(
|
|
331
|
+
`${pc.green("Done.")} ${plan.route_hint}
|
|
332
|
+
${pc.dim("Public id:")} ${plan.public_id}`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
main().catch((err) => {
|
|
336
|
+
log.error(reason(err));
|
|
337
|
+
process.exit(1);
|
|
338
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scadable/wizard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The SCADABLE setup wizard. Wires SCADABLE into your codebase (privacy policy now, more to come).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"scadable-wizard": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"scadable",
|
|
21
|
+
"privacy",
|
|
22
|
+
"privacy-policy",
|
|
23
|
+
"compliance",
|
|
24
|
+
"wizard",
|
|
25
|
+
"cli",
|
|
26
|
+
"setup"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@clack/prompts": "^0.7.0",
|
|
33
|
+
"picocolors": "^1.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.16.0",
|
|
37
|
+
"tsup": "^8.3.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/scadable/sdk.git",
|
|
46
|
+
"directory": "wizard"
|
|
47
|
+
}
|
|
48
|
+
}
|