zuro-cli 0.0.2-beta.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/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +841 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +818 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +41 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import ora from "ora";
|
|
8
|
+
import chalk2 from "chalk";
|
|
9
|
+
import fs4 from "fs-extra";
|
|
10
|
+
import path4 from "path";
|
|
11
|
+
import prompts from "prompts";
|
|
12
|
+
|
|
13
|
+
// src/utils/registry.ts
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
var DEFAULT_REGISTRY_BASE_URL = "https://zuro-cli.devbybriyan.com/registry";
|
|
16
|
+
var DEFAULT_REGISTRY_ENTRY_URL = `${DEFAULT_REGISTRY_BASE_URL}/channels/stable.json`;
|
|
17
|
+
var REGISTRY_ENV_VAR = "ZURO_REGISTRY_URL";
|
|
18
|
+
var REQUEST_TIMEOUT_MS = 8e3;
|
|
19
|
+
var MAX_RETRIES = 2;
|
|
20
|
+
function withTrailingSlash(url) {
|
|
21
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
22
|
+
}
|
|
23
|
+
function resolveRegistryEntryUrl() {
|
|
24
|
+
const override = process.env[REGISTRY_ENV_VAR]?.trim();
|
|
25
|
+
if (!override) {
|
|
26
|
+
return DEFAULT_REGISTRY_ENTRY_URL;
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(override);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Invalid ${REGISTRY_ENV_VAR} value '${override}'. Expected a valid http(s) URL.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (parsed.pathname.endsWith(".json")) {
|
|
37
|
+
return parsed.toString();
|
|
38
|
+
}
|
|
39
|
+
return new URL("channels/stable.json", withTrailingSlash(parsed.toString())).toString();
|
|
40
|
+
}
|
|
41
|
+
function isRegistryManifest(data) {
|
|
42
|
+
if (!data || typeof data !== "object") {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const candidate = data;
|
|
46
|
+
return !!candidate.modules && typeof candidate.modules === "object";
|
|
47
|
+
}
|
|
48
|
+
function isChannelPointer(data) {
|
|
49
|
+
if (!data || typeof data !== "object") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const candidate = data;
|
|
53
|
+
return typeof candidate.indexPath === "string" || typeof candidate.indexUrl === "string";
|
|
54
|
+
}
|
|
55
|
+
function delay(ms) {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
58
|
+
async function fetchWithRetry(url) {
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
headers: { Accept: "application/json, text/plain, */*" },
|
|
66
|
+
signal: controller.signal
|
|
67
|
+
});
|
|
68
|
+
if (response.ok) {
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
const shouldRetry = response.status >= 500 && attempt < MAX_RETRIES;
|
|
72
|
+
if (shouldRetry) {
|
|
73
|
+
await delay(250 * (attempt + 1));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Request failed (${response.status} ${response.statusText}) for ${url}`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
lastError = error;
|
|
79
|
+
if (attempt === MAX_RETRIES) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
await delay(250 * (attempt + 1));
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (lastError instanceof Error) {
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Failed to fetch ${url}`);
|
|
91
|
+
}
|
|
92
|
+
async function fetchJson(url) {
|
|
93
|
+
const response = await fetchWithRetry(url);
|
|
94
|
+
const data = await response.json();
|
|
95
|
+
return {
|
|
96
|
+
data,
|
|
97
|
+
resolvedUrl: response.url || url
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function resolveManifestUrl(pointer, pointerUrl) {
|
|
101
|
+
const explicit = pointer.indexUrl || pointer.indexPath;
|
|
102
|
+
if (!explicit) {
|
|
103
|
+
throw new Error("Registry channel pointer is missing indexUrl/indexPath");
|
|
104
|
+
}
|
|
105
|
+
return new URL(explicit, pointerUrl).toString();
|
|
106
|
+
}
|
|
107
|
+
async function fetchRegistry() {
|
|
108
|
+
const registryEntryUrl = resolveRegistryEntryUrl();
|
|
109
|
+
let entry;
|
|
110
|
+
try {
|
|
111
|
+
entry = await fetchJson(registryEntryUrl);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Unable to fetch registry from ${registryEntryUrl}. For local testing set ${REGISTRY_ENV_VAR}=http://127.0.0.1:8787.`,
|
|
115
|
+
{ cause: error }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (isRegistryManifest(entry.data)) {
|
|
119
|
+
return {
|
|
120
|
+
manifest: entry.data,
|
|
121
|
+
manifestUrl: entry.resolvedUrl,
|
|
122
|
+
fileBaseUrl: withTrailingSlash(new URL(".", entry.resolvedUrl).toString())
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (!isChannelPointer(entry.data)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Invalid registry payload at ${registryEntryUrl}. Expected manifest or channel pointer.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const manifestUrl = resolveManifestUrl(entry.data, entry.resolvedUrl);
|
|
131
|
+
const manifestResult = await fetchJson(manifestUrl);
|
|
132
|
+
if (!isRegistryManifest(manifestResult.data)) {
|
|
133
|
+
throw new Error(`Invalid manifest payload at ${manifestUrl}.`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
manifest: manifestResult.data,
|
|
137
|
+
manifestUrl: manifestResult.resolvedUrl,
|
|
138
|
+
fileBaseUrl: withTrailingSlash(new URL(".", manifestResult.resolvedUrl).toString())
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function fetchFile(filePath, options) {
|
|
142
|
+
const normalizedPath = filePath.replace(/^\//, "");
|
|
143
|
+
const fileUrl = new URL(normalizedPath, withTrailingSlash(options.baseUrl)).toString();
|
|
144
|
+
const response = await fetchWithRetry(fileUrl);
|
|
145
|
+
const content = await response.text();
|
|
146
|
+
if (typeof options.expectedSize === "number") {
|
|
147
|
+
const actualSize = Buffer.byteLength(content, "utf8");
|
|
148
|
+
if (actualSize !== options.expectedSize) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Size mismatch for ${filePath}. Expected ${options.expectedSize}, got ${actualSize}.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options.expectedSha256) {
|
|
155
|
+
const actualSha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
|
156
|
+
if (actualSha256 !== options.expectedSha256) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Checksum mismatch for ${filePath}. Expected ${options.expectedSha256}, got ${actualSha256}.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return content;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/utils/pm.ts
|
|
166
|
+
import fs from "fs-extra";
|
|
167
|
+
import path from "path";
|
|
168
|
+
import { execa } from "execa";
|
|
169
|
+
async function initPackageJson(cwd, force = false) {
|
|
170
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
171
|
+
if (force || !await fs.pathExists(pkgPath)) {
|
|
172
|
+
await fs.writeJson(pkgPath, {
|
|
173
|
+
name: "zuro-app",
|
|
174
|
+
version: "0.0.1",
|
|
175
|
+
private: true,
|
|
176
|
+
scripts: {
|
|
177
|
+
"dev": "tsx watch src/server.ts",
|
|
178
|
+
"build": "tsc",
|
|
179
|
+
"start": "node dist/server.js"
|
|
180
|
+
}
|
|
181
|
+
}, { spaces: 2 });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function installDependencies(pm, deps, cwd, options = {}) {
|
|
185
|
+
const uniqueDeps = [...new Set(deps)].filter(Boolean);
|
|
186
|
+
if (uniqueDeps.length === 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const isDev = options.dev ?? false;
|
|
190
|
+
if (pm === "npm") {
|
|
191
|
+
const args = ["install", ...isDev ? ["--save-dev"] : [], ...uniqueDeps];
|
|
192
|
+
await execa("npm", args, { cwd });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (pm === "pnpm") {
|
|
196
|
+
const args = ["add", ...isDev ? ["-D"] : [], ...uniqueDeps];
|
|
197
|
+
await execa("pnpm", args, { cwd });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (pm === "yarn") {
|
|
201
|
+
const args = ["add", ...isDev ? ["-D"] : [], ...uniqueDeps];
|
|
202
|
+
await execa("yarn", args, { cwd });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (pm === "bun") {
|
|
206
|
+
const args = ["add", ...isDev ? ["-d"] : [], ...uniqueDeps];
|
|
207
|
+
await execa("bun", args, { cwd });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`Unsupported package manager: ${pm}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/utils/env-manager.ts
|
|
214
|
+
import fs2 from "fs-extra";
|
|
215
|
+
import path2 from "path";
|
|
216
|
+
import os from "os";
|
|
217
|
+
var updateEnvFile = async (cwd, variables, createIfMissing = true) => {
|
|
218
|
+
const envPath = path2.join(cwd, ".env");
|
|
219
|
+
let content = "";
|
|
220
|
+
if (fs2.existsSync(envPath)) {
|
|
221
|
+
content = await fs2.readFile(envPath, "utf-8");
|
|
222
|
+
} else if (!createIfMissing) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
let modified = false;
|
|
226
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
227
|
+
const regex = new RegExp(`^${key}=`, "m");
|
|
228
|
+
if (!regex.test(content)) {
|
|
229
|
+
if (content && !content.endsWith("\n")) {
|
|
230
|
+
content += os.EOL;
|
|
231
|
+
}
|
|
232
|
+
content += `${key}=${value}${os.EOL}`;
|
|
233
|
+
modified = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (modified || !fs2.existsSync(envPath)) {
|
|
237
|
+
await fs2.writeFile(envPath, content);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
var updateEnvSchema = async (cwd, srcDir, fields) => {
|
|
241
|
+
const envPath = path2.join(cwd, srcDir, "env.ts");
|
|
242
|
+
if (!fs2.existsSync(envPath)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
let content = await fs2.readFile(envPath, "utf-8");
|
|
246
|
+
let modified = false;
|
|
247
|
+
for (const field of fields) {
|
|
248
|
+
if (content.includes(`${field.name}:`)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const schemaEndRegex = /(\n\s*)(}\);?\s*\n\s*export const env)/;
|
|
252
|
+
const match = content.match(schemaEndRegex);
|
|
253
|
+
if (match) {
|
|
254
|
+
const indent = " ";
|
|
255
|
+
const newField = `${indent}${field.name}: ${field.schema},
|
|
256
|
+
`;
|
|
257
|
+
content = content.replace(
|
|
258
|
+
schemaEndRegex,
|
|
259
|
+
`
|
|
260
|
+
${newField}$1$2`
|
|
261
|
+
);
|
|
262
|
+
modified = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (modified) {
|
|
266
|
+
await fs2.writeFile(envPath, content);
|
|
267
|
+
}
|
|
268
|
+
return modified;
|
|
269
|
+
};
|
|
270
|
+
var createInitialEnv = async (cwd) => {
|
|
271
|
+
const envPath = path2.join(cwd, ".env");
|
|
272
|
+
if (fs2.existsSync(envPath)) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const content = `# Environment Variables
|
|
276
|
+
PORT=3000
|
|
277
|
+
NODE_ENV=development
|
|
278
|
+
`;
|
|
279
|
+
await fs2.writeFile(envPath, content);
|
|
280
|
+
};
|
|
281
|
+
var ENV_CONFIGS = {
|
|
282
|
+
"database-pg": {
|
|
283
|
+
envVars: {
|
|
284
|
+
DATABASE_URL: "postgresql://postgres@localhost:5432/mydb"
|
|
285
|
+
},
|
|
286
|
+
schemaFields: [
|
|
287
|
+
{ name: "DATABASE_URL", schema: "z.string().url()" }
|
|
288
|
+
]
|
|
289
|
+
},
|
|
290
|
+
"database-mysql": {
|
|
291
|
+
envVars: {
|
|
292
|
+
DATABASE_URL: "mysql://root@localhost:3306/mydb"
|
|
293
|
+
},
|
|
294
|
+
schemaFields: [
|
|
295
|
+
{ name: "DATABASE_URL", schema: "z.string().url()" }
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
auth: {
|
|
299
|
+
envVars: {
|
|
300
|
+
BETTER_AUTH_SECRET: "your-secret-key-at-least-32-characters-long",
|
|
301
|
+
BETTER_AUTH_URL: "http://localhost:3000"
|
|
302
|
+
},
|
|
303
|
+
schemaFields: [
|
|
304
|
+
{ name: "BETTER_AUTH_SECRET", schema: "z.string().min(32)" },
|
|
305
|
+
{ name: "BETTER_AUTH_URL", schema: "z.string().url()" }
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// src/utils/config.ts
|
|
311
|
+
import fs3 from "fs-extra";
|
|
312
|
+
import path3 from "path";
|
|
313
|
+
function sanitizeConfig(input) {
|
|
314
|
+
if (!input || typeof input !== "object") {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
const raw = input;
|
|
318
|
+
const config = {};
|
|
319
|
+
if (typeof raw.name === "string") {
|
|
320
|
+
config.name = raw.name;
|
|
321
|
+
}
|
|
322
|
+
if (typeof raw.pm === "string") {
|
|
323
|
+
config.pm = raw.pm;
|
|
324
|
+
}
|
|
325
|
+
if (typeof raw.srcDir === "string") {
|
|
326
|
+
config.srcDir = raw.srcDir;
|
|
327
|
+
}
|
|
328
|
+
return config;
|
|
329
|
+
}
|
|
330
|
+
function getConfigPath(cwd) {
|
|
331
|
+
return path3.join(cwd, "zuro.json");
|
|
332
|
+
}
|
|
333
|
+
async function readZuroConfig(cwd) {
|
|
334
|
+
const configPath = getConfigPath(cwd);
|
|
335
|
+
if (!await fs3.pathExists(configPath)) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const raw = await fs3.readJson(configPath);
|
|
339
|
+
return sanitizeConfig(raw);
|
|
340
|
+
}
|
|
341
|
+
async function writeZuroConfig(cwd, config) {
|
|
342
|
+
const configPath = getConfigPath(cwd);
|
|
343
|
+
await fs3.writeJson(configPath, sanitizeConfig(config), { spaces: 2 });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/utils/project-guard.ts
|
|
347
|
+
import chalk from "chalk";
|
|
348
|
+
function showNonZuroProjectMessage() {
|
|
349
|
+
console.log(chalk.yellow("This directory looks like an existing project that wasn't created with Zuro CLI."));
|
|
350
|
+
console.log("");
|
|
351
|
+
console.log(chalk.yellow("We stopped here because we don't want to make unnecessary changes to your project."));
|
|
352
|
+
console.log("");
|
|
353
|
+
console.log("Zuro CLI works in:");
|
|
354
|
+
console.log("- a fresh/empty directory, or");
|
|
355
|
+
console.log("- an existing project already managed by Zuro CLI.");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/commands/init.ts
|
|
359
|
+
function resolveSafeTargetPath(projectRoot, srcDir, file) {
|
|
360
|
+
const relativeTargetPath = path4.join(srcDir, file.target);
|
|
361
|
+
const targetPath = path4.resolve(projectRoot, relativeTargetPath);
|
|
362
|
+
const normalizedRoot = path4.resolve(projectRoot);
|
|
363
|
+
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path4.sep}`)) {
|
|
364
|
+
throw new Error(`Refusing to write outside project directory: ${file.target}`);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
relativeTargetPath,
|
|
368
|
+
targetPath
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async function init() {
|
|
372
|
+
const cwd = process.cwd();
|
|
373
|
+
const isExistingProject = await fs4.pathExists(path4.join(cwd, "package.json"));
|
|
374
|
+
const existingZuroConfig = await readZuroConfig(cwd);
|
|
375
|
+
if (isExistingProject && !existingZuroConfig) {
|
|
376
|
+
showNonZuroProjectMessage();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let targetDir = cwd;
|
|
380
|
+
let pm = "npm";
|
|
381
|
+
let srcDir = "src";
|
|
382
|
+
let projectName = path4.basename(cwd);
|
|
383
|
+
if (isExistingProject) {
|
|
384
|
+
console.log(chalk2.blue("\u2139 Existing project detected."));
|
|
385
|
+
projectName = path4.basename(cwd);
|
|
386
|
+
if (await fs4.pathExists(path4.join(cwd, "pnpm-lock.yaml"))) {
|
|
387
|
+
pm = "pnpm";
|
|
388
|
+
} else if (await fs4.pathExists(path4.join(cwd, "bun.lockb"))) {
|
|
389
|
+
pm = "bun";
|
|
390
|
+
} else if (await fs4.pathExists(path4.join(cwd, "yarn.lock"))) {
|
|
391
|
+
pm = "yarn";
|
|
392
|
+
}
|
|
393
|
+
const response = await prompts({
|
|
394
|
+
type: "text",
|
|
395
|
+
name: "srcDir",
|
|
396
|
+
message: "Where is your source code located?",
|
|
397
|
+
initial: "src"
|
|
398
|
+
});
|
|
399
|
+
srcDir = response.srcDir || "src";
|
|
400
|
+
} else {
|
|
401
|
+
console.log(chalk2.dim(` Tip: Leave blank to use current folder (${path4.basename(cwd)})
|
|
402
|
+
`));
|
|
403
|
+
const response = await prompts([
|
|
404
|
+
{
|
|
405
|
+
type: "text",
|
|
406
|
+
name: "path",
|
|
407
|
+
message: "Project name (blank for current folder)",
|
|
408
|
+
initial: ""
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
type: "select",
|
|
412
|
+
name: "pm",
|
|
413
|
+
message: "Package Manager?",
|
|
414
|
+
choices: [
|
|
415
|
+
{ title: "npm", value: "npm" },
|
|
416
|
+
{ title: "pnpm", value: "pnpm" },
|
|
417
|
+
{ title: "bun", value: "bun" }
|
|
418
|
+
],
|
|
419
|
+
initial: 0
|
|
420
|
+
}
|
|
421
|
+
]);
|
|
422
|
+
if (response.pm === void 0) {
|
|
423
|
+
console.log(chalk2.red("Operation cancelled."));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
pm = response.pm;
|
|
427
|
+
srcDir = "src";
|
|
428
|
+
if (!response.path || response.path.trim() === "") {
|
|
429
|
+
projectName = path4.basename(cwd);
|
|
430
|
+
targetDir = cwd;
|
|
431
|
+
console.log(chalk2.blue(`\u2139 Using current folder: ${projectName}`));
|
|
432
|
+
} else {
|
|
433
|
+
projectName = response.path.trim();
|
|
434
|
+
targetDir = path4.resolve(cwd, projectName);
|
|
435
|
+
await fs4.ensureDir(targetDir);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const existingConfig = targetDir === cwd ? existingZuroConfig : await readZuroConfig(targetDir);
|
|
439
|
+
const zuroConfig = {
|
|
440
|
+
name: projectName,
|
|
441
|
+
pm,
|
|
442
|
+
srcDir: srcDir || existingConfig?.srcDir || "src"
|
|
443
|
+
};
|
|
444
|
+
await writeZuroConfig(targetDir, zuroConfig);
|
|
445
|
+
const spinner = ora("Connecting to Zuro Registry...").start();
|
|
446
|
+
try {
|
|
447
|
+
const registryContext = await fetchRegistry();
|
|
448
|
+
const coreModule = registryContext.manifest.modules.core;
|
|
449
|
+
if (!coreModule) {
|
|
450
|
+
spinner.fail("Core module not found in registry.");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
spinner.text = "Initializing project...";
|
|
454
|
+
const hasPackageJson = await fs4.pathExists(path4.join(targetDir, "package.json"));
|
|
455
|
+
if (!hasPackageJson) {
|
|
456
|
+
await initPackageJson(targetDir, true);
|
|
457
|
+
}
|
|
458
|
+
spinner.text = `Installing dependencies using ${pm}...`;
|
|
459
|
+
let runtimeDeps = [];
|
|
460
|
+
let devDeps = [];
|
|
461
|
+
if (isExistingProject) {
|
|
462
|
+
const safeDeps = ["zod", "dotenv"];
|
|
463
|
+
const coreDeps = coreModule.dependencies || [];
|
|
464
|
+
runtimeDeps = coreDeps.filter((dependency) => safeDeps.includes(dependency));
|
|
465
|
+
devDeps = coreModule.devDependencies || [];
|
|
466
|
+
} else {
|
|
467
|
+
runtimeDeps = coreModule.dependencies || [];
|
|
468
|
+
devDeps = coreModule.devDependencies || [];
|
|
469
|
+
}
|
|
470
|
+
await installDependencies(pm, runtimeDeps, targetDir);
|
|
471
|
+
await installDependencies(pm, devDeps, targetDir, { dev: true });
|
|
472
|
+
spinner.text = "Fetching core module files...";
|
|
473
|
+
for (const file of coreModule.files) {
|
|
474
|
+
const { relativeTargetPath, targetPath } = resolveSafeTargetPath(targetDir, srcDir, file);
|
|
475
|
+
const fileName = path4.basename(targetPath);
|
|
476
|
+
if (isExistingProject) {
|
|
477
|
+
if (fileName === "app.ts" || fileName === "server.ts") {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const relativeParts = relativeTargetPath.split(path4.sep);
|
|
481
|
+
const isSafe = fileName === "env.ts" || relativeParts.includes("lib");
|
|
482
|
+
if (!isSafe) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const content = await fetchFile(file.path, {
|
|
487
|
+
baseUrl: registryContext.fileBaseUrl,
|
|
488
|
+
expectedSha256: file.sha256,
|
|
489
|
+
expectedSize: file.size
|
|
490
|
+
});
|
|
491
|
+
await fs4.ensureDir(path4.dirname(targetPath));
|
|
492
|
+
await fs4.writeFile(targetPath, content);
|
|
493
|
+
}
|
|
494
|
+
await createInitialEnv(targetDir);
|
|
495
|
+
spinner.succeed(chalk2.green("Project initialized successfully!"));
|
|
496
|
+
console.log(`
|
|
497
|
+
${chalk2.bold("Next steps:")}`);
|
|
498
|
+
if (targetDir !== cwd) {
|
|
499
|
+
console.log(chalk2.cyan(` cd ${projectName}`));
|
|
500
|
+
}
|
|
501
|
+
console.log(chalk2.cyan(` ${pm} run dev`));
|
|
502
|
+
console.log(`
|
|
503
|
+
${chalk2.dim("Add modules: zuro-cli add database, zuro-cli add auth")}`);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
spinner.fail(chalk2.red("Failed to initialize project."));
|
|
506
|
+
console.error(error);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/commands/add.ts
|
|
511
|
+
import prompts2 from "prompts";
|
|
512
|
+
import ora2 from "ora";
|
|
513
|
+
import path6 from "path";
|
|
514
|
+
import fs6 from "fs-extra";
|
|
515
|
+
|
|
516
|
+
// src/utils/dependency.ts
|
|
517
|
+
import fs5 from "fs-extra";
|
|
518
|
+
import path5 from "path";
|
|
519
|
+
import chalk3 from "chalk";
|
|
520
|
+
var BLOCK_SIGNATURES = {
|
|
521
|
+
core: "env.ts",
|
|
522
|
+
"database-pg": "db/index.ts",
|
|
523
|
+
"database-mysql": "db/index.ts",
|
|
524
|
+
validator: "middleware/validate.ts",
|
|
525
|
+
"error-handler": "lib/errors.ts",
|
|
526
|
+
logger: "lib/logger.ts",
|
|
527
|
+
auth: "lib/auth.ts"
|
|
528
|
+
};
|
|
529
|
+
var resolveDependencies = async (moduleDependencies, cwd) => {
|
|
530
|
+
if (!moduleDependencies || moduleDependencies.length === 0) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const config = await readZuroConfig(cwd);
|
|
534
|
+
const srcDir = config?.srcDir || "src";
|
|
535
|
+
for (const dep of moduleDependencies) {
|
|
536
|
+
if (dep === "database") {
|
|
537
|
+
const pgExists = fs5.existsSync(path5.join(cwd, srcDir, BLOCK_SIGNATURES["database-pg"]));
|
|
538
|
+
const mysqlExists = fs5.existsSync(path5.join(cwd, srcDir, BLOCK_SIGNATURES["database-mysql"]));
|
|
539
|
+
if (pgExists || mysqlExists) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
console.log(chalk3.blue(`\u2139 Dependency '${dep}' is missing. Triggering install...`));
|
|
543
|
+
await add("database");
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const signature = BLOCK_SIGNATURES[dep];
|
|
547
|
+
if (!signature) {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (fs5.existsSync(path5.join(cwd, srcDir, signature))) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
console.log(chalk3.blue(`\u2139 Installing missing dependency: ${dep}...`));
|
|
554
|
+
await add(dep);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// src/commands/add.ts
|
|
559
|
+
import chalk4 from "chalk";
|
|
560
|
+
function resolveSafeTargetPath2(projectRoot, srcDir, file) {
|
|
561
|
+
const targetPath = path6.resolve(projectRoot, srcDir, file.target);
|
|
562
|
+
const normalizedRoot = path6.resolve(projectRoot);
|
|
563
|
+
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path6.sep}`)) {
|
|
564
|
+
throw new Error(`Refusing to write outside project directory: ${file.target}`);
|
|
565
|
+
}
|
|
566
|
+
return targetPath;
|
|
567
|
+
}
|
|
568
|
+
async function injectErrorHandler(projectRoot, srcDir) {
|
|
569
|
+
const appPath = path6.join(projectRoot, srcDir, "app.ts");
|
|
570
|
+
if (!fs6.existsSync(appPath)) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
let content = await fs6.readFile(appPath, "utf-8");
|
|
574
|
+
if (content.includes("errorHandler")) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
const errorImport = `import { errorHandler, notFoundHandler } from "./middleware/error-handler";`;
|
|
578
|
+
const importRegex = /^import .+ from .+;?\s*$/gm;
|
|
579
|
+
let lastImportIndex = 0;
|
|
580
|
+
let match;
|
|
581
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
582
|
+
lastImportIndex = match.index + match[0].length;
|
|
583
|
+
}
|
|
584
|
+
if (lastImportIndex > 0) {
|
|
585
|
+
content = content.slice(0, lastImportIndex) + `
|
|
586
|
+
${errorImport}` + content.slice(lastImportIndex);
|
|
587
|
+
}
|
|
588
|
+
const errorSetup = `
|
|
589
|
+
// Error handling (must be last)
|
|
590
|
+
app.use(notFoundHandler);
|
|
591
|
+
app.use(errorHandler);
|
|
592
|
+
`;
|
|
593
|
+
const exportMatch = content.match(/export default app;?\s*$/m);
|
|
594
|
+
if (exportMatch && exportMatch.index !== void 0) {
|
|
595
|
+
content = content.slice(0, exportMatch.index) + errorSetup + "\n" + content.slice(exportMatch.index);
|
|
596
|
+
}
|
|
597
|
+
await fs6.writeFile(appPath, content);
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
async function injectAuthRoutes(projectRoot, srcDir) {
|
|
601
|
+
const appPath = path6.join(projectRoot, srcDir, "app.ts");
|
|
602
|
+
if (!fs6.existsSync(appPath)) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
let content = await fs6.readFile(appPath, "utf-8");
|
|
606
|
+
if (content.includes("routes/auth.routes")) {
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
const authImport = `import authRoutes from "./routes/auth.routes";`;
|
|
610
|
+
const userImport = `import userRoutes from "./routes/user.routes";`;
|
|
611
|
+
const routeSetup = `
|
|
612
|
+
// Auth routes
|
|
613
|
+
app.use(authRoutes);
|
|
614
|
+
app.use("/api/users", userRoutes);
|
|
615
|
+
`;
|
|
616
|
+
const importRegex = /^import .+ from .+;?\s*$/gm;
|
|
617
|
+
let lastImportIndex = 0;
|
|
618
|
+
let match;
|
|
619
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
620
|
+
lastImportIndex = match.index + match[0].length;
|
|
621
|
+
}
|
|
622
|
+
if (lastImportIndex > 0) {
|
|
623
|
+
content = content.slice(0, lastImportIndex) + `
|
|
624
|
+
${authImport}
|
|
625
|
+
${userImport}` + content.slice(lastImportIndex);
|
|
626
|
+
}
|
|
627
|
+
const exportMatch = content.match(/export default app;?\s*$/m);
|
|
628
|
+
if (exportMatch && exportMatch.index !== void 0) {
|
|
629
|
+
content = content.slice(0, exportMatch.index) + routeSetup + "\n" + content.slice(exportMatch.index);
|
|
630
|
+
}
|
|
631
|
+
await fs6.writeFile(appPath, content);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
var add = async (moduleName) => {
|
|
635
|
+
const projectRoot = process.cwd();
|
|
636
|
+
const projectConfig = await readZuroConfig(projectRoot);
|
|
637
|
+
if (!projectConfig) {
|
|
638
|
+
showNonZuroProjectMessage();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
let srcDir = projectConfig.srcDir || "src";
|
|
642
|
+
let customDbUrl;
|
|
643
|
+
const DEFAULT_URLS = {
|
|
644
|
+
"database-pg": "postgresql://root@localhost:5432/mydb",
|
|
645
|
+
"database-mysql": "mysql://root@localhost:3306/mydb"
|
|
646
|
+
};
|
|
647
|
+
if (moduleName === "database") {
|
|
648
|
+
const variantResponse = await prompts2({
|
|
649
|
+
type: "select",
|
|
650
|
+
name: "variant",
|
|
651
|
+
message: "Which database dialect?",
|
|
652
|
+
choices: [
|
|
653
|
+
{ title: "PostgreSQL", value: "database-pg" },
|
|
654
|
+
{ title: "MySQL", value: "database-mysql" }
|
|
655
|
+
]
|
|
656
|
+
});
|
|
657
|
+
if (!variantResponse.variant) {
|
|
658
|
+
process.exit(0);
|
|
659
|
+
}
|
|
660
|
+
moduleName = variantResponse.variant;
|
|
661
|
+
const defaultUrl = DEFAULT_URLS[moduleName];
|
|
662
|
+
console.log(chalk4.dim(` Tip: Leave blank to use ${defaultUrl}
|
|
663
|
+
`));
|
|
664
|
+
const urlResponse = await prompts2({
|
|
665
|
+
type: "text",
|
|
666
|
+
name: "dbUrl",
|
|
667
|
+
message: "Database URL",
|
|
668
|
+
initial: ""
|
|
669
|
+
});
|
|
670
|
+
customDbUrl = urlResponse.dbUrl?.trim() || defaultUrl;
|
|
671
|
+
}
|
|
672
|
+
if ((moduleName === "database-pg" || moduleName === "database-mysql") && customDbUrl === void 0) {
|
|
673
|
+
const defaultUrl = DEFAULT_URLS[moduleName];
|
|
674
|
+
console.log(chalk4.dim(` Tip: Leave blank to use ${defaultUrl}
|
|
675
|
+
`));
|
|
676
|
+
const response = await prompts2({
|
|
677
|
+
type: "text",
|
|
678
|
+
name: "dbUrl",
|
|
679
|
+
message: "Database URL",
|
|
680
|
+
initial: ""
|
|
681
|
+
});
|
|
682
|
+
customDbUrl = response.dbUrl?.trim() || defaultUrl;
|
|
683
|
+
}
|
|
684
|
+
const spinner = ora2(`Checking registry for ${moduleName}...`).start();
|
|
685
|
+
try {
|
|
686
|
+
const registryContext = await fetchRegistry();
|
|
687
|
+
const module = registryContext.manifest.modules[moduleName];
|
|
688
|
+
if (!module) {
|
|
689
|
+
spinner.fail(`Module '${moduleName}' not found.`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
spinner.succeed(`Found module: ${chalk4.cyan(moduleName)}`);
|
|
693
|
+
const moduleDeps = module.moduleDependencies || [];
|
|
694
|
+
await resolveDependencies(moduleDeps, projectRoot);
|
|
695
|
+
spinner.start("Installing dependencies...");
|
|
696
|
+
let pm = "npm";
|
|
697
|
+
if (fs6.existsSync(path6.join(projectRoot, "pnpm-lock.yaml"))) {
|
|
698
|
+
pm = "pnpm";
|
|
699
|
+
} else if (fs6.existsSync(path6.join(projectRoot, "bun.lockb"))) {
|
|
700
|
+
pm = "bun";
|
|
701
|
+
} else if (fs6.existsSync(path6.join(projectRoot, "yarn.lock"))) {
|
|
702
|
+
pm = "yarn";
|
|
703
|
+
}
|
|
704
|
+
await installDependencies(pm, module.dependencies || [], projectRoot);
|
|
705
|
+
await installDependencies(pm, module.devDependencies || [], projectRoot, { dev: true });
|
|
706
|
+
spinner.succeed("Dependencies installed");
|
|
707
|
+
spinner.start("Scaffolding files...");
|
|
708
|
+
for (const file of module.files) {
|
|
709
|
+
const content = await fetchFile(file.path, {
|
|
710
|
+
baseUrl: registryContext.fileBaseUrl,
|
|
711
|
+
expectedSha256: file.sha256,
|
|
712
|
+
expectedSize: file.size
|
|
713
|
+
});
|
|
714
|
+
const targetPath = resolveSafeTargetPath2(projectRoot, srcDir, file);
|
|
715
|
+
await fs6.ensureDir(path6.dirname(targetPath));
|
|
716
|
+
await fs6.writeFile(targetPath, content);
|
|
717
|
+
}
|
|
718
|
+
spinner.succeed("Files generated");
|
|
719
|
+
if (moduleName === "auth") {
|
|
720
|
+
spinner.start("Configuring routes in app.ts...");
|
|
721
|
+
const injected = await injectAuthRoutes(projectRoot, srcDir);
|
|
722
|
+
if (injected) {
|
|
723
|
+
spinner.succeed("Routes configured in app.ts");
|
|
724
|
+
} else {
|
|
725
|
+
spinner.warn("Could not find app.ts - routes need manual setup");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (moduleName === "error-handler") {
|
|
729
|
+
spinner.start("Configuring error handler in app.ts...");
|
|
730
|
+
const injected = await injectErrorHandler(projectRoot, srcDir);
|
|
731
|
+
if (injected) {
|
|
732
|
+
spinner.succeed("Error handler configured in app.ts");
|
|
733
|
+
} else {
|
|
734
|
+
spinner.warn("Could not find app.ts - error handler needs manual setup");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const envConfig = ENV_CONFIGS[moduleName];
|
|
738
|
+
if (envConfig) {
|
|
739
|
+
spinner.start("Updating environment configuration...");
|
|
740
|
+
const envVars = { ...envConfig.envVars };
|
|
741
|
+
if (customDbUrl && (moduleName === "database-pg" || moduleName === "database-mysql")) {
|
|
742
|
+
envVars.DATABASE_URL = customDbUrl;
|
|
743
|
+
}
|
|
744
|
+
await updateEnvFile(projectRoot, envVars);
|
|
745
|
+
await updateEnvSchema(projectRoot, srcDir, envConfig.schemaFields);
|
|
746
|
+
spinner.succeed("Environment configured");
|
|
747
|
+
}
|
|
748
|
+
console.log(chalk4.green(`
|
|
749
|
+
\u2714 ${moduleName} added successfully!
|
|
750
|
+
`));
|
|
751
|
+
if (moduleName === "auth") {
|
|
752
|
+
console.log(chalk4.bold("\u{1F4CB} Next Steps:\n"));
|
|
753
|
+
console.log(chalk4.yellow("1. Update your .env file:"));
|
|
754
|
+
console.log(
|
|
755
|
+
chalk4.dim(" We added placeholder values. Update BETTER_AUTH_SECRET with a secure key.\n")
|
|
756
|
+
);
|
|
757
|
+
console.log(chalk4.yellow("2. Run database migrations:"));
|
|
758
|
+
console.log(chalk4.cyan(" npx drizzle-kit generate"));
|
|
759
|
+
console.log(chalk4.cyan(" npx drizzle-kit migrate\n"));
|
|
760
|
+
console.log(chalk4.yellow("3. Available endpoints:"));
|
|
761
|
+
console.log(chalk4.dim(" POST /auth/sign-up/email - Register"));
|
|
762
|
+
console.log(chalk4.dim(" POST /auth/sign-in/email - Login"));
|
|
763
|
+
console.log(chalk4.dim(" POST /auth/sign-out - Logout"));
|
|
764
|
+
console.log(chalk4.dim(" GET /api/users/me - Current user\n"));
|
|
765
|
+
} else if (moduleName === "error-handler") {
|
|
766
|
+
console.log(chalk4.bold("\u{1F4CB} Usage:\n"));
|
|
767
|
+
console.log(chalk4.yellow("Throw errors in your controllers:"));
|
|
768
|
+
console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
769
|
+
console.log(chalk4.white(` import { UnauthorizedError, NotFoundError } from "./lib/errors";`));
|
|
770
|
+
console.log("");
|
|
771
|
+
console.log(chalk4.white(` throw new UnauthorizedError("Invalid credentials");`));
|
|
772
|
+
console.log(chalk4.white(` throw new NotFoundError("User not found");`));
|
|
773
|
+
console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
774
|
+
console.log(chalk4.yellow("Available error classes:"));
|
|
775
|
+
console.log(chalk4.dim(" BadRequestError (400)"));
|
|
776
|
+
console.log(chalk4.dim(" UnauthorizedError (401)"));
|
|
777
|
+
console.log(chalk4.dim(" ForbiddenError (403)"));
|
|
778
|
+
console.log(chalk4.dim(" NotFoundError (404)"));
|
|
779
|
+
console.log(chalk4.dim(" ConflictError (409)"));
|
|
780
|
+
console.log(chalk4.dim(" ValidationError (422)"));
|
|
781
|
+
console.log(chalk4.dim(" InternalServerError (500)\n"));
|
|
782
|
+
console.log(chalk4.yellow("Wrap async handlers:"));
|
|
783
|
+
console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
784
|
+
console.log(chalk4.white(` import { asyncHandler } from "./middleware/error-handler";`));
|
|
785
|
+
console.log("");
|
|
786
|
+
console.log(chalk4.white(` router.get("/users", asyncHandler(async (req, res) => {`));
|
|
787
|
+
console.log(chalk4.white(" // errors auto-caught"));
|
|
788
|
+
console.log(chalk4.white(" }));"));
|
|
789
|
+
console.log(chalk4.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
790
|
+
} else if (moduleName.includes("database")) {
|
|
791
|
+
console.log(chalk4.bold("\u{1F4CB} Next Steps:\n"));
|
|
792
|
+
let stepNum = 1;
|
|
793
|
+
if (!customDbUrl) {
|
|
794
|
+
console.log(chalk4.yellow(`${stepNum}. Update DATABASE_URL in .env:`));
|
|
795
|
+
console.log(
|
|
796
|
+
chalk4.dim(" We added a placeholder. Update with your actual database credentials.\n")
|
|
797
|
+
);
|
|
798
|
+
stepNum++;
|
|
799
|
+
}
|
|
800
|
+
console.log(chalk4.yellow(`${stepNum}. Create schemas in src/db/schema/:`));
|
|
801
|
+
console.log(chalk4.dim(" Add table files and export from index.ts\n"));
|
|
802
|
+
stepNum++;
|
|
803
|
+
console.log(chalk4.yellow(`${stepNum}. Run migrations:`));
|
|
804
|
+
console.log(chalk4.cyan(" npx drizzle-kit generate"));
|
|
805
|
+
console.log(chalk4.cyan(" npx drizzle-kit migrate\n"));
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
spinner.fail(`Failed to add module: ${error.message}`);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// src/index.ts
|
|
813
|
+
var program = new Command();
|
|
814
|
+
program.name("zuro-cli").description("Zuro CLI tool").version("0.0.1");
|
|
815
|
+
program.command("init").description("Initialize a new Zuro project").action(init);
|
|
816
|
+
program.command("add <module>").description("Add a module to your project").action(add);
|
|
817
|
+
program.parse(process.argv);
|
|
818
|
+
//# sourceMappingURL=index.mjs.map
|