authica 1.2.0 → 2.0.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 CHANGED
@@ -1,11 +1,11 @@
1
1
  <div align="center">
2
2
 
3
- # ⚡ authica
3
+ # ⚡authica 🔏
4
4
 
5
5
  **Zero-config CLI that inspects your design tokens and injects polished, animated authentication routes into React & Next.js apps in seconds.**
6
6
 
7
- [![npm version](https://img.shields.io/npm/v/authica.svg?style=for-the-badge&color=8B5CF6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
8
- [![npm downloads](https://img.shields.io/npm/dm/authica.svg?style=for-the-badge&color=3B82F6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
7
+ [![npm version](https://img.shields.io/npm/v/authica?style=for-the-badge&color=8B5CF6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/authica?style=for-the-badge&color=3B82F6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg?style=for-the-badge&labelColor=0F172A)](https://opensource.org/licenses/MIT)
10
10
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-EC4899.svg?style=for-the-badge&labelColor=0F172A)](http://makeapullrequest.com)
11
11
 
@@ -20,7 +20,7 @@
20
20
 
21
21
  ```
22
22
  ┌─────────────────────────────────────────────────────────────────────────────┐
23
- │ ⚡ authica v1.2.0 │
23
+ │ ⚡ authica v2.0.0 │
24
24
  ├─────────────────────────────────────────────────────────────────────────────┤
25
25
  │ │
26
26
  │ ? Global CSS path? (src/index.css) │
@@ -47,6 +47,7 @@
47
47
  * **🎨 5 Layout Frameworks:** Choose between `split`, `centered`, `canvas`, `modal`, and `glass`.
48
48
  * **✨ 3 Aesthetic Presets:** Toggle between `default` (Shadcn-esque), `brutalist` (hard shadows, 2px borders), and `glassmorphism` (frosted blur).
49
49
  * **🔑 Pure Inline SVG OAuth:** Built-in crisp vector logos for **Google**, **GitHub**, **Apple**, **Discord**, and **Facebook**—no extra icon packages required.
50
+ * **🧪 Opt-in tooling:** AST route insertion with regex fallback, an advisory token-storage scan, Next.js middleware starters, and an isolated local preview studio.
50
51
 
51
52
 
52
53
 
@@ -98,6 +99,27 @@ Skip prompts by passing flags directly:
98
99
  | **Supabase** | `--auth=supabase` | `@supabase/supabase-js` client initialization, `signInWithPassword`, `signUp`, and OAuth redirects. |
99
100
  | **Clerk** | `--auth=clerk` | `@clerk/react` or `@clerk/nextjs` hooks, auto-created `/sso-callback` redirect route. |
100
101
  | **Next-Auth** | `--auth=next-auth` | `signIn("credentials")` and provider mapping for Auth.js. |
102
+ | **WebAuthn starter** | `--auth=webauthn` | Adds passkey sign-in/registration browser calls; requires your own verified server endpoints and credential storage. |
103
+
104
+ ### Opt-in developer tools
105
+
106
+ These features do not change the default `npx authica` generation path:
107
+
108
+ ```sh
109
+ npx authica --ast-inject --yes
110
+ npx authica --auth=clerk --middleware --yes
111
+ npx authica --scan-security --yes
112
+ npx authica audit
113
+ npx authica preview
114
+ ```
115
+
116
+ * `--ast-inject` enables Recast/Babel route edits for Vite + React Router. If parsing fails, Authica tries its legacy injector; if it cannot place the route safely, it prints manual steps and leaves the router untouched. Next.js uses file-system routes, so there is no entry file to edit.
117
+ * `--middleware` is Next.js-only and requires `--auth=supabase`, `--auth=clerk`, or `--auth=next-auth`. It protects `/dashboard`; existing middleware is backed up when `--force` is used. Supabase middleware also needs `@supabase/ssr`; NextAuth config should set `pages.signIn` to `/login` too.
118
+ * `--scan-security` and `authica audit` perform a read-only heuristic scan for likely auth-token writes to `localStorage`. Findings are advisory and may be incomplete.
119
+ * `--auth=webauthn` installs `@simplewebauthn/browser` in the target app unless `--no-install` is passed. Implement POST `/api/generate-options` and `/api/verify` on the server, including per-session challenge storage, origin/RP validation, credential persistence, and mode-aware registration/authentication verification. This starter is not a complete authentication backend and does not configure OAuth.
120
+ * `authica preview` serves a local-only visual preview at `http://localhost:4321`; it does not install dependencies or write to the project.
121
+
122
+ The CLI also accepts the existing `--layout`, `--style`, `--auth`, `--providers`, `--css`, and `--yes` options in non-interactive use. The Web Studio reference is [authica.dev](https://authica.dev).
101
123
 
102
124
 
103
125
  ## ⚙️ Configuration (`.authicarc`)
@@ -128,4 +150,3 @@ Distributed under the MIT License. See [`LICENSE`](https://www.google.com/search
128
150
  Made with ❤️ by [Sachin Mehta](https://github.com/AEGON247)
129
151
 
130
152
  [★ Star on GitHub](https://www.google.com/search?q=https://github.com/AEGON247/authica)
131
-
@@ -0,0 +1,193 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import babelParser from "@babel/parser";
4
+ import recast from "recast";
5
+ import recastBabelTsParser from "recast/parsers/babel-ts.js";
6
+
7
+ const traverse = (node, visit) => {
8
+ if (!node || typeof node !== "object") return;
9
+ if (Array.isArray(node)) {
10
+ for (const item of node) traverse(item, visit);
11
+ return;
12
+ }
13
+ if (typeof node.type === "string") visit(node);
14
+ for (const [key, value] of Object.entries(node)) {
15
+ if (key !== "loc" && key !== "tokens" && key !== "comments" && key !== "extra") traverse(value, visit);
16
+ }
17
+ };
18
+
19
+ export function insertViteRoute(source, importPath, routerPackage = "react-router-dom") {
20
+ const ast = recast.parse(source, { parser: recastBabelTsParser });
21
+ const lineStarts = [0];
22
+ for (let i = 0; i < source.length; i++) if (source[i] === "\n") lineStarts.push(i + 1);
23
+ const offset = (position) => lineStarts[position.line - 1] + position.column;
24
+ let routeContainer = null;
25
+ let routeKind = "";
26
+ let hasLogin = false;
27
+ let hasRouteImport = false;
28
+
29
+ traverse(ast, (node) => {
30
+ if (node.type === "ImportDeclaration" && node.source.value === routerPackage) {
31
+ hasRouteImport ||= node.specifiers.some((specifier) =>
32
+ specifier.type === "ImportSpecifier" &&
33
+ (specifier.imported.name ?? specifier.imported.value) === "Route" &&
34
+ specifier.local.name === "Route",
35
+ );
36
+ }
37
+ if (node.type === "JSXElement" && node.openingElement.name.type === "JSXIdentifier") {
38
+ if (node.openingElement.name.name === "Route") {
39
+ const pathAttr = node.openingElement.attributes.find((a) => a.type === "JSXAttribute" && a.name.name === "path");
40
+ if (pathAttr?.value?.value === "/login") hasLogin = true;
41
+ }
42
+ if (node.openingElement.name.name === "Routes") {
43
+ routeContainer = node;
44
+ routeKind = "jsx";
45
+ }
46
+ }
47
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && /create(?:Browser|Hash|Memory)Router/.test(node.callee.name)) {
48
+ if (node.arguments[0]?.type === "ArrayExpression" && !routeContainer) {
49
+ routeContainer = node.arguments[0];
50
+ routeKind = "object";
51
+ }
52
+ }
53
+ });
54
+
55
+ if (!routeContainer) throw new Error("No supported <Routes> or create*Router([...]) route container found.");
56
+ if (routeKind === "object") {
57
+ hasLogin = routeContainer.elements.some((entry) => entry.type === "ObjectExpression" &&
58
+ entry.properties.some((property) => property.type === "ObjectProperty" &&
59
+ (property.key.name ?? property.key.value) === "path" &&
60
+ property.value.type === "StringLiteral" && property.value.value === "/login"));
61
+ }
62
+ if (hasLogin) return { code: source, alreadyWired: true };
63
+
64
+ const edits = [];
65
+ let routeSnippet;
66
+ if (routeKind === "jsx") {
67
+ routeSnippet = '<Route path="/login" element={<Authica />} />';
68
+ if (routeContainer.openingElement.selfClosing) {
69
+ const openingEnd = offset(routeContainer.openingElement.loc.end);
70
+ edits.push({
71
+ start: openingEnd - 2,
72
+ end: openingEnd,
73
+ text: `>${routeSnippet}</${routeContainer.openingElement.name.name}>`,
74
+ });
75
+ } else {
76
+ const closeStart = offset(routeContainer.closingElement.loc.start);
77
+ const lineStart = source.lastIndexOf("\n", closeStart) + 1;
78
+ const closeIndent = source.slice(lineStart, closeStart);
79
+ if (closeIndent.trim() === "") {
80
+ edits.push({ start: lineStart, end: lineStart, text: `${closeIndent} ${routeSnippet}\n` });
81
+ } else {
82
+ const openLineStart = source.lastIndexOf("\n", routeContainer.openingElement.start) + 1;
83
+ const indent = source.slice(openLineStart, routeContainer.openingElement.start).match(/^[\t ]*/)?.[0] ?? "";
84
+ edits.push({ start: closeStart, end: closeStart, text: `\n${indent} ${routeSnippet}\n${indent}` });
85
+ }
86
+ }
87
+ } else {
88
+ routeSnippet = '{ path: "/login", element: <Authica /> }';
89
+ const close = offset(routeContainer.loc.end) - 1;
90
+ if (routeContainer.elements.length) {
91
+ const last = routeContainer.elements.at(-1);
92
+ const lastStart = offset(last.loc.start);
93
+ const lastEnd = offset(last.loc.end);
94
+ const lastLineStart = source.lastIndexOf("\n", lastStart) + 1;
95
+ const indent = source.slice(lastLineStart, lastStart).match(/^[\t ]*/)?.[0] ?? " ";
96
+ const comma = source.indexOf(",", lastEnd);
97
+ if (comma >= 0 && comma < close) {
98
+ edits.push({ start: comma + 1, end: comma + 1, text: `\n${indent}${routeSnippet},` });
99
+ } else {
100
+ edits.push({ start: lastEnd, end: lastEnd, text: `,\n${indent}${routeSnippet}` });
101
+ }
102
+ } else {
103
+ const open = offset(routeContainer.loc.start);
104
+ const lineStart = source.lastIndexOf("\n", open) + 1;
105
+ const baseIndent = source.slice(lineStart, open).match(/^[\t ]*/)?.[0] ?? "";
106
+ edits.push({ start: open + 1, end: open + 1, text: `\n${baseIndent} ${routeSnippet}\n${baseIndent}` });
107
+ }
108
+ }
109
+
110
+ const lastImport = [...ast.program.body].reverse().find((node) => node.type === "ImportDeclaration");
111
+ const importPos = lastImport ? offset(lastImport.loc.end) : 0;
112
+ const importText = `${lastImport ? "\n" : ""}import Authica from ${JSON.stringify(importPath)};${routeKind === "jsx" && !hasRouteImport ? `\nimport { Route } from "${routerPackage}";` : ""}\n`;
113
+ edits.push({ start: importPos, end: importPos, text: importText });
114
+ let code = source;
115
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
116
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
117
+ }
118
+ return { code, alreadyWired: false };
119
+ }
120
+
121
+ const sourceExtensions = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
122
+ const ignoredDirectories = new Set([
123
+ "node_modules", ".git", ".next", ".turbo", ".vercel", ".cache", "dist", "build", "out", "coverage",
124
+ "vendor", "storybook-static", "test-results", "playwright-report",
125
+ ]);
126
+ const tokenKey = /(?:token|auth|session|jwt|access|refresh)/i;
127
+
128
+ function sourceFiles(root) {
129
+ const found = [];
130
+ const errors = [];
131
+ const walk = (dir) => {
132
+ let entries;
133
+ try {
134
+ entries = fs.readdirSync(dir, { withFileTypes: true });
135
+ } catch (error) {
136
+ errors.push(`${path.relative(root, dir) || "."}: ${error.message}`);
137
+ return;
138
+ }
139
+ for (const entry of entries) {
140
+ if (entry.isDirectory()) {
141
+ if (!ignoredDirectories.has(entry.name)) walk(path.join(dir, entry.name));
142
+ } else if (sourceExtensions.has(path.extname(entry.name))) {
143
+ found.push(path.join(dir, entry.name));
144
+ }
145
+ }
146
+ };
147
+ walk(root);
148
+ return { files: found, errors };
149
+ }
150
+
151
+ export function scanLocalStorage(root) {
152
+ const findings = [];
153
+ const { files, errors } = sourceFiles(root);
154
+ for (const file of files) {
155
+ let ast;
156
+ try {
157
+ ast = babelParser.parse(fs.readFileSync(file, "utf8"), {
158
+ sourceType: "unambiguous",
159
+ plugins: ["jsx", "typescript", "decorators-legacy", "importAttributes"],
160
+ });
161
+ } catch (error) {
162
+ errors.push(`${path.relative(root, file)}: ${error.message}`);
163
+ continue;
164
+ }
165
+ traverse(ast, (node) => {
166
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
167
+ const { object, property, computed } = node.callee;
168
+ const member = computed ? node.callee.property.value : property.name;
169
+ if (object.type !== "Identifier" || object.name !== "localStorage" || member !== "setItem") return;
170
+ const key = node.arguments[0];
171
+ if (key && (key.type === "StringLiteral" || key.type === "TemplateLiteral") && tokenKey.test(key.value ?? key.quasis?.[0]?.value?.cooked ?? "")) {
172
+ findings.push({ file: path.relative(root, file), line: node.loc?.start.line ?? 1, key: key.value ?? key.quasis[0].value.cooked });
173
+ }
174
+ });
175
+ }
176
+ return { findings, errors };
177
+ }
178
+
179
+ export function printAudit(root) {
180
+ const { findings, errors } = scanLocalStorage(root);
181
+ console.log("\nAuthica advisory security scan (read-only)");
182
+ if (!findings.length) console.log("No likely auth-token localStorage writes found.");
183
+ for (const finding of findings) {
184
+ console.warn(`⚠ ${finding.file}:${finding.line} stores "${finding.key}" in localStorage. Consider a server-managed, httpOnly cookie where appropriate.`);
185
+ }
186
+ if (errors.length) {
187
+ console.warn(`Skipped ${errors.length} file(s) or directories that could not be read or parsed; findings may be incomplete.`);
188
+ for (const error of errors.slice(0, 10)) console.warn(` ${error}`);
189
+ if (errors.length > 10) console.warn(` ...and ${errors.length - 10} more.`);
190
+ }
191
+ console.log("This heuristic scan is advisory and does not prove a vulnerability or guarantee coverage.");
192
+ return findings.length;
193
+ }
package/bin/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import readline from "node:readline/promises";
7
7
 
@@ -9,6 +9,14 @@ const args = process.argv.slice(2);
9
9
  const created = [], modified = [], steps = [];
10
10
  let themeNote = "", rcNote = "", extraRoutes = [];
11
11
  const flag = (n) => args.includes(`--${n}`);
12
+ const cliPackage = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+ const cliVersion = cliPackage.version;
14
+ if (args[0] === "preview") {
15
+ await import("./preview.js");
16
+ } else if (args[0] === "audit") {
17
+ const { printAudit } = await import("./ast-tools.js");
18
+ printAudit(process.cwd());
19
+ } else {
12
20
  let rc = {};
13
21
  const hasOpt = (n) => args.some((a) => a.startsWith(`--${n}=`)) || rc[n] !== undefined;
14
22
  const opt = (n, d) => {
@@ -18,13 +26,49 @@ const opt = (n, d) => {
18
26
  return v === undefined ? d : Array.isArray(v) ? v.join(",") : String(v);
19
27
  };
20
28
  if (flag("help") || flag("h")) {
21
- console.log("Usage: npx authica [--auth=none|supabase|next-auth|clerk] [--layout=split|centered|canvas|modal|glass] [--hero=<path|url>] [--providers=google,github,...] [--style=default|brutalist|glassmorphism] [--otp|--magic-link] [--css=<file>] [--entry=<file>] [--no-name] [--confirm] [--no-social] [--yes] [--force] [--no-install]");
29
+ const c = {
30
+ reset: "\u001b[0m",
31
+ violet: "\u001b[38;5;141m",
32
+ cyan: "\u001b[36m",
33
+ green: "\u001b[32m",
34
+ yellow: "\u001b[33m",
35
+ muted: "\u001b[90m",
36
+ bold: "\u001b[1m",
37
+ };
38
+ const heading = (text) => `${c.violet}${c.bold}${text}${c.reset}`;
39
+ console.log(`${c.violet}${c.bold} ⚡ AUTHICA${c.reset} ${c.muted}Authentication UI generator${c.reset}`);
40
+ console.log(` ${c.cyan}[ NPM v${cliVersion} ]${c.reset} ${c.green}[ License: ${cliPackage.license} ]${c.reset}\n`);
41
+ console.log(`${heading("USAGE")}`);
42
+ console.log(" npx authica [options]\n");
43
+ console.log(`${heading("SUBCOMMANDS")}`);
44
+ console.log(` ${c.yellow}authica audit${c.reset} Advisory AppSec scan for likely auth tokens in localStorage`);
45
+ console.log(` ${c.yellow}authica preview${c.reset} Open the local visual studio at http://localhost:4321\n`);
46
+ console.log(`${heading("GENERATOR FLAGS")}`);
47
+ console.log(" --layout=<name> split | centered | canvas | modal | glass");
48
+ console.log(" --style=<name> default | brutalist | glassmorphism");
49
+ console.log(" --auth=<provider> none | supabase | next-auth | clerk | webauthn");
50
+ console.log(" --providers=<list> Comma-separated OAuth providers");
51
+ console.log(" --css=<file> Global CSS file used for theme tokens");
52
+ console.log(" --ast-inject Opt in to AST route insertion (Vite + React Router)");
53
+ console.log(" --middleware Opt in to Next.js provider middleware generation");
54
+ console.log(" --scan-security Run the advisory scan before generation");
55
+ console.log(" --force Overwrite generated files (keeps backups)");
56
+ console.log(" --yes --no-install Skip prompts and dependency installation\n");
57
+ console.log(`${heading("QUICKSTART EXAMPLES")} ${c.muted}(bash)${c.reset}`);
58
+ console.log(` ${c.green}npx authica${c.reset}`);
59
+ console.log(` ${c.green}npx authica --layout=glass --style=brutalist --auth=supabase --providers=google,github --yes${c.reset}`);
60
+ console.log(` ${c.green}npx authica --auth=clerk --middleware --ast-inject --yes${c.reset}`);
22
61
  process.exit(0);
23
62
  }
24
63
  const fail = (m) => { console.error(`✖ ${m}`); process.exit(1); };
25
64
  const cwd = process.cwd();
26
65
  const p = (...x) => path.join(cwd, ...x);
27
66
  const ex = (f) => fs.existsSync(p(f));
67
+ const enabled = (name) => {
68
+ const configName = { "ast-inject": "astInject", "scan-security": "scanSecurity" }[name] || name;
69
+ return flag(name) || rc[configName] === true || opt(name, "false") === "true";
70
+ };
71
+ console.log(`authica v${cliVersion} · Web Studio: https://authica.dev`);
28
72
 
29
73
  // ---- team config (.authicarc / .authicarc.json); CLI flags win over it
30
74
  const rcFile = [".authicarc", ".authicarc.json"].find(ex);
@@ -32,13 +76,13 @@ if (rcFile) {
32
76
  try { rc = JSON.parse(fs.readFileSync(p(rcFile), "utf8")); }
33
77
  catch (e) { fail(`${rcFile} is not valid JSON: ${e.message}`); }
34
78
  if (!rc || typeof rc !== "object" || Array.isArray(rc)) fail(`${rcFile} must contain a JSON object.`);
35
- const known = ["auth", "layout", "style", "method", "css", "hero", "entry", "providers", "name", "confirm", "social"];
79
+ const known = ["auth", "layout", "style", "method", "css", "hero", "entry", "providers", "name", "confirm", "social", "astInject", "middleware", "scanSecurity"];
36
80
  const unknown = Object.keys(rc).filter((k) => !known.includes(k));
37
81
  if (unknown.length) console.warn(`⚠ ${rcFile}: ignoring unknown keys: ${unknown.join(", ")}`);
38
82
  rcNote = rcFile;
39
83
  }
40
84
  const auth = opt("auth", "none");
41
- if (!["none", "supabase", "next-auth", "clerk"].includes(auth)) fail(`Unknown auth "${auth}"`);
85
+ if (!["none", "supabase", "next-auth", "clerk", "webauthn"].includes(auth)) fail(`Unknown auth "${auth}"`);
42
86
  if (!ex("package.json")) fail("No package.json found. Run this in your project root.");
43
87
  const pkg = JSON.parse(fs.readFileSync(p("package.json"), "utf8"));
44
88
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
@@ -46,10 +90,17 @@ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
46
90
  // ---- detect stack
47
91
  const isNext = !!deps.next;
48
92
  const isVite = !isNext && !!deps.vite && !!(deps["react-router-dom"] || deps["react-router"]);
93
+ const routerPackage = deps["react-router-dom"] ? "react-router-dom" : "react-router";
49
94
  if (!isNext && !isVite) fail("Unsupported project: need Next.js, or Vite + react-router(-dom).");
50
95
  if (!deps.react) fail("React not found in package.json.");
51
96
  if (!ex("tsconfig.json")) fail("TypeScript project required (tsconfig.json not found).");
52
97
  if (auth === "next-auth" && !isNext) fail("--auth=next-auth requires Next.js.");
98
+ if (enabled("middleware") && !isNext) fail("--middleware requires a Next.js project.");
99
+ if (enabled("middleware") && !["supabase", "clerk", "next-auth"].includes(auth)) fail("--middleware requires --auth=supabase, --auth=clerk, or --auth=next-auth.");
100
+ if (enabled("ast-inject") && isNext) console.log("Next.js App Router uses file-system routes; no route-entry AST edit is needed.");
101
+ if (auth === "webauthn" && (flag("otp") || flag("magic-link") || opt("method", "password") !== "password")) fail("--auth=webauthn cannot be combined with --otp or --magic-link.");
102
+ const middlewareFile = enabled("middleware") ? (ex("src/app") ? "src/middleware.ts" : "middleware.ts") : "";
103
+ if (middlewareFile && ex(middlewareFile) && !flag("force")) fail(`${middlewareFile} already exists. Use --force to overwrite.`);
53
104
 
54
105
  if (!deps.tailwindcss && !deps["@tailwindcss/vite"] && !deps["@tailwindcss/postcss"])
55
106
  console.warn("⚠ Tailwind CSS not detected; using self-contained CSS fallbacks.");
@@ -62,6 +113,33 @@ const askBool = async (q, def) => {
62
113
  const a = (await ask(`${q} [y/n]`, def ? "y" : "n")).toLowerCase();
63
114
  return a.startsWith("y");
64
115
  };
116
+ let studioStarted = false;
117
+ function launchStudio() {
118
+ if (!studioStarted) {
119
+ const previewPath = fileURLToPath(new URL("./preview.js", import.meta.url));
120
+ const child = spawn(process.execPath, [previewPath, "--open"], {
121
+ detached: true,
122
+ stdio: "ignore",
123
+ windowsHide: true,
124
+ });
125
+ child.on("error", (error) => console.error(`Could not launch the local visual studio: ${error.message}`));
126
+ child.unref();
127
+ studioStarted = true;
128
+ }
129
+ console.log("Opening the local visual studio at http://localhost:4321; return here to continue.");
130
+ }
131
+ async function askWithStudio(q, def) {
132
+ while (true) {
133
+ const answer = await ask(q, def);
134
+ if (answer.trim().toLowerCase() !== "more") return answer;
135
+ launchStudio();
136
+ }
137
+ }
138
+
139
+ if (enabled("scan-security")) {
140
+ const { printAudit } = await import("./ast-tools.js");
141
+ printAudit(cwd);
142
+ }
65
143
 
66
144
  // ---- image path normalizer
67
145
  function processHeroImage(inputPath) {
@@ -130,9 +208,9 @@ map.CARD = tok(["card", "background"], "#ffffff");
130
208
  map.CARDFG = tok(["card-foreground", "foreground"], "#0a0a0a");
131
209
 
132
210
  // ---- layout / fields / hero choices
133
- let layout = opt("layout", "") || (await ask("Layout: split, centered, canvas, modal or glass?", "split"));
211
+ let layout = opt("layout", "") || (await askWithStudio("Layout:\n split [|====] Modern 2-column hero layout\n centered [ --- ] Floating conversion card\n canvas [.....] Gradient background canvas\n modal [-----] Backdrop-blur overlay dialog\n glass [ ~~~ ] Translucent frosted card\n more [ 🌐 Launch Visual Web Studio ]\nChoose layout:", "split"));
134
212
  if (!["split", "centered", "canvas", "modal", "glass"].includes(layout)) { console.warn(`⚠ Unknown layout "${layout}", using split.`); layout = "split"; }
135
- let style = opt("style", "") || (await ask("Visual style: default, brutalist or glassmorphism?", "default"));
213
+ let style = opt("style", "") || (await askWithStudio("Visual style:\n default [neutral] Shadcn-like\n brutalist [bold] Hard borders and shadows\n glassmorphism [frosted] Blur and glow\n more [ 🌐 Launch Visual Web Studio ]\nChoose style:", "default"));
136
214
  if (!["default", "brutalist", "glassmorphism"].includes(style)) { console.warn(`⚠ Unknown style "${style}", using default.`); style = "default"; }
137
215
  let method = flag("otp") ? "otp" : flag("magic-link") ? "magic" : opt("method", "") || (await ask("Sign-in method: password, otp or magic-link?", "password"));
138
216
  if (method === "magic-link") method = "magic";
@@ -149,6 +227,10 @@ if (!flag("no-social") && rc.social !== false) {
149
227
  if (bad.length) console.warn(`⚠ Ignoring unknown providers: ${bad.join(", ")}`);
150
228
  providers = [...new Set(providers.filter((x) => ALL.includes(x)))];
151
229
  }
230
+ if (auth === "webauthn" && providers.length) {
231
+ steps.push("WebAuthn starter does not configure OAuth providers; OAuth requires a provider-specific backend and route.");
232
+ providers = [];
233
+ }
152
234
  const rawHero = !["split", "canvas"].includes(layout) ? "" : opt("hero", "") || (hasOpt("hero") ? "" : await ask("Hero image path or URL (blank = built-in artwork)?", ""));
153
235
  const hero = processHeroImage(rawHero);
154
236
 
@@ -240,6 +322,16 @@ function useAuth() {
240
322
  }`,
241
323
  pkgs: [isNext ? "@clerk/nextjs" : "@clerk/react"],
242
324
  },
325
+ webauthn: {
326
+ imports: `import { startAuthentication, startRegistration } from "@simplewebauthn/browser";\n`,
327
+ hook: `function useAuth() {
328
+ return {
329
+ submit: async (_mode: Mode, _d: Data) => { throw new Error("Use passkey sign-in."); },
330
+ social: async (_p: Social) => { throw new Error("Social sign-in is not configured for this WebAuthn starter."); },
331
+ };
332
+ }`,
333
+ pkgs: ["@simplewebauthn/browser"],
334
+ },
243
335
  }[auth];
244
336
 
245
337
  const pw = method === "password";
@@ -308,6 +400,37 @@ src = src.replace("__DIRECTIVE__", () => (isNext ? '"use client";\n\n' : ""))
308
400
  .replace("__AUTH_IMPORTS__", () => hooks.imports)
309
401
  .replace("__AUTH_HOOK__", () => hooks.hook);
310
402
  for (const [k, v] of Object.entries({ ...map, LAYOUT: layout, STYLE: style, METHOD: method, SHOW_NAME: String(showName), CONFIRM: String(confirmPw), PROVIDERS: JSON.stringify(providers), HERO: JSON.stringify(hero) })) src = rep(src, k, v);
403
+ if (auth === "webauthn") {
404
+ const helper = `\n\nasync function authicaPasskey(email: string, mode: Mode) {
405
+ const optionsResponse = await fetch("/api/generate-options", {
406
+ method: "POST",
407
+ headers: { "Content-Type": "application/json" },
408
+ body: JSON.stringify({ email, mode }),
409
+ });
410
+ if (!optionsResponse.ok) throw new Error("Could not start passkey sign-in.");
411
+ const optionsJSON = await optionsResponse.json();
412
+ const credential = mode === "signup"
413
+ ? await startRegistration({ optionsJSON })
414
+ : await startAuthentication({ optionsJSON });
415
+ const verifyResponse = await fetch("/api/verify", {
416
+ method: "POST",
417
+ headers: { "Content-Type": "application/json" },
418
+ body: JSON.stringify({ email, mode, credential }),
419
+ });
420
+ if (!verifyResponse.ok) throw new Error("Passkey verification failed.");
421
+ window.location.href = "/";
422
+ }`;
423
+ src = src.replace(hooks.hook, `${hooks.hook}${helper}`);
424
+ src = src.replace("const PASSWORDLESS = METHOD !== \"password\";", "const PASSWORDLESS = true;");
425
+ src = src.replace(
426
+ '<button type="submit" disabled={loading} className="as-btn">',
427
+ '<button type="button" disabled={loading || !emailOk} onClick={() => run(() => authicaPasskey(v.email, mode))} className="as-btn">',
428
+ );
429
+ src = src.replace(
430
+ '{loading ? "Please wait…" : PASSWORDLESS ? (METHOD === "otp" ? "Send code" : "Send magic link") : isLogin ? "Sign In" : "Sign Up"}',
431
+ '{loading ? "Please wait…" : isLogin ? "Sign in with a passkey" : "Create a passkey"}',
432
+ );
433
+ }
311
434
  if (/__[A-Z]+__/.test(src)) fail("Unresolved template placeholder (internal error).");
312
435
 
313
436
  // ---- target path
@@ -330,8 +453,55 @@ if (isVite) {
330
453
  if (!file) manual();
331
454
  else {
332
455
  let c = fs.readFileSync(p(file), "utf8");
333
- if (c.includes("authica")) modified.push(`${file} (already wired)`);
334
- else {
456
+ if (c.includes("authica") || (enabled("ast-inject") && c.includes("Authica"))) modified.push(`${file} (already wired)`);
457
+ else if (enabled("ast-inject")) {
458
+ let astResult;
459
+ try {
460
+ const { insertViteRoute } = await import("./ast-tools.js");
461
+ astResult = insertViteRoute(c, (() => {
462
+ let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
463
+ return rel.startsWith(".") ? rel : `./${rel}`;
464
+ })(), routerPackage);
465
+ } catch (error) {
466
+ console.warn(`⚠ AST route insertion could not safely parse ${file}: ${error.message}`);
467
+ }
468
+ if (astResult?.alreadyWired) {
469
+ modified.push(`${file} (already wired)`);
470
+ } else {
471
+ if (astResult) c = astResult.code;
472
+ else {
473
+ const before = c;
474
+ let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
475
+ if (!rel.startsWith(".")) rel = "./" + rel;
476
+ if (/<Routes[\s>]/.test(c)) c = c.replace(/<Routes(\s[^>]*)?>/, (m) => `${m}\n <Route path="/login" element={<Authica />} />`);
477
+ else c = c.replace(/(create\w*Router\(\s*\[)/, `$1\n { path: "/login", element: <Authica /> },`);
478
+ if (c === before) {
479
+ manual();
480
+ console.log("AST fallback found no supported route position; target router was left unchanged.");
481
+ c = "";
482
+ } else {
483
+ let end = 0;
484
+ for (const m of c.matchAll(/^import\s[^;]*?['"][^'"]+['"];?[ \t]*$/gm)) end = Math.max(end, m.index + m[0].length);
485
+ const relImport = path.posix.relative(path.posix.dirname(file), "src/routes/login").replace(/^(?!\.)/, "./");
486
+ const imp = `import Authica from "${relImport}";`;
487
+ c = end ? c.slice(0, end) + "\n" + imp + c.slice(end) : imp + "\n" + c;
488
+ const routeImportPattern = new RegExp(`import\\s*\\{[^}]*\\bRoute\\b[^}]*\\}\\s*from\\s*["']${routerPackage}["']`);
489
+ if (/<Routes[\s>]/.test(before) && !routeImportPattern.test(c)) {
490
+ const routeImport = `import { Route } from "${routerPackage}";`;
491
+ const importEnd = [...c.matchAll(/^import\s[^;]*?['"][^'"]+['"];?[ \t]*$/gm)].reduce((end, match) => Math.max(end, match.index + match[0].length), 0);
492
+ c = importEnd ? c.slice(0, importEnd) + "\n" + routeImport + c.slice(importEnd) : routeImport + "\n" + c;
493
+ }
494
+ }
495
+ }
496
+ if (c) {
497
+ const dirty = spawnSync("git", ["status", "--porcelain", "--", file], { cwd, encoding: "utf8" });
498
+ if (dirty.status === 0 && dirty.stdout.trim()) console.warn(`⚠ ${file} has uncommitted changes.`);
499
+ if (!ex(file + ".bak")) fs.copyFileSync(p(file), p(file + ".bak"));
500
+ fs.writeFileSync(p(file), c);
501
+ modified.push(`${file} (+/login route, backup ${file}.bak)`);
502
+ }
503
+ }
504
+ } else {
335
505
  let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
336
506
  if (!rel.startsWith(".")) rel = "./" + rel;
337
507
  if (/<Routes[\s>]/.test(c)) c = c.replace(/<Routes(\s[^>]*)?>/, (m) => `${m}\n <Route path="/login" element={<authica />} />`);
@@ -349,6 +519,19 @@ if (isVite) {
349
519
  }
350
520
  }
351
521
 
522
+ if (middlewareFile) {
523
+ const middleware = auth === "clerk"
524
+ ? `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\nimport { NextResponse } from "next/server";\n\nconst isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]);\n\nexport default clerkMiddleware(async (auth, request) => {\n if (!isProtectedRoute(request)) return;\n const { userId } = await auth();\n if (!userId) return NextResponse.redirect(new URL("/login", request.url));\n});\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`
525
+ : auth === "supabase"
526
+ ? `import { createServerClient } from "@supabase/ssr";\nimport { NextResponse, type NextRequest } from "next/server";\n\nexport async function middleware(request: NextRequest) {\n let response = NextResponse.next({ request });\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n { cookies: {\n getAll: () => request.cookies.getAll(),\n setAll: (cookies) => {\n cookies.forEach(({ name, value }) => request.cookies.set(name, value));\n response = NextResponse.next({ request });\n cookies.forEach(({ name, value, options }) => response.cookies.set(name, value, options));\n },\n } },\n );\n const { data: { user } } = await supabase.auth.getUser();\n if (request.nextUrl.pathname.startsWith("/dashboard") && !user) {\n const loginUrl = request.nextUrl.clone();\n loginUrl.pathname = "/login";\n loginUrl.searchParams.set("next", request.nextUrl.pathname);\n return NextResponse.redirect(loginUrl);\n }\n return response;\n}\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`
527
+ : `import { withAuth } from "next-auth/middleware";\n\nexport default withAuth({ pages: { signIn: "/login" } });\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`;
528
+ fs.mkdirSync(path.dirname(p(middlewareFile)), { recursive: true });
529
+ if (ex(middlewareFile) && !ex(middlewareFile + ".bak")) fs.copyFileSync(p(middlewareFile), p(middlewareFile + ".bak"));
530
+ fs.writeFileSync(p(middlewareFile), middleware);
531
+ created.push(middlewareFile);
532
+ if (auth === "supabase") steps.push("Install @supabase/ssr for the generated Supabase middleware.");
533
+ }
534
+
352
535
  // ---- provider notices / Clerk SSO callback route
353
536
  if (providers.length) {
354
537
  if (auth === "clerk") {
@@ -377,7 +560,7 @@ if (method === "otp" && auth === "supabase") steps.push("Supabase email template
377
560
  if (method !== "password" && auth === "next-auth") steps.push('next-auth: add an Email provider (id "email")' + (method === "otp" ? " with generateVerificationToken returning 6 digits." : "."));
378
561
 
379
562
  // ---- dependencies
380
- const need = ["framer-motion", ...hooks.pkgs].filter((d) => !deps[d]);
563
+ const need = ["framer-motion", ...hooks.pkgs, ...(middlewareFile && auth === "supabase" ? ["@supabase/ssr"] : [])].filter((d) => !deps[d]);
381
564
  if (need.length && !flag("no-install")) {
382
565
  const pm = ex("pnpm-lock.yaml") ? "pnpm" : ex("yarn.lock") ? "yarn" : ex("bun.lockb") || ex("bun.lock") ? "bun" : "npm";
383
566
  console.log(`Installing ${need.join(", ")} with ${pm}…`);
@@ -393,11 +576,14 @@ const envVars = {
393
576
  supabase: [pre + "SUPABASE_URL", pre + "SUPABASE_ANON_KEY"],
394
577
  clerk: isNext ? ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"] : ["VITE_CLERK_PUBLISHABLE_KEY"],
395
578
  "next-auth": ["NEXTAUTH_URL", "NEXTAUTH_SECRET"],
579
+ webauthn: [],
396
580
  }[auth];
397
581
  const next = [...steps];
398
582
  if (envVars.length) next.splice(next.findIndex((x) => !x.startsWith("Install")) < 0 ? next.length : next.findIndex((x) => !x.startsWith("Install")), 0, `Add to ${isNext ? ".env.local" : ".env"}: ${envVars.join(", ")}`);
399
583
  if (auth === "clerk") next.push(`Wrap your app in <ClerkProvider publishableKey={...}>.`);
400
584
  if (auth === "next-auth") next.push("OAuth client ID/secret for each provider go in your NextAuth config.");
585
+ if (middlewareFile && auth === "next-auth") next.push('Set `pages.signIn` to "/login" in your NextAuth config as well.');
586
+ if (auth === "webauthn") next.push("WebAuthn starter requires server POST endpoints /api/generate-options and /api/verify with challenge storage, origin/RP validation, credential persistence, and mode-aware registration/authentication verification.");
401
587
  if (layout === "modal") next.push('Modal: use as the /login route, or render <authica trigger="Sign in" /> (props: open, onClose, trigger).');
402
588
  next.push("Start your dev server and open /login.");
403
589
  const routes = ["/login", ...extraRoutes];
@@ -437,3 +623,4 @@ const title = " authica ✔ ";
437
623
  console.log("\n╭" + title + "─".repeat(W - 2 - title.length) + "╮");
438
624
  for (const l of body) console.log("│ " + l.padEnd(inner) + " │");
439
625
  console.log("╰" + "─".repeat(W - 2) + "╯");
626
+ }
package/bin/preview.js ADDED
@@ -0,0 +1,203 @@
1
+ import http from "node:http";
2
+ import { spawn } from "node:child_process";
3
+
4
+ const port = 4321;
5
+ const page = `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
9
+ <meta name="color-scheme" content="dark">
10
+ <title>Authica · Visual Studio</title>
11
+ <style>
12
+ :root{color-scheme:dark;--bg:#0B0B0E;--canvas:#0B0B0E;--panel:#13131A;--border:#22222E;--accent:#8B5CF6;--accent-soft:#8B5CF626;--text:#F4F2FA;--muted:#92909F;--card:#181820;--field:#101016}
13
+ *{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--canvas);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
14
+ button,input{font:inherit}button{color:inherit}.app{min-height:100vh;display:grid;grid-template-columns:248px minmax(0,1fr) 250px}
15
+ .sidebar,.inspector{background:var(--panel);border-color:var(--border);min-height:100vh}.sidebar{border-right:1px solid var(--border);padding:22px 14px;position:sticky;top:0;height:100vh;overflow:auto}.inspector{border-left:1px solid var(--border);padding:22px 16px}
16
+ .brand{display:flex;align-items:center;gap:10px;padding:0 8px 22px;font-weight:700;font-size:15px}.brand-mark{display:grid;place-items:center;width:30px;height:30px;border-radius:10px;background:linear-gradient(145deg,#a78bfa,var(--accent));box-shadow:0 0 24px #8b5cf644}
17
+ .brand small{display:block;color:var(--muted);font-size:11px;font-weight:500}.search{width:100%;height:38px;border:1px solid var(--border);border-radius:11px;background:#0d0d13;color:var(--text);padding:0 11px;outline:none;margin-bottom:24px}.search:focus{border-color:var(--accent)}
18
+ .section{margin:0 0 22px}.section-title,.panel-title{margin:0 8px 9px;color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}
19
+ .nav-list{display:grid;gap:4px}.nav-item{width:100%;border:1px solid transparent;border-radius:999px;background:transparent;padding:8px 11px;display:flex;align-items:center;gap:10px;text-align:left;cursor:pointer;color:#bbb9c7;transition:background .16s,border-color .16s,color .16s}
20
+ .nav-item:hover{background:#ffffff08;color:var(--text)}.nav-item[aria-pressed=true]{color:#e5d9ff;background:var(--accent-soft);border-color:#8b5cf640}.nav-icon{width:19px;text-align:center;color:#a6a2b3}.nav-item[aria-pressed=true] .nav-icon{color:#c4aaff}
21
+ .main{min-width:0;padding:24px clamp(18px,3vw,42px) 36px}.topbar{display:flex;align-items:center;gap:10px;border:1px solid var(--border);background:#121219;border-radius:14px;padding:8px 9px 8px 14px;box-shadow:0 8px 32px #0002}
22
+ .command{flex:1;min-width:0;overflow:auto;color:#d5c6ff;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:nowrap}.prompt{color:#8f8c9a;margin-right:8px}
23
+ .copy{flex:none;border:1px solid #8b5cf64d;border-radius:9px;background:#8b5cf61a;padding:7px 11px;color:#d7c8ff;font-size:12px;cursor:pointer}.copy:hover{background:#8b5cf633}
24
+ .intro{display:flex;justify-content:space-between;align-items:end;gap:18px;margin:30px 0 17px}.eyebrow{color:#b79aff;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.intro h1{margin:5px 0 3px;font-size:25px;letter-spacing:-.04em}.intro p{margin:0;color:var(--muted);font-size:13px}
25
+ .preview-label{color:var(--muted);font-size:11px}.viewport{min-height:520px;position:relative;display:grid;place-items:center;overflow:hidden;border:1px solid var(--border);border-radius:18px;background:radial-gradient(ellipse at 50% 45%,#171521,#0d0d12 70%);box-shadow:0 24px 80px #0004}
26
+ .viewport::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.28;background-image:radial-gradient(#777084 0.6px,transparent 0.6px);background-size:22px 22px}
27
+ .mock{position:relative;z-index:1;width:min(760px,88%);min-height:400px;display:flex;overflow:hidden;border:1px solid #ffffff24;border-radius:20px;background:var(--card);box-shadow:0 24px 80px #0008;transition:all .22s}
28
+ .mock.centered,.mock.canvas,.mock.modal,.mock.glass{width:min(410px,88%);min-height:0}.mock.canvas{background:radial-gradient(ellipse at 80% 0%,#8b5cf644,#17151f 65%)}.mock.modal{border-radius:14px;box-shadow:0 30px 90px #000b,0 0 0 999px #0002}.mock.glass{background:linear-gradient(145deg,#211a35,#111721);box-shadow:0 0 55px #8b5cf622}
29
+ .hero{display:none;position:relative;flex:1;background:linear-gradient(145deg,#5036a0,#241c41 58%,#17171f);padding:26px}.mock.split .hero{display:flex;flex-direction:column;justify-content:space-between}
30
+ .hero-chip{width:max-content;border:1px solid #ffffff30;background:#ffffff12;border-radius:999px;padding:6px 10px;color:#e9ddff;font-size:10px}.hero h2{max-width:230px;margin:0;font-size:27px;line-height:1.15;letter-spacing:-.04em}.hero p{margin:8px 0 0;color:#d4cae8;font-size:12px}
31
+ .form{width:100%;padding:clamp(22px,4vw,36px);display:flex;flex-direction:column;justify-content:center}.mock.split .form{width:50%}.form-brand{color:#c3aaff;font-size:11px;font-weight:700;letter-spacing:.08em}.form h2{margin:12px 0 3px;font-size:23px;letter-spacing:-.04em}.form .hint{margin:0 0 20px;color:#9c99a8;font-size:12px}
32
+ .field-label{font-size:10px;color:#c1becc;margin:10px 0 5px}.field{height:38px;border:1px solid #353441;border-radius:9px;background:var(--field);padding:0 11px;color:#d0ced8;font-size:11px;display:flex;align-items:center}.mock.glass .field{background:#ffffff0b}
33
+ .submit{height:39px;margin-top:16px;border:0;border-radius:999px;background:var(--accent);color:white;font-weight:650;font-size:11px;box-shadow:0 5px 22px #8b5cf644}
34
+ .mock.brutalist{border:3px solid #f4f2fa;border-radius:3px;box-shadow:7px 7px 0 #000}.mock.brutalist .submit{border-radius:4px;background:#facc15;color:#17130a;box-shadow:none}.mock.glassmorphism{background:linear-gradient(145deg,#8b5cf633,#111721dd);border-color:#ffffff30;backdrop-filter:blur(16px)}.mock.glassmorphism .form{background:#ffffff08}
35
+ .divider{display:flex;align-items:center;gap:9px;color:#777482;font-size:9px;margin:17px 0 10px}.divider::before,.divider::after{content:"";height:1px;flex:1;background:#34323f}
36
+ .social{display:flex;gap:8px}.social span{height:34px;flex:1;display:grid;place-items:center;border:1px solid #353441;border-radius:9px;color:#c7c4d0;font-size:10px}
37
+ .inspector-heading{padding:1px 5px 20px}.inspector-heading h2{font-size:14px;margin:0 0 3px}.inspector-heading p{font-size:11px;color:var(--muted);margin:0}
38
+ .panel{border-top:1px solid var(--border);padding:17px 3px}.panel-title{margin:0 0 12px}.swatches{display:grid;gap:9px}.swatch-row{display:flex;align-items:center;justify-content:space-between;color:#c1becc;font-size:11px}.swatch-value{display:flex;align-items:center;gap:7px;color:var(--muted);font:10px ui-monospace,monospace}
39
+ .color-input{appearance:none;width:20px;height:20px;border:1px solid #ffffff30;border-radius:7px;padding:0;background:transparent;overflow:hidden;cursor:pointer}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:0;border-radius:6px}
40
+ .providers{display:grid;gap:7px}.provider{width:100%;display:flex;justify-content:space-between;align-items:center;border:1px solid var(--border);border-radius:10px;background:#0f0f15;padding:9px 10px;cursor:pointer;color:#c4c1ce;font-size:11px}.provider[aria-pressed=true]{border-color:#8b5cf655;background:#8b5cf610;color:#e2d7ff}
41
+ .switch{width:27px;height:16px;border-radius:999px;background:#34323e;padding:2px;transition:background .15s}.switch::after{content:"";display:block;width:12px;height:12px;border-radius:50%;background:#a09dab;transition:transform .15s}.provider[aria-pressed=true] .switch{background:var(--accent)}.provider[aria-pressed=true] .switch::after{transform:translateX(11px);background:white}
42
+ .empty{display:none;color:var(--muted);padding:8px;font-size:12px}.footer-note{margin-top:20px;color:#777482;font-size:10px;line-height:1.6}
43
+ @media(max-width:1050px){.app{grid-template-columns:220px minmax(0,1fr)}.inspector{grid-column:2;min-height:0;border-left:0;border-top:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.inspector-heading{grid-column:1/-1}.panel{align-self:start}}
44
+ @media(max-width:700px){.app{display:block}.sidebar{position:relative;height:auto;min-height:0;border-right:0;border-bottom:1px solid var(--border);padding:14px}.brand{padding-bottom:12px}.search{margin-bottom:13px}.section{margin-bottom:11px}.nav-list{display:flex;flex-wrap:wrap}.nav-item{width:auto;padding:7px 10px;font-size:12px}.main{padding:20px 14px}.intro{align-items:start;flex-direction:column;margin-top:22px}.viewport{min-height:450px}.mock.split{width:94%;min-height:360px}.mock.split .form{width:58%;padding:18px}.mock.split .hero{padding:18px}.hero h2{font-size:22px}.inspector{display:block;padding:18px 16px}.panel{padding:14px 3px}}
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="app">
49
+ <aside class="sidebar" aria-label="Studio navigation">
50
+ <div class="brand"><span class="brand-mark">⚡</span><span>authica <small>VISUAL STUDIO</small></span></div>
51
+ <input id="search" class="search" type="search" placeholder="Search templates…" aria-label="Search templates">
52
+ <section class="section" data-section="layouts">
53
+ <h2 class="section-title">Layout Frameworks</h2>
54
+ <nav class="nav-list" aria-label="Layout Frameworks">
55
+ <button class="nav-item" data-layout="split" aria-pressed="true"><span class="nav-icon">◫</span>Split</button>
56
+ <button class="nav-item" data-layout="centered" aria-pressed="false"><span class="nav-icon">▣</span>Centered</button>
57
+ <button class="nav-item" data-layout="canvas" aria-pressed="false"><span class="nav-icon">▧</span>Canvas</button>
58
+ <button class="nav-item" data-layout="modal" aria-pressed="false"><span class="nav-icon">▢</span>Modal</button>
59
+ <button class="nav-item" data-layout="glass" aria-pressed="false"><span class="nav-icon">✧</span>Glass</button>
60
+ </nav><p class="empty" data-empty="layouts">No layouts match.</p>
61
+ </section>
62
+ <section class="section" data-section="styles">
63
+ <h2 class="section-title">Aesthetic Presets</h2>
64
+ <nav class="nav-list" aria-label="Aesthetic Presets">
65
+ <button class="nav-item" data-style="default" aria-pressed="true"><span class="nav-icon">◌</span>Default</button>
66
+ <button class="nav-item" data-style="brutalist" aria-pressed="false"><span class="nav-icon">▦</span>Brutalist</button>
67
+ <button class="nav-item" data-style="glassmorphism" aria-pressed="false"><span class="nav-icon">◈</span>Glassmorphism</button>
68
+ </nav><p class="empty" data-empty="styles">No presets match.</p>
69
+ </section>
70
+ </aside>
71
+ <main class="main">
72
+ <div class="topbar"><code class="command"><span class="prompt">$</span><span id="command">npx authica --layout=split --style=default</span></code><button class="copy" id="copy" type="button">Copy</button></div>
73
+ <div class="intro"><div><span class="eyebrow">Design your sign-in flow</span><h1>Authentication, styled.</h1><p>Choose a layout and preset. Your CLI command updates as you explore.</p></div><span class="preview-label">LIVE PREVIEW&nbsp; · &nbsp;LOCAL ONLY</span></div>
74
+ <section class="viewport" aria-label="Interactive login preview">
75
+ <div id="mock" class="mock split">
76
+ <div class="hero"><span class="hero-chip">✦ &nbsp; Secure by design</span><div><h2>Welcome to your next chapter.</h2><p>A sign-in experience that feels like your product.</p></div></div>
77
+ <div class="form"><span class="form-brand">AUTHICA</span><h2>Welcome back</h2><p class="hint">Sign in to continue to your account.</p><label class="field-label">Email address</label><div class="field">you@example.com</div><label class="field-label">Password</label><div class="field">••••••••••</div><button class="submit" type="button">Sign in</button><div class="divider">OR CONTINUE WITH</div><div class="social" id="mock-social"><span>G &nbsp; Google</span><span>⌘ &nbsp; GitHub</span></div></div>
78
+ </div>
79
+ </section>
80
+ </main>
81
+ <aside class="inspector" aria-label="Theme and provider settings">
82
+ <div class="inspector-heading"><h2>Quick customize</h2><p>Adjust preview-only options.</p></div>
83
+ <section class="panel"><h3 class="panel-title">CSS theme variables</h3><div class="swatches">
84
+ <label class="swatch-row">Primary accent <span class="swatch-value"><input class="color-input" type="color" data-color="accent" value="#8b5cf6"><code id="accent-value">#8B5CF6</code></span></label>
85
+ <label class="swatch-row">Card surface <span class="swatch-value"><input class="color-input" type="color" data-color="card" value="#181820"><code id="card-value">#181820</code></span></label>
86
+ <label class="swatch-row">Field surface <span class="swatch-value"><input class="color-input" type="color" data-color="field" value="#101016"><code id="field-value">#101016</code></span></label>
87
+ <label class="swatch-row">Canvas <span class="swatch-value"><input class="color-input" type="color" data-color="canvas" value="#0b0b0e"><code id="canvas-value">#0B0B0E</code></span></label>
88
+ </div></section>
89
+ <section class="panel"><h3 class="panel-title">OAuth providers</h3><div class="providers">
90
+ <button class="provider" data-provider="google" aria-pressed="true"><span>Google</span><span class="switch"></span></button>
91
+ <button class="provider" data-provider="github" aria-pressed="true"><span>GitHub</span><span class="switch"></span></button>
92
+ <button class="provider" data-provider="apple" aria-pressed="false"><span>Apple</span><span class="switch"></span></button>
93
+ </div><p class="footer-note">These controls are a visual preview only. Authica does not write project files in Studio.</p></section>
94
+ </aside>
95
+ </div>
96
+ <script>
97
+ (() => {
98
+ const layoutButtons = [...document.querySelectorAll("[data-layout]")];
99
+ const styleButtons = [...document.querySelectorAll("[data-style]")];
100
+ const search = document.querySelector("#search");
101
+ const mock = document.querySelector("#mock");
102
+ const command = document.querySelector("#command");
103
+ const providers = [...document.querySelectorAll("[data-provider]")];
104
+ let layout = "split", style = "default";
105
+ const selectedProviders = new Set(["google", "github"]);
106
+ const upper = (text) => text.charAt(0).toUpperCase() + text.slice(1);
107
+ function update() {
108
+ mock.className = "mock " + layout + (style === "default" ? "" : " " + style);
109
+ document.querySelector(".intro h1").textContent = upper(layout) + " authentication.";
110
+ command.textContent = "npx authica --layout=" + layout + " --style=" + style +
111
+ (selectedProviders.size ? " --providers=" + [...selectedProviders].join(",") : "");
112
+ layoutButtons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.layout === layout)));
113
+ styleButtons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.style === style)));
114
+ providers.forEach((button) => button.setAttribute("aria-pressed", String(selectedProviders.has(button.dataset.provider))));
115
+ const social = document.querySelector("#mock-social");
116
+ const names = [...selectedProviders].map(upper);
117
+ social.innerHTML = names.length ? names.map((name) => "<span>" + name + "</span>").join("") : "<span>Email sign-in</span>";
118
+ }
119
+ layoutButtons.forEach((button) => button.addEventListener("click", () => { layout = button.dataset.layout; update(); }));
120
+ styleButtons.forEach((button) => button.addEventListener("click", () => { style = button.dataset.style; update(); }));
121
+ providers.forEach((button) => button.addEventListener("click", () => {
122
+ const name = button.dataset.provider;
123
+ if (selectedProviders.has(name)) selectedProviders.delete(name); else selectedProviders.add(name);
124
+ update();
125
+ }));
126
+ search.addEventListener("input", () => {
127
+ const query = search.value.trim().toLowerCase();
128
+ for (const section of document.querySelectorAll("[data-section]")) {
129
+ const buttons = [...section.querySelectorAll(".nav-item")];
130
+ let visible = 0;
131
+ buttons.forEach((button) => {
132
+ const show = button.textContent.toLowerCase().includes(query);
133
+ button.hidden = !show;
134
+ if (show) visible++;
135
+ });
136
+ section.querySelector(".empty").style.display = visible ? "none" : "block";
137
+ section.querySelector(".section-title").hidden = !visible;
138
+ }
139
+ });
140
+ document.querySelectorAll("[data-color]").forEach((input) => input.addEventListener("input", () => {
141
+ const name = input.dataset.color;
142
+ const value = input.value.toUpperCase();
143
+ document.documentElement.style.setProperty("--" + name, input.value);
144
+ document.querySelector("#" + name + "-value").textContent = value;
145
+ }));
146
+ document.querySelector("#copy").addEventListener("click", async (event) => {
147
+ const button = event.currentTarget;
148
+ try {
149
+ await navigator.clipboard.writeText(command.textContent);
150
+ button.textContent = "Copied!";
151
+ setTimeout(() => { button.textContent = "Copy"; }, 1400);
152
+ } catch {
153
+ const range = document.createRange();
154
+ range.selectNodeContents(command);
155
+ const selection = window.getSelection();
156
+ selection.removeAllRanges();
157
+ selection.addRange(range);
158
+ button.textContent = "Select & copy";
159
+ setTimeout(() => { button.textContent = "Copy"; selection.removeAllRanges(); }, 1800);
160
+ }
161
+ });
162
+ update();
163
+ })();
164
+ </script>
165
+ </body></html>`;
166
+
167
+ function openBrowser() {
168
+ const url = `http://localhost:${port}`;
169
+ const platform = process.platform;
170
+ const command = platform === "win32" ? "cmd.exe" : platform === "darwin" ? "open" : "xdg-open";
171
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
172
+ const browser = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
173
+ browser.on("error", (error) => console.error(`Could not open a browser automatically: ${error.message}`));
174
+ browser.unref();
175
+ }
176
+
177
+ const server = http.createServer((request, response) => {
178
+ if (request.method !== "GET" || (request.url !== "/" && request.url !== "/index.html")) {
179
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" });
180
+ response.end("Not found");
181
+ return;
182
+ }
183
+ response.writeHead(200, {
184
+ "Content-Type": "text/html; charset=utf-8",
185
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';",
186
+ "X-Content-Type-Options": "nosniff",
187
+ "Cache-Control": "no-store",
188
+ });
189
+ response.end(page);
190
+ });
191
+
192
+ server.on("error", (error) => {
193
+ console.error(`Could not start Authica preview at http://localhost:${port}: ${error.message}`);
194
+ process.exitCode = 1;
195
+ });
196
+ server.listen(port, "127.0.0.1", () => {
197
+ console.log(`Authica preview running at http://localhost:${port} (local-only; Ctrl+C to stop).`);
198
+ if (process.argv.includes("--open")) openBrowser();
199
+ });
200
+
201
+ for (const signal of ["SIGINT", "SIGTERM"]) {
202
+ process.on(signal, () => server.close(() => process.exit(0)));
203
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "authica",
3
- "version": "1.2.0",
4
- "description": "Adds a theme-matched, animated sliding sign-in/sign-up page (split, centered or canvas) to Next.js or Vite + React Router projects.",
3
+ "version": "2.0.0",
4
+ "description": "Generates theme-matched authentication UI for Next.js and Vite, with opt-in AST safety tools and a local preview studio.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "authica": "bin/cli.js"
@@ -13,10 +13,15 @@
13
13
  "engines": {
14
14
  "node": ">=18"
15
15
  },
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
16
19
  "keywords": [
17
20
  "authica",
18
21
  "auth",
19
22
  "authentication",
23
+ "webauthn",
24
+ "passkeys",
20
25
  "nextjs-auth",
21
26
  "supabase-auth",
22
27
  "clerk-auth",
@@ -28,8 +33,12 @@
28
33
  ],
29
34
  "repository": {
30
35
  "type": "git",
31
- "url": "https://github.com/AEGON247/authica.git"
36
+ "url": "git+https://github.com/AEGON247/authica.git"
32
37
  },
33
38
  "homepage": "https://github.com/AEGON247/authica#readme",
34
- "license": "MIT"
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "@babel/parser": "^7.29.13",
42
+ "recast": "^0.24.0"
43
+ }
35
44
  }