@fluixi/cli 0.1.0-alpha.82 → 0.1.0-alpha.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +21 -3
  2. package/dist/add.cjs +590 -0
  3. package/dist/add.d.ts +10 -0
  4. package/dist/add.d.ts.map +1 -0
  5. package/dist/add.js +107 -0
  6. package/dist/add.mjs +562 -0
  7. package/dist/check.cjs +102 -0
  8. package/dist/check.d.ts +13 -0
  9. package/dist/check.d.ts.map +1 -0
  10. package/dist/check.js +88 -0
  11. package/dist/check.mjs +76 -0
  12. package/dist/cli.cjs +977 -78
  13. package/dist/cli.js +44 -2
  14. package/dist/cli.mjs +976 -77
  15. package/dist/doctor.cjs +119 -0
  16. package/dist/doctor.d.ts +13 -0
  17. package/dist/doctor.d.ts.map +1 -0
  18. package/dist/doctor.js +114 -0
  19. package/dist/doctor.mjs +92 -0
  20. package/dist/generate.cjs +614 -65
  21. package/dist/generate.d.ts.map +1 -1
  22. package/dist/generate.js +118 -48
  23. package/dist/generate.mjs +619 -64
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.mjs +1 -1
  26. package/dist/report.cjs +54 -0
  27. package/dist/report.d.ts +5 -0
  28. package/dist/report.d.ts.map +1 -0
  29. package/dist/report.js +31 -0
  30. package/dist/report.mjs +28 -0
  31. package/dist/scaffold/auth.cjs +422 -0
  32. package/dist/scaffold/auth.d.ts +39 -0
  33. package/dist/scaffold/auth.d.ts.map +1 -0
  34. package/dist/scaffold/auth.js +137 -0
  35. package/dist/scaffold/auth.mjs +389 -0
  36. package/dist/scaffold/edit.cjs +153 -0
  37. package/dist/scaffold/edit.d.ts +35 -0
  38. package/dist/scaffold/edit.d.ts.map +1 -0
  39. package/dist/scaffold/edit.js +140 -0
  40. package/dist/scaffold/edit.mjs +124 -0
  41. package/dist/scaffold/index.cjs +610 -0
  42. package/dist/scaffold/index.d.ts +10 -0
  43. package/dist/scaffold/index.d.ts.map +1 -0
  44. package/dist/scaffold/index.js +9 -0
  45. package/dist/scaffold/index.mjs +569 -0
  46. package/dist/scaffold/prompt.cjs +108 -0
  47. package/dist/scaffold/prompt.d.ts +9 -0
  48. package/dist/scaffold/prompt.d.ts.map +1 -0
  49. package/dist/scaffold/prompt.js +95 -0
  50. package/dist/scaffold/prompt.mjs +81 -0
  51. package/dist/scaffold/templates.cjs +274 -0
  52. package/dist/scaffold/templates.d.ts +26 -0
  53. package/dist/scaffold/templates.d.ts.map +1 -0
  54. package/dist/scaffold/templates.js +216 -0
  55. package/dist/scaffold/templates.mjs +229 -0
  56. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  57. package/package.json +19 -3
package/dist/add.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `fluixi add <feature>`: set a feature up in an existing app. Where `fluixi g` writes files,
3
+ * `add` also asks what it needs to know and installs the packages the files import.
4
+ *
5
+ * fluixi add auth asks
6
+ * fluixi add auth oauth2 --provider keycloak --tokens server --no-install
7
+ */
8
+ import { execFileSync } from 'node:child_process';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { stdin } from 'node:process';
12
+ import { c } from '@fluixi/start/ui';
13
+ import { reportAuth } from './report.js';
14
+ import { askSelect, projectFormat, PROVIDERS, SERVER_ONLY, ScaffoldError, setupBetterAuth, setupOAuth2, } from './scaffold/index.js';
15
+ export const ADD_USAGE = `Usage: fluixi add <feature> [options]
16
+
17
+ Features:
18
+ auth [oauth2|better-auth] authentication, asked for when not given
19
+ --provider <p> ${PROVIDERS.join(', ')}
20
+ --tokens browser|server where an OAuth2 app keeps its tokens
21
+ --no-install write the files, leave the packages to you
22
+ `;
23
+ function option(args, name) {
24
+ const i = args.indexOf(`--${name}`);
25
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
26
+ return args[i + 1];
27
+ return args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
28
+ }
29
+ /** The install command for the package manager the lockfile names. */
30
+ export function installCommand(root, specs) {
31
+ if (existsSync(join(root, 'pnpm-lock.yaml')))
32
+ return ['pnpm', ['add', ...specs]];
33
+ if (existsSync(join(root, 'yarn.lock')))
34
+ return ['yarn', ['add', ...specs]];
35
+ if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')))
36
+ return ['bun', ['add', ...specs]];
37
+ return ['npm', ['install', ...specs]];
38
+ }
39
+ /**
40
+ * The dist-tag the app's framework came from: an app on `alpha` gets the auth package from
41
+ * `alpha` too, so the two agree on the framework they expect.
42
+ */
43
+ export function channelOf(root) {
44
+ try {
45
+ const version = JSON.parse(readFileSync(join(root, 'node_modules/@fluixi/start/package.json'), 'utf8')).version;
46
+ return version.match(/-([a-z]+)\./)?.[1] ?? 'latest';
47
+ }
48
+ catch {
49
+ return 'alpha';
50
+ }
51
+ }
52
+ async function addAuth(root, args) {
53
+ const ask = stdin.isTTY;
54
+ // Only the word right after `auth`: a later bare word is an option's value.
55
+ let kind = (args[1] && !args[1].startsWith('-') ? args[1] : undefined);
56
+ let provider = option(args, 'provider');
57
+ let tokens = option(args, 'tokens');
58
+ if (kind && kind !== 'oauth2' && kind !== 'better-auth')
59
+ throw new ScaffoldError(`unknown auth kind ${kind}: oauth2 or better-auth`);
60
+ if (tokens && tokens !== 'browser' && tokens !== 'server')
61
+ throw new ScaffoldError('--tokens is browser or server');
62
+ if (provider && !PROVIDERS.includes(provider))
63
+ throw new ScaffoldError(`unknown provider ${provider}: ${PROVIDERS.join(', ')}`);
64
+ if (!kind && ask) {
65
+ kind = (await askSelect('Authentication', [
66
+ { value: 'oauth2', label: 'OAuth2 / OIDC', hint: '@fluixi/oauth2 · Keycloak, Auth0, Okta, Google…' },
67
+ { value: 'better-auth', label: 'Better Auth', hint: '@fluixi/auth · your own user table' },
68
+ ]));
69
+ }
70
+ kind ??= 'oauth2';
71
+ if (kind === 'oauth2' && !provider && ask) {
72
+ provider = (await askSelect('Identity provider', PROVIDERS.map((p) => ({ value: p, label: p, hint: '' }))));
73
+ }
74
+ if (kind === 'oauth2' && !tokens && ask && !SERVER_ONLY[provider ?? 'oidc']) {
75
+ tokens = (await askSelect('Where the tokens live', [
76
+ { value: 'browser', label: 'Browser', hint: 'public client · PKCE' },
77
+ { value: 'server', label: 'Server', hint: 'httpOnly cookie · client secret' },
78
+ ]));
79
+ }
80
+ const channel = channelOf(root);
81
+ const result = kind === 'oauth2' ? setupOAuth2(root, { provider, tokens, format: projectFormat(root) }, channel) : setupBetterAuth(root, channel);
82
+ const specs = Object.entries(result.dependencies).map(([name, spec]) => `${name}@${spec}`);
83
+ if (args.includes('--no-install')) {
84
+ reportAuth(result, `install ${specs.join(' ')}`);
85
+ return;
86
+ }
87
+ reportAuth(result);
88
+ const [bin, binArgs] = installCommand(root, specs);
89
+ console.log(` ${c.gray('›')} ${c.cyan([bin, ...binArgs].join(' '))}`);
90
+ execFileSync(bin, binArgs, { cwd: root, stdio: 'inherit' });
91
+ }
92
+ export async function add(feature, args, root = process.cwd()) {
93
+ if (feature !== 'auth') {
94
+ console.error(ADD_USAGE);
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ try {
99
+ await addAuth(root, args);
100
+ }
101
+ catch (error) {
102
+ if (!(error instanceof ScaffoldError))
103
+ throw error;
104
+ console.error(` ${c.red('✗')} ${error.message}`);
105
+ process.exitCode = 1;
106
+ }
107
+ }
package/dist/add.mjs ADDED
@@ -0,0 +1,562 @@
1
+ // src/add.ts
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
4
+ import { join as join2 } from "node:path";
5
+ import { stdin } from "node:process";
6
+ import { c as c3 } from "@fluixi/start/ui";
7
+
8
+ // src/report.ts
9
+ import { relative } from "node:path";
10
+ import { c } from "@fluixi/start/ui";
11
+ var rel = (file) => relative(process.cwd(), file) || ".";
12
+ function report(steps) {
13
+ for (const s of steps) {
14
+ if (s.kind === "hint") {
15
+ console.log(` ${c.yellow("!")} ${c.gray("add to")} ${c.cyan(rel(s.file))}${c.gray(":")}`);
16
+ for (const l of s.lines) console.log(` ${l}`);
17
+ } else if (s.kind === "kept") {
18
+ console.log(` ${c.gray("-")} ${c.cyan(rel(s.file))} ${c.gray("exists, kept")}`);
19
+ } else if (s.kind === "listed") {
20
+ console.log(` ${c.gray("-")} ${c.cyan(rel(s.file))} ${c.gray("already lists it")}`);
21
+ } else {
22
+ console.log(` ${c.green("✓")} ${c.gray(s.kind)} ${c.cyan(rel(s.file))}${s.detail ? c.gray(`: ${s.detail}`) : ""}`);
23
+ }
24
+ }
25
+ }
26
+ function reportAuth(result, installHint) {
27
+ for (const n of result.notes) console.log(` ${c.gray(n)}`);
28
+ report(result.steps);
29
+ const next = [...installHint ? [installHint] : [], ...result.next];
30
+ if (next.length) console.log(` ${c.gray("next:")} ${next.map((n) => c.gray(n)).join(c.gray("; "))}`);
31
+ }
32
+
33
+ // src/scaffold/auth.ts
34
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
35
+ import { dirname as dirname2, join, relative as relative2 } from "node:path";
36
+
37
+ // src/scaffold/edit.ts
38
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
39
+ import { dirname } from "node:path";
40
+ function closing(text, open) {
41
+ let depth = 0;
42
+ for (let i = open; i < text.length; i++) {
43
+ const ch = text[i];
44
+ if (ch === "/" && text[i + 1] === "/") i = text.indexOf("\n", i) === -1 ? text.length : text.indexOf("\n", i);
45
+ else if (ch === "/" && text[i + 1] === "*") i = text.indexOf("*/", i + 2) + 1;
46
+ else if (ch === "'" || ch === '"' || ch === "`") {
47
+ for (i++; i < text.length && text[i] !== ch; i++) if (text[i] === "\\") i++;
48
+ } else if ("([{".includes(ch)) depth++;
49
+ else if (")]}".includes(ch) && --depth === 0) return i;
50
+ }
51
+ return -1;
52
+ }
53
+ function appendToArray(text, open, items) {
54
+ const close = closing(text, open);
55
+ const inner = text.slice(open + 1, close);
56
+ if (!inner.trim()) return text.slice(0, open + 1) + items.join(", ") + text.slice(close);
57
+ if (inner.includes("\n")) {
58
+ const last = inner.trimEnd();
59
+ const indent = last.slice(last.lastIndexOf("\n") + 1).match(/^\s*/)[0];
60
+ const comma = last.endsWith(",") ? "" : ",";
61
+ const tail = inner.slice(last.length);
62
+ return text.slice(0, open + 1) + last + comma + items.map((i) => `
63
+ ${indent}${i},`).join("") + tail + text.slice(close);
64
+ }
65
+ const body = inner.replace(/,?\s*$/, "");
66
+ const trailing = inner.slice(body.length).replace(/^,/, "");
67
+ return text.slice(0, open + 1) + body + ", " + items.join(", ") + trailing + text.slice(close);
68
+ }
69
+ function addImport(text, line) {
70
+ if (text.includes(line)) return text;
71
+ const imports = [...text.matchAll(/^import[\s\S]*?from\s+['"][^'"]+['"];?[^\n]*\n/gm)];
72
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
73
+ return text.slice(0, at) + line + "\n" + text.slice(at);
74
+ }
75
+ function register(log, file, header, r) {
76
+ const imports = [...r.extraImports ?? [], r.importLine];
77
+ if (!existsSync(file)) {
78
+ mkdirSync(dirname(file), { recursive: true });
79
+ writeFileSync(
80
+ file,
81
+ `${header}import { defineApp } from '@fluixi/start/app';
82
+ ${imports.join("\n")}
83
+
84
+ export default defineApp({ imports: [${r.entry}] });
85
+ `
86
+ );
87
+ log.push({ kind: "created", file });
88
+ return true;
89
+ }
90
+ let text = readFileSync(file, "utf8");
91
+ if (text.includes(r.importLine)) {
92
+ log.push({ kind: "listed", file, detail: r.entry });
93
+ return true;
94
+ }
95
+ const m = text.match(/\bimports\s*:\s*\[/);
96
+ if (!m) {
97
+ log.push({ kind: "hint", file, lines: [...imports, `imports: [${r.entry}]`] });
98
+ return false;
99
+ }
100
+ text = appendToArray(text, m.index + m[0].length - 1, [r.entry]);
101
+ for (const line of imports) text = addImport(text, line);
102
+ writeFileSync(file, text);
103
+ log.push({ kind: "updated", file, detail: `imports ${r.entry}` });
104
+ return true;
105
+ }
106
+ function envPublicNames(configFile) {
107
+ if (!existsSync(configFile)) return null;
108
+ const text = readFileSync(configFile, "utf8");
109
+ const m = text.match(/\bpublic\s*:\s*\[/);
110
+ if (!m) return null;
111
+ const open = m.index + m[0].length - 1;
112
+ const list = text.slice(open, closing(text, open) + 1);
113
+ return [...list.matchAll(/['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g)].map((x) => x[1]);
114
+ }
115
+ function addEnvPublic(log, configFile, names) {
116
+ const list = (indent) => `${indent}public: [
117
+ ${names.map((n) => `${indent} '${n}',
118
+ `).join("")}${indent}],
119
+ `;
120
+ const have = envPublicNames(configFile);
121
+ if (have === null) {
122
+ const text2 = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
123
+ const call = text2.match(/defineConfig\(\{\n/);
124
+ if (call && !/\benv\s*:/.test(text2)) {
125
+ const at = call.index + call[0].length;
126
+ writeFileSync(configFile, text2.slice(0, at) + ` env: {
127
+ ${list(" ")} },
128
+ ` + text2.slice(at));
129
+ log.push({ kind: "updated", file: configFile, detail: `env.public ${names.join(", ")}` });
130
+ return;
131
+ }
132
+ log.push({ kind: "hint", file: configFile, lines: [`env: { public: [${names.map((n) => `'${n}'`).join(", ")}] },`] });
133
+ return;
134
+ }
135
+ const missing = names.filter((n) => !have.includes(n));
136
+ if (!missing.length) return;
137
+ const text = readFileSync(configFile, "utf8");
138
+ const m = text.match(/\bpublic\s*:\s*\[/);
139
+ writeFileSync(configFile, appendToArray(text, m.index + m[0].length - 1, missing.map((n) => `'${n}'`)));
140
+ log.push({ kind: "updated", file: configFile, detail: `env.public + ${missing.join(", ")}` });
141
+ }
142
+ function addEnvExample(log, file, comment, vars) {
143
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
144
+ const have = new Set([...text.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]));
145
+ const add2 = Object.entries(vars).filter(([k]) => !have.has(k) && !have.has(`VITE_${k}`));
146
+ if (!add2.length) return;
147
+ const block = `${comment.split("\n").map((l) => `# ${l}`).join("\n")}
148
+ ${add2.map(([k, v]) => `${k}=${v}`).join("\n")}
149
+ `;
150
+ writeFileSync(file, text ? `${text.replace(/\n*$/, "\n")}
151
+ ${block}` : block);
152
+ log.push({ kind: text ? "updated" : "created", file, detail: add2.map(([k]) => k).join(", ") });
153
+ }
154
+
155
+ // src/scaffold/templates.ts
156
+ var PROVIDERS = ["oidc", "keycloak", "auth0", "okta", "google", "dex", "oauth2"];
157
+ var SERVER_ONLY = {
158
+ google: "Google answers a token request without a client secret with `client_secret is missing`",
159
+ dex: "Dex sends no CORS headers on discovery or the token endpoint"
160
+ };
161
+ var PROVIDER_VARS = {
162
+ oidc: { AUTH_ISSUER: "https://id.example.com" },
163
+ keycloak: { AUTH_BASE_URL: "http://localhost:8080", AUTH_REALM: "master" },
164
+ auth0: { AUTH_DOMAIN: "example.eu.auth0.com", AUTH_AUDIENCE: "https://api.example.com" },
165
+ okta: { AUTH_DOMAIN: "example.okta.com" },
166
+ google: {},
167
+ dex: { AUTH_BASE_URL: "http://localhost:5556/dex" },
168
+ oauth2: {
169
+ AUTH_ISSUER: "https://id.example.com",
170
+ AUTH_AUTHORIZATION_ENDPOINT: "https://id.example.com/authorize",
171
+ AUTH_TOKEN_ENDPOINT: "https://id.example.com/token"
172
+ }
173
+ };
174
+ var AUTH_READER = `// Provider, issuer, client and token mode from the AUTH_* variables in env.public. Moving to
175
+ // another provider is a change to .env.
176
+ export const auth = authFromEnv(publicEnv(), { clientPrefix: '' });
177
+ `;
178
+ var authEnvConfig = () => `import { publicEnv } from '@fluixi/start/env';
179
+ import { authFromEnv } from '@fluixi/oauth2/env';
180
+
181
+ ${AUTH_READER}`;
182
+ var authServiceFile = (tokens) => `import { createAuthService, authModule } from '@fluixi/oauth2';
183
+ import { env } from '@fluixi/start/env';
184
+ ${tokens === "browser" ? `import { auth as authEnv } from '../config/env.js';
185
+ ` : ""}
186
+ export interface User {
187
+ sub: string;
188
+ email?: string;
189
+ name?: string;
190
+ }
191
+
192
+ const appUrl = env('APP_URL') ?? 'http://localhost:3000';
193
+
194
+ export const auth = createAuthService<User>({
195
+ ${tokens === "browser" ? ` config: {
196
+ ...authEnv.client,
197
+ tokens: 'browser',
198
+ redirectUri: \`\${appUrl}/callback\`,
199
+ postLogoutRedirectUri: \`\${appUrl}/login\`,
200
+ },` : ` // The tokens stay on the server behind an httpOnly cookie. The browser calls the routes
201
+ // auth.server.ts mounts under basePath and never sees a token.
202
+ config: {
203
+ tokens: 'server',
204
+ redirectUri: \`\${appUrl}/auth/callback\`,
205
+ postLogoutRedirectUri: \`\${appUrl}/login\`,
206
+ basePath: '/auth',
207
+ },`}
208
+ });
209
+
210
+ export const AuthModule = authModule(auth);
211
+ export const { protectedRoute, Protect, Can, useUser } = auth;
212
+ `;
213
+ var authServerFile = () => `import { authServerModule } from '@fluixi/oauth2/server';
214
+ import { publicEnv, serverEnv } from '@fluixi/start/env';
215
+ import { auth } from '../config/env.js';
216
+
217
+ // A function: serverEnv with required throws when a secret is unset, and it is only read
218
+ // when app.config.server.ts registers this.
219
+ export function authServer() {
220
+ const appUrl = publicEnv().APP_URL ?? 'http://localhost:3000';
221
+ return authServerModule({
222
+ ...auth.client,
223
+ clientSecret: serverEnv('OIDC_CLIENT_SECRET', { required: true }),
224
+ cookieSecret: serverEnv('SESSION_SECRET', { required: true }),
225
+ redirectUri: \`\${appUrl}/auth/callback\`,
226
+ defaultReturnTo: '/',
227
+ secureCookie: appUrl.startsWith('https://'),
228
+ });
229
+ }
230
+ `;
231
+ var callbackRoute = (html) => html ? `import { html } from '@fluixi/core';
232
+ import { OAuthCallback } from '@fluixi/oauth2/callback';
233
+
234
+ export default OAuthCallback({
235
+ pending: () => html\`<p>Signing you in</p>\`,
236
+ failed: (reason) => html\`<p>\${reason}</p><a href="/login">Try again</a>\`,
237
+ });
238
+ ` : `import { OAuthCallback } from '@fluixi/oauth2/callback';
239
+
240
+ export default OAuthCallback({
241
+ pending: () => <p>Signing you in</p>,
242
+ failed: (reason) => (
243
+ <>
244
+ <p>{reason}</p>
245
+ <a href="/login">Try again</a>
246
+ </>
247
+ ),
248
+ });
249
+ `;
250
+ var loginRoute = (html) => html ? `import { html } from '@fluixi/core';
251
+ import { auth } from '../services/auth.js';
252
+
253
+ export default function Login() {
254
+ return html\`<button @click=\${() => auth.signIn({ returnTo: '/' })}>Sign in</button>\`;
255
+ }
256
+ ` : `import { auth } from '../services/auth.js';
257
+
258
+ export default function Login() {
259
+ return <button onClick={() => auth.signIn({ returnTo: '/' })}>Sign in</button>;
260
+ }
261
+ `;
262
+ var betterAuthConfig = () => `import { betterAuth } from 'better-auth';
263
+ import { defineAuth } from '@fluixi/auth/server';
264
+
265
+ export type AppUser = { id: string; email: string; name?: string; role?: string };
266
+
267
+ // Better Auth owns the audited primitives; defineAuth registers it with @fluixi/auth.
268
+ export const auth = defineAuth<AppUser>(
269
+ betterAuth({
270
+ // database: <your adapter>,
271
+ emailAndPassword: { enabled: true },
272
+ // socialProviders: { google: { … }, github: { … } },
273
+ } as Parameters<typeof betterAuth>[0]),
274
+ );
275
+ `;
276
+ var betterAuthClient = () => `import { createAuthClient } from 'better-auth/client';
277
+ import { createAuthHooks } from '@fluixi/auth/client';
278
+ import type { AppUser } from './auth.config.js';
279
+
280
+ const client = createAuthClient({ baseURL: '/api/auth' });
281
+
282
+ // Or, to reuse an existing reactive session: createAuthHooks({ session: yourSessionStore }).
283
+ export const { useUser, useSession, useAuth, useSignIn, useSignOut, protectedRoute } =
284
+ createAuthHooks<AppUser>({ client, loginPath: '/login' });
285
+ `;
286
+ var betterAuthMiddleware = () => `import { defineMiddleware } from '@fluixi/start';
287
+ import { authMiddleware } from '@fluixi/auth/server';
288
+ import './auth.config.js';
289
+
290
+ export default defineMiddleware([authMiddleware()]);
291
+ `;
292
+ var betterAuthRoute = () => `import { mountAuthRoutes } from '@fluixi/auth/server';
293
+ import '../../auth.config.js';
294
+
295
+ const handler = mountAuthRoutes();
296
+ export const GET = handler;
297
+ export const POST = handler;
298
+ export default handler;
299
+ `;
300
+
301
+ // src/scaffold/auth.ts
302
+ var ScaffoldError = class extends Error {
303
+ };
304
+ function projectFormat(root) {
305
+ try {
306
+ const ts = JSON.parse(readFileSync2(join(root, "tsconfig.json"), "utf8"));
307
+ if (ts?.compilerOptions && !ts.compilerOptions.jsx) return "html";
308
+ } catch {
309
+ }
310
+ return "jsx";
311
+ }
312
+ function importPath(from, to) {
313
+ const p = relative2(dirname2(from), to).replace(/\\/g, "/").replace(/\.tsx?$/, ".js");
314
+ return p.startsWith(".") ? p : `./${p}`;
315
+ }
316
+ function writeOnce(log, file, content) {
317
+ if (existsSync2(file)) {
318
+ log.push({ kind: "kept", file });
319
+ return false;
320
+ }
321
+ mkdirSync2(dirname2(file), { recursive: true });
322
+ writeFileSync2(file, content);
323
+ log.push({ kind: "created", file });
324
+ return true;
325
+ }
326
+ var APP_HEADER = "// Registered by @fluixi/start ahead of both entries.\n";
327
+ var APP_SERVER_HEADER = "// Only the server build imports this file, so what it lists never reaches the browser.\n";
328
+ function registerModule(log, root, server, file, name, entry = name) {
329
+ const config = join(root, server ? "src/app.config.server.ts" : "src/app.config.ts");
330
+ register(log, config, server ? APP_SERVER_HEADER : APP_HEADER, {
331
+ importLine: `import { ${name} } from '${importPath(config, file)}';`,
332
+ entry
333
+ });
334
+ }
335
+ function ensureAuthEnv(log, root) {
336
+ const file = join(root, "src/config/env.ts");
337
+ if (writeOnce(log, file, authEnvConfig())) return;
338
+ let text = readFileSync2(file, "utf8");
339
+ if (/export\s+const\s+auth\b/.test(text)) return;
340
+ if (!/\bpublicEnv\b/.test(text)) text = addImport(text, `import { publicEnv } from '@fluixi/start/env';`);
341
+ text = addImport(text, `import { authFromEnv } from '@fluixi/oauth2/env';`);
342
+ writeFileSync2(file, `${text.replace(/\n*$/, "\n")}
343
+ ${AUTH_READER}`);
344
+ log.push({ kind: "updated", file, detail: "auth" });
345
+ }
346
+ function resolveTokens(provider, tokens) {
347
+ const refusal = SERVER_ONLY[provider];
348
+ if (refusal && tokens === "browser") throw new ScaffoldError(`${provider} needs server tokens: ${refusal}`);
349
+ return { tokens: tokens ?? (refusal ? "server" : "browser"), forced: !!refusal && !tokens };
350
+ }
351
+ function setupOAuth2(root, options, dependencySpec = "alpha") {
352
+ const provider = options.provider ?? "oidc";
353
+ if (!PROVIDERS.includes(provider)) throw new ScaffoldError(`unknown provider ${provider}: ${PROVIDERS.join(", ")}`);
354
+ const { tokens, forced } = resolveTokens(provider, options.tokens);
355
+ const html = options.format === "html";
356
+ const ext = html ? "ts" : "tsx";
357
+ const steps = [];
358
+ const at = (rel2) => join(root, rel2);
359
+ ensureAuthEnv(steps, root);
360
+ const service = at("src/services/auth.ts");
361
+ if (writeOnce(steps, service, authServiceFile(tokens))) registerModule(steps, root, false, service, "AuthModule");
362
+ if (tokens === "browser") writeOnce(steps, at(`src/routes/callback.${ext}`), callbackRoute(html));
363
+ writeOnce(steps, at(`src/routes/login.${ext}`), loginRoute(html));
364
+ if (tokens === "server") {
365
+ const server = at("src/services/auth.server.ts");
366
+ if (writeOnce(steps, server, authServerFile())) registerModule(steps, root, true, server, "authServer", "authServer()");
367
+ }
368
+ const vars = {
369
+ AUTH_PROVIDER: provider,
370
+ AUTH_CLIENT_ID: "my-app",
371
+ AUTH_TOKENS: tokens,
372
+ ...PROVIDER_VARS[provider],
373
+ APP_URL: "http://localhost:3000"
374
+ };
375
+ addEnvPublic(steps, at("fluixi.config.ts"), Object.keys(vars));
376
+ addEnvExample(steps, at(".env.example"), `@fluixi/oauth2 (${provider}). Listed in env.public: the browser sees them.`, vars);
377
+ if (tokens === "server") {
378
+ addEnvExample(steps, at(".env.example"), "Server only, never in env.public. SESSION_SECRET: 32 bytes or more.", {
379
+ OIDC_CLIENT_SECRET: "",
380
+ SESSION_SECRET: ""
381
+ });
382
+ }
383
+ const redirect = tokens === "browser" ? "/callback" : "/auth/callback";
384
+ return {
385
+ steps,
386
+ dependencies: { "@fluixi/oauth2": dependencySpec },
387
+ next: ["copy .env.example to .env and fill it in", `register <APP_URL>${redirect} as the redirect URI at the provider`],
388
+ notes: forced ? [`${provider} refuses a public client, so the tokens stay on the server`] : []
389
+ };
390
+ }
391
+ function setupBetterAuth(root, dependencySpec = "alpha") {
392
+ const steps = [];
393
+ const at = (rel2) => join(root, rel2);
394
+ writeOnce(steps, at("src/auth.config.ts"), betterAuthConfig());
395
+ writeOnce(steps, at("src/auth.client.ts"), betterAuthClient());
396
+ if (!writeOnce(steps, at("src/middleware.ts"), betterAuthMiddleware())) {
397
+ steps.push({
398
+ kind: "hint",
399
+ file: at("src/middleware.ts"),
400
+ lines: [`import { authMiddleware } from '@fluixi/auth/server';`, `import './auth.config.js';`, "authMiddleware() in the middleware list"]
401
+ });
402
+ }
403
+ writeOnce(steps, at("src/api/auth/[...all].ts"), betterAuthRoute());
404
+ return {
405
+ steps,
406
+ dependencies: { "@fluixi/auth": dependencySpec, "better-auth": "^1" },
407
+ next: ["set your database adapter in src/auth.config.ts"],
408
+ notes: []
409
+ };
410
+ }
411
+
412
+ // src/scaffold/prompt.ts
413
+ import { createInterface } from "node:readline/promises";
414
+ import { emitKeypressEvents } from "node:readline";
415
+ import { stdin as input, stdout as output, exit, env } from "node:process";
416
+ var COLOR = env.NO_COLOR || env.FORCE_COLOR === "0" ? false : (!!output.isTTY || !!env.FORCE_COLOR) && env.TERM !== "dumb";
417
+ var sgr = (o, x) => (s) => COLOR ? `\x1B[${o}m${s}\x1B[${x}m` : `${s}`;
418
+ var c2 = { bold: sgr(1, 22), green: sgr(32, 39), cyan: sgr(36, 39), gray: sgr(90, 39) };
419
+ var BAR = c2.gray("│");
420
+ function askSelect(label, choices) {
421
+ return new Promise((done) => {
422
+ let idx = 0;
423
+ output.write(`${c2.green("◆")} ${c2.bold(label)}
424
+ `);
425
+ const draw = (first) => {
426
+ if (!first) output.write(`\x1B[${choices.length}A`);
427
+ for (let i = 0; i < choices.length; i++) {
428
+ const on = i === idx;
429
+ const dot = on ? c2.green("●") : c2.gray("○");
430
+ const name = on ? c2.cyan(c2.bold(choices[i].label)) : choices[i].label;
431
+ output.write(`\x1B[2K${BAR} ${dot} ${name} ${c2.gray(choices[i].hint)}
432
+ `);
433
+ }
434
+ };
435
+ draw(true);
436
+ emitKeypressEvents(input);
437
+ if (input.isTTY) input.setRawMode(true);
438
+ const onKey = (_s, k) => {
439
+ if (k.name === "up" || k.name === "k") {
440
+ idx = (idx - 1 + choices.length) % choices.length;
441
+ draw(false);
442
+ } else if (k.name === "down" || k.name === "j") {
443
+ idx = (idx + 1) % choices.length;
444
+ draw(false);
445
+ } else if (k.name === "return" || k.name === "enter") {
446
+ finish();
447
+ done(choices[idx].value);
448
+ } else if (k.name === "c" && k.ctrl) {
449
+ finish();
450
+ output.write("\n");
451
+ exit(1);
452
+ }
453
+ };
454
+ const onEnd = () => {
455
+ finish();
456
+ done(choices[idx].value);
457
+ };
458
+ const finish = () => {
459
+ input.off("keypress", onKey);
460
+ input.off("end", onEnd);
461
+ input.off("close", onEnd);
462
+ if (input.isTTY) input.setRawMode(false);
463
+ input.pause();
464
+ output.write(`${BAR}
465
+ `);
466
+ };
467
+ if (input.readableEnded) {
468
+ finish();
469
+ done(choices[idx].value);
470
+ return;
471
+ }
472
+ input.on("keypress", onKey);
473
+ input.on("end", onEnd);
474
+ input.on("close", onEnd);
475
+ input.resume();
476
+ });
477
+ }
478
+
479
+ // src/add.ts
480
+ var ADD_USAGE = `Usage: fluixi add <feature> [options]
481
+
482
+ Features:
483
+ auth [oauth2|better-auth] authentication, asked for when not given
484
+ --provider <p> ${PROVIDERS.join(", ")}
485
+ --tokens browser|server where an OAuth2 app keeps its tokens
486
+ --no-install write the files, leave the packages to you
487
+ `;
488
+ function option(args, name) {
489
+ const i = args.indexOf(`--${name}`);
490
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith("-")) return args[i + 1];
491
+ return args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
492
+ }
493
+ function installCommand(root, specs) {
494
+ if (existsSync3(join2(root, "pnpm-lock.yaml"))) return ["pnpm", ["add", ...specs]];
495
+ if (existsSync3(join2(root, "yarn.lock"))) return ["yarn", ["add", ...specs]];
496
+ if (existsSync3(join2(root, "bun.lockb")) || existsSync3(join2(root, "bun.lock"))) return ["bun", ["add", ...specs]];
497
+ return ["npm", ["install", ...specs]];
498
+ }
499
+ function channelOf(root) {
500
+ try {
501
+ const version = JSON.parse(readFileSync3(join2(root, "node_modules/@fluixi/start/package.json"), "utf8")).version;
502
+ return version.match(/-([a-z]+)\./)?.[1] ?? "latest";
503
+ } catch {
504
+ return "alpha";
505
+ }
506
+ }
507
+ async function addAuth(root, args) {
508
+ const ask = stdin.isTTY;
509
+ let kind = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
510
+ let provider = option(args, "provider");
511
+ let tokens = option(args, "tokens");
512
+ if (kind && kind !== "oauth2" && kind !== "better-auth") throw new ScaffoldError(`unknown auth kind ${kind}: oauth2 or better-auth`);
513
+ if (tokens && tokens !== "browser" && tokens !== "server") throw new ScaffoldError("--tokens is browser or server");
514
+ if (provider && !PROVIDERS.includes(provider)) throw new ScaffoldError(`unknown provider ${provider}: ${PROVIDERS.join(", ")}`);
515
+ if (!kind && ask) {
516
+ kind = await askSelect("Authentication", [
517
+ { value: "oauth2", label: "OAuth2 / OIDC", hint: "@fluixi/oauth2 · Keycloak, Auth0, Okta, Google…" },
518
+ { value: "better-auth", label: "Better Auth", hint: "@fluixi/auth · your own user table" }
519
+ ]);
520
+ }
521
+ kind ??= "oauth2";
522
+ if (kind === "oauth2" && !provider && ask) {
523
+ provider = await askSelect("Identity provider", PROVIDERS.map((p) => ({ value: p, label: p, hint: "" })));
524
+ }
525
+ if (kind === "oauth2" && !tokens && ask && !SERVER_ONLY[provider ?? "oidc"]) {
526
+ tokens = await askSelect("Where the tokens live", [
527
+ { value: "browser", label: "Browser", hint: "public client · PKCE" },
528
+ { value: "server", label: "Server", hint: "httpOnly cookie · client secret" }
529
+ ]);
530
+ }
531
+ const channel = channelOf(root);
532
+ const result = kind === "oauth2" ? setupOAuth2(root, { provider, tokens, format: projectFormat(root) }, channel) : setupBetterAuth(root, channel);
533
+ const specs = Object.entries(result.dependencies).map(([name, spec]) => `${name}@${spec}`);
534
+ if (args.includes("--no-install")) {
535
+ reportAuth(result, `install ${specs.join(" ")}`);
536
+ return;
537
+ }
538
+ reportAuth(result);
539
+ const [bin, binArgs] = installCommand(root, specs);
540
+ console.log(` ${c3.gray("›")} ${c3.cyan([bin, ...binArgs].join(" "))}`);
541
+ execFileSync(bin, binArgs, { cwd: root, stdio: "inherit" });
542
+ }
543
+ async function add(feature, args, root = process.cwd()) {
544
+ if (feature !== "auth") {
545
+ console.error(ADD_USAGE);
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+ try {
550
+ await addAuth(root, args);
551
+ } catch (error) {
552
+ if (!(error instanceof ScaffoldError)) throw error;
553
+ console.error(` ${c3.red("✗")} ${error.message}`);
554
+ process.exitCode = 1;
555
+ }
556
+ }
557
+ export {
558
+ ADD_USAGE,
559
+ add,
560
+ channelOf,
561
+ installCommand
562
+ };