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.
- package/bin/create-macostack.ts +190 -60
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
if (flags.server || flags.client) {
|
|
202
|
-
|
|
203
|
-
} else if (flags.yes) {
|
|
204
|
-
|
|
205
|
-
} else {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 ${
|
|
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 ${
|
|
388
|
+
: `${p.label} — bun install had issues (run it again inside ${displayRoot}/${p.folder})`,
|
|
299
389
|
);
|
|
300
390
|
}
|
|
301
391
|
|
|
302
|
-
//
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
`
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
|