create-basalt 1.1.1 → 1.2.1

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
@@ -93,13 +93,16 @@ Behavior notes (faithful to the code):
93
93
 
94
94
  - **Package manager detection**: by default, detects who invoked the command via the `npm_config_user_agent` variable (set by npm/pnpm/yarn/bun); unknown managers fall back to `npm`. `--pm=` overrides this.
95
95
  - **`--ui` forces pnpm**: the `web/` frontend is a member of a pnpm *workspace* (declared in the generated `pnpm-workspace.yaml`). npm, yarn, and bun can't install or run that structure, so if you request `--ui` with another manager, you'll see `Note: --ui projects are pnpm workspaces — using pnpm instead of <manager>.` and pnpm is used.
96
- - **Interactive mode**: only happens when you don't pass a name, you're in a terminal (TTY), and you didn't use `--yes`. Ctrl+C during a question ends cleanly with "Cancelled." (exit code 130).
96
+ - **Interactive wizard**: when you don't pass a name, you're in a terminal (TTY), and you didn't use `--yes`, you get the guided wizard — an intro, a **starting-point preset** (SaaS starter / API only / Full stack / Minimal / Custom), an arrow-key **feature multiselect** on the custom path, package-manager select, and a **summary + confirm** step before anything is written. Ctrl+C (or declining the final confirm) ends cleanly with "Cancelled." (exit code 130).
97
97
  - **Occupied folder**: if the destination folder exists and isn't empty, the command refuses with `Target directory "<dir>" already exists and is not empty.` and exits with code 1.
98
98
  - At the end, it prints the created files and the "Next steps" appropriate to your choices.
99
99
 
100
100
  ### Invocation examples
101
101
 
102
102
  ```bash
103
+ # Interactive wizard (presets, feature multiselect, summary):
104
+ pnpm create basalt
105
+
103
106
  # Full project, no questions, with everything:
104
107
  pnpm create basalt my-app --billing --ui --cli --install --git
105
108
 
@@ -249,6 +249,7 @@ import { buildApp } from '../src/app.js'
249
249
  // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
250
250
  // ONLY here \u2014 the runtime server (src/server.ts) never loads them, so the SaaS
251
251
  // runs without the codegen/AI layer.
252
+ // pnpm basalt dev \u2014 dev server: route table + watch (--worker for a queue worker)
252
253
  // pnpm basalt list \u2014 show available commands
253
254
  // pnpm basalt routes \u2014 list registered HTTP routes
254
255
  // pnpm basalt make:resource Project \u2014 generate a full resource vertical
@@ -1045,6 +1046,241 @@ function MfaCard({ status, api, onChange }: { status: { enabled: boolean; pendin
1045
1046
  `;
1046
1047
  }
1047
1048
 
1049
+ // src/prompt.ts
1050
+ import { emitKeypressEvents } from "readline";
1051
+ import { createInterface } from "readline/promises";
1052
+ import { stdin, stdout } from "process";
1053
+ var WizardCancelledError = class extends Error {
1054
+ constructor() {
1055
+ super("Cancelled.");
1056
+ this.name = "WizardCancelledError";
1057
+ }
1058
+ };
1059
+ var c = {
1060
+ dim: (s) => `\x1B[2m${s}\x1B[0m`,
1061
+ cyan: (s) => `\x1B[36m${s}\x1B[0m`,
1062
+ green: (s) => `\x1B[32m${s}\x1B[0m`,
1063
+ bold: (s) => `\x1B[1m${s}\x1B[0m`
1064
+ };
1065
+ function ttyPrompter() {
1066
+ return {
1067
+ intro: (message) => stdout.write(`
1068
+ ${c.bold(c.cyan("\u25C6 " + message))}
1069
+
1070
+ `),
1071
+ note: (message) => stdout.write(`${message}
1072
+ `),
1073
+ outro: (message) => stdout.write(`
1074
+ ${c.green("\u2713 " + message)}
1075
+ `),
1076
+ async text({ message, placeholder, initial, validate }) {
1077
+ const rl = createInterface({ input: stdin, output: stdout });
1078
+ rl.on("SIGINT", () => {
1079
+ rl.close();
1080
+ throw new WizardCancelledError();
1081
+ });
1082
+ try {
1083
+ for (; ; ) {
1084
+ const hint = placeholder ? c.dim(` (${placeholder})`) : "";
1085
+ const answer = (await rl.question(`${c.cyan("?")} ${message}${hint}: `)).trim() || initial || "";
1086
+ const error = validate?.(answer);
1087
+ if (!error) return answer;
1088
+ stdout.write(` ${c.dim(error)}
1089
+ `);
1090
+ }
1091
+ } catch (error) {
1092
+ if (error.code === "ABORT_ERR") throw new WizardCancelledError();
1093
+ throw error;
1094
+ } finally {
1095
+ rl.close();
1096
+ }
1097
+ },
1098
+ select({ message, choices, initial = 0 }) {
1099
+ return keyboardSelect(message, choices, initial, false).then((v) => v[0]);
1100
+ },
1101
+ multiselect({ message, choices, initial = [] }) {
1102
+ const preselected = choices.map((choice) => initial.includes(choice.value));
1103
+ return keyboardSelect(message, choices, 0, true, preselected);
1104
+ },
1105
+ async confirm({ message, initial = false }) {
1106
+ const rl = createInterface({ input: stdin, output: stdout });
1107
+ try {
1108
+ const answer = (await rl.question(`${c.cyan("?")} ${message} ${c.dim(initial ? "(Y/n)" : "(y/N)")} `)).trim().toLowerCase();
1109
+ return answer ? answer.startsWith("y") : initial;
1110
+ } finally {
1111
+ rl.close();
1112
+ }
1113
+ }
1114
+ };
1115
+ }
1116
+ function keyboardSelect(message, choices, initialIndex, multi, selected = choices.map(() => false)) {
1117
+ return new Promise((resolve2, reject) => {
1118
+ let index = initialIndex;
1119
+ let rendered = 0;
1120
+ emitKeypressEvents(stdin);
1121
+ const wasRaw = stdin.isTTY ? stdin.isRaw : false;
1122
+ if (stdin.isTTY) stdin.setRawMode(true);
1123
+ const render = () => {
1124
+ if (rendered > 0) stdout.write(`\x1B[${rendered}A\x1B[0J`);
1125
+ const lines = [`${c.cyan("?")} ${message}${multi ? c.dim(" (space to toggle, enter to confirm)") : ""}`];
1126
+ choices.forEach((choice, i) => {
1127
+ const pointer = i === index ? c.cyan("\u276F") : " ";
1128
+ const box = multi ? (selected[i] ? c.green("\u25C9") : "\u25EF") + " " : "";
1129
+ const label = i === index ? c.cyan(choice.label) : choice.label;
1130
+ const hint = choice.hint ? c.dim(` \u2014 ${choice.hint}`) : "";
1131
+ lines.push(`${pointer} ${box}${label}${hint}`);
1132
+ });
1133
+ stdout.write(lines.join("\n") + "\n");
1134
+ rendered = lines.length;
1135
+ };
1136
+ const cleanup = () => {
1137
+ stdin.off("keypress", onKey);
1138
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
1139
+ stdin.pause();
1140
+ };
1141
+ const onKey = (_str, key) => {
1142
+ if (key.ctrl && key.name === "c") {
1143
+ cleanup();
1144
+ reject(new WizardCancelledError());
1145
+ } else if (key.name === "up" || key.name === "k") {
1146
+ index = (index - 1 + choices.length) % choices.length;
1147
+ render();
1148
+ } else if (key.name === "down" || key.name === "j") {
1149
+ index = (index + 1) % choices.length;
1150
+ render();
1151
+ } else if (multi && key.name === "space") {
1152
+ selected[index] = !selected[index];
1153
+ render();
1154
+ } else if (key.name === "return") {
1155
+ cleanup();
1156
+ resolve2(
1157
+ multi ? choices.filter((_, i) => selected[i]).map((choice) => choice.value) : [choices[index].value]
1158
+ );
1159
+ }
1160
+ };
1161
+ stdin.resume();
1162
+ stdin.on("keypress", onKey);
1163
+ render();
1164
+ });
1165
+ }
1166
+ function scriptedPrompter(script) {
1167
+ const text = [...script.text ?? []];
1168
+ const select = [...script.select ?? []];
1169
+ const multiselect = [...script.multiselect ?? []];
1170
+ const confirm = [...script.confirm ?? []];
1171
+ const log = [];
1172
+ const next = (queue, kind) => {
1173
+ if (queue.length === 0) throw new Error(`scriptedPrompter: no more ${kind} answers`);
1174
+ return queue.shift();
1175
+ };
1176
+ return {
1177
+ log,
1178
+ intro: (m) => log.push(m),
1179
+ note: (m) => log.push(m),
1180
+ outro: (m) => log.push(m),
1181
+ async text() {
1182
+ return next(text, "text");
1183
+ },
1184
+ async select() {
1185
+ return next(select, "select");
1186
+ },
1187
+ async multiselect() {
1188
+ return next(multiselect, "multiselect");
1189
+ },
1190
+ async confirm() {
1191
+ return next(confirm, "confirm");
1192
+ }
1193
+ };
1194
+ }
1195
+
1196
+ // src/wizard.ts
1197
+ var FEATURES = [
1198
+ { value: "tenancy", label: "Multi-tenancy", hint: "per-tenant data isolation" },
1199
+ { value: "auth", label: "Authentication", hint: "JWT, sessions, MFA, OAuth" },
1200
+ { value: "billing", label: "Subscriptions / billing", hint: "Stripe, Paddle, Lemon Squeezy" },
1201
+ { value: "ui", label: "Web UI", hint: "React + shadcn + SDK (pnpm workspace)" },
1202
+ { value: "cli", label: "basalt CLI", hint: "code generators + commands" }
1203
+ ];
1204
+ var PRESETS = [
1205
+ { value: "saas", label: "SaaS starter", hint: "tenancy + auth + billing + CLI", features: ["tenancy", "auth", "billing", "cli"] },
1206
+ { value: "api", label: "API only", hint: "auth, no tenancy or UI", features: ["auth"] },
1207
+ { value: "full", label: "Full stack", hint: "everything + web UI", features: ["tenancy", "auth", "billing", "ui", "cli"] },
1208
+ { value: "minimal", label: "Minimal", hint: "no batteries, add them later", features: [] },
1209
+ { value: "custom", label: "Custom", hint: "pick features yourself", features: null }
1210
+ ];
1211
+ var PACKAGE_MANAGERS = [
1212
+ { value: "pnpm", label: "pnpm", hint: "recommended" },
1213
+ { value: "npm", label: "npm" },
1214
+ { value: "yarn", label: "yarn" },
1215
+ { value: "bun", label: "bun" }
1216
+ ];
1217
+ function validateProjectName(name) {
1218
+ const trimmed = name.trim();
1219
+ if (!trimmed) return "Please enter a project name.";
1220
+ if (trimmed.length > 214) return "Name is too long (max 214 characters).";
1221
+ if (!/^(?:@[a-z0-9-~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(trimmed)) {
1222
+ return "Use a valid npm package name (lowercase, no spaces).";
1223
+ }
1224
+ return void 0;
1225
+ }
1226
+ async function runWizard(prompter, options = {}) {
1227
+ prompter.intro("create-basalt \u2014 scaffold a Basalt application");
1228
+ const name = options.name && !validateProjectName(options.name) ? options.name : await prompter.text({
1229
+ message: "Project name",
1230
+ placeholder: "my-saas",
1231
+ initial: "my-saas",
1232
+ validate: validateProjectName
1233
+ });
1234
+ const presetValue = await prompter.select({
1235
+ message: "Choose a starting point",
1236
+ choices: PRESETS.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
1237
+ initial: 0
1238
+ });
1239
+ const preset = PRESETS.find((p) => p.value === presetValue) ?? PRESETS[0];
1240
+ const featureSet = new Set(
1241
+ preset.features ?? await prompter.multiselect({
1242
+ message: "Select features",
1243
+ choices: FEATURES,
1244
+ initial: ["tenancy", "auth"]
1245
+ })
1246
+ );
1247
+ let pm = await prompter.select({
1248
+ message: "Package manager",
1249
+ choices: PACKAGE_MANAGERS,
1250
+ initial: Math.max(0, PACKAGE_MANAGERS.findIndex((m) => m.value === (options.defaultPm ?? "pnpm")))
1251
+ });
1252
+ if (featureSet.has("ui") && pm !== "pnpm") {
1253
+ prompter.note(` Note: the Web UI is a pnpm workspace \u2014 using pnpm instead of ${pm}.`);
1254
+ pm = "pnpm";
1255
+ }
1256
+ const install = await prompter.confirm({ message: "Install dependencies now?", initial: false });
1257
+ const git = await prompter.confirm({ message: "Initialize a git repository?", initial: false });
1258
+ const enabled = FEATURES.filter((f) => featureSet.has(f.value)).map((f) => f.label);
1259
+ prompter.note(
1260
+ `
1261
+ Summary
1262
+ name ${name}
1263
+ features ${enabled.length ? enabled.join(", ") : "(none)"}
1264
+ pm ${pm}
1265
+ install ${install ? "yes" : "no"}
1266
+ git ${git ? "yes" : "no"}
1267
+ `
1268
+ );
1269
+ const ok = await prompter.confirm({ message: "Create project?", initial: true });
1270
+ if (!ok) throw new WizardCancelledError();
1271
+ return {
1272
+ name,
1273
+ tenancy: featureSet.has("tenancy"),
1274
+ auth: featureSet.has("auth"),
1275
+ billing: featureSet.has("billing"),
1276
+ ui: featureSet.has("ui"),
1277
+ cli: featureSet.has("cli"),
1278
+ pm,
1279
+ install,
1280
+ git
1281
+ };
1282
+ }
1283
+
1048
1284
  // src/index.ts
1049
1285
  var TargetNotEmptyError = class extends Error {
1050
1286
  constructor(dir) {
@@ -1093,6 +1329,13 @@ async function createProject(input) {
1093
1329
  }
1094
1330
 
1095
1331
  export {
1332
+ WizardCancelledError,
1333
+ ttyPrompter,
1334
+ scriptedPrompter,
1335
+ FEATURES,
1336
+ PRESETS,
1337
+ validateProjectName,
1338
+ runWizard,
1096
1339
  TargetNotEmptyError,
1097
1340
  detectPackageManager,
1098
1341
  createProject
package/dist/cli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  TargetNotEmptyError,
4
+ WizardCancelledError,
4
5
  createProject,
5
- detectPackageManager
6
- } from "./chunk-TYOPE3RU.js";
6
+ detectPackageManager,
7
+ runWizard,
8
+ ttyPrompter
9
+ } from "./chunk-Z6RTS4I7.js";
7
10
 
8
11
  // src/cli.ts
9
12
  import { spawn } from "child_process";
10
13
  import { stdin, stdout } from "process";
11
- import { createInterface } from "readline/promises";
12
14
  var USAGE = `Usage: npm create basalt <name> [options]
13
15
 
14
16
  Options:
@@ -70,36 +72,26 @@ function cancel() {
70
72
  stdout.write("\nCancelled.\n");
71
73
  process.exit(130);
72
74
  }
73
- async function promptMissing(flags2) {
74
- if (flags2.yes || flags2.name !== void 0 || !stdin.isTTY) return flags2;
75
- const rl = createInterface({ input: stdin, output: stdout });
76
- rl.on("SIGINT", cancel);
75
+ var flags = parseArgs(process.argv.slice(2));
76
+ if (!flags.yes && flags.name === void 0 && stdin.isTTY) {
77
77
  try {
78
- const ask = async (question, fallback) => {
79
- const answer = (await rl.question(`${question} `)).trim();
80
- return answer || fallback;
81
- };
82
- const confirm = async (question, fallback) => {
83
- const answer = (await rl.question(`${question} (${fallback ? "Y/n" : "y/N"}) `)).trim().toLowerCase();
84
- return answer ? answer.startsWith("y") : fallback;
85
- };
86
- flags2.name = await ask("Project name:", "my-saas");
87
- flags2.tenancy = await confirm("Multi-tenancy?", true);
88
- flags2.auth = await confirm("Authentication?", true);
89
- flags2.billing = await confirm("Subscriptions / billing?", false);
90
- flags2.ui = await confirm("Web UI (React + shadcn)?", false);
91
- flags2.cli = await confirm("'basalt' CLI (code generators)?", false);
92
- flags2.install = await confirm("Install dependencies now?", false);
93
- flags2.git = await confirm("Initialize a git repository?", false);
78
+ const result = await runWizard(ttyPrompter(), {
79
+ defaultPm: flags.pm ?? detectPackageManager()
80
+ });
81
+ flags.name = result.name;
82
+ flags.tenancy = result.tenancy;
83
+ flags.auth = result.auth;
84
+ flags.billing = result.billing;
85
+ flags.ui = result.ui;
86
+ flags.cli = result.cli;
87
+ flags.install = result.install;
88
+ flags.git = result.git;
89
+ flags.pm = result.pm;
94
90
  } catch (error) {
95
- if (error.code === "ABORT_ERR") cancel();
91
+ if (error instanceof WizardCancelledError) cancel();
96
92
  throw error;
97
- } finally {
98
- rl.close();
99
93
  }
100
- return flags2;
101
94
  }
102
- var flags = await promptMissing(parseArgs(process.argv.slice(2)));
103
95
  if (!flags.name) {
104
96
  stdout.write(USAGE);
105
97
  process.exit(1);
package/dist/index.d.ts CHANGED
@@ -9,6 +9,101 @@ interface ProjectOptions {
9
9
  cli: boolean;
10
10
  }
11
11
 
12
+ /** A selectable option. `value` is returned; `label`/`hint` are shown. */
13
+ interface Choice<T> {
14
+ value: T;
15
+ label: string;
16
+ hint?: string;
17
+ }
18
+ /**
19
+ * The prompt surface the wizard talks to. Abstracted so the flow (`runWizard`)
20
+ * is testable with a scripted implementation — the raw-mode TTY version is the
21
+ * thin, untested I/O shell.
22
+ */
23
+ interface Prompter {
24
+ intro(message: string): void;
25
+ note(message: string): void;
26
+ outro(message: string): void;
27
+ text(opts: {
28
+ message: string;
29
+ placeholder?: string;
30
+ initial?: string;
31
+ validate?: (v: string) => string | undefined;
32
+ }): Promise<string>;
33
+ select<T>(opts: {
34
+ message: string;
35
+ choices: Choice<T>[];
36
+ initial?: number;
37
+ }): Promise<T>;
38
+ multiselect<T>(opts: {
39
+ message: string;
40
+ choices: Choice<T>[];
41
+ initial?: T[];
42
+ }): Promise<T[]>;
43
+ confirm(opts: {
44
+ message: string;
45
+ initial?: boolean;
46
+ }): Promise<boolean>;
47
+ }
48
+ /** Thrown when the user aborts (Ctrl+C, or declines the final confirmation). */
49
+ declare class WizardCancelledError extends Error {
50
+ constructor();
51
+ }
52
+ /**
53
+ * A rich, dependency-free terminal prompter: arrow-key `select`/`multiselect`
54
+ * (raw mode), validated `text`, and `confirm`. Only usable in a TTY.
55
+ */
56
+ declare function ttyPrompter(): Prompter;
57
+ /**
58
+ * A scripted prompter for tests: each call dequeues the next answer of its kind.
59
+ * `log` captures intro/note/outro lines so a test can assert the summary.
60
+ */
61
+ declare function scriptedPrompter(script: {
62
+ text?: string[];
63
+ select?: unknown[];
64
+ multiselect?: unknown[][];
65
+ confirm?: boolean[];
66
+ }): Prompter & {
67
+ log: string[];
68
+ };
69
+
70
+ type FeatureKey = 'tenancy' | 'auth' | 'billing' | 'ui' | 'cli';
71
+ /** The feature toggles offered in the "custom" preset (and shown in the summary). */
72
+ declare const FEATURES: Choice<FeatureKey>[];
73
+ interface Preset {
74
+ value: string;
75
+ label: string;
76
+ hint: string;
77
+ /** null → let the user pick features (custom). */
78
+ features: FeatureKey[] | null;
79
+ }
80
+ declare const PRESETS: Preset[];
81
+ /** Validates a project name as an npm-installable package name. */
82
+ declare function validateProjectName(name: string): string | undefined;
83
+ interface WizardResult {
84
+ name: string;
85
+ tenancy: boolean;
86
+ auth: boolean;
87
+ billing: boolean;
88
+ ui: boolean;
89
+ cli: boolean;
90
+ pm: PackageManager;
91
+ install: boolean;
92
+ git: boolean;
93
+ }
94
+ interface WizardOptions {
95
+ /** Pre-filled name (from argv) — skips the name prompt when valid. */
96
+ name?: string;
97
+ /** Detected package manager, used as the default selection. */
98
+ defaultPm?: PackageManager;
99
+ }
100
+ /**
101
+ * The interactive scaffolding flow. Pure with respect to I/O — it drives the
102
+ * injected {@link Prompter}, so tests script the answers and assert the result.
103
+ * Throws {@link WizardCancelledError} if the user declines the final confirm.
104
+ */
105
+ declare function runWizard(prompter: Prompter, options?: WizardOptions): Promise<WizardResult>;
106
+
12
107
  interface CreateProjectInput {
13
108
  name: string;
14
109
  /** Target directory. Default: ./<name> under cwd. */
@@ -38,4 +133,4 @@ declare function detectPackageManager(userAgent?: string | undefined): PackageMa
38
133
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
39
134
  declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
40
135
 
41
- export { type CreateProjectInput, type CreateProjectResult, type PackageManager, type ProjectOptions, TargetNotEmptyError, createProject, detectPackageManager };
136
+ export { type Choice, type CreateProjectInput, type CreateProjectResult, FEATURES, type FeatureKey, PRESETS, type PackageManager, type ProjectOptions, type Prompter, TargetNotEmptyError, WizardCancelledError, type WizardOptions, type WizardResult, createProject, detectPackageManager, runWizard, scriptedPrompter, ttyPrompter, validateProjectName };
package/dist/index.js CHANGED
@@ -1,10 +1,24 @@
1
1
  import {
2
+ FEATURES,
3
+ PRESETS,
2
4
  TargetNotEmptyError,
5
+ WizardCancelledError,
3
6
  createProject,
4
- detectPackageManager
5
- } from "./chunk-TYOPE3RU.js";
7
+ detectPackageManager,
8
+ runWizard,
9
+ scriptedPrompter,
10
+ ttyPrompter,
11
+ validateProjectName
12
+ } from "./chunk-Z6RTS4I7.js";
6
13
  export {
14
+ FEATURES,
15
+ PRESETS,
7
16
  TargetNotEmptyError,
17
+ WizardCancelledError,
8
18
  createProject,
9
- detectPackageManager
19
+ detectPackageManager,
20
+ runWizard,
21
+ scriptedPrompter,
22
+ ttyPrompter,
23
+ validateProjectName
10
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Scaffold a production-shaped Basalt SaaS app: npm create basalt my-saas [--billing] [--ui] [--cli] [--install] [--git]",
5
5
  "license": "MIT",
6
6
  "type": "module",