@pracht/cli 0.0.1 → 1.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.
@@ -0,0 +1,633 @@
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, pathToFileURL } 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 repoRoot = resolve(dirname(cliPath), "../../..");
11
+ const repoTempRoot = resolve(dirname(cliPath), "../test/.tmp");
12
+ const coreImportPath = resolve(repoRoot, "packages/framework/src/index.ts");
13
+ const nodeAdapterImportPath = resolve(repoRoot, "packages/adapter-node/src/index.ts");
14
+ const vitePluginImportPath = resolve(repoRoot, "packages/vite-plugin/src/index.ts");
15
+ const tempDirs = [];
16
+
17
+ afterEach(() => {
18
+ while (tempDirs.length > 0) {
19
+ rmSync(tempDirs.pop(), { force: true, recursive: true });
20
+ }
21
+ });
22
+
23
+ describe("@pracht/cli", () => {
24
+ it("scaffolds shell, middleware, route, and api modules for manifest apps", () => {
25
+ const appDir = createTempDir("pracht-cli-manifest-");
26
+ writeManifestApp(appDir);
27
+
28
+ runCli(["generate", "shell", "--name", "app"], { cwd: appDir });
29
+ runCli(["generate", "middleware", "--name", "auth"], { cwd: appDir });
30
+
31
+ const apiResult = runCli(
32
+ ["generate", "api", "--path", "/health", "--methods", "GET,POST", "--json"],
33
+ { cwd: appDir },
34
+ );
35
+ const apiJson = JSON.parse(apiResult.stdout);
36
+
37
+ runCli(
38
+ [
39
+ "generate",
40
+ "route",
41
+ "--path",
42
+ "/dashboard",
43
+ "--render",
44
+ "isg",
45
+ "--revalidate",
46
+ "120",
47
+ "--shell",
48
+ "app",
49
+ "--middleware",
50
+ "auth",
51
+ "--loader",
52
+ "--error-boundary",
53
+ ],
54
+ { cwd: appDir },
55
+ );
56
+
57
+ const manifest = readFileSync(join(appDir, "src/routes.ts"), "utf-8");
58
+ const shellSource = readFileSync(join(appDir, "src/shells/app.tsx"), "utf-8");
59
+ const middlewareSource = readFileSync(join(appDir, "src/middleware/auth.ts"), "utf-8");
60
+ const routeSource = readFileSync(join(appDir, "src/routes/dashboard.tsx"), "utf-8");
61
+ const apiSource = readFileSync(join(appDir, "src/api/health.ts"), "utf-8");
62
+
63
+ expect(apiJson).toMatchObject({
64
+ created: ["src/api/health.ts"],
65
+ kind: "api",
66
+ ok: true,
67
+ updated: [],
68
+ });
69
+ expect(shellSource).toContain("export function Shell({ children }: ShellProps)");
70
+ expect(middlewareSource).toContain("export const middleware: MiddlewareFn");
71
+ expect(routeSource).toContain("export async function loader(_args: LoaderArgs)");
72
+ expect(routeSource).toContain("export function ErrorBoundary({ error }: ErrorBoundaryProps)");
73
+ expect(apiSource).toContain("export function GET(_args: BaseRouteArgs)");
74
+ expect(apiSource).toContain("export async function POST({ request }: BaseRouteArgs)");
75
+ expect(manifest).toContain('import { defineApp, route, timeRevalidate } from "@pracht/core";');
76
+ expect(manifest).toContain('shells: {\n app: "./shells/app.tsx",\n },');
77
+ expect(manifest).toContain('middleware: {\n auth: "./middleware/auth.ts",\n },');
78
+ expect(manifest).toContain('route("/dashboard", "./routes/dashboard.tsx", {');
79
+ expect(manifest).toContain('shell: "app",');
80
+ expect(manifest).toContain('middleware: ["auth"],');
81
+ expect(manifest).toContain("revalidate: timeRevalidate(120)");
82
+ });
83
+
84
+ it("reports a healthy manifest app in doctor json output", () => {
85
+ const appDir = createTempDir("pracht-cli-doctor-ok-");
86
+ writeManifestApp(appDir, {
87
+ routesSource: `import { defineApp, route } from "@pracht/core";
88
+
89
+ export const app = defineApp({
90
+ shells: {
91
+ app: "./shells/app.tsx",
92
+ },
93
+ middleware: {
94
+ auth: "./middleware/auth.ts",
95
+ },
96
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", shell: "app", middleware: ["auth"], render: "ssr" })],
97
+ });
98
+ `,
99
+ });
100
+
101
+ writeProjectFile(
102
+ appDir,
103
+ "src/shells/app.tsx",
104
+ `import type { ShellProps } from "@pracht/core";
105
+
106
+ export function Shell({ children }: ShellProps) {
107
+ return <main>{children}</main>;
108
+ }
109
+ `,
110
+ );
111
+ writeProjectFile(
112
+ appDir,
113
+ "src/middleware/auth.ts",
114
+ `import type { MiddlewareFn } from "@pracht/core";
115
+
116
+ export const middleware: MiddlewareFn = async () => {
117
+ return;
118
+ };
119
+ `,
120
+ );
121
+ writeProjectFile(
122
+ appDir,
123
+ "src/routes/dashboard.tsx",
124
+ `export function Component() {
125
+ return <h1>Dashboard</h1>;
126
+ }
127
+ `,
128
+ );
129
+
130
+ const result = runCli(["doctor", "--json"], { cwd: appDir });
131
+ const report = JSON.parse(result.stdout);
132
+
133
+ expect(report.ok).toBe(true);
134
+ expect(report.mode).toBe("manifest");
135
+ expect(report.checks.some((check) => check.message.includes("app manifest"))).toBe(true);
136
+ expect(report.checks.some((check) => check.message.includes("adapter dependency"))).toBe(true);
137
+ });
138
+
139
+ it("reports blocking doctor failures for broken manifest references", () => {
140
+ const appDir = createTempDir("pracht-cli-doctor-bad-");
141
+ writeManifestApp(appDir, {
142
+ routesSource: `import { defineApp, route } from "@pracht/core";
143
+
144
+ export const app = defineApp({
145
+ routes: [route("/broken", "./routes/missing.tsx", { id: "broken", render: "ssr" })],
146
+ });
147
+ `,
148
+ });
149
+
150
+ const result = runCliStatus(["doctor", "--json"], { cwd: appDir });
151
+ expect(result.status).toBe(1);
152
+
153
+ const report = JSON.parse(result.stdout);
154
+ expect(report.ok).toBe(false);
155
+ expect(report.checks.some((check) => check.message.includes("missing files"))).toBe(true);
156
+ });
157
+
158
+ it("reports a healthy manifest app in verify json output", () => {
159
+ const appDir = createTempDir("pracht-cli-verify-ok-");
160
+ writeManifestApp(appDir, {
161
+ routesSource: `import { defineApp, route } from "@pracht/core";
162
+
163
+ export const app = defineApp({
164
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", render: "ssr" })],
165
+ });
166
+ `,
167
+ });
168
+
169
+ writeProjectFile(
170
+ appDir,
171
+ "src/routes/dashboard.tsx",
172
+ `export function Component() {
173
+ return <h1>Dashboard</h1>;
174
+ }
175
+ `,
176
+ );
177
+ writeProjectFile(
178
+ appDir,
179
+ "src/api/health.ts",
180
+ `export function GET() {
181
+ return new Response("ok");
182
+ }
183
+ `,
184
+ );
185
+
186
+ const result = runCli(["verify", "--json"], { cwd: appDir });
187
+ const report = JSON.parse(result.stdout);
188
+
189
+ expect(report.ok).toBe(true);
190
+ expect(report.scope).toBe("full");
191
+ expect(report.checks.some((check) => check.message.includes("manifest module path"))).toBe(
192
+ true,
193
+ );
194
+ expect(report.checks.some((check) => check.message.includes("API route discovery"))).toBe(true);
195
+ });
196
+
197
+ it("reports duplicate API discovery failures in verify json output", () => {
198
+ const appDir = createTempDir("pracht-cli-verify-api-dupe-");
199
+ writeManifestApp(appDir);
200
+
201
+ writeProjectFile(
202
+ appDir,
203
+ "src/api/users.ts",
204
+ `export function GET() {
205
+ return new Response("users");
206
+ }
207
+ `,
208
+ );
209
+ writeProjectFile(
210
+ appDir,
211
+ "src/api/users/index.ts",
212
+ `export function GET() {
213
+ return new Response("users-index");
214
+ }
215
+ `,
216
+ );
217
+
218
+ const result = runCliStatus(["verify", "--json"], { cwd: appDir });
219
+ expect(result.status).toBe(1);
220
+
221
+ const report = JSON.parse(result.stdout);
222
+ expect(report.ok).toBe(false);
223
+ expect(report.checks.some((check) => check.message.includes("duplicate paths"))).toBe(true);
224
+ });
225
+
226
+ it("limits verify --changed to changed framework files", () => {
227
+ const appDir = createTempDir("pracht-cli-verify-changed-");
228
+ writeManifestApp(appDir, {
229
+ routesSource: `import { defineApp, route } from "@pracht/core";
230
+
231
+ export const app = defineApp({
232
+ routes: [route("/dashboard", "./routes/dashboard.tsx", { id: "dashboard", render: "ssr" })],
233
+ });
234
+ `,
235
+ });
236
+
237
+ writeProjectFile(
238
+ appDir,
239
+ "src/routes/dashboard.tsx",
240
+ `export function Component() {
241
+ return <h1>Dashboard</h1>;
242
+ }
243
+ `,
244
+ );
245
+
246
+ initializeGitRepo(appDir);
247
+
248
+ writeProjectFile(
249
+ appDir,
250
+ "src/routes/dashboard.tsx",
251
+ `export function Component() {
252
+ return <h1>Updated dashboard</h1>;
253
+ }
254
+ `,
255
+ );
256
+ writeProjectFile(appDir, "notes.txt", "ignored");
257
+
258
+ const result = runCli(["verify", "--changed", "--json"], { cwd: appDir });
259
+ const report = JSON.parse(result.stdout);
260
+
261
+ expect(report.ok).toBe(true);
262
+ expect(report.scope).toBe("changed");
263
+ expect(report.frameworkFiles).toContain("src/routes/dashboard.tsx");
264
+ expect(report.frameworkFiles).not.toContain("notes.txt");
265
+ expect(
266
+ report.checks.some(
267
+ (check) =>
268
+ check.message.includes("Changed route module") &&
269
+ check.message.includes("src/routes/dashboard.tsx"),
270
+ ),
271
+ ).toBe(true);
272
+ });
273
+
274
+ it("inspects resolved routes, api handlers, and build metadata as JSON", () => {
275
+ const appDir = createRepoTempDir("pracht-cli-inspect-");
276
+ writeInspectableManifestApp(appDir);
277
+
278
+ const routes = JSON.parse(runCli(["inspect", "routes", "--json"], { cwd: appDir }).stdout);
279
+ const api = JSON.parse(runCli(["inspect", "api", "--json"], { cwd: appDir }).stdout);
280
+ const build = JSON.parse(runCli(["inspect", "build", "--json"], { cwd: appDir }).stdout);
281
+ const all = JSON.parse(runCli(["inspect", "--json"], { cwd: appDir }).stdout);
282
+
283
+ expect(routes).toEqual({
284
+ mode: "manifest",
285
+ routes: [
286
+ {
287
+ file: "./routes/dashboard.tsx",
288
+ id: "dashboard",
289
+ loaderFile: "./server/dashboard-loader.ts",
290
+ middleware: ["auth"],
291
+ path: "/dashboard",
292
+ render: "isg",
293
+ revalidate: {
294
+ kind: "time",
295
+ seconds: 60,
296
+ },
297
+ shell: "app",
298
+ shellFile: "./shells/app.tsx",
299
+ },
300
+ ],
301
+ });
302
+
303
+ expect(api).toEqual({
304
+ api: [
305
+ {
306
+ file: "/src/api/health.ts",
307
+ methods: ["GET", "POST"],
308
+ path: "/api/health",
309
+ },
310
+ ],
311
+ mode: "manifest",
312
+ });
313
+
314
+ expect(build).toEqual({
315
+ build: {
316
+ adapterTarget: "node",
317
+ clientEntryUrl: "/assets/client.js",
318
+ cssManifest: {
319
+ "src/routes/dashboard.tsx": ["/assets/dashboard.css"],
320
+ "src/shells/app.tsx": ["/assets/app.css"],
321
+ },
322
+ jsManifest: {
323
+ "src/routes/dashboard.tsx": ["/assets/dashboard.js", "/assets/vendor.js"],
324
+ "src/shells/app.tsx": ["/assets/app.js", "/assets/vendor.js"],
325
+ },
326
+ },
327
+ mode: "manifest",
328
+ });
329
+
330
+ expect(all).toEqual({
331
+ ...routes,
332
+ ...api,
333
+ ...build,
334
+ });
335
+ });
336
+
337
+ it("scaffolds pages-router routes without touching a manifest", () => {
338
+ const appDir = createTempDir("pracht-cli-pages-");
339
+ writePagesApp(appDir);
340
+
341
+ runCli(["generate", "route", "--path", "/blog/:slug", "--render", "ssg", "--loader"], {
342
+ cwd: appDir,
343
+ });
344
+
345
+ const routePath = join(appDir, "src/pages/blog/[slug].tsx");
346
+ expect(existsSync(routePath)).toBe(true);
347
+
348
+ const routeSource = readFileSync(routePath, "utf-8");
349
+ expect(routeSource).toContain('export const RENDER_MODE = "ssg";');
350
+ expect(routeSource).toContain("export function getStaticPaths()");
351
+ expect(routeSource).toContain('slug: "example-slug"');
352
+ });
353
+ });
354
+
355
+ function createTempDir(prefix) {
356
+ const dir = mkdtempSync(join(tmpdir(), prefix));
357
+ tempDirs.push(dir);
358
+ return dir;
359
+ }
360
+
361
+ function createRepoTempDir(prefix) {
362
+ mkdirSync(repoTempRoot, { recursive: true });
363
+ const dir = mkdtempSync(join(repoTempRoot, prefix));
364
+ tempDirs.push(dir);
365
+ return dir;
366
+ }
367
+
368
+ function runCli(args, { cwd }) {
369
+ return {
370
+ stdout: execFileSync(process.execPath, [cliPath, ...args], {
371
+ cwd,
372
+ encoding: "utf-8",
373
+ env: process.env,
374
+ stdio: ["ignore", "pipe", "pipe"],
375
+ }),
376
+ };
377
+ }
378
+
379
+ function runCliStatus(args, { cwd }) {
380
+ return spawnSync(process.execPath, [cliPath, ...args], {
381
+ cwd,
382
+ encoding: "utf-8",
383
+ env: process.env,
384
+ });
385
+ }
386
+
387
+ function initializeGitRepo(appDir) {
388
+ execFileSync("git", ["init"], {
389
+ cwd: appDir,
390
+ env: process.env,
391
+ stdio: "ignore",
392
+ });
393
+ execFileSync("git", ["config", "user.email", "test@example.com"], {
394
+ cwd: appDir,
395
+ env: process.env,
396
+ stdio: "ignore",
397
+ });
398
+ execFileSync("git", ["config", "user.name", "Pracht Tests"], {
399
+ cwd: appDir,
400
+ env: process.env,
401
+ stdio: "ignore",
402
+ });
403
+ execFileSync("git", ["add", "."], {
404
+ cwd: appDir,
405
+ env: process.env,
406
+ stdio: "ignore",
407
+ });
408
+ execFileSync("git", ["commit", "-m", "initial"], {
409
+ cwd: appDir,
410
+ env: process.env,
411
+ stdio: "ignore",
412
+ });
413
+ }
414
+
415
+ function writeManifestApp(appDir, { routesSource } = {}) {
416
+ writeProjectFile(
417
+ appDir,
418
+ "package.json",
419
+ JSON.stringify(
420
+ {
421
+ name: "fixture-app",
422
+ private: true,
423
+ dependencies: {
424
+ "@pracht/adapter-node": "workspace:*",
425
+ "@pracht/cli": "workspace:*",
426
+ },
427
+ },
428
+ null,
429
+ 2,
430
+ ),
431
+ );
432
+ writeProjectFile(
433
+ appDir,
434
+ "vite.config.ts",
435
+ `import { defineConfig } from "vite";
436
+ import { pracht } from "@pracht/vite-plugin";
437
+
438
+ export default defineConfig(async () => ({
439
+ plugins: [await pracht()],
440
+ }));
441
+ `,
442
+ );
443
+ writeProjectFile(
444
+ appDir,
445
+ "src/routes.ts",
446
+ routesSource ??
447
+ `import { defineApp } from "@pracht/core";
448
+
449
+ export const app = defineApp({
450
+ routes: [],
451
+ });
452
+ `,
453
+ );
454
+ }
455
+
456
+ function writeInspectableManifestApp(appDir) {
457
+ const vitePluginImport = pathToFileURL(vitePluginImportPath).href;
458
+
459
+ writeProjectFile(
460
+ appDir,
461
+ "package.json",
462
+ JSON.stringify(
463
+ {
464
+ name: "fixture-inspect-app",
465
+ private: true,
466
+ type: "module",
467
+ },
468
+ null,
469
+ 2,
470
+ ),
471
+ );
472
+ writeProjectFile(
473
+ appDir,
474
+ "vite.config.ts",
475
+ `import { defineConfig } from "vite";
476
+ import { pracht } from ${JSON.stringify(vitePluginImport)};
477
+
478
+ export default defineConfig(async () => ({
479
+ plugins: [await pracht()],
480
+ resolve: {
481
+ alias: {
482
+ "@pracht/adapter-node": ${JSON.stringify(nodeAdapterImportPath)},
483
+ "@pracht/core": ${JSON.stringify(coreImportPath)},
484
+ },
485
+ },
486
+ }));
487
+ `,
488
+ );
489
+ writeProjectFile(
490
+ appDir,
491
+ "src/routes.ts",
492
+ `import { defineApp, group, route, timeRevalidate } from "@pracht/core";
493
+
494
+ export const app = defineApp({
495
+ shells: {
496
+ app: () => import("./shells/app.tsx"),
497
+ },
498
+ middleware: {
499
+ auth: () => import("./middleware/auth.ts"),
500
+ },
501
+ routes: [
502
+ group({ shell: "app", middleware: ["auth"] }, [
503
+ route("/dashboard", {
504
+ component: () => import("./routes/dashboard.tsx"),
505
+ loader: () => import("./server/dashboard-loader.ts"),
506
+ render: "isg",
507
+ revalidate: timeRevalidate(60),
508
+ }),
509
+ ]),
510
+ ],
511
+ });
512
+ `,
513
+ );
514
+ writeProjectFile(
515
+ appDir,
516
+ "src/routes/dashboard.tsx",
517
+ `import type { RouteComponentProps } from "@pracht/core";
518
+
519
+ export function Component({ data }: RouteComponentProps) {
520
+ return <main>{JSON.stringify(data)}</main>;
521
+ }
522
+ `,
523
+ );
524
+ writeProjectFile(
525
+ appDir,
526
+ "src/server/dashboard-loader.ts",
527
+ `import type { LoaderArgs } from "@pracht/core";
528
+
529
+ export async function loader(_args: LoaderArgs) {
530
+ return { ok: true };
531
+ }
532
+ `,
533
+ );
534
+ writeProjectFile(
535
+ appDir,
536
+ "src/shells/app.tsx",
537
+ `import type { ShellProps } from "@pracht/core";
538
+
539
+ export function Shell({ children }: ShellProps) {
540
+ return <div>{children}</div>;
541
+ }
542
+ `,
543
+ );
544
+ writeProjectFile(
545
+ appDir,
546
+ "src/middleware/auth.ts",
547
+ `import type { MiddlewareFn } from "@pracht/core";
548
+
549
+ export const middleware: MiddlewareFn = async () => {
550
+ return;
551
+ };
552
+ `,
553
+ );
554
+ writeProjectFile(
555
+ appDir,
556
+ "src/api/health.ts",
557
+ `import type { BaseRouteArgs } from "@pracht/core";
558
+
559
+ export function GET(_args: BaseRouteArgs) {
560
+ return Response.json({ ok: true });
561
+ }
562
+
563
+ export async function POST(_args: BaseRouteArgs) {
564
+ return Response.json({ created: true }, { status: 201 });
565
+ }
566
+ `,
567
+ );
568
+ writeProjectFile(
569
+ appDir,
570
+ "dist/client/.vite/manifest.json",
571
+ JSON.stringify(
572
+ {
573
+ "virtual:pracht/client": {
574
+ file: "assets/client.js",
575
+ imports: ["assets/vendor.js"],
576
+ },
577
+ "src/routes/dashboard.tsx": {
578
+ css: ["assets/dashboard.css"],
579
+ file: "assets/dashboard.js",
580
+ imports: ["assets/vendor.js"],
581
+ src: "src/routes/dashboard.tsx",
582
+ },
583
+ "src/shells/app.tsx": {
584
+ css: ["assets/app.css"],
585
+ file: "assets/app.js",
586
+ imports: ["assets/vendor.js"],
587
+ src: "src/shells/app.tsx",
588
+ },
589
+ "assets/vendor.js": {
590
+ file: "assets/vendor.js",
591
+ },
592
+ },
593
+ null,
594
+ 2,
595
+ ),
596
+ );
597
+ }
598
+
599
+ function writePagesApp(appDir) {
600
+ writeProjectFile(
601
+ appDir,
602
+ "package.json",
603
+ JSON.stringify(
604
+ {
605
+ name: "fixture-pages-app",
606
+ private: true,
607
+ dependencies: {
608
+ "@pracht/adapter-node": "workspace:*",
609
+ "@pracht/cli": "workspace:*",
610
+ },
611
+ },
612
+ null,
613
+ 2,
614
+ ),
615
+ );
616
+ writeProjectFile(
617
+ appDir,
618
+ "vite.config.ts",
619
+ `import { defineConfig } from "vite";
620
+ import { pracht } from "@pracht/vite-plugin";
621
+
622
+ export default defineConfig(async () => ({
623
+ plugins: [await pracht({ pagesDir: "/src/pages" })],
624
+ }));
625
+ `,
626
+ );
627
+ }
628
+
629
+ function writeProjectFile(appDir, relativePath, contents) {
630
+ const filePath = resolve(appDir, relativePath);
631
+ mkdirSync(dirname(filePath), { recursive: true });
632
+ writeFileSync(filePath, contents.endsWith("\n") ? contents : `${contents}\n`, "utf-8");
633
+ }