periderm-cli 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 ADDED
@@ -0,0 +1,13 @@
1
+ # periderm
2
+
3
+ A pre-launch checklist for every project.
4
+
5
+ ```bash
6
+ periderm scan # scan current directory, print verdict, write .periderm/last-report.md
7
+ periderm scan --upload # also push to dashboard
8
+ periderm watch # rerun on file change
9
+ periderm login # authenticate the CLI
10
+ periderm whoami # show current token + plan
11
+ ```
12
+
13
+ See full install + usage at the project `/docs` page.
package/dist/api.js ADDED
@@ -0,0 +1,60 @@
1
+ import { readConfig } from "./config.js";
2
+ export async function verifyToken(token) {
3
+ const { apiUrl } = readConfig();
4
+ try {
5
+ const res = await fetch(`${apiUrl}/api/public/verify-token`, {
6
+ method: "POST",
7
+ headers: { "Content-Type": "application/json" },
8
+ body: JSON.stringify({ token }),
9
+ });
10
+ if (!res.ok) {
11
+ const text = await res.text();
12
+ let errorStr = "Unknown server error";
13
+ try {
14
+ const json = JSON.parse(text);
15
+ if (json.error)
16
+ errorStr = json.error;
17
+ }
18
+ catch {
19
+ errorStr = text.includes("<!") ? "Server returned an HTML error page." : text.slice(0, 100);
20
+ }
21
+ return { valid: false, error: `HTTP ${res.status}: ${errorStr}` };
22
+ }
23
+ return (await res.json());
24
+ }
25
+ catch (e) {
26
+ return { valid: false, error: e.message || "Failed to reach server" };
27
+ }
28
+ }
29
+ export async function uploadScan(token, result) {
30
+ const { apiUrl } = readConfig();
31
+ try {
32
+ const res = await fetch(`${apiUrl}/api/public/scan`, {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "x-periderm-token": token,
37
+ },
38
+ body: JSON.stringify({
39
+ project_name: result.projectName,
40
+ score: result.score,
41
+ reality_score: result.scores.reality,
42
+ perceived_score: result.scores.perceived,
43
+ verdict: result.verdict,
44
+ critical_count: result.counts.critical,
45
+ high_count: result.counts.high,
46
+ medium_count: result.counts.medium,
47
+ low_count: result.counts.low,
48
+ findings: result.findings,
49
+ markdown_report: result.markdown,
50
+ }),
51
+ });
52
+ if (!res.ok) {
53
+ return { error: `${res.status} ${await res.text()}` };
54
+ }
55
+ return (await res.json());
56
+ }
57
+ catch (e) {
58
+ return { error: e.message || "Network error: failed to reach server" };
59
+ }
60
+ }
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import dotenv from "dotenv";
5
+ // Try to load .env from the current working directory (useful for local development)
6
+ dotenv.config({ path: path.resolve(process.cwd(), ".env") });
7
+ const CONFIG_DIR = path.join(os.homedir(), ".periderm");
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
9
+ // Read API URL purely from environment variables (local .env or Vercel config)
10
+ const DEFAULT_API_URL = process.env.PERIDERM_API_URL;
11
+ export function readConfig() {
12
+ try {
13
+ if (fs.existsSync(CONFIG_FILE)) {
14
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
15
+ return { apiUrl: raw.apiUrl || DEFAULT_API_URL, token: raw.token };
16
+ }
17
+ }
18
+ catch (e) {
19
+ console.error("[periderm] Failed to read config file — using defaults.", e);
20
+ return { apiUrl: DEFAULT_API_URL };
21
+ }
22
+ return { apiUrl: DEFAULT_API_URL };
23
+ }
24
+ export function writeConfig(cfg) {
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
27
+ }
@@ -0,0 +1,10 @@
1
+ export const PERIDERM_ASCII = String.raw `
2
+ /$$$$$$$ /$$ /$$
3
+ | $$__ $$ |__/ | $$
4
+ | $$ \ $$ /$$$$$$ /$$$$$$ /$$ /$$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$/$$$$
5
+ | $$$$$$$//$$__ $$ /$$__ $$| $$ /$$__ $$ /$$__ $$ /$$__ $$| $$_ $$_ $$
6
+ | $$____/| $$$$$$$$| $$ \__/| $$| $$ | $$| $$$$$$$$| $$ \__/| $$ \ $$ \ $$
7
+ | $$ | $$_____/| $$ | $$| $$ | $$| $$_____/| $$ | $$ | $$ | $$
8
+ | $$ | $$$$$$$| $$ | $$| $$$$$$$| $$$$$$$| $$ | $$ | $$ | $$
9
+ |__/ \_______/|__/ |__/ \_______/ \_______/|__/ |__/ |__/ |__/
10
+ `;
package/dist/index.js ADDED
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import open from "open";
8
+ import { scan } from "./scanner/index.js";
9
+ import { runDeepReview } from "./review/deep.js";
10
+ import { printTerminal } from "./report/terminal.js";
11
+ import { readConfig, writeConfig } from "./config.js";
12
+ import { verifyToken, uploadScan } from "./api.js";
13
+ import http from "node:http";
14
+ import ora from "ora";
15
+ import { PERIDERM_ASCII } from "./constants.js";
16
+ import { PRE_SCAN_MESSAGES, UPLOAD_MESSAGES, startRotatingMessages, finishRotatingMessages } from "./loading-messages.js";
17
+ import { formatScanPath } from "./scanner/progress.js";
18
+ const program = new Command();
19
+ program
20
+ .name("periderm")
21
+ .description("A pre-launch checklist.")
22
+ .version("0.1.0");
23
+ program
24
+ .command("scan")
25
+ .description("Scan the current directory and write a markdown report.")
26
+ .option("--upload", "Upload the scan to your Periderm dashboard")
27
+ .option("--cwd <dir>", "Directory to scan", process.cwd())
28
+ .action(async (opts) => {
29
+ const root = path.resolve(opts.cwd);
30
+ const cfg = readConfig();
31
+ if (!cfg.token) {
32
+ console.error(chalk.red("Error: You must be logged in to scan. Run `periderm login` first."));
33
+ process.exit(1);
34
+ }
35
+ const verifySpinner = ora({ text: PRE_SCAN_MESSAGES[0], color: "cyan" }).start();
36
+ const verifyStarted = Date.now();
37
+ const stopRotating = startRotatingMessages(verifySpinner, PRE_SCAN_MESSAGES);
38
+ const v = await verifyToken(cfg.token);
39
+ await finishRotatingMessages(stopRotating, verifyStarted);
40
+ if (!v.valid) {
41
+ verifySpinner.fail(`Couldn't connect — ${v.error}. Run \`periderm login\` again.`);
42
+ process.exit(1);
43
+ }
44
+ if (v.plan !== "unlimited" && (v.scans_remaining === undefined || v.scans_remaining <= 0)) {
45
+ verifySpinner.fail(`No scans left on your ${v.plan} plan.`);
46
+ console.info(chalk.yellow(`\nRenew or upgrade at ${cfg.apiUrl}/dashboard/billing\n`));
47
+ process.exit(1);
48
+ }
49
+ verifySpinner.stop();
50
+ const startTime = Date.now();
51
+ console.info(chalk.cyan("Discovering project files…"));
52
+ let scanSpinner = ora({ text: "Initializing...", color: "cyan" }).start();
53
+ let lastFileUpdate = 0;
54
+ // helper for ASCII progress bar
55
+ const makeBar = (curr, total) => {
56
+ const pct = total === 0 ? 0 : curr / total;
57
+ const filled = Math.round(pct * 20);
58
+ const empty = 20 - filled;
59
+ return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${Math.round(pct * 100)}%`;
60
+ };
61
+ const result = await scan(root, {
62
+ onProgress: (ev) => {
63
+ switch (ev.type) {
64
+ case "discovering":
65
+ scanSpinner.text = "Discovering project files…";
66
+ break;
67
+ case "discovered":
68
+ scanSpinner.stopAndPersist({ symbol: chalk.green("✔"), text: chalk.white(`Found ${ev.total} source files.`) });
69
+ scanSpinner = ora({ text: "Scanning...", color: "cyan" }).start();
70
+ break;
71
+ case "file": {
72
+ const now = Date.now();
73
+ const isLast = ev.index === ev.total;
74
+ if (!isLast && now - lastFileUpdate < 90)
75
+ break;
76
+ lastFileUpdate = now;
77
+ const bar = makeBar(ev.index, ev.total);
78
+ scanSpinner.text = `${bar} ${formatScanPath(ev.file)}`;
79
+ break;
80
+ }
81
+ case "checks":
82
+ scanSpinner.text = `Running deep checks [${ev.current}/${ev.total}]…`;
83
+ break;
84
+ case "phase":
85
+ scanSpinner.text = ev.label;
86
+ break;
87
+ }
88
+ },
89
+ });
90
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
91
+ const totalFindings = result.counts.critical +
92
+ result.counts.high +
93
+ result.counts.medium +
94
+ result.counts.low;
95
+ scanSpinner.succeed(`Scanned ${result.filesScanned} files · ${totalFindings} finding${totalFindings === 1 ? "" : "s"} · ${elapsed}s`);
96
+ printTerminal(result);
97
+ const outDir = path.join(root, ".periderm");
98
+ await fs.mkdir(outDir, { recursive: true });
99
+ await fs.writeFile(path.join(outDir, "last-report.md"), result.markdown, "utf8");
100
+ await fs.writeFile(path.join(outDir, "last-report.json"), JSON.stringify(result, null, 2), "utf8");
101
+ if (opts.upload) {
102
+ const cfg = readConfig();
103
+ if (!cfg.token) {
104
+ console.info(chalk.yellow("no token configured — run `periderm login` first to sync to dashboard."));
105
+ }
106
+ else {
107
+ const uploadSpinner = ora({ text: UPLOAD_MESSAGES[0], color: "cyan" }).start();
108
+ const uploadStarted = Date.now();
109
+ const stopUploadRotate = startRotatingMessages(uploadSpinner, UPLOAD_MESSAGES);
110
+ const res = await uploadScan(cfg.token, result);
111
+ await finishRotatingMessages(stopUploadRotate, uploadStarted);
112
+ if (res.error) {
113
+ uploadSpinner.fail(`upload failed: ${res.error}`);
114
+ if (res.error.includes("401") || res.error.includes("Invalid token") || res.error.includes("expired")) {
115
+ writeConfig({ ...cfg, token: undefined });
116
+ console.info(chalk.yellow("\nYour session expired or your token is invalid. Please run `periderm login` to authenticate again."));
117
+ }
118
+ // Throw so it gets caught by the crash reporter and sent to Formspree
119
+ throw new Error(`Upload failed: ${res.error}\nContext: Uploading scan for project ${result.projectName}`);
120
+ }
121
+ else {
122
+ uploadSpinner.succeed(`Uploaded! View it at: ${chalk.underline.blue(`${cfg.apiUrl}/dashboard/scans/${res.id ?? ""}`)}`);
123
+ if (v.plan !== "unlimited" && v.scans_remaining !== undefined) {
124
+ const remaining = Math.max(0, v.scans_remaining - 1);
125
+ console.info("");
126
+ console.info(`${chalk.white("Quota:")} ${chalk.bold.white(`${remaining} scans remaining`)}`);
127
+ if (remaining <= 3) {
128
+ console.info(chalk.yellow(`⚠️ Almost exhausted! Renew or upgrade at `) + chalk.underline.cyan(`${cfg.apiUrl}/dashboard/billing`));
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ });
135
+ program
136
+ .command("review")
137
+ .description("Run AI deep review on the last scan (Scale/Unlimited). Requires GROQ_API_KEY.")
138
+ .option("--deep", "Run the AI agent deep review")
139
+ .option("--cwd <dir>", "Project directory", process.cwd())
140
+ .action(async (opts) => {
141
+ if (!opts.deep) {
142
+ console.error(chalk.yellow("Use `periderm review --deep` to run the AI agent review."));
143
+ process.exit(1);
144
+ }
145
+ const root = path.resolve(opts.cwd);
146
+ const cfg = readConfig();
147
+ if (!cfg.token) {
148
+ console.error(chalk.red("Error: You must be logged in. Run `periderm login` first."));
149
+ process.exit(1);
150
+ }
151
+ const apiKey = process.env.GROQ_API_KEY?.trim();
152
+ if (!apiKey) {
153
+ console.error(chalk.red("Error: Set GROQ_API_KEY in your environment to run deep review."));
154
+ process.exit(1);
155
+ }
156
+ const reportPath = path.join(root, ".periderm", "last-report.json");
157
+ let scanResult;
158
+ try {
159
+ scanResult = JSON.parse(await fs.readFile(reportPath, "utf8"));
160
+ }
161
+ catch {
162
+ console.error(chalk.red("No scan found. Run `periderm scan` first."));
163
+ process.exit(1);
164
+ }
165
+ const verifySpinner = ora({ text: "Verifying plan…", color: "cyan" }).start();
166
+ const v = await verifyToken(cfg.token);
167
+ if (!v.valid) {
168
+ verifySpinner.fail(`Couldn't connect — ${v.error}`);
169
+ process.exit(1);
170
+ }
171
+ if (v.plan !== "scale" && v.plan !== "unlimited") {
172
+ verifySpinner.fail("Deep review requires Scale or Unlimited plan.");
173
+ console.info(chalk.yellow(`\nUpgrade at ${cfg.apiUrl}/dashboard/billing\n`));
174
+ process.exit(1);
175
+ }
176
+ verifySpinner.succeed("Plan verified");
177
+ const reviewSpinner = ora({ text: "Starting deep review agent…", color: "cyan" }).start();
178
+ const { markdown, findings } = await runDeepReview(root, scanResult, apiKey, (msg) => {
179
+ reviewSpinner.text = msg;
180
+ });
181
+ scanResult.findings.push(...findings);
182
+ for (const f of findings)
183
+ scanResult.counts[f.severity]++;
184
+ const outDir = path.join(root, ".periderm");
185
+ const mdPath = path.join(outDir, "last-report.md");
186
+ const existingMd = await fs.readFile(mdPath, "utf8").catch(() => scanResult.markdown);
187
+ const combinedMd = `${existingMd}\n\n${markdown}`;
188
+ scanResult.markdown = combinedMd;
189
+ await fs.writeFile(mdPath, combinedMd, "utf8");
190
+ await fs.writeFile(reportPath, JSON.stringify(scanResult, null, 2), "utf8");
191
+ reviewSpinner.succeed(`Deep review complete · ${findings.length} additional finding${findings.length === 1 ? "" : "s"}`);
192
+ console.info(chalk.white(`\nAppended to ${mdPath}\n`));
193
+ });
194
+ program
195
+ .command("watch")
196
+ .description("Rerun the scan on file change.")
197
+ .option("--cwd <dir>", "Directory to watch", process.cwd())
198
+ .action(async (opts) => {
199
+ const root = path.resolve(opts.cwd);
200
+ console.info(chalk.white(`watching ${root}… (Ctrl-C to stop)`));
201
+ let scheduled = null;
202
+ const run = async () => {
203
+ const result = await scan(root);
204
+ console.clear();
205
+ printTerminal(result);
206
+ };
207
+ await run();
208
+ try {
209
+ const watcher = fs.watch(root, { recursive: true });
210
+ for await (const _ of watcher) {
211
+ if (scheduled)
212
+ clearTimeout(scheduled);
213
+ scheduled = setTimeout(run, 400);
214
+ }
215
+ }
216
+ catch (e) {
217
+ console.error(chalk.red("watch failed:"), e);
218
+ process.exitCode = 1;
219
+ }
220
+ });
221
+ program
222
+ .command("login")
223
+ .description("Open the dashboard to grab a CLI token, then paste it here.")
224
+ .action(async () => {
225
+ const { apiUrl } = readConfig();
226
+ const server = http.createServer(async (req, res) => {
227
+ const url = new URL(req.url || "", `http://127.0.0.1:${req.socket.localPort}`);
228
+ const token = url.searchParams.get("token");
229
+ if (token) {
230
+ res.writeHead(200, { "Content-Type": "text/html" });
231
+ const html = `
232
+ <!DOCTYPE html>
233
+ <html lang="en">
234
+ <head>
235
+ <meta charset="UTF-8">
236
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
237
+ <title>Periderm CLI Authenticated</title>
238
+ <style>
239
+ :root {
240
+ --bg: #FAFAFA;
241
+ --card: #FFFFFF;
242
+ --border: #EAEAEA;
243
+ --text: #111111;
244
+ --text-muted: #888888;
245
+ --ember: #FF4D00;
246
+ --ember-bg: rgba(255, 77, 0, 0.1);
247
+ --btn-bg: #111111;
248
+ --btn-text: #FFFFFF;
249
+ --btn-hover: #333333;
250
+ }
251
+ body { background-color: var(--bg); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; transition: background-color 0.2s, color 0.2s; }
252
+ .card { background-color: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; text-align: center; max-width: 420px; box-shadow: 0 8px 32px rgba(0,0,0,0.08); }
253
+ h1 { margin-top: 0; font-size: 24px; font-weight: 600; letter-spacing: -0.02em; }
254
+ p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
255
+ .icon { width: 56px; height: 56px; border-radius: 12px; background-color: var(--ember-bg); color: var(--ember); display: inline-flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 24px; border: 1px solid rgba(255, 77, 0, 0.2); }
256
+ .btn { display: block; width: 100%; padding: 12px 24px; background-color: var(--btn-bg); color: var(--btn-text); text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 500; transition: background-color 0.2s; box-sizing: border-box; }
257
+ .btn:hover { background-color: var(--btn-hover); }
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="card">
262
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" width="56" height="56" style="margin: 0 auto 24px auto; display: block; box-shadow: 0 4px 12px rgba(0,0,0,0.1); border-radius: 12px;">
263
+ <rect width="32" height="32" rx="7" fill="#111111"/>
264
+ <text x="5" y="23" font-family="ui-monospace,monospace" font-size="16" font-weight="600" fill="#ffffff">▸_</text>
265
+ <g transform="translate(15, 6) scale(0.55)">
266
+ <path d="m9.03 21.69 2.33-1.96c.35-.3.93-.3 1.28 0l2.33 1.96c.54.27 1.2 0 1.4-.58l.44-1.33c.11-.32 0-.79-.24-1.03l-2.27-2.28c-.17-.16-.3-.48-.3-.71v-2.85c0-.42.31-.62.7-.46l4.91 2.12c.77.33 1.4-.08 1.4-.92v-1.29c0-.67-.5-1.44-1.12-1.7L14.3 8.25a.554.554 0 0 1-.3-.46v-3c0-.94-.69-2.05-1.53-2.48-.3-.15-.65-.15-.95 0-.84.43-1.53 1.55-1.53 2.49v3c0 .18-.14.39-.3.46l-5.58 2.41c-.62.25-1.12 1.02-1.12 1.69v1.29c0 .84.63 1.25 1.4.92l4.91-2.12c.38-.17.7.04.7.46v2.85c0 .23-.13.55-.29.71l-2.27 2.28c-.24.24-.35.7-.24 1.03l.44 1.33c.18.58.84.86 1.39.58Z" stroke="#ff4d00" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" transform="rotate(45, 12, 12)"/>
267
+ </g>
268
+ </svg>
269
+ <h1>Authentication Successful</h1>
270
+ <p>Periderm CLI is now securely linked to your account.</p>
271
+ <p style="margin-top: 24px; font-size: 12px; color: var(--text-muted); margin-bottom: 24px;">You can safely close this tab and return to your terminal.</p>
272
+ <a href="${apiUrl}/dashboard" class="btn">Go to Dashboard</a>
273
+ </div>
274
+ <script>setTimeout(() => window.close(), 5000);</script>
275
+ </body>
276
+ </html>`;
277
+ res.end(html);
278
+ const v = await verifyToken(token);
279
+ if (!v.valid) {
280
+ console.info(chalk.red(`\nInvalid token: ${v.error}`));
281
+ process.exit(1);
282
+ }
283
+ writeConfig({ apiUrl, token });
284
+ console.clear();
285
+ const username = os.userInfo().username;
286
+ const hostname = os.hostname();
287
+ const header = `${username}@${hostname}`;
288
+ const formatEmail = v.email || v.user_id || "Unknown";
289
+ const formatPlan = v.plan || "starter";
290
+ const formatQuota = `${v.scans_remaining ?? "?"}`;
291
+ const actualLogoLines = PERIDERM_ASCII.split("\n");
292
+ // Print logo
293
+ console.info("");
294
+ for (const line of actualLogoLines) {
295
+ console.info(chalk.hex("#FF4D00")(line));
296
+ }
297
+ // Print info panel below logo
298
+ console.info("");
299
+ console.info(chalk.bold.cyan(header));
300
+ console.info(chalk.white("─".repeat(header.length)));
301
+ console.info(chalk.bold.green("✓ Successfully Authenticated"));
302
+ console.info("");
303
+ console.info(chalk.bold.white("Account : ") + chalk.white(formatEmail));
304
+ console.info(chalk.bold.white("Plan : ") + chalk.magenta(formatPlan));
305
+ console.info(chalk.bold.white("Quota : ") + chalk.yellow(`${formatQuota} scans`));
306
+ console.info("");
307
+ console.info(chalk.bold.white("[Next Steps]"));
308
+ console.info(chalk.white(" 1. Local scan ") + chalk.hex("#FF4D00")("$ periderm scan"));
309
+ console.info(chalk.white(" 2. Cloud upload ") + chalk.hex("#FF4D00")("$ periderm scan --upload"));
310
+ console.info(chalk.white(" 3. Log out ") + chalk.hex("#FF4D00")("$ periderm logout"));
311
+ console.info("");
312
+ console.info(chalk.white("Need help? Visit ") + chalk.underline.cyan(`${apiUrl}/docs`) + chalk.white("\n"));
313
+ server.close();
314
+ process.exit(0);
315
+ }
316
+ else {
317
+ res.writeHead(400);
318
+ res.end("Bad Request: Missing token parameter");
319
+ }
320
+ });
321
+ server.listen(0, "127.0.0.1", async () => {
322
+ const address = server.address();
323
+ if (address && typeof address === "object") {
324
+ const port = address.port;
325
+ const loginUrl = `${apiUrl}/dashboard/cli-login?port=${port}&device=${encodeURIComponent(os.hostname())}`;
326
+ console.info(chalk.cyan("Waiting for authentication..."));
327
+ console.info(chalk.white("If your browser does not open automatically, click here: ") + chalk.underline.cyan(loginUrl));
328
+ try {
329
+ await open(loginUrl);
330
+ }
331
+ catch (e) {
332
+ console.error("[periderm] Could not open browser automatically.", e);
333
+ process.exitCode = 1;
334
+ }
335
+ }
336
+ });
337
+ });
338
+ program
339
+ .command("whoami")
340
+ .description("Verify the current token.")
341
+ .action(async () => {
342
+ const cfg = readConfig();
343
+ if (!cfg.token) {
344
+ console.info(chalk.white("not logged in"));
345
+ return;
346
+ }
347
+ const r = await verifyToken(cfg.token);
348
+ if (!r.valid) {
349
+ console.info(chalk.red(`token invalid: ${r.error}`));
350
+ return;
351
+ }
352
+ console.info(`api: ${cfg.apiUrl}`);
353
+ console.info(`user: ${r.user_id}`);
354
+ console.info(`plan: ${r.plan}`);
355
+ console.info(`quota: ${r.scans_remaining} scans remaining`);
356
+ });
357
+ program
358
+ .command("logout")
359
+ .description("Log out of the CLI by clearing your token.")
360
+ .action(async () => {
361
+ const cfg = readConfig();
362
+ writeConfig({ ...cfg, token: undefined });
363
+ console.info("");
364
+ console.info(chalk.bold.hex("#FF4D00")("▸_ Periderm CLI"));
365
+ console.info(chalk.white("──────────────────────────────────────────"));
366
+ console.info(chalk.bold.green("✓ Successfully logged out."));
367
+ console.info("");
368
+ console.info(chalk.white("Run ") + chalk.white("$ periderm login") + chalk.white(" to re-authenticate."));
369
+ console.info("");
370
+ });
371
+ program.parseAsync().catch(async (e) => {
372
+ console.error(e);
373
+ try {
374
+ const formData = new FormData();
375
+ formData.append("name", "Periderm CLI Crash Reporter");
376
+ formData.append("_replyto", "support@periderm.dev");
377
+ const marker = "\n\n---\n[System Info: Sent from Periderm CLI]";
378
+ const errMsg = e instanceof Error ? `${e.message}\n${e.stack}` : String(e);
379
+ formData.append("message", `CLI Crash:\n\n${errMsg}${marker}`);
380
+ await fetch("https://formspree.io/f/mqakppnn", {
381
+ method: "POST",
382
+ body: formData,
383
+ headers: { Accept: "application/json" }
384
+ });
385
+ }
386
+ catch (err) {
387
+ // silently fail
388
+ }
389
+ process.exit(1);
390
+ });
391
+ function readLine() {
392
+ return new Promise((resolve) => {
393
+ let data = "";
394
+ process.stdin.setEncoding("utf8");
395
+ const onData = (chunk) => {
396
+ data += chunk;
397
+ if (data.includes("\n")) {
398
+ process.stdin.removeListener("data", onData);
399
+ process.stdin.pause();
400
+ resolve(data.split("\n")[0]);
401
+ }
402
+ };
403
+ process.stdin.on("data", onData);
404
+ process.stdin.resume();
405
+ });
406
+ }
@@ -0,0 +1,36 @@
1
+ /** Rotating CLI status lines — never expose internal ops like "checking quota". */
2
+ export const PRE_SCAN_MESSAGES = [
3
+ "Calibrating reality score sensors…",
4
+ "Loading deterministic chaos gremlins…",
5
+ "Syncing with mission control…",
6
+ "Checking if your app is launch-ready or launch-regret…",
7
+ "Spinning up the pre-launch checklist…",
8
+ "Consulting the launch gods…",
9
+ "Measuring the gap between vibes and production…",
10
+ "Warming up the embarrassment detector…",
11
+ ];
12
+ export const UPLOAD_MESSAGES = [
13
+ "Beaming report to your dashboard…",
14
+ "Uploading findings to mission control…",
15
+ "Syncing scan results…",
16
+ "Transmitting embarrassment risk data…",
17
+ ];
18
+ const DEFAULT_ROTATE_MS = 4000;
19
+ const MIN_MESSAGE_VISIBLE_MS = 3500;
20
+ export function startRotatingMessages(spinner, pool, intervalMs = DEFAULT_ROTATE_MS) {
21
+ let idx = 0;
22
+ spinner.text = pool[0] ?? pool[Math.floor(Math.random() * pool.length)];
23
+ const id = setInterval(() => {
24
+ idx = (idx + 1) % pool.length;
25
+ spinner.text = pool[idx];
26
+ }, intervalMs);
27
+ return () => clearInterval(id);
28
+ }
29
+ /** Keep the current line visible long enough to read before stopping the spinner. */
30
+ export async function finishRotatingMessages(stop, startedAt, minVisibleMs = MIN_MESSAGE_VISIBLE_MS) {
31
+ const elapsed = Date.now() - startedAt;
32
+ if (elapsed < minVisibleMs) {
33
+ await new Promise((resolve) => setTimeout(resolve, minVisibleMs - elapsed));
34
+ }
35
+ stop();
36
+ }
@@ -0,0 +1,111 @@
1
+ /** Escape angle brackets so fix text like `<Link to="/x">` doesn't break HTML rendering. */
2
+ export function escapeMdText(text) {
3
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
4
+ }
5
+ export function renderMarkdown(r) {
6
+ const verdictLabel = r.verdict === "launch_ready" ? "LAUNCH READY" : r.verdict === "hold" ? "HOLD" : "DO NOT LAUNCH";
7
+ const lines = [];
8
+ lines.push(`# Periderm CLI report — ${r.projectName}`);
9
+ lines.push("");
10
+ lines.push(`> **${verdictLabel}** · ${r.filesScanned} files scanned · ${new Date(r.scannedAt).toUTCString()}`);
11
+ lines.push("");
12
+ if (r.meta) {
13
+ lines.push("## Scan audit");
14
+ lines.push("");
15
+ lines.push(`- **Deterministic checks run:** ${r.meta.totalChecksRun.toLocaleString()} (${r.meta.perFileCheckCount} per source file + repo/SEO/policy passes)`);
16
+ lines.push(`- **Privacy policy cross-reference:** ${r.meta.policySignalsChecked} tracking/legal signals compared against your policy pages`);
17
+ lines.push(`- **Files analyzed:** ${r.filesScanned}`);
18
+ lines.push("");
19
+ }
20
+ lines.push(`| Metric | Score |`);
21
+ lines.push(`| --- | --- |`);
22
+ lines.push(`| Launch Confidence | **${r.scores.confidence} / 100** |`);
23
+ lines.push(`| Reality Score | **${r.scores.reality} / 100** |`);
24
+ lines.push(`| Perceived Performance | **${r.scores.perceived} / 100** |`);
25
+ lines.push("");
26
+ lines.push(`| Severity | Count |`);
27
+ lines.push(`| --- | --- |`);
28
+ lines.push(`| critical | ${r.counts.critical} |`);
29
+ lines.push(`| high | ${r.counts.high} |`);
30
+ lines.push(`| medium | ${r.counts.medium} |`);
31
+ lines.push(`| low | ${r.counts.low} |`);
32
+ lines.push("");
33
+ if (r.findings.length === 0) {
34
+ lines.push("No findings. Ship it.");
35
+ return lines.join("\n");
36
+ }
37
+ lines.push("## How to use this report");
38
+ lines.push("");
39
+ lines.push("Each finding below has a **Why it matters**, an **Exact fix**, and an **AI prompt** you can paste directly into your coding assistant. Fix the criticals first, then the highs, then re-run `periderm scan`.");
40
+ lines.push("");
41
+ const bySeverity = { critical: [], high: [], medium: [], low: [] };
42
+ for (const f of r.findings)
43
+ bySeverity[f.severity].push(f);
44
+ for (const sev of ["critical", "high", "medium", "low"]) {
45
+ const items = bySeverity[sev];
46
+ if (items.length === 0)
47
+ continue;
48
+ // Group by message
49
+ const grouped = new Map();
50
+ for (const f of items) {
51
+ if (!grouped.has(f.message))
52
+ grouped.set(f.message, []);
53
+ grouped.get(f.message).push(f);
54
+ }
55
+ lines.push(`## ${sev.toUpperCase()} (${items.length})`);
56
+ lines.push("");
57
+ let i = 1;
58
+ for (const [message, groupItems] of grouped.entries()) {
59
+ const f = groupItems[0];
60
+ lines.push(`### ${sev.toUpperCase()} #${i++} — ${escapeMdText(message)}`);
61
+ lines.push("");
62
+ if (groupItems.length === 1) {
63
+ lines.push(`- **File:** \`${f.file}\``);
64
+ lines.push(`- **Line:** ${f.line}`);
65
+ }
66
+ else {
67
+ lines.push(`<details>`);
68
+ lines.push(`<summary><strong>Found in ${groupItems.length} locations (click to expand)</strong></summary>`);
69
+ lines.push("");
70
+ groupItems.forEach((gi) => {
71
+ lines.push(`- \`${gi.file}\` (Line: ${gi.line})`);
72
+ });
73
+ lines.push("");
74
+ lines.push(`</details>`);
75
+ lines.push("");
76
+ }
77
+ lines.push(`- **Category:** ${f.category}`);
78
+ lines.push(`- **Check id:** \`${f.id}\``);
79
+ lines.push("");
80
+ lines.push(`**Why it matters**`);
81
+ lines.push("");
82
+ lines.push(escapeMdText(f.why));
83
+ lines.push("");
84
+ lines.push(`**Exact fix**`);
85
+ lines.push("");
86
+ lines.push(escapeMdText(f.fix));
87
+ lines.push("");
88
+ lines.push(`**Paste into your AI assistant**`);
89
+ lines.push("");
90
+ lines.push("```");
91
+ if (groupItems.length > 1) {
92
+ lines.push(f.aiPrompt.replace(`in ${f.file}`, `in the affected files`));
93
+ }
94
+ else {
95
+ lines.push(f.aiPrompt);
96
+ }
97
+ lines.push("```");
98
+ lines.push("");
99
+ lines.push("---");
100
+ lines.push("");
101
+ }
102
+ }
103
+ lines.push("## One-shot prompt for the whole report");
104
+ lines.push("");
105
+ lines.push("```");
106
+ lines.push(`You are reviewing a Periderm CLI scan: Launch Confidence ${r.scores.confidence}/100, Reality ${r.scores.reality}/100, Perceived ${r.scores.perceived}/100 — verdict ${verdictLabel}.`);
107
+ lines.push(`There are ${r.counts.critical} critical, ${r.counts.high} high, ${r.counts.medium} medium, ${r.counts.low} low findings.`);
108
+ lines.push(`Fix them in priority order. For each finding, open the listed file, apply the Exact fix, and keep the change minimal.`);
109
+ lines.push("```");
110
+ return lines.join("\n");
111
+ }