create-zerotal 1.7.0 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - **A non-interactive mode, so the scaffolder can be scripted.** Every prompt now has a flag —
14
+ `--template`, `--db`, `--name` — plus `--yes` to take the defaults for anything unset,
15
+ `--no-install`, `--help` and `--version`. `bun create zerotal my-app` is unchanged.
16
+
17
+ ### Fixed
18
+
19
+ - **With no TTY the scaffolder hung instead of failing.** It read `process.argv[2]` as the project
20
+ name and asked for everything else through `readline`, which waits for a line that never arrives
21
+ in CI, in a pipeline, or under an agent. So it did not error and did not exit — it held the job
22
+ open until something timed it out. The workaround in this repo was to bypass the CLI entirely and
23
+ call `scaffold()` directly, which is the shape of an admission.
24
+
25
+ It now detects the absent terminal and refuses, naming the flag that would have answered the
26
+ question, with a non-zero exit code. Unknown flags are refused too rather than dropped: silently
27
+ ignoring `--tempalte=api` means asking again for something the caller thought they had given,
28
+ which in CI is the hang all over again. A failed `bun install` also exits non-zero when there is
29
+ no terminal — a half-built project reported as a success is worse than one that stops.
30
+
31
+
11
32
  ## [1.6.3] — 2026-08-15
12
33
 
13
34
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zerotal",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Create a new Zerotal application",
5
5
  "license": "MIT",
6
6
  "maturity": "stable",
package/src/args.ts ADDED
@@ -0,0 +1,192 @@
1
+ import type { Database, Template } from './scaffold.ts';
2
+
3
+ export const TEMPLATES: readonly Template[] = ['api', 'admin', 'flow', 'react', 'vue', 'minimal'];
4
+ export const DATABASES: readonly Database[] = ['sqlite', 'postgres', 'mysql'];
5
+
6
+ /** Defaults, matching the first option of each prompt. */
7
+ export const DEFAULT_TEMPLATE: Template = 'api';
8
+ export const DEFAULT_DATABASE: Database = 'sqlite';
9
+
10
+ export interface ParsedArgs {
11
+ name?: string;
12
+ template?: string;
13
+ db?: string;
14
+ /** Take defaults for anything not supplied, instead of asking. */
15
+ yes: boolean;
16
+ install: boolean;
17
+ help: boolean;
18
+ version: boolean;
19
+ /** Flags that are not ours — reported rather than ignored. */
20
+ unknown: string[];
21
+ }
22
+
23
+ /**
24
+ * Parse the command line.
25
+ *
26
+ * Supports `--flag value` and `--flag=value`, plus `-t`/`-y`/`-h` shorthands, so
27
+ * neither habit is wrong. The first non-flag argument is the project name, which
28
+ * keeps `bun create zerotal my-app` working exactly as before.
29
+ *
30
+ * Unknown flags are collected rather than dropped. Silently ignoring
31
+ * `--tempalte=api` and then interactively asking for the template is the worst
32
+ * of both worlds: in CI it hangs, and a human is asked a question they thought
33
+ * they had answered.
34
+ */
35
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
36
+ const out: ParsedArgs = { yes: false, install: true, help: false, version: false, unknown: [] };
37
+ const takesValue = new Set(['name', 'template', 'db', 't']);
38
+
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const arg = argv[i]!;
41
+
42
+ if (!arg.startsWith('-')) {
43
+ out.name ??= arg;
44
+ continue;
45
+ }
46
+
47
+ const [rawKey, inlineValue] = arg.replace(/^--?/, '').split(/=(.*)/s, 2) as [string, string?];
48
+ const key = rawKey === 't' ? 'template' : rawKey === 'y' ? 'yes' : rawKey === 'h' ? 'help' : rawKey;
49
+
50
+ // `--no-install` — the standard spelling for negating a boolean flag.
51
+ if (key === 'no-install') {
52
+ out.install = false;
53
+ continue;
54
+ }
55
+ if (key === 'yes') {
56
+ out.yes = true;
57
+ continue;
58
+ }
59
+ if (key === 'help') {
60
+ out.help = true;
61
+ continue;
62
+ }
63
+ if (key === 'version' || key === 'v') {
64
+ out.version = true;
65
+ continue;
66
+ }
67
+
68
+ if (takesValue.has(key === 'template' ? 'template' : key)) {
69
+ // `--template api` as well as `--template=api`; a following flag is not a
70
+ // value, so `--template --yes` is a missing value rather than the string
71
+ // "--yes".
72
+ const next = argv[i + 1];
73
+ const value = inlineValue ?? (next && !next.startsWith('-') ? (i++, next) : undefined);
74
+ // Assigned only when present: `exactOptionalPropertyTypes` treats an
75
+ // explicit `undefined` as different from an absent key, and "absent" is
76
+ // exactly what a flag with no value means.
77
+ if (value !== undefined) {
78
+ if (key === 'name') out.name = value;
79
+ else if (key === 'template') out.template = value;
80
+ else if (key === 'db') out.db = value;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ out.unknown.push(arg);
86
+ }
87
+
88
+ return out;
89
+ }
90
+
91
+ export interface Resolution {
92
+ ok: boolean;
93
+ /** Populated when `ok` — everything the scaffolder needs. */
94
+ name?: string | undefined;
95
+ template?: Template | undefined;
96
+ db?: Database | undefined;
97
+ install?: boolean | undefined;
98
+ /** Questions still to ask. Empty in non-interactive mode. */
99
+ askFor?: Array<'name' | 'template' | 'db'>;
100
+ /** Populated when `!ok` — a message a human or a CI log can act on. */
101
+ error?: string;
102
+ }
103
+
104
+ /**
105
+ * Turn parsed flags into either a plan or a refusal.
106
+ *
107
+ * The rule that matters: **never wait on stdin that is not there.** Without a
108
+ * TTY the prompts' `readline` never receives a line, so the old scaffolder did
109
+ * not fail in CI — it hung, forever, holding the job open until the runner timed
110
+ * it out. A missing answer is now an error with the flag that would have
111
+ * supplied it.
112
+ *
113
+ * `--yes` takes the defaults for whatever is unspecified, which is what makes
114
+ * the tool scriptable in one word.
115
+ */
116
+ export function resolveOptions(args: ParsedArgs, interactive: boolean): Resolution {
117
+ if (args.unknown.length) {
118
+ return { ok: false, error: `Unknown option${args.unknown.length > 1 ? 's' : ''}: ${args.unknown.join(', ')}` };
119
+ }
120
+
121
+ if (args.template !== undefined && !TEMPLATES.includes(args.template as Template)) {
122
+ return { ok: false, error: `Unknown template "${args.template}". Choose one of: ${TEMPLATES.join(', ')}` };
123
+ }
124
+ if (args.db !== undefined && !DATABASES.includes(args.db as Database)) {
125
+ return { ok: false, error: `Unknown database "${args.db}". Choose one of: ${DATABASES.join(', ')}` };
126
+ }
127
+
128
+ const template = (args.template as Template | undefined);
129
+ const db = (args.db as Database | undefined);
130
+
131
+ if (interactive && !args.yes) {
132
+ const askFor: Array<'name' | 'template' | 'db'> = [];
133
+ if (!args.name) askFor.push('name');
134
+ if (!template) askFor.push('template');
135
+ // Only the API template offers a database choice; the rest have no DB config.
136
+ if (!db && (template === undefined || template === 'api')) askFor.push('db');
137
+ return { ok: true, name: args.name, template, db, install: args.install, askFor };
138
+ }
139
+
140
+ // Non-interactive from here: everything must already be known, or defaulted.
141
+ if (!args.name) {
142
+ return {
143
+ ok: false,
144
+ error:
145
+ 'A project name is required when there is no terminal to ask on.\n' +
146
+ ' bunx create-zerotal my-app --template=api --yes',
147
+ };
148
+ }
149
+ if (!template && !args.yes) {
150
+ return {
151
+ ok: false,
152
+ error:
153
+ 'No template given, and no terminal to ask on.\n' +
154
+ ` Pass --template=<${TEMPLATES.join('|')}>, or --yes to take the default (${DEFAULT_TEMPLATE}).`,
155
+ };
156
+ }
157
+
158
+ const chosen = template ?? DEFAULT_TEMPLATE;
159
+ return {
160
+ ok: true,
161
+ name: args.name,
162
+ template: chosen,
163
+ db: chosen === 'api' ? (db ?? DEFAULT_DATABASE) : DEFAULT_DATABASE,
164
+ install: args.install,
165
+ askFor: [],
166
+ };
167
+ }
168
+
169
+ /** The `--help` text. */
170
+ export function helpText(): string {
171
+ return [
172
+ 'create-zerotal — scaffold a Zerotal application',
173
+ '',
174
+ 'Usage',
175
+ ' bun create zerotal <name> [options]',
176
+ ' bunx create-zerotal <name> [options]',
177
+ '',
178
+ 'Options',
179
+ ` -t, --template <name> ${TEMPLATES.join(' | ')}`,
180
+ ` --db <name> ${DATABASES.join(' | ')} (api template only)`,
181
+ ' -y, --yes Take defaults for anything not given; never prompt',
182
+ ' --no-install Skip bun install',
183
+ ' -h, --help Show this message',
184
+ ' -v, --version Print the scaffolder version',
185
+ '',
186
+ 'Non-interactive',
187
+ ' With no TTY (CI, an agent, a pipe) the scaffolder never prompts. Supply the',
188
+ ' answers as flags, or pass --yes to take the defaults.',
189
+ '',
190
+ ' bunx create-zerotal my-app --template=api --db=postgres --yes',
191
+ ].join('\n');
192
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { resolve } from 'node:path';
11
11
  import { printBanner, ask, choose, log, info, warn, step, dim, c } from './prompts.ts';
12
12
  import { scaffold, install, type Template, type Database } from './scaffold.ts';
13
+ import { parseArgs, resolveOptions, helpText, TEMPLATES, DATABASES } from './args.ts';
13
14
  import { newerScaffolderVersion } from './staleness.ts';
14
15
 
15
16
  /** This scaffolder's own version, read from the manifest that ships beside it. */
@@ -18,6 +19,28 @@ const ZT_SELF_VERSION: string = (
18
19
  ).version;
19
20
 
20
21
  async function main(): Promise<void> {
22
+ const args = parseArgs(process.argv.slice(2));
23
+
24
+ if (args.help) {
25
+ process.stdout.write(helpText() + '\n');
26
+ return;
27
+ }
28
+ if (args.version) {
29
+ process.stdout.write(ZT_SELF_VERSION + '\n');
30
+ return;
31
+ }
32
+
33
+ // Whether there is anybody to answer a question. Without this the prompts'
34
+ // readline waits on a line that never comes, so a scaffolder run in CI or by
35
+ // an agent did not fail — it hung until something killed it.
36
+ const interactive = Boolean(process.stdin.isTTY);
37
+ const plan = resolveOptions(args, interactive);
38
+ if (!plan.ok) {
39
+ process.stderr.write(`\x1b[31mError: ${plan.error}\x1b[0m\n\n${helpText()}\n`);
40
+ process.exit(1);
41
+ }
42
+ const asking = new Set(plan.askFor ?? []);
43
+
21
44
  // Started before the banner and awaited after the prompts, so the check costs
22
45
  // no perceived time at all — the answer arrives while a human is reading.
23
46
  const newerScaffolder = newerScaffolderVersion(ZT_SELF_VERSION);
@@ -25,9 +48,9 @@ async function main(): Promise<void> {
25
48
  printBanner();
26
49
 
27
50
  // ── Project name ────────────────────────────────────────────────────────────
28
- const nameArg = process.argv[2]?.trim() ?? '';
29
- const name = nameArg
30
- || await ask('Project name', 'my-zerotal-app');
51
+ const name = asking.has('name')
52
+ ? await ask('Project name', 'my-zerotal-app')
53
+ : plan.name!;
31
54
 
32
55
  const target = resolve(process.cwd(), name);
33
56
 
@@ -40,7 +63,9 @@ async function main(): Promise<void> {
40
63
 
41
64
  // ── Template ────────────────────────────────────────────────────────────────
42
65
  log('');
43
- const template = await choose<Template>('Template', [
66
+ const template: Template = !asking.has('template')
67
+ ? plan.template!
68
+ : await choose<Template>('Template', [
44
69
  {
45
70
  value: 'api',
46
71
  label: 'API',
@@ -75,8 +100,8 @@ async function main(): Promise<void> {
75
100
 
76
101
  // ── Database ─────────────────────────────────────────────────────────────────
77
102
  // Only the API template ships a database config; minimal/flow have no DB.
78
- let db: Database = 'sqlite';
79
- if (template === 'api') {
103
+ let db: Database = plan.db ?? 'sqlite';
104
+ if (template === 'api' && asking.has('db')) {
80
105
  log('');
81
106
  db = await choose<Database>('Database', [
82
107
  {
@@ -118,13 +143,19 @@ async function main(): Promise<void> {
118
143
  info(`Project files created`);
119
144
 
120
145
  // ── Install ─────────────────────────────────────────────────────────────────
121
- log('');
122
- step('Installing dependencies…');
123
- const ok = await install(target);
124
- if (ok) {
125
- info(`Dependencies installed`);
126
- } else {
127
- warn(`bun install failed — run it manually inside the project`);
146
+ if (plan.install !== false) {
147
+ log('');
148
+ step('Installing dependencies…');
149
+ const ok = await install(target);
150
+ if (ok) {
151
+ info(`Dependencies installed`);
152
+ } else {
153
+ warn(`bun install failed — run it manually inside the project`);
154
+ // A failed install is a broken project, and a script that carried on
155
+ // regardless would report success for one. A human has the message above
156
+ // and the directory in front of them; CI needs the exit code.
157
+ if (!interactive) process.exit(1);
158
+ }
128
159
  }
129
160
 
130
161
  // ── Done ─────────────────────────────────────────────────────────────────────
package/src/scaffold.ts CHANGED
@@ -13,7 +13,7 @@ export type Template = 'minimal' | 'api' | 'admin' | 'flow' | 'react' | 'vue';
13
13
  // "^1.1.0" found for specifier "zerotal"` — the first thing anyone trying the
14
14
  // framework saw. `scaffold.test.ts` now asserts the two agree, so CI fails rather
15
15
  // than the user's install.
16
- export const ZT_VERSION = "^1.7.0";
16
+ export const ZT_VERSION = "^1.7.2";
17
17
 
18
18
  export interface ScaffoldOptions {
19
19
  name: string;