create-macostack 0.2.1 → 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.
@@ -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.
@@ -88,6 +89,7 @@ if (flags.help) {
88
89
 
89
90
  Usage:
90
91
  bun create macostack <app-name>
92
+ bun create macostack . scaffold into the current (empty) folder
91
93
 
92
94
  Flags:
93
95
  --server / --client scaffold only that part (default: it asks)
@@ -139,13 +141,33 @@ const isEmptyDir = (dir: string) =>
139
141
  // WIZARD
140
142
  //////////////////////////////////////////////////////////////////
141
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
+
142
154
  intro("create-macostack");
143
155
 
144
156
  // 1. App name (positional arg or prompt).
145
157
  const positional = parsed.positionals[0];
146
158
  const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
147
159
  let appName: string;
148
- 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) {
149
171
  if (!validName(positional)) {
150
172
  bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
151
173
  }
@@ -164,27 +186,122 @@ if (positional) {
164
186
  );
165
187
  }
166
188
 
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.`);
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
+ );
170
286
  }
171
287
 
172
288
  // 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));
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
+ }
188
305
  }
189
306
 
190
307
  // 3. GitHub access — checked before anything is created.
@@ -259,7 +376,7 @@ for (const p of selected) {
259
376
  } finally {
260
377
  rmSync(tmp, { recursive: true, force: true });
261
378
  }
262
- s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
379
+ s.stop(`${p.label} — downloaded into ${displayRoot}/${p.folder}`);
263
380
 
264
381
  if (!flags["skip-install"]) {
265
382
  const s2 = spinner();
@@ -268,7 +385,7 @@ for (const p of selected) {
268
385
  s2.stop(
269
386
  code === 0
270
387
  ? `${p.label} — dependencies installed`
271
- : `${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})`,
272
389
  );
273
390
  }
274
391
 
@@ -324,7 +441,7 @@ for (const p of selected) {
324
441
  } else {
325
442
  s3.stop(`${p.label} — setup didn't finish`);
326
443
  note(
327
- `Everything downloaded fine — just finish it yourself:\n cd ${appName}/${p.folder} && bun run setup`,
444
+ `Everything downloaded fine — just finish it yourself:\n cd ${displayRoot}/${p.folder} && bun run setup`,
328
445
  "Heads up",
329
446
  );
330
447
  }
@@ -350,16 +467,16 @@ const server = ready.find((p) => p.id === "server");
350
467
  const client = ready.find((p) => p.id === "client");
351
468
  if (server) {
352
469
  steps.push(
353
- `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`,
354
471
  );
355
472
  }
356
473
  if (client) {
357
- 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`);
358
475
  }
359
476
  steps.push(
360
477
  `When you're ready to publish (each part is its own repo):\n` +
361
478
  ready
362
- .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`)
363
480
  .join("\n"),
364
481
  );
365
482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {