@nakedev/nextjs-fsd 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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/bin/nextjs-fsd.js +2 -0
  4. package/dist/commands/add.js +182 -0
  5. package/dist/commands/config.js +53 -0
  6. package/dist/commands/generate.js +403 -0
  7. package/dist/commands/init.js +229 -0
  8. package/dist/index.js +291 -0
  9. package/dist/prompts.js +38 -0
  10. package/dist/types.js +5 -0
  11. package/dist/utils/config.js +86 -0
  12. package/dist/utils/copy.js +87 -0
  13. package/dist/utils/naming.js +76 -0
  14. package/dist/utils/project.js +288 -0
  15. package/dist/utils/render.js +66 -0
  16. package/dist/utils/version.js +23 -0
  17. package/package.json +66 -0
  18. package/templates/add/auth/auth-errors.ts.hbs +22 -0
  19. package/templates/add/auth/index.ts.hbs +4 -0
  20. package/templates/add/auth/login-form.tsx.hbs +48 -0
  21. package/templates/add/auth/login-index.ts.hbs +1 -0
  22. package/templates/add/auth/login-page.tsx.hbs +18 -0
  23. package/templates/add/auth/require-session.ts.hbs +29 -0
  24. package/templates/add/auth/session.ts.hbs +75 -0
  25. package/templates/add/errors/access-token.ts.hbs +19 -0
  26. package/templates/add/errors/api-error.ts.hbs +69 -0
  27. package/templates/add/errors/client.test.ts.hbs +61 -0
  28. package/templates/add/errors/client.ts.hbs +91 -0
  29. package/templates/add/errors/config-index.ts.hbs +1 -0
  30. package/templates/add/errors/env.ts.hbs +3 -0
  31. package/templates/add/errors/error-catalog.ts.hbs +19 -0
  32. package/templates/add/errors/error-resolver.ts.hbs +30 -0
  33. package/templates/add/errors/form-error.tsx.hbs +50 -0
  34. package/templates/add/errors/index.ts.hbs +5 -0
  35. package/templates/add/errors/providers.tsx.hbs +14 -0
  36. package/templates/add/errors/query-client.ts.hbs +25 -0
  37. package/templates/generate/layout/layout.tsx.hbs +20 -0
  38. package/templates/generate/layout/route.tsx.hbs +1 -0
  39. package/templates/generate/page/content.tsx.hbs +17 -0
  40. package/templates/generate/page/errors.ts.hbs +22 -0
  41. package/templates/generate/page/index.ts.hbs +1 -0
  42. package/templates/generate/page/page.tsx.hbs +22 -0
  43. package/templates/generate/page/route.tsx.hbs +3 -0
  44. package/templates/generate/slice/api.ts.hbs +42 -0
  45. package/templates/generate/slice/errors.ts.hbs +22 -0
  46. package/templates/generate/slice/index.ts.hbs +15 -0
  47. package/templates/generate/slice/lib.ts.hbs +4 -0
  48. package/templates/generate/slice/model.ts.hbs +10 -0
  49. package/templates/generate/slice/ui.tsx.hbs +17 -0
  50. package/templates/init/agents-section.md.hbs +25 -0
  51. package/templates/init/claude.md.hbs +1 -0
  52. package/templates/init/components.json.hbs +21 -0
  53. package/templates/init/eslint.fsd.mjs.hbs +91 -0
  54. package/templates/init/fsd.md.hbs +133 -0
  55. package/templates/init/globals.css.hbs +31 -0
  56. package/templates/init/skill.md.hbs +167 -0
  57. package/templates/init/steiger.config.ts.hbs +28 -0
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.packageJsonPath = packageJsonPath;
7
+ exports.readPackageJson = readPackageJson;
8
+ exports.detectPackageManager = detectPackageManager;
9
+ exports.detectAppDir = detectAppDir;
10
+ exports.addDependencies = addDependencies;
11
+ exports.installDependencies = installDependencies;
12
+ exports.runCommand = runCommand;
13
+ exports.patchTsconfigPaths = patchTsconfigPaths;
14
+ exports.appendScript = appendScript;
15
+ exports.appendExport = appendExport;
16
+ exports.appendEnvExample = appendEnvExample;
17
+ exports.patchLayoutStyleImport = patchLayoutStyleImport;
18
+ exports.patchLayoutProviders = patchLayoutProviders;
19
+ exports.patchEslintConfig = patchEslintConfig;
20
+ const path_1 = __importDefault(require("path"));
21
+ const fs_extra_1 = __importDefault(require("fs-extra"));
22
+ const child_process_1 = require("child_process");
23
+ const picocolors_1 = __importDefault(require("picocolors"));
24
+ function packageJsonPath(projectDir) {
25
+ return path_1.default.join(projectDir, "package.json");
26
+ }
27
+ function readPackageJson(projectDir) {
28
+ const file = packageJsonPath(projectDir);
29
+ if (!fs_extra_1.default.existsSync(file)) {
30
+ throw new Error(`no package.json in ${projectDir} — run this inside a Next.js project`);
31
+ }
32
+ return fs_extra_1.default.readJsonSync(file);
33
+ }
34
+ // Lockfile, not the `packageManager` field: the field is often absent and the
35
+ // lockfile is what actually decided which client installed node_modules.
36
+ function detectPackageManager(projectDir) {
37
+ const lockfiles = [
38
+ ["bun.lock", "bun"],
39
+ ["bun.lockb", "bun"],
40
+ ["pnpm-lock.yaml", "pnpm"],
41
+ ["yarn.lock", "yarn"],
42
+ ["package-lock.json", "npm"],
43
+ ];
44
+ for (const [lockfile, manager] of lockfiles) {
45
+ if (fs_extra_1.default.existsSync(path_1.default.join(projectDir, lockfile)))
46
+ return manager;
47
+ }
48
+ return "npm";
49
+ }
50
+ /**
51
+ * Locates the Next.js App Router directory.
52
+ *
53
+ * Both layouts create-next-app can produce are supported as-is: `app/` at the
54
+ * root, and `src/app/` when "use src directory" was chosen. FSD's own app
55
+ * layer is `src/_app`, so it never collides with either — which is why this
56
+ * detects rather than moves anything.
57
+ */
58
+ function detectAppDir(projectDir) {
59
+ const pkg = readPackageJson(projectDir);
60
+ const hasNext = Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
61
+ if (!hasNext) {
62
+ throw new Error("this package.json has no `next` dependency — create the app first (`bunx create-next-app@latest`), then run `nextjs-fsd init` inside it");
63
+ }
64
+ // Posix separators, deliberately, even on Windows: this value is not only a
65
+ // path. It is interpolated into ESLint `files` globs, Tailwind `@source`
66
+ // lines and the generated docs, and a glob with a backslash matches nothing
67
+ // — so a project initialised on Windows would silently lose the rule that
68
+ // keeps features out of the routing layer. `path.join` normalises it back
69
+ // for the filesystem calls that need it.
70
+ for (const candidate of ["app", "src/app"]) {
71
+ if (fs_extra_1.default.existsSync(path_1.default.join(projectDir, candidate, "layout.tsx")))
72
+ return candidate;
73
+ }
74
+ throw new Error("no App Router layout found at app/layout.tsx or src/app/layout.tsx — this CLI only supports the App Router, not the legacy pages/ router");
75
+ }
76
+ /**
77
+ * Adds dependencies that are missing, leaving any already-declared version
78
+ * alone — a project pinned to axios ^1.5 should not get silently bumped
79
+ * because a template happened to be written against a newer one.
80
+ * Returns the names actually added.
81
+ */
82
+ function addDependencies(projectDir, deps, kind = "dependencies") {
83
+ const file = packageJsonPath(projectDir);
84
+ const pkg = fs_extra_1.default.readJsonSync(file);
85
+ const target = { ...(pkg[kind] ?? {}) };
86
+ const declared = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
87
+ const added = [];
88
+ for (const [name, range] of Object.entries(deps)) {
89
+ if (declared[name])
90
+ continue;
91
+ target[name] = range;
92
+ added.push(name);
93
+ }
94
+ if (added.length === 0)
95
+ return [];
96
+ // Sorted so a diff of package.json stays reviewable instead of appending in
97
+ // whatever order a template listed its dependencies.
98
+ pkg[kind] = Object.fromEntries(Object.entries(target).sort(([a], [b]) => a.localeCompare(b)));
99
+ fs_extra_1.default.writeJsonSync(file, pkg, { spaces: 2 });
100
+ return added;
101
+ }
102
+ function installDependencies(projectDir, manager) {
103
+ const command = manager === "npm" ? ["npm", "install"] : [manager, "install"];
104
+ console.log(picocolors_1.default.dim(`> ${command.join(" ")}`));
105
+ (0, child_process_1.execFileSync)(command[0], command.slice(1), { cwd: projectDir, stdio: "inherit" });
106
+ }
107
+ function runCommand(projectDir, manager, args) {
108
+ const runner = manager === "npm" ? "npx" : manager === "yarn" ? "yarn" : manager === "pnpm" ? "pnpm" : "bunx";
109
+ console.log(picocolors_1.default.dim(`> ${runner} ${args.join(" ")}`));
110
+ (0, child_process_1.execFileSync)(runner, args, { cwd: projectDir, stdio: "inherit" });
111
+ }
112
+ /**
113
+ * Puts `srcDir` first in the tsconfig `alias/*` path, keeping whatever was
114
+ * mapped there as a fallback.
115
+ *
116
+ * create-next-app maps `@/*` to `./*` (the project root) when it does not use
117
+ * a src directory, and TypeScript tries a paths array in order — so prepending
118
+ * makes `@/_pages/login` resolve while an existing `@/app/thing` import in the
119
+ * project keeps resolving too. Replacing the array outright would break those
120
+ * silently, in files this CLI never looked at.
121
+ *
122
+ * Returns false if srcDir was already first.
123
+ */
124
+ function patchTsconfigPaths(projectDir, alias, srcDir) {
125
+ const file = path_1.default.join(projectDir, "tsconfig.json");
126
+ if (!fs_extra_1.default.existsSync(file))
127
+ throw new Error("no tsconfig.json — expected one in a Next.js TypeScript project");
128
+ // Read as text and parse leniently: create-next-app writes strict JSON, but
129
+ // a hand-edited tsconfig with comments is normal and JSON5 is not worth a
130
+ // dependency here. A parse failure is reported, not swallowed.
131
+ let config;
132
+ try {
133
+ config = fs_extra_1.default.readJsonSync(file);
134
+ }
135
+ catch {
136
+ throw new Error("could not parse tsconfig.json as JSON (comments?) — add this by hand instead:\n" +
137
+ ` "paths": { "${alias}/*": ["./${srcDir}/*"] }`);
138
+ }
139
+ const key = `${alias}/*`;
140
+ const first = `./${srcDir}/*`;
141
+ const options = (config.compilerOptions ??= {});
142
+ const paths = (options.paths ??= {});
143
+ const existing = Array.isArray(paths[key]) ? paths[key] : [];
144
+ if (existing[0] === first)
145
+ return false;
146
+ paths[key] = [first, ...existing.filter((entry) => entry !== first)];
147
+ fs_extra_1.default.writeJsonSync(file, config, { spaces: 2 });
148
+ return true;
149
+ }
150
+ /**
151
+ * Adds a step to an npm script, or creates it. Appended with `&&` rather than
152
+ * replaced: `lint` already runs eslint in a fresh Next.js project and both
153
+ * checks matter.
154
+ */
155
+ function appendScript(projectDir, name, step) {
156
+ const file = packageJsonPath(projectDir);
157
+ const pkg = fs_extra_1.default.readJsonSync(file);
158
+ const scripts = (pkg.scripts ??= {});
159
+ const existing = scripts[name];
160
+ if (existing?.includes(step))
161
+ return false;
162
+ scripts[name] = existing ? `${existing} && ${step}` : step;
163
+ fs_extra_1.default.writeJsonSync(file, pkg, { spaces: 2 });
164
+ return true;
165
+ }
166
+ /** Appends an export line to a barrel, creating it if absent. */
167
+ function appendExport(projectDir, barrel, line) {
168
+ const file = path_1.default.join(projectDir, barrel);
169
+ if (!fs_extra_1.default.existsSync(file)) {
170
+ fs_extra_1.default.ensureDirSync(path_1.default.dirname(file));
171
+ fs_extra_1.default.writeFileSync(file, `${line}\n`);
172
+ return true;
173
+ }
174
+ const current = fs_extra_1.default.readFileSync(file, "utf8");
175
+ if (current.includes(line))
176
+ return false;
177
+ fs_extra_1.default.writeFileSync(file, current.replace(/\n*$/, "\n") + `${line}\n`);
178
+ return true;
179
+ }
180
+ /** Appends a block to .env.example, skipping it if the key is already there. */
181
+ function appendEnvExample(projectDir, key, block) {
182
+ const file = path_1.default.join(projectDir, ".env.example");
183
+ const current = fs_extra_1.default.existsSync(file) ? fs_extra_1.default.readFileSync(file, "utf8") : "";
184
+ if (current.includes(key))
185
+ return false;
186
+ fs_extra_1.default.writeFileSync(file, current === "" ? block : current.replace(/\n*$/, "\n\n") + block);
187
+ return true;
188
+ }
189
+ /**
190
+ * Repoints the root layout's stylesheet import at the FSD app layer.
191
+ *
192
+ * The CSS file moves because Tailwind v4's `@theme` is project-wide
193
+ * configuration, which is app-layer, not route-adjacent — and the import in
194
+ * layout.tsx is the only reference to its old path.
195
+ */
196
+ function patchLayoutStyleImport(projectDir, appDir, alias) {
197
+ const file = path_1.default.join(projectDir, appDir, "layout.tsx");
198
+ const source = fs_extra_1.default.readFileSync(file, "utf8");
199
+ const target = `import "${alias}/_app/styles/globals.css";`;
200
+ if (source.includes(target))
201
+ return false;
202
+ const patched = source.replace(/^import\s+["']\.\/globals\.css["'];?[ \t]*$/m, target);
203
+ if (patched === source)
204
+ return false;
205
+ fs_extra_1.default.writeFileSync(file, patched);
206
+ return true;
207
+ }
208
+ /**
209
+ * Wraps the root layout's JSX `{children}` in `<Providers>`.
210
+ *
211
+ * Anchored on `<body`, because `{ children }` appears twice in every App
212
+ * Router layout and the first one is the destructured parameter —
213
+ * `RootLayout({ children }: LayoutProps<"/">)`. Patching that one produces
214
+ * `RootLayout(<Providers>{children}</Providers>: ...)`, a file that no longer
215
+ * parses, which is why the search starts after the opening body tag instead of
216
+ * at the top of the file.
217
+ *
218
+ * Regex, not an AST: this runs once on a file whose shape create-next-app
219
+ * fixes, and every way it can miss — already wrapped, no <body>, no children —
220
+ * returns without touching the file so the caller can print instructions.
221
+ */
222
+ function patchLayoutProviders(projectDir, appDir, alias) {
223
+ const file = path_1.default.join(projectDir, appDir, "layout.tsx");
224
+ const source = fs_extra_1.default.readFileSync(file, "utf8");
225
+ if (/<Providers[\s>]/.test(source))
226
+ return "already";
227
+ const bodyAt = source.indexOf("<body");
228
+ if (bodyAt === -1)
229
+ return "manual";
230
+ const children = /\{\s*children\s*\}/.exec(source.slice(bodyAt));
231
+ if (!children)
232
+ return "manual";
233
+ const imports = [...source.matchAll(/^import .*$/gm)];
234
+ const lastImport = imports[imports.length - 1];
235
+ // index 0 is a real position: a layout whose first line is an import.
236
+ if (lastImport?.index === undefined)
237
+ return "manual";
238
+ const importAt = source.indexOf("\n", lastImport.index) + 1;
239
+ // Children first, then the import: both edits are index-based, and the
240
+ // import sits earlier in the file, so doing the later one first keeps the
241
+ // earlier offset valid.
242
+ const childrenAt = bodyAt + children.index;
243
+ const withProvider = source.slice(0, childrenAt) +
244
+ "<Providers>" +
245
+ children[0] +
246
+ "</Providers>" +
247
+ source.slice(childrenAt + children[0].length);
248
+ const importLine = `import { Providers } from "${alias}/_app/providers";\n`;
249
+ fs_extra_1.default.writeFileSync(file, withProvider.slice(0, importAt) + importLine + withProvider.slice(importAt));
250
+ return "patched";
251
+ }
252
+ const ESLINT_CONFIG_FILES = ["eslint.config.mjs", "eslint.config.js", "eslint.config.ts", "eslint.config.cjs"];
253
+ /**
254
+ * Spreads the generated FSD boundary config into the project's flat ESLint
255
+ * config.
256
+ *
257
+ * A separate `eslint.fsd.mjs` plus a two-line patch, rather than injecting the
258
+ * rules into the array literal: create-next-app's config is
259
+ * `export default eslintConfig;` over a `defineConfig([...])` call, and
260
+ * splicing rules into that call means parsing JS to find the right closing
261
+ * bracket. Two anchors — the last import, and the default export — are all
262
+ * this needs, and a config someone has restructured falls through to printed
263
+ * instructions instead of a wrong edit.
264
+ */
265
+ function patchEslintConfig(projectDir) {
266
+ const file = ESLINT_CONFIG_FILES.map((name) => path_1.default.join(projectDir, name)).find((candidate) => fs_extra_1.default.existsSync(candidate));
267
+ if (!file)
268
+ return "missing";
269
+ const source = fs_extra_1.default.readFileSync(file, "utf8");
270
+ if (source.includes("eslint.fsd.mjs"))
271
+ return "already";
272
+ // `export default <identifier>;` is the shape every create-next-app config
273
+ // has had. An inline array or call expression is left alone.
274
+ const exported = /^export default (\w+);?[ \t]*$/m.exec(source);
275
+ const imports = [...source.matchAll(/^import .*$/gm)];
276
+ const lastImport = imports[imports.length - 1];
277
+ if (!exported || lastImport?.index === undefined)
278
+ return "manual";
279
+ // The export sits after the imports, so replacing it first keeps the
280
+ // import offset computed from the original source valid.
281
+ const importAt = source.indexOf("\n", lastImport.index) + 1;
282
+ // A named const rather than `export default [...]`: eslint-config-next
283
+ // warns on an anonymous default export, and init should not hand someone a
284
+ // config file that lints with a warning.
285
+ const withExport = source.replace(exported[0], `const fsdEslintConfig = [...${exported[1]}, ...fsdBoundary];\nexport default fsdEslintConfig;`);
286
+ fs_extra_1.default.writeFileSync(file, withExport.slice(0, importAt) + 'import fsdBoundary from "./eslint.fsd.mjs";\n' + withExport.slice(importAt));
287
+ return "patched";
288
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getTemplatesRoot = getTemplatesRoot;
7
+ exports.renderString = renderString;
8
+ exports.renderTemplate = renderTemplate;
9
+ exports.applyTemplates = applyTemplates;
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const fs_extra_1 = __importDefault(require("fs-extra"));
13
+ const handlebars_1 = __importDefault(require("handlebars"));
14
+ handlebars_1.default.registerHelper("eq", (a, b) => a === b);
15
+ handlebars_1.default.registerHelper("includes", (list, value) => Array.isArray(list) && list.includes(value));
16
+ function getTemplatesRoot() {
17
+ const candidates = [
18
+ path_1.default.join(__dirname, "..", "..", "templates"),
19
+ path_1.default.join(__dirname, "..", "..", "..", "templates"),
20
+ ];
21
+ const resolved = candidates.find((candidate) => fs_1.default.existsSync(path_1.default.join(candidate, "init", "steiger.config.ts.hbs")));
22
+ if (!resolved)
23
+ throw new Error("unable to locate templates directory");
24
+ return resolved;
25
+ }
26
+ function renderString(source, context) {
27
+ return handlebars_1.default.compile(source, { noEscape: true })(context);
28
+ }
29
+ function renderTemplate(template, context) {
30
+ const source = fs_1.default.readFileSync(path_1.default.join(getTemplatesRoot(), template), "utf8");
31
+ return renderString(source, context);
32
+ }
33
+ /**
34
+ * Renders a set of templates, refusing the whole batch if any output already
35
+ * exists.
36
+ *
37
+ * All-or-nothing on purpose: a partial write leaves a slice with two of its
38
+ * four files rendered against a name the other two never saw, and the second
39
+ * run then refuses because of the files the first run made. Reporting every
40
+ * collision up front also means one message instead of one per re-run.
41
+ */
42
+ async function applyTemplates(projectRoot, entries, context, opts = {}) {
43
+ const root = getTemplatesRoot();
44
+ let planned = entries.filter((entry) => !entry.when || entry.when(context));
45
+ const exists = (entry) => fs_extra_1.default.existsSync(path_1.default.join(projectRoot, entry.output));
46
+ if (opts.skipExisting) {
47
+ planned = planned.filter((entry) => entry.overwrite || !exists(entry));
48
+ }
49
+ else {
50
+ const collisions = planned.filter((entry) => !entry.overwrite && exists(entry)).map((entry) => entry.output);
51
+ if (collisions.length > 0) {
52
+ throw new Error(`refusing to overwrite existing file${collisions.length > 1 ? "s" : ""}:\n` +
53
+ collisions.map((file) => ` ${file}`).join("\n") +
54
+ "\nDelete them first, or generate under a different name.");
55
+ }
56
+ }
57
+ const written = [];
58
+ for (const entry of planned) {
59
+ const source = await fs_extra_1.default.readFile(path_1.default.join(root, entry.template), "utf8");
60
+ const outputPath = path_1.default.join(projectRoot, entry.output);
61
+ await fs_extra_1.default.ensureDir(path_1.default.dirname(outputPath));
62
+ await fs_extra_1.default.writeFile(outputPath, renderString(source, context));
63
+ written.push(entry.output);
64
+ }
65
+ return written;
66
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cliVersion = cliVersion;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ // cliVersion reads this CLI's own version out of its package.json — one source
11
+ // of truth for `--version` and for the stamp written into a project's
12
+ // nextjs-fsd.config.json. Two candidates because __dirname is dist/utils when
13
+ // installed and src/utils when running from a checkout.
14
+ function cliVersion() {
15
+ const candidates = [
16
+ path_1.default.join(__dirname, "..", "..", "package.json"),
17
+ path_1.default.join(__dirname, "..", "..", "..", "package.json"),
18
+ ];
19
+ const resolved = candidates.find((candidate) => fs_1.default.existsSync(candidate));
20
+ if (!resolved)
21
+ throw new Error("unable to locate package.json");
22
+ return fs_extra_1.default.readJsonSync(resolved).version;
23
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@nakedev/nextjs-fsd",
3
+ "version": "0.1.0",
4
+ "description": "Keep a Next.js App Router project on Feature-Sliced Design: init the layout, generate slices, add auth and API error handling",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/NakePranob/nextjs-fsd.git"
8
+ },
9
+ "bin": {
10
+ "nextjs-fsd": "bin/nextjs-fsd.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "templates",
15
+ "bin",
16
+ "LICENSE"
17
+ ],
18
+ "type": "commonjs",
19
+ "packageManager": "pnpm@11.10.0",
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "test": "node --test tests/*.test.mjs && node scripts/smoke-test.mjs",
24
+ "verify": "pnpm run build && pnpm run test",
25
+ "prepack": "rm -rf dist && pnpm run build",
26
+ "test:smoke": "node scripts/smoke-test.mjs",
27
+ "test:integration": "node scripts/integration-test.mjs",
28
+ "prepublishOnly": "pnpm run verify"
29
+ },
30
+ "keywords": [
31
+ "nextjs",
32
+ "feature-sliced",
33
+ "fsd",
34
+ "scaffold",
35
+ "cli",
36
+ "generator"
37
+ ],
38
+ "author": "nakedev",
39
+ "license": "MIT",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@inquirer/prompts": "^7.5.1",
45
+ "commander": "^15.0.0",
46
+ "fs-extra": "^11.3.0",
47
+ "handlebars": "^4.7.8",
48
+ "picocolors": "^1.1.1"
49
+ },
50
+ "devDependencies": {
51
+ "@tanstack/react-query": "^5.101.4",
52
+ "@types/bun": "^1.3.14",
53
+ "@types/fs-extra": "^11.0.4",
54
+ "@types/node": "^24.0.0",
55
+ "@types/react": "^19",
56
+ "@types/react-dom": "^19",
57
+ "axios": "^1.19.0",
58
+ "next": "16.3.4",
59
+ "react": "19.2.8",
60
+ "react-dom": "19.2.8",
61
+ "typescript": "^5.7.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=20.9"
65
+ }
66
+ }
@@ -0,0 +1,22 @@
1
+ import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
2
+
3
+ /**
4
+ * Codes the auth surface owns. TODO: match the codes your API actually raises
5
+ * — these are the common ones, and an unmapped code falls through to the
6
+ * caller's fallback sentence rather than breaking anything.
7
+ */
8
+ export const authErrorCatalog: ErrorCatalog = {
9
+ {{authCatalogEntries}}
10
+ };
11
+
12
+ /**
13
+ * Pass this to `<FormError catalogs={...}>` on an auth screen. Auth first,
14
+ * then the codes every endpoint shares — a screen that needs to reword one
15
+ * code puts its own catalog in front.
16
+ */
17
+ export const authErrorCatalogs: readonly ErrorCatalog[] = [authErrorCatalog, commonErrorCatalog];
18
+
19
+ /** For auth copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
20
+ export function resolveAuthError(error: unknown, fallback = "{{copy.genericError}}"): string {
21
+ return resolveApiError(error, { catalogs: authErrorCatalogs, fallback });
22
+ }
@@ -0,0 +1,4 @@
1
+ export { getAccessToken, setAccessToken } from "./access-token";
2
+ export { authErrorCatalog, authErrorCatalogs, resolveAuthError } from "./auth-errors";
3
+ export { sessionKey, useLogin, useLogout, useSession, type LoginInput, type Session } from "./session";
4
+ export { useRequireSession } from "./require-session";
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { type FormEvent } from "react";
5
+
6
+ import { authErrorCatalogs, useLogin } from "{{alias}}/shared/auth";
7
+ import { FormError } from "{{alias}}/shared/ui";
8
+
9
+ const field = "rounded-md border border-black/15 px-3 py-2 text-sm dark:border-white/20";
10
+
11
+ export function LoginForm() {
12
+ const login = useLogin();
13
+ const router = useRouter();
14
+
15
+ function onSubmit(event: FormEvent<HTMLFormElement>) {
16
+ event.preventDefault();
17
+ const form = new FormData(event.currentTarget);
18
+ login.mutate(
19
+ { email: String(form.get("email") ?? ""), password: String(form.get("password") ?? "") },
20
+ { onSuccess: () => router.replace("/") },
21
+ );
22
+ }
23
+
24
+ return (
25
+ // Native HTML validation only: `required`/`type="email"` catch the obvious
26
+ // mistakes before a round trip, and the API re-validates every field
27
+ // anyway. Swap in a schema library once a form here has rules the browser
28
+ // cannot express.
29
+ <form onSubmit={onSubmit} className="flex flex-col gap-4">
30
+ <FormError error={login.error} catalogs={authErrorCatalogs} fallback="{{copy.signInFailed}}" />
31
+ <label className="flex flex-col gap-1.5 text-sm">
32
+ {{copy.email}}
33
+ <input name="email" type="email" required autoComplete="email" className={field} />
34
+ </label>
35
+ <label className="flex flex-col gap-1.5 text-sm">
36
+ {{copy.password}}
37
+ <input name="password" type="password" required autoComplete="current-password" className={field} />
38
+ </label>
39
+ <button
40
+ type="submit"
41
+ disabled={login.isPending}
42
+ className="mt-2 rounded-md bg-foreground px-3 py-2 text-sm text-background disabled:opacity-60"
43
+ >
44
+ {login.isPending ? "{{copy.signingIn}}" : "{{copy.signIn}}"}
45
+ </button>
46
+ </form>
47
+ );
48
+ }
@@ -0,0 +1 @@
1
+ export { LoginPage, metadata } from "./ui/login-page";
@@ -0,0 +1,18 @@
1
+ import type { Metadata } from "next";
2
+
3
+ import { LoginForm } from "./login-form";
4
+
5
+ export const metadata: Metadata = { title: "{{copy.signIn}}" };
6
+
7
+ // Server component — the form below is the only part that needs the browser.
8
+ export function LoginPage() {
9
+ return (
10
+ <main className="mx-auto flex min-h-svh w-full max-w-sm flex-col justify-center gap-6 p-6">
11
+ <div className="flex flex-col gap-1">
12
+ <h1 className="text-2xl font-semibold">{{copy.signIn}}</h1>
13
+ <p className="text-sm opacity-70">{{copy.signInSubtitle}}</p>
14
+ </div>
15
+ <LoginForm />
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useEffect } from "react";
5
+
6
+ import { useSession } from "./session";
7
+
8
+ /**
9
+ * Session for a page that needs one, sending anonymous visitors to /login.
10
+ *
11
+ * UX only — the real gate is the API's own auth middleware, which every
12
+ * request passes through regardless of what the browser rendered. It cannot
13
+ * move into `proxy.ts` (Next 16's renamed middleware) either: the refresh
14
+ * cookie belongs to the API's origin, so the Next server never sees it and
15
+ * has nothing to check.
16
+ *
17
+ * No return-to-url: add a `?next=` round trip when deep links into protected
18
+ * pages start mattering.
19
+ */
20
+ export function useRequireSession() {
21
+ const session = useSession();
22
+ const router = useRouter();
23
+
24
+ useEffect(() => {
25
+ if (session.isError) router.replace("/login");
26
+ }, [session.isError, router]);
27
+
28
+ return session;
29
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useMutation, useQuery, useQueryClient, type QueryClient, type UseQueryResult } from "@tanstack/react-query";
4
+
5
+ import { ApiError, api } from "{{alias}}/shared/api";
6
+
7
+ import { setAccessToken } from "./access-token";
8
+
9
+ /** GET /users/me. TODO: match the fields your API actually returns. */
10
+ export type Session = {
11
+ id: string;
12
+ email: string;
13
+ name: string;
14
+ role: string;
15
+ };
16
+
17
+ export type LoginInput = { email: string; password: string };
18
+
19
+ /** Body of login/refresh — the refresh token is an httpOnly cookie, not here. */
20
+ type AuthTokens = { access_token: string; token_type: string; expires_in: number };
21
+
22
+ export const sessionKey = ["session"] as const;
23
+
24
+ /**
25
+ * The signed-in user, or an error when there is none.
26
+ *
27
+ * On a fresh page load there is no access token yet, so this 401s once and the
28
+ * client's response interceptor spends the refresh cookie before retrying —
29
+ * meaning this hook doubles as the session bootstrap. `retry: false` because
30
+ * that recovery already happened one layer down: a 401 that reaches here means
31
+ * the cookie is gone too, and asking again will not help.
32
+ */
33
+ export function useSession(): UseQueryResult<Session, ApiError> {
34
+ return useQuery<Session, ApiError>({
35
+ queryKey: sessionKey,
36
+ queryFn: () => api.get<Session>("/users/me").then((response) => response.data),
37
+ retry: false,
38
+ staleTime: 5 * 60_000,
39
+ });
40
+ }
41
+
42
+ export function useLogin() {
43
+ const queryClient = useQueryClient();
44
+ return useMutation<void, ApiError, LoginInput>({
45
+ mutationFn: async (input) => {
46
+ const { data } = await api.post<AuthTokens>("/auth/login", input);
47
+ setAccessToken(data.access_token);
48
+ // Refetch rather than write a session object we do not have: the
49
+ // response carries tokens only, so /users/me stays the single source of
50
+ // the user's name and role.
51
+ await queryClient.invalidateQueries({ queryKey: sessionKey });
52
+ },
53
+ });
54
+ }
55
+
56
+ export function useLogout() {
57
+ const queryClient = useQueryClient();
58
+ return useMutation<void, ApiError, void>({
59
+ // Server-side too, not just locally: /auth/logout revokes the refresh
60
+ // token and clears the cookie, otherwise the next reload silently signs
61
+ // the user back in.
62
+ mutationFn: () => api.post("/auth/logout").then(() => undefined),
63
+ onSettled: () => endSessionLocally(queryClient),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Ends the session on this device even when the server call failed — the user
69
+ * asked to leave. `clear()`, not `remove(sessionKey)`: every other cached
70
+ * query holds the previous user's data.
71
+ */
72
+ function endSessionLocally(queryClient: QueryClient): void {
73
+ setAccessToken(null);
74
+ queryClient.clear();
75
+ }
@@ -0,0 +1,19 @@
1
+ // The access token lives in a module variable and nowhere else.
2
+ //
3
+ // The API returns it in the login/refresh response body and should keep the
4
+ // refresh token in an httpOnly cookie, so there is nothing to persist here: a
5
+ // reload starts with no token and the first 401 spends the cookie on a new
6
+ // one. localStorage would only make the token readable by any injected script.
7
+ //
8
+ // This file imports nothing on purpose — shared/api reads it inside the
9
+ // request interceptor and shared/auth writes it after login, so keeping it
10
+ // dependency-free is what stops those two from importing each other.
11
+ let accessToken: string | null = null;
12
+
13
+ export function getAccessToken(): string | null {
14
+ return accessToken;
15
+ }
16
+
17
+ export function setAccessToken(token: string | null): void {
18
+ accessToken = token;
19
+ }