@pracht/cli 1.0.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @pracht/cli
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#70](https://github.com/JoviDeCroock/pracht/pull/70) [`ddd50a1`](https://github.com/JoviDeCroock/pracht/commit/ddd50a1edf82a6884881a91ce7172d87ec571cde) Thanks [@JoviDeCroock](https://github.com/JoviDeCroock)! - Add `pracht inspect` as a machine-readable app graph command.
8
+
9
+ The CLI can now emit resolved routes, API handlers, and build metadata via:
10
+
11
+ - `pracht inspect routes --json`
12
+ - `pracht inspect api --json`
13
+ - `pracht inspect build --json`
14
+ - `pracht inspect --json`
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [[`0d33c3d`](https://github.com/JoviDeCroock/pracht/commit/0d33c3dee00bf3940dc56bef3a171249a3d73e21), [`ba1eaea`](https://github.com/JoviDeCroock/pracht/commit/ba1eaeaf68ab63b47b08411fbdafae2fd98e5f09)]:
19
+ - @pracht/core@0.2.0
20
+
3
21
  ## 1.0.0
4
22
 
5
23
  ### Major Changes
package/README.md CHANGED
@@ -68,6 +68,17 @@ Create an API route under `src/api/`.
68
68
  pracht generate api --path /health --methods GET,POST
69
69
  ```
70
70
 
71
+ ### `pracht inspect`
72
+
73
+ Inspect the resolved app graph. Use `--json` for agent/tool consumption.
74
+
75
+ ```bash
76
+ pracht inspect --json
77
+ pracht inspect routes --json
78
+ pracht inspect api --json
79
+ pracht inspect build --json
80
+ ```
81
+
71
82
  ### `pracht doctor`
72
83
 
73
84
  Validate the local app wiring across the whole project. Use `--json` for
package/bin/pracht.js CHANGED
@@ -5,6 +5,7 @@ import { buildCommand } from "../lib/commands/build.js";
5
5
  import { devCommand } from "../lib/commands/dev.js";
6
6
  import { generateCommand } from "../lib/commands/generate.js";
7
7
  import { verifyCommand } from "../lib/commands/verify.js";
8
+ import { inspectCommand } from "../lib/commands/inspect.js";
8
9
  import { handleCliError, printHelp } from "../lib/cli.js";
9
10
  import { VERSION } from "../lib/constants.js";
10
11
 
@@ -28,6 +29,7 @@ const handlers = {
28
29
  doctor: doctorCommand,
29
30
  generate: generateCommand,
30
31
  verify: verifyCommand,
32
+ inspect: inspectCommand,
31
33
  };
32
34
 
33
35
  if (!(command in handlers)) {
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const MANIFEST_PATHS = ["dist/client/.vite/manifest.json", "dist/.vite/manifest.json"];
5
+
6
+ export function readClientBuildAssets(root = process.cwd()) {
7
+ const manifestPath = MANIFEST_PATHS.map((candidate) => resolve(root, candidate)).find((path) =>
8
+ existsSync(path),
9
+ );
10
+
11
+ if (!manifestPath) {
12
+ return {
13
+ clientEntryUrl: null,
14
+ cssManifest: {},
15
+ jsManifest: {},
16
+ };
17
+ }
18
+
19
+ const rawManifest = readFileSync(manifestPath, "utf-8");
20
+ const manifest = JSON.parse(rawManifest);
21
+ const clientEntry = manifest["virtual:pracht/client"];
22
+
23
+ function collectTransitiveDeps(key) {
24
+ const css = new Set();
25
+ const js = new Set();
26
+ const visited = new Set();
27
+
28
+ function collect(currentKey) {
29
+ if (visited.has(currentKey)) return;
30
+ visited.add(currentKey);
31
+
32
+ const entry = manifest[currentKey];
33
+ if (!entry) return;
34
+
35
+ for (const cssFile of entry.css ?? []) {
36
+ css.add(cssFile);
37
+ }
38
+
39
+ js.add(entry.file);
40
+
41
+ for (const importedKey of entry.imports ?? []) {
42
+ collect(importedKey);
43
+ }
44
+ }
45
+
46
+ collect(key);
47
+ return {
48
+ css: [...css],
49
+ js: [...js],
50
+ };
51
+ }
52
+
53
+ const cssManifest = {};
54
+ const jsManifest = {};
55
+
56
+ for (const [key, entry] of Object.entries(manifest)) {
57
+ if (!entry.src) continue;
58
+
59
+ const deps = collectTransitiveDeps(key);
60
+ if (deps.css.length > 0) {
61
+ cssManifest[key] = deps.css.map((file) => `/${file}`);
62
+ }
63
+ if (deps.js.length > 0) {
64
+ jsManifest[key] = deps.js.map((file) => `/${file}`);
65
+ }
66
+ }
67
+
68
+ return {
69
+ clientEntryUrl: clientEntry ? `/${clientEntry.file}` : null,
70
+ cssManifest,
71
+ jsManifest,
72
+ };
73
+ }
package/lib/cli.js CHANGED
@@ -8,6 +8,7 @@ Usage:
8
8
  pracht build Production build (client + server)
9
9
  pracht generate <kind> [flags] Scaffold framework files
10
10
  pracht verify [--changed] [--json] Fast framework-aware verification
11
+ pracht inspect [target] [--json] Inspect resolved app graph
11
12
  pracht doctor [--json] Validate app wiring
12
13
 
13
14
  Generate kinds:
@@ -18,6 +19,13 @@ Generate kinds:
18
19
  `);
19
20
  }
20
21
 
22
+ export function printInspectHelp() {
23
+ console.log(`Usage:
24
+ pracht inspect [routes|api|build] [--json]
25
+ pracht inspect --json
26
+ `);
27
+ }
28
+
21
29
  export function printGenerateHelp() {
22
30
  console.log(`Usage:
23
31
  pracht generate route --path /dashboard [--render ssr|spa|ssg|isg] [--shell app] [--middleware auth] [--loader] [--json]
@@ -1,16 +1,9 @@
1
- import {
2
- cpSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- readdirSync,
7
- rmSync,
8
- writeFileSync,
9
- } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
2
  import { dirname, join, resolve } from "node:path";
11
3
 
12
4
  import { build as viteBuild } from "vite";
13
5
 
6
+ import { readClientBuildAssets } from "../build-metadata.js";
14
7
  import { writeVercelBuildOutput } from "../build-shared.js";
15
8
 
16
9
  export async function buildCommand() {
@@ -57,43 +50,11 @@ export async function buildCommand() {
57
50
  if (existsSync(serverEntry)) {
58
51
  const serverMod = await import(serverEntry);
59
52
  const { prerenderApp } = serverMod;
60
- const manifestPath = resolve(clientDir, ".vite/manifest.json");
61
- const viteManifest = existsSync(manifestPath)
62
- ? JSON.parse(readFileSync(manifestPath, "utf-8"))
63
- : {};
64
-
65
- const clientEntry = viteManifest["virtual:pracht/client"];
66
- const clientEntryUrl = clientEntry ? `/${clientEntry.file}` : undefined;
67
-
68
- function collectTransitiveCss(key) {
69
- const css = new Set();
70
- const visited = new Set();
71
-
72
- function collect(currentKey) {
73
- if (visited.has(currentKey)) return;
74
- visited.add(currentKey);
75
- const entry = viteManifest[currentKey];
76
- if (!entry) return;
77
- for (const cssFile of entry.css ?? []) css.add(cssFile);
78
- for (const importedKey of entry.imports ?? []) collect(importedKey);
79
- }
80
-
81
- collect(key);
82
- return [...css];
83
- }
84
-
85
- const cssManifest = {};
86
- for (const [key, entry] of Object.entries(viteManifest)) {
87
- if (!entry.src) continue;
88
- const css = collectTransitiveCss(key);
89
- if (css.length > 0) {
90
- cssManifest[key] = css.map((file) => `/${file}`);
91
- }
92
- }
53
+ const { clientEntryUrl, cssManifest } = readClientBuildAssets(root);
93
54
 
94
55
  const { pages, isgManifest } = await prerenderApp({
95
56
  app: serverMod.resolvedApp,
96
- clientEntryUrl,
57
+ clientEntryUrl: clientEntryUrl ?? undefined,
97
58
  cssManifest,
98
59
  registry: serverMod.registry,
99
60
  withISGManifest: true,
@@ -0,0 +1,163 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { createServer } from "vite";
5
+
6
+ import { handleCliError, parseFlags, printInspectHelp, requireOptionalString } from "../cli.js";
7
+ import { readClientBuildAssets } from "../build-metadata.js";
8
+ import { HTTP_METHODS } from "../constants.js";
9
+ import { readProjectConfig, resolveProjectPath } from "../project.js";
10
+
11
+ 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
+ }
26
+
27
+ const report = await runInspect(process.cwd(), { target });
28
+
29
+ if (options.json) {
30
+ console.log(JSON.stringify(report, null, 2));
31
+ return;
32
+ }
33
+
34
+ printInspectReport(report);
35
+ }
36
+
37
+ export async function runInspect(root, { target = "all" } = {}) {
38
+ const project = readProjectConfig(root);
39
+
40
+ if (!project.configFile) {
41
+ throw new Error(
42
+ "Missing vite config. `pracht inspect` requires a project with pracht configured.",
43
+ );
44
+ }
45
+
46
+ if (!project.hasPrachtPlugin) {
47
+ throw new Error("vite.config does not appear to register the pracht plugin.");
48
+ }
49
+
50
+ if (project.mode === "manifest") {
51
+ const manifestPath = resolveProjectPath(project.root, project.appFile);
52
+ try {
53
+ readFileSync(manifestPath, "utf-8");
54
+ } catch {
55
+ throw new Error(`App manifest is missing at ${project.appFile}.`);
56
+ }
57
+ }
58
+
59
+ const server = await createServer({
60
+ root,
61
+ logLevel: "silent",
62
+ server: {
63
+ middlewareMode: true,
64
+ },
65
+ });
66
+
67
+ try {
68
+ const serverModule = await server.ssrLoadModule("virtual:pracht/server");
69
+ const report = {
70
+ mode: project.mode,
71
+ };
72
+
73
+ if (target === "routes" || target === "all") {
74
+ report.routes = serializeRoutes(serverModule.resolvedApp.routes);
75
+ }
76
+
77
+ if (target === "api" || target === "all") {
78
+ report.api = await Promise.all(
79
+ serverModule.apiRoutes.map(async (route) => ({
80
+ file: route.file,
81
+ methods: await detectApiMethods(server, root, route.file),
82
+ path: route.path,
83
+ })),
84
+ );
85
+ }
86
+
87
+ if (target === "build" || target === "all") {
88
+ const buildAssets = readClientBuildAssets(root);
89
+ report.build = {
90
+ adapterTarget: serverModule.buildTarget,
91
+ clientEntryUrl: buildAssets.clientEntryUrl,
92
+ cssManifest: buildAssets.cssManifest,
93
+ jsManifest: buildAssets.jsManifest,
94
+ };
95
+ }
96
+
97
+ return report;
98
+ } finally {
99
+ await server.close();
100
+ }
101
+ }
102
+
103
+ function serializeRoutes(routes) {
104
+ return routes.map((route) => ({
105
+ file: route.file,
106
+ id: route.id,
107
+ loaderFile: route.loaderFile ?? null,
108
+ middleware: route.middleware,
109
+ path: route.path,
110
+ render: route.render ?? null,
111
+ revalidate: route.revalidate ?? null,
112
+ shell: route.shell ?? null,
113
+ shellFile: route.shellFile ?? null,
114
+ }));
115
+ }
116
+
117
+ async function detectApiMethods(server, root, file) {
118
+ const resolvedFile = resolve(root, `.${file}`);
119
+ const source = readFileSync(resolvedFile, "utf-8");
120
+
121
+ // Use module evaluation first so re-exported handlers are reflected too.
122
+ try {
123
+ const module = await server.ssrLoadModule(file);
124
+ return METHOD_ORDER.filter((method) => typeof module[method] === "function");
125
+ } catch {
126
+ return METHOD_ORDER.filter((method) =>
127
+ new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${method}\\b`).test(source),
128
+ );
129
+ }
130
+ }
131
+
132
+ function printInspectReport(report) {
133
+ console.log(`Pracht inspect (${report.mode} mode)`);
134
+
135
+ if (report.routes) {
136
+ console.log("\nRoutes");
137
+ for (const route of report.routes) {
138
+ console.log(
139
+ ` ${route.path} id=${route.id} render=${route.render ?? "n/a"} file=${route.file}`,
140
+ );
141
+ }
142
+ }
143
+
144
+ if (report.api) {
145
+ console.log("\nAPI");
146
+ if (report.api.length === 0) {
147
+ console.log(" No API routes found.");
148
+ } else {
149
+ for (const route of report.api) {
150
+ const methods = route.methods.length > 0 ? route.methods.join(",") : "none";
151
+ console.log(` ${route.path} methods=${methods} file=${route.file}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ if (report.build) {
157
+ console.log("\nBuild");
158
+ console.log(` adapterTarget=${report.build.adapterTarget}`);
159
+ console.log(` clientEntryUrl=${report.build.clientEntryUrl ?? "null"}`);
160
+ console.log(` cssManifestKeys=${Object.keys(report.build.cssManifest).length}`);
161
+ console.log(` jsManifestKeys=${Object.keys(report.build.jsManifest).length}`);
162
+ }
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/cli",
5
5
  "bugs": {
6
6
  "url": "https://github.com/JoviDeCroock/pracht/issues"
@@ -18,6 +18,6 @@
18
18
  "provenance": true
19
19
  },
20
20
  "dependencies": {
21
- "@pracht/core": "0.1.0"
21
+ "@pracht/core": "0.2.0"
22
22
  }
23
23
  }
@@ -2,11 +2,16 @@ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
7
  import { afterEach, describe, expect, it } from "vitest";
8
8
 
9
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");
10
15
  const tempDirs = [];
11
16
 
12
17
  afterEach(() => {
@@ -266,6 +271,69 @@ export const app = defineApp({
266
271
  ).toBe(true);
267
272
  });
268
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
+
269
337
  it("scaffolds pages-router routes without touching a manifest", () => {
270
338
  const appDir = createTempDir("pracht-cli-pages-");
271
339
  writePagesApp(appDir);
@@ -290,6 +358,13 @@ function createTempDir(prefix) {
290
358
  return dir;
291
359
  }
292
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
+
293
368
  function runCli(args, { cwd }) {
294
369
  return {
295
370
  stdout: execFileSync(process.execPath, [cliPath, ...args], {
@@ -378,6 +453,149 @@ export const app = defineApp({
378
453
  );
379
454
  }
380
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
+
381
599
  function writePagesApp(appDir) {
382
600
  writeProjectFile(
383
601
  appDir,