create-daloy 0.1.17 → 0.1.19

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