create-daloy 0.1.16 → 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.
@@ -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
  }
@@ -221,16 +434,33 @@ async function patchPackageJson(dir, projectName, packageManager) {
221
434
  await writeFile(file, JSON.stringify(json, null, 2) + "\n", "utf8");
222
435
  }
223
436
 
224
- async function patchReadme(dir, packageManager) {
437
+ async function patchTemplateTextFiles(dir, packageManager) {
225
438
  if (packageManager === "pnpm") return;
226
- const file = path.join(dir, "README.md");
227
- if (!existsSync(file)) return;
228
- const raw = await readFile(file, "utf8");
229
- const next = raw
439
+ for (const fileName of ["README.md", "AGENTS.md", "SKILL.md"]) {
440
+ const file = path.join(dir, fileName);
441
+ if (!existsSync(file)) continue;
442
+ const raw = await readFile(file, "utf8");
443
+ const next = rewritePackageManagerText(raw, packageManager);
444
+ if (next !== raw) await writeFile(file, next, "utf8");
445
+ }
446
+ }
447
+
448
+ function rewritePackageManagerText(raw, packageManager) {
449
+ return raw
450
+ .replace(
451
+ "Package manager: pnpm (use `pnpm` unless the project's `package.json` was rewritten for npm/yarn/bun).",
452
+ `Package manager: ${packageManager}.`,
453
+ )
230
454
  .replace(/\bpnpm install\b/g, `${packageManager} install`)
455
+ .replace(/\bpnpm gen:openapi\b/g, `${packageManager} run gen:openapi`)
456
+ .replace(/\bpnpm gen:client\b/g, `${packageManager} run gen:client`)
457
+ .replace(/\bpnpm typecheck\b/g, `${packageManager} run typecheck`)
458
+ .replace(/\bpnpm build\b/g, `${packageManager} run build`)
459
+ .replace(/\bpnpm deploy\b/g, `${packageManager} run deploy`)
231
460
  .replace(/\bpnpm dev\b/g, `${packageManager} run dev`)
232
461
  .replace(/\bpnpm gen\b/g, `${packageManager} run gen`)
233
- .replace(/\bpnpm build\b/g, `${packageManager} run build`)
462
+ .replace(/\bpnpm test\b/g, `${packageManager} test`)
463
+ .replace(/\bpnpm audit\b/g, `${packageManager} audit`)
234
464
  .replace(
235
465
  "- Hardened `.npmrc` for safer installs.",
236
466
  `- Package-manager scripts adjusted for ${packageManager}.`,
@@ -238,8 +468,11 @@ async function patchReadme(dir, packageManager) {
238
468
  .replace(
239
469
  "- Hey API codegen wired to `pnpm gen`.",
240
470
  `- Hey API codegen wired to \`${packageManager} run gen\`.`,
471
+ )
472
+ .replace(
473
+ "- Do not add runtime dependencies without checking the hardened `.npmrc` (installs wait 24h after publish by default).",
474
+ `- Add runtime dependencies with \`${packageManager} install <package>\` and rerun the quality gates after dependency changes.`,
241
475
  );
242
- await writeFile(file, next, "utf8");
243
476
  }
244
477
 
245
478
  /**
@@ -340,17 +573,59 @@ function run(cmd, args, cwd) {
340
573
  });
341
574
  }
342
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
+
343
611
  async function ask(rl, question, defaultValue) {
344
- const suffix = defaultValue !== undefined ? color(COLORS.dim, ` (${defaultValue})`) : "";
345
- const answer = (await rl.question(`${color(COLORS.cyan, "?")} ${question}${suffix} `)).trim();
346
- 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;
347
620
  }
348
621
 
349
622
  async function askYesNo(rl, question, defaultYes) {
623
+ printPromptHeader(question);
350
624
  const def = defaultYes ? "Y/n" : "y/N";
351
- 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})`)} `))
352
626
  .trim()
353
627
  .toLowerCase();
628
+ printRailGap();
354
629
  if (answer.length === 0) return defaultYes;
355
630
  return answer === "y" || answer === "yes";
356
631
  }
@@ -359,68 +634,264 @@ function optionValue(option) {
359
634
  return typeof option === "string" ? option : option.value;
360
635
  }
361
636
 
362
- function optionLabel(option) {
363
- 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 ?? "";
364
643
  }
365
644
 
645
+ // Arrow-key powered choice prompt. Falls back to numbered input whenever raw
646
+ // mode is unavailable (CI, piped stdin, integration tests).
366
647
  async function askChoice(rl, question, choices, defaultChoice) {
367
- const nameWidth = Math.max(...choices.map((choice) => optionLabel(choice).replace(/\x1b\[[0-9;]*m/g, "").length));
368
- const list = choices
369
- .map((choice, i) => {
370
- const value = optionValue(choice);
371
- const label = optionLabel(choice).padEnd(nameWidth);
372
- const description = typeof choice === "string" ? "" : color(COLORS.dim, ` ${choice.description}`);
373
- const defaultMarker = value === defaultChoice ? color(COLORS.green, " recommended") : "";
374
- return ` ${color(COLORS.dim, `${i + 1})`)} ${label}${description}${defaultMarker}`;
375
- })
376
- .join("\n");
377
- console.log(`${color(COLORS.cyan, "?")} ${question}\n${list}`);
378
- 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();
379
775
  if (raw.length === 0) return defaultChoice;
380
776
  const asNumber = Number.parseInt(raw, 10);
381
777
  if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= choices.length) {
382
778
  return optionValue(choices[asNumber - 1]);
383
779
  }
384
780
  if (choices.some((choice) => optionValue(choice) === raw)) return raw;
385
- console.error(color(COLORS.red, `Invalid choice. Pick one of: ${choices.map(optionValue).join(", ")}`));
386
- 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);
387
783
  }
388
784
 
389
785
  function logStep(message, detail) {
390
- const suffix = detail ? color(COLORS.dim, ` ${detail}`) : "";
391
- 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}`);
392
788
  }
393
789
 
394
790
  function logWarn(message) {
395
- 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
+ };
396
846
  }
397
847
 
398
848
  function printSummary({ projectName, template, packageManager, installDeps, skipPackageManager }) {
399
- console.log(color(COLORS.green, "\nCreated a new DaloyJS project."));
400
- console.log(`\n ${color(COLORS.bold, "Project")} ${projectName}`);
401
- 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
+ ];
402
857
  if (skipPackageManager) {
403
- console.log(` ${color(COLORS.bold, "Runtime")} ${template === "deno-basic" ? "Deno" : "runtime"}`);
404
- console.log(`\n ${color(COLORS.bold, "Next steps")}`);
405
- console.log(` cd ${projectName}`);
406
- console.log(` deno task dev`);
407
- console.log(`\n ${color(COLORS.bold, "Useful commands")}`);
408
- console.log(` deno task typecheck`);
409
- console.log(` deno task test`);
410
- console.log(` deno task gen:openapi`);
858
+ summaryLines.push(`${color(COLORS.gray, "Runtime ")} ${color(COLORS.cyan, template === "deno-basic" ? "Deno" : "runtime")}`);
411
859
  } else {
412
- console.log(` ${color(COLORS.bold, "Manager")} ${packageManager}`);
413
- console.log(`\n ${color(COLORS.bold, "Next steps")}`);
414
- console.log(` cd ${projectName}`);
415
- if (!installDeps) console.log(` ${packageManager} install`);
416
- console.log(` ${packageManager} run dev`);
417
- console.log(`\n ${color(COLORS.bold, "Useful commands")}`);
418
- console.log(` ${packageManager} run typecheck`);
419
- console.log(` ${packageManager} test`);
420
- if (template === "node-basic" || template === "bun-basic") console.log(` ${packageManager} run gen`);
860
+ summaryLines.push(`${color(COLORS.gray, "Manager ")} ${color(COLORS.cyan, packageManager)}`);
421
861
  }
422
- console.log(`\n ${color(COLORS.dim, "Docs: https://daloyjs.dev/docs")}`);
423
- console.log(color(COLORS.dim, " Issues: https://github.com/daloyjs/daloy/issues\n"));
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")}`);
878
+ if (skipPackageManager) {
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")}`);
882
+ } else {
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
+ }
888
+ }
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`);
424
895
  }
425
896
 
426
897
  async function main() {
@@ -439,14 +910,16 @@ async function main() {
439
910
  process.exit(0);
440
911
  }
441
912
 
442
- console.log(color(COLORS.bold, "\ncreate-daloy"));
443
- console.log(color(COLORS.dim, "Contract-first REST APIs for Node, Vercel Edge, and Workers"));
444
- console.log(color(COLORS.dim, "https://daloyjs.dev\n"));
913
+ printBanner(await readPkgVersion());
445
914
 
446
915
  const detectedPm = detectPackageManager();
447
916
  const interactive = !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
448
917
  const rl = interactive ? createInterface({ input, output }) : null;
449
918
 
919
+ if (interactive) {
920
+ printIntro("Let's set up your DaloyJS project");
921
+ }
922
+
450
923
  try {
451
924
  let projectName = opts.projectName;
452
925
  if (!projectName) {
@@ -458,7 +931,7 @@ async function main() {
458
931
  projectName = candidate;
459
932
  break;
460
933
  }
461
- console.error(color(COLORS.red, ` ${valid}`));
934
+ logError(valid);
462
935
  }
463
936
  } else {
464
937
  projectName = "my-daloy-app";
@@ -466,7 +939,7 @@ async function main() {
466
939
  }
467
940
  const nameCheck = validateProjectName(projectName);
468
941
  if (nameCheck !== true) {
469
- console.error(color(COLORS.red, nameCheck));
942
+ logError(nameCheck);
470
943
  process.exit(1);
471
944
  }
472
945
 
@@ -475,13 +948,13 @@ async function main() {
475
948
  template = rl ? await askChoice(rl, "Choose a starter template:", TEMPLATE_OPTIONS, "node-basic") : "node-basic";
476
949
  }
477
950
  if (!TEMPLATES.includes(template)) {
478
- console.error(color(COLORS.red, `Unknown template "${template}". Available: ${TEMPLATES.join(", ")}`));
951
+ logError(`Unknown template "${template}". Available: ${TEMPLATES.join(", ")}`);
479
952
  process.exit(1);
480
953
  }
481
954
 
482
955
  const templateDir = path.join(TEMPLATES_DIR, template);
483
956
  if (!existsSync(templateDir)) {
484
- console.error(color(COLORS.red, `Template "${template}" is missing from this CLI build.`));
957
+ logError(`Template "${template}" is missing from this CLI build.`);
485
958
  process.exit(1);
486
959
  }
487
960
 
@@ -489,12 +962,7 @@ async function main() {
489
962
  if (existsSync(targetDir)) {
490
963
  const empty = await isDirEmpty(targetDir);
491
964
  if (!empty && !opts.force) {
492
- console.error(
493
- color(
494
- COLORS.red,
495
- `Directory ${projectName} is not empty. Re-run with --force to overwrite.`,
496
- ),
497
- );
965
+ logError(`Directory ${projectName} is not empty. Re-run with --force to overwrite.`);
498
966
  process.exit(1);
499
967
  }
500
968
  }
@@ -511,9 +979,7 @@ async function main() {
511
979
  }
512
980
  }
513
981
  if (!PACKAGE_MANAGERS.includes(packageManager)) {
514
- console.error(
515
- color(COLORS.red, `Unknown --package-manager "${packageManager}". Use one of: ${PACKAGE_MANAGERS.join(", ")}`),
516
- );
982
+ logError(`Unknown --package-manager "${packageManager}". Use one of: ${PACKAGE_MANAGERS.join(", ")}`);
517
983
  process.exit(1);
518
984
  }
519
985
 
@@ -533,7 +999,12 @@ async function main() {
533
999
 
534
1000
  rl?.close();
535
1001
 
536
- 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("");
537
1008
 
538
1009
  await mkdir(targetDir, { recursive: true });
539
1010
  await copyTemplate(templateDir, targetDir);
@@ -545,7 +1016,7 @@ async function main() {
545
1016
  if (!skipPackageManager) {
546
1017
  await patchPackageJson(targetDir, projectName, packageManager);
547
1018
  logStep("Package metadata written", projectName);
548
- await patchReadme(targetDir, packageManager);
1019
+ await patchTemplateTextFiles(targetDir, packageManager);
549
1020
  await normalizePackageManagerFiles(targetDir, packageManager);
550
1021
  if (packageManager !== "pnpm") {
551
1022
  logStep("Package-manager config normalized", packageManager);
@@ -562,19 +1033,31 @@ async function main() {
562
1033
  }
563
1034
 
564
1035
  if (installDeps) {
565
- console.log(color(COLORS.dim, ` Installing dependencies with ${packageManager}...`));
566
- 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);
567
1039
  if (code !== 0) {
568
- 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`);
569
1047
  } else {
570
- logStep("Dependencies installed", packageManager);
1048
+ spinner.stop(`Installed dependencies with ${color(COLORS.cyan, packageManager)}`);
571
1049
  }
572
1050
  }
573
1051
 
574
1052
  printSummary({ projectName, template, packageManager, installDeps, skipPackageManager });
575
1053
  } catch (err) {
576
1054
  rl?.close();
577
- 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}`);
578
1061
  process.exit(1);
579
1062
  }
580
1063
  }