create-macostack 0.1.1 → 0.2.1

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.
@@ -20,52 +20,52 @@ import { tmpdir } from "node:os";
20
20
  import { join, resolve } from "node:path";
21
21
  import { parseArgs } from "node:util";
22
22
 
23
- const STACK_PART = {
24
- SERVER: "server",
25
- CLIENT: "client",
26
- } as const;
27
-
28
- type StackPart = (typeof STACK_PART)[keyof typeof STACK_PART];
29
-
30
- type TemplateConfig = {
31
- part: StackPart;
32
- directory: string;
23
+ // create-macostack start a new app from the macostack templates.
24
+ //
25
+ // bun create macostack my-app
26
+ //
27
+ // It asks what your app needs (backend API, web app, or both), downloads each
28
+ // template WITHOUT its git history, installs dependencies, and hands you over
29
+ // to each template's own setup wizard. The orchestrator stays deliberately
30
+ // dumb: it never knows what modules exist — each template owns its questions.
31
+
32
+ //////////////////////////////////////////////////////////////////
33
+ // CONFIG
34
+ //////////////////////////////////////////////////////////////////
35
+
36
+ const DEFAULT_OWNER = Bun.env.MACOSTACK_GITHUB_OWNER ?? "macobits";
37
+ const DEFAULT_REF = Bun.env.MACOSTACK_REF ?? "main";
38
+
39
+ type Part = {
40
+ id: "server" | "client";
41
+ folder: string;
33
42
  repo: string;
34
- };
35
-
36
- type ScaffoldConfig = {
37
- owner: string;
38
- ref: string;
39
- token: string;
40
- targetRoot: string;
41
- targetName: string;
42
- skipInstall: boolean;
43
- skipSetup: boolean;
44
- };
45
-
46
- type DownloadTemplateProps = {
47
- config: ScaffoldConfig;
48
- template: TemplateConfig;
49
- destination: string;
50
- };
51
-
52
- type RunCommandProps = {
53
- command: string[];
54
- cwd: string;
55
43
  label: string;
56
- allowFailure?: boolean;
44
+ hint: string;
57
45
  };
58
46
 
59
- type PackageJson = {
60
- scripts?: Record<string, string>;
61
- };
47
+ const PARTS: Part[] = [
48
+ {
49
+ id: "server",
50
+ folder: "server",
51
+ repo: Bun.env.MACOSTACK_SERVER_REPO ?? "macostack-server",
52
+ label: "Backend API",
53
+ hint: "Bun + Hono + Postgres — auth included",
54
+ },
55
+ {
56
+ id: "client",
57
+ folder: "client",
58
+ repo: Bun.env.MACOSTACK_CLIENT_REPO ?? "macostack-client",
59
+ label: "Web app",
60
+ hint: "the frontend",
61
+ },
62
+ ];
62
63
 
63
- const DEFAULT_OWNER = "macobits";
64
- const DEFAULT_REF = "main";
65
- const DEFAULT_SERVER_REPO = "macostack-server";
66
- const DEFAULT_CLIENT_REPO = "macostack-client";
64
+ //////////////////////////////////////////////////////////////////
65
+ // FLAGS
66
+ //////////////////////////////////////////////////////////////////
67
67
 
68
- const parsedArgs = parseArgs({
68
+ const parsed = parseArgs({
69
69
  args: Bun.argv.slice(2),
70
70
  allowPositionals: true,
71
71
  options: {
@@ -81,36 +81,35 @@ const parsedArgs = parseArgs({
81
81
  "skip-setup": { type: "boolean", default: false },
82
82
  },
83
83
  });
84
+ const flags = parsed.values;
84
85
 
85
- const flags = parsedArgs.values;
86
-
87
- const HELP_TEXT = `create-macostack
86
+ if (flags.help) {
87
+ console.log(`create-macostack — start a new app from the macostack templates
88
88
 
89
89
  Usage:
90
- bun create macostack <venture-folder>
91
- bun run dev <venture-folder> [flags]
90
+ bun create macostack <app-name>
92
91
 
93
92
  Flags:
94
- --server scaffold only server
95
- --client scaffold only client
96
- --owner <owner> GitHub owner/org (default: macobits)
97
- --ref <ref> Git ref/tag/branch (default: main)
98
- --server-repo <repo> server template repo (default: macostack-server)
99
- --client-repo <repo> client template repo (default: macostack-client)
100
- --skip-install do not run bun install in generated halves
101
- --skip-setup do not run bun run setup in generated halves
102
- --yes skip create-macostack prompts; selects server + client
103
- --help show this help
104
-
105
- Auth:
106
- Set GITHUB_TOKEN or run gh auth login for private template tarballs.
107
- `;
108
-
109
- if (flags.help === true) {
110
- console.log(HELP_TEXT);
93
+ --server / --client scaffold only that part (default: it asks)
94
+ --yes no questions: both parts, template defaults
95
+ --owner <org> GitHub owner (default: ${DEFAULT_OWNER})
96
+ --ref <ref> branch or tag to pull (default: ${DEFAULT_REF})
97
+ --server-repo <name> server template repo
98
+ --client-repo <name> client template repo
99
+ --skip-install don't run bun install
100
+ --skip-setup don't run each template's setup wizard
101
+
102
+ Auth (templates are private):
103
+ export GITHUB_TOKEN=github_pat_… or gh auth login
104
+ (fine-grained token, Contents: Read-only is enough)
105
+ `);
111
106
  process.exit(0);
112
107
  }
113
108
 
109
+ //////////////////////////////////////////////////////////////////
110
+ // HELPERS
111
+ //////////////////////////////////////////////////////////////////
112
+
114
113
  const bail = (message: string): never => {
115
114
  cancel(message);
116
115
  process.exit(1);
@@ -118,393 +117,250 @@ const bail = (message: string): never => {
118
117
 
119
118
  const answered = <T>(value: T | symbol): T => {
120
119
  if (isCancel(value)) {
121
- cancel("Scaffold aborted — nothing else will be touched.");
120
+ cancel("No problem — nothing was created.");
122
121
  process.exit(0);
123
122
  }
124
-
125
123
  return value as T;
126
124
  };
127
125
 
128
- const getStringFlag = (
129
- value: string | boolean | undefined,
130
- fallback: string,
131
- ) => {
132
- if (typeof value === "string" && value.length > 0) {
133
- return value;
134
- }
135
-
136
- return fallback;
126
+ const githubToken = (): string | null => {
127
+ const fromEnv = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
128
+ if (fromEnv) return fromEnv;
129
+ const gh = Bun.spawnSync(["gh", "auth", "token"], { stdout: "pipe", stderr: "pipe" });
130
+ const token = gh.exitCode === 0 ? gh.stdout.toString().trim() : "";
131
+ return token || null;
137
132
  };
138
133
 
139
- const getBooleanFlag = (value: string | boolean | undefined) => value === true;
140
-
141
- const isRecord = (value: unknown): value is Record<string, unknown> =>
142
- typeof value === "object" && value !== null;
134
+ const isEmptyDir = (dir: string) =>
135
+ !existsSync(dir) ||
136
+ readdirSync(dir).filter((e) => e !== ".DS_Store").length === 0;
143
137
 
144
- const isValidDirectoryName = (value: string) =>
145
- /^[a-z0-9][a-z0-9-]*$/.test(value);
138
+ //////////////////////////////////////////////////////////////////
139
+ // WIZARD
140
+ //////////////////////////////////////////////////////////////////
146
141
 
147
- const isStackPart = (value: string): value is StackPart =>
148
- value === STACK_PART.SERVER || value === STACK_PART.CLIENT;
142
+ intro("create-macostack");
149
143
 
150
- const readPackageJson = async (directory: string): Promise<PackageJson | null> => {
151
- const packagePath = resolve(directory, "package.json");
152
- if (!existsSync(packagePath)) {
153
- return null;
144
+ // 1. App name (positional arg or prompt).
145
+ const positional = parsed.positionals[0];
146
+ const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
147
+ let appName: string;
148
+ if (positional) {
149
+ if (!validName(positional)) {
150
+ bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
154
151
  }
155
-
156
- const value: unknown = await Bun.file(packagePath).json();
157
- if (!isRecord(value)) {
158
- return null;
159
- }
160
-
161
- const scriptsValue = value.scripts;
162
- if (!isRecord(scriptsValue)) {
163
- return {};
164
- }
165
-
166
- const scripts = Object.fromEntries(
167
- Object.entries(scriptsValue).filter(
168
- (entry): entry is [string, string] => typeof entry[1] === "string",
169
- ),
152
+ appName = positional;
153
+ } else if (flags.yes) {
154
+ bail("Tell me the app name: bun create macostack my-app --yes");
155
+ throw ""; // unreachable
156
+ } else {
157
+ appName = answered(
158
+ await text({
159
+ message: "What should we call your app?",
160
+ placeholder: "soccer-app",
161
+ validate: (v) =>
162
+ validName(v ?? "") ? undefined : "lowercase letters, digits and dashes — e.g. soccer-app",
163
+ }),
170
164
  );
165
+ }
171
166
 
172
- return { scripts };
173
- };
174
-
175
- const getGhToken = (): string | null => {
176
- const result = Bun.spawnSync(["gh", "auth", "token"], {
177
- stdout: "pipe",
178
- stderr: "pipe",
179
- });
180
-
181
- if (result.exitCode !== 0) {
182
- return null;
183
- }
184
-
185
- const token = result.stdout.toString().trim();
186
- return token.length > 0 ? token : null;
187
- };
188
-
189
- const getGithubToken = (): string | null => {
190
- const envToken = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
191
- if (envToken && envToken.length > 0) {
192
- return envToken;
193
- }
194
-
195
- return getGhToken();
196
- };
197
-
198
- const requireGithubToken = (): string => {
199
- const token = getGithubToken();
200
- if (typeof token === "string" && token.length > 0) {
201
- return token;
202
- }
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.`);
170
+ }
203
171
 
204
- return bail(
205
- "Private templates require GitHub auth. Set GITHUB_TOKEN or run `gh auth login`. " +
206
- "Use a fine-grained token with Contents: Read-only.",
172
+ // 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
+ }),
207
186
  );
208
- };
209
-
210
- const ensureEmptyDirectory = (directory: string, label: string) => {
211
- if (!existsSync(directory)) {
212
- mkdirSync(directory, { recursive: true });
213
- return;
214
- }
187
+ selected = PARTS.filter((p) => picked.includes(p.id));
188
+ }
215
189
 
216
- const entries = readdirSync(directory).filter((entry) => entry !== ".DS_Store");
217
- if (entries.length > 0) {
218
- bail(`${label} already exists and is not empty: ${directory}`);
219
- }
220
- };
190
+ // 3. GitHub access checked before anything is created.
191
+ const owner = flags.owner ?? DEFAULT_OWNER;
192
+ const ref = flags.ref ?? DEFAULT_REF;
193
+ const token = githubToken();
194
+ if (!token) {
195
+ bail(
196
+ "I need GitHub access to download the templates (they're private).\n" +
197
+ " Easiest: gh auth login\n" +
198
+ " Or: export GITHUB_TOKEN=github_pat_… (Contents: Read-only)",
199
+ );
200
+ throw ""; // unreachable
201
+ }
202
+ for (const p of selected) {
203
+ p.repo = (p.id === "server" ? flags["server-repo"] : flags["client-repo"]) ?? p.repo;
204
+ }
221
205
 
222
- const runCommand = ({
223
- command,
224
- cwd,
225
- label,
226
- allowFailure = false,
227
- }: RunCommandProps) => {
228
- const result = Bun.spawnSync(command, {
229
- cwd,
230
- stdin: "inherit",
231
- stdout: "inherit",
232
- stderr: "inherit",
206
+ // Preflight every selected template so a typo'd repo or missing access fails
207
+ // BEFORE any folders exist.
208
+ const gh = (path: string) =>
209
+ fetch(`https://api.github.com${path}`, {
210
+ headers: {
211
+ Accept: "application/vnd.github+json",
212
+ Authorization: `Bearer ${token}`,
213
+ "User-Agent": "create-macostack",
214
+ "X-GitHub-Api-Version": "2022-11-28",
215
+ },
233
216
  });
234
-
235
- if (result.exitCode === 0 || allowFailure) {
236
- return result.exitCode;
217
+ for (const p of selected) {
218
+ const res = await gh(`/repos/${owner}/${p.repo}`);
219
+ if (!res.ok) {
220
+ bail(
221
+ `Can't reach ${owner}/${p.repo} (HTTP ${res.status}).\n` +
222
+ " Does the repo exist, and does your token have read access to it?",
223
+ );
237
224
  }
225
+ }
238
226
 
239
- bail(`${label} failed with exit code ${result.exitCode}.`);
240
- };
227
+ //////////////////////////////////////////////////////////////////
228
+ // SCAFFOLD
229
+ //////////////////////////////////////////////////////////////////
241
230
 
242
- const downloadTemplate = async ({
243
- config,
244
- template,
245
- destination,
246
- }: DownloadTemplateProps) => {
247
- const temporaryDirectory = mkdtempSync(join(tmpdir(), "create-macostack-"));
248
- const archivePath = join(temporaryDirectory, `${template.part}.tar.gz`);
249
- const tarballUrl = `https://api.github.com/repos/${config.owner}/${template.repo}/tarball/${config.ref}`;
231
+ mkdirSync(targetRoot, { recursive: true });
232
+ const ready: Part[] = [];
233
+
234
+ for (const p of selected) {
235
+ const dest = resolve(targetRoot, p.folder);
236
+ const s = spinner();
250
237
 
238
+ // Download the tarball — no git history comes with it, so each part starts
239
+ // life fully detached from the template.
240
+ s.start(`${p.label} — downloading ${owner}/${p.repo}@${ref}`);
241
+ const tmp = mkdtempSync(join(tmpdir(), "create-macostack-"));
251
242
  try {
252
- const response = await fetch(tarballUrl, {
253
- headers: {
254
- Accept: "application/vnd.github+json",
255
- Authorization: `Bearer ${config.token}`,
256
- "User-Agent": "create-macostack",
257
- "X-GitHub-Api-Version": "2022-11-28",
258
- },
259
- });
260
-
261
- if (!response.ok) {
262
- const details = await response.text();
263
- bail(
264
- `GitHub refused ${config.owner}/${template.repo}@${config.ref} (${response.status}). ` +
265
- `Check GITHUB_TOKEN permissions. ${details.slice(0, 240)}`,
266
- );
243
+ const res = await gh(`/repos/${owner}/${p.repo}/tarball/${ref}`);
244
+ if (!res.ok) {
245
+ s.stop(`${p.label} — download failed`);
246
+ bail(`GitHub refused the download (HTTP ${res.status}). Try again, or check --ref ${ref}.`);
247
+ }
248
+ const archive = join(tmp, "template.tar.gz");
249
+ await Bun.write(archive, await res.arrayBuffer());
250
+ mkdirSync(dest, { recursive: true });
251
+ const tar = Bun.spawnSync(
252
+ ["tar", "-xzf", archive, "-C", dest, "--strip-components=1"],
253
+ { stdout: "pipe", stderr: "pipe" },
254
+ );
255
+ if (tar.exitCode !== 0) {
256
+ s.stop(`${p.label} — extract failed`);
257
+ bail(`Couldn't unpack the template: ${tar.stderr.toString().slice(0, 200)}`);
267
258
  }
268
-
269
- await Bun.write(archivePath, await response.arrayBuffer());
270
- ensureEmptyDirectory(destination, template.directory);
271
- runCommand({
272
- command: [
273
- "tar",
274
- "-xzf",
275
- archivePath,
276
- "-C",
277
- destination,
278
- "--strip-components=1",
279
- ],
280
- cwd: destination,
281
- label: `Extract ${template.part}`,
282
- });
283
259
  } finally {
284
- rmSync(temporaryDirectory, { recursive: true, force: true });
285
- }
286
- };
287
-
288
- const runInstall = (directory: string, template: TemplateConfig) => {
289
- runCommand({
290
- command: ["bun", "install"],
291
- cwd: directory,
292
- label: `bun install in ${template.part}`,
293
- });
294
- };
295
-
296
- const runSetup = async (directory: string, template: TemplateConfig) => {
297
- const packageJson = await readPackageJson(directory);
298
- if (!packageJson?.scripts?.setup) {
299
- note(`No setup script found in ${template.directory}; skipping delegation.`, template.part);
300
- return;
260
+ rmSync(tmp, { recursive: true, force: true });
301
261
  }
302
-
303
- runCommand({
304
- command: ["bun", "run", "setup"],
305
- cwd: directory,
306
- label: `setup in ${template.part}`,
307
- });
308
- };
309
-
310
- const ensureGitRepository = (directory: string, template: TemplateConfig) => {
311
- if (existsSync(resolve(directory, ".git"))) {
312
- return;
313
- }
314
-
315
- runCommand({
316
- command: ["git", "init", "-b", "main"],
317
- cwd: directory,
318
- label: `git init in ${template.part}`,
319
- });
320
- runCommand({
321
- command: ["git", "add", "-A"],
322
- cwd: directory,
323
- label: `git add in ${template.part}`,
324
- });
325
-
326
- const commitExitCode = runCommand({
327
- command: ["git", "commit", "-m", "init from template"],
328
- cwd: directory,
329
- label: `git commit in ${template.part}`,
330
- allowFailure: true,
331
- });
332
-
333
- if (commitExitCode !== 0) {
334
- note(
335
- `Fresh ${template.part} repo created, but initial commit failed. ` +
336
- `Run: cd ${template.directory} && git add -A && git commit -m "init"`,
337
- "Git",
262
+ s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
263
+
264
+ if (!flags["skip-install"]) {
265
+ const s2 = spinner();
266
+ s2.start(`${p.label} installing dependencies`);
267
+ const code = Bun.spawnSync(["bun", "install"], { cwd: dest, stdout: "pipe", stderr: "pipe" }).exitCode;
268
+ s2.stop(
269
+ code === 0
270
+ ? `${p.label} dependencies installed`
271
+ : `${p.label} — bun install had issues (run it again inside ${appName}/${p.folder})`,
338
272
  );
339
273
  }
340
- };
341
-
342
- const getSelectedPartsFromFlags = () => {
343
- const selected: StackPart[] = [];
344
-
345
- if (getBooleanFlag(flags.server)) {
346
- selected.push(STACK_PART.SERVER);
347
- }
348
274
 
349
- if (getBooleanFlag(flags.client)) {
350
- selected.push(STACK_PART.CLIENT);
351
- }
352
-
353
- return selected;
354
- };
355
-
356
- const resolveTargetName = async () => {
357
- const positionalTarget = parsedArgs.positionals[0];
358
- if (positionalTarget) {
359
- if (!isValidDirectoryName(positionalTarget)) {
360
- bail("Project folder must use lowercase letters, digits and dashes only.");
275
+ // Configure the template. It OWNS its questions — we fetch them as data
276
+ // (`setup --questions`), ask them in THIS wizard (nested interactive
277
+ // prompts don't get keyboard control under `bun create`), and apply the
278
+ // answers non-interactively (`setup --yes`).
279
+ const pkg = existsSync(resolve(dest, "package.json"))
280
+ ? await Bun.file(resolve(dest, "package.json")).json()
281
+ : null;
282
+ if (!flags["skip-setup"] && pkg?.scripts?.setup) {
283
+ let questions: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
284
+ if (!flags.yes) {
285
+ const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
286
+ cwd: dest,
287
+ stdout: "pipe",
288
+ stderr: "pipe",
289
+ });
290
+ if (q.exitCode === 0) {
291
+ try {
292
+ questions = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
293
+ } catch {}
294
+ }
361
295
  }
362
296
 
363
- return positionalTarget;
364
- }
365
-
366
- if (getBooleanFlag(flags.yes)) {
367
- bail("Missing project folder. Usage: bun create macostack my-venture");
368
- }
369
-
370
- const value = answered(
371
- await text({
372
- message: "Venture folder",
373
- placeholder: "mi-tienda",
374
- validate: (input) =>
375
- isValidDirectoryName(input ?? "")
376
- ? undefined
377
- : "lowercase letters, digits and dashes only",
378
- }),
379
- );
380
-
381
- return value;
382
- };
383
-
384
- const resolveSelectedParts = async () => {
385
- const selectedFromFlags = getSelectedPartsFromFlags();
386
- if (selectedFromFlags.length > 0) {
387
- return selectedFromFlags;
388
- }
389
-
390
- if (getBooleanFlag(flags.yes)) {
391
- return [STACK_PART.SERVER, STACK_PART.CLIENT];
392
- }
393
-
394
- const selected = answered(
395
- await multiselect({
396
- message: "¿Qué incluye este venture?",
397
- options: [
398
- { value: STACK_PART.SERVER, label: "server" },
399
- { value: STACK_PART.CLIENT, label: "client" },
400
- ],
401
- initialValues: [STACK_PART.SERVER, STACK_PART.CLIENT],
402
- required: true,
403
- }),
404
- );
405
-
406
- const validSelected = selected.filter(isStackPart);
407
- if (validSelected.length === 0) {
408
- bail("Select at least one half.");
409
- }
410
-
411
- return validSelected;
412
- };
413
-
414
- const buildTemplates = (selectedParts: StackPart[]) => {
415
- const serverRepo = getStringFlag(
416
- flags["server-repo"],
417
- Bun.env.MACOSTACK_SERVER_REPO ?? DEFAULT_SERVER_REPO,
418
- );
419
- const clientRepo = getStringFlag(
420
- flags["client-repo"],
421
- Bun.env.MACOSTACK_CLIENT_REPO ?? DEFAULT_CLIENT_REPO,
422
- );
423
-
424
- const templates = {
425
- [STACK_PART.SERVER]: {
426
- part: STACK_PART.SERVER,
427
- directory: "server",
428
- repo: serverRepo,
429
- },
430
- [STACK_PART.CLIENT]: {
431
- part: STACK_PART.CLIENT,
432
- directory: "client",
433
- repo: clientRepo,
434
- },
435
- } satisfies Record<StackPart, TemplateConfig>;
436
-
437
- return [STACK_PART.SERVER, STACK_PART.CLIENT]
438
- .filter((part) => selectedParts.includes(part))
439
- .map((part) => templates[part]);
440
- };
441
-
442
- const scaffoldTemplate = async (config: ScaffoldConfig, template: TemplateConfig) => {
443
- const destination = resolve(config.targetRoot, template.directory);
444
- const s = spinner();
445
-
446
- s.start(`Downloading ${config.owner}/${template.repo}@${config.ref} → ${template.directory}`);
447
- await downloadTemplate({ config, template, destination });
448
- s.stop(`${template.directory} downloaded`);
297
+ const setupArgs = ["run", "setup", "--yes", "--name", appName];
298
+
299
+ if (questions?.modules) {
300
+ let chosen: string[];
301
+ for (;;) {
302
+ chosen = answered(
303
+ await multiselect({
304
+ 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 })),
306
+ initialValues: questions.modules.initial,
307
+ required: false,
308
+ }),
309
+ );
310
+ const clash = (questions.modules.exclusive ?? []).find((pair) =>
311
+ pair.every((m) => chosen.includes(m)),
312
+ );
313
+ if (!clash) break;
314
+ note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
315
+ }
316
+ setupArgs.push("--modules", chosen.join(","));
317
+ }
449
318
 
450
- if (!config.skipInstall) {
451
- runInstall(destination, template);
319
+ const s3 = spinner();
320
+ s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
321
+ const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
322
+ if (setup.exitCode === 0) {
323
+ s3.stop(`${p.label} — configured`);
324
+ } else {
325
+ s3.stop(`${p.label} — setup didn't finish`);
326
+ note(
327
+ `Everything downloaded fine — just finish it yourself:\n cd ${appName}/${p.folder} && bun run setup`,
328
+ "Heads up",
329
+ );
330
+ }
452
331
  }
453
332
 
454
- if (!config.skipSetup) {
455
- await runSetup(destination, template);
333
+ // Safety net: whatever happened above, each part ends up as its own fresh
334
+ // git repo (the wizard usually did this already).
335
+ if (!existsSync(resolve(dest, ".git"))) {
336
+ Bun.spawnSync(["git", "init", "-b", "main"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
337
+ Bun.spawnSync(["git", "add", "-A"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
338
+ Bun.spawnSync(["git", "commit", "-m", "init from template"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
456
339
  }
457
340
 
458
- ensureGitRepository(destination, template);
459
- };
460
-
461
- const main = async () => {
462
- intro("create-macostack");
341
+ ready.push(p);
342
+ }
463
343
 
464
- const targetName = await resolveTargetName();
465
- const selectedParts = await resolveSelectedParts();
466
- const owner = getStringFlag(
467
- flags.owner,
468
- Bun.env.MACOSTACK_GITHUB_OWNER ?? DEFAULT_OWNER,
469
- );
470
- const ref = getStringFlag(flags.ref, Bun.env.MACOSTACK_REF ?? DEFAULT_REF);
471
- const githubToken = requireGithubToken();
472
-
473
- const config: ScaffoldConfig = {
474
- owner,
475
- ref,
476
- token: githubToken,
477
- targetRoot: resolve(process.cwd(), targetName),
478
- targetName,
479
- skipInstall: getBooleanFlag(flags["skip-install"]),
480
- skipSetup: getBooleanFlag(flags["skip-setup"]),
481
- };
482
- const templates = buildTemplates(selectedParts);
483
-
484
- ensureEmptyDirectory(config.targetRoot, "Target root");
485
-
486
- for (const template of templates) {
487
- await scaffoldTemplate(config, template);
488
- }
344
+ //////////////////////////////////////////////////////////////////
345
+ // DONE
346
+ //////////////////////////////////////////////////////////////////
489
347
 
490
- const remoteSteps = templates
491
- .map(
492
- (template) =>
493
- ` cd ${config.targetName}/${template.directory}\n` +
494
- ` git remote add origin <${template.part}-repo-url>\n` +
495
- " git push -u origin main",
496
- )
497
- .join("\n\n");
498
-
499
- const devSteps = templates
500
- .map((template) => ` cd ${config.targetName}/${template.directory} && bun dev`)
501
- .join("\n");
502
-
503
- outro(
504
- `Done. Root folder is intentionally not a git repo.\n\n` +
505
- `Next remotes:\n${remoteSteps}\n\n` +
506
- `Dev:\n${devSteps}`,
348
+ const steps: string[] = [];
349
+ const server = ready.find((p) => p.id === "server");
350
+ const client = ready.find((p) => p.id === "client");
351
+ if (server) {
352
+ steps.push(
353
+ `Start the backend:\n cd ${appName}/server\n docker compose up -d && bun run db:migrate && bun dev`,
507
354
  );
508
- };
509
-
510
- await main();
355
+ }
356
+ if (client) {
357
+ steps.push(`Start the web app:\n cd ${appName}/client\n bun dev`);
358
+ }
359
+ steps.push(
360
+ `When you're ready to publish (each part is its own repo):\n` +
361
+ ready
362
+ .map((p) => ` cd ${appName}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
363
+ .join("\n"),
364
+ );
365
+
366
+ outro(`${appName} is ready 🎉\n\n${steps.join("\n\n")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {