@waelio/cli 0.1.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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +52 -0
- package/dist/localRepos.d.ts +29 -0
- package/dist/localRepos.js +137 -0
- package/dist/localRepos.test.d.ts +1 -0
- package/dist/localRepos.test.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +366 -0
- package/dist/siteforge.d.ts +44 -0
- package/dist/siteforge.js +281 -0
- package/dist/siteforge.test.d.ts +1 -0
- package/dist/siteforge.test.js +53 -0
- package/package.json +52 -0
- package/ui/dist/assets/index-BJ1Dzrgp.css +1 -0
- package/ui/dist/assets/index-DS_pYwiX.js +18 -0
- package/ui/dist/favicon.svg +4 -0
- package/ui/dist/index.html +44 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_SITEFORGE_REPO, formatBuildPlan, getDoctorReport, prepareBuildPlan, runBuild, } from "./siteforge.js";
|
|
6
|
+
import { DEFAULT_LOCAL_REPOS_ROOT, listLocalRepositoryDirectory, scanLocalRepositories, } from "./localRepos.js";
|
|
7
|
+
const helperRepositories = [
|
|
8
|
+
{
|
|
9
|
+
name: "waelio/ustore",
|
|
10
|
+
url: "https://github.com/waelio/ustore",
|
|
11
|
+
description: "Universal storage adapters with a clean CRUD-style API.",
|
|
12
|
+
suggestedUse: "Great for caching UI state, history, and future build session persistence.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "waelio/utils",
|
|
16
|
+
url: "https://github.com/waelio/utils",
|
|
17
|
+
description: "Shared utilities for config, storage, and UI-friendly helpers.",
|
|
18
|
+
suggestedUse: "A good future home for reusable notifications, config helpers, or shared browser utilities.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "waelio/waelio-messaging",
|
|
22
|
+
url: "https://github.com/waelio/waelio-messaging",
|
|
23
|
+
description: "Realtime messaging and event distribution with FeathersJS and Socket.io.",
|
|
24
|
+
suggestedUse: "Useful later if you want shared build dashboards, collaboration, or remote build notifications.",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const recommendedStack = {
|
|
28
|
+
name: "Vite + TypeScript + Vue",
|
|
29
|
+
reasons: [
|
|
30
|
+
"Vite keeps the UI extremely fast during local development.",
|
|
31
|
+
"TypeScript matches the existing CLI and build code.",
|
|
32
|
+
"Vue fits well with the broader waelio ecosystem and is quick to iterate on.",
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
const uiDistDir = path.join(projectRoot, "ui", "dist");
|
|
37
|
+
let buildInProgress = false;
|
|
38
|
+
export async function startServer(options = {}) {
|
|
39
|
+
const port = options.port ?? Number(process.env.PORT ?? 3000);
|
|
40
|
+
const server = createServer((request, response) => {
|
|
41
|
+
void handleRequest(request, response);
|
|
42
|
+
});
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
const onError = (error) => {
|
|
45
|
+
server.off("listening", onListening);
|
|
46
|
+
reject(error);
|
|
47
|
+
};
|
|
48
|
+
const onListening = () => {
|
|
49
|
+
server.off("error", onError);
|
|
50
|
+
resolve();
|
|
51
|
+
};
|
|
52
|
+
server.once("error", onError);
|
|
53
|
+
server.once("listening", onListening);
|
|
54
|
+
server.listen(port);
|
|
55
|
+
});
|
|
56
|
+
console.log(`waelio UI server ready on http://localhost:${port}`);
|
|
57
|
+
return server;
|
|
58
|
+
}
|
|
59
|
+
async function handleRequest(request, response) {
|
|
60
|
+
const requestUrl = new URL(request.url ?? "/", "http://localhost");
|
|
61
|
+
try {
|
|
62
|
+
if (request.method === "OPTIONS") {
|
|
63
|
+
sendNoContent(response);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (requestUrl.pathname.startsWith("/api/")) {
|
|
67
|
+
await handleApiRequest(request, response, requestUrl);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await handleUiRequest(response, requestUrl.pathname);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
sendJson(response, 500, {
|
|
74
|
+
error: toErrorMessage(error),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function handleApiRequest(request, response, requestUrl) {
|
|
79
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/health") {
|
|
80
|
+
sendJson(response, 200, {
|
|
81
|
+
defaultRepoUrl: DEFAULT_SITEFORGE_REPO,
|
|
82
|
+
localReposRoot: DEFAULT_LOCAL_REPOS_ROOT,
|
|
83
|
+
recommendedStack,
|
|
84
|
+
helperRepositories,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/local-repos") {
|
|
89
|
+
const snapshot = await scanLocalRepositories();
|
|
90
|
+
sendJson(response, 200, {
|
|
91
|
+
root: snapshot.root,
|
|
92
|
+
count: snapshot.repositories.length,
|
|
93
|
+
repositories: snapshot.repositories,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/local-repos/tree") {
|
|
98
|
+
const repositoryId = requestUrl.searchParams.get("repoId")?.trim();
|
|
99
|
+
if (!repositoryId) {
|
|
100
|
+
sendJson(response, 400, {
|
|
101
|
+
error: "Missing repoId query parameter.",
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const snapshot = await scanLocalRepositories();
|
|
106
|
+
try {
|
|
107
|
+
const listing = await listLocalRepositoryDirectory(snapshot, repositoryId, requestUrl.searchParams.get("path") ?? undefined);
|
|
108
|
+
sendJson(response, 200, listing);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const message = toErrorMessage(error);
|
|
112
|
+
sendJson(response, message.includes("Unknown local repository id") ? 404 : 400, {
|
|
113
|
+
error: message,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/doctor") {
|
|
119
|
+
const checks = await getDoctorReport();
|
|
120
|
+
sendJson(response, 200, { checks });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/plan") {
|
|
124
|
+
const payload = await readJsonBody(request);
|
|
125
|
+
const options = normalizeBuildOptions(payload);
|
|
126
|
+
const plan = await prepareBuildPlan(options);
|
|
127
|
+
sendJson(response, 200, {
|
|
128
|
+
plan,
|
|
129
|
+
formatted: formatBuildPlan(plan),
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/build/stream") {
|
|
134
|
+
await handleBuildStream(response, buildOptionsFromSearchParams(requestUrl.searchParams));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
sendJson(response, 404, {
|
|
138
|
+
error: `Unknown API route: ${requestUrl.pathname}`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async function handleBuildStream(response, options) {
|
|
142
|
+
if (buildInProgress) {
|
|
143
|
+
sendJson(response, 409, {
|
|
144
|
+
error: "A build is already running. Please wait for it to finish.",
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
buildInProgress = true;
|
|
149
|
+
response.writeHead(200, {
|
|
150
|
+
"Access-Control-Allow-Origin": "*",
|
|
151
|
+
"Cache-Control": "no-cache, no-transform",
|
|
152
|
+
Connection: "keep-alive",
|
|
153
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
154
|
+
});
|
|
155
|
+
let clientClosed = false;
|
|
156
|
+
response.on("close", () => {
|
|
157
|
+
clientClosed = true;
|
|
158
|
+
});
|
|
159
|
+
sendSseEvent(response, "ready", {
|
|
160
|
+
message: "Build stream connected.",
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const plan = await runBuild(options, {
|
|
164
|
+
onPlan: (nextPlan) => {
|
|
165
|
+
sendSseEvent(response, "plan", { plan: nextPlan }, clientClosed);
|
|
166
|
+
},
|
|
167
|
+
onInfo: (message) => {
|
|
168
|
+
sendSseEvent(response, "info", { message }, clientClosed);
|
|
169
|
+
},
|
|
170
|
+
onStepStart: (step, context) => {
|
|
171
|
+
sendSseEvent(response, "step", {
|
|
172
|
+
step,
|
|
173
|
+
index: context.index,
|
|
174
|
+
total: context.total,
|
|
175
|
+
}, clientClosed);
|
|
176
|
+
},
|
|
177
|
+
onStdout: (chunk) => {
|
|
178
|
+
sendSseEvent(response, "stdout", { chunk }, clientClosed);
|
|
179
|
+
},
|
|
180
|
+
onStderr: (chunk) => {
|
|
181
|
+
sendSseEvent(response, "stderr", { chunk }, clientClosed);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
sendSseEvent(response, "complete", { plan }, clientClosed);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
sendSseEvent(response, "failure", {
|
|
188
|
+
message: toErrorMessage(error),
|
|
189
|
+
}, clientClosed);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
buildInProgress = false;
|
|
193
|
+
if (!clientClosed && !response.writableEnded) {
|
|
194
|
+
response.end();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function handleUiRequest(response, pathname) {
|
|
199
|
+
const uiExists = await pathExists(uiDistDir);
|
|
200
|
+
if (!uiExists) {
|
|
201
|
+
sendHtml(response, 503, `<!doctype html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="UTF-8" />
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
206
|
+
<title>waelio UI not built yet</title>
|
|
207
|
+
<style>
|
|
208
|
+
body { font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #08111f; color: #e2e8f0; display: grid; place-items: center; min-height: 100vh; margin: 0; }
|
|
209
|
+
main { max-width: 720px; padding: 32px; border-radius: 24px; background: rgba(15, 23, 42, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
|
|
210
|
+
code { background: rgba(148, 163, 184, 0.15); padding: 2px 8px; border-radius: 8px; }
|
|
211
|
+
a { color: #8b5cf6; }
|
|
212
|
+
</style>
|
|
213
|
+
</head>
|
|
214
|
+
<body>
|
|
215
|
+
<main>
|
|
216
|
+
<h1>UI build not found</h1>
|
|
217
|
+
<p>Run <code>npm run dev</code> for development or <code>npm run build</code> followed by <code>npm start</code> for the compiled UI.</p>
|
|
218
|
+
<p>The API is still available at <code>/api/*</code>.</p>
|
|
219
|
+
</main>
|
|
220
|
+
</body>
|
|
221
|
+
</html>`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const assetPath = await resolveUiAssetPath(pathname);
|
|
225
|
+
if (!assetPath) {
|
|
226
|
+
sendJson(response, 404, {
|
|
227
|
+
error: `UI asset not found: ${pathname}`,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const fileContents = await readFile(assetPath);
|
|
232
|
+
response.writeHead(200, {
|
|
233
|
+
"Content-Type": getContentType(assetPath),
|
|
234
|
+
});
|
|
235
|
+
response.end(fileContents);
|
|
236
|
+
}
|
|
237
|
+
async function resolveUiAssetPath(pathname) {
|
|
238
|
+
const normalizedPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
239
|
+
const candidate = path.normalize(path.join(uiDistDir, normalizedPath));
|
|
240
|
+
if (!candidate.startsWith(uiDistDir)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (await pathExists(candidate)) {
|
|
244
|
+
const details = await stat(candidate);
|
|
245
|
+
if (details.isFile()) {
|
|
246
|
+
return candidate;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (path.extname(normalizedPath) !== "") {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const indexFile = path.join(uiDistDir, "index.html");
|
|
253
|
+
return (await pathExists(indexFile)) ? indexFile : null;
|
|
254
|
+
}
|
|
255
|
+
function buildOptionsFromSearchParams(searchParams) {
|
|
256
|
+
const dryRunValue = normalizeString(searchParams.get("dryRun"));
|
|
257
|
+
return {
|
|
258
|
+
repoUrl: normalizeString(searchParams.get("repo") ?? searchParams.get("repoUrl")),
|
|
259
|
+
ref: normalizeString(searchParams.get("ref")),
|
|
260
|
+
source: normalizeString(searchParams.get("source")),
|
|
261
|
+
workdir: normalizeString(searchParams.get("workdir")),
|
|
262
|
+
dryRun: dryRunValue === "true",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function normalizeBuildOptions(value) {
|
|
266
|
+
if (!isRecord(value)) {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
repoUrl: normalizeString(value.repoUrl ?? value.repo),
|
|
271
|
+
ref: normalizeString(value.ref),
|
|
272
|
+
source: normalizeString(value.source),
|
|
273
|
+
workdir: normalizeString(value.workdir),
|
|
274
|
+
dryRun: value.dryRun === true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async function readJsonBody(request) {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
for await (const chunk of request) {
|
|
280
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
281
|
+
}
|
|
282
|
+
if (chunks.length === 0) {
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
286
|
+
}
|
|
287
|
+
function sendSseEvent(response, event, data, clientClosed = false) {
|
|
288
|
+
if (clientClosed || response.writableEnded || response.destroyed) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
response.write(`event: ${event}\n`);
|
|
292
|
+
response.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
293
|
+
}
|
|
294
|
+
function sendJson(response, statusCode, payload) {
|
|
295
|
+
response.writeHead(statusCode, {
|
|
296
|
+
"Access-Control-Allow-Origin": "*",
|
|
297
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
298
|
+
});
|
|
299
|
+
response.end(JSON.stringify(payload));
|
|
300
|
+
}
|
|
301
|
+
function sendHtml(response, statusCode, html) {
|
|
302
|
+
response.writeHead(statusCode, {
|
|
303
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
304
|
+
});
|
|
305
|
+
response.end(html);
|
|
306
|
+
}
|
|
307
|
+
function sendNoContent(response) {
|
|
308
|
+
response.writeHead(204, {
|
|
309
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
310
|
+
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
311
|
+
"Access-Control-Allow-Origin": "*",
|
|
312
|
+
});
|
|
313
|
+
response.end();
|
|
314
|
+
}
|
|
315
|
+
function getContentType(filePath) {
|
|
316
|
+
switch (path.extname(filePath)) {
|
|
317
|
+
case ".css":
|
|
318
|
+
return "text/css; charset=utf-8";
|
|
319
|
+
case ".html":
|
|
320
|
+
return "text/html; charset=utf-8";
|
|
321
|
+
case ".js":
|
|
322
|
+
return "text/javascript; charset=utf-8";
|
|
323
|
+
case ".json":
|
|
324
|
+
return "application/json; charset=utf-8";
|
|
325
|
+
case ".svg":
|
|
326
|
+
return "image/svg+xml";
|
|
327
|
+
case ".png":
|
|
328
|
+
return "image/png";
|
|
329
|
+
case ".ico":
|
|
330
|
+
return "image/x-icon";
|
|
331
|
+
default:
|
|
332
|
+
return "text/plain; charset=utf-8";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function isRecord(value) {
|
|
336
|
+
return typeof value === "object" && value !== null;
|
|
337
|
+
}
|
|
338
|
+
function normalizeString(value) {
|
|
339
|
+
if (typeof value !== "string") {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
const trimmed = value.trim();
|
|
343
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
344
|
+
}
|
|
345
|
+
async function pathExists(targetPath) {
|
|
346
|
+
try {
|
|
347
|
+
await stat(targetPath);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function toErrorMessage(error) {
|
|
355
|
+
return error instanceof Error ? error.message : String(error);
|
|
356
|
+
}
|
|
357
|
+
function isDirectRun() {
|
|
358
|
+
const entryPoint = process.argv[1];
|
|
359
|
+
return Boolean(entryPoint) && path.resolve(entryPoint) === fileURLToPath(import.meta.url);
|
|
360
|
+
}
|
|
361
|
+
if (isDirectRun()) {
|
|
362
|
+
startServer().catch((error) => {
|
|
363
|
+
console.error(`\nError: ${toErrorMessage(error)}`);
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const DEFAULT_SITEFORGE_REPO = "https://github.com/waelio/siteforge.git";
|
|
2
|
+
export interface BuildSiteforgeOptions {
|
|
3
|
+
repoUrl?: string;
|
|
4
|
+
ref?: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
workdir?: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildStep {
|
|
10
|
+
title: string;
|
|
11
|
+
command: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface BuildPlan {
|
|
16
|
+
projectDir: string;
|
|
17
|
+
usesExistingSource: boolean;
|
|
18
|
+
requiredTools: string[];
|
|
19
|
+
steps: BuildStep[];
|
|
20
|
+
}
|
|
21
|
+
export interface ToolCheckResult {
|
|
22
|
+
tool: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
details: string;
|
|
25
|
+
}
|
|
26
|
+
export interface BuildExecutionHooks {
|
|
27
|
+
onPlan?: (plan: BuildPlan) => void;
|
|
28
|
+
onInfo?: (message: string) => void;
|
|
29
|
+
onStepStart?: (step: BuildStep, context: {
|
|
30
|
+
index: number;
|
|
31
|
+
total: number;
|
|
32
|
+
}) => void;
|
|
33
|
+
onStdout?: (chunk: string, step: BuildStep) => void;
|
|
34
|
+
onStderr?: (chunk: string, step: BuildStep) => void;
|
|
35
|
+
}
|
|
36
|
+
export declare function createBuildPlan(options: Omit<BuildSiteforgeOptions, "workdir" | "dryRun"> & {
|
|
37
|
+
projectDir: string;
|
|
38
|
+
}): BuildPlan;
|
|
39
|
+
export declare function getDoctorReport(): Promise<ToolCheckResult[]>;
|
|
40
|
+
export declare function formatDoctorReport(checks: ToolCheckResult[]): string;
|
|
41
|
+
export declare function runDoctor(): Promise<ToolCheckResult[]>;
|
|
42
|
+
export declare function prepareBuildPlan(options: BuildSiteforgeOptions): Promise<BuildPlan>;
|
|
43
|
+
export declare function runBuild(options: BuildSiteforgeOptions, hooks?: BuildExecutionHooks): Promise<BuildPlan>;
|
|
44
|
+
export declare function formatBuildPlan(plan: BuildPlan): string;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const DEFAULT_SITEFORGE_REPO = "https://github.com/waelio/siteforge.git";
|
|
6
|
+
const TOOL_VERSION_ARGS = {
|
|
7
|
+
git: ["--version"],
|
|
8
|
+
npm: ["--version"],
|
|
9
|
+
go: ["version"],
|
|
10
|
+
};
|
|
11
|
+
export function createBuildPlan(options) {
|
|
12
|
+
const repoUrl = options.repoUrl ?? DEFAULT_SITEFORGE_REPO;
|
|
13
|
+
const usesExistingSource = Boolean(options.source);
|
|
14
|
+
const requiredTools = new Set(["npm", "go"]);
|
|
15
|
+
const steps = [];
|
|
16
|
+
if (!usesExistingSource) {
|
|
17
|
+
requiredTools.add("git");
|
|
18
|
+
steps.push({
|
|
19
|
+
title: "Clone siteforge",
|
|
20
|
+
command: "git",
|
|
21
|
+
args: ["clone", repoUrl, options.projectDir],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (options.ref) {
|
|
25
|
+
requiredTools.add("git");
|
|
26
|
+
steps.push({
|
|
27
|
+
title: `Checkout ${options.ref}`,
|
|
28
|
+
command: "git",
|
|
29
|
+
args: ["checkout", options.ref],
|
|
30
|
+
cwd: options.projectDir,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
steps.push({
|
|
34
|
+
title: "Install dependencies",
|
|
35
|
+
command: "npm",
|
|
36
|
+
args: ["ci"],
|
|
37
|
+
cwd: options.projectDir,
|
|
38
|
+
}, {
|
|
39
|
+
title: "Build website",
|
|
40
|
+
command: "npm",
|
|
41
|
+
args: ["run", "build"],
|
|
42
|
+
cwd: options.projectDir,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
projectDir: options.projectDir,
|
|
46
|
+
usesExistingSource,
|
|
47
|
+
requiredTools: [...requiredTools],
|
|
48
|
+
steps,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function getDoctorReport() {
|
|
52
|
+
return Promise.all(["git", "npm", "go"].map((tool) => checkTool(tool)));
|
|
53
|
+
}
|
|
54
|
+
export function formatDoctorReport(checks) {
|
|
55
|
+
return checks
|
|
56
|
+
.map((check) => `${check.ok ? "✔" : "✘"} ${check.tool}: ${check.details}`)
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
export async function runDoctor() {
|
|
60
|
+
const checks = await getDoctorReport();
|
|
61
|
+
let hasFailure = false;
|
|
62
|
+
for (const check of checks) {
|
|
63
|
+
if (check.ok) {
|
|
64
|
+
console.log(`✔ ${check.tool}: ${check.details}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
hasFailure = true;
|
|
68
|
+
console.error(`✘ ${check.tool}: ${check.details}`);
|
|
69
|
+
}
|
|
70
|
+
if (hasFailure) {
|
|
71
|
+
throw new Error("One or more required tools are missing.");
|
|
72
|
+
}
|
|
73
|
+
return checks;
|
|
74
|
+
}
|
|
75
|
+
export async function prepareBuildPlan(options) {
|
|
76
|
+
const location = await resolveProjectDir(options);
|
|
77
|
+
return createBuildPlan({
|
|
78
|
+
repoUrl: options.repoUrl,
|
|
79
|
+
ref: options.ref,
|
|
80
|
+
source: options.source,
|
|
81
|
+
projectDir: location.projectDir,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export async function runBuild(options, hooks) {
|
|
85
|
+
const plan = await prepareBuildPlan(options);
|
|
86
|
+
hooks?.onPlan?.(plan);
|
|
87
|
+
if (options.dryRun) {
|
|
88
|
+
writeInfo("Dry run: planned build steps", hooks);
|
|
89
|
+
writeInfo(formatBuildPlan(plan), hooks);
|
|
90
|
+
return plan;
|
|
91
|
+
}
|
|
92
|
+
await assertRequiredTools(plan.requiredTools);
|
|
93
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
94
|
+
hooks?.onStepStart?.(step, { index: index + 1, total: plan.steps.length });
|
|
95
|
+
writeInfo(`==> ${step.title}`, hooks);
|
|
96
|
+
await runProcess(step.command, step.args, {
|
|
97
|
+
cwd: step.cwd,
|
|
98
|
+
stdio: hooks ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
99
|
+
onStdout: (chunk) => {
|
|
100
|
+
hooks?.onStdout?.(chunk, step);
|
|
101
|
+
},
|
|
102
|
+
onStderr: (chunk) => {
|
|
103
|
+
hooks?.onStderr?.(chunk, step);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
writeInfo("Build completed successfully.", hooks);
|
|
108
|
+
writeInfo(`Working directory: ${plan.projectDir}`, hooks);
|
|
109
|
+
return plan;
|
|
110
|
+
}
|
|
111
|
+
export function formatBuildPlan(plan) {
|
|
112
|
+
return [
|
|
113
|
+
`Repository directory: ${plan.projectDir}`,
|
|
114
|
+
`Using existing source: ${plan.usesExistingSource ? "yes" : "no"}`,
|
|
115
|
+
`Required tools: ${plan.requiredTools.join(", ")}`,
|
|
116
|
+
...plan.steps.map((step, index) => `${index + 1}. ${step.title}\n ${formatStep(step)}`),
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
async function resolveProjectDir(options) {
|
|
120
|
+
if (options.source) {
|
|
121
|
+
const projectDir = path.resolve(options.source);
|
|
122
|
+
await assertExistingDirectory(projectDir, "source");
|
|
123
|
+
return {
|
|
124
|
+
projectDir,
|
|
125
|
+
usesExistingSource: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (options.workdir) {
|
|
129
|
+
const projectDir = path.resolve(options.workdir);
|
|
130
|
+
await assertCloneTargetIsReady(projectDir);
|
|
131
|
+
return {
|
|
132
|
+
projectDir,
|
|
133
|
+
usesExistingSource: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const projectDir = await mkdtemp(path.join(os.tmpdir(), "waelio-siteforge-"));
|
|
137
|
+
return {
|
|
138
|
+
projectDir,
|
|
139
|
+
usesExistingSource: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function assertRequiredTools(tools) {
|
|
143
|
+
const failures = [];
|
|
144
|
+
for (const tool of tools) {
|
|
145
|
+
const result = await checkTool(tool);
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
failures.push(`${tool}: ${result.details}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (failures.length > 0) {
|
|
151
|
+
throw new Error(`Missing required tools:\n- ${failures.join("\n- ")}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function checkTool(tool) {
|
|
155
|
+
const args = TOOL_VERSION_ARGS[tool] ?? ["--version"];
|
|
156
|
+
try {
|
|
157
|
+
const output = await captureProcess(tool, args);
|
|
158
|
+
return {
|
|
159
|
+
tool,
|
|
160
|
+
ok: true,
|
|
161
|
+
details: output.split(/\r?\n/)[0]?.trim() || "available",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
return {
|
|
167
|
+
tool,
|
|
168
|
+
ok: false,
|
|
169
|
+
details: message,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function assertExistingDirectory(targetPath, label) {
|
|
174
|
+
let details;
|
|
175
|
+
try {
|
|
176
|
+
details = await stat(targetPath);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (isMissingPathError(error)) {
|
|
180
|
+
throw new Error(`The ${label} path does not exist: ${targetPath}`);
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (!details.isDirectory()) {
|
|
185
|
+
throw new Error(`The ${label} path is not a directory: ${targetPath}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function assertCloneTargetIsReady(targetPath) {
|
|
189
|
+
try {
|
|
190
|
+
const details = await stat(targetPath);
|
|
191
|
+
if (!details.isDirectory()) {
|
|
192
|
+
throw new Error(`The workdir path exists but is not a directory: ${targetPath}`);
|
|
193
|
+
}
|
|
194
|
+
const entries = await readdir(targetPath);
|
|
195
|
+
if (entries.length > 0) {
|
|
196
|
+
throw new Error(`The workdir path must be empty before cloning: ${targetPath}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (isMissingPathError(error)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function formatStep(step) {
|
|
207
|
+
const command = [step.command, ...step.args].map(quoteArgument).join(" ");
|
|
208
|
+
if (!step.cwd) {
|
|
209
|
+
return command;
|
|
210
|
+
}
|
|
211
|
+
return `${command} (cwd: ${step.cwd})`;
|
|
212
|
+
}
|
|
213
|
+
function quoteArgument(value) {
|
|
214
|
+
if (/^[a-zA-Z0-9_./:=@-]+$/.test(value)) {
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
return JSON.stringify(value);
|
|
218
|
+
}
|
|
219
|
+
function writeInfo(message, hooks) {
|
|
220
|
+
if (hooks?.onInfo) {
|
|
221
|
+
hooks.onInfo(message);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
console.log(message);
|
|
225
|
+
}
|
|
226
|
+
async function captureProcess(command, args) {
|
|
227
|
+
let stdout = "";
|
|
228
|
+
let stderr = "";
|
|
229
|
+
await runProcess(command, args, {
|
|
230
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
231
|
+
onStdout: (chunk) => {
|
|
232
|
+
stdout += chunk;
|
|
233
|
+
},
|
|
234
|
+
onStderr: (chunk) => {
|
|
235
|
+
stderr += chunk;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
return `${stdout}${stderr}`.trim();
|
|
239
|
+
}
|
|
240
|
+
function runProcess(command, args, options) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const child = spawn(resolveCommand(command), args, {
|
|
243
|
+
cwd: options.cwd,
|
|
244
|
+
stdio: options.stdio ?? "inherit",
|
|
245
|
+
});
|
|
246
|
+
child.stdout?.setEncoding("utf8");
|
|
247
|
+
child.stdout?.on("data", (chunk) => {
|
|
248
|
+
options.onStdout?.(chunk);
|
|
249
|
+
});
|
|
250
|
+
child.stderr?.setEncoding("utf8");
|
|
251
|
+
child.stderr?.on("data", (chunk) => {
|
|
252
|
+
options.onStderr?.(chunk);
|
|
253
|
+
});
|
|
254
|
+
child.on("error", (error) => {
|
|
255
|
+
if (error.code === "ENOENT") {
|
|
256
|
+
reject(new Error(`Command not found: ${command}`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
reject(error);
|
|
260
|
+
});
|
|
261
|
+
child.on("close", (code) => {
|
|
262
|
+
if (code === 0) {
|
|
263
|
+
resolve();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
reject(new Error(`Command failed with exit code ${code}: ${formatCommand(command, args)}`));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function resolveCommand(command) {
|
|
271
|
+
if (process.platform === "win32" && command === "npm") {
|
|
272
|
+
return "npm.cmd";
|
|
273
|
+
}
|
|
274
|
+
return command;
|
|
275
|
+
}
|
|
276
|
+
function formatCommand(command, args) {
|
|
277
|
+
return [command, ...args].map(quoteArgument).join(" ");
|
|
278
|
+
}
|
|
279
|
+
function isMissingPathError(error) {
|
|
280
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
281
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|