create-better-schemic 0.1.0-alpha.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "create-better-schemic",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Scaffold a new Better-schemic project — package.json, tsconfig, a driver, and the database/ schema, in one command.",
5
+ "license": "MIT",
6
+ "author": "Vertio Solutions",
7
+ "homepage": "https://github.com/NONSTANDARDCODE/better-schemic",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/NONSTANDARDCODE/better-schemic.git",
11
+ "directory": "packages/create-better-schemic"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "create-better-schemic": "lib/index.js"
16
+ },
17
+ "sideEffects": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "src",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "prepack": "bun run build",
29
+ "typecheck": "tsc --noEmit",
30
+ "lint": "biome check ."
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "^2.3.11",
34
+ "@clack/prompts": "^1.6.0",
35
+ "@types/bun": "latest",
36
+ "tsup": "^8",
37
+ "typescript": "^5"
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,357 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { basename, join, resolve } from "node:path";
10
+ import * as p from "@clack/prompts";
11
+ // The @better-schemic versions this scaffolder pins are its OWN version (the packages release lockstep), so a
12
+ // fresh project always gets a matching set. Inlined at build by tsup.
13
+ import { version as BETTER_SCHEMIC_VERSION } from "../package.json";
14
+
15
+ const RANGE = `^${BETTER_SCHEMIC_VERSION}`;
16
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
17
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
18
+
19
+ /** Supported drivers: the package + the runtime deps a project authoring/connecting with it needs. */
20
+ const DRIVERS: Record<
21
+ string,
22
+ { label: string; pkg: string; deps: Record<string, string> }
23
+ > = {
24
+ surrealdb: {
25
+ label: "SurrealDB",
26
+ pkg: "@better-schemic/surrealdb",
27
+ deps: { surrealdb: "^2.0.3", zod: "^4.3.5" },
28
+ },
29
+ };
30
+ const DRIVER_NAMES = Object.keys(DRIVERS);
31
+ const PMS = ["bun", "npm", "pnpm", "yarn"] as const;
32
+ type Pm = (typeof PMS)[number];
33
+
34
+ interface Options {
35
+ dir?: string;
36
+ driver?: string;
37
+ pm?: string;
38
+ install?: boolean; // undefined = ask
39
+ git: boolean;
40
+ yes: boolean;
41
+ }
42
+
43
+ function parseArgs(argv: string[]): Options {
44
+ const o: Options = { git: true, yes: false };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a === "-y" || a === "--yes") o.yes = true;
48
+ else if (a === "--no-install") o.install = false;
49
+ else if (a === "--no-git") o.git = false;
50
+ else if (a === "--driver") o.driver = argv[++i];
51
+ else if (a === "--pm") o.pm = argv[++i];
52
+ else if (a === "-h" || a === "--help") {
53
+ printHelp();
54
+ process.exit(0);
55
+ } else if (!a.startsWith("-") && o.dir === undefined) o.dir = a;
56
+ }
57
+ return o;
58
+ }
59
+
60
+ function printHelp(): void {
61
+ console.log(`create-better-schemic — scaffold a new Better-schemic project
62
+
63
+ Usage: create-better-schemic [directory] [options]
64
+
65
+ Options:
66
+ --driver <name> ${DRIVER_NAMES.join(" | ")}
67
+ --pm <name> ${PMS.join(" | ")} (the package manager to install with)
68
+ --no-install scaffold only; don't install or run \`better-schemic init\`
69
+ --no-git don't run \`git init\`
70
+ -y, --yes accept defaults, no prompts
71
+ -h, --help show this help`);
72
+ }
73
+
74
+ /** Which package manager invoked us (from npm_config_user_agent), for a sensible default. */
75
+ function detectPm(): Pm {
76
+ const ua = process.env.npm_config_user_agent ?? "";
77
+ for (const pm of PMS) if (ua.startsWith(pm)) return pm;
78
+ return "npm";
79
+ }
80
+
81
+ /** Abort cleanly on Ctrl-C / cancel from any prompt. */
82
+ function abortIfCancel<T>(value: T | symbol): T {
83
+ if (p.isCancel(value)) {
84
+ p.cancel("Cancelled.");
85
+ process.exit(1);
86
+ }
87
+ return value as T;
88
+ }
89
+
90
+ // --- templates ---------------------------------------------------------------------------------
91
+
92
+ // One `db` script aliases the `better-schemic` CLI — `bun run db gen`, `bun run db migrate`, `bun run db seed`,
93
+ // etc. (bun/pnpm/yarn forward the subcommand directly; npm needs `npm run db -- gen`).
94
+ const SCRIPTS: Record<string, string> = {
95
+ db: "better-schemic",
96
+ };
97
+
98
+ /** Runtime deps for `driver` under `pm` (pnpm needs @better-schemic/core directly — strict node_modules). */
99
+ function depMap(driver: string, pm: Pm): Record<string, string> {
100
+ const d = DRIVERS[driver];
101
+ const deps: Record<string, string> = {
102
+ "@better-schemic/cli": RANGE,
103
+ [d.pkg]: RANGE,
104
+ ...d.deps,
105
+ };
106
+ if (pm === "pnpm") deps["@better-schemic/core"] = RANGE;
107
+ return deps;
108
+ }
109
+
110
+ // Kept conservative so it works across TypeScript 5, 6, and 7: `module: "ESNext"` (5.0+) rather than
111
+ // "Preserve" (5.4+); `moduleResolution: "bundler"` (5.0+). Seeds load supporting files via `ctx.file()`
112
+ // (no import attributes), so nothing here is version-gated.
113
+ const TSCONFIG = `${JSON.stringify(
114
+ {
115
+ compilerOptions: {
116
+ target: "ESNext",
117
+ module: "ESNext",
118
+ moduleResolution: "bundler",
119
+ moduleDetection: "force",
120
+ strict: true,
121
+ skipLibCheck: true,
122
+ resolveJsonModule: true,
123
+ noEmit: true,
124
+ lib: ["ESNext"],
125
+ types: ["node"],
126
+ },
127
+ exclude: ["node_modules"],
128
+ },
129
+ null,
130
+ 2,
131
+ )}\n`;
132
+
133
+ /** NEW project → write a fresh package.json. EXISTING → MERGE deps + scripts (never clobber). */
134
+ function applyPackageJson(
135
+ target: string,
136
+ name: string,
137
+ driver: string,
138
+ pm: Pm,
139
+ ): string {
140
+ const path = join(target, "package.json");
141
+ const deps = depMap(driver, pm);
142
+ if (!existsSync(path)) {
143
+ const fresh = {
144
+ name,
145
+ version: "0.0.0",
146
+ private: true,
147
+ type: "module",
148
+ scripts: SCRIPTS,
149
+ dependencies: Object.fromEntries(Object.entries(deps).sort()),
150
+ devDependencies: { "@types/node": "^20", typescript: "^5" },
151
+ };
152
+ writeFileSync(path, `${JSON.stringify(fresh, null, 2)}\n`);
153
+ return "+ package.json";
154
+ }
155
+ const pkg = JSON.parse(readFileSync(path, "utf8")) as {
156
+ dependencies?: Record<string, string>;
157
+ scripts?: Record<string, string>;
158
+ };
159
+ pkg.dependencies ??= {};
160
+ const added: string[] = [];
161
+ for (const [k, v] of Object.entries(deps))
162
+ if (!(k in pkg.dependencies)) {
163
+ pkg.dependencies[k] = v;
164
+ added.push(k);
165
+ }
166
+ pkg.dependencies = Object.fromEntries(
167
+ Object.entries(pkg.dependencies).sort(),
168
+ );
169
+ pkg.scripts ??= {};
170
+ for (const [k, v] of Object.entries(SCRIPTS))
171
+ if (!(k in pkg.scripts)) pkg.scripts[k] = v;
172
+ writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
173
+ return added.length
174
+ ? `~ package.json (added ${added.join(", ")})`
175
+ : dim("· package.json (deps already present)");
176
+ }
177
+
178
+ /** NEW project → write a fresh tsconfig. EXISTING → keep theirs, with a compatibility note. */
179
+ function applyTsconfig(target: string): string {
180
+ const path = join(target, "tsconfig.json");
181
+ if (!existsSync(path)) {
182
+ writeFileSync(path, TSCONFIG);
183
+ return "+ tsconfig.json";
184
+ }
185
+ return dim(
186
+ "· tsconfig.json (kept — needs module esnext/preserve/nodenext + resolveJsonModule for seed imports)",
187
+ );
188
+ }
189
+
190
+ const GITIGNORE = `node_modules/
191
+ .env
192
+ *.log
193
+ .DS_Store
194
+ `;
195
+
196
+ // --- write + run -------------------------------------------------------------------------------
197
+
198
+ function writeIfAbsent(dir: string, file: string, content: string): string {
199
+ const path = join(dir, file);
200
+ if (existsSync(path)) return dim(`· ${file} (exists, skipped)`);
201
+ writeFileSync(path, content);
202
+ return `+ ${file}`;
203
+ }
204
+
205
+ function run(
206
+ cmd: string,
207
+ args: string[],
208
+ cwd: string,
209
+ capture = false,
210
+ ): { ok: boolean; out: string } {
211
+ const r = spawnSync(cmd, args, {
212
+ cwd,
213
+ stdio: capture ? "pipe" : "inherit",
214
+ encoding: "utf8",
215
+ });
216
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}` };
217
+ }
218
+
219
+ async function main(): Promise<void> {
220
+ const opts = parseArgs(process.argv.slice(2));
221
+ const tty = !!process.stdin.isTTY && !opts.yes;
222
+
223
+ p.intro(bold("create-better-schemic"));
224
+
225
+ // 1. directory / name
226
+ let dir = opts.dir;
227
+ if (!dir && tty) {
228
+ dir = abortIfCancel(
229
+ await p.text({
230
+ message: "Project directory",
231
+ placeholder: "better-schemic-app",
232
+ defaultValue: "better-schemic-app",
233
+ }),
234
+ );
235
+ }
236
+ dir ||= "better-schemic-app";
237
+ const target = resolve(process.cwd(), dir);
238
+ const name = basename(target);
239
+ mkdirSync(target, { recursive: true });
240
+ const isExisting = existsSync(join(target, "package.json"));
241
+ // Warn only for a non-empty NON-project dir (random files); a package.json = a deliberate adapt.
242
+ if (readdirSync(target).length && !isExisting && tty) {
243
+ const ok = abortIfCancel(
244
+ await p.confirm({
245
+ message: `${bold(dir)} is not empty — continue?`,
246
+ initialValue: false,
247
+ }),
248
+ );
249
+ if (!ok) {
250
+ p.cancel("Aborted.");
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ // 2. driver
256
+ let driver = opts.driver;
257
+ if (!driver && tty) {
258
+ driver = abortIfCancel(
259
+ await p.select({
260
+ message: "Database driver",
261
+ options: DRIVER_NAMES.map((n) => ({
262
+ value: n,
263
+ label: DRIVERS[n].label,
264
+ })),
265
+ initialValue: "surrealdb",
266
+ }),
267
+ );
268
+ }
269
+ driver ||= "surrealdb";
270
+ if (!DRIVERS[driver]) {
271
+ p.cancel(`Unknown driver "${driver}". Known: ${DRIVER_NAMES.join(", ")}.`);
272
+ process.exit(1);
273
+ }
274
+
275
+ // 3. install? + which package manager (ask, default = detected)
276
+ const detected = detectPm();
277
+ let install = opts.install;
278
+ if (install === undefined)
279
+ install = tty
280
+ ? abortIfCancel(
281
+ await p.confirm({
282
+ message: "Install dependencies now?",
283
+ initialValue: true,
284
+ }),
285
+ )
286
+ : true;
287
+ let pm: Pm = (opts.pm as Pm) ?? detected;
288
+ if (install && !opts.pm && tty) {
289
+ pm = abortIfCancel(
290
+ await p.select({
291
+ message: "Install with which package manager?",
292
+ options: [detected, ...PMS.filter((x) => x !== detected)].map((m) => ({
293
+ value: m,
294
+ label: m === detected ? `${m} (detected)` : m,
295
+ })),
296
+ initialValue: detected,
297
+ }),
298
+ ) as Pm;
299
+ }
300
+
301
+ // 4. write/merge the project envelope (fresh for a new project, merge into an existing one)
302
+ const written = [
303
+ applyPackageJson(target, name, driver, pm),
304
+ applyTsconfig(target),
305
+ writeIfAbsent(target, ".gitignore", GITIGNORE),
306
+ ];
307
+ const header = isExisting ? `Adding Better-schemic to ${name}` : name;
308
+ p.log.step(
309
+ `${bold(header)} ${dim(`(${DRIVERS[driver].label})`)}\n${written.join("\n")}`,
310
+ );
311
+ if (opts.git && !existsSync(join(target, ".git")))
312
+ run("git", ["init", "-q"], target);
313
+
314
+ // 5. install + compose on `better-schemic init` (which scaffolds config + database/ via the driver)
315
+ if (install) {
316
+ const s = p.spinner();
317
+ s.start(`Installing dependencies with ${pm}`);
318
+ const r = run(pm, ["install"], target, true);
319
+ if (!r.ok) {
320
+ s.stop(`${pm} install failed`);
321
+ p.log.error(r.out.trim().split("\n").slice(-8).join("\n"));
322
+ p.outro(`Fix the install, then run ${bold("better-schemic init")}.`);
323
+ process.exit(1);
324
+ }
325
+ s.stop("Dependencies installed");
326
+ p.log.step("Scaffolding the schema");
327
+ const runtime = pm === "bun" ? "bun" : "node";
328
+ const cliJs = join(
329
+ target,
330
+ "node_modules",
331
+ "@better-schemic",
332
+ "cli",
333
+ "lib",
334
+ "cli.js",
335
+ );
336
+ // Mark the inner init as already-bootstrapped so it scaffolds directly (never forwards back here).
337
+ process.env.BETTER_SCHEMIC_NO_BOOTSTRAP = "1";
338
+ process.env.SCHEMIC_NO_BOOTSTRAP = "1"; // legacy alias
339
+ run(runtime, [cliJs, "init", "--driver", driver], target);
340
+ }
341
+
342
+ // 6. next steps
343
+ const cd = dir === "." ? "" : `cd ${dir}\n`;
344
+ // npm needs `npm run db -- <sub>` to forward args; bun/pnpm/yarn forward `<pm> run db <sub>` directly.
345
+ const dbCmd = (sub: string) =>
346
+ pm === "npm" ? `npm run db -- ${sub}` : `${pm} run db ${sub}`;
347
+ const steps = install
348
+ ? `${cd}cp .env.example .env ${dim("# set your connection")}\n${dbCmd("gen")}\n${dbCmd("migrate")}`
349
+ : `${cd}${pm} install\n${pm} exec better-schemic init --driver ${driver}\ncp .env.example .env\n${dbCmd("gen")}`;
350
+ p.note(steps, "Next steps");
351
+ p.outro(`${bold(name)} is ready.`);
352
+ }
353
+
354
+ main().catch((e: unknown) => {
355
+ p.log.error(e instanceof Error ? e.message : String(e));
356
+ process.exit(1);
357
+ });