@pracht/cli 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ const cliPath = fileURLToPath(new URL("../bin/pracht.js", import.meta.url));
10
+ const tempDirs = [];
11
+
12
+ afterEach(() => {
13
+ while (tempDirs.length > 0) {
14
+ rmSync(tempDirs.pop(), { force: true, recursive: true });
15
+ }
16
+ });
17
+
18
+ describe("@pracht/cli", () => {
19
+ it("scaffolds shell, middleware, route, and api modules for manifest apps", () => {
20
+ const appDir = createTempDir("pracht-cli-manifest-");
21
+ writeManifestApp(appDir);
22
+
23
+ runCli(["generate", "shell", "--name", "app"], { cwd: appDir });
24
+ runCli(["generate", "middleware", "--name", "auth"], { cwd: appDir });
25
+
26
+ const apiResult = runCli(
27
+ ["generate", "api", "--path", "/health", "--methods", "GET,POST", "--json"],
28
+ { cwd: appDir },
29
+ );
30
+ const apiJson = JSON.parse(apiResult.stdout);
31
+
32
+ runCli(
33
+ [
34
+ "generate",
35
+ "route",
36
+ "--path",
37
+ "/dashboard",
38
+ "--render",
39
+ "isg",
40
+ "--revalidate",
41
+ "120",
42
+ "--shell",
43
+ "app",
44
+ "--middleware",
45
+ "auth",
46
+ "--loader",
47
+ "--error-boundary",
48
+ ],
49
+ { cwd: appDir },
50
+ );
51
+
52
+ const manifest = readFileSync(join(appDir, "src/routes.ts"), "utf-8");
53
+ const shellSource = readFileSync(join(appDir, "src/shells/app.tsx"), "utf-8");
54
+ const middlewareSource = readFileSync(join(appDir, "src/middleware/auth.ts"), "utf-8");
55
+ const routeSource = readFileSync(join(appDir, "src/routes/dashboard.tsx"), "utf-8");
56
+ const apiSource = readFileSync(join(appDir, "src/api/health.ts"), "utf-8");
57
+
58
+ expect(apiJson).toMatchObject({
59
+ created: ["src/api/health.ts"],
60
+ kind: "api",
61
+ ok: true,
62
+ updated: [],
63
+ });
64
+ expect(shellSource).toContain("export function Shell({ children }: ShellProps)");
65
+ expect(middlewareSource).toContain("export const middleware: MiddlewareFn");
66
+ expect(routeSource).toContain("export async function loader(_args: LoaderArgs)");
67
+ expect(routeSource).toContain("export function ErrorBoundary({ error }: ErrorBoundaryProps)");
68
+ expect(apiSource).toContain("export function GET(_args: BaseRouteArgs)");
69
+ expect(apiSource).toContain("export async function POST({ request }: BaseRouteArgs)");
70
+ expect(manifest).toContain('import { defineApp, route, timeRevalidate } from "@pracht/core";');
71
+ expect(manifest).toContain('shells: {\n app: "./shells/app.tsx",\n },');
72
+ expect(manifest).toContain('middleware: {\n auth: "./middleware/auth.ts",\n },');
73
+ expect(manifest).toContain('route("/dashboard", "./routes/dashboard.tsx", {');
74
+ expect(manifest).toContain('shell: "app",');
75
+ expect(manifest).toContain('middleware: ["auth"],');
76
+ expect(manifest).toContain("revalidate: timeRevalidate(120)");
77
+ });
78
+
79
+ it("reports a healthy manifest app in doctor json output", () => {
80
+ const appDir = createTempDir("pracht-cli-doctor-ok-");
81
+ writeManifestApp(appDir, {
82
+ routesSource: `import { defineApp, route } from "@pracht/core";
83
+
84
+ export const app = defineApp({
85
+ shells: {
86
+ app: "./shells/app.tsx",
87
+ },
88
+ middleware: {
89
+ auth: "./middleware/auth.ts",
90
+ },
91
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", shell: "app", middleware: ["auth"], render: "ssr" })],
92
+ });
93
+ `,
94
+ });
95
+
96
+ writeProjectFile(
97
+ appDir,
98
+ "src/shells/app.tsx",
99
+ `import type { ShellProps } from "@pracht/core";
100
+
101
+ export function Shell({ children }: ShellProps) {
102
+ return <main>{children}</main>;
103
+ }
104
+ `,
105
+ );
106
+ writeProjectFile(
107
+ appDir,
108
+ "src/middleware/auth.ts",
109
+ `import type { MiddlewareFn } from "@pracht/core";
110
+
111
+ export const middleware: MiddlewareFn = async () => {
112
+ return;
113
+ };
114
+ `,
115
+ );
116
+ writeProjectFile(
117
+ appDir,
118
+ "src/routes/dashboard.tsx",
119
+ `export function Component() {
120
+ return <h1>Dashboard</h1>;
121
+ }
122
+ `,
123
+ );
124
+
125
+ const result = runCli(["doctor", "--json"], { cwd: appDir });
126
+ const report = JSON.parse(result.stdout);
127
+
128
+ expect(report.ok).toBe(true);
129
+ expect(report.mode).toBe("manifest");
130
+ expect(report.checks.some((check) => check.message.includes("app manifest"))).toBe(true);
131
+ expect(report.checks.some((check) => check.message.includes("adapter dependency"))).toBe(true);
132
+ });
133
+
134
+ it("reports blocking doctor failures for broken manifest references", () => {
135
+ const appDir = createTempDir("pracht-cli-doctor-bad-");
136
+ writeManifestApp(appDir, {
137
+ routesSource: `import { defineApp, route } from "@pracht/core";
138
+
139
+ export const app = defineApp({
140
+ routes: [route("/broken", "./routes/missing.tsx", { id: "broken", render: "ssr" })],
141
+ });
142
+ `,
143
+ });
144
+
145
+ const result = runCliStatus(["doctor", "--json"], { cwd: appDir });
146
+ expect(result.status).toBe(1);
147
+
148
+ const report = JSON.parse(result.stdout);
149
+ expect(report.ok).toBe(false);
150
+ expect(report.checks.some((check) => check.message.includes("missing files"))).toBe(true);
151
+ });
152
+
153
+ it("reports a healthy manifest app in verify json output", () => {
154
+ const appDir = createTempDir("pracht-cli-verify-ok-");
155
+ writeManifestApp(appDir, {
156
+ routesSource: `import { defineApp, route } from "@pracht/core";
157
+
158
+ export const app = defineApp({
159
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", render: "ssr" })],
160
+ });
161
+ `,
162
+ });
163
+
164
+ writeProjectFile(
165
+ appDir,
166
+ "src/routes/dashboard.tsx",
167
+ `export function Component() {
168
+ return <h1>Dashboard</h1>;
169
+ }
170
+ `,
171
+ );
172
+ writeProjectFile(
173
+ appDir,
174
+ "src/api/health.ts",
175
+ `export function GET() {
176
+ return new Response("ok");
177
+ }
178
+ `,
179
+ );
180
+
181
+ const result = runCli(["verify", "--json"], { cwd: appDir });
182
+ const report = JSON.parse(result.stdout);
183
+
184
+ expect(report.ok).toBe(true);
185
+ expect(report.scope).toBe("full");
186
+ expect(report.checks.some((check) => check.message.includes("manifest module path"))).toBe(
187
+ true,
188
+ );
189
+ expect(report.checks.some((check) => check.message.includes("API route discovery"))).toBe(true);
190
+ });
191
+
192
+ it("reports duplicate API discovery failures in verify json output", () => {
193
+ const appDir = createTempDir("pracht-cli-verify-api-dupe-");
194
+ writeManifestApp(appDir);
195
+
196
+ writeProjectFile(
197
+ appDir,
198
+ "src/api/users.ts",
199
+ `export function GET() {
200
+ return new Response("users");
201
+ }
202
+ `,
203
+ );
204
+ writeProjectFile(
205
+ appDir,
206
+ "src/api/users/index.ts",
207
+ `export function GET() {
208
+ return new Response("users-index");
209
+ }
210
+ `,
211
+ );
212
+
213
+ const result = runCliStatus(["verify", "--json"], { cwd: appDir });
214
+ expect(result.status).toBe(1);
215
+
216
+ const report = JSON.parse(result.stdout);
217
+ expect(report.ok).toBe(false);
218
+ expect(report.checks.some((check) => check.message.includes("duplicate paths"))).toBe(true);
219
+ });
220
+
221
+ it("limits verify --changed to changed framework files", () => {
222
+ const appDir = createTempDir("pracht-cli-verify-changed-");
223
+ writeManifestApp(appDir, {
224
+ routesSource: `import { defineApp, route } from "@pracht/core";
225
+
226
+ export const app = defineApp({
227
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", render: "ssr" })],
228
+ });
229
+ `,
230
+ });
231
+
232
+ writeProjectFile(
233
+ appDir,
234
+ "src/routes/dashboard.tsx",
235
+ `export function Component() {
236
+ return <h1>Dashboard</h1>;
237
+ }
238
+ `,
239
+ );
240
+
241
+ initializeGitRepo(appDir);
242
+
243
+ writeProjectFile(
244
+ appDir,
245
+ "src/routes/dashboard.tsx",
246
+ `export function Component() {
247
+ return <h1>Updated dashboard</h1>;
248
+ }
249
+ `,
250
+ );
251
+ writeProjectFile(appDir, "notes.txt", "ignored");
252
+
253
+ const result = runCli(["verify", "--changed", "--json"], { cwd: appDir });
254
+ const report = JSON.parse(result.stdout);
255
+
256
+ expect(report.ok).toBe(true);
257
+ expect(report.scope).toBe("changed");
258
+ expect(report.frameworkFiles).toContain("src/routes/dashboard.tsx");
259
+ expect(report.frameworkFiles).not.toContain("notes.txt");
260
+ expect(
261
+ report.checks.some(
262
+ (check) =>
263
+ check.message.includes("Changed route module") &&
264
+ check.message.includes("src/routes/dashboard.tsx"),
265
+ ),
266
+ ).toBe(true);
267
+ });
268
+
269
+ it("scaffolds pages-router routes without touching a manifest", () => {
270
+ const appDir = createTempDir("pracht-cli-pages-");
271
+ writePagesApp(appDir);
272
+
273
+ runCli(["generate", "route", "--path", "/blog/:slug", "--render", "ssg", "--loader"], {
274
+ cwd: appDir,
275
+ });
276
+
277
+ const routePath = join(appDir, "src/pages/blog/[slug].tsx");
278
+ expect(existsSync(routePath)).toBe(true);
279
+
280
+ const routeSource = readFileSync(routePath, "utf-8");
281
+ expect(routeSource).toContain('export const RENDER_MODE = "ssg";');
282
+ expect(routeSource).toContain("export function getStaticPaths()");
283
+ expect(routeSource).toContain('slug: "example-slug"');
284
+ });
285
+ });
286
+
287
+ function createTempDir(prefix) {
288
+ const dir = mkdtempSync(join(tmpdir(), prefix));
289
+ tempDirs.push(dir);
290
+ return dir;
291
+ }
292
+
293
+ function runCli(args, { cwd }) {
294
+ return {
295
+ stdout: execFileSync(process.execPath, [cliPath, ...args], {
296
+ cwd,
297
+ encoding: "utf-8",
298
+ env: process.env,
299
+ stdio: ["ignore", "pipe", "pipe"],
300
+ }),
301
+ };
302
+ }
303
+
304
+ function runCliStatus(args, { cwd }) {
305
+ return spawnSync(process.execPath, [cliPath, ...args], {
306
+ cwd,
307
+ encoding: "utf-8",
308
+ env: process.env,
309
+ });
310
+ }
311
+
312
+ function initializeGitRepo(appDir) {
313
+ execFileSync("git", ["init"], {
314
+ cwd: appDir,
315
+ env: process.env,
316
+ stdio: "ignore",
317
+ });
318
+ execFileSync("git", ["config", "user.email", "test@example.com"], {
319
+ cwd: appDir,
320
+ env: process.env,
321
+ stdio: "ignore",
322
+ });
323
+ execFileSync("git", ["config", "user.name", "Pracht Tests"], {
324
+ cwd: appDir,
325
+ env: process.env,
326
+ stdio: "ignore",
327
+ });
328
+ execFileSync("git", ["add", "."], {
329
+ cwd: appDir,
330
+ env: process.env,
331
+ stdio: "ignore",
332
+ });
333
+ execFileSync("git", ["commit", "-m", "initial"], {
334
+ cwd: appDir,
335
+ env: process.env,
336
+ stdio: "ignore",
337
+ });
338
+ }
339
+
340
+ function writeManifestApp(appDir, { routesSource } = {}) {
341
+ writeProjectFile(
342
+ appDir,
343
+ "package.json",
344
+ JSON.stringify(
345
+ {
346
+ name: "fixture-app",
347
+ private: true,
348
+ dependencies: {
349
+ "@pracht/adapter-node": "workspace:*",
350
+ "@pracht/cli": "workspace:*",
351
+ },
352
+ },
353
+ null,
354
+ 2,
355
+ ),
356
+ );
357
+ writeProjectFile(
358
+ appDir,
359
+ "vite.config.ts",
360
+ `import { defineConfig } from "vite";
361
+ import { pracht } from "@pracht/vite-plugin";
362
+
363
+ export default defineConfig(async () => ({
364
+ plugins: [await pracht()],
365
+ }));
366
+ `,
367
+ );
368
+ writeProjectFile(
369
+ appDir,
370
+ "src/routes.ts",
371
+ routesSource ??
372
+ `import { defineApp } from "@pracht/core";
373
+
374
+ export const app = defineApp({
375
+ routes: [],
376
+ });
377
+ `,
378
+ );
379
+ }
380
+
381
+ function writePagesApp(appDir) {
382
+ writeProjectFile(
383
+ appDir,
384
+ "package.json",
385
+ JSON.stringify(
386
+ {
387
+ name: "fixture-pages-app",
388
+ private: true,
389
+ dependencies: {
390
+ "@pracht/adapter-node": "workspace:*",
391
+ "@pracht/cli": "workspace:*",
392
+ },
393
+ },
394
+ null,
395
+ 2,
396
+ ),
397
+ );
398
+ writeProjectFile(
399
+ appDir,
400
+ "vite.config.ts",
401
+ `import { defineConfig } from "vite";
402
+ import { pracht } from "@pracht/vite-plugin";
403
+
404
+ export default defineConfig(async () => ({
405
+ plugins: [await pracht({ pagesDir: "/src/pages" })],
406
+ }));
407
+ `,
408
+ );
409
+ }
410
+
411
+ function writeProjectFile(appDir, relativePath, contents) {
412
+ const filePath = resolve(appDir, relativePath);
413
+ mkdirSync(dirname(filePath), { recursive: true });
414
+ writeFileSync(filePath, contents.endsWith("\n") ? contents : `${contents}\n`, "utf-8");
415
+ }