create-macostack 0.2.1 → 0.4.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.
@@ -6,6 +6,7 @@ import {
6
6
  multiselect,
7
7
  note,
8
8
  outro,
9
+ select,
9
10
  spinner,
10
11
  text,
11
12
  } from "@clack/prompts";
@@ -17,7 +18,7 @@ import {
17
18
  rmSync,
18
19
  } from "node:fs";
19
20
  import { tmpdir } from "node:os";
20
- import { join, resolve } from "node:path";
21
+ import { basename, join, resolve } from "node:path";
21
22
  import { parseArgs } from "node:util";
22
23
 
23
24
  // create-macostack — start a new app from the macostack templates.
@@ -44,6 +45,24 @@ type Part = {
44
45
  hint: string;
45
46
  };
46
47
 
48
+ // The module question a template exposes via `setup --questions`. The CLI stays
49
+ // dumb: it renders whatever the template declares (options + optional one-line
50
+ // descriptions), never hardcoding which modules exist.
51
+ type ModuleSpec = {
52
+ options: string[];
53
+ initial: string[];
54
+ descriptions?: Record<string, string>;
55
+ exclusive?: string[][];
56
+ };
57
+
58
+ // Optional deploy-target question a template may expose (e.g. vps vs cloudflare
59
+ // workers). Same dumb-CLI contract as modules: rendered from what setup declares.
60
+ type RuntimeSpec = {
61
+ options: string[];
62
+ initial: string;
63
+ descriptions?: Record<string, string>;
64
+ };
65
+
47
66
  const PARTS: Part[] = [
48
67
  {
49
68
  id: "server",
@@ -88,6 +107,7 @@ if (flags.help) {
88
107
 
89
108
  Usage:
90
109
  bun create macostack <app-name>
110
+ bun create macostack . scaffold into the current (empty) folder
91
111
 
92
112
  Flags:
93
113
  --server / --client scaffold only that part (default: it asks)
@@ -139,13 +159,33 @@ const isEmptyDir = (dir: string) =>
139
159
  // WIZARD
140
160
  //////////////////////////////////////////////////////////////////
141
161
 
162
+ const BLUE = "\x1b[38;5;33m";
163
+ const RESET = "\x1b[0m";
164
+ console.log(`${BLUE}
165
+ ███ ███ █████ ██████ ██████ ███████ ████████ █████ ██████ ██ ██
166
+ ████ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
167
+ ██ ████ ██ ███████ ██ ██ ██ ███████ ██ ███████ ██ █████
168
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
169
+ ██ ██ ██ ██ ██████ ██████ ███████ ██ ██ ██ ██████ ██ ██
170
+ ${RESET}`);
171
+
142
172
  intro("create-macostack");
143
173
 
144
174
  // 1. App name (positional arg or prompt).
145
175
  const positional = parsed.positionals[0];
146
176
  const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
147
177
  let appName: string;
148
- if (positional) {
178
+ // `.` means "right here": the current folder is the app, its name is the name.
179
+ const scaffoldHere = positional === "." || positional === "./";
180
+ if (scaffoldHere) {
181
+ appName = basename(process.cwd());
182
+ if (!validName(appName)) {
183
+ bail(
184
+ `This folder is called "${appName}" — app names use lowercase letters, digits and dashes.\n` +
185
+ " Rename the folder, or run me with an explicit name: bun create macostack my-app",
186
+ );
187
+ }
188
+ } else if (positional) {
149
189
  if (!validName(positional)) {
150
190
  bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
151
191
  }
@@ -164,27 +204,122 @@ if (positional) {
164
204
  );
165
205
  }
166
206
 
167
- const targetRoot = resolve(process.cwd(), appName);
168
- if (!isEmptyDir(targetRoot)) {
169
- bail(`The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`);
207
+ const targetRoot = scaffoldHere ? process.cwd() : resolve(process.cwd(), appName);
208
+ // How we refer to the app folder in messages: "cd ./server" when scaffolding
209
+ // in place, "cd my-app/server" otherwise.
210
+ const displayRoot = scaffoldHere ? "." : appName;
211
+
212
+ //////////////////////////////////////////////////////////////////
213
+ // ALREADY SET UP? → manage instead of scaffold
214
+ //////////////////////////////////////////////////////////////////
215
+
216
+ // Toggle a part's modules by talking to ITS setup (--questions → choices →
217
+ // per-module `bun run module on/off`). Used when macostack already lives here.
218
+ const adjustModules = async (part: Part) => {
219
+ const dir = resolve(targetRoot, part.folder);
220
+ const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
221
+ cwd: dir,
222
+ stdout: "pipe",
223
+ stderr: "pipe",
224
+ });
225
+ let spec: { modules?: ModuleSpec } | null = null;
226
+ if (q.exitCode === 0) {
227
+ try {
228
+ spec = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
229
+ } catch {}
230
+ }
231
+ const mods = spec?.modules;
232
+ if (!mods) {
233
+ note(`This ${part.label.toLowerCase()} doesn't expose adjustable modules.`, part.label);
234
+ return;
235
+ }
236
+
237
+ let chosen: string[];
238
+ for (;;) {
239
+ chosen = answered(
240
+ await multiselect({
241
+ message: `${part.label} — which modules should be ON? (your current setup is preselected)`,
242
+ options: mods.options.map((m) => ({ value: m, label: m, hint: mods.descriptions?.[m] })),
243
+ initialValues: mods.initial,
244
+ required: false,
245
+ }),
246
+ );
247
+ const clash = (mods.exclusive ?? []).find((pair) => pair.every((m) => chosen.includes(m)));
248
+ if (!clash) break;
249
+ note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
250
+ }
251
+
252
+ const turnOn = chosen.filter((m) => !mods.initial.includes(m));
253
+ const turnOff = mods.initial.filter((m) => !chosen.includes(m));
254
+ if (turnOn.length === 0 && turnOff.length === 0) {
255
+ note("No changes — everything stays exactly as it was.", part.label);
256
+ return;
257
+ }
258
+ const s = spinner();
259
+ s.start(`${part.label} — applying your changes`);
260
+ for (const m of turnOn) Bun.spawnSync(["bun", "run", "module", m, "on"], { cwd: dir, stdout: "pipe", stderr: "pipe" });
261
+ for (const m of turnOff) Bun.spawnSync(["bun", "run", "module", m, "off"], { cwd: dir, stdout: "pipe", stderr: "pipe" });
262
+ const changes = [...turnOn.map((m) => `${m} → on`), ...turnOff.map((m) => `${m} → off`)];
263
+ s.stop(`${part.label} — updated: ${changes.join(", ")}`);
264
+ };
265
+
266
+ const present = PARTS.filter((p) => existsSync(resolve(targetRoot, p.folder, "package.json")));
267
+ let selected: Part[] | null = null;
268
+
269
+ if (present.length > 0) {
270
+ if (flags.yes) {
271
+ bail(`${displayRoot} already has macostack in it — run me without --yes to manage it.`);
272
+ }
273
+ const missing = PARTS.filter((p) => !present.includes(p));
274
+ const stateLine = PARTS.map((p) => `${p.label}: ${present.includes(p) ? "✓ installed" : "— not yet"}`).join(" ");
275
+ note(stateLine, "You already have macostack here");
276
+
277
+ const actions = [
278
+ ...missing.map((p) => ({ value: `add:${p.id}`, label: `Add the ${p.label.toLowerCase()}`, hint: p.hint })),
279
+ ...present.map((p) => ({ value: `adjust:${p.id}`, label: `Adjust ${p.label.toLowerCase()} modules`, hint: "turn things on or off" })),
280
+ { value: "exit", label: "Nothing — just looking", hint: "leaves everything untouched" },
281
+ ];
282
+ const action = answered(
283
+ await select({ message: "What would you like to do?", options: actions }),
284
+ );
285
+
286
+ if (action === "exit") {
287
+ outro("All good — nothing was touched. 👋");
288
+ process.exit(0);
289
+ }
290
+ const [verb, partId] = (action as string).split(":");
291
+ const part = PARTS.find((p) => p.id === partId)!;
292
+ if (verb === "adjust") {
293
+ await adjustModules(part);
294
+ outro(`${appName} is up to date ✨`);
295
+ process.exit(0);
296
+ }
297
+ selected = [part]; // "add:" → scaffold just the missing part below
298
+ } else if (!isEmptyDir(targetRoot)) {
299
+ bail(
300
+ scaffoldHere
301
+ ? "This folder isn't empty — I only scaffold into a clean one."
302
+ : `The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`,
303
+ );
170
304
  }
171
305
 
172
306
  // 2. Which parts?
173
- let selected: Part[];
174
- if (flags.server || flags.client) {
175
- selected = PARTS.filter((p) => flags[p.id]);
176
- } else if (flags.yes) {
177
- selected = PARTS;
178
- } else {
179
- const picked = answered(
180
- await multiselect({
181
- message: `What does ${appName} need? (space to pick, enter to continue)`,
182
- options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
183
- initialValues: PARTS.map((p) => p.id),
184
- required: true,
185
- }),
186
- );
187
- selected = PARTS.filter((p) => picked.includes(p.id));
307
+ if (!selected) {
308
+ if (flags.server || flags.client) {
309
+ selected = PARTS.filter((p) => flags[p.id]);
310
+ } else if (flags.yes) {
311
+ selected = PARTS;
312
+ } else {
313
+ const picked = answered(
314
+ await multiselect({
315
+ message: `What does ${appName} need? (space to pick, enter to continue)`,
316
+ options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
317
+ initialValues: PARTS.map((p) => p.id),
318
+ required: true,
319
+ }),
320
+ );
321
+ selected = PARTS.filter((p) => picked.includes(p.id));
322
+ }
188
323
  }
189
324
 
190
325
  // 3. GitHub access — checked before anything is created.
@@ -259,7 +394,7 @@ for (const p of selected) {
259
394
  } finally {
260
395
  rmSync(tmp, { recursive: true, force: true });
261
396
  }
262
- s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
397
+ s.stop(`${p.label} — downloaded into ${displayRoot}/${p.folder}`);
263
398
 
264
399
  if (!flags["skip-install"]) {
265
400
  const s2 = spinner();
@@ -268,7 +403,7 @@ for (const p of selected) {
268
403
  s2.stop(
269
404
  code === 0
270
405
  ? `${p.label} — dependencies installed`
271
- : `${p.label} — bun install had issues (run it again inside ${appName}/${p.folder})`,
406
+ : `${p.label} — bun install had issues (run it again inside ${displayRoot}/${p.folder})`,
272
407
  );
273
408
  }
274
409
 
@@ -280,7 +415,7 @@ for (const p of selected) {
280
415
  ? await Bun.file(resolve(dest, "package.json")).json()
281
416
  : null;
282
417
  if (!flags["skip-setup"] && pkg?.scripts?.setup) {
283
- let questions: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
418
+ let questions: { modules?: ModuleSpec; runtime?: RuntimeSpec } | null = null;
284
419
  if (!flags.yes) {
285
420
  const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
286
421
  cwd: dest,
@@ -302,7 +437,7 @@ for (const p of selected) {
302
437
  chosen = answered(
303
438
  await multiselect({
304
439
  message: `${p.label} — which modules should start ON? (everything stays in the code; off = dormant, ready when you need it)`,
305
- options: questions.modules.options.map((m) => ({ value: m, label: m })),
440
+ options: questions.modules.options.map((m) => ({ value: m, label: m, hint: questions.modules?.descriptions?.[m] })),
306
441
  initialValues: questions.modules.initial,
307
442
  required: false,
308
443
  }),
@@ -316,6 +451,21 @@ for (const p of selected) {
316
451
  setupArgs.push("--modules", chosen.join(","));
317
452
  }
318
453
 
454
+ if (questions?.runtime && questions.runtime.options.length > 1) {
455
+ const rt = answered(
456
+ await select({
457
+ message: `${p.label} — where will you deploy?`,
458
+ options: questions.runtime.options.map((o) => ({
459
+ value: o,
460
+ label: o,
461
+ hint: questions.runtime?.descriptions?.[o],
462
+ })),
463
+ initialValue: questions.runtime.initial,
464
+ }),
465
+ );
466
+ setupArgs.push("--runtime", rt as string);
467
+ }
468
+
319
469
  const s3 = spinner();
320
470
  s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
321
471
  const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
@@ -324,7 +474,7 @@ for (const p of selected) {
324
474
  } else {
325
475
  s3.stop(`${p.label} — setup didn't finish`);
326
476
  note(
327
- `Everything downloaded fine — just finish it yourself:\n cd ${appName}/${p.folder} && bun run setup`,
477
+ `Everything downloaded fine — just finish it yourself:\n cd ${displayRoot}/${p.folder} && bun run setup`,
328
478
  "Heads up",
329
479
  );
330
480
  }
@@ -350,16 +500,16 @@ const server = ready.find((p) => p.id === "server");
350
500
  const client = ready.find((p) => p.id === "client");
351
501
  if (server) {
352
502
  steps.push(
353
- `Start the backend:\n cd ${appName}/server\n docker compose up -d && bun run db:migrate && bun dev`,
503
+ `Start the backend:\n cd ${displayRoot}/server\n docker compose up -d && bun run db:migrate && bun dev`,
354
504
  );
355
505
  }
356
506
  if (client) {
357
- steps.push(`Start the web app:\n cd ${appName}/client\n bun dev`);
507
+ steps.push(`Start the web app:\n cd ${displayRoot}/client\n bun dev`);
358
508
  }
359
509
  steps.push(
360
510
  `When you're ready to publish (each part is its own repo):\n` +
361
511
  ready
362
- .map((p) => ` cd ${appName}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
512
+ .map((p) => ` cd ${displayRoot}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
363
513
  .join("\n"),
364
514
  );
365
515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {