create-macostack 0.2.0 → 0.3.0

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.
@@ -1,27 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  cancel,
4
- confirm,
5
4
  intro,
6
5
  isCancel,
7
- log,
8
6
  multiselect,
9
7
  note,
10
8
  outro,
9
+ select,
11
10
  spinner,
12
11
  text,
13
12
  } from "@clack/prompts";
14
13
  import {
15
- closeSync,
16
14
  existsSync,
17
15
  mkdirSync,
18
16
  mkdtempSync,
19
- openSync,
20
17
  readdirSync,
21
18
  rmSync,
22
19
  } from "node:fs";
23
20
  import { tmpdir } from "node:os";
24
- import { join, resolve } from "node:path";
21
+ import { basename, join, resolve } from "node:path";
25
22
  import { parseArgs } from "node:util";
26
23
 
27
24
  // create-macostack — start a new app from the macostack templates.
@@ -92,6 +89,7 @@ if (flags.help) {
92
89
 
93
90
  Usage:
94
91
  bun create macostack <app-name>
92
+ bun create macostack . scaffold into the current (empty) folder
95
93
 
96
94
  Flags:
97
95
  --server / --client scaffold only that part (default: it asks)
@@ -127,29 +125,6 @@ const answered = <T>(value: T | symbol): T => {
127
125
  return value as T;
128
126
  };
129
127
 
130
- // The setup wizards downstream are interactive. Under `bun create` / `bunx`,
131
- // stdin often arrives as a pipe (not a TTY) even though a human is right
132
- // there — so we hand children the real terminal via /dev/tty.
133
- const openTty = (): number | null => {
134
- try {
135
- return openSync("/dev/tty", "r");
136
- } catch {
137
- return null;
138
- }
139
- };
140
-
141
- const run = (command: string[], cwd: string) => {
142
- const ttyFd = openTty();
143
- const result = Bun.spawnSync(command, {
144
- cwd,
145
- stdin: ttyFd ?? "inherit",
146
- stdout: "inherit",
147
- stderr: "inherit",
148
- });
149
- if (ttyFd !== null) closeSync(ttyFd);
150
- return result.exitCode;
151
- };
152
-
153
128
  const githubToken = (): string | null => {
154
129
  const fromEnv = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
155
130
  if (fromEnv) return fromEnv;
@@ -166,13 +141,33 @@ const isEmptyDir = (dir: string) =>
166
141
  // WIZARD
167
142
  //////////////////////////////////////////////////////////////////
168
143
 
144
+ const BLUE = "\x1b[38;5;33m";
145
+ const RESET = "\x1b[0m";
146
+ console.log(`${BLUE}
147
+ ███ ███ █████ ██████ ██████ ███████ ████████ █████ ██████ ██ ██
148
+ ████ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
149
+ ██ ████ ██ ███████ ██ ██ ██ ███████ ██ ███████ ██ █████
150
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
151
+ ██ ██ ██ ██ ██████ ██████ ███████ ██ ██ ██ ██████ ██ ██
152
+ ${RESET}`);
153
+
169
154
  intro("create-macostack");
170
155
 
171
156
  // 1. App name (positional arg or prompt).
172
157
  const positional = parsed.positionals[0];
173
158
  const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
174
159
  let appName: string;
175
- if (positional) {
160
+ // `.` means "right here": the current folder is the app, its name is the name.
161
+ const scaffoldHere = positional === "." || positional === "./";
162
+ if (scaffoldHere) {
163
+ appName = basename(process.cwd());
164
+ if (!validName(appName)) {
165
+ bail(
166
+ `This folder is called "${appName}" — app names use lowercase letters, digits and dashes.\n` +
167
+ " Rename the folder, or run me with an explicit name: bun create macostack my-app",
168
+ );
169
+ }
170
+ } else if (positional) {
176
171
  if (!validName(positional)) {
177
172
  bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
178
173
  }
@@ -191,27 +186,122 @@ if (positional) {
191
186
  );
192
187
  }
193
188
 
194
- const targetRoot = resolve(process.cwd(), appName);
195
- if (!isEmptyDir(targetRoot)) {
196
- bail(`The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`);
189
+ const targetRoot = scaffoldHere ? process.cwd() : resolve(process.cwd(), appName);
190
+ // How we refer to the app folder in messages: "cd ./server" when scaffolding
191
+ // in place, "cd my-app/server" otherwise.
192
+ const displayRoot = scaffoldHere ? "." : appName;
193
+
194
+ //////////////////////////////////////////////////////////////////
195
+ // ALREADY SET UP? → manage instead of scaffold
196
+ //////////////////////////////////////////////////////////////////
197
+
198
+ // Toggle a part's modules by talking to ITS setup (--questions → choices →
199
+ // per-module `bun run module on/off`). Used when macostack already lives here.
200
+ const adjustModules = async (part: Part) => {
201
+ const dir = resolve(targetRoot, part.folder);
202
+ const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
203
+ cwd: dir,
204
+ stdout: "pipe",
205
+ stderr: "pipe",
206
+ });
207
+ let spec: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
208
+ if (q.exitCode === 0) {
209
+ try {
210
+ spec = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
211
+ } catch {}
212
+ }
213
+ const mods = spec?.modules;
214
+ if (!mods) {
215
+ note(`This ${part.label.toLowerCase()} doesn't expose adjustable modules.`, part.label);
216
+ return;
217
+ }
218
+
219
+ let chosen: string[];
220
+ for (;;) {
221
+ chosen = answered(
222
+ await multiselect({
223
+ message: `${part.label} — which modules should be ON? (your current setup is preselected)`,
224
+ options: mods.options.map((m) => ({ value: m, label: m })),
225
+ initialValues: mods.initial,
226
+ required: false,
227
+ }),
228
+ );
229
+ const clash = (mods.exclusive ?? []).find((pair) => pair.every((m) => chosen.includes(m)));
230
+ if (!clash) break;
231
+ note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
232
+ }
233
+
234
+ const turnOn = chosen.filter((m) => !mods.initial.includes(m));
235
+ const turnOff = mods.initial.filter((m) => !chosen.includes(m));
236
+ if (turnOn.length === 0 && turnOff.length === 0) {
237
+ note("No changes — everything stays exactly as it was.", part.label);
238
+ return;
239
+ }
240
+ const s = spinner();
241
+ s.start(`${part.label} — applying your changes`);
242
+ for (const m of turnOn) Bun.spawnSync(["bun", "run", "module", m, "on"], { cwd: dir, stdout: "pipe", stderr: "pipe" });
243
+ for (const m of turnOff) Bun.spawnSync(["bun", "run", "module", m, "off"], { cwd: dir, stdout: "pipe", stderr: "pipe" });
244
+ const changes = [...turnOn.map((m) => `${m} → on`), ...turnOff.map((m) => `${m} → off`)];
245
+ s.stop(`${part.label} — updated: ${changes.join(", ")}`);
246
+ };
247
+
248
+ const present = PARTS.filter((p) => existsSync(resolve(targetRoot, p.folder, "package.json")));
249
+ let selected: Part[] | null = null;
250
+
251
+ if (present.length > 0) {
252
+ if (flags.yes) {
253
+ bail(`${displayRoot} already has macostack in it — run me without --yes to manage it.`);
254
+ }
255
+ const missing = PARTS.filter((p) => !present.includes(p));
256
+ const stateLine = PARTS.map((p) => `${p.label}: ${present.includes(p) ? "✓ installed" : "— not yet"}`).join(" ");
257
+ note(stateLine, "You already have macostack here");
258
+
259
+ const actions = [
260
+ ...missing.map((p) => ({ value: `add:${p.id}`, label: `Add the ${p.label.toLowerCase()}`, hint: p.hint })),
261
+ ...present.map((p) => ({ value: `adjust:${p.id}`, label: `Adjust ${p.label.toLowerCase()} modules`, hint: "turn things on or off" })),
262
+ { value: "exit", label: "Nothing — just looking", hint: "leaves everything untouched" },
263
+ ];
264
+ const action = answered(
265
+ await select({ message: "What would you like to do?", options: actions }),
266
+ );
267
+
268
+ if (action === "exit") {
269
+ outro("All good — nothing was touched. 👋");
270
+ process.exit(0);
271
+ }
272
+ const [verb, partId] = (action as string).split(":");
273
+ const part = PARTS.find((p) => p.id === partId)!;
274
+ if (verb === "adjust") {
275
+ await adjustModules(part);
276
+ outro(`${appName} is up to date ✨`);
277
+ process.exit(0);
278
+ }
279
+ selected = [part]; // "add:" → scaffold just the missing part below
280
+ } else if (!isEmptyDir(targetRoot)) {
281
+ bail(
282
+ scaffoldHere
283
+ ? "This folder isn't empty — I only scaffold into a clean one."
284
+ : `The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`,
285
+ );
197
286
  }
198
287
 
199
288
  // 2. Which parts?
200
- let selected: Part[];
201
- if (flags.server || flags.client) {
202
- selected = PARTS.filter((p) => flags[p.id]);
203
- } else if (flags.yes) {
204
- selected = PARTS;
205
- } else {
206
- const picked = answered(
207
- await multiselect({
208
- message: `What does ${appName} need? (space to pick, enter to continue)`,
209
- options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
210
- initialValues: PARTS.map((p) => p.id),
211
- required: true,
212
- }),
213
- );
214
- selected = PARTS.filter((p) => picked.includes(p.id));
289
+ if (!selected) {
290
+ if (flags.server || flags.client) {
291
+ selected = PARTS.filter((p) => flags[p.id]);
292
+ } else if (flags.yes) {
293
+ selected = PARTS;
294
+ } else {
295
+ const picked = answered(
296
+ await multiselect({
297
+ message: `What does ${appName} need? (space to pick, enter to continue)`,
298
+ options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
299
+ initialValues: PARTS.map((p) => p.id),
300
+ required: true,
301
+ }),
302
+ );
303
+ selected = PARTS.filter((p) => picked.includes(p.id));
304
+ }
215
305
  }
216
306
 
217
307
  // 3. GitHub access — checked before anything is created.
@@ -286,7 +376,7 @@ for (const p of selected) {
286
376
  } finally {
287
377
  rmSync(tmp, { recursive: true, force: true });
288
378
  }
289
- s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
379
+ s.stop(`${p.label} — downloaded into ${displayRoot}/${p.folder}`);
290
380
 
291
381
  if (!flags["skip-install"]) {
292
382
  const s2 = spinner();
@@ -295,23 +385,63 @@ for (const p of selected) {
295
385
  s2.stop(
296
386
  code === 0
297
387
  ? `${p.label} — dependencies installed`
298
- : `${p.label} — bun install had issues (run it again inside ${appName}/${p.folder})`,
388
+ : `${p.label} — bun install had issues (run it again inside ${displayRoot}/${p.folder})`,
299
389
  );
300
390
  }
301
391
 
302
- // Hand over to the template's own wizardit knows its modules, we don't.
392
+ // Configure the template. It OWNS its questions we fetch them as data
393
+ // (`setup --questions`), ask them in THIS wizard (nested interactive
394
+ // prompts don't get keyboard control under `bun create`), and apply the
395
+ // answers non-interactively (`setup --yes`).
303
396
  const pkg = existsSync(resolve(dest, "package.json"))
304
397
  ? await Bun.file(resolve(dest, "package.json")).json()
305
398
  : null;
306
399
  if (!flags["skip-setup"] && pkg?.scripts?.setup) {
307
- log.step(`Now shaping the ${p.label.toLowerCase()} its own wizard takes it from here:`);
308
- const args = flags.yes
309
- ? ["run", "setup", "--yes", "--name", appName]
310
- : ["run", "setup", "--name", appName];
311
- const code = run(["bun", ...args], dest);
312
- if (code !== 0) {
400
+ let questions: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
401
+ if (!flags.yes) {
402
+ const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
403
+ cwd: dest,
404
+ stdout: "pipe",
405
+ stderr: "pipe",
406
+ });
407
+ if (q.exitCode === 0) {
408
+ try {
409
+ questions = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
410
+ } catch {}
411
+ }
412
+ }
413
+
414
+ const setupArgs = ["run", "setup", "--yes", "--name", appName];
415
+
416
+ if (questions?.modules) {
417
+ let chosen: string[];
418
+ for (;;) {
419
+ chosen = answered(
420
+ await multiselect({
421
+ message: `${p.label} — which modules should start ON? (everything stays in the code; off = dormant, ready when you need it)`,
422
+ options: questions.modules.options.map((m) => ({ value: m, label: m })),
423
+ initialValues: questions.modules.initial,
424
+ required: false,
425
+ }),
426
+ );
427
+ const clash = (questions.modules.exclusive ?? []).find((pair) =>
428
+ pair.every((m) => chosen.includes(m)),
429
+ );
430
+ if (!clash) break;
431
+ note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
432
+ }
433
+ setupArgs.push("--modules", chosen.join(","));
434
+ }
435
+
436
+ const s3 = spinner();
437
+ s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
438
+ const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
439
+ if (setup.exitCode === 0) {
440
+ s3.stop(`${p.label} — configured`);
441
+ } else {
442
+ s3.stop(`${p.label} — setup didn't finish`);
313
443
  note(
314
- `The ${p.folder} wizard didn't finish. Everything downloaded fine — resume with:\n cd ${appName}/${p.folder} && bun run setup`,
444
+ `Everything downloaded fine — just finish it yourself:\n cd ${displayRoot}/${p.folder} && bun run setup`,
315
445
  "Heads up",
316
446
  );
317
447
  }
@@ -337,16 +467,16 @@ const server = ready.find((p) => p.id === "server");
337
467
  const client = ready.find((p) => p.id === "client");
338
468
  if (server) {
339
469
  steps.push(
340
- `Start the backend:\n cd ${appName}/server\n docker compose up -d && bun run db:migrate && bun dev`,
470
+ `Start the backend:\n cd ${displayRoot}/server\n docker compose up -d && bun run db:migrate && bun dev`,
341
471
  );
342
472
  }
343
473
  if (client) {
344
- steps.push(`Start the web app:\n cd ${appName}/client\n bun dev`);
474
+ steps.push(`Start the web app:\n cd ${displayRoot}/client\n bun dev`);
345
475
  }
346
476
  steps.push(
347
477
  `When you're ready to publish (each part is its own repo):\n` +
348
478
  ready
349
- .map((p) => ` cd ${appName}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
479
+ .map((p) => ` cd ${displayRoot}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
350
480
  .join("\n"),
351
481
  );
352
482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {