@woniru/we-installer 0.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/README.md +107 -0
- package/bin/we-installer.js +34 -0
- package/package.json +20 -0
- package/src/auth.js +87 -0
- package/src/authServer.js +490 -0
- package/src/download.js +123 -0
- package/src/extractZip.js +32 -0
- package/src/githubApi.js +22 -0
- package/src/githubDeviceFlow.js +82 -0
- package/src/githubDownload.js +33 -0
- package/src/prompt.js +14 -0
- package/src/sqlRunner.js +165 -0
- package/templates/auth.html +377 -0
- package/templates/configure.html +488 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// src/authServer.js
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const { spawn } = require("child_process");
|
|
7
|
+
const mysql = require("mysql2/promise");
|
|
8
|
+
const { executeSqlFileWithDelimiters } = require("./sqlRunner.js");
|
|
9
|
+
|
|
10
|
+
// node-redis (install in installer package): npm i redis
|
|
11
|
+
const { createClient } = require("redis");
|
|
12
|
+
|
|
13
|
+
function loadTemplate(fileName, replacements = {}) {
|
|
14
|
+
const templatePath = path.join(__dirname, "..", "templates", fileName);
|
|
15
|
+
let html = fs.readFileSync(templatePath, "utf8");
|
|
16
|
+
|
|
17
|
+
for (const [k, v] of Object.entries(replacements)) {
|
|
18
|
+
html = html.replaceAll(k, String(v));
|
|
19
|
+
}
|
|
20
|
+
return html;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runCommand(cmd, args, { cwd, statusCb }) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
statusCb?.(`Running: ${cmd} ${args.join(" ")}`);
|
|
26
|
+
|
|
27
|
+
const child = spawn(cmd, args, {
|
|
28
|
+
cwd,
|
|
29
|
+
stdio: "inherit",
|
|
30
|
+
shell: process.platform === "win32"
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
child.on("close", (code) => {
|
|
34
|
+
if (code === 0) resolve();
|
|
35
|
+
else reject(new Error(`${cmd} ${args.join(" ")} exited with code ${code}`));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readSchemaSql(installDir) {
|
|
41
|
+
// You can change this default anytime.
|
|
42
|
+
// Put schema at: <installedProject>/db/schema.sql OR <installedProject>/schema.sql
|
|
43
|
+
const candidates = [
|
|
44
|
+
path.join(installDir, "DB Backup", "WoniruCRMMeta.sql"),
|
|
45
|
+
path.join(installDir, "schema.sql")
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const p of candidates) {
|
|
49
|
+
if (fs.existsSync(p)) return { filePath: p, sql: fs.readFileSync(p, "utf8") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error("Schema file not found. Expected db/schema.sql or schema.sql in install directory.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readJsonBody(req) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let body = "";
|
|
58
|
+
req.on("data", (chunk) => {
|
|
59
|
+
body += chunk;
|
|
60
|
+
if (body.length > 200_000) reject(new Error("Request body too large"));
|
|
61
|
+
});
|
|
62
|
+
req.on("end", () => {
|
|
63
|
+
try {
|
|
64
|
+
resolve(JSON.parse(body || "{}"));
|
|
65
|
+
} catch {
|
|
66
|
+
reject(new Error("Bad JSON"));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function randomKey() {
|
|
73
|
+
// strong, URL-safe-ish key
|
|
74
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function withRedisClient({ host, port, username, password }, fn) {
|
|
78
|
+
const url = `redis://${(password && username != 'default') ? encodeURIComponent(username) + ":" + encodeURIComponent(password) + "@" : ""}${host}:${port}`;
|
|
79
|
+
const client = createClient({ url });
|
|
80
|
+
client.on("error", () => { /* handled by connect/commands */ });
|
|
81
|
+
|
|
82
|
+
await client.connect();
|
|
83
|
+
try {
|
|
84
|
+
return await fn(client);
|
|
85
|
+
} finally {
|
|
86
|
+
try { await client.quit(); } catch { }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startAuthServer({ verificationUri, userCode }) {
|
|
91
|
+
const state = {
|
|
92
|
+
// Progress (auth page uses /status to drive phase + redirect)
|
|
93
|
+
phase: "auth", // auth | install | download | npm | configure
|
|
94
|
+
status: "Waiting for approval in GitHub…",
|
|
95
|
+
userLogin: null,
|
|
96
|
+
|
|
97
|
+
// Install selection
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
installChoice: null, // accept | manual | null
|
|
100
|
+
|
|
101
|
+
// Installer runtime context
|
|
102
|
+
installDir: null, // set by CLI once directory is decided
|
|
103
|
+
|
|
104
|
+
// Configure wizard data
|
|
105
|
+
configure: {
|
|
106
|
+
step: 1,
|
|
107
|
+
redis: {
|
|
108
|
+
existing: false,
|
|
109
|
+
adminKey: "",
|
|
110
|
+
// will be generated only when existing=false and setup runs
|
|
111
|
+
sessUserKey: "",
|
|
112
|
+
permUserKey: "",
|
|
113
|
+
aclFilePath: ""
|
|
114
|
+
},
|
|
115
|
+
db: {
|
|
116
|
+
host: "",
|
|
117
|
+
user: "",
|
|
118
|
+
password: "",
|
|
119
|
+
name: ""
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const server = http.createServer(async (req, res) => {
|
|
125
|
+
try {
|
|
126
|
+
// -------------------------
|
|
127
|
+
// Status endpoint
|
|
128
|
+
// -------------------------
|
|
129
|
+
if (req.url === "/status") {
|
|
130
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
131
|
+
res.end(JSON.stringify({
|
|
132
|
+
phase: state.phase,
|
|
133
|
+
status: state.status,
|
|
134
|
+
userLogin: state.userLogin
|
|
135
|
+
}));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -------------------------
|
|
140
|
+
// Context endpoint
|
|
141
|
+
// -------------------------
|
|
142
|
+
if (req.url === "/context") {
|
|
143
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
144
|
+
res.end(JSON.stringify({
|
|
145
|
+
cwd: state.cwd,
|
|
146
|
+
installChoice: state.installChoice
|
|
147
|
+
}));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -------------------------
|
|
152
|
+
// Install choice (accept/manual)
|
|
153
|
+
// -------------------------
|
|
154
|
+
if (req.url === "/install-choice" && req.method === "POST") {
|
|
155
|
+
const data = await readJsonBody(req);
|
|
156
|
+
const choice = data.choice;
|
|
157
|
+
|
|
158
|
+
if (choice !== "accept" && choice !== "manual") {
|
|
159
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid choice" }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
state.installChoice = choice;
|
|
165
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
166
|
+
res.end(JSON.stringify({ ok: true }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -------------------------
|
|
171
|
+
// Configure: get state (Step 1/2 rendering)
|
|
172
|
+
// -------------------------
|
|
173
|
+
if (req.url === "/configure/state") {
|
|
174
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
175
|
+
res.end(JSON.stringify({
|
|
176
|
+
step: state.configure.step,
|
|
177
|
+
installDir: state.installDir,
|
|
178
|
+
redis: {
|
|
179
|
+
existing: state.configure.redis.existing,
|
|
180
|
+
adminKey: state.configure.redis.adminKey,
|
|
181
|
+
aclFilePath: state.configure.redis.aclFilePath
|
|
182
|
+
},
|
|
183
|
+
db: state.configure.db
|
|
184
|
+
}));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -------------------------
|
|
189
|
+
// Configure Step 1: apply Redis choice
|
|
190
|
+
//
|
|
191
|
+
// Rules:
|
|
192
|
+
// - Always collect adminKey.
|
|
193
|
+
// - If existing=true:
|
|
194
|
+
// - do NOT create users
|
|
195
|
+
// - just test connection as admin@127.0.0.1:6379 (PING)
|
|
196
|
+
// - If existing=false:
|
|
197
|
+
// - create users (admin/sess_user/perm_user + default off)
|
|
198
|
+
// - set aclfile + ACL SAVE
|
|
199
|
+
// - On success -> advance to step 2
|
|
200
|
+
// -------------------------
|
|
201
|
+
if (req.url === "/configure/redis/apply" && req.method === "POST") {
|
|
202
|
+
const data = await readJsonBody(req);
|
|
203
|
+
const existing = !!data.existing;
|
|
204
|
+
const adminKey = (data.adminKey || "").toString();
|
|
205
|
+
|
|
206
|
+
if (!adminKey || adminKey.trim().length < 8) {
|
|
207
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
208
|
+
res.end(JSON.stringify({ ok: false, error: "Redis admin key must be at least 8 characters." }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// persist what user entered
|
|
213
|
+
state.configure.redis.existing = existing;
|
|
214
|
+
state.configure.redis.adminKey = adminKey;
|
|
215
|
+
|
|
216
|
+
const host = "127.0.0.1";
|
|
217
|
+
const port = 6379;
|
|
218
|
+
const username = "default";
|
|
219
|
+
|
|
220
|
+
// Attempt connection as admin
|
|
221
|
+
state.status = existing
|
|
222
|
+
? "Testing Redis connection (admin@127.0.0.1:6379)…"
|
|
223
|
+
: "Connecting to Redis to configure ACL users…";
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
if (existing) {
|
|
227
|
+
await withRedisClient({ host, port, username: 'admin', password: adminKey }, async (client) => {
|
|
228
|
+
const pong = await client.ping();
|
|
229
|
+
if (!pong) throw new Error("No PING response");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
state.configure.step = 2; // proceed
|
|
233
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
234
|
+
res.end(JSON.stringify({ ok: true, nextStep: 2 }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Not existing: we configure ACL users + save aclfile
|
|
239
|
+
// Ensure we have an installDir to write the ACL file into
|
|
240
|
+
if (!state.installDir) {
|
|
241
|
+
throw new Error("Installer installDir not set yet. (Internal error: missing install directory)");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* const aclDir = path.join(state.installDir, "config");
|
|
245
|
+
fs.mkdirSync(aclDir, { recursive: true });
|
|
246
|
+
|
|
247
|
+
const aclFilePath = path.join(aclDir, "redis-users.acl");
|
|
248
|
+
state.configure.redis.aclFilePath = aclFilePath; */
|
|
249
|
+
|
|
250
|
+
state.status = "Building Redis docker image (no-cache)…";
|
|
251
|
+
await runCommand("docker", ["compose", "build", "--no-cache"], {
|
|
252
|
+
cwd: state.installDir,
|
|
253
|
+
statusCb: (s) => (state.status = s)
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
state.status = "Starting Redis container…";
|
|
257
|
+
await runCommand("docker", ["compose", "up", "-d"], {
|
|
258
|
+
cwd: state.installDir,
|
|
259
|
+
statusCb: (s) => (state.status = s)
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Optional: small delay before first connect (helps on slower machines)
|
|
263
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
264
|
+
|
|
265
|
+
const sessKey = randomKey();
|
|
266
|
+
const permKey = randomKey();
|
|
267
|
+
|
|
268
|
+
await withRedisClient({ host, port, username, password: adminKey }, async (client) => {
|
|
269
|
+
// Create/modify users as per your rules
|
|
270
|
+
// admin user
|
|
271
|
+
await client.sendCommand(["ACL", "SETUSER", "admin", "ON", `>${adminKey}`, "+@all"]);
|
|
272
|
+
|
|
273
|
+
// session user
|
|
274
|
+
await client.sendCommand([
|
|
275
|
+
"ACL", "SETUSER", "sess_user",
|
|
276
|
+
"ON", `>${sessKey}`,
|
|
277
|
+
"~sess:*",
|
|
278
|
+
"+ping", "+get", "+set", "+del",
|
|
279
|
+
"+expire", "+pexpire", "+ttl", "+pttl", "+exists"
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
// permission user
|
|
283
|
+
await client.sendCommand([
|
|
284
|
+
"ACL", "SETUSER", "perm_user",
|
|
285
|
+
"ON", `>${permKey}`,
|
|
286
|
+
"~userPerm:*",
|
|
287
|
+
"+ping", "+get", "+set", "+del",
|
|
288
|
+
"+expire", "+pexpire", "+ttl", "+pttl", "+exists",
|
|
289
|
+
"+hget", "+hset", "+hdel", "+hmget", "+hgetall",
|
|
290
|
+
"+sadd", "+srem", "+smembers", "+scard", "+scan",
|
|
291
|
+
"+hscan", "+sscan"
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
// Turn off default user
|
|
295
|
+
await client.sendCommand(["ACL", "SETUSER", "default", "OFF"]);
|
|
296
|
+
|
|
297
|
+
// Point Redis at our aclfile, then save
|
|
298
|
+
// await client.sendCommand(["CONFIG", "SET", "aclfile", aclFilePath]);
|
|
299
|
+
await client.sendCommand(["ACL", "SAVE"]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// store generated keys in state (we’ll persist later to real secrets storage)
|
|
303
|
+
state.configure.redis.sessUserKey = sessKey;
|
|
304
|
+
state.configure.redis.permUserKey = permKey;
|
|
305
|
+
|
|
306
|
+
state.configure.step = 2; // proceed
|
|
307
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
308
|
+
res.end(JSON.stringify({ ok: true, nextStep: 2 }));
|
|
309
|
+
return;
|
|
310
|
+
|
|
311
|
+
} catch (err) {
|
|
312
|
+
res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
313
|
+
res.end(JSON.stringify({ ok: false, error: err.message || "Redis step failed" }));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -------------------------
|
|
319
|
+
// Configure Step 2: apply DB (MySQL)
|
|
320
|
+
// - Save DB inputs
|
|
321
|
+
// - Test connection
|
|
322
|
+
// - Create DB if missing (if permitted)
|
|
323
|
+
// - Apply schema.sql using mysql2 (remote-safe)
|
|
324
|
+
// - On success -> advance to step 3
|
|
325
|
+
// -------------------------
|
|
326
|
+
if (req.url === "/configure/db/apply" && req.method === "POST") {
|
|
327
|
+
const data = await readJsonBody(req);
|
|
328
|
+
|
|
329
|
+
const host = (data.host || "").toString().trim();
|
|
330
|
+
const user = (data.user || "").toString().trim();
|
|
331
|
+
const password = (data.password || "").toString();
|
|
332
|
+
const dbName = (data.name || "").toString().trim();
|
|
333
|
+
|
|
334
|
+
if (!host) {
|
|
335
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
336
|
+
res.end(JSON.stringify({ ok: false, error: "DB host is required." }));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!user) {
|
|
340
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
341
|
+
res.end(JSON.stringify({ ok: false, error: "DB user is required." }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!dbName) {
|
|
345
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
346
|
+
res.end(JSON.stringify({ ok: false, error: "DB name is required." }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!state.installDir) {
|
|
350
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
351
|
+
res.end(JSON.stringify({ ok: false, error: "Install directory not set (internal error)." }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// persist
|
|
356
|
+
state.configure.db.host = host;
|
|
357
|
+
state.configure.db.user = user;
|
|
358
|
+
state.configure.db.password = password;
|
|
359
|
+
state.configure.db.name = dbName;
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
state.status = "Testing database connection…";
|
|
363
|
+
|
|
364
|
+
// 1) connect without selecting database (so we can CREATE DATABASE if needed)
|
|
365
|
+
const adminConn = await mysql.createConnection({
|
|
366
|
+
host,
|
|
367
|
+
user,
|
|
368
|
+
password,
|
|
369
|
+
// NOTE: no database here on purpose
|
|
370
|
+
// Enable multi statements only when we apply schema below
|
|
371
|
+
multipleStatements: false
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// 2) ensure database exists (best-effort)
|
|
375
|
+
state.status = `Ensuring database exists: ${dbName}…`;
|
|
376
|
+
await adminConn.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
|
|
377
|
+
await adminConn.end();
|
|
378
|
+
|
|
379
|
+
state.status = "Reading schema file…";
|
|
380
|
+
const { filePath, sql } = readSchemaSql(state.installDir);
|
|
381
|
+
|
|
382
|
+
state.status = "Applying database schema…";
|
|
383
|
+
|
|
384
|
+
const dbConn = await mysql.createConnection({
|
|
385
|
+
host,
|
|
386
|
+
user,
|
|
387
|
+
password,
|
|
388
|
+
database: dbName,
|
|
389
|
+
multipleStatements: false // IMPORTANT: we execute statements one-by-one
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await executeSqlFileWithDelimiters(dbConn, sql, {
|
|
393
|
+
onProgress: () => {
|
|
394
|
+
// keep this light; don’t spam huge SQL into status
|
|
395
|
+
state.status = "Applying database schema…";
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await dbConn.end();
|
|
400
|
+
|
|
401
|
+
// 5) advance to step 3
|
|
402
|
+
state.configure.step = 3;
|
|
403
|
+
state.status = `Database ready ✅ (${dbName})`;
|
|
404
|
+
|
|
405
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
406
|
+
res.end(JSON.stringify({ ok: true, nextStep: 3, schemaFile: filePath }));
|
|
407
|
+
return;
|
|
408
|
+
|
|
409
|
+
} catch (err) {
|
|
410
|
+
res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
411
|
+
res.end(JSON.stringify({
|
|
412
|
+
ok: false,
|
|
413
|
+
error: err.message || "Database step failed"
|
|
414
|
+
}));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// -------------------------
|
|
420
|
+
// Configure: done/exit signal
|
|
421
|
+
// - Browser calls this on Finish and on unload
|
|
422
|
+
// - CLI can end after server closes
|
|
423
|
+
// -------------------------
|
|
424
|
+
if (req.url === "/configure/done" && (req.method === "POST" || req.method === "GET")) {
|
|
425
|
+
state.status = "Installer finished. Closing…";
|
|
426
|
+
|
|
427
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
428
|
+
res.end(JSON.stringify({ ok: true }));
|
|
429
|
+
|
|
430
|
+
// Close the HTTP server right after responding
|
|
431
|
+
setTimeout(() => {
|
|
432
|
+
try { server.close(); } catch { }
|
|
433
|
+
}, 1000);
|
|
434
|
+
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// -------------------------
|
|
439
|
+
// Pages
|
|
440
|
+
// -------------------------
|
|
441
|
+
if (req.url === "/" || req.url.startsWith("/?")) {
|
|
442
|
+
const html = loadTemplate("auth.html", {
|
|
443
|
+
"{{USER_CODE}}": userCode,
|
|
444
|
+
"{{VERIFICATION_URI}}": verificationUri
|
|
445
|
+
});
|
|
446
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
447
|
+
res.end(html);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (req.url === "/configure" || req.url.startsWith("/configure?")) {
|
|
452
|
+
const html = loadTemplate("configure.html", {});
|
|
453
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
454
|
+
res.end(html);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
459
|
+
res.end("Not Found");
|
|
460
|
+
} catch (err) {
|
|
461
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
462
|
+
res.end(JSON.stringify({ ok: false, error: err.message || "Server error" }));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return new Promise((resolve, reject) => {
|
|
467
|
+
server.on("error", reject);
|
|
468
|
+
server.listen(0, "127.0.0.1", () => {
|
|
469
|
+
const port = server.address().port;
|
|
470
|
+
|
|
471
|
+
resolve({
|
|
472
|
+
url: `http://127.0.0.1:${port}/`,
|
|
473
|
+
|
|
474
|
+
// setters used by CLI
|
|
475
|
+
setStatus: (s) => { state.status = String(s); },
|
|
476
|
+
setPhase: (p) => { state.phase = String(p); },
|
|
477
|
+
setUserLogin: (login) => { state.userLogin = login ? String(login) : null; },
|
|
478
|
+
setInstallDir: (dir) => { state.installDir = dir ? String(dir) : null; },
|
|
479
|
+
|
|
480
|
+
// getters used by CLI
|
|
481
|
+
getInstallChoice: () => state.installChoice,
|
|
482
|
+
getCwd: () => state.cwd,
|
|
483
|
+
|
|
484
|
+
close: () => new Promise((r) => server.close(() => r()))
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
module.exports = { startAuthServer };
|
package/src/download.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// src/download.js
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const { spawn } = require("child_process");
|
|
6
|
+
|
|
7
|
+
const { ask } = require("./prompt");
|
|
8
|
+
const { downloadRepoZip } = require("./githubDownload");
|
|
9
|
+
const { extractZipStripTopDir } = require("./extractZip");
|
|
10
|
+
|
|
11
|
+
function parseRepo(repoStr) {
|
|
12
|
+
const [owner, repo] = String(repoStr || "").split("/");
|
|
13
|
+
if (!owner || !repo) throw new Error("WE_GITHUB_REPO must be like: Owner/Repo");
|
|
14
|
+
return { owner, repo };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dirExists(p) {
|
|
18
|
+
try { return fs.statSync(p).isDirectory(); }
|
|
19
|
+
catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDirEmpty(p) {
|
|
23
|
+
const entries = fs.readdirSync(p);
|
|
24
|
+
return entries.length === 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForInstallChoice(ui) {
|
|
28
|
+
ui?.setPhase("install");
|
|
29
|
+
ui?.setStatus("Choose install location in the browser: Accept (use current directory) or Choose manually…");
|
|
30
|
+
|
|
31
|
+
while (true) {
|
|
32
|
+
const choice = ui?.getInstallChoice?.();
|
|
33
|
+
if (choice === "accept" || choice === "manual") return choice;
|
|
34
|
+
await new Promise(r => setTimeout(r, 400));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runNpmInstall(targetDir, ui) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
ui?.setPhase("npm");
|
|
41
|
+
ui?.setStatus("Installing dependencies (npm install)…");
|
|
42
|
+
|
|
43
|
+
console.log("\nRunning npm install...\n");
|
|
44
|
+
|
|
45
|
+
const child = spawn("npm", ["install"], {
|
|
46
|
+
cwd: targetDir,
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
shell: process.platform === "win32"
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
child.on("close", (code) => {
|
|
52
|
+
if (code === 0) {
|
|
53
|
+
ui?.setStatus("Dependencies installed successfully ✅");
|
|
54
|
+
resolve();
|
|
55
|
+
} else {
|
|
56
|
+
ui?.setStatus("npm install failed ❌");
|
|
57
|
+
reject(new Error(`npm install exited with code ${code}`));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runDownload({ token, ui }) {
|
|
64
|
+
const repoStr = 'yathanshsharma/woniru-crm';
|
|
65
|
+
const ref = "main";
|
|
66
|
+
const { owner, repo } = parseRepo(repoStr);
|
|
67
|
+
|
|
68
|
+
const choice = await waitForInstallChoice(ui);
|
|
69
|
+
|
|
70
|
+
const defaultDir = path.join(process.cwd(), "woniru-engine");
|
|
71
|
+
let targetDir;
|
|
72
|
+
|
|
73
|
+
if (choice === "accept") {
|
|
74
|
+
targetDir = defaultDir;
|
|
75
|
+
console.log(`Using default install directory: ${targetDir}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log("Manual selection chosen in browser.");
|
|
78
|
+
const answer = await ask(`Install location (must be empty) [default: ${defaultDir}]: `);
|
|
79
|
+
targetDir = (answer && answer.trim()) ? path.resolve(answer.trim()) : defaultDir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// enforce empty or non-existent directory
|
|
83
|
+
if (dirExists(targetDir)) {
|
|
84
|
+
if (!isDirEmpty(targetDir)) {
|
|
85
|
+
ui?.setStatus(`Failed ❌ Install directory not empty: ${targetDir}`);
|
|
86
|
+
throw new Error(`Install directory must be empty: ${targetDir}`);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const zipPath = path.join(os.tmpdir(), `we-installer-${repo}-${Date.now()}.zip`);
|
|
93
|
+
|
|
94
|
+
ui?.setPhase("download");
|
|
95
|
+
ui?.setStatus(`Downloading ${owner}/${repo}@${ref}…`);
|
|
96
|
+
console.log(`Downloading ${owner}/${repo}@${ref}...`);
|
|
97
|
+
|
|
98
|
+
await downloadRepoZip({ token, owner, repo, ref, outFile: zipPath });
|
|
99
|
+
|
|
100
|
+
ui?.setStatus(`Extracting into: ${targetDir}…`);
|
|
101
|
+
console.log(`Extracting to: ${targetDir}...`);
|
|
102
|
+
|
|
103
|
+
extractZipStripTopDir(zipPath, targetDir);
|
|
104
|
+
|
|
105
|
+
ui?.setStatus("Download complete ✅");
|
|
106
|
+
|
|
107
|
+
// ---------------------
|
|
108
|
+
// NEW: npm stage
|
|
109
|
+
// ---------------------
|
|
110
|
+
await runNpmInstall(targetDir, ui);
|
|
111
|
+
ui.setInstallDir(targetDir);
|
|
112
|
+
|
|
113
|
+
// Move to wizard phase
|
|
114
|
+
ui?.setPhase("configure");
|
|
115
|
+
ui?.setStatus("Installation complete. Opening configuration wizard…");
|
|
116
|
+
|
|
117
|
+
console.log("\n🎉 Installation complete.");
|
|
118
|
+
console.log(`Path: ${targetDir}`);
|
|
119
|
+
|
|
120
|
+
return { targetDir };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { runDownload };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/extractZip.js
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const AdmZip = require("adm-zip");
|
|
5
|
+
|
|
6
|
+
function ensureDir(dir) {
|
|
7
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractZipStripTopDir(zipFile, destDir) {
|
|
11
|
+
ensureDir(destDir);
|
|
12
|
+
|
|
13
|
+
const zip = new AdmZip(zipFile);
|
|
14
|
+
const entries = zip.getEntries();
|
|
15
|
+
|
|
16
|
+
// GitHub zipball structure: <repo>-<hash>/<files...>
|
|
17
|
+
// We strip the first folder segment.
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (e.isDirectory) continue;
|
|
20
|
+
|
|
21
|
+
const parts = e.entryName.split("/").filter(Boolean);
|
|
22
|
+
if (parts.length < 2) continue;
|
|
23
|
+
|
|
24
|
+
const relativePath = parts.slice(1).join("/"); // drop root folder
|
|
25
|
+
const outPath = path.join(destDir, relativePath);
|
|
26
|
+
|
|
27
|
+
ensureDir(path.dirname(outPath));
|
|
28
|
+
fs.writeFileSync(outPath, e.getData());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { extractZipStripTopDir };
|
package/src/githubApi.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/githubApi.js
|
|
2
|
+
async function getGithubUser({ token }) {
|
|
3
|
+
const res = await fetch("https://api.github.com/user", {
|
|
4
|
+
method: "GET",
|
|
5
|
+
headers: {
|
|
6
|
+
"Accept": "application/vnd.github+json",
|
|
7
|
+
"Authorization": `Bearer ${token}`,
|
|
8
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
9
|
+
"User-Agent": "we-npx-installer"
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const data = await res.json().catch(() => ({}));
|
|
14
|
+
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
throw new Error(`Failed to fetch GitHub user (HTTP ${res.status}): ${JSON.stringify(data)}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return data; // contains login, id, name, etc
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { getGithubUser };
|