create-daloy 0.1.17 → 0.1.18

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 CHANGED
@@ -19,6 +19,11 @@ The CLI is interactive when arguments are missing. It will ask you for:
19
19
  - Whether to install dependencies
20
20
  - Whether to initialize a git repository
21
21
 
22
+ Interactive runs use a polished terminal UI with a DaloyJS welcome banner,
23
+ arrow-key template and package-manager pickers, progress indicators, and a
24
+ boxed completion summary. Non-TTY environments and `--yes` mode keep a plain,
25
+ script-friendly transcript with the same decisions and next steps.
26
+
22
27
  ## Non-interactive usage
23
28
 
24
29
  ```bash
@@ -113,6 +118,7 @@ re-run with `--minimal`, or delete the marked blocks by hand later.
113
118
  ## What the CLI guarantees
114
119
 
115
120
  - Zero runtime dependencies (uses only Node built-ins) for a clean supply-chain footprint.
121
+ - A modern terminal experience with Unicode/color capability detection and ASCII fallbacks.
116
122
  - Templates are copied verbatim from this package's `templates/` directory.
117
123
  - Files prefixed with `_` are renamed (`_gitignore` → `.gitignore`, `_npmrc` → `.npmrc`) to survive npm packing.
118
124
  - pnpm-specific `.npmrc` hardening is kept only when you choose `pnpm`; other package managers get a clean project without unsupported config warnings.
@@ -69,48 +69,261 @@ const NO_PACKAGE_JSON_TEMPLATES = new Set(["deno-basic"]);
69
69
  // sentinels.
70
70
  const MINIMAL_STRIP_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".md"]);
71
71
 
72
- const COLORS = process.stdout.isTTY
72
+ // ----------------------------------------------------------------------------
73
+ // Terminal capability detection + style primitives.
74
+ //
75
+ // Zero runtime dependencies: we hand-roll color, Unicode, and box-drawing
76
+ // helpers so the CLI ships fast and stays auditable.
77
+ // ----------------------------------------------------------------------------
78
+
79
+ const SUPPORTS_COLOR = process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
80
+ const SUPPORTS_TRUECOLOR =
81
+ SUPPORTS_COLOR && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit");
82
+ const SUPPORTS_UNICODE =
83
+ process.platform !== "win32" ||
84
+ Boolean(process.env.WT_SESSION) ||
85
+ process.env.TERM_PROGRAM === "vscode" ||
86
+ process.env.TERM === "xterm-256color";
87
+
88
+ const COLORS = SUPPORTS_COLOR
73
89
  ? {
74
90
  reset: "\x1b[0m",
75
91
  bold: "\x1b[1m",
76
92
  dim: "\x1b[2m",
93
+ italic: "\x1b[3m",
94
+ underline: "\x1b[4m",
95
+ inverse: "\x1b[7m",
77
96
  cyan: "\x1b[36m",
78
97
  green: "\x1b[32m",
79
98
  red: "\x1b[31m",
80
99
  yellow: "\x1b[33m",
100
+ magenta: "\x1b[35m",
101
+ gray: "\x1b[90m",
102
+ white: "\x1b[97m",
81
103
  }
82
- : { reset: "", bold: "", dim: "", cyan: "", green: "", red: "", yellow: "" };
104
+ : {
105
+ reset: "",
106
+ bold: "",
107
+ dim: "",
108
+ italic: "",
109
+ underline: "",
110
+ inverse: "",
111
+ cyan: "",
112
+ green: "",
113
+ red: "",
114
+ yellow: "",
115
+ magenta: "",
116
+ gray: "",
117
+ white: "",
118
+ };
83
119
 
84
120
  function color(code, s) {
85
121
  return `${code}${s}${COLORS.reset}`;
86
122
  }
87
123
 
124
+ function rgb(r, g, b) {
125
+ if (!SUPPORTS_TRUECOLOR) return "";
126
+ return `\x1b[38;2;${r};${g};${b}m`;
127
+ }
128
+
129
+ function stringWidth(s) {
130
+ let width = 0;
131
+ for (const char of s.replace(/\x1b\[[0-9;]*m/g, "")) {
132
+ const code = char.codePointAt(0) ?? 0;
133
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) continue;
134
+ if (
135
+ code >= 0x1100 &&
136
+ (code <= 0x115f ||
137
+ code === 0x2329 ||
138
+ code === 0x232a ||
139
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
140
+ (code >= 0xac00 && code <= 0xd7a3) ||
141
+ (code >= 0xf900 && code <= 0xfaff) ||
142
+ (code >= 0xfe10 && code <= 0xfe19) ||
143
+ (code >= 0xfe30 && code <= 0xfe6f) ||
144
+ (code >= 0xff00 && code <= 0xff60) ||
145
+ (code >= 0xffe0 && code <= 0xffe6) ||
146
+ (code >= 0x1f300 && code <= 0x1faff))
147
+ ) {
148
+ width += 2;
149
+ } else {
150
+ width += 1;
151
+ }
152
+ }
153
+ return width;
154
+ }
155
+
156
+ // Unicode/ASCII glyphs used throughout the prompt UI. Symbols mirror Clack
157
+ // and Astro's `create` flows so users get the familiar vertical-rail
158
+ // experience, with safe ASCII fallbacks for legacy terminals.
159
+ const SYMBOLS = SUPPORTS_UNICODE
160
+ ? {
161
+ stepActive: "\u25C6", // ◆
162
+ stepDone: "\u25C7", // ◇
163
+ radioOff: "\u25CB", // ○
164
+ radioOn: "\u25C9", // ◉
165
+ success: "\u2714", // ✔
166
+ warn: "\u26A0", // ⚠
167
+ error: "\u2716", // ✖
168
+ info: "\u2139", // ℹ
169
+ bar: "\u2502", // │
170
+ arrow: "\u2192", // →
171
+ pointer: "\u276F", // ❯
172
+ sparkle: "\u2728", // ✨
173
+ star: "\u2605", // ★
174
+ cornerTL: "\u256D", // ╭
175
+ cornerTR: "\u256E", // ╮
176
+ cornerBL: "\u2570", // ╰
177
+ cornerBR: "\u256F", // ╯
178
+ lineH: "\u2500", // ─
179
+ lineV: "\u2502", // │
180
+ }
181
+ : {
182
+ stepActive: "*",
183
+ stepDone: "o",
184
+ radioOff: "( )",
185
+ radioOn: "(*)",
186
+ success: "v",
187
+ warn: "!",
188
+ error: "x",
189
+ info: "i",
190
+ bar: "|",
191
+ arrow: ">",
192
+ pointer: ">",
193
+ sparkle: "*",
194
+ star: "*",
195
+ cornerTL: "+",
196
+ cornerTR: "+",
197
+ cornerBL: "+",
198
+ cornerBR: "+",
199
+ lineH: "-",
200
+ lineV: "|",
201
+ };
202
+
203
+ // Pretty rail-printer. All interactive prompts and status lines flow through
204
+ // these helpers so the column with the vertical bar stays aligned.
205
+ const BAR = color(COLORS.gray, SYMBOLS.bar);
206
+
207
+ function printIntro(title) {
208
+ console.log(`${color(COLORS.cyan, SYMBOLS.cornerTL + SYMBOLS.lineH)} ${color(COLORS.bold, title)}`);
209
+ console.log(BAR);
210
+ }
211
+
212
+ function printOutro(text) {
213
+ console.log(`${color(COLORS.cyan, SYMBOLS.cornerBL + SYMBOLS.lineH)} ${text}`);
214
+ }
215
+
216
+ function printRailLine(text = "") {
217
+ console.log(`${BAR} ${text}`);
218
+ }
219
+
220
+ function printRailGap() {
221
+ console.log(BAR);
222
+ }
223
+
224
+ // Render a horizontally-bounded box with a title bar. Used for the welcome
225
+ // banner and the final "Next steps" outro.
226
+ function renderBox(lines, options = {}) {
227
+ const innerPadding = 2;
228
+ const contentWidth = Math.max(40, ...lines.map((line) => stringWidth(line)));
229
+ const horizontal = SYMBOLS.lineH.repeat(contentWidth + innerPadding * 2);
230
+ const accent = options.accent ?? COLORS.cyan;
231
+ const top = color(accent, `${SYMBOLS.cornerTL}${horizontal}${SYMBOLS.cornerTR}`);
232
+ const bottom = color(accent, `${SYMBOLS.cornerBL}${horizontal}${SYMBOLS.cornerBR}`);
233
+ const out = [top];
234
+ for (const line of lines) {
235
+ const padding = " ".repeat(Math.max(0, contentWidth - stringWidth(line)));
236
+ out.push(`${color(accent, SYMBOLS.lineV)}${" ".repeat(innerPadding)}${line}${padding}${" ".repeat(innerPadding)}${color(accent, SYMBOLS.lineV)}`);
237
+ }
238
+ out.push(bottom);
239
+ return out.join("\n");
240
+ }
241
+
242
+ // Block-letter "DALOY" banner with a left-to-right cyan→magenta gradient when
243
+ // the terminal supports truecolor. Falls back to a single bold-cyan line on
244
+ // non-truecolor TTYs and to plain text in dumb terminals. The shape is built
245
+ // from half-block characters so it stays compact (2 lines tall).
246
+ const LOGO_LINES = [
247
+ " \u2588\u2580\u2584 \u2584\u2580\u2588 \u2588 \u2588\u2580\u2588 \u2588 \u2588 ",
248
+ " \u2588\u2584\u2580 \u2588\u2580\u2588 \u2588\u2584\u2584 \u2588\u2584\u2588 \u2580\u2584\u2580 ",
249
+ ];
250
+
251
+ function gradientLine(line, startRgb, endRgb) {
252
+ if (!SUPPORTS_TRUECOLOR) return color(COLORS.cyan, line);
253
+ const chars = [...line];
254
+ const max = Math.max(1, chars.length - 1);
255
+ let out = "";
256
+ for (let i = 0; i < chars.length; i += 1) {
257
+ const ratio = i / max;
258
+ const r = Math.round(startRgb[0] + (endRgb[0] - startRgb[0]) * ratio);
259
+ const g = Math.round(startRgb[1] + (endRgb[1] - startRgb[1]) * ratio);
260
+ const b = Math.round(startRgb[2] + (endRgb[2] - startRgb[2]) * ratio);
261
+ out += `${rgb(r, g, b)}${chars[i]}`;
262
+ }
263
+ return `${out}${COLORS.reset}`;
264
+ }
265
+
266
+ function printBanner(version) {
267
+ if (!SUPPORTS_UNICODE) {
268
+ console.log(`\n${color(COLORS.bold, "create-daloy")} ${color(COLORS.dim, `v${version}`)}`);
269
+ console.log(color(COLORS.dim, "Contract-first REST APIs for Node, Bun, Deno, Vercel Edge, and Workers"));
270
+ console.log(color(COLORS.dim, "https://daloyjs.dev\n"));
271
+ return;
272
+ }
273
+ const start = [56, 189, 248]; // sky-400
274
+ const end = [217, 70, 239]; // fuchsia-500
275
+ console.log("");
276
+ for (const line of LOGO_LINES) {
277
+ console.log(` ${gradientLine(line, start, end)}`);
278
+ }
279
+ // Build the welcome content lines (each contains its own ANSI color codes).
280
+ const headline = `${color(COLORS.bold, "Welcome to DaloyJS")} ${color(COLORS.gray, `\u2014 v${version}`)}`;
281
+ const subline = color(COLORS.dim, "Contract-first REST APIs for Node, Bun, Deno, Vercel Edge, and Workers.");
282
+ const docs = `${color(COLORS.gray, "docs:")} ${color(COLORS.cyan, "https://daloyjs.dev/docs")}`;
283
+ console.log("");
284
+ console.log(renderBox([headline, subline, "", docs]));
285
+ console.log("");
286
+ }
287
+
288
+
88
289
  function printHelp() {
89
- console.log(`${color(COLORS.bold, "create-daloy")} - scaffold a DaloyJS project
90
-
91
- Usage:
92
- pnpm create daloy@latest [project-name] [options]
93
- npm create daloy@latest [project-name] [options]
94
-
95
- Options:
96
- --template <name> ${TEMPLATES.join(" | ")} (default: node-basic)
97
- --package-manager <pm> ${PACKAGE_MANAGERS.join(" | ")} (default: pnpm)
98
- --list-templates Print available templates and exit.
99
- --install / --no-install Install dependencies after scaffolding.
100
- --git / --no-git Initialize a git repository.
101
- --minimal Strip the bookstore + Swagger/OpenAPI demo routes.
102
- --force Overwrite an existing non-empty directory.
103
- --yes, -y Accept all defaults; never prompt.
104
- --help, -h Print this help.
105
- --version, -v Print version.
290
+ const heading = (text) => color(COLORS.bold + COLORS.cyan, text);
291
+ console.log(`
292
+ ${color(COLORS.bold, "create-daloy")} ${color(COLORS.dim, "\u2014 scaffold a DaloyJS project")}
293
+
294
+ ${heading("Usage")}
295
+ ${color(COLORS.cyan, "pnpm")} create daloy@latest ${color(COLORS.dim, "[project-name] [options]")}
296
+ ${color(COLORS.cyan, "npm")} create daloy@latest ${color(COLORS.dim, "[project-name] [options]")}
297
+ ${color(COLORS.cyan, "yarn")} create daloy ${color(COLORS.dim, "[project-name] [options]")}
298
+ ${color(COLORS.cyan, "bun")} create daloy ${color(COLORS.dim, "[project-name] [options]")}
299
+
300
+ ${heading("Options")}
301
+ ${color(COLORS.green, "--template <name>")} ${TEMPLATES.join(" | ")} ${color(COLORS.dim, "(default: node-basic)")}
302
+ ${color(COLORS.green, "--package-manager <pm>")} ${PACKAGE_MANAGERS.join(" | ")} ${color(COLORS.dim, "(default: pnpm)")}
303
+ ${color(COLORS.green, "--list-templates")} Print available templates and exit.
304
+ ${color(COLORS.green, "--install / --no-install")} Install dependencies after scaffolding.
305
+ ${color(COLORS.green, "--git / --no-git")} Initialize a git repository.
306
+ ${color(COLORS.green, "--minimal")} Strip the bookstore + Swagger/OpenAPI demo routes.
307
+ ${color(COLORS.green, "--force")} Overwrite an existing non-empty directory.
308
+ ${color(COLORS.green, "--yes, -y")} Accept all defaults; never prompt.
309
+ ${color(COLORS.green, "--help, -h")} Print this help.
310
+ ${color(COLORS.green, "--version, -v")} Print version.
311
+
312
+ ${heading("Docs")} ${color(COLORS.cyan, "https://daloyjs.dev/docs")}
106
313
  `);
107
314
  }
108
315
 
109
316
  function printTemplates() {
110
- console.log(color(COLORS.bold, "Available DaloyJS templates\n"));
317
+ console.log("");
318
+ console.log(`${color(COLORS.cyan, SYMBOLS.sparkle)} ${color(COLORS.bold, "Available DaloyJS templates")}`);
319
+ console.log("");
320
+ const valueWidth = Math.max(...TEMPLATE_OPTIONS.map((option) => option.value.length));
111
321
  for (const option of TEMPLATE_OPTIONS) {
112
- console.log(` ${color(COLORS.cyan, option.value.padEnd(18))} ${option.title}`);
113
- console.log(color(COLORS.dim, ` ${" ".repeat(18)} ${option.description}\n`));
322
+ const value = color(COLORS.cyan, option.value.padEnd(valueWidth));
323
+ const title = color(COLORS.bold, option.title);
324
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${value} ${title}`);
325
+ console.log(` ${color(COLORS.dim, option.description)}`);
326
+ console.log("");
114
327
  }
115
328
  }
116
329
 
@@ -157,7 +370,7 @@ function parseArgs(argv) {
157
370
  else if (a?.startsWith("--pm=")) out.packageManager = a.slice("--pm=".length);
158
371
  else if (a && !a.startsWith("-") && out.projectName === undefined) out.projectName = a;
159
372
  else if (a) {
160
- console.error(color(COLORS.red, `Unknown argument: ${a}`));
373
+ logError(`Unknown argument: ${a}`);
161
374
  process.exit(1);
162
375
  }
163
376
  }
@@ -360,17 +573,59 @@ function run(cmd, args, cwd) {
360
573
  });
361
574
  }
362
575
 
576
+ // Same as `run`, but captures output so the spinner can stay clean. The
577
+ // transcript tail is returned so callers can replay useful failure context
578
+ // without buffering unbounded package-manager output in memory.
579
+ function runQuiet(cmd, args, cwd) {
580
+ return new Promise((resolve) => {
581
+ const maxOutputBytes = 64 * 1024;
582
+ let output = "";
583
+ const appendOutput = (chunk) => {
584
+ output += chunk.toString("utf8");
585
+ if (output.length > maxOutputBytes) output = output.slice(-maxOutputBytes);
586
+ };
587
+ const proc = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
588
+ proc.stdout.on("data", appendOutput);
589
+ proc.stderr.on("data", appendOutput);
590
+ proc.on("exit", (code) => resolve({ code: code ?? 0, output }));
591
+ proc.on("error", (err) => resolve({ code: 1, output: String(err?.message ?? err) }));
592
+ });
593
+ }
594
+
595
+ // ----------------------------------------------------------------------------
596
+ // Prompt primitives.
597
+ //
598
+ // `ask`/`askYesNo` use readline for resilience (paste, history, multi-line
599
+ // input). `askChoice` upgrades to raw-mode arrow-key navigation when stdin is
600
+ // a TTY, with a numbered fallback used by the readline-driven tests.
601
+ // ----------------------------------------------------------------------------
602
+
603
+ function printPromptHeader(question) {
604
+ console.log(`${color(COLORS.cyan, SYMBOLS.stepActive)} ${color(COLORS.bold, question)}`);
605
+ }
606
+
607
+ function printPromptResult(question, value) {
608
+ console.log(`${color(COLORS.green, SYMBOLS.stepDone)} ${question} ${color(COLORS.dim, SYMBOLS.arrow)} ${color(COLORS.cyan, value)}`);
609
+ }
610
+
363
611
  async function ask(rl, question, defaultValue) {
364
- const suffix = defaultValue !== undefined ? color(COLORS.dim, ` (${defaultValue})`) : "";
365
- const answer = (await rl.question(`${color(COLORS.cyan, "?")} ${question}${suffix} `)).trim();
366
- return answer.length === 0 ? defaultValue : answer;
612
+ printPromptHeader(question);
613
+ const hint = defaultValue !== undefined ? color(COLORS.dim, ` (default: ${defaultValue})`) : "";
614
+ const answer = (await rl.question(`${BAR} ${color(COLORS.gray, SYMBOLS.pointer)}${hint} `)).trim();
615
+ const value = answer.length === 0 ? defaultValue : answer;
616
+ // readline already echoed the prompt + answer line; emit a final summary
617
+ // line on the rail so the transcript reads cleanly after scroll-back.
618
+ printRailGap();
619
+ return value;
367
620
  }
368
621
 
369
622
  async function askYesNo(rl, question, defaultYes) {
623
+ printPromptHeader(question);
370
624
  const def = defaultYes ? "Y/n" : "y/N";
371
- const answer = (await rl.question(`${color(COLORS.cyan, "?")} ${question} ${color(COLORS.dim, `(${def})`)} `))
625
+ const answer = (await rl.question(`${BAR} ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, `(${def})`)} `))
372
626
  .trim()
373
627
  .toLowerCase();
628
+ printRailGap();
374
629
  if (answer.length === 0) return defaultYes;
375
630
  return answer === "y" || answer === "yes";
376
631
  }
@@ -379,68 +634,264 @@ function optionValue(option) {
379
634
  return typeof option === "string" ? option : option.value;
380
635
  }
381
636
 
382
- function optionLabel(option) {
383
- return typeof option === "string" ? option : `${option.title} ${color(COLORS.dim, `(${option.value})`)}`;
637
+ function optionTitle(option) {
638
+ return typeof option === "string" ? option : option.title;
639
+ }
640
+
641
+ function optionDescription(option) {
642
+ return typeof option === "string" ? "" : option.description ?? "";
384
643
  }
385
644
 
645
+ // Arrow-key powered choice prompt. Falls back to numbered input whenever raw
646
+ // mode is unavailable (CI, piped stdin, integration tests).
386
647
  async function askChoice(rl, question, choices, defaultChoice) {
387
- const nameWidth = Math.max(...choices.map((choice) => optionLabel(choice).replace(/\x1b\[[0-9;]*m/g, "").length));
388
- const list = choices
389
- .map((choice, i) => {
390
- const value = optionValue(choice);
391
- const label = optionLabel(choice).padEnd(nameWidth);
392
- const description = typeof choice === "string" ? "" : color(COLORS.dim, ` ${choice.description}`);
393
- const defaultMarker = value === defaultChoice ? color(COLORS.green, " recommended") : "";
394
- return ` ${color(COLORS.dim, `${i + 1})`)} ${label}${description}${defaultMarker}`;
395
- })
396
- .join("\n");
397
- console.log(`${color(COLORS.cyan, "?")} ${question}\n${list}`);
398
- const raw = (await rl.question(` > `)).trim();
648
+ const canRawMode = process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
649
+ if (!canRawMode) return askChoiceNumbered(rl, question, choices, defaultChoice);
650
+
651
+ printPromptHeader(question);
652
+ printRailLine(color(COLORS.dim, `Use \u2191 \u2193 to navigate, Enter to confirm, type a number to jump.`));
653
+
654
+ let index = Math.max(
655
+ 0,
656
+ choices.findIndex((choice) => optionValue(choice) === defaultChoice),
657
+ );
658
+ const titleWidth = Math.max(...choices.map((choice) => optionTitle(choice).length));
659
+ const valueWidth = Math.max(...choices.map((choice) => optionValue(choice).length));
660
+
661
+ function render(active) {
662
+ return choices
663
+ .map((choice, i) => {
664
+ const isActive = i === active;
665
+ const isDefault = optionValue(choice) === defaultChoice;
666
+ const marker = isActive ? color(COLORS.cyan, SYMBOLS.radioOn) : color(COLORS.gray, SYMBOLS.radioOff);
667
+ const titleRaw = optionTitle(choice).padEnd(titleWidth);
668
+ const valueRaw = optionValue(choice).padEnd(valueWidth);
669
+ const title = isActive ? color(COLORS.bold + COLORS.cyan, titleRaw) : color(COLORS.white, titleRaw);
670
+ const value = color(COLORS.dim, `(${valueRaw})`);
671
+ const description = optionDescription(choice);
672
+ const descColored = isActive ? color(COLORS.cyan, description) : color(COLORS.dim, description);
673
+ const recommended = isDefault ? color(COLORS.green, ` ${SYMBOLS.star} recommended`) : "";
674
+ return `${BAR} ${marker} ${title} ${value} ${descColored}${recommended}`;
675
+ })
676
+ .join("\n");
677
+ }
678
+
679
+ // Pause readline so it doesn't fight us for stdin while we're in raw mode.
680
+ rl.pause();
681
+ process.stdin.setRawMode(true);
682
+ process.stdin.resume();
683
+ process.stdin.setEncoding("utf8");
684
+
685
+ // Initial render
686
+ process.stdout.write(render(index) + "\n");
687
+
688
+ const result = await new Promise((resolve, reject) => {
689
+ function rerender(newIndex) {
690
+ // Move cursor up `choices.length` lines, clear them, redraw.
691
+ process.stdout.write(`\x1b[${choices.length}A`);
692
+ for (let i = 0; i < choices.length; i += 1) process.stdout.write("\x1b[2K\n");
693
+ process.stdout.write(`\x1b[${choices.length}A`);
694
+ process.stdout.write(render(newIndex) + "\n");
695
+ }
696
+ function cleanup() {
697
+ process.stdin.removeListener("data", onData);
698
+ process.stdin.setRawMode(false);
699
+ process.stdin.pause();
700
+ }
701
+ function onData(chunk) {
702
+ const data = chunk.toString();
703
+ // Ctrl+C / Ctrl+D — abort cleanly
704
+ if (data === "\u0003" || data === "\u0004") {
705
+ cleanup();
706
+ process.stdout.write("\n");
707
+ reject(new Error("Cancelled"));
708
+ return;
709
+ }
710
+ // Enter
711
+ if (data === "\r" || data === "\n") {
712
+ cleanup();
713
+ resolve(optionValue(choices[index]));
714
+ return;
715
+ }
716
+ // Number shortcut (1..9)
717
+ if (/^[1-9]$/.test(data)) {
718
+ const n = Number.parseInt(data, 10);
719
+ if (n >= 1 && n <= choices.length) {
720
+ index = n - 1;
721
+ rerender(index);
722
+ }
723
+ return;
724
+ }
725
+ // Arrow keys / vim keys
726
+ if (data === "\u001b[A" || data === "k") {
727
+ index = (index - 1 + choices.length) % choices.length;
728
+ rerender(index);
729
+ } else if (data === "\u001b[B" || data === "j") {
730
+ index = (index + 1) % choices.length;
731
+ rerender(index);
732
+ } else if (data === "\u001b[H") {
733
+ index = 0;
734
+ rerender(index);
735
+ } else if (data === "\u001b[F") {
736
+ index = choices.length - 1;
737
+ rerender(index);
738
+ }
739
+ }
740
+ process.stdin.on("data", onData);
741
+ });
742
+
743
+ // Replace the rendered list with a single confirmation line.
744
+ // Rendered block was choices.length lines; we also printed the hint line
745
+ // above the list. Move up and clear them.
746
+ const linesToClear = choices.length + 1; // hint + list
747
+ process.stdout.write(`\x1b[${linesToClear}A`);
748
+ for (let i = 0; i < linesToClear; i += 1) process.stdout.write("\x1b[2K\n");
749
+ process.stdout.write(`\x1b[${linesToClear}A`);
750
+
751
+ // Also clear the prompt header we printed at the very top.
752
+ process.stdout.write("\x1b[1A\x1b[2K");
753
+
754
+ printPromptResult(question, result);
755
+ printRailGap();
756
+ rl.resume();
757
+ return result;
758
+ }
759
+
760
+ async function askChoiceNumbered(rl, question, choices, defaultChoice) {
761
+ printPromptHeader(question);
762
+ const titleWidth = Math.max(...choices.map((choice) => optionTitle(choice).length));
763
+ for (let i = 0; i < choices.length; i += 1) {
764
+ const choice = choices[i];
765
+ const isDefault = optionValue(choice) === defaultChoice;
766
+ const idx = color(COLORS.dim, `${String(i + 1).padStart(2, " ")})`);
767
+ const title = color(COLORS.white, optionTitle(choice).padEnd(titleWidth));
768
+ const value = color(COLORS.dim, `(${optionValue(choice)})`);
769
+ const description = color(COLORS.dim, optionDescription(choice));
770
+ const recommended = isDefault ? color(COLORS.green, ` ${SYMBOLS.star} recommended`) : "";
771
+ printRailLine(`${idx} ${title} ${value} ${description}${recommended}`);
772
+ }
773
+ const raw = (await rl.question(`${BAR} ${color(COLORS.gray, SYMBOLS.pointer)} `)).trim();
774
+ printRailGap();
399
775
  if (raw.length === 0) return defaultChoice;
400
776
  const asNumber = Number.parseInt(raw, 10);
401
777
  if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= choices.length) {
402
778
  return optionValue(choices[asNumber - 1]);
403
779
  }
404
780
  if (choices.some((choice) => optionValue(choice) === raw)) return raw;
405
- console.error(color(COLORS.red, `Invalid choice. Pick one of: ${choices.map(optionValue).join(", ")}`));
406
- return askChoice(rl, question, choices, defaultChoice);
781
+ console.error(`${BAR} ${color(COLORS.red, `Invalid choice. Pick one of: ${choices.map(optionValue).join(", ")}`)}`);
782
+ return askChoiceNumbered(rl, question, choices, defaultChoice);
407
783
  }
408
784
 
409
785
  function logStep(message, detail) {
410
- const suffix = detail ? color(COLORS.dim, ` ${detail}`) : "";
411
- console.log(`${color(COLORS.green, " [ok]")} ${message}${suffix}`);
786
+ const suffix = detail ? color(COLORS.dim, ` \u2014 ${detail}`) : "";
787
+ console.log(`${color(COLORS.green, SYMBOLS.success)} ${message}${suffix}`);
412
788
  }
413
789
 
414
790
  function logWarn(message) {
415
- console.warn(`${color(COLORS.yellow, " [warn]")} ${message}`);
791
+ console.warn(`${color(COLORS.yellow, SYMBOLS.warn)} ${color(COLORS.yellow, message)}`);
792
+ }
793
+
794
+ function logError(message) {
795
+ console.error(`${color(COLORS.red, SYMBOLS.error)} ${color(COLORS.red, message)}`);
796
+ }
797
+
798
+ // ----------------------------------------------------------------------------
799
+ // Spinner — tiny braille animation for long-running steps (e.g. install).
800
+ // ----------------------------------------------------------------------------
801
+
802
+ const SPINNER_FRAMES = SUPPORTS_UNICODE
803
+ ? ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
804
+ : ["|", "/", "-", "\\"];
805
+
806
+ function createSpinner(initialMessage) {
807
+ let message = initialMessage;
808
+ let frame = 0;
809
+ let timer = null;
810
+ let active = false;
811
+ function render() {
812
+ if (!process.stdout.isTTY) return;
813
+ process.stdout.write(`\r\x1b[2K${color(COLORS.cyan, SPINNER_FRAMES[frame])} ${message}`);
814
+ frame = (frame + 1) % SPINNER_FRAMES.length;
815
+ }
816
+ return {
817
+ start(text) {
818
+ if (text) message = text;
819
+ active = true;
820
+ if (process.stdout.isTTY) {
821
+ timer = setInterval(render, 80);
822
+ render();
823
+ } else {
824
+ console.log(`${color(COLORS.cyan, SYMBOLS.stepActive)} ${message}`);
825
+ }
826
+ },
827
+ update(text) {
828
+ message = text;
829
+ if (active && process.stdout.isTTY) render();
830
+ },
831
+ stop(text, ok = true) {
832
+ if (timer) clearInterval(timer);
833
+ timer = null;
834
+ active = false;
835
+ const symbol = ok
836
+ ? color(COLORS.green, SYMBOLS.success)
837
+ : color(COLORS.red, SYMBOLS.error);
838
+ const finalMessage = text ?? message;
839
+ if (process.stdout.isTTY) {
840
+ process.stdout.write(`\r\x1b[2K${symbol} ${finalMessage}\n`);
841
+ } else {
842
+ console.log(`${symbol} ${finalMessage}`);
843
+ }
844
+ },
845
+ };
416
846
  }
417
847
 
418
848
  function printSummary({ projectName, template, packageManager, installDeps, skipPackageManager }) {
419
- console.log(color(COLORS.green, "\nCreated a new DaloyJS project."));
420
- console.log(`\n ${color(COLORS.bold, "Project")} ${projectName}`);
421
- console.log(` ${color(COLORS.bold, "Template")} ${template}`);
849
+ const templateMeta = TEMPLATE_OPTIONS.find((option) => option.value === template);
850
+ const templateLabel = templateMeta ? `${templateMeta.title} ${color(COLORS.dim, `(${template})`)}` : template;
851
+ const summaryLines = [
852
+ `${color(COLORS.green, SYMBOLS.sparkle)} ${color(COLORS.bold, "Your DaloyJS project is ready!")}`,
853
+ "",
854
+ `${color(COLORS.gray, "Project ")} ${color(COLORS.bold, projectName)}`,
855
+ `${color(COLORS.gray, "Template ")} ${templateLabel}`,
856
+ ];
857
+ if (skipPackageManager) {
858
+ summaryLines.push(`${color(COLORS.gray, "Runtime ")} ${color(COLORS.cyan, template === "deno-basic" ? "Deno" : "runtime")}`);
859
+ } else {
860
+ summaryLines.push(`${color(COLORS.gray, "Manager ")} ${color(COLORS.cyan, packageManager)}`);
861
+ }
862
+ console.log("");
863
+ console.log(renderBox(summaryLines, { accent: COLORS.green }));
864
+ console.log("");
865
+
866
+ const arrow = color(COLORS.cyan, SYMBOLS.arrow);
867
+ console.log(`${color(COLORS.bold, "Next steps")}`);
868
+ console.log(` ${arrow} ${color(COLORS.cyan, `cd ${projectName}`)}`);
869
+ if (skipPackageManager) {
870
+ console.log(` ${arrow} ${color(COLORS.cyan, "deno task dev")}`);
871
+ } else {
872
+ if (!installDeps) console.log(` ${arrow} ${color(COLORS.cyan, `${packageManager} install`)}`);
873
+ console.log(` ${arrow} ${color(COLORS.cyan, `${packageManager} run dev`)}`);
874
+ }
875
+
876
+ console.log("");
877
+ console.log(`${color(COLORS.bold, "Useful commands")}`);
422
878
  if (skipPackageManager) {
423
- console.log(` ${color(COLORS.bold, "Runtime")} ${template === "deno-basic" ? "Deno" : "runtime"}`);
424
- console.log(`\n ${color(COLORS.bold, "Next steps")}`);
425
- console.log(` cd ${projectName}`);
426
- console.log(` deno task dev`);
427
- console.log(`\n ${color(COLORS.bold, "Useful commands")}`);
428
- console.log(` deno task typecheck`);
429
- console.log(` deno task test`);
430
- console.log(` deno task gen:openapi`);
879
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "deno task typecheck")}`);
880
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "deno task test")}`);
881
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "deno task gen:openapi")}`);
431
882
  } else {
432
- console.log(` ${color(COLORS.bold, "Manager")} ${packageManager}`);
433
- console.log(`\n ${color(COLORS.bold, "Next steps")}`);
434
- console.log(` cd ${projectName}`);
435
- if (!installDeps) console.log(` ${packageManager} install`);
436
- console.log(` ${packageManager} run dev`);
437
- console.log(`\n ${color(COLORS.bold, "Useful commands")}`);
438
- console.log(` ${packageManager} run typecheck`);
439
- console.log(` ${packageManager} test`);
440
- if (template === "node-basic" || template === "bun-basic") console.log(` ${packageManager} run gen`);
883
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, `${packageManager} run typecheck`)}`);
884
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, `${packageManager} test`)}`);
885
+ if (template === "node-basic" || template === "bun-basic") {
886
+ console.log(` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, `${packageManager} run gen`)}`);
887
+ }
441
888
  }
442
- console.log(`\n ${color(COLORS.dim, "Docs: https://daloyjs.dev/docs")}`);
443
- console.log(color(COLORS.dim, " Issues: https://github.com/daloyjs/daloy/issues\n"));
889
+
890
+ console.log("");
891
+ console.log(`${color(COLORS.gray, "Docs:")} ${color(COLORS.cyan, "https://daloyjs.dev/docs")}`);
892
+ console.log(`${color(COLORS.gray, "Issues:")} ${color(COLORS.cyan, "https://github.com/daloyjs/daloy/issues")}`);
893
+ console.log("");
894
+ console.log(`${color(COLORS.magenta, SYMBOLS.sparkle)} ${color(COLORS.bold, "Happy shipping!")}\n`);
444
895
  }
445
896
 
446
897
  async function main() {
@@ -459,14 +910,16 @@ async function main() {
459
910
  process.exit(0);
460
911
  }
461
912
 
462
- console.log(color(COLORS.bold, "\ncreate-daloy"));
463
- console.log(color(COLORS.dim, "Contract-first REST APIs for Node, Vercel Edge, and Workers"));
464
- console.log(color(COLORS.dim, "https://daloyjs.dev\n"));
913
+ printBanner(await readPkgVersion());
465
914
 
466
915
  const detectedPm = detectPackageManager();
467
916
  const interactive = !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
468
917
  const rl = interactive ? createInterface({ input, output }) : null;
469
918
 
919
+ if (interactive) {
920
+ printIntro("Let's set up your DaloyJS project");
921
+ }
922
+
470
923
  try {
471
924
  let projectName = opts.projectName;
472
925
  if (!projectName) {
@@ -478,7 +931,7 @@ async function main() {
478
931
  projectName = candidate;
479
932
  break;
480
933
  }
481
- console.error(color(COLORS.red, ` ${valid}`));
934
+ logError(valid);
482
935
  }
483
936
  } else {
484
937
  projectName = "my-daloy-app";
@@ -486,7 +939,7 @@ async function main() {
486
939
  }
487
940
  const nameCheck = validateProjectName(projectName);
488
941
  if (nameCheck !== true) {
489
- console.error(color(COLORS.red, nameCheck));
942
+ logError(nameCheck);
490
943
  process.exit(1);
491
944
  }
492
945
 
@@ -495,13 +948,13 @@ async function main() {
495
948
  template = rl ? await askChoice(rl, "Choose a starter template:", TEMPLATE_OPTIONS, "node-basic") : "node-basic";
496
949
  }
497
950
  if (!TEMPLATES.includes(template)) {
498
- console.error(color(COLORS.red, `Unknown template "${template}". Available: ${TEMPLATES.join(", ")}`));
951
+ logError(`Unknown template "${template}". Available: ${TEMPLATES.join(", ")}`);
499
952
  process.exit(1);
500
953
  }
501
954
 
502
955
  const templateDir = path.join(TEMPLATES_DIR, template);
503
956
  if (!existsSync(templateDir)) {
504
- console.error(color(COLORS.red, `Template "${template}" is missing from this CLI build.`));
957
+ logError(`Template "${template}" is missing from this CLI build.`);
505
958
  process.exit(1);
506
959
  }
507
960
 
@@ -509,12 +962,7 @@ async function main() {
509
962
  if (existsSync(targetDir)) {
510
963
  const empty = await isDirEmpty(targetDir);
511
964
  if (!empty && !opts.force) {
512
- console.error(
513
- color(
514
- COLORS.red,
515
- `Directory ${projectName} is not empty. Re-run with --force to overwrite.`,
516
- ),
517
- );
965
+ logError(`Directory ${projectName} is not empty. Re-run with --force to overwrite.`);
518
966
  process.exit(1);
519
967
  }
520
968
  }
@@ -531,9 +979,7 @@ async function main() {
531
979
  }
532
980
  }
533
981
  if (!PACKAGE_MANAGERS.includes(packageManager)) {
534
- console.error(
535
- color(COLORS.red, `Unknown --package-manager "${packageManager}". Use one of: ${PACKAGE_MANAGERS.join(", ")}`),
536
- );
982
+ logError(`Unknown --package-manager "${packageManager}". Use one of: ${PACKAGE_MANAGERS.join(", ")}`);
537
983
  process.exit(1);
538
984
  }
539
985
 
@@ -553,7 +999,12 @@ async function main() {
553
999
 
554
1000
  rl?.close();
555
1001
 
556
- console.log(color(COLORS.bold, "\nScaffolding"));
1002
+ if (interactive) {
1003
+ printOutro(color(COLORS.dim, "Configuration locked in. Building your project\u2026"));
1004
+ }
1005
+ console.log("");
1006
+ console.log(`${color(COLORS.cyan, SYMBOLS.sparkle)} ${color(COLORS.bold, "Scaffolding your project")}`);
1007
+ console.log("");
557
1008
 
558
1009
  await mkdir(targetDir, { recursive: true });
559
1010
  await copyTemplate(templateDir, targetDir);
@@ -582,19 +1033,31 @@ async function main() {
582
1033
  }
583
1034
 
584
1035
  if (installDeps) {
585
- console.log(color(COLORS.dim, ` Installing dependencies with ${packageManager}...`));
586
- const code = await run(packageManager, ["install"], targetDir);
1036
+ const spinner = createSpinner(`Installing dependencies with ${color(COLORS.cyan, packageManager)}\u2026`);
1037
+ spinner.start();
1038
+ const { code, output: installOutput } = await runQuiet(packageManager, ["install"], targetDir);
587
1039
  if (code !== 0) {
588
- logWarn(`${packageManager} install exited with code ${code}; you can retry inside ${projectName}.`);
1040
+ spinner.stop(`${packageManager} install failed (exit ${code})`, false);
1041
+ // Replay the captured output so the user can see what went wrong.
1042
+ const tail = installOutput.split(/\r?\n/).slice(-40).join("\n");
1043
+ if (tail.trim().length > 0) {
1044
+ console.error(color(COLORS.dim, tail));
1045
+ }
1046
+ logWarn(`Retry inside ${projectName} with: ${packageManager} install`);
589
1047
  } else {
590
- logStep("Dependencies installed", packageManager);
1048
+ spinner.stop(`Installed dependencies with ${color(COLORS.cyan, packageManager)}`);
591
1049
  }
592
1050
  }
593
1051
 
594
1052
  printSummary({ projectName, template, packageManager, installDeps, skipPackageManager });
595
1053
  } catch (err) {
596
1054
  rl?.close();
597
- console.error(color(COLORS.red, `\n Failed: ${(err && err.message) || err}`));
1055
+ if (err && err.message === "Cancelled") {
1056
+ console.log("");
1057
+ logWarn("Cancelled. No project was created.");
1058
+ process.exit(130);
1059
+ }
1060
+ logError(`Failed: ${(err && err.message) || err}`);
598
1061
  process.exit(1);
599
1062
  }
600
1063
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-daloy",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
5
5
  "type": "module",
6
6
  "license": "MIT",