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.
- package/bin/create-macostack.ts +143 -26
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
if (flags.server || flags.client) {
|
|
175
|
-
|
|
176
|
-
} else if (flags.yes) {
|
|
177
|
-
|
|
178
|
-
} else {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
|