@pracht/cli 1.1.5 → 1.2.1

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.
@@ -1,40 +1,76 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
 
4
- import { createServer } from "vite";
4
+ import { defineCommand } from "citty";
5
+ import { createServer, type ViteDevServer } from "vite";
5
6
 
6
- import { handleCliError, parseFlags, printInspectHelp, requireOptionalString } from "../cli.js";
7
+ import { handleCliError } from "../utils.js";
7
8
  import { readClientBuildAssets } from "../build-metadata.js";
8
- import { HTTP_METHODS } from "../constants.js";
9
+ import { HTTP_METHODS, type HttpMethod } from "../constants.js";
9
10
  import { readProjectConfig, resolveProjectPath } from "../project.js";
10
11
 
11
12
  const INSPECT_TARGETS = new Set(["routes", "api", "build", "all"]);
12
- const METHOD_ORDER = [...HTTP_METHODS];
13
-
14
- export async function inspectCommand(args) {
15
- const options = parseFlags(args);
16
- const target = requireOptionalString(options, "target") ?? options._[0] ?? "all";
17
-
18
- if (options.help || target === "help") {
19
- printInspectHelp();
20
- return;
21
- }
22
-
23
- if (!INSPECT_TARGETS.has(target)) {
24
- handleCliError(new Error(`Unknown inspect target: ${target}`), { json: !!options.json });
25
- }
13
+ const METHOD_ORDER: HttpMethod[] = [...HTTP_METHODS];
14
+
15
+ export default defineCommand({
16
+ meta: {
17
+ name: "inspect",
18
+ description: "Inspect resolved app graph",
19
+ },
20
+ args: {
21
+ target: {
22
+ type: "positional",
23
+ description: "Inspect target: routes, api, build, or all",
24
+ required: false,
25
+ },
26
+ json: {
27
+ type: "boolean",
28
+ description: "Output as JSON",
29
+ },
30
+ },
31
+ async run({ args }) {
32
+ const target = args.target || "all";
33
+
34
+ if (!INSPECT_TARGETS.has(target)) {
35
+ handleCliError(new Error(`Unknown inspect target: ${target}`), {
36
+ json: Boolean(args.json),
37
+ });
38
+ }
26
39
 
27
- const report = await runInspect(process.cwd(), { target });
40
+ const report = await runInspect(process.cwd(), { target });
28
41
 
29
- if (options.json) {
30
- console.log(JSON.stringify(report, null, 2));
31
- return;
32
- }
42
+ if (args.json) {
43
+ console.log(JSON.stringify(report, null, 2));
44
+ return;
45
+ }
33
46
 
34
- printInspectReport(report);
47
+ printInspectReport(report);
48
+ },
49
+ });
50
+
51
+ interface InspectReport {
52
+ api?: { file: string; methods: string[]; path: string }[];
53
+ build?: {
54
+ adapterTarget: string;
55
+ clientEntryUrl: string | null;
56
+ cssManifest: Record<string, string[]>;
57
+ jsManifest: Record<string, string[]>;
58
+ };
59
+ mode: string;
60
+ routes?: {
61
+ file: string;
62
+ id: string;
63
+ loaderFile: string | null;
64
+ middleware: string[];
65
+ path: string;
66
+ render: string | null;
67
+ revalidate: unknown;
68
+ shell: string | null;
69
+ shellFile: string | null;
70
+ }[];
35
71
  }
36
72
 
37
- export async function runInspect(root, { target = "all" } = {}) {
73
+ async function runInspect(root: string, { target = "all" } = {}): Promise<InspectReport> {
38
74
  const project = readProjectConfig(root);
39
75
 
40
76
  if (!project.configFile) {
@@ -49,9 +85,7 @@ export async function runInspect(root, { target = "all" } = {}) {
49
85
 
50
86
  if (project.mode === "manifest") {
51
87
  const manifestPath = resolveProjectPath(project.root, project.appFile);
52
- try {
53
- readFileSync(manifestPath, "utf-8");
54
- } catch {
88
+ if (!existsSync(manifestPath)) {
55
89
  throw new Error(`App manifest is missing at ${project.appFile}.`);
56
90
  }
57
91
  }
@@ -66,7 +100,7 @@ export async function runInspect(root, { target = "all" } = {}) {
66
100
 
67
101
  try {
68
102
  const serverModule = await server.ssrLoadModule("virtual:pracht/server");
69
- const report = {
103
+ const report: InspectReport = {
70
104
  mode: project.mode,
71
105
  };
72
106
 
@@ -76,7 +110,7 @@ export async function runInspect(root, { target = "all" } = {}) {
76
110
 
77
111
  if (target === "api" || target === "all") {
78
112
  report.api = await Promise.all(
79
- serverModule.apiRoutes.map(async (route) => ({
113
+ serverModule.apiRoutes.map(async (route: { file: string; path: string }) => ({
80
114
  file: route.file,
81
115
  methods: await detectApiMethods(server, root, route.file),
82
116
  path: route.path,
@@ -100,7 +134,19 @@ export async function runInspect(root, { target = "all" } = {}) {
100
134
  }
101
135
  }
102
136
 
103
- function serializeRoutes(routes) {
137
+ interface RouteEntry {
138
+ file: string;
139
+ id: string;
140
+ loaderFile?: string;
141
+ middleware: string[];
142
+ path: string;
143
+ render?: string;
144
+ revalidate?: unknown;
145
+ shell?: string;
146
+ shellFile?: string;
147
+ }
148
+
149
+ function serializeRoutes(routes: RouteEntry[]) {
104
150
  return routes.map((route) => ({
105
151
  file: route.file,
106
152
  id: route.id,
@@ -114,11 +160,14 @@ function serializeRoutes(routes) {
114
160
  }));
115
161
  }
116
162
 
117
- async function detectApiMethods(server, root, file) {
163
+ async function detectApiMethods(
164
+ server: ViteDevServer,
165
+ root: string,
166
+ file: string,
167
+ ): Promise<string[]> {
118
168
  const resolvedFile = resolve(root, `.${file}`);
119
169
  const source = readFileSync(resolvedFile, "utf-8");
120
170
 
121
- // Use module evaluation first so re-exported handlers are reflected too.
122
171
  try {
123
172
  const module = await server.ssrLoadModule(file);
124
173
  return METHOD_ORDER.filter((method) => typeof module[method] === "function");
@@ -129,7 +178,7 @@ async function detectApiMethods(server, root, file) {
129
178
  }
130
179
  }
131
180
 
132
- function printInspectReport(report) {
181
+ function printInspectReport(report: InspectReport): void {
133
182
  console.log(`Pracht inspect (${report.mode} mode)`);
134
183
 
135
184
  if (report.routes) {
@@ -0,0 +1,37 @@
1
+ import { defineCommand } from "citty";
2
+
3
+ import { runVerification } from "../verification.js";
4
+
5
+ export default defineCommand({
6
+ meta: {
7
+ name: "verify",
8
+ description: "Fast framework-aware verification",
9
+ },
10
+ args: {
11
+ changed: {
12
+ type: "boolean",
13
+ description: "Only check changed files",
14
+ },
15
+ json: {
16
+ type: "boolean",
17
+ description: "Output as JSON",
18
+ },
19
+ },
20
+ async run({ args }) {
21
+ const report = runVerification(process.cwd(), { changed: Boolean(args.changed) });
22
+
23
+ if (args.json) {
24
+ console.log(JSON.stringify(report, null, 2));
25
+ } else {
26
+ console.log(`Pracht verify (${report.mode} mode, ${report.scope} scope)`);
27
+ for (const check of report.checks) {
28
+ console.log(`${check.status.toUpperCase().padEnd(7)} ${check.message}`);
29
+ }
30
+ console.log(report.ok ? "\nNo blocking issues found." : "\nBlocking issues found.");
31
+ }
32
+
33
+ if (!report.ok) {
34
+ process.exitCode = 1;
35
+ }
36
+ },
37
+ });
@@ -0,0 +1,26 @@
1
+ import type { HttpMethod } from "@pracht/core";
2
+
3
+ export type { HttpMethod };
4
+
5
+ export const VERSION = "0.0.0";
6
+
7
+ export const PROJECT_DEFAULTS = {
8
+ apiDir: "/src/api",
9
+ appFile: "/src/routes.ts",
10
+ middlewareDir: "/src/middleware",
11
+ pagesDefaultRender: "ssr",
12
+ pagesDir: "",
13
+ routesDir: "/src/routes",
14
+ serverDir: "/src/server",
15
+ shellsDir: "/src/shells",
16
+ };
17
+
18
+ export const HTTP_METHODS = new Set<HttpMethod>([
19
+ "GET",
20
+ "POST",
21
+ "PUT",
22
+ "PATCH",
23
+ "DELETE",
24
+ "HEAD",
25
+ "OPTIONS",
26
+ ]);
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineCommand, runMain } from "citty";
2
+
3
+ import { VERSION } from "./constants.js";
4
+
5
+ const main = defineCommand({
6
+ meta: {
7
+ name: "pracht",
8
+ version: VERSION,
9
+ description: "The pracht CLI",
10
+ },
11
+ subCommands: {
12
+ build: () => import("./commands/build.js").then((m) => m.default),
13
+ dev: () => import("./commands/dev.js").then((m) => m.default),
14
+ doctor: () => import("./commands/doctor.js").then((m) => m.default),
15
+ generate: () => import("./commands/generate.js").then((m) => m.default),
16
+ inspect: () => import("./commands/inspect.js").then((m) => m.default),
17
+ verify: () => import("./commands/verify.js").then((m) => m.default),
18
+ },
19
+ });
20
+
21
+ runMain(main);
@@ -1,4 +1,4 @@
1
- export function ensureCoreNamedImport(source, name) {
1
+ export function ensureCoreNamedImport(source: string, name: string): string {
2
2
  const match = source.match(/import\s*\{([^}]+)\}\s*from\s*["']@pracht\/core["'];?/);
3
3
  if (!match) {
4
4
  return `import { ${name} } from "@pracht/core";\n${source}`;
@@ -15,7 +15,7 @@ export function ensureCoreNamedImport(source, name) {
15
15
  return source.replace(match[0], `import { ${names.join(", ")} } from "@pracht/core";`);
16
16
  }
17
17
 
18
- export function upsertObjectEntry(source, key, entry) {
18
+ export function upsertObjectEntry(source: string, key: string, entry: string): string {
19
19
  const property = findNamedBlock(source, key, "{", "}");
20
20
  if (!property) {
21
21
  const routesMatch = source.match(/^(\s*)routes\s*:/m);
@@ -31,7 +31,7 @@ export function upsertObjectEntry(source, key, entry) {
31
31
  return insertBlockEntry(source, property, entry);
32
32
  }
33
33
 
34
- export function insertArrayItem(source, key, item) {
34
+ export function insertArrayItem(source: string, key: string, item: string): string {
35
35
  const property = findNamedBlock(source, key, "[", "]");
36
36
  if (!property) {
37
37
  throw new Error(`Could not find "${key}" in the app manifest.`);
@@ -40,7 +40,7 @@ export function insertArrayItem(source, key, item) {
40
40
  return insertBlockEntry(source, property, item);
41
41
  }
42
42
 
43
- export function toManifestModulePath(manifestPath, targetFilePath) {
43
+ export function toManifestModulePath(manifestPath: string, targetFilePath: string): string {
44
44
  const relativePath = targetFilePath
45
45
  .replaceAll("\\", "/")
46
46
  .replace(manifestPath.replaceAll("\\", "/").replace(/\/[^/]+$/, ""), "")
@@ -49,11 +49,14 @@ export function toManifestModulePath(manifestPath, targetFilePath) {
49
49
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
50
50
  }
51
51
 
52
- export function extractRegistryEntries(source, key) {
52
+ export function extractRegistryEntries(
53
+ source: string,
54
+ key: string,
55
+ ): { name: string; path: string }[] {
53
56
  const block = findNamedBlock(source, key, "{", "}");
54
57
  if (!block) return [];
55
58
  const inner = source.slice(block.openIndex + 1, block.closeIndex);
56
- const entries = [];
59
+ const entries: { name: string; path: string }[] = [];
57
60
  const pattern =
58
61
  /([A-Za-z0-9_-]+)\s*:\s*(?:(["'`])([^"'`]+)\2|\(\)\s*=>\s*import\(\s*(["'`])([^"'`]+)\4\s*\))/g;
59
62
 
@@ -64,15 +67,21 @@ export function extractRegistryEntries(source, key) {
64
67
  return entries;
65
68
  }
66
69
 
67
- export function extractRelativeModulePaths(source) {
68
- const results = new Set();
70
+ export function extractRelativeModulePaths(source: string): Set<string> {
71
+ const results = new Set<string>();
69
72
  for (const match of source.matchAll(/["'`]((?:\.\.\/|\.\/)[^"'`]+)["'`]/g)) {
70
73
  results.add(match[1]);
71
74
  }
72
75
  return results;
73
76
  }
74
77
 
75
- function insertBlockEntry(source, block, entry) {
78
+ interface BlockLocation {
79
+ closeIndex: number;
80
+ indent: string;
81
+ openIndex: number;
82
+ }
83
+
84
+ function insertBlockEntry(source: string, block: BlockLocation, entry: string): string {
76
85
  const inner = source.slice(block.openIndex + 1, block.closeIndex);
77
86
  const closingIndent = block.indent;
78
87
  const childIndent = `${closingIndent} `;
@@ -87,7 +96,12 @@ function insertBlockEntry(source, block, entry) {
87
96
  return `${source.slice(0, block.closeIndex)}${insertPrefix}\n${indentMultiline(entry, childIndent)}\n${closingIndent}${source.slice(block.closeIndex)}`;
88
97
  }
89
98
 
90
- function findNamedBlock(source, key, openChar, closeChar) {
99
+ function findNamedBlock(
100
+ source: string,
101
+ key: string,
102
+ openChar: string,
103
+ closeChar: string,
104
+ ): BlockLocation | null {
91
105
  const pattern = new RegExp(`^([ \\t]*)${key}\\s*:\\s*\\${openChar}`, "m");
92
106
  const match = source.match(pattern);
93
107
  if (!match || match.index == null) {
@@ -103,9 +117,14 @@ function findNamedBlock(source, key, openChar, closeChar) {
103
117
  };
104
118
  }
105
119
 
106
- function findMatchingDelimiter(source, openIndex, openChar, closeChar) {
120
+ function findMatchingDelimiter(
121
+ source: string,
122
+ openIndex: number,
123
+ openChar: string,
124
+ closeChar: string,
125
+ ): number {
107
126
  let depth = 0;
108
- let quoteChar = null;
127
+ let quoteChar: string | null = null;
109
128
  let escaping = false;
110
129
 
111
130
  for (let index = openIndex; index < source.length; index += 1) {
@@ -139,7 +158,7 @@ function findMatchingDelimiter(source, openIndex, openChar, closeChar) {
139
158
  throw new Error(`Could not find matching ${closeChar} for ${openChar}.`);
140
159
  }
141
160
 
142
- function indentMultiline(value, indent) {
161
+ function indentMultiline(value: string, indent: string): string {
143
162
  return value
144
163
  .split("\n")
145
164
  .map((line) => `${indent}${line}`)
@@ -1,16 +1,33 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, relative, resolve } from "node:path";
3
3
 
4
- import { ensureTrailingNewline } from "./cli.js";
4
+ import { ensureTrailingNewline } from "./utils.js";
5
5
  import { PROJECT_DEFAULTS } from "./constants.js";
6
6
 
7
- export function readProjectConfig(root) {
7
+ export interface ProjectConfig {
8
+ apiDir: string;
9
+ appFile: string;
10
+ configFile: string | null;
11
+ hasPrachtPlugin: boolean;
12
+ middlewareDir: string;
13
+ mode: "manifest" | "pages";
14
+ pagesDefaultRender: string;
15
+ pagesDir: string;
16
+ rawConfig: string;
17
+ root: string;
18
+ routesDir: string;
19
+ serverDir: string;
20
+ shellsDir: string;
21
+ }
22
+
23
+ export function readProjectConfig(root: string): ProjectConfig {
8
24
  const configFile = findConfigFile(root);
9
25
  const rawConfig = configFile ? readFileSync(configFile, "utf-8") : "";
10
- const config = {
26
+ const config: Record<string, unknown> = {
11
27
  ...PROJECT_DEFAULTS,
12
28
  configFile,
13
29
  hasPrachtPlugin: /\bpracht\s*\(/.test(rawConfig),
30
+ mode: "manifest" as const,
14
31
  rawConfig,
15
32
  root,
16
33
  };
@@ -23,45 +40,56 @@ export function readProjectConfig(root) {
23
40
  }
24
41
 
25
42
  config.mode = config.pagesDir ? "pages" : "manifest";
26
- return config;
43
+ return config as unknown as ProjectConfig;
27
44
  }
28
45
 
29
- export function resolveProjectPath(root, configPath) {
46
+ export function resolveProjectPath(root: string, configPath: string): string {
30
47
  return resolve(root, `.${configPath}`);
31
48
  }
32
49
 
33
- export function resolveScopedFile(root, configDir, fileName) {
50
+ export function resolveScopedFile(root: string, configDir: string, fileName: string): string {
34
51
  return resolve(resolveProjectPath(root, configDir), fileName);
35
52
  }
36
53
 
37
- export function resolveRouteModulePath(project, routePath, extension) {
38
- const segments = segmentsFromRoutePath(routePath);
54
+ export function resolveRouteModulePath(
55
+ project: ProjectConfig,
56
+ routePath: string,
57
+ extension: string,
58
+ ): { absolutePath: string; relativePath: string } {
59
+ const segments = segmentsFromPath(routePath);
39
60
  const relativePath =
40
61
  segments.length === 0 ? `index${extension}` : `${segments.join("/")}${extension}`;
41
62
  const absolutePath = resolve(resolveProjectPath(project.root, project.routesDir), relativePath);
42
63
  return { absolutePath, relativePath };
43
64
  }
44
65
 
45
- export function resolvePagesRouteModulePath(project, routePath, extension) {
46
- const segments = segmentsFromRoutePath(routePath);
66
+ export function resolvePagesRouteModulePath(
67
+ project: ProjectConfig,
68
+ routePath: string,
69
+ extension: string,
70
+ ): { absolutePath: string; relativePath: string } {
71
+ const segments = segmentsFromPath(routePath);
47
72
  const relativePath =
48
73
  segments.length === 0 ? `index${extension}` : `${segments.join("/")}${extension}`;
49
74
  const absolutePath = resolve(resolveProjectPath(project.root, project.pagesDir), relativePath);
50
75
  return { absolutePath, relativePath };
51
76
  }
52
77
 
53
- export function resolveApiModulePath(project, endpointPath) {
54
- const segments = segmentsFromApiPath(endpointPath);
78
+ export function resolveApiModulePath(
79
+ project: ProjectConfig,
80
+ endpointPath: string,
81
+ ): { absolutePath: string; relativePath: string } {
82
+ const segments = segmentsFromPath(endpointPath);
55
83
  const relativePath = segments.length === 0 ? "index.ts" : `${segments.join("/")}.ts`;
56
84
  const absolutePath = resolve(resolveProjectPath(project.root, project.apiDir), relativePath);
57
85
  return { absolutePath, relativePath };
58
86
  }
59
87
 
60
- export function displayPath(root, filePath) {
88
+ export function displayPath(root: string, filePath: string): string {
61
89
  return relative(root, filePath) || ".";
62
90
  }
63
91
 
64
- export function writeGeneratedFile(filePath, source) {
92
+ export function writeGeneratedFile(filePath: string, source: string): void {
65
93
  if (existsSync(filePath)) {
66
94
  throw new Error(`Refusing to overwrite existing file ${filePath}.`);
67
95
  }
@@ -70,14 +98,14 @@ export function writeGeneratedFile(filePath, source) {
70
98
  writeFileSync(filePath, ensureTrailingNewline(source), "utf-8");
71
99
  }
72
100
 
73
- export function assertFileExists(filePath, message) {
101
+ export function assertFileExists(filePath: string, message: string): void {
74
102
  if (!existsSync(filePath)) {
75
103
  throw new Error(message);
76
104
  }
77
105
  }
78
106
 
79
- export function listFilesRecursively(dir) {
80
- const files = [];
107
+ export function listFilesRecursively(dir: string): string[] {
108
+ const files: string[] = [];
81
109
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
82
110
  const fullPath = resolve(dir, entry.name);
83
111
  if (entry.isDirectory()) {
@@ -89,11 +117,11 @@ export function listFilesRecursively(dir) {
89
117
  return files;
90
118
  }
91
119
 
92
- export function hasPagesAppShell(filePath) {
120
+ export function hasPagesAppShell(filePath: string): boolean {
93
121
  return /^_app\.(ts|tsx|js|jsx)$/.test(basename(filePath));
94
122
  }
95
123
 
96
- function findConfigFile(root) {
124
+ function findConfigFile(root: string): string | null {
97
125
  for (const name of [
98
126
  "vite.config.ts",
99
127
  "vite.config.mts",
@@ -108,31 +136,20 @@ function findConfigFile(root) {
108
136
  return null;
109
137
  }
110
138
 
111
- function readQuotedConfigValue(source, key) {
139
+ function readQuotedConfigValue(source: string, key: string): string | null {
112
140
  if (!source) return null;
113
141
  const pattern = new RegExp(`${key}\\s*:\\s*(["'\\\`])([^"'\\\`]+)\\1`);
114
142
  const match = source.match(pattern);
115
143
  return match ? match[2] : null;
116
144
  }
117
145
 
118
- function normalizeConfigPath(value) {
146
+ function normalizeConfigPath(value: string): string {
119
147
  if (!value) return value;
120
148
  return value.startsWith("/") ? value : `/${value}`;
121
149
  }
122
150
 
123
- function segmentsFromRoutePath(routePath) {
124
- return routePath
125
- .split("/")
126
- .filter(Boolean)
127
- .map((segment) => {
128
- if (segment.startsWith(":")) return `[${segment.slice(1)}]`;
129
- if (segment === "*") return "[...slug]";
130
- return segment;
131
- });
132
- }
133
-
134
- function segmentsFromApiPath(endpointPath) {
135
- return endpointPath
151
+ function segmentsFromPath(path: string): string[] {
152
+ return path
136
153
  .split("/")
137
154
  .filter(Boolean)
138
155
  .map((segment) => {
package/src/utils.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { HTTP_METHODS, type HttpMethod } from "./constants.js";
2
+
3
+ export function quote(value: string): string {
4
+ return JSON.stringify(value);
5
+ }
6
+
7
+ export function ensureTrailingNewline(value: string): string {
8
+ return value.endsWith("\n") ? value : `${value}\n`;
9
+ }
10
+
11
+ export function parseCommaList(value: string | string[] | boolean | undefined): string[] {
12
+ if (!value || typeof value === "boolean") return [];
13
+ const values = Array.isArray(value) ? value : [value];
14
+ return values
15
+ .flatMap((entry) => String(entry).split(","))
16
+ .map((entry) => entry.trim())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ export function parseApiMethods(value: string | string[] | boolean | undefined): HttpMethod[] {
21
+ const methods = parseCommaList(value);
22
+ const normalized =
23
+ methods.length === 0
24
+ ? (["GET"] as HttpMethod[])
25
+ : methods.map((entry) => entry.toUpperCase() as HttpMethod);
26
+
27
+ for (const method of normalized) {
28
+ if (!HTTP_METHODS.has(method)) {
29
+ throw new Error(`Unsupported HTTP method "${method}".`);
30
+ }
31
+ }
32
+
33
+ return [...new Set(normalized)];
34
+ }
35
+
36
+ export function requireEnum(
37
+ value: string | undefined,
38
+ key: string,
39
+ allowed: string[],
40
+ fallback: string,
41
+ ): string {
42
+ const val = value ?? fallback;
43
+ if (!allowed.includes(val)) {
44
+ throw new Error(`Invalid value for --${key}. Expected one of ${allowed.join(", ")}.`);
45
+ }
46
+ return val;
47
+ }
48
+
49
+ export function requirePositiveInteger(
50
+ value: string | undefined,
51
+ key: string,
52
+ fallback: number,
53
+ ): number {
54
+ const parsed = value == null ? fallback : Number.parseInt(value, 10);
55
+ if (!Number.isInteger(parsed) || parsed <= 0) {
56
+ throw new Error(`--${key} must be a positive integer.`);
57
+ }
58
+ return parsed;
59
+ }
60
+
61
+ export function handleCliError(error: unknown, { json }: { json: boolean }): never {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ if (json) {
64
+ console.error(JSON.stringify({ ok: false, error: message }, null, 2));
65
+ } else {
66
+ console.error(message);
67
+ if (error instanceof Error && error.stack && process.env.DEBUG) {
68
+ console.error(error.stack);
69
+ }
70
+ }
71
+ process.exit(1);
72
+ }