@paramour-js/next 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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/bin/paramour.js +19 -0
  3. package/dist/app.d.ts +76 -0
  4. package/dist/app.js +32 -0
  5. package/dist/cli-args.d.ts +28 -0
  6. package/dist/cli-args.js +35 -0
  7. package/dist/cli-inputs.d.ts +32 -0
  8. package/dist/cli-inputs.js +80 -0
  9. package/dist/cli-io.d.ts +15 -0
  10. package/dist/cli-io.js +16 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +5 -0
  13. package/dist/collisions.d.ts +37 -0
  14. package/dist/collisions.js +80 -0
  15. package/dist/commands/doctor.d.ts +7 -0
  16. package/dist/commands/doctor.js +56 -0
  17. package/dist/commands/generate.d.ts +11 -0
  18. package/dist/commands/generate.js +222 -0
  19. package/dist/commands/init.d.ts +9 -0
  20. package/dist/commands/init.js +205 -0
  21. package/dist/commands/list.d.ts +9 -0
  22. package/dist/commands/list.js +94 -0
  23. package/dist/config.d.ts +35 -0
  24. package/dist/config.js +99 -0
  25. package/dist/doctor/checks.d.ts +16 -0
  26. package/dist/doctor/checks.js +231 -0
  27. package/dist/emit.d.ts +35 -0
  28. package/dist/emit.js +74 -0
  29. package/dist/generate.d.ts +70 -0
  30. package/dist/generate.js +106 -0
  31. package/dist/index.d.ts +9 -0
  32. package/dist/index.js +9 -0
  33. package/dist/init/scaffold.d.ts +39 -0
  34. package/dist/init/scaffold.js +244 -0
  35. package/dist/init/wrap-next-config.d.ts +41 -0
  36. package/dist/init/wrap-next-config.js +99 -0
  37. package/dist/list/discover-route-defs.d.ts +52 -0
  38. package/dist/list/discover-route-defs.js +121 -0
  39. package/dist/list/render.d.ts +35 -0
  40. package/dist/list/render.js +132 -0
  41. package/dist/lock.d.ts +29 -0
  42. package/dist/lock.js +88 -0
  43. package/dist/pages.d.ts +49 -0
  44. package/dist/pages.js +62 -0
  45. package/dist/run-cli.d.ts +11 -0
  46. package/dist/run-cli.js +53 -0
  47. package/dist/scan-app.d.ts +30 -0
  48. package/dist/scan-app.js +149 -0
  49. package/dist/scan-pages.d.ts +10 -0
  50. package/dist/scan-pages.js +102 -0
  51. package/dist/scan.d.ts +32 -0
  52. package/dist/scan.js +77 -0
  53. package/dist/select.d.ts +94 -0
  54. package/dist/select.js +195 -0
  55. package/dist/watch.d.ts +39 -0
  56. package/dist/watch.js +87 -0
  57. package/dist/with-typed-routes.d.ts +44 -0
  58. package/dist/with-typed-routes.js +200 -0
  59. package/package.json +67 -0
@@ -0,0 +1,222 @@
1
+ import { parseCommandFlags } from "../cli-args.js";
2
+ import { resolveInputs } from "../cli-inputs.js";
3
+ import { message, resolveIo } from "../cli-io.js";
4
+ import { RouteCollisionError } from "../collisions.js";
5
+ import { checkArtifact, formatRouteDiff, generate, } from "../generate.js";
6
+ import { acquireWatcherLock, watcherLockPath, } from "../lock.js";
7
+ import { watchRouteDirs } from "../watch.js";
8
+ const SHARED_OPTIONS = {
9
+ "app-dir": { type: "string" },
10
+ help: { default: false, short: "h", type: "boolean" },
11
+ "out-file": { type: "string" },
12
+ "page-extensions": { type: "string" },
13
+ "pages-dir": { type: "string" },
14
+ };
15
+ const SHARED_OPTION_LINES = [
16
+ " --app-dir <dir> app directory (default: discovered app/ or src/app/)",
17
+ " --help, -h show this help",
18
+ " --out-file <file> artifact path (default: paramour-env.d.ts)",
19
+ " --page-extensions <list> comma-separated, no leading dots (default: tsx,ts,jsx,js)",
20
+ " --pages-dir <dir> pages directory (default: discovered pages/ or src/pages/)",
21
+ ];
22
+ const GENERATE_USAGE = [
23
+ "Usage: paramour generate [options]",
24
+ "",
25
+ "Generate paramour-env.d.ts from the app and pages directories.",
26
+ "",
27
+ "Options:",
28
+ ...SHARED_OPTION_LINES,
29
+ " --check verify the artifact is current; exit 1 on drift, never writes",
30
+ " --watch regenerate on route-dir changes",
31
+ ].join("\n");
32
+ const CHECK_USAGE = [
33
+ "Usage: paramour check [options]",
34
+ "",
35
+ "Verify the artifact is current; exit 1 on drift, never writes.",
36
+ "",
37
+ "Options:",
38
+ ...SHARED_OPTION_LINES,
39
+ ].join("\n");
40
+ /**
41
+ * @internal `paramour generate` and its `check` alias (TR7), in-process
42
+ * testable: returns the exit code instead of exiting. Codes are grep-style
43
+ * so CI can tell drift from breakage: 0 success, 1 check-drift ONLY, 2
44
+ * usage/config/operational errors — route collisions included (PR9: Next
45
+ * itself fails that build, so there is no artifact to emit). Unlike the
46
+ * wrapper's never-load-bearing stance (§7.3), the CLI fails loudly —
47
+ * running it is explicit user intent.
48
+ */
49
+ export async function runGenerate(argv, io, mode) {
50
+ const { stderr, stdout } = resolveIo(io);
51
+ const usage = mode === "check" ? CHECK_USAGE : GENERATE_USAGE;
52
+ let flags;
53
+ if (mode === "generate") {
54
+ const parsed = parseCommandFlags(argv, {
55
+ ...SHARED_OPTIONS,
56
+ check: { default: false, type: "boolean" },
57
+ watch: { default: false, type: "boolean" },
58
+ }, usage, { stderr, stdout });
59
+ if ("exit" in parsed)
60
+ return parsed.exit;
61
+ flags = parsed.values;
62
+ }
63
+ else {
64
+ // `check` omits --check/--watch entirely: `paramour check --watch`
65
+ // fails as an unknown option, which is the right message for it.
66
+ const parsed = parseCommandFlags(argv, SHARED_OPTIONS, usage, {
67
+ stderr,
68
+ stdout,
69
+ });
70
+ if ("exit" in parsed)
71
+ return parsed.exit;
72
+ flags = { ...parsed.values, check: true, watch: false };
73
+ }
74
+ if (flags.watch && flags.check) {
75
+ stderr("paramour: --watch and --check are mutually exclusive");
76
+ return 2;
77
+ }
78
+ const projectRoot = process.cwd();
79
+ let inputs;
80
+ try {
81
+ inputs = await resolveInputs(flags, projectRoot);
82
+ }
83
+ catch (error) {
84
+ stderr(`paramour: ${message(error)}`);
85
+ return 2;
86
+ }
87
+ if (flags.check)
88
+ return runCheck(inputs, stdout, stderr);
89
+ if (flags.watch)
90
+ return runWatch(inputs, projectRoot, io, stdout, stderr);
91
+ return runOnce(inputs, stdout, stderr);
92
+ }
93
+ function count(n, noun) {
94
+ return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
95
+ }
96
+ /** `(2 app routes, 1 pages route)` — per-router so hybrid output reads. */
97
+ function describeRoutes(result) {
98
+ const parts = [
99
+ ...(result.appRoutes.length > 0
100
+ ? [count(result.appRoutes.length, "app route")]
101
+ : []),
102
+ ...(result.pagesRoutes.length > 0
103
+ ? [count(result.pagesRoutes.length, "pages route")]
104
+ : []),
105
+ ];
106
+ return parts.length === 0 ? "0 routes" : parts.join(", ");
107
+ }
108
+ /** `--check` (TR7): exit 1 on any drift, including a missing artifact. */
109
+ function runCheck(inputs, stdout, stderr) {
110
+ let result;
111
+ try {
112
+ result = checkArtifact(inputs);
113
+ }
114
+ catch (error) {
115
+ stderr(`paramour: ${message(error)}`);
116
+ return 2;
117
+ }
118
+ if (result.upToDate) {
119
+ stdout(`paramour: ${inputs.artifactPath} is up to date`);
120
+ return 0;
121
+ }
122
+ stderr(result.missingFile
123
+ ? `paramour: ${inputs.artifactPath} is missing.`
124
+ : `paramour: ${inputs.artifactPath} is out of date.`);
125
+ const diff = formatRouteDiff(result.app, result.pages);
126
+ if (diff.length === 0) {
127
+ // Byte drift with an identical route set — a hand-edited artifact.
128
+ stderr(" content differs from generator output");
129
+ }
130
+ for (const line of diff)
131
+ stderr(line);
132
+ stderr("Run `paramour generate` and commit the result.");
133
+ return 1;
134
+ }
135
+ /** One-shot `paramour generate` (TR7). */
136
+ function runOnce(inputs, stdout, stderr) {
137
+ let result;
138
+ try {
139
+ result = generate(inputs);
140
+ }
141
+ catch (error) {
142
+ stderr(`paramour: ${message(error)}`);
143
+ return 2;
144
+ }
145
+ const described = describeRoutes(result);
146
+ stdout(result.written
147
+ ? `paramour: wrote ${inputs.artifactPath} (${described})`
148
+ : `paramour: ${inputs.artifactPath} unchanged (${described})`);
149
+ return 0;
150
+ }
151
+ /**
152
+ * `--watch` (TR7): TR5 watcher behind the TR6 lock, over both route dirs
153
+ * (PR8). A declined lock exits 0 — another live watcher (usually `next dev`)
154
+ * is the designed dedupe case, and the initial generation already ran.
155
+ * Without an abort signal the returned promise stays pending; the process
156
+ * lives via the FSWatcher refs and dies with the standard signal exits
157
+ * (lock.ts re-raises after cleanup).
158
+ */
159
+ function runWatch(inputs, projectRoot, io, stdout, stderr) {
160
+ try {
161
+ generate(inputs);
162
+ }
163
+ catch (error) {
164
+ // Transient I/O failure (or a collision the user is mid-fixing)
165
+ // shouldn't kill an editor-companion watcher; only option-resolution
166
+ // errors (earlier) are fatal.
167
+ stderr(`paramour: initial generation failed: ${message(error)}`);
168
+ }
169
+ let lock;
170
+ try {
171
+ lock = acquireWatcherLock(watcherLockPath(projectRoot));
172
+ }
173
+ catch (error) {
174
+ // A corrupt lock location (e.g. a directory at the pidfile path) is an
175
+ // operational error, not a crash: exit 2 like every other one (TR7).
176
+ stderr(`paramour: ${message(error)}`);
177
+ return 2;
178
+ }
179
+ if (!lock.acquired) {
180
+ stdout(`paramour: watcher already running (pid ${String(lock.ownerPid)}); exiting`);
181
+ return 0;
182
+ }
183
+ const dirs = [inputs.appDir, inputs.pagesDir].filter((dir) => dir !== undefined);
184
+ let warned = false;
185
+ const watcher = watchRouteDirs(dirs, {
186
+ ignorePaths: [inputs.artifactPath],
187
+ onError: (error) => {
188
+ if (warned)
189
+ return;
190
+ warned = true;
191
+ stderr(`paramour: watcher error; continuing: ${message(error)}`);
192
+ },
193
+ onRescan: () => {
194
+ try {
195
+ generate(inputs);
196
+ }
197
+ catch (error) {
198
+ if (error instanceof RouteCollisionError) {
199
+ // PR9's watch exception: a collision mid-watch is usually a file
200
+ // mid-move — log loudly every time, keep the last good artifact
201
+ // on disk, keep running (TR5).
202
+ stderr(`paramour: ${message(error)}; keeping the last good artifact and watching for the fix`);
203
+ return;
204
+ }
205
+ throw error; // routed to onError by the watcher (TR5 non-fatal)
206
+ }
207
+ },
208
+ });
209
+ stdout(`paramour: watching ${dirs.join(", ")}`);
210
+ return new Promise((resolveExit) => {
211
+ const stop = () => {
212
+ watcher.close();
213
+ lock.release?.();
214
+ resolveExit(0);
215
+ };
216
+ if (io.signal?.aborted) {
217
+ stop();
218
+ return;
219
+ }
220
+ io.signal?.addEventListener("abort", stop, { once: true });
221
+ });
222
+ }
@@ -0,0 +1,9 @@
1
+ import { type CliIo } from "../cli-io.js";
2
+ /**
3
+ * @internal `paramour init` — non-interactive by design: it runs straight
4
+ * through with defaults and prints one status line per step. Exit codes: 0
5
+ * on success INCLUDING manual-fallback wraps (a printed instruction is a
6
+ * successful outcome, not a failure); 2 only on hard errors (no/broken
7
+ * package.json, invalid config file, route collisions).
8
+ */
9
+ export declare function runInit(argv: readonly string[], io: CliIo): Promise<number>;
@@ -0,0 +1,205 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve } from "node:path";
3
+ import { parseCommandFlags } from "../cli-args.js";
4
+ import { NoRouteDirsError, resolveInputs } from "../cli-inputs.js";
5
+ import { message, resolveIo } from "../cli-io.js";
6
+ import { CONFIG_FILE_NAMES, loadConfigFile } from "../config.js";
7
+ import { generate } from "../generate.js";
8
+ import { addPackageScript, checkSetup, paramourConfigTemplate, } from "../init/scaffold.js";
9
+ import { findNextConfig, manualSnippet, wrapNextConfigSource, } from "../init/wrap-next-config.js";
10
+ import { scanRoutes } from "../scan.js";
11
+ const USAGE = [
12
+ "Usage: paramour init [options]",
13
+ "",
14
+ "Set up paramour in this project: scaffold paramour.config.ts, wrap",
15
+ 'next.config with withTypedRoutes, add a "paramour" script, and run the',
16
+ "first generate. Every step is idempotent and individually skippable.",
17
+ "",
18
+ "Options:",
19
+ " --dry-run report every step without writing anything",
20
+ " --force overwrite an existing paramour.config with the scaffold",
21
+ " --help, -h show this help",
22
+ " --no-config skip scaffolding paramour.config.ts",
23
+ " --no-generate skip the first generate",
24
+ " --no-script skip adding the package.json script",
25
+ " --no-wrap skip wrapping next.config",
26
+ ].join("\n");
27
+ /**
28
+ * @internal `paramour init` — non-interactive by design: it runs straight
29
+ * through with defaults and prints one status line per step. Exit codes: 0
30
+ * on success INCLUDING manual-fallback wraps (a printed instruction is a
31
+ * successful outcome, not a failure); 2 only on hard errors (no/broken
32
+ * package.json, invalid config file, route collisions).
33
+ */
34
+ export async function runInit(argv, io) {
35
+ const { stderr, stdout } = resolveIo(io);
36
+ const parsed = parseCommandFlags(argv, {
37
+ "dry-run": { default: false, type: "boolean" },
38
+ force: { default: false, type: "boolean" },
39
+ help: { default: false, short: "h", type: "boolean" },
40
+ "no-config": { default: false, type: "boolean" },
41
+ "no-generate": { default: false, type: "boolean" },
42
+ "no-script": { default: false, type: "boolean" },
43
+ "no-wrap": { default: false, type: "boolean" },
44
+ }, USAGE, { stderr, stdout });
45
+ if ("exit" in parsed)
46
+ return parsed.exit;
47
+ const flags = parsed.values;
48
+ const projectRoot = process.cwd();
49
+ const dry = flags["dry-run"];
50
+ const write = (path, content) => {
51
+ if (!dry)
52
+ writeFileSync(path, content);
53
+ };
54
+ // The one hard prerequisite: init edits package.json and reasons about
55
+ // dependencies, so a project without one has nothing to initialize into.
56
+ const packageJsonPath = join(projectRoot, "package.json");
57
+ if (!existsSync(packageJsonPath)) {
58
+ stderr("paramour: no package.json here — run init at the project root");
59
+ return 2;
60
+ }
61
+ stdout(dry ? "paramour init (dry run — nothing written)" : "paramour init");
62
+ // 1. Scaffold paramour.config.ts.
63
+ if (!flags["no-config"]) {
64
+ const existing = CONFIG_FILE_NAMES.find((name) => existsSync(join(projectRoot, name)));
65
+ if (existing !== undefined && !flags.force) {
66
+ stdout(` • ${existing} already exists — skipped (--force overwrites)`);
67
+ }
68
+ else {
69
+ // A .mjs/.json left behind would be shadowed by the scaffold under the
70
+ // ts-first discovery order (§7.2) — --force must truly replace it.
71
+ if (existing !== undefined && existing !== "paramour.config.ts" && !dry) {
72
+ unlinkSync(join(projectRoot, existing));
73
+ }
74
+ write(join(projectRoot, "paramour.config.ts"), paramourConfigTemplate());
75
+ const suffix = existing === undefined
76
+ ? ""
77
+ : existing === "paramour.config.ts"
78
+ ? " (overwrote via --force)"
79
+ : ` (replaced ${existing} via --force)`;
80
+ stdout(` ✔ ${dry ? "would create" : "created"} paramour.config.ts${suffix}`);
81
+ }
82
+ }
83
+ // Read whatever config actually exists on disk (the scaffold is
84
+ // commented-out defaults, so scaffolding never changes these values).
85
+ let config;
86
+ try {
87
+ config = (await loadConfigFile(projectRoot))?.config ?? {};
88
+ }
89
+ catch (error) {
90
+ stderr(`paramour: ${message(error)}`);
91
+ return 2;
92
+ }
93
+ // 2. Wrap next.config with withTypedRoutes.
94
+ if (!flags["no-wrap"]) {
95
+ const found = findNextConfig(projectRoot);
96
+ if (found === undefined) {
97
+ stdout(" → no next.config found — create one and wrap it yourself:");
98
+ for (const line of manualSnippet().split("\n"))
99
+ stdout(` ${line}`);
100
+ }
101
+ else {
102
+ const name = basename(found.path);
103
+ let source;
104
+ try {
105
+ source = readFileSync(found.path, "utf8");
106
+ }
107
+ catch (error) {
108
+ // Same stance as the transform's manual fallback: an unreadable
109
+ // config degrades to the printed snippet, never a crash.
110
+ stdout(` → could not read ${name} (${message(error)}) — apply this yourself:`);
111
+ for (const line of manualSnippet().split("\n"))
112
+ stdout(` ${line}`);
113
+ }
114
+ if (source !== undefined) {
115
+ const result = await wrapNextConfigSource(source);
116
+ if (result.status === "already-wrapped") {
117
+ stdout(` • ${name} already wraps withTypedRoutes — skipped`);
118
+ }
119
+ else if (result.status === "wrapped") {
120
+ write(found.path, result.code);
121
+ stdout(` ✔ ${dry ? "would wrap" : "wrapped"} ${name} with withTypedRoutes`);
122
+ }
123
+ else {
124
+ stdout(` → could not transform ${name} safely — apply this yourself:`);
125
+ for (const line of result.snippet.split("\n")) {
126
+ stdout(` ${line}`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // 3. package.json script.
133
+ if (!flags["no-script"]) {
134
+ let result;
135
+ try {
136
+ result = addPackageScript(readFileSync(packageJsonPath, "utf8"));
137
+ }
138
+ catch (error) {
139
+ stderr(`paramour: package.json: ${message(error)}`);
140
+ return 2;
141
+ }
142
+ if (result.changed) {
143
+ write(packageJsonPath, result.text);
144
+ stdout(` ✔ ${dry ? "would add" : "added"} "paramour" script to package.json`);
145
+ }
146
+ else {
147
+ stdout(` • package.json already has a "paramour" script — skipped`);
148
+ }
149
+ }
150
+ // 4. First generate.
151
+ // resolve, not join — an absolute outFile must win, as it does in
152
+ // resolveInputs.
153
+ let artifactPath = resolve(projectRoot, config.outFile ?? "paramour-env.d.ts");
154
+ if (!flags["no-generate"]) {
155
+ let inputs;
156
+ try {
157
+ inputs = await resolveInputs({}, projectRoot, config);
158
+ }
159
+ catch (error) {
160
+ if (!(error instanceof NoRouteDirsError)) {
161
+ stderr(`paramour: ${message(error)}`);
162
+ return 2;
163
+ }
164
+ stdout(" ⚠ no route directory yet — skipped generate (run `paramour generate` once app/ or pages/ exists)");
165
+ }
166
+ if (inputs !== undefined) {
167
+ artifactPath = inputs.artifactPath;
168
+ const artifactRel = relative(projectRoot, artifactPath).replaceAll("\\", "/");
169
+ try {
170
+ if (dry) {
171
+ const routes = scanRoutes(inputs, inputs.pageExtensions);
172
+ stdout(` ✔ would write ${artifactRel} (${countRoutes(routes)})`);
173
+ }
174
+ else {
175
+ const result = generate(inputs);
176
+ stdout(result.written
177
+ ? ` ✔ wrote ${artifactRel} (${countRoutes(result)})`
178
+ : ` • ${artifactRel} already up to date (${countRoutes(result)}) — skipped`);
179
+ }
180
+ }
181
+ catch (error) {
182
+ stderr(`paramour: ${message(error)}`);
183
+ return 2;
184
+ }
185
+ }
186
+ }
187
+ return finishWithSummary(projectRoot, artifactPath, stdout);
188
+ }
189
+ function countRoutes(routes) {
190
+ const total = routes.appRoutes.length + routes.pagesRoutes.length;
191
+ return `${String(total)} route${total === 1 ? "" : "s"}`;
192
+ }
193
+ function finishWithSummary(projectRoot, artifactPath, stdout) {
194
+ // Detect-and-verify summary (warn-level — never affects the exit code).
195
+ stdout("");
196
+ stdout("setup:");
197
+ for (const check of checkSetup(projectRoot, artifactPath)) {
198
+ stdout(` ${check.ok ? "✔" : "⚠"} ${check.label}`);
199
+ if (check.detail !== undefined)
200
+ stdout(` ${check.detail}`);
201
+ }
202
+ stdout("");
203
+ stdout("Commit the generated artifact — `paramour check` verifies it stays current in CI.");
204
+ return 0;
205
+ }
@@ -0,0 +1,9 @@
1
+ import { type CliIo } from "../cli-io.js";
2
+ /**
3
+ * @internal `paramour list`: the filesystem scan is authoritative for WHICH
4
+ * routes exist (same engine as generate); discovered definitions overlay
5
+ * the shapes. A filesystem route with no definition and a definition with
6
+ * no filesystem route are both warnings, not errors — the report doubles
7
+ * as a coverage check, and warnings exit 0.
8
+ */
9
+ export declare function runList(argv: readonly string[], io: CliIo): Promise<number>;
@@ -0,0 +1,94 @@
1
+ import { describeRoute } from "paramour";
2
+ import { parseCommandFlags } from "../cli-args.js";
3
+ import { resolveInputs } from "../cli-inputs.js";
4
+ import { message, resolveIo } from "../cli-io.js";
5
+ import { loadConfigFile } from "../config.js";
6
+ import { discoverRouteDefinitions, routeKey, } from "../list/discover-route-defs.js";
7
+ import { buildListJson, renderListReport, } from "../list/render.js";
8
+ import { scanRoutes } from "../scan.js";
9
+ const USAGE = [
10
+ "Usage: paramour list [options]",
11
+ "",
12
+ "Print every filesystem route with its params/search shape.",
13
+ "",
14
+ "Shapes come from defineAppRoute/definePagesRoute call sites: list scans",
15
+ "source files for those calls and EVALUATES the matching modules to read",
16
+ "their route objects (set `routeFiles` globs in paramour.config to pin",
17
+ "which modules). Modules that fail to load are reported and skipped.",
18
+ "",
19
+ "Options:",
20
+ " --app-dir <dir> app directory (default: discovered app/ or src/app/)",
21
+ " --help, -h show this help",
22
+ " --json machine-readable output",
23
+ " --page-extensions <list> comma-separated, no leading dots (default: tsx,ts,jsx,js)",
24
+ " --pages-dir <dir> pages directory (default: discovered pages/ or src/pages/)",
25
+ ].join("\n");
26
+ /**
27
+ * @internal `paramour list`: the filesystem scan is authoritative for WHICH
28
+ * routes exist (same engine as generate); discovered definitions overlay
29
+ * the shapes. A filesystem route with no definition and a definition with
30
+ * no filesystem route are both warnings, not errors — the report doubles
31
+ * as a coverage check, and warnings exit 0.
32
+ */
33
+ export async function runList(argv, io) {
34
+ const { stderr, stdout } = resolveIo(io);
35
+ const parsed = parseCommandFlags(argv, {
36
+ "app-dir": { type: "string" },
37
+ help: { default: false, short: "h", type: "boolean" },
38
+ json: { default: false, type: "boolean" },
39
+ "page-extensions": { type: "string" },
40
+ "pages-dir": { type: "string" },
41
+ }, USAGE, { stderr, stdout });
42
+ if ("exit" in parsed)
43
+ return parsed.exit;
44
+ const flags = parsed.values;
45
+ const projectRoot = process.cwd();
46
+ let report;
47
+ try {
48
+ const config = (await loadConfigFile(projectRoot))?.config ?? {};
49
+ const inputs = await resolveInputs(flags, projectRoot, config);
50
+ const routes = scanRoutes(inputs, inputs.pageExtensions);
51
+ const discovery = await discoverRouteDefinitions(projectRoot, {
52
+ routeFiles: config.routeFiles,
53
+ });
54
+ const described = new Map();
55
+ for (const definition of discovery.definitions) {
56
+ described.set(routeKey(definition.route["~router"], definition.route.path), {
57
+ description: describeRoute(definition.route),
58
+ exportName: definition.exportName,
59
+ file: definition.file,
60
+ });
61
+ }
62
+ const matched = new Set();
63
+ const overlay = (router, paths) => paths.map((path) => {
64
+ const key = routeKey(router, path);
65
+ const definition = described.get(key) ?? null;
66
+ if (definition !== null)
67
+ matched.add(key);
68
+ return { definition, path };
69
+ });
70
+ const appRoutes = overlay("app", routes.appRoutes);
71
+ const pagesRoutes = overlay("pages", routes.pagesRoutes);
72
+ const orphans = [...described.entries()]
73
+ .filter(([key]) => !matched.has(key))
74
+ .map(([, definition]) => definition);
75
+ report = {
76
+ appRoutes,
77
+ duplicates: discovery.duplicates,
78
+ loadFailures: discovery.loadFailures,
79
+ orphans,
80
+ pagesRoutes,
81
+ };
82
+ }
83
+ catch (error) {
84
+ stderr(`paramour: ${message(error)}`);
85
+ return 2;
86
+ }
87
+ if (flags.json) {
88
+ stdout(JSON.stringify(buildListJson(report), null, 2));
89
+ return 0;
90
+ }
91
+ for (const line of renderListReport(report))
92
+ stdout(line);
93
+ return 0;
94
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shape of `paramour.config.{ts,mjs,json}` (§7.2 / TR7) — the CLI's config
3
+ * file. Every field is optional; the CLI's precedence is flags → this file →
4
+ * inference. `.ts`/`.mjs` files default-export this object.
5
+ */
6
+ export interface ParamourConfig {
7
+ /** App dir, relative to the project root; default: joint discovery (PR8). */
8
+ appDir?: string;
9
+ /** Artifact path, relative to the project root (TR3 escape hatch). */
10
+ outFile?: string;
11
+ /** Page extensions, no leading dot; default: Next's four. */
12
+ pageExtensions?: string[];
13
+ /** Pages dir, relative to the project root; default: joint discovery (PR8). */
14
+ pagesDir?: string;
15
+ /**
16
+ * Globs (relative to the project root) of modules exporting route
17
+ * definitions, for `paramour list`/`doctor` only — generation never reads
18
+ * it. Overrides their automatic defineAppRoute/definePagesRoute content
19
+ * scan when the heuristic misfires.
20
+ */
21
+ routeFiles?: string[];
22
+ }
23
+ /** @internal Discovery order at the project root (TR7) — first match wins. */
24
+ export declare const CONFIG_FILE_NAMES: readonly ["paramour.config.ts", "paramour.config.mjs", "paramour.config.json"];
25
+ /**
26
+ * @internal Load and validate the project's config file, or `undefined`
27
+ * when none exists. No upward traversal — the documented contract is three
28
+ * filenames at the project root (TR7). jiti (the §7.2 loader carry-over) is
29
+ * imported dynamically so only CLI runs that actually have a `.ts`/`.mjs`
30
+ * config pay for it; `withTypedRoutes` users never execute it.
31
+ */
32
+ export declare function loadConfigFile(projectRoot: string): Promise<undefined | {
33
+ config: ParamourConfig;
34
+ path: string;
35
+ }>;
package/dist/config.js ADDED
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** @internal Discovery order at the project root (TR7) — first match wins. */
4
+ export const CONFIG_FILE_NAMES = [
5
+ "paramour.config.ts",
6
+ "paramour.config.mjs",
7
+ "paramour.config.json",
8
+ ];
9
+ /**
10
+ * @internal Load and validate the project's config file, or `undefined`
11
+ * when none exists. No upward traversal — the documented contract is three
12
+ * filenames at the project root (TR7). jiti (the §7.2 loader carry-over) is
13
+ * imported dynamically so only CLI runs that actually have a `.ts`/`.mjs`
14
+ * config pay for it; `withTypedRoutes` users never execute it.
15
+ */
16
+ export async function loadConfigFile(projectRoot) {
17
+ for (const name of CONFIG_FILE_NAMES) {
18
+ const path = join(projectRoot, name);
19
+ if (!existsSync(path))
20
+ continue;
21
+ if (name.endsWith(".json")) {
22
+ const text = readFileSync(path, "utf8");
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(text);
26
+ }
27
+ catch (error) {
28
+ // Name the file: a bare SyntaxError("Unexpected token …") gives the
29
+ // user nothing to grep for.
30
+ throw new Error(`${name}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
31
+ }
32
+ return { config: validateConfig(parsed, name), path };
33
+ }
34
+ const { createJiti } = await import("jiti");
35
+ // fsCache off: config files are tiny and user projects shouldn't grow
36
+ // a transform cache just because the CLI ran.
37
+ const jiti = createJiti(import.meta.url, {
38
+ fsCache: false,
39
+ interopDefault: true,
40
+ });
41
+ const mod = await jiti.import(path);
42
+ const value = typeof mod === "object" && mod !== null && "default" in mod
43
+ ? mod.default
44
+ : mod;
45
+ return { config: validateConfig(value, name), path };
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Hand-rolled validation: a 5-key schema can afford to reject unknown keys —
51
+ * a silently ignored `pagesExtensions` typo is exactly the footgun this
52
+ * prevents. Throws with the file and key named; the CLI maps it to exit 2.
53
+ */
54
+ function validateConfig(value, sourceName) {
55
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
56
+ throw new Error(`${sourceName} must export a config object`);
57
+ }
58
+ const config = {};
59
+ for (const [key, entry] of Object.entries(value)) {
60
+ switch (key) {
61
+ case "appDir":
62
+ case "outFile":
63
+ case "pagesDir": {
64
+ if (typeof entry !== "string" || entry === "") {
65
+ throw new Error(`${sourceName}: \`${key}\` must be a non-empty string`);
66
+ }
67
+ config[key] = entry;
68
+ break;
69
+ }
70
+ case "pageExtensions": {
71
+ if (!Array.isArray(entry) ||
72
+ entry.length === 0 ||
73
+ !entry.every((ext) => typeof ext === "string" && ext !== "")) {
74
+ throw new Error(`${sourceName}: \`pageExtensions\` must be a non-empty array of non-empty strings`);
75
+ }
76
+ // A leading dot silently matches nothing (`page..tsx` never exists) —
77
+ // exactly the class of typo this hand-rolled validation exists for.
78
+ const dotted = entry.find((ext) => ext.startsWith("."));
79
+ if (dotted !== undefined) {
80
+ throw new Error(`${sourceName}: \`pageExtensions\` entries must not start with a dot: "${dotted}"`);
81
+ }
82
+ config.pageExtensions = entry;
83
+ break;
84
+ }
85
+ case "routeFiles": {
86
+ if (!Array.isArray(entry) ||
87
+ entry.length === 0 ||
88
+ !entry.every((glob) => typeof glob === "string" && glob !== "")) {
89
+ throw new Error(`${sourceName}: \`routeFiles\` must be a non-empty array of non-empty glob strings`);
90
+ }
91
+ config.routeFiles = entry;
92
+ break;
93
+ }
94
+ default:
95
+ throw new Error(`${sourceName}: unknown key \`${key}\``);
96
+ }
97
+ }
98
+ return config;
99
+ }