@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.
- package/CHANGELOG.md +47 -0
- package/README.md +70 -3
- package/bin/pracht.js +23 -451
- package/lib/build-metadata.js +73 -0
- package/lib/build-shared.js +127 -0
- package/lib/cli.js +164 -0
- package/lib/commands/build.js +104 -0
- package/lib/commands/dev.js +16 -0
- package/lib/commands/doctor.js +21 -0
- package/lib/commands/generate.js +572 -0
- package/lib/commands/inspect.js +163 -0
- package/lib/commands/verify.js +21 -0
- package/lib/constants.js +22 -0
- package/lib/manifest.js +147 -0
- package/lib/project.js +143 -0
- package/lib/verification.js +641 -0
- package/package.json +6 -6
- package/test/pracht-cli.test.js +633 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_SECURITY_HEADERS, VERSION } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
|
|
7
|
+
|
|
8
|
+
export function setDefaultSecurityHeaders(res, headers = {}) {
|
|
9
|
+
for (const [key, value] of Object.entries({
|
|
10
|
+
...DEFAULT_SECURITY_HEADERS,
|
|
11
|
+
...headers,
|
|
12
|
+
})) {
|
|
13
|
+
res.setHeader(key, value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeVercelBuildOutput({ functionName, regions, root, staticRoutes, isgRoutes }) {
|
|
18
|
+
const outputDir = join(root, ".vercel/output");
|
|
19
|
+
const staticDir = join(outputDir, "static");
|
|
20
|
+
const functionDir = join(outputDir, "functions", `${functionName || "render"}.func`);
|
|
21
|
+
|
|
22
|
+
rmSync(outputDir, { force: true, recursive: true });
|
|
23
|
+
mkdirSync(outputDir, { recursive: true });
|
|
24
|
+
cpSync(join(root, "dist/client"), staticDir, { recursive: true });
|
|
25
|
+
cpSync(join(root, "dist/server"), functionDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(outputDir, "config.json"),
|
|
29
|
+
`${JSON.stringify(createVercelOutputConfig({ functionName, staticRoutes, isgRoutes }), null, 2)}\n`,
|
|
30
|
+
"utf-8",
|
|
31
|
+
);
|
|
32
|
+
writeFileSync(
|
|
33
|
+
join(functionDir, ".vc-config.json"),
|
|
34
|
+
`${JSON.stringify(createVercelFunctionConfig({ regions }), null, 2)}\n`,
|
|
35
|
+
"utf-8",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return ".vercel/output";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createVercelOutputConfig({ functionName, staticRoutes, isgRoutes }) {
|
|
42
|
+
const target = `/${functionName || "render"}`;
|
|
43
|
+
const routes = [
|
|
44
|
+
{
|
|
45
|
+
dest: target,
|
|
46
|
+
has: [{ type: "header", key: ROUTE_STATE_REQUEST_HEADER, value: "1" }],
|
|
47
|
+
src: "/(.*)",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
for (const route of sortStaticRoutes(staticRoutes)) {
|
|
52
|
+
routes.push({
|
|
53
|
+
dest: routeToStaticHtmlPath(route),
|
|
54
|
+
src: routeToRouteExpression(route),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const route of isgRoutes) {
|
|
59
|
+
routes.push({
|
|
60
|
+
dest: target,
|
|
61
|
+
src: routeToRouteExpression(route),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
routes.push({ handle: "filesystem" });
|
|
66
|
+
routes.push({ dest: target, src: "/(.*)" });
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
headers: [
|
|
70
|
+
{
|
|
71
|
+
headers: [
|
|
72
|
+
{
|
|
73
|
+
key: "permissions-policy",
|
|
74
|
+
value:
|
|
75
|
+
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()",
|
|
76
|
+
},
|
|
77
|
+
{ key: "referrer-policy", value: "strict-origin-when-cross-origin" },
|
|
78
|
+
{ key: "x-content-type-options", value: "nosniff" },
|
|
79
|
+
{ key: "x-frame-options", value: "SAMEORIGIN" },
|
|
80
|
+
],
|
|
81
|
+
source: "/(.*)",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
framework: {
|
|
85
|
+
version: VERSION,
|
|
86
|
+
},
|
|
87
|
+
routes,
|
|
88
|
+
version: 3,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createVercelFunctionConfig({ regions }) {
|
|
93
|
+
const config = {
|
|
94
|
+
entrypoint: "server.js",
|
|
95
|
+
runtime: "edge",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (regions) {
|
|
99
|
+
config.regions = regions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sortStaticRoutes(routes) {
|
|
106
|
+
return [...new Set(routes)].sort((left, right) => right.length - left.length);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function routeToRouteExpression(route) {
|
|
110
|
+
if (route === "/") {
|
|
111
|
+
return "^/$";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return `^${escapeRegex(route)}/?$`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function routeToStaticHtmlPath(route) {
|
|
118
|
+
if (route === "/") {
|
|
119
|
+
return "/index.html";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return `${route}/index.html`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function escapeRegex(value) {
|
|
126
|
+
return value.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
127
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { HTTP_METHODS, VERSION } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export function printHelp() {
|
|
4
|
+
console.log(`pracht ${VERSION}
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
pracht dev [port] Start development server with HMR
|
|
8
|
+
pracht build Production build (client + server)
|
|
9
|
+
pracht generate <kind> [flags] Scaffold framework files
|
|
10
|
+
pracht verify [--changed] [--json] Fast framework-aware verification
|
|
11
|
+
pracht inspect [target] [--json] Inspect resolved app graph
|
|
12
|
+
pracht doctor [--json] Validate app wiring
|
|
13
|
+
|
|
14
|
+
Generate kinds:
|
|
15
|
+
route --path /dashboard [--render ssr|spa|ssg|isg] [--shell app] [--middleware auth] [--loader]
|
|
16
|
+
shell --name app
|
|
17
|
+
middleware --name auth
|
|
18
|
+
api --path /health [--methods GET,POST]
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function printInspectHelp() {
|
|
23
|
+
console.log(`Usage:
|
|
24
|
+
pracht inspect [routes|api|build] [--json]
|
|
25
|
+
pracht inspect --json
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function printGenerateHelp() {
|
|
30
|
+
console.log(`Usage:
|
|
31
|
+
pracht generate route --path /dashboard [--render ssr|spa|ssg|isg] [--shell app] [--middleware auth] [--loader] [--json]
|
|
32
|
+
pracht generate shell --name app [--json]
|
|
33
|
+
pracht generate middleware --name auth [--json]
|
|
34
|
+
pracht generate api --path /health [--methods GET,POST] [--json]
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseFlags(args) {
|
|
39
|
+
const options = { _: [] };
|
|
40
|
+
|
|
41
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
+
const token = args[index];
|
|
43
|
+
if (!token.startsWith("--")) {
|
|
44
|
+
options._.push(token);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (token.startsWith("--no-")) {
|
|
49
|
+
options[token.slice(5)] = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const equalsIndex = token.indexOf("=");
|
|
54
|
+
if (equalsIndex !== -1) {
|
|
55
|
+
const key = token.slice(2, equalsIndex);
|
|
56
|
+
const value = token.slice(equalsIndex + 1);
|
|
57
|
+
assignOption(options, key, value);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const key = token.slice(2);
|
|
62
|
+
const next = args[index + 1];
|
|
63
|
+
if (next && !next.startsWith("--")) {
|
|
64
|
+
assignOption(options, key, next);
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
assignOption(options, key, true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return options;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function requireStringOption(options, key) {
|
|
76
|
+
const value = requireOptionalString(options, key);
|
|
77
|
+
if (!value) {
|
|
78
|
+
throw new Error(`Missing required flag --${key}.`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function requireOptionalString(options, key) {
|
|
84
|
+
const value = options[key];
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return String(value[value.length - 1]);
|
|
87
|
+
}
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function requireEnumOption(options, key, allowed, fallback) {
|
|
95
|
+
const value = requireOptionalString(options, key) ?? fallback;
|
|
96
|
+
if (!allowed.includes(value)) {
|
|
97
|
+
throw new Error(`Invalid value for --${key}. Expected one of ${allowed.join(", ")}.`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function requirePositiveIntegerOption(options, key, fallback) {
|
|
103
|
+
const raw = requireOptionalString(options, key);
|
|
104
|
+
const value = raw == null ? fallback : Number.parseInt(raw, 10);
|
|
105
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
106
|
+
throw new Error(`--${key} must be a positive integer.`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseCommaList(value) {
|
|
112
|
+
if (!value) return [];
|
|
113
|
+
const values = Array.isArray(value) ? value : [value];
|
|
114
|
+
return values
|
|
115
|
+
.flatMap((entry) => String(entry).split(","))
|
|
116
|
+
.map((entry) => entry.trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseApiMethods(value) {
|
|
121
|
+
const methods = parseCommaList(value);
|
|
122
|
+
const normalized = methods.length === 0 ? ["GET"] : methods.map((entry) => entry.toUpperCase());
|
|
123
|
+
|
|
124
|
+
for (const method of normalized) {
|
|
125
|
+
if (!HTTP_METHODS.has(method)) {
|
|
126
|
+
throw new Error(`Unsupported HTTP method "${method}".`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...new Set(normalized)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function quote(value) {
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function ensureTrailingNewline(value) {
|
|
138
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function handleCliError(error, { json }) {
|
|
142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
143
|
+
if (json) {
|
|
144
|
+
console.error(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
145
|
+
} else {
|
|
146
|
+
console.error(message);
|
|
147
|
+
if (error instanceof Error && error.stack && process.env.DEBUG) {
|
|
148
|
+
console.error(error.stack);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assignOption(options, key, value) {
|
|
155
|
+
if (!(key in options)) {
|
|
156
|
+
options[key] = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!Array.isArray(options[key])) {
|
|
161
|
+
options[key] = [options[key]];
|
|
162
|
+
}
|
|
163
|
+
options[key].push(value);
|
|
164
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { build as viteBuild } from "vite";
|
|
5
|
+
|
|
6
|
+
import { readClientBuildAssets } from "../build-metadata.js";
|
|
7
|
+
import { writeVercelBuildOutput } from "../build-shared.js";
|
|
8
|
+
|
|
9
|
+
export async function buildCommand() {
|
|
10
|
+
const root = process.cwd();
|
|
11
|
+
|
|
12
|
+
console.log("\n Building client...\n");
|
|
13
|
+
await viteBuild({
|
|
14
|
+
root,
|
|
15
|
+
build: {
|
|
16
|
+
outDir: "dist",
|
|
17
|
+
manifest: true,
|
|
18
|
+
rollupOptions: {
|
|
19
|
+
input: "virtual:pracht/client",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
console.log("\n Building server...\n");
|
|
25
|
+
await viteBuild({
|
|
26
|
+
root,
|
|
27
|
+
build: {
|
|
28
|
+
outDir: "dist/server",
|
|
29
|
+
ssr: "virtual:pracht/server",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const serverEntry = resolve(root, "dist/server/server.js");
|
|
34
|
+
let clientDir;
|
|
35
|
+
if (existsSync(resolve(root, "dist/client/.vite/manifest.json"))) {
|
|
36
|
+
clientDir = resolve(root, "dist/client");
|
|
37
|
+
} else {
|
|
38
|
+
clientDir = resolve(root, "dist/client");
|
|
39
|
+
const distRoot = resolve(root, "dist");
|
|
40
|
+
mkdirSync(clientDir, { recursive: true });
|
|
41
|
+
for (const entry of readdirSync(distRoot)) {
|
|
42
|
+
if (entry === "server" || entry === "client") continue;
|
|
43
|
+
const sourcePath = join(distRoot, entry);
|
|
44
|
+
const destinationPath = join(clientDir, entry);
|
|
45
|
+
cpSync(sourcePath, destinationPath, { recursive: true });
|
|
46
|
+
rmSync(sourcePath, { force: true, recursive: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (existsSync(serverEntry)) {
|
|
51
|
+
const serverMod = await import(serverEntry);
|
|
52
|
+
const { prerenderApp } = serverMod;
|
|
53
|
+
const { clientEntryUrl, cssManifest } = readClientBuildAssets(root);
|
|
54
|
+
|
|
55
|
+
const { pages, isgManifest } = await prerenderApp({
|
|
56
|
+
app: serverMod.resolvedApp,
|
|
57
|
+
clientEntryUrl: clientEntryUrl ?? undefined,
|
|
58
|
+
cssManifest,
|
|
59
|
+
registry: serverMod.registry,
|
|
60
|
+
withISGManifest: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (pages.length > 0) {
|
|
64
|
+
console.log(`\n Prerendering ${pages.length} SSG/ISG route(s)...\n`);
|
|
65
|
+
for (const page of pages) {
|
|
66
|
+
const filePath =
|
|
67
|
+
page.path === "/"
|
|
68
|
+
? join(clientDir, "index.html")
|
|
69
|
+
: join(clientDir, page.path, "index.html");
|
|
70
|
+
|
|
71
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
72
|
+
writeFileSync(filePath, page.html, "utf-8");
|
|
73
|
+
console.log(` ${page.path} → ${filePath.replace(root + "/", "")}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Object.keys(isgManifest).length > 0) {
|
|
78
|
+
const isgManifestPath = resolve(root, "dist/server/isg-manifest.json");
|
|
79
|
+
writeFileSync(isgManifestPath, JSON.stringify(isgManifest, null, 2), "utf-8");
|
|
80
|
+
console.log(
|
|
81
|
+
`\n ISG manifest → dist/server/isg-manifest.json (${Object.keys(isgManifest).length} route(s))\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (serverMod.buildTarget === "cloudflare") {
|
|
86
|
+
console.log("\n Cloudflare worker → dist/server/server.js\n");
|
|
87
|
+
console.log(" Deploy with: wrangler deploy\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (serverMod.buildTarget === "vercel") {
|
|
91
|
+
const outputPath = writeVercelBuildOutput({
|
|
92
|
+
functionName: serverMod.vercelFunctionName,
|
|
93
|
+
isgRoutes: Object.keys(isgManifest),
|
|
94
|
+
regions: serverMod.vercelRegions,
|
|
95
|
+
root,
|
|
96
|
+
staticRoutes: pages.map((page) => page.path).filter((path) => !(path in isgManifest)),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
console.log(`\n Vercel build output → ${outputPath}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log("\n Build complete.\n");
|
|
104
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer } from "vite";
|
|
2
|
+
|
|
3
|
+
import { parseFlags } from "../cli.js";
|
|
4
|
+
|
|
5
|
+
export async function devCommand(args) {
|
|
6
|
+
const options = parseFlags(args);
|
|
7
|
+
const port = parseInt(process.env.PORT || options._[0] || "3000", 10);
|
|
8
|
+
|
|
9
|
+
const server = await createServer({
|
|
10
|
+
root: process.cwd(),
|
|
11
|
+
server: { port },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await server.listen();
|
|
15
|
+
server.printUrls();
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseFlags } from "../cli.js";
|
|
2
|
+
import { runDoctor } from "../verification.js";
|
|
3
|
+
|
|
4
|
+
export async function doctorCommand(args) {
|
|
5
|
+
const options = parseFlags(args);
|
|
6
|
+
const report = runDoctor(process.cwd());
|
|
7
|
+
|
|
8
|
+
if (options.json) {
|
|
9
|
+
console.log(JSON.stringify(report, null, 2));
|
|
10
|
+
} else {
|
|
11
|
+
console.log(`Pracht doctor (${report.mode} mode)`);
|
|
12
|
+
for (const check of report.checks) {
|
|
13
|
+
console.log(`${check.status.toUpperCase().padEnd(5)} ${check.message}`);
|
|
14
|
+
}
|
|
15
|
+
console.log(report.ok ? "\nNo blocking issues found." : "\nBlocking issues found.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!report.ok) {
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
21
|
+
}
|