@cellajs/create-cella 0.2.2 → 0.3.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.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { existsSync as existsSync2 } from "fs";
5
5
  import { basename as basename2, resolve as resolve3 } from "path";
6
6
  import { input, select } from "@inquirer/prompts";
7
- import pc5 from "picocolors";
7
+ import pc6 from "picocolors";
8
8
 
9
9
  // src/constants.ts
10
10
  import pc from "picocolors";
@@ -12,7 +12,7 @@ import pc from "picocolors";
12
12
  // package.json
13
13
  var package_default = {
14
14
  name: "@cellajs/create-cella",
15
- version: "0.2.2",
15
+ version: "0.3.0",
16
16
  private: false,
17
17
  license: "MIT",
18
18
  description: "Cella is a TypeScript template to create web apps with sync and offline capabilities.",
@@ -21,8 +21,7 @@ var package_default = {
21
21
  },
22
22
  repository: {
23
23
  type: "git",
24
- url: "https://github.com/cellajs/cella",
25
- directory: "cli/create-cella"
24
+ url: "https://github.com/cellajs/create-cella"
26
25
  },
27
26
  homepage: "https://cellajs.com",
28
27
  author: "CellaJS <info@cellajs.com>",
@@ -45,6 +44,7 @@ var package_default = {
45
44
  },
46
45
  scripts: {
47
46
  start: "tsx ./src/create-cella-cli.ts",
47
+ try: "tsx ./src/create-cella-cli.ts ../cella-try-app",
48
48
  clean: "rimraf ./dist",
49
49
  build: "tsup",
50
50
  "test-build": "pnpm run build && node index.js",
@@ -54,26 +54,58 @@ var package_default = {
54
54
  lint: "biome check .",
55
55
  "lint:fix": "biome check --write .",
56
56
  test: "vitest run",
57
- "test:release": "pnpm run build && vitest run tests/release-artifact.test.ts tests/release-smoke.test.ts",
58
- "test:watch": "vitest"
57
+ "test:release": "pnpm run build && vitest run --config vitest.release.config.ts",
58
+ "test:watch": "vitest",
59
+ prepare: "lefthook install"
59
60
  },
60
61
  dependencies: {
61
62
  "@inquirer/prompts": "^8.5.2",
62
- axios: "^1.17.0",
63
63
  commander: "^15.0.0",
64
- giget: "^3.1.2",
65
- "nano-spawn": "^2.0.0",
66
- ora: "^9.3.0",
64
+ ignore: "^7.0.5",
65
+ "isomorphic-git": "^1.38.5",
66
+ nanotar: "^0.3.0",
67
+ ora: "^9.4.1",
67
68
  picocolors: "^1.1.1",
68
69
  "validate-npm-package-name": "^8.0.0"
69
70
  },
70
71
  devDependencies: {
71
- "@types/node": "catalog:",
72
- "@typescript/native-preview": "catalog:",
72
+ "@biomejs/biome": "^2.5.1",
73
+ "@commitlint/cli": "^21.1.0",
74
+ "@commitlint/config-conventional": "^21.1.0",
75
+ "@evilmartians/lefthook": "^2.1.9",
76
+ "@types/node": "24.10.13",
73
77
  "@types/validate-npm-package-name": "^4.0.2",
74
- tsup: "catalog:",
75
- tsx: "catalog:",
76
- vitest: "catalog:"
78
+ "@typescript/native-preview": "7.0.0-dev.20260628.1",
79
+ rimraf: "^6.1.3",
80
+ tsup: "^8.5.1",
81
+ tsx: "^4.22.4",
82
+ typescript: "^6.0.3",
83
+ vitest: "^4.1.9"
84
+ },
85
+ packageManager: "pnpm@11.8.0",
86
+ commitlint: {
87
+ extends: [
88
+ "@commitlint/config-conventional"
89
+ ],
90
+ rules: {
91
+ "type-enum": [
92
+ 2,
93
+ "always",
94
+ [
95
+ "feat",
96
+ "fix",
97
+ "perf",
98
+ "refactor",
99
+ "revert",
100
+ "docs",
101
+ "chore",
102
+ "build",
103
+ "ci",
104
+ "style",
105
+ "test"
106
+ ]
107
+ ]
108
+ }
77
109
  }
78
110
  };
79
111
 
@@ -84,15 +116,12 @@ var TEMPLATE_URL = "github:cellajs/cella";
84
116
  var CELLA_REMOTE_URL = "git@github.com:cellajs/cella.git";
85
117
  var DESCRIPTION = package_default.description;
86
118
  var VERSION = package_default.version;
87
- var AUTHOR = package_default.author;
88
119
  var WEBSITE = package_default.homepage;
89
- var GITHUB = package_default.repository.url;
90
120
  function getHeaderLine(templateVersion) {
91
- const leftText = `\u29C8 ${NAME} \xB7 v${VERSION} \xB7 cella v${templateVersion}`;
92
- const rightText = package_default.homepage.replace("https://", "");
93
- const left = `${pc.cyan(`\u29C8 ${NAME}`)} ${pc.dim(`\xB7 v${VERSION} \xB7 cella v${templateVersion}`)}`;
94
- const right = pc.cyan(rightText);
95
- const padding = Math.max(1, 60 - leftText.length - rightText.length);
121
+ const leftText = `\u29C8 ${NAME} \xB7 ${VERSION} \xB7 cella ${templateVersion}`;
122
+ const left = `${pc.cyan(`\u29C8 ${NAME}`)} ${pc.dim(`\xB7 ${VERSION} \xB7 cella ${templateVersion}`)}`;
123
+ const right = pc.cyan(WEBSITE);
124
+ const padding = Math.max(1, 60 - leftText.length - WEBSITE.length);
96
125
  return `${left}${" ".repeat(padding)}${right}`;
97
126
  }
98
127
  var TO_REMOVE = ["./cli/create", "./info/QUICKSTART.md"];
@@ -103,10 +132,10 @@ var TO_COPY = {
103
132
  };
104
133
  var PLACEHOLDER_CONFIG = "configs/default-config.ts.template";
105
134
  async function generateEnvFromExample(examplePath, replacements) {
106
- const { readFile: readFile2 } = await import("fs/promises");
135
+ const { readFile: readFile4 } = await import("fs/promises");
107
136
  let content;
108
137
  try {
109
- content = await readFile2(examplePath, "utf8");
138
+ content = await readFile4(examplePath, "utf8");
110
139
  } catch {
111
140
  return null;
112
141
  }
@@ -191,47 +220,35 @@ ${nameEntry}${body}
191
220
 
192
221
  // src/create.ts
193
222
  import { existsSync } from "fs";
194
- import { cp, mkdir } from "fs/promises";
195
- import { join, relative, resolve as resolve2 } from "path";
196
- import { downloadTemplate } from "giget";
223
+ import { cp, mkdir as mkdir2, rm } from "fs/promises";
224
+ import { dirname as dirname3, isAbsolute, join as join4, relative as relative2, resolve as resolve2 } from "path";
225
+ import { checkbox } from "@inquirer/prompts";
226
+ import pc5 from "picocolors";
197
227
 
198
228
  // src/utils/git/command.ts
199
- import { execFile } from "child_process";
200
- import { promisify } from "util";
201
- var execFileAsync = promisify(execFile);
202
- async function runGitCommand(args, repoPath, options2 = {}) {
203
- const gitArgs = repoPath ? ["-C", repoPath, ...args] : args;
204
- const env = {
205
- ...process.env,
206
- ...options2.skipEditor ? { GIT_EDITOR: "true" } : {}
207
- };
208
- const maxBuffer = options2.maxBuffer ?? 10 * 1024 * 1024;
209
- const { stdout } = await execFileAsync("git", gitArgs, { env, maxBuffer });
210
- return stdout.trim();
211
- }
229
+ import fs from "fs";
230
+ import git from "isomorphic-git";
231
+ var INITIAL_AUTHOR = { name: "cella", email: "info@cellajs.com" };
212
232
  async function gitInit(repoPath) {
213
- return runGitCommand(["init"], repoPath);
233
+ await git.init({ fs, dir: repoPath, defaultBranch: "main" });
214
234
  }
215
235
  async function gitAddAll(repoPath) {
216
- return runGitCommand(["add", "."], repoPath);
236
+ await git.add({ fs, dir: repoPath, filepath: "." });
217
237
  }
218
238
  async function gitCommit(repoPath, message) {
219
- return runGitCommand(["commit", "--quiet", "--no-verify", "-m", message], repoPath);
220
- }
221
- async function gitBranch(repoPath, branchName) {
222
- return runGitCommand(["branch", branchName], repoPath);
223
- }
224
- async function gitCheckout(repoPath, branchName) {
225
- return runGitCommand(["checkout", branchName], repoPath);
239
+ return git.commit({ fs, dir: repoPath, message, author: INITIAL_AUTHOR });
226
240
  }
227
241
  async function gitRemoteGetUrl(repoPath, remoteName) {
228
- return runGitCommand(["remote", "get-url", remoteName], repoPath);
242
+ const remotes = await git.listRemotes({ fs, dir: repoPath });
243
+ const match = remotes.find((r) => r.remote === remoteName);
244
+ if (!match) throw new Error(`No such remote '${remoteName}'`);
245
+ return match.url;
229
246
  }
230
247
  async function gitRemoteAdd(repoPath, remoteName, remoteUrl) {
231
- return runGitCommand(["remote", "add", remoteName, remoteUrl], repoPath);
248
+ await git.addRemote({ fs, dir: repoPath, remote: remoteName, url: remoteUrl });
232
249
  }
233
250
  async function gitRemoteRemove(repoPath, remoteName) {
234
- return runGitCommand(["remote", "remove", remoteName], repoPath);
251
+ await git.deleteRemote({ fs, dir: repoPath, remote: remoteName });
235
252
  }
236
253
 
237
254
  // src/add-remote.ts
@@ -290,7 +307,6 @@ function parsePortOffset(value) {
290
307
  return parsed;
291
308
  }
292
309
  var directory = null;
293
- var newBranchName = null;
294
310
  var packageManager = "pnpm";
295
311
  var command = new Command(NAME).version(VERSION, "-v, --version", `output the current version of ${NAME}`).argument("[directory]", "the directory name for the new project").usage("[directory] [options]").helpOption("-h, --help", "display this help message").option("--template <path>", "use a custom template (local path or github:user/repo)").option("--port-offset <number>", "set the port offset (0-490 in steps of 10)", parsePortOffset).option("--admin-email <email>", "set the admin email for the initial seed user").action((name) => {
296
312
  const trimmedName = typeof name === "string" ? name.trim() : name;
@@ -308,7 +324,6 @@ function runCli() {
308
324
  options,
309
325
  args: command.args,
310
326
  directory,
311
- newBranchName,
312
327
  packageManager
313
328
  };
314
329
  }
@@ -332,29 +347,33 @@ function showWelcome(templateVersion) {
332
347
  console.info(getHeaderLine(templateVersion));
333
348
  console.info(DIVIDER);
334
349
  }
350
+ function confirmChoice(label, value) {
351
+ console.info(`${pc2.green("\u2713")} ${label} \xB7 ${value}`);
352
+ console.info();
353
+ }
335
354
  function showSuccess(projectName, _targetFolder, relativePath, needsCd, packageManager2) {
355
+ console.info();
356
+ console.info(pc2.bold("Next steps"));
336
357
  console.info(DIVIDER);
337
358
  console.info();
338
359
  if (needsCd) {
339
- console.info(`${pc2.green("\u2192")} cd ${pc2.cyan(relativePath)}`);
340
- console.info();
360
+ console.info(`${pc2.dim("$")} cd ${pc2.cyan(relativePath)}`);
341
361
  }
342
- console.info(`${pc2.green("\u2192")} ${pc2.cyan(`${packageManager2} quick`)} ${pc2.gray("(pglite, no docker)")}`);
343
- console.info();
344
- console.info(pc2.gray("or, for full setup:"));
345
- console.info();
362
+ console.info(`${pc2.dim("$")} ${pc2.cyan(`${packageManager2} install`)}`);
363
+ console.info(`${pc2.dim("$")} ${pc2.cyan(`${packageManager2} generate`)}`);
346
364
  console.info(
347
- `${pc2.green("\u2192")} ${pc2.cyan(`${packageManager2} docker`)} ${pc2.dim("&&")} ${pc2.cyan(`${packageManager2} seed`)} ${pc2.dim("&&")} ${pc2.cyan(`${packageManager2} dev`)}`
365
+ `${pc2.dim("$")} ${pc2.cyan(`${packageManager2} docker`)} ${pc2.dim("&&")} ${pc2.cyan(`${packageManager2} seed`)} ${pc2.dim("&&")} ${pc2.cyan(`${packageManager2} dev`)}`
348
366
  );
349
367
  console.info();
350
- console.info(`sign in: ${pc2.gray("admin-test@cellajs.com / 12345678")}`);
368
+ const readmePath = relativePath ? `${relativePath}/README.md` : "README.md";
369
+ console.info(`${pc2.gray("To get started building, please read:")} ${pc2.cyan(readmePath)}`);
351
370
  console.info();
352
- console.info(`enjoy building ${pc2.green(projectName)} with cella!`);
371
+ console.info(`\u{1F973} enjoy building ${pc2.green(projectName)} with cella!`);
353
372
  console.info();
354
373
  }
355
374
 
356
375
  // src/utils/clean-template.ts
357
- import fs from "fs/promises";
376
+ import fs2 from "fs/promises";
358
377
  import path from "path";
359
378
  import { fileURLToPath } from "url";
360
379
  import pc3 from "picocolors";
@@ -364,7 +383,7 @@ async function resolvePlaceholderConfigPath() {
364
383
  const candidates = [path.resolve(here, "../..", PLACEHOLDER_CONFIG), path.resolve(here, "..", PLACEHOLDER_CONFIG)];
365
384
  for (const candidate of candidates) {
366
385
  try {
367
- await fs.access(candidate);
386
+ await fs2.access(candidate);
368
387
  return candidate;
369
388
  } catch {
370
389
  }
@@ -396,12 +415,12 @@ async function cleanTemplate({
396
415
  backendReplacements
397
416
  );
398
417
  if (backendEnv) {
399
- await fs.writeFile(path.resolve(targetFolder, "backend/.env"), backendEnv, "utf8");
418
+ await fs2.writeFile(path.resolve(targetFolder, "backend/.env"), backendEnv, "utf8");
400
419
  }
401
420
  const envConfigs = generateEnvConfigs(projectName, displayName, portOffset);
402
421
  await Promise.all(
403
422
  Object.entries(envConfigs).map(
404
- ([filePath, content]) => fs.writeFile(path.resolve(targetFolder, filePath), content, "utf8")
423
+ ([filePath, content]) => fs2.writeFile(path.resolve(targetFolder, filePath), content, "utf8")
405
424
  )
406
425
  );
407
426
  await Promise.all(
@@ -424,27 +443,27 @@ async function cleanTemplate({
424
443
  });
425
444
  }
426
445
  async function removeFolderContents(folderPath) {
427
- const files = await fs.readdir(folderPath);
446
+ const files = await fs2.readdir(folderPath);
428
447
  await Promise.all(
429
448
  files.map(async (file) => {
430
449
  const filePath = path.join(folderPath, file);
431
- const stat = await fs.lstat(filePath);
450
+ const stat = await fs2.lstat(filePath);
432
451
  if (stat.isDirectory()) {
433
- await fs.rm(filePath, { recursive: true, force: true });
452
+ await fs2.rm(filePath, { recursive: true, force: true });
434
453
  } else {
435
- await fs.rm(filePath);
454
+ await fs2.rm(filePath);
436
455
  }
437
456
  })
438
457
  );
439
458
  }
440
459
  async function removeFileOrFolder(pathToRemove) {
441
- await fs.rm(pathToRemove, { recursive: true, force: true });
460
+ await fs2.rm(pathToRemove, { recursive: true, force: true });
442
461
  }
443
462
  async function copyFile(src, dest) {
444
463
  try {
445
- await fs.access(src);
446
- await fs.mkdir(path.dirname(dest), { recursive: true });
447
- await fs.copyFile(src, dest);
464
+ await fs2.access(src);
465
+ await fs2.mkdir(path.dirname(dest), { recursive: true });
466
+ await fs2.copyFile(src, dest);
448
467
  } catch (err) {
449
468
  if (err.code === "ENOENT") {
450
469
  console.info(`
@@ -459,10 +478,10 @@ async function applyPlaceholderConfig(targetFolder, projectName) {
459
478
  const src = await resolvePlaceholderConfigPath();
460
479
  const dest = path.resolve(targetFolder, "./shared/config/config.default.ts");
461
480
  try {
462
- let content = await fs.readFile(src, "utf8");
481
+ let content = await fs2.readFile(src, "utf8");
463
482
  content = content.replaceAll("__project_name__", displayName);
464
483
  content = content.replaceAll("__project_slug__", projectName);
465
- await fs.writeFile(dest, content, "utf8");
484
+ await fs2.writeFile(dest, content, "utf8");
466
485
  } catch (err) {
467
486
  if (err.code === "ENOENT") {
468
487
  console.info(`
@@ -473,10 +492,75 @@ ${warningMark} Placeholder config "${src}" not found > Skip`);
473
492
  }
474
493
  }
475
494
 
495
+ // src/utils/download-template.ts
496
+ import { mkdir, writeFile } from "fs/promises";
497
+ import { dirname, join } from "path";
498
+ import { parseTarGzip } from "nanotar";
499
+ function parseGithubRepo(template) {
500
+ const [owner, repo] = template.replace(/^github:/, "").split("/");
501
+ if (!owner || !repo) {
502
+ throw new Error(`Invalid template '${template}'. Expected 'github:owner/repo'.`);
503
+ }
504
+ return { owner, repo };
505
+ }
506
+ async function downloadGithubTemplate(template, ref, targetFolder) {
507
+ const { owner, repo } = parseGithubRepo(template);
508
+ const url = `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}`;
509
+ const response = await fetch(url);
510
+ if (!response.ok) {
511
+ throw new Error(`Failed to download template ${owner}/${repo}@${ref}: ${response.status} ${response.statusText}`);
512
+ }
513
+ const archive = new Uint8Array(await response.arrayBuffer());
514
+ const entries = await parseTarGzip(archive);
515
+ for (const entry of entries) {
516
+ if (entry.type !== "file" || !entry.data) continue;
517
+ const relativePath = entry.name.replace(/^[^/]+\//, "");
518
+ if (!relativePath) continue;
519
+ const dest = join(targetFolder, relativePath);
520
+ await mkdir(dirname(dest), { recursive: true });
521
+ const mode = entry.attrs?.mode ? Number.parseInt(entry.attrs.mode, 8) : void 0;
522
+ await writeFile(dest, entry.data, mode ? { mode } : void 0);
523
+ }
524
+ }
525
+
526
+ // src/utils/list-template-files.ts
527
+ import { readdir, readFile } from "fs/promises";
528
+ import { join as join2 } from "path";
529
+ import ignore from "ignore";
530
+ async function listTemplateFiles(root) {
531
+ const ig = ignore().add([".git", "node_modules"]);
532
+ try {
533
+ ig.add(await readFile(join2(root, ".gitignore"), "utf8"));
534
+ } catch {
535
+ }
536
+ const files = [];
537
+ async function walk(relDir) {
538
+ const entries = await readdir(relDir ? join2(root, relDir) : root, { withFileTypes: true });
539
+ for (const entry of entries) {
540
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
541
+ if (ig.ignores(entry.isDirectory() ? `${rel}/` : rel)) continue;
542
+ if (entry.isDirectory()) await walk(rel);
543
+ else if (entry.isFile()) files.push(rel);
544
+ }
545
+ }
546
+ await walk("");
547
+ return files;
548
+ }
549
+
476
550
  // src/utils/progress.ts
477
551
  import ora from "ora";
478
552
  import pc4 from "picocolors";
479
553
  var activeSpinner = null;
554
+ function pauseSpinner() {
555
+ if (activeSpinner) {
556
+ activeSpinner.stop();
557
+ }
558
+ }
559
+ function resumeSpinner() {
560
+ if (activeSpinner) {
561
+ activeSpinner.start();
562
+ }
563
+ }
480
564
  function createProgress(title, silent = false) {
481
565
  const completedSteps = [];
482
566
  const spinner = ora({
@@ -515,48 +599,48 @@ function createProgress(title, silent = false) {
515
599
  spinner.stop();
516
600
  activeSpinner = null;
517
601
  log(`${pc4.red("\u2717")} ${pc4.red(message)}`);
518
- },
519
- wrap: async (fn) => {
520
- try {
521
- return await fn();
522
- } catch (error) {
523
- const errorMessage = error instanceof Error ? error.message : String(error);
524
- spinner.stop();
525
- activeSpinner = null;
526
- log(pc4.cyan(`
527
- ${title}`));
528
- for (const step of completedSteps) {
529
- log(pc4.gray(` \u251C\u2500 ${step}`));
530
- }
531
- log(pc4.red(` \u2514\u2500 \u2717 ${errorMessage}`));
532
- throw error;
533
- }
534
602
  }
535
603
  };
536
604
  }
537
605
 
538
- // src/utils/run-package-manager-command.ts
539
- import spawn from "nano-spawn";
540
- async function runPackageManagerCommand(packageManager2, args, env = {}) {
541
- try {
542
- await spawn(packageManager2, args, {
543
- env: { ...process.env, ...env },
544
- stdio: ["pipe", "pipe", "pipe"]
545
- });
546
- } catch (error) {
547
- const err = error;
548
- throw new Error(`"${packageManager2} ${args.join(" ")}" failed ${err.stdout || ""} ${err.stderr || ""}`);
606
+ // src/utils/scan-optional-modules.ts
607
+ import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
608
+ import { dirname as dirname2, join as join3, relative } from "path";
609
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"]);
610
+ async function scanOptionalModules(targetFolder) {
611
+ const files = [];
612
+ await collectModuleFiles(targetFolder, files);
613
+ const modules = [];
614
+ for (const file of files) {
615
+ const src = await readFile2(file, "utf8");
616
+ if (!/optional:\s*true/.test(src)) continue;
617
+ const name = src.match(/name:\s*['"]([^'"]+)['"]/)?.[1];
618
+ const description = src.match(/description:\s*['"]([^'"]+)['"]/)?.[1] ?? "";
619
+ if (!name) continue;
620
+ modules.push({ name, description, folder: relative(targetFolder, dirname2(file)) });
621
+ }
622
+ return modules.sort((a, b) => a.name.localeCompare(b.name));
623
+ }
624
+ function optionalModuleFolders({ name, folder }) {
625
+ return [
626
+ folder,
627
+ `frontend/src/routes/_public/_${name}`,
628
+ `frontend/src/routes/_public/${name}`,
629
+ `frontend/src/routes/_app/_${name}`,
630
+ `frontend/src/routes/_app/${name}`,
631
+ `frontend/public/static/${name}`
632
+ ];
633
+ }
634
+ async function collectModuleFiles(dir, out) {
635
+ const entries = await readdir2(dir, { withFileTypes: true });
636
+ for (const entry of entries) {
637
+ if (entry.isDirectory()) {
638
+ if (SKIP_DIRS.has(entry.name)) continue;
639
+ await collectModuleFiles(join3(dir, entry.name), out);
640
+ } else if (entry.name.endsWith("-module.ts")) {
641
+ out.push(join3(dir, entry.name));
642
+ }
549
643
  }
550
- }
551
- async function install(packageManager2) {
552
- return runPackageManagerCommand(packageManager2, ["install"], {
553
- NODE_ENV: "development"
554
- });
555
- }
556
- async function generate(packageManager2) {
557
- return runPackageManagerCommand(packageManager2, ["generate"], {
558
- NODE_ENV: "development"
559
- });
560
644
  }
561
645
 
562
646
  // src/create.ts
@@ -569,9 +653,9 @@ function shouldSkipStep(name) {
569
653
  async function create({
570
654
  projectName,
571
655
  targetFolder,
572
- newBranchName: newBranchName2,
573
656
  packageManager: packageManager2,
574
657
  templateUrl,
658
+ templateRef,
575
659
  portOffset,
576
660
  adminEmail,
577
661
  silent = false
@@ -582,83 +666,100 @@ async function create({
582
666
  const progress = createProgress("creating project", silent);
583
667
  try {
584
668
  progress.step("creating project folder");
585
- await mkdir(targetFolder, { recursive: true });
669
+ await mkdir2(targetFolder, { recursive: true });
586
670
  process.chdir(targetFolder);
587
671
  if (isLocalTemplate) {
588
672
  progress.step("copying local template");
589
673
  const sourcePath = resolve2(originalCwd, templateUrl);
590
- if (targetFolder.startsWith(sourcePath)) {
674
+ const rel = relative2(sourcePath, targetFolder);
675
+ const targetInsideTemplate = rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
676
+ if (targetInsideTemplate) {
591
677
  throw new Error(
592
678
  `Cannot create project inside the template directory.
593
679
  Run from outside: cd ~ && pnpm create @cellajs/cella ${projectName} --template ${templateUrl}`
594
680
  );
595
681
  }
596
- await cp(sourcePath, targetFolder, {
597
- recursive: true,
598
- filter: (src) => !src.includes("node_modules") && !src.includes(".git")
599
- });
682
+ const tracked = await listTemplateFiles(sourcePath);
683
+ for (const rel2 of tracked) {
684
+ const src = join4(sourcePath, rel2);
685
+ if (!existsSync(src)) continue;
686
+ const dest = join4(targetFolder, rel2);
687
+ await mkdir2(dirname3(dest), { recursive: true });
688
+ await cp(src, dest);
689
+ }
600
690
  } else {
601
- progress.step("downloading cella template");
602
- await downloadTemplate(template, {
603
- cwd: process.cwd(),
604
- dir: targetFolder,
605
- force: true,
606
- provider: "github"
607
- });
691
+ const ref = templateRef ?? "main";
692
+ progress.step(`downloading cella template${templateRef ? ` (${templateRef})` : ""}`);
693
+ await downloadGithubTemplate(template, ref, targetFolder);
608
694
  }
695
+ if (!silent) await promptOptionalModules(targetFolder);
609
696
  progress.step("cleaning template");
610
697
  const displayName = projectName.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
611
698
  await cleanTemplate({ targetFolder, projectName, displayName, portOffset, adminEmail });
612
- if (!shouldSkipStep("install")) {
613
- progress.step("installing dependencies");
614
- await install(packageManager2);
615
- }
616
- if (!shouldSkipStep("generate")) {
617
- progress.step("generating migrations");
618
- await generate(packageManager2);
619
- }
620
- const gitFolderPath = join(targetFolder, ".git");
699
+ const gitFolderPath = join4(targetFolder, ".git");
621
700
  if (!shouldSkipStep("git") && !existsSync(gitFolderPath)) {
622
701
  progress.step("initializing git");
623
702
  await gitInit(targetFolder);
624
703
  await gitAddAll(targetFolder);
625
704
  await gitCommit(targetFolder, "Initial commit");
626
- if (newBranchName2) {
627
- progress.step(`creating branch '${newBranchName2}'`);
628
- await gitBranch(targetFolder, newBranchName2);
629
- await gitCheckout(targetFolder, newBranchName2);
630
- }
631
705
  }
632
706
  if (!shouldSkipStep("remote")) {
633
707
  progress.step("adding upstream remote");
634
708
  await addRemote({ targetFolder, silent: true });
635
709
  }
636
- progress.done(`created ${projectName}`);
710
+ progress.done(pc5.bold(`created ${projectName}`));
637
711
  } catch (error) {
638
712
  progress.fail(error instanceof Error ? error.message : String(error));
639
- process.exit(1);
713
+ throw error;
640
714
  }
641
715
  const needsCd = originalCwd !== targetFolder;
642
- const relativePath = relative(originalCwd, targetFolder);
716
+ const relativePath = relative2(originalCwd, targetFolder);
643
717
  showSuccess(projectName, targetFolder, relativePath, needsCd, packageManager2);
644
718
  }
719
+ async function promptOptionalModules(targetFolder) {
720
+ const modules = await scanOptionalModules(targetFolder);
721
+ if (modules.length === 0) return;
722
+ if (!process.stdin.isTTY) return;
723
+ pauseSpinner();
724
+ console.info();
725
+ const keep = await checkbox(
726
+ {
727
+ message: "Optional modules to include",
728
+ // Leading space on the radio icons adds a gap after the cursor (inquirer renders
729
+ // `${cursor}${checkbox} ${name}` with no space between cursor and checkbox).
730
+ theme: { icon: { checked: ` ${pc5.green("\u25C9")}`, unchecked: " \u25EF" } },
731
+ choices: modules.map((m) => ({
732
+ name: `${m.name} ${pc5.dim(`\xB7 ${m.description}`)}`,
733
+ value: m.name,
734
+ checked: true
735
+ }))
736
+ },
737
+ // Clear the answered prompt so we can print a compact, non-bold summary that lists
738
+ // only the kept module names (not their descriptions).
739
+ { clearPromptOnDone: true }
740
+ );
741
+ confirmChoice("Optional modules", keep.length ? keep.join(", ") : pc5.dim("none"));
742
+ resumeSpinner();
743
+ const folders = modules.filter((m) => !keep.includes(m.name)).flatMap(optionalModuleFolders);
744
+ await Promise.all(folders.map((f) => rm(join4(targetFolder, f), { recursive: true, force: true })));
745
+ }
645
746
 
646
747
  // src/utils/detect-used-ports.ts
647
- import { readdir, readFile } from "fs/promises";
648
- import { dirname, join as join2 } from "path";
748
+ import { readdir as readdir3, readFile as readFile3 } from "fs/promises";
749
+ import { dirname as dirname4, join as join5 } from "path";
649
750
  async function detectUsedPorts(targetFolder) {
650
- const parentDir = dirname(targetFolder);
751
+ const parentDir = dirname4(targetFolder);
651
752
  const used = [];
652
753
  let siblings;
653
754
  try {
654
- siblings = await readdir(parentDir);
755
+ siblings = await readdir3(parentDir);
655
756
  } catch {
656
757
  return used;
657
758
  }
658
759
  for (const name of siblings) {
659
- const configPath = join2(parentDir, name, "shared/config/config.development.ts");
760
+ const configPath = join5(parentDir, name, "shared/config/config.development.ts");
660
761
  try {
661
- const content = await readFile(configPath, "utf8");
762
+ const content = await readFile3(configPath, "utf8");
662
763
  const feMatch = content.match(/frontendUrl:\s*'http:\/\/localhost:(\d+)'/);
663
764
  const beMatch = content.match(/backendUrl:\s*'http:\/\/localhost:(\d+)'/);
664
765
  if (feMatch && beMatch) {
@@ -685,23 +786,63 @@ function findNextOffset(usedPorts) {
685
786
  }
686
787
 
687
788
  // src/utils/extract-package-json-version-from-uri.ts
688
- import axios from "axios";
689
789
  async function extractPackageJsonVersionFromUri(repositoryUrl, branch = "main") {
690
790
  const [owner, repo] = repositoryUrl.replace("github:", "").split("/");
691
- const packageJsonUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/package.json`;
791
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/package.json?ref=${branch}`;
692
792
  try {
693
- const response = await axios.get(packageJsonUrl);
694
- const packageJson = response.data;
793
+ const response = await fetch(apiUrl, {
794
+ headers: { Accept: "application/vnd.github.raw+json" }
795
+ });
796
+ if (!response.ok) return "unknown";
797
+ const packageJson = await response.json();
695
798
  return packageJson.version || "unknown";
696
- } catch (error) {
799
+ } catch {
697
800
  return "unknown";
698
801
  }
699
802
  }
700
803
 
804
+ // src/utils/fetch-template-metadata.ts
805
+ function parseRepo(repositoryUrl) {
806
+ const [owner, repo] = repositoryUrl.replace("github:", "").split("/");
807
+ return [owner, repo];
808
+ }
809
+ function toDate(iso) {
810
+ return iso ? iso.slice(0, 10) : "unknown";
811
+ }
812
+ var GITHUB_HEADERS = { Accept: "application/vnd.github+json" };
813
+ async function fetchLatestRelease(repositoryUrl) {
814
+ const [owner, repo] = parseRepo(repositoryUrl);
815
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=30`;
816
+ try {
817
+ const response = await fetch(apiUrl, { headers: GITHUB_HEADERS });
818
+ if (!response.ok) return null;
819
+ const releases = await response.json();
820
+ const release = releases.find((r) => !r.draft && !r.prerelease && /^v\d/.test(r.tag_name ?? ""));
821
+ if (!release?.tag_name) return null;
822
+ return { tag: release.tag_name, date: toDate(release.published_at) };
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+ async function fetchLatestCommit(repositoryUrl, branch = "main") {
828
+ const [owner, repo] = parseRepo(repositoryUrl);
829
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/${branch}`;
830
+ try {
831
+ const response = await fetch(apiUrl, { headers: GITHUB_HEADERS });
832
+ if (!response.ok) return null;
833
+ const data = await response.json();
834
+ if (!data.sha) return null;
835
+ const message = (data.commit?.message ?? "").split("\n")[0];
836
+ return { sha: data.sha, shortSha: data.sha.slice(0, 7), message, date: toDate(data.commit?.author?.date) };
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
841
+
701
842
  // src/utils/is-empty-directory.ts
702
- import { readdir as readdir2 } from "fs/promises";
843
+ import { readdir as readdir4 } from "fs/promises";
703
844
  async function isEmptyDirectory(path2) {
704
- const files = await readdir2(path2);
845
+ const files = await readdir4(path2);
705
846
  return files.length === 0 || files.length === 1 && files[0] === ".git";
706
847
  }
707
848
 
@@ -711,6 +852,7 @@ async function main() {
711
852
  showWelcome(templateVersion);
712
853
  const promptTheme = { prefix: "", style: { answer: (text) => text } };
713
854
  const promptContext = { clearPromptOnDone: true };
855
+ const templateRef = cli.options.template ? void 0 : await promptTemplateRef(promptTheme, promptContext);
714
856
  if (!cli.directory) {
715
857
  cli.directory = await input(
716
858
  {
@@ -725,9 +867,6 @@ async function main() {
725
867
  promptContext
726
868
  );
727
869
  }
728
- if (!cli.newBranchName) {
729
- cli.newBranchName = "development";
730
- }
731
870
  const targetFolder = resolve3(cli.directory);
732
871
  const projectName = basename2(targetFolder)?.toLowerCase() || "project";
733
872
  if (existsSync2(targetFolder) && !await isEmptyDirectory(targetFolder)) {
@@ -749,44 +888,84 @@ async function main() {
749
888
  }
750
889
  }
751
890
  const portOffset = cli.options.portOffset ?? await promptPortOffset(targetFolder, promptTheme, promptContext);
891
+ confirmChoice("Port offset", formatOffset(portOffset));
752
892
  const adminEmail = cli.options.adminEmail ?? await input(
753
893
  {
754
894
  message: "Admin email for initial seed user",
755
- default: `admin@${projectName}.com`,
895
+ default: "admin@example.com",
756
896
  theme: promptTheme
757
897
  },
758
898
  promptContext
759
899
  );
900
+ confirmChoice("Admin email", adminEmail);
760
901
  const createOptions = {
761
902
  projectName,
762
903
  targetFolder,
763
- newBranchName: cli.newBranchName,
764
904
  packageManager: cli.packageManager,
765
905
  templateUrl: cli.options.template,
906
+ templateRef,
766
907
  portOffset,
767
908
  adminEmail
768
909
  };
769
910
  await create(createOptions);
770
911
  }
771
- main().catch(console.error);
912
+ main().catch((error) => {
913
+ console.error(error);
914
+ process.exit(1);
915
+ });
916
+ async function promptTemplateRef(theme, context) {
917
+ const [release, commit] = await Promise.all([fetchLatestRelease(TEMPLATE_URL), fetchLatestCommit(TEMPLATE_URL)]);
918
+ if (!release && !commit) return void 0;
919
+ console.info(pc6.dim("You are about to install the Cella template."));
920
+ console.info();
921
+ if (release) console.info(` Latest release: ${release.tag} ${pc6.dim(`\xB7 ${release.date}`)}`);
922
+ if (commit) {
923
+ console.info(` Latest commit: ${commit.shortSha} ${pc6.dim(`\xB7 ${commit.message} \xB7 ${commit.date}`)}`);
924
+ const repo = TEMPLATE_URL.replace("github:", "");
925
+ console.info(` ${pc6.gray(`https://github.com/${repo}/commit/${commit.sha}`)}`);
926
+ }
927
+ console.info();
928
+ if (!release) return commit?.sha;
929
+ if (!commit) return release.tag;
930
+ const choice = await select(
931
+ {
932
+ message: "Start from",
933
+ theme,
934
+ choices: [
935
+ { name: "Latest release", value: "release" },
936
+ { name: "Latest commit", value: "commit" }
937
+ ]
938
+ },
939
+ context
940
+ );
941
+ if (choice === "release") {
942
+ confirmChoice("Latest release", release.tag);
943
+ return release.tag;
944
+ }
945
+ confirmChoice("Latest commit", commit.shortSha);
946
+ return commit.sha;
947
+ }
772
948
  function formatOffset(o, suffix = "") {
773
949
  return `${o} \u2192 :${3e3 + o} / :${4e3 + o} / :${5432 + o}${suffix}`;
774
950
  }
775
951
  async function promptPortOffset(targetFolder, theme, context) {
776
952
  const usedPorts = await detectUsedPorts(targetFolder);
777
953
  const suggested = findNextOffset(usedPorts);
954
+ let message = `${pc6.bold("Port offset")} ${pc6.dim("\xB7 to avoid conflicts with other forks/clones")}`;
778
955
  if (usedPorts.length > 0) {
779
- console.info(pc5.dim("\nDetected cella forks in sibling directories:"));
780
- for (const p of usedPorts) {
781
- console.info(pc5.dim(` ${p.project}: frontend :${p.frontend}, backend :${p.backend} (offset ${p.offset})`));
782
- }
783
- console.info();
956
+ const count = usedPorts.length;
957
+ message += pc6.dim(`
958
+ Detected ${count} sibling fork${count === 1 ? "" : "s"} \xB7 suggested offset ${suggested}`);
784
959
  }
785
- const presets = [0, 10, 20, 30].filter((o) => o !== suggested);
960
+ const portTheme = {
961
+ ...theme,
962
+ style: { ...theme.style ?? {}, message: (text) => text }
963
+ };
964
+ const presets = [10, 20, 30].filter((o) => o !== suggested);
786
965
  const choice = await select(
787
966
  {
788
- message: "Port offset (avoids conflicts with sibling forks)",
789
- theme,
967
+ message,
968
+ theme: portTheme,
790
969
  choices: [
791
970
  ...suggested > 0 ? [{ name: formatOffset(suggested, " (suggested)"), value: suggested }] : [],
792
971
  { name: formatOffset(0, " (default)"), value: 0 },