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